-
Notifications
You must be signed in to change notification settings - Fork 21
Fund sidebar: power card + recently visited #982
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
nicktytarenko
wants to merge
3
commits into
stack/01-activity-feed-redesign
Choose a base branch
from
stack/02-fund-sidebar-widgets
base: stack/01-activity-feed-redesign
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() { | ||
| 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> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
|
||
| 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> | ||
| ); | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 passFundingSidebarServerwhile/activitysetsrightSidebar={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 👍 / 👎.