,
+ 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 = (
{
- e.stopPropagation();
- e.preventDefault();
-
- if (smAndDown) {
- setIsMobileDrawerOpen(true);
- }
- }}
- >
-
- Post
-
- );
-
- // Compact trigger for minimized sidebar
- const compactTrigger = (
- {
- e.stopPropagation();
- e.preventDefault();
-
if (smAndDown) {
+ e.preventDefault();
setIsMobileDrawerOpen(true);
}
}}
+ aria-label="Open post menu"
>
-
+
+
+ Post
+
);
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({
isHomeTabPath(p),
getInfo: () => ({
- title: 'Home',
+ title: 'Fund Scientific Research',
icon: ,
}),
},
diff --git a/components/Activity/ActivityCardFull.tsx b/components/Activity/ActivityCardFull.tsx
index a6cec1011..9791dc207 100644
--- a/components/Activity/ActivityCardFull.tsx
+++ b/components/Activity/ActivityCardFull.tsx
@@ -1,20 +1,17 @@
'use client';
-import { FC, useState } from 'react';
-import { useRouter } from 'next/navigation';
-import { ArrowRight } from 'lucide-react';
+import { FC } from 'react';
import { Avatar } from '@/components/ui/Avatar';
-import { Button } from '@/components/ui/Button';
import { CommentReadOnly } from '@/components/Comment/CommentReadOnly';
-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 { useNavigation } from '@/contexts/NavigationContext';
import { ActivityCardHeader } from './ActivityCardHeader';
+import { ActivityWorkActions } from './ActivityWorkActions';
+import { ActivityWorkMetadata } from './ActivityWorkMetadata';
import { WorkPreviewCard } from './WorkPreviewCard';
import { getActivityHeaderMessage, getCommentPreview } from './lib/feedEntryAdapters';
-import { getActivityWorkContext, getWorkCardPresentation } from './lib/activityWorkContext';
+import { getActivityWork, getWorkCardPresentation } from './lib/activityWorkContext';
import type { FeedEntry } from '@/types/feed';
interface ActivityCardFullProps {
@@ -22,61 +19,29 @@ interface ActivityCardFullProps {
}
export const ActivityCardFull: FC = ({ entry }) => {
- const work = getActivityWorkContext(entry);
- const router = useRouter();
+ const work = getActivityWork(entry);
const { showUSD } = useCurrencyPreference();
const { exchangeRate } = useExchangeRate();
- const { showShareModal } = useShareModalContext();
- const [isContributeModalOpen, setIsContributeModalOpen] = useState(false);
+ const { updateLastClickedEntryId } = useNavigation();
if (!work) return null;
+ const entryId = String(entry.id);
const message = getActivityHeaderMessage(entry);
const commentPreview = getCommentPreview(entry);
- const presentation = getWorkCardPresentation(entry, work, {
+ const { showComment: allowComment } = getWorkCardPresentation(entry, work, {
showUSD,
exchangeRate,
isReview: commentPreview?.isReview,
});
+ const showComment = allowComment && !!commentPreview;
- const showComment = presentation.showComment && !!commentPreview;
- const voteCount = entry.metrics?.adjustedScore ?? entry.metrics?.votes ?? 0;
- const isReviewOfProposal = !!commentPreview?.isReview && work.documentType === 'preregistration';
- const feedContentType = work.documentType === 'paper' ? 'PAPER' : 'POST';
-
- const handleFundClick = () => {
- setIsContributeModalOpen(true);
- };
-
- const handleContributeSuccess = () => {
- setIsContributeModalOpen(false);
- showShareModal({
- url: window.location.href,
- docTitle: work.title,
- action: 'USER_FUNDED_PROPOSAL',
- });
- router.refresh();
+ const markEntryClicked = () => {
+ updateLastClickedEntryId(entryId);
};
- const action = (() => {
- const cta = presentation.cta;
- if (!cta) return undefined;
-
- return (
- router.push(cta.href)}
- className="rounded-md gap-1"
- >
- {cta.label}
-
-
- );
- })();
-
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 ? (
+ setIsContributeModalOpen(true)
+ : () => {
+ onNavigate?.();
+ router.push(cta.href);
+ }
+ }
+ className="rounded-md gap-1"
+ >
+ {cta.label}
+
+
+ ) : 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 ? (
= ({
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 && (
-
);
-};
+}
+
+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 ? (
- {
- e.stopPropagation();
- }}
- onClick={handleCopyDocumentUrl}
- className="flex h-8 w-8 !p-0 items-center justify-center rounded-full text-gray-700 transition-colors hover:bg-white hover:text-gray-900 hover:shadow-sm"
- >
-
-
+ isCompact ? (
+ e.stopPropagation()}
+ onClick={handleCopyDocumentUrl}
+ className="flex h-6 w-6 items-center justify-center text-gray-500 transition-colors hover:text-gray-800"
+ aria-label="Share"
+ title={showTooltips ? 'Copy link' : undefined}
+ >
+
+
+ ) : (
+ {
+ e.stopPropagation();
+ }}
+ onClick={handleCopyDocumentUrl}
+ className="flex h-8 w-8 !p-0 items-center justify-center rounded-full text-gray-700 transition-colors hover:bg-white hover:text-gray-900 hover:shadow-sm"
+ >
+
+
+ )
) : null;
const saveButton = canSave ? (
- {
- e.stopPropagation();
- }}
- onClick={handleAddToList}
- disabled={isTogglingDefaultList}
- className={cn(
- 'flex h-8 w-8 !p-0 items-center justify-center rounded-full transition-colors',
- isDocumentInList
- ? 'text-green-600 hover:bg-white hover:text-green-700 hover:shadow-sm'
- : 'text-gray-700 hover:bg-white hover:text-gray-900 hover:shadow-sm'
- )}
- >
-
-
+ isCompact ? (
+ e.stopPropagation()}
+ onClick={handleAddToList}
+ disabled={isTogglingDefaultList}
+ className={cn(
+ 'flex h-6 w-6 items-center justify-center transition-colors',
+ isDocumentInList ? 'text-green-600' : 'text-gray-500 hover:text-gray-800',
+ isTogglingDefaultList && 'opacity-50 cursor-not-allowed'
+ )}
+ aria-label={isDocumentInList ? 'Remove from list' : 'Save'}
+ title={showTooltips ? 'Save' : undefined}
+ >
+
+
+ ) : (
+ {
+ e.stopPropagation();
+ }}
+ onClick={handleAddToList}
+ disabled={isTogglingDefaultList}
+ className={cn(
+ 'flex h-8 w-8 !p-0 items-center justify-center rounded-full transition-colors',
+ isDocumentInList
+ ? 'text-green-600 hover:bg-white hover:text-green-700 hover:shadow-sm'
+ : 'text-gray-700 hover:bg-white hover:text-gray-900 hover:shadow-sm'
+ )}
+ >
+
+
+ )
) : null;
return (
@@ -466,46 +506,85 @@ export const FeedItemActions: FC = ({
-
+
handleVote(e, 'up')}
disabled={isVoting}
className={cn(
- 'flex h-7 w-7 items-center justify-center rounded-full transition-colors',
- localUserVote === 'UPVOTE'
- ? 'bg-green-50 text-green-600'
- : 'text-gray-500 hover:bg-gray-100 hover:text-gray-900',
+ 'flex items-center justify-center transition-colors',
+ isCompact
+ ? cn(
+ 'h-6 w-6',
+ localUserVote === 'UPVOTE'
+ ? 'text-green-600'
+ : 'text-gray-500 hover:text-gray-800'
+ )
+ : cn(
+ 'h-7 w-7 rounded-full',
+ localUserVote === 'UPVOTE'
+ ? 'bg-green-50 text-green-600'
+ : 'text-gray-500 hover:bg-gray-100 hover:text-gray-900'
+ ),
isVoting ? 'cursor-not-allowed' : 'cursor-pointer'
)}
aria-label="Upvote"
>
-
+
-
+
{localVoteCount}
handleVote(e, 'down')}
disabled={isVoting}
className={cn(
- 'flex h-7 w-7 items-center justify-center rounded-full transition-colors',
- localUserVote === 'DOWNVOTE'
- ? 'bg-red-50 text-red-600'
- : 'text-gray-500 hover:bg-gray-100 hover:text-gray-900',
+ 'flex items-center justify-center transition-colors',
+ isCompact
+ ? cn(
+ 'h-6 w-6',
+ localUserVote === 'DOWNVOTE'
+ ? 'text-red-600'
+ : 'text-gray-500 hover:text-gray-800'
+ )
+ : cn(
+ 'h-7 w-7 rounded-full',
+ localUserVote === 'DOWNVOTE'
+ ? 'bg-red-50 text-red-600'
+ : 'text-gray-500 hover:bg-gray-100 hover:text-gray-900'
+ ),
isVoting ? 'cursor-not-allowed' : 'cursor-pointer'
)}
aria-label="Downvote"
>
-
+
{!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 (
@@ -65,13 +70,14 @@ export const FundingPowerCard = ({ className }: FundingPowerCardProps) => {
{isEmpty ? '—' : fmt(total)}
{!isEmpty && (
-
Deposit
-
+
)}
@@ -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) => (
- (
+
{children}
-
+
);
-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 (
+
+
+
+ {SKELETON_ROWS.map((row) => (
+
+
+
+
+ ))}
+
+
+ );
+}
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);