Skip to content
Open
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
22 changes: 22 additions & 0 deletions components/Funding/FundSidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
'use client';

import { FundingPowerCard } from './FundingPowerCard';
import { RecentlyVisitedCard, useRecentlyVisited } from './RecentlyVisitedCard';
import { cn } from '@/utils/styles';

export function FundSidebar() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wire the new sidebar into fund layouts

As of this commit, rg "FundSidebar" only finds this declaration and imports inside the same file; /fund, /fund/proposals, /grants, and the grant layout still pass FundingSidebarServer while /activity sets rightSidebar={false}. That means the funding power and recently visited widgets never render in any of the Activity/RFP/Proposal surfaces described here, so this ships as dead UI unless the existing right-sidebar wiring is replaced or composed with this component.

Useful? React with 👍 / 👎.

const recentlyVisited = useRecentlyVisited();
const showsRecentlyVisited = recentlyVisited.pages.length > 0;

return (
<div>
<FundingPowerCard className="w-full" />
{showsRecentlyVisited && (
<RecentlyVisitedCard
{...recentlyVisited}
className={cn('w-full', 'mt-4 border-t border-gray-200/80 pt-4')}
/>
)}
</div>
);
}
226 changes: 226 additions & 0 deletions components/Funding/FundingPowerCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
'use client';

import Link from 'next/link';
import { Plus } from 'lucide-react';
import { RSC_COLORS } from '@/components/ui/icons/ResearchCoinIcon';
import { Tooltip } from '@/components/ui/Tooltip';
import { formatCurrency } from '@/utils/currency';
import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext';
import { useExchangeRate } from '@/contexts/ExchangeRateContext';
import { useUser } from '@/contexts/UserContext';
import { getAvailableAndPromotionalRscBalance } from '@/components/ResearchCoin/lib/promotionalBalance';
import { cn } from '@/utils/styles';

interface FundingPowerCardProps {
className?: string;
}

/**
* Wallet card for the Activity sidebar. Leads with total funding power,
* visualizes the split between RSC and fund-only credits
*/
export const FundingPowerCard = ({ className }: FundingPowerCardProps) => {
const { user, isLoading: isUserLoading } = useUser();
const { showUSD } = useCurrencyPreference();
const { exchangeRate, isLoading: isRateLoading } = useExchangeRate();

const isReady = !isUserLoading && (!showUSD || !isRateLoading);

if (!isReady) {
return <FundingPowerCardSkeleton className={className} />;
}

const canShowUSD = showUSD && exchangeRate > 0;

const fmt = (rscAmount: number) =>
formatCurrency({
amount: canShowUSD ? rscAmount * exchangeRate : rscAmount,
showUSD: canShowUSD,
exchangeRate,
shorten: true,
skipConversion: true,
});

const balanceRaw = getAvailableAndPromotionalRscBalance(user);
const creditsRaw = user?.fundingCredits ?? 0;
const total = balanceRaw + creditsRaw;
const isEmpty = !user || total === 0;

const rscWidth = total > 0 ? (balanceRaw / total) * 100 : 0;
const creditsWidth = total > 0 ? (creditsRaw / total) * 100 : 0;

return (
<aside className={cn('w-[250px]', className)}>
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-500">
Funding power
</p>

<div className="mt-1.5 flex items-center justify-between gap-2">
<span
className={cn(
'font-mono text-2xl font-bold leading-none tracking-tight',
isEmpty ? 'text-gray-300' : 'text-gray-900'
)}
>
{isEmpty ? '—' : fmt(total)}
</span>
{!isEmpty && (
<Link
href="/researchcoin?action=deposit"
className="inline-flex items-center gap-1 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-[13px] font-semibold text-gray-800 shadow-sm transition-colors hover:border-gray-300 hover:bg-gray-50"
>
<Plus size={14} className="shrink-0" />
Deposit
</Link>
)}
</div>

{!isEmpty && (
<div className="mt-3 flex h-2 overflow-hidden rounded-full bg-white">
<div style={{ width: `${rscWidth}%` }}>
<Tooltip
content={
<div className="text-left">
<div className="text-sm font-bold text-gray-900 mb-1">ResearchCoin</div>
<div className="text-sm font-semibold text-gray-900">{fmt(balanceRaw)}</div>
</div>
}
position="top"
width="w-56"
className="text-left"
wrapperClassName="!flex w-full"
>
<span
className="block h-full w-full cursor-help transition-[filter] duration-150 hover:brightness-110"
style={{ backgroundColor: RSC_COLORS.orange }}
/>
</Tooltip>
</div>
<div style={{ width: `${creditsWidth}%` }}>
<Tooltip
content={
<div className="text-left">
<div className="text-sm font-bold text-gray-900 mb-1">Funding Credits</div>
<div className="text-sm font-semibold text-gray-900">{fmt(creditsRaw)}</div>
</div>
}
position="top"
width="w-56"
className="text-left"
wrapperClassName="!flex w-full"
>
<span
className="block h-full w-full cursor-help transition-[filter] duration-150 hover:brightness-110"
style={{ backgroundColor: RSC_COLORS.green }}
/>
</Tooltip>
</div>
</div>
)}

{isEmpty && (
<p className="mt-2.5 text-[13px] leading-snug text-gray-500">
Deposit ResearchCoin or earn fund-only credits by peer reviewing — then put it toward
research you believe in.
</p>
)}

{isEmpty && (
<div className="mt-2.5 flex gap-2">
<PrimaryCta href="/researchcoin?action=deposit">Deposit RSC</PrimaryCta>
<SecondaryCta href="/earn">Earn credits</SecondaryCta>
</div>
)}

{!isEmpty && (
<div className="mt-1">
<SourceRow
label="ResearchCoin"
tooltip="RSC you own. Spend it on funding, tipping, and more — or withdraw it anytime."
dotColor={RSC_COLORS.orange}
value={fmt(balanceRaw)}
valueClassName="text-gray-900"
/>
<SourceRow
label="Funding Credits"
tooltip="Earned automatically as yield on the ResearchCoin you hold. Credits can only be used to fund research."
dotColor={RSC_COLORS.green}
value={fmt(creditsRaw)}
valueClassName="text-[#19a74e]"
/>
</div>
)}
</aside>
);
};

