Replace MainLayout with a top-nav-only FundraiserLayout

The previous overhaul left the campaigns content nested inside the
Twitter-style three-column MainLayout (LeftSidebar + 600-px center
column + WidgetSidebar + mobile FAB + mobile bottom nav). It looked
like a Nostr client that happened to render campaign cards instead of
a fundraising site.

This commit takes the chrome down to studs:

- New FundraiserLayout: a sticky GoFundMe-style TopNav with logo,
  Discover / Start a campaign / About links, the existing LoginArea
  on the right (so the avatar dropdown / Log in & Sign up buttons all
  keep working unchanged), and a primary "Start a campaign" pill.
  Mobile collapses to a hamburger drawer with the same items plus
  quick shortcuts to Wallet / Bitcoin / Notifications / Profile /
  Settings for logged-in users.
- One full-width content area below the nav and a slim site footer.
  No LeftSidebar, no WidgetSidebar, no FAB, no MobileTopBar/BottomNav.
- The old layout still provides LayoutStoreContext / DrawerContext /
  CenterColumnContext / NavHiddenContext so every page that calls
  useLayoutOptions(...) keeps mounting cleanly. FAB / sidebar /
  scroll-direction options are simply ignored.

Routing changes:

- / now renders CampaignsPage directly (instead of dispatching
  through a configurable HomePage). /campaigns redirects to /.
- The orphaned HomePage.tsx is removed.

Campaign pages were calibrated for the old 600-px center column.
Re-flowed them to take advantage of the full canvas:

- Hero copy is recentred under max-w-7xl with GoFundMe-style language
  ("Where successful fundraisers start.").
- Campaign grid grows to four columns on xl screens.
- CampaignDetailPage drops its local sticky sub-header (redundant
  under the global TopNav) and the donation rail re-anchors to the
  new nav height.
- CreateCampaignPage drops its sticky sub-header and reads as a
  proper landing form.

