teecup/frontend/components/teecup/bottom-nav.tsx
Erol Haagenrud 2562e20ec3 13-årsgrense — håndhevet server-side (eneste vei inn i appen, ADR-051) og med umiddelbar rød feilmelding klientside i begge profil-skjemaer. Testet på nøyaktig dagsgrensen.
PWA-installasjonstekst — skiller nå Safari/Chrome på iOS, ikke lenger feilaktig Safari-only.
Beskrivelsestekst — "golfklubber" fjernet, nytt punkt om at turneringsadmin/-presentasjon fungerer godt på PC.
Dette krever både teecup_api og teecup_frontend (ingen migrasjon).
2026-08-10 07:00:11 +02:00

144 lines
6.3 KiB
TypeScript

"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { Flag, Home, MoreHorizontal, Trophy, UserRound } from "lucide-react"
import type { LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"
type Tab = {
label: string
icon: LucideIcon
href: string
/** Optional count shown as a small badge (e.g. unread items). */
badge?: number
/** Ekstra ruter som også skal telle som "denne fanen er aktiv" --
* for sider som ikke selv er et direkte fanemål, men som appens egen
* IA (se more-menu.tsx) allerede grupperer under denne fanen. */
matchPaths?: string[]
}
// hrefs rettet til de faktiske frontend-rutene (V0 kjente ikke disse) --
// /rounds er reservert API-prefiks (samme kollisjonsklasse som
// /rounds->/my-rounds tidligere i prosjektet), og det finnes ingen egen
// "/tournaments"-liste-side, kun /tournaments/[id]. "Turneringer" peker
// derfor til dashbordets egen "Kommende turneringer"-seksjon.
const TABS: Tab[] = [
{ label: "Hjem", icon: Home, href: "/dashboard" },
{ label: "Runder", icon: Flag, href: "/my-rounds" },
{ label: "Turneringer", icon: Trophy, href: "/dashboard#kommende-turneringer" },
{ label: "Profil", icon: UserRound, href: "/account" },
// /my-friends, /my-feed og /my-notifications er ikke egne fanemål, men er
// allerede listet som "Mer"-sidens egne undersider (more-menu.tsx) -- uten
// matchPaths viste ingen fane som aktiv i det hele tatt på disse tre
// sidene (reelt funnet under scratch-verifisering, se CHANGELOG.md).
{ label: "Mer", icon: MoreHorizontal, href: "/more", matchPaths: ["/my-friends", "/my-feed", "/my-notifications"] },
]
function isActive(pathname: string, href: string) {
// IKKE strippe et evt. #hash før sammenligning (fristende, men galt --
// forsøkt og reelt browser-verifisert som en regresjon, se CHANGELOG.md):
// "Turneringer" sin href er et anker PÅ dashbordet (/dashboard#kommende-
// turneringer), og usePathname() inneholder ALDRI hash. Strippes hashen
// før sammenligning, matcher BÅDE "Hjem" og "Turneringer" samtidig på
// /dashboard -- to faner fremhevet på én gang. Rå strengsammenligning
// (V0s opprinnelige, ikke rørt her) unngår dette: "Turneringer" matcher
// aldri via ren pathname, nøyaktig som dagens BottomTabBar-erstatning
// uansett aldri fremhevet den (dashbordet satte alltid active="home").
if (href === "/") return pathname === "/"
return pathname === href || pathname.startsWith(`${href}/`)
}
export function BottomNav({
tabs = TABS,
disabledHrefs,
onDisabledClick,
}: {
tabs?: Tab[]
/**
* Fanenes hrefs som skal vises låst (ADR-049, /velkommen — innlogget-
* men-ikke-fullført-profil skal se HELE den vanlige navigasjonen, ikke
* en avkortet versjon, men kun kunne gå til profilen før resten låses
* opp). Rendres som en `<button>` i stedet for `<Link>`, aldri
* `aria-current`, og navigerer aldri.
*/
disabledHrefs?: string[]
onDisabledClick?: (tab: Tab) => void
}) {
const pathname = usePathname() ?? ""
return (
// The bar is deliberately self-contained: it renders on six screens that
// belong to two different visual languages (the new "clubhouse" look and
// the older one). Rather than inherit any page palette, it reads as its
// own floating white surface with a hairline + soft lift, so it looks
// intentional on any background. The only brand token it leans on is the
// green accent, which is the one constant shared across both palettes.
<nav
aria-label="Hovednavigasjon"
className="fixed inset-x-0 bottom-0 z-40 border-t border-black/10 bg-white/90 shadow-[0_-1px_12px_rgba(23,32,15,0.06)] backdrop-blur-md supports-[backdrop-filter]:bg-white/80"
>
<ul className="mx-auto flex max-w-xl items-stretch justify-around gap-1 px-2 pb-[env(safe-area-inset-bottom)]">
{tabs.map((tab) => {
const Icon = tab.icon
const disabled = disabledHrefs?.includes(tab.href) ?? false
const active =
!disabled &&
(isActive(pathname, tab.href) || (tab.matchPaths?.some((p) => isActive(pathname, p)) ?? false))
const itemClassName = cn(
"group relative flex min-h-[56px] flex-col items-center justify-center gap-1 rounded-xl px-1 py-2 text-[11px] font-semibold leading-none transition-colors",
"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-tee-strong",
disabled
? "text-neutral-400"
: active
? "text-tee-strong"
: "text-neutral-500 hover:text-neutral-900",
)
const iconWrapClassName = cn(
"relative flex h-8 w-12 items-center justify-center rounded-full transition-colors",
active ? "bg-tee/20" : "bg-transparent group-hover:bg-black/[0.04]",
)
const inner = (
<>
<span className={iconWrapClassName}>
<Icon className="h-[22px] w-[22px]" strokeWidth={active ? 2.6 : 2} aria-hidden="true" />
{typeof tab.badge === "number" && tab.badge > 0 && (
<span
className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-cup px-1 text-[10px] font-bold text-white ring-2 ring-white"
aria-hidden="true"
>
{tab.badge > 9 ? "9+" : tab.badge}
</span>
)}
</span>
<span>
{tab.label}
{typeof tab.badge === "number" && tab.badge > 0 && (
<span className="sr-only">{`, ${tab.badge} nye`}</span>
)}
</span>
</>
)
return (
<li key={tab.href} className="flex-1">
{disabled ? (
<button
type="button"
aria-disabled="true"
onClick={() => onDisabledClick?.(tab)}
className={itemClassName}
>
{inner}
</button>
) : (
<Link href={tab.href} aria-current={active ? "page" : undefined} className={itemClassName}>
{inner}
</Link>
)}
</li>
)
})}
</ul>
</nav>
)
}