const FundingPowerCardSkeleton = ({ className }: { className?: string }) => (
<aside className={cn('w-[250px] animate-pulse', className)} aria-hidden>
<div className="h-3 w-24 rounded bg-gray-200" />
<div className="mt-2.5 flex items-center justify-between gap-2">
<div className="h-7 w-20 rounded bg-gray-200" />
<div className="h-8 w-[88px] rounded-lg bg-gray-200" />
</div>
<div className="mt-3 h-2 w-full rounded-full bg-gray-200" />
<div className="mt-2 space-y-2">
<div className="flex items-center justify-between gap-2 py-1.5">
<div className="h-3.5 w-28 rounded bg-gray-200" />
<div className="h-3.5 w-12 rounded bg-gray-200" />
</div>
<div className="flex items-center justify-between gap-2 py-1.5">
<div className="h-3.5 w-32 rounded bg-gray-200" />
<div className="h-3.5 w-12 rounded bg-gray-200" />
</div>
</div>
</aside>
);

interface SourceRowProps {
label: string;
tooltip: string;
dotColor: string;
value: string;
valueClassName?: string;
}

const SourceRow = ({ label, tooltip, dotColor, value, valueClassName }: SourceRowProps) => (
<Tooltip content={tooltip} position="top" width="w-56" wrapperClassName="w-full">
<div className="flex w-full cursor-help items-center gap-2 rounded-md py-1.5 transition-colors hover:bg-white/70">
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: dotColor }} />
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-gray-700">{label}</span>
<span className={cn('shrink-0 font-mono text-[13px] font-semibold', valueClassName)}>
{value}
</span>
</div>
</Tooltip>
);

interface CtaProps {
href: string;
children: React.ReactNode;
className?: string;
}

const PrimaryCta = ({ href, children, className }: CtaProps) => (
<Link
href={href}
className={cn(
'inline-flex flex-1 items-center justify-center rounded-full bg-primary-600 px-3 py-1 text-xs font-semibold text-white transition-colors hover:bg-primary-700',
className
)}
>
{children}
</Link>
);

