From a3ca7bc2fd6beaeb770964dfaa19c345d22e2983 Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Sat, 1 Aug 2026 16:34:20 +0300 Subject: [PATCH 1/2] Add fund sidebar power and recently-visited widgets. Introduce FundSidebar with funding power stats and recently visited suggestions so the homepage hub can mount a shared right rail. Co-authored-by: Cursor --- components/Funding/FundSidebar.tsx | 26 +++ components/Funding/FundingPowerCard.tsx | 227 +++++++++++++++++++++ components/Funding/RecentlyVisitedCard.tsx | 124 +++++++++++ components/Search/SearchSuggestions.tsx | 46 +++-- hooks/useSearchSuggestions.ts | 7 +- utils/searchHistory.ts | 4 + 6 files changed, 413 insertions(+), 21 deletions(-) create mode 100644 components/Funding/FundSidebar.tsx create mode 100644 components/Funding/FundingPowerCard.tsx create mode 100644 components/Funding/RecentlyVisitedCard.tsx diff --git a/components/Funding/FundSidebar.tsx b/components/Funding/FundSidebar.tsx new file mode 100644 index 000000000..c626e0972 --- /dev/null +++ b/components/Funding/FundSidebar.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { FundingPowerCard } from './FundingPowerCard'; +import { RecentlyVisitedCard, useRecentlyVisited } from './RecentlyVisitedCard'; +import { cn } from '@/utils/styles'; + +/** + * Shared Fund right column (Activity / RFPs / Proposals): funding power on top, + * recently visited beneath (dropped entirely once cleared / empty). + */ +export function FundSidebar() { + const recentlyVisited = useRecentlyVisited(); + const showsRecentlyVisited = recentlyVisited.pages.length > 0; + + return ( +
+ + {showsRecentlyVisited && ( + + )} +
+ ); +} diff --git a/components/Funding/FundingPowerCard.tsx b/components/Funding/FundingPowerCard.tsx new file mode 100644 index 000000000..0b9bc6343 --- /dev/null +++ b/components/Funding/FundingPowerCard.tsx @@ -0,0 +1,227 @@ +'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, and surfaces quick + * actions to deposit, earn, or fund research. + */ +export const FundingPowerCard = ({ className }: FundingPowerCardProps) => { + const { user, isLoading: isUserLoading } = useUser(); + const { showUSD } = useCurrencyPreference(); + const { exchangeRate, isLoading: isRateLoading } = useExchangeRate(); + + // Wait for auth + (when showing USD) a usable rate so we don't flash the + // empty CTA or an RSC figure that then swaps to dollars. + const isReady = !isUserLoading && (!showUSD || (!isRateLoading && exchangeRate > 0)); + + if (!isReady) { + return ; + } + + const fmt = (rscAmount: number) => + formatCurrency({ + amount: showUSD ? rscAmount * exchangeRate : rscAmount, + showUSD, + 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 ( + + ); +}; + +const FundingPowerCardSkeleton = ({ className }: { className?: string }) => ( + +); + +interface SourceRowProps { + label: string; + tooltip: string; + dotColor: string; + value: string; + valueClassName?: string; +} + +const SourceRow = ({ label, tooltip, dotColor, value, valueClassName }: SourceRowProps) => ( + +
+ + {label} + + {value} + +
+
+); + +interface CtaProps { + href: string; + children: React.ReactNode; + className?: string; +} + +const PrimaryCta = ({ href, children, className }: CtaProps) => ( + + {children} + +); + +const SecondaryCta = ({ href, children, className }: CtaProps) => ( + + {children} + +); diff --git a/components/Funding/RecentlyVisitedCard.tsx b/components/Funding/RecentlyVisitedCard.tsx new file mode 100644 index 000000000..c6f7530a6 --- /dev/null +++ b/components/Funding/RecentlyVisitedCard.tsx @@ -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 = { + 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 = { + 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(); + + 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) { + if (pages.length === 0) return null; + + return ( + + ); +} diff --git a/components/Search/SearchSuggestions.tsx b/components/Search/SearchSuggestions.tsx index e2b9947af..ad1eda2d5 100644 --- a/components/Search/SearchSuggestions.tsx +++ b/components/Search/SearchSuggestions.tsx @@ -17,10 +17,14 @@ interface SearchSuggestionsProps { suggestions?: SearchSuggestion[]; hasLocalSuggestions?: boolean; clearSearchHistory?: () => void; + /** Cap on rendered rows. Defaults to 7 (search modal results). */ + maxResults?: number; + /** When false, omit the Recent / Clear all header (caller owns chrome). */ + showRecentHeader?: boolean; } -// Maximum number of search results to display -const MAX_RESULTS = 7; +// Maximum number of search results to display by default +const DEFAULT_MAX_RESULTS = 7; // Maximum length for titles before truncating const MAX_TITLE_LENGTH = 100; @@ -40,6 +44,8 @@ export function SearchSuggestions({ suggestions = [], hasLocalSuggestions = false, clearSearchHistory, + maxResults = DEFAULT_MAX_RESULTS, + showRecentHeader = true, }: SearchSuggestionsProps) { const [erroredSuggestions, setErroredSuggestions] = useState>(new Set()); @@ -275,7 +281,7 @@ export function SearchSuggestions({ return false; } }) - .slice(0, MAX_RESULTS); // Limit to maximum number of results + .slice(0, maxResults); // Limit to maximum number of results // Group suggestions by recent vs search results for inline mode const recentSuggestions = safeSuggestions.filter((s) => s.isRecent); @@ -291,23 +297,25 @@ export function SearchSuggestions({ {/* Local suggestions section */} {showSuggestionsOnFocus && !query && hasLocalSuggestions && (
-
- - Recent - - -
+ + Recent + + +
+ )}
    {safeSuggestions.map(renderSuggestion)}
