diff --git a/app/grant/[id]/[slug]/layout.tsx b/app/grant/[id]/[slug]/layout.tsx index 9ed97032e..f2ca39f3b 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<{ @@ -58,6 +59,7 @@ export default async function GrantSlugLayout({ params, children }: Props) { const grant = work.note?.post?.grant; const grantId = grant?.id ?? undefined; const grantTitle = grant?.shortTitle || work.title; + const badgeAmountUsd = grant ? getGrantBadgeAmount(grant).usd : undefined; const isPending = grant?.status === 'PENDING'; const isActive = grant?.status === 'OPEN' && (grant?.endDate ? isDeadlineInFuture(grant.endDate) : true); @@ -65,19 +67,27 @@ export default async function GrantSlugLayout({ params, children }: Props) { const metadata = await MetadataService.get(work.unifiedDocumentId?.toString() || ''); return ( - + }> - + } > diff --git a/components/Activity/cards/ActivityCardCompact.tsx b/components/Activity/cards/ActivityCardCompact.tsx index 8006e0572..ab74f54ca 100644 --- a/components/Activity/cards/ActivityCardCompact.tsx +++ b/components/Activity/cards/ActivityCardCompact.tsx @@ -18,21 +18,43 @@ import { getReviewEarning, getReviewScore, } from '../lib/activityDisplay.utils'; -import { getActivityBounty, shouldShowAuthorBadge } from '../lib/activityWork.utils'; +import { + getActivityBounty, + getActivityWork, + shouldShowAuthorBadge, +} from '../lib/activityWork.utils'; import { formatTimeAgo } from '@/utils/date'; import { Tooltip } from '@/components/ui/Tooltip'; import type { FeedEntry } from '@/types/feed'; +import { ID } from '@/types/root'; interface ActivityCardCompactProps { entry: FeedEntry; + currentDocumentId?: ID; +} + +function isEntryAboutDocument(entry: FeedEntry, documentId: ID): boolean { + const targetId = Number(documentId); + if (!Number.isFinite(targetId)) return false; + + const work = getActivityWork(entry); + if (work?.id != null && Number(work.id) === targetId) return true; + + if (entry.relatedWork?.id != null && Number(entry.relatedWork.id) === targetId) return true; + + const contentId = entry.content?.id; + return contentId != null && Number(contentId) === targetId; } /** Compact activity row used in the activity sidebar. */ -export const ActivityCardCompact: FC = ({ entry }) => { +export const ActivityCardCompact: FC = ({ entry, currentDocumentId }) => { const { title, href } = getEntryMeta(entry); if (!title) return null; + /** When the entry's work is this post, hide the title link (already on that page). */ + const hideTitle = currentDocumentId != null && isEntryAboutDocument(entry, currentDocumentId); + const message = getActivityHeaderMessage(entry); const actionIcon = getActionIcon(entry); const reviewScore = getReviewScore(entry); @@ -118,7 +140,9 @@ export const ActivityCardCompact: FC = ({ entry }) => className="text-sm leading-5" isAuthor={shouldShowAuthorBadge(entry, message.actor.id)} /> - {titleEl} + {!hideTitle && ( + {titleEl} + )} {formatTimeAgo(entry.timestamp)} diff --git a/components/Activity/cards/ActivityFundingGroupCard.tsx b/components/Activity/cards/ActivityFundingGroupCard.tsx index d4d5d5962..f54f1ce80 100644 --- a/components/Activity/cards/ActivityFundingGroupCard.tsx +++ b/components/Activity/cards/ActivityFundingGroupCard.tsx @@ -60,9 +60,10 @@ const FunderName: FC<{ funder: AuthorProfile }> = ({ funder }) => { ); }; -const FunderSummary: FC<{ funders: AuthorProfile[] }> = ({ funders }) => { +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.'; return ( <> @@ -80,7 +81,7 @@ const FunderSummary: FC<{ funders: AuthorProfile[] }> = ({ funders }) => { {remaining > 0 && ( {` and ${remaining} ${remaining === 1 ? 'other' : 'others'}`} )} - funded this proposal. + {action} ); }; @@ -98,6 +99,7 @@ export const ActivityFundingGroupCard: FC = ({ ro const latestEntryId = String(latestEntry.id); const presentation = getWorkCardPresentation(latestEntry, work, { showUSD, exchangeRate }); const total = toPreferredTotal(totals, showUSD, exchangeRate); + const isRfp = work.documentType === 'funding_request'; const avatarItems = funders.map((funder) => ({ src: funder.profileImage || '', @@ -129,7 +131,7 @@ export const ActivityFundingGroupCard: FC = ({ ro
- {' '} + {' '}
diff --git a/components/Activity/lib/activityDisplay.utils.ts b/components/Activity/lib/activityDisplay.utils.ts index 135c1e60c..b108f0c09 100644 --- a/components/Activity/lib/activityDisplay.utils.ts +++ b/components/Activity/lib/activityDisplay.utils.ts @@ -90,6 +90,13 @@ function getFundingActivityMessage(content: FeedFundingActivityContent): Activit }; } +export function isFundingPoolContribution(entry: FeedEntry): boolean { + if (entry.contentType !== 'PURCHASE' && entry.contentType !== 'USDFUNDRAISECONTRIBUTION') { + return false; + } + return entry.relatedWork?.contentType === 'funding_request'; +} + function getDefaultActivityMessage(entry: FeedEntry): ActivityHeaderMessage { const actor = entry.content.createdBy; @@ -129,7 +136,10 @@ function getDefaultActivityMessage(entry: FeedEntry): ActivityHeaderMessage { } if (entry.contentType === 'USDFUNDRAISECONTRIBUTION' || entry.contentType === 'PURCHASE') { - return { actor, verb: 'funded this proposal.' }; + return { + actor, + verb: isFundingPoolContribution(entry) ? 'contributed to this RFP.' : 'funded this proposal.', + }; } return { diff --git a/components/Activity/sidebar/ActivitySidebar.tsx b/components/Activity/sidebar/ActivitySidebar.tsx index b13fa0ff4..56f36a950 100644 --- a/components/Activity/sidebar/ActivitySidebar.tsx +++ b/components/Activity/sidebar/ActivitySidebar.tsx @@ -5,14 +5,22 @@ import { Activity, Reply } from 'lucide-react'; import { ActivityCardCompact } from '../cards/ActivityCardCompact'; import type { FeedEntry } from '@/types/feed'; import { SidebarHeader } from '@/components/ui/SidebarHeader'; +import { ID } from '@/types/root'; interface ActivitySidebarProps { topSection?: ReactNode; entries?: FeedEntry[]; grantTitle?: string; + /** Post id of the page being viewed — hides same-document title links. */ + currentDocumentId?: ID; } -export const ActivitySidebar: FC = ({ topSection, entries, grantTitle }) => { +export const ActivitySidebar: FC = ({ + topSection, + entries, + grantTitle, + currentDocumentId, +}) => { const hasEntries = entries && entries.length > 0; return ( @@ -45,7 +53,11 @@ export const ActivitySidebar: FC = ({ topSection, entries, ) : (
{entries.map((entry) => ( - + ))}
)} diff --git a/components/Activity/sidebar/ActivitySidebarServer.tsx b/components/Activity/sidebar/ActivitySidebarServer.tsx index 8801b50e7..444e51298 100644 --- a/components/Activity/sidebar/ActivitySidebarServer.tsx +++ b/components/Activity/sidebar/ActivitySidebarServer.tsx @@ -2,11 +2,14 @@ import { ReactNode } from 'react'; import { ActivityService, ActivityScope } from '@/services/activity.service'; import { ActivitySidebar } from './ActivitySidebar'; import type { FeedEntry } from '@/types/feed'; +import { ID } from '@/types/root'; interface ActivitySidebarServerProps { topSection?: ReactNode; grantId?: number | string; grantTitle?: string; + /** Post id of the page being viewed — hides same-document title links. */ + currentDocumentId?: ID; scope?: ActivityScope; } @@ -14,6 +17,7 @@ export async function ActivitySidebarServer({ topSection, grantId, grantTitle, + currentDocumentId, scope = 'grants', }: ActivitySidebarServerProps) { let entries: FeedEntry[] = []; @@ -29,5 +33,12 @@ export async function ActivitySidebarServer({ console.error('Error loading activity sidebar entries:', error); } - return ; + return ( + + ); } diff --git a/components/Feed/items/FeedItemGrantWithApplicants.tsx b/components/Feed/items/FeedItemGrantWithApplicants.tsx index c54b18586..e0de09367 100644 --- a/components/Feed/items/FeedItemGrantWithApplicants.tsx +++ b/components/Feed/items/FeedItemGrantWithApplicants.tsx @@ -16,6 +16,7 @@ import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; import { useExchangeRate } from '@/contexts/ExchangeRateContext'; import { formatCurrency } from '@/utils/currency'; import { Application } from '@/types/funding'; +import { getGrantBadgeAmount } from '@/types/grant'; interface FeedItemGrantWithApplicantsProps { entry: FeedEntry; @@ -149,9 +150,8 @@ export const FeedItemGrantWithApplicants: FC = 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/GrantPageContent.tsx b/components/Funding/GrantPageContent.tsx index 7480421c7..1bafaab65 100644 --- a/components/Funding/GrantPageContent.tsx +++ b/components/Funding/GrantPageContent.tsx @@ -1,8 +1,11 @@ 'use client'; -import { createContext, useContext, useState, ReactNode } from 'react'; +import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; import { useActivityFeed } from '@/hooks/useActivityFeed'; import { FeedEntry } from '@/types/feed'; +import type { Application } from '@/types/funding'; +import type { FundingPool } from '@/types/grant'; +import { ID } from '@/types/root'; export type GrantBannerTab = 'proposals' | 'details' | 'activity'; @@ -22,6 +25,10 @@ interface GrantTabContextValue { lastClickedEntryId: string | null; restorationTab: string; }; + fundingPool: FundingPool | null; + setFundingPool: (pool: FundingPool | null) => void; + applications: Application[]; + grantCreatedByUserId: ID | null; } const GrantTabContext = createContext(null); @@ -32,16 +39,32 @@ export function useGrantTab() { return ctx; } +export function useGrantAllocateContext(): GrantTabContextValue | null { + return useContext(GrantTabContext); +} + export function GrantTabProvider({ children, defaultTab = 'details', grantId, + fundingPool: initialFundingPool = null, + applications: initialApplications = [], + grantCreatedByUserId = null, }: { children: ReactNode; defaultTab?: GrantBannerTab; grantId?: number | string; + fundingPool?: FundingPool | null; + applications?: Application[]; + grantCreatedByUserId?: ID | null; }) { const [activeTab, setActiveTab] = useState(defaultTab); + const [fundingPool, setFundingPool] = useState(initialFundingPool); + + useEffect(() => { + setFundingPool(initialFundingPool); + }, [initialFundingPool]); + const { entries, isLoading, @@ -59,6 +82,10 @@ export function GrantTabProvider({ grantId, }); + const handleSetFundingPool = useCallback((pool: FundingPool | null) => { + setFundingPool(pool); + }, []); + return ( {children} diff --git a/components/Funding/PaymentRequestButton.tsx b/components/Funding/PaymentRequestButton.tsx index 454d0a219..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,7 +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( @@ -147,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({ = ({ entry, onNavigate const { showUSD } = useCurrencyPreference(); const { exchangeRate } = useExchangeRate(); const { updateLastClickedEntryId } = useNavigation(); + const { user } = useUser(); + const router = useRouter(); + const searchParams = useSearchParams(); + const isRfpFundingPoolEnabled = + searchParams.get(RFP_FUNDING_POOL_PARAM) === 'true' || + searchParams.get(RFP_FUNDING_POOL_PARAM) === '1'; + const { isGrantScoped, refresh: refreshProposals } = useFundraises(); + const grantAllocate = useGrantAllocateContext(); + + const [isAllocateOpen, setIsAllocateOpen] = useState(false); const work = getActivityWork(entry); + const fundingPool = grantAllocate?.fundingPool ?? null; + const applicationId = + work && grantAllocate + ? findGrantApplicationIdForPost(grantAllocate.applications, work.id) + : undefined; + + const isGrantCreator = + user?.id != null && + grantAllocate?.grantCreatedByUserId != null && + Number(user.id) === Number(grantAllocate.grantCreatedByUserId); + const canManagePool = isGrantCreator || !!user?.isModerator; + + const canAllocate = + isRfpFundingPoolEnabled && + isGrantScoped && + canManagePool && + fundingPool?.status === 'OPEN' && + (fundingPool.amountHolding.rsc ?? 0) > 0 && + work?.fundraise?.status === 'OPEN' && + applicationId != null; + + const handleAllocateSuccess = useCallback( + (updatedPool: FundingPool) => { + grantAllocate?.setFundingPool(updatedPool); + void refreshProposals(); + router.refresh(); + }, + [grantAllocate, refreshProposals, router] + ); + if (!work) return null; const entryId = String(entry.id); @@ -105,10 +156,41 @@ export const ProposalWorkCard: FC = ({ entry, onNavigate - +
+
+ +
+ {canAllocate && ( + + )} +
+ + {canAllocate && fundingPool && applicationId != null && ( + setIsAllocateOpen(false)} + fundingPool={fundingPool} + applicationId={applicationId} + proposalTitle={work.title} + onSuccess={handleAllocateSuccess} + /> + )} ); }; diff --git a/components/Funding/QuickAmountSelector.tsx b/components/Funding/QuickAmountSelector.tsx index e55b5fe74..faa9ddf36 100644 --- a/components/Funding/QuickAmountSelector.tsx +++ b/components/Funding/QuickAmountSelector.tsx @@ -20,6 +20,8 @@ interface QuickAmountSelectorProps { onAmountSelect: (amount: number) => 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 && ( + )} + + + {currencyLabel} + + } + /> + {amountError &&

{amountError}

} + + +
+ + +
+ + + ); +} diff --git a/components/modals/ContributeToFundraiseModal.tsx b/components/modals/ContributeToFundraiseModal.tsx index 5e3fb48ff..c0454d3ed 100644 --- a/components/modals/ContributeToFundraiseModal.tsx +++ b/components/modals/ContributeToFundraiseModal.tsx @@ -1,14 +1,17 @@ 'use client'; -import { useState, useCallback, useRef, useEffect } from 'react'; +import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { toast } from 'react-hot-toast'; import { FundraiseService } from '@/services/fundraise.service'; -import { PaymentService } from '@/services/payment.service'; +import { FundingPoolService } from '@/services/funding-pool.service'; +import { PaymentService, type PaymentIntentTarget } from '@/services/payment.service'; import { extractApiErrorMessage } from '@/services/lib/serviceUtils'; import AnalyticsService, { LogEvent } from '@/services/analytics.service'; import { useUser } from '@/contexts/UserContext'; import { useExchangeRate } from '@/contexts/ExchangeRateContext'; +import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; import { Fundraise } from '@/types/funding'; +import { FundingPool } from '@/types/grant'; import { Work } from '@/types/work'; import { ArrowLeft, MoveRight, DollarSign } from 'lucide-react'; import { @@ -28,31 +31,29 @@ import { useIsMobile } from '@/hooks/useIsMobile'; import { EndaomentProvider } from '@/contexts/EndaomentContext'; import { useNonprofitByFundraiseId } from '@/hooks/useNonprofitByFundraiseId'; import { getAvailableAndPromotionalRscBalance } from '@/components/ResearchCoin/lib/promotionalBalance'; +import { formatCurrency } from '@/utils/currency'; import AuthContent from '@/components/Auth/AuthContent'; -interface ContributeToFundraiseModalProps { + +interface ContributeModalCommonProps { isOpen: boolean; onClose: () => void; onContributeSuccess?: () => void; - fundraise: Fundraise; - /** Title of the proposal being funded */ + /** Title of the proposal / RFP being funded */ proposalTitle?: string; - /** Work object containing author information */ + /** Work object containing author information (proposal fundraise only) */ work?: Work; - /** Replaces the "Fund Proposal" heading. */ + /** Replaces the default heading. */ headerTitle?: string; /** Replaces the `proposalTitle` subtitle. */ headerSubtitle?: string; /** - * Progress figures to show instead of the target fundraise'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). */ progressOverride?: { currentAmountUsd: number; goalAmountUsd: number }; /** - * Whether to offer the Endaoment donor-advised fund option. Pooled campaigns - * turn it off: the target is picked for the funder, so tax-deductibility - * would vary by draw. + * Whether to offer the Endaoment donor-advised fund option. Forced off in + * fundingPool mode (RSC / credits / card / Apple Pay only). */ allowDafPayment?: boolean; /** Replaces the default contribution success toast. */ @@ -64,6 +65,21 @@ interface ContributeToFundraiseModalProps { maxAmountUsd?: number; } +export type ContributeToFundraiseModalProps = ContributeModalCommonProps & + ( + | { + /** @default 'fundraise' */ + mode?: 'fundraise'; + fundraise: Fundraise; + fundingPool?: never; + } + | { + mode: 'fundingPool'; + fundingPool: FundingPool; + fundraise?: never; + } + ); + type ModalView = 'funding' | 'auth' | 'payment'; /** @@ -101,30 +117,42 @@ export function ContributeToFundraiseModal(props: ContributeToFundraiseModalProp ); } -function ContributeToFundraiseModalInner({ - isOpen, - onClose, - onContributeSuccess, - fundraise, - proposalTitle, - work, - headerTitle, - headerSubtitle, - progressOverride, - allowDafPayment = true, - successMessage, - maxAmountUsd, -}: Readonly) { +function ContributeToFundraiseModalInner(props: Readonly) { + const { + isOpen, + onClose, + onContributeSuccess, + proposalTitle, + work, + headerTitle, + headerSubtitle, + progressOverride, + successMessage, + maxAmountUsd, + } = props; + + const isPoolMode = props.mode === 'fundingPool'; + const fundraise = !isPoolMode ? props.fundraise : undefined; + const fundingPool = isPoolMode ? props.fundingPool : undefined; + // DAF is never offered for RFP funding pools. + const allowDafPayment = isPoolMode ? false : (props.allowDafPayment ?? true); + const { user, refreshUser } = useUser(); const walletAvailability = useWalletAvailability(); const { exchangeRate } = useExchangeRate(); + const { showUSD } = useCurrencyPreference(); const isMobile = useIsMobile(); // Skipping the id entirely when DAF is off avoids the hook's nonprofit-link // and EIN-search round trips on every open. - const { nonprofit } = useNonprofitByFundraiseId(allowDafPayment ? fundraise.id : undefined); + const { nonprofit } = useNonprofitByFundraiseId( + allowDafPayment && fundraise ? fundraise.id : undefined + ); const hasNonprofit = allowDafPayment && nonprofit !== null; const contributionSuccessMessage = - successMessage ?? 'Your contribution has been successfully added to the fundraise.'; + successMessage ?? + (isPoolMode + ? 'Your contribution has been added to the RFP funding pool.' + : 'Your contribution has been successfully added to the fundraise.'); const [amountUsd, setAmountUsd] = useState(100); const [isContributing, setIsContributing] = useState(false); const [error, setError] = useState(null); @@ -136,6 +164,20 @@ function ContributeToFundraiseModalInner({ // Store Stripe context for credit card payments const stripeContextRef = useRef(null); + const paymentTarget: PaymentIntentTarget = useMemo(() => { + if (isPoolMode && fundingPool) { + return { fundingPoolId: fundingPool.id }; + } + return { fundraiseId: fundraise!.id }; + }, [isPoolMode, fundingPool, fundraise]); + + const analyticsTarget = useMemo(() => { + if (isPoolMode && fundingPool) { + return { funding_pool_id: fundingPool.id }; + } + return { fundraise_id: fundraise!.id }; + }, [isPoolMode, fundingPool, fundraise]); + // Handle Stripe context updates from CreditCardForm const handleStripeReady = useCallback((context: StripePaymentContext | null) => { stripeContextRef.current = context; @@ -147,7 +189,6 @@ function ContributeToFundraiseModalInner({ const fundingCreditsBalance = user?.fundingCredits ?? 0; // Calculate conversions - const rscToUsd = (rsc: number) => (exchangeRate ? rsc * exchangeRate : 0); const usdToRsc = (usd: number) => (exchangeRate ? usd / exchangeRate : 0); // Get amount in RSC (derived from USD amount) @@ -202,7 +243,7 @@ function ContributeToFundraiseModalInner({ useEffect(() => { if (isOpen) { AnalyticsService.logEvent(LogEvent.FUNDRAISE_CONTRIBUTION_AMOUNT_STEP, { - fundraise_id: fundraise.id, + ...analyticsTarget, amount_usd: amountUsd, amount_rsc: amountInRsc, }); @@ -217,30 +258,40 @@ function ContributeToFundraiseModalInner({ } else { // Track funnel step: user reached payment step AnalyticsService.logEvent(LogEvent.FUNDRAISE_CONTRIBUTION_PAYMENT_STEP, { - fundraise_id: fundraise.id, + ...analyticsTarget, amount_usd: amountUsd, amount_rsc: amountInRsc, }); setCurrentView('payment'); } - }, [user, fundraise.id, amountUsd, amountInRsc]); + }, [user, analyticsTarget, amountUsd, amountInRsc]); const handleAuthSuccess = useCallback(async () => { AnalyticsService.logEvent(LogEvent.FUNDRAISE_CONTRIBUTION_PAYMENT_STEP, { - fundraise_id: fundraise.id, + ...analyticsTarget, amount_usd: amountUsd, amount_rsc: amountInRsc, }); refreshUser?.(); setCurrentView('payment'); - }, [fundraise.id, amountUsd, amountInRsc, refreshUser]); + }, [analyticsTarget, amountUsd, amountInRsc, refreshUser]); + + const handleClose = useCallback(() => { + setCurrentView('funding'); + setSelectedQuickAmount(100); + setAmountUsd(100); + setError(null); + setAmountError(undefined); + setIsSliderControlled(false); + onClose(); + }, [onClose]); const handleConfirmPayment = async (paymentMethod: Exclude) => { try { if (amountUsd < minAmountUsd) { setError(`Minimum contribution is $${minAmountUsd}`); AnalyticsService.logEvent(LogEvent.FUNDRAISE_CONTRIBUTION_PAYMENT_ERROR, { - fundraise_id: fundraise.id, + ...analyticsTarget, payment_method: paymentMethod, error_type: 'validation', error_message: 'Amount below minimum', @@ -255,7 +306,7 @@ function ContributeToFundraiseModalInner({ })}` ); AnalyticsService.logEvent(LogEvent.FUNDRAISE_CONTRIBUTION_PAYMENT_ERROR, { - fundraise_id: fundraise.id, + ...analyticsTarget, payment_method: paymentMethod, error_type: 'validation', error_message: 'Amount above maximum', @@ -269,12 +320,19 @@ function ContributeToFundraiseModalInner({ if (paymentMethod === 'rsc' || paymentMethod === 'funding_credits') { // The backend draws from funding credits only when that payment method // is selected. Otherwise it draws from available and promotional RSC. - await FundraiseService.contributeToFundraise( - fundraise.id, - amountInRsc, - 'rsc', - paymentMethod === 'funding_credits' - ); + if (isPoolMode && fundingPool) { + await FundingPoolService.createContribution(fundingPool.id, { + amount: amountInRsc, + useCredits: paymentMethod === 'funding_credits', + }); + } else if (fundraise) { + await FundraiseService.contributeToFundraise( + fundraise.id, + amountInRsc, + 'rsc', + paymentMethod === 'funding_credits' + ); + } toast.success(contributionSuccessMessage); } else if (paymentMethod === 'credit_card') { // Credit card payment flow: @@ -286,7 +344,7 @@ function ContributeToFundraiseModalInner({ if (!stripeContext) { setError('Payment form is not ready. Please try again.'); AnalyticsService.logEvent(LogEvent.FUNDRAISE_CONTRIBUTION_PAYMENT_ERROR, { - fundraise_id: fundraise.id, + ...analyticsTarget, payment_method: paymentMethod, error_type: 'stripe', error_message: 'Payment form not ready', @@ -297,10 +355,10 @@ function ContributeToFundraiseModalInner({ const { stripe, cardElement } = stripeContext; - // Step 1: Create payment intent with amount and fundraise ID (backend adds fees and handles contribution) + // Step 1: Create payment intent (backend adds fees and handles contribution) const { clientSecret } = await PaymentService.createPaymentIntent( amountInRsc, - fundraise.id + paymentTarget ); // Step 2: Confirm payment with Stripe @@ -316,7 +374,7 @@ function ContributeToFundraiseModalInner({ 'We had an issue processing your credit card. Choose a different payment method.' ); AnalyticsService.logEvent(LogEvent.FUNDRAISE_CONTRIBUTION_PAYMENT_ERROR, { - fundraise_id: fundraise.id, + ...analyticsTarget, payment_method: paymentMethod, error_type: 'stripe', error_message: 'Card payment failed', @@ -330,7 +388,7 @@ function ContributeToFundraiseModalInner({ 'We had an issue processing your credit card. Choose a different payment method.' ); AnalyticsService.logEvent(LogEvent.FUNDRAISE_CONTRIBUTION_PAYMENT_ERROR, { - fundraise_id: fundraise.id, + ...analyticsTarget, payment_method: paymentMethod, error_type: 'stripe', error_message: 'Payment not succeeded', @@ -351,7 +409,7 @@ function ContributeToFundraiseModalInner({ // Track successful payment AnalyticsService.logEvent(LogEvent.FUNDRAISE_CONTRIBUTION_PAYMENT_SUCCESSFUL, { - fundraise_id: fundraise.id, + ...analyticsTarget, payment_method: paymentMethod, amount_usd: amountUsd, amount_rsc: amountInRsc, @@ -366,9 +424,9 @@ function ContributeToFundraiseModalInner({ handleClose(); } catch (err) { - console.error('Failed to contribute to fundraise:', err); + console.error('Failed to contribute:', err); AnalyticsService.logEvent(LogEvent.FUNDRAISE_CONTRIBUTION_PAYMENT_ERROR, { - fundraise_id: fundraise.id, + ...analyticsTarget, payment_method: paymentMethod, error_type: 'api', error_message: 'Request failed', @@ -391,10 +449,17 @@ function ContributeToFundraiseModalInner({ setIsSliderControlled(false); // Quick buttons set scaled visual mode }, []); - // Calculate amounts in USD for display - const currentAmountUsd = progressOverride?.currentAmountUsd ?? fundraise.amountRaised?.usd ?? 0; - const goalAmountUsd = progressOverride?.goalAmountUsd ?? fundraise.goalAmount?.usd ?? 0; + // Calculate amounts in USD for display. + const poolRaisedUsd = fundingPool?.amountRaised.usd ?? 0; + const poolRaisedRsc = fundingPool?.amountRaised.rsc ?? 0; + const currentAmountUsd = isPoolMode + ? poolRaisedUsd + : (progressOverride?.currentAmountUsd ?? fundraise?.amountRaised?.usd ?? 0); + const goalAmountUsd = isPoolMode + ? 0 + : (progressOverride?.goalAmountUsd ?? fundraise?.goalAmount?.usd ?? 0); const remainingGoalUsd = Math.max(0, goalAmountUsd - currentAmountUsd); + const quickAmountCeilingUsd = isPoolMode ? 10000 : remainingGoalUsd; const handleBack = useCallback(() => { if (currentView === 'payment' || currentView === 'auth') { @@ -402,18 +467,10 @@ function ContributeToFundraiseModalInner({ } }, [currentView]); - const handleClose = useCallback(() => { - setCurrentView('funding'); - setSelectedQuickAmount(100); - setAmountUsd(100); - setError(null); - setAmountError(undefined); - setIsSliderControlled(false); - onClose(); - }, [onClose]); - const handleEndaomentPaymentConfirm = useCallback( async (originFundId: string) => { + if (!fundraise) return; + try { setIsContributing(true); setError(null); @@ -426,7 +483,7 @@ function ContributeToFundraiseModalInner({ // Track successful payment AnalyticsService.logEvent(LogEvent.FUNDRAISE_CONTRIBUTION_PAYMENT_SUCCESSFUL, { - fundraise_id: fundraise.id, + ...analyticsTarget, payment_method: 'endaoment', amount_usd: amountUsd, amount_rsc: amountInRsc, @@ -441,7 +498,7 @@ function ContributeToFundraiseModalInner({ } catch (err) { console.error('Failed to contribute via Endaoment:', err); AnalyticsService.logEvent(LogEvent.FUNDRAISE_CONTRIBUTION_PAYMENT_ERROR, { - fundraise_id: fundraise.id, + ...analyticsTarget, payment_method: 'endaoment', error_type: 'api', error_message: 'Request failed', @@ -452,7 +509,8 @@ function ContributeToFundraiseModalInner({ } }, [ - fundraise.id, + fundraise, + analyticsTarget, amountUsd, amountInRsc, refreshUser, @@ -467,7 +525,7 @@ function ContributeToFundraiseModalInner({ (paymentMethod?: 'apple_pay' | 'google_pay') => { // Track successful payment AnalyticsService.logEvent(LogEvent.FUNDRAISE_CONTRIBUTION_PAYMENT_SUCCESSFUL, { - fundraise_id: fundraise.id, + ...analyticsTarget, payment_method: paymentMethod || 'payment_request', amount_usd: amountUsd, amount_rsc: amountInRsc, @@ -481,7 +539,7 @@ function ContributeToFundraiseModalInner({ handleClose(); }, [ - fundraise.id, + analyticsTarget, amountUsd, amountInRsc, refreshUser, @@ -491,21 +549,23 @@ function ContributeToFundraiseModalInner({ ] ); + const defaultTitle = isPoolMode ? 'Contribute to RFP' : 'Fund Proposal'; + // Get title based on current view const getTitle = () => { switch (currentView) { case 'funding': - return headerTitle ?? 'Fund Proposal'; + return headerTitle ?? defaultTitle; case 'auth': return 'Sign in to continue'; case 'payment': return 'Select Payment Method'; default: - return headerTitle ?? 'Fund Proposal'; + return headerTitle ?? defaultTitle; } }; - // Get subtitle - show proposal title on funding and payment screens + // Get subtitle - show proposal/RFP title on funding and payment screens const getSubtitle = () => { if (currentView === 'funding' || currentView === 'payment') { return headerSubtitle ?? proposalTitle; @@ -529,7 +589,7 @@ function ContributeToFundraiseModalInner({ amountDisplay={getAmountDisplay()} rscBalance={rscBalance} fundingCreditsBalance={fundingCreditsBalance} - fundraiseId={fundraise.id} + paymentTarget={paymentTarget} isProcessing={isContributing} error={error} walletAvailability={walletAvailability} @@ -575,12 +635,27 @@ function ContributeToFundraiseModalInner({ - {/* Funding Impact Preview with Slider */} - {goalAmountUsd > 0 && ( + {isPoolMode && (showUSD ? poolRaisedUsd : poolRaisedRsc) > 0 && ( +

+ Raised so far{' '} + + {formatCurrency({ + amount: showUSD ? poolRaisedUsd : poolRaisedRsc, + showUSD, + exchangeRate: 1, + skipConversion: true, + })} + +

+ )} + + {/* Goal progress + slider — proposal fundraises only (pool has no goal). */} + {!isPoolMode && goalAmountUsd > 0 && ( setActiveTab(tabId as GrantBannerTab), [setActiveTab] ); + const isGrantCreator = + user?.id != null && + 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 = ( ); const requiresPrivateApplications = applicationVisibility === 'PRIVATE'; + const canContributeToPool = + isRfpFundingPoolEnabled && !!grantId && isActive && fundingPool?.status === 'OPEN'; + + const handleContributeSuccess = useCallback(() => { + setIsContributeModalOpen(false); + router.refresh(); + }, [router]); const subtitle = organization ? (
@@ -63,18 +112,53 @@ export function WorkHeaderGrant({ const primaryAction = grantId && isActive ? ( <> - - + )} + + + +
+ {showPoolHolding && fundingPool && ( +
- Submit Proposal - - - + + Pool holding{' '} + + {formatPoolAmount(fundingPool.amountHolding, showUSD)} + + + {(fundingPool.amountDistributed.rsc ?? 0) > 0 && ( + + Distributed{' '} + + {formatPoolAmount(fundingPool.amountDistributed, showUSD)} + + + )} +
+ )} {requiresPrivateApplications && (
@@ -132,27 +216,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 + } + /> + + {isRfpFundingPoolEnabled && 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; diff --git a/contexts/FundraiseContext.tsx b/contexts/FundraiseContext.tsx index 48240213f..d5db66bd2 100644 --- a/contexts/FundraiseContext.tsx +++ b/contexts/FundraiseContext.tsx @@ -44,6 +44,8 @@ interface FundraiseContextValue { /** Call once from the consuming component to trigger the initial fetch. */ activate: () => void; + /** Refetch the current proposals page (e.g. after pool allocate updates raised). */ + refresh: () => Promise; restoredScrollPosition: number | null; lastClickedEntryId: string | null; @@ -212,6 +214,7 @@ export function FundraiseProvider({ children, grantId }: FundraiseProviderProps) setSortBy, isGrantScoped, activate, + refresh: fetchProposals, restoredScrollPosition, lastClickedEntryId, restorationTab, @@ -230,6 +233,7 @@ export function FundraiseProvider({ children, grantId }: FundraiseProviderProps) sortBy, isGrantScoped, activate, + fetchProposals, restoredScrollPosition, lastClickedEntryId, restorationTab, diff --git a/services/funding-pool.service.ts b/services/funding-pool.service.ts new file mode 100644 index 000000000..06a263a6a --- /dev/null +++ b/services/funding-pool.service.ts @@ -0,0 +1,59 @@ +import { ApiClient } from './client'; +import { roundRscAmount } from './lib/serviceUtils'; +import { FundingPool, transformFundingPool } from '@/types/grant'; +import { ID } from '@/types/root'; + +export interface CreateFundingPoolContributionParams { + amount: number; + useCredits?: boolean; +} + +export interface DistributeFundingPoolParams { + amount: number; + applicationId: ID; +} + +/** + * Service for RFP community FundingPool APIs. + */ +export class FundingPoolService { + private static readonly POOL_BASE_PATH = '/api/funding_pool'; + + /** + * Contribute RSC (wallet balance or funding credits) into the pool. + * Pool contributions are RSC-only. + * + * @param poolId - Funding pool id + * @param params - Contribution amount and whether to use funding credits + * @returns The updated funding pool + */ + static async createContribution( + poolId: ID, + params: CreateFundingPoolContributionParams + ): Promise { + const response = await ApiClient.post( + `${this.POOL_BASE_PATH}/${poolId}/create_contribution/`, + { + amount: roundRscAmount(params.amount), + amount_currency: 'RSC', + use_credits: params.useCredits ?? false, + } + ); + return transformFundingPool(response); + } + + /** + * Allocate pool holdings into an open proposal fundraise via its grant application. + * + * @param poolId - Funding pool id + * @param params - RSC amount and application id + * @returns The updated funding pool + */ + static async distribute(poolId: ID, params: DistributeFundingPoolParams): Promise { + const response = await ApiClient.post(`${this.POOL_BASE_PATH}/${poolId}/distribute/`, { + amount: roundRscAmount(params.amount), + application_id: params.applicationId, + }); + return transformFundingPool(response); + } +} diff --git a/services/payment.service.ts b/services/payment.service.ts index 2a5ed90b1..30bec17d0 100644 --- a/services/payment.service.ts +++ b/services/payment.service.ts @@ -30,6 +30,10 @@ export interface PaymentIntentResponse { stripeAmountCents: number; } +export type PaymentIntentTarget = + | { fundraiseId: ID; fundingPoolId?: never } + | { fundingPoolId: ID; fundraiseId?: never }; + /** * Service for handling payment-related API calls. */ @@ -37,27 +41,31 @@ export class PaymentService { private static readonly BASE_PATH = '/api/payment'; /** - * Creates a payment intent for purchasing RSC and contributing to a fundraise. - * The backend will add fees to the amount and handle the contribution. + * Creates a payment intent for purchasing RSC and contributing to a fundraise + * or funding pool. The backend adds fees and handles the contribution. * * @param amount The RSC amount to purchase (without fees) - * @param fundraiseId The ID of the fundraise to contribute to + * @param target Exactly one of fundraiseId or fundingPoolId * @returns Promise containing the Stripe client secret and payment details */ static async createPaymentIntent( amount: number, - fundraiseId: ID + target: PaymentIntentTarget ): Promise { + const hasFundingPool = 'fundingPoolId' in target; + const body = { + amount: roundRscAmount(amount), + currency: 'RSC' as const, + ...(hasFundingPool + ? { funding_pool_id: target.fundingPoolId } + : { fundraise_id: target.fundraiseId }), + }; + const response = await ApiClient.post( `${this.BASE_PATH}/payment-intent/`, - { - amount: roundRscAmount(amount), - currency: 'RSC', - fundraise_id: fundraiseId, - } + body ); - // Transform snake_case to camelCase return { clientSecret: response.client_secret, paymentIntentId: response.payment_intent_id, diff --git a/types/feed.ts b/types/feed.ts index a137bda29..9de4d6062 100644 --- a/types/feed.ts +++ b/types/feed.ts @@ -20,7 +20,7 @@ import { UserVoteType } from './reaction'; import { stripHtml } from '@/utils/stringUtils'; import { Tip } from './tip'; import { FOUNDATION_USER_ID } from '@/config/constants'; -import { GrantStatus } from './grant'; +import { FundingPool, GrantStatus, transformFundingPool } from './grant'; export type FeedActionType = 'contribute' | 'open' | 'publish' | 'post'; @@ -270,6 +270,7 @@ export interface FeedGrantContent extends BaseFeedContent { currency: string; createdBy: AuthorProfile; applicants: Application[]; + fundingPool: FundingPool | null; }; organization?: string; grantAmount?: { @@ -1115,6 +1116,9 @@ export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => { ? transformAuthorProfile(content_object.grant.created_by) : transformAuthorProfile(author), applicants: (content_object.grant.applications || []).map(transformApplication), + fundingPool: content_object.grant.funding_pool + ? transformFundingPool(content_object.grant.funding_pool) + : null, }, organization: content_object.grant.organization || '', grantAmount: content_object.grant.amount || {}, diff --git a/types/funding.ts b/types/funding.ts index f763d7297..b67b89db1 100644 --- a/types/funding.ts +++ b/types/funding.ts @@ -152,6 +152,7 @@ export function transformApplicationFundraise(raw: any): ApplicationFundraise { } export interface Application { + id: number; profile: AuthorProfile; preregistrationPostId?: number; fundraise?: ApplicationFundraise; @@ -161,6 +162,7 @@ export interface Application { export function transformApplication(raw: any): Application { return { + id: raw.id, profile: transformAuthorProfile(raw.applicant), preregistrationPostId: raw.preregistration_post_id ?? undefined, keyInsight: transformKeyInsight(raw.key_insight), diff --git a/types/grant.ts b/types/grant.ts index 1dcfadbb4..d2872b04e 100644 --- a/types/grant.ts +++ b/types/grant.ts @@ -2,6 +2,7 @@ import { Currency, ID } from './root'; import { createTransformer } from './transformer'; import { AuthorProfile, transformAuthorProfile } from './authorProfile'; import { Contact, transformContact } from './note'; +import type { Application } from './funding'; export type GrantStatus = 'OPEN' | 'CLOSED' | 'PENDING' | 'DECLINED' | 'COMPLETED'; @@ -40,6 +41,62 @@ export interface GrantAmount { formatted: string; } +export type FundingPoolStatus = 'OPEN' | 'CLOSED'; + +export interface FundingPoolAmount { + usd: number; + rsc: number; +} + +export interface FundingPool { + id: number; + status: FundingPoolStatus; + amountHolding: FundingPoolAmount; + amountDistributed: FundingPoolAmount; + amountRaised: FundingPoolAmount; +} + +function parseFundingPoolAmount(raw: unknown): FundingPoolAmount { + const amount = raw as { usd?: unknown; rsc?: unknown } | null | undefined; + return { + usd: Number(amount?.usd ?? 0) || 0, + rsc: Number(amount?.rsc ?? 0) || 0, + }; +} + +export function transformFundingPool(raw: any): FundingPool { + return { + id: raw.id, + status: raw.status as FundingPoolStatus, + amountHolding: parseFundingPoolAmount(raw.amount_holding), + amountDistributed: parseFundingPoolAmount(raw.amount_distributed), + amountRaised: parseFundingPoolAmount(raw.amount_raised), + }; +} + +/** + * Badge total = grant.amount + pool amount_raised when present. + */ +export function getGrantBadgeAmount(grant: { + amount: Pick; + fundingPool?: FundingPool | null; +}): { usd: number; rsc: number } { + const raised = grant.fundingPool?.amountRaised; + return { + usd: (grant.amount.usd ?? 0) + (raised?.usd ?? 0), + rsc: (grant.amount.rsc ?? 0) + (raised?.rsc ?? 0), + }; +} + +export function findGrantApplicationIdForPost( + applications: Application[] | undefined, + postId: number | string +): number | undefined { + const numericPostId = Number(postId); + if (!Number.isFinite(numericPostId)) return undefined; + return applications?.find((app) => app.preregistrationPostId === numericPostId)?.id; +} + /** The Request for Proposal a notebook draft is answering, as its card draws it. */ export interface SelectedGrantDetails { id: string; @@ -77,7 +134,9 @@ export interface Grant { endDate: string; contacts: Contact[]; applicationVisibility: GrantApplicationVisibility; + fundingPool: FundingPool | null; applicants?: AuthorProfile[]; + applications?: Application[]; reviewedBy?: { id: ID; authorProfile: AuthorProfile; @@ -88,41 +147,51 @@ export interface Grant { declineReason?: string; } -export const transformGrant = createTransformer((raw) => ({ - id: raw.id, - createdBy: { - id: raw.created_by.id, - authorProfile: transformAuthorProfile(raw.created_by.author_profile), - firstName: raw.created_by.first_name, - lastName: raw.created_by.last_name, - }, - amount: { - usd: raw.amount.usd, - rsc: raw.amount.rsc, - formatted: raw.amount.formatted, - }, - currency: raw.currency as Currency, - organization: raw.organization, - description: raw.description, - shortTitle: raw.short_title || '', - status: raw.status as GrantStatus, - startDate: raw.start_date, - endDate: raw.end_date, - contacts: Array.isArray(raw.contacts) - ? raw.contacts.map((contact: any) => transformContact(contact)) - : undefined, - applicationVisibility: (raw.application_visibility as GrantApplicationVisibility) ?? 'OPTIONAL', - applicants: Array.isArray(raw.applications) - ? raw.applications.map((application: any) => transformAuthorProfile(application.applicant)) - : undefined, - reviewedBy: raw.reviewed_by - ? { - id: raw.reviewed_by.id, - authorProfile: transformAuthorProfile(raw.reviewed_by.author_profile), - firstName: raw.reviewed_by.first_name, - lastName: raw.reviewed_by.last_name, - } - : undefined, - reviewedDate: raw.reviewed_date ?? undefined, - declineReason: raw.decline_reason ?? undefined, -})); +export const transformGrant = createTransformer((raw) => { + const applications: Application[] | undefined = Array.isArray(raw.applications) + ? raw.applications.map((application: any) => ({ + id: application.id, + profile: transformAuthorProfile(application.applicant), + preregistrationPostId: application.preregistration_post_id ?? undefined, + })) + : undefined; + + return { + id: raw.id, + createdBy: { + id: raw.created_by.id, + authorProfile: transformAuthorProfile(raw.created_by.author_profile), + firstName: raw.created_by.first_name, + lastName: raw.created_by.last_name, + }, + amount: { + usd: raw.amount.usd, + rsc: raw.amount.rsc, + formatted: raw.amount.formatted, + }, + currency: raw.currency as Currency, + organization: raw.organization, + description: raw.description, + shortTitle: raw.short_title || '', + status: raw.status as GrantStatus, + startDate: raw.start_date, + endDate: raw.end_date, + contacts: Array.isArray(raw.contacts) + ? raw.contacts.map((contact: any) => transformContact(contact)) + : undefined, + applicationVisibility: (raw.application_visibility as GrantApplicationVisibility) ?? 'OPTIONAL', + fundingPool: raw.funding_pool ? transformFundingPool(raw.funding_pool) : null, + applications, + applicants: applications?.map((application) => application.profile), + reviewedBy: raw.reviewed_by + ? { + id: raw.reviewed_by.id, + authorProfile: transformAuthorProfile(raw.reviewed_by.author_profile), + firstName: raw.reviewed_by.first_name, + lastName: raw.reviewed_by.last_name, + } + : undefined, + reviewedDate: raw.reviewed_date ?? undefined, + declineReason: raw.decline_reason ?? undefined, + }; +}); diff --git a/types/moderation.ts b/types/moderation.ts index b2577d98d..a94e15dc9 100644 --- a/types/moderation.ts +++ b/types/moderation.ts @@ -2,7 +2,7 @@ import { createTransformer } from './transformer'; import { transformAuthorProfile } from '@/types/authorProfile'; import { transformApplication } from '@/types/funding'; import { FeedEntry, FeedGrantContent, RawApiFeedEntry, transformFeedEntry } from '@/types/feed'; -import type { GrantStatus } from '@/types/grant'; +import { transformFundingPool, type GrantStatus } from '@/types/grant'; import { transformTopic } from '@/types/topic'; import { stripHtml } from '@/utils/stringUtils'; @@ -119,6 +119,7 @@ export const transformPendingGrantToFeedEntry = (entry: RawApiFeedEntry): FeedEn shortTitle: grant.short_title || '', createdBy, applicants: (grant.applications || []).map(transformApplication), + fundingPool: grant.funding_pool ? transformFundingPool(grant.funding_pool) : null, }, organization: grant.organization || '', grantAmount: grant.amount || {}, diff --git a/utils/number.ts b/utils/number.ts index fe4e24dad..03650c2b4 100644 --- a/utils/number.ts +++ b/utils/number.ts @@ -250,3 +250,56 @@ export function formatCombinedBalanceSecondary({ return exchangeRate > 0 ? formatUsdValue(totalRaw.toString(), exchangeRate) : '$0.00 USD'; } } + +/** + * Parse a decimal amount from user input. Accepts optional thousand separators + * (`,` or spaces) and a `.` decimal; rejects trailing/leading junk that + * `Number.parseFloat` would ignore (e.g. `"1,000"` → `1000`, `"1abc"` → `NaN`). + */ +export function parseStrictDecimal(value: string): number { + const trimmed = value.trim(); + if (!trimmed) return NaN; + + // Drop grouping separators that formatRSC / locale display may use. + const normalized = trimmed.replace(/[,\u00A0\u202F\s]/g, ''); + + // Entire string must be a decimal — no partial parse of "1abc" or "1,000x". + if (!/^(?:\d+(?:\.\d+)?|\.\d+)$/.test(normalized)) { + return NaN; + } + + const parsed = Number(normalized); + return Number.isFinite(parsed) ? parsed : NaN; +} + +export interface ValidatePositiveDecimalOptions { + /** When set, amounts above this value fail validation. */ + max?: number; + /** Error when amount exceeds `max`. */ + maxError?: string; + /** Error when the value is missing, non-numeric, or ≤ 0. */ + invalidError?: string; +} + +/** + * Validate user-entered decimal input as a positive amount (optionally capped). + */ +export function validatePositiveDecimal( + value: string, + options: ValidatePositiveDecimalOptions = {} +): { amount: number; error?: string } { + const { + max, + maxError = 'Amount exceeds the maximum', + invalidError = 'Enter a valid positive amount', + } = options; + + const amount = parseStrictDecimal(value); + if (!Number.isFinite(amount) || amount <= 0) { + return { amount: NaN, error: invalidError }; + } + if (max != null && amount > max) { + return { amount, error: maxError }; + } + return { amount }; +}