Scan the QR printed on a delivery note, see what it is, and act on it: reprint, photograph the signed copy, or walk it toward invoicing. It is its own route group outside `(app)` so it gets a clean full-bleed layout and its own manifest — "add to home screen" should give the operator a scanner, not the CRM. Mirrors the pattern `(counter)` already uses. Two detectors, not one. `BarcodeDetector` is fast and allocation-free but is Chrome-only: Safari/iOS still has it "Under Consideration" (WebKit bug #254573) and Firefox does not ship it. A scanner that only used it would simply not work on an iPhone. So native where it exists, jsQR everywhere else — pure JS, so no 2 MB WASM payload on a weak warehouse signal, and the delivery-note QR is always a QR code. Both run against the same canvas frame so the caller never branches. Also requires HTTPS, which the page says plainly when it isn't. The QR carries the human number, so the first call is a new resolve endpoint mapping number -> PB id. Everything after that speaks ids, which is what every existing route already takes. The number is shape-validated before any network call, so a supplier's packing label can't fire a lookup. STATUS LADDER. mark-invoiced hard-refuses anything that isn't po_received, and customer-po refuses a note that isn't sent/loaded — so rather than offering a button that 500s, each action only appears when the server would accept it, and the card shows which step comes next. It routes draft -> loaded -> po_received -> invoiced via the existing load-dn, NOT draft -> sent, because draft -> sent means "create a quote and email it" and a warehouse scan must never trigger that silently. The signed-copy upload deliberately uses the upload-only endpoint, not /api/v1/deliveries/mark-signed-copy — that one flips the note to `completed` in the same write, which would jump a note straight past invoicing. Manual entry is always available, so the page works on a device with no camera or a browser that won't scan. Tests decode the REAL delivery-note QR through the production fallback path at print size, and assert the shape check rejects a supplier QR, a random string and an over-long number.
555 lines
20 KiB
TypeScript
555 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { usePathname } from "next/navigation";
|
|
import { useAuth } from "./auth-provider";
|
|
import { useTheme } from "next-themes";
|
|
import type { LucideIcon } from "lucide-react";
|
|
import {
|
|
Archive,
|
|
BarChart3,
|
|
BookOpen,
|
|
Bot,
|
|
Boxes,
|
|
Building2,
|
|
CheckCircle,
|
|
ChevronDown,
|
|
ClipboardList,
|
|
FileText,
|
|
LayoutDashboard,
|
|
LogOut,
|
|
MapPin,
|
|
Menu,
|
|
Moon,
|
|
Package,
|
|
Plus,
|
|
QrCode,
|
|
Settings as SettingsIcon,
|
|
ShoppingCart,
|
|
Sun,
|
|
Tag,
|
|
Truck,
|
|
Users,
|
|
Wrench,
|
|
Zap,
|
|
} from "lucide-react";;
|
|
import { Button } from "@/components/ui/button";
|
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
|
import { BrandStripe } from "@/components/brand-stripe";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "@/components/ui/sheet";
|
|
import { useEffect, useState } from "react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
/* ============================================================================
|
|
* Sidebar nav structure
|
|
*
|
|
* Flat sections — every nav group renders the same. The active state is
|
|
* the theme's single accent. No per-category brand colours so the eye
|
|
* scans label + icon, not colour.
|
|
* ========================================================================== */
|
|
|
|
type NavEntry = {
|
|
href: string;
|
|
label: string;
|
|
icon: LucideIcon;
|
|
};
|
|
|
|
const dashboardItems: NavEntry[] = [
|
|
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
|
];
|
|
|
|
const managementItems: NavEntry[] = [
|
|
{ href: "/companies", label: "Companies", icon: Building2 },
|
|
{ href: "/customers", label: "Customers", icon: Users },
|
|
// Office pin tool + (future) visit log. The page itself is a
|
|
// launcher right now — the full visit-history view is blocked on
|
|
// PB schema + FleetAI credentials per the rep-visit-check-in
|
|
// design doc.
|
|
{ href: "/visits", label: "Visits", icon: MapPin },
|
|
{ href: "/suppliers", label: "Suppliers", icon: Truck },
|
|
];
|
|
|
|
const quoteItems: NavEntry[] = [
|
|
{ href: "/quotes", label: "Quote List", icon: FileText },
|
|
// ── TEMPORARILY HIDDEN ──────────────────────────────────────────────
|
|
// Quote sprint board — disabled pending a rebuild. Operators should
|
|
// process quotes through /quotes/[id]/process until this is
|
|
// re-enabled. Revert by uncommenting the line below.
|
|
// { href: "/sprint", label: "Sprint", icon: Zap },
|
|
{ href: "/quotes/approvals", label: "Pending Approvals", icon: CheckCircle },
|
|
];
|
|
|
|
// Assign Drivers used to live here as its own sidebar entry, but the
|
|
// driver-assignment workflow is now fully embedded in the Collection
|
|
// Pool page (per-row Assign + per-bucket bulk Assign). Keeping a
|
|
// redundant top-level link only invited confusion, so it's gone.
|
|
// The full /orders/assign route is still reachable by URL for the
|
|
// older admin flow but is no longer surfaced in the nav.
|
|
const orderItems: NavEntry[] = [
|
|
{ href: "/orders/new", label: "New Order", icon: Plus },
|
|
{ href: "/orders", label: "Order List", icon: ShoppingCart },
|
|
// ── TEMPORARILY HIDDEN ──────────────────────────────────────────────
|
|
// Order sprint board — disabled pending a rebuild. Operators should
|
|
// work orders through /orders/[id] until this is re-enabled.
|
|
// Revert by uncommenting the line below.
|
|
// { href: "/orders/sprint", label: "Order Sprint", icon: Truck },
|
|
{ href: "/orders/tasks", label: "Buyer Tasks", icon: ClipboardList },
|
|
];
|
|
|
|
// Office-side view of the driver / collection workflow. The driver app
|
|
// does the actual pickups — these pages are how the office sees and
|
|
// manages that activity. Split out of Operations so the menu reflects
|
|
// the actual lifecycle: orders originate in Orders, become physical
|
|
// work here in Logistics, then close out in Operations (invoicing).
|
|
const logisticsItems: NavEntry[] = [
|
|
{ href: "/collection-pool", label: "Collection Pool", icon: Package },
|
|
{ href: "/collection-map", label: "Collection Map", icon: MapPin },
|
|
{ href: "/collection-confirm", label: "Collection Confirm", icon: CheckCircle },
|
|
];
|
|
|
|
const operationsItems: NavEntry[] = [
|
|
{ href: "/invoicing", label: "Invoicing", icon: FileText },
|
|
{ href: "/delivery-notes", label: "Delivery Notes", icon: ClipboardList },
|
|
// Post-invoice physical-delivery queue. Tracks the
|
|
// invoiced → with_driver → checked_in lifecycle. Picked
|
|
// up from the Invoicing page once the office hits
|
|
// "Mark invoiced", lives here until the driver brings
|
|
// back the signed paper and the operator types the
|
|
// reference number. n8n will eventually auto-complete
|
|
// these (docs/deliveries-n8n-workflow.md).
|
|
{ href: "/deliveries", label: "Deliveries", icon: Truck },
|
|
// Counter-side scanner for the printed delivery-note QR. Separate
|
|
// standalone PWA at /scan (it declares its own manifest), linked
|
|
// here so the office can find it from the CRM too.
|
|
{ href: "/scan", label: "DN Scanner", icon: QrCode },
|
|
];
|
|
|
|
// Stock is its own top-level group (not nested under Operations) so the
|
|
// stock-orders / pricelist / stock-catalog trio lives together. Pricelist
|
|
// was previously the lone "Other" item; promoting it here keeps related
|
|
// inventory-adjacent surfaces together without inventing a new
|
|
// catch-all bucket just for one entry.
|
|
const stockNavItems: NavEntry[] = [
|
|
{ href: "/stock", label: "Stock", icon: Boxes },
|
|
{ href: "/stock-orders", label: "Stock Orders", icon: Archive },
|
|
{ href: "/pricelist", label: "Pricelist", icon: Tag },
|
|
];
|
|
|
|
// Repairs lives here for now — it doesn't fit Logistics (it's not a
|
|
// driver / collection workflow) and it doesn't fit Operations (which
|
|
// is reserved for the order-close-out loop). Move it once it has a
|
|
// clearer home.
|
|
const otherItems: NavEntry[] = [
|
|
{ href: "/repairs", label: "Repairs", icon: Wrench },
|
|
{ href: "/giellie", label: "Giellie Agent", icon: Bot },
|
|
// Standard Operating Procedures for the operator roles. Markdown
|
|
// source of truth lives in docs/sops/ — the /sops route renders
|
|
// it. See src/lib/sops.ts.
|
|
{ href: "/sops", label: "SOPs", icon: BookOpen },
|
|
// Monthly per-rep quotes/orders roll-up. Reuses the same
|
|
// getBossDashboardData aggregation as the /m phone dashboard's
|
|
// month tab — see /api/v1/reports/rep-performance.
|
|
{ href: "/reports/rep-performance", label: "Rep Performance", icon: BarChart3 },
|
|
];
|
|
|
|
/* ============================================================================
|
|
* Active-route resolution
|
|
* The naïve "exact OR starts-with-prefix" rule double-highlights
|
|
* `/orders/new` and `/orders`. We fix it by demanding the item is the
|
|
* most-specific match within its group.
|
|
* ========================================================================== */
|
|
function isActive(itemHref: string, siblings: NavEntry[], pathname: string): boolean {
|
|
if (pathname === itemHref) return true;
|
|
if (!pathname.startsWith(itemHref + "/")) return false;
|
|
return !siblings.some(
|
|
(other) =>
|
|
other.href !== itemHref &&
|
|
other.href.length > itemHref.length &&
|
|
(pathname === other.href || pathname.startsWith(other.href + "/"))
|
|
);
|
|
}
|
|
|
|
/* ============================================================================
|
|
* NavSection — collapsible group with a small uppercase label
|
|
* ========================================================================== */
|
|
function NavSection({
|
|
label,
|
|
items,
|
|
pathname,
|
|
defaultOpen = false,
|
|
onLinkClick,
|
|
}: {
|
|
label: string;
|
|
items: NavEntry[];
|
|
pathname: string;
|
|
defaultOpen?: boolean;
|
|
onLinkClick?: () => void;
|
|
}) {
|
|
const sectionActive = items.some(
|
|
(i) => pathname === i.href || pathname.startsWith(i.href + "/")
|
|
);
|
|
const [open, setOpen] = useState(defaultOpen || sectionActive);
|
|
|
|
useEffect(() => {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
if (sectionActive) setOpen(true);
|
|
}, [sectionActive]);
|
|
|
|
return (
|
|
<div className="space-y-0.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen((o) => !o)}
|
|
className="flex w-full items-center gap-1.5 px-2.5 py-1 text-[10.5px] font-semibold uppercase tracking-widest text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
{label}
|
|
<ChevronDown
|
|
className={cn(
|
|
"ml-auto h-3 w-3 transition-transform",
|
|
open ? "" : "-rotate-90"
|
|
)}
|
|
/>
|
|
</button>
|
|
{open && (
|
|
<div className="space-y-0.5">
|
|
{items.map((item) => (
|
|
<NavLink
|
|
key={item.href}
|
|
item={item}
|
|
active={isActive(item.href, items, pathname)}
|
|
onClick={onLinkClick}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ============================================================================
|
|
* NavLink — single nav row, theme-driven active state
|
|
* ========================================================================== */
|
|
function NavLink({
|
|
item,
|
|
active,
|
|
onClick,
|
|
}: {
|
|
item: NavEntry;
|
|
active: boolean;
|
|
onClick?: () => void;
|
|
}) {
|
|
return (
|
|
<Link
|
|
href={item.href}
|
|
onClick={onClick}
|
|
className={cn(
|
|
"group flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors",
|
|
active
|
|
? "bg-sidebar-accent text-sidebar-accent-foreground"
|
|
: "text-sidebar-foreground/80 hover:bg-sidebar-accent/60 hover:text-sidebar-foreground"
|
|
)}
|
|
>
|
|
<item.icon
|
|
className={cn(
|
|
"h-[17px] w-[17px] shrink-0",
|
|
active ? "text-primary" : "text-sidebar-foreground/60 group-hover:text-sidebar-foreground/80"
|
|
)}
|
|
aria-hidden
|
|
/>
|
|
<span className="truncate">{item.label}</span>
|
|
{active && (
|
|
<span
|
|
className="ml-auto flex h-2 items-center gap-[2px] rounded-full p-[2px]"
|
|
aria-hidden
|
|
style={{
|
|
background:
|
|
"linear-gradient(90deg, var(--brand-blue, var(--primary)) 0% 33%, var(--brand-orange, var(--warn)) 33% 66%, var(--brand-red, var(--destructive)) 66% 100%)",
|
|
}}
|
|
>
|
|
<span className="h-1 w-1 rounded-full" style={{ background: "rgba(255,255,255,0.6)" }} />
|
|
</span>
|
|
)}
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
/* ============================================================================
|
|
* NavContent — the actual sidebar body
|
|
* ========================================================================== */
|
|
function NavContent({ onLinkClick }: { onLinkClick?: () => void }) {
|
|
const pathname = usePathname();
|
|
const { user, logout } = useAuth();
|
|
const { theme, setTheme } = useTheme();
|
|
|
|
return (
|
|
<div className="flex h-full flex-col bg-sidebar text-sidebar-foreground">
|
|
{/* Brand block — the WFDASH logo (transparent background) sits
|
|
naturally on the sidebar surface in both light and dark
|
|
modes. Sized 36px so it matches the visual weight of the
|
|
previous Package icon. The "Westcott Fasteners" subtitle
|
|
keeps the operator-facing context that the original block
|
|
carried. */}
|
|
<Link
|
|
href="/dashboard"
|
|
onClick={onLinkClick}
|
|
className="flex items-center gap-3 px-5 pt-6 pb-5"
|
|
>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src="/wfdash-logo-mark.png"
|
|
alt="WFDASH"
|
|
width={36}
|
|
height={36}
|
|
className="h-9 w-9 shrink-0"
|
|
/>
|
|
<div className="leading-tight">
|
|
<div className="text-[15px] font-semibold tracking-tight font-heading-theme">
|
|
WFDASH
|
|
</div>
|
|
<div className="text-[11px] text-muted-foreground">
|
|
Westcott Fasteners
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
|
|
<Separator className="mx-3" />
|
|
|
|
{/* Tri-color brand accent — sits flush against the brand block so
|
|
the logo + WFDASH text + the three-peak stripe read as one
|
|
signature unit. 3px tall so it reads as a "rule", not a
|
|
hairline. */}
|
|
<BrandStripe className="h-[3px] shrink-0" />
|
|
|
|
{/* Nav */}
|
|
<ScrollArea className="flex-1 px-3 py-3">
|
|
<nav className="space-y-3">
|
|
{/* Dashboard — single, top-level */}
|
|
<NavLink
|
|
item={dashboardItems[0]}
|
|
active={isActive(dashboardItems[0].href, dashboardItems, pathname)}
|
|
onClick={onLinkClick}
|
|
/>
|
|
|
|
<Separator className="mx-1" />
|
|
|
|
<NavSection
|
|
label="Management"
|
|
items={managementItems}
|
|
pathname={pathname}
|
|
onLinkClick={onLinkClick}
|
|
/>
|
|
<NavSection
|
|
label="Quotes"
|
|
items={quoteItems}
|
|
pathname={pathname}
|
|
onLinkClick={onLinkClick}
|
|
/>
|
|
<NavSection
|
|
label="Orders"
|
|
items={orderItems}
|
|
pathname={pathname}
|
|
onLinkClick={onLinkClick}
|
|
/>
|
|
<NavSection
|
|
label="Logistics"
|
|
items={logisticsItems}
|
|
pathname={pathname}
|
|
onLinkClick={onLinkClick}
|
|
/>
|
|
<NavSection
|
|
label="Operations"
|
|
items={operationsItems}
|
|
pathname={pathname}
|
|
onLinkClick={onLinkClick}
|
|
/>
|
|
<NavSection
|
|
label="Stock"
|
|
items={stockNavItems}
|
|
pathname={pathname}
|
|
onLinkClick={onLinkClick}
|
|
/>
|
|
{otherItems.length > 0 && (
|
|
<NavSection
|
|
label="Other"
|
|
items={otherItems}
|
|
pathname={pathname}
|
|
onLinkClick={onLinkClick}
|
|
/>
|
|
)}
|
|
</nav>
|
|
</ScrollArea>
|
|
|
|
<Separator className="mx-3" />
|
|
|
|
{/* Bottom block */}
|
|
<div className="space-y-1 p-3">
|
|
<Link
|
|
href="/settings"
|
|
onClick={onLinkClick}
|
|
aria-label="Settings"
|
|
title="Settings"
|
|
className="flex h-9 items-center gap-3 rounded-lg px-2.5 text-sm text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
|
>
|
|
<SettingsIcon className="h-[17px] w-[17px]" />
|
|
Settings
|
|
</Link>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
|
className="flex h-9 w-full items-center gap-3 rounded-lg px-2.5 text-sm text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
|
>
|
|
{theme === "dark" ? (
|
|
<>
|
|
<Sun className="h-[17px] w-[17px]" />
|
|
Light mode
|
|
</>
|
|
) : (
|
|
<>
|
|
<Moon className="h-[17px] w-[17px]" />
|
|
Dark mode
|
|
</>
|
|
)}
|
|
</button>
|
|
|
|
<Separator className="my-1" />
|
|
|
|
<div className="flex items-center gap-3 px-2.5 py-2">
|
|
<Avatar className="h-8 w-8 shrink-0">
|
|
<AvatarFallback className="text-xs font-medium bg-muted text-foreground">
|
|
{user?.name?.charAt(0)?.toUpperCase() ||
|
|
user?.email?.charAt(0)?.toUpperCase() ||
|
|
"U"}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-medium">
|
|
{user?.name || "User"}
|
|
</p>
|
|
<p className="truncate text-[11px] text-muted-foreground">
|
|
{user?.email}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={logout}
|
|
aria-label="Logout"
|
|
title="Logout"
|
|
className="grid size-7 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
|
>
|
|
<LogOut className="h-[15px] w-[15px]" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tri-color brand signature at the bottom of the sidebar —
|
|
mirrors the top stripe so the brand identity wraps the
|
|
whole sidebar. 2px tall so it reads as a footer rule. */}
|
|
<BrandStripe className="h-[2px] shrink-0" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ============================================================================
|
|
* Mobile top-bar label — derived from the current pathname so the bar
|
|
* reads as "Hamburger · Orders" rather than the redundant "Hamburger ·
|
|
* WFDASH · WFDASH" it shipped with (the brand block is already in the
|
|
* drawer). Map is checked longest-prefix-first so /orders/sprint wins
|
|
* over /orders before the generic /orders entry.
|
|
* ========================================================================== */
|
|
const MOBILE_TITLE_MAP: { prefix: string; label: string }[] = [
|
|
{ prefix: "/collection-confirm", label: "Collection Confirm" },
|
|
{ prefix: "/collection-map", label: "Collection Map" },
|
|
{ prefix: "/collection-pool", label: "Collection Pool" },
|
|
{ prefix: "/delivery-notes", label: "Delivery Notes" },
|
|
{ prefix: "/stock-orders", label: "Stock Orders" },
|
|
{ prefix: "/orders/sprint", label: "Sprint" },
|
|
{ prefix: "/orders/assign", label: "Assign Driver" },
|
|
{ prefix: "/orders/new", label: "New Order" },
|
|
{ prefix: "/orders", label: "Orders" },
|
|
{ prefix: "/quotes/approvals", label: "Quote Approvals" },
|
|
{ prefix: "/quotes/archive", label: "Quote Archive" },
|
|
{ prefix: "/quotes", label: "Quotes" },
|
|
{ prefix: "/customers", label: "Customers" },
|
|
{ prefix: "/visits", label: "Visits" },
|
|
{ prefix: "/supplier-map", label: "Supplier Map" },
|
|
{ prefix: "/suppliers", label: "Suppliers" },
|
|
{ prefix: "/companies", label: "Companies" },
|
|
{ prefix: "/pricelist", label: "Pricelist" },
|
|
{ prefix: "/repairs", label: "Repairs" },
|
|
{ prefix: "/invoicing", label: "Invoicing" },
|
|
{ prefix: "/deliveries", label: "Deliveries" },
|
|
{ prefix: "/sprint", label: "Sprint" },
|
|
{ prefix: "/stock", label: "Stock" },
|
|
{ prefix: "/settings", label: "Settings" },
|
|
{ prefix: "/giellie", label: "Giellie Agent" },
|
|
{ prefix: "/webview", label: "Webview" },
|
|
{ prefix: "/dashboard", label: "Dashboard" },
|
|
];
|
|
|
|
function mobileTitleFor(pathname: string): string {
|
|
for (const { prefix, label } of MOBILE_TITLE_MAP) {
|
|
if (pathname === prefix || pathname.startsWith(prefix + "/")) return label;
|
|
}
|
|
return "WFDASH";
|
|
}
|
|
|
|
/* ============================================================================
|
|
* Sidebar — desktop + mobile (Sheet) wrappers
|
|
* ========================================================================== */
|
|
export function Sidebar() {
|
|
const [open, setOpen] = useState(false);
|
|
const pathname = usePathname();
|
|
const mobileTitle = mobileTitleFor(pathname);
|
|
|
|
return (
|
|
<>
|
|
<aside className="hidden lg:flex lg:w-60 lg:flex-col lg:h-dvh lg:sticky lg:top-0 lg:border-r lg:border-sidebar-border">
|
|
<NavContent />
|
|
</aside>
|
|
|
|
{/*
|
|
* Mobile top bar — hamburger on the left, page title centred-left
|
|
* in the bar. NO brand block here: the brand already lives in the
|
|
* drawer (NavContent at the top of NavContent renders the logo
|
|
* + "WFDASH / Westcott Fasteners" stack). Shipping the brand
|
|
* twice on a 390px viewport ate ~30px of vertical room and
|
|
* pushed the first row of every shared page below the fold —
|
|
* see docs/audit/frontend-ux.md §76.
|
|
*/}
|
|
<div className="fixed inset-x-0 top-0 z-40 flex h-14 items-center gap-3 border-b bg-background/95 px-3 backdrop-blur-sm lg:hidden">
|
|
<Sheet open={open} onOpenChange={setOpen}>
|
|
<SheetTrigger
|
|
render={<Button variant="ghost" size="icon" className="h-9 w-9" />}
|
|
aria-label="Open navigation"
|
|
>
|
|
<Menu className="h-5 w-5" />
|
|
</SheetTrigger>
|
|
<SheetContent
|
|
side="left"
|
|
className="w-64 gap-0 border-r border-sidebar-border p-0"
|
|
>
|
|
<SheetTitle className="sr-only">Navigation</SheetTitle>
|
|
<NavContent onLinkClick={() => setOpen(false)} />
|
|
</SheetContent>
|
|
</Sheet>
|
|
{/*
|
|
* Page title — presentational text, not an <h1>. Every shared
|
|
* page renders its own <h1> in the page body (e.g.
|
|
* `app/(app)/orders/page.tsx` "Orders"), and stacking two h1's
|
|
* on the same page breaks the document outline. Using a span
|
|
* with `aria-label` keeps it announced as a landmark for
|
|
* screen readers without duplicating the heading semantics.
|
|
*/}
|
|
<span
|
|
aria-label={`Current page: ${mobileTitle}`}
|
|
className="min-w-0 flex-1 truncate text-base font-semibold font-heading-theme"
|
|
>
|
|
{mobileTitle}
|
|
</span>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|