diff --git a/app/grant/[id]/[slug]/layout.tsx b/app/grant/[id]/[slug]/layout.tsx index 9ed97032e..af03de82d 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,12 +74,13 @@ 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/Funding/PaymentRequestButton.tsx b/components/Funding/PaymentRequestButton.tsx index f822b9257..68a69dad7 100644 --- a/components/Funding/PaymentRequestButton.tsx +++ b/components/Funding/PaymentRequestButton.tsx @@ -5,16 +5,15 @@ import { PaymentRequestButtonElement, useStripe } from '@stripe/react-stripe-js' import type { PaymentRequest, PaymentRequestPaymentMethodEvent } from '@stripe/stripe-js'; import { Button } from '@/components/ui/Button'; import { StripeProvider } from './StripeProvider'; -import { PaymentService } from '@/services/payment.service'; -import { ID } from '@/types/root'; +import { PaymentService, type PaymentIntentTarget } from '@/services/payment.service'; interface PaymentRequestButtonProps { /** Amount in cents */ amountCents: number; /** Amount in RSC for creating payment intent */ amountInRsc: number; - /** Fundraise ID for the contribution */ - fundraiseId: ID; + /** Fundraise or funding pool target for the contribution */ + paymentTarget: PaymentIntentTarget; /** Label shown in the payment sheet */ label?: string; /** Button text to show when payment method is not available */ @@ -34,7 +33,7 @@ interface PaymentRequestButtonProps { function PaymentRequestButtonInner({ amountCents, amountInRsc, - fundraiseId, + paymentTarget, label = 'Fund Research', unavailableText = 'Not available on this device', onSuccess, @@ -104,9 +103,10 @@ function PaymentRequestButtonInner({ try { // Create payment intent on our backend - const { clientSecret } = await PaymentService.createPaymentIntent(amountInRsc, { - fundraiseId, - }); + const { clientSecret } = await PaymentService.createPaymentIntent( + amountInRsc, + paymentTarget + ); // Confirm the payment with the payment method from Apple Pay/Google Pay const { error: confirmError, paymentIntent } = await stripe!.confirmCardPayment( @@ -149,7 +149,7 @@ function PaymentRequestButtonInner({ return () => { 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 && (
@@ -132,27 +158,42 @@ export function WorkHeaderGrant({ 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} + proposalTitle={grantTitle} + /> + )} + ); } 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;