The legacy MainLayout / LeftSidebar / WidgetSidebar / MobileTopBar /
MobileBottomNav / MobileDrawer / FloatingComposeButton components
remain on disk but are no longer mounted; they tree-shake out of the
production bundle.
This commit is contained in:
Alex Gleason
2026-05-17 16:58:58 -05:00
parent 1db8b4d5b0
commit 704cb42e99
7 changed files with 336 additions and 109 deletions
+8 -10
View File
@@ -6,7 +6,7 @@ import { MinimizedAudioBar } from "@/components/MinimizedAudioBar";
import { AudioPlayerProvider } from "@/contexts/AudioPlayerContext";
import { sidebarItemIcon } from "@/lib/sidebarItems";
import { Toaster } from "./components/ui/toaster";
import { MainLayout } from "./components/MainLayout";
import { FundraiserLayout } from "./components/FundraiserLayout";
import { ScrollToTop } from "./components/ScrollToTop";
import { VersionCheck } from "./components/VersionCheck";
import { useCurrentUser } from "./hooks/useCurrentUser";
@@ -24,11 +24,9 @@ const ReplyComposeModal = lazy(() => import("@/components/ReplyComposeModal").th
// Lazy-loaded emoji pack dialog
const EmojiPackDialog = lazy(() => import("@/components/EmojiPackDialog").then(m => ({ default: m.EmojiPackDialog })));
// HomePage eagerly imported all page components; now lazy-loaded
const HomePage = lazy(() => import("./pages/HomePage").then(m => ({ default: m.HomePage })));
// Campaigns: lazy-loaded list + create pages. (Detail page is dispatched from
// NIP19Page when an naddr resolves to kind 30223.)
// Campaigns: home + create. (Campaign detail is dispatched from NIP19Page
// when an naddr resolves to kind 30223.) The campaigns list IS the homepage;
// the configurable HomePage delegation from the Twitter-era app is gone.
const CampaignsPage = lazy(() => import("./pages/CampaignsPage").then(m => ({ default: m.CampaignsPage })));
const CreateCampaignPage = lazy(() => import("./pages/CreateCampaignPage").then(m => ({ default: m.CreateCampaignPage })));
@@ -159,11 +157,11 @@ export function AppRouter() {
{/* Auto-follow deep link: fullscreen immersive (no sidebars/nav) */}
<Route path="/follow/:npub" element={<FollowPage />} />
{/* All routes share the persistent MainLayout (sidebar + nav) */}
<Route element={<MainLayout />}>
<Route path="/" element={<HomePage />} />
{/* All routes share the persistent FundraiserLayout (top nav + footer) */}
<Route element={<FundraiserLayout />}>
<Route path="/" element={<CampaignsPage />} />
<Route path="/feed" element={<Index />} />
<Route path="/campaigns" element={<CampaignsPage />} />
<Route path="/campaigns" element={<Navigate to="/" replace />} />
<Route path="/campaigns/new" element={<CreateCampaignPage />} />
<Route path="/notifications" element={<NotificationsPage />} />
<Route path="/messages" element={<MessagesPage />} />
+105
View File
@@ -0,0 +1,105 @@
import { Suspense, useCallback, useMemo, useRef, useState } from 'react';
import { Outlet } from 'react-router-dom';
import { TopNav } from '@/components/TopNav';
import { Skeleton } from '@/components/ui/skeleton';
import {
CenterColumnContext,
DrawerContext,
LayoutStore,
LayoutStoreContext,
NavHiddenContext,
} from '@/contexts/LayoutContext';
import { cn } from '@/lib/utils';
/**
* Persistent app shell for the fundraising-platform overhaul.
*
* Replaces the previous Twitter-style three-column `MainLayout` with a
* GoFundMe-style top-nav-only chrome. Routes render in a single full-width
* content area below the {@link TopNav}.
*
* Compatibility surface:
* - We still provide `LayoutStoreContext`, so pages that call
* `useLayoutOptions(...)` keep working. Most options (FAB, sidebars,
* mobile arc) are intentionally ignored here because the new shell has
* no FAB and no sidebars. The store still drives the
* `wrapperClassName` escape hatch for pages that need to widen.
* - `CenterColumnContext` exposes the content `<div>` so legacy components
* (e.g. nsite preview overlay) can still portal into it.
* - `DrawerContext` and `NavHiddenContext` are kept as no-op providers so
* pages that read them don't crash.
*/
function PageSkeleton() {
return (
<div className="mx-auto w-full max-w-6xl px-4 sm:px-6 py-8 space-y-4">
<Skeleton className="h-8 w-1/3" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
<Skeleton className="h-72 w-full rounded-xl" />
</div>
);
}
function FundraiserLayoutInner() {
const centerColumnRef = useRef<HTMLDivElement>(null);
const [centerColumnEl, setCenterColumnEl] = useState<HTMLElement | null>(null);
// Mobile drawer is owned by TopNav now, so consumers of `useOpenDrawer`
// become no-ops. Keeping the context shape avoids touching every page that
// pulls the hook.
const openDrawer = useCallback(() => {}, []);
return (
<CenterColumnContext.Provider value={centerColumnEl}>
<DrawerContext.Provider value={openDrawer}>
<NavHiddenContext.Provider value={false}>
<div className="min-h-dvh flex flex-col bg-background">
<TopNav />
<Suspense fallback={<PageSkeleton />}>
<div
ref={(el) => {
centerColumnRef.current = el;
setCenterColumnEl(el);
}}
className={cn('flex-1 min-w-0 w-full')}
>
<Outlet />
</div>
</Suspense>
<SiteFooter />
</div>
</NavHiddenContext.Provider>
</DrawerContext.Provider>
</CenterColumnContext.Provider>
);
}
function SiteFooter() {
return (
<footer className="border-t border-border bg-background mt-auto">
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-8 flex flex-col sm:flex-row items-center justify-between gap-4 text-xs text-muted-foreground">
<span>&copy; {new Date().getFullYear()} Agora. Fundraisers on Nostr.</span>
<nav className="flex items-center gap-5">
<a href="/help" className="hover:text-foreground motion-safe:transition-colors">Help</a>
<a href="/privacy" className="hover:text-foreground motion-safe:transition-colors">Privacy</a>
<a href="/safety" className="hover:text-foreground motion-safe:transition-colors">Safety</a>
</nav>
</div>
</footer>
);
}
export function FundraiserLayout() {
const store = useMemo(() => new LayoutStore(), []);
return (
<LayoutStoreContext.Provider value={store}>
<FundraiserLayoutInner />
</LayoutStoreContext.Provider>
);
}
export default FundraiserLayout;
+196
View File
@@ -0,0 +1,196 @@
import { useState } from 'react';
import { Link, NavLink } from 'react-router-dom';
import { Menu, PlusCircle, X } from 'lucide-react';
import { LoginArea } from '@/components/auth/LoginArea';
import { Button } from '@/components/ui/button';
import { LogoIcon } from '@/components/icons/LogoIcon';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { cn } from '@/lib/utils';
interface NavItem {
label: string;
to: string;
/** If true, this link is treated as active only on an exact match. */
exact?: boolean;
}
const NAV_ITEMS: NavItem[] = [
{ label: 'Discover', to: '/', exact: true },
{ label: 'Start a campaign', to: '/campaigns/new' },
{ label: 'About', to: '/help' },
];
/**
* Persistent top navigation bar rendered by {@link FundraiserLayout}. Mirrors
* the GoFundMe-style chrome: brand mark on the left, primary nav links in the
* middle, "Sign in" / account avatar plus a "Start a campaign" pill on the
* right. Collapses to a hamburger menu below the `md` breakpoint.
*/
export function TopNav() {
const { config } = useAppContext();
const { user } = useCurrentUser();
const [mobileOpen, setMobileOpen] = useState(false);
return (
<header className="sticky top-0 z-40 w-full border-b border-border bg-background/85 backdrop-blur supports-[backdrop-filter]:bg-background/70">
<div className="mx-auto flex h-16 max-w-7xl items-center gap-4 px-4 sm:px-6">
{/* Mobile menu trigger */}
<button
type="button"
onClick={() => setMobileOpen(true)}
className="md:hidden -ml-2 p-2 rounded-full hover:bg-secondary motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label="Open menu"
>
<Menu className="size-5" />
</button>
{/* Brand */}
<Link
to="/"
className="flex items-center gap-2 font-bold text-lg tracking-tight text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-md px-1"
aria-label={`${config.appName} home`}
>
<LogoIcon className="size-6" />
<span>{config.appName}</span>
</Link>
{/* Desktop nav */}
<nav className="hidden md:flex items-center gap-1 ml-6">
{NAV_ITEMS.map((item) => (
<NavLinkButton key={item.to} item={item} />
))}
</nav>
<div className="flex-1" />
{/* Right cluster */}
<div className="flex items-center gap-2 sm:gap-3">
{/* Primary CTA pill — hidden on small screens to keep the bar uncluttered;
the same action lives at the top of the mobile menu and as a FAB-style
button in the homepage hero. */}
<Button asChild size="sm" className="hidden sm:inline-flex rounded-full">
<Link to="/campaigns/new">
<PlusCircle className="size-4 mr-1.5" />
Start a campaign
</Link>
</Button>
{/* LoginArea handles both logged-in (account avatar dropdown) and
logged-out (Log in / Sign up) states. We render it inline-flex
and let it style its own children. */}
<LoginArea className={cn(user ? 'shrink-0' : 'max-w-[260px]')} />
</div>
</div>
{/* Mobile drawer */}
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetContent side="left" className="w-72 p-0 flex flex-col gap-0">
<div className="flex items-center justify-between px-5 py-4 border-b border-border">
<Link
to="/"
onClick={() => setMobileOpen(false)}
className="flex items-center gap-2 font-bold text-lg text-primary"
>
<LogoIcon className="size-6" />
<span>{config.appName}</span>
</Link>
<button
onClick={() => setMobileOpen(false)}
className="p-1.5 -mr-1.5 rounded-full hover:bg-secondary"
aria-label="Close menu"
>
<X className="size-5" />
</button>
</div>
<nav className="flex-1 overflow-y-auto px-3 py-4">
<ul className="space-y-1">
{NAV_ITEMS.map((item) => (
<li key={item.to}>
<NavLink
to={item.to}
end={item.exact}
onClick={() => setMobileOpen(false)}
className={({ isActive }) =>
cn(
'flex items-center px-3 py-2.5 rounded-lg text-sm font-medium motion-safe:transition-colors',
isActive
? 'bg-primary/10 text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary',
)
}
>
{item.label}
</NavLink>
</li>
))}
</ul>
</nav>
<div className="border-t border-border p-4 space-y-3">
<Button asChild className="w-full rounded-full" onClick={() => setMobileOpen(false)}>
<Link to="/campaigns/new">
<PlusCircle className="size-4 mr-1.5" />
Start a campaign
</Link>
</Button>
<SecondaryMobileLinks onClose={() => setMobileOpen(false)} />
</div>
</SheetContent>
</Sheet>
</header>
);
}
function NavLinkButton({ item }: { item: NavItem }) {
return (
<NavLink
to={item.to}
end={item.exact}
className={({ isActive }) =>
cn(
'px-3 py-2 rounded-md text-sm font-medium motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
isActive
? 'text-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary',
)
}
>
{item.label}
</NavLink>
);
}
/**
* Secondary links inside the mobile drawer for the logged-in user — quick
* shortcuts to the parts of the app that live outside the fundraising flow
* but should still be reachable.
*/
function SecondaryMobileLinks({ onClose }: { onClose: () => void }) {
const { user } = useCurrentUser();
if (!user) return null;
const items: { label: string; to: string }[] = [
{ label: 'Wallet', to: '/wallet' },
{ label: 'Bitcoin', to: '/bitcoin' },
{ label: 'Notifications', to: '/notifications' },
{ label: 'Profile', to: '/profile' },
{ label: 'Settings', to: '/settings' },
];
return (
<ul className="space-y-1">
{items.map((item) => (
<li key={item.to}>
<Link
to={item.to}
onClick={onClose}
className="block px-3 py-2 rounded-lg text-sm text-muted-foreground hover:text-foreground hover:bg-secondary motion-safe:transition-colors"
>
{item.label}
</Link>
</li>
))}
</ul>
);
}
+7 -15
View File
@@ -119,25 +119,22 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
return (
<main className="min-h-screen pb-16">
{/* Sticky-ish top bar with back button */}
<div className="sticky top-0 z-20 bg-background/85 backdrop-blur border-b border-border">
<div className="max-w-5xl mx-auto px-4 py-3 flex items-center gap-4">
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-6 lg:py-10">
{/* Inline back arrow — the global TopNav handles primary navigation. */}
<div className="flex items-center gap-2 mb-4 -ml-2">
<button
onClick={() => navigate(-1)}
className="p-2 -ml-2 rounded-full hover:bg-secondary motion-safe:transition-colors"
className="p-2 rounded-full hover:bg-secondary motion-safe:transition-colors text-muted-foreground hover:text-foreground"
aria-label="Go back"
>
<ChevronLeft className="size-5" />
</button>
<h1 className="text-base font-semibold truncate flex-1 min-w-0">{campaign.title}</h1>
<Button variant="ghost" size="sm" onClick={handleShare} className="shrink-0">
<Button variant="ghost" size="sm" onClick={handleShare} className="ml-auto">
<Share2 className="size-4 sm:mr-1.5" />
<span className="hidden sm:inline">Share</span>
</Button>
</div>
</div>
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-6 lg:py-10">
{/* Hero */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 lg:gap-10">
<div className="lg:col-span-2 space-y-6">
@@ -239,7 +236,7 @@ function CampaignDetailContent({ campaign }: { campaign: ParsedCampaign }) {
</div>
{/* Donation rail */}
<aside className="lg:col-span-1 lg:sticky lg:top-20 self-start space-y-4">
<aside className="lg:col-span-1 lg:sticky lg:top-[5rem] self-start space-y-4">
<Card>
<CardContent className="p-5 space-y-4">
{statsLoading ? (
@@ -357,13 +354,8 @@ function RecipientRow({ pubkey, weight }: { pubkey: string; weight: number }) {
function CampaignDetailSkeleton() {
return (
<main className="min-h-screen pb-16">
<div className="sticky top-0 z-20 bg-background/85 backdrop-blur border-b border-border">
<div className="max-w-5xl mx-auto px-4 py-3 flex items-center gap-4">
<Skeleton className="size-9 rounded-full" />
<Skeleton className="h-5 w-48 flex-1" />
</div>
</div>
<div className="max-w-5xl mx-auto px-4 sm:px-6 py-6 lg:py-10">
<Skeleton className="h-9 w-9 rounded-full mb-4" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 lg:gap-10">
<div className="lg:col-span-2 space-y-6">
<Skeleton className="aspect-[16/9] w-full rounded-xl" />
+9 -9
View File
@@ -112,28 +112,28 @@ export function CampaignsPage() {
<main className="min-h-screen pb-16">
{/* Hero */}
<section className="relative overflow-hidden border-b border-border bg-gradient-to-br from-primary/15 via-background to-secondary/40">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-12 lg:py-16">
<div className="max-w-2xl space-y-5">
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-14 lg:py-20">
<div className="max-w-3xl space-y-5">
<div className="inline-flex items-center gap-2 rounded-full bg-background/70 backdrop-blur px-3 py-1 border border-border text-xs font-medium">
<Sparkles className="size-3.5 text-primary" />
On-chain fundraising on Nostr
</div>
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight leading-tight">
Fund the people and projects that matter to you.
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold tracking-tight leading-[1.1]">
Where successful fundraisers start.
</h1>
<p className="text-base sm:text-lg text-muted-foreground">
<p className="text-base sm:text-lg text-muted-foreground max-w-2xl">
Every donation is a single Bitcoin transaction, split directly to each campaign's
beneficiaries. No middlemen, no chargebacks, no held funds.
</p>
<div className="flex flex-wrap gap-3 pt-2">
<Button size="lg" asChild>
<Button size="lg" asChild className="rounded-full">
<Link to="/campaigns/new">
<PlusCircle className="size-4 mr-2" />
Start a campaign
</Link>
</Button>
{!user && (
<Button variant="outline" size="lg" asChild>
<Button variant="outline" size="lg" asChild className="rounded-full">
<a href="#campaigns">Explore campaigns</a>
</Button>
)}
@@ -142,7 +142,7 @@ export function CampaignsPage() {
</div>
</section>
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-10 lg:py-14 space-y-12" id="campaigns">
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-10 lg:py-14 space-y-12" id="campaigns">
{/* Featured */}
<section className="space-y-5">
<div className="flex items-end justify-between gap-4">
@@ -179,7 +179,7 @@ export function CampaignsPage() {
) : userCampaigns.length === 0 ? (
<EmptyState />
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-5">
{userCampaigns.map((campaign) => (
<CampaignCard key={campaign.aTag} campaign={campaign} />
))}
+11 -13
View File
@@ -277,19 +277,6 @@ export function CreateCampaignPage() {
return (
<main className="min-h-screen pb-16">
<div className="sticky top-0 z-20 bg-background/85 backdrop-blur border-b border-border">
<div className="max-w-3xl mx-auto px-4 py-3 flex items-center gap-4">
<button
onClick={() => navigate(-1)}
className="p-2 -ml-2 rounded-full hover:bg-secondary motion-safe:transition-colors"
aria-label="Go back"
>
<ArrowLeft className="size-5" />
</button>
<h1 className="text-base font-semibold flex-1 min-w-0">Start a campaign</h1>
</div>
</div>
<form
className="max-w-3xl mx-auto px-4 sm:px-6 py-8 lg:py-10 space-y-8"
onSubmit={(e) => {
@@ -298,6 +285,17 @@ export function CreateCampaignPage() {
submitMutation.mutate();
}}
>
<div className="flex items-center gap-2 -ml-2">
<button
type="button"
onClick={() => navigate(-1)}
className="p-2 rounded-full hover:bg-secondary motion-safe:transition-colors text-muted-foreground hover:text-foreground"
aria-label="Go back"
>
<ArrowLeft className="size-5" />
</button>
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight">Start a campaign</h1>
</div>
{/* Organizer banner */}
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<Avatar className="size-8">
-62
View File
@@ -1,62 +0,0 @@
import { lazy } from 'react';
import { useAppContext } from '@/hooks/useAppContext';
import { getExtraKindDef } from '@/lib/extraKinds';
import { sidebarItemIcon } from '@/lib/sidebarItems';
import Index from './Index';
// All other pages are lazy-loaded so they don't bloat the index chunk.
// HomePage renders exactly ONE page at a time, so only that page's chunk is loaded.
const PAGE_LOADERS: Record<string, React.LazyExoticComponent<React.ComponentType>> = {
'campaigns': lazy(() => import('./CampaignsPage').then(m => ({ default: m.CampaignsPage }))),
'notifications': lazy(() => import('./NotificationsPage').then(m => ({ default: m.NotificationsPage }))),
'search': lazy(() => import('./SearchPage').then(m => ({ default: m.SearchPage }))),
'bookmarks': lazy(() => import('./BookmarksPage').then(m => ({ default: m.BookmarksPage }))),
'changelog': lazy(() => import('./ChangelogPage').then(m => ({ default: m.ChangelogPage }))),
'settings': lazy(() => import('./SettingsPage').then(m => ({ default: m.SettingsPage }))),
'badges': lazy(() => import('./BadgesPage').then(m => ({ default: m.BadgesPage }))),
'events': lazy(() => import('./EventsFeedPage').then(m => ({ default: m.EventsFeedPage }))),
'photos': lazy(() => import('./PhotosFeedPage').then(m => ({ default: m.PhotosFeedPage }))),
'world': lazy(() => import('./WorldPage').then(m => ({ default: m.WorldPage }))),
'lists': lazy(() => import('./UserListsPage').then(m => ({ default: m.UserListsPage }))),
'letters': lazy(() => import('./LettersPage').then(m => ({ default: m.LettersPage }))),
'help': lazy(() => import('./HelpPage').then(m => ({ default: m.HelpPage }))),
};
/** Sidebar items that use KindFeedPage and need extra kind definitions. */
const KIND_FEED_ITEMS = ['polls', 'articles'] as const;
// KindFeedPage is lazy too
const LazyKindFeedPage = lazy(() => import('./KindFeedPage').then(m => ({ default: m.KindFeedPage })));
function KindFeedWrapper({ itemId }: { itemId: string }) {
const def = getExtraKindDef(itemId);
if (!def) return <Index />;
return <LazyKindFeedPage kind={def.kind} title={def.label} icon={sidebarItemIcon(itemId, 'size-5')} />;
}
/**
* Renders the page component configured as the homepage.
* Falls back to the Feed if the configured homePage is invalid.
*
* This component is rendered inside MainLayout's Suspense boundary,
* so lazy components will show the page skeleton while loading.
*/
export function HomePage() {
const { config } = useAppContext();
const homePage = config.homePage;
// Check if it's a kind feed item
if ((KIND_FEED_ITEMS as readonly string[]).includes(homePage)) {
return <KindFeedWrapper itemId={homePage} />;
}
// Check the lazy component map
const PageComponent = PAGE_LOADERS[homePage];
if (PageComponent) {
return <PageComponent />;
}
// Default fallback: Feed (eagerly loaded)
return <Index />;
}