diff --git a/app/earn/page.tsx b/app/earn/page.tsx index c893e093a..984fe46c5 100644 --- a/app/earn/page.tsx +++ b/app/earn/page.tsx @@ -19,6 +19,8 @@ export default function EarnPage() { loadMore, sort, handleSortChange, + bountyFilter, + handleBountyFilterChange, selectedHubs, handleHubsChange, restoredScrollPosition, @@ -34,19 +36,35 @@ export default function EarnPage() { { label: 'RSC amount', value: '-total_amount' }, ]; + // Available bounty type options + const bountyTypeOptions = [ + { label: 'All Bounties', value: 'ALL' }, + { label: 'Foundation Only', value: 'FOUNDATION' }, + { label: 'Community Only', value: 'COMMUNITY' }, + ]; + const renderFilters = () => (
{/* Top filter bar */}
-
- +
+
+ +
+
+ handleBountyFilterChange(opt.value as any)} + options={bountyTypeOptions} + /> +
-
+
handleSortChange(opt.value)} diff --git a/components/Bounty/BountyMetadataLine.tsx b/components/Bounty/BountyMetadataLine.tsx index 5a97ca053..b9c76d047 100644 --- a/components/Bounty/BountyMetadataLine.tsx +++ b/components/Bounty/BountyMetadataLine.tsx @@ -2,8 +2,9 @@ import { formatDeadline } from '@/utils/date'; import { CurrencyBadge } from '@/components/ui/CurrencyBadge'; import { RadiatingDot } from '@/components/ui/RadiatingDot'; import { ContentTypeBadge } from '@/components/ui/ContentTypeBadge'; -import { Check } from 'lucide-react'; +import { Check, XCircle } from 'lucide-react'; import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; +import { BountyStatus } from '@/types/bounty'; interface BountyMetadataLineProps { amount: number; @@ -13,7 +14,7 @@ interface BountyMetadataLineProps { className?: string; solutionsCount?: number; showDeadline?: boolean; - bountyStatus?: 'OPEN' | 'CLOSED' | 'ASSESSMENT'; + bountyStatus?: BountyStatus; /** * If true, the amount is already in the target currency and should not be converted. * Useful when the caller has pre-calculated the amount (e.g., Foundation bounty flat fee). @@ -33,15 +34,41 @@ export const BountyMetadataLine = ({ }: BountyMetadataLineProps) => { const { showUSD } = useCurrencyPreference(); - // Format the deadline text - const deadlineText = - bountyStatus === 'ASSESSMENT' - ? 'Assessment Period' - : isOpen - ? expirationDate - ? formatDeadline(expirationDate) - : 'No deadline' - : 'Completed'; + // Helper to determine the deadline text + const getDeadlineText = () => { + if (bountyStatus === 'ASSESSMENT') return 'Assessment Period'; + if (bountyStatus === 'EXPIRED') return 'Expired'; + if (bountyStatus === 'CANCELLED') return 'Cancelled'; + if (isOpen) { + return expirationDate ? formatDeadline(expirationDate) : 'No deadline'; + } + return 'Completed'; + }; + + const deadlineText = getDeadlineText(); + const isInactive = bountyStatus === 'EXPIRED' || bountyStatus === 'CANCELLED'; + + // Helper to determine the status icon + const renderStatusIcon = () => { + if (isOpen) { + return ; + } + if (isInactive) { + return ; + } + return ; + }; + + // Helper to determine the status text color + const getStatusColorClass = () => { + if (isOpen) { + return expiringSoon ? 'text-orange-600 font-medium' : 'text-gray-700'; + } + if (isInactive) { + return 'text-gray-500 italic'; + } + return 'text-green-700 font-medium'; + }; return (
@@ -49,28 +76,21 @@ export const BountyMetadataLine = ({
{/* Badges */}
- +
{showDeadline && (
- {isOpen ? ( - - ) : ( - - )} - - {deadlineText} - + {renderStatusIcon()} + {deadlineText}
)}
diff --git a/components/Bounty/lib/bountyUtil.ts b/components/Bounty/lib/bountyUtil.ts index d5cafce2d..466d5bd43 100644 --- a/components/Bounty/lib/bountyUtil.ts +++ b/components/Bounty/lib/bountyUtil.ts @@ -523,6 +523,7 @@ const getCreatorUserId = (bounty: Bounty): number | undefined => { * @returns True if the bounty was created by the Foundation account */ export const isFoundationBounty = (bounty: Bounty): boolean => { + if (bounty.createdBy?.isOfficialAccount) return true; if (!FOUNDATION_USER_ID) return false; const creatorUserId = getCreatorUserId(bounty); diff --git a/components/Feed/FeedItemActions.tsx b/components/Feed/FeedItemActions.tsx index 66a7391f1..65482c8d5 100644 --- a/components/Feed/FeedItemActions.tsx +++ b/components/Feed/FeedItemActions.tsx @@ -146,8 +146,10 @@ interface FeedItemActionsProps { comment?: string; upvote?: string; report?: string; + award?: string; }; onComment?: () => void; + onAward?: () => void; // Prop for inline award action onTip?: () => void; // Callback for tip action (when provided, tip button shows) children?: ReactNode; // Add children prop to accept additional action buttons showTooltips?: boolean; // New property for controlling tooltips @@ -201,6 +203,7 @@ export const FeedItemActions: FC = ({ reviews = [], bounties = [], awardedBountyAmount, + onAward, tips = [], relatedDocumentTopics, relatedDocumentUnifiedDocumentId, @@ -415,14 +418,15 @@ export const FeedItemActions: FC = ({ const showInlineReviews = showPeerReviews && reviews.length > 0; const showInlineBounties = hasOpenBounties; - // Calculate total awarded amount (tips + bounty awards) + // Calculate tip amount and awarded bounty amount separately const tipAmount = tips.reduce((total, tip) => total + (tip.amount || 0), 0); - const totalAwarded = tipAmount + (awardedBountyAmount || 0); + const hasBountyAwards = (awardedBountyAmount || 0) > 0; return ( <>
+ {/* ... voting buttons ... */}
= ({ showTooltip={showTooltips} /> )} - {(onTip || totalAwarded > 0) && - (showTooltips && totalAwarded > 0 ? ( - - } - position="top" - width="w-[320px]" - > - {onTip ? ( - - ) : ( -
- - {totalAwarded > 0 ? ( - - {formatCurrency({ - amount: totalAwarded, - showUSD, - exchangeRate, - shorten: true, - })} - - ) : ( - Tip - )} -
- )} -
- ) : onTip ? ( - - ) : ( + + ) : ( +
+ + + {formatCurrency({ amount: tipAmount, showUSD, exchangeRate, shorten: true })} + +
+ )} + + )} + + {/* Bounty Awarded Badge */} + {hasBountyAwards && ( + +

Bounty Awarded

+

Total amount awarded from bounties on this document.

+
+ } + position="top" + width="w-[240px]" + disabled={!showTooltips} + >
- - {totalAwarded > 0 ? ( - - {formatCurrency({ amount: totalAwarded, showUSD, exchangeRate, shorten: true })} - - ) : ( - Tip - )} + + + {formatCurrency({ + amount: awardedBountyAmount || 0, + showUSD, + exchangeRate, + shorten: true, + })} + {!showUSD && ' RSC'} +
- ))} + + )} {showInlineReviews && (showTooltips && reviews.length > 0 ? ( = ({ onClick={handleBountyClick} /> ))} + + {/* Inline Award Button - For bounty creators to award specific comments */} + {feedContentType === 'COMMENT' && onAward && ( + + )} + {children}
diff --git a/components/banners/EarningOpportunityBanner.tsx b/components/banners/EarningOpportunityBanner.tsx index 260b6404a..bf724e406 100644 --- a/components/banners/EarningOpportunityBanner.tsx +++ b/components/banners/EarningOpportunityBanner.tsx @@ -48,7 +48,7 @@ export const EarningOpportunityBanner = ({ } else { const bountiesUrl = buildWorkUrl({ id: work.id, - contentType: work.contentType === 'paper' ? 'paper' : 'post', + contentType: work.contentType, slug: work.slug, tab: 'bounties', }); diff --git a/hooks/useBounties.ts b/hooks/useBounties.ts index 14f11c157..ad447c49d 100644 --- a/hooks/useBounties.ts +++ b/hooks/useBounties.ts @@ -67,6 +67,7 @@ export const useBounties = () => { const [selectedHubs, setSelectedHubs] = useState([]); const selectedHubsRef = useRef([]); const [sort, setSort] = useState(sortFromUrl); + const [bountyFilter, setBountyFilter] = useState<'ALL' | 'FOUNDATION' | 'COMMUNITY'>('ALL'); const previousHubsParamRef = useRef(''); const hasInitialFetchRef = useRef(false); const previousSortRef = useRef(sortFromUrl); @@ -236,6 +237,22 @@ export const useBounties = () => { router.replace(`?${params.toString()}`, { scroll: false }); }; + const handleBountyFilterChange = (filter: 'ALL' | 'FOUNDATION' | 'COMMUNITY') => { + setBountyFilter(filter); + }; + + const filteredEntries = useMemo(() => { + if (bountyFilter === 'ALL') return entries; + + return entries.filter((entry) => { + const bounties = entry.content.bounties || []; + const hasFoundation = bounties.some((b) => b.createdBy?.isOfficialAccount); + if (bountyFilter === 'FOUNDATION') return hasFoundation; + if (bountyFilter === 'COMMUNITY') return !hasFoundation; + return true; + }); + }, [entries, bountyFilter]); + const loadMore = () => { fetchBounties(); }; @@ -294,12 +311,14 @@ export const useBounties = () => { }, [event, router, searchParams]); return { - entries, + entries: filteredEntries, isLoading, hasMore, loadMore, sort, handleSortChange, + bountyFilter, + handleBountyFilterChange, selectedHubs, handleHubsChange, restoredScrollPosition, diff --git a/types/bounty.ts b/types/bounty.ts index 340bf8515..d635c27d2 100644 --- a/types/bounty.ts +++ b/types/bounty.ts @@ -3,6 +3,7 @@ import { BaseTransformer } from './transformer'; import { User, transformUser } from './user'; export type BountyType = 'REVIEW' | 'ANSWER' | 'BOUNTY' | 'GENERIC_COMMENT'; +export type BountyStatus = 'OPEN' | 'CLOSED' | 'ASSESSMENT' | 'EXPIRED' | 'CANCELLED'; export type SolutionStatus = 'AWARDED' | 'PENDING'; export type ContributionStatus = 'ACTIVE' | 'REFUNDED'; @@ -36,7 +37,7 @@ export interface BountyComment { export interface Bounty { id: number; amount: string; - status: 'OPEN' | 'CLOSED' | 'ASSESSMENT'; + status: BountyStatus; expirationDate?: string; bountyType: BountyType; createdBy: User; diff --git a/types/user.ts b/types/user.ts index d52888685..899dec2fe 100644 --- a/types/user.ts +++ b/types/user.ts @@ -21,6 +21,7 @@ export interface User { moderator: boolean; editorOfHubs?: Hub[]; isModerator?: boolean; + isOfficialAccount?: boolean; referralCode?: string; authProvider?: 'google' | 'credentials'; } @@ -78,6 +79,7 @@ const baseTransformUser = (raw: any): User => { moderator: raw.moderator || false, editorOfHubs: editorOfHubs, isModerator: raw.moderator || false, + isOfficialAccount: raw.is_official_account || false, referralCode: raw.referral_code || undefined, authProvider: raw.auth_provider ? raw.auth_provider === 'google'