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 ( +
  1. + {href ? ( + + {row} + + ) : ( +
    {row}
    + )} +
  2. + ); + })} +
+
+ ); +} 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 ( ); 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 && ( + + )} {canAllocate && ( + ); + })} +
+ {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({