diff --git a/app/proposal/[id]/[slug]/layout.tsx b/app/proposal/[id]/[slug]/layout.tsx index 557ebc3ca..702453b2a 100644 --- a/app/proposal/[id]/[slug]/layout.tsx +++ b/app/proposal/[id]/[slug]/layout.tsx @@ -119,7 +119,9 @@ export default async function ProposalSlugLayout({ params, children }: Props) { > {children} - {metadata.fundraising && } + {metadata.fundraising && ( + + )} diff --git a/components/AIMode/document/PublishDialog.tsx b/components/AIMode/document/PublishDialog.tsx index b0ab6edbf..f3f0993c4 100644 --- a/components/AIMode/document/PublishDialog.tsx +++ b/components/AIMode/document/PublishDialog.tsx @@ -9,6 +9,8 @@ import { NonprofitConfirmModal, NonprofitSearchSection } from '@/components/Nonp import { usePublishingController } from '@/components/Notebook/PublishingForm'; import { FUNDRAISE_DURATION_HELP } from '@/components/Notebook/PublishingForm/components/EndDateSection'; import { GRANT_APPLICATION_VISIBILITY_HELP } from '@/components/Notebook/PublishingForm/components/GrantApplicationVisibilitySection'; +import { isAwardMechanism } from '@/components/Funding/lib/fundingMechanism'; +import { GrantFundingMechanismSection } from '@/components/Notebook/PublishingForm/components/GrantFundingMechanismSection'; import { useIsLockedPrivate, useShowPrivateWarning, @@ -70,7 +72,8 @@ export function PublishDialog({ title, onRename }: PublishDialogProps) { const publish = () => { if (!canPublish) return; - if (isNewProposal && selectedNonprofit) setConfirmingNonprofit(true); + const isAwardApplication = isAwardMechanism(watch('selectedGrant')?.fundingMechanism); + if (isNewProposal && selectedNonprofit && !isAwardApplication) setConfirmingNonprofit(true); else void confirmPublish(title); }; @@ -143,6 +146,7 @@ export function PublishDialog({ title, onRename }: PublishDialogProps) { {isNewProposal && } + {isRfp && } {isRfp && !isUpdate && } {showGuidelines && ( @@ -316,7 +320,8 @@ function ChoiceBox({ /** * A new proposal's settings: who can see it, how long it stays open, and a - * nonprofit to receive the funds. Two rules come with the first, and the + * nonprofit to receive the funds. An award has no nonprofit: the funder pays + * for results. Two rules come with the first, and the * first of them holds back the dialog's Publish: a private proposal needs an * RFP, and an RFP that only takes private proposals makes the proposal private. */ @@ -324,6 +329,7 @@ function ProposalSettings() { const { watch, setValue } = useFormContext(); const isPublic = watch('isPublic'); const days = watch('fundraiseEndDays') ?? DEFAULT_FUNDRAISE_END_DAYS; + const isAwardApplication = isAwardMechanism(watch('selectedGrant')?.fundingMechanism); const isLockedPrivate = useIsLockedPrivate(); const privateNeedsRfp = useShowPrivateWarning(); // The RFP may have been chosen since the form loaded, which is the only @@ -361,7 +367,7 @@ function ProposalSettings() { /> - + {!isAwardApplication && } > ); } diff --git a/components/AIMode/document/masthead/mastheadWidgets.ts b/components/AIMode/document/masthead/mastheadWidgets.ts index a598892a5..4247e8949 100644 --- a/components/AIMode/document/masthead/mastheadWidgets.ts +++ b/components/AIMode/document/masthead/mastheadWidgets.ts @@ -7,6 +7,7 @@ import { Users, type LucideIcon, } from 'lucide-react'; +import { isAwardMechanism } from '@/components/Funding/lib/fundingMechanism'; import type { PublishingFieldKey } from '@/components/Notebook/PublishingForm/completion'; import type { ArticleType, @@ -83,6 +84,8 @@ const FUNDING_GOAL: MastheadWidgetConfig = { hint: 'How much you are raising for this work. It cannot be changed once the fundraise is open.', // A fundraise that is open keeps the goal it opened with. lockedWhen: (values) => Boolean(values.workId), + // An award application has no goal. The server opens its escrow at 0. + hiddenWhen: (values) => isAwardMechanism(values.selectedGrant?.fundingMechanism), }; const APPLYING_TO: MastheadWidgetConfig = { diff --git a/components/AIMode/useAIModeChat.ts b/components/AIMode/useAIModeChat.ts index f14410f85..22b8d2edc 100644 --- a/components/AIMode/useAIModeChat.ts +++ b/components/AIMode/useAIModeChat.ts @@ -21,6 +21,7 @@ import { useUser } from '@/contexts/UserContext'; import { useAgentChatList, type UseAgentChatListResult } from '@/hooks/useAgentChat'; import { useChatSession, type ChatSession } from '@/hooks/useChatSession'; import { getChatTransport } from '@/services/chatTransport'; +import { GrantService } from '@/services/grant.service'; import type { AgentChat, ChatNoteRef } from '@/types/agentChat'; import type { SelectedGrantDetails } from '@/types/grant'; import type { Note } from '@/types/note'; @@ -118,6 +119,25 @@ export function useAIModeChat(): AIModeChatState { const intent = urlIntent ?? rememberedIntent; const [selectedGrant, setSelectedGrant] = useState(null); + // An RFP page's Apply leads here with the RFP named (`?rfp=`): it starts + // out picked, as if chosen on this screen, and can still be changed. The + // RFP is read signed in, so a visitor who logs in here gets it then. + const { user, isLoading: userLoading } = useUser(); + const signedInUserId = user?.id ?? null; + const urlGrantId = target.kind === 'new' ? (target.grantId ?? null) : null; + useEffect(() => { + if (urlGrantId == null || signedInUserId == null) return; + let cancelled = false; + GrantService.getSelectedGrant(urlGrantId) + .then((grant) => { + if (!cancelled) setSelectedGrant(grant); + }) + .catch((error) => console.error('Error loading the RFP to apply to:', error)); + return () => { + cancelled = true; + }; + }, [urlGrantId, signedInUserId]); + // ---- the first message of a new draft ---- // It creates the document, then a chat on it, then goes out there. A note // created by an attempt whose chat then failed is kept here, so a retry @@ -307,7 +327,6 @@ export function useAIModeChat(): AIModeChatState { // ---- a signed-out visitor ---- // What would start a draft asks them to log in, and keeps what they typed // (or the suggestion they picked) in the box for when they are back. - const { user, isLoading: userLoading } = useUser(); const { showAuthModal } = useAuthModalContext(); const signedOut = user == null && !userLoading; const { setDraft } = session; diff --git a/components/AIMode/workspaceUrl.ts b/components/AIMode/workspaceUrl.ts index 91da712da..df28b719b 100644 --- a/components/AIMode/workspaceUrl.ts +++ b/components/AIMode/workspaceUrl.ts @@ -6,11 +6,13 @@ export const WORKSPACE_PATH = '/workspace'; * `note=` opens a document, with `chat` naming the chat on it: an id, or * `new` for a chat not yet started; absent, its most recent chat. * `new=rfp|proposal` is the screen that starts a new draft for one side of - * the money. Anything else is that screen too. + * the money, with `rfp=` naming the RFP a new proposal answers. + * Anything else is that screen too. */ const CHAT_PARAM = 'chat'; const NOTE_PARAM = 'note'; const NEW_PARAM = 'new'; +const RFP_PARAM = 'rfp'; const NEW_CHAT = 'new'; /** @@ -29,6 +31,8 @@ export type WorkspaceTarget = readonly kind: 'new'; /** What the door it was opened through will draft: an RFP or a proposal. */ readonly intent?: FundingIntent; + /** The RFP a new proposal answers, picked before the workspace opened. */ + readonly grantId?: string; } | { readonly kind: 'document'; @@ -36,9 +40,15 @@ export type WorkspaceTarget = readonly chat: DocumentChat; }; -/** The screen that starts a new draft, for one side of the money or the one last used. */ -export const newDraftTarget = (intent?: FundingIntent): WorkspaceTarget => - intent ? { kind: 'new', intent } : { kind: 'new' }; +/** + * The screen that starts a new draft, for one side of the money or the one + * last used, and for a proposal, optionally the RFP it answers. + */ +export const newDraftTarget = (intent?: FundingIntent, grantId?: string): WorkspaceTarget => ({ + kind: 'new', + ...(intent ? { intent } : {}), + ...(grantId ? { grantId } : {}), +}); /** A document, on its most recent chat unless another is named. */ export const documentTarget = (noteId: number, chat: DocumentChat = 'latest'): WorkspaceTarget => ({ @@ -60,7 +70,13 @@ function parseId(raw: string | null): number | null { export function readWorkspaceTarget(params: Params): WorkspaceTarget { const noteId = parseId(params.get(NOTE_PARAM)); - if (noteId == null) return newDraftTarget(INTENT_BY_PARAM[params.get(NEW_PARAM) ?? '']); + if (noteId == null) { + const grantId = parseId(params.get(RFP_PARAM)); + return newDraftTarget( + INTENT_BY_PARAM[params.get(NEW_PARAM) ?? ''], + grantId == null ? undefined : String(grantId) + ); + } const chat = params.get(CHAT_PARAM); return documentTarget(noteId, chat === NEW_CHAT ? 'new' : (parseId(chat) ?? 'latest')); } @@ -70,8 +86,9 @@ export function workspaceHref(target: WorkspaceTarget): string { if (target.kind === 'document') { params.set(NOTE_PARAM, String(target.noteId)); if (target.chat !== 'latest') params.set(CHAT_PARAM, String(target.chat)); - } else if (target.intent) { - params.set(NEW_PARAM, PARAM_BY_INTENT[target.intent]); + } else { + if (target.intent) params.set(NEW_PARAM, PARAM_BY_INTENT[target.intent]); + if (target.grantId) params.set(RFP_PARAM, target.grantId); } const query = params.toString(); return query ? `${WORKSPACE_PATH}?${query}` : WORKSPACE_PATH; diff --git a/components/Activity/lib/activityDisplay.utils.ts b/components/Activity/lib/activityDisplay.utils.ts index 299923282..5b183e38b 100644 --- a/components/Activity/lib/activityDisplay.utils.ts +++ b/components/Activity/lib/activityDisplay.utils.ts @@ -1,3 +1,4 @@ +import { isAwardMechanism } from '@/components/Funding/lib/fundingMechanism'; import { buildWorkUrl } from '@/utils/url'; import { isFoundationUser } from '@/components/Bounty/lib/bountyUtil'; import type { CurrencyAmount } from '@/utils/currency'; @@ -148,8 +149,11 @@ function getDefaultActivityMessage(entry: FeedEntry): ActivityHeaderMessage { } if (entry.contentType === 'USDFUNDRAISECONTRIBUTION' || entry.contentType === 'PURCHASE') { - return isFundingPoolContribution(entry) - ? { actor, verb: 'contributed', suffix: ' to the funding pool' } + if (isFundingPoolContribution(entry)) { + return { actor, verb: 'contributed', suffix: ' to the funding pool' }; + } + return isAwardMechanism(entry.relatedWork?.fundingMechanism) + ? { actor, verb: 'awarded this proposal with' } : { actor, verb: 'funded this proposal with' }; } @@ -570,10 +574,14 @@ export function getActivityActionLine( return { label: 'Contributed to a bounty' }; } if (entry.contentType === 'USDFUNDRAISECONTRIBUTION' || entry.contentType === 'PURCHASE') { + const awardedProposal = + !isFundingPoolContribution(entry) && isAwardMechanism(entry.relatedWork?.fundingMechanism); return { label: isFundingPoolContribution(entry) ? 'Added to an RFP’s funding pool' - : 'Funded a proposal', + : awardedProposal + ? 'Awarded a proposal' + : 'Funded a proposal', value: contributionValue(getContribution(entry)), }; } @@ -582,7 +590,14 @@ export function getActivityActionLine( return { label: 'Opened an RFP', value: amount ? { kind: 'grant', amount } : undefined }; } if (entry.contentType === 'PREREGISTRATION') { - return { label: 'Submitted a proposal for funding' }; + const mechanism = + entry.relatedWork?.fundingMechanism ?? + (entry.associatedGrants?.some((grant) => isAwardMechanism(grant.fundingMechanism)) + ? 'AWARD' + : undefined); + if (mechanism === 'AWARD') return { label: 'Submitted a proposal for an award' }; + if (mechanism === 'FUNDING') return { label: 'Submitted a proposal for funding' }; + return { label: 'Submitted a proposal' }; } if (entry.contentType === 'PAPER') { return { label: 'Published a preprint' }; diff --git a/components/Activity/lib/activityWork.utils.ts b/components/Activity/lib/activityWork.utils.ts index 7ab8e1ee3..4da48c951 100644 --- a/components/Activity/lib/activityWork.utils.ts +++ b/components/Activity/lib/activityWork.utils.ts @@ -12,6 +12,7 @@ import { } from './activityDisplay.utils'; import { formatCurrency } from '@/utils/currency'; import { toOptionalNumber } from '@/utils/number'; +import { awardRaisedLabel, isAwardMechanism } from '@/components/Funding/lib/fundingMechanism'; import { getGrantBadgeAmount, type FundingPool } from '@/types/grant'; import type { ActivityAction, @@ -151,6 +152,21 @@ function formatAmount( }); } +export function getAwardCardStat( + fundraise: { status?: string; amountRaised?: { usd?: number; rsc?: number } }, + showUSD: boolean, + exchangeRate: number +): WorkCardStat | null { + const label = awardRaisedLabel(fundraise); + if (!label) return null; + const raised = (showUSD ? fundraise.amountRaised?.usd : fundraise.amountRaised?.rsc) ?? 0; + return { + label, + value: formatAmount(raised, showUSD, exchangeRate, true), + accent: true, + }; +} + function resolveReviewScore(entry: FeedEntry): number | null { const entryScore = entry.metrics?.reviewScore; return entryScore && entryScore > 0 ? entryScore : null; @@ -206,15 +222,43 @@ function buildBasePresentation( }; } +/** The entry is a proposal to an award RFP: the funder awards it, so it asks for nothing. */ +export function isAwardFeedEntry(entry: FeedEntry): boolean { + if (isAwardMechanism(entry.relatedWork?.fundingMechanism)) return true; + return entry.associatedGrants?.some((grant) => isAwardMechanism(grant.fundingMechanism)) ?? false; +} + +function isAwardFundraise( + fundraise: NonNullable, + entry?: FeedEntry +): boolean { + if (entry != null && isAwardFeedEntry(entry)) return true; + const goalUsd = fundraise.goalAmount?.usd; + const goalRsc = fundraise.goalAmount?.rsc; + if (goalUsd == null && goalRsc == null) return false; + return (goalUsd ?? 0) <= 0 && (goalRsc ?? 0) <= 0; +} + function presentFundraise( base: WorkCardPresentation, fundraise: NonNullable, showUSD: boolean, - exchangeRate: number + exchangeRate: number, + entry: FeedEntry ): WorkCardPresentation { const goalUsd = fundraise.goalAmount?.usd ?? 0; const goalRsc = fundraise.goalAmount?.rsc ?? 0; const raisedUsd = fundraise.amountRaised?.usd ?? 0; + + if (isAwardFundraise(fundraise, entry)) { + const stat = getAwardCardStat(fundraise, showUSD, exchangeRate); + return { + ...base, + stats: stat ? [stat] : base.stats, + progress: undefined, + }; + } + const goalAmount = showUSD ? goalUsd : goalRsc; return { @@ -307,7 +351,7 @@ export function getWorkCardPresentation( const base = buildBasePresentation(entry, work, slot, { isReview }); if (slot === 'fundraise' && work.fundraise) { - return presentFundraise(base, work.fundraise, showUSD, exchangeRate); + return presentFundraise(base, work.fundraise, showUSD, exchangeRate, entry); } if (slot === 'grant' && work.grant) { return presentGrant(base, work.grant, showUSD, exchangeRate); @@ -393,6 +437,10 @@ function getSlotStat( ): WorkCoverStat | null { if (slot === 'fundraise' && work.fundraise) { const { goalAmount } = work.fundraise; + if (isAwardFundraise(work.fundraise)) { + const stat = getAwardCardStat(work.fundraise, showUSD, exchangeRate); + return stat ? { label: stat.label, value: stat.value } : null; + } const goal = (showUSD ? goalAmount?.usd : goalAmount?.rsc) ?? 0; return goal > 0 ? { label: 'Raising', value: formatAmount(goal, showUSD, exchangeRate, true) } @@ -538,7 +586,7 @@ export function getActivityCta(entry: FeedEntry, work: ActivityWork): ActivityCt : link('View RFP'); } - if (openFundraise) { + if (openFundraise && !isAwardFundraise(openFundraise, entry)) { return { kind: 'fund-proposal', label: 'Fund proposal', diff --git a/components/Feed/FeedItemFundingBadges.tsx b/components/Feed/FeedItemFundingBadges.tsx index d67cdea14..8d171fba0 100644 --- a/components/Feed/FeedItemFundingBadges.tsx +++ b/components/Feed/FeedItemFundingBadges.tsx @@ -14,6 +14,8 @@ interface FeedItemFundingBadgesProps { href: string; isNonprofit?: boolean; fundraiseStatus?: FundraiseStatus; + /** Replaces "Funded" on a completed fundraise. Award RFPs say "Awarded". */ + completedLabel?: string; variant?: 'default' | 'overlay'; } @@ -26,6 +28,7 @@ export const FeedItemFundingBadges: FC = ({ href, isNonprofit, fundraiseStatus, + completedLabel = 'Funded', variant = 'default', }) => { const hasReviewScore = reviewScore !== undefined && reviewScore > 0; @@ -66,7 +69,7 @@ export const FeedItemFundingBadges: FC = ({ }`} > - Funded + {completedLabel} ) : ( isNonprofit && ( diff --git a/components/Feed/items/FeedItemGrantWithApplicants.tsx b/components/Feed/items/FeedItemGrantWithApplicants.tsx index e0de09367..c472d3abf 100644 --- a/components/Feed/items/FeedItemGrantWithApplicants.tsx +++ b/components/Feed/items/FeedItemGrantWithApplicants.tsx @@ -17,6 +17,7 @@ import { useExchangeRate } from '@/contexts/ExchangeRateContext'; import { formatCurrency } from '@/utils/currency'; import { Application } from '@/types/funding'; import { getGrantBadgeAmount } from '@/types/grant'; +import { isAwardMechanism } from '@/components/Funding/lib/fundingMechanism'; interface FeedItemGrantWithApplicantsProps { entry: FeedEntry; @@ -36,9 +37,17 @@ interface ProposalRowProps { showUSD: boolean; exchangeRate: number; isLast: boolean; + /** An award RFP's proposals ask for nothing: the funder sets the award. */ + showAsk: boolean; } -const ProposalRow: FC = ({ application, showUSD, exchangeRate, isLast }) => { +const ProposalRow: FC = ({ + application, + showUSD, + exchangeRate, + isLast, + showAsk, +}) => { const { profile, fundraise: fundraiseRaw } = application; const fundraise = fundraiseRaw!; @@ -63,18 +72,21 @@ const ProposalRow: FC = ({ application, showUSD, exchangeRate, {/* Ask amount — no left padding on the row, so the value sits equidistant between the card edge and the divider */} - - - {formatCompact(askAmount, showUSD, exchangeRate)} + {showAsk && ( + + + {formatCompact(askAmount, showUSD, exchangeRate)} + + ask - ask - + )} {/* Title + author + org + score */} @@ -159,6 +171,7 @@ export const FeedItemGrantWithApplicants: FC = const hasProposals = allProposals.length > 0; const isClosed = grant.status === 'CLOSED' || grant.isExpired || !grant.isActive; + const isAward = isAwardMechanism(grant.fundingMechanism); const totalRequested = allProposals.reduce((sum, a) => { const ask = showUSD ? (a.fundraise?.goalAmount.usd ?? 0) : (a.fundraise?.goalAmount.rsc ?? 0); @@ -251,6 +264,7 @@ export const FeedItemGrantWithApplicants: FC = application={application} showUSD={showUSD} exchangeRate={exchangeRate} + showAsk={!isAward} isLast={ i === shown.length - 1 && (expanded || allProposals.length <= VISIBLE_PROPOSALS) } @@ -298,7 +312,11 @@ export const FeedItemGrantWithApplicants: FC = {hasProposals - ? `${allProposals.length} proposal${allProposals.length !== 1 ? 's' : ''} · ${formatCompact(Math.round(totalRequested), showUSD, exchangeRate)} total requested` + ? `${allProposals.length} proposal${allProposals.length !== 1 ? 's' : ''}${ + isAward + ? '' + : ` · ${formatCompact(Math.round(totalRequested), showUSD, exchangeRate)} total requested` + }` : 'Rolling funding · Apply anytime'} diff --git a/components/Funding/AwardPlacements.tsx b/components/Funding/AwardPlacements.tsx new file mode 100644 index 000000000..28e76aae2 --- /dev/null +++ b/components/Funding/AwardPlacements.tsx @@ -0,0 +1,131 @@ +'use client'; + +import Link from 'next/link'; +import { Avatar } from '@/components/ui/Avatar'; +import { SidebarCard } from '@/components/ui/SidebarCard'; +import { Tooltip } from '@/components/ui/Tooltip'; +import { formatFundingAmount } from '@/components/Funding/lib/fundingAmounts'; +import { + getAwardPhase, + isAwardMechanism, + type AwardPhase, +} from '@/components/Funding/lib/fundingMechanism'; +import { useGrantPage } from '@/components/Funding/GrantPageContext'; +import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; +import type { Application } from '@/types/funding'; +import { buildWorkUrl, generateSlug } from '@/utils/url'; + +const MAX_PLACES = 5; + +function raisedUsd(application: Application): number { + return application.fundraise?.amountRaised.usd ?? 0; +} + +const SET_ASIDE_NOTE = + 'Set aside. Paid out once this proposal has been open for a week, on the next daily payout.'; + +function AwardAmount({ amount, phase }: { amount: string; phase: AwardPhase }) { + const figure = ( + + {phase === 'set-aside' ? ( + + {amount} + + ) : ( + amount + )} + + ); + if (phase !== 'set-aside') return figure; + return ( + + {figure} + + ); +} + +function hasAwardMoney(application: Application): boolean { + return getAwardPhase(application.fundraise) !== 'none'; +} + +function proposalHref(application: Application): string | null { + if (application.preregistrationPostId == null) return null; + const title = application.fundraise?.title; + return buildWorkUrl({ + id: application.preregistrationPostId, + contentType: 'preregistration', + slug: title ? generateSlug(title) : undefined, + }); +} + +/** + * The award RFP's top five, by what each proposal has received. Includes money + * still set aside. There is no separate awardees list: this is the grant's + * own applications. + */ +export function AwardPlacements() { + const { fundingMechanism, applications } = useGrantPage(); + const { showUSD } = useCurrencyPreference(); + + if (!isAwardMechanism(fundingMechanism)) return null; + + const placed = applications + .filter(hasAwardMoney) + .sort( + (a, b) => + raisedUsd(b) - raisedUsd(a) || + (b.fundraise?.amountRaised.rsc ?? 0) - (a.fundraise?.amountRaised.rsc ?? 0) + ) + .slice(0, MAX_PLACES); + + if (placed.length === 0) return null; + + return ( + + + {placed.map((application, index) => { + const href = proposalHref(application); + const title = application.fundraise?.title || 'Proposal'; + const phase = getAwardPhase(application.fundraise); + const amount = application.fundraise + ? formatFundingAmount(application.fundraise.amountRaised, showUSD) + : ''; + const row = ( + <> + + {index + 1} + + + + + {application.profile.fullName} + + {title} + + + > + ); + const className = 'flex items-center gap-2.5 rounded-md py-2'; + + return ( + + {href ? ( + + {row} + + ) : ( + {row} + )} + + ); + })} + + + ); +} diff --git a/components/Funding/FundButton.tsx b/components/Funding/FundButton.tsx index 3daceb7b5..a600ecdc9 100644 --- a/components/Funding/FundButton.tsx +++ b/components/Funding/FundButton.tsx @@ -1,7 +1,7 @@ 'use client'; import type { ReactNode } from 'react'; -import { Coins } from 'lucide-react'; +import { Coins, type LucideIcon } from 'lucide-react'; import { Button, type ButtonProps } from '@/components/ui/Button'; import { useFundPanel } from '@/components/Funding/FundPanelContext'; import { cn } from '@/utils/styles'; @@ -11,11 +11,19 @@ interface FundButtonProps { children: ReactNode; size?: ButtonProps['size']; className?: string; + /** Coins for a fundraise. An award passes a trophy. */ + icon?: LucideIcon; 'data-testid'?: string; } /** Opens the page's Fund panel: the coins and a label, in the brand blue. */ -export function FundButton({ children, size, className, ...props }: FundButtonProps) { +export function FundButton({ + children, + size, + className, + icon: Icon = Coins, + ...props +}: FundButtonProps) { const { openFundPanel } = useFundPanel(); return ( - + {children} ); diff --git a/components/Funding/GrantPageContext.tsx b/components/Funding/GrantPageContext.tsx index f7b41a1f9..35ae780ac 100644 --- a/components/Funding/GrantPageContext.tsx +++ b/components/Funding/GrantPageContext.tsx @@ -16,6 +16,7 @@ import { isGrantActive, type FundingPool, type FundingPoolAmount, + type FundingMechanism, type Grant, type GrantApplicationVisibility, } from '@/types/grant'; @@ -37,6 +38,7 @@ interface GrantPageContextValue { /** The organization's own account, when one of the RFP's contacts is it: its face for the byline and cover. */ organizationProfile?: AuthorProfile; applicationVisibility?: GrantApplicationVisibility; + fundingMechanism: FundingMechanism; /** The funder's own award, before backers add to the pool. */ grantAmount: FundingPoolAmount | null; isPending: boolean; @@ -139,6 +141,7 @@ export function GrantPageProvider({ organization: grant?.organization, organizationProfile: grant ? findOrganizationProfile(grant) : undefined, applicationVisibility: grant?.applicationVisibility, + fundingMechanism: grant?.fundingMechanism ?? 'FUNDING', grantAmount: grant ? { usd: grant.amount.usd ?? 0, rsc: grant.amount.rsc ?? 0 } : null, isPending: grant?.status === 'PENDING', isActive, diff --git a/components/Funding/GrantSidebar.tsx b/components/Funding/GrantSidebar.tsx index 142bc1a33..144b90fba 100644 --- a/components/Funding/GrantSidebar.tsx +++ b/components/Funding/GrantSidebar.tsx @@ -1,10 +1,11 @@ 'use client'; +import { AwardPlacements } from '@/components/Funding/AwardPlacements'; import { GrantPoolCard } from '@/components/Funding/GrantPoolCard'; import { GrantActivityCard } from '@/components/Funding/GrantActivityCard'; import { ID } from '@/types/root'; -/** The RFP page's right sidebar: the pool card over the RFP's activity. */ +/** The RFP page's right sidebar: the pool card, awardees when it is an award, then activity. */ export function GrantSidebar({ currentDocumentId, }: { @@ -14,6 +15,7 @@ export function GrantSidebar({ return ( + ); diff --git a/components/Funding/PaymentStep.tsx b/components/Funding/PaymentStep.tsx index c72278f68..ea82c5dcd 100644 --- a/components/Funding/PaymentStep.tsx +++ b/components/Funding/PaymentStep.tsx @@ -23,6 +23,7 @@ import { } from './lib'; import type { StripePaymentContext } from './CreditCardForm'; import { + AWARD_PAYMENT_METHODS, PAYMENT_FEES, PLATFORM_FEE_PERCENTAGE_RSC, PAYMENT_PROCESSING_FEE, @@ -72,6 +73,11 @@ interface PaymentStepProps { * buttons included. */ canPay?: boolean; + /** + * Award proposals. RSC, the RFP pool, and Stripe (card, Apple Pay, Google + * Pay) stay. Donor-advised funds and PayPal stay hidden. + */ + isAward?: boolean; /** * Pins the confirm button, with any error or balance alert just above it, to * the bottom of the drawer or side panel this step scrolls in. @@ -100,22 +106,30 @@ export function PaymentStep({ onEndaomentPaymentConfirm, onStripeReady, canPay = true, + isAward = false, pinActionIn, }: PaymentStepProps) { const fundingPoolHoldingRsc = allocateFromPool?.fundingPool.amountHolding.rsc ?? null; - const defaultPaymentMethod = useMemo( - () => - getDefaultPaymentMethod( - rscBalance, - fundingCreditsBalance, - amountInRsc, - PLATFORM_FEE_PERCENTAGE_RSC, - walletAvailability, - fundingPoolHoldingRsc - ), - [rscBalance, fundingCreditsBalance, amountInRsc, walletAvailability, fundingPoolHoldingRsc] - ); + const defaultPaymentMethod = useMemo(() => { + const method = getDefaultPaymentMethod( + rscBalance, + fundingCreditsBalance, + amountInRsc, + PLATFORM_FEE_PERCENTAGE_RSC, + walletAvailability, + fundingPoolHoldingRsc + ); + if (!isAward || (method != null && AWARD_PAYMENT_METHODS.includes(method))) return method; + return 'rsc'; + }, [ + rscBalance, + fundingCreditsBalance, + amountInRsc, + walletAvailability, + fundingPoolHoldingRsc, + isAward, + ]); const [selectedMethod, setSelectedMethod] = useState( defaultPaymentMethod @@ -337,6 +351,7 @@ export function PaymentStep({ hideButton walletAvailability={walletAvailability} hasNonprofit={hasNonprofit} + isAward={isAward} /> {/* Receipt-style line items */} @@ -347,7 +362,11 @@ export function PaymentStep({ {/* Funding contribution (amount going to fundraise) */} - {isFundingPoolMethod ? 'Allocation from pool' : 'Funding contribution'} + {isFundingPoolMethod + ? 'Allocation from pool' + : isAward + ? 'Award' + : 'Funding contribution'} {formatUsd(amountInUsd)} diff --git a/components/Funding/PaymentWidget.tsx b/components/Funding/PaymentWidget.tsx index f74f55f6c..5c1791823 100644 --- a/components/Funding/PaymentWidget.tsx +++ b/components/Funding/PaymentWidget.tsx @@ -11,6 +11,7 @@ import Image from 'next/image'; import { usePaymentMethod, usePaymentCalculations, + AWARD_PAYMENT_METHODS, HIDDEN_PAYMENT_METHODS, type AllocateFromFundingPoolOption, type PaymentMethodType, @@ -69,6 +70,11 @@ interface PaymentWidgetProps { walletAvailability: WalletAvailability; /** Whether the fundraise has a non-profit org attached */ hasNonprofit?: boolean; + /** + * Award proposals. RSC, the RFP pool, and Stripe (card, Apple Pay, Google + * Pay) stay. A Stripe charge is an RSC purchase that lands in the escrow. + */ + isAward?: boolean; } /** @@ -94,6 +100,7 @@ export function PaymentWidget({ hideButton = false, walletAvailability, hasNonprofit = false, + isAward = false, }: PaymentWidgetProps) { const selectedMethod = selectedPaymentMethod ?? null; const { isExpanded, toggleExpanded, selectMethod } = usePaymentMethod({ @@ -229,6 +236,7 @@ export function PaymentWidget({ // options that may not be available const visiblePaymentOptions = paymentOptions.filter((option) => { if (HIDDEN_PAYMENT_METHODS.includes(option.id)) return false; + if (isAward && !AWARD_PAYMENT_METHODS.includes(option.id)) return false; if (option.id === 'funding_pool') { return !!allocateFromPool && fundingPoolHoldingRsc > 0; } diff --git a/components/Funding/ProposalWorkCard.tsx b/components/Funding/ProposalWorkCard.tsx index 15767338b..c2f04762e 100644 --- a/components/Funding/ProposalWorkCard.tsx +++ b/components/Funding/ProposalWorkCard.tsx @@ -2,7 +2,7 @@ import { FC, useState, useCallback } from 'react'; import { useRouter } from 'next/navigation'; -import { Coins } from 'lucide-react'; +import { Coins, Trophy } from 'lucide-react'; import { ActivityTimestamp, ActivityWorkActions, @@ -11,14 +11,22 @@ import { } from '@/components/Activity'; import { getActivityWork, + getAwardCardStat, getWorkCardPresentation, + isAwardFeedEntry, type WorkCardStat, } from '@/components/Activity/lib/activityWork.utils'; import { FeedItemFundingBadges } from '@/components/Feed/FeedItemFundingBadges'; import { AllocateFundingPoolModal } from '@/components/modals/AllocateFundingPoolModal'; +import { ContributeToFundraiseModal } from '@/components/modals/ContributeToFundraiseModal'; import { Button } from '@/components/ui/Button'; import { useGrantAllocateContext } from '@/components/Funding/GrantPageContext'; import { canManageGrantPool } from '@/components/Funding/lib/canManageGrantPool'; +import { + awardActionLabel, + canAwardProposal, + isAwardMechanism, +} from '@/components/Funding/lib/fundingMechanism'; import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; import { useExchangeRate } from '@/contexts/ExchangeRateContext'; import { useFundraises } from '@/contexts/FundraiseContext'; @@ -76,6 +84,7 @@ export const ProposalWorkCard: FC = ({ entry, onNavigate const grantAllocate = useGrantAllocateContext(); const [isAllocateOpen, setIsAllocateOpen] = useState(false); + const [isAwardOpen, setIsAwardOpen] = useState(false); const work = getActivityWork(entry); @@ -96,6 +105,15 @@ export const ProposalWorkCard: FC = ({ entry, onNavigate fundingPool?.status === 'OPEN' && work?.fundraise?.status === 'OPEN' && applicationId != null; + const canAward = + isGrantScoped && + canAwardProposal({ + userId: user?.id, + grantCreatorUserId: grantAllocate?.grantCreatedByUserId, + isAward: isAwardMechanism(grantAllocate?.fundingMechanism), + grantOpen: grantAllocate?.isActive === true, + fundraise: work?.fundraise, + }); const handleAllocateSuccess = useCallback( (updatedPool: FundingPool) => { @@ -116,16 +134,23 @@ export const ProposalWorkCard: FC = ({ entry, onNavigate // from the fundraise rather than inferred from `activityAction` the way an // activity row has to. const base = getWorkCardPresentation(entry, work, { showUSD, exchangeRate }); - const presentation = fundraise - ? { - ...base, - stats: buildFundraiseStats(fundraise, showUSD, exchangeRate), - progress: - fundraise.goalAmount.usd > 0 - ? fundraise.amountRaised.usd / fundraise.goalAmount.usd - : undefined, - } - : base; + // On an award RFP's page the RFP says so; elsewhere (`/fund/proposals`) the + // entry names the RFP it answers. Either way it shows no ask. + const isAward = isAwardMechanism(grantAllocate?.fundingMechanism) || isAwardFeedEntry(entry); + const awardStat = + isAward && fundraise ? getAwardCardStat(fundraise, showUSD, exchangeRate) : null; + const presentation = isAward + ? { ...base, stats: awardStat ? [awardStat] : base.stats, progress: undefined } + : fundraise + ? { + ...base, + stats: buildFundraiseStats(fundraise, showUSD, exchangeRate), + progress: + fundraise.goalAmount.usd > 0 + ? fundraise.amountRaised.usd / fundraise.goalAmount.usd + : undefined, + } + : base; const handleNavigate = () => { updateLastClickedEntryId(entryId); @@ -140,6 +165,7 @@ export const ProposalWorkCard: FC = ({ entry, onNavigate href={work.href} isNonprofit={isNonprofit} fundraiseStatus={fundraise?.status} + completedLabel={isAward ? 'Awarded' : 'Funded'} variant="overlay" /> @@ -151,6 +177,22 @@ export const ProposalWorkCard: FC = ({ entry, onNavigate + {canAward && fundraise && ( + { + e.preventDefault(); + e.stopPropagation(); + setIsAwardOpen(true); + }} + > + + {awardActionLabel(fundraise)} + + )} {canAllocate && ( = ({ entry, onNavigate + {canAward && fundraise && ( + setIsAwardOpen(false)} + fundraise={fundraise} + proposalTitle={work.title} + isAward + variant="panel" + successText="The award has been added to this proposal." + onContributeSuccess={() => { + void refreshProposals(); + router.refresh(); + }} + /> + )} + {canAllocate && fundingPool && applicationId != null && ( 0 || (raised?.rsc ?? 0) > 0; + if (!hasAmount) return 'none'; + return fundraise?.status === 'COMPLETED' ? 'awarded' : 'set-aside'; +} + +/** Award the first time. Award more once this proposal already has money. */ +export function awardActionLabel( + fundraise?: { status?: string; amountRaised?: { usd?: number; rsc?: number } } | null +): 'Award' | 'Award more' { + return getAwardPhase(fundraise) === 'none' ? 'Award' : 'Award more'; +} + +/** The card label for money on an award proposal, once any has been committed. */ +export function awardRaisedLabel( + fundraise?: { status?: string; amountRaised?: { usd?: number; rsc?: number } } | null +): 'Awarded' | 'Set aside' | null { + const phase = getAwardPhase(fundraise); + if (phase === 'awarded') return 'Awarded'; + if (phase === 'set-aside') return 'Set aside'; + return null; +} + +/** + * The Award button: an award RFP that is still open, a fundraise still taking + * money, and the grant creator. Moderators allocate from the pool instead. + */ +export function canAwardProposal(input: { + userId: ID | null | undefined; + grantCreatorUserId: ID | null | undefined; + isAward: boolean; + grantOpen: boolean; + fundraise?: Fundraise | null; +}): boolean { + return ( + input.isAward && + input.grantOpen && + isGrantCreator(input.userId, input.grantCreatorUserId) && + !!input.fundraise && + isFundraiseActive(input.fundraise) + ); +} diff --git a/components/Funding/lib/index.ts b/components/Funding/lib/index.ts index 960733042..23d59acd0 100644 --- a/components/Funding/lib/index.ts +++ b/components/Funding/lib/index.ts @@ -1,5 +1,5 @@ // Constants -export { HIDDEN_PAYMENT_METHODS, type PaymentMethodType } from './constants'; +export { AWARD_PAYMENT_METHODS, HIDDEN_PAYMENT_METHODS, type PaymentMethodType } from './constants'; // Hooks export { usePaymentMethod } from './usePaymentMethod'; diff --git a/components/Notebook/PublishingForm/components/FundingSection.tsx b/components/Notebook/PublishingForm/components/FundingSection.tsx index 2e6a538d9..3d0832f7f 100644 --- a/components/Notebook/PublishingForm/components/FundingSection.tsx +++ b/components/Notebook/PublishingForm/components/FundingSection.tsx @@ -10,6 +10,7 @@ import type { Note } from '@/types/note'; import { NonprofitSearchSection } from '@/components/Nonprofit'; import { useNonprofitByFundraiseId } from '@/hooks/useNonprofitByFundraiseId'; import { useNonprofitSearch } from '@/hooks/useNonprofitSearch'; +import { isAwardMechanism } from '@/components/Funding/lib/fundingMechanism'; import { FundingGoalField } from './FundingGoalField'; import { FundingOpportunityField } from './FundingOpportunityField'; @@ -28,6 +29,8 @@ export function FundingSection({ note }: Readonly) { const [previewUrl, setPreviewUrl] = useState(null); const [isEditingNonprofit, setIsEditingNonprofit] = useState(false); + const selectedGrant = watch('selectedGrant'); + const isAwardApplication = isAwardMechanism(selectedGrant?.fundingMechanism); const fundraise = note.post?.fundraise; const { nonprofit, @@ -101,8 +104,8 @@ export function FundingSection({ note }: Readonly) { - {fundraise ? ( - <> + {!isAwardApplication && + (fundraise ? ( {isLoadingNonprofit && ( Loading nonprofit information... @@ -118,16 +121,14 @@ export function FundingSection({ note }: Readonly) { /> )} - > - ) : ( - <> - - - - - - > - )} + ) : ( + <> + + + + + > + ))} {FEATURE_FLAG_NFT_REWARDS && ( <> diff --git a/components/Notebook/PublishingForm/components/GrantFundingMechanismSection.tsx b/components/Notebook/PublishingForm/components/GrantFundingMechanismSection.tsx new file mode 100644 index 000000000..091f9565e --- /dev/null +++ b/components/Notebook/PublishingForm/components/GrantFundingMechanismSection.tsx @@ -0,0 +1,100 @@ +import { useFormContext } from 'react-hook-form'; +import { Gauge, Trophy, type LucideIcon } from 'lucide-react'; +import type { FundingMechanism } from '@/types/grant'; +import { cn } from '@/utils/styles'; +import type { SectionProps } from './SectionProps'; +import type { PublishingFormData } from '../schema'; + +/** What the setting decides; shared with the workspace's publish dialog. */ +export const GRANT_FUNDING_MECHANISM_HELP = + 'Both take proposals. Funding pays before the work; an award pays for results.'; + +const OPTIONS: { + value: FundingMechanism; + label: string; + description: string; + icon: LucideIcon; +}[] = [ + { + value: 'FUNDING', + label: 'Funding', + description: 'Proposals raise money before the work, with a funding bar.', + icon: Gauge, + }, + { + value: 'AWARD', + label: 'Award', + description: 'Proposals compete for the award, paid once results are posted.', + icon: Trophy, + }, +]; + +/** + * Funding or Award, chosen once when an RFP is opened. A published RFP shows + * the choice and does not send it again. + */ +export function GrantFundingMechanismSection({ className }: SectionProps) { + const { watch, setValue } = useFormContext(); + const value = watch('fundingMechanism') ?? 'FUNDING'; + const locked = Boolean(watch('workId')); + + return ( + + Type + {GRANT_FUNDING_MECHANISM_HELP} + + {OPTIONS.map((option) => { + const selected = value === option.value; + const Icon = option.icon; + return ( + + setValue('fundingMechanism', option.value, { + shouldDirty: true, + shouldValidate: true, + }) + } + className={cn( + 'flex flex-col rounded-xl border p-3 text-left transition-colors', + 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-600/40', + selected + ? 'border-primary-600 bg-primary-50' + : 'border-gray-200 bg-white hover:border-gray-300', + locked && 'cursor-default hover:border-gray-200' + )} + > + + + + + + {selected && } + + + {option.label} + + {option.description} + + + ); + })} + + {locked && Chosen when this RFP was opened.} + + ); +} diff --git a/components/Notebook/PublishingForm/formMapping.ts b/components/Notebook/PublishingForm/formMapping.ts index 2796488b7..0ebf62097 100644 --- a/components/Notebook/PublishingForm/formMapping.ts +++ b/components/Notebook/PublishingForm/formMapping.ts @@ -31,6 +31,7 @@ export const FORM_DEFAULTS = { applicationDeadline: null, fundraiseEndDays: DEFAULT_FUNDRAISE_END_DAYS as '60', applicationVisibility: 'OPTIONAL' as const, + fundingMechanism: 'FUNDING' as const, isPublic: true, }; @@ -72,6 +73,9 @@ const populateGrantFields = (grant: any, setValue: SetValue) => { if (grant.applicationVisibility) { setValue('applicationVisibility', grant.applicationVisibility); } + if (grant.fundingMechanism) { + setValue('fundingMechanism', grant.fundingMechanism); + } }; /** Populates the form from the work this note was published as. */ @@ -129,6 +133,7 @@ export const populateFormFromNoteDetails = (note: NoteWithContent, setValue: Set if (grantSettings.applicationVisibility) { setValue('applicationVisibility', grantSettings.applicationVisibility); } + setValue('fundingMechanism', grantSettings.fundingMechanism); if (grantSettings.contacts.length > 0) { setValue( 'contacts', @@ -192,6 +197,8 @@ export const buildNoteDetailsUpdate = ( return isGrant ? { grantSettings: { applicationVisibility: values.applicationVisibility } } : null; + case 'fundingMechanism': + return isGrant ? { grantSettings: { fundingMechanism: values.fundingMechanism } } : null; case 'fundraiseEndDays': return isProposal ? { preregistrationSettings: { durationDays: Number(values.fundraiseEndDays) } } diff --git a/components/Notebook/PublishingForm/index.tsx b/components/Notebook/PublishingForm/index.tsx index 74a9bddbe..6f5b22e88 100644 --- a/components/Notebook/PublishingForm/index.tsx +++ b/components/Notebook/PublishingForm/index.tsx @@ -14,6 +14,7 @@ import { FundingSection } from './components/FundingSection'; import { GrantApplicationVisibilitySection } from './components/GrantApplicationVisibilitySection'; import { GrantDescriptionSection } from './components/GrantDescriptionSection'; import { GrantFundingAmountSection } from './components/GrantFundingAmountSection'; +import { GrantFundingMechanismSection } from './components/GrantFundingMechanismSection'; import { GrantOrganizationSection } from './components/GrantOrganizationSection'; import { PreregistrationPrivacyLockedAlert, @@ -140,6 +141,7 @@ export function PublishingForm({ )} + {articleType === 'grant' && } {articleType === 'grant' && } {articleType === 'grant' && } {articleType === 'preregistration' && } diff --git a/components/Notebook/PublishingForm/schema.ts b/components/Notebook/PublishingForm/schema.ts index 9a2c7e3cb..b9e30da36 100644 --- a/components/Notebook/PublishingForm/schema.ts +++ b/components/Notebook/PublishingForm/schema.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { isAwardMechanism } from '@/components/Funding/lib/fundingMechanism'; const optionSchema = z.object({ value: z.string(), @@ -13,7 +14,7 @@ const parseBudget = (budget?: string): number => Number.parseFloat(budget?.replaceAll(/[^0-9.]/g, '') || '0'); const validatePreregistration = (data: any, ctx: z.RefinementCtx) => { - if (parseBudget(data.budget) <= 0) { + if (!isAwardMechanism(data.selectedGrant?.fundingMechanism) && parseBudget(data.budget) <= 0) { addIssue(ctx, 'budget', 'Funding goal must be greater than 0'); } if (!data.coverImage?.file && !data.coverImage?.url) { @@ -72,6 +73,7 @@ export const publishingFormSchema = z applicationDeadline: z.date().nullable().optional(), fundraiseEndDays: z.enum(['30', '60', '90']).optional(), applicationVisibility: z.enum(['OPTIONAL', 'PRIVATE', 'PUBLIC'] as const).optional(), + fundingMechanism: z.enum(['FUNDING', 'AWARD'] as const).optional(), isPublic: z.boolean().optional(), }) .superRefine((data, ctx) => { diff --git a/components/Notebook/PublishingForm/usePublishAction.ts b/components/Notebook/PublishingForm/usePublishAction.ts index f43d337cb..24dab3137 100644 --- a/components/Notebook/PublishingForm/usePublishAction.ts +++ b/components/Notebook/PublishingForm/usePublishAction.ts @@ -15,6 +15,7 @@ import { ApiError } from '@/services/types'; import type { NoteWithContent } from '@/types/note'; import { getFieldErrorMessage } from '@/utils/form'; import { mergeRegisteredReportPrefill } from '@/utils/registeredReportPrefill'; +import { isAwardMechanism } from '@/components/Funding/lib/fundingMechanism'; import { DEFAULT_FUNDRAISE_END_DAYS, getWorkPath, mapOptionsToIds } from './formMapping'; import type { PublishingFormData } from './schema'; @@ -144,7 +145,7 @@ export function usePublishAction({ return; } - const { articleType, selectedNonprofit } = methods.getValues(); + const { articleType, selectedNonprofit, selectedGrant } = methods.getValues(); if ( articleType !== 'preregistration' && articleType !== 'changelog' && @@ -155,7 +156,9 @@ export function usePublishAction({ return; } - setPendingConfirmation(selectedNonprofit ? 'nonprofit' : 'publish'); + const isAwardApplication = + articleType === 'preregistration' && isAwardMechanism(selectedGrant?.fundingMechanism); + setPendingConfirmation(selectedNonprofit && !isAwardApplication ? 'nonprofit' : 'publish'); }, [readOnly, isNewPreprint, canPublishRegisteredReport, methods, hostConfirms]); const uploadCoverImage = useCallback( @@ -186,7 +189,9 @@ export function usePublishAction({ formData: PublishingFormData, fundraiseId: string | number | undefined ): Promise => { + const isAwardApplication = isAwardMechanism(formData.selectedGrant?.fundingMechanism); if ( + isAwardApplication || !formData.selectedNonprofit || !fundraiseId || formData.articleType !== 'preregistration' @@ -269,6 +274,8 @@ export function usePublishAction({ } const isNewProposal = formData.articleType === 'preregistration' && !formData.workId; + const isAwardApplication = + isNewProposal && isAwardMechanism(formData.selectedGrant?.fundingMechanism); const grantId = isNewProposal ? (formData.selectedGrant?.id ?? null) : null; const proposalId = note.proposalId; @@ -319,6 +326,11 @@ export function usePublishAction({ grantId, applicationVisibility: formData.articleType === 'grant' ? formData.applicationVisibility : undefined, + fundingMechanism: + formData.articleType === 'grant' && !formData.workId + ? (formData.fundingMechanism ?? 'FUNDING') + : undefined, + omitFundraiseGoal: isAwardApplication, isPublic: isNewProposal ? formData.isPublic : undefined, }, formData.workId diff --git a/components/ResearchCoin/AnimatedResearchCoin/AnimatedResearchCoin.module.css b/components/ResearchCoin/AnimatedResearchCoin/AnimatedResearchCoin.module.css index ddc1a527e..6f3007250 100644 --- a/components/ResearchCoin/AnimatedResearchCoin/AnimatedResearchCoin.module.css +++ b/components/ResearchCoin/AnimatedResearchCoin/AnimatedResearchCoin.module.css @@ -95,9 +95,7 @@ .glyph { width: 66%; height: 66%; - filter: drop-shadow( - 0 2px 0 color-mix(in srgb, var(--rc-shadow) 30%, transparent) - ); + filter: drop-shadow(0 2px 0 color-mix(in srgb, var(--rc-shadow) 30%, transparent)); } .mote { diff --git a/components/modals/ApplyToGrantPanel.tsx b/components/modals/ApplyToGrantPanel.tsx index d5438bd48..984dd6769 100644 --- a/components/modals/ApplyToGrantPanel.tsx +++ b/components/modals/ApplyToGrantPanel.tsx @@ -4,11 +4,18 @@ import { type ReactNode, useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; import { toast } from 'react-hot-toast'; import { Lock } from 'lucide-react'; +import { untitledDraftTitle } from '@/components/AIMode/copy'; +import { rememberWorkspaceView } from '@/components/AIMode/shell/workspaceView'; import { documentTarget, newDraftTarget, workspaceHref } from '@/components/AIMode/workspaceUrl'; +import { + createFundingDraft, + DOCUMENT_TYPE_BY_INTENT, +} from '@/components/Funding/createFundingDraft'; import { Button } from '@/components/ui/Button'; import { PinnedFooter } from '@/components/ui/PinnedFooter'; import { SidePanel } from '@/components/ui/SidePanel'; import { FULL_DRAWER_HEIGHT, SwipeableDrawer } from '@/components/ui/SwipeableDrawer'; +import { useFundingNotes } from '@/contexts/FundingNotesContext'; import { useOrganizationContext } from '@/contexts/OrganizationContext'; import { useUser } from '@/contexts/UserContext'; import { useIsMobile } from '@/hooks/useIsMobile'; @@ -35,7 +42,7 @@ const DraftsSkeleton = () => ( ); -/** A new proposal is drafted with AI in the workspace, or written in the editor. */ +/** A new proposal is drafted with AI in the workspace, or written there by hand. */ type NewDraftMethod = 'ai' | 'editor'; const NEW_DRAFT_OPTIONS: { method: NewDraftMethod; label: string; description: string }[] = [ @@ -47,7 +54,7 @@ const NEW_DRAFT_OPTIONS: { method: NewDraftMethod; label: string; description: s { method: 'editor', label: 'Write it yourself', - description: 'A blank proposal in the editor, with this RFP attached.', + description: 'A blank proposal in the workspace, with this RFP attached.', }, ]; @@ -101,6 +108,7 @@ export function ApplyToGrantPanel({ const [isSavingSelection, setIsSavingSelection] = useState(false); const { user } = useUser(); const { selectedOrg } = useOrganizationContext(); + const { addDraft } = useFundingNotes(); const router = useRouter(); const isMobile = useIsMobile(); @@ -116,15 +124,36 @@ export function ApplyToGrantPanel({ setNewDraftMethod(null); }; - // The workspace picks its RFP on its own start screen; it can't be handed one yet. + // The workspace's start screen opens with this RFP already picked. const handleDraftWithAI = () => { onClose(); - router.push(workspaceHref(newDraftTarget('need_funding'))); + router.push(workspaceHref(newDraftTarget('need_funding', grantId))); }; - const handleDraftInEditor = () => { - onClose(); - router.push(`/notebook?newFunding=true&selectedGrantId=${encodeURIComponent(grantId)}`); + // A blank proposal answering this RFP, opened in the workspace on the + // document alone, as its own "Start with a blank page" does. + const handleDraftInEditor = async () => { + if (!selectedOrg?.slug) return; + + setIsSavingSelection(true); + try { + const note = await createFundingDraft({ + orgSlug: selectedOrg.slug, + title: untitledDraftTitle('need_funding'), + documentType: DOCUMENT_TYPE_BY_INTENT.need_funding, + // Only its author sees it until it is published. + grouping: 'PRIVATE', + selectedGrantId: grantId, + onNoteCreated: addDraft, + }); + rememberWorkspaceView(note.id, 'document'); + onClose(); + router.push(workspaceHref(documentTarget(note.id, 'new'))); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Couldn’t start the proposal'); + } finally { + setIsSavingSelection(false); + } }; const handleContinueWithDraft = async () => { @@ -144,7 +173,7 @@ export function ApplyToGrantPanel({ const handleStart = () => { if (newDraftMethod === 'ai') handleDraftWithAI(); - else if (newDraftMethod === 'editor') handleDraftInEditor(); + else if (newDraftMethod === 'editor') void handleDraftInEditor(); else void handleContinueWithDraft(); }; @@ -305,11 +334,18 @@ export function ApplyToGrantPanel({ {isSavingSelection - ? 'Saving...' + ? newDraftMethod === 'editor' + ? 'Starting...' + : 'Saving...' : selectedDraftNote ? 'Continue with this draft' : 'Start a proposal'} diff --git a/components/modals/ContributeToFundraiseModal.tsx b/components/modals/ContributeToFundraiseModal.tsx index 00b99dfea..68a77628f 100644 --- a/components/modals/ContributeToFundraiseModal.tsx +++ b/components/modals/ContributeToFundraiseModal.tsx @@ -63,6 +63,12 @@ interface ContributeModalCommonProps { * fundingPool mode (RSC / credits / card / Apple Pay only). */ allowDafPayment?: boolean; + /** + * Award proposals. RSC, the RFP pool, and Stripe (card, Apple Pay, Google + * Pay) stay: a Stripe charge buys RSC into the escrow. Donor-advised funds + * and PayPal stay hidden. + */ + isAward?: boolean; /** Replaces the default contribution success toast. */ successMessage?: string; /** @@ -155,6 +161,7 @@ function ContributeToFundraiseModalInner(props: Readonly 0; + const quickAmountCeilingUsd = hasGoal ? remainingGoalUsd : 10000; const handleEndaomentPaymentConfirm = useCallback( async (originFundId: string) => { @@ -605,7 +615,7 @@ function ContributeToFundraiseModalInner(props: Readonly { @@ -652,7 +662,7 @@ function ContributeToFundraiseModalInner(props: Readonly ); @@ -670,6 +680,7 @@ function ContributeToFundraiseModalInner(props: Readonly {/* The page's line is about the funder's own money, which an allocation isn't. */} {successText && !paid.fromPool && ( diff --git a/components/modals/SelectFundingOpportunityModal.tsx b/components/modals/SelectFundingOpportunityModal.tsx index 10f31a95f..507bef26d 100644 --- a/components/modals/SelectFundingOpportunityModal.tsx +++ b/components/modals/SelectFundingOpportunityModal.tsx @@ -84,6 +84,7 @@ export function SelectFundingOpportunityModal({ imageUrl: content.previewImage || '', fundingAmount: content.grant.amount?.usd || 0, organization: content.grant.organization || '', + fundingMechanism: content.grant.fundingMechanism, }); onClose(); }; diff --git a/components/work/AwardProposalCard.tsx b/components/work/AwardProposalCard.tsx new file mode 100644 index 000000000..df57581c3 --- /dev/null +++ b/components/work/AwardProposalCard.tsx @@ -0,0 +1,227 @@ +'use client'; + +import type { ReactNode } from 'react'; +import Link from 'next/link'; +import { Check, Trophy } from 'lucide-react'; +import { FundButton } from '@/components/Funding/FundButton'; +import { FundingCard } from '@/components/Funding/FundingCard'; +import { formatFundingAmount } from '@/components/Funding/lib/fundingAmounts'; +import { + awardActionLabel, + canAwardProposal, + getAwardPhase, +} from '@/components/Funding/lib/fundingMechanism'; +import { isFundraiseActive } from '@/components/Fund/lib/fundraiseUtils'; +import { getLinkedGrantLink } from '@/components/work/lib/linkedGrant'; +import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; +import { useExchangeRate } from '@/contexts/ExchangeRateContext'; +import { useUser } from '@/contexts/UserContext'; +import type { Fundraise } from '@/types/funding'; +import type { LinkedGrant, Work } from '@/types/work'; +import { formatCurrency } from '@/utils/currency'; +import { formatTimestamp } from '@/utils/date'; +import { cn } from '@/utils/styles'; + +interface TimelineStep { + readonly key: string; + readonly label: string; + /** When it happened, beside the label. */ + readonly date?: string; + /** The line under the label: the RFP's name, a date, what is waited on. */ + readonly detail?: ReactNode; + /** The detail stays on one line, cut short: the RFP's name can run long. */ + readonly truncate?: boolean; + readonly done: boolean; + /** It can no longer happen: the RFP closed or the proposal expired. */ + readonly disabled: boolean; +} + +/** + * An award proposal's sidebar card. It shows the RFP's award and that nobody + * fundraises on the proposal. Once money is in, the amount is what it received + * and Awarded is done, even while the payout is still waiting. The grant + * creator can still pay it while the RFP is open and the proposal has not expired. + */ +export function AwardProposalCard({ + fundraise, + linkedGrant, + publishedDate, +}: { + fundraise: Fundraise; + linkedGrant: LinkedGrant; + publishedDate?: string; +}) { + const { user } = useUser(); + const { showUSD } = useCurrencyPreference(); + const { exchangeRate } = useExchangeRate(); + const phase = getAwardPhase(fundraise); + // Money in escrow counts. The payout can still be waiting. + const awarded = phase !== 'none'; + const grantOpen = linkedGrant.status === 'OPEN'; + const stillOpen = grantOpen && isFundraiseActive(fundraise); + const canFund = canAwardProposal({ + userId: user?.id, + grantCreatorUserId: linkedGrant.createdByUserId, + isAward: true, + grantOpen, + fundraise, + }); + const who = linkedGrant.organization.trim() || 'The funder'; + const headline = + phase === 'none' + ? formatCurrency({ + amount: linkedGrant.fundingAmount, + showUSD, + exchangeRate, + skipConversion: showUSD, + }) + : formatFundingAmount(fundraise.amountRaised, showUSD); + const headlineLabel = awarded ? 'awarded' : 'in awards'; + + const rfp = getLinkedGrantLink({ linkedGrant } as Work); + // The award is the funder's payment into the fundraise: its first + // contribution dates it. + const awardedDate = awarded ? firstContributionDate(fundraise) : undefined; + + const steps: TimelineStep[] = [ + { + key: 'rfp', + label: 'Applied to RFP', + // Publishing the proposal is what submits it to the RFP. + date: publishedDate, + detail: rfp.title ? ( + rfp.href ? ( + + {rfp.title} + + ) : ( + rfp.title + ) + ) : undefined, + truncate: true, + done: true, + disabled: false, + }, + { + key: 'awarded', + label: 'Awarded', + date: awardedDate, + detail: awarded + ? `${headline} from ${who}` + : stillOpen + ? `${who} picks who gets the award once results are posted.` + : 'No longer open for awards', + done: awarded, + disabled: !awarded && !stillOpen, + }, + ]; + const nextStep = steps.findIndex((step) => !step.done && !step.disabled); + + return ( + + + Award + + } + testId="proposal-award" + > + + + {headline} + + {headlineLabel} + + + {steps.map((step, index) => { + const isNext = index === nextStep; + const isLater = (nextStep !== -1 && index > nextStep) || step.disabled; + const isLast = index === steps.length - 1; + return ( + + {/* The rail to the next step, green while both ends are done. */} + {!isLast && ( + + )} + + {step.done && } + + + + + {step.label} + + {step.done ? ' (done)' : isNext ? ' (next)' : ''} + + + {step.date && ( + + {formatTimestamp(step.date, false)} + + )} + + {step.detail && ( + + {step.detail} + + )} + + + ); + })} + + {canFund && ( + + {awardActionLabel(fundraise)} + + )} + + ); +} + +/** The earliest contribution to the fundraise, if its contributors carry dates. */ +function firstContributionDate(fundraise: Fundraise): string | undefined { + const dates = (fundraise.contributors?.topContributors ?? []) + .flatMap((contributor) => contributor.contributions ?? []) + .map((contribution) => contribution.date) + .filter((date) => date && !Number.isNaN(Date.parse(date))); + if (dates.length === 0) return undefined; + return dates.reduce((earliest, date) => + Date.parse(date) < Date.parse(earliest) ? date : earliest + ); +} diff --git a/components/work/ProposalActionBar.tsx b/components/work/ProposalActionBar.tsx index d501649cc..3a0db06fd 100644 --- a/components/work/ProposalActionBar.tsx +++ b/components/work/ProposalActionBar.tsx @@ -1,17 +1,54 @@ 'use client'; +import { Trophy } from 'lucide-react'; import { StickyActionBar } from '@/components/ui/StickyActionBar'; import { FundButton } from '@/components/Funding/FundButton'; +import { + awardActionLabel, + canAwardProposal, + isAwardMechanism, +} from '@/components/Funding/lib/fundingMechanism'; import { useFundraiseSummary } from '@/components/Funding/lib/useFundraiseSummary'; +import { useUser } from '@/contexts/UserContext'; import type { Fundraise } from '@/types/funding'; +import type { LinkedGrant } from '@/types/work'; /** * Fund, for the proposal page wherever its funding card is hidden: a bar as * wide as the page for how far the fundraise has come, then raised of the * goal, the funders and the time left beside the button. */ -export function ProposalActionBar({ fundraise }: { fundraise: Fundraise }) { +export function ProposalActionBar({ + fundraise, + linkedGrant, +}: { + fundraise: Fundraise; + linkedGrant?: LinkedGrant | null; +}) { + const { user } = useUser(); const summary = useFundraiseSummary(fundraise); + const isAward = isAwardMechanism(linkedGrant?.fundingMechanism); + + if (isAward) { + if ( + !canAwardProposal({ + userId: user?.id, + grantCreatorUserId: linkedGrant?.createdByUserId, + isAward, + grantOpen: linkedGrant?.status === 'OPEN', + fundraise, + }) + ) { + return null; + } + return ( + + + {awardActionLabel(fundraise)} + + + ); + } if (!summary.isActive) return null; diff --git a/components/work/ProposalSidebar.tsx b/components/work/ProposalSidebar.tsx index 25e2f1daa..4189ed6ca 100644 --- a/components/work/ProposalSidebar.tsx +++ b/components/work/ProposalSidebar.tsx @@ -2,6 +2,8 @@ import type { WorkMetadata } from '@/services/metadata.service'; import type { Work } from '@/types/work'; +import { isAwardMechanism } from '@/components/Funding/lib/fundingMechanism'; +import { AwardProposalCard } from './AwardProposalCard'; import { ProposalFundingCard } from './ProposalFundingCard'; import { ProposalReviewsCard } from './ProposalReviewsCard'; import { AiPeerReviewSection } from './components/AiPeerReviewSection'; @@ -19,10 +21,18 @@ interface ProposalSidebarProps { */ export function ProposalSidebar({ work, metadata }: ProposalSidebarProps) { const fundraise = metadata.fundraising; + const isAward = isAwardMechanism(work.linkedGrant?.fundingMechanism); return ( - {fundraise && } + {fundraise && isAward && work.linkedGrant && ( + + )} + {fundraise && !isAward && } {fundraise && } diff --git a/components/work/WorkHeader/WorkHeaderProposal.tsx b/components/work/WorkHeader/WorkHeaderProposal.tsx index c0b709fda..1d56a473c 100644 --- a/components/work/WorkHeader/WorkHeaderProposal.tsx +++ b/components/work/WorkHeader/WorkHeaderProposal.tsx @@ -8,6 +8,7 @@ import { BaseMenuItem } from '@/components/ui/form/BaseMenu'; import { ConfirmationModal } from '@/components/ui/form/ConfirmationModal'; import { ContributeToFundraiseModal } from '@/components/modals/ContributeToFundraiseModal'; import { useFundPanel } from '@/components/Funding/FundPanelContext'; +import { isAwardMechanism } from '@/components/Funding/lib/fundingMechanism'; import { CoverBadge } from '@/components/work/WorkCover'; import { WorkCoverCard } from '@/components/work/WorkCoverCard'; import { ShareLinkModal } from '@/components/modals/ShareLinkModal'; @@ -54,6 +55,7 @@ export function WorkHeaderProposal({ work, metadata, preTitle }: WorkHeaderPropo const router = useRouter(); const displayedWork = updatedWork ?? work; + const isAward = isAwardMechanism(displayedWork.linkedGrant?.fundingMechanism); const fundraise = metadata.fundraising ?? null; const isActive = fundraise ? isFundraiseActive(fundraise) : false; @@ -155,6 +157,7 @@ export function WorkHeaderProposal({ work, metadata, preTitle }: WorkHeaderPropo fundraise={fundraise} proposalTitle={displayedWork.title} work={displayedWork} + isAward={isAward} variant="panel" topContent={ } - successText="Your name now shows with this proposal's funders." + successText={ + isAward + ? 'The award has been added to this proposal.' + : "Your name now shows with this proposal's funders." + } /> )} diff --git a/contexts/ActivityFeedContext.tsx b/contexts/ActivityFeedContext.tsx index aa2a259ea..74644d4ed 100644 --- a/contexts/ActivityFeedContext.tsx +++ b/contexts/ActivityFeedContext.tsx @@ -1,13 +1,6 @@ 'use client'; -import { - createContext, - useCallback, - useContext, - useEffect, - useState, - type ReactNode, -} from 'react'; +import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react'; import { useSearchParams } from 'next/navigation'; import { useActivityFeed } from '@/hooks/useActivityFeed'; diff --git a/hooks/useDocument.ts b/hooks/useDocument.ts index c4c1de680..6c51a0be5 100644 --- a/hooks/useDocument.ts +++ b/hooks/useDocument.ts @@ -31,9 +31,11 @@ export interface PreregistrationPostParams { description?: string | null; contacts?: number[]; applicationVisibility?: 'OPTIONAL' | 'PRIVATE' | 'PUBLIC'; + fundingMechanism?: 'FUNDING' | 'AWARD'; // Preregistration specific (create-only — server marks it read-only on update) isPublic?: boolean; + omitFundraiseGoal?: boolean; // RFP attachment grantId?: string | null; @@ -106,8 +108,10 @@ export const useUpsertPost = (): UseUpsertPostReturn => { // Include fundraise fields for creation of proposals and grants payload.reward_funders = postParams.rewardFunders; payload.nft_supply = postParams.nftSupply; - payload.fundraise_goal_currency = 'USD'; - payload.fundraise_goal_amount = parseFloat(postParams.budget.replace(/[^0-9.]/g, '')); + if (!postParams.omitFundraiseGoal) { + payload.fundraise_goal_currency = 'USD'; + payload.fundraise_goal_amount = parseFloat(postParams.budget.replace(/[^0-9.]/g, '')); + } if (postParams.applicationDeadline) { payload.fundraise_end_date = postParams.applicationDeadline.toISOString(); @@ -130,6 +134,9 @@ export const useUpsertPost = (): UseUpsertPostReturn => { if (postParams.applicationVisibility) { payload.grant_application_visibility = postParams.applicationVisibility; } + if (!postId && postParams.fundingMechanism) { + payload.grant_funding_mechanism = postParams.fundingMechanism; + } } const response = (await PostService.upsert(payload)) as TransformedWork; diff --git a/hooks/useFeedStateRestoration.ts b/hooks/useFeedStateRestoration.ts index 4a033c942..b6fc2ee47 100644 --- a/hooks/useFeedStateRestoration.ts +++ b/hooks/useFeedStateRestoration.ts @@ -58,13 +58,7 @@ export function useFeedStateRestoration( } return null; - }, [ - isBackNavigation, - feedKey, - getFeedState, - clearFeedState, - options.shouldRestore, - ]); + }, [isBackNavigation, feedKey, getFeedState, clearFeedState, options.shouldRestore]); const initialEntries = restoredState?.entries || []; const restoredScrollPosition = restoredState?.scrollPosition ?? null; diff --git a/services/grant.service.ts b/services/grant.service.ts index 34dce716f..6db554dcc 100644 --- a/services/grant.service.ts +++ b/services/grant.service.ts @@ -1,5 +1,6 @@ import { ApiClient } from './client'; import { FeedEntry, transformFeedEntry, RawApiFeedEntry } from '@/types/feed'; +import { transformSelectedGrant, type SelectedGrantDetails } from '@/types/grant'; interface GrantFeedResponse { count: number; @@ -104,6 +105,12 @@ export class GrantService { } } + /** An RFP as a proposal draft's RFP card draws it. */ + static async getSelectedGrant(id: number | string): Promise { + const response = await ApiClient.get(`${this.BASE_PATH}/${id}/`); + return transformSelectedGrant(response); + } + static async getAvailableFunding(): Promise { try { const response = await ApiClient.get( diff --git a/types/feed.ts b/types/feed.ts index 38dd9fa26..60baa55c1 100644 --- a/types/feed.ts +++ b/types/feed.ts @@ -13,7 +13,13 @@ import { UserVoteType } from './reaction'; import { stripHtml } from '@/utils/stringUtils'; import { Tip } from './tip'; import { FOUNDATION_USER_ID } from '@/config/constants'; -import { FundingPool, GrantStatus, transformFundingPool } from './grant'; +import { + FundingPool, + GrantStatus, + parseFundingMechanism, + transformFundingPool, + type FundingMechanism, +} from './grant'; export type FeedActionType = 'contribute' | 'open' | 'publish' | 'post'; @@ -264,6 +270,7 @@ export interface FeedGrantContent extends BaseFeedContent { createdBy: AuthorProfile; applicants: Application[]; fundingPool: FundingPool | null; + fundingMechanism: FundingMechanism; }; organization?: string; grantAmount?: { @@ -373,6 +380,7 @@ export interface AssociatedGrant { description: string | null; image: string | null; numApplicants: number; + fundingMechanism: FundingMechanism; } export type ActivityAction = @@ -500,6 +508,7 @@ export interface RawApiFeedEntry { description: string | null; image: string | null; num_applicants: number; + funding_mechanism?: string; }>; hot_score_breakdown?: { steps: string[]; @@ -619,6 +628,12 @@ export function getUnifiedDocumentId(content_object: any): string | undefined { return undefined; } +/** Present only when the activity payload names the proposal's RFP type. */ +function readRelatedFundingMechanism(raw: unknown): FundingMechanism | undefined { + if (raw === 'AWARD' || raw === 'FUNDING') return raw; + return undefined; +} + function transformActivityRelatedWorkGrant(rawGrant: unknown): WorkGrantSummary | undefined { if (!rawGrant || typeof rawGrant !== 'object') return undefined; @@ -690,6 +705,7 @@ export function transformActivityRelatedWork(raw: any): Work | undefined { unifiedDocumentId: raw.unified_document_id, image: raw.image_url ?? undefined, fundraise, + fundingMechanism: readRelatedFundingMechanism(raw.funding_mechanism), grantSummary: raw.grant ? transformActivityRelatedWorkGrant(raw.grant) : undefined, reviewMetrics: raw.review_metrics?.count ? { avg: raw.review_metrics.avg, count: raw.review_metrics.count } @@ -1123,6 +1139,7 @@ export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => { fundingPool: content_object.grant.funding_pool ? transformFundingPool(content_object.grant.funding_pool) : null, + fundingMechanism: parseFundingMechanism(content_object.grant.funding_mechanism), }, organization: content_object.grant.organization || '', grantAmount: content_object.grant.amount || {}, @@ -1385,6 +1402,7 @@ export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => { description: g.description ?? null, image: g.image ?? null, numApplicants: g.num_applicants ?? 0, + fundingMechanism: parseFundingMechanism(g.funding_mechanism), })), journalPostIds: feedEntry.post_ids ? { diff --git a/types/funding.ts b/types/funding.ts index b67b89db1..dfb52967d 100644 --- a/types/funding.ts +++ b/types/funding.ts @@ -53,10 +53,13 @@ export interface ApplicationFundraise { /** * When a fundraise is COMPLETED, derive USD from the ratio (goalUsd / raisedRsc) - * so the displayed total matches the goal. Returns null for active fundraises (use live rate). + * so the displayed total matches the goal. Returns null for active fundraises + * (use the reported amount), and when the goal is 0. An award has no goal, so + * pinning its total to that goal would show 0 after the payout. */ export function computeGoalRate(status: string, goalUsd: number, raisedRsc: number): number | null { - return status === 'COMPLETED' && raisedRsc > 0 ? goalUsd / raisedRsc : null; + if (status !== 'COMPLETED' || raisedRsc <= 0 || goalUsd <= 0) return null; + return goalUsd / raisedRsc; } /** diff --git a/types/grant.ts b/types/grant.ts index 144d33427..35cd039a4 100644 --- a/types/grant.ts +++ b/types/grant.ts @@ -2,11 +2,17 @@ import { Currency, ID } from './root'; import { createTransformer } from './transformer'; import { AuthorProfile, transformAuthorProfile } from './authorProfile'; import { Contact, transformContact } from './note'; -import type { Application } from './funding'; +import { transformApplication, type Application } from './funding'; import { isOpenBeforeDeadline } from '@/utils/date'; export type GrantStatus = 'OPEN' | 'CLOSED' | 'PENDING' | 'DECLINED' | 'COMPLETED'; +export type FundingMechanism = 'FUNDING' | 'AWARD'; + +export function parseFundingMechanism(raw: unknown): FundingMechanism { + return raw === 'AWARD' ? 'AWARD' : 'FUNDING'; +} + export type GrantApplicationVisibility = 'OPTIONAL' | 'PRIVATE' | 'PUBLIC'; export const GRANT_IMAGE_FALLBACK_GRADIENT = @@ -121,6 +127,7 @@ export interface SelectedGrantDetails { fundingAmount: number; organization: string; applicationVisibility?: GrantApplicationVisibility; + fundingMechanism: FundingMechanism; } export const transformSelectedGrant = createTransformer((raw) => ({ @@ -130,6 +137,7 @@ export const transformSelectedGrant = 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, - })) + ? raw.applications.map((application: any) => transformApplication(application)) : undefined; return { @@ -196,6 +201,7 @@ export const transformGrant = createTransformer((raw) => { ? raw.contacts.map((contact: any) => transformContact(contact)) : undefined, applicationVisibility: (raw.application_visibility as GrantApplicationVisibility) ?? 'OPTIONAL', + fundingMechanism: parseFundingMechanism(raw.funding_mechanism), fundingPool: raw.funding_pool ? transformFundingPool(raw.funding_pool) : null, applications, applicants: applications?.map((application) => application.profile), diff --git a/types/moderation.ts b/types/moderation.ts index 6addf3d4c..fa9bb33ee 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 { transformFundingPool, type GrantStatus } from '@/types/grant'; +import { parseFundingMechanism, transformFundingPool, type GrantStatus } from '@/types/grant'; import { transformTopic } from '@/types/topic'; import { stripHtml } from '@/utils/stringUtils'; @@ -120,6 +120,7 @@ export const transformPendingGrantToFeedEntry = (entry: RawApiFeedEntry): FeedEn createdBy, applicants: (grant.applications || []).map(transformApplication), fundingPool: grant.funding_pool ? transformFundingPool(grant.funding_pool) : null, + fundingMechanism: parseFundingMechanism(grant.funding_mechanism), }, organization: grant.organization || '', grantAmount: grant.amount || {}, diff --git a/types/note.ts b/types/note.ts index 6a8bbdb0a..92e794a48 100644 --- a/types/note.ts +++ b/types/note.ts @@ -8,8 +8,10 @@ import { Grant, GrantApplicationVisibility, SelectedGrantDetails, + parseFundingMechanism, transformGrant, transformSelectedGrant, + type FundingMechanism, } from './grant'; import { NonprofitOrg, transformNonprofitDetailsToOrg } from './nonprofit'; import { AuthorProfile, transformAuthorProfile } from './authorProfile'; @@ -53,6 +55,7 @@ export interface NoteGrantSettings { organization: string | null; description: string | null; applicationVisibility: GrantApplicationVisibility | null; + fundingMechanism: FundingMechanism; contacts: Contact[]; } @@ -98,6 +101,7 @@ export interface NoteGrantSettingsUpdate { organization?: string; description?: string; applicationVisibility?: GrantApplicationVisibility; + fundingMechanism?: FundingMechanism; /** User ids, not author profile ids. */ contactIds?: number[]; } @@ -141,6 +145,7 @@ const GRANT_SETTINGS_KEYS: Record = { organization: 'organization', description: 'description', applicationVisibility: 'application_visibility', + fundingMechanism: 'funding_mechanism', contactIds: 'contact_ids', }; @@ -246,6 +251,7 @@ const transformNoteGrantSettings = createTransformer((ra organization: raw.organization ?? null, description: raw.description ?? null, applicationVisibility: raw.application_visibility ?? null, + fundingMechanism: parseFundingMechanism(raw.funding_mechanism), // Saved grant contacts are users, so they arrive as names rather than a label. contacts: (raw.contacts ?? []).map((contact: any) => ({ id: contact.id, diff --git a/types/work.ts b/types/work.ts index e2979f4c6..ccffd5623 100644 --- a/types/work.ts +++ b/types/work.ts @@ -8,7 +8,13 @@ import { NoteWithContent, transformNoteWithContent } from './note'; import { stripHtml } from '../utils/stringUtils'; import { transformTip, Tip } from './tip'; import { transformProposalReview, type ProposalReview } from './aiPeerReview'; -import { transformFundingPool, type FundingPool, type GrantApplicationVisibility } from './grant'; +import { + parseFundingMechanism, + transformFundingPool, + type FundingMechanism, + type FundingPool, + type GrantApplicationVisibility, +} from './grant'; import { Fundraise, ReviewMetrics, transformFundraise } from './funding'; import type { Review } from './feed'; @@ -120,6 +126,7 @@ export interface Work { */ aiPeerReview?: ProposalReview | null; linkedGrant?: LinkedGrant | null; + fundingMechanism?: FundingMechanism; grantSummary?: WorkGrantSummary; moderationStatus?: ModerationStatus; isPublic?: boolean; @@ -135,6 +142,7 @@ export interface LinkedGrant { currency: string; status: string; applicationVisibility: GrantApplicationVisibility; + fundingMechanism: FundingMechanism; imageUrl: string | null; applicantCount: number; fundingPool?: FundingPool | null; @@ -278,6 +286,7 @@ function transformAndPickLinkedGrant(raw: any): LinkedGrant | null { currency: g.currency ?? 'USD', status: g.status ?? '', applicationVisibility: (g.application_visibility as GrantApplicationVisibility) ?? 'OPTIONAL', + fundingMechanism: parseFundingMechanism(g.funding_mechanism), imageUrl: g.image_url ?? null, applicantCount: g.applicant_count ?? 0, fundingPool: g.funding_pool ? transformFundingPool(g.funding_pool) : null,
Loading nonprofit information...
{GRANT_FUNDING_MECHANISM_HELP}
Chosen when this RFP was opened.
+ + {headline} + + {headlineLabel} +
+ + {step.label} + + {step.done ? ' (done)' : isNext ? ' (next)' : ''} + + + {step.date && ( + + {formatTimestamp(step.date, false)} + + )} +
+ {step.detail} +