diff --git a/app/grant/[id]/[slug]/layout.tsx b/app/grant/[id]/[slug]/layout.tsx index af03de82d..f2ca39f3b 100644 --- a/app/grant/[id]/[slug]/layout.tsx +++ b/app/grant/[id]/[slug]/layout.tsx @@ -59,15 +59,21 @@ 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() || ''); return ( - + }> - + } > diff --git a/components/Activity/cards/ActivityCardCompact.tsx b/components/Activity/cards/ActivityCardCompact.tsx index 8006e0572..ab74f54ca 100644 --- a/components/Activity/cards/ActivityCardCompact.tsx +++ b/components/Activity/cards/ActivityCardCompact.tsx @@ -18,21 +18,43 @@ import { getReviewEarning, getReviewScore, } from '../lib/activityDisplay.utils'; -import { getActivityBounty, shouldShowAuthorBadge } from '../lib/activityWork.utils'; +import { + getActivityBounty, + getActivityWork, + shouldShowAuthorBadge, +} from '../lib/activityWork.utils'; import { formatTimeAgo } from '@/utils/date'; import { Tooltip } from '@/components/ui/Tooltip'; import type { FeedEntry } from '@/types/feed'; +import { ID } from '@/types/root'; interface ActivityCardCompactProps { entry: FeedEntry; + currentDocumentId?: ID; +} + +function isEntryAboutDocument(entry: FeedEntry, documentId: ID): boolean { + const targetId = Number(documentId); + if (!Number.isFinite(targetId)) return false; + + const work = getActivityWork(entry); + if (work?.id != null && Number(work.id) === targetId) return true; + + if (entry.relatedWork?.id != null && Number(entry.relatedWork.id) === targetId) return true; + + const contentId = entry.content?.id; + return contentId != null && Number(contentId) === targetId; } /** Compact activity row used in the activity sidebar. */ -export const ActivityCardCompact: FC = ({ entry }) => { +export const ActivityCardCompact: FC = ({ entry, currentDocumentId }) => { const { title, href } = getEntryMeta(entry); if (!title) return null; + /** When the entry's work is this post, hide the title link (already on that page). */ + const hideTitle = currentDocumentId != null && isEntryAboutDocument(entry, currentDocumentId); + const message = getActivityHeaderMessage(entry); const actionIcon = getActionIcon(entry); const reviewScore = getReviewScore(entry); @@ -118,7 +140,9 @@ export const ActivityCardCompact: FC = ({ entry }) => className="text-sm leading-5" isAuthor={shouldShowAuthorBadge(entry, message.actor.id)} /> - {titleEl} + {!hideTitle && ( + {titleEl} + )} {formatTimeAgo(entry.timestamp)} diff --git a/components/Activity/cards/ActivityFundingGroupCard.tsx b/components/Activity/cards/ActivityFundingGroupCard.tsx index d4d5d5962..f54f1ce80 100644 --- a/components/Activity/cards/ActivityFundingGroupCard.tsx +++ b/components/Activity/cards/ActivityFundingGroupCard.tsx @@ -60,9 +60,10 @@ const FunderName: FC<{ funder: AuthorProfile }> = ({ funder }) => { ); }; -const FunderSummary: FC<{ funders: AuthorProfile[] }> = ({ funders }) => { +const FunderSummary: FC<{ funders: AuthorProfile[]; isRfp: boolean }> = ({ funders, isRfp }) => { const named = funders.slice(0, MAX_NAMED_FUNDERS); const remaining = funders.length - named.length; + const action = isRfp ? ' contributed to this RFP.' : ' funded this proposal.'; return ( <> @@ -80,7 +81,7 @@ const FunderSummary: FC<{ funders: AuthorProfile[] }> = ({ funders }) => { {remaining > 0 && ( {` and ${remaining} ${remaining === 1 ? 'other' : 'others'}`} )} - funded this proposal. + {action} ); }; @@ -98,6 +99,7 @@ export const ActivityFundingGroupCard: FC = ({ ro const latestEntryId = String(latestEntry.id); const presentation = getWorkCardPresentation(latestEntry, work, { showUSD, exchangeRate }); const total = toPreferredTotal(totals, showUSD, exchangeRate); + const isRfp = work.documentType === 'funding_request'; const avatarItems = funders.map((funder) => ({ src: funder.profileImage || '', @@ -129,7 +131,7 @@ export const ActivityFundingGroupCard: FC = ({ ro
- {' '} + {' '}
diff --git a/components/Activity/lib/activityDisplay.utils.ts b/components/Activity/lib/activityDisplay.utils.ts index 135c1e60c..b108f0c09 100644 --- a/components/Activity/lib/activityDisplay.utils.ts +++ b/components/Activity/lib/activityDisplay.utils.ts @@ -90,6 +90,13 @@ function getFundingActivityMessage(content: FeedFundingActivityContent): Activit }; } +export function isFundingPoolContribution(entry: FeedEntry): boolean { + if (entry.contentType !== 'PURCHASE' && entry.contentType !== 'USDFUNDRAISECONTRIBUTION') { + return false; + } + return entry.relatedWork?.contentType === 'funding_request'; +} + function getDefaultActivityMessage(entry: FeedEntry): ActivityHeaderMessage { const actor = entry.content.createdBy; @@ -129,7 +136,10 @@ function getDefaultActivityMessage(entry: FeedEntry): ActivityHeaderMessage { } if (entry.contentType === 'USDFUNDRAISECONTRIBUTION' || entry.contentType === 'PURCHASE') { - return { actor, verb: 'funded this proposal.' }; + return { + actor, + verb: isFundingPoolContribution(entry) ? 'contributed to this RFP.' : 'funded this proposal.', + }; } return { diff --git a/components/Activity/sidebar/ActivitySidebar.tsx b/components/Activity/sidebar/ActivitySidebar.tsx index b13fa0ff4..56f36a950 100644 --- a/components/Activity/sidebar/ActivitySidebar.tsx +++ b/components/Activity/sidebar/ActivitySidebar.tsx @@ -5,14 +5,22 @@ import { Activity, Reply } from 'lucide-react'; import { ActivityCardCompact } from '../cards/ActivityCardCompact'; import type { FeedEntry } from '@/types/feed'; import { SidebarHeader } from '@/components/ui/SidebarHeader'; +import { ID } from '@/types/root'; interface ActivitySidebarProps { topSection?: ReactNode; entries?: FeedEntry[]; grantTitle?: string; + /** Post id of the page being viewed — hides same-document title links. */ + currentDocumentId?: ID; } -export const ActivitySidebar: FC = ({ topSection, entries, grantTitle }) => { +export const ActivitySidebar: FC = ({ + topSection, + entries, + grantTitle, + currentDocumentId, +}) => { const hasEntries = entries && entries.length > 0; return ( @@ -45,7 +53,11 @@ export const ActivitySidebar: FC = ({ topSection, entries, ) : (
{entries.map((entry) => ( - + ))}
)} diff --git a/components/Activity/sidebar/ActivitySidebarServer.tsx b/components/Activity/sidebar/ActivitySidebarServer.tsx index 8801b50e7..444e51298 100644 --- a/components/Activity/sidebar/ActivitySidebarServer.tsx +++ b/components/Activity/sidebar/ActivitySidebarServer.tsx @@ -2,11 +2,14 @@ import { ReactNode } from 'react'; import { ActivityService, ActivityScope } from '@/services/activity.service'; import { ActivitySidebar } from './ActivitySidebar'; import type { FeedEntry } from '@/types/feed'; +import { ID } from '@/types/root'; interface ActivitySidebarServerProps { topSection?: ReactNode; grantId?: number | string; grantTitle?: string; + /** Post id of the page being viewed — hides same-document title links. */ + currentDocumentId?: ID; scope?: ActivityScope; } @@ -14,6 +17,7 @@ export async function ActivitySidebarServer({ topSection, grantId, grantTitle, + currentDocumentId, scope = 'grants', }: ActivitySidebarServerProps) { let entries: FeedEntry[] = []; @@ -29,5 +33,12 @@ export async function ActivitySidebarServer({ console.error('Error loading activity sidebar entries:', error); } - return ; + return ( + + ); } diff --git a/components/Funding/GrantPageContent.tsx b/components/Funding/GrantPageContent.tsx index 7480421c7..1bafaab65 100644 --- a/components/Funding/GrantPageContent.tsx +++ b/components/Funding/GrantPageContent.tsx @@ -1,8 +1,11 @@ 'use client'; -import { createContext, useContext, useState, ReactNode } from 'react'; +import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; import { useActivityFeed } from '@/hooks/useActivityFeed'; import { FeedEntry } from '@/types/feed'; +import type { Application } from '@/types/funding'; +import type { FundingPool } from '@/types/grant'; +import { ID } from '@/types/root'; export type GrantBannerTab = 'proposals' | 'details' | 'activity'; @@ -22,6 +25,10 @@ interface GrantTabContextValue { lastClickedEntryId: string | null; restorationTab: string; }; + fundingPool: FundingPool | null; + setFundingPool: (pool: FundingPool | null) => void; + applications: Application[]; + grantCreatedByUserId: ID | null; } const GrantTabContext = createContext(null); @@ -32,16 +39,32 @@ export function useGrantTab() { return ctx; } +export function useGrantAllocateContext(): GrantTabContextValue | null { + return useContext(GrantTabContext); +} + export function GrantTabProvider({ children, defaultTab = 'details', grantId, + fundingPool: initialFundingPool = null, + applications: initialApplications = [], + grantCreatedByUserId = null, }: { children: ReactNode; defaultTab?: GrantBannerTab; grantId?: number | string; + fundingPool?: FundingPool | null; + applications?: Application[]; + grantCreatedByUserId?: ID | null; }) { const [activeTab, setActiveTab] = useState(defaultTab); + const [fundingPool, setFundingPool] = useState(initialFundingPool); + + useEffect(() => { + setFundingPool(initialFundingPool); + }, [initialFundingPool]); + const { entries, isLoading, @@ -59,6 +82,10 @@ export function GrantTabProvider({ grantId, }); + const handleSetFundingPool = useCallback((pool: FundingPool | null) => { + setFundingPool(pool); + }, []); + return ( {children} diff --git a/components/Funding/ProposalWorkCard.tsx b/components/Funding/ProposalWorkCard.tsx index a3b5e5317..083b8ee16 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, useSearchParams } from 'next/navigation'; +import { Coins } from 'lucide-react'; import { ActivityTimestamp, ActivityWorkActions, @@ -13,12 +15,21 @@ 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'; + +const RFP_FUNDING_POOL_PARAM = 'rfpFundingPool'; interface ProposalWorkCardProps { entry: FeedEntry; @@ -61,9 +72,49 @@ export const ProposalWorkCard: FC = ({ entry, onNavigate const { showUSD } = useCurrencyPreference(); const { exchangeRate } = useExchangeRate(); const { updateLastClickedEntryId } = useNavigation(); + const { user } = useUser(); + const router = useRouter(); + const searchParams = useSearchParams(); + const isRfpFundingPoolEnabled = + searchParams.get(RFP_FUNDING_POOL_PARAM) === 'true' || + searchParams.get(RFP_FUNDING_POOL_PARAM) === '1'; + const { isGrantScoped, refresh: refreshProposals } = useFundraises(); + const grantAllocate = useGrantAllocateContext(); + + const [isAllocateOpen, setIsAllocateOpen] = useState(false); const work = getActivityWork(entry); + const fundingPool = grantAllocate?.fundingPool ?? null; + const applicationId = + work && grantAllocate + ? findGrantApplicationIdForPost(grantAllocate.applications, work.id) + : undefined; + + const isGrantCreator = + user?.id != null && + grantAllocate?.grantCreatedByUserId != null && + Number(user.id) === Number(grantAllocate.grantCreatedByUserId); + const canManagePool = isGrantCreator || !!user?.isModerator; + + const canAllocate = + isRfpFundingPoolEnabled && + isGrantScoped && + canManagePool && + fundingPool?.status === 'OPEN' && + (fundingPool.amountHolding.rsc ?? 0) > 0 && + work?.fundraise?.status === 'OPEN' && + applicationId != null; + + const handleAllocateSuccess = useCallback( + (updatedPool: FundingPool) => { + grantAllocate?.setFundingPool(updatedPool); + void refreshProposals(); + router.refresh(); + }, + [grantAllocate, refreshProposals, router] + ); + if (!work) return null; const entryId = String(entry.id); @@ -105,10 +156,41 @@ export const ProposalWorkCard: FC = ({ entry, onNavigate - +
+
+ +
+ {canAllocate && ( + + )} +
+ + {canAllocate && fundingPool && applicationId != null && ( + setIsAllocateOpen(false)} + fundingPool={fundingPool} + applicationId={applicationId} + proposalTitle={work.title} + onSuccess={handleAllocateSuccess} + /> + )} ); }; diff --git a/components/modals/AllocateFundingPoolModal.tsx b/components/modals/AllocateFundingPoolModal.tsx new file mode 100644 index 000000000..9e108d5c4 --- /dev/null +++ b/components/modals/AllocateFundingPoolModal.tsx @@ -0,0 +1,212 @@ +'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 { 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 { formatCurrency } from '@/utils/currency'; +import { validatePositiveDecimal } 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 { showUSD } = useCurrencyPreference(); + const { exchangeRate } = useExchangeRate(); + + const holdingRsc = fundingPool.amountHolding.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); + const [amountError, setAmountError] = useState(); + + useEffect(() => { + if (!isOpen) return; + setAmountInput(''); + setAmountError(undefined); + setIsSubmitting(false); + }, [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: holdingDisplay, + maxError: `Cannot exceed ${formatPoolAmount(fundingPool.amountHolding)} holding`, + }), + [holdingDisplay, fundingPool.amountHolding, showUSD] + ); + + const handleAmountChange = (e: React.ChangeEvent) => { + const next = e.target.value; + setAmountInput(next); + + if (!next.trim()) { + setAmountError(undefined); + return; + } + + const { error } = validateAmount(next); + setAmountError(error); + }; + + const handleAllocateMax = () => { + setAmountInput(String(holdingDisplay)); + setAmountError(undefined); + }; + + const handleSubmit = async () => { + 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: amountRsc, + 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: parsedDisplayAmount, error: parsedError } = amountInput.trim() + ? validateAmount(amountInput) + : { amount: NaN, error: undefined }; + const canSubmit = + Number.isFinite(parsedDisplayAmount) && + parsedDisplayAmount > 0 && + parsedDisplayAmount <= holdingDisplay && + (!showUSD || exchangeRate > 0) && + !isSubmitting && + !amountError && + !parsedError; + + return ( + +
+

{proposalTitle}

+ +
+
+ Pool holding + + {formatPoolAmount(fundingPool.amountHolding)} + +
+
+ Already distributed + + {formatPoolAmount(fundingPool.amountDistributed)} + +
+
+ +
+
+ + {holdingDisplay > 0 && ( + + )} +
+ + {currencyLabel} +
+ } + /> + {amountError &&

{amountError}

} +
+ +
+ + +
+ +
+ ); +} diff --git a/components/modals/ContributeToFundraiseModal.tsx b/components/modals/ContributeToFundraiseModal.tsx index 3d0ceaffa..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'; @@ -47,7 +49,6 @@ interface ContributeModalCommonProps { headerSubtitle?: string; /** * 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 }; /** @@ -139,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 651f55ffc..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'; @@ -11,10 +11,25 @@ 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 { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; import type { FundingPool, GrantApplicationVisibility } from '@/types/grant'; +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; @@ -25,6 +40,7 @@ interface WorkHeaderGrantProps { organization?: string; applicationVisibility?: GrantApplicationVisibility; fundingPool?: FundingPool | null; + grantCreatedByUserId?: ID | null; className?: string; preTitle?: ReactNode; } @@ -38,27 +54,48 @@ export function WorkHeaderGrant({ isPending = false, organization, applicationVisibility, - fundingPool = null, + fundingPool: fundingPoolProp = null, + grantCreatedByUserId = null, className, 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 } = 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 = + 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); @@ -101,6 +138,27 @@ export function WorkHeaderGrant({ + {showPoolHolding && fundingPool && ( +
+ + Pool holding{' '} + + {formatPoolAmount(fundingPool.amountHolding, showUSD)} + + + {(fundingPool.amountDistributed.rsc ?? 0) > 0 && ( + + Distributed{' '} + + {formatPoolAmount(fundingPool.amountDistributed, showUSD)} + + + )} +
+ )} {requiresPrivateApplications && (
@@ -184,7 +242,7 @@ export function WorkHeaderGrant({ } /> - {fundingPool?.status === 'OPEN' && ( + {isRfpFundingPoolEnabled && fundingPool?.status === 'OPEN' && ( 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 5f7643c27..d2872b04e 100644 --- a/types/grant.ts +++ b/types/grant.ts @@ -2,6 +2,7 @@ import { Currency, ID } from './root'; import { createTransformer } from './transformer'; import { AuthorProfile, transformAuthorProfile } from './authorProfile'; import { Contact, transformContact } from './note'; +import type { Application } from './funding'; export type GrantStatus = 'OPEN' | 'CLOSED' | 'PENDING' | 'DECLINED' | 'COMPLETED'; @@ -87,6 +88,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; @@ -126,6 +136,7 @@ export interface Grant { applicationVisibility: GrantApplicationVisibility; fundingPool: FundingPool | null; applicants?: AuthorProfile[]; + applications?: Application[]; reviewedBy?: { id: ID; authorProfile: AuthorProfile; @@ -136,42 +147,51 @@ export interface Grant { declineReason?: string; } -export const transformGrant = createTransformer((raw) => ({ - id: raw.id, - createdBy: { - id: raw.created_by.id, - authorProfile: transformAuthorProfile(raw.created_by.author_profile), - firstName: raw.created_by.first_name, - lastName: raw.created_by.last_name, - }, - amount: { - usd: raw.amount.usd, - rsc: raw.amount.rsc, - formatted: raw.amount.formatted, - }, - currency: raw.currency as Currency, - organization: raw.organization, - description: raw.description, - shortTitle: raw.short_title || '', - status: raw.status as GrantStatus, - startDate: raw.start_date, - endDate: raw.end_date, - contacts: Array.isArray(raw.contacts) - ? raw.contacts.map((contact: any) => transformContact(contact)) - : undefined, - applicationVisibility: (raw.application_visibility as GrantApplicationVisibility) ?? 'OPTIONAL', - 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, + }; +}); 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 }; +}