From 325023217c9ddb419644101b7e7b383a1e54ec5f Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Wed, 9 Sep 2026 22:00:47 +0300 Subject: [PATCH 1/6] 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 2/6] 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 ef7ffa99a992d8d7f685bd26c0c497658819e617 Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Thu, 10 Sep 2026 13:56:07 +0300 Subject: [PATCH 3/6] 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 4/6] 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 5/6] 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 6/6] 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.', }; }