const SecondaryCta = ({ href, children, className }: CtaProps) => (
<Link
href={href}
className={cn(
'inline-flex flex-1 items-center justify-center rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-700 transition-colors hover:border-primary-300 hover:text-primary-700',
className
)}
>
{children}
</Link>
);
124 changes: 124 additions & 0 deletions components/Funding/RecentlyVisitedCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
'use client';

import { useCallback, useMemo, useState } from 'react';
import Link from 'next/link';
import { useActivityFeed } from '@/hooks/useActivityFeed';
import { getEntryMeta } from '@/components/Activity/lib/feedEntryAdapters';
import { cn } from '@/utils/styles';

const MAX_ITEMS = 10;

const ENTRY_TYPE_LABELS: Record<string, string> = {
GRANT: 'Request for Proposal',
PREREGISTRATION: 'Proposal',
USDFUNDRAISECONTRIBUTION: 'Proposal',
PURCHASE: 'Proposal',
PAPER: 'Paper',
POST: 'Post',
};

/** Comment/bounty entries point at a document, so label them by that work. */
const WORK_TYPE_LABELS: Record<string, string> = {
paper: 'Paper',
post: 'Post',
preregistration: 'Proposal',
question: 'Question',
discussion: 'Discussion',
funding_request: 'Request for Proposal',
};

interface RecentPage {
href: string;
title: string;
typeLabel?: string;
}

export interface RecentlyVisited {
pages: RecentPage[];
clear: () => void;
}

/**
* The viewer's recent pages plus the ability to forget them. Lifted out of the
* card so the surrounding column can drop the section entirely once it's
* cleared, rather than leaving an empty panel behind.
*
* Sources the activity feed until real visit tracking exists.
*/
export function useRecentlyVisited(): RecentlyVisited {
const { entries, isLoading } = useActivityFeed();
const [isCleared, setIsCleared] = useState(false);

const pages = useMemo(() => {
const collected: RecentPage[] = [];
const seen = new Set<string>();

for (const entry of entries) {
const { title, href } = getEntryMeta(entry);
if (!title || !href || seen.has(href)) continue;
seen.add(href);
const relatedType = entry.relatedWork?.contentType;
collected.push({
href,
title,
typeLabel:
ENTRY_TYPE_LABELS[entry.contentType] ??
(relatedType ? WORK_TYPE_LABELS[relatedType] : undefined),
});
if (collected.length === MAX_ITEMS) break;
}

return collected;
}, [entries]);

const clear = useCallback(() => setIsCleared(true), []);

return { pages: isCleared || (isLoading && pages.length === 0) ? [] : pages, clear };
}

interface RecentlyVisitedCardProps extends RecentlyVisited {
className?: string;
}

/**
* Lightweight browsing history for the Activity sidebar: a plain text list of
* documents from the activity feed, no thumbnails or metrics.
*/
export function RecentlyVisitedCard({ pages, clear, className }: RecentlyVisitedCardProps) {

Check warning on line 87 in components/Funding/RecentlyVisitedCard.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=AZ-9jQj2YF6UPUsdXExg&open=AZ-9jQj2YF6UPUsdXExg&pullRequest=982
if (pages.length === 0) return null;

return (
<aside className={cn('w-[250px]', className)}>
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] font-semibold uppercase tracking-wider text-gray-500">
Recently visited
</p>
<button
type="button"
onClick={clear}
className="shrink-0 text-[11px] font-semibold text-gray-400 transition-colors hover:text-gray-700"
>
Clear
</button>
</div>

<ul className="-mx-2 mt-1.5">
{pages.map((page) => (
<li key={page.href}>
<Link
href={page.href}
className="block rounded-md px-2 py-1.5 transition-colors hover:bg-white"
>
<span className="line-clamp-2 text-[13px] font-medium leading-snug text-gray-700">
{page.title}
</span>
{page.typeLabel && (
<span className="mt-0.5 block text-[11px] text-gray-400">{page.typeLabel}</span>
)}
</Link>
</li>
))}
</ul>
</aside>
);
}
Loading