Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/grant/[id]/[slug]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export default async function GrantSlugLayout({ params, children }: Props) {
work={work}
metadata={metadata}
amountUsd={badgeAmountUsd}
grantAmount={grant ? { usd: grant.amount.usd ?? 0, rsc: grant.amount.rsc ?? 0 } : null}
grantId={grantId?.toString()}
isActive={isActive}
isPending={isPending}
Expand Down
2 changes: 1 addition & 1 deletion components/Activity/cards/ActivityFundingGroupCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ const FunderName: FC<{ funder: AuthorProfile }> = ({ funder }) => {
const FunderSummary: FC<{ funders: AuthorProfile[]; isRfp: boolean }> = ({ funders, isRfp }) => {
const named = funders.slice(0, MAX_NAMED_FUNDERS);
const remaining = funders.length - named.length;
const action = isRfp ? ' contributed to this RFP.' : ' funded this proposal.';
const action = isRfp ? ' contributed to RFP' : ' funded this proposal.';

return (
<>
Expand Down
2 changes: 1 addition & 1 deletion components/Activity/lib/activityDisplay.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ function getDefaultActivityMessage(entry: FeedEntry): ActivityHeaderMessage {
if (entry.contentType === 'USDFUNDRAISECONTRIBUTION' || entry.contentType === 'PURCHASE') {
return {
actor,
verb: isFundingPoolContribution(entry) ? 'contributed to this RFP.' : 'funded this proposal.',
verb: isFundingPoolContribution(entry) ? 'contributed to RFP' : 'funded this proposal.',
};
}

Expand Down
21 changes: 14 additions & 7 deletions components/modals/ContributeToFundraiseModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,12 @@ import AuthContent from '@/components/Auth/AuthContent';
interface ContributeModalCommonProps {
isOpen: boolean;
onClose: () => void;
onContributeSuccess?: () => void;
/**
* Called after a contribution lands. In fundingPool mode, RSC and credit
* contributions pass back the pool the API returned so the page can update
* without a refetch; card and wallet payments settle server-side and pass nothing.
*/
onContributeSuccess?: (updatedPool?: FundingPool) => void;
/** Title of the proposal / RFP being funded */
proposalTitle?: string;
/** Work object containing author information (proposal fundraise only) */
Expand Down Expand Up @@ -153,12 +158,12 @@ function ContributeToFundraiseModalInner(props: Readonly<ContributeToFundraiseMo
(isPoolMode
? 'Your contribution has been added to the RFP funding pool.'
: 'Your contribution has been successfully added to the fundraise.');
const [amountUsd, setAmountUsd] = useState(100);
const [amountUsd, setAmountUsd] = useState(1000);
const [isContributing, setIsContributing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [amountError, setAmountError] = useState<string | undefined>(undefined);
const [currentView, setCurrentView] = useState<ModalView>('funding');
const [selectedQuickAmount, setSelectedQuickAmount] = useState<number | null>(100);
const [selectedQuickAmount, setSelectedQuickAmount] = useState<number | null>(1000);
const [isSliderControlled, setIsSliderControlled] = useState(false);

// Store Stripe context for credit card payments
Expand Down Expand Up @@ -317,11 +322,13 @@ function ContributeToFundraiseModalInner(props: Readonly<ContributeToFundraiseMo
setIsContributing(true);
setError(null);

let updatedPool: FundingPool | undefined;

if (paymentMethod === 'rsc' || paymentMethod === 'funding_credits') {
// The backend draws from funding credits only when that payment method
// is selected. Otherwise it draws from available and promotional RSC.
if (isPoolMode && fundingPool) {
await FundingPoolService.createContribution(fundingPool.id, {
updatedPool = await FundingPoolService.createContribution(fundingPool.id, {
amount: amountInRsc,
useCredits: paymentMethod === 'funding_credits',
});
Expand Down Expand Up @@ -419,7 +426,7 @@ function ContributeToFundraiseModalInner(props: Readonly<ContributeToFundraiseMo
refreshUser?.();

if (onContributeSuccess) {
onContributeSuccess();
onContributeSuccess(updatedPool);
}

handleClose();
Expand Down Expand Up @@ -549,7 +556,7 @@ function ContributeToFundraiseModalInner(props: Readonly<ContributeToFundraiseMo
]
);

const defaultTitle = isPoolMode ? 'Contribute to RFP' : 'Fund Proposal';
const defaultTitle = isPoolMode ? 'Add to the funding pool' : 'Fund Proposal';

// Get title based on current view
const getTitle = () => {
Expand Down Expand Up @@ -627,7 +634,7 @@ function ContributeToFundraiseModalInner(props: Readonly<ContributeToFundraiseMo
onChange={handleAmountChange}
icon={<DollarSign className="h-5 w-5 text-gray-500" />}
error={amountError}
label="Funding amount"
label="Amount in USD"
className="text-lg"
/>

Expand Down
220 changes: 220 additions & 0 deletions components/work/WorkHeader/GrantFundingPoolWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
'use client';

import { ArrowUpFromLine, Coins, Info } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Tooltip } from '@/components/ui/Tooltip';
import { SubmitProposalTooltip } from '@/components/tooltips/SubmitProposalTooltip';
import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext';
import type { FundingPool, FundingPoolAmount, GrantApplicationVisibility } from '@/types/grant';
import { formatCurrency } from '@/utils/currency';
import { formatRSC } from '@/utils/number';
import { cn } from '@/utils/styles';

/** A first $100 against a $25K grant is a sliver; floor it so it shows on the bar. */
const MIN_VISIBLE_PERCENT = 4;

function formatAmount(amount: FundingPoolAmount, showUSD: boolean): string {
return formatCurrency({
amount: showUSD ? amount.usd : amount.rsc,
showUSD,
exchangeRate: 1,
skipConversion: true,
});
}

/** $850, $2.4K, $12K. The shared shortener rounds $2.4K down to $2K, which misreads. */
function formatCompact(amount: FundingPoolAmount, showUSD: boolean): string {
if (!showUSD) return `${formatRSC({ amount: amount.rsc, shorten: true })} RSC`;
const usd = amount.usd;
if (usd < 1_000) return `$${Math.round(usd).toLocaleString()}`;
if (usd < 10_000) return `$${parseFloat((usd / 1_000).toFixed(1))}K`;

Check warning on line 30 in components/work/WorkHeader/GrantFundingPoolWidget.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseFloat` over `parseFloat`.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AaCRqxCLNM_GaF89dWg-&open=AaCRqxCLNM_GaF89dWg-&pullRequest=1104
if (usd < 1_000_000) return `$${Math.round(usd / 1_000)}K`;
return `$${parseFloat((usd / 1_000_000).toFixed(1))}M`;

Check warning on line 32 in components/work/WorkHeader/GrantFundingPoolWidget.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseFloat` over `parseFloat`.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AaCRqxCLNM_GaF89dWg_&open=AaCRqxCLNM_GaF89dWg_&pullRequest=1104
}

interface GrantFundingPoolWidgetProps {
organization: string;
grantAmount: FundingPoolAmount;
fundingPool: FundingPool;
/** Whether pool contributions are currently accepted. */
isOpen: boolean;
/** Whether the grant is accepting proposals. */
canApply: boolean;
applicationVisibility?: GrantApplicationVisibility;
/** Grant creators and moderators also see what is still waiting to be allocated. */
canManagePool: boolean;
onApply: () => void;
onContribute: () => void;
className?: string;
}

/**
* One card for both audiences: researchers apply, funders add to the pool.
* The breakdown is a single fixed-height line so the card matches the title
* block beside it; the full numbers live in the info tooltip.
*/
export function GrantFundingPoolWidget({
organization,
grantAmount,
fundingPool,
isOpen,
canApply,
applicationVisibility,
canManagePool,
onApply,
onContribute,
className,
}: GrantFundingPoolWidgetProps) {

Check warning on line 67 in components/work/WorkHeader/GrantFundingPoolWidget.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AaCRqxCLNM_GaF89dWhA&open=AaCRqxCLNM_GaF89dWhA&pullRequest=1104
const { showUSD } = useCurrencyPreference();

const raised = fundingPool.amountRaised;
const allocated = fundingPool.amountDistributed;
const holding = fundingPool.amountHolding;

const total: FundingPoolAmount = {
usd: (grantAmount.usd ?? 0) + (raised.usd ?? 0),
rsc: (grantAmount.rsc ?? 0) + (raised.rsc ?? 0),
};

const grantShare = showUSD ? grantAmount.usd : grantAmount.rsc;
const raisedShare = showUSD ? raised.usd : raised.rsc;
const totalShare = grantShare + raisedShare;
const hasCommunityFunding = raisedShare > 0;
const rawCommunityPercent = totalShare > 0 ? (raisedShare / totalShare) * 100 : 0;
const communityPercent = hasCommunityFunding
? Math.max(MIN_VISIBLE_PERCENT, rawCommunityPercent)
: 0;
const showHolding = canManagePool && isOpen;

const funderLabel = organization || 'The funder';

const breakdownRow = (label: string, value: string, emphasis = false) => (
<div className="flex items-center justify-between gap-4">
<span className={emphasis ? 'text-gray-900' : 'text-gray-500'}>{label}</span>
<span className={cn('font-mono tabular-nums', emphasis ? 'text-gray-900' : 'text-gray-700')}>
{value}
</span>
</div>
);

const breakdown = (
<div className="space-y-2 text-left text-xs">
<p className="text-sm leading-snug text-gray-800">
Community contributions are pooled and awarded to the strongest proposals by {funderLabel}.
</p>
<div className="space-y-1 border-t border-gray-200 pt-2">
{breakdownRow(funderLabel, formatAmount(grantAmount, showUSD))}
{breakdownRow('Community', formatAmount(raised, showUSD))}
{breakdownRow('Allocated to proposals', formatAmount(allocated, showUSD))}
{showHolding && breakdownRow('Ready to allocate', formatAmount(holding, showUSD), true)}
</div>
</div>
);

const nothingToDo = !canApply && !isOpen;

return (
<div
data-testid="grant-funding-pool"
className={cn('w-full rounded-xl border border-gray-200 bg-white px-3.5 py-3', className)}
>
<div className="flex items-baseline justify-between gap-3">
<span className="flex items-center gap-1 text-xs text-gray-500">
Funding pool
{!isOpen && <span className="text-gray-400">· Closed</span>}
<Tooltip content={breakdown} position="bottom" width="w-64" wrapperClassName="!h-auto">
<button
type="button"
className="flex items-center text-gray-400 transition-colors hover:text-gray-600"
aria-label="How the funding pool works"
data-testid="grant-funding-pool-info"
>
<Info className="h-3.5 w-3.5" />
</button>
</Tooltip>
</span>
<span className="font-mono text-base font-semibold text-gray-900 tabular-nums leading-none">
{formatAmount(total, showUSD)}
</span>
</div>

<div
className="mt-2 flex h-1.5 w-full overflow-hidden rounded bg-gray-100"
role="img"
aria-label={`${Math.round(100 - rawCommunityPercent)}% from ${funderLabel}, ${Math.round(
rawCommunityPercent
)}% from the community`}
>
<span
className="h-full bg-green-600 transition-all duration-300"
style={{ width: `${100 - communityPercent}%` }}
/>
<span
className="h-full bg-indigo-400 transition-all duration-300"
style={{ width: `${communityPercent}%` }}
/>
</div>

{/* Fixed-width labels ("Funder", "Community") so this line never wraps; the org name is in the subtitle beside it. */}
<div className="mt-1.5 flex h-4 items-center gap-3 whitespace-nowrap text-xs text-gray-500">
<span className="flex items-center gap-1.5" title={`${funderLabel}, original funder`}>
<span className="h-2 w-2 shrink-0 rounded-sm bg-green-600" aria-hidden="true" />

Check warning on line 161 in components/work/WorkHeader/GrantFundingPoolWidget.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Ambiguous spacing after previous element span

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AaCRqxCLNM_GaF89dWhB&open=AaCRqxCLNM_GaF89dWhB&pullRequest=1104
Funder
<span className="font-mono text-gray-900 tabular-nums">
{formatCompact(grantAmount, showUSD)}
</span>
</span>
{hasCommunityFunding ? (
<span className="flex items-center gap-1.5" title="Community contributions">
<span className="h-2 w-2 rounded-sm bg-indigo-400" aria-hidden="true" />

Check warning on line 169 in components/work/WorkHeader/GrantFundingPoolWidget.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Ambiguous spacing after previous element span

See more on https://sonarcloud.io/project/issues?id=ResearchHub_web&issues=AaCRqxCLNM_GaF89dWhC&open=AaCRqxCLNM_GaF89dWhC&pullRequest=1104
Community
<span className="font-mono text-gray-900 tabular-nums">
{formatCompact(raised, showUSD)}
</span>
</span>
) : (
<span className="flex items-center gap-1.5">
<span className="h-2 w-2 rounded-sm bg-gray-200" aria-hidden="true" />
{isOpen ? 'Be the first to add' : 'No community funding'}
</span>
)}
</div>

{nothingToDo ? (
<p className="mt-3 text-center text-xs text-gray-500">This RFP is closed</p>
) : (
<div className="mt-3 flex flex-col gap-2">
{canApply && (
<SubmitProposalTooltip
isPrivate={applicationVisibility === 'PRIVATE'}
wrapperClassName="w-full"
>
<Button
data-testid="grant-submit-proposal"
variant="default"
size="md"
onClick={onApply}
className="w-full gap-2"
>
Apply with proposal
<ArrowUpFromLine className="h-4 w-4" />
</Button>
</SubmitProposalTooltip>
)}
{isOpen && (
<Button
data-testid="grant-contribute"
variant="outlined"
size="md"
onClick={onContribute}
className="w-full gap-2"
>
<Coins className="h-4 w-4" />
Add to the pool
</Button>
)}
</div>
)}
</div>
);
}
4 changes: 4 additions & 0 deletions components/work/WorkHeader/WorkHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ interface WorkHeaderProps {
reviewsTabUrl?: string;
primaryAction?: ReactNode;
hideVoteWidget?: boolean;
/** Top-align title and CTA column; use when the CTA is a tall panel. */
alignTop?: boolean;
grantModalProps?: {
isApplyToGrantModalOpen: boolean;
onCloseApplyToGrantModal: () => void;
Expand All @@ -73,6 +75,7 @@ export function WorkHeader({
reviewsTabUrl: reviewsTabUrlOverride,
primaryAction,
hideVoteWidget = false,
alignTop = false,
grantModalProps,
}: WorkHeaderProps) {
const [isTipModalOpen, setIsTipModalOpen] = useState(false);
Expand Down Expand Up @@ -256,6 +259,7 @@ export function WorkHeader({
actions={actionBar}
cta={primaryAction}
className={className}
alignTop={alignTop}
>
<div className="mt-3 sm:mt-4">
<div className="min-w-0">{resolvedTabs}</div>
Expand Down
Loading
Loading