diff --git a/components/Activity/cards/ActivityCardCompact.tsx b/components/Activity/cards/ActivityCardCompact.tsx index a014e1685..8d0bc9b82 100644 --- a/components/Activity/cards/ActivityCardCompact.tsx +++ b/components/Activity/cards/ActivityCardCompact.tsx @@ -14,6 +14,7 @@ import { getActivityHeaderMessage, getContribution, getEntryMeta, + getFundraiseFullyFundedAmount, getGrantAmount, getReviewEarning, getReviewScore, @@ -75,11 +76,17 @@ export const ActivityCardCompact: FC = ({ const reviewScore = getReviewScore(entry); const reviewEarning = getReviewEarning(entry); const grantAmount = getGrantAmount(entry); + const fullyFundedAmount = getFundraiseFullyFundedAmount(entry); const contribution = getContribution(entry); const bounty = entry.activityAction === 'bounty_opened' ? getActivityBounty(entry) : undefined; const hasAmount = Boolean( - grantAmount || contribution || reviewEarning || bounty || reviewScore != null + grantAmount || + fullyFundedAmount || + contribution || + reviewEarning || + bounty || + reviewScore != null ); const titleEl = href ? ( @@ -98,6 +105,17 @@ export const ActivityCardCompact: FC = ({ )} + {fullyFundedAmount && ( + <> + {' '} + + + )} {contribution && ( <> {' '} diff --git a/components/Activity/lib/activityDisplay.utils.ts b/components/Activity/lib/activityDisplay.utils.ts index 6962d6a82..dfd245d66 100644 --- a/components/Activity/lib/activityDisplay.utils.ts +++ b/components/Activity/lib/activityDisplay.utils.ts @@ -8,6 +8,7 @@ import type { FeedCommentContent, FeedContentType, FeedEntry, + FeedFundraiseContent, FeedFundingActivityContent, FeedGrantContent, FeedPaperContent, @@ -157,6 +158,10 @@ function getDefaultActivityMessage(entry: FeedEntry): ActivityHeaderMessage { : { actor, verb: 'funded this proposal with' }; } + if (isProposalFullyFunded(entry)) { + return { actor: getProposalFullyFundedActor(entry), verb: 'reached their funding goal' }; + } + // Registered reports arrive as `post` entries; only the document type sets them apart, // and it sits on the entry itself or on the work it relates to. const postType = (entry.content as FeedPostContent).postType ?? entry.relatedWork?.postType; @@ -180,6 +185,17 @@ export function isGrantOpened(entry: FeedEntry): boolean { return entry.activityAction === 'grant_opened' || entry.contentType === 'GRANT'; } +/** Proposal reached its fundraise goal. */ +export function isProposalFullyFunded(entry: FeedEntry): boolean { + return entry.activityAction === 'proposal_fully_funded' || entry.contentType === 'FUNDRAISE'; +} + +export function getProposalFullyFundedActor(entry: FeedEntry): AuthorProfile { + const relatedAuthor = entry.relatedWork?.authors?.[0]?.authorProfile; + if (relatedAuthor?.fullName?.trim()) return relatedAuthor; + return entry.content.createdBy; +} + /** A document's own publication, which all of its authors share credit for. */ export function isDocumentPublication(entry: FeedEntry): boolean { return entry.contentType in DOC_ACTION_LABELS; @@ -348,6 +364,9 @@ export function getActionIcon(entry: FeedEntry): ActivityActionIconName { ) { return 'coins'; } + if (entry.contentType === 'FUNDRAISE' || entry.activityAction === 'proposal_fully_funded') { + return 'fund'; + } if (entry.contentType === 'BOUNTY') return 'coins'; if (entry.contentType !== 'COMMENT') return null; @@ -410,6 +429,18 @@ export function getGrantAmount(entry: FeedEntry): ActivityGrantAmount | undefine return grant.amount; } +/** Raised total on a goal-met FUNDRAISE event. */ +export function getFundraiseFullyFundedAmount(entry: FeedEntry): CurrencyAmount | undefined { + if (!isProposalFullyFunded(entry)) return undefined; + const content = entry.content as FeedFundraiseContent; + const usd = content.amountRaised?.usd ?? 0; + const rsc = content.amountRaised?.rsc ?? 0; + if (usd <= 0 && rsc <= 0) return undefined; + // Prefer the goal currency when present. + if (usd > 0) return { amount: usd, currency: 'USD' }; + return { amount: rsc, currency: 'RSC' }; +} + export interface ActivityCommentPreview { content: CommentContent; format: ContentFormat; @@ -585,6 +616,12 @@ export function getActivityActionLine( value: contributionValue(getContribution(entry)), }; } + if (isProposalFullyFunded(entry)) { + return { + label: 'Proposal reached its goal', + value: contributionValue(getFundraiseFullyFundedAmount(entry)), + }; + } if (entry.contentType === 'GRANT') { const amount = getGrantAmount(entry); return { label: 'Opened an RFP', value: amount ? { kind: 'grant', amount } : undefined }; diff --git a/components/Activity/lib/activityGrouping.utils.ts b/components/Activity/lib/activityGrouping.utils.ts index b109b1597..e6ebfea17 100644 --- a/components/Activity/lib/activityGrouping.utils.ts +++ b/components/Activity/lib/activityGrouping.utils.ts @@ -117,7 +117,9 @@ function resolveWorkKey(work: ActivityWork): string { * their own row even back to back. */ function toGroupKind(entry: FeedEntry): ActivityGroupKind | null { - if (entry.activityAction === 'fundraise_contribution') return 'funding'; + if (entry.activityAction === 'fundraise_contribution') { + return 'funding'; + } if (entry.activityAction !== 'comment_published') return null; const { comment } = entry.content as FeedCommentContent; diff --git a/components/Activity/lib/activityWork.utils.ts b/components/Activity/lib/activityWork.utils.ts index 4da48c951..55b228690 100644 --- a/components/Activity/lib/activityWork.utils.ts +++ b/components/Activity/lib/activityWork.utils.ts @@ -6,6 +6,7 @@ import { getActivityReview, isFundingPoolContribution, isGrantOpened, + isProposalFullyFunded, isProposalSubmission, isVerifiedAuthor, type ActivityCommentKind, @@ -18,6 +19,7 @@ import type { ActivityAction, FeedBountyContent, FeedEntry, + FeedFundraiseContent, FeedGrantContent, FeedPostContent, Review, @@ -102,6 +104,9 @@ function resolveActivityBodySlot( if (work?.grant && work.documentType === 'funding_request') { return 'grant'; } + if (activityAction === 'proposal_fully_funded') { + return 'default'; + } if ( activityAction === 'tip_review' || activityAction === 'bounty_payout' || @@ -681,6 +686,27 @@ function getWorkFromContent(entry: FeedEntry): ActivityWork | null { }; } + if (entry.contentType === 'FUNDRAISE') { + const fundraise = entry.content as FeedFundraiseContent; + const title = fundraise.proposalTitle?.trim(); + if (!title || fundraise.postId == null) return null; + const documentType: ContentType = 'preregistration'; + return { + id: fundraise.postId, + slug: fundraise.proposalSlug || '', + title, + href: buildActivityWorkHref({ + id: fundraise.postId, + slug: fundraise.proposalSlug || undefined, + contentType: documentType, + }), + documentType, + unifiedDocumentId: toOptionalNumber(fundraise.unifiedDocumentId), + bounty, + authors: undefined, + }; + } + return null; } @@ -723,7 +749,9 @@ export function isActivityWorkAuthor(entry: FeedEntry, authorId?: number | null) * would be redundant. */ export function shouldShowAuthorBadge(entry: FeedEntry, authorId?: number | null): boolean { - if (isProposalSubmission(entry) || isGrantOpened(entry)) return false; + if (isProposalSubmission(entry) || isGrantOpened(entry) || isProposalFullyFunded(entry)) { + return false; + } return isActivityWorkAuthor(entry, authorId); } diff --git a/components/Feed/FeedEntryItem.tsx b/components/Feed/FeedEntryItem.tsx index 026c92d60..90ec79caf 100644 --- a/components/Feed/FeedEntryItem.tsx +++ b/components/Feed/FeedEntryItem.tsx @@ -2,8 +2,15 @@ import { FC, useCallback } from 'react'; import { useInView } from 'react-intersection-observer'; -import { FeedCommentContent, FeedEntry, FeedPostContent, FeedGrantContent } from '@/types/feed'; +import { + FeedCommentContent, + FeedEntry, + FeedFundraiseContent, + FeedPostContent, + FeedGrantContent, +} from '@/types/feed'; import { FeedItemFundraise } from './items/DeprecatedFeedItemFundraise'; +import { FeedItemFundraiseCompleted } from './items/FeedItemFundraiseCompleted'; import { FeedItemComment } from './items/FeedItemComment'; import { FeedItemPost } from './items/FeedItemPost'; import { FeedItemGrant } from './items/FeedItemGrant'; @@ -170,6 +177,16 @@ export const FeedEntryItem: FC = ({ }); } + case 'FUNDRAISE': { + const fundraiseContent = entry.content as FeedFundraiseContent; + if (fundraiseContent.postId == null) return undefined; + return buildWorkUrl({ + id: fundraiseContent.postId, + slug: fundraiseContent.proposalSlug || undefined, + contentType: 'preregistration', + }); + } + default: return undefined; } @@ -282,6 +299,18 @@ export const FeedEntryItem: FC = ({ content = ; break; + case 'FUNDRAISE': + content = ( + + ); + break; + default: throw new Error(`Unsupported content type: ${entry.contentType}`); } diff --git a/components/Feed/items/FeedItemFundraiseCompleted.tsx b/components/Feed/items/FeedItemFundraiseCompleted.tsx new file mode 100644 index 000000000..9957bb215 --- /dev/null +++ b/components/Feed/items/FeedItemFundraiseCompleted.tsx @@ -0,0 +1,132 @@ +'use client'; + +import { FC } from 'react'; +import Link from 'next/link'; +import { CheckCircle } from 'lucide-react'; +import { + BaseFeedItem, + TitleSection, + MetadataSection, + PrimaryActionSection, +} from '@/components/Feed/BaseFeedItem'; +import { FeedItemEyebrow } from '@/components/Feed/FeedItemEyebrow'; +import { Avatar } from '@/components/ui/Avatar'; +import { AuthorTooltip } from '@/components/ui/AuthorTooltip'; +import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; +import { formatFundingAmount } from '@/components/Funding/lib/fundingAmounts'; +import { buildWorkUrl } from '@/utils/url'; +import type { FeedEntry, FeedFundraiseContent } from '@/types/feed'; + +interface FeedItemFundraiseCompletedProps { + entry: FeedEntry; + href?: string; + showActions?: boolean; + showTooltips?: boolean; + maxLength?: number; + onFeedItemClick?: () => void; +} + +export const FeedItemFundraiseCompleted: FC = ({ + entry, + href, + showActions = true, + showTooltips = true, + maxLength, + onFeedItemClick, +}) => { + const { showUSD } = useCurrencyPreference(); + const content = entry.content as FeedFundraiseContent; + const author = entry.relatedWork?.authors?.[0]?.authorProfile ?? content.createdBy; + + const proposalId = content.postId; + const proposalSlug = content.proposalSlug || undefined; + const title = content.proposalTitle?.trim() || 'Proposal'; + + const fundingPageUrl = + href || + (proposalId != null + ? buildWorkUrl({ + id: proposalId, + slug: proposalSlug, + contentType: 'preregistration', + }) + : undefined); + + const raisedLabel = formatFundingAmount( + { usd: content.amountRaised.usd, rsc: content.amountRaised.rsc }, + showUSD + ); + const goalRsc = content.goalAmount.rsc ?? content.amountRaised.rsc; + const goalLabel = formatFundingAmount({ usd: content.goalAmount.usd, rsc: goalRsc }, showUSD); + + return ( + + + + + + + {author ? ( +
+ + + +
+ e.stopPropagation()} + > + {author.fullName} + + {entry.nonprofit?.name ? ( + {entry.nonprofit.name} + ) : null} +
+
+ ) : null} +
+ + +
+
+
+ Raised + + {raisedLabel} + +
+
+ Goal + {goalLabel} +
+
+ + + Funded + +
+
+
+ ); +}; diff --git a/types/feed.ts b/types/feed.ts index 60baa55c1..a0f0986b0 100644 --- a/types/feed.ts +++ b/types/feed.ts @@ -7,7 +7,13 @@ import { mapApiDocumentTypeToClientType, type ApiDocumentType } from '@/utils/co import { Bounty, BountyWithComment, transformBounty } from './bounty'; import { Comment, CommentType, ContentFormat, transformComment } from './comment'; import type { CommentContent } from '@/components/Comment/lib/types'; -import { Fundraise, transformFundraise, Application, transformApplication } from './funding'; +import { + Fundraise, + FundraiseStatus, + transformFundraise, + Application, + transformApplication, +} from './funding'; import { Journal } from './journal'; import { UserVoteType } from './reaction'; import { stripHtml } from '@/utils/stringUtils'; @@ -305,6 +311,23 @@ export interface FeedFundingActivityContent extends BaseFeedContent { peerReview?: FeedFundingActivityPeerReview; } +/** + * One-shot “proposal fully funded” event (`content_type: FUNDRAISE`). + * Emitted when raised amount meets the goal. + */ +export interface FeedFundraiseContent extends BaseFeedContent { + contentType: 'FUNDRAISE'; + status: FundraiseStatus; + amountRaised: { usd: number; rsc: number }; + goalAmount: { usd: number; rsc: number | null }; + postId: number | null; + proposalTitle: string | null; + proposalSlug: string | null; + hub?: Topic | null; + category?: Topic; + subcategory?: Topic; +} + // Update the Content union type to include the base interface export type Content = | FeedPostContent @@ -313,7 +336,8 @@ export type Content = | FeedCommentContent | FeedApplicationContent | FeedGrantContent - | FeedFundingActivityContent; + | FeedFundingActivityContent + | FeedFundraiseContent; export type FeedContentType = | 'PAPER' @@ -325,7 +349,8 @@ export type FeedContentType = | 'GRANT' | 'USDFUNDRAISECONTRIBUTION' | 'PURCHASE' - | 'FUNDINGACTIVITY'; + | 'FUNDINGACTIVITY' + | 'FUNDRAISE'; export interface ExternalMetrics { score: number; @@ -387,6 +412,7 @@ export type ActivityAction = | 'tip_review' | 'bounty_payout' | 'fundraise_contribution' + | 'proposal_fully_funded' | 'bounty_opened' | 'bounty_contributed' | 'peer_review_published' @@ -422,6 +448,8 @@ export function deriveActivityAction(feedEntry: RawApiFeedEntry): ActivityAction case 'PURCHASE': case 'USDFUNDRAISECONTRIBUTION': return 'fundraise_contribution'; + case 'FUNDRAISE': + return 'proposal_fully_funded'; case 'FUNDINGACTIVITY': { const sourceType = obj.source_type as string | undefined; if (sourceType === 'BOUNTY_PAYOUT') return 'bounty_payout'; @@ -1264,6 +1292,65 @@ export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => { } break; + case 'FUNDRAISE': { + contentType = 'FUNDRAISE'; + try { + const relatedWorkRaw = feedEntry.related_work; + const createdBy = transformAuthorProfile(author); + const hubTopic = content_object.hub + ? content_object.hub.id + ? transformTopic(content_object.hub) + : { + id: 0, + name: content_object.hub.name || '', + slug: content_object.hub.slug || '', + } + : null; + const rawStatus = (content_object.status as string | undefined)?.toUpperCase(); + const status: FundraiseStatus = + rawStatus === 'COMPLETED' || rawStatus === 'CLOSED' ? rawStatus : 'OPEN'; + const fundraiseEntry: FeedFundraiseContent = { + id: content_object.id ?? id, + contentType: 'FUNDRAISE', + status, + createdDate: action_date || created_date, + createdBy, + unifiedDocumentId: String( + content_object.unified_document_id ?? relatedWorkRaw?.unified_document_id ?? '' + ), + amountRaised: { + usd: Number(content_object.amount_raised?.usd) || 0, + rsc: Number(content_object.amount_raised?.rsc) || 0, + }, + goalAmount: { + usd: Number(content_object.goal_amount?.usd) || 0, + rsc: + content_object.goal_amount?.rsc != null && + Number.isFinite(Number(content_object.goal_amount.rsc)) + ? Number(content_object.goal_amount.rsc) + : null, + }, + postId: + content_object.post_id != null && Number.isFinite(Number(content_object.post_id)) + ? Number(content_object.post_id) + : (relatedWorkRaw?.id ?? null), + proposalTitle: + stripHtml(content_object.proposal_title || relatedWorkRaw?.title || '') || null, + proposalSlug: content_object.proposal_slug || relatedWorkRaw?.slug || null, + hub: hubTopic, + category: content_object.category ? transformTopic(content_object.category) : undefined, + subcategory: content_object.subcategory + ? transformTopic(content_object.subcategory) + : undefined, + }; + content = fundraiseEntry; + } catch (error) { + console.error('Error transforming FUNDRAISE:', error); + throw new Error(`Failed to transform FUNDRAISE: ${error}`); + } + break; + } + case 'FUNDINGACTIVITY': contentType = 'FUNDINGACTIVITY'; try { @@ -1620,6 +1707,10 @@ export function mapFeedContentTypeToContentType( return 'post'; case 'GRANT': return 'funding_request'; + case 'FUNDRAISE': + case 'PURCHASE': + case 'USDFUNDRAISECONTRIBUTION': + return 'preregistration'; default: return undefined; }