"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 { isSurfaceVisible } from "@/lib/modules/surface"; import { useCallback, useMemo, useState } from "react"; import { useAuth } from "@/hooks/use-auth"; import { useNavModules } from "@/hooks/use-nav-modules"; import { useMounted } from "@/hooks/use-mounted"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, } from "@/components/ui/sheet"; import { CAPTURE_KIND_ORDER, CapturePicker, visibleCaptureKinds, } 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; Settings and Notifications live in the top-bar menu) — 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. // The Meds slot follows the medications module through the surface map // (`nav:/medications`): a pinned tab over a module that is off would be a // visible control over a notice. const PRIMARY_LEFT: ReadonlyArray = [ { href: "/", tKey: "nav.dashboard", icon: Home }, { href: "/medications", tKey: "nav.medications", icon: Pill }, ]; // The Insights slot belongs to no module (the `insights` key is AI analysis, // not the area), so it stays whatever the module map says; it still drops // inside a shared record, like any destination sharing does not cover. const PRIMARY_RIGHT: ReadonlyArray = [ { href: "/insights", tKey: "nav.insights", icon: Lightbulb }, ]; 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 capabilities = useRecordCapabilities(); const { inSharedRecord: sharedRecord, sections } = capabilities; // The capture button opens the picker, so it shows exactly when the picker // would offer at least one kind — the same per-section rule, asked once. const canCapture = visibleCaptureKinds(capabilities, CAPTURE_KIND_ORDER, user?.modules) .length > 0; // 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. // The Coach entry follows the `coach` AI capability (a Coach that cannot // answer is not offered); the sidebar reads the same map. const navModules = useNavModules(); const moreHub = useMemo>( () => mobileMoreHubDestinations({ modules: navModules, mounted, sharedRecord, sections, }), [navModules, mounted, sharedRecord, sections], ); // Every slot but Home asks two things: does sharing cover it, and does its // module (if the surface map names one) stay on. Fail-closed until mounted, // so SSR and the first paint agree, then the real map applies: a missing // key / unloaded map keeps the slot, the gate's default-on contract. const primarySlotVisible = useCallback( (item: NavLink) => { // Home is pinned unconditionally — the bar always has an anchor, // whatever the module map or a record switch says. if (item.href === "/") return true; return ( // v1.36.0 — the fixed slot asks the shared destination model // whether sharing covers it, rather than carrying its own answer: // Insights drops out under a switch. (!sharedRecord || isDestinationInSharedRecord(item.href, sections)) && mounted && isSurfaceVisible(`nav:${item.href}`, navModules) ); }, [navModules, mounted, sharedRecord, sections], ); // Both fixed flanks run through the ONE slot filter, so a module-gated // tab (Meds left, Insights right) is dropped — not relocated — when the // account turned its module off, matching the More hub's exclusion. const primaryLeft = useMemo( () => PRIMARY_LEFT.filter(primarySlotVisible), [primarySlotVisible], ); const primaryRight = useMemo( () => PRIMARY_RIGHT.filter(primarySlotVisible), [primarySlotVisible], ); 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. */} {/* The side insets keep the outer tabs clear of the notch on a phone held sideways. `data-keyboard-hide`: while the on-screen keyboard is open the bar steps aside (globals.css) instead of floating over the field being typed into. */} {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", )} >
); }