diff --git a/hooks/useSearchSuggestions.ts b/hooks/useSearchSuggestions.ts index b7ce55c65..1634f4682 100644 --- a/hooks/useSearchSuggestions.ts +++ b/hooks/useSearchSuggestions.ts @@ -1,7 +1,10 @@ import { useState, useEffect, useMemo } from 'react'; import { SearchService } from '@/services/search.service'; import { SearchSuggestion } from '@/types/search'; -import { getSearchHistory, SEARCH_HISTORY_KEY } from '@/utils/searchHistory'; +import { + getSearchHistory, + clearSearchHistory as clearStoredSearchHistory, +} from '@/utils/searchHistory'; import { EntityType } from '@/types/search'; interface UseSearchSuggestionsConfig { @@ -154,7 +157,7 @@ export function useSearchSuggestions({ // Clear all search history const clearSearchHistory = () => { if (!includeLocalSuggestions) return; - localStorage.removeItem(SEARCH_HISTORY_KEY); + clearStoredSearchHistory(); setLocalSuggestions([]); }; diff --git a/utils/searchHistory.ts b/utils/searchHistory.ts index ac7a79e64..905052948 100644 --- a/utils/searchHistory.ts +++ b/utils/searchHistory.ts @@ -26,3 +26,7 @@ export const saveSearchHistory = (items: SearchSuggestion[]) => { console.error('Error saving to localStorage:', error); } }; + +export const clearSearchHistory = () => { + saveSearchHistory([]); +}; From ea8ea0f6bed34196f23f491a42872eb826c107b5 Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Sun, 2 Aug 2026 17:27:49 +0300 Subject: [PATCH 2/2] Refactor FundingPowerCard to improve USD display logic and remove unnecessary comments in FundSidebar. --- components/Funding/FundSidebar.tsx | 4 ---- components/Funding/FundingPowerCard.tsx | 13 ++++++------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/components/Funding/FundSidebar.tsx b/components/Funding/FundSidebar.tsx index c626e0972..669acdd3c 100644 --- a/components/Funding/FundSidebar.tsx +++ b/components/Funding/FundSidebar.tsx @@ -4,10 +4,6 @@ import { FundingPowerCard } from './FundingPowerCard'; import { RecentlyVisitedCard, useRecentlyVisited } from './RecentlyVisitedCard'; import { cn } from '@/utils/styles'; -/** - * Shared Fund right column (Activity / RFPs / Proposals): funding power on top, - * recently visited beneath (dropped entirely once cleared / empty). - */ export function FundSidebar() { const recentlyVisited = useRecentlyVisited(); const showsRecentlyVisited = recentlyVisited.pages.length > 0; diff --git a/components/Funding/FundingPowerCard.tsx b/components/Funding/FundingPowerCard.tsx index 0b9bc6343..4ebd6a01d 100644 --- a/components/Funding/FundingPowerCard.tsx +++ b/components/Funding/FundingPowerCard.tsx @@ -17,26 +17,25 @@ interface FundingPowerCardProps { /** * Wallet card for the Activity sidebar. Leads with total funding power, - * visualizes the split between RSC and fund-only credits, and surfaces quick - * actions to deposit, earn, or fund research. + * 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(); - // Wait for auth + (when showing USD) a usable rate so we don't flash the - // empty CTA or an RSC figure that then swaps to dollars. - const isReady = !isUserLoading && (!showUSD || (!isRateLoading && exchangeRate > 0)); + const isReady = !isUserLoading && (!showUSD || !isRateLoading); if (!isReady) { return ; } + const canShowUSD = showUSD && exchangeRate > 0; + const fmt = (rscAmount: number) => formatCurrency({ - amount: showUSD ? rscAmount * exchangeRate : rscAmount, - showUSD, + amount: canShowUSD ? rscAmount * exchangeRate : rscAmount, + showUSD: canShowUSD, exchangeRate, shorten: true, skipConversion: true,