3 sections
Store navigation
The store's header: departments, search, the account and the cart.
Store header with a departments menu
store-navigation-01A line of the shop's terms over the bar, then the mark, a Departments menu of every department with its count and aisles beside the bestseller, three links, the search, the account and the cart with its count.
import * as React from "react"
import { RotateCcwIcon, TruckIcon } from "lucide-react"
import { formatCurrency } from "@/lib/format"
import { ProductArt } from "@/components/ui/product-art"
import { StoreHeader01 } from "./store-navigation-01.client"
import { ACCOUNT, BRAND, CART_HREF, LINKS, MENU, NAV_LABEL, SEARCH, storeHeader } from "./store-navigation-01.data"
export type StoreNavigation01Props = {
/** The page the header is on, when the route alone does not say. */
activeHref?: string
/** Opens the phone's menu on the first render — for a preview; false when installed. */
defaultOpen?: boolean
}
/**
* The store's header: a line of the shop's terms over the bar, then the mark,
* a Departments menu whose panel lists every department with its count and
* its aisles beside the bestseller, three plain links, the search, the
* account and the cart with its count. The band is the page's `<header>`,
* holding the menu's `<nav>` named "Shop"; on a phone the menu is a sheet.
*
* A server component: the departments, the bestseller, the cart's count and
* the terms come from `./store-navigation-01.data`, the bestseller drawn here.
*/
export function StoreNavigation01({ activeHref, defaultOpen = false }: StoreNavigation01Props) {
const { departments, featured, cartCount, freeFromCents, returnDays } = storeHeader()
return (
<header data-section="store-navigation-01" data-slot="section" className="w-full border-b border-rule bg-background text-foreground">
<p className="flex items-center justify-center gap-x-4 gap-y-1 bg-surface px-4 py-2 text-xs text-muted-foreground max-sm:flex-col">
<span className="inline-flex items-center gap-1.5">
<TruckIcon aria-hidden="true" className="size-3.5" />
Free standard delivery from {formatCurrency(freeFromCents / 100, "USD", { maximumFractionDigits: 0 })}
</span>
<span className="inline-flex items-center gap-1.5">
<RotateCcwIcon aria-hidden="true" className="size-3.5" />
{returnDays} days to send anything back
</span>
</p>
<StoreHeader01
brand={BRAND}
label={NAV_LABEL}
departments={departments}
featured={featured ? { ...featured, art: <ProductArt name={featured.name} category={featured.category} className="rounded-sm" /> } : null}
links={LINKS}
search={SEARCH}
account={ACCOUNT}
cartHref={CART_HREF}
cartCount={cartCount}
menu={MENU}
activeHref={activeHref}
defaultOpen={defaultOpen}
/>
</header>
)
}
export default StoreNavigation01"use client"
import * as React from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { ArrowRightIcon, MenuIcon, ShoppingBagIcon, UserIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { itemCount, money } from "@/lib/dashboards/ecommerce/checkout"
import { currentHref } from "@/lib/nav-config"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Button, buttonVariants } from "@/components/ui/button"
import { LogoMark, type LogoName } from "@/components/ui/logo-mark"
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu"
import { SearchInput } from "@/components/ui/search-input"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
import type { MenuDepartment, NavLink } from "./store-navigation-01.data"
type Search = { action: string; param: string; label: string; placeholder: string }
export type StoreHeader01Props = {
brand: { name: LogoName; href: string; label: string }
label: string
departments: MenuDepartment[]
featured: { name: string; priceCents: number; href: string; art: React.ReactNode } | null
links: readonly NavLink[]
search: Search
account: NavLink
cartHref: string
cartCount: number
menu: { open: string; title: string; description: string }
activeHref?: string
defaultOpen?: boolean
}
/** The search as a plain GET form, so a query reaches the results page with or without script. */
function SearchForm({ search, className }: { search: Search; className?: string }) {
// Uncontrolled on purpose: the kit's SearchInput held with no debounce can lose a keystroke to the owner's render.
return (
<form role="search" action={search.action} method="get" aria-label={search.label} className={className}>
<SearchInput name={search.param} aria-label={search.label} placeholder={search.placeholder} />
</form>
)
}
/** A count beside a link: the figure for the eye, and what it counts for the ear. */
function Count({ count }: { count: number }) {
return (
<>
<span aria-hidden="true" className="text-xs font-normal text-muted-foreground tabular-nums">
{count}
</span>
<span className="sr-only">
, {count} {count === 1 ? "product" : "products"}
</span>
</>
)
}
/**
* The store's header: the mark, a Departments menu — the kit's navigation
* menu, its panel every department with its count and its aisles beside the
* bestseller — the plain links, a search that sends its query to the results
* page, the account and the cart with its count. Below `lg` the departments
* and the links move into a sheet, each department an accordion section, and
* below `md` the search goes there too.
*/
export function StoreHeader01({ brand, label, departments, featured, links, search, account, cartHref, cartCount, menu, activeHref, defaultOpen = false }: StoreHeader01Props) {
const pathname = usePathname()
const hrefs = [...departments.flatMap((department) => [department.href, ...department.aisles.map((aisle) => aisle.href)]), ...links.map((link) => link.href)]
const current = activeHref ?? currentHref(hrefs, pathname)
const [open, setOpen] = React.useState(defaultOpen)
const close = () => setOpen(false)
const cartLabel = `Cart, ${itemCount(cartCount)}`
return (
<div className="mx-auto flex h-16 max-w-7xl items-center gap-4 px-4 md:px-6 lg:gap-6">
<Link href={brand.href} aria-label={brand.label} className="-mx-1 flex h-10 shrink-0 items-center rounded-md px-1 focus-ring">
<LogoMark name={brand.name} />
</Link>
<NavigationMenu aria-label={label} className="max-lg:hidden">
<NavigationMenuList className="gap-0.5">
<NavigationMenuItem>
<NavigationMenuTrigger className="text-muted-foreground hover:text-foreground data-popup-open:text-foreground">Departments</NavigationMenuTrigger>
<NavigationMenuContent>
<div className="grid w-[48rem] grid-cols-[1fr_1fr_13rem] gap-2 p-2">
{[departments.slice(0, 3), departments.slice(3)].map((column, index) => (
<ul key={index} className="flex flex-col gap-1">
{column.map((department) => (
<li key={department.href} className="flex flex-col">
<NavigationMenuLink render={<Link href={department.href} />} active={department.href === current} className="justify-between font-medium">
{department.label}
<Count count={department.count} />
</NavigationMenuLink>
<ul className="mb-1 flex flex-wrap gap-x-3 gap-y-0.5 px-2">
{department.aisles.map((aisle) => (
<li key={aisle.href}>
<NavigationMenuLink render={<Link href={aisle.href} />} active={aisle.href === current} className="p-0 text-xs text-muted-foreground hover:bg-transparent hover:text-foreground focus:bg-transparent">
{aisle.label}
</NavigationMenuLink>
</li>
))}
</ul>
</li>
))}
</ul>
))}
{featured ? (
<div className="flex flex-col gap-2 rounded-md bg-surface p-3 ring-1 ring-border">
<div className="w-full">{featured.art}</div>
<p className="type-eyebrow">Bestseller</p>
<NavigationMenuLink render={<Link href={featured.href} />} className="-mx-2 flex-col items-start gap-0.5 px-2 py-1">
<span className="font-medium text-foreground">{featured.name}</span>
<span className="text-muted-foreground tabular-nums">{money(featured.priceCents)}</span>
</NavigationMenuLink>
</div>
) : null}
</div>
</NavigationMenuContent>
</NavigationMenuItem>
{links.map((link) => (
<NavigationMenuItem key={link.href}>
<NavigationMenuLink
render={<Link href={link.href} />}
active={link.href === current}
className={cn(navigationMenuTriggerStyle(), "text-muted-foreground hover:text-foreground data-active:text-foreground")}
>
{link.label}
</NavigationMenuLink>
</NavigationMenuItem>
))}
</NavigationMenuList>
</NavigationMenu>
<div className="ml-auto flex items-center gap-1 sm:gap-2">
<SearchForm search={search} className="w-52 max-md:hidden xl:w-72" />
<Link href={account.href} aria-label={account.label} className={cn(buttonVariants({ variant: "ghost", size: "icon" }), "max-sm:hidden")}>
<UserIcon />
</Link>
<Link href={cartHref} aria-label={cartLabel} className={cn(buttonVariants({ variant: "ghost" }), "gap-1.5 px-2")}>
<ShoppingBagIcon aria-hidden="true" />
<span aria-hidden="true" className="min-w-5 rounded-full bg-foreground px-1.5 text-center text-xs font-medium text-background tabular-nums">
{cartCount}
</span>
</Link>
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger
render={
<Button variant="ghost" size="icon" aria-label={menu.open} className="max-md:size-10 lg:hidden">
<MenuIcon />
</Button>
}
/>
<SheetContent side="left" className="gap-0 p-0">
<SheetHeader className="h-16 flex-row items-center border-b border-border px-4 py-0">
<LogoMark name={brand.name} aria-hidden="true" />
<SheetTitle className="sr-only">{menu.title}</SheetTitle>
<SheetDescription className="sr-only">{menu.description}</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-4 py-3">
<SearchForm search={search} className="mb-2 md:hidden" />
<nav aria-label={label}>
<Accordion>
{departments.map((department) => (
<AccordionItem key={department.href} value={department.href} className="border-border">
<AccordionTrigger className="h-12 items-center py-0 text-base hover:no-underline">{department.label}</AccordionTrigger>
<AccordionContent className="[&_a]:no-underline">
<ul className="flex flex-col">
{[{ label: `All ${department.label.toLowerCase()}`, href: department.href, count: department.count }, ...department.aisles].map((entry) => (
<li key={entry.href}>
<Link
href={entry.href}
onClick={close}
aria-current={entry.href === current ? "page" : undefined}
className="flex h-10 items-center justify-between rounded-md px-2 text-sm text-foreground focus-ring hover:bg-muted aria-[current=page]:bg-brand-muted aria-[current=page]:font-medium"
>
{entry.label}
<Count count={entry.count} />
</Link>
</li>
))}
</ul>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
<ul className="flex flex-col border-t border-border pt-2">
{[...links, account].map((link) => (
<li key={link.href}>
<Link href={link.href} onClick={close} className="flex h-12 items-center rounded-md text-base font-medium text-foreground focus-ring">
{link.label}
</Link>
</li>
))}
</ul>
</nav>
</div>
<div className="border-t border-border p-4">
<Link href={cartHref} onClick={close} className={cn(buttonVariants({ size: "lg" }), "h-10 w-full")}>
<ShoppingBagIcon data-icon="inline-start" aria-hidden="true" />
{cartLabel}
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" />
</Link>
</div>
</SheetContent>
</Sheet>
</div>
</div>
)
}/**
* What `store-navigation-01` reads and says. The departments and their aisles
* are the storefront's (`storeDepartments`), each with the count of products
* still sold in it, so the menu never lists an empty shelf; the product the
* panel features is the shop's bestseller; the cart's count is the shopper's
* cart (cart-ecommerce); the line over the bar quotes the checkout terms. The
* brand, the plain links and the search are the header's own words, kept here.
*
* Selectors only, reading the store inside functions and run on the server.
* The island takes plain props; it never imports this module.
*/
import { shopperCart } from "@/lib/dashboards/ecommerce/cart"
import { FREE_DELIVERY_FROM_CENTS, RETURN_DAYS } from "@/lib/dashboards/ecommerce/checkout"
import { departmentHref, storeDepartments, storeShelf } from "@/lib/dashboards/ecommerce/storefront"
import type { LogoName } from "@/components/ui/logo-mark"
export type NavLink = { label: string; href: string }
/** A department as the menu lists it: its page, how many products it holds, and its aisles with theirs. */
export type MenuDepartment = NavLink & { count: number; aisles: (NavLink & { count: number })[] }
export const BRAND: { name: LogoName; href: string; label: string } = { name: "Northwind", href: "/", label: "Northwind, home" }
/** The header's navigation, as a screen reader names it. */
export const NAV_LABEL = "Shop"
export const LINKS: readonly NavLink[] = [
{ label: "New in", href: "/products?sort=new" },
{ label: "Bestsellers", href: "/products?sort=bestselling" },
{ label: "Sale", href: "/sale" },
]
export const SEARCH = { action: "/search", param: "q", label: "Search the shop", placeholder: "Search products" } as const
export const ACCOUNT: NavLink = { label: "Account", href: "/account" }
export const CART_HREF = "/cart"
export const MENU = { open: "Open the menu", title: "Menu", description: "The shop's departments, its search and your account." } as const
/** Everything the header shows that the store decides: the departments, the featured product, the cart's count and the terms. */
export function storeHeader() {
const departments: MenuDepartment[] = storeDepartments().map((department) => ({
label: department.name,
href: departmentHref(department.name),
count: department.products.length,
aisles: department.subcategories.map((aisle) => ({ label: aisle.name, href: departmentHref(department.name, aisle.name), count: aisle.products.length })),
}))
const top = storeShelf().find((product) => product.stock > 0)
const featured = top ? { name: top.name, category: top.category, priceCents: top.priceCents, href: top.href } : null
const cartCount = shopperCart().reduce((sum, line) => sum + line.qty, 0)
return { departments, featured, cartCount, freeFromCents: FREE_DELIVERY_FROM_CENTS, returnDays: RETURN_DAYS }
}$
npx shadcn@latest add @vibra/store-navigation-01Store header with a menu per department
store-navigation-02The mark, a search that can be kept to one department, the account and the cart; under them a menu per department, each its aisles with their counts beside its three bestsellers.
import * as React from "react"
import { ProductArt } from "@/components/ui/product-art"
import { StoreHeader02 } from "./store-navigation-02.client"
import { ACCOUNT, BRAND, CART_HREF, MENU, NAV_LABEL, SEARCH, departmentMenus } from "./store-navigation-02.data"
export type StoreNavigation02Props = {
/** The page the header is on, when the route alone does not say. */
activeHref?: string
/** Opens the phone's menu on the first render — for a preview; false when installed. */
defaultOpen?: boolean
}
/**
* The store's header in two rows: the mark, a search that can be kept to one
* department, the account and the cart; then a menu per department, each
* panel its aisles with their counts and its three bestsellers. The band is
* the page's `<header>`, holding the `<nav>` named "Departments"; on a phone
* the search has its own row and the departments sit in a sheet.
*
* A server component: the departments, their bestsellers and the cart's count
* come from `./store-navigation-02.data`; every picture is drawn here.
*/
export function StoreNavigation02({ activeHref, defaultOpen = false }: StoreNavigation02Props) {
const { menus, cartCount } = departmentMenus()
const art = Object.fromEntries(
menus.flatMap((entry) => entry.top).map((product) => [product.id, <ProductArt key={product.id} name={product.name} category={product.category} className="rounded-sm" />])
)
return (
<header data-section="store-navigation-02" data-slot="section" className="w-full border-b border-rule bg-background text-foreground">
<StoreHeader02
brand={BRAND}
label={NAV_LABEL}
menus={menus}
art={art}
search={SEARCH}
account={ACCOUNT}
cartHref={CART_HREF}
cartCount={cartCount}
menu={MENU}
activeHref={activeHref}
defaultOpen={defaultOpen}
/>
</header>
)
}
export default StoreNavigation02"use client"
import * as React from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { MenuIcon, SearchIcon, ShoppingBagIcon, UserIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { itemCount, money } from "@/lib/dashboards/ecommerce/checkout"
import { currentHref } from "@/lib/nav-config"
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
import { Button, buttonVariants } from "@/components/ui/button"
import { LogoMark, type LogoName } from "@/components/ui/logo-mark"
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"
import { NavigationMenu, NavigationMenuContent, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger } from "@/components/ui/navigation-menu"
import { SearchInput } from "@/components/ui/search-input"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
import type { DepartmentMenu, NavLink } from "./store-navigation-02.data"
type Search = { action: string; param: string; scope: string; label: string; scopeLabel: string; everywhere: string; placeholder: string }
export type StoreHeader02Props = {
brand: { name: LogoName; href: string; label: string }
label: string
menus: DepartmentMenu[]
/** Each department's bestsellers, drawn on the server, by product id. */
art: Record<string, React.ReactNode>
search: Search
account: NavLink
cartHref: string
cartCount: number
menu: { open: string; title: string; description: string }
activeHref?: string
defaultOpen?: boolean
}
/** A count beside a link: the figure for the eye, and what it counts for the ear. */
function Count({ count }: { count: number }) {
return (
<>
<span aria-hidden="true" className="text-xs font-normal text-muted-foreground tabular-nums">
{count}
</span>
<span className="sr-only">
, {count} {count === 1 ? "product" : "products"}
</span>
</>
)
}
/**
* The store's header in two rows: the mark, a search that can be kept to one
* department (a plain GET form: the query and the department reach the
* results page with or without script), the account and the cart with its
* count; and under them a menu per department — the kit's navigation menu —
* each panel its aisles with their counts beside its three bestsellers. On a
* phone the search takes its own row under the mark and the departments move
* into a sheet, one accordion section each.
*/
export function StoreHeader02({ brand, label, menus, art, search, account, cartHref, cartCount, menu, activeHref, defaultOpen = false }: StoreHeader02Props) {
const pathname = usePathname()
const scopeId = React.useId()
const current = activeHref ?? currentHref(menus.flatMap((entry) => [entry.href, ...entry.aisles.map((aisle) => aisle.href)]), pathname)
const [open, setOpen] = React.useState(defaultOpen)
const close = () => setOpen(false)
return (
<>
<div className="mx-auto grid max-w-7xl grid-cols-[auto_1fr] items-center gap-x-4 gap-y-3 px-4 py-3 md:grid-cols-[auto_minmax(0,1fr)_auto] md:px-6 lg:gap-x-8">
<Link href={brand.href} aria-label={brand.label} className="order-1 -mx-1 flex h-10 items-center rounded-md px-1 focus-ring">
<LogoMark name={brand.name} />
</Link>
<form role="search" action={search.action} method="get" aria-label={search.label} className="order-3 col-span-2 flex min-w-0 items-center gap-2 md:order-2 md:col-span-1 md:max-w-2xl md:justify-self-center md:w-full">
<label htmlFor={scopeId} className="sr-only">
{search.scopeLabel}
</label>
<NativeSelect id={scopeId} name={search.scope} defaultValue="" className="w-32 shrink-0 max-sm:hidden sm:w-40 [&_select]:h-9 [&_select]:bg-card dark:[&_select]:bg-card">
<NativeSelectOption value="">{search.everywhere}</NativeSelectOption>
{menus.map((entry) => (
<NativeSelectOption key={entry.label} value={entry.label}>
{entry.label}
</NativeSelectOption>
))}
</NativeSelect>
{/* Uncontrolled on purpose: the kit's SearchInput held with no debounce can lose a keystroke to the owner's render. */}
<SearchInput name={search.param} aria-label={search.label} placeholder={search.placeholder} className="[&_input]:h-9" />
<Button type="submit" size="icon-lg" aria-label="Search" className="shrink-0">
<SearchIcon />
</Button>
</form>
<div className="order-2 flex items-center justify-self-end gap-1 md:order-3">
<Link href={account.href} aria-label={account.label} className={buttonVariants({ variant: "ghost", size: "icon" })}>
<UserIcon />
</Link>
<Link href={cartHref} aria-label={`Cart, ${itemCount(cartCount)}`} className={cn(buttonVariants({ variant: "ghost" }), "gap-1.5 px-2")}>
<ShoppingBagIcon aria-hidden="true" />
<span aria-hidden="true" className="min-w-5 rounded-full bg-foreground px-1.5 text-center text-xs font-medium text-background tabular-nums">
{cartCount}
</span>
</Link>
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger
render={
<Button variant="ghost" size="icon" aria-label={menu.open} className="max-md:size-10 lg:hidden">
<MenuIcon />
</Button>
}
/>
<SheetContent side="left" className="gap-0 p-0">
<SheetHeader className="h-16 flex-row items-center border-b border-border px-4 py-0">
<SheetTitle>{menu.title}</SheetTitle>
<SheetDescription className="sr-only">{menu.description}</SheetDescription>
</SheetHeader>
<nav aria-label={label} className="flex-1 overflow-y-auto px-4 py-2">
<Accordion>
{menus.map((entry) => (
<AccordionItem key={entry.href} value={entry.href} className="border-border">
<AccordionTrigger className="h-12 items-center py-0 text-base hover:no-underline">{entry.label}</AccordionTrigger>
<AccordionContent className="[&_a]:no-underline">
<ul className="flex flex-col">
{[{ label: `All ${entry.label.toLowerCase()}`, href: entry.href, count: entry.count }, ...entry.aisles].map((aisle) => (
<li key={aisle.href}>
<Link
href={aisle.href}
onClick={close}
aria-current={aisle.href === current ? "page" : undefined}
className="flex h-10 items-center justify-between rounded-md px-2 text-sm text-foreground focus-ring hover:bg-muted aria-[current=page]:bg-brand-muted aria-[current=page]:font-medium"
>
{aisle.label}
<Count count={aisle.count} />
</Link>
</li>
))}
</ul>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</nav>
</SheetContent>
</Sheet>
</div>
</div>
<div className="border-t border-rule max-lg:hidden">
<div className="mx-auto max-w-7xl px-4 md:px-6">
<NavigationMenu aria-label={label} className="h-11">
<NavigationMenuList className="gap-1">
{menus.map((entry) => (
<NavigationMenuItem key={entry.href}>
<NavigationMenuTrigger className="h-8 text-muted-foreground hover:text-foreground data-popup-open:text-foreground">{entry.label}</NavigationMenuTrigger>
<NavigationMenuContent>
<div className="grid w-[40rem] grid-cols-[13rem_1fr] gap-4 p-3">
<ul className="flex flex-col gap-0.5">
{[{ label: `All ${entry.label.toLowerCase()}`, href: entry.href, count: entry.count }, ...entry.aisles].map((aisle, index) => (
<li key={aisle.href}>
<NavigationMenuLink render={<Link href={aisle.href} />} active={aisle.href === current} className={cn("justify-between", index === 0 && "font-medium")}>
{aisle.label}
<Count count={aisle.count} />
</NavigationMenuLink>
</li>
))}
</ul>
<div className="flex flex-col gap-2">
<p className="px-1 type-eyebrow">Bestsellers</p>
<ul className="grid grid-cols-3 gap-2">
{entry.top.map((product) => (
<li key={product.id} className="relative flex flex-col gap-1.5 rounded-md p-1.5 hover:bg-muted has-[a:focus-visible]:focus-outline">
{/* The drawing sits outside the link, whose own rule would shrink it to an icon; the link's box covers the card. */}
{art[product.id]}
<NavigationMenuLink
render={<Link href={product.href} />}
className="flex-col items-start gap-0.5 p-0 after:absolute after:inset-0 hover:bg-transparent focus:bg-transparent"
>
<span className="text-xs font-medium text-foreground">{product.name}</span>{" "}
<span className="text-xs text-muted-foreground tabular-nums">{money(product.priceCents)}</span>
</NavigationMenuLink>
</li>
))}
</ul>
</div>
</div>
</NavigationMenuContent>
</NavigationMenuItem>
))}
</NavigationMenuList>
</NavigationMenu>
</div>
</div>
</>
)
}/**
* What `store-navigation-02` reads and says. Each department (from the
* storefront's `storeDepartments`) is a menu of its own: its aisles with the
* count of products still sold in each, and its three bestsellers with their
* prices; the search can be kept to one department. The cart's count is the
* shopper's cart (cart-ecommerce). The brand, the search and the account are
* the header's own words, kept here.
*
* Selectors only, reading the store inside functions and run on the server.
* The island takes plain props; it never imports this module.
*/
import { shopperCart } from "@/lib/dashboards/ecommerce/cart"
import { departmentHref, storeDepartments } from "@/lib/dashboards/ecommerce/storefront"
import type { LogoName } from "@/components/ui/logo-mark"
export type NavLink = { label: string; href: string }
/** A product a department's menu puts forward. */
export type MenuProduct = { id: string; name: string; category: string; priceCents: number; href: string }
/** A department as its menu shows it. */
export type DepartmentMenu = NavLink & { count: number; aisles: (NavLink & { count: number })[]; top: MenuProduct[] }
export const BRAND: { name: LogoName; href: string; label: string } = { name: "Northwind", href: "/", label: "Northwind, home" }
export const NAV_LABEL = "Departments"
export const SEARCH = {
action: "/search",
param: "q",
scope: "department",
label: "Search the shop",
scopeLabel: "Search in",
everywhere: "All departments",
placeholder: "What are you looking for?",
} as const
export const ACCOUNT: NavLink = { label: "Account", href: "/account" }
export const CART_HREF = "/cart"
export const MENU = { open: "Open the menu", title: "Departments", description: "Every department, its aisles and the search." } as const
/** Every department with its aisles and bestsellers, and the cart's count. */
export function departmentMenus() {
const menus: DepartmentMenu[] = storeDepartments().map((department) => ({
label: department.name,
href: departmentHref(department.name),
count: department.products.length,
aisles: department.subcategories.map((aisle) => ({ label: aisle.name, href: departmentHref(department.name, aisle.name), count: aisle.products.length })),
top: department.products
.filter((product) => product.stock > 0)
.slice(0, 3)
.map(({ id, name, category, priceCents, href }) => ({ id, name, category, priceCents, href })),
}))
return { menus, cartCount: shopperCart().reduce((sum, line) => sum + line.qty, 0) }
}$
npx shadcn@latest add @vibra/store-navigation-02Centred store header with instant search and a mini cart
store-navigation-03The Shop and Help menus on the left, the mark in the middle, and on the right a search that offers products as you type and a cart button that opens what is in it with the subtotal.
import * as React from "react"
import { ProductArt } from "@/components/ui/product-art"
import { StoreHeader03 } from "./store-navigation-03.client"
import { BRAND, CART, HELP, MENU, NAV_LABEL, SEARCH, storeHeader } from "./store-navigation-03.data"
export type StoreNavigation03Props = {
/** Opens the phone's menu on the first render — for a preview; false when installed. */
defaultOpen?: boolean
}
/**
* A centred store header: the Shop and Help menus on the left, the mark in
* the middle, and on the right a search that offers products as the reader
* types and the cart, whose button opens what is in it. The band is the
* page's `<header>`, holding the `<nav>` named "Shop"; on a phone the menus
* and a plain search sit in a sheet.
*
* A server component: the departments, the products the search offers and
* the cart come from `./store-navigation-03.data`; the cart's pictures are
* drawn here.
*/
export function StoreNavigation03({ defaultOpen = false }: StoreNavigation03Props) {
const { departments, findable, cart } = storeHeader()
return (
<header data-section="store-navigation-03" data-slot="section" className="w-full border-b border-rule bg-background text-foreground">
<StoreHeader03
brand={BRAND}
label={NAV_LABEL}
departments={departments}
help={HELP}
findable={findable}
search={SEARCH}
cart={cart.map((line) => ({ ...line, art: <ProductArt name={line.name} category={line.category} className="rounded-sm" /> }))}
cartLinks={CART}
menu={MENU}
defaultOpen={defaultOpen}
/>
</header>
)
}
export default StoreNavigation03"use client"
import * as React from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { MenuIcon, ShoppingBagIcon } from "lucide-react"
import { cartTotals, itemCount, money } from "@/lib/dashboards/ecommerce/checkout"
import { Autocomplete, type AutocompleteSuggestion } from "@/components/ui/autocomplete"
import { Button, buttonVariants } from "@/components/ui/button"
import { LogoMark, type LogoName } from "@/components/ui/logo-mark"
import { NavigationMenu, NavigationMenuContent, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger } from "@/components/ui/navigation-menu"
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover"
import { SearchInput } from "@/components/ui/search-input"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
import type { CartLine, Findable, NavLink } from "./store-navigation-03.data"
type Search = { action: string; param: string; label: string; placeholder: string; empty: string }
export type StoreHeader03Props = {
brand: { name: LogoName; href: string; label: string }
label: string
departments: (NavLink & { count: number })[]
help: readonly (NavLink & { description: string })[]
findable: Findable[]
search: Search
cart: (CartLine & { art: React.ReactNode })[]
cartLinks: { href: string; checkout: string }
menu: { open: string; title: string; description: string }
defaultOpen?: boolean
}
/** The search as you type: products whose name or department holds what is typed, eight at most, each taken to its page. */
function InstantSearch({ findable, search, className }: { findable: Findable[]; search: Search; className?: string }) {
const router = useRouter()
const [query, setQuery] = React.useState("")
const hrefOf = React.useMemo(() => new Map(findable.map((product) => [product.name, product.href])), [findable])
const suggest = (typed: string): AutocompleteSuggestion[] => {
const needle = typed.trim().toLowerCase()
return findable
.filter((product) => product.name.toLowerCase().includes(needle) || product.category.toLowerCase().includes(needle))
.slice(0, 8)
.map((product) => ({ value: product.name, label: product.name, description: `${product.category} · ${money(product.priceCents)}` }))
}
return (
<form role="search" action={search.action} method="get" aria-label={search.label} className={className}>
<Autocomplete
name={search.param}
aria-label={search.label}
placeholder={search.placeholder}
value={query}
onValueChange={setQuery}
getSuggestions={suggest}
onSelect={(suggestion) => {
const href = hrefOf.get(suggestion.value)
if (href) router.push(href)
}}
minLength={2}
emptyText={search.empty}
className="[&_input]:h-9"
/>
</form>
)
}
/** A count beside a link: the figure for the eye, and what it counts for the ear. */
function Count({ count }: { count: number }) {
return (
<>
<span aria-hidden="true" className="text-xs font-normal text-muted-foreground tabular-nums">
{count}
</span>
<span className="sr-only">
, {count} {count === 1 ? "product" : "products"}
</span>
</>
)
}
/**
* A centred store header: the Shop and Help menus on the left — the kit's
* navigation menu — the mark in the middle, and on the right a search that
* offers products as the reader types (the kit's Autocomplete: the arrow keys
* move through them and Enter takes one to its page, or sends the words to
* the results page when none is picked) and the cart, whose button opens a
* popover of what is in it with the subtotal. Below `lg` the menus move into
* a sheet with a plain search of their own.
*/
export function StoreHeader03({ brand, label, departments, help, findable, search, cart, cartLinks, menu, defaultOpen = false }: StoreHeader03Props) {
const [open, setOpen] = React.useState(defaultOpen)
const close = () => setOpen(false)
const totals = cartTotals(cart)
const cartLabel = `Cart, ${itemCount(totals.units)}`
const everything = departments.reduce((sum, department) => sum + department.count, 0)
return (
<div className="mx-auto grid h-16 max-w-7xl grid-cols-[1fr_auto_1fr] items-center gap-4 px-4 md:px-6">
<div className="flex items-center">
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger
render={
<Button variant="ghost" size="icon" aria-label={menu.open} className="-ml-2 max-md:size-10 lg:hidden">
<MenuIcon />
</Button>
}
/>
<SheetContent side="left" className="gap-0 p-0">
<SheetHeader className="h-16 flex-row items-center border-b border-border px-4 py-0">
<LogoMark name={brand.name} aria-hidden="true" />
<SheetTitle className="sr-only">{menu.title}</SheetTitle>
<SheetDescription className="sr-only">{menu.description}</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-4 py-4">
<form role="search" action={search.action} method="get" aria-label={search.label}>
<SearchInput name={search.param} aria-label={search.label} placeholder={search.placeholder} />
</form>
<nav aria-label={label} className="mt-4 flex flex-col gap-4">
{[
{ title: "Shop", entries: departments.map((department): NavLink & { count?: number } => department) },
{ title: "Help", entries: help.map((entry): NavLink & { count?: number } => ({ label: entry.label, href: entry.href })) },
].map((group) => (
<div key={group.title} className="flex flex-col">
<p className="px-2 pb-1 type-eyebrow">{group.title}</p>
<ul className="flex flex-col">
{group.entries.map((entry) => (
<li key={entry.href}>
<Link href={entry.href} onClick={close} className="flex h-11 items-center justify-between rounded-md px-2 text-base text-foreground focus-ring hover:bg-muted">
{entry.label}
{entry.count === undefined ? null : <Count count={entry.count} />}
</Link>
</li>
))}
</ul>
</div>
))}
</nav>
</div>
</SheetContent>
</Sheet>
<NavigationMenu aria-label={label} className="max-lg:hidden">
<NavigationMenuList className="gap-0.5">
<NavigationMenuItem>
<NavigationMenuTrigger className="text-muted-foreground hover:text-foreground data-popup-open:text-foreground">Shop</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-[30rem] grid-cols-2 gap-0.5 p-2">
{[{ label: "Everything", href: "/products", count: everything }, ...departments].map((department) => (
<li key={department.href}>
<NavigationMenuLink render={<Link href={department.href} />} className="justify-between">
{department.label}
<Count count={department.count} />
</NavigationMenuLink>
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger className="text-muted-foreground hover:text-foreground data-popup-open:text-foreground">Help</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="grid w-[26rem] gap-0.5 p-2">
{help.map((entry) => (
<li key={entry.href}>
<NavigationMenuLink render={<Link href={entry.href} />} className="flex-col items-start gap-0.5">
<span className="font-medium text-foreground">{entry.label}</span>{" "}
<span className="text-xs text-muted-foreground">{entry.description}</span>
</NavigationMenuLink>
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenu>
</div>
<Link href={brand.href} aria-label={brand.label} className="-mx-1 flex h-10 items-center rounded-md px-1 focus-ring">
<LogoMark name={brand.name} />
</Link>
<div className="flex items-center justify-end gap-2">
<InstantSearch findable={findable} search={search} className="w-full max-w-64 max-md:hidden" />
<Popover>
<PopoverTrigger
render={
<Button variant="ghost" aria-label={cartLabel} className="gap-1.5 px-2">
<ShoppingBagIcon aria-hidden="true" />
<span aria-hidden="true" className="min-w-5 rounded-full bg-foreground px-1.5 text-center text-xs font-medium text-background tabular-nums">
{totals.units}
</span>
</Button>
}
/>
<PopoverContent align="end" className="w-80 gap-3 p-3">
<PopoverTitle className="text-sm">Your cart</PopoverTitle>
<ul aria-label="Items in your cart" className="flex flex-col gap-2.5">
{cart.map((line) => (
<li key={line.productId} className="flex items-center gap-3 text-sm">
<div className="w-10 shrink-0">{line.art}</div>
<Link href={line.href} className="min-w-0 flex-1 truncate rounded-sm focus-ring hover:underline">
{line.name}
</Link>
<span className="text-muted-foreground tabular-nums">
{line.qty} × {money(line.unitCents)}
</span>
</li>
))}
</ul>
<p className="flex justify-between border-t border-rule pt-2.5 text-sm">
<span className="text-muted-foreground">Subtotal</span>
<span className="font-semibold tabular-nums">{money(totals.subtotalCents)}</span>
</p>
<div className="grid grid-cols-2 gap-2">
<Link href={cartLinks.href} className={buttonVariants({ variant: "outline" })}>
View cart
</Link>
<Link href={cartLinks.checkout} className={buttonVariants()}>
Check out
</Link>
</div>
</PopoverContent>
</Popover>
</div>
</div>
)
}/**
* What `store-navigation-03` reads and says. The Shop menu is the
* storefront's departments with their counts (`storeDepartments`); the search
* offers the products still sold as the reader types (the storefront's shelf);
* the mini cart is the shopper's cart (cart-ecommerce), totalled by the
* checkout terms. The brand, the help pages and the words around them are the
* header's own, kept here.
*
* Selectors only, reading the store inside functions and run on the server.
* The island takes plain props; it never imports this module.
*/
import { shopperCart, type CartLine } from "@/lib/dashboards/ecommerce/cart"
import { departmentHref, storeDepartments, storeShelf } from "@/lib/dashboards/ecommerce/storefront"
import type { LogoName } from "@/components/ui/logo-mark"
export type { CartLine }
export type NavLink = { label: string; href: string }
/** A product the search can offer. */
export type Findable = { id: string; name: string; category: string; priceCents: number; href: string }
export const BRAND: { name: LogoName; href: string; label: string } = { name: "Northwind", href: "/", label: "Northwind, home" }
export const NAV_LABEL = "Shop"
/** The help pages the Help menu lists, each with one line on what it answers. */
export const HELP: readonly (NavLink & { description: string })[] = [
{ label: "Delivery", href: "/help/delivery", description: "Standard and express, and when an order arrives." },
{ label: "Returns", href: "/help/returns", description: "How to send something back, and when the refund lands." },
{ label: "Track an order", href: "/account/orders", description: "Where a parcel is, from the order's own page." },
{ label: "Contact us", href: "/contact", description: "Write to the shop, or find a counter near you." },
]
export const SEARCH = { action: "/search", param: "q", label: "Search the shop", placeholder: "Search products", empty: "No product by that name" } as const
export const CART = { href: "/cart", checkout: "/checkout" } as const
export const MENU = { open: "Open the menu", title: "Menu", description: "The shop's departments, the search and help." } as const
/** The departments with their counts, every product the search can offer, and the cart. */
export function storeHeader() {
const departments = storeDepartments().map((department) => ({ label: department.name, href: departmentHref(department.name), count: department.products.length }))
const findable: Findable[] = storeShelf().map(({ id, name, category, priceCents, href }) => ({ id, name, category, priceCents, href }))
return { departments, findable, cart: shopperCart() }
}$
npx shadcn@latest add @vibra/store-navigation-03