"use client"; import { Home, Lightbulb, MoreHorizontal, Pill, Plus, type LucideIcon, } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; import { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medications"; import { isNavDestinationActive, isDestinationInSharedRecord, mobileMoreHubDestinations, } from "@/components/layout/nav-model"; import type { ModuleKey } from "@/lib/modules/registry"; import { useMemo, useState } from "react"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, } from "@/components/ui/sheet"; import { CapturePicker } from "@/components/layout/capture-picker"; import { cn } from "@/lib/utils"; import { useTranslations } from "@/lib/i18n/context"; interface NavLink { href: string; tKey: string; icon: LucideIcon; } // v1.12.x — iOS-parity bottom bar (additive middle-path). // // The mobile bar is a 5-slot shape: Home · Meds · Log(center) · // Insights · More — mirroring the native client. The center slot is a // capture ACTION (not a destination): it opens a picker that routes to // the existing measurement / medication / mood quick-entry surfaces // (see ``). // // v1.17.1 (F-1) — the two always-visible flanking anchors and the "More" // hub are now BOTH derived from the one shared destination model // (`nav-model.ts`), the same ordered list the desktop sidebar renders. // The bar keeps its ergonomic 5-slot shape, but the hub is computed by the // model's `mobileMoreHubDestinations()` (feature list minus the primary // slots, plus the shared utility tail) — so the two surfaces tell one story // instead of two hand-curated ones that drift, and the headline invariant // is a tested model function rather than inline bar logic. const PRIMARY_LEFT: ReadonlyArray = [ { href: "/", tKey: "nav.dashboard", icon: Home }, { href: "/medications", tKey: "nav.medications", icon: Pill }, ]; // v1.18.0 — the Insights primary slot is module-gated. Insights is now a // toggleable module (`requiresModule: "insights"` in the shared nav model), // so the fixed slot must respect the same per-user map the More hub already // honours rather than pinning a destination the account turned off. When // insights is disabled the slot is dropped (it is excluded from the More hub // by `BOTTOM_NAV_PRIMARY_SLOT_HREFS`, so a disabled module is hidden // everywhere, not relocated). Fail-open: a missing key / unloaded map keeps // the slot, mirroring the gate's default-on contract. const PRIMARY_RIGHT: ReadonlyArray = [ { href: "/insights", tKey: "nav.insights", icon: Lightbulb, requiresModule: "insights", }, ]; export function BottomNav() { const pathname = usePathname(); const { t } = useTranslations(); const { user } = useAuth(); const queryClient = useQueryClient(); // v1.16.7 — touch intent on the medications tab starts the list // request before the navigation commits (see sidebar-nav). const medsIntent = medicationsPrefetchIntentProps(queryClient); const [moreOpen, setMoreOpen] = useState(false); const [captureOpen, setCaptureOpen] = useState(false); // The module map rides the client-only `/api/auth/me` query, so it is not // settled on SSR or the first client paint. Gating the module filter behind // `useMounted()` keeps SSR and first paint identical and stops a disabled // module from flickering into the hub / primary slot before the query // resolves (the #418-class divergence); once mounted the real map applies. const mounted = useMounted(); // v1.36.0 — acting on somebody else's record. Same gate as the sidebar, from // the same resolved payload, so the two bars cannot disagree about what a // delegate is offered. const { inSharedRecord: sharedRecord, sections, canAdd, canManage, } = useRecordCapabilities(); const canCapture = canAdd || canManage; // v1.17.1 (F-1) — the More hub is the model-computed hub: every visible // feature destination that isn't a primary slot, plus the shared utility // tail. Cycle is gated by the same flag the sidebar uses, so the two // surfaces gate identically and cannot drift. const moreHub = useMemo>( () => mobileMoreHubDestinations({ modules: user?.modules, mounted, sharedRecord, sections, }), [user?.modules, mounted, sharedRecord, sections], ); // v1.18.0 — drop a module-gated primary slot (Insights) when the account // has that module disabled. Fail-closed until mounted (so a disabled // Insights never flickers in), then the real map applies: a missing key / // unloaded map keeps the slot, mirroring the gate + More-hub default-on // contract. const primaryRight = useMemo( () => PRIMARY_RIGHT.filter( (item) => // v1.36.0 — the fixed slot asks the shared destination model // whether sharing covers it, rather than carrying its own answer: // Insights is an AI surface and drops out under a switch. (!sharedRecord || isDestinationInSharedRecord(item.href, sections)) && (!item.requiresModule || (mounted && user?.modules?.[item.requiresModule] !== false)), ), [user?.modules, mounted, sharedRecord, sections], ); function isActiveLink(href: string) { return isNavDestinationActive(href, pathname); } // The "More" entry is treated as active when any of its children // is the current page — gives the user a clue that they've drilled // into a sub-route via the hub. const moreActive = moreHub.some((item) => isActiveLink(item.href)); function renderPrimary(item: NavLink) { const isActive = isActiveLink(item.href); return ( {isActive && ( )} ); } return ( <> {/* v1.4.33 F14 — bar height and safe-area defence so the bar can never overlap the last viewport line of scrollable page content. `min-h-16` floors the bar at 64px; `pb-[env(safe-area-inset-bottom)]` reserves the home-indicator inset on iOS / iPadOS PWA installs; `bg-card` + `border-t` give the bar a clean visual edge so page content that scrolls under the bar is occluded, not bleeding through. */} {t("nav.moreSheetTitle")} {t("nav.moreSheetDescription")} {/* v1.22.1 — tighten the grid gutters (`px-3`) so each tile claims a little more width on a 320 px phone; the long single-word labels (de "Benachrichtigungen") then have room to break onto a clean second line instead of clipping the last character at the tile edge. */}
{moreHub.map((item) => { const isActive = isActiveLink(item.href); return ( setMoreOpen(false)} aria-current={isActive ? "page" : undefined} // v1.12 — `min-h-14` matches the capture-picker tiles so // the equivalent tappable rows share one height at this // tier (and the larger row is a more comfortable target). // v1.22.1 — trim the inner padding/gap (`px-3`, `gap-2.5`) // to hand the label more horizontal room without changing // the shared tile height. className={cn( "border-border flex min-h-14 items-center gap-2.5 rounded-lg border px-3 py-3 transition-colors", isActive ? "text-primary bg-primary/5" : "text-foreground hover:bg-accent/40", )} >
); }