Repository Public
1'use client';2import { useState } from 'react';3import Link from 'next/link';4import { usePathname } from 'next/navigation';5import { motion } from 'motion/react';6import { Ellipsis, LogOut } from 'lucide-react';7import { useVv } from '@/hooks/use-viewport';8import { usePendingCount } from '@/lib/queries';9import { BottomSheet } from '@/components/ui/bottom-sheet';10import { haptic } from '@/lib/haptic';11import { cn } from '@/lib/cn';12import { BRAND } from '@/lib/brand';13import { MOBILE_MORE, MOBILE_NAV, TITLES, activeHref } from './nav';14import { NotificationsBell } from './notifications';15import { ConnectionIndicator } from './connection-indicator';16import { ThemeSegmented } from './theme-toggle';17import { signOut } from './header';1819/**20 * Phone layout. The root is position:fixed and sized from window.visualViewport, so its bottom21 * edge is the top of the software keyboard. The floating tab bar hides while the keyboard is open22 * and on the mission screen (which has its own header and a composer at the bottom).23 */24export function MobileShell({ children }: { children: React.ReactNode }) {25 const pathname = usePathname();26 const keyboardOpen = useVv((s) => s.keyboardOpen);27 const pending = usePendingCount();28 const [more, setMore] = useState(false);29 const path = pathname.endsWith('/') ? pathname : `${pathname}/`;30 const current = activeHref(pathname);31 const detail = path === '/mission/'; // brings its own header; no tab bar32 const inMore = MOBILE_MORE.some((m) => m.href === current);33 const items = [...MOBILE_NAV.map((n) => ({ ...n, href: n.href as string | null, active: current === n.href })), { href: null, label: 'More', icon: Ellipsis, active: inMore }];34 return (35 <div className="fixed inset-x-0 flex flex-col overflow-hidden bg-bg text-[15px]" style={{ top: 'var(--vvt, 0px)', height: 'var(--vvh, 100dvh)' }} data-testid="mobile-shell" data-keyboard={keyboardOpen ? 'open' : 'closed'}>36 {!detail && (37 <header className="pt-safe shrink-0 bg-bg">38 <div className="px-safe flex h-14 items-center gap-1 pl-5 pr-2">39 <h1 className="flex-1 truncate text-[22px] font-bold tracking-tight" data-testid="page-title">{TITLES[path] ?? BRAND.product}</h1>40 <ConnectionIndicator compact className="px-2" />41 <NotificationsBell />42 </div>43 </header>44 )}45 <motion.main key={path} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ type: 'spring', stiffness: 380, damping: 34 }} className="relative flex min-h-0 flex-1 flex-col overflow-hidden">46 {children}47 </motion.main>48 {!keyboardOpen && !detail && (49 <nav aria-label="Primary" data-testid="mobile-nav" className="pointer-events-none relative z-30 -mt-7 shrink-0 bg-gradient-to-t from-bg from-55% to-transparent px-4 pb-[max(10px,env(safe-area-inset-bottom))] pt-7">50 <ul className="glass pointer-events-auto mx-auto flex h-[62px] max-w-md items-stretch rounded-full border border-card-line p-1.5 shadow-float">51 {items.map((n) => {52 const badge = n.href === '/approvals/' && pending > 0 ? pending : 0;53 const inner = (54 <>55 {n.active && <motion.span layoutId="nav-active" className="absolute inset-0 rounded-full bg-accent/12" transition={{ type: 'spring', stiffness: 480, damping: 36 }} />}56 <motion.span animate={n.active ? { y: [0, -4, 0], scale: [1, 1.12, 1] } : { y: 0, scale: 1 }} transition={{ duration: 0.36, ease: [0.34, 1.4, 0.64, 1] }} className="relative">57 <n.icon className="size-[22px]" strokeWidth={n.active ? 2.4 : 1.9} aria-hidden />58 {badge > 0 && (59 <span data-testid="pending-badge" className="tnum absolute -right-2.5 -top-1.5 flex h-[17px] min-w-[17px] items-center justify-center rounded-full bg-info-ink px-1 text-[10.5px] font-bold leading-none text-info-fg ring-2 ring-surface">60 {badge > 99 ? '99+' : badge}61 </span>62 )}63 </motion.span>64 <span className="relative text-[10.5px] font-semibold leading-none">{n.label}</span>65 </>66 );67 const cls = cn('relative flex h-full w-full flex-col items-center justify-center gap-1 rounded-full transition-colors', n.active ? 'text-accent' : 'text-muted');68 return (69 <li key={n.label} className="min-w-0 flex-1">70 {n.href ? (71 <Link href={n.href} aria-current={n.active ? 'page' : undefined} aria-label={badge ? `${n.label}, ${badge} pending` : undefined} onClick={() => haptic(6)} className={cls} data-testid={`tab-${n.label.toLowerCase()}`}>72 {inner}73 </Link>74 ) : (75 <button onClick={() => { haptic(6); setMore(true); }} aria-haspopup="dialog" className={cls} data-testid="nav-more">76 {inner}77 </button>78 )}79 </li>80 );81 })}82 </ul>83 </nav>84 )}85 <BottomSheet open={more} onOpenChange={setMore} title="More" testId="more-sheet">86 <ul className="grid grid-cols-3 gap-2 px-4 pt-1">87 {MOBILE_MORE.map((n) => (88 <li key={n.href}>89 <Link href={n.href} onClick={() => setMore(false)} aria-current={current === n.href ? 'page' : undefined} className={cn('pressable flex h-[84px] flex-col items-center justify-center gap-2 rounded-md text-[13px] font-medium', current === n.href ? 'bg-accent/12 text-accent' : 'bg-surface-2 text-text')}>90 <n.icon className={cn('size-6', current === n.href ? 'text-accent' : 'text-text-2')} aria-hidden />91 {n.label}92 </Link>93 </li>94 ))}95 </ul>96 <div className="mt-5 px-4">97 <p className="mb-2 px-1 text-xs font-semibold uppercase tracking-wide text-muted">Appearance</p>98 <ThemeSegmented />99 </div>100 <div className="mt-4 px-4">101 <button onClick={() => void signOut()} className="pressable flex h-12 w-full items-center justify-center gap-2 rounded-full text-sm font-medium text-text-2 hover:bg-surface-2">102 <LogOut className="size-4" aria-hidden /> Sign out103 </button>104 </div>105 </BottomSheet>106 </div>107 );108}109