From fab03508489ab97634e342612f1fe15c000a91ab Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Wed, 9 Sep 2026 20:30:07 +0300 Subject: [PATCH 01/13] Add FundingPool types and grant badge helpers --- types/feed.ts | 7 +++++- types/funding.ts | 3 +++ types/grant.ts | 57 +++++++++++++++++++++++++++++++++++++++++++++ types/moderation.ts | 3 ++- 4 files changed, 68 insertions(+), 2 deletions(-) diff --git a/types/feed.ts b/types/feed.ts index f33aa8341..2a7b8d024 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,8 @@ export interface FeedGrantContent extends BaseFeedContent { currency: string; createdBy: AuthorProfile; applicants: Application[]; + /** Present when the serializer includes it; slim grant_feed omits this. */ + fundingPool: FundingPool | null; }; organization?: string; grantAmount?: { @@ -1120,6 +1122,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..73f6a9970 100644 --- a/types/funding.ts +++ b/types/funding.ts @@ -152,6 +152,8 @@ export function transformApplicationFundraise(raw: any): ApplicationFundraise { } export interface Application { + /** GrantApplication id — required for funding-pool distribute (`application_id`). */ + id: number; profile: AuthorProfile; preregistrationPostId?: number; fundraise?: ApplicationFundraise; @@ -161,6 +163,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..eda19cf86 100644 --- a/types/grant.ts +++ b/types/grant.ts @@ -40,6 +40,60 @@ export interface GrantAmount { formatted: string; } +/** Status of an RFP community funding pool (separate from proposal fundraises). */ +export type FundingPoolStatus = 'OPEN' | 'CLOSED'; + +export interface FundingPoolAmount { + usd: number; + rsc: number; +} + +/** + * Community escrow pot on an RFP. Do not treat as a Fundraise — proposal + * crowdfunding stays on nested applications[].fundraise. + */ +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, + // BE may send RSC as a decimal string (e.g. "20.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. + * If pool is null/absent (new grants before ensure, or slim feed), returns grant.amount only. + */ +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), + }; +} + /** The Request for Proposal a notebook draft is answering, as its card draws it. */ export interface SelectedGrantDetails { id: string; @@ -77,6 +131,8 @@ export interface Grant { endDate: string; contacts: Contact[]; applicationVisibility: GrantApplicationVisibility; + /** Null until ensure / create; slim feed serializers may omit the field entirely. */ + fundingPool: FundingPool | null; applicants?: AuthorProfile[]; reviewedBy?: { id: ID; @@ -112,6 +168,7 @@ export const transformGrant = createTransformer((raw) => ({ ? raw.contacts.map((contact: any) => transformContact(contact)) : undefined, applicationVisibility: (raw.application_visibility as GrantApplicationVisibility) ?? 'OPTIONAL', + fundingPool: raw.funding_pool ? transformFundingPool(raw.funding_pool) : null, applicants: Array.isArray(raw.applications) ? raw.applications.map((application: any) => transformAuthorProfile(application.applicant)) : 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 || {}, From 189c1f44b91ade4b714bd2dcf2a0a2009d78e694 Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Wed, 9 Sep 2026 20:43:59 +0300 Subject: [PATCH 02/13] Add FundingPool service --- components/Funding/PaymentRequestButton.tsx | 4 +- .../modals/ContributeToFundraiseModal.tsx | 7 +-- services/funding-pool.service.ts | 60 +++++++++++++++++++ services/payment.service.ts | 28 +++++---- 4 files changed, 84 insertions(+), 15 deletions(-) create mode 100644 services/funding-pool.service.ts diff --git a/components/Funding/PaymentRequestButton.tsx b/components/Funding/PaymentRequestButton.tsx index 454d0a219..f822b9257 100644 --- a/components/Funding/PaymentRequestButton.tsx +++ b/components/Funding/PaymentRequestButton.tsx @@ -104,7 +104,9 @@ function PaymentRequestButtonInner({ try { // Create payment intent on our backend - const { clientSecret } = await PaymentService.createPaymentIntent(amountInRsc, fundraiseId); + const { clientSecret } = await PaymentService.createPaymentIntent(amountInRsc, { + fundraiseId, + }); // Confirm the payment with the payment method from Apple Pay/Google Pay const { error: confirmError, paymentIntent } = await stripe!.confirmCardPayment( diff --git a/components/modals/ContributeToFundraiseModal.tsx b/components/modals/ContributeToFundraiseModal.tsx index 5e3fb48ff..793108786 100644 --- a/components/modals/ContributeToFundraiseModal.tsx +++ b/components/modals/ContributeToFundraiseModal.tsx @@ -298,10 +298,9 @@ function ContributeToFundraiseModalInner({ const { stripe, cardElement } = stripeContext; // Step 1: Create payment intent with amount and fundraise ID (backend adds fees and handles contribution) - const { clientSecret } = await PaymentService.createPaymentIntent( - amountInRsc, - fundraise.id - ); + const { clientSecret } = await PaymentService.createPaymentIntent(amountInRsc, { + fundraiseId: fundraise.id, + }); // Step 2: Confirm payment with Stripe const { error: stripeError, paymentIntent: stripePaymentIntent } = diff --git a/services/funding-pool.service.ts b/services/funding-pool.service.ts new file mode 100644 index 000000000..f17addfd6 --- /dev/null +++ b/services/funding-pool.service.ts @@ -0,0 +1,60 @@ +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. + * Distinct from FundraiseService — proposal crowdfunding stays on /api/fundraise/. + */ +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 (not post/fundraise 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, From 60c082c58352891e684ad7f76af761cd08509ad6 Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Wed, 9 Sep 2026 21:11:56 +0300 Subject: [PATCH 03/13] 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 04/13] 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 325023217c9ddb419644101b7e7b383a1e54ec5f Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Wed, 9 Sep 2026 22:00:47 +0300 Subject: [PATCH 05/13] Owner/mod Allocate modal wired to distribute + application id + refresh --- app/grant/[id]/[slug]/layout.tsx | 9 +- components/Funding/GrantPageContent.tsx | 37 +++- components/Funding/ProposalWorkCard.tsx | 79 +++++++- .../modals/AllocateFundingPoolModal.tsx | 169 ++++++++++++++++++ .../work/WorkHeader/WorkHeaderGrant.tsx | 41 ++++- contexts/FundraiseContext.tsx | 4 + types/grant.ts | 98 ++++++---- 7 files changed, 392 insertions(+), 45 deletions(-) create mode 100644 components/modals/AllocateFundingPoolModal.tsx diff --git a/app/grant/[id]/[slug]/layout.tsx b/app/grant/[id]/[slug]/layout.tsx index 96857b875..28c7ab703 100644 --- a/app/grant/[id]/[slug]/layout.tsx +++ b/app/grant/[id]/[slug]/layout.tsx @@ -67,7 +67,13 @@ export default async function GrantSlugLayout({ params, children }: Props) { const metadata = await MetadataService.get(work.unifiedDocumentId?.toString() || ''); return ( - + void; + applications: Application[]; + grantCreatedByUserId: ID | null; } const GrantTabContext = createContext(null); @@ -32,16 +39,36 @@ export function useGrantTab() { return ctx; } +/** + * Optional allocate helpers — safe outside GrantTabProvider (e.g. global /fund feed). + * Returns null when not on an RFP page. + */ +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 +86,10 @@ export function GrantTabProvider({ grantId, }); + const handleSetFundingPool = useCallback((pool: FundingPool | null) => { + setFundingPool(pool); + }, []); + return ( {children} diff --git a/components/Funding/ProposalWorkCard.tsx b/components/Funding/ProposalWorkCard.tsx index a3b5e5317..2f59976ef 100644 --- a/components/Funding/ProposalWorkCard.tsx +++ b/components/Funding/ProposalWorkCard.tsx @@ -1,6 +1,8 @@ 'use client'; -import { FC } from 'react'; +import { FC, useState, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { Coins } from 'lucide-react'; import { ActivityTimestamp, ActivityWorkActions, @@ -13,12 +15,19 @@ import { type WorkCardStat, } from '@/components/Activity/lib/activityWork.utils'; import { FeedItemFundingBadges } from '@/components/Feed/FeedItemFundingBadges'; +import { AllocateFundingPoolModal } from '@/components/modals/AllocateFundingPoolModal'; +import { Button } from '@/components/ui/Button'; +import { useGrantAllocateContext } from '@/components/Funding/GrantPageContent'; import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; import { useExchangeRate } from '@/contexts/ExchangeRateContext'; +import { useFundraises } from '@/contexts/FundraiseContext'; import { useNavigation } from '@/contexts/NavigationContext'; +import { useUser } from '@/contexts/UserContext'; +import { findGrantApplicationIdForPost } from '@/types/grant'; import { formatCurrency } from '@/utils/currency'; import type { FeedEntry } from '@/types/feed'; import type { Fundraise } from '@/types/funding'; +import type { FundingPool } from '@/types/grant'; interface ProposalWorkCardProps { entry: FeedEntry; @@ -61,9 +70,44 @@ export const ProposalWorkCard: FC = ({ entry, onNavigate const { showUSD } = useCurrencyPreference(); const { exchangeRate } = useExchangeRate(); const { updateLastClickedEntryId } = useNavigation(); + const { user } = useUser(); + const router = useRouter(); + 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 = + 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 +149,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/modals/AllocateFundingPoolModal.tsx b/components/modals/AllocateFundingPoolModal.tsx new file mode 100644 index 000000000..d1aba8dcf --- /dev/null +++ b/components/modals/AllocateFundingPoolModal.tsx @@ -0,0 +1,169 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { toast } from 'react-hot-toast'; +import { Modal } from '@/components/ui/form/Modal'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/form/Input'; +import { FundingPoolService } from '@/services/funding-pool.service'; +import { extractApiErrorMessage } from '@/services/lib/serviceUtils'; +import type { FundingPool } from '@/types/grant'; +import { formatRSC } from '@/utils/number'; +import { ID } from '@/types/root'; + +interface AllocateFundingPoolModalProps { + isOpen: boolean; + onClose: () => void; + fundingPool: FundingPool; + applicationId: ID; + proposalTitle: string; + onSuccess: (updatedPool: FundingPool) => void; +} + +/** + * Owner/mod modal to move RSC from the RFP funding pool into an open proposal fundraise. + */ +export function AllocateFundingPoolModal({ + isOpen, + onClose, + fundingPool, + applicationId, + proposalTitle, + onSuccess, +}: AllocateFundingPoolModalProps) { + const holdingRsc = fundingPool.amountHolding.rsc; + const distributedRsc = fundingPool.amountDistributed.rsc; + + const [amountInput, setAmountInput] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [amountError, setAmountError] = useState(); + + useEffect(() => { + if (!isOpen) return; + setAmountInput(''); + setAmountError(undefined); + setIsSubmitting(false); + }, [isOpen, fundingPool.id, applicationId]); + + const parseAmount = useCallback((value: string): number => { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : NaN; + }, []); + + const handleAmountChange = (e: React.ChangeEvent) => { + const next = e.target.value; + setAmountInput(next); + setAmountError(undefined); + + if (!next.trim()) return; + const amount = parseAmount(next); + if (!Number.isFinite(amount) || amount <= 0) { + setAmountError('Enter a positive amount'); + return; + } + if (amount > holdingRsc) { + setAmountError(`Cannot exceed ${formatRSC({ amount: holdingRsc, decimalPlaces: 2 })} RSC holding`); + } + }; + + const handleAllocateMax = () => { + setAmountInput(String(holdingRsc)); + setAmountError(undefined); + }; + + const handleSubmit = async () => { + const amount = parseAmount(amountInput); + if (!Number.isFinite(amount) || amount <= 0) { + setAmountError('Enter a positive amount'); + return; + } + if (amount > holdingRsc) { + setAmountError(`Cannot exceed ${formatRSC({ amount: holdingRsc, decimalPlaces: 2 })} RSC holding`); + return; + } + + setIsSubmitting(true); + try { + const updatedPool = await FundingPoolService.distribute(fundingPool.id, { + amount, + applicationId, + }); + toast.success('Allocated to proposal'); + onSuccess(updatedPool); + onClose(); + } catch (error) { + toast.error(extractApiErrorMessage(error, 'Failed to allocate from funding pool')); + } finally { + setIsSubmitting(false); + } + }; + + const amount = parseAmount(amountInput); + const canSubmit = + Number.isFinite(amount) && amount > 0 && amount <= holdingRsc && !isSubmitting && !amountError; + + return ( + +
+

{proposalTitle}

+ +
+
+ Pool holding + + {formatRSC({ amount: holdingRsc, decimalPlaces: 2 })} RSC + +
+
+ Already distributed + + {formatRSC({ amount: distributedRsc, decimalPlaces: 2 })} RSC + +
+
+ +
+
+ + {holdingRsc > 0 && ( + + )} +
+ + RSC +
+ } + /> + {amountError &&

{amountError}

} +
+ +
+ + +
+ +
+ ); +} diff --git a/components/work/WorkHeader/WorkHeaderGrant.tsx b/components/work/WorkHeader/WorkHeaderGrant.tsx index b0c66df8a..6c17e4ecc 100644 --- a/components/work/WorkHeader/WorkHeaderGrant.tsx +++ b/components/work/WorkHeader/WorkHeaderGrant.tsx @@ -11,7 +11,10 @@ import { SubmitProposalTooltip } from '@/components/tooltips/SubmitProposalToolt import { ContributeToFundraiseModal } from '@/components/modals/ContributeToFundraiseModal'; import { useGrantTab, type GrantBannerTab } from '@/components/Funding/GrantPageContent'; import { useFundraises } from '@/contexts/FundraiseContext'; +import { useUser } from '@/contexts/UserContext'; import type { FundingPool, GrantApplicationVisibility } from '@/types/grant'; +import { formatRSC } from '@/utils/number'; +import { ID } from '@/types/root'; import { WorkHeader } from './WorkHeader'; import { WorkHeaderGrantEyebrow } from './WorkHeaderGrantEyebrow'; @@ -26,6 +29,7 @@ interface WorkHeaderGrantProps { organization?: string; applicationVisibility?: GrantApplicationVisibility; fundingPool?: FundingPool | null; + grantCreatedByUserId?: ID | null; className?: string; preTitle?: ReactNode; } @@ -40,21 +44,33 @@ export function WorkHeaderGrant({ isPending = false, organization, applicationVisibility, - fundingPool = null, + fundingPool: fundingPoolProp = null, + grantCreatedByUserId = null, className, preTitle, }: WorkHeaderGrantProps) { const router = useRouter(); + const { user } = useUser(); const [isApplyModalOpen, setIsApplyModalOpen] = useState(false); const [isContributeModalOpen, setIsContributeModalOpen] = useState(false); - const { activeTab, setActiveTab, activity } = useGrantTab(); + const { activeTab, setActiveTab, activity, fundingPool: contextPool } = useGrantTab(); const { proposalCount } = useFundraises(); + const fundingPool = contextPool ?? fundingPoolProp; + const handleTabChange = useCallback( (tabId: string) => setActiveTab(tabId as GrantBannerTab), [setActiveTab] ); + const isGrantCreator = + user?.id != null && + grantCreatedByUserId != null && + Number(user.id) === Number(grantCreatedByUserId); + const canManagePool = isGrantCreator || !!user?.isModerator; + const showPoolHolding = + canManagePool && fundingPool?.status === 'OPEN' && (fundingPool.amountHolding.rsc ?? 0) >= 0; + const eyebrow = ( ); @@ -103,6 +119,27 @@ export function WorkHeaderGrant({ + {showPoolHolding && fundingPool && ( +
+ + Pool holding{' '} + + {formatRSC({ amount: fundingPool.amountHolding.rsc, decimalPlaces: 2 })} RSC + + + {(fundingPool.amountDistributed.rsc ?? 0) > 0 && ( + + Distributed{' '} + + {formatRSC({ amount: fundingPool.amountDistributed.rsc, decimalPlaces: 2 })} RSC + + + )} +
+ )} {requiresPrivateApplications && (
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/types/grant.ts b/types/grant.ts index 3535eb9fa..a92898fb4 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'; @@ -94,6 +95,15 @@ export function getGrantBadgeAmount(grant: { }; } +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; @@ -134,6 +144,7 @@ export interface Grant { /** Null when omitted (slim feed) or not yet backfilled; BE creates pools on grant create. */ fundingPool: FundingPool | null; applicants?: AuthorProfile[]; + applications?: Application[]; reviewedBy?: { id: ID; authorProfile: AuthorProfile; @@ -144,42 +155,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', - fundingPool: raw.funding_pool ? transformFundingPool(raw.funding_pool) : null, - 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, + }; +}); From ec990f60461a734990633ce9fee7a1d148675c41 Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Wed, 9 Sep 2026 23:48:07 +0300 Subject: [PATCH 06/13] Refactor grant amount handling in ContributeToFundraiseModal and WorkHeaderGrant components; remove unused grantAmountUsd prop and adjust related calculations for funding pool mode. --- app/grant/[id]/[slug]/layout.tsx | 1 - .../modals/ContributeToFundraiseModal.tsx | 33 +++++++++++-------- .../work/WorkHeader/WorkHeaderGrant.tsx | 3 -- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/app/grant/[id]/[slug]/layout.tsx b/app/grant/[id]/[slug]/layout.tsx index 28c7ab703..d45bf8d61 100644 --- a/app/grant/[id]/[slug]/layout.tsx +++ b/app/grant/[id]/[slug]/layout.tsx @@ -81,7 +81,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..4417aa50a 100644 --- a/components/modals/ContributeToFundraiseModal.tsx +++ b/components/modals/ContributeToFundraiseModal.tsx @@ -46,9 +46,7 @@ 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). */ progressOverride?: { currentAmountUsd: number; goalAmountUsd: number }; /** @@ -72,12 +70,10 @@ export type ContributeToFundraiseModalProps = ContributeModalCommonProps & mode?: 'fundraise'; fundraise: Fundraise; fundingPool?: never; - grantAmount?: never; } | { mode: 'fundingPool'; fundingPool: FundingPool; - grantAmount: { usd: number }; fundraise?: never; } ); @@ -136,7 +132,6 @@ function ContributeToFundraiseModalInner(props: Readonly { if (currentView === 'payment' || currentView === 'auth') { @@ -636,12 +632,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 6c17e4ecc..d39da3afd 100644 --- a/components/work/WorkHeader/WorkHeaderGrant.tsx +++ b/components/work/WorkHeader/WorkHeaderGrant.tsx @@ -22,7 +22,6 @@ interface WorkHeaderGrantProps { work: Work; metadata: WorkMetadata; amountUsd?: number; - grantAmountUsd?: number; grantId?: string; isActive?: boolean; isPending?: boolean; @@ -38,7 +37,6 @@ export function WorkHeaderGrant({ work, metadata, amountUsd, - grantAmountUsd, grantId, isActive = true, isPending = false, @@ -230,7 +228,6 @@ export function WorkHeaderGrant({ onClose={() => setIsContributeModalOpen(false)} onContributeSuccess={handleContributeSuccess} fundingPool={fundingPool} - grantAmount={{ usd: grantAmountUsd ?? amountUsd ?? 0 }} proposalTitle={grantTitle} /> )} From 7f22068df58de61534b5b7b0e45613f70778e9be Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Thu, 10 Sep 2026 13:37:17 +0300 Subject: [PATCH 07/13] Remove unused comments from feed, funding, and grant type definitions --- types/feed.ts | 1 - types/funding.ts | 1 - types/grant.ts | 8 -------- 3 files changed, 10 deletions(-) diff --git a/types/feed.ts b/types/feed.ts index 2a7b8d024..8a0e1768e 100644 --- a/types/feed.ts +++ b/types/feed.ts @@ -270,7 +270,6 @@ export interface FeedGrantContent extends BaseFeedContent { currency: string; createdBy: AuthorProfile; applicants: Application[]; - /** Present when the serializer includes it; slim grant_feed omits this. */ fundingPool: FundingPool | null; }; organization?: string; diff --git a/types/funding.ts b/types/funding.ts index 73f6a9970..b67b89db1 100644 --- a/types/funding.ts +++ b/types/funding.ts @@ -152,7 +152,6 @@ export function transformApplicationFundraise(raw: any): ApplicationFundraise { } export interface Application { - /** GrantApplication id — required for funding-pool distribute (`application_id`). */ id: number; profile: AuthorProfile; preregistrationPostId?: number; diff --git a/types/grant.ts b/types/grant.ts index eda19cf86..5f7643c27 100644 --- a/types/grant.ts +++ b/types/grant.ts @@ -40,7 +40,6 @@ export interface GrantAmount { formatted: string; } -/** Status of an RFP community funding pool (separate from proposal fundraises). */ export type FundingPoolStatus = 'OPEN' | 'CLOSED'; export interface FundingPoolAmount { @@ -48,10 +47,6 @@ export interface FundingPoolAmount { rsc: number; } -/** - * Community escrow pot on an RFP. Do not treat as a Fundraise — proposal - * crowdfunding stays on nested applications[].fundraise. - */ export interface FundingPool { id: number; status: FundingPoolStatus; @@ -64,7 +59,6 @@ function parseFundingPoolAmount(raw: unknown): FundingPoolAmount { const amount = raw as { usd?: unknown; rsc?: unknown } | null | undefined; return { usd: Number(amount?.usd ?? 0) || 0, - // BE may send RSC as a decimal string (e.g. "20.0"). rsc: Number(amount?.rsc ?? 0) || 0, }; } @@ -81,7 +75,6 @@ export function transformFundingPool(raw: any): FundingPool { /** * Badge total = grant.amount + pool amount_raised when present. - * If pool is null/absent (new grants before ensure, or slim feed), returns grant.amount only. */ export function getGrantBadgeAmount(grant: { amount: Pick; @@ -131,7 +124,6 @@ export interface Grant { endDate: string; contacts: Contact[]; applicationVisibility: GrantApplicationVisibility; - /** Null until ensure / create; slim feed serializers may omit the field entirely. */ fundingPool: FundingPool | null; applicants?: AuthorProfile[]; reviewedBy?: { From 77c78bf1069da23b03f0ac229b2eb44106c1f00d Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Thu, 10 Sep 2026 13:39:58 +0300 Subject: [PATCH 08/13] Refine comments in FundingPoolService: removed redundant note on service distinction and clarified parameter description in distribute method. --- services/funding-pool.service.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/services/funding-pool.service.ts b/services/funding-pool.service.ts index f17addfd6..06a263a6a 100644 --- a/services/funding-pool.service.ts +++ b/services/funding-pool.service.ts @@ -15,7 +15,6 @@ export interface DistributeFundingPoolParams { /** * Service for RFP community FundingPool APIs. - * Distinct from FundraiseService — proposal crowdfunding stays on /api/fundraise/. */ export class FundingPoolService { private static readonly POOL_BASE_PATH = '/api/funding_pool'; @@ -47,7 +46,7 @@ export class FundingPoolService { * Allocate pool holdings into an open proposal fundraise via its grant application. * * @param poolId - Funding pool id - * @param params - RSC amount and application id (not post/fundraise id) + * @param params - RSC amount and application id * @returns The updated funding pool */ static async distribute(poolId: ID, params: DistributeFundingPoolParams): Promise { From 8e496f654d22d1590e288a91426b51e8e5a4437f Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Thu, 10 Sep 2026 13:47:01 +0300 Subject: [PATCH 09/13] 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} /> )} From ef7ffa99a992d8d7f685bd26c0c497658819e617 Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Thu, 10 Sep 2026 13:56:07 +0300 Subject: [PATCH 10/13] Refactor AllocateFundingPoolModal to use validatePositiveDecimal for amount validation; remove deprecated parseAmount function. Clean up GrantPageContent by removing unused comments. --- components/Funding/GrantPageContent.tsx | 4 -- .../modals/AllocateFundingPoolModal.tsx | 48 +++++++++-------- utils/number.ts | 53 +++++++++++++++++++ 3 files changed, 79 insertions(+), 26 deletions(-) diff --git a/components/Funding/GrantPageContent.tsx b/components/Funding/GrantPageContent.tsx index 6bc806bcc..1bafaab65 100644 --- a/components/Funding/GrantPageContent.tsx +++ b/components/Funding/GrantPageContent.tsx @@ -39,10 +39,6 @@ export function useGrantTab() { return ctx; } -/** - * Optional allocate helpers — safe outside GrantTabProvider (e.g. global /fund feed). - * Returns null when not on an RFP page. - */ export function useGrantAllocateContext(): GrantTabContextValue | null { return useContext(GrantTabContext); } diff --git a/components/modals/AllocateFundingPoolModal.tsx b/components/modals/AllocateFundingPoolModal.tsx index d1aba8dcf..ffb605905 100644 --- a/components/modals/AllocateFundingPoolModal.tsx +++ b/components/modals/AllocateFundingPoolModal.tsx @@ -8,7 +8,7 @@ import { Input } from '@/components/ui/form/Input'; import { FundingPoolService } from '@/services/funding-pool.service'; import { extractApiErrorMessage } from '@/services/lib/serviceUtils'; import type { FundingPool } from '@/types/grant'; -import { formatRSC } from '@/utils/number'; +import { formatRSC, validatePositiveDecimal } from '@/utils/number'; import { ID } from '@/types/root'; interface AllocateFundingPoolModalProps { @@ -45,25 +45,26 @@ export function AllocateFundingPoolModal({ setIsSubmitting(false); }, [isOpen, fundingPool.id, applicationId]); - const parseAmount = useCallback((value: string): number => { - const parsed = Number.parseFloat(value); - return Number.isFinite(parsed) ? parsed : NaN; - }, []); + const validateAmount = useCallback( + (value: string) => + validatePositiveDecimal(value, { + max: holdingRsc, + maxError: `Cannot exceed ${formatRSC({ amount: holdingRsc, decimalPlaces: 2 })} RSC holding`, + }), + [holdingRsc] + ); const handleAmountChange = (e: React.ChangeEvent) => { const next = e.target.value; setAmountInput(next); - setAmountError(undefined); - if (!next.trim()) return; - const amount = parseAmount(next); - if (!Number.isFinite(amount) || amount <= 0) { - setAmountError('Enter a positive amount'); + if (!next.trim()) { + setAmountError(undefined); return; } - if (amount > holdingRsc) { - setAmountError(`Cannot exceed ${formatRSC({ amount: holdingRsc, decimalPlaces: 2 })} RSC holding`); - } + + const { error } = validateAmount(next); + setAmountError(error); }; const handleAllocateMax = () => { @@ -72,13 +73,9 @@ export function AllocateFundingPoolModal({ }; const handleSubmit = async () => { - const amount = parseAmount(amountInput); - if (!Number.isFinite(amount) || amount <= 0) { - setAmountError('Enter a positive amount'); - return; - } - if (amount > holdingRsc) { - setAmountError(`Cannot exceed ${formatRSC({ amount: holdingRsc, decimalPlaces: 2 })} RSC holding`); + const { amount, error } = validateAmount(amountInput); + if (error || !Number.isFinite(amount)) { + setAmountError(error ?? 'Enter a valid positive amount'); return; } @@ -98,9 +95,16 @@ export function AllocateFundingPoolModal({ } }; - const amount = parseAmount(amountInput); + const { amount, error: parsedError } = amountInput.trim() + ? validateAmount(amountInput) + : { amount: NaN, error: undefined }; const canSubmit = - Number.isFinite(amount) && amount > 0 && amount <= holdingRsc && !isSubmitting && !amountError; + Number.isFinite(amount) && + amount > 0 && + amount <= holdingRsc && + !isSubmitting && + !amountError && + !parsedError; return ( 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 }; +} From 5ff099e3575593c4d0d34096337414d90cf2cda9 Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Thu, 10 Sep 2026 22:05:39 +0300 Subject: [PATCH 11/13] Refactor funding pool components to incorporate currency preference handling; update AllocateFundingPoolModal and ContributeToFundraiseModal for improved amount display and validation. Enhance WorkHeaderGrant and ProposalWorkCard to support RFP funding pool logic. --- app/grant/[id]/[slug]/layout.tsx | 2 +- components/Funding/ProposalWorkCard.tsx | 9 ++- .../modals/AllocateFundingPoolModal.tsx | 79 ++++++++++++++----- .../modals/ContributeToFundraiseModal.tsx | 13 ++- .../work/WorkHeader/WorkHeaderGrant.tsx | 35 ++++++-- 5 files changed, 107 insertions(+), 31 deletions(-) diff --git a/app/grant/[id]/[slug]/layout.tsx b/app/grant/[id]/[slug]/layout.tsx index d45bf8d61..071d641e3 100644 --- a/app/grant/[id]/[slug]/layout.tsx +++ b/app/grant/[id]/[slug]/layout.tsx @@ -59,10 +59,10 @@ 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); - const badgeAmountUsd = grant ? getGrantBadgeAmount(grant).usd : undefined; const metadata = await MetadataService.get(work.unifiedDocumentId?.toString() || ''); diff --git a/components/Funding/ProposalWorkCard.tsx b/components/Funding/ProposalWorkCard.tsx index 2f59976ef..083b8ee16 100644 --- a/components/Funding/ProposalWorkCard.tsx +++ b/components/Funding/ProposalWorkCard.tsx @@ -1,7 +1,7 @@ 'use client'; import { FC, useState, useCallback } from 'react'; -import { useRouter } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; import { Coins } from 'lucide-react'; import { ActivityTimestamp, @@ -29,6 +29,8 @@ import type { FeedEntry } from '@/types/feed'; import type { Fundraise } from '@/types/funding'; import type { FundingPool } from '@/types/grant'; +const RFP_FUNDING_POOL_PARAM = 'rfpFundingPool'; + interface ProposalWorkCardProps { entry: FeedEntry; /** Fired when the user opens the proposal, for feed click analytics. */ @@ -72,6 +74,10 @@ export const ProposalWorkCard: FC = ({ entry, onNavigate 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(); @@ -92,6 +98,7 @@ export const ProposalWorkCard: FC = ({ entry, onNavigate const canManagePool = isGrantCreator || !!user?.isModerator; const canAllocate = + isRfpFundingPoolEnabled && isGrantScoped && canManagePool && fundingPool?.status === 'OPEN' && diff --git a/components/modals/AllocateFundingPoolModal.tsx b/components/modals/AllocateFundingPoolModal.tsx index ffb605905..9e108d5c4 100644 --- a/components/modals/AllocateFundingPoolModal.tsx +++ b/components/modals/AllocateFundingPoolModal.tsx @@ -5,10 +5,13 @@ import { toast } from 'react-hot-toast'; import { Modal } from '@/components/ui/form/Modal'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/form/Input'; +import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; +import { useExchangeRate } from '@/contexts/ExchangeRateContext'; import { FundingPoolService } from '@/services/funding-pool.service'; import { extractApiErrorMessage } from '@/services/lib/serviceUtils'; import type { FundingPool } from '@/types/grant'; -import { formatRSC, validatePositiveDecimal } from '@/utils/number'; +import { formatCurrency } from '@/utils/currency'; +import { validatePositiveDecimal } from '@/utils/number'; import { ID } from '@/types/root'; interface AllocateFundingPoolModalProps { @@ -31,8 +34,20 @@ export function AllocateFundingPoolModal({ proposalTitle, onSuccess, }: AllocateFundingPoolModalProps) { + const { showUSD } = useCurrencyPreference(); + const { exchangeRate } = useExchangeRate(); + const holdingRsc = fundingPool.amountHolding.rsc; - const distributedRsc = fundingPool.amountDistributed.rsc; + const holdingDisplay = showUSD ? fundingPool.amountHolding.usd : holdingRsc; + const currencyLabel = showUSD ? 'USD' : 'RSC'; + + const formatPoolAmount = (amount: { usd: number; rsc: number }) => + formatCurrency({ + amount: showUSD ? amount.usd : amount.rsc, + showUSD, + exchangeRate: 1, + skipConversion: true, + }); const [amountInput, setAmountInput] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); @@ -43,15 +58,24 @@ export function AllocateFundingPoolModal({ setAmountInput(''); setAmountError(undefined); setIsSubmitting(false); - }, [isOpen, fundingPool.id, applicationId]); + }, [isOpen, fundingPool.id, applicationId, showUSD]); + + const toRscAmount = useCallback( + (displayAmount: number) => { + if (!showUSD) return displayAmount; + if (!(exchangeRate > 0)) return NaN; + return displayAmount / exchangeRate; + }, + [showUSD, exchangeRate] + ); const validateAmount = useCallback( (value: string) => validatePositiveDecimal(value, { - max: holdingRsc, - maxError: `Cannot exceed ${formatRSC({ amount: holdingRsc, decimalPlaces: 2 })} RSC holding`, + max: holdingDisplay, + maxError: `Cannot exceed ${formatPoolAmount(fundingPool.amountHolding)} holding`, }), - [holdingRsc] + [holdingDisplay, fundingPool.amountHolding, showUSD] ); const handleAmountChange = (e: React.ChangeEvent) => { @@ -68,21 +92,35 @@ export function AllocateFundingPoolModal({ }; const handleAllocateMax = () => { - setAmountInput(String(holdingRsc)); + setAmountInput(String(holdingDisplay)); setAmountError(undefined); }; const handleSubmit = async () => { - const { amount, error } = validateAmount(amountInput); - if (error || !Number.isFinite(amount)) { + const { amount: displayAmount, error } = validateAmount(amountInput); + if (error || !Number.isFinite(displayAmount)) { setAmountError(error ?? 'Enter a valid positive amount'); return; } + const isMaxAllocation = displayAmount >= holdingDisplay; + const amountRsc = isMaxAllocation + ? holdingRsc + : Math.min(toRscAmount(displayAmount), holdingRsc); + + if (!Number.isFinite(amountRsc) || amountRsc <= 0) { + setAmountError( + showUSD && !(exchangeRate > 0) + ? 'Exchange rate unavailable. Switch to RSC or try again.' + : 'Enter a valid positive amount' + ); + return; + } + setIsSubmitting(true); try { const updatedPool = await FundingPoolService.distribute(fundingPool.id, { - amount, + amount: amountRsc, applicationId, }); toast.success('Allocated to proposal'); @@ -95,13 +133,14 @@ export function AllocateFundingPoolModal({ } }; - const { amount, error: parsedError } = amountInput.trim() + const { amount: parsedDisplayAmount, error: parsedError } = amountInput.trim() ? validateAmount(amountInput) : { amount: NaN, error: undefined }; const canSubmit = - Number.isFinite(amount) && - amount > 0 && - amount <= holdingRsc && + Number.isFinite(parsedDisplayAmount) && + parsedDisplayAmount > 0 && + parsedDisplayAmount <= holdingDisplay && + (!showUSD || exchangeRate > 0) && !isSubmitting && !amountError && !parsedError; @@ -112,16 +151,16 @@ export function AllocateFundingPoolModal({

{proposalTitle}

-
+
Pool holding - {formatRSC({ amount: holdingRsc, decimalPlaces: 2 })} RSC + {formatPoolAmount(fundingPool.amountHolding)}
-
+
Already distributed - {formatRSC({ amount: distributedRsc, decimalPlaces: 2 })} RSC + {formatPoolAmount(fundingPool.amountDistributed)}
@@ -131,7 +170,7 @@ export function AllocateFundingPoolModal({ - {holdingRsc > 0 && ( + {holdingDisplay > 0 && (
} /> diff --git a/components/modals/ContributeToFundraiseModal.tsx b/components/modals/ContributeToFundraiseModal.tsx index 4417aa50a..c0454d3ed 100644 --- a/components/modals/ContributeToFundraiseModal.tsx +++ b/components/modals/ContributeToFundraiseModal.tsx @@ -9,6 +9,7 @@ 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'; @@ -30,6 +31,7 @@ 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'; @@ -138,6 +140,7 @@ function ContributeToFundraiseModalInner(props: Readonly
- {isPoolMode && poolRaisedUsd > 0 && ( + {isPoolMode && (showUSD ? poolRaisedUsd : poolRaisedRsc) > 0 && (

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

)} diff --git a/components/work/WorkHeader/WorkHeaderGrant.tsx b/components/work/WorkHeader/WorkHeaderGrant.tsx index d39da3afd..6c632dc62 100644 --- a/components/work/WorkHeader/WorkHeaderGrant.tsx +++ b/components/work/WorkHeader/WorkHeaderGrant.tsx @@ -2,7 +2,7 @@ import { type ReactNode, useState, useCallback } from 'react'; import { ArrowUpFromLine, Coins, Lock } from 'lucide-react'; -import { useRouter } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; import { Work } from '@/types/work'; import { WorkMetadata } from '@/services/metadata.service'; import { Button } from '@/components/ui/Button'; @@ -12,12 +12,24 @@ import { ContributeToFundraiseModal } from '@/components/modals/ContributeToFund import { useGrantTab, type GrantBannerTab } from '@/components/Funding/GrantPageContent'; import { useFundraises } from '@/contexts/FundraiseContext'; import { useUser } from '@/contexts/UserContext'; +import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; import type { FundingPool, GrantApplicationVisibility } from '@/types/grant'; -import { formatRSC } from '@/utils/number'; +import { formatCurrency } from '@/utils/currency'; import { ID } from '@/types/root'; import { WorkHeader } from './WorkHeader'; import { WorkHeaderGrantEyebrow } from './WorkHeaderGrantEyebrow'; +const RFP_FUNDING_POOL_PARAM = 'rfpFundingPool'; + +function formatPoolAmount(amount: { usd: number; rsc: number }, showUSD: boolean): string { + return formatCurrency({ + amount: showUSD ? amount.usd : amount.rsc, + showUSD, + exchangeRate: 1, + skipConversion: true, + }); +} + interface WorkHeaderGrantProps { work: Work; metadata: WorkMetadata; @@ -48,7 +60,12 @@ export function WorkHeaderGrant({ preTitle, }: WorkHeaderGrantProps) { const router = useRouter(); + const searchParams = useSearchParams(); const { user } = useUser(); + const { showUSD } = useCurrencyPreference(); + const isRfpFundingPoolEnabled = + searchParams.get(RFP_FUNDING_POOL_PARAM) === 'true' || + searchParams.get(RFP_FUNDING_POOL_PARAM) === '1'; const [isApplyModalOpen, setIsApplyModalOpen] = useState(false); const [isContributeModalOpen, setIsContributeModalOpen] = useState(false); const { activeTab, setActiveTab, activity, fundingPool: contextPool } = useGrantTab(); @@ -67,14 +84,18 @@ export function WorkHeaderGrant({ Number(user.id) === Number(grantCreatedByUserId); const canManagePool = isGrantCreator || !!user?.isModerator; const showPoolHolding = - canManagePool && fundingPool?.status === 'OPEN' && (fundingPool.amountHolding.rsc ?? 0) >= 0; + isRfpFundingPoolEnabled && + canManagePool && + fundingPool?.status === 'OPEN' && + (fundingPool.amountHolding.rsc ?? 0) >= 0; const eyebrow = ( ); const requiresPrivateApplications = applicationVisibility === 'PRIVATE'; - const canContributeToPool = !!grantId && isActive && fundingPool?.status === 'OPEN'; + const canContributeToPool = + isRfpFundingPoolEnabled && !!grantId && isActive && fundingPool?.status === 'OPEN'; const handleContributeSuccess = useCallback(() => { setIsContributeModalOpen(false); @@ -125,14 +146,14 @@ export function WorkHeaderGrant({ Pool holding{' '} - {formatRSC({ amount: fundingPool.amountHolding.rsc, decimalPlaces: 2 })} RSC + {formatPoolAmount(fundingPool.amountHolding, showUSD)} {(fundingPool.amountDistributed.rsc ?? 0) > 0 && ( Distributed{' '} - {formatRSC({ amount: fundingPool.amountDistributed.rsc, decimalPlaces: 2 })} RSC + {formatPoolAmount(fundingPool.amountDistributed, showUSD)} )} @@ -221,7 +242,7 @@ export function WorkHeaderGrant({ } /> - {fundingPool?.status === 'OPEN' && ( + {isRfpFundingPoolEnabled && fundingPool?.status === 'OPEN' && ( Date: Fri, 11 Sep 2026 09:12:22 +0300 Subject: [PATCH 12/13] Enhance Activity components to support current document context; update ActivityCardCompact to conditionally hide title links based on the current document ID. Modify ActivitySidebar and ActivitySidebarServer to pass currentDocumentId prop for improved user experience in activity tracking. --- app/grant/[id]/[slug]/layout.tsx | 6 +++- .../Activity/cards/ActivityCardCompact.tsx | 30 +++++++++++++++++-- .../cards/ActivityFundingGroupCard.tsx | 18 ++++++----- .../Activity/lib/activityDisplay.utils.ts | 12 +++++++- .../Activity/sidebar/ActivitySidebar.tsx | 16 ++++++++-- .../sidebar/ActivitySidebarServer.tsx | 13 +++++++- 6 files changed, 80 insertions(+), 15 deletions(-) diff --git a/app/grant/[id]/[slug]/layout.tsx b/app/grant/[id]/[slug]/layout.tsx index 071d641e3..f2ca39f3b 100644 --- a/app/grant/[id]/[slug]/layout.tsx +++ b/app/grant/[id]/[slug]/layout.tsx @@ -99,7 +99,11 @@ export default async function GrantSlugLayout({ params, children }: Props) { } rightSidebar={ }> - + } > 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 5c05ebb61..566c6714a 100644 --- a/components/Activity/cards/ActivityFundingGroupCard.tsx +++ b/components/Activity/cards/ActivityFundingGroupCard.tsx @@ -60,15 +60,18 @@ const FunderName: FC<{ funder: AuthorProfile }> = ({ funder }) => { ); }; -const FunderSummary: FC<{ funders: AuthorProfile[]; contributionCount: number }> = ({ - funders, - contributionCount, -}) => { +const FunderSummary: FC<{ + funders: AuthorProfile[]; + contributionCount: number; + isRfp: boolean; +}> = ({ funders, contributionCount, isRfp }) => { + const targetLabel = isRfp ? 'RFP' : 'proposal'; + if (funders.length === 1) { return ( <> - {` funded this proposal ${contributionCount} times`} + {` funded this ${targetLabel} ${contributionCount} times`} ); } @@ -92,7 +95,7 @@ const FunderSummary: FC<{ funders: AuthorProfile[]; contributionCount: number }> {remaining > 0 && ( {` and ${remaining} ${remaining === 1 ? 'other' : 'others'}`} )} - funded this proposal for + {` funded this ${targetLabel} for`} ); }; @@ -110,6 +113,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 || '', @@ -141,7 +145,7 @@ export const ActivityFundingGroupCard: FC = ({ ro
- {' '} + {' '}
diff --git a/components/Activity/lib/activityDisplay.utils.ts b/components/Activity/lib/activityDisplay.utils.ts index bea19a22b..13314b463 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 proposal for' }; + return { + actor, + verb: isFundingPoolContribution(entry) ? 'contributed to RFP' : 'funded proposal for', + }; } 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 ( + + ); } From 98502797879b0cfc36992717eb693b65e9f39c09 Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Fri, 11 Sep 2026 10:17:59 +0300 Subject: [PATCH 13/13] Refactor FunderSummary component to simplify props and improve text consistency. Update verb usage in activity messages for clarity. --- .../cards/ActivityFundingGroupCard.tsx | 23 ++++--------------- .../Activity/lib/activityDisplay.utils.ts | 2 +- 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/components/Activity/cards/ActivityFundingGroupCard.tsx b/components/Activity/cards/ActivityFundingGroupCard.tsx index e1dfb0e24..f54f1ce80 100644 --- a/components/Activity/cards/ActivityFundingGroupCard.tsx +++ b/components/Activity/cards/ActivityFundingGroupCard.tsx @@ -60,24 +60,10 @@ const FunderName: FC<{ funder: AuthorProfile }> = ({ funder }) => { ); }; -const FunderSummary: FC<{ - funders: AuthorProfile[]; - contributionCount: number; - isRfp: boolean; -}> = ({ funders, contributionCount, isRfp }) => { - const targetLabel = isRfp ? 'RFP' : 'proposal'; - - if (funders.length === 1) { - return ( - <> - - {` funded this ${targetLabel} ${contributionCount} times`} - - ); - } - +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 ( <> @@ -95,7 +81,7 @@ const FunderSummary: FC<{ {remaining > 0 && ( {` and ${remaining} ${remaining === 1 ? 'other' : 'others'}`} )} - {` funded this ${targetLabel} for`} + {action} ); }; @@ -114,7 +100,6 @@ export const ActivityFundingGroupCard: FC = ({ ro const presentation = getWorkCardPresentation(latestEntry, work, { showUSD, exchangeRate }); const total = toPreferredTotal(totals, showUSD, exchangeRate); const isRfp = work.documentType === 'funding_request'; - const contributionCount = entries.length; const avatarItems = funders.map((funder) => ({ src: funder.profileImage || '', @@ -146,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 13314b463..b108f0c09 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 RFP' : 'funded proposal for', + verb: isFundingPoolContribution(entry) ? 'contributed to this RFP.' : 'funded this proposal.', }; }