From 321504c3dc05840f868b01b572a0103ce610122f Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Thu, 6 Aug 2026 00:35:37 +0300 Subject: [PATCH 1/5] Comments addressing --- app/layouts/LeftSidebar.tsx | 20 +- app/layouts/Navigation.tsx | 122 ++++++------ app/layouts/PageLayout.tsx | 2 +- app/layouts/PublishMenu.tsx | 174 ++++++++---------- app/layouts/TopBar.tsx | 49 +---- .../components/RightSidebarContainer.tsx | 2 +- app/layouts/topbar/TopBarBreadcrumb.tsx | 8 +- app/layouts/topbar/pageRoutes.tsx | 2 +- .../Activity/lib/activityWorkContext.ts | 23 ++- components/Activity/lib/feedEntryAdapters.ts | 2 +- components/Earn/EarnRightSidebar.tsx | 4 +- components/Funding/HomeTabs.tsx | 17 +- components/Notebook/NotebookHome.tsx | 4 +- .../ResearchCoin/ResearchCoinRightSidebar.tsx | 4 +- hooks/useFundTabs.tsx | 9 +- 15 files changed, 211 insertions(+), 231 deletions(-) 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} + )}
)} - {showTopBarFundTabs && ( -
- -
- )}
{/* Right side */} @@ -162,31 +143,21 @@ export function TopBar({ onMenuClick }: TopBarProps) {
{/* Feed tabs — mobile only, stacked below title when content tabs scroll out of view */} - {(isFeedPage || isFundPage) && ( + {isFeedPage && (
- {isFeedPage && ( - - )} - {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..2516977a0 100644 --- a/app/layouts/topbar/TopBarBreadcrumb.tsx +++ b/app/layouts/topbar/TopBarBreadcrumb.tsx @@ -9,19 +9,19 @@ export const TopBarBreadcrumb = ({ pageInfo, variant }: 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' + ? 'block min-w-0 truncate text-lg font-semibold leading-tight text-gray-900' : 'leading-tight flex-shrink-0 font-semibold text-gray-900'; - const titleStyle = isMobile ? undefined : { fontSize: '24px', letterSpacing: '-0.5px' }; + const titleStyle = isMobile ? undefined : { fontSize: '26px', letterSpacing: '-0.5px' }; return (
{pageInfo.title ? ( diff --git a/app/layouts/topbar/pageRoutes.tsx b/app/layouts/topbar/pageRoutes.tsx index 6579fac18..f3d48bd49 100644 --- a/app/layouts/topbar/pageRoutes.tsx +++ b/app/layouts/topbar/pageRoutes.tsx @@ -43,7 +43,7 @@ const ROUTE_RULES: RouteRule[] = [ { match: (p) => isHomeTabPath(p), getInfo: () => ({ - title: 'Home', + title: 'Fund Scientific Research', icon: , }), }, diff --git a/components/Activity/lib/activityWorkContext.ts b/components/Activity/lib/activityWorkContext.ts index 11bf025fa..9e9f11912 100644 --- a/components/Activity/lib/activityWorkContext.ts +++ b/components/Activity/lib/activityWorkContext.ts @@ -401,9 +401,30 @@ function getWorkContextFromContent(entry: FeedEntry): ActivityWorkContext | null return null; } +/** + * 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; + + const content = entry.content as { authors?: AuthorProfile[] } | undefined; + if (Array.isArray(content?.authors) && content.authors.length > 0) { + return content.authors; + } + + return relatedAuthors; +} + function workContextFromRelatedWork(entry: FeedEntry, related: Work): ActivityWorkContext { 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,7 +441,7 @@ 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, }; } diff --git a/components/Activity/lib/feedEntryAdapters.ts b/components/Activity/lib/feedEntryAdapters.ts index 83418177d..2290353bc 100644 --- a/components/Activity/lib/feedEntryAdapters.ts +++ b/components/Activity/lib/feedEntryAdapters.ts @@ -115,7 +115,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 { 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/Funding/HomeTabs.tsx b/components/Funding/HomeTabs.tsx index de1b63e62..24e581098 100644 --- a/components/Funding/HomeTabs.tsx +++ b/components/Funding/HomeTabs.tsx @@ -1,20 +1,23 @@ 'use client'; -import { FeedTabs } from '@/components/Feed/FeedTabs'; -import { useContentTabsVisibilitySentinel } from '@/hooks/useContentTabsVisibilitySentinel'; +import { Tabs } from '@/components/ui/Tabs'; import { useFundTabs } from '@/hooks/useFundTabs'; /** - * 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(); 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/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(); From 081ea328278ce76e28629cd2dad77669ac06648a Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Fri, 7 Aug 2026 15:20:10 +0300 Subject: [PATCH 2/5] Enhance funding and activity components by integrating search history tracking and improving UI elements. Added SearchHistoryTracker to GrantSlugLayout, updated TopBar to support fund tabs, and refactored RecentlyVisitedCard for better hydration handling. Adjusted FeedItemActions for compact mode and improved ActivityCard components for better display. Updated FundingPowerCard to include deposit modal functionality. --- app/grant/[id]/[slug]/layout.tsx | 2 + app/layouts/TopBar.tsx | 59 ++++-- app/layouts/topbar/TopBarBreadcrumb.tsx | 18 +- components/Activity/ActivityCardFull.tsx | 3 +- components/Activity/ActivityCardHeader.tsx | 1 + components/Activity/AmountBadge.tsx | 10 +- components/Activity/BountyAmount.tsx | 2 +- components/Activity/WorkPreviewCard.tsx | 15 +- components/Activity/lib/feedEntryAdapters.ts | 28 ++- components/Feed/FeedItemActions.tsx | 179 +++++++++++++------ components/Funding/FundSidebar.tsx | 21 ++- components/Funding/FundingPowerCard.tsx | 37 ++-- components/Funding/HomeTabs.tsx | 4 +- components/Funding/RecentlyVisitedCard.tsx | 161 ++++++++++++----- components/work/SearchHistoryTracker.tsx | 12 +- types/feed.ts | 59 ++++++ utils/searchHistory.ts | 5 +- 17 files changed, 450 insertions(+), 166 deletions(-) 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/TopBar.tsx b/app/layouts/TopBar.tsx index 581a403f4..64f968009 100644 --- a/app/layouts/TopBar.tsx +++ b/app/layouts/TopBar.tsx @@ -11,6 +11,7 @@ import { calculateProfileCompletion } from '@/utils/profileCompletion'; import { Logo } from '@/components/ui/Logo'; import { FeedTabs } from '@/components/Feed/FeedTabs'; import { useFeedTabs } from '@/hooks/useFeedTabs'; +import { useFundTabs } from '@/hooks/useFundTabs'; import { useFeedTabsVisibility } from '@/contexts/FeedTabsVisibilityContext'; import { useTopBarSlot } from '@/contexts/TopBarSlotContext'; import { useSmartBack } from '@/hooks/useSmartBack'; @@ -36,8 +37,16 @@ export function TopBar({ onMenuClick }: TopBarProps) { const { showAuthModal } = useAuthModalContext(); const { tabs, highlightedTab, handleTabChange, isFeedPage } = useFeedTabs(); + const { + tabs: fundTabs, + highlightedTab: fundHighlightedTab, + handleTabChange: handleFundTabChange, + isFundPage, + } = useFundTabs(); const { contentTabsHidden } = useFeedTabsVisibility(); const showTopBarFeedTabs = isFeedPage && contentTabsHidden; + const showTopBarFundTabs = isFundPage && contentTabsHidden; + const showTopBarContentTabs = showTopBarFeedTabs || showTopBarFundTabs; // A page (e.g. the notebook) can inject a custom control here in place of the // default breadcrumb. @@ -95,13 +104,19 @@ export function TopBar({ onMenuClick }: TopBarProps) { {showBackButton && } - {pageInfo && } + {pageInfo && ( + + )} )} {/* Inline content tabs — desktop only, shown once page tabs scroll away */} {showTopBarFeedTabs && ( -
+
)} + {showTopBarFundTabs && ( +
+ +
+ )}
{/* Right side */} @@ -142,22 +166,31 @@ export function TopBar({ onMenuClick }: TopBarProps) {
- {/* Feed tabs — mobile only, stacked below title when content tabs scroll out of view */} - {isFeedPage && ( + {/* Content tabs — mobile only, stacked below title when page tabs scroll out of view */} + {(isFeedPage || isFundPage) && (
- + {showTopBarFeedTabs && ( + + )} + {showTopBarFundTabs && ( + + )}
)}
diff --git a/app/layouts/topbar/TopBarBreadcrumb.tsx b/app/layouts/topbar/TopBarBreadcrumb.tsx index 2516977a0..fa5b1bbda 100644 --- a/app/layouts/topbar/TopBarBreadcrumb.tsx +++ b/app/layouts/topbar/TopBarBreadcrumb.tsx @@ -1,20 +1,30 @@ 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 min-w-0 flex-1 items-center overflow-hidden tablet:!hidden' - : 'hidden tablet:!flex items-center min-w-0'; + : cn('hidden tablet:!flex items-center min-w-0', truncateTitle && 'max-w-[min(240px,36%)]'); const titleClass = isMobile ? 'block min-w-0 truncate text-lg font-semibold leading-tight text-gray-900' - : 'leading-tight flex-shrink-0 font-semibold text-gray-900'; + : cn( + 'leading-tight font-semibold text-gray-900', + truncateTitle ? 'min-w-0 truncate' : 'flex-shrink-0' + ); const titleStyle = isMobile ? undefined : { fontSize: '26px', letterSpacing: '-0.5px' }; @@ -24,7 +34,7 @@ export const TopBarBreadcrumb = ({ pageInfo, variant }: TopBarBreadcrumbProps) = className={`${isMobile ? 'min-w-0 flex-1 overflow-hidden' : 'min-w-0'} flex items-center gap-1.5`} > {pageInfo.title ? ( - + {pageInfo.title} ) : ( diff --git a/components/Activity/ActivityCardFull.tsx b/components/Activity/ActivityCardFull.tsx index a6cec1011..b5e25d12f 100644 --- a/components/Activity/ActivityCardFull.tsx +++ b/components/Activity/ActivityCardFull.tsx @@ -129,10 +129,9 @@ export const ActivityCardFull: FC = ({ entry }) => { href={work.href} hideCommentButton hideReportButton - variant="inline" + variant="compact" leadingUtilityActions rightSideActionButton={action} - className="gap-1" /> } /> 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/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..dd5f0ee30 100644 --- a/components/Activity/WorkPreviewCard.tsx +++ b/components/Activity/WorkPreviewCard.tsx @@ -48,14 +48,19 @@ export const WorkPreviewCard: FC = ({ }) => { const showFooter = !!actions; - const authorLine = - organization || - (authors.length > 0 + const authorNames = + authors.length > 0 ? authors .slice(0, 2) .map((a) => a.name) .join(', ') + (authors.length > 2 ? ` +${authors.length - 2}` : '') - : institution || null); + : null; + + const authorLine = + organization || + (authorNames && institution + ? `${authorNames} · ${institution}` + : authorNames || institution || null); const imageBlock = (
= ({ )} {showFooter && ( -
+
{actions}
)} diff --git a/components/Activity/lib/feedEntryAdapters.ts b/components/Activity/lib/feedEntryAdapters.ts index 2290353bc..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' }; } @@ -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/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/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) => ( +
= { - 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/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/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); From adce5fb7f7f7bd377ef06f6a42f003d34565a905 Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Fri, 7 Aug 2026 15:29:29 +0300 Subject: [PATCH 3/5] Update activity work context to conditionally handle authors based on content type, specifically for 'PURCHASE' and 'USDFUNDRAISECONTRIBUTION'. --- components/Activity/lib/activityWorkContext.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/components/Activity/lib/activityWorkContext.ts b/components/Activity/lib/activityWorkContext.ts index 9e9f11912..df118ea98 100644 --- a/components/Activity/lib/activityWorkContext.ts +++ b/components/Activity/lib/activityWorkContext.ts @@ -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, }; } @@ -412,6 +415,10 @@ function resolveWorkAuthors( ): 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; From 9fe7b243535cb630c74918d87991bc70231c9013 Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Sun, 9 Aug 2026 19:23:52 +0300 Subject: [PATCH 4/5] Refactor Activity components to enhance functionality and UI. Introduced ActivityWorkActions and ActivityWorkMetadata for better separation of concerns in ActivityCardFull. Updated ActivityCardSkeleton for improved loading states. Enhanced feed scroll tracking in FundActivityPageContent and GrantContentSwitcher. --- app/activity/page.tsx | 38 ++++- app/layouts/TopBar.tsx | 13 +- app/layouts/topbar/TopBarBreadcrumb.tsx | 2 +- app/layouts/topbar/TopBarSearchButton.tsx | 2 +- components/Activity/ActivityCardFull.tsx | 108 +++--------- components/Activity/ActivityCardSkeleton.tsx | 15 +- components/Activity/ActivityWorkActions.tsx | 102 +++++++++++ components/Activity/ActivityWorkMetadata.tsx | 103 ++++++++++++ components/Activity/WorkPreviewCard.tsx | 159 +++++++----------- .../Activity/lib/activityWorkContext.ts | 36 ++-- .../Funding/FundActivityPageContent.tsx | 39 ++++- components/Funding/GrantContentSwitcher.tsx | 40 ++++- components/Funding/GrantPageContent.tsx | 30 +++- hooks/useActivityFeed.ts | 43 ++++- 14 files changed, 503 insertions(+), 227 deletions(-) create mode 100644 components/Activity/ActivityWorkActions.tsx create mode 100644 components/Activity/ActivityWorkMetadata.tsx 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/layouts/TopBar.tsx b/app/layouts/TopBar.tsx index 64f968009..7b7ab3b99 100644 --- a/app/layouts/TopBar.tsx +++ b/app/layouts/TopBar.tsx @@ -10,6 +10,7 @@ import Link from 'next/link'; import { calculateProfileCompletion } from '@/utils/profileCompletion'; import { Logo } from '@/components/ui/Logo'; import { FeedTabs } from '@/components/Feed/FeedTabs'; +import { Tabs } from '@/components/ui/Tabs'; import { useFeedTabs } from '@/hooks/useFeedTabs'; import { useFundTabs } from '@/hooks/useFundTabs'; import { useFeedTabsVisibility } from '@/contexts/FeedTabsVisibilityContext'; @@ -127,10 +128,12 @@ export function TopBar({ onMenuClick }: TopBarProps) { )} {showTopBarFundTabs && (
-
)} @@ -185,10 +188,12 @@ export function TopBar({ onMenuClick }: TopBarProps) { /> )} {showTopBarFundTabs && ( - )}
diff --git a/app/layouts/topbar/TopBarBreadcrumb.tsx b/app/layouts/topbar/TopBarBreadcrumb.tsx index fa5b1bbda..143cef289 100644 --- a/app/layouts/topbar/TopBarBreadcrumb.tsx +++ b/app/layouts/topbar/TopBarBreadcrumb.tsx @@ -17,7 +17,7 @@ export const TopBarBreadcrumb = ({ const containerClass = isMobile ? 'flex min-w-0 flex-1 items-center overflow-hidden tablet:!hidden' - : cn('hidden tablet:!flex items-center min-w-0', truncateTitle && 'max-w-[min(240px,36%)]'); + : 'hidden tablet:!flex items-center min-w-0'; const titleClass = isMobile ? 'block min-w-0 truncate text-lg font-semibold leading-tight text-gray-900' 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,48 +71,17 @@ export const ActivityCardFull: FC = ({ entry }) => { )}
- - } - /> + + + + + + + +
- - {work.fundraise && ( - setIsContributeModalOpen(false)} - onContributeSuccess={handleContributeSuccess} - fundraise={work.fundraise} - proposalTitle={work.title} - /> - )}
); }; 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/WorkPreviewCard.tsx b/components/Activity/WorkPreviewCard.tsx index dd5f0ee30..806e866e6 100644 --- a/components/Activity/WorkPreviewCard.tsx +++ b/components/Activity/WorkPreviewCard.tsx @@ -1,67 +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 authorNames = - authors.length > 0 - ? authors - .slice(0, 2) - .map((a) => a.name) - .join(', ') + (authors.length > 2 ? ` +${authors.length - 2}` : '') - : null; - - const authorLine = - organization || - (authorNames && institution - ? `${authorNames} · ${institution}` - : authorNames || 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}
)}
@@ -164,8 +116,14 @@ export const WorkPreviewCard: FC = ({ className )} > - {href ? ( - + {work.href ? ( + { + onNavigate?.(); + }} + > {imageBlock} ) : ( @@ -179,4 +137,9 @@ export const WorkPreviewCard: FC = ({ )}
); -}; +} + +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 df118ea98..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); @@ -427,7 +427,7 @@ function resolveWorkAuthors( return relatedAuthors; } -function workContextFromRelatedWork(entry: FeedEntry, related: Work): ActivityWorkContext { +function workFromRelatedWork(entry: FeedEntry, related: Work): ActivityWork { const tab = resolveTabFromContext(entry.activityContext); const documentType = related.contentType; const relatedAuthors = related.authors?.map((authorship) => authorship.authorProfile); @@ -453,11 +453,11 @@ function workContextFromRelatedWork(entry: FeedEntry, related: Work): ActivityWo }; } -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/Funding/FundActivityPageContent.tsx b/components/Funding/FundActivityPageContent.tsx index cbc6be4a6..edaca650f 100644 --- a/components/Funding/FundActivityPageContent.tsx +++ b/components/Funding/FundActivityPageContent.tsx @@ -1,12 +1,49 @@ 'use client'; +import { 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 { 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, + restoredScrollPosition, + lastClickedEntryId, + restorationTab, + } = useActivityFeed({}); + + 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/GrantContentSwitcher.tsx b/components/Funding/GrantContentSwitcher.tsx index e29e8100a..c853936af 100644 --- a/components/Funding/GrantContentSwitcher.tsx +++ b/components/Funding/GrantContentSwitcher.tsx @@ -1,11 +1,14 @@ 'use client'; -import { ReactNode } from 'react'; +import { ReactNode, useMemo } from 'react'; +import { usePathname, useSearchParams } from 'next/navigation'; import { useInView } from 'react-intersection-observer'; import { useGrantTab } from '@/components/Funding/GrantPageContent'; import { GrantDetailsInline } from '@/components/Funding/GrantDetailsInline'; import { ActivityCardFull } from '@/components/Activity/ActivityCardFull'; import { ActivityCardSkeleton } from '@/components/Activity/ActivityCardSkeleton'; +import { useFeedScrollTracking } from '@/hooks/useFeedScrollTracking'; +import { getFeedKey } from '@/contexts/NavigationContext'; interface GrantContentSwitcherProps { children: ReactNode; @@ -14,8 +17,41 @@ interface GrantContentSwitcherProps { } export function GrantContentSwitcher({ children, content, imageUrl }: GrantContentSwitcherProps) { + const pathname = usePathname(); + const searchParams = useSearchParams(); const { activeTab, activity } = useGrantTab(); - const { entries, isLoading, isLoadingMore, hasMore, loadMore } = activity; + const { + entries, + isLoading, + isLoadingMore, + hasMore, + page, + loadMore, + restoredScrollPosition, + lastClickedEntryId, + restorationTab, + } = activity; + + 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/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/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, }; } From 4cd1ee8009680aa21cb0ea79e65ef3caeaf02924 Mon Sep 17 00:00:00 2001 From: nicktytarenko Date: Sun, 9 Aug 2026 19:51:26 +0300 Subject: [PATCH 5/5] Update layout to include HomeFeedsProvider, ensuring feeds remain mounted during tab switches. --- app/(home)/layout.tsx | 12 +- app/fund/FundGrantsPageContent.tsx | 35 ++--- .../Funding/FundActivityPageContent.tsx | 11 +- components/Funding/HomeFeedsProvider.tsx | 14 ++ contexts/ActivityFeedContext.tsx | 141 ++++++++++++++++++ contexts/GrantFeedContext.tsx | 117 +++++++++++++++ 6 files changed, 299 insertions(+), 31 deletions(-) create mode 100644 components/Funding/HomeFeedsProvider.tsx create mode 100644 contexts/ActivityFeedContext.tsx create mode 100644 contexts/GrantFeedContext.tsx 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/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/components/Funding/FundActivityPageContent.tsx b/components/Funding/FundActivityPageContent.tsx index edaca650f..9dea279e3 100644 --- a/components/Funding/FundActivityPageContent.tsx +++ b/components/Funding/FundActivityPageContent.tsx @@ -1,11 +1,11 @@ 'use client'; -import { useMemo } from 'react'; +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'; @@ -19,10 +19,15 @@ export function FundActivityPageContent() { hasMore, page, loadMore, + activate, restoredScrollPosition, lastClickedEntryId, restorationTab, - } = useActivityFeed({}); + } = useActivityFeeds(); + + useEffect(() => { + activate(); + }, [activate]); const feedKey = useMemo(() => { const queryParams: Record = {}; 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/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; +}