Files
eranos/src/components/TopNav.tsx
T
Goblin f131198feb
Deploy to GitHub Pages / deploy (push) Has been cancelled
Test / test (push) Has been cancelled
Eranos: Grin-only fundraising (rebrand + Grin payments + gold)
Rebrand Agora to Eranos and strip the non-Grin rails. Add Grin donations:
a GoblinPay client + GrinPayDialog, on-chain payment-proof verification
(receiver-sig + kernel-on-chain + dedupe), and a proof-verified campaign
tally (kind 3414). Shift the brand from orange to gold. 118 tests green.
2026-07-02 08:12:51 -04:00

306 lines
11 KiB
TypeScript

import { useState, type ComponentType } from 'react';
import { Link, NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
Activity,
Bell,
Download,
HandHeart,
Info,
LayoutDashboard,
Menu,
Search,
Settings,
User,
X,
} from 'lucide-react';
import { Capacitor } from '@capacitor/core';
import { nip19 } from 'nostr-tools';
import { LoginArea } from '@/components/auth/LoginArea';
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
import { LogoIcon } from '@/components/icons/LogoIcon';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { useAppContext } from '@/hooks/useAppContext';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFeedSettings } from '@/hooks/useFeedSettings';
import { cn } from '@/lib/utils';
import { ZAPSTORE_URL } from '@/lib/zapstore';
interface NavItem {
/** i18n key under `nav.*` for the label. */
labelKey: string;
to: string;
icon: ComponentType<{ className?: string }>;
/** If true, this link is treated as active only on an exact match. */
exact?: boolean;
}
const NAV_ITEMS: NavItem[] = [
{ labelKey: 'nav.campaigns', to: '/campaigns', icon: HandHeart },
{ labelKey: 'nav.activity', to: '/feed', icon: Activity },
// Groups and Pledges are intentionally hidden from the main nav for
// launch — keep the routes and feature code intact so we can re-add
// them later by uncommenting these two lines (and re-importing the
// `Users` and `Megaphone` icons from `lucide-react` at the top of
// this file). Both pages still work when visited directly and are
// still linked from in-page CTAs and user-authored content.
// { labelKey: 'nav.groups', to: '/groups', icon: Users },
// { labelKey: 'nav.pledge', to: '/pledges', icon: Megaphone },
];
interface MobileLinkItem extends NavItem {
requiresAuth?: boolean;
}
/**
* 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 on the right. Collapses to a hamburger
* menu below the `md` breakpoint.
*/
export function TopNav() {
const { t } = useTranslation();
const { config } = useAppContext();
const { user } = useCurrentUser();
const { orderedItems } = useFeedSettings();
const [mobileOpen, setMobileOpen] = useState(false);
return (
<header className="safe-area-top 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-1 md: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={t('nav.openMenu')}
>
<Menu className="size-5" />
</button>
{/* Brand — bolt + wordmark. The bolt SVG leans ~10° to the left
(the dominant slash runs from top-right (13.4,1) to bottom-left
(9.5,23) within the 24-unit viewBox, ≈ arctan(3.9/22) ≈ 10°).
The wordmark splits the first letter (which mirrors the bolt
silhouette — a steeply leaned capital) from the remaining
letters (a gentler oblique that's easier to read at nav-bar
size). The 0.022em currentColor stroke matches the hero recipe
(Bebas only ships weight 400, so this fattens letterforms
without the fuzz of a synthetic bold). */}
<Link
to="/"
className="flex items-center text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-md px-1"
aria-label={t('nav.brandHome', { appName: config.appName })}
>
<LogoIcon className="size-9" />
<span
className="latin-display font-display font-normal tracking-wide leading-none uppercase text-3xl inline-block -ml-0.5"
style={{
WebkitTextStroke: '0.022em currentColor',
transform: 'skewX(-6deg) scaleX(1.1)',
transformOrigin: '0 100%',
}}
>
{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">
{!user && (
<>
{/* When logged out, surface the language switcher so a visitor
who can't read the current UI language can find and pick
theirs. Logged-in users change language via Settings, so it's
hidden to keep the bar uncluttered. Switches in place. */}
<LanguageSwitcher />
<Link
to="/search"
className="shrink-0 size-9 rounded-full flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-secondary motion-safe:transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
aria-label={t('nav.search')}
title={t('nav.search')}
>
<Search className="size-5" />
</Link>
</>
)}
{/* 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" hideClose 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 text-primary"
>
<LogoIcon className="size-9" />
<span
className="latin-display font-display font-normal tracking-wide leading-none uppercase text-3xl inline-block -ml-0.5"
style={{
WebkitTextStroke: '0.022em currentColor',
transform: 'skewX(-6deg) scaleX(1.1)',
transformOrigin: '0 100%',
}}
>
{config.appName}
</span>
</Link>
<button
onClick={() => setMobileOpen(false)}
className="p-1.5 -mr-1.5 rounded-full hover:bg-secondary"
aria-label={t('nav.closeMenu')}
>
<X className="size-5" />
</button>
</div>
<nav className="flex-1 overflow-y-auto px-3 py-4">
<MobileLinkList items={NAV_ITEMS} onClose={() => setMobileOpen(false)} />
<MobileLinkList
items={getProfileMenuItems({
userPubkey: user?.pubkey,
showDashboard: orderedItems.includes('dashboard'),
})}
onClose={() => setMobileOpen(false)}
/>
{!Capacitor.isNativePlatform() && (
<a
href={ZAPSTORE_URL}
target="_blank"
rel="noopener noreferrer"
onClick={() => setMobileOpen(false)}
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-secondary motion-safe:transition-colors"
>
<Download className="size-4 shrink-0" />
{t('nav.getApp')}
</a>
)}
</nav>
<div className="border-t border-border p-4 space-y-3">
<MobileFooterLinks onClose={() => setMobileOpen(false)} />
</div>
</SheetContent>
</Sheet>
</header>
);
}
function NavLinkButton({ item }: { item: NavItem }) {
const { t } = useTranslation();
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-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary',
)
}
>
{t(item.labelKey)}
</NavLink>
);
}
function MobileFooterLinks({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
const items = [
{ labelKey: 'nav.privacy', to: '/privacy' },
{ labelKey: 'nav.safety', to: '/safety' },
{ labelKey: 'nav.changelog', to: '/changelog' },
];
return (
<nav className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
{items.map((item) => (
<Link
key={item.to}
to={item.to}
onClick={onClose}
className="hover:text-foreground motion-safe:transition-colors"
>
{t(item.labelKey)}
</Link>
))}
</nav>
);
}
function getProfileMenuItems({
userPubkey,
showDashboard,
}: {
userPubkey?: string;
showDashboard: boolean;
}): MobileLinkItem[] {
// Logged-out users still get Settings (appearance, language, network, etc.)
// and About in the menu — the account-specific items are added below.
if (!userPubkey) {
return [
{ labelKey: 'nav.search', to: '/search', icon: Search },
{ labelKey: 'nav.settings', to: '/settings', icon: Settings },
{ labelKey: 'nav.about', to: '/about', icon: Info },
];
}
return [
...(showDashboard ? [{ labelKey: 'nav.dashboard', to: '/dashboard', icon: Activity }] : []),
{ labelKey: 'nav.myDashboard', to: '/my-dashboard', icon: LayoutDashboard },
{ labelKey: 'nav.notifications', to: '/notifications', icon: Bell },
{ labelKey: 'nav.profile', to: `/${nip19.npubEncode(userPubkey)}`, icon: User },
{ labelKey: 'nav.search', to: '/search', icon: Search },
{ labelKey: 'nav.settings', to: '/settings', icon: Settings },
{ labelKey: 'nav.about', to: '/about', icon: Info },
];
}
function MobileLinkList({ items, onClose }: { items: MobileLinkItem[]; onClose: () => void }) {
const { t } = useTranslation();
if (items.length === 0) return null;
return (
<ul className="space-y-1">
{items.map((item) => (
<li key={item.to}>
<NavLink
to={item.to}
end={item.exact}
onClick={onClose}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium motion-safe:transition-colors',
isActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-secondary',
)
}
>
<item.icon className="size-4 shrink-0" />
{t(item.labelKey)}
</NavLink>
</li>
))}
</ul>
);
}