Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion components/Activity/cards/ActivityCardCompact.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
getActivityHeaderMessage,
getContribution,
getEntryMeta,
getFundraiseFullyFundedAmount,
getGrantAmount,
getReviewEarning,
getReviewScore,
Expand Down Expand Up @@ -75,11 +76,17 @@ export const ActivityCardCompact: FC<ActivityCardCompactProps> = ({
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 ? (
Expand All @@ -98,6 +105,17 @@ export const ActivityCardCompact: FC<ActivityCardCompactProps> = ({
<GrantFundingAmount amount={grantAmount} size="sm" className="align-middle" />
</>
)}
{fullyFundedAmount && (
<>
{' '}
<ContributionAmount
contribution={fullyFundedAmount}
showSign={false}
size="sm"
className="align-middle"
/>
</>
)}
{contribution && (
<>
{' '}
Expand Down
37 changes: 37 additions & 0 deletions components/Activity/lib/activityDisplay.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
FeedCommentContent,
FeedContentType,
FeedEntry,
FeedFundraiseContent,
FeedFundingActivityContent,
FeedGrantContent,
FeedPaperContent,
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
Expand Down
4 changes: 3 additions & 1 deletion components/Activity/lib/activityGrouping.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
30 changes: 29 additions & 1 deletion components/Activity/lib/activityWork.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getActivityReview,
isFundingPoolContribution,
isGrantOpened,
isProposalFullyFunded,
isProposalSubmission,
isVerifiedAuthor,
type ActivityCommentKind,
Expand All @@ -18,6 +19,7 @@ import type {
ActivityAction,
FeedBountyContent,
FeedEntry,
FeedFundraiseContent,
FeedGrantContent,
FeedPostContent,
Review,
Expand Down Expand Up @@ -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' ||
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
}

Expand Down
31 changes: 30 additions & 1 deletion components/Feed/FeedEntryItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -170,6 +177,16 @@ export const FeedEntryItem: FC<FeedEntryItemProps> = ({
});
}

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;
}
Expand Down Expand Up @@ -282,6 +299,18 @@ export const FeedEntryItem: FC<FeedEntryItemProps> = ({
content = <FeedItemGrantWithApplicants entry={entry} showApplyCta={showGrantApplyCta} />;
break;

case 'FUNDRAISE':
content = (
<FeedItemFundraiseCompleted
entry={entry}
href={href}
showActions={!hideActions}
maxLength={maxLength}
onFeedItemClick={handleFeedItemClick}
/>
);
break;

default:
throw new Error(`Unsupported content type: ${entry.contentType}`);
}
Expand Down
132 changes: 132 additions & 0 deletions components/Feed/items/FeedItemFundraiseCompleted.tsx
Original file line number Diff line number Diff line change
@@ -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<FeedItemFundraiseCompletedProps> = ({
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 (
<BaseFeedItem
entry={entry}
href={fundingPageUrl}
showActions={showActions}
showTooltips={showTooltips}
customActionText="fully funded a proposal"
maxLength={maxLength}
onFeedItemClick={onFeedItemClick}
showHeader={false}
hideReportButton={false}
>
<FeedItemEyebrow label="Fully funded" publishedAt={entry.timestamp} />

<TitleSection
title={title}
href={fundingPageUrl}
onClick={onFeedItemClick}
className="text-md md:!text-md"
/>

<MetadataSection className="mb-0 py-2">
{author ? (
<div className="flex items-center gap-2.5">
<AuthorTooltip authorId={author.id !== 0 ? author.id : undefined}>
<Avatar
src={author.profileImage || undefined}
alt={author.fullName}
size="sm"
disableTooltip
/>
</AuthorTooltip>
<div className="flex flex-col min-w-0">
<Link
href={author.profileUrl || '#'}
className="text-sm font-medium text-gray-900 hover:underline truncate"
onClick={(e) => e.stopPropagation()}
>
{author.fullName}
</Link>
{entry.nonprofit?.name ? (
<span className="text-xs text-gray-500 truncate">{entry.nonprofit.name}</span>
) : null}
</div>
</div>
) : null}
</MetadataSection>

<PrimaryActionSection>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-start gap-4 min-w-0">
<div className="flex flex-col leading-tight whitespace-nowrap">
<span className="text-[11px] text-gray-500 uppercase tracking-wide">Raised</span>
<span className="font-mono font-semibold text-primary-600 text-lg">
{raisedLabel}
</span>
</div>
<div className="flex flex-col leading-tight whitespace-nowrap">
<span className="text-[11px] text-gray-500 uppercase tracking-wide">Goal</span>
<span className="font-mono font-semibold text-gray-900 text-lg">{goalLabel}</span>
</div>
</div>
<span className="inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] font-medium bg-green-500 text-white border border-green-400">
<CheckCircle size={12} className="text-white" aria-hidden />
Funded
</span>
</div>
</PrimaryActionSection>
</BaseFeedItem>
);
};
Loading