From 17741798aa9073e8dbdff3292ba8887473e28e3b Mon Sep 17 00:00:00 2001
From: nicktytarenko
Date: Wed, 4 Nov 2026 01:02:55 +0200
Subject: [PATCH 1/6] Add award RFPs
---
app/proposal/[id]/[slug]/layout.tsx | 4 +-
components/AIMode/document/PublishDialog.tsx | 12 +-
.../document/masthead/mastheadWidgets.ts | 3 +
.../Activity/lib/activityDisplay.utils.ts | 19 ++-
components/Activity/lib/activityWork.utils.ts | 53 ++++++-
components/Feed/FeedItemFundingBadges.tsx | 5 +-
components/Funding/AwardPlacements.tsx | 131 ++++++++++++++++++
components/Funding/FundButton.tsx | 14 +-
components/Funding/GrantPageContext.tsx | 3 +
components/Funding/GrantSidebar.tsx | 4 +-
components/Funding/PaymentStep.tsx | 45 ++++--
components/Funding/PaymentWidget.tsx | 8 ++
components/Funding/ProposalWorkCard.tsx | 73 ++++++++--
components/Funding/lib/canManageGrantPool.ts | 4 +-
components/Funding/lib/constants.ts | 8 ++
components/Funding/lib/fundingMechanism.ts | 65 +++++++++
components/Funding/lib/index.ts | 2 +-
.../components/FundingSection.tsx | 25 ++--
.../GrantFundingMechanismSection.tsx | 100 +++++++++++++
.../Notebook/PublishingForm/formMapping.ts | 7 +
components/Notebook/PublishingForm/index.tsx | 2 +
components/Notebook/PublishingForm/schema.ts | 4 +-
.../PublishingForm/usePublishAction.ts | 16 ++-
.../AnimatedResearchCoin.module.css | 4 +-
.../modals/ContributeToFundraiseModal.tsx | 21 ++-
.../modals/SelectFundingOpportunityModal.tsx | 1 +
components/work/AwardProposalCard.tsx | 129 +++++++++++++++++
components/work/ProposalActionBar.tsx | 35 ++++-
components/work/ProposalSidebar.tsx | 8 +-
.../work/WorkHeader/WorkHeaderProposal.tsx | 9 +-
contexts/ActivityFeedContext.tsx | 9 +-
hooks/useDocument.ts | 11 +-
hooks/useFeedStateRestoration.ts | 8 +-
types/feed.ts | 20 ++-
types/funding.ts | 7 +-
types/grant.ts | 18 ++-
types/moderation.ts | 3 +-
types/note.ts | 6 +
types/work.ts | 11 +-
39 files changed, 811 insertions(+), 96 deletions(-)
create mode 100644 components/Funding/AwardPlacements.tsx
create mode 100644 components/Funding/lib/fundingMechanism.ts
create mode 100644 components/Notebook/PublishingForm/components/GrantFundingMechanismSection.tsx
create mode 100644 components/work/AwardProposalCard.tsx
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/Activity/lib/activityDisplay.utils.ts b/components/Activity/lib/activityDisplay.utils.ts
index 299923282..0445f3404 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,10 @@ 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;
+ 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..7edb3a242 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,42 @@ function buildBasePresentation(
};
}
+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 +350,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 +436,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 +585,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/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..cbd4fdda5 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,17 @@ import {
} from '@/components/Activity';
import {
getActivityWork,
+ getAwardCardStat,
getWorkCardPresentation,
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 { canAwardProposal, isAwardMechanism } from '@/components/Funding/lib/fundingMechanism';
import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext';
import { useExchangeRate } from '@/contexts/ExchangeRateContext';
import { useFundraises } from '@/contexts/FundraiseContext';
@@ -76,6 +79,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 +100,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 +129,21 @@ 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;
+ const isAward = isAwardMechanism(grantAllocate?.fundingMechanism);
+ 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 +158,7 @@ export const ProposalWorkCard: FC = ({ entry, onNavigate
href={work.href}
isNonprofit={isNonprofit}
fundraiseStatus={fundraise?.status}
+ completedLabel={isAward ? 'Awarded' : 'Funded'}
variant="overlay"
/>
@@ -151,6 +170,22 @@ export const ProposalWorkCard: FC = ({ entry, onNavigate
+ {canAward && fundraise && (
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ setIsAwardOpen(true);
+ }}
+ >
+
+ Award
+
+ )}
{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';
+}
+
+/** 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/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..cd6f23ee0
--- /dev/null
+++ b/components/work/AwardProposalCard.tsx
@@ -0,0 +1,129 @@
+'use client';
+
+import Link from 'next/link';
+import { Check, ChevronRight, Circle, Trophy } from 'lucide-react';
+import { FundButton } from '@/components/Funding/FundButton';
+import { FundingCard } from '@/components/Funding/FundingCard';
+import { formatFundingAmount } from '@/components/Funding/lib/fundingAmounts';
+import { 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';
+
+/**
+ * An award proposal's sidebar card. It shows the RFP's award and that nobody
+ * fundraises on the proposal. Once this proposal has been paid, the amount is
+ * what it received and Awarded is done. The grant creator can still pay it
+ * while the RFP is open and the proposal has not expired.
+ */
+export function AwardProposalCard({
+ fundraise,
+ linkedGrant,
+}: {
+ fundraise: Fundraise;
+ linkedGrant: LinkedGrant;
+}) {
+ const { user } = useUser();
+ const { showUSD } = useCurrencyPreference();
+ const { exchangeRate } = useExchangeRate();
+ const phase = getAwardPhase(fundraise);
+ const awarded = phase === 'awarded';
+ const grantOpen = linkedGrant.status === 'OPEN';
+ const stillOpen = grantOpen && isFundraiseActive(fundraise);
+ const canFund = canAwardProposal({
+ userId: user?.id,
+ grantCreatorUserId: linkedGrant.createdByUserId,
+ isAward: true,
+ grantOpen,
+ fundraise,
+ });
+ const rfp = getLinkedGrantLink({ linkedGrant } as Work);
+ 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' : phase === 'set-aside' ? 'set aside' : 'in awards';
+
+ const steps = [
+ { label: 'Proposal published', done: true, disabled: false },
+ { label: 'Awarded', done: awarded, disabled: !awarded && !stillOpen },
+ ];
+ const nextStep = steps.findIndex((step) => !step.done && !step.disabled);
+
+ return (
+
+
+ Award
+
+ }
+ testId="proposal-award"
+ >
+
+
+ {headline}
+
+ {headlineLabel}
+
+
+ {who} picks who gets the award once results are posted.
+
+
+ {steps.map((step, index) => {
+ const isNext = index === nextStep;
+ const isLater = (nextStep !== -1 && index > nextStep) || step.disabled;
+ return (
+
+ {step.done ? (
+
+ ) : (
+
+ )}
+
+ {step.label}
+
+
+ );
+ })}
+
+ {canFund && (
+
+ Award
+
+ )}
+ {grantOpen && rfp.href && rfp.title && (
+
+ Add to {rfp.title}'s award pool
+
+
+ )}
+
+ );
+}
diff --git a/components/work/ProposalActionBar.tsx b/components/work/ProposalActionBar.tsx
index d501649cc..a09e47a30 100644
--- a/components/work/ProposalActionBar.tsx
+++ b/components/work/ProposalActionBar.tsx
@@ -1,17 +1,50 @@
'use client';
+import { Trophy } from 'lucide-react';
import { StickyActionBar } from '@/components/ui/StickyActionBar';
import { FundButton } from '@/components/Funding/FundButton';
+import { 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 (
+
+
+ Award
+
+
+ );
+ }
if (!summary.isActive) return null;
diff --git a/components/work/ProposalSidebar.tsx b/components/work/ProposalSidebar.tsx
index 25e2f1daa..fd09bcf7d 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,14 @@ 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/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,
From 918010821e0f3843a308659b57cdafaf3551648a Mon Sep 17 00:00:00 2001
From: Kobe Attias
Date: Tue, 3 Nov 2026 22:32:40 -0500
Subject: [PATCH 2/6] Apply to RFP opens the workspace with the RFP; award card
timeline
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Apply panel: "Draft it with AI" opens the workspace start screen with the
RFP already picked (`?new=proposal&rfp=`); "Write it yourself"
creates a blank proposal with the RFP attached and opens it in the
workspace instead of the deprecated notebook.
- Award proposal card: drop the "Add to 's award pool" link and show
the steps as a timeline (Applied to RFP → Proposal published → Awarded),
with the funder's pick line under Awarded.
- /fund/proposals: cards for proposals to award RFPs no longer show a
"Raising $X" ask or progress bar.
Co-Authored-By: Claude Opus 5.5
---
components/AIMode/useAIModeChat.ts | 21 ++-
components/AIMode/workspaceUrl.ts | 31 ++++-
components/Activity/lib/activityWork.utils.ts | 3 +-
components/Funding/ProposalWorkCard.tsx | 5 +-
components/modals/ApplyToGrantPanel.tsx | 56 ++++++--
components/work/AwardProposalCard.tsx | 131 +++++++++++++-----
services/grant.service.ts | 7 +
7 files changed, 202 insertions(+), 52 deletions(-)
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/activityWork.utils.ts b/components/Activity/lib/activityWork.utils.ts
index 7edb3a242..4da48c951 100644
--- a/components/Activity/lib/activityWork.utils.ts
+++ b/components/Activity/lib/activityWork.utils.ts
@@ -222,7 +222,8 @@ function buildBasePresentation(
};
}
-function isAwardFeedEntry(entry: FeedEntry): boolean {
+/** 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;
}
diff --git a/components/Funding/ProposalWorkCard.tsx b/components/Funding/ProposalWorkCard.tsx
index cbd4fdda5..49b126f69 100644
--- a/components/Funding/ProposalWorkCard.tsx
+++ b/components/Funding/ProposalWorkCard.tsx
@@ -13,6 +13,7 @@ import {
getActivityWork,
getAwardCardStat,
getWorkCardPresentation,
+ isAwardFeedEntry,
type WorkCardStat,
} from '@/components/Activity/lib/activityWork.utils';
import { FeedItemFundingBadges } from '@/components/Feed/FeedItemFundingBadges';
@@ -129,7 +130,9 @@ 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 isAward = isAwardMechanism(grantAllocate?.fundingMechanism);
+ // 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
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/work/AwardProposalCard.tsx b/components/work/AwardProposalCard.tsx
index cd6f23ee0..2cb1697cb 100644
--- a/components/work/AwardProposalCard.tsx
+++ b/components/work/AwardProposalCard.tsx
@@ -1,7 +1,8 @@
'use client';
+import type { ReactNode } from 'react';
import Link from 'next/link';
-import { Check, ChevronRight, Circle, Trophy } from 'lucide-react';
+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';
@@ -14,6 +15,20 @@ 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;
+ /** 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
@@ -42,7 +57,6 @@ export function AwardProposalCard({
grantOpen,
fundraise,
});
- const rfp = getLinkedGrantLink({ linkedGrant } as Work);
const who = linkedGrant.organization.trim() || 'The funder';
const headline =
phase === 'none'
@@ -55,9 +69,47 @@ export function AwardProposalCard({
: formatFundingAmount(fundraise.amountRaised, showUSD);
const headlineLabel = awarded ? 'awarded' : phase === 'set-aside' ? 'set aside' : 'in awards';
- const steps = [
- { label: 'Proposal published', done: true, disabled: false },
- { label: 'Awarded', done: awarded, disabled: !awarded && !stillOpen },
+ const rfp = getLinkedGrantLink({ linkedGrant } as Work);
+ const publishedAt = fundraise.startDate || fundraise.createdDate;
+
+ const steps: TimelineStep[] = [
+ {
+ key: 'rfp',
+ label: 'Applied to RFP',
+ detail: rfp.title ? (
+ rfp.href ? (
+
+ {rfp.title}
+
+ ) : (
+ rfp.title
+ )
+ ) : undefined,
+ truncate: true,
+ done: true,
+ disabled: false,
+ },
+ {
+ key: 'published',
+ label: 'Proposal published',
+ detail: publishedAt ? formatTimestamp(publishedAt, false) : undefined,
+ done: true,
+ disabled: false,
+ },
+ {
+ key: 'awarded',
+ label: 'Awarded',
+ 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);
@@ -78,34 +130,58 @@ export function AwardProposalCard({
{headlineLabel}
-
- {who} picks who gets the award once results are posted.
-
-
+
{steps.map((step, index) => {
const isNext = index === nextStep;
const isLater = (nextStep !== -1 && index > nextStep) || step.disabled;
+ const isLast = index === steps.length - 1;
return (
-
- {step.done ? (
-
- ) : (
-
+ {/* The rail to the next step, green while both ends are done. */}
+ {!isLast && (
+
)}
- {step.label}
+ {step.done && }
+
+
+ {step.label}
+ {step.done ? ' (done)' : isNext ? ' (next)' : ''}
+
+ {step.detail && (
+
+ {step.detail}
+
+ )}
+
);
})}
@@ -115,15 +191,6 @@ export function AwardProposalCard({
Award
)}
- {grantOpen && rfp.href && rfp.title && (
-
- Add to {rfp.title}'s award pool
-
-
- )}
);
}
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(
From dc35eaa8e70c63dee1c27e61d504bb769f6f1fc0 Mon Sep 17 00:00:00 2001
From: nicktytarenko
Date: Wed, 4 Nov 2026 12:44:33 +0200
Subject: [PATCH 3/6] Refactor award proposal functionality to use dynamic
action labels
---
components/Funding/ProposalWorkCard.tsx | 8 ++++++--
components/Funding/lib/fundingMechanism.ts | 7 +++++++
components/work/AwardProposalCard.tsx | 21 +++++++++++++--------
components/work/ProposalActionBar.tsx | 8 ++++++--
4 files changed, 32 insertions(+), 12 deletions(-)
diff --git a/components/Funding/ProposalWorkCard.tsx b/components/Funding/ProposalWorkCard.tsx
index 49b126f69..c2f04762e 100644
--- a/components/Funding/ProposalWorkCard.tsx
+++ b/components/Funding/ProposalWorkCard.tsx
@@ -22,7 +22,11 @@ import { ContributeToFundraiseModal } from '@/components/modals/ContributeToFund
import { Button } from '@/components/ui/Button';
import { useGrantAllocateContext } from '@/components/Funding/GrantPageContext';
import { canManageGrantPool } from '@/components/Funding/lib/canManageGrantPool';
-import { canAwardProposal, isAwardMechanism } from '@/components/Funding/lib/fundingMechanism';
+import {
+ awardActionLabel,
+ canAwardProposal,
+ isAwardMechanism,
+} from '@/components/Funding/lib/fundingMechanism';
import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext';
import { useExchangeRate } from '@/contexts/ExchangeRateContext';
import { useFundraises } from '@/contexts/FundraiseContext';
@@ -186,7 +190,7 @@ export const ProposalWorkCard: FC = ({ entry, onNavigate
}}
>
- Award
+ {awardActionLabel(fundraise)}
)}
{canAllocate && (
diff --git a/components/Funding/lib/fundingMechanism.ts b/components/Funding/lib/fundingMechanism.ts
index 5a861c899..03e43ea21 100644
--- a/components/Funding/lib/fundingMechanism.ts
+++ b/components/Funding/lib/fundingMechanism.ts
@@ -34,6 +34,13 @@ export function getAwardPhase(
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
diff --git a/components/work/AwardProposalCard.tsx b/components/work/AwardProposalCard.tsx
index 2cb1697cb..d8cc88d70 100644
--- a/components/work/AwardProposalCard.tsx
+++ b/components/work/AwardProposalCard.tsx
@@ -6,7 +6,11 @@ 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 { canAwardProposal, getAwardPhase } from '@/components/Funding/lib/fundingMechanism';
+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';
@@ -32,9 +36,9 @@ interface TimelineStep {
/**
* An award proposal's sidebar card. It shows the RFP's award and that nobody
- * fundraises on the proposal. Once this proposal has been paid, the amount is
- * what it received and Awarded is done. The grant creator can still pay it
- * while the RFP is open and the proposal has not expired.
+ * 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,
@@ -47,7 +51,8 @@ export function AwardProposalCard({
const { showUSD } = useCurrencyPreference();
const { exchangeRate } = useExchangeRate();
const phase = getAwardPhase(fundraise);
- const awarded = phase === 'awarded';
+ // 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({
@@ -67,7 +72,7 @@ export function AwardProposalCard({
skipConversion: showUSD,
})
: formatFundingAmount(fundraise.amountRaised, showUSD);
- const headlineLabel = awarded ? 'awarded' : phase === 'set-aside' ? 'set aside' : 'in awards';
+ const headlineLabel = awarded ? 'awarded' : 'in awards';
const rfp = getLinkedGrantLink({ linkedGrant } as Work);
const publishedAt = fundraise.startDate || fundraise.createdDate;
@@ -115,7 +120,7 @@ export function AwardProposalCard({
return (
@@ -188,7 +193,7 @@ export function AwardProposalCard({
{canFund && (
- Award
+ {awardActionLabel(fundraise)}
)}
diff --git a/components/work/ProposalActionBar.tsx b/components/work/ProposalActionBar.tsx
index a09e47a30..3a0db06fd 100644
--- a/components/work/ProposalActionBar.tsx
+++ b/components/work/ProposalActionBar.tsx
@@ -3,7 +3,11 @@
import { Trophy } from 'lucide-react';
import { StickyActionBar } from '@/components/ui/StickyActionBar';
import { FundButton } from '@/components/Funding/FundButton';
-import { canAwardProposal, isAwardMechanism } from '@/components/Funding/lib/fundingMechanism';
+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';
@@ -40,7 +44,7 @@ export function ProposalActionBar({
return (
- Award
+ {awardActionLabel(fundraise)}
);
From 1e227bfb9f0ffa7f93f84566546f6b949be317de Mon Sep 17 00:00:00 2001
From: nicktytarenko
Date: Wed, 4 Nov 2026 15:23:27 +0200
Subject: [PATCH 4/6] Update AwardProposalCard to accept publishedDate prop for
improved timeline display
---
components/work/AwardProposalCard.tsx | 5 +++--
components/work/ProposalSidebar.tsx | 6 +++++-
2 files changed, 8 insertions(+), 3 deletions(-)
diff --git a/components/work/AwardProposalCard.tsx b/components/work/AwardProposalCard.tsx
index d8cc88d70..9d46692d8 100644
--- a/components/work/AwardProposalCard.tsx
+++ b/components/work/AwardProposalCard.tsx
@@ -43,9 +43,11 @@ interface TimelineStep {
export function AwardProposalCard({
fundraise,
linkedGrant,
+ publishedDate,
}: {
fundraise: Fundraise;
linkedGrant: LinkedGrant;
+ publishedDate?: string;
}) {
const { user } = useUser();
const { showUSD } = useCurrencyPreference();
@@ -75,7 +77,6 @@ export function AwardProposalCard({
const headlineLabel = awarded ? 'awarded' : 'in awards';
const rfp = getLinkedGrantLink({ linkedGrant } as Work);
- const publishedAt = fundraise.startDate || fundraise.createdDate;
const steps: TimelineStep[] = [
{
@@ -100,7 +101,7 @@ export function AwardProposalCard({
{
key: 'published',
label: 'Proposal published',
- detail: publishedAt ? formatTimestamp(publishedAt, false) : undefined,
+ detail: publishedDate ? formatTimestamp(publishedDate, false) : undefined,
done: true,
disabled: false,
},
diff --git a/components/work/ProposalSidebar.tsx b/components/work/ProposalSidebar.tsx
index fd09bcf7d..4189ed6ca 100644
--- a/components/work/ProposalSidebar.tsx
+++ b/components/work/ProposalSidebar.tsx
@@ -26,7 +26,11 @@ export function ProposalSidebar({ work, metadata }: ProposalSidebarProps) {
return (
{fundraise && isAward && work.linkedGrant && (
-
+
)}
{fundraise && !isAward &&
}
From 8a56ed124e431249935b6968f1bd72710627bac0 Mon Sep 17 00:00:00 2001
From: nicktytarenko
Date: Wed, 4 Nov 2026 15:44:54 +0200
Subject: [PATCH 5/6] Enhance activity display logic to determine funding
mechanism for preregistration entries, defaulting to 'AWARD' if applicable.
---
components/Activity/lib/activityDisplay.utils.ts | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/components/Activity/lib/activityDisplay.utils.ts b/components/Activity/lib/activityDisplay.utils.ts
index 0445f3404..5b183e38b 100644
--- a/components/Activity/lib/activityDisplay.utils.ts
+++ b/components/Activity/lib/activityDisplay.utils.ts
@@ -590,7 +590,11 @@ export function getActivityActionLine(
return { label: 'Opened an RFP', value: amount ? { kind: 'grant', amount } : undefined };
}
if (entry.contentType === 'PREREGISTRATION') {
- const mechanism = entry.relatedWork?.fundingMechanism;
+ 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' };
From bcb9b1da30464420f06d106598f6f8456dc823a8 Mon Sep 17 00:00:00 2001
From: Kobe Attias
Date: Wed, 4 Nov 2026 11:43:10 -0500
Subject: [PATCH 6/6] Award card dates; no ask on award RFP feed cards
- Award proposal card: drop "Proposal published" (applying to the RFP is
publishing it) and show a date beside each step: the publish date for
Applied to RFP, the first contribution for Awarded. Both come from data
the page already loads.
- RFP feed card: for award RFPs, proposal rows drop the "ask" column and
the footer drops "total requested".
Co-Authored-By: Claude Opus 5.5
---
.../items/FeedItemGrantWithApplicants.tsx | 34 +++++++++---
components/work/AwardProposalCard.tsx | 53 ++++++++++++++-----
2 files changed, 65 insertions(+), 22 deletions(-)
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/work/AwardProposalCard.tsx b/components/work/AwardProposalCard.tsx
index 9d46692d8..df57581c3 100644
--- a/components/work/AwardProposalCard.tsx
+++ b/components/work/AwardProposalCard.tsx
@@ -25,6 +25,8 @@ 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. */
@@ -77,11 +79,16 @@ export function AwardProposalCard({
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 ? (
}
-
+
+ {step.label}
+
+ {step.done ? ' (done)' : isNext ? ' (next)' : ''}
+
+
+ {step.date && (
+
+ {formatTimestamp(step.date, false)}
+
)}
- >
- {step.label}
- {step.done ? ' (done)' : isNext ? ' (next)' : ''}
{step.detail && (
);
}
+
+/** 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
+ );
+}