diff --git a/app/(home)/fund/loading.tsx b/app/(home)/fund/loading.tsx new file mode 100644 index 000000000..7e50ad550 --- /dev/null +++ b/app/(home)/fund/loading.tsx @@ -0,0 +1,16 @@ +import { FeedItemSkeleton } from '@/components/Feed/FeedItemSkeleton'; + +export default function FundLoading() { + return ( +
+
+
+
+
+ {Array.from({ length: 3 }, (_, i) => ( + + ))} +
+
+ ); +} diff --git a/app/fund/page.tsx b/app/(home)/fund/page.tsx similarity index 66% rename from app/fund/page.tsx rename to app/(home)/fund/page.tsx index a5892037b..ef2eacae6 100644 --- a/app/fund/page.tsx +++ b/app/(home)/fund/page.tsx @@ -1,44 +1,16 @@ -import { Suspense } from 'react'; import { Metadata } from 'next'; import { buildOpenGraphMetadata } from '@/lib/metadata'; -import { PageLayout } from '@/app/layouts/PageLayout'; -import { FundingSidebarServer } from '@/components/Funding/FundingSidebarServer'; -import { ActivitySidebarSkeleton } from '@/components/Funding/ActivitySidebarSkeleton'; -import { HeroHeader } from '@/components/ui/HeroHeader'; -import { FundGrantsPageContent } from './FundGrantsPageContent'; -import { MarketplaceCards } from '@/components/Funding/MarketplaceCards'; -import { FundingHeroPanel } from '@/components/Funding/FundingHeroPanel'; -import { OpenFundingOpportunityCTA } from './OpenFundingOpportunityCTA'; +import { FundGrantsPageContent } from '@/app/fund/FundGrantsPageContent'; export const metadata: Metadata = buildOpenGraphMetadata({ - title: 'Funding Opportunities', + title: 'Fund', description: 'Apply for funding opportunities via proposals.', url: '/fund', }); -export default async function FundPage() { +export default function FundPage() { return ( - - Apply for funding opportunities via proposals. -

- } - cta={} />} - alignTop - > - - - } - rightSidebar={ - }> - - - } - > + <>

