diff --git a/app/(home)/layout.tsx b/app/(home)/layout.tsx index 7c9573e32..7dc2c68c8 100644 --- a/app/(home)/layout.tsx +++ b/app/(home)/layout.tsx @@ -2,18 +2,22 @@ import { ReactNode } from 'react'; import { PageLayout } from '@/app/layouts/PageLayout'; import { FundSidebar } from '@/components/Funding/FundSidebar'; import { HomeTabs } from '@/components/Funding/HomeTabs'; +import { HomeFeedsProvider } from '@/components/Funding/HomeFeedsProvider'; /** - * Shared shell for homepage hub tabs (Activity / Fund / Proposals). - * Keeps PageLayout, HomeTabs, and FundSidebar mounted while only the feed slot swaps. + * Shared shell for homepage tabs (Activity / Fund / Proposals). + * Keeps PageLayout, HomeTabs, feed providers, and FundSidebar mounted while + * only the feed slot swaps. * * `/fund/dashboard` lives outside this group and keeps its own layout. */ export default function HomeLayout({ children }: { children: ReactNode }) { return ( }> - - {children} + + + {children} + ); } diff --git a/app/activity/page.tsx b/app/activity/page.tsx index 00bc7dfae..8c6f3a36a 100644 --- a/app/activity/page.tsx +++ b/app/activity/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useRouter, useSearchParams } from 'next/navigation'; +import { useRouter, useSearchParams, usePathname } from 'next/navigation'; import { useInView } from 'react-intersection-observer'; import { LayoutList, Star, Coins, Reply } from 'lucide-react'; import { PageLayout } from '@/app/layouts/PageLayout'; @@ -10,6 +10,8 @@ import { PillTabs } from '@/components/ui/PillTabs'; import { ActivityCardFull } from '@/components/Activity/ActivityCardFull'; import { ActivityCardSkeleton } from '@/components/Activity/ActivityCardSkeleton'; import { useActivityFeed, ActivityTab } from '@/hooks/useActivityFeed'; +import { useFeedScrollTracking } from '@/hooks/useFeedScrollTracking'; +import { getFeedKey } from '@/contexts/NavigationContext'; import { ActivityScope } from '@/services/activity.service'; import { GrantService } from '@/services/grant.service'; @@ -31,6 +33,7 @@ function isValidTab(value: string | null): value is ActivityTab { export default function ActivityPage() { const router = useRouter(); + const pathname = usePathname(); const searchParams = useSearchParams(); const tabParam = searchParams.get('tab'); @@ -50,11 +53,42 @@ export default function ActivityPage() { }); }, [grantIdParam]); - const { entries, isLoading, isLoadingMore, hasMore, loadMore } = useActivityFeed({ + const { + entries, + isLoading, + isLoadingMore, + hasMore, + page, + loadMore, + restoredScrollPosition, + lastClickedEntryId, + restorationTab, + } = useActivityFeed({ scope, grantId: grantIdParam || undefined, }); + const feedKey = useMemo(() => { + const queryParams: Record = {}; + for (const [key, value] of searchParams) { + queryParams[key] = value; + } + return getFeedKey({ + pathname, + tab: restorationTab, + queryParams: Object.keys(queryParams).length > 0 ? queryParams : undefined, + }); + }, [pathname, restorationTab, searchParams]); + + useFeedScrollTracking({ + feedKey, + entries, + hasMore, + page, + restoredScrollPosition, + lastClickedEntryId: lastClickedEntryId ?? undefined, + }); + const { ref: sentinelRef } = useInView({ threshold: 0, rootMargin: '200px', diff --git a/app/fund/FundGrantsPageContent.tsx b/app/fund/FundGrantsPageContent.tsx index 7e25cf174..c483535ee 100644 --- a/app/fund/FundGrantsPageContent.tsx +++ b/app/fund/FundGrantsPageContent.tsx @@ -1,37 +1,24 @@ 'use client'; -import { useMemo, useState } from 'react'; +import { useEffect } from 'react'; import { FeedContent } from '@/components/Feed/FeedContent'; -import { useFeed } from '@/hooks/useFeed'; import { GrantSortAndFilters } from '@/components/Funding/GrantSortAndFilters'; -import type { GrantSortOption } from '@/components/Funding/lib/grantSortConfig'; +import { useGrantFeed } from '@/contexts/GrantFeedContext'; export function FundGrantsPageContent() { - const [grantSort, setGrantSort] = useState('newest'); + const { entries, isLoading, hasMore, loadMore, sortBy, setSortBy, activate } = useGrantFeed(); - const grantFeedOptions = useMemo( - () => ({ - endpoint: 'grant_feed' as const, - contentType: 'GRANT', - ordering: grantSort, - }), - [grantSort] - ); - - const { - entries: grantEntries, - isLoading: isGrantFeedLoading, - hasMore: hasMoreGrants, - loadMore: loadMoreGrants, - } = useFeed('all', grantFeedOptions); + useEffect(() => { + activate(); + }, [activate]); return ( } + entries={entries} + isLoading={isLoading} + hasMore={hasMore} + loadMore={loadMore} + filters={} skeletonVariant="grant" showGrantHeaders={false} showPostHeaders={false} diff --git a/app/grant/[id]/[slug]/layout.tsx b/app/grant/[id]/[slug]/layout.tsx index 22a1e8ba7..217753407 100644 --- a/app/grant/[id]/[slug]/layout.tsx +++ b/app/grant/[id]/[slug]/layout.tsx @@ -12,6 +12,7 @@ import { isDeadlineInFuture } from '@/utils/date'; import { GrantTabProvider } from '@/components/Funding/GrantPageContent'; import { WorkHeaderGrant } from '@/components/work/WorkHeader/index'; import { RegisteredReportRouteTrackerLoader } from '@/components/work/RegisteredReportRouteTrackerLoader'; +import { SearchHistoryTracker } from '@/components/work/SearchHistoryTracker'; interface Props { params: Promise<{ @@ -96,6 +97,7 @@ export default async function GrantSlugLayout({ params, children }: Props) { } > {children} + ); diff --git a/app/layouts/LeftSidebar.tsx b/app/layouts/LeftSidebar.tsx index b2fc5e385..2c5ba369d 100644 --- a/app/layouts/LeftSidebar.tsx +++ b/app/layouts/LeftSidebar.tsx @@ -55,21 +55,17 @@ export const LeftSidebar: React.FC = ({ forceMinimize = false -
+
-
-
- -
-
+
diff --git a/app/layouts/Navigation.tsx b/app/layouts/Navigation.tsx index db6bbae2f..ccdb06d7b 100644 --- a/app/layouts/Navigation.tsx +++ b/app/layouts/Navigation.tsx @@ -2,7 +2,6 @@ import { useAuthenticatedAction } from '@/contexts/AuthModalContext'; import { useRouter } from 'next/navigation'; -import { useCallback } from 'react'; import Link from 'next/link'; import Icon from '@/components/ui/icons/Icon'; import { IconName } from '@/components/ui/icons/Icon'; @@ -13,6 +12,7 @@ import { Sprout, Star } from 'lucide-react'; import { Badge } from '@/components/ui/Badge'; import { useDismissableFeature } from '@/hooks/useDismissableFeature'; import { isHomeTabPath } from '@/hooks/useFundTabs'; +import { cn } from '@/utils/styles'; const ENDOWMENT_NAV_FEATURE = 'endowment_nav_new_badge'; // Stop showing the "New" badge on the Endowment nav item after this date, @@ -80,9 +80,6 @@ export const Navigation: React.FC = ({ onUnimplementedFeature, forceMinimize = false, }) => { - const { executeAuthenticatedAction } = useAuthenticatedAction(); - const router = useRouter(); - // Dismissable "New" badge for the Endowment nav item. Lifted to the parent // so the click handler in NavLink can call dismissFeature() without each // NavLink unconditionally calling the hook. @@ -92,13 +89,6 @@ export const Navigation: React.FC = ({ dismissStatus: endowmentBadgeStatus, } = useDismissableFeature(ENDOWMENT_NAV_FEATURE); - const handleNavigate = useCallback( - (href: string) => { - router.push(href); - }, - [router] - ); - const navigationItems: NavigationItem[] = [ { label: 'Home', @@ -114,6 +104,13 @@ export const Navigation: React.FC = ({ requiresAuth: true, description: 'Track the impact of the research you fund', }, + { + label: 'Notebook', + href: '/notebook', + iconKey: 'notebook', + requiresAuth: true, + description: 'Access your research notebook', + }, { label: 'Peer Review', href: '/earn', @@ -136,17 +133,18 @@ export const Navigation: React.FC = ({ }, ]; - const getButtonStyles = (path: string, currentPath: string) => { + const getButtonStyles = (path: string) => { const isActive = isPathActive(path); - // Use either responsive or force minimized classes - const responsiveClasses = forceMinimize - ? '!px-2 !justify-center' - : 'tablet:max-sidebar-compact:!px-2 tablet:max-sidebar-compact:!justify-center'; - - return isActive - ? `flex items-center w-full px-5 py-3.5 text-[15px] font-medium text-primary-600 ${responsiveClasses} bg-primary-50 rounded-lg group` - : `flex items-center w-full px-5 py-3.5 text-[15px] font-medium text-gray-700 ${responsiveClasses} hover:bg-gray-50 rounded-lg group`; + return cn( + 'flex w-full items-center rounded-lg px-3 py-2.5 text-[15px] transition-colors', + forceMinimize + ? '!justify-center !px-2' + : 'tablet:max-sidebar-compact:!justify-center tablet:max-sidebar-compact:!px-2', + isActive + ? 'bg-primary-50 font-semibold text-primary-600' + : 'font-medium text-gray-700 hover:bg-gray-50' + ); }; const isPathActive = (path: string) => { @@ -185,7 +183,7 @@ export const Navigation: React.FC = ({ }> = ({ item, currentPath, onUnimplementedFeature, showNewBadge, onDismissNew }) => { const { executeAuthenticatedAction } = useAuthenticatedAction(); const router = useRouter(); - const buttonStyles = getButtonStyles(item.href, currentPath); + const buttonStyles = getButtonStyles(item.href); const isActive = isPathActive(item.href); // Set icon colors based on active state @@ -220,26 +218,32 @@ export const Navigation: React.FC = ({ // Determine if the current item is the Home item using FontAwesome const isHomeIcon = item.isFontAwesome && item.iconKey === 'home'; - // Conditionally apply minimized classes - const iconContainerClass = forceMinimize - ? 'h-[26px] w-[26px] mr-0 flex items-center justify-center flex-shrink-0' - : 'h-[26px] w-[26px] mr-4 tablet:max-sidebar-compact:!mr-0 flex items-center justify-center flex-shrink-0'; + const iconContainerClass = cn( + 'flex h-[26px] w-[26px] flex-shrink-0 items-center justify-center', + forceMinimize ? 'mr-0' : 'mr-3.5 tablet:max-sidebar-compact:!mr-0' + ); const textContainerClass = forceMinimize - ? 'flex items-center justify-between w-full min-w-0 !hidden' - : 'w-full min-w-0 tablet:max-sidebar-compact:!hidden'; + ? 'hidden' + : 'flex w-full min-w-0 items-center tablet:max-sidebar-compact:!hidden'; return ( - +
{isHomeIcon ? ( ) : item.isLucideSprout ? ( - + ) : item.isLucideStar ? ( = ({ )}
- + {item.label} {showNewBadge && ( = ({ }; return ( -
- {navigationItems.map((item) => { - const isBeforeEndowmentCutoff = Date.now() < ENDOWMENT_NEW_BADGE_CUTOFF.getTime(); - const isEndowmentNewBadge = - item.newFeatureName === ENDOWMENT_NAV_FEATURE && - item.isNew === true && - isBeforeEndowmentCutoff && - endowmentBadgeStatus === 'checked' && - !isEndowmentBadgeDismissed; - - return ( - - ); - })} -
+ ); }; diff --git a/app/layouts/PageLayout.tsx b/app/layouts/PageLayout.tsx index c4b817bde..45ad7a68c 100644 --- a/app/layouts/PageLayout.tsx +++ b/app/layouts/PageLayout.tsx @@ -103,7 +103,7 @@ function PageLayoutInner({ > {topBanner &&
{topBanner}
} -
+
, + title: 'RFP', + description: 'Fund research', + icon: , handler: 'handleOpenGrant' as const, requiresAuth: true, }, @@ -39,7 +39,7 @@ const PUBLISH_MENU_SECTIONS = [ id: 'request-funding', title: 'Proposal', description: 'Raise money for your research', - icon: , + icon: , handler: 'handleFundResearch' as const, requiresAuth: true, }, @@ -55,26 +55,25 @@ interface MenuItemContentProps { const MenuItemContent: React.FC = ({ icon, title, description }) => { return ( -
-
-
- {icon} -
+
+
+ {icon}
-
-
{title}
-
{description}
+
+
{title}
+
{description}
- +
); }; -export const PublishMenu: React.FC = ({ children, forceMinimize = false }) => { +export const PublishMenu: React.FC = ({ forceMinimize = false }) => { const router = useRouter(); const { executeAuthenticatedAction } = useAuthenticatedAction(); const { smAndDown } = useScreenSize(); const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false); + const [isDesktopMenuOpen, setIsDesktopMenuOpen] = useState(false); const [isFundingOpportunityModalOpen, setIsFundingOpportunityModalOpen] = useState(false); const [isProposalModalOpen, setIsProposalModalOpen] = useState(false); @@ -116,71 +115,72 @@ export const PublishMenu: React.FC = ({ children, forceMinimiz } }; - // Regular trigger for standard mode - const standardTrigger = ( + const isMenuOpen = smAndDown ? isMobileDrawerOpen : isDesktopMenuOpen; + const trigger = ( - ); - - // Compact trigger for minimized sidebar - const compactTrigger = ( - ); const menuContent = ( -
- {PUBLISH_MENU_SECTIONS.map((section) => ( -
-
- {section.items.map((item) => ( - handleMenuItemClick(item)} - className="group w-full cursor-pointer px-2 py-2 rounded-lg transition-colors duration-150 hover:bg-gray-100 focus:bg-gray-100" - > - - - ))} + <> +

+ Create new +

+
+ {PUBLISH_MENU_SECTIONS.map((section) => ( +
+
+ {section.items.map((item) => ( + handleMenuItemClick(item)} + className="group w-full cursor-pointer rounded-lg px-2.5 py-2 transition-colors duration-150 hover:bg-gray-50 focus:bg-gray-50" + > + + + ))} +
-
- ))} -
+ ))} +
+ ); // Mobile drawer content const mobileDrawerContent = (
+

Create new

{PUBLISH_MENU_SECTIONS.map((section) => (
-
+
{section.items.map((item) => (
= ({ children, forceMinimiz {/* Mobile view with SwipeableDrawer */} {smAndDown && ( <> -
setIsMobileDrawerOpen(true)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - setIsMobileDrawerOpen(true); - } - }} - role="button" - tabIndex={0} - aria-label="Open post menu" - > - {standardTrigger} - {compactTrigger} -
+ {trigger} setIsMobileDrawerOpen(false)} @@ -242,31 +228,17 @@ export const PublishMenu: React.FC = ({ children, forceMinimiz {/* Desktop view with BaseMenu */} {!smAndDown && ( - <> - {/* Standard Menu */} - - {menuContent} - - - {/* Compact Menu - same content, different trigger */} - - {menuContent} - - + + {menuContent} + )} } - {pageInfo && } + {pageInfo && ( + + )} )} {/* Inline content tabs — desktop only, shown once page tabs scroll away */} {showTopBarFeedTabs && ( -
+
)} {showTopBarFundTabs && ( -
- +
)} @@ -161,17 +169,17 @@ export function TopBar({ onMenuClick }: TopBarProps) {
- {/* Feed tabs — mobile only, stacked below title when content tabs scroll out of view */} + {/* Content tabs — mobile only, stacked below title when page tabs scroll out of view */} {(isFeedPage || isFundPage) && (
- {isFeedPage && ( + {showTopBarFeedTabs && ( )} - {isFundPage && ( - )}
diff --git a/app/layouts/components/RightSidebarContainer.tsx b/app/layouts/components/RightSidebarContainer.tsx index 481f8af49..1556dbc23 100644 --- a/app/layouts/components/RightSidebarContainer.tsx +++ b/app/layouts/components/RightSidebarContainer.tsx @@ -58,7 +58,7 @@ export function RightSidebarContainer({ 'sticky top-0 mt-10 z-30', 'h-[calc(100vh-var(--top-bar-height))]', 'lg:!flex !hidden right-sidebar:!flex', - 'w-80 flex-shrink-0 flex-col gap-3' + 'w-72 flex-shrink-0 flex-col gap-3' )} > {aboveSidebar} diff --git a/app/layouts/topbar/TopBarBreadcrumb.tsx b/app/layouts/topbar/TopBarBreadcrumb.tsx index 184bf6133..143cef289 100644 --- a/app/layouts/topbar/TopBarBreadcrumb.tsx +++ b/app/layouts/topbar/TopBarBreadcrumb.tsx @@ -1,30 +1,40 @@ import type { PageInfo } from './pageRoutes'; +import { cn } from '@/utils/styles'; interface TopBarBreadcrumbProps { pageInfo: PageInfo; variant: 'mobile' | 'desktop'; + /** Ellipsize a long title so sibling controls (e.g. sticky tab pills) stay visible. */ + truncateTitle?: boolean; } -export const TopBarBreadcrumb = ({ pageInfo, variant }: TopBarBreadcrumbProps) => { +export const TopBarBreadcrumb = ({ + pageInfo, + variant, + truncateTitle = false, +}: TopBarBreadcrumbProps) => { const isMobile = variant === 'mobile'; const containerClass = isMobile - ? 'flex tablet:!hidden items-center min-w-0' + ? 'flex min-w-0 flex-1 items-center overflow-hidden tablet:!hidden' : 'hidden tablet:!flex items-center min-w-0'; const titleClass = isMobile - ? 'leading-tight flex-shrink-0 font-semibold text-gray-900 text-lg' - : 'leading-tight flex-shrink-0 font-semibold text-gray-900'; + ? 'block min-w-0 truncate text-lg font-semibold leading-tight text-gray-900' + : cn( + 'leading-tight font-semibold text-gray-900', + truncateTitle ? 'min-w-0 truncate' : 'flex-shrink-0' + ); - const titleStyle = isMobile ? undefined : { fontSize: '24px', letterSpacing: '-0.5px' }; + const titleStyle = isMobile ? undefined : { fontSize: '26px', letterSpacing: '-0.5px' }; return (
{pageInfo.title ? ( - + {pageInfo.title} ) : ( diff --git a/app/layouts/topbar/TopBarSearchButton.tsx b/app/layouts/topbar/TopBarSearchButton.tsx index a590a9125..8f430edef 100644 --- a/app/layouts/topbar/TopBarSearchButton.tsx +++ b/app/layouts/topbar/TopBarSearchButton.tsx @@ -33,7 +33,7 @@ function TopBarSearchButtonInner({
- ); - })(); - return ( -
+
@@ -106,49 +71,17 @@ export const ActivityCardFull: FC = ({ entry }) => { )}
- - } - /> + + + + + + + +
- - {work.fundraise && ( - setIsContributeModalOpen(false)} - onContributeSuccess={handleContributeSuccess} - fundraise={work.fundraise} - proposalTitle={work.title} - /> - )}
); }; diff --git a/components/Activity/ActivityCardHeader.tsx b/components/Activity/ActivityCardHeader.tsx index 98bc2b8e3..1504a61bb 100644 --- a/components/Activity/ActivityCardHeader.tsx +++ b/components/Activity/ActivityCardHeader.tsx @@ -79,6 +79,7 @@ export const ActivityCardHeader: FC = ({ entry }) => { )} + {message.suffix && {message.suffix}}
diff --git a/components/Activity/ActivityCardSkeleton.tsx b/components/Activity/ActivityCardSkeleton.tsx index 5f02667f7..dedc0cf49 100644 --- a/components/Activity/ActivityCardSkeleton.tsx +++ b/components/Activity/ActivityCardSkeleton.tsx @@ -16,11 +16,16 @@ export const ActivityCardSkeleton: FC = () => (
-
-
-
-
-
+ {/* Matches compact FeedItemActions: flat vote/save/share + CTA */} +
+
+
+
+
+
+
+
+
diff --git a/components/Activity/ActivityWorkActions.tsx b/components/Activity/ActivityWorkActions.tsx new file mode 100644 index 000000000..bd2c9a07c --- /dev/null +++ b/components/Activity/ActivityWorkActions.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { FC, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { ArrowRight } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { FeedItemActions } from '@/components/Feed/FeedItemActions'; +import { ContributeToFundraiseModal } from '@/components/modals/ContributeToFundraiseModal'; +import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; +import { useExchangeRate } from '@/contexts/ExchangeRateContext'; +import { useShareModalContext } from '@/contexts/ShareContext'; +import { getCommentPreview } from './lib/feedEntryAdapters'; +import { getWorkCardPresentation, type ActivityWork } from './lib/activityWorkContext'; +import type { FeedEntry } from '@/types/feed'; + +interface ActivityWorkActionsProps { + entry: FeedEntry; + work: ActivityWork; + /** Called when a link CTA navigates away (e.g. scroll-restore click tracking). */ + onNavigate?: () => void; +} + +/** + * Footer actions for an activity work card (votes/share + CTA), including fund modal. + */ +export const ActivityWorkActions: FC = ({ entry, work, onNavigate }) => { + const router = useRouter(); + const { showUSD } = useCurrencyPreference(); + const { exchangeRate } = useExchangeRate(); + const { showShareModal } = useShareModalContext(); + const [isContributeModalOpen, setIsContributeModalOpen] = useState(false); + + const commentPreview = getCommentPreview(entry); + const presentation = getWorkCardPresentation(entry, work, { + showUSD, + exchangeRate, + isReview: commentPreview?.isReview, + }); + + const voteCount = entry.metrics?.adjustedScore ?? entry.metrics?.votes ?? 0; + const feedContentType = work.documentType === 'paper' ? 'PAPER' : 'POST'; + const cta = presentation.cta; + + const handleContributeSuccess = () => { + setIsContributeModalOpen(false); + showShareModal({ + url: window.location.href, + docTitle: work.title, + action: 'USER_FUNDED_PROPOSAL', + }); + router.refresh(); + }; + + const rightSideActionButton = cta ? ( + + ) : undefined; + + return ( + <> + + + {work.fundraise && ( + setIsContributeModalOpen(false)} + onContributeSuccess={handleContributeSuccess} + fundraise={work.fundraise} + proposalTitle={work.title} + /> + )} + + ); +}; diff --git a/components/Activity/ActivityWorkMetadata.tsx b/components/Activity/ActivityWorkMetadata.tsx new file mode 100644 index 000000000..9f20cff96 --- /dev/null +++ b/components/Activity/ActivityWorkMetadata.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { FC } from 'react'; +import { Star } from 'lucide-react'; +import { cn } from '@/utils/styles'; +import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; +import { useExchangeRate } from '@/contexts/ExchangeRateContext'; +import { getCommentPreview } from './lib/feedEntryAdapters'; +import { getWorkCardPresentation, type ActivityWork } from './lib/activityWorkContext'; +import type { FeedEntry } from '@/types/feed'; + +interface ActivityWorkMetadataProps { + entry: FeedEntry; + work: ActivityWork; +} + +/** + * Frosted-bar content for an activity work card (title, authors/org, rating, stats, progress). + */ +export const ActivityWorkMetadata: FC = ({ entry, work }) => { + const { showUSD } = useCurrencyPreference(); + const { exchangeRate } = useExchangeRate(); + const commentPreview = getCommentPreview(entry); + const isReviewOfProposal = !!commentPreview?.isReview && work.documentType === 'preregistration'; + + const presentation = getWorkCardPresentation(entry, work, { + showUSD, + exchangeRate, + isReview: commentPreview?.isReview, + }); + + const authors = isReviewOfProposal ? [] : presentation.authors; + const authorNames = + authors.length > 0 + ? authors + .slice(0, 2) + .map((a) => a.name) + .join(', ') + (authors.length > 2 ? ` +${authors.length - 2}` : '') + : null; + + const authorLine = + presentation.organization || + (authorNames && presentation.institution + ? `${authorNames} · ${presentation.institution}` + : authorNames || presentation.institution || null); + + const { score, stats, progress } = presentation; + + return ( + <> +
+
+
+ {work.title} +
+ {authorLine && ( +
{authorLine}
+ )} +
+ + {(score != null || stats?.length) && ( +
+ {score != null && ( +
+
+ Rating +
+
+ + {score.toFixed(1)} +
+
+ )} + {stats?.map((s) => ( +
+
+ {s.label} +
+
+ {s.value} +
+
+ ))} +
+ )} +
+ + {progress != null && ( +
+
+
+ )} + + ); +}; diff --git a/components/Activity/AmountBadge.tsx b/components/Activity/AmountBadge.tsx index b9297fb49..4d94827df 100644 --- a/components/Activity/AmountBadge.tsx +++ b/components/Activity/AmountBadge.tsx @@ -3,22 +3,16 @@ import { FC, ReactNode } from 'react'; import { cn } from '@/utils/styles'; -const TONES = { - green: 'bg-green-100 text-green-800', - orange: 'bg-orange-100 text-orange-700', -} as const; - interface AmountBadgeProps { - tone?: keyof typeof TONES; className?: string; children: ReactNode; } -export const AmountBadge: FC = ({ tone = 'green', className, children }) => ( +export const AmountBadge: FC = ({ className, children }) => ( diff --git a/components/Activity/BountyAmount.tsx b/components/Activity/BountyAmount.tsx index 0945aab73..c81214173 100644 --- a/components/Activity/BountyAmount.tsx +++ b/components/Activity/BountyAmount.tsx @@ -19,7 +19,7 @@ export const BountyAmount: FC = ({ bounty, className }) => { const { amount } = getBountyDisplayAmount(bounty, exchangeRate, showUSD); return ( - + {formatCurrency({ amount: Math.round(amount), showUSD, diff --git a/components/Activity/WorkPreviewCard.tsx b/components/Activity/WorkPreviewCard.tsx index 9b8587f01..806e866e6 100644 --- a/components/Activity/WorkPreviewCard.tsx +++ b/components/Activity/WorkPreviewCard.tsx @@ -1,62 +1,64 @@ 'use client'; -import { FC, ReactNode } from 'react'; +import { Children, FC, isValidElement, ReactElement, ReactNode } from 'react'; import Link from 'next/link'; import Image from 'next/image'; -import { Star } from 'lucide-react'; import { cn } from '@/utils/styles'; -import type { WorkCardAuthor, WorkCardStat } from './lib/activityWorkContext'; +import type { ActivityWork } from './lib/activityWorkContext'; + +type WorkPreviewShell = Pick; + +interface SlotProps { + children?: ReactNode; +} + +function WorkPreviewCardMetadata({ children }: SlotProps) { + return <>{children}; +} + +function WorkPreviewCardActions({ children }: SlotProps) { + return <>{children}; +} + +function findSlot(children: ReactNode, slot: FC): ReactElement | undefined { + return Children.toArray(children).find( + (child): child is ReactElement => isValidElement(child) && child.type === slot + ); +} interface WorkPreviewCardProps { - title: string; - href?: string; - imageSrc?: string; + work: WorkPreviewShell; + children?: ReactNode; /** Render a gradient placeholder when no image is available. */ showPlaceholder?: boolean; - authors?: WorkCardAuthor[]; - /** Funding organization; takes precedence over authors on the meta line. */ - organization?: string | null; - institution?: string | null; - /** Average peer-review score shown next to the authors. */ - score?: number | null; - /** Extra stats on the right of the frosted bar (label + value). */ - stats?: WorkCardStat[]; - /** Fundraise progress in the 0–1 range. */ - progress?: number; - /** Full footer row (typically vote/save/share + CTA). */ - actions?: ReactNode; + /** Fired when the user navigates via the work link (not footer actions). */ + onNavigate?: () => void; className?: string; } /** * Full-bleed frosted-image card for activity feed rows. * Image fills the card; metadata sits in a translucent bar at the bottom. + * + * Compose with slots: + * ```tsx + * + * ... + * ... + * + * ``` */ -export const WorkPreviewCard: FC = ({ - title, - href, - imageSrc, +function WorkPreviewCardRoot({ + work, + children, showPlaceholder = true, - authors = [], - organization, - institution, - score, - stats, - progress, - actions, + onNavigate, className, -}) => { +}: WorkPreviewCardProps) { + const metadata = findSlot(children, WorkPreviewCardMetadata)?.props.children; + const actions = findSlot(children, WorkPreviewCardActions)?.props.children; const showFooter = !!actions; - const authorLine = - organization || - (authors.length > 0 - ? authors - .slice(0, 2) - .map((a) => a.name) - .join(', ') + (authors.length > 2 ? ` +${authors.length - 2}` : '') - : institution || null); - const imageBlock = (
= ({ : 'rounded-[10px]' )} > - {imageSrc ? ( + {work.imageUrl ? ( {title} = ({ background: 'rgba(0,0,0,0.52)', }} > -
-
-
- {title} -
- {authorLine && ( -
{authorLine}
- )} -
- - {(score != null || stats?.length) && ( -
- {score != null && ( -
-
- Rating -
-
- - {score.toFixed(1)} -
-
- )} - {stats?.map((s) => ( -
-
- {s.label} -
-
- {s.value} -
-
- ))} -
- )} -
- - {progress != null && ( -
-
+ {metadata ?? ( +
+ {work.title}
)}
@@ -159,8 +116,14 @@ export const WorkPreviewCard: FC = ({ className )} > - {href ? ( - + {work.href ? ( + { + onNavigate?.(); + }} + > {imageBlock} ) : ( @@ -168,10 +131,15 @@ export const WorkPreviewCard: FC = ({ )} {showFooter && ( -
+
{actions}
)}
); -}; +} + +export const WorkPreviewCard = Object.assign(WorkPreviewCardRoot, { + Metadata: WorkPreviewCardMetadata, + Actions: WorkPreviewCardActions, +}); diff --git a/components/Activity/lib/activityWorkContext.ts b/components/Activity/lib/activityWorkContext.ts index 11bf025fa..51c5a319f 100644 --- a/components/Activity/lib/activityWorkContext.ts +++ b/components/Activity/lib/activityWorkContext.ts @@ -20,7 +20,7 @@ import type { ContentType, Work, WorkGrantSummary } from '@/types/work'; type ActivityBodySlot = 'fundraise' | 'bounty' | 'grant' | 'default'; -export interface ActivityWorkContext { +export interface ActivityWork { id: number; slug: string; title: string; @@ -73,7 +73,7 @@ export function getActivityBounty(entry: FeedEntry): Bounty | undefined { return undefined; } -function resolveTabFromContext(activityContext?: ActivityContext): ActivityWorkContext['tab'] { +function resolveTabFromContext(activityContext?: ActivityContext): ActivityWork['tab'] { switch (activityContext) { case 'tip_review': case 'peer_review_published': @@ -91,7 +91,7 @@ function resolveTabFromContext(activityContext?: ActivityContext): ActivityWorkC function resolveActivityBodySlot( activityContext?: ActivityContext, - work?: Pick, + work?: Pick, options?: { isReview?: boolean } ): ActivityBodySlot { if (activityContext === 'bounty_opened' || activityContext === 'bounty_contributed') { @@ -129,7 +129,7 @@ function toCardAuthors(authors?: AuthorProfile[]): WorkCardAuthor[] { } /** Funding organization, from related work when present and the entry itself otherwise. */ -function resolveOrganization(entry: FeedEntry, work: ActivityWorkContext): string | null { +function resolveOrganization(entry: FeedEntry, work: ActivityWork): string | null { if (work.grant?.organization) return work.grant.organization; if (entry.contentType === 'GRANT') { return (entry.content as FeedGrantContent).grant?.organization || null; @@ -152,7 +152,7 @@ function formatAmount( }); } -function resolveReviewScore(entry: FeedEntry, work: ActivityWorkContext): number | null { +function resolveReviewScore(entry: FeedEntry, work: ActivityWork): number | null { const entryScore = entry.metrics?.reviewScore; if (entryScore && entryScore > 0) return entryScore; @@ -164,7 +164,7 @@ function resolveReviewScore(entry: FeedEntry, work: ActivityWorkContext): number function buildBasePresentation( entry: FeedEntry, - work: ActivityWorkContext, + work: ActivityWork, slot: ActivityBodySlot ): WorkCardPresentation { return { @@ -179,7 +179,7 @@ function buildBasePresentation( function presentFundraise( base: WorkCardPresentation, - fundraise: NonNullable, + fundraise: NonNullable, showUSD: boolean, exchangeRate: number ): WorkCardPresentation { @@ -201,15 +201,15 @@ function presentFundraise( }; } -function isGrantActive(grant: NonNullable): boolean { +function isGrantActive(grant: NonNullable): boolean { if (grant.status !== 'OPEN') return false; return grant.endDate ? isDeadlineInFuture(grant.endDate) : true; } function presentGrant( base: WorkCardPresentation, - work: ActivityWorkContext, - grant: NonNullable, + work: ActivityWork, + grant: NonNullable, showUSD: boolean, exchangeRate: number ): WorkCardPresentation { @@ -245,7 +245,7 @@ function isBountyActive(bounty: Bounty): boolean { function presentBounty( base: WorkCardPresentation, - work: ActivityWorkContext, + work: ActivityWork, bounty: Bounty, showUSD: boolean, exchangeRate: number @@ -276,7 +276,7 @@ function presentBounty( export function getWorkCardPresentation( entry: FeedEntry, - work: ActivityWorkContext, + work: ActivityWork, options: { showUSD: boolean; exchangeRate: number; isReview?: boolean } ): WorkCardPresentation { const { showUSD, exchangeRate, isReview } = options; @@ -309,10 +309,10 @@ function grantSummaryFromFeedGrant(content: FeedGrantContent): WorkGrantSummary } /** - * Build work context from a top-level document payload when `related_work` is + * Build work from a top-level document payload when `related_work` is * absent (PAPER / POST / GRANT / proposal / contribution events). */ -function getWorkContextFromContent(entry: FeedEntry): ActivityWorkContext | null { +function getWorkFromContent(entry: FeedEntry): ActivityWork | null { const tab = resolveTabFromContext(entry.activityContext); const bounty = getActivityBounty(entry); @@ -393,7 +393,10 @@ function getWorkContextFromContent(entry: FeedEntry): ActivityWorkContext | null unifiedDocumentId: toOptionalNumber(post.unifiedDocumentId), fundraise: post.fundraise, bounty, - authors: post.authors, + authors: + entry.contentType === 'PURCHASE' || entry.contentType === 'USDFUNDRAISECONTRIBUTION' + ? undefined + : post.authors, tab, }; } @@ -401,9 +404,34 @@ function getWorkContextFromContent(entry: FeedEntry): ActivityWorkContext | null return null; } -function workContextFromRelatedWork(entry: FeedEntry, related: Work): ActivityWorkContext { +/** + * Prefer related-work authors when present; otherwise use the entry content's + * authors (activity `related_work` often ships without an authors list while + * `content_object.authors` is populated for proposal submissions). + */ +function resolveWorkAuthors( + entry: FeedEntry, + relatedAuthors?: AuthorProfile[] +): AuthorProfile[] | undefined { + if (relatedAuthors?.length) return relatedAuthors; + + if (entry.contentType === 'PURCHASE' || entry.contentType === 'USDFUNDRAISECONTRIBUTION') { + return undefined; + } + + const content = entry.content as { authors?: AuthorProfile[] } | undefined; + if (Array.isArray(content?.authors) && content.authors.length > 0) { + return content.authors; + } + + return relatedAuthors; +} + +function workFromRelatedWork(entry: FeedEntry, related: Work): ActivityWork { const tab = resolveTabFromContext(entry.activityContext); const documentType = related.contentType; + const relatedAuthors = related.authors?.map((authorship) => authorship.authorProfile); + return { id: related.id, slug: related.slug, @@ -420,16 +448,16 @@ function workContextFromRelatedWork(entry: FeedEntry, related: Work): ActivityWo fundraise: related.fundraise, grant: related.grantSummary, bounty: getActivityBounty(entry), - authors: related.authors?.map((authorship) => authorship.authorProfile), + authors: resolveWorkAuthors(entry, relatedAuthors), tab, }; } -export function getActivityWorkContext(entry: FeedEntry): ActivityWorkContext | null { +export function getActivityWork(entry: FeedEntry): ActivityWork | null { const related = entry.relatedWork; if (related?.title) { - return workContextFromRelatedWork(entry, related); + return workFromRelatedWork(entry, related); } - return getWorkContextFromContent(entry); + return getWorkFromContent(entry); } diff --git a/components/Activity/lib/feedEntryAdapters.ts b/components/Activity/lib/feedEntryAdapters.ts index 83418177d..cb138d084 100644 --- a/components/Activity/lib/feedEntryAdapters.ts +++ b/components/Activity/lib/feedEntryAdapters.ts @@ -46,6 +46,7 @@ export interface ActivityHeaderMessage { verb: string; target?: ActivityHeaderTarget; isEarning?: boolean; + suffix?: string; } /** @@ -63,7 +64,12 @@ function getFundingActivityMessage(content: FeedFundingActivityContent): Activit const recipient = content.recipient; if (recipient && (content.sourceType === 'BOUNTY_PAYOUT' || isFoundationProfile(actor))) { - return { actor: recipient, verb: 'earned', isEarning: true }; + return { + actor: recipient, + verb: 'earned', + isEarning: true, + suffix: ' for their peer review', + }; } if (content.sourceType === 'BOUNTY_PAYOUT') { @@ -99,7 +105,14 @@ function getDefaultActivityMessage(entry: FeedEntry): ActivityHeaderMessage { const commentType = commentContent.comment?.commentType; if (commentType === 'REVIEW') { - if (getReviewEarning(entry)) return { actor, verb: 'earned', isEarning: true }; + if (getReviewEarning(entry)) { + return { + actor, + verb: 'earned', + isEarning: true, + suffix: ' for their peer review', + }; + } const score = commentContent.review?.score ?? commentContent.comment.reviewScore; return { actor, verb: score ? 'peer reviewed and scored' : 'peer reviewed' }; } @@ -115,7 +128,7 @@ function getDefaultActivityMessage(entry: FeedEntry): ActivityHeaderMessage { } if (entry.contentType === 'USDFUNDRAISECONTRIBUTION' || entry.contentType === 'PURCHASE') { - return { actor, verb: 'funded proposal' }; + return { actor, verb: 'funded proposal for' }; } return { @@ -350,7 +363,16 @@ export interface FeedCommentPreview { } export function getCommentPreview(entry: FeedEntry): FeedCommentPreview | null { - if (entry.contentType === 'FUNDINGACTIVITY') return null; + if (entry.contentType === 'FUNDINGACTIVITY') { + const peerReview = (entry.content as FeedFundingActivityContent).peerReview; + if (!peerReview?.content) return null; + return { + content: peerReview.content, + format: peerReview.contentFormat, + isReview: peerReview.isReview, + }; + } + if (entry.contentType !== 'COMMENT') return null; const { comment } = entry.content as FeedCommentContent; if (!comment?.content) return null; diff --git a/components/Earn/EarnRightSidebar.tsx b/components/Earn/EarnRightSidebar.tsx index f210eb05c..1a5f634e3 100644 --- a/components/Earn/EarnRightSidebar.tsx +++ b/components/Earn/EarnRightSidebar.tsx @@ -24,8 +24,8 @@ const useItems: SidebarItem[] = [ href: '/fund/proposals', }, { - title: 'Open Funding Opportunity', - description: 'Fund specific research you care about.', + title: 'Open Request for Proposal', + description: 'Fund research', icon: , href: '/fund', }, diff --git a/components/Feed/FeedItemActions.tsx b/components/Feed/FeedItemActions.tsx index b9614dfd1..4d9dc17b0 100644 --- a/components/Feed/FeedItemActions.tsx +++ b/components/Feed/FeedItemActions.tsx @@ -168,7 +168,12 @@ interface FeedItemActionsProps { onExpand?: (e?: React.MouseEvent) => void; isExpanded?: boolean; className?: string; - variant?: 'default' | 'inline'; + /** + * `default` — feed footer bar with pill vote control. + * `inline` — same pill chrome, no gray bar (e.g. comment rows). + * `compact` — flat vote/save/share icons matching activity preview cards. + */ + variant?: 'default' | 'inline' | 'compact'; leadingUtilityActions?: boolean; } @@ -416,49 +421,84 @@ export const FeedItemActions: FC = ({ feedContentType !== 'APPLICATION' && showPeerReviews; - const showShare = leadingUtilityActions || variant !== 'inline'; + const showShare = leadingUtilityActions || (variant !== 'inline' && variant !== 'compact'); const showMoreMenu = !!(listDetailContext && relatedDocumentUnifiedDocumentId) || menuItems.length > 0 || !hideReportButton; + const isCompact = variant === 'compact'; const shareButton = showShare ? ( - + isCompact ? ( + + ) : ( + + ) ) : null; const saveButton = canSave ? ( - + isCompact ? ( + + ) : ( + + ) ) : null; return ( @@ -466,46 +506,85 @@ export const FeedItemActions: FC = ({
-
+
- + {localVoteCount}
{!hideCommentButton && ( diff --git a/components/Funding/FundActivityPageContent.tsx b/components/Funding/FundActivityPageContent.tsx index cbc6be4a6..9dea279e3 100644 --- a/components/Funding/FundActivityPageContent.tsx +++ b/components/Funding/FundActivityPageContent.tsx @@ -1,12 +1,54 @@ 'use client'; +import { useEffect, useMemo } from 'react'; +import { usePathname, useSearchParams } from 'next/navigation'; import { useInView } from 'react-intersection-observer'; import { ActivityCardFull } from '@/components/Activity/ActivityCardFull'; import { ActivityCardSkeleton } from '@/components/Activity/ActivityCardSkeleton'; -import { useActivityFeed } from '@/hooks/useActivityFeed'; +import { useActivityFeeds } from '@/contexts/ActivityFeedContext'; +import { useFeedScrollTracking } from '@/hooks/useFeedScrollTracking'; +import { getFeedKey } from '@/contexts/NavigationContext'; export function FundActivityPageContent() { - const { entries, isLoading, isLoadingMore, hasMore, loadMore } = useActivityFeed({}); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const { + entries, + isLoading, + isLoadingMore, + hasMore, + page, + loadMore, + activate, + restoredScrollPosition, + lastClickedEntryId, + restorationTab, + } = useActivityFeeds(); + + useEffect(() => { + activate(); + }, [activate]); + + const feedKey = useMemo(() => { + const queryParams: Record = {}; + for (const [key, value] of searchParams) { + queryParams[key] = value; + } + return getFeedKey({ + pathname, + tab: restorationTab, + queryParams: Object.keys(queryParams).length > 0 ? queryParams : undefined, + }); + }, [pathname, restorationTab, searchParams]); + + useFeedScrollTracking({ + feedKey, + entries, + hasMore, + page, + restoredScrollPosition, + lastClickedEntryId: lastClickedEntryId ?? undefined, + }); const { ref: sentinelRef } = useInView({ threshold: 0, diff --git a/components/Funding/FundSidebar.tsx b/components/Funding/FundSidebar.tsx index 669acdd3c..30a604d4b 100644 --- a/components/Funding/FundSidebar.tsx +++ b/components/Funding/FundSidebar.tsx @@ -1,21 +1,26 @@ 'use client'; +import { + RecentlyVisitedCard, + RecentlyVisitedCardSkeleton, + useRecentlyVisited, +} from './RecentlyVisitedCard'; 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; + const { pages, clear, isHydrated } = useRecentlyVisited(); + const sectionClassName = cn('w-full', 'mt-4 border-t border-gray-200/80 pt-4'); return (
- {showsRecentlyVisited && ( - + {!isHydrated ? ( + + ) : ( + pages.length > 0 && ( + + ) )}
); diff --git a/components/Funding/FundingPowerCard.tsx b/components/Funding/FundingPowerCard.tsx index 4ebd6a01d..e41a56ed9 100644 --- a/components/Funding/FundingPowerCard.tsx +++ b/components/Funding/FundingPowerCard.tsx @@ -1,9 +1,11 @@ 'use client'; +import { useState } from 'react'; 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 { DepositModal } from '@/components/modals/ResearchCoin/DepositModal'; import { formatCurrency } from '@/utils/currency'; import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; import { useExchangeRate } from '@/contexts/ExchangeRateContext'; @@ -20,6 +22,7 @@ interface FundingPowerCardProps { * visualizes the split between RSC and fund-only credits */ export const FundingPowerCard = ({ className }: FundingPowerCardProps) => { + const [isDepositModalOpen, setIsDepositModalOpen] = useState(false); const { user, isLoading: isUserLoading } = useUser(); const { showUSD } = useCurrencyPreference(); const { exchangeRate, isLoading: isRateLoading } = useExchangeRate(); @@ -49,6 +52,8 @@ export const FundingPowerCard = ({ className }: FundingPowerCardProps) => { const rscWidth = total > 0 ? (balanceRaw / total) * 100 : 0; const creditsWidth = total > 0 ? (creditsRaw / total) * 100 : 0; + const openDepositModal = () => setIsDepositModalOpen(true); + return (
@@ -127,7 +133,7 @@ export const FundingPowerCard = ({ className }: FundingPowerCardProps) => { {isEmpty && (
- Deposit RSC + Deposit RSC Earn credits
)} @@ -150,6 +156,8 @@ export const FundingPowerCard = ({ className }: FundingPowerCardProps) => { />
)} + + setIsDepositModalOpen(false)} /> ); }; @@ -195,25 +203,32 @@ const SourceRow = ({ label, tooltip, dotColor, value, valueClassName }: SourceRo ); -interface CtaProps { - href: string; +interface PrimaryCtaProps { + onClick: () => void; children: React.ReactNode; className?: string; } -const PrimaryCta = ({ href, children, className }: CtaProps) => ( - ( + ); -const SecondaryCta = ({ href, children, className }: CtaProps) => ( +interface SecondaryCtaProps { + href: string; + children: React.ReactNode; + className?: string; +} + +const SecondaryCta = ({ href, children, className }: SecondaryCtaProps) => ( { + const queryParams: Record = {}; + for (const [key, value] of searchParams) { + queryParams[key] = value; + } + return getFeedKey({ + pathname, + tab: restorationTab, + queryParams: Object.keys(queryParams).length > 0 ? queryParams : undefined, + }); + }, [pathname, restorationTab, searchParams]); + + useFeedScrollTracking({ + feedKey, + entries, + hasMore, + page, + restoredScrollPosition, + lastClickedEntryId: lastClickedEntryId ?? undefined, + }); const { ref: sentinelRef } = useInView({ threshold: 0, diff --git a/components/Funding/GrantPageContent.tsx b/components/Funding/GrantPageContent.tsx index fc962314b..8a700ebc7 100644 --- a/components/Funding/GrantPageContent.tsx +++ b/components/Funding/GrantPageContent.tsx @@ -15,7 +15,11 @@ interface GrantTabContextValue { isLoadingMore: boolean; hasMore: boolean; count: number; + page: number; loadMore: () => void; + restoredScrollPosition: number | null; + lastClickedEntryId: string | null; + restorationTab: string; }; } @@ -37,7 +41,18 @@ export function GrantTabProvider({ grantId?: number | string; }) { const [activeTab, setActiveTab] = useState(defaultTab); - const { entries, isLoading, isLoadingMore, hasMore, count, loadMore } = useActivityFeed({ + const { + entries, + isLoading, + isLoadingMore, + hasMore, + count, + page, + loadMore, + restoredScrollPosition, + lastClickedEntryId, + restorationTab, + } = useActivityFeed({ scope: 'grants', grantId, }); @@ -47,7 +62,18 @@ export function GrantTabProvider({ value={{ activeTab, setActiveTab, - activity: { entries, isLoading, isLoadingMore, hasMore, count, loadMore }, + activity: { + entries, + isLoading, + isLoadingMore, + hasMore, + count, + page, + loadMore, + restoredScrollPosition, + lastClickedEntryId, + restorationTab, + }, }} > {children} diff --git a/components/Funding/HomeFeedsProvider.tsx b/components/Funding/HomeFeedsProvider.tsx new file mode 100644 index 000000000..9afc633f5 --- /dev/null +++ b/components/Funding/HomeFeedsProvider.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { type ReactNode } from 'react'; +import { ActivityFeedProvider } from '@/contexts/ActivityFeedContext'; +import { GrantFeedProvider } from '@/contexts/GrantFeedContext'; + +/** Keeps homepage Activity + Grant feed state mounted across tab switches. */ +export function HomeFeedsProvider({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/components/Funding/HomeTabs.tsx b/components/Funding/HomeTabs.tsx index de1b63e62..19c73b466 100644 --- a/components/Funding/HomeTabs.tsx +++ b/components/Funding/HomeTabs.tsx @@ -1,20 +1,25 @@ 'use client'; -import { FeedTabs } from '@/components/Feed/FeedTabs'; -import { useContentTabsVisibilitySentinel } from '@/hooks/useContentTabsVisibilitySentinel'; +import { Tabs } from '@/components/ui/Tabs'; import { useFundTabs } from '@/hooks/useFundTabs'; +import { useContentTabsVisibilitySentinel } from '@/hooks/useContentTabsVisibilitySentinel'; /** - * Homepage hub tabs (Activity / Fund / Proposals). Pill style matches the old - * for-you feed; the sentinel lifts a sticky copy into the TopBar on scroll. + * Homepage hub tabs (Activity / Request for Proposals / Proposals). */ export function HomeTabs() { const { tabs, highlightedTab, handleTabChange } = useFundTabs(); - const tabsSentinelRef = useContentTabsVisibilitySentinel(); + const tabsSentinelRef = useContentTabsVisibilitySentinel(true); return ( -
- +
+
); } diff --git a/components/Funding/RecentlyVisitedCard.tsx b/components/Funding/RecentlyVisitedCard.tsx index c6f7530a6..64ac285f7 100644 --- a/components/Funding/RecentlyVisitedCard.tsx +++ b/components/Funding/RecentlyVisitedCard.tsx @@ -1,23 +1,13 @@ 'use client'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; -import { useActivityFeed } from '@/hooks/useActivityFeed'; -import { getEntryMeta } from '@/components/Activity/lib/feedEntryAdapters'; +import { getSearchHistory, clearSearchHistory, MAX_HISTORY_ITEMS } from '@/utils/searchHistory'; +import { buildWorkUrl } from '@/utils/url'; +import { SearchSuggestion } from '@/types/search'; +import { ContentType } from '@/types/work'; 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', @@ -27,6 +17,14 @@ const WORK_TYPE_LABELS: Record = { funding_request: 'Request for Proposal', }; +const SKELETON_ROWS = [ + { title: ['w-[92%]', 'w-[68%]'], meta: 'w-16' }, + { title: ['w-[85%]', 'w-[55%]'], meta: 'w-14' }, + { title: ['w-[90%]', 'w-[72%]'], meta: 'w-20' }, + { title: ['w-[78%]', 'w-[48%]'], meta: 'w-14' }, + { title: ['w-[88%]', 'w-[60%]'], meta: 'w-16' }, +] as const; + interface RecentPage { href: string; title: string; @@ -36,6 +34,57 @@ interface RecentPage { export interface RecentlyVisited { pages: RecentPage[]; clear: () => void; + isHydrated: boolean; +} + +/** Stored visit record — shares the search-history localStorage shape. */ +type VisitRecord = SearchSuggestion; + +function toRecentPage(visit: VisitRecord): RecentPage | null { + const title = visit.displayName?.trim(); + if (!title) return null; + + if (visit.entityType === 'paper') { + const contentType = (visit.contentType || 'paper') as ContentType; + const href = buildWorkUrl({ + id: visit.id, + contentType, + doi: 'doi' in visit ? visit.doi : undefined, + slug: visit.slug, + }); + if (!href || href === '#') return null; + return { + href, + title, + typeLabel: WORK_TYPE_LABELS[contentType], + }; + } + + if (visit.entityType === 'post') { + return { + href: visit.url || `/post/${visit.id}`, + title, + typeLabel: WORK_TYPE_LABELS.post, + }; + } + + // Skip users / hubs — this sidebar is for visited documents only. + return null; +} + +function visitsToPages(visits: VisitRecord[]): RecentPage[] { + const collected: RecentPage[] = []; + const seen = new Set(); + + for (const visit of visits) { + const page = toRecentPage(visit); + if (!page || seen.has(page.href)) continue; + seen.add(page.href); + collected.push(page); + if (collected.length === MAX_HISTORY_ITEMS) break; + } + + return collected; } /** @@ -43,46 +92,43 @@ export interface RecentlyVisited { * 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. + * Same localStorage + event pattern as useSearchSuggestions. */ 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 }; + const [visits, setVisits] = useState([]); + const [isHydrated, setIsHydrated] = useState(false); + + useEffect(() => { + setVisits(getSearchHistory()); + setIsHydrated(true); + + const handleStorageChange = () => { + setVisits(getSearchHistory()); + }; + + window.addEventListener('search-history-updated', handleStorageChange); + return () => { + window.removeEventListener('search-history-updated', handleStorageChange); + }; + }, []); + + const pages = useMemo(() => visitsToPages(visits), [visits]); + + const clear = useCallback(() => { + clearSearchHistory(); + setVisits([]); + }, []); + + return { pages, clear, isHydrated }; } -interface RecentlyVisitedCardProps extends RecentlyVisited { +interface RecentlyVisitedCardProps extends Omit { className?: string; } /** * Lightweight browsing history for the Activity sidebar: a plain text list of - * documents from the activity feed, no thumbnails or metrics. + * documents from local visit history, no thumbnails or metrics. */ export function RecentlyVisitedCard({ pages, clear, className }: RecentlyVisitedCardProps) { if (pages.length === 0) return null; @@ -122,3 +168,26 @@ export function RecentlyVisitedCard({ pages, clear, className }: RecentlyVisited ); } + +/** Placeholder shown until localStorage history is readable after hydration. */ +export function RecentlyVisitedCardSkeleton({ className }: { className?: string }) { + return ( + + ); +} diff --git a/components/Notebook/NotebookHome.tsx b/components/Notebook/NotebookHome.tsx index 06a73d651..9bb7c3cc2 100644 --- a/components/Notebook/NotebookHome.tsx +++ b/components/Notebook/NotebookHome.tsx @@ -52,8 +52,8 @@ export function NotebookHome() { const createOptions: CreateOption[] = [ { id: 'funding-opportunity', - title: 'Funding Opportunity', - description: 'Fund specific research you care about', + title: 'RFP', + description: 'Fund research', icon: , onClick: () => setIsFundingOpportunityModalOpen(true), }, diff --git a/components/ResearchCoin/ResearchCoinRightSidebar.tsx b/components/ResearchCoin/ResearchCoinRightSidebar.tsx index 12a97b341..1421bc08f 100644 --- a/components/ResearchCoin/ResearchCoinRightSidebar.tsx +++ b/components/ResearchCoin/ResearchCoinRightSidebar.tsx @@ -24,8 +24,8 @@ const useItems: SidebarItem[] = [ href: '/fund/proposals', }, { - title: 'Open Funding Opportunity', - description: 'Fund specific research you care about.', + title: 'Open Request for Proposal', + description: 'Fund research', icon: , href: '/fund', }, diff --git a/components/work/SearchHistoryTracker.tsx b/components/work/SearchHistoryTracker.tsx index 2b6ab1810..509459952 100644 --- a/components/work/SearchHistoryTracker.tsx +++ b/components/work/SearchHistoryTracker.tsx @@ -11,10 +11,8 @@ interface SearchHistoryTrackerProps { export function SearchHistoryTracker({ work }: SearchHistoryTrackerProps) { useEffect(() => { - // Get existing history - const history = getSearchHistory(); + const history = [...getSearchHistory()]; - // Create new suggestion from work const newSuggestion: SearchSuggestion = { id: work.id, entityType: 'paper', @@ -29,23 +27,17 @@ export function SearchHistoryTracker({ work }: SearchHistoryTrackerProps) { contentType: work.contentType, }; - // Update or add the current suggestion const existingIndex = history.findIndex((item) => item.id === work.id); - if (existingIndex !== -1) { - // Remove from current position history.splice(existingIndex, 1); } - // Add to the start of history history.unshift(newSuggestion); - // Keep only the most recent suggestions if (history.length > MAX_HISTORY_ITEMS) { - history.pop(); + history.length = MAX_HISTORY_ITEMS; } - // Save updated history saveSearchHistory(history); }, [work]); diff --git a/contexts/ActivityFeedContext.tsx b/contexts/ActivityFeedContext.tsx new file mode 100644 index 000000000..a76937e2f --- /dev/null +++ b/contexts/ActivityFeedContext.tsx @@ -0,0 +1,141 @@ +'use client'; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react'; +import { FeedEntry } from '@/types/feed'; +import { ActivityService } from '@/services/activity.service'; +import { useFeedStateRestoration } from '@/hooks/useFeedStateRestoration'; + +interface ActivityFeedContextValue { + entries: FeedEntry[]; + isLoading: boolean; + isLoadingMore: boolean; + hasMore: boolean; + page: number; + loadMore: () => Promise; + activate: () => void; + restoredScrollPosition: number | null; + lastClickedEntryId: string | null; + restorationTab: string; +} + +const ActivityFeedContext = createContext(null); + +const RESTORATION_TAB = 'activity'; + +/** + * Homepage Activity feed. Stays mounted across home tab switches so we only + * refetch when the provider remounts (leave home) or after back-nav restore. + */ +export function ActivityFeedProvider({ children }: { children: ReactNode }) { + const { restoredState, restoredScrollPosition, lastClickedEntryId } = useFeedStateRestoration({ + activeTab: RESTORATION_TAB, + }); + + const hasRestoredEntries = restoredState !== null && (restoredState.entries?.length ?? 0) > 0; + const initialEntries = restoredState?.entries ?? []; + const initialHasMore = restoredState?.hasMore ?? false; + const initialPage = restoredState?.page ?? 1; + + const [entries, setEntries] = useState(initialEntries); + const [isLoading, setIsLoading] = useState(!hasRestoredEntries); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(initialHasMore); + const [page, setPage] = useState(initialPage); + const pageRef = useRef(initialPage); + + const [activated, setActivated] = useState(hasRestoredEntries); + const activate = useCallback(() => setActivated(true), []); + const skipFetchAfterRestoreRef = useRef(hasRestoredEntries); + + const fetchInitial = useCallback(async () => { + setEntries([]); + setIsLoading(true); + pageRef.current = 1; + setPage(1); + + try { + const result = await ActivityService.getActivity({ page: 1 }); + setEntries(result.entries); + setHasMore(result.hasMore); + } catch (error) { + console.error('Error fetching activity feed:', error); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + if (!activated) return; + if (skipFetchAfterRestoreRef.current) { + skipFetchAfterRestoreRef.current = false; + return; + } + fetchInitial(); + }, [activated, fetchInitial]); + + const loadMore = useCallback(async () => { + if (isLoading || isLoadingMore || !hasMore) return; + + setIsLoadingMore(true); + const nextPage = pageRef.current + 1; + + try { + const result = await ActivityService.getActivity({ page: nextPage }); + setEntries((prev) => [...prev, ...result.entries]); + setHasMore(result.hasMore); + pageRef.current = nextPage; + setPage(nextPage); + } catch (error) { + console.error('Error loading more activity:', error); + } finally { + setIsLoadingMore(false); + } + }, [isLoading, isLoadingMore, hasMore]); + + const value = useMemo( + () => ({ + entries, + isLoading: !activated || isLoading, + isLoadingMore, + hasMore, + page, + loadMore, + activate, + restoredScrollPosition, + lastClickedEntryId, + restorationTab: RESTORATION_TAB, + }), + [ + entries, + activated, + isLoading, + isLoadingMore, + hasMore, + page, + loadMore, + activate, + restoredScrollPosition, + lastClickedEntryId, + ] + ); + + return {children}; +} + +/** Shared homepage activity feed (distinct from the parameterized `useActivityFeed` hook). */ +export function useActivityFeeds() { + const context = useContext(ActivityFeedContext); + if (!context) { + throw new Error('useActivityFeeds must be used within an ActivityFeedProvider'); + } + return context; +} diff --git a/contexts/GrantFeedContext.tsx b/contexts/GrantFeedContext.tsx new file mode 100644 index 000000000..3bff8b483 --- /dev/null +++ b/contexts/GrantFeedContext.tsx @@ -0,0 +1,117 @@ +'use client'; + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react'; +import { FeedEntry } from '@/types/feed'; +import { FeedService } from '@/services/feed.service'; +import type { GrantSortOption } from '@/components/Funding/lib/grantSortConfig'; + +interface GrantFeedContextValue { + entries: FeedEntry[]; + isLoading: boolean; + hasMore: boolean; + loadMore: () => Promise; + sortBy: GrantSortOption; + setSortBy: (value: GrantSortOption) => void; + activate: () => void; +} + +const GrantFeedContext = createContext(null); + +const PAGE_SIZE = 20; + +/** + * Homepage grant (RFP) feed. Stays mounted across home tab switches so we only + * refetch when sort changes or the provider remounts. + */ +export function GrantFeedProvider({ children }: { children: ReactNode }) { + const [entries, setEntries] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(false); + const [page, setPage] = useState(1); + const [sortBy, setSortBy] = useState('newest'); + const [activated, setActivated] = useState(false); + + const activate = useCallback(() => setActivated(true), []); + + const fetchInitial = useCallback(async () => { + setEntries([]); + setIsLoading(true); + try { + const result = await FeedService.getFeed({ + page: 1, + pageSize: PAGE_SIZE, + endpoint: 'grant_feed', + contentType: 'GRANT', + ordering: sortBy, + }); + setEntries(result.entries); + setHasMore(result.hasMore); + setPage(1); + } catch (error) { + console.error('Error fetching grant feed:', error); + } finally { + setIsLoading(false); + } + }, [sortBy]); + + useEffect(() => { + if (activated) { + fetchInitial(); + } + }, [activated, fetchInitial]); + + const loadMore = useCallback(async () => { + if (isLoading || isLoadingMore || !hasMore) return; + + setIsLoadingMore(true); + const nextPage = page + 1; + try { + const result = await FeedService.getFeed({ + page: nextPage, + pageSize: PAGE_SIZE, + endpoint: 'grant_feed', + contentType: 'GRANT', + ordering: sortBy, + }); + setEntries((prev) => [...prev, ...result.entries]); + setHasMore(result.hasMore); + setPage(nextPage); + } catch (error) { + console.error('Error loading more grants:', error); + } finally { + setIsLoadingMore(false); + } + }, [isLoading, isLoadingMore, hasMore, page, sortBy]); + + const value = useMemo( + () => ({ + entries, + isLoading: !activated || isLoading, + hasMore, + loadMore, + sortBy, + setSortBy, + activate, + }), + [entries, activated, isLoading, hasMore, loadMore, sortBy, activate] + ); + + return {children}; +} + +export function useGrantFeed() { + const context = useContext(GrantFeedContext); + if (!context) { + throw new Error('useGrantFeed must be used within a GrantFeedProvider'); + } + return context; +} diff --git a/hooks/useActivityFeed.ts b/hooks/useActivityFeed.ts index a663c3b45..eb892dec9 100644 --- a/hooks/useActivityFeed.ts +++ b/hooks/useActivityFeed.ts @@ -1,8 +1,9 @@ 'use client'; -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { FeedEntry } from '@/types/feed'; import { ActivityService, ActivityScope } from '@/services/activity.service'; +import { useFeedStateRestoration } from '@/hooks/useFeedStateRestoration'; export type ActivityTab = 'all' | 'peer_reviews' | 'financial'; @@ -12,17 +13,38 @@ interface UseActivityFeedOptions { } export function useActivityFeed({ scope, grantId }: UseActivityFeedOptions = {}) { - const [entries, setEntries] = useState([]); - const [isLoading, setIsLoading] = useState(true); + const restorationTab = useMemo(() => { + const parts = ['activity']; + if (grantId != null) parts.push(`grant-${grantId}`); + if (scope) parts.push(scope); + return parts.join('-'); + }, [grantId, scope]); + + const { restoredState, restoredScrollPosition, lastClickedEntryId } = useFeedStateRestoration({ + activeTab: restorationTab, + }); + + const hasRestoredEntries = restoredState !== null; + const initialEntries = restoredState?.entries ?? []; + const initialHasMore = restoredState?.hasMore ?? false; + const initialPage = restoredState?.page ?? 1; + + const [entries, setEntries] = useState(initialEntries); + const [isLoading, setIsLoading] = useState(!hasRestoredEntries); const [isLoadingMore, setIsLoadingMore] = useState(false); - const [hasMore, setHasMore] = useState(false); - const [count, setCount] = useState(0); - const pageRef = useRef(1); + const [hasMore, setHasMore] = useState(initialHasMore); + const [count, setCount] = useState(initialEntries.length); + const [page, setPage] = useState(initialPage); + const pageRef = useRef(initialPage); + // Skip the first fetch when we restored entries; subsequent fetchInitial + // identity changes (scope / grantId) still refetch. + const skipNextFetchRef = useRef(hasRestoredEntries && initialEntries.length > 0); const fetchInitial = useCallback(async () => { setEntries([]); setIsLoading(true); pageRef.current = 1; + setPage(1); try { const result = await ActivityService.getActivity({ @@ -41,6 +63,10 @@ export function useActivityFeed({ scope, grantId }: UseActivityFeedOptions = {}) }, [scope, grantId]); useEffect(() => { + if (skipNextFetchRef.current) { + skipNextFetchRef.current = false; + return; + } fetchInitial(); }, [fetchInitial]); @@ -63,6 +89,7 @@ export function useActivityFeed({ scope, grantId }: UseActivityFeedOptions = {}) }); setHasMore(result.hasMore); pageRef.current = nextPage; + setPage(nextPage); } catch (error) { console.error('Error loading more activity:', error); } finally { @@ -76,6 +103,10 @@ export function useActivityFeed({ scope, grantId }: UseActivityFeedOptions = {}) isLoadingMore, hasMore, count, + page, loadMore, + restoredScrollPosition, + lastClickedEntryId, + restorationTab, }; } diff --git a/hooks/useFundTabs.tsx b/hooks/useFundTabs.tsx index 8a099685b..bfc1f6ddd 100644 --- a/hooks/useFundTabs.tsx +++ b/hooks/useFundTabs.tsx @@ -19,19 +19,23 @@ export const HOME_TAB_PATHS = ['/', '/fund', '/fund/proposals']; export const isHomeTabPath = (pathname: string) => HOME_TAB_PATHS.includes(pathname); +const HOME_TAB_ACTIVE_CLASS_NAME = 'border-b-primary-600 text-primary-600 !border-b-4'; + export const FUND_TABS = [ { id: 'activity' as const, label: 'Activity', href: '/', icon: Waves, + activeClassName: HOME_TAB_ACTIVE_CLASS_NAME, scroll: false, }, { id: 'fund' as const, - label: 'Fund', + label: 'Request for Proposals', href: '/fund', icon: BullhornIcon as LucideIcon, + activeClassName: HOME_TAB_ACTIVE_CLASS_NAME, scroll: false, }, { @@ -39,11 +43,12 @@ export const FUND_TABS = [ label: 'Proposals', href: '/fund/proposals', icon: FileText, + activeClassName: HOME_TAB_ACTIVE_CLASS_NAME, scroll: false, }, ]; -/** Homepage hub: Activity / Fund / Proposals (shared shell + FundSidebar). */ +/** Homepage hub: Activity / Request for Proposals / Proposals (shared shell + FundSidebar). */ export function useFundTabs() { const pathname = usePathname(); const router = useRouter(); diff --git a/types/feed.ts b/types/feed.ts index cbecf756d..cd4afa7d5 100644 --- a/types/feed.ts +++ b/types/feed.ts @@ -13,6 +13,7 @@ import { import { mapApiDocumentTypeToClientType, type ApiDocumentType } from '@/utils/contentTypeMapping'; import { Bounty, BountyWithComment, transformBounty } from './bounty'; import { Comment, CommentType, ContentFormat, transformComment } from './comment'; +import type { CommentContent } from '@/components/Comment/lib/types'; import { Fundraise, transformFundraise, Application, transformApplication } from './funding'; import { Journal } from './journal'; import { UserVoteType } from './reaction'; @@ -59,6 +60,51 @@ function transformFundingActivityFunder(funder: unknown): AuthorProfile | undefi } } +function transformFundingActivityPeerReview( + raw: unknown +): FeedFundingActivityPeerReview | undefined { + if (!raw || typeof raw !== 'object') return undefined; + + const peerReview = raw as { + id?: number; + comment_content_json?: unknown; + comment_content_type?: string; + comment_type?: string; + created_date?: string; + author?: unknown; + }; + + if (peerReview.id == null) return undefined; + + const rawCommentType = peerReview.comment_type; + const isReview = + rawCommentType === 'PEER_REVIEW' || + rawCommentType === 'COMMUNITY_REVIEW' || + rawCommentType === 'REVIEW'; + + let author: AuthorProfile | undefined; + if (peerReview.author && typeof peerReview.author === 'object') { + try { + author = transformAuthorProfile(peerReview.author as Record); + } catch { + author = undefined; + } + } + + const contentFormat: ContentFormat = + peerReview.comment_content_type === 'TIPTAP' ? 'TIPTAP' : 'QUILL_EDITOR'; + + return { + id: peerReview.id, + content: (peerReview.comment_content_json ?? null) as CommentContent | null, + contentFormat, + commentType: rawCommentType ?? 'PEER_REVIEW', + isReview, + createdDate: peerReview.created_date, + author, + }; +} + // Recursive helper function to transform nested parent comments const transformNestedParentComment = (rawParent: any): ParentCommentPreview | undefined => { if (!rawParent) { @@ -237,6 +283,16 @@ export interface FeedGrantContent extends BaseFeedContent { export type FundingActivitySourceType = 'BOUNTY_PAYOUT' | 'TIP_REVIEW'; +export interface FeedFundingActivityPeerReview { + id: number; + content: CommentContent | null; + contentFormat: ContentFormat; + commentType: string; + isReview: boolean; + createdDate?: string; + author?: AuthorProfile; +} + export interface FeedFundingActivityContent extends BaseFeedContent { contentType: 'FUNDINGACTIVITY'; sourceType: FundingActivitySourceType; @@ -245,6 +301,8 @@ export interface FeedFundingActivityContent extends BaseFeedContent { totalUsd: number; activityDate?: string; recipient?: AuthorProfile; + /** Linked peer-review comment when the payout/tip resolves to one. */ + peerReview?: FeedFundingActivityPeerReview; } // Update the Content union type to include the base interface @@ -1167,6 +1225,7 @@ export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => { (content_object.total_usd_cents ? content_object.total_usd_cents / 100 : 0), activityDate: content_object.activity_date, recipient: transformFundingActivityRecipient(content_object.recipients), + peerReview: transformFundingActivityPeerReview(content_object.peer_review), }; content = fundingActivityContent; } catch (error) { diff --git a/utils/searchHistory.ts b/utils/searchHistory.ts index 905052948..7f19878d7 100644 --- a/utils/searchHistory.ts +++ b/utils/searchHistory.ts @@ -3,13 +3,11 @@ import { SearchSuggestion } from '@/types/search'; export const SEARCH_HISTORY_KEY = 'search_history'; export const MAX_HISTORY_ITEMS = 10; -// Helper functions to handle localStorage export const getSearchHistory = (): SearchSuggestion[] => { if (typeof window === 'undefined') return []; try { const stored = localStorage.getItem(SEARCH_HISTORY_KEY); - const history = stored ? JSON.parse(stored) : []; - return history; + return stored ? JSON.parse(stored) : []; } catch (error) { console.error('Error reading from localStorage:', error); return []; @@ -20,7 +18,6 @@ export const saveSearchHistory = (items: SearchSuggestion[]) => { if (typeof window === 'undefined') return; try { localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(items)); - // Dispatch a custom event to notify other components window.dispatchEvent(new Event('search-history-updated')); } catch (error) { console.error('Error saving to localStorage:', error);