diff --git a/messages/de.json b/messages/de.json index 1d1ba962d..ebdb1c107 100644 --- a/messages/de.json +++ b/messages/de.json @@ -250,7 +250,8 @@ }, "cycle": "Zyklus", "labs": "Laborwerte", - "recovery": "Erholung" + "recovery": "Erholung", + "vorsorge": "Vorsorge" }, "auth": { "login": "Anmelden", @@ -3862,6 +3863,7 @@ "layout": { "title": "Layout & Personalisierung", "description": "Lege fest, wie deine App aufgebaut ist — ein Ort für Dashboard, Insights, Medikamente und Stimmung.", + "backToHub": "Zurück zu Layout & Personalisierung", "dashboard": { "title": "Dashboard", "description": "Kachel-Layout und Reihenfolge." diff --git a/messages/en.json b/messages/en.json index 3a8b08b12..16d4a7f17 100644 --- a/messages/en.json +++ b/messages/en.json @@ -250,7 +250,8 @@ }, "cycle": "Cycle", "labs": "Labs", - "recovery": "Recovery" + "recovery": "Recovery", + "vorsorge": "Preventive Care" }, "auth": { "login": "Sign in", @@ -3862,6 +3863,7 @@ "layout": { "title": "Layout & Personalization", "description": "Arrange how your app is laid out — one home for the dashboard, insights, medications, and mood.", + "backToHub": "Back to Layout & Personalization", "dashboard": { "title": "Dashboard", "description": "Tile layout and order." diff --git a/messages/es.json b/messages/es.json index 210c603ad..895532faf 100644 --- a/messages/es.json +++ b/messages/es.json @@ -250,7 +250,8 @@ }, "cycle": "Ciclo", "labs": "Análisis", - "recovery": "Recuperación" + "recovery": "Recuperación", + "vorsorge": "Prevención" }, "auth": { "login": "Iniciar sesión", @@ -3862,6 +3863,7 @@ "layout": { "title": "Diseño y personalización", "description": "Organiza cómo está dispuesta tu app — un único lugar para el panel, los análisis, los medicamentos y el ánimo.", + "backToHub": "Volver a Diseño y personalización", "dashboard": { "title": "Panel", "description": "Diseño y orden de los mosaicos." diff --git a/messages/fr.json b/messages/fr.json index e73f56d89..af2a0fdde 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -250,7 +250,8 @@ }, "cycle": "Cycle", "labs": "Analyses", - "recovery": "Récupération" + "recovery": "Récupération", + "vorsorge": "Prévention" }, "auth": { "login": "Se connecter", @@ -3862,6 +3863,7 @@ "layout": { "title": "Disposition et personnalisation", "description": "Organisez la disposition de votre application — un seul endroit pour le tableau de bord, les analyses, les médicaments et l’humeur.", + "backToHub": "Retour à Disposition et personnalisation", "dashboard": { "title": "Tableau de bord", "description": "Disposition et ordre des tuiles." diff --git a/messages/it.json b/messages/it.json index 5e20abdb5..dfeb6bf93 100644 --- a/messages/it.json +++ b/messages/it.json @@ -250,7 +250,8 @@ }, "cycle": "Ciclo", "labs": "Esami", - "recovery": "Recupero" + "recovery": "Recupero", + "vorsorge": "Prevenzione" }, "auth": { "login": "Accedi", @@ -3862,6 +3863,7 @@ "layout": { "title": "Layout e personalizzazione", "description": "Organizza la disposizione della tua app — un unico posto per cruscotto, approfondimenti, farmaci e umore.", + "backToHub": "Torna a Layout e personalizzazione", "dashboard": { "title": "Cruscotto", "description": "Layout e ordine dei riquadri." diff --git a/messages/pl.json b/messages/pl.json index f62178eb5..77769413a 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -250,7 +250,8 @@ }, "cycle": "Cykl", "labs": "Wyniki badań", - "recovery": "Regeneracja" + "recovery": "Regeneracja", + "vorsorge": "Profilaktyka" }, "auth": { "login": "Zaloguj się", @@ -3862,6 +3863,7 @@ "layout": { "title": "Układ i personalizacja", "description": "Ustal, jak rozłożona jest Twoja aplikacja — jedno miejsce dla pulpitu, analiz, leków i nastroju.", + "backToHub": "Powrót do Układ i personalizacja", "dashboard": { "title": "Pulpit", "description": "Układ i kolejność kafelków." diff --git a/src/components/layout/__tests__/nav-model.test.ts b/src/components/layout/__tests__/nav-model.test.ts index 86484cb44..eefb35d2c 100644 --- a/src/components/layout/__tests__/nav-model.test.ts +++ b/src/components/layout/__tests__/nav-model.test.ts @@ -10,9 +10,13 @@ import { describe, expect, it } from "vitest"; import { + BOTTOM_NAV_PRIMARY_SLOT_HREFS, NAV_DESTINATIONS, + NAV_UTILITY_DESTINATIONS, isNavDestinationActive, + mobileMoreHubDestinations, visibleNavDestinations, + visibleUtilityDestinations, } from "../nav-model"; describe("nav-model destination list", () => { @@ -47,6 +51,51 @@ describe("nav-model destination list", () => { } }); + it("gives Vorsorge a top-level nav home in the clinical spine (N-3)", () => { + const hrefs = NAV_DESTINATIONS.map((d) => d.href); + expect(hrefs).toContain("/vorsorge"); + const vorsorge = NAV_DESTINATIONS.find((d) => d.href === "/vorsorge"); + expect(vorsorge?.tKey).toBe("nav.vorsorge"); + // Peer to Labs / Recovery: sits in the clinical spine, before Insights. + expect(hrefs.indexOf("/labs")).toBeLessThan(hrefs.indexOf("/vorsorge")); + expect(hrefs.indexOf("/vorsorge")).toBeLessThan(hrefs.indexOf("/insights")); + }); + +}); + +describe("nav-model utility tail (N-1 — one shared list)", () => { + it("every utility destination carries a nav-namespace i18n key", () => { + for (const d of NAV_UTILITY_DESTINATIONS) { + expect(d.tKey.startsWith("nav.")).toBe(true); + } + }); + + it("lists the shared utility surfaces (Bug Report, Settings, Notifications)", () => { + const hrefs = NAV_UTILITY_DESTINATIONS.map((d) => d.href); + expect(hrefs).toContain("/bugreport"); + expect(hrefs).toContain("/settings/account"); + expect(hrefs).toContain("/notifications"); + }); + + it("drops the bug-report entry unless the operator flag is on", () => { + const off = visibleUtilityDestinations(false).map((d) => d.href); + const on = visibleUtilityDestinations(true).map((d) => d.href); + expect(off).not.toContain("/bugreport"); + expect(on).toContain("/bugreport"); + // Non-gated entries are present either way. + expect(off).toContain("/settings/account"); + expect(off).toContain("/notifications"); + }); + + it("keeps a stable order (Bug Report → Settings → Notifications)", () => { + const hrefs = visibleUtilityDestinations(true).map((d) => d.href); + expect(hrefs.indexOf("/bugreport")).toBeLessThan( + hrefs.indexOf("/settings/account"), + ); + expect(hrefs.indexOf("/settings/account")).toBeLessThan( + hrefs.indexOf("/notifications"), + ); + }); }); describe("visibleNavDestinations cycle gate", () => { @@ -60,6 +109,62 @@ describe("visibleNavDestinations cycle gate", () => { }); }); +describe("mobileMoreHubDestinations — the F-1 mobile invariant (N-2)", () => { + it("is exactly the visible feature destinations minus the primary slots, then the utility tail", () => { + const opts = { cycleTrackingEnabled: false, bugReportEnabled: false }; + const hub = mobileMoreHubDestinations(opts).map((d) => d.href); + + const expectedFeatures = visibleNavDestinations(opts.cycleTrackingEnabled) + .map((d) => d.href) + .filter((href) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(href)); + const expectedTail = visibleUtilityDestinations( + opts.bugReportEnabled, + ).map((d) => d.href); + + expect(hub).toEqual([...expectedFeatures, ...expectedTail]); + }); + + it("never contains a primary slot (Home / Meds / Insights)", () => { + const hub = mobileMoreHubDestinations({ + cycleTrackingEnabled: true, + bugReportEnabled: true, + }).map((d) => d.href); + for (const slot of BOTTOM_NAV_PRIMARY_SLOT_HREFS) { + expect(hub).not.toContain(slot); + } + }); + + it("keeps the feature destinations and the utility tail reachable in the hub", () => { + const hub = mobileMoreHubDestinations({ + cycleTrackingEnabled: false, + bugReportEnabled: true, + }).map((d) => d.href); + // Feature surfaces that left the always-visible strip stay reachable. + expect(hub).toContain("/measurements"); + expect(hub).toContain("/mood"); + expect(hub).toContain("/vorsorge"); + // The shared utility tail rides at the end. + expect(hub).toContain("/bugreport"); + expect(hub).toContain("/settings/account"); + expect(hub).toContain("/notifications"); + }); + + it("gates Cycle and Bug Report by the same flags the sidebar uses", () => { + const off = mobileMoreHubDestinations({ + cycleTrackingEnabled: false, + bugReportEnabled: false, + }).map((d) => d.href); + const on = mobileMoreHubDestinations({ + cycleTrackingEnabled: true, + bugReportEnabled: true, + }).map((d) => d.href); + expect(off).not.toContain("/cycle"); + expect(off).not.toContain("/bugreport"); + expect(on).toContain("/cycle"); + expect(on).toContain("/bugreport"); + }); +}); + describe("isNavDestinationActive most-specific resolution", () => { it("matches the dashboard only on an exact path", () => { expect(isNavDestinationActive("/", "/")).toBe(true); diff --git a/src/components/layout/__tests__/sidebar-nav.test.tsx b/src/components/layout/__tests__/sidebar-nav.test.tsx index 82955553a..c60f50bc6 100644 --- a/src/components/layout/__tests__/sidebar-nav.test.tsx +++ b/src/components/layout/__tests__/sidebar-nav.test.tsx @@ -51,6 +51,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { I18nProvider } from "@/lib/i18n/context"; import { SidebarNav } from "../sidebar-nav"; import { ADMIN_SECTIONS } from "@/components/admin/admin-shell"; +import { visibleUtilityDestinations } from "../nav-model"; function render({ pathname = "/", @@ -154,6 +155,37 @@ describe(" unified destination model (v1.17.1 F-1 / F-3)", () => { }); }); +describe(" shared utility tail (v1.17.1 N-1 parity guard)", () => { + // The footer renders the shared utility tail EXCEPT Notifications, which + // lives in the avatar dropdown (a Radix menu that is collapsed — and so + // not in the SSR markup — until opened). The footer-visible utilities are + // therefore the shared list minus `/notifications`. Asserting they all + // render straight from the shared list is the parity net that keeps the + // sidebar footer from drifting away from the mobile More-hub tail. + const footerUtilities = (bugReportEnabled: boolean) => + visibleUtilityDestinations(bugReportEnabled).filter( + (d) => d.href !== "/notifications", + ); + + it("renders every footer utility destination straight from the shared list", () => { + const html = render({ bugReportEnabled: true }); + for (const dest of footerUtilities(true)) { + expect(html).toContain(`href="${dest.href}"`); + } + // Bug Report and Settings are the footer utilities; both are present. + expect(html).toContain('href="/bugreport"'); + expect(html).toContain('href="/settings/account"'); + }); + + it("drops the bug-report utility entry when the operator flag is off", () => { + const html = render({ bugReportEnabled: false }); + for (const dest of footerUtilities(false)) { + expect(html).toContain(`href="${dest.href}"`); + } + expect(html).not.toContain('href="/bugreport"'); + }); +}); + describe(" admin entry mirrors Settings (no sub-item expansion)", () => { // v1.4.16 A1: the maintainer reported the global sidebar expanding admin // sub-items on `/admin/*` was unwanted UX — the in-shell `` diff --git a/src/components/layout/bottom-nav.tsx b/src/components/layout/bottom-nav.tsx index cb0e510a3..45542a3ac 100644 --- a/src/components/layout/bottom-nav.tsx +++ b/src/components/layout/bottom-nav.tsx @@ -1,14 +1,11 @@ "use client"; import { - Bell, - Bug, Home, Lightbulb, MoreHorizontal, Pill, Plus, - Settings, type LucideIcon, } from "lucide-react"; import Link from "next/link"; @@ -17,7 +14,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medications"; import { isNavDestinationActive, - visibleNavDestinations, + mobileMoreHubDestinations, } from "@/components/layout/nav-model"; import { useMemo, useState } from "react"; import { useAppSettings } from "@/components/app-settings-provider"; @@ -50,11 +47,11 @@ interface NavLink { // 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 every destination that -// isn't a primary slot falls into the hub in the model's order — so the -// two surfaces tell one story instead of two hand-curated ones that drift. -const PRIMARY_SLOT_HREFS = ["/", "/medications", "/insights"] as const; - +// 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 }, @@ -64,29 +61,6 @@ const PRIMARY_RIGHT: ReadonlyArray = [ { href: "/insights", tKey: "nav.insights", icon: Lightbulb }, ]; -// Mobile-only hub conveniences that have no main-list home on desktop -// (the sidebar reaches them through its footer + avatar menu). They sit -// at the tail of the hub, after every shared destination. -const NOTIFICATIONS_HUB_ITEM: NavLink = { - href: "/notifications", - tKey: "nav.notifications", - icon: Bell, -}; -const SETTINGS_HUB_ITEM: NavLink = { - href: "/settings/account", - tKey: "nav.settings", - icon: Settings, -}; - -// The bug-report entry, appended under the same `bugReportEnabled` -// operator flag that gates the desktop sidebar entry. Pre-splice the -// route was desktop-only — a phone user had no path to `/bugreport`. -const BUGREPORT_HUB_ITEM: NavLink = { - href: "/bugreport", - tKey: "nav.bugreport", - icon: Bug, -}; - export function BottomNav() { const pathname = usePathname(); const { t } = useTranslations(); @@ -99,19 +73,18 @@ export function BottomNav() { const [moreOpen, setMoreOpen] = useState(false); const [captureOpen, setCaptureOpen] = useState(false); - // v1.17.1 (F-1) — the More hub is every shared destination that isn't an - // always-visible primary slot, in the model's order, plus the mobile-only - // Notifications / Settings conveniences (and Bug Report behind its flag). - // Cycle is filtered by the same account gate the sidebar uses, so the two - // surfaces gate it identically. - const moreHub = useMemo>(() => { - const shared = visibleNavDestinations(user?.cycleTrackingEnabled) - .filter((d) => !PRIMARY_SLOT_HREFS.includes(d.href as never)) - .map((d) => ({ href: d.href, tKey: d.tKey, icon: d.icon })); - const tail: NavLink[] = [NOTIFICATIONS_HUB_ITEM, SETTINGS_HUB_ITEM]; - if (bugReportEnabled) tail.unshift(BUGREPORT_HUB_ITEM); - return [...shared, ...tail]; - }, [user?.cycleTrackingEnabled, bugReportEnabled]); + // 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 + Bug Report are gated by the same flags the sidebar uses, + // so the two surfaces gate identically and cannot drift. + const moreHub = useMemo>( + () => + mobileMoreHubDestinations({ + cycleTrackingEnabled: user?.cycleTrackingEnabled, + bugReportEnabled, + }), + [user?.cycleTrackingEnabled, bugReportEnabled], + ); function isActiveLink(href: string) { return isNavDestinationActive(href, pathname); diff --git a/src/components/layout/nav-model.ts b/src/components/layout/nav-model.ts index 2a3c94fe9..eef6484a0 100644 --- a/src/components/layout/nav-model.ts +++ b/src/components/layout/nav-model.ts @@ -1,5 +1,7 @@ import { Activity, + Bell, + Bug, Droplets, Dumbbell, FlaskConical, @@ -8,6 +10,8 @@ import { Lightbulb, MessagesSquare, Pill, + Settings, + Stethoscope, Trophy, Waves, type LucideIcon, @@ -77,6 +81,18 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ icon: FlaskConical, tourId: "nav-labs", }, + // v1.17.1 — Vorsorge (preventive-care) gets a top-level nav home in the + // clinical spine, peer to Labs and Recovery. It is a first-class tracking + // surface ("wann muss ich was wo machen"), not pure configuration, so it + // belongs in the model both bars render — not buried three taps deep under + // Settings → Reminders. The Reminders hub still links to it; this is the + // direct front door so the customer knows where it lives. + { + href: "/vorsorge", + tKey: "nav.vorsorge", + icon: Stethoscope, + tourId: "nav-vorsorge", + }, { href: "/insights/workouts", tKey: "nav.workouts", @@ -118,6 +134,54 @@ export const NAV_DESTINATIONS: ReadonlyArray = [ }, ]; +/** + * v1.17.1 (F-1 residue) — the shared UTILITY tail. + * + * Bug Report, Settings and Notifications are reachable on both bars but are + * not feature destinations: on desktop they live in the sidebar footer + + * avatar menu, on mobile at the tail of the "More" hub. They used to be a + * second hand-curated list on each bar — the exact drift the one-model + * contract above set out to kill, just pushed down a level. This list is + * the single source both bars consume for the tail, so the two surfaces + * can no longer disagree on which utility links exist or in which order. + * + * Order is the footer/hub order: Bug Report (gated) → Settings → + * Notifications. `bugReportGated` entries drop when the operator flag is + * off, exactly as the feature list drops cycle-gated entries. + * + * Admin is intentionally NOT here: it is a role-gated, desktop-sidebar-only + * surface (the mobile bar never exposes it), so it is not a shared tail + * destination and stays local to the sidebar. + */ +export interface NavUtilityDestination { + href: string; + /** i18n key under the `nav.*` namespace. */ + tKey: string; + icon: LucideIcon; + /** Drop the entry unless the operator's bug-report flag is on. */ + bugReportGated?: boolean; +} + +export const NAV_UTILITY_DESTINATIONS: ReadonlyArray = [ + { href: "/bugreport", tKey: "nav.bugreport", icon: Bug, bugReportGated: true }, + { href: "/settings/account", tKey: "nav.settings", icon: Settings }, + { href: "/notifications", tKey: "nav.notifications", icon: Bell }, +]; + +/** + * The utility tail visible to this account — drops the bug-report entry + * when the operator flag is off. Both bars consume this for their tail + * (the sidebar footer + avatar menu, the bottom-nav More hub) so the two + * surfaces share one definition of the utility links. + */ +export function visibleUtilityDestinations( + bugReportEnabled: boolean | undefined, +): NavUtilityDestination[] { + return NAV_UTILITY_DESTINATIONS.filter( + (d) => !d.bugReportGated || bugReportEnabled === true, + ); +} + /** * The ordered destinations visible to this account — drops cycle-gated * entries when the flag is off. Both bars start from this. @@ -130,6 +194,48 @@ export function visibleNavDestinations( ); } +/** + * The mobile bottom-nav's three always-visible primary slots + * (Home · Meds · Insights). Every other feature destination falls into the + * "More" hub. Kept here (not in the bar) so the headline F-1 invariant — + * the hub is the shared feature list minus the primary slots, plus the + * shared utility tail — is a tested model function, not inline bar logic. + */ +export const BOTTOM_NAV_PRIMARY_SLOT_HREFS: ReadonlyArray = [ + "/", + "/medications", + "/insights", +]; + +export interface MobileMoreHubEntry { + href: string; + tKey: string; + icon: LucideIcon; +} + +/** + * The ordered "More" hub for the mobile bottom-nav: every visible feature + * destination that isn't a primary slot, in model order, followed by the + * shared utility tail (Bug Report behind its flag, Settings, Notifications). + * The desktop sidebar renders the same feature list inline and consumes the + * same utility tail in its footer + avatar menu, so the two bars cannot + * drift into two hand-curated lists. + */ +export function mobileMoreHubDestinations(opts: { + cycleTrackingEnabled: boolean | undefined; + bugReportEnabled: boolean | undefined; +}): MobileMoreHubEntry[] { + const features = visibleNavDestinations(opts.cycleTrackingEnabled) + .filter((d) => !BOTTOM_NAV_PRIMARY_SLOT_HREFS.includes(d.href)) + .map((d) => ({ href: d.href, tKey: d.tKey, icon: d.icon })); + const tail = visibleUtilityDestinations(opts.bugReportEnabled).map((d) => ({ + href: d.href, + tKey: d.tKey, + icon: d.icon, + })); + return [...features, ...tail]; +} + /** * Whether `href` is the active nav destination for the current `pathname`, * resolved against the full destination set so the most-specific entry diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index c3f1d3bc0..18b4756cb 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -3,7 +3,6 @@ import { useMemo, useState } from "react"; import { Bell, - Bug, ChevronsLeft, ChevronsRight, LogOut, @@ -21,6 +20,7 @@ import { medicationsPrefetchIntentProps } from "@/lib/queries/prefetch-medicatio import { isNavDestinationActive, visibleNavDestinations, + visibleUtilityDestinations, } from "@/components/layout/nav-model"; import { cn } from "@/lib/utils"; import { Logo } from "@/components/ui/logo"; @@ -266,6 +266,118 @@ export function SidebarNav() { }); } + // v1.17.1 (F-1 residue) — the sidebar footer utility links derive from + // the SAME shared list the mobile More-hub tail consumes, so the two + // surfaces can no longer drift into two hand-curated utility lists. + // Notifications is surfaced in the avatar menu (not the footer), so the + // footer takes every utility entry except `/notifications`; Admin is the + // role-gated, sidebar-only surface and is inserted separately below. + const footerUtilityItems = useMemo( + () => + visibleUtilityDestinations(bugReportEnabled).filter( + (d) => d.href !== "/notifications", + ), + [bugReportEnabled], + ); + + function isUtilityActive(href: string) { + // Settings matches the whole `/settings/*` shell; the rest match exact. + return href === "/settings/account" + ? pathname.startsWith("/settings") + : pathname === href; + } + + function renderUtilityLink( + item: { href: string; tKey: string; icon: typeof Settings }, + ) { + const Icon = item.icon; + const isActive = isUtilityActive(item.href); + const tourId = item.href === "/settings/account" ? "nav-settings" : undefined; + if (collapsed) { + return ( + + + + + + + + {t(item.tKey)} + + + ); + } + return ( + + + {t(item.tKey)} + + ); + } + + function renderAdminLink() { + if (!isAdmin) return null; + if (collapsed) { + return ( + + + + + + + + {t("nav.admin")} + + + ); + } + return ( + + + {t("nav.admin")} + + ); + } + function renderCollapseToggle(className?: string) { return ( @@ -402,127 +514,17 @@ export function SidebarNav() { - {/* Bottom utility links */} + {/* Bottom utility links — the shared utility tail (minus + Notifications, which lives in the avatar menu) with the + role-gated Admin entry inserted before Settings. */}
- {collapsed ? ( - <> - {bugReportEnabled && ( - - - - - - - - {t("nav.bugreport")} - - - )} - {isAdmin && ( - - - - - - - - {t("nav.admin")} - - - )} - - - - - - - - {t("nav.settings")} - - - - ) : ( - <> - {bugReportEnabled && ( - - - {t("nav.bugreport")} - - )} - {isAdmin && ( - - - {t("nav.admin")} - - )} - - - {t("nav.settings")} - - - )} + {footerUtilityItems + .filter((item) => item.href !== "/settings/account") + .map((item) => renderUtilityLink(item))} + {renderAdminLink()} + {footerUtilityItems + .filter((item) => item.href === "/settings/account") + .map((item) => renderUtilityLink(item))}