From 60c082c58352891e684ad7f76af761cd08509ad6 Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Wed, 9 Sep 2026 21:11:56 +0300 Subject: [PATCH 1/3] Add RFP Contribute CTA and funding-pool mode on contribute --- app/grant/[id]/[slug]/layout.tsx | 2 + components/Funding/PaymentRequestButton.tsx | 18 +- components/Funding/PaymentStep.tsx | 16 +- components/Funding/QuickAmountSelector.tsx | 5 +- .../modals/ContributeToFundraiseModal.tsx | 214 +++++++++++------- .../work/WorkHeader/WorkHeaderGrant.tsx | 118 ++++++---- types/grant.ts | 2 +- 7 files changed, 243 insertions(+), 132 deletions(-) diff --git a/app/grant/[id]/[slug]/layout.tsx b/app/grant/[id]/[slug]/layout.tsx index 9ed97032e..7efd0c2d2 100644 --- a/app/grant/[id]/[slug]/layout.tsx +++ b/app/grant/[id]/[slug]/layout.tsx @@ -73,11 +73,13 @@ export default async function GrantSlugLayout({ params, children }: Props) { work={work} metadata={metadata} amountUsd={grant?.amount?.usd} + grantAmountUsd={grant?.amount?.usd} grantId={grantId?.toString()} isActive={isActive} isPending={isPending} organization={grant?.organization} applicationVisibility={grant?.applicationVisibility} + fundingPool={grant?.fundingPool ?? null} preTitle={ { paymentRequest.off('paymentmethod', handlePaymentMethod); }; - }, [paymentRequest, stripe, amountInRsc, fundraiseId, onSuccess, onError]); + }, [paymentRequest, stripe, amountInRsc, paymentTarget, onSuccess, onError]); // Still checking availability if (canMakePayment === null) { diff --git a/components/Funding/PaymentStep.tsx b/components/Funding/PaymentStep.tsx index 30ed3974d..c575bf245 100644 --- a/components/Funding/PaymentStep.tsx +++ b/components/Funding/PaymentStep.tsx @@ -25,8 +25,8 @@ import { PAYMENT_PROCESSING_FEE, METHODS_WITH_PROCESSING_FEE, } from './lib/constants'; -import { ID } from '@/types/root'; import AnalyticsService, { LogEvent } from '@/services/analytics.service'; +import type { PaymentIntentTarget } from '@/services/payment.service'; interface PaymentStepProps { /** Amount in RSC (before fees) */ @@ -39,8 +39,8 @@ interface PaymentStepProps { rscBalance: number; /** User's funding credits balance (excludes promotional RSC) */ fundingCreditsBalance?: number; - /** Fundraise ID for payment request button */ - fundraiseId: ID; + /** Fundraise or funding pool target for Apple Pay / Google Pay */ + paymentTarget: PaymentIntentTarget; /** Wallet payment method availability from Stripe (resolved at modal level) */ walletAvailability: WalletAvailability; /** Whether the fundraise has a non-profit org (shows Endaoment option) */ @@ -71,7 +71,7 @@ export function PaymentStep({ amountDisplay, rscBalance, fundingCreditsBalance = 0, - fundraiseId, + paymentTarget, walletAvailability, hasNonprofit = false, isProcessing = false, @@ -176,13 +176,15 @@ export function PaymentStep({ // Track payment method selection if (method) { AnalyticsService.logEvent(LogEvent.FUNDRAISE_CONTRIBUTION_PAYMENT_METHOD_SELECTED, { - fundraise_id: fundraiseId, + ...('fundingPoolId' in paymentTarget + ? { funding_pool_id: paymentTarget.fundingPoolId } + : { fundraise_id: paymentTarget.fundraiseId }), payment_method: method, amount_usd: amountInUsd, }); } }, - [fundraiseId, amountInUsd] + [paymentTarget, amountInUsd] ); // Dummy handlers for PaymentWidget (we handle the action in this component) @@ -324,7 +326,7 @@ export function PaymentStep({ void; /** Remaining goal amount in USD */ remainingGoalUsd: number; + /** Hide the Remaining button (e.g. unbounded RFP pool contributions) */ + showRemaining?: boolean; /** Optional class name */ className?: string; } @@ -32,6 +34,7 @@ export const QuickAmountSelector: FC = ({ selectedAmount, onAmountSelect, remainingGoalUsd, + showRemaining = true, className, }) => { const handleAmountClick = useCallback( @@ -79,7 +82,7 @@ export const QuickAmountSelector: FC = ({ ))} {/* Remaining button */} - {remainingGoalUsd > 0 && ( + {showRemaining && remainingGoalUsd > 0 && ( - +
+ {canContributeToPool && ( + + )} + + + +
{requiresPrivateApplications && (
@@ -130,31 +158,45 @@ export function WorkHeaderGrant({ }, ]; - const tabs = ( - - ); + const tabs = ; + + const grantTitle = work.note?.post?.grant?.shortTitle || work.title; return ( - setIsApplyModalOpen(false), - grantId, - grantApplicationVisibility: applicationVisibility, - } - : undefined - } - /> + <> + setIsApplyModalOpen(false), + grantId, + grantApplicationVisibility: applicationVisibility, + } + : undefined + } + /> + + {fundingPool?.status === 'OPEN' && ( + setIsContributeModalOpen(false)} + onContributeSuccess={handleContributeSuccess} + fundingPool={fundingPool} + grantAmount={{ usd: grantAmountUsd ?? amountUsd ?? 0 }} + proposalTitle={grantTitle} + /> + )} + ); } diff --git a/types/grant.ts b/types/grant.ts index eda19cf86..3535eb9fa 100644 --- a/types/grant.ts +++ b/types/grant.ts @@ -131,7 +131,7 @@ export interface Grant { endDate: string; contacts: Contact[]; applicationVisibility: GrantApplicationVisibility; - /** Null until ensure / create; slim feed serializers may omit the field entirely. */ + /** Null when omitted (slim feed) or not yet backfilled; BE creates pools on grant create. */ fundingPool: FundingPool | null; applicants?: AuthorProfile[]; reviewedBy?: { From dcac83740f017188340a8d9ef3c446a3f618742b Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Wed, 9 Sep 2026 21:28:27 +0300 Subject: [PATCH 2/3] Add RFP funding pool contribute flow and badge totals --- app/grant/[id]/[slug]/layout.tsx | 4 +++- components/Feed/items/FeedItemGrantWithApplicants.tsx | 6 +++--- components/Fund/FundraiseProgress.tsx | 5 ++--- components/Fund/FundraiseProgressV2.tsx | 5 ++--- components/Funding/GrantCard.tsx | 3 ++- components/Funding/GrantCarousel.tsx | 6 ++++-- components/work/WorkHeader/WorkHeaderProposal.tsx | 5 ++--- 7 files changed, 18 insertions(+), 16 deletions(-) diff --git a/app/grant/[id]/[slug]/layout.tsx b/app/grant/[id]/[slug]/layout.tsx index 7efd0c2d2..96857b875 100644 --- a/app/grant/[id]/[slug]/layout.tsx +++ b/app/grant/[id]/[slug]/layout.tsx @@ -12,6 +12,7 @@ import { GrantTabProvider } from '@/components/Funding/GrantPageContent'; import { WorkHeaderGrant } from '@/components/work/WorkHeader/index'; import { RegisteredReportRouteTrackerLoader } from '@/components/work/RegisteredReportRouteTrackerLoader'; import { SearchHistoryTracker } from '@/components/work/SearchHistoryTracker'; +import { getGrantBadgeAmount } from '@/types/grant'; interface Props { params: Promise<{ @@ -61,6 +62,7 @@ export default async function GrantSlugLayout({ params, children }: Props) { const isPending = grant?.status === 'PENDING'; const isActive = grant?.status === 'OPEN' && (grant?.endDate ? isDeadlineInFuture(grant.endDate) : true); + const badgeAmountUsd = grant ? getGrantBadgeAmount(grant).usd : undefined; const metadata = await MetadataService.get(work.unifiedDocumentId?.toString() || ''); @@ -72,7 +74,7 @@ export default async function GrantSlugLayout({ params, children }: Props) { = slug: content.slug, }); - const budgetAmount = showUSD - ? Math.round(grant.amount?.usd || 0) - : Math.round(grant.amount?.rsc || 0); + const badgeAmount = getGrantBadgeAmount(grant); + const budgetAmount = showUSD ? Math.round(badgeAmount.usd) : Math.round(badgeAmount.rsc); const allProposals = grant.applicants?.filter((a) => a.fundraise) ?? []; const shown = expanded ? allProposals : allProposals.slice(0, VISIBLE_PROPOSALS); diff --git a/components/Fund/FundraiseProgress.tsx b/components/Fund/FundraiseProgress.tsx index c76354cd4..29bc04b1e 100644 --- a/components/Fund/FundraiseProgress.tsx +++ b/components/Fund/FundraiseProgress.tsx @@ -3,7 +3,7 @@ import { FC, useState } from 'react'; import { Progress } from '@/components/ui/Progress'; import { ContributorsButton } from '@/components/ui/ContributorsButton'; -import { Clock } from 'lucide-react'; +import { Clock, Coins } from 'lucide-react'; import { formatDeadline, formatExactTime } from '@/utils/date'; import { isFundraiseActive } from '@/components/Fund/lib/fundraiseUtils'; import type { Fundraise } from '@/types/funding'; @@ -12,7 +12,6 @@ import { Button } from '@/components/ui/Button'; import { cn } from '@/utils/styles'; import { CurrencyBadge } from '@/components/ui/CurrencyBadge'; import { ContributeToFundraiseModal } from '@/components/modals/ContributeToFundraiseModal'; -import { Icon } from '../ui/icons'; import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; import { useShareModalContext } from '@/contexts/ShareContext'; import { useRouter } from 'next/navigation'; @@ -257,7 +256,7 @@ export const FundraiseProgress: FC = ({ )} onClick={handleContributeClick} > - + Fund proposal ) : onDetailsClick ? ( diff --git a/components/Fund/FundraiseProgressV2.tsx b/components/Fund/FundraiseProgressV2.tsx index 3d0e91ae9..32a6c5455 100644 --- a/components/Fund/FundraiseProgressV2.tsx +++ b/components/Fund/FundraiseProgressV2.tsx @@ -4,7 +4,7 @@ import { FC, useState } from 'react'; import { Progress } from '@/components/ui/Progress'; import { AvatarStack } from '@/components/ui/AvatarStack'; import { ContributorsButton } from '@/components/ui/ContributorsButton'; -import { Clock } from 'lucide-react'; +import { Clock, Coins } from 'lucide-react'; import { formatDeadline, formatExactTime, formatDate } from '@/utils/date'; import { isFundraiseActive } from '@/components/Fund/lib/fundraiseUtils'; import type { Fundraise } from '@/types/funding'; @@ -13,7 +13,6 @@ import { Button } from '@/components/ui/Button'; import { cn } from '@/utils/styles'; import { CurrencyBadge } from '@/components/ui/CurrencyBadge'; import { ContributeToFundraiseModal } from '@/components/modals/ContributeToFundraiseModal'; -import { Icon } from '../ui/icons'; import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; import { useShareModalContext } from '@/contexts/ShareContext'; import { useRouter } from 'next/navigation'; @@ -347,7 +346,7 @@ export const FundraiseProgress: FC = ({ className="flex items-center gap-1.5 bg-orange-500 hover:bg-orange-600 text-white font-semibold transition-all duration-200 border-0" onClick={handleContributeClick} > - + Fund this research ) : onDetailsClick ? ( diff --git a/components/Funding/GrantCard.tsx b/components/Funding/GrantCard.tsx index 919493d0d..aaa4154f2 100644 --- a/components/Funding/GrantCard.tsx +++ b/components/Funding/GrantCard.tsx @@ -4,6 +4,7 @@ import { FC } from 'react'; import Image from 'next/image'; import Link from 'next/link'; import { FeedEntry, FeedGrantContent } from '@/types/feed'; +import { getGrantBadgeAmount } from '@/types/grant'; import { cn } from '@/utils/styles'; import { buildWorkUrl } from '@/utils/url'; import { Users } from 'lucide-react'; @@ -31,7 +32,7 @@ export const GrantCard: FC = ({ entry, className }) => { const isClosed = grant.status === 'CLOSED'; const applicantCount = grant.applicants?.length ?? 0; - const amount = grant.amount?.usd ?? 0; + const amount = getGrantBadgeAmount(grant).usd; return ( = ({ const hasFetchedProposals = hasBeenVisible && (entries.length > 0 || !isLoading); const showSkeleton = !hasFetchedProposals; + const badgeUsd = getGrantBadgeAmount(grantData).usd; return (
@@ -90,7 +92,7 @@ export const GrantCarousel: FC = ({ {getShortTitle(content.grant.shortTitle, content.title)} - {grantData.amount?.usd && ( + {badgeUsd > 0 && ( = ({ : 'bg-green-50 border border-green-200 text-green-700' )} > - {formatCompactUSD(grantData.amount.usd)} pool + {formatCompactUSD(badgeUsd)} pool )}
diff --git a/components/work/WorkHeader/WorkHeaderProposal.tsx b/components/work/WorkHeader/WorkHeaderProposal.tsx index feb495b50..93dafa010 100644 --- a/components/work/WorkHeader/WorkHeaderProposal.tsx +++ b/components/work/WorkHeader/WorkHeaderProposal.tsx @@ -1,11 +1,10 @@ 'use client'; import { type ReactNode, useState } from 'react'; -import { Globe2, Link2 } from 'lucide-react'; +import { Coins, Globe2, Link2 } from 'lucide-react'; import { Work } from '@/types/work'; import { WorkMetadata } from '@/services/metadata.service'; import { Button } from '@/components/ui/Button'; -import { Icon } from '@/components/ui/icons'; import { BaseMenuItem } from '@/components/ui/form/BaseMenu'; import { ConfirmationModal } from '@/components/ui/form/ConfirmationModal'; import { ContributeToFundraiseModal } from '@/components/modals/ContributeToFundraiseModal'; @@ -119,7 +118,7 @@ export function WorkHeaderProposal({ onClick={() => setIsFundModalOpen(true)} className="hidden tablet:flex gap-2" > - + Fund Proposal ) : undefined; From 8e496f654d22d1590e288a91426b51e8e5a4437f Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Thu, 10 Sep 2026 13:47:01 +0300 Subject: [PATCH 3/3] Remove unused grantAmountUsd prop from GrantSlugLayout and WorkHeaderGrant components; update ContributeToFundraiseModal to handle funding pool mode without grant amount. --- app/grant/[id]/[slug]/layout.tsx | 1 - .../modals/ContributeToFundraiseModal.tsx | 34 +++++++++++-------- .../work/WorkHeader/WorkHeaderGrant.tsx | 3 -- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/app/grant/[id]/[slug]/layout.tsx b/app/grant/[id]/[slug]/layout.tsx index 96857b875..af03de82d 100644 --- a/app/grant/[id]/[slug]/layout.tsx +++ b/app/grant/[id]/[slug]/layout.tsx @@ -75,7 +75,6 @@ export default async function GrantSlugLayout({ params, children }: Props) { work={work} metadata={metadata} amountUsd={badgeAmountUsd} - grantAmountUsd={grant?.amount?.usd} grantId={grantId?.toString()} isActive={isActive} isPending={isPending} diff --git a/components/modals/ContributeToFundraiseModal.tsx b/components/modals/ContributeToFundraiseModal.tsx index 7a9d8682d..3d0ceaffa 100644 --- a/components/modals/ContributeToFundraiseModal.tsx +++ b/components/modals/ContributeToFundraiseModal.tsx @@ -46,9 +46,8 @@ interface ContributeModalCommonProps { /** Replaces the `proposalTitle` subtitle. */ headerSubtitle?: string; /** - * Progress figures to show instead of the target's own. Pooled campaigns - * contribute to one fundraise but present the pool's totals, since that's - * the goal the funder is actually backing. + * Progress figures to show instead of the fundraise's own (e.g. app/pool campaigns). + * Ignored in fundingPool mode — the RFP pool is unbounded extra money with no goal. */ progressOverride?: { currentAmountUsd: number; goalAmountUsd: number }; /** @@ -72,12 +71,10 @@ export type ContributeToFundraiseModalProps = ContributeModalCommonProps & mode?: 'fundraise'; fundraise: Fundraise; fundingPool?: never; - grantAmount?: never; } | { mode: 'fundingPool'; fundingPool: FundingPool; - grantAmount: { usd: number }; fundraise?: never; } ); @@ -136,7 +133,6 @@ function ContributeToFundraiseModalInner(props: Readonly { if (currentView === 'payment' || currentView === 'auth') { @@ -636,12 +633,21 @@ function ContributeToFundraiseModalInner(props: Readonly 0} + showRemaining={!isPoolMode} /> - {/* Funding Impact Preview with Slider */} - {goalAmountUsd > 0 && ( + {isPoolMode && poolRaisedUsd > 0 && ( +

+ Raised so far{' '} + + {formatUsd(poolRaisedUsd)} + +

+ )} + + {/* Goal progress + slider — proposal fundraises only (pool has no goal). */} + {!isPoolMode && goalAmountUsd > 0 && ( a.authorProfile)} + authors={work?.authors.map((a) => a.authorProfile)} /> )} diff --git a/components/work/WorkHeader/WorkHeaderGrant.tsx b/components/work/WorkHeader/WorkHeaderGrant.tsx index b0c66df8a..651f55ffc 100644 --- a/components/work/WorkHeader/WorkHeaderGrant.tsx +++ b/components/work/WorkHeader/WorkHeaderGrant.tsx @@ -19,7 +19,6 @@ interface WorkHeaderGrantProps { work: Work; metadata: WorkMetadata; amountUsd?: number; - grantAmountUsd?: number; grantId?: string; isActive?: boolean; isPending?: boolean; @@ -34,7 +33,6 @@ export function WorkHeaderGrant({ work, metadata, amountUsd, - grantAmountUsd, grantId, isActive = true, isPending = false, @@ -193,7 +191,6 @@ export function WorkHeaderGrant({ onClose={() => setIsContributeModalOpen(false)} onContributeSuccess={handleContributeSuccess} fundingPool={fundingPool} - grantAmount={{ usd: grantAmountUsd ?? amountUsd ?? 0 }} proposalTitle={grantTitle} /> )}