From 19c4e35f6e5b7c3d32a746a2c9992e9709449488 Mon Sep 17 00:00:00 2001 From: Kobe Attias Date: Fri, 11 Sep 2026 13:56:22 -0400 Subject: [PATCH] Add funding pool widget to RFP header Behind the rfpFundingPool flag, replace the amount eyebrow and the bare Contribute button with a single card in the header's CTA column. The card shows the pool total, a bar splitting the original grant from community contributions, a one-line breakdown, and both actions: "Apply with proposal" as the primary and "Add to the pool" as the secondary. Full figures (funder, community, allocated, and ready-to-allocate for managers) live in an info tooltip so the card stays the height of the title block beside it. Also: the contribute modal passes the updated pool back on RSC and credit contributions so the header updates without a refetch, its pool-mode title matches the button, and activity copy for pool contributions is shortened. Co-Authored-By: Claude Fable 5.1 --- app/grant/[id]/[slug]/layout.tsx | 1 + .../cards/ActivityFundingGroupCard.tsx | 2 +- .../Activity/lib/activityDisplay.utils.ts | 2 +- .../modals/ContributeToFundraiseModal.tsx | 21 +- .../WorkHeader/GrantFundingPoolWidget.tsx | 220 ++++++++++++++++++ components/work/WorkHeader/WorkHeader.tsx | 4 + .../work/WorkHeader/WorkHeaderGrant.tsx | 121 +++++++--- 7 files changed, 327 insertions(+), 44 deletions(-) create mode 100644 components/work/WorkHeader/GrantFundingPoolWidget.tsx diff --git a/app/grant/[id]/[slug]/layout.tsx b/app/grant/[id]/[slug]/layout.tsx index f2ca39f3b..794b18081 100644 --- a/app/grant/[id]/[slug]/layout.tsx +++ b/app/grant/[id]/[slug]/layout.tsx @@ -81,6 +81,7 @@ export default async function GrantSlugLayout({ params, children }: Props) { work={work} metadata={metadata} amountUsd={badgeAmountUsd} + grantAmount={grant ? { usd: grant.amount.usd ?? 0, rsc: grant.amount.rsc ?? 0 } : null} grantId={grantId?.toString()} isActive={isActive} isPending={isPending} diff --git a/components/Activity/cards/ActivityFundingGroupCard.tsx b/components/Activity/cards/ActivityFundingGroupCard.tsx index f54f1ce80..c77e87e2c 100644 --- a/components/Activity/cards/ActivityFundingGroupCard.tsx +++ b/components/Activity/cards/ActivityFundingGroupCard.tsx @@ -63,7 +63,7 @@ const FunderName: FC<{ funder: AuthorProfile }> = ({ funder }) => { const FunderSummary: FC<{ funders: AuthorProfile[]; isRfp: boolean }> = ({ funders, isRfp }) => { const named = funders.slice(0, MAX_NAMED_FUNDERS); const remaining = funders.length - named.length; - const action = isRfp ? ' contributed to this RFP.' : ' funded this proposal.'; + const action = isRfp ? ' contributed to RFP' : ' funded this proposal.'; return ( <> diff --git a/components/Activity/lib/activityDisplay.utils.ts b/components/Activity/lib/activityDisplay.utils.ts index b108f0c09..6f53ea2cc 100644 --- a/components/Activity/lib/activityDisplay.utils.ts +++ b/components/Activity/lib/activityDisplay.utils.ts @@ -138,7 +138,7 @@ function getDefaultActivityMessage(entry: FeedEntry): ActivityHeaderMessage { if (entry.contentType === 'USDFUNDRAISECONTRIBUTION' || entry.contentType === 'PURCHASE') { return { actor, - verb: isFundingPoolContribution(entry) ? 'contributed to this RFP.' : 'funded this proposal.', + verb: isFundingPoolContribution(entry) ? 'contributed to RFP' : 'funded this proposal.', }; } diff --git a/components/modals/ContributeToFundraiseModal.tsx b/components/modals/ContributeToFundraiseModal.tsx index c0454d3ed..3413822fe 100644 --- a/components/modals/ContributeToFundraiseModal.tsx +++ b/components/modals/ContributeToFundraiseModal.tsx @@ -38,7 +38,12 @@ import AuthContent from '@/components/Auth/AuthContent'; interface ContributeModalCommonProps { isOpen: boolean; onClose: () => void; - onContributeSuccess?: () => void; + /** + * Called after a contribution lands. In fundingPool mode, RSC and credit + * contributions pass back the pool the API returned so the page can update + * without a refetch; card and wallet payments settle server-side and pass nothing. + */ + onContributeSuccess?: (updatedPool?: FundingPool) => void; /** Title of the proposal / RFP being funded */ proposalTitle?: string; /** Work object containing author information (proposal fundraise only) */ @@ -153,12 +158,12 @@ function ContributeToFundraiseModalInner(props: Readonly(null); const [amountError, setAmountError] = useState(undefined); const [currentView, setCurrentView] = useState('funding'); - const [selectedQuickAmount, setSelectedQuickAmount] = useState(100); + const [selectedQuickAmount, setSelectedQuickAmount] = useState(1000); const [isSliderControlled, setIsSliderControlled] = useState(false); // Store Stripe context for credit card payments @@ -317,11 +322,13 @@ function ContributeToFundraiseModalInner(props: Readonly { @@ -627,7 +634,7 @@ function ContributeToFundraiseModalInner(props: Readonly} error={amountError} - label="Funding amount" + label="Amount in USD" className="text-lg" /> diff --git a/components/work/WorkHeader/GrantFundingPoolWidget.tsx b/components/work/WorkHeader/GrantFundingPoolWidget.tsx new file mode 100644 index 000000000..b995bfcaa --- /dev/null +++ b/components/work/WorkHeader/GrantFundingPoolWidget.tsx @@ -0,0 +1,220 @@ +'use client'; + +import { ArrowUpFromLine, Coins, Info } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { Tooltip } from '@/components/ui/Tooltip'; +import { SubmitProposalTooltip } from '@/components/tooltips/SubmitProposalTooltip'; +import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; +import type { FundingPool, FundingPoolAmount, GrantApplicationVisibility } from '@/types/grant'; +import { formatCurrency } from '@/utils/currency'; +import { formatRSC } from '@/utils/number'; +import { cn } from '@/utils/styles'; + +/** A first $100 against a $25K grant is a sliver; floor it so it shows on the bar. */ +const MIN_VISIBLE_PERCENT = 4; + +function formatAmount(amount: FundingPoolAmount, showUSD: boolean): string { + return formatCurrency({ + amount: showUSD ? amount.usd : amount.rsc, + showUSD, + exchangeRate: 1, + skipConversion: true, + }); +} + +/** $850, $2.4K, $12K. The shared shortener rounds $2.4K down to $2K, which misreads. */ +function formatCompact(amount: FundingPoolAmount, showUSD: boolean): string { + if (!showUSD) return `${formatRSC({ amount: amount.rsc, shorten: true })} RSC`; + const usd = amount.usd; + if (usd < 1_000) return `$${Math.round(usd).toLocaleString()}`; + if (usd < 10_000) return `$${parseFloat((usd / 1_000).toFixed(1))}K`; + if (usd < 1_000_000) return `$${Math.round(usd / 1_000)}K`; + return `$${parseFloat((usd / 1_000_000).toFixed(1))}M`; +} + +interface GrantFundingPoolWidgetProps { + organization: string; + grantAmount: FundingPoolAmount; + fundingPool: FundingPool; + /** Whether pool contributions are currently accepted. */ + isOpen: boolean; + /** Whether the grant is accepting proposals. */ + canApply: boolean; + applicationVisibility?: GrantApplicationVisibility; + /** Grant creators and moderators also see what is still waiting to be allocated. */ + canManagePool: boolean; + onApply: () => void; + onContribute: () => void; + className?: string; +} + +/** + * One card for both audiences: researchers apply, funders add to the pool. + * The breakdown is a single fixed-height line so the card matches the title + * block beside it; the full numbers live in the info tooltip. + */ +export function GrantFundingPoolWidget({ + organization, + grantAmount, + fundingPool, + isOpen, + canApply, + applicationVisibility, + canManagePool, + onApply, + onContribute, + className, +}: GrantFundingPoolWidgetProps) { + const { showUSD } = useCurrencyPreference(); + + const raised = fundingPool.amountRaised; + const allocated = fundingPool.amountDistributed; + const holding = fundingPool.amountHolding; + + const total: FundingPoolAmount = { + usd: (grantAmount.usd ?? 0) + (raised.usd ?? 0), + rsc: (grantAmount.rsc ?? 0) + (raised.rsc ?? 0), + }; + + const grantShare = showUSD ? grantAmount.usd : grantAmount.rsc; + const raisedShare = showUSD ? raised.usd : raised.rsc; + const totalShare = grantShare + raisedShare; + const hasCommunityFunding = raisedShare > 0; + const rawCommunityPercent = totalShare > 0 ? (raisedShare / totalShare) * 100 : 0; + const communityPercent = hasCommunityFunding + ? Math.max(MIN_VISIBLE_PERCENT, rawCommunityPercent) + : 0; + const showHolding = canManagePool && isOpen; + + const funderLabel = organization || 'The funder'; + + const breakdownRow = (label: string, value: string, emphasis = false) => ( +
+ {label} + + {value} + +
+ ); + + const breakdown = ( +
+

+ Community contributions are pooled and awarded to the strongest proposals by {funderLabel}. +

+
+ {breakdownRow(funderLabel, formatAmount(grantAmount, showUSD))} + {breakdownRow('Community', formatAmount(raised, showUSD))} + {breakdownRow('Allocated to proposals', formatAmount(allocated, showUSD))} + {showHolding && breakdownRow('Ready to allocate', formatAmount(holding, showUSD), true)} +
+
+ ); + + const nothingToDo = !canApply && !isOpen; + + return ( +
+
+ + Funding pool + {!isOpen && ยท Closed} + + + + + + {formatAmount(total, showUSD)} + +
+ +
+ + +
+ + {/* Fixed-width labels ("Funder", "Community") so this line never wraps; the org name is in the subtitle beside it. */} +
+ + + {hasCommunityFunding ? ( + + + ) : ( + + + )} +
+ + {nothingToDo ? ( +

This RFP is closed

+ ) : ( +
+ {canApply && ( + + + + )} + {isOpen && ( + + )} +
+ )} +
+ ); +} diff --git a/components/work/WorkHeader/WorkHeader.tsx b/components/work/WorkHeader/WorkHeader.tsx index f7c73ec14..f24334095 100644 --- a/components/work/WorkHeader/WorkHeader.tsx +++ b/components/work/WorkHeader/WorkHeader.tsx @@ -51,6 +51,8 @@ interface WorkHeaderProps { reviewsTabUrl?: string; primaryAction?: ReactNode; hideVoteWidget?: boolean; + /** Top-align title and CTA column; use when the CTA is a tall panel. */ + alignTop?: boolean; grantModalProps?: { isApplyToGrantModalOpen: boolean; onCloseApplyToGrantModal: () => void; @@ -73,6 +75,7 @@ export function WorkHeader({ reviewsTabUrl: reviewsTabUrlOverride, primaryAction, hideVoteWidget = false, + alignTop = false, grantModalProps, }: WorkHeaderProps) { const [isTipModalOpen, setIsTipModalOpen] = useState(false); @@ -256,6 +259,7 @@ export function WorkHeader({ actions={actionBar} cta={primaryAction} className={className} + alignTop={alignTop} >
{resolvedTabs}
diff --git a/components/work/WorkHeader/WorkHeaderGrant.tsx b/components/work/WorkHeader/WorkHeaderGrant.tsx index 6c632dc62..63cdbad76 100644 --- a/components/work/WorkHeader/WorkHeaderGrant.tsx +++ b/components/work/WorkHeader/WorkHeaderGrant.tsx @@ -13,11 +13,13 @@ import { useGrantTab, type GrantBannerTab } from '@/components/Funding/GrantPage import { useFundraises } from '@/contexts/FundraiseContext'; import { useUser } from '@/contexts/UserContext'; import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; -import type { FundingPool, GrantApplicationVisibility } from '@/types/grant'; +import type { FundingPool, FundingPoolAmount, GrantApplicationVisibility } from '@/types/grant'; import { formatCurrency } from '@/utils/currency'; import { ID } from '@/types/root'; import { WorkHeader } from './WorkHeader'; import { WorkHeaderGrantEyebrow } from './WorkHeaderGrantEyebrow'; +import { GrantFundingPoolWidget } from './GrantFundingPoolWidget'; +import { PendingReviewBadge } from './PendingReviewBadge'; const RFP_FUNDING_POOL_PARAM = 'rfpFundingPool'; @@ -34,6 +36,8 @@ interface WorkHeaderGrantProps { work: Work; metadata: WorkMetadata; amountUsd?: number; + /** The grant's own amount, before any community contributions. */ + grantAmount?: FundingPoolAmount | null; grantId?: string; isActive?: boolean; isPending?: boolean; @@ -49,6 +53,7 @@ export function WorkHeaderGrant({ work, metadata, amountUsd, + grantAmount = null, grantId, isActive = true, isPending = false, @@ -68,7 +73,13 @@ export function WorkHeaderGrant({ searchParams.get(RFP_FUNDING_POOL_PARAM) === '1'; const [isApplyModalOpen, setIsApplyModalOpen] = useState(false); const [isContributeModalOpen, setIsContributeModalOpen] = useState(false); - const { activeTab, setActiveTab, activity, fundingPool: contextPool } = useGrantTab(); + const { + activeTab, + setActiveTab, + activity, + fundingPool: contextPool, + setFundingPool, + } = useGrantTab(); const { proposalCount } = useFundraises(); const fundingPool = contextPool ?? fundingPoolProp; @@ -83,24 +94,31 @@ export function WorkHeaderGrant({ grantCreatedByUserId != null && Number(user.id) === Number(grantCreatedByUserId); const canManagePool = isGrantCreator || !!user?.isModerator; - const showPoolHolding = - isRfpFundingPoolEnabled && - canManagePool && - fundingPool?.status === 'OPEN' && - (fundingPool.amountHolding.rsc ?? 0) >= 0; - const eyebrow = ( + // The pool widget replaces the amount eyebrow and the bare Contribute button + // whenever the flag is on and the grant has a pool, open or closed. + const showPoolWidget = isRfpFundingPoolEnabled && !!fundingPool && !!grantAmount; + const isPoolOpen = !!grantId && isActive && fundingPool?.status === 'OPEN'; + + const eyebrow = showPoolWidget ? ( + isPending ? ( + + ) : null + ) : ( ); const requiresPrivateApplications = applicationVisibility === 'PRIVATE'; - const canContributeToPool = - isRfpFundingPoolEnabled && !!grantId && isActive && fundingPool?.status === 'OPEN'; + const canContributeToPool = isRfpFundingPoolEnabled && isPoolOpen; - const handleContributeSuccess = useCallback(() => { - setIsContributeModalOpen(false); - router.refresh(); - }, [router]); + const handleContributeSuccess = useCallback( + (updatedPool?: FundingPool) => { + setIsContributeModalOpen(false); + if (updatedPool) setFundingPool(updatedPool); + router.refresh(); + }, + [router, setFundingPool] + ); const subtitle = organization ? (
@@ -109,8 +127,55 @@ export function WorkHeaderGrant({
) : undefined; - const primaryAction = + const submitProposalButton = grantId && isActive ? ( + + + + ) : null; + + const privateApplicationsNote = requiresPrivateApplications ? ( +
+ + Your proposal will be submitted privately +
+ ) : null; + + const showLegacyPoolHolding = + !showPoolWidget && + isRfpFundingPoolEnabled && + canManagePool && + fundingPool?.status === 'OPEN' && + (fundingPool.amountHolding.rsc ?? 0) >= 0; + + let primaryAction: ReactNode; + if (showPoolWidget && fundingPool && grantAmount) { + primaryAction = ( +
+ setIsApplyModalOpen(true)} + onContribute={() => setIsContributeModalOpen(true)} + /> +
+ ); + } else if (grantId && isActive) { + primaryAction = ( <>
{canContributeToPool && ( @@ -125,20 +190,9 @@ export function WorkHeaderGrant({ Contribute )} - - - + {submitProposalButton}
- {showPoolHolding && fundingPool && ( + {showLegacyPoolHolding && fundingPool && (
)} - {requiresPrivateApplications && ( -
- - Your proposal will be submitted privately -
- )} + {privateApplicationsNote} - ) : undefined; + ); + } const activityCount = activity.count; const activityCountLabel = @@ -230,6 +280,7 @@ export function WorkHeaderGrant({ tabs={tabs} primaryAction={primaryAction} hideVoteWidget + alignTop={showPoolWidget} grantModalProps={ grantId ? {