ResearchHub provides direct funding pathways for scientific research. Browse open funding @@ -75,6 +47,6 @@ export default async function FundPage() {

-
+ ); } diff --git a/app/(home)/fund/proposals/loading.tsx b/app/(home)/fund/proposals/loading.tsx new file mode 100644 index 000000000..8c6fe7880 --- /dev/null +++ b/app/(home)/fund/proposals/loading.tsx @@ -0,0 +1,16 @@ +import { FeedItemSkeleton } from '@/components/Feed/FeedItemSkeleton'; + +export default function FundProposalsLoading() { + return ( +
+
+
+
+
+ {Array.from({ length: 3 }, (_, i) => ( + + ))} +
+
+ ); +} diff --git a/app/(home)/fund/proposals/page.tsx b/app/(home)/fund/proposals/page.tsx new file mode 100644 index 000000000..45dc8e745 --- /dev/null +++ b/app/(home)/fund/proposals/page.tsx @@ -0,0 +1,19 @@ +import { Metadata } from 'next'; +import { buildOpenGraphMetadata } from '@/lib/metadata'; +import { ProposalFeed } from '@/components/Funding/ProposalFeed'; +import { ProposalSortAndFilters } from '@/components/Funding/ProposalSortAndFilters'; + +export const metadata: Metadata = buildOpenGraphMetadata({ + title: 'Proposals', + description: 'Propose research, get reviewed, receive funding.', + url: '/fund/proposals', +}); + +export default function FundProposalsPage() { + return ( +
+ + +
+ ); +} diff --git a/app/(home)/layout.tsx b/app/(home)/layout.tsx new file mode 100644 index 000000000..7c9573e32 --- /dev/null +++ b/app/(home)/layout.tsx @@ -0,0 +1,19 @@ +import { ReactNode } from 'react'; +import { PageLayout } from '@/app/layouts/PageLayout'; +import { FundSidebar } from '@/components/Funding/FundSidebar'; +import { HomeTabs } from '@/components/Funding/HomeTabs'; + +/** + * Shared shell for homepage hub tabs (Activity / Fund / Proposals). + * Keeps PageLayout, HomeTabs, 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} + + ); +} diff --git a/app/(home)/loading.tsx b/app/(home)/loading.tsx new file mode 100644 index 000000000..e8b789e45 --- /dev/null +++ b/app/(home)/loading.tsx @@ -0,0 +1,11 @@ +import { ActivityCardSkeleton } from '@/components/Activity/ActivityCardSkeleton'; + +export default function HomeLoading() { + return ( +
+ {[...Array(6)].map((_, i) => ( + + ))} +
+ ); +} diff --git a/app/(home)/page.tsx b/app/(home)/page.tsx new file mode 100644 index 000000000..15e121f31 --- /dev/null +++ b/app/(home)/page.tsx @@ -0,0 +1,13 @@ +import { Metadata } from 'next'; +import { buildOpenGraphMetadata } from '@/lib/metadata'; +import { FundActivityPageContent } from '@/components/Funding/FundActivityPageContent'; + +export const metadata: Metadata = buildOpenGraphMetadata({ + title: 'Activity', + description: 'Recent activity across funding opportunities and proposals.', + url: '/', +}); + +export default function HomeActivityPage() { + return ; +} diff --git a/app/about/components/CTASection.tsx b/app/about/components/CTASection.tsx index be22ae7fe..59448b13d 100644 --- a/app/about/components/CTASection.tsx +++ b/app/about/components/CTASection.tsx @@ -45,7 +45,7 @@ export const CTASection = () => { { kind: 'action', label: 'Join ResearchHub', - onAction: () => showAuthModal(() => router.push('/popular')), + onAction: () => showAuthModal(() => router.push('/')), }, { kind: 'link', href: '/fund', label: 'Fund science', external: true }, ]; diff --git a/app/activity/page.tsx b/app/activity/page.tsx index 4cb34ccd5..00bc7dfae 100644 --- a/app/activity/page.tsx +++ b/app/activity/page.tsx @@ -8,7 +8,7 @@ import { PageLayout } from '@/app/layouts/PageLayout'; import { HeroHeader } from '@/components/ui/HeroHeader'; import { PillTabs } from '@/components/ui/PillTabs'; import { ActivityCardFull } from '@/components/Activity/ActivityCardFull'; -import { ActivityCardSkeletonList } from '@/components/Activity/ActivityCardSkeleton'; +import { ActivityCardSkeleton } from '@/components/Activity/ActivityCardSkeleton'; import { useActivityFeed, ActivityTab } from '@/hooks/useActivityFeed'; import { ActivityScope } from '@/services/activity.service'; import { GrantService } from '@/services/grant.service'; @@ -106,7 +106,8 @@ export default function ActivityPage() { ))} - {(isLoading || isLoadingMore) && } + {(isLoading || isLoadingMore) && + [...Array(6)].map((_, i) => )} {!isLoading && !isLoadingMore && entries.length === 0 && (
diff --git a/app/expert-finder/ExpertFinderClientLayout.tsx b/app/expert-finder/ExpertFinderClientLayout.tsx index 2d19c9a61..0b960b393 100644 --- a/app/expert-finder/ExpertFinderClientLayout.tsx +++ b/app/expert-finder/ExpertFinderClientLayout.tsx @@ -29,7 +29,7 @@ export default function ExpertFinderClientLayout({ children }: ExpertFinderClien useEffect(() => { if (!isLoading && !canAccessExpertFinder) { - router.push('/popular'); + router.push('/'); } }, [isLoading, canAccessExpertFinder, router]); diff --git a/app/fund/loading.tsx b/app/fund/loading.tsx deleted file mode 100644 index 14d6d1fb6..000000000 --- a/app/fund/loading.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { PageLayout } from '@/app/layouts/PageLayout'; -import { ActivitySidebarSkeleton } from '@/components/Funding/ActivitySidebarSkeleton'; -import { FundingBannerSkeleton } from '@/components/Funding/FundingBannerSkeleton'; -import { FeedItemSkeleton } from '@/components/Feed/FeedItemSkeleton'; - -export default function FundLoading() { - return ( - } - rightSidebar={} - > -
-
-
-
- - {/* Feed skeletons */} -
- {Array.from({ length: 3 }, (_, i) => ( - - ))} -
-
- - ); -} diff --git a/app/fund/proposals/loading.tsx b/app/fund/proposals/loading.tsx deleted file mode 100644 index aa48bffd6..000000000 --- a/app/fund/proposals/loading.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { PageLayout } from '@/app/layouts/PageLayout'; -import { ActivitySidebarSkeleton } from '@/components/Funding/ActivitySidebarSkeleton'; -import { FundingBannerSkeleton } from '@/components/Funding/FundingBannerSkeleton'; -import { FeedItemSkeleton } from '@/components/Feed/FeedItemSkeleton'; - -export default function FundProposalsLoading() { - return ( - } - rightSidebar={} - > -
-
-
-
- -
- {Array.from({ length: 3 }, (_, i) => ( - - ))} -
-
- - ); -} diff --git a/app/fund/proposals/page.tsx b/app/fund/proposals/page.tsx deleted file mode 100644 index 7629cee78..000000000 --- a/app/fund/proposals/page.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { Suspense } from 'react'; -import { Metadata } from 'next'; -import Link from 'next/link'; -import { ArrowUpFromLine } from 'lucide-react'; -import { buildOpenGraphMetadata } from '@/lib/metadata'; -import { PageLayout } from '@/app/layouts/PageLayout'; -import { ProposalFeed } from '@/components/Funding/ProposalFeed'; -import { ProposalSortAndFilters } from '@/components/Funding/ProposalSortAndFilters'; -import { FundingSidebarServer } from '@/components/Funding/FundingSidebarServer'; -import { ActivitySidebarSkeleton } from '@/components/Funding/ActivitySidebarSkeleton'; -import { HeroHeader } from '@/components/ui/HeroHeader'; -import { Button } from '@/components/ui/Button'; -import { SubmitProposalTooltip } from '@/components/tooltips/SubmitProposalTooltip'; -import { MarketplaceCards } from '@/components/Funding/MarketplaceCards'; -import { FundingHeroPanel } from '@/components/Funding/FundingHeroPanel'; - -export const metadata: Metadata = buildOpenGraphMetadata({ - title: 'Proposals', - description: 'Propose research, get reviewed, receive funding.', - url: '/fund/proposals', -}); - -function SubmitProposalCTA() { - return ( - - - - - - ); -} - -export default async function FundProposalsPage() { - return ( - - Propose research, get reviewed, receive funding. -

- } - cta={} />} - alignTop - > - - - } - rightSidebar={ - }> - - - } - > -
- - -
-
- ); -} diff --git a/app/grant/[id]/[slug]/page.tsx b/app/grant/[id]/[slug]/page.tsx index f8150136e..a6e010542 100644 --- a/app/grant/[id]/[slug]/page.tsx +++ b/app/grant/[id]/[slug]/page.tsx @@ -41,12 +41,7 @@ export default async function GrantSlugPage({ params }: Props) { const grantId = grant?.id ?? undefined; return ( - + {grant?.description && } diff --git a/app/layouts/MobileBottomNav.tsx b/app/layouts/MobileBottomNav.tsx index 31650c990..d292fe7b5 100644 --- a/app/layouts/MobileBottomNav.tsx +++ b/app/layouts/MobileBottomNav.tsx @@ -13,7 +13,7 @@ import { faBars, } from '@fortawesome/pro-light-svg-icons'; import { faXTwitter, faDiscord, faGithub, faLinkedin } from '@fortawesome/free-brands-svg-icons'; -import { Sprout } from 'lucide-react'; +import { Sprout, Star } from 'lucide-react'; import { ChangelogLink } from '@/components/changelog/ChangelogLink'; import { Icon } from '@/components/ui/icons'; import { IconName } from '@/components/ui/icons/Icon'; @@ -22,7 +22,7 @@ import { SwipeableDrawer } from '@/components/ui/SwipeableDrawer'; import { useAuthenticatedAction } from '@/contexts/AuthModalContext'; import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; import { useScrollContainer } from '@/contexts/ScrollContainerContext'; -import { useUser } from '@/contexts/UserContext'; +import { isHomeTabPath } from '@/hooks/useFundTabs'; interface NavItem { label: string; @@ -43,11 +43,12 @@ const moreNavItems: NavItem[] = [ // Check if a path is active const isPathActive = (path: string, currentPath: string): boolean => { - if (path === '/for-you' || path === '/popular') { - return ['/popular', '/for-you', '/latest', '/following', '/'].includes(currentPath); + // Activity, Fund and Proposals are tabs of the same homepage hub. + if (path === '/') { + return isHomeTabPath(currentPath); } - if (path === '/fund') { - return currentPath.startsWith('/fund'); + if (path === '/fund/dashboard') { + return currentPath === '/fund/dashboard' || currentPath.startsWith('/fund/dashboard/'); } if (path === '/notebook') { return currentPath.startsWith('/notebook'); @@ -73,15 +74,11 @@ export const MobileBottomNav: React.FC = () => { const { executeAuthenticatedAction } = useAuthenticatedAction(); const { showUSD, toggleCurrency } = useCurrencyPreference(); const scrollContainerRef = useScrollContainer(); - const { user } = useUser(); - - // Home href depends on auth state: logged in -> /for-you, logged out -> /popular - const homeHref = user ? '/for-you' : '/popular'; const mainNavItems: NavItem[] = [ - { label: 'Home', href: homeHref, iconKey: 'home', isDynamicHome: true }, - { label: 'Earn', href: '/earn', iconKey: 'earn' }, - { label: 'Fund', href: '/fund', iconKey: 'fund' }, + { label: 'Home', href: '/', iconKey: 'home', isDynamicHome: true }, + { label: 'Peer Review', href: '/earn', iconKey: 'peer-review' }, + { label: 'Your Funding', href: '/fund/dashboard', iconKey: 'fund' }, { label: 'Wallet', href: '/researchcoin', iconKey: 'wallet' }, { label: 'More', isMore: true, iconKey: 'more' }, ]; @@ -142,6 +139,15 @@ export const MobileBottomNav: React.FC = () => { color={iconColor} /> ); + case 'peer-review': + return ( + + ); case 'earn': return ( = ({ }) => { const { executeAuthenticatedAction } = useAuthenticatedAction(); const router = useRouter(); - const { user } = useUser(); // Dismissable "New" badge for the Endowment nav item. Lifted to the parent // so the click handler in NavLink can call dismissFeature() without each @@ -99,27 +99,24 @@ export const Navigation: React.FC = ({ [router] ); - // Home href depends on auth state: logged in -> /for-you, logged out -> /popular - const homeHref = user ? '/for-you' : '/popular'; - const navigationItems: NavigationItem[] = [ { label: 'Home', - href: homeHref, + href: '/', iconKey: 'home', isFontAwesome: true, description: 'Navigate to the home page', }, { - label: 'Fund', - href: '/fund', + label: 'Your Funding', + href: '/fund/dashboard', iconKey: 'fund', - description: 'Browse grants and fundraising opportunities', + description: 'Track the impact of the research you fund', }, { - label: 'Earn', + label: 'Peer Review', href: '/earn', - iconKey: 'earn', + isLucideStar: true, description: 'Earn RSC for completing peer reviews', }, { @@ -152,13 +149,13 @@ export const Navigation: React.FC = ({ }; const isPathActive = (path: string) => { - // Special case for home page - if (path === '/for-you' || path === '/popular') { - return ['/popular', '/for-you', '/latest', '/following'].includes(currentPath); + // Activity, Fund and Proposals are tabs of the same homepage hub. + if (path === '/') { + return isHomeTabPath(currentPath); } - if (path === '/fund') { - return currentPath.startsWith('/fund'); + if (path === '/fund/dashboard') { + return currentPath === '/fund/dashboard' || currentPath.startsWith('/fund/dashboard/'); } if (path === '/earn') { @@ -243,6 +240,13 @@ export const Navigation: React.FC = ({ /> ) : item.isLucideSprout ? ( + ) : item.isLucideStar ? ( + ) : item.iconKey ? ( ) : ( diff --git a/app/layouts/PublishMenu.tsx b/app/layouts/PublishMenu.tsx index a9358ee0d..624679491 100644 --- a/app/layouts/PublishMenu.tsx +++ b/app/layouts/PublishMenu.tsx @@ -6,8 +6,6 @@ import { BaseMenu, BaseMenuItem } from '@/components/ui/form/BaseMenu'; import { FundingIcon } from '@/components/ui/icons/FundingIcon'; import { useAuthenticatedAction } from '@/contexts/AuthModalContext'; import Icon from '@/components/ui/icons/Icon'; -import { useUser } from '@/contexts/UserContext'; -import { navigateToAuthorProfile } from '@/utils/navigation'; import { SwipeableDrawer } from '@/components/ui/SwipeableDrawer'; import { OpenFundingOpportunityModal, @@ -27,15 +25,14 @@ interface PublishMenuProps { const PUBLISH_MENU_SECTIONS = [ { - title: 'Publish on ResearchHub', + title: 'Post on ResearchHub', items: [ { id: 'give-funding', title: 'Funding Opportunity', description: 'Fund specific research you care about', icon: , - action: 'function', - handler: 'handleOpenGrant', + handler: 'handleOpenGrant' as const, requiresAuth: true, }, { @@ -43,17 +40,7 @@ const PUBLISH_MENU_SECTIONS = [ title: 'Proposal', description: 'Raise money for your research', icon: , - action: 'function', - handler: 'handleFundResearch', - requiresAuth: true, - }, - { - id: 'submit-paper', - title: 'Preprint', - description: 'Publish your research as a preprint', - icon: , - action: 'navigate', - path: '/notebook?newResearch=true', + handler: 'handleFundResearch' as const, requiresAuth: true, }, ], @@ -86,7 +73,6 @@ const MenuItemContent: React.FC = ({ icon, title, descript export const PublishMenu: React.FC = ({ children, forceMinimize = false }) => { const router = useRouter(); const { executeAuthenticatedAction } = useAuthenticatedAction(); - const { user } = useUser(); const { smAndDown } = useScreenSize(); const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false); const [isFundingOpportunityModalOpen, setIsFundingOpportunityModalOpen] = useState(false); @@ -110,28 +96,18 @@ export const PublishMenu: React.FC = ({ children, forceMinimiz router.push(`/notebook?newGrant=true&grantSource=${method}`); }; - const handleViewProfile = () => { - navigateToAuthorProfile(user?.id, false); - }; - const handleMenuItemClick = (item: (typeof PUBLISH_MENU_SECTIONS)[number]['items'][number]) => { if (item.requiresAuth) { executeAuthenticatedAction(() => { - if (item.action === 'navigate') { - router.push(item.path); - } else if (item.action === 'function') { - switch (item.handler) { - case 'handleFundResearch': - handleFundResearch(); - break; - case 'handleOpenGrant': - handleOpenGrant(); - break; - } + switch (item.handler) { + case 'handleFundResearch': + handleFundResearch(); + break; + case 'handleOpenGrant': + handleOpenGrant(); + break; } }); - } else if (item.action === 'navigate') { - router.push(item.path); } // Close mobile drawer after action @@ -154,7 +130,7 @@ export const PublishMenu: React.FC = ({ children, forceMinimiz }} > - Publish + Post ); @@ -248,7 +224,7 @@ export const PublishMenu: React.FC = ({ children, forceMinimiz }} role="button" tabIndex={0} - aria-label="Open publish menu" + aria-label="Open post menu" > {standardTrigger} {compactTrigger} diff --git a/app/layouts/components/RightSidebarContainer.tsx b/app/layouts/components/RightSidebarContainer.tsx index 79a67d315..481f8af49 100644 --- a/app/layouts/components/RightSidebarContainer.tsx +++ b/app/layouts/components/RightSidebarContainer.tsx @@ -11,7 +11,7 @@ import { RightSidebar } from '../RightSidebar'; function getSidebarInstanceKey(pathname: string, rightSidebar: boolean | ReactNode): string { if (typeof rightSidebar !== 'boolean') { - return `custom:${pathname}`; + return 'custom'; } if (pathname.startsWith('/paper/create')) { diff --git a/app/layouts/topbar/pageRoutes.tsx b/app/layouts/topbar/pageRoutes.tsx index 961c236ac..6579fac18 100644 --- a/app/layouts/topbar/pageRoutes.tsx +++ b/app/layouts/topbar/pageRoutes.tsx @@ -12,6 +12,7 @@ import { Icon } from '@/components/ui/icons'; import { getTopicEmoji } from '@/components/Topic/TopicEmojis'; import { toTitleCase } from '@/utils/stringUtils'; import { getSourceLogo, getPreprintDisplayName } from '@/utils/preprintUtil'; +import { HOME_TAB_PATHS, isHomeTabPath } from '@/hooks/useFundTabs'; export interface PageInfo { title: string; @@ -19,15 +20,8 @@ export interface PageInfo { } export const ROOT_NAVIGATION_PATHS = new Set([ - '/', - '/following', - '/latest', - '/popular', - '/for-you', - '/feed', + ...HOME_TAB_PATHS, '/earn', - '/fund', - '/fund/proposals', '/journal', '/notebook', '/browse', @@ -47,7 +41,7 @@ interface RouteRule { const ROUTE_RULES: RouteRule[] = [ { - match: (p) => ['/', '/following', '/latest', '/popular', '/for-you', '/feed'].includes(p), + match: (p) => isHomeTabPath(p), getInfo: () => ({ title: 'Home', icon: , @@ -152,7 +146,7 @@ const ROUTE_RULES: RouteRule[] = [ }), }, { - match: (p) => p === '/fund' || p.startsWith('/fund/') || p.startsWith('/grant/'), + match: (p) => p.startsWith('/fund/') || p.startsWith('/grant/'), getInfo: () => ({ title: 'Fund', icon: , diff --git a/app/moderators/ModerationClientLayout.tsx b/app/moderators/ModerationClientLayout.tsx index ae7ea4830..bd58db083 100644 --- a/app/moderators/ModerationClientLayout.tsx +++ b/app/moderators/ModerationClientLayout.tsx @@ -26,7 +26,7 @@ export default function ModerationClientLayout({ children }: ModerationClientLay useEffect(() => { if (!isLoading && !isModerator) { - router.push('/popular'); + router.push('/'); } }, [isLoading, isModerator, router]); diff --git a/app/page.tsx b/app/page.tsx deleted file mode 100644 index 23b44dca0..000000000 --- a/app/page.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Metadata } from 'next'; -import { getServerSession } from 'next-auth/next'; -import { buildOpenGraphMetadata } from '@/lib/metadata'; -import { handleTrendingRedirect } from '@/utils/navigation'; -import { LandingPage } from '@/components/landing/LandingPage'; - -export const metadata: Metadata = buildOpenGraphMetadata({ - title: 'Open Science Community & Research Platform', - description: - 'ResearchHub is a collaborative platform for sharing, reviewing, and funding scientific research. Join the open science community.', - url: '/', -}); - -export default async function Home({ - searchParams, -}: { - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; -}) { - const session = await getServerSession(); - - const resolvedSearchParams = await searchParams; - - const urlSearchParams = new URLSearchParams(); - Object.entries(resolvedSearchParams).forEach(([key, value]) => { - if (typeof value === 'string') { - urlSearchParams.set(key, value); - } else if (Array.isArray(value)) { - if (value.length > 0) { - urlSearchParams.set(key, value[0]); - } - } - }); - - handleTrendingRedirect(!!session?.user, urlSearchParams); - - return ; -} diff --git a/app/robots.ts b/app/robots.ts index e824a1714..f6c1ff37f 100644 --- a/app/robots.ts +++ b/app/robots.ts @@ -15,8 +15,6 @@ export default function robots(): MetadataRoute.Robots { '/dashboard/', '/notifications/', '/activity/', - '/for-you/', - '/following/', '/notebook/', '/moderators/', '/expert-finder/', diff --git a/app/sitemap.ts b/app/sitemap.ts index 3d6ef82c3..e6c74d4f6 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -66,9 +66,7 @@ async function buildSlugSitemap( } const STATIC_ROUTES: MetadataRoute.Sitemap = [ - { url: SITE_URL, changeFrequency: 'daily', priority: 1 }, - { url: `${SITE_URL}/popular`, changeFrequency: 'hourly', priority: 0.9 }, - { url: `${SITE_URL}/latest`, changeFrequency: 'hourly', priority: 0.8 }, + { url: SITE_URL, changeFrequency: 'hourly', priority: 1 }, { url: `${SITE_URL}/fund`, changeFrequency: 'daily', priority: 0.8 }, { url: `${SITE_URL}/fund/proposals`, changeFrequency: 'daily', priority: 0.7 }, { url: `${SITE_URL}/earn`, changeFrequency: 'daily', priority: 0.8 }, diff --git a/components/Activity/ActivityCardFull.tsx b/components/Activity/ActivityCardFull.tsx index 48a0d1da8..3583df8a9 100644 --- a/components/Activity/ActivityCardFull.tsx +++ b/components/Activity/ActivityCardFull.tsx @@ -1,24 +1,21 @@ 'use client'; import { FC, useState } from 'react'; -import Link from 'next/link'; -import { Star, ChevronDown, ChevronUp } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { ArrowRight } from 'lucide-react'; import { Avatar } from '@/components/ui/Avatar'; import { AuthorTooltip } from '@/components/ui/AuthorTooltip'; +import { Button } from '@/components/ui/Button'; import { CommentReadOnly } from '@/components/Comment/CommentReadOnly'; -import { ContributionAmount } from './ContributionAmount'; -import { FeedEntryIcon } from './FeedEntryIcon'; -import { GrantFundingAmount } from './GrantFundingAmount'; -import { - getActionIcon, - getActionLabel, - getCommentPreview, - getContribution, - getEntryMeta, - getGrantAmount, - getReviewScore, -} from './lib/feedEntryAdapters'; -import { formatTimeAgo } from '@/utils/date'; +import { ContributeToFundraiseModal } from '@/components/modals/ContributeToFundraiseModal'; +import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; +import { useExchangeRate } from '@/contexts/ExchangeRateContext'; +import { useShareModalContext } from '@/contexts/ShareContext'; +import { ActivityCardHeader } from './ActivityCardHeader'; +import { WorkCardActions } from './WorkCardActions'; +import { WorkPreviewCard } from './WorkPreviewCard'; +import { getActivityHeaderMessage, getCommentPreview } from './lib/feedEntryAdapters'; +import { getActivityWorkContext, getWorkCardPresentation } from './lib/activityWorkContext'; import type { FeedEntry } from '@/types/feed'; interface ActivityCardFullProps { @@ -26,96 +23,125 @@ interface ActivityCardFullProps { } export const ActivityCardFull: FC = ({ entry }) => { - const { title, author, href } = getEntryMeta(entry); - const [reviewExpanded, setReviewExpanded] = useState(false); + const work = getActivityWorkContext(entry); + const router = useRouter(); + const { showUSD } = useCurrencyPreference(); + const { exchangeRate } = useExchangeRate(); + const { showShareModal } = useShareModalContext(); + const [isContributeModalOpen, setIsContributeModalOpen] = useState(false); - if (!title) return null; + if (!work) return null; - const actionLabel = getActionLabel(entry); - const actionIcon = getActionIcon(entry); - const reviewScore = getReviewScore(entry); - const grantAmount = getGrantAmount(entry); - const contribution = getContribution(entry); + const message = getActivityHeaderMessage(entry); const commentPreview = getCommentPreview(entry); + const presentation = getWorkCardPresentation(entry, work, { + showUSD, + exchangeRate, + isReview: commentPreview?.isReview, + }); - const titleEl = href ? ( - - {title} - - ) : ( - {title} - ); + const showComment = presentation.showComment && !!commentPreview; + const voteCount = entry.metrics?.adjustedScore ?? entry.metrics?.votes ?? 0; + const isReviewOfProposal = !!commentPreview?.isReview && work.documentType === 'preregistration'; + + const handleFundClick = () => { + setIsContributeModalOpen(true); + }; + + const handleContributeSuccess = () => { + setIsContributeModalOpen(false); + showShareModal({ + url: window.location.href, + docTitle: work.title, + action: 'USER_FUNDED_PROPOSAL', + }); + router.refresh(); + }; + + const action = (() => { + const cta = presentation.cta; + if (!cta) return undefined; + + return ( + + ); + })(); return ( -
-
-
- - - -
-
- {author?.fullName || 'Unknown'} - {actionLabel} - - {reviewScore != null && ( - - - {reviewScore.toFixed(1)} - - )} - {grantAmount && } - {contribution && ( - - )} +
+
+
+
+ + + +
- {titleEl} -
- {commentPreview && !commentPreview.isReview && ( -
- -
- )} +
+ - {commentPreview && commentPreview.isReview && ( -
- - {reviewExpanded && ( + {showComment && commentPreview && (
)} + +
+ + } + /> +
- )} +
- - {formatTimeAgo(entry.timestamp)} - -
+ {work.fundraise && ( + setIsContributeModalOpen(false)} + onContributeSuccess={handleContributeSuccess} + fundraise={work.fundraise} + proposalTitle={work.title} + /> + )} + ); }; diff --git a/components/Activity/ActivityCardHeader.tsx b/components/Activity/ActivityCardHeader.tsx new file mode 100644 index 000000000..98bc2b8e3 --- /dev/null +++ b/components/Activity/ActivityCardHeader.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { FC } from 'react'; +import { ActivityHeaderActionText } from './ActivityHeaderActionText'; +import { BountyAmount } from './BountyAmount'; +import { ContributionAmount } from './ContributionAmount'; +import { FeedEntryIcon } from './FeedEntryIcon'; +import { GrantFundingAmount } from './GrantFundingAmount'; +import { ReviewScoreStars } from './ReviewScoreStars'; +import { + getActionIcon, + getActivityHeaderMessage, + getContribution, + getGrantAmount, + getReviewEarning, + getReviewScore, +} from './lib/feedEntryAdapters'; +import { getActivityBounty } from './lib/activityWorkContext'; +import { formatTimeAgo } from '@/utils/date'; +import { Tooltip } from '@/components/ui/Tooltip'; +import type { FeedEntry } from '@/types/feed'; + +interface ActivityCardHeaderProps { + entry: FeedEntry; +} + +export const ActivityCardHeader: FC = ({ entry }) => { + const message = getActivityHeaderMessage(entry); + const actionIcon = getActionIcon(entry); + const reviewScore = getReviewScore(entry); + const reviewEarning = getReviewEarning(entry); + const grantAmount = getGrantAmount(entry); + const contribution = getContribution(entry); + const bounty = entry.activityContext === 'bounty_opened' ? getActivityBounty(entry) : undefined; + + const hasAmount = Boolean( + grantAmount || contribution || reviewEarning || bounty || reviewScore != null + ); + + return ( +
+
+ + {grantAmount && ( + <> + {' '} + + + )} + {contribution && ( + <> + {' '} + + + )} + {reviewEarning && ( + <> + {' '} + + + )} + {bounty && ( + <> + {' '} + + + )} + {reviewScore != null && reviewScore > 0 && ( + <> + {' '} + + + )} + +
+ + + + {formatTimeAgo(entry.timestamp)} + + +
+ ); +}; diff --git a/components/Activity/ActivityCardSkeleton.tsx b/components/Activity/ActivityCardSkeleton.tsx index ff83e5d26..5f02667f7 100644 --- a/components/Activity/ActivityCardSkeleton.tsx +++ b/components/Activity/ActivityCardSkeleton.tsx @@ -1,41 +1,31 @@ 'use client'; import { FC } from 'react'; -import { cn } from '@/utils/styles'; -const TITLE_WIDTHS = ['w-3/4', 'w-2/3', 'w-5/6'] as const; +export const ActivityCardSkeleton: FC = () => ( +
+
+
-interface ActivityCardSkeletonProps { - titleWidth?: (typeof TITLE_WIDTHS)[number]; -} +
+
+
+
+
+
-export const ActivityCardSkeleton: FC = ({ titleWidth = 'w-2/3' }) => ( -
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-); - -interface ActivityCardSkeletonListProps { - count?: number; - className?: string; -} - -export const ActivityCardSkeletonList: FC = ({ - count = 15, - className, -}) => ( -
- {Array.from({ length: count }, (_, i) => ( - - ))}
); diff --git a/components/Activity/ActivityHeaderActionText.tsx b/components/Activity/ActivityHeaderActionText.tsx new file mode 100644 index 000000000..58d0bd51f --- /dev/null +++ b/components/Activity/ActivityHeaderActionText.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { FC } from 'react'; +import Link from 'next/link'; +import { AuthorTooltip } from '@/components/ui/AuthorTooltip'; +import type { ActivityHeaderMessage } from './lib/feedEntryAdapters'; + +interface ActivityHeaderActionTextProps { + message: ActivityHeaderMessage; + className?: string; +} + +export const ActivityHeaderActionText: FC = ({ + message, + className, +}) => { + const { actor, verb, target } = message; + + return ( + + + + {actor.fullName || 'Unknown'} + + + {verb} + {target && ( + <> + {' '} + + + {target.author.fullName || 'Unknown'} + + + {target.suffix && {target.suffix}} + + )} + + ); +}; diff --git a/components/Activity/AmountBadge.tsx b/components/Activity/AmountBadge.tsx new file mode 100644 index 000000000..b9297fb49 --- /dev/null +++ b/components/Activity/AmountBadge.tsx @@ -0,0 +1,27 @@ +'use client'; + +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 }) => ( + + {children} + +); diff --git a/components/Activity/BountyAmount.tsx b/components/Activity/BountyAmount.tsx new file mode 100644 index 000000000..0945aab73 --- /dev/null +++ b/components/Activity/BountyAmount.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { FC } from 'react'; +import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; +import { useExchangeRate } from '@/contexts/ExchangeRateContext'; +import { getBountyDisplayAmount } from '@/components/Bounty/lib/bountyUtil'; +import { formatCurrency } from '@/utils/currency'; +import { AmountBadge } from './AmountBadge'; +import type { Bounty } from '@/types/bounty'; + +interface BountyAmountProps { + bounty: Bounty; + className?: string; +} + +export const BountyAmount: FC = ({ bounty, className }) => { + const { showUSD } = useCurrencyPreference(); + const { exchangeRate } = useExchangeRate(); + const { amount } = getBountyDisplayAmount(bounty, exchangeRate, showUSD); + + return ( + + {formatCurrency({ + amount: Math.round(amount), + showUSD, + exchangeRate, + skipConversion: true, + shorten: true, + })} + + ); +}; diff --git a/components/Activity/ContributionAmount.tsx b/components/Activity/ContributionAmount.tsx index 9eb3493fe..1ea82a025 100644 --- a/components/Activity/ContributionAmount.tsx +++ b/components/Activity/ContributionAmount.tsx @@ -4,15 +4,21 @@ import { FC } from 'react'; import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; import { useExchangeRate } from '@/contexts/ExchangeRateContext'; import { formatCurrency } from '@/utils/currency'; -import { cn } from '@/utils/styles'; +import { AmountBadge } from './AmountBadge'; import { resolveDisplayedContribution, type FeedContribution } from './lib/feedEntryAdapters'; interface ContributionAmountProps { contribution: FeedContribution; className?: string; + /** Prefix with "+" (default). Set false for earnings like "earned $150". */ + showSign?: boolean; } -export const ContributionAmount: FC = ({ contribution, className }) => { +export const ContributionAmount: FC = ({ + contribution, + className, + showSign = true, +}) => { const { showUSD } = useCurrencyPreference(); const { exchangeRate } = useExchangeRate(); const { amount, inUSD } = resolveDisplayedContribution(contribution, showUSD, exchangeRate); @@ -25,5 +31,5 @@ export const ContributionAmount: FC = ({ contribution, shorten: true, }); - return +{formatted}; + return {showSign ? `+${formatted}` : formatted}; }; diff --git a/components/Activity/FeedEntryIcon.tsx b/components/Activity/FeedEntryIcon.tsx index 29fdb1e6d..b984e4493 100644 --- a/components/Activity/FeedEntryIcon.tsx +++ b/components/Activity/FeedEntryIcon.tsx @@ -1,9 +1,16 @@ +'use client'; + import { FC } from 'react'; -import { Bell, Coins, MessageCircle, type LucideIcon } from 'lucide-react'; +import { Bell, MessageCircle, type LucideIcon } from 'lucide-react'; +import Icon from '@/components/ui/icons/Icon'; +import { ResearchCoinIcon, RSC_COLORS } from '@/components/ui/icons/ResearchCoinIcon'; +import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; import type { FeedEntryIconName } from './lib/feedEntryAdapters'; -const ICONS: Record, LucideIcon> = { - coins: Coins, +const ICONS: Record< + Exclude, + LucideIcon +> = { bell: Bell, message: MessageCircle, }; @@ -13,7 +20,33 @@ interface FeedEntryIconProps { } export const FeedEntryIcon: FC = ({ name }) => { + const { showUSD } = useCurrencyPreference(); + if (!name) return null; - const Icon = ICONS[name]; - return ; + if (name === 'coins') { + if (showUSD) return null; + return ; + } + if (name === 'fund') { + return ( + + ); + } + if (name === 'earn') { + return ( + + ); + } + if (name === 'proposal') { + return ( + + ); + } + const IconComponent = ICONS[name]; + return ; }; diff --git a/components/Activity/GrantFundingAmount.tsx b/components/Activity/GrantFundingAmount.tsx index 5ae5b4a5e..c1067a5be 100644 --- a/components/Activity/GrantFundingAmount.tsx +++ b/components/Activity/GrantFundingAmount.tsx @@ -3,7 +3,7 @@ import { FC } from 'react'; import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; import { formatCurrency } from '@/utils/currency'; -import { cn } from '@/utils/styles'; +import { AmountBadge } from './AmountBadge'; import type { FeedGrantAmount } from './lib/feedEntryAdapters'; interface GrantFundingAmountProps { @@ -21,5 +21,5 @@ export const GrantFundingAmount: FC = ({ amount, classN shorten: true, }); - return {formatted}; + return {formatted}; }; diff --git a/components/Activity/ReviewScoreStars.tsx b/components/Activity/ReviewScoreStars.tsx new file mode 100644 index 000000000..d77d38c0d --- /dev/null +++ b/components/Activity/ReviewScoreStars.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { FC } from 'react'; +import { Star } from 'lucide-react'; +import { cn } from '@/utils/styles'; + +interface ReviewScoreStarsProps { + score: number; + size?: 'sm' | 'md'; + className?: string; +} + +const SIZES = { + sm: 13, + md: 14, +} as const; + +/** Five-star score display; filled up to the rounded score. */ +export const ReviewScoreStars: FC = ({ score, size = 'sm', className }) => { + const rounded = Math.round(score); + + return ( + + {[1, 2, 3, 4, 5].map((i) => ( + + ))} + + ); +}; diff --git a/components/Activity/WorkCardActions.tsx b/components/Activity/WorkCardActions.tsx new file mode 100644 index 000000000..e26767465 --- /dev/null +++ b/components/Activity/WorkCardActions.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { FC, ReactNode } from 'react'; +import { FeedItemActions } from '@/components/Feed/FeedItemActions'; +import type { UserVoteType } from '@/types/reaction'; +import type { FeedContentType } from '@/types/feed'; +import type { ActivityWorkContext } from './lib/activityWorkContext'; + +interface WorkCardActionsProps { + work: ActivityWorkContext; + voteCount: number; + userVote?: UserVoteType; + cta?: ReactNode; +} + +function toFeedContentType(documentType: ActivityWorkContext['documentType']): FeedContentType { + return documentType === 'paper' ? 'PAPER' : 'POST'; +} + +export const WorkCardActions: FC = ({ work, voteCount, userVote, cta }) => ( + +); diff --git a/components/Activity/WorkPreviewCard.tsx b/components/Activity/WorkPreviewCard.tsx new file mode 100644 index 000000000..9b8587f01 --- /dev/null +++ b/components/Activity/WorkPreviewCard.tsx @@ -0,0 +1,177 @@ +'use client'; + +import { FC, 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'; + +interface WorkPreviewCardProps { + title: string; + href?: string; + imageSrc?: string; + /** 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; + className?: string; +} + +/** + * Full-bleed frosted-image card for activity feed rows. + * Image fills the card; metadata sits in a translucent bar at the bottom. + */ +export const WorkPreviewCard: FC = ({ + title, + href, + imageSrc, + showPlaceholder = true, + authors = [], + organization, + institution, + score, + stats, + progress, + actions, + className, +}) => { + 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 = ( +
+ {imageSrc ? ( + {title} + ) : showPlaceholder ? ( +
+ ) : ( +
+ )} + +
+
+
+
+ {title} +
+ {authorLine && ( +
{authorLine}
+ )} +
+ + {(score != null || stats?.length) && ( +
+ {score != null && ( +
+
+ Rating +
+
+ + {score.toFixed(1)} +
+
+ )} + {stats?.map((s) => ( +
+
+ {s.label} +
+
+ {s.value} +
+
+ ))} +
+ )} +
+ + {progress != null && ( +
+
+
+ )} +
+
+ ); + + return ( +
+ {href ? ( + + {imageBlock} + + ) : ( + imageBlock + )} + + {showFooter && ( +
+
{actions}
+
+ )} +
+ ); +}; diff --git a/components/Activity/lib/activityWorkContext.ts b/components/Activity/lib/activityWorkContext.ts new file mode 100644 index 000000000..3b127e939 --- /dev/null +++ b/components/Activity/lib/activityWorkContext.ts @@ -0,0 +1,287 @@ +import { buildWorkUrl } from '@/utils/url'; +import { isFundraiseActive } from '@/components/Fund/lib/fundraiseUtils'; +import { getBountyDisplayAmount, isOpenBounty } from '@/components/Bounty/lib/bountyUtil'; +import { formatCurrency } from '@/utils/currency'; +import { isDeadlineInFuture } from '@/utils/date'; +import type { ContentType } from '@/types/work'; +import type { + ActivityContext, + FeedBountyContent, + FeedCommentContent, + FeedEntry, + FeedGrantContent, +} from '@/types/feed'; +import type { Bounty } from '@/types/bounty'; +import type { Fundraise } from '@/types/funding'; +import type { AuthorProfile } from '@/types/authorProfile'; +import type { WorkGrantSummary } from '@/types/work'; + +type ActivityBodySlot = 'fundraise' | 'bounty' | 'grant' | 'default'; + +export interface ActivityWorkContext { + id: number; + slug: string; + title: string; + href: string; + imageUrl?: string; + documentType: ContentType; + unifiedDocumentId?: number | null; + fundraise?: Fundraise; + grant?: WorkGrantSummary; + bounty?: Bounty; + authors?: AuthorProfile[]; + tab?: 'reviews' | 'bounties' | 'conversation'; +} + +export interface WorkCardAuthor { + name: string; + verified?: boolean; + authorUrl?: string; +} + +export interface WorkCardStat { + label: string; + value: string; + accent?: boolean; +} + +export type WorkCardCta = + | { kind: 'fund-modal'; label: 'Fund' } + | { kind: 'link'; label: string; href: string }; + +export interface WorkCardPresentation { + authors: WorkCardAuthor[]; + /** Funding organization, shown in place of authors when present. */ + organization?: string | null; + institution?: string | null; + score?: number | null; + stats?: WorkCardStat[]; + progress?: number; + cta?: WorkCardCta; + showComment: boolean; +} + +export function getActivityBounty(entry: FeedEntry): Bounty | undefined { + if (entry.contentType === 'COMMENT') { + return (entry.content as FeedCommentContent).bounties?.[0]; + } + if (entry.contentType === 'BOUNTY') { + return (entry.content as FeedBountyContent).bounty; + } + return undefined; +} + +function resolveTabFromContext(activityContext?: ActivityContext): ActivityWorkContext['tab'] { + switch (activityContext) { + case 'tip_review': + case 'peer_review_published': + return 'reviews'; + case 'bounty_opened': + case 'bounty_contributed': + case 'bounty_payout': + return 'bounties'; + case 'comment_published': + return 'conversation'; + default: + return undefined; + } +} + +function resolveActivityBodySlot( + activityContext?: ActivityContext, + work?: Pick, + options?: { isReview?: boolean } +): ActivityBodySlot { + if (activityContext === 'bounty_opened' || activityContext === 'bounty_contributed') { + return work?.bounty ? 'bounty' : 'default'; + } + if (activityContext === 'grant_opened') { + return work?.grant ? 'grant' : 'default'; + } + if ( + activityContext === 'tip_review' || + activityContext === 'bounty_payout' || + activityContext === 'fundraise_contribution' || + activityContext === 'proposal_submitted' + ) { + return work?.fundraise ? 'fundraise' : 'default'; + } + if (activityContext === 'peer_review_published' || options?.isReview) { + return work?.fundraise ? 'fundraise' : 'default'; + } + if (activityContext === 'comment_published' && work?.fundraise) { + return 'fundraise'; + } + return 'default'; +} + +function toCardAuthors(authors?: AuthorProfile[]): WorkCardAuthor[] { + if (!authors?.length) return []; + return authors + .filter((author) => !!author.fullName?.trim()) + .map((author) => ({ + name: author.fullName, + verified: author.user?.isVerified ?? author.isVerified, + authorUrl: author.id === 0 ? undefined : author.profileUrl, + })); +} + +/** Funding organization, from related work when present and the entry itself otherwise. */ +function resolveOrganization(entry: FeedEntry, work: ActivityWorkContext): string | null { + if (work.grant?.organization) return work.grant.organization; + if (entry.contentType === 'GRANT') { + return (entry.content as FeedGrantContent).grant?.organization || null; + } + return null; +} + +function formatAmount( + amount: number, + showUSD: boolean, + exchangeRate: number, + skipConversion = false +): string { + return formatCurrency({ + amount: Math.round(amount), + showUSD, + exchangeRate, + skipConversion, + shorten: true, + }); +} + +export function getWorkCardPresentation( + entry: FeedEntry, + work: ActivityWorkContext, + options: { showUSD: boolean; exchangeRate: number; isReview?: boolean } +): WorkCardPresentation { + const { showUSD, exchangeRate, isReview } = options; + const slot = resolveActivityBodySlot(entry.activityContext, work, { isReview }); + + // Prefer real document score; omit when absent (no mocks). + const score = + entry.metrics?.reviewScore && entry.metrics.reviewScore > 0 + ? entry.metrics.reviewScore + : work.fundraise?.reviewMetrics?.avg && work.fundraise.reviewMetrics.avg > 0 + ? work.fundraise.reviewMetrics.avg + : null; + + const authors = toCardAuthors(work.authors); + const institution = entry.nonprofit?.name ?? null; + const base: WorkCardPresentation = { + authors, + organization: resolveOrganization(entry, work), + institution, + score, + // Caller ANDs with commentPreview presence; here we only gate by slot. + showComment: slot !== 'bounty' && slot !== 'grant', + }; + + if (slot === 'fundraise' && work.fundraise) { + const fundraise = work.fundraise; + const goalAmount = showUSD ? fundraise.goalAmount.usd : fundraise.goalAmount.rsc; + const goalUsd = fundraise.goalAmount.usd; + const raisedUsd = fundraise.amountRaised.usd; + + return { + ...base, + stats: [ + { + label: 'Raising', + value: formatAmount(goalAmount, showUSD, exchangeRate, true), + accent: true, + }, + ], + progress: goalUsd > 0 ? raisedUsd / goalUsd : undefined, + cta: isFundraiseActive(fundraise) ? { kind: 'fund-modal', label: 'Fund' } : undefined, + }; + } + + if (slot === 'grant' && work.grant) { + const grant = work.grant; + const isActive = + grant.status === 'OPEN' && (grant.endDate ? isDeadlineInFuture(grant.endDate) : true); + const budgetAmount = showUSD ? grant.amount.usd : (grant.amount.rsc ?? 0); + const hasBudget = grant.amount.usd > 0 || (grant.amount.rsc ?? 0) > 0; + const stats: WorkCardStat[] = []; + + if (hasBudget) { + stats.push({ + label: 'Available', + value: formatAmount(budgetAmount, showUSD, exchangeRate, showUSD), + accent: true, + }); + } + stats.push({ + label: 'Proposals', + value: String(grant.numApplicants), + }); + + return { + ...base, + stats: stats.length ? stats : undefined, + cta: isActive ? { kind: 'link', label: 'Apply', href: work.href } : undefined, + }; + } + + if (slot === 'bounty' && work.bounty) { + const bounty = work.bounty; + const { amount } = getBountyDisplayAmount(bounty, exchangeRate, showUSD); + const isReviewBounty = bounty.bountyType === 'REVIEW'; + const href = `${buildWorkUrl({ + id: work.id, + slug: work.slug, + contentType: work.documentType, + tab: 'bounties', + })}?focus=true`; + const active = + bounty.status === 'OPEN' + ? bounty.expirationDate + ? isDeadlineInFuture(bounty.expirationDate) + : true + : bounty.status === 'ASSESSMENT' || isOpenBounty(bounty); + + return { + ...base, + stats: [ + { + label: isReviewBounty ? 'Peer Review' : 'Bounty', + value: formatAmount(amount, showUSD, exchangeRate, true), + accent: true, + }, + ], + cta: active ? { kind: 'link', label: isReviewBounty ? 'Review' : 'Solve', href } : undefined, + }; + } + + return base; +} + +export function getActivityWorkContext(entry: FeedEntry): ActivityWorkContext | null { + const related = entry.relatedWork; + if (!related?.title) return null; + + const tab = resolveTabFromContext(entry.activityContext); + const documentType = related.contentType; + const href = buildWorkUrl({ + id: related.id, + slug: related.slug, + contentType: documentType, + tab, + }); + + return { + id: related.id, + slug: related.slug, + title: related.title, + href, + imageUrl: related.image, + documentType, + unifiedDocumentId: related.unifiedDocumentId, + fundraise: related.fundraise, + grant: related.grantSummary, + bounty: getActivityBounty(entry), + authors: related.authors?.map((authorship) => authorship.authorProfile), + tab, + }; +} diff --git a/components/Activity/lib/deriveActivityContext.ts b/components/Activity/lib/deriveActivityContext.ts new file mode 100644 index 000000000..880fd4052 --- /dev/null +++ b/components/Activity/lib/deriveActivityContext.ts @@ -0,0 +1,40 @@ +import type { ActivityContext, RawApiFeedEntry } from '@/types/feed'; + +const REVIEW_COMMENT_TYPES = new Set(['PEER_REVIEW', 'REVIEW']); + +export function deriveActivityContext(feedEntry: RawApiFeedEntry): ActivityContext | undefined { + const contentType = feedEntry.content_type?.toUpperCase(); + const obj = feedEntry.content_object; + if (!contentType || !obj) return undefined; + + switch (contentType) { + case 'RHCOMMENTMODEL': { + const commentType = obj.comment_type as string | undefined; + if (commentType && REVIEW_COMMENT_TYPES.has(commentType)) { + return 'peer_review_published'; + } + if (Array.isArray(obj.bounties) && obj.bounties.length > 0) { + return 'bounty_opened'; + } + return 'comment_published'; + } + case 'RESEARCHHUBPOST': { + if (obj.type === 'GRANT') return 'grant_opened'; + if (obj.type === 'PREREGISTRATION') return 'proposal_submitted'; + return undefined; + } + case 'PURCHASE': + case 'USDFUNDRAISECONTRIBUTION': + return 'fundraise_contribution'; + case 'FUNDINGACTIVITY': { + const sourceType = obj.source_type as string | undefined; + if (sourceType === 'BOUNTY_PAYOUT') return 'bounty_payout'; + if (sourceType === 'TIP_REVIEW') return 'tip_review'; + return undefined; + } + case 'BOUNTY': + return 'bounty_contributed'; + default: + return undefined; + } +} diff --git a/components/Activity/lib/feedEntryAdapters.ts b/components/Activity/lib/feedEntryAdapters.ts index f8e4e558a..be97f4138 100644 --- a/components/Activity/lib/feedEntryAdapters.ts +++ b/components/Activity/lib/feedEntryAdapters.ts @@ -1,8 +1,10 @@ import { buildWorkUrl } from '@/utils/url'; +import { isFoundationUser } from '@/components/Bounty/lib/bountyUtil'; import type { FeedCommentContent, FeedContentType, FeedEntry, + FeedFundingActivityContent, FeedGrantContent, FeedPaperContent, FeedPostContent, @@ -15,13 +17,13 @@ import type { ContentType } from '@/types/work'; const COMMENT_ACTION_LABELS: Record = { GENERIC_COMMENT: 'commented on', REVIEW: 'peer reviewed', - AUTHOR_UPDATE: 'posted an update on', + AUTHOR_UPDATE: 'posted an update', ANSWER: 'answered on', BOUNTY: 'contributed to', }; const DOC_ACTION_LABELS: Partial> = { - GRANT: 'opened funding', + GRANT: 'opened an RFP for', PREREGISTRATION: 'submitted proposal', POST: 'posted discussion', PAPER: 'published preprint', @@ -34,17 +36,106 @@ const FEED_TO_CONTENT_TYPE: Partial> = { PAPER: 'paper', }; -export function getActionLabel(entry: FeedEntry): string { +export interface ActivityHeaderTarget { + author: AuthorProfile; + suffix?: string; +} + +export interface ActivityHeaderMessage { + actor: AuthorProfile; + verb: string; + target?: ActivityHeaderTarget; + /** Payout rather than contribution — the amount renders without a "+". */ + isEarning?: boolean; +} + +/** + * True when the profile belongs to the ResearchHub Foundation account. + * + * Feed payloads are inconsistent about which id they expose for an actor, so we + * prefer the explicit user fields and only fall back to `id` when the profile + * carries no user reference at all (funder objects are serialized that way). + */ +function isFoundationProfile(profile?: AuthorProfile): boolean { + if (!profile) return false; + if (profile.userId != null) return isFoundationUser(profile.userId); + if (profile.user?.id != null) return isFoundationUser(profile.user.id); + return isFoundationUser(profile.id); +} + +function getFundingActivityMessage(content: FeedFundingActivityContent): ActivityHeaderMessage { + const actor = content.createdBy; + const recipient = content.recipient; + + // Bounty payouts and Foundation tips read as the recipient earning — the + // person who received the money is the interesting subject. + if (recipient && (content.sourceType === 'BOUNTY_PAYOUT' || isFoundationProfile(actor))) { + return { actor: recipient, verb: 'earned', isEarning: true }; + } + + if (content.sourceType === 'BOUNTY_PAYOUT') { + return { actor, verb: 'awarded bounty', isEarning: true }; + } + + if (!recipient) { + return { actor, verb: 'tipped review' }; + } + return { + actor, + verb: 'tipped', + target: { + author: recipient, + suffix: ' on peer review', + }, + }; +} + +function getDefaultActivityMessage(entry: FeedEntry): ActivityHeaderMessage { + const actor = entry.content.createdBy; + if (entry.contentType === 'COMMENT') { const commentContent = entry.content as FeedCommentContent; - if (commentContent.hasBounties) return 'opened a bounty'; - return COMMENT_ACTION_LABELS[commentContent.comment?.commentType] ?? 'commented on'; + const bounty = commentContent.bounties?.[0]; + if (bounty) { + const isReviewBounty = bounty.bountyType === 'REVIEW'; + return { + actor, + verb: isReviewBounty ? 'opened a peer review bounty for' : 'opened a bounty for', + }; + } + + const commentType = commentContent.comment?.commentType; + if (commentType === 'REVIEW') { + if (getReviewEarning(entry)) return { actor, verb: 'earned', isEarning: true }; + const score = commentContent.review?.score ?? commentContent.comment.reviewScore; + return { actor, verb: score ? 'peer reviewed and scored' : 'peer reviewed' }; + } + + return { + actor, + verb: COMMENT_ACTION_LABELS[commentType] ?? 'commented on', + }; } - if (entry.contentType === 'BOUNTY') return 'contributed to'; + + if (entry.contentType === 'BOUNTY') { + return { actor, verb: 'contributed to' }; + } + if (entry.contentType === 'USDFUNDRAISECONTRIBUTION' || entry.contentType === 'PURCHASE') { - return 'Funded Proposal'; + return { actor, verb: 'funded proposal' }; } - return DOC_ACTION_LABELS[entry.contentType] ?? 'contributed'; + + return { + actor, + verb: DOC_ACTION_LABELS[entry.contentType] ?? 'contributed to', + }; +} + +export function getActivityHeaderMessage(entry: FeedEntry): ActivityHeaderMessage { + if (entry.contentType === 'FUNDINGACTIVITY') { + return getFundingActivityMessage(entry.content as FeedFundingActivityContent); + } + return getDefaultActivityMessage(entry); } export interface FeedEntryMeta { @@ -60,12 +151,45 @@ function resolveCommentWorkTab( comment: FeedCommentContent | null ): CommentWorkTab { if (comment?.comment?.commentType === 'REVIEW') return 'reviews'; - if (entry.contentType === 'BOUNTY' || comment?.hasBounties) return 'bounties'; + if (entry.contentType === 'BOUNTY' || (comment?.bounties?.length ?? 0) > 0) return 'bounties'; if (entry.contentType === 'COMMENT') return 'conversation'; return undefined; } +function resolveRelatedWorkTab(entry: FeedEntry): CommentWorkTab { + if (entry.contentType === 'FUNDINGACTIVITY') { + const funding = entry.content as FeedFundingActivityContent; + return funding.sourceType === 'BOUNTY_PAYOUT' ? 'bounties' : 'reviews'; + } + if (entry.contentType === 'COMMENT') { + return resolveCommentWorkTab(entry, entry.content as FeedCommentContent); + } + if (entry.contentType === 'BOUNTY') return 'bounties'; + return undefined; +} + +function getRelatedWorkMeta(entry: FeedEntry): FeedEntryMeta | null { + const related = entry.relatedWork; + if (!related?.title) return null; + + const tab = resolveRelatedWorkTab(entry); + + return { + title: related.title, + author: entry.content.createdBy, + href: buildWorkUrl({ + id: related.id, + slug: related.slug, + contentType: related.contentType, + tab, + }), + }; +} + export function getEntryMeta(entry: FeedEntry): FeedEntryMeta { + const relatedMeta = getRelatedWorkMeta(entry); + if (relatedMeta) return relatedMeta; + const content = entry.content; const author = content.createdBy; @@ -117,17 +241,30 @@ export function getEntryMeta(entry: FeedEntry): FeedEntryMeta { }; } -export type FeedEntryIconName = 'coins' | 'bell' | 'message' | null; +export type FeedEntryIconName = 'coins' | 'fund' | 'earn' | 'proposal' | 'bell' | 'message' | null; export function getActionIcon(entry: FeedEntry): FeedEntryIconName { - if (entry.contentType === 'USDFUNDRAISECONTRIBUTION' || entry.contentType === 'PURCHASE') { + if (entry.contentType === 'GRANT' || entry.activityContext === 'grant_opened') { + return 'fund'; + } + if (entry.activityContext === 'bounty_opened') { + return 'earn'; + } + if (entry.activityContext === 'proposal_submitted' || entry.contentType === 'PREREGISTRATION') { + return null; + } + if ( + entry.contentType === 'USDFUNDRAISECONTRIBUTION' || + entry.contentType === 'PURCHASE' || + entry.contentType === 'FUNDINGACTIVITY' + ) { return 'coins'; } if (entry.contentType === 'BOUNTY') return 'coins'; if (entry.contentType !== 'COMMENT') return null; const commentContent = entry.content as FeedCommentContent; - if (commentContent.hasBounties) return 'coins'; + if ((commentContent.bounties?.length ?? 0) > 0) return 'coins'; const commentType = commentContent.comment?.commentType; if (commentType === 'AUTHOR_UPDATE') return 'bell'; @@ -136,18 +273,40 @@ export function getActionIcon(entry: FeedEntry): FeedEntryIconName { } export function getReviewScore(entry: FeedEntry): number | undefined { + if (entry.contentType === 'FUNDINGACTIVITY') return undefined; if (entry.contentType !== 'COMMENT') return undefined; const commentContent = entry.content as FeedCommentContent; if (commentContent.comment?.commentType !== 'REVIEW') return undefined; + // When the header leads with an earning, the score stays on the document card. + if (getReviewEarning(entry)) return undefined; return commentContent.review?.score ?? commentContent.comment.reviewScore; } +/** Bounty payout shown on the header line for awarded peer reviews. */ +export function getReviewEarning(entry: FeedEntry): FeedContribution | undefined { + if (entry.contentType !== 'COMMENT') return undefined; + const commentContent = entry.content as FeedCommentContent; + if (commentContent.comment?.commentType !== 'REVIEW') return undefined; + + const awarded = entry.awardedBountyAmount; + if (awarded == null || awarded <= 0) return undefined; + + return { amount: awarded, currency: 'RSC' }; +} + export interface FeedContribution { amount: number; currency: 'USD' | 'RSC'; } export function getContribution(entry: FeedEntry): FeedContribution | undefined { + if (entry.contentType === 'FUNDINGACTIVITY') { + const funding = entry.content as FeedFundingActivityContent; + if (funding.totalUsdCents > 0) { + return { amount: funding.totalUsd, currency: 'USD' }; + } + return { amount: funding.totalAmount, currency: 'RSC' }; + } if (entry.contentType !== 'USDFUNDRAISECONTRIBUTION' && entry.contentType !== 'PURCHASE') { return undefined; } @@ -199,6 +358,7 @@ export interface FeedCommentPreview { } export function getCommentPreview(entry: FeedEntry): FeedCommentPreview | null { + if (entry.contentType === 'FUNDINGACTIVITY') return null; if (entry.contentType !== 'COMMENT') return null; const { comment } = entry.content as FeedCommentContent; if (!comment?.content) return null; diff --git a/components/Comment/CommentReadOnly.tsx b/components/Comment/CommentReadOnly.tsx index 9dcab35ff..b60b8d073 100644 --- a/components/Comment/CommentReadOnly.tsx +++ b/components/Comment/CommentReadOnly.tsx @@ -25,6 +25,7 @@ interface CommentReadOnlyProps { maxLength?: number; initiallyExpanded?: boolean; showReadMoreButton?: boolean; + showLinkPreviews?: boolean; createdDate?: string | Date; updatedDate?: string | Date; className?: string; @@ -68,6 +69,7 @@ export const CommentReadOnly = ({ maxLength = 1000, initiallyExpanded = false, showReadMoreButton = true, + showLinkPreviews = true, createdDate, updatedDate, className, @@ -79,7 +81,8 @@ export const CommentReadOnly = ({ // Embeds derived from the comment doc — surfaced as a carousel below the // body so a single saved comment can show multiple link previews without // breaking the prose flow. Only meaningful for TipTap content. - const carouselEmbeds = contentFormat === 'TIPTAP' ? extractDocEmbeds(parsedContent) : []; + const carouselEmbeds = + showLinkPreviews && contentFormat === 'TIPTAP' ? extractDocEmbeds(parsedContent) : []; const textContent = contentFormat === 'TIPTAP' @@ -147,6 +150,7 @@ export const CommentReadOnly = ({ truncate={shouldTruncate && !isExpanded} maxLength={maxLength} debug={debugEnabled} + showLinkPreviews={showLinkPreviews} />, ]; } catch (error) { diff --git a/components/Comment/lib/TipTapRenderer.tsx b/components/Comment/lib/TipTapRenderer.tsx index 4debe84d2..cd6164db8 100644 --- a/components/Comment/lib/TipTapRenderer.tsx +++ b/components/Comment/lib/TipTapRenderer.tsx @@ -15,6 +15,7 @@ interface TipTapRendererProps { renderSectionHeader?: (props: SectionHeaderProps) => ReactNode; truncate?: boolean; maxLength?: number; + showLinkPreviews?: boolean; } /** @@ -67,6 +68,25 @@ export const renderTextWithMarks = (text: string, marks: any[]): ReactNode => { return result; }; +function demoteRichLinks(node: any): any { + if (!node || typeof node !== 'object') return node; + + if (node.type === 'richLink' && node.attrs?.url) { + const url = String(node.attrs.url); + return { + type: 'text', + text: url, + marks: [{ type: 'link', attrs: { href: url, target: '_blank' } }], + }; + } + + if (Array.isArray(node.content)) { + return { ...node, content: node.content.map(demoteRichLinks) }; + } + + return node; +} + /** * Helper function to extract plain text from TipTap JSON */ @@ -107,6 +127,7 @@ const TipTapRenderer: React.FC = ({ renderSectionHeader, truncate = false, maxLength = 300, + showLinkPreviews = true, }) => { if (debug) { console.log('||TipTapRenderer props received:', { @@ -146,7 +167,11 @@ const TipTapRenderer: React.FC = ({ // whose anchor text equals the href become `richLink` nodes so they // render with the same inline preview + hover surface as freshly pasted // links. Idempotent — already-converted docs pass through unchanged. - documentContent = normalizeRichLinks(documentContent); + // Skip when link previews are disabled (e.g. activity feed) and demote + // any existing richLink atoms back to plain anchors. + documentContent = showLinkPreviews + ? normalizeRichLinks(documentContent) + : demoteRichLinks(documentContent); // If truncation is enabled, extract the full text to check length let shouldTruncate = false; diff --git a/components/Feed/FeedItemActions.tsx b/components/Feed/FeedItemActions.tsx index 5107b1c3c..f5d251fa9 100644 --- a/components/Feed/FeedItemActions.tsx +++ b/components/Feed/FeedItemActions.tsx @@ -169,6 +169,11 @@ interface FeedItemActionsProps { isExpanded?: boolean; className?: string; variant?: 'default' | 'inline'; + /** + * When true, render share + save next to the vote control (left) so the + * trailing side can hold only `rightSideActionButton` (e.g. Fund / Review). + */ + leadingUtilityActions?: boolean; } // Define interface for avatar items used in local state @@ -207,6 +212,7 @@ export const FeedItemActions: FC = ({ isExpanded = false, className, variant = 'default', + leadingUtilityActions = false, }) => { const { executeAuthenticatedAction } = useAuthenticatedAction(); const { showUSD } = useCurrencyPreference(); @@ -407,6 +413,58 @@ export const FeedItemActions: FC = ({ const tipAmount = tips.reduce((total, tip) => total + (tip.amount || 0), 0); const totalAwarded = tipAmount + (awardedBountyAmount || 0); + const canSave = + !!relatedDocumentUnifiedDocumentId && + feedContentType !== 'COMMENT' && + feedContentType !== 'BOUNTY' && + feedContentType !== 'APPLICATION' && + showPeerReviews; + + const showShare = leadingUtilityActions || variant !== 'inline'; + const showMoreMenu = + !!(listDetailContext && relatedDocumentUnifiedDocumentId) || + menuItems.length > 0 || + !hideReportButton; + + const shareButton = showShare ? ( + + ) : null; + + const saveButton = canSave ? ( + + ) : null; + return ( <>
= ({ /> )} {children} + {leadingUtilityActions && ( + <> + {saveButton} + {shareButton} + + )}
{rightSideActionButton} - { - e.stopPropagation(); - }} - onClick={(e) => { - e.stopPropagation(); - }} - variant="ghost" - size="sm" - className="flex h-8 w-8 !p-0 items-center justify-center rounded-full text-gray-700 transition-all hover:bg-white hover:text-gray-900 hover:shadow-sm" - > - - - } - align="end" - open={isMenuOpen} - onOpenChange={setIsMenuOpen} - > - {listDetailContext && relatedDocumentUnifiedDocumentId && ( - - - Remove from list - - )} - - {menuItems.map((item, index) => ( - { - setIsMenuOpen(false); - item.onClick(e); - }} - className={cn('flex items-center gap-2', item.className)} - > - {item.icon && } - {item.label} - - ))} - - {showSeparator &&
} - - {!hideReportButton && ( - - - {actionLabels?.report || 'Report'} - - )} - - {variant !== 'inline' && ( - + } + align="end" + open={isMenuOpen} + onOpenChange={setIsMenuOpen} > - - + {listDetailContext && relatedDocumentUnifiedDocumentId && ( + + + Remove from list + + )} + + {menuItems.map((item, index) => ( + { + setIsMenuOpen(false); + item.onClick(e); + }} + className={cn('flex items-center gap-2', item.className)} + > + {item.icon && } + {item.label} + + ))} + + {showSeparator &&
} + + {!hideReportButton && ( + + + {actionLabels?.report || 'Report'} + + )} + + )} + {!leadingUtilityActions && ( + <> + {shareButton} + {saveButton} + )} - {relatedDocumentUnifiedDocumentId && - feedContentType !== 'COMMENT' && - feedContentType !== 'BOUNTY' && - feedContentType !== 'APPLICATION' && - showPeerReviews && ( - - )}
diff --git a/components/Funding/ActivityCard.tsx b/components/Funding/ActivityCard.tsx index 6fe9303ef..80db42d48 100644 --- a/components/Funding/ActivityCard.tsx +++ b/components/Funding/ActivityCard.tsx @@ -2,37 +2,49 @@ import { FC } from 'react'; import Link from 'next/link'; -import { Star } from 'lucide-react'; import { Avatar } from '@/components/ui/Avatar'; import { AuthorTooltip } from '@/components/ui/AuthorTooltip'; -import { formatTimeAgo } from '@/utils/date'; -import type { FeedEntry } from '@/types/feed'; +import { ActivityHeaderActionText } from '@/components/Activity/ActivityHeaderActionText'; +import { BountyAmount } from '@/components/Activity/BountyAmount'; +import { ContributionAmount } from '@/components/Activity/ContributionAmount'; +import { FeedEntryIcon } from '@/components/Activity/FeedEntryIcon'; +import { GrantFundingAmount } from '@/components/Activity/GrantFundingAmount'; +import { ReviewScoreStars } from '@/components/Activity/ReviewScoreStars'; import { getActionIcon, - getActionLabel, + getActivityHeaderMessage, getContribution, getEntryMeta, getGrantAmount, + getReviewEarning, getReviewScore, } from '@/components/Activity/lib/feedEntryAdapters'; -import { ContributionAmount } from '@/components/Activity/ContributionAmount'; -import { FeedEntryIcon } from '@/components/Activity/FeedEntryIcon'; -import { GrantFundingAmount } from '@/components/Activity/GrantFundingAmount'; +import { getActivityBounty } from '@/components/Activity/lib/activityWorkContext'; +import { formatTimeAgo } from '@/utils/date'; +import { Tooltip } from '@/components/ui/Tooltip'; +import type { FeedEntry } from '@/types/feed'; interface ActivityCardProps { entry: FeedEntry; } +/** Compact activity row used in the funding sidebar. */ export const ActivityCard: FC = ({ entry }) => { - const { title, author, href } = getEntryMeta(entry); + const { title, href } = getEntryMeta(entry); if (!title) return null; - const actionLabel = getActionLabel(entry); + const message = getActivityHeaderMessage(entry); const actionIcon = getActionIcon(entry); const reviewScore = getReviewScore(entry); + const reviewEarning = getReviewEarning(entry); const grantAmount = getGrantAmount(entry); const contribution = getContribution(entry); + const bounty = entry.activityContext === 'bounty_opened' ? getActivityBounty(entry) : undefined; + + const hasAmount = Boolean( + grantAmount || contribution || reviewEarning || bounty || reviewScore != null + ); const titleEl = href ? ( @@ -45,51 +57,66 @@ export const ActivityCard: FC = ({ entry }) => { return (
-
- +
+
- {author?.id ? ( - - - {author.fullName || 'Unknown'} - - - ) : ( - - {author?.fullName || 'Unknown'} - - )} - - {actionLabel} - - {reviewScore != null && ( - - - {reviewScore.toFixed(1)} - + + + {grantAmount && ( + <> + {' '} + + )} - {grantAmount && } {contribution && ( - + <> + {' '} + + )} + {reviewEarning && ( + <> + {' '} + + + )} + {bounty && ( + <> + {' '} + + + )} + {reviewScore != null && reviewScore > 0 && ( + <> + {' '} + + + )} + {titleEl}
- - {formatTimeAgo(entry.timestamp)} - + + + {formatTimeAgo(entry.timestamp)} + +
); }; diff --git a/components/Funding/FundActivityPageContent.tsx b/components/Funding/FundActivityPageContent.tsx new file mode 100644 index 000000000..cbc6be4a6 --- /dev/null +++ b/components/Funding/FundActivityPageContent.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { useInView } from 'react-intersection-observer'; +import { ActivityCardFull } from '@/components/Activity/ActivityCardFull'; +import { ActivityCardSkeleton } from '@/components/Activity/ActivityCardSkeleton'; +import { useActivityFeed } from '@/hooks/useActivityFeed'; + +export function FundActivityPageContent() { + const { entries, isLoading, isLoadingMore, hasMore, loadMore } = useActivityFeed({}); + + const { ref: sentinelRef } = useInView({ + threshold: 0, + rootMargin: '200px', + onChange: (inView) => { + if (inView && hasMore && !isLoading && !isLoadingMore) { + loadMore(); + } + }, + }); + + return ( +
+ {entries.map((entry) => ( + + ))} + + {(isLoading || isLoadingMore) && + [...Array(6)].map((_, i) => )} + + {!isLoading && !isLoadingMore && entries.length === 0 && ( +
+

No activity found

+
+ )} + + {!isLoading && !isLoadingMore && hasMore &&
} +
+ ); +} diff --git a/components/Funding/FundSidebar.tsx b/components/Funding/FundSidebar.tsx new file mode 100644 index 000000000..c626e0972 --- /dev/null +++ b/components/Funding/FundSidebar.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { FundingPowerCard } from './FundingPowerCard'; +import { RecentlyVisitedCard, useRecentlyVisited } from './RecentlyVisitedCard'; +import { cn } from '@/utils/styles'; + +/** + * Shared Fund right column (Activity / RFPs / Proposals): funding power on top, + * recently visited beneath (dropped entirely once cleared / empty). + */ +export function FundSidebar() { + const recentlyVisited = useRecentlyVisited(); + const showsRecentlyVisited = recentlyVisited.pages.length > 0; + + return ( +
+ + {showsRecentlyVisited && ( + + )} +
+ ); +} diff --git a/components/Funding/FundingBannerSkeleton.tsx b/components/Funding/FundingBannerSkeleton.tsx deleted file mode 100644 index a411457f8..000000000 --- a/components/Funding/FundingBannerSkeleton.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { ResearchCoinSnapshotSkeleton } from '@/components/ResearchCoin/ResearchCoinSnapshot'; - -interface FundingBannerSkeletonProps { - showTabs?: boolean; -} - -export function FundingBannerSkeleton({ showTabs = false }: FundingBannerSkeletonProps) { - return ( -
-
-
-
-
-
-
- -
- -
-
-
- -
- -
-
- - {showTabs && ( -
-
-
-
-
-
-
-
-
-
-
-
- )} -
-
- ); -} diff --git a/components/Funding/FundingPowerCard.tsx b/components/Funding/FundingPowerCard.tsx new file mode 100644 index 000000000..0b9bc6343 --- /dev/null +++ b/components/Funding/FundingPowerCard.tsx @@ -0,0 +1,227 @@ +'use client'; + +import Link from 'next/link'; +import { Plus } from 'lucide-react'; +import { RSC_COLORS } from '@/components/ui/icons/ResearchCoinIcon'; +import { Tooltip } from '@/components/ui/Tooltip'; +import { formatCurrency } from '@/utils/currency'; +import { useCurrencyPreference } from '@/contexts/CurrencyPreferenceContext'; +import { useExchangeRate } from '@/contexts/ExchangeRateContext'; +import { useUser } from '@/contexts/UserContext'; +import { getAvailableAndPromotionalRscBalance } from '@/components/ResearchCoin/lib/promotionalBalance'; +import { cn } from '@/utils/styles'; + +interface FundingPowerCardProps { + className?: string; +} + +/** + * Wallet card for the Activity sidebar. Leads with total funding power, + * visualizes the split between RSC and fund-only credits, and surfaces quick + * actions to deposit, earn, or fund research. + */ +export const FundingPowerCard = ({ className }: FundingPowerCardProps) => { + const { user, isLoading: isUserLoading } = useUser(); + const { showUSD } = useCurrencyPreference(); + const { exchangeRate, isLoading: isRateLoading } = useExchangeRate(); + + // Wait for auth + (when showing USD) a usable rate so we don't flash the + // empty CTA or an RSC figure that then swaps to dollars. + const isReady = !isUserLoading && (!showUSD || (!isRateLoading && exchangeRate > 0)); + + if (!isReady) { + return ; + } + + const fmt = (rscAmount: number) => + formatCurrency({ + amount: showUSD ? rscAmount * exchangeRate : rscAmount, + showUSD, + exchangeRate, + shorten: true, + skipConversion: true, + }); + + const balanceRaw = getAvailableAndPromotionalRscBalance(user); + const creditsRaw = user?.fundingCredits ?? 0; + const total = balanceRaw + creditsRaw; + const isEmpty = !user || total === 0; + + const rscWidth = total > 0 ? (balanceRaw / total) * 100 : 0; + const creditsWidth = total > 0 ? (creditsRaw / total) * 100 : 0; + + return ( +
+
+ )} + + {isEmpty && ( +

+ Deposit ResearchCoin or earn fund-only credits by peer reviewing — then put it toward + research you believe in. +

+ )} + + {isEmpty && ( +
+ Deposit RSC + Earn credits +
+ )} + + {!isEmpty && ( +
+ + +
+ )} + + ); +}; + +const FundingPowerCardSkeleton = ({ className }: { className?: string }) => ( + +); + +interface SourceRowProps { + label: string; + tooltip: string; + dotColor: string; + value: string; + valueClassName?: string; +} + +const SourceRow = ({ label, tooltip, dotColor, value, valueClassName }: SourceRowProps) => ( + +
+ + {label} + + {value} + +
+
+); + +interface CtaProps { + href: string; + children: React.ReactNode; + className?: string; +} + +const PrimaryCta = ({ href, children, className }: CtaProps) => ( + + {children} + +); + +const SecondaryCta = ({ href, children, className }: CtaProps) => ( + + {children} + +); diff --git a/components/Funding/GrantContentSwitcher.tsx b/components/Funding/GrantContentSwitcher.tsx index 706f1dd22..e29e8100a 100644 --- a/components/Funding/GrantContentSwitcher.tsx +++ b/components/Funding/GrantContentSwitcher.tsx @@ -5,22 +5,15 @@ 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'; interface GrantContentSwitcherProps { children: ReactNode; content?: string; imageUrl?: string; - hasDescription: boolean; - grantId?: number | string; } -export function GrantContentSwitcher({ - children, - content, - imageUrl, - hasDescription, - grantId, -}: GrantContentSwitcherProps) { +export function GrantContentSwitcher({ children, content, imageUrl }: GrantContentSwitcherProps) { const { activeTab, activity } = useGrantTab(); const { entries, isLoading, isLoadingMore, hasMore, loadMore } = activity; @@ -46,22 +39,8 @@ export function GrantContentSwitcher({ ))} - {(isLoading || isLoadingMore) && ( -
- {[...Array(8)].map((_, i) => ( -
-
-
-
-
-
-
-
-
-
- ))} -
- )} + {(isLoading || isLoadingMore) && + [...Array(8)].map((_, i) => )} {!isLoading && !isLoadingMore && entries.length === 0 && (
diff --git a/components/Funding/HomeTabs.tsx b/components/Funding/HomeTabs.tsx new file mode 100644 index 000000000..de1b63e62 --- /dev/null +++ b/components/Funding/HomeTabs.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { FeedTabs } from '@/components/Feed/FeedTabs'; +import { useContentTabsVisibilitySentinel } from '@/hooks/useContentTabsVisibilitySentinel'; +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. + */ +export function HomeTabs() { + const { tabs, highlightedTab, handleTabChange } = useFundTabs(); + const tabsSentinelRef = useContentTabsVisibilitySentinel(); + + return ( +
+ +
+ ); +} diff --git a/components/Funding/MarketplaceCards.tsx b/components/Funding/MarketplaceCards.tsx deleted file mode 100644 index fa7357a94..000000000 --- a/components/Funding/MarketplaceCards.tsx +++ /dev/null @@ -1,32 +0,0 @@ -'use client'; - -import { Tabs } from '@/components/ui/Tabs'; -import { useUser } from '@/contexts/UserContext'; -import { useContentTabsVisibilitySentinel } from '@/hooks/useContentTabsVisibilitySentinel'; -import { FUND_TABS, type FundTab } from '@/hooks/useFundTabs'; - -export type MarketplaceTab = FundTab; - -interface MarketplaceCardsProps { - selected?: MarketplaceTab; -} - -export function MarketplaceCards({ selected = 'grants' }: MarketplaceCardsProps) { - const { user, isLoading: isLoadingUser } = useUser(); - // Pull the tabs up only when the panel is or will be taller (snapshot visible). - // Logged-out users see just the CTA (shorter panel), so no offset is needed. - const snapshotVisible = isLoadingUser || !!user; - const tabsSentinelRef = useContentTabsVisibilitySentinel(); - - return ( -
- {}} - variant="primary" - className="mt-4" - /> -
- ); -} diff --git a/components/Funding/RecentlyVisitedCard.tsx b/components/Funding/RecentlyVisitedCard.tsx new file mode 100644 index 000000000..c6f7530a6 --- /dev/null +++ b/components/Funding/RecentlyVisitedCard.tsx @@ -0,0 +1,124 @@ +'use client'; + +import { useCallback, useMemo, useState } from 'react'; +import Link from 'next/link'; +import { useActivityFeed } from '@/hooks/useActivityFeed'; +import { getEntryMeta } from '@/components/Activity/lib/feedEntryAdapters'; +import { cn } from '@/utils/styles'; + +const MAX_ITEMS = 10; + +const ENTRY_TYPE_LABELS: Record = { + GRANT: 'Request for Proposal', + PREREGISTRATION: 'Proposal', + USDFUNDRAISECONTRIBUTION: 'Proposal', + PURCHASE: 'Proposal', + PAPER: 'Paper', + POST: 'Post', +}; + +/** Comment/bounty entries point at a document, so label them by that work. */ +const WORK_TYPE_LABELS: Record = { + paper: 'Paper', + post: 'Post', + preregistration: 'Proposal', + question: 'Question', + discussion: 'Discussion', + funding_request: 'Request for Proposal', +}; + +interface RecentPage { + href: string; + title: string; + typeLabel?: string; +} + +export interface RecentlyVisited { + pages: RecentPage[]; + clear: () => void; +} + +/** + * The viewer's recent pages plus the ability to forget them. Lifted out of the + * card so the surrounding column can drop the section entirely once it's + * cleared, rather than leaving an empty panel behind. + * + * Sources the activity feed until real visit tracking exists. + */ +export function useRecentlyVisited(): RecentlyVisited { + const { entries, isLoading } = useActivityFeed(); + const [isCleared, setIsCleared] = useState(false); + + const pages = useMemo(() => { + const collected: RecentPage[] = []; + const seen = new Set(); + + for (const entry of entries) { + const { title, href } = getEntryMeta(entry); + if (!title || !href || seen.has(href)) continue; + seen.add(href); + const relatedType = entry.relatedWork?.contentType; + collected.push({ + href, + title, + typeLabel: + ENTRY_TYPE_LABELS[entry.contentType] ?? + (relatedType ? WORK_TYPE_LABELS[relatedType] : undefined), + }); + if (collected.length === MAX_ITEMS) break; + } + + return collected; + }, [entries]); + + const clear = useCallback(() => setIsCleared(true), []); + + return { pages: isCleared || (isLoading && pages.length === 0) ? [] : pages, clear }; +} + +interface RecentlyVisitedCardProps extends RecentlyVisited { + className?: string; +} + +/** + * Lightweight browsing history for the Activity sidebar: a plain text list of + * documents from the activity feed, no thumbnails or metrics. + */ +export function RecentlyVisitedCard({ pages, clear, className }: RecentlyVisitedCardProps) { + if (pages.length === 0) return null; + + return ( + + ); +} diff --git a/components/Search/SearchSuggestions.tsx b/components/Search/SearchSuggestions.tsx index e2b9947af..ad1eda2d5 100644 --- a/components/Search/SearchSuggestions.tsx +++ b/components/Search/SearchSuggestions.tsx @@ -17,10 +17,14 @@ interface SearchSuggestionsProps { suggestions?: SearchSuggestion[]; hasLocalSuggestions?: boolean; clearSearchHistory?: () => void; + /** Cap on rendered rows. Defaults to 7 (search modal results). */ + maxResults?: number; + /** When false, omit the Recent / Clear all header (caller owns chrome). */ + showRecentHeader?: boolean; } -// Maximum number of search results to display -const MAX_RESULTS = 7; +// Maximum number of search results to display by default +const DEFAULT_MAX_RESULTS = 7; // Maximum length for titles before truncating const MAX_TITLE_LENGTH = 100; @@ -40,6 +44,8 @@ export function SearchSuggestions({ suggestions = [], hasLocalSuggestions = false, clearSearchHistory, + maxResults = DEFAULT_MAX_RESULTS, + showRecentHeader = true, }: SearchSuggestionsProps) { const [erroredSuggestions, setErroredSuggestions] = useState>(new Set()); @@ -275,7 +281,7 @@ export function SearchSuggestions({ return false; } }) - .slice(0, MAX_RESULTS); // Limit to maximum number of results + .slice(0, maxResults); // Limit to maximum number of results // Group suggestions by recent vs search results for inline mode const recentSuggestions = safeSuggestions.filter((s) => s.isRecent); @@ -291,23 +297,25 @@ export function SearchSuggestions({ {/* Local suggestions section */} {showSuggestionsOnFocus && !query && hasLocalSuggestions && (
-
- - Recent - - -
+ + Recent + + +
+ )}
    {safeSuggestions.map(renderSuggestion)}
diff --git a/components/landing/LandingPageHero.tsx b/components/landing/LandingPageHero.tsx index 3feee2a37..f293dc4b8 100644 --- a/components/landing/LandingPageHero.tsx +++ b/components/landing/LandingPageHero.tsx @@ -16,7 +16,7 @@ export function LandingPageHero() { }; const handleExplore = () => { - router.push('/popular'); + router.push('/'); }; return ( diff --git a/components/work/WorkHeader/WorkHeaderGrant.tsx b/components/work/WorkHeader/WorkHeaderGrant.tsx index ca791ab23..5ff1a72a3 100644 --- a/components/work/WorkHeader/WorkHeaderGrant.tsx +++ b/components/work/WorkHeader/WorkHeaderGrant.tsx @@ -84,6 +84,8 @@ export function WorkHeaderGrant({ ) : undefined; const activityCount = activity.count; + const activityCountLabel = + activityCount > 0 && activity.hasMore ? `${activityCount}+` : activityCount; const grantTabs = [ { id: 'details' as const, label: 'Details' }, @@ -119,7 +121,7 @@ export function WorkHeaderGrant({ : 'bg-gray-100 text-gray-600' }`} > - {activityCount} + {activityCountLabel} )}
diff --git a/hooks/useActivityFeed.ts b/hooks/useActivityFeed.ts index cb45a3176..a663c3b45 100644 --- a/hooks/useActivityFeed.ts +++ b/hooks/useActivityFeed.ts @@ -56,9 +56,12 @@ export function useActivityFeed({ scope, grantId }: UseActivityFeedOptions = {}) scope, grantId, }); - setEntries((prev) => [...prev, ...result.entries]); + setEntries((prev) => { + const next = [...prev, ...result.entries]; + setCount(next.length); + return next; + }); setHasMore(result.hasMore); - setCount(result.count); pageRef.current = nextPage; } catch (error) { console.error('Error loading more activity:', error); diff --git a/hooks/useFeedTabs.ts b/hooks/useFeedTabs.ts index 384def73d..57caa33ed 100644 --- a/hooks/useFeedTabs.ts +++ b/hooks/useFeedTabs.ts @@ -18,7 +18,9 @@ export const useFeedTabs = (onBeforeNavigate?: () => void) => { const isTopicPage = pathname.startsWith('/topic/'); const isJournalPage = pathname.startsWith('/journal'); - const isHomeFeedPage = ['/', '/following', '/latest', '/popular', '/for-you', '/feed'].includes( + // Legacy research-feed routes (redirected to `/` for homepage hub). + // `/` is now the Activity hub tab — not a research feed. + const isHomeFeedPage = ['/following', '/latest', '/popular', '/for-you', '/feed'].includes( pathname ); diff --git a/hooks/useFundTabs.ts b/hooks/useFundTabs.ts deleted file mode 100644 index 78c44293b..000000000 --- a/hooks/useFundTabs.ts +++ /dev/null @@ -1,65 +0,0 @@ -'use client'; - -import { useMemo } from 'react'; -import { usePathname, useRouter } from 'next/navigation'; -import { ArrowDownCircle, ArrowUpCircle } from 'lucide-react'; - -export type FundTab = 'grants' | 'proposals'; - -export const FUND_TABS = [ - { - id: 'grants' as const, - label: 'Funding Opportunities', - href: '/fund', - icon: ArrowDownCircle, - iconClassName: 'w-5 h-5', - activeClassName: 'text-emerald-600 border-b-emerald-600', - }, - { - id: 'proposals' as const, - label: 'Proposals', - href: '/fund/proposals', - icon: ArrowUpCircle, - iconClassName: 'w-5 h-5', - activeClassName: 'text-primary-600 border-b-primary-600', - }, -]; - -export function useFundTabs() { - const pathname = usePathname(); - const router = useRouter(); - - const isFundPage = pathname === '/fund' || pathname === '/fund/proposals'; - - const activeTab = useMemo((): FundTab => { - if (pathname === '/fund/proposals') return 'proposals'; - return 'grants'; - }, [pathname]); - - const tabs = useMemo(() => FUND_TABS, []); - - const handleTabChange = (tab: string, e?: React.MouseEvent) => { - if (tab === activeTab) { - e?.preventDefault(); - return; - } - - const href = FUND_TABS.find((t) => t.id === tab)?.href; - if (!href) return; - - if (e && !e.metaKey && !e.ctrlKey && !e.shiftKey && e.button === 0) { - e.preventDefault(); - router.push(href); - } else if (!e) { - router.push(href); - } - }; - - return { - tabs, - activeTab, - highlightedTab: activeTab, - handleTabChange, - isFundPage, - }; -} diff --git a/hooks/useFundTabs.tsx b/hooks/useFundTabs.tsx new file mode 100644 index 000000000..8a099685b --- /dev/null +++ b/hooks/useFundTabs.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { useMemo } from 'react'; +import { usePathname, useRouter } from 'next/navigation'; +import { FileText, Waves, type LucideIcon, type LucideProps } from 'lucide-react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faBullhorn } from '@fortawesome/free-solid-svg-icons'; +import { useScrollContainer } from '@/contexts/ScrollContainerContext'; + +/** Sized via PillTabs' className to match Lucide stroke icons in the pills. */ +function BullhornIcon({ className }: LucideProps) { + return ; +} + +export type FundTab = 'activity' | 'fund' | 'proposals'; + +/** Routes that make up the homepage hub — all of them highlight "Home" in the nav. */ +export const HOME_TAB_PATHS = ['/', '/fund', '/fund/proposals']; + +export const isHomeTabPath = (pathname: string) => HOME_TAB_PATHS.includes(pathname); + +export const FUND_TABS = [ + { + id: 'activity' as const, + label: 'Activity', + href: '/', + icon: Waves, + scroll: false, + }, + { + id: 'fund' as const, + label: 'Fund', + href: '/fund', + icon: BullhornIcon as LucideIcon, + scroll: false, + }, + { + id: 'proposals' as const, + label: 'Proposals', + href: '/fund/proposals', + icon: FileText, + scroll: false, + }, +]; + +/** Homepage hub: Activity / Fund / Proposals (shared shell + FundSidebar). */ +export function useFundTabs() { + const pathname = usePathname(); + const router = useRouter(); + const scrollContainerRef = useScrollContainer(); + + const isFundPage = isHomeTabPath(pathname); + + const activeTab = useMemo((): FundTab => { + if (pathname === '/fund/proposals') return 'proposals'; + if (pathname === '/fund') return 'fund'; + return 'activity'; + }, [pathname]); + + const tabs = useMemo(() => FUND_TABS, []); + + const scrollToTop = () => { + const container = scrollContainerRef?.current; + if (container) { + container.scrollTop = 0; + } else { + window.scrollTo(0, 0); + } + }; + + const handleTabChange = (tab: string, e?: React.MouseEvent) => { + if (tab === activeTab) { + e?.preventDefault(); + return; + } + + const href = FUND_TABS.find((t) => t.id === tab)?.href; + if (!href) return; + + if (e && !e.metaKey && !e.ctrlKey && !e.shiftKey && e.button === 0) { + e.preventDefault(); + scrollToTop(); + router.push(href, { scroll: false }); + } else if (!e) { + scrollToTop(); + router.push(href, { scroll: false }); + } + }; + + return { + tabs, + activeTab, + highlightedTab: activeTab, + handleTabChange, + isFundPage, + }; +} diff --git a/hooks/useSearchSuggestions.ts b/hooks/useSearchSuggestions.ts index b7ce55c65..1634f4682 100644 --- a/hooks/useSearchSuggestions.ts +++ b/hooks/useSearchSuggestions.ts @@ -1,7 +1,10 @@ import { useState, useEffect, useMemo } from 'react'; import { SearchService } from '@/services/search.service'; import { SearchSuggestion } from '@/types/search'; -import { getSearchHistory, SEARCH_HISTORY_KEY } from '@/utils/searchHistory'; +import { + getSearchHistory, + clearSearchHistory as clearStoredSearchHistory, +} from '@/utils/searchHistory'; import { EntityType } from '@/types/search'; interface UseSearchSuggestionsConfig { @@ -154,7 +157,7 @@ export function useSearchSuggestions({ // Clear all search history const clearSearchHistory = () => { if (!includeLocalSuggestions) return; - localStorage.removeItem(SEARCH_HISTORY_KEY); + clearStoredSearchHistory(); setLocalSuggestions([]); }; diff --git a/services/activity.service.ts b/services/activity.service.ts index 9e8543825..8f34eaf7e 100644 --- a/services/activity.service.ts +++ b/services/activity.service.ts @@ -1,5 +1,10 @@ import { ApiClient } from './client'; -import { FeedEntry, FeedApiResponse, transformFeedEntry, RawApiFeedEntry } from '@/types/feed'; +import { + FeedEntry, + ActivityFeedApiResponse, + transformFeedEntry, + RawApiFeedEntry, +} from '@/types/feed'; export type ActivityDocumentType = 'PREREGISTRATION' | 'GRANT' | 'DISCUSSION'; @@ -17,7 +22,7 @@ export interface GetActivityParams { export class ActivityService { private static readonly BASE_PATH = '/api/activity_feed'; - private static readonly DEFAULT_PAGE_SIZE = 25; + private static readonly DEFAULT_PAGE_SIZE = 20; static async getActivity(params?: GetActivityParams): Promise<{ entries: FeedEntry[]; @@ -37,7 +42,7 @@ export class ActivityService { const qs = queryParams.toString(); const url = `${this.BASE_PATH}/${qs ? `?${qs}` : ''}`; try { - const response = await ApiClient.get(url); + const response = await ApiClient.get(url); const entries = response.results .map((entry: RawApiFeedEntry) => { @@ -49,7 +54,7 @@ export class ActivityService { }) .filter((e): e is FeedEntry => e !== null); - return { entries, hasMore: !!response.next, count: response.count ?? entries.length }; + return { entries, hasMore: !!response.next, count: entries.length }; } catch (error) { console.error('Error fetching activity feed:', error); return { entries: [], hasMore: false, count: 0 }; diff --git a/types/feed.ts b/types/feed.ts index 7c022574f..cbecf756d 100644 --- a/types/feed.ts +++ b/types/feed.ts @@ -2,17 +2,25 @@ import { AuthorProfile, transformAuthorProfile } from './authorProfile'; import { ContentMetrics } from './metrics'; import { Topic, transformTopic } from './topic'; import { createTransformer, BaseTransformed } from './transformer'; -import { Work, transformPaper, transformPost, FundingRequest, ContentType } from './work'; +import { + Work, + transformPaper, + transformPost, + FundingRequest, + ContentType, + type WorkGrantSummary, +} from './work'; +import { mapApiDocumentTypeToClientType, type ApiDocumentType } from '@/utils/contentTypeMapping'; import { Bounty, BountyWithComment, transformBounty } from './bounty'; import { Comment, CommentType, ContentFormat, transformComment } from './comment'; import { Fundraise, transformFundraise, Application, transformApplication } from './funding'; import { Journal } from './journal'; import { UserVoteType } from './reaction'; -import { User } from './user'; import { stripHtml } from '@/utils/stringUtils'; import { Tip } from './tip'; import { FOUNDATION_USER_ID } from '@/config/constants'; import { GrantStatus } from './grant'; +import { deriveActivityContext } from '@/components/Activity/lib/deriveActivityContext'; export type FeedActionType = 'contribute' | 'open' | 'publish' | 'post'; @@ -28,6 +36,29 @@ export interface ParentCommentPreview { parentComment?: ParentCommentPreview | undefined; // Add recursive field } +function transformFundingActivityRecipient(recipients: unknown): AuthorProfile | undefined { + const first = Array.isArray(recipients) ? recipients[0] : undefined; + if (!first || typeof first !== 'object') return undefined; + + const recipientUser = (first as { recipient_user?: Record }).recipient_user; + if (!recipientUser || typeof recipientUser !== 'object') return undefined; + + try { + return transformAuthorProfile(recipientUser); + } catch { + return undefined; + } +} + +function transformFundingActivityFunder(funder: unknown): AuthorProfile | undefined { + if (!funder || typeof funder !== 'object') return undefined; + try { + return transformAuthorProfile(funder as Record); + } catch { + return undefined; + } +} + // Recursive helper function to transform nested parent comments const transformNestedParentComment = (rawParent: any): ParentCommentPreview | undefined => { if (!rawParent) { @@ -141,7 +172,6 @@ export interface FeedCommentContent extends BaseFeedContent { objectId: number; }; }; - hasBounties?: boolean; isRemoved?: boolean; relatedDocumentId?: number | string; relatedDocumentContentType?: ContentType; @@ -205,6 +235,18 @@ export interface FeedGrantContent extends BaseFeedContent { isExpired?: boolean; } +export type FundingActivitySourceType = 'BOUNTY_PAYOUT' | 'TIP_REVIEW'; + +export interface FeedFundingActivityContent extends BaseFeedContent { + contentType: 'FUNDINGACTIVITY'; + sourceType: FundingActivitySourceType; + totalAmount: number; + totalUsdCents: number; + totalUsd: number; + activityDate?: string; + recipient?: AuthorProfile; +} + // Update the Content union type to include the base interface export type Content = | FeedPostContent @@ -212,7 +254,8 @@ export type Content = | FeedBountyContent | FeedCommentContent | FeedApplicationContent - | FeedGrantContent; + | FeedGrantContent + | FeedFundingActivityContent; export type FeedContentType = | 'PAPER' @@ -223,7 +266,8 @@ export type FeedContentType = | 'APPLICATION' | 'GRANT' | 'USDFUNDRAISECONTRIBUTION' - | 'PURCHASE'; + | 'PURCHASE' + | 'FUNDINGACTIVITY'; export interface ExternalMetrics { score: number; @@ -280,6 +324,17 @@ export interface AssociatedGrant { numApplicants: number; } +export type ActivityContext = + | 'tip_review' + | 'bounty_payout' + | 'fundraise_contribution' + | 'bounty_opened' + | 'bounty_contributed' + | 'peer_review_published' + | 'comment_published' + | 'grant_opened' + | 'proposal_submitted'; + export interface JournalPostIds { grantPostId: number | null; proposalPostId: number | null; @@ -307,6 +362,7 @@ export interface FeedEntry { externalMetrics?: ExternalMetrics; nonprofit?: Nonprofit; associatedGrants?: AssociatedGrant[]; + activityContext?: ActivityContext; journalPostIds?: JournalPostIds; searchMetadata?: { highlightedTitle?: string; @@ -344,6 +400,7 @@ export interface RawApiFeedEntry { base_wallet_address: string; }; hot_score_v2?: number; + related_work?: any; risk_score?: number | null; associated_grants?: Array<{ id: number; @@ -442,6 +499,12 @@ export interface FeedApiResponse { results: RawApiFeedEntry[]; } +export interface ActivityFeedApiResponse { + next: string | null; + previous: string | null; + results: RawApiFeedEntry[]; +} + /** * Safely extracts the unified document ID from a content object. * @@ -471,6 +534,78 @@ export function getUnifiedDocumentId(content_object: any): string | undefined { export type TransformedContent = Content & BaseTransformed; export type TransformedFeedEntry = FeedEntry & BaseTransformed; +function transformActivityRelatedWorkGrant(rawGrant: unknown): WorkGrantSummary | undefined { + if (!rawGrant || typeof rawGrant !== 'object') return undefined; + + const grant = rawGrant as { + status?: string; + organization?: string; + amount?: { usd?: number; rsc?: number | null }; + application_count?: number; + end_date?: string | null; + }; + + if (typeof grant.amount !== 'object' || grant.amount === null) { + return undefined; + } + + return { + status: grant.status ?? '', + organization: grant.organization ?? '', + amount: { + usd: grant.amount.usd ?? 0, + rsc: grant.amount.rsc ?? null, + }, + numApplicants: grant.application_count ?? 0, + endDate: grant.end_date ?? undefined, + }; +} + +function transformActivityRelatedWork(raw: any): Work | undefined { + if (!raw) return undefined; + + const contentType = + mapApiDocumentTypeToClientType(raw.document_type as ApiDocumentType) ?? 'post'; + + let fundraise: Fundraise | undefined; + if (raw.fundraise) { + try { + fundraise = transformFundraise(raw.fundraise); + } catch (error) { + console.error('Error transforming activity related_work fundraise:', error); + } + } + + const hubTopic = raw.hub + ? raw.hub.id + ? transformTopic(raw.hub) + : { id: 0, name: raw.hub.name || '', slug: raw.hub.slug || '' } + : undefined; + + return { + id: raw.id, + slug: raw.slug || '', + title: stripHtml(raw.title || ''), + contentType, + createdDate: raw.created_date || '', + abstract: '', + authors: Array.isArray(raw.authors) + ? raw.authors.map((author: unknown) => ({ + authorProfile: transformAuthorProfile(author), + isCorresponding: false, + position: 'middle' as const, + })) + : [], + topics: hubTopic ? [hubTopic] : [], + formats: [], + figures: [], + unifiedDocumentId: raw.unified_document_id, + image: raw.image_url ?? undefined, + fundraise, + grantSummary: raw.grant ? transformActivityRelatedWorkGrant(raw.grant) : undefined, + }; +} + // Updated transformFeedEntry function to use the simplified Content type export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => { if (!feedEntry) { @@ -743,8 +878,16 @@ export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => { // Transform the comment to get score and other properties const transformedComment = transformComment(commentData); - const hasBounties = - Array.isArray(content_object.bounties) && content_object.bounties.length > 0; + const bounties = Array.isArray(content_object.bounties) + ? content_object.bounties.map((bounty: Record) => + transformBounty(bounty, { ignoreBaseAmount: true }) + ) + : undefined; + + const rawCommentType = content_object.comment_type as string | undefined; + const normalizedCommentType = ( + rawCommentType === 'PEER_REVIEW' ? 'REVIEW' : rawCommentType + ) as CommentType; // Create a FeedCommentContent object const commentContent: FeedCommentContent = { @@ -755,12 +898,12 @@ export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => { updatedDate: content_object.updated_date || action_date || created_date, createdBy: transformAuthorProfile(author || content_object.author), isRemoved: content_object.is_removed, - hasBounties, + ...(bounties?.length ? { bounties } : {}), comment: { id: content_object.id, content: content_object.comment_content_json, contentFormat: (content_object.comment_content_type as ContentFormat) || 'QUILL_EDITOR', - commentType: content_object.comment_type as CommentType, + commentType: normalizedCommentType, score: transformedComment.score || 0, reviewScore: transformedComment.reviewScore || 0, isAssessed: transformedComment.isAssessed ?? false, @@ -961,14 +1104,16 @@ export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => { case 'USDFUNDRAISECONTRIBUTION': contentType = content_type as 'PURCHASE' | 'USDFUNDRAISECONTRIBUTION'; try { + const relatedWorkRaw = feedEntry.related_work; const contributionEntry: FeedPostContent = { - id: content_object.post_id ?? content_object.id ?? id, - unifiedDocumentId: content_object.unified_document_id, + id: content_object.post_id ?? relatedWorkRaw?.id ?? id, + unifiedDocumentId: + content_object.unified_document_id ?? relatedWorkRaw?.unified_document_id, contentType: 'PREREGISTRATION', createdDate: action_date || created_date, textPreview: '', - slug: content_object.proposal_slug || '', - title: stripHtml(content_object.proposal_title || ''), + slug: content_object.proposal_slug || relatedWorkRaw?.slug || '', + title: stripHtml(content_object.proposal_title || relatedWorkRaw?.title || ''), authors: [transformAuthorProfile(author)], topics: content_object.hub ? [ @@ -994,6 +1139,42 @@ export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => { } break; + case 'FUNDINGACTIVITY': + contentType = 'FUNDINGACTIVITY'; + try { + const relatedWorkRaw = feedEntry.related_work; + + const totalAmountRaw = content_object.total_amount; + const totalAmount = + typeof totalAmountRaw === 'string' + ? parseFloat(totalAmountRaw) || 0 + : totalAmountRaw || 0; + + const funder = transformFundingActivityFunder(content_object.funder); + + const fundingActivityContent: FeedFundingActivityContent = { + id: content_object.id ?? id, + unifiedDocumentId: + content_object.unified_document_id ?? relatedWorkRaw?.unified_document_id, + contentType: 'FUNDINGACTIVITY', + createdDate: action_date || created_date, + createdBy: funder ?? transformAuthorProfile(author), + sourceType: content_object.source_type as FundingActivitySourceType, + totalAmount, + totalUsdCents: content_object.total_usd_cents || 0, + totalUsd: + content_object.total_usd ?? + (content_object.total_usd_cents ? content_object.total_usd_cents / 100 : 0), + activityDate: content_object.activity_date, + recipient: transformFundingActivityRecipient(content_object.recipients), + }; + content = fundingActivityContent; + } catch (error) { + console.error('Error transforming FUNDINGACTIVITY:', error); + throw new Error(`Failed to transform FUNDINGACTIVITY: ${error}`); + } + break; + default: // For unsupported types, try to transform to a Work console.warn( @@ -1036,6 +1217,11 @@ export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => { } // Complete the feed entry + const activityRelatedWork = transformActivityRelatedWork(feedEntry.related_work); + if (activityRelatedWork) { + relatedWork = activityRelatedWork; + } + return { ...baseFeedEntry, content, @@ -1106,6 +1292,7 @@ export const transformFeedEntry = (feedEntry: RawApiFeedEntry): FeedEntry => { baseWalletAddress: nonprofit.base_wallet_address, } : undefined, + activityContext: deriveActivityContext(feedEntry), } as FeedEntry; }; diff --git a/types/work.ts b/types/work.ts index 054e373c1..f9dae199c 100644 --- a/types/work.ts +++ b/types/work.ts @@ -133,6 +133,7 @@ export interface Work { aiPeerReview?: ProposalReview | null; enrichments?: Enrichment[]; linkedGrant?: LinkedGrant | null; + grantSummary?: WorkGrantSummary; moderationStatus?: ModerationStatus; isPublic?: boolean; } @@ -151,6 +152,15 @@ export interface LinkedGrant { applicantCount: number; } +/** Slim grant metadata attached to activity feed related_work */ +export interface WorkGrantSummary { + status: string; + organization: string; + amount: { usd: number; rsc: number | null }; + numApplicants: number; + endDate?: string; +} + export interface FundingRequest extends Work { type: 'funding_request'; contentType: 'funding_request'; diff --git a/utils/navigation.ts b/utils/navigation.ts index 74dac91e5..865bcf316 100644 --- a/utils/navigation.ts +++ b/utils/navigation.ts @@ -124,27 +124,15 @@ export function handleMissingSlugRedirect( } /** - * Handles redirection to trending page if user is authorized - * @param isUserLoggedIn Whether the user is logged in - * @param searchParams Optional search parameters to preserve in the redirect + * Redirects into the homepage Activity hub. + * Preserves search parameters when provided. */ -export function handleTrendingRedirect(isUserLoggedIn: boolean, searchParams?: URLSearchParams) { - if (isUserLoggedIn) { - let redirectUrl = '/for-you'; +export function handleTrendingRedirect(_isUserLoggedIn: boolean, searchParams?: URLSearchParams) { + let redirectUrl = '/'; - // Preserve search parameters if provided - if (searchParams && searchParams.toString()) { - redirectUrl += `?${searchParams.toString()}`; - } - - redirect(redirectUrl); - } else { - let popularUrl = '/popular'; - - if (searchParams && searchParams.toString()) { - popularUrl += `?${searchParams.toString()}`; - } - - redirect(popularUrl); + if (searchParams && searchParams.toString()) { + redirectUrl += `?${searchParams.toString()}`; } + + redirect(redirectUrl); } diff --git a/utils/searchHistory.ts b/utils/searchHistory.ts index ac7a79e64..905052948 100644 --- a/utils/searchHistory.ts +++ b/utils/searchHistory.ts @@ -26,3 +26,7 @@ export const saveSearchHistory = (items: SearchSuggestion[]) => { console.error('Error saving to localStorage:', error); } }; + +export const clearSearchHistory = () => { + saveSearchHistory([]); +};