Wip stage M4

This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:22 +07:00
parent 5386bc51d1
commit d5cacc1208
113 changed files with 9486 additions and 919 deletions
+4
View File
@@ -17,10 +17,14 @@
"@sport/ui": "workspace:*",
"@sport/validation": "workspace:*",
"@tanstack/react-query": "^5.101.4",
"embla-carousel-react": "8.6.0",
"lucide-react": "1.31.0",
"motion": "13.1.0",
"next": "catalog:",
"next-intl": "^4.13.6",
"react": "catalog:",
"react-dom": "catalog:",
"tw-animate-css": "1.4.0",
"zod": "catalog:",
"zustand": "^5.0.14"
},
@@ -2,10 +2,10 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
import type { Locale } from '@sport/types';
import { SiteHeader } from '@/components/commerce/site-header';
import { SiteFooter } from '@/components/layout/site-footer';
import { SiteHeader } from '@/components/layout/site-header';
import { fetchNavigation } from '@/features/product/services/catalog';
import { Link } from '@/i18n/navigation';
import { fetchNavigation } from '@/lib/catalog';
import { routes } from '@/lib/routes';
/**
@@ -4,7 +4,7 @@ import { setRequestLocale } from 'next-intl/server';
import type { Locale } from '@sport/types';
import { ProductListing } from '@/features/product/components/product-listing';
import { ProductListing } from '@/components/commerce/product-listing';
import { CATALOG_CACHE, getServerApi } from '@/lib/api';
import { parseListingParams, type RawSearchParams } from '@/lib/search-params';
@@ -2,9 +2,9 @@ import { setRequestLocale } from 'next-intl/server';
import type { Locale } from '@sport/types';
import { SiteHeader } from '@/components/commerce/site-header';
import { SiteFooter } from '@/components/layout/site-footer';
import { SiteHeader } from '@/components/layout/site-header';
import { fetchNavigation } from '@/features/product/services/catalog';
import { fetchNavigation } from '@/lib/catalog';
/**
* Chrome shared by every browsing route.
@@ -3,7 +3,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
import type { Locale } from '@sport/types';
import { ProductListing } from '@/features/product/components/product-listing';
import { ProductListing } from '@/components/commerce/product-listing';
import { parseListingParams, type RawSearchParams } from '@/lib/search-params';
type PageProps = {
@@ -1,11 +1,11 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import type { Locale } from '@sport/types';
import { Button } from '@sport/ui';
import { ProductGrid } from '@/features/product/components/product-grid';
import { fetchProducts } from '@/features/product/services/catalog';
import { Hero } from '@/components/commerce/hero';
import { ProductGrid } from '@/components/commerce/product-grid';
import { Link } from '@/i18n/navigation';
import { fetchProducts } from '@/lib/catalog';
import { SPORT_NAV, routes } from '@/lib/routes';
type PageProps = { params: Promise<{ locale: string }> };
@@ -26,34 +26,15 @@ export default async function HomePage({ params }: PageProps) {
return (
<>
<section className="border-ink-200 bg-ink-950 border-b text-white">
<div className="max-w-page px-gutter mx-auto py-24 sm:py-32">
<p className="text-volt-500 text-xs font-semibold uppercase tracking-widest">
{t('heroEyebrow')}
</p>
<h1 className="mt-4 max-w-3xl text-5xl font-black uppercase leading-[0.95] sm:text-7xl">
{t('heroTitle')}
</h1>
<p className="text-ink-300 mt-6 max-w-xl text-base">{t('heroBody')}</p>
<div className="mt-10 flex flex-wrap gap-3">
<Link href={routes.collection('new-arrivals')}>
<Button variant="accent" size="lg">
{t('heroCta')}
</Button>
</Link>
<Link href={routes.men()}>
<Button
variant="outline"
size="lg"
className="hover:text-ink-950 border-white text-white hover:bg-white"
>
{t('heroSecondary')}
</Button>
</Link>
</div>
</div>
</section>
<Hero
eyebrow={t('heroEyebrow')}
title={t('heroTitle')}
body={t('heroBody')}
primaryCta={t('heroCta')}
primaryHref={routes.collection('new-arrivals')}
secondaryCta={t('heroSecondary')}
secondaryHref={routes.men()}
/>
<section className="max-w-page px-gutter mx-auto py-16">
<h2 className="text-ink-400 text-xs font-semibold uppercase tracking-widest">
@@ -4,10 +4,10 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
import { LOCALES, type Locale } from '@sport/types';
import { ProductDetail } from '@/features/product/components/product-detail';
import { fetchProduct } from '@/features/product/services/catalog';
import { ProductDetail } from '@/components/commerce/product-detail';
import { Link, redirect } from '@/i18n/navigation';
import { getServerApi } from '@/lib/api';
import { fetchProduct } from '@/lib/catalog';
import { routes } from '@/lib/routes';
type PageProps = { params: Promise<{ locale: string; slug: string }> };
@@ -3,7 +3,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
import type { Locale } from '@sport/types';
import { ProductListing } from '@/features/product/components/product-listing';
import { ProductListing } from '@/components/commerce/product-listing';
import { parseListingParams, type RawSearchParams } from '@/lib/search-params';
type PageProps = {
@@ -4,7 +4,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
import type { Locale } from '@sport/types';
import { ProductListing } from '@/features/product/components/product-listing';
import { ProductListing } from '@/components/commerce/product-listing';
import { routing } from '@/i18n/routing';
import { SPORT_NAV, isSportSlug } from '@/lib/routes';
import { parseListingParams, type RawSearchParams } from '@/lib/search-params';
@@ -3,7 +3,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
import type { Locale } from '@sport/types';
import { ProductListing } from '@/features/product/components/product-listing';
import { ProductListing } from '@/components/commerce/product-listing';
import { parseListingParams, type RawSearchParams } from '@/lib/search-params';
type PageProps = {
@@ -13,9 +13,9 @@ export default function NotFound() {
<p className="text-ink-400 text-xs font-semibold uppercase tracking-widest">{t('code')}</p>
<h1 className="mt-4 text-5xl font-black uppercase">{t('title')}</h1>
<p className="text-ink-500 mt-4 max-w-md">{t('body')}</p>
<Link href={routes.home()} className="mt-8">
<Button>{t('cta')}</Button>
</Link>
<Button className="mt-8" asChild>
<Link href={routes.home()}>{t('cta')}</Link>
</Button>
</div>
);
}
@@ -0,0 +1,55 @@
'use client';
import { SlidersHorizontal } from 'lucide-react';
import { useTranslations } from 'next-intl';
import type { ReactNode } from 'react';
import { Button, Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@sport/ui';
/**
* Puts the filter rail behind a button on small screens.
*
* Without this the rail stacks above the grid, so on a phone every product sits
* below a full screen of filter controls — the listing appears empty until you
* scroll past the thing that is meant to help you narrow it.
*
* The rail itself is passed in as `children` and stays a Server Component: this
* wrapper only owns the open/closed state, so no filter rendering moves to the
* client. The links inside are ordinary navigations, which close the sheet by
* unmounting it.
*/
export function FilterSheet({
children,
activeCount,
}: {
children: ReactNode;
activeCount: number;
}) {
const t = useTranslations('listing');
return (
<Sheet>
<SheetTrigger asChild>
<Button variant="outline" size="sm" className="w-full lg:hidden">
<SlidersHorizontal />
{t('filters')}
{activeCount > 0 ? (
<span className="bg-ink-950 grid size-5 place-items-center rounded-full text-[0.625rem] text-white">
{activeCount}
</span>
) : null}
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-[85vw] overflow-y-auto sm:max-w-sm">
<SheetHeader className="border-ink-200 border-b">
<SheetTitle className="text-sm font-semibold uppercase tracking-widest">
{t('filters')}
</SheetTitle>
</SheetHeader>
<div className="px-gutter py-6">{children}</div>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,117 @@
'use client';
import { ArrowRight } from 'lucide-react';
import { motion, useReducedMotion } from 'motion/react';
import { Button } from '@sport/ui';
import { Link } from '@/i18n/navigation';
/**
* The homepage hero.
*
* Hand-built rather than assembled from primitives: this is the first thing a
* shopper sees and it is almost entirely brand — the oversized condensed
* headline, the volt eyebrow, the stagger on entry. There is no registry
* component that would make this better, only one that would make it generic.
*
* The animation is a single staggered rise. It runs once, on mount, and it is
* short — a hero that keeps moving is a hero that delays the first click.
*/
export function Hero({
eyebrow,
title,
body,
primaryCta,
primaryHref,
secondaryCta,
secondaryHref,
}: {
eyebrow: string;
title: string;
body: string;
primaryCta: string;
primaryHref: string;
secondaryCta: string;
secondaryHref: string;
}) {
// Honour the OS setting rather than animating regardless: vestibular
// sensitivity is exactly the case a large moving headline aggravates.
const reduceMotion = useReducedMotion();
const rise = reduceMotion
? {}
: {
initial: { opacity: 0, y: 16 },
animate: { opacity: 1, y: 0 },
};
return (
<section className="bg-ink-950 relative overflow-hidden border-b border-white/10 text-white">
{/*
A single volt bloom, well off-centre, painted as a radial gradient.
The first version was a blurred div — `size-[32rem]` with `blur(64px)`.
It promoted a half-megapixel element to its own composited layer, and in
that state the browser dropped the paint of everything beneath it: the
headline, the copy and both CTAs rendered as empty black. A gradient
produces the same image with no filter and no extra layer.
*/}
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
background:
'radial-gradient(38rem 38rem at 88% -10%, color-mix(in oklab, var(--color-volt-500) 18%, transparent), transparent 70%)',
}}
/>
<div className="max-w-page px-gutter relative mx-auto py-24 sm:py-32">
<motion.p
{...rise}
transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }}
className="text-volt-500 text-xs font-semibold uppercase tracking-widest"
>
{eyebrow}
</motion.p>
<motion.h1
{...rise}
transition={{ duration: 0.5, delay: 0.06, ease: [0.22, 1, 0.36, 1] }}
className="mt-4 max-w-3xl text-5xl font-black uppercase leading-[0.95] sm:text-7xl"
>
{title}
</motion.h1>
<motion.p
{...rise}
transition={{ duration: 0.5, delay: 0.12, ease: [0.22, 1, 0.36, 1] }}
className="text-ink-300 mt-6 max-w-xl text-base"
>
{body}
</motion.p>
<motion.div
{...rise}
transition={{ duration: 0.5, delay: 0.18, ease: [0.22, 1, 0.36, 1] }}
className="mt-10 flex flex-wrap gap-3"
>
<Button variant="accent" size="lg" asChild>
<Link href={primaryHref}>
{primaryCta}
<ArrowRight />
</Link>
</Button>
<Button
variant="outline"
size="lg"
className="hover:text-ink-950 border-white text-white hover:bg-white"
asChild
>
<Link href={secondaryHref}>{secondaryCta}</Link>
</Button>
</motion.div>
</div>
</section>
);
}
@@ -0,0 +1,117 @@
'use client';
import { Loader2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { isApiClientError, type ProductListQuery } from '@sport/api-client';
import type { Locale, ProductListItem } from '@sport/types';
import { Button } from '@sport/ui';
import { Link } from '@/i18n/navigation';
import { browserApi } from '@/lib/api';
import { buildListingHref } from '@/lib/listing-href';
import { ProductCard } from './product-card';
/**
* Appends the next page of products in place.
*
* The button is a real `<a href="?cursor=…">`, and that is the whole design.
* With JavaScript the click is intercepted and the next page is appended, which
* is what a shopper browsing a category actually wants. Without it — and for a
* crawler — the link is followed and the server renders the next page normally.
* So the catalog stays reachable beyond the first 24 products without a sitemap
* and without an infinite scroll that search engines cannot walk.
*
* Only the *appended* products are client-rendered. The first page is server
* rendered by `ProductGrid` above this component, so the initial paint and its
* markup are unchanged.
*/
export function LoadMore({
locale,
query,
basePath,
initialCursor,
initialCount,
totalCount,
}: {
locale: Locale;
query: ProductListQuery;
basePath: string;
initialCursor: string;
initialCount: number;
totalCount: number;
}) {
const t = useTranslations('listing');
const [extra, setExtra] = useState<ProductListItem[]>([]);
const [cursor, setCursor] = useState<string | null>(initialCursor);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const shown = initialCount + extra.length;
async function loadNext() {
if (!cursor || loading) return;
setLoading(true);
setError(null);
try {
const result = await browserApi.catalog.listProducts(locale, { ...query, cursor });
setExtra((current) => [...current, ...result.items]);
setCursor(result.pageInfo.nextCursor);
} catch (caught) {
// Leaves the href intact, so the fallback is still a working navigation
// rather than a dead end.
setError(isApiClientError(caught) ? caught.message : t('loadFailed'));
} finally {
setLoading(false);
}
}
return (
<>
{extra.length > 0 ? (
<div className="mt-10 grid grid-cols-2 gap-x-4 gap-y-10 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{extra.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
) : null}
<div className="mt-12 flex flex-col items-center gap-3">
<p className="text-ink-400 text-xs uppercase tracking-widest">
{t('showing', { shown, total: totalCount })}
</p>
{error ? (
<p role="alert" className="text-danger text-sm">
{error}
</p>
) : null}
{cursor ? (
<Button variant="outline" size="lg" asChild>
<Link
href={buildListingHref(basePath, query, { cursor })}
onClick={(event) => {
// Let modified clicks (new tab, download, middle click) behave
// like the ordinary link this genuinely is.
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
event.preventDefault();
void loadNext();
}}
aria-busy={loading}
>
{loading ? <Loader2 className="animate-spin" /> : null}
{t('loadMore')}
</Link>
</Button>
) : null}
</div>
</>
);
}
@@ -0,0 +1,132 @@
'use client';
import { ChevronDown } from 'lucide-react';
import { useEffect, useState } from 'react';
import { cn } from '@sport/ui';
import { Link } from '@/i18n/navigation';
export interface MegaMenuColumn {
readonly heading: string;
readonly links: readonly { readonly label: string; readonly href: string }[];
}
export interface MegaMenuEntry {
readonly id: string;
readonly label: string;
readonly href: string;
readonly columns: readonly MegaMenuColumn[];
}
/**
* Desktop navigation with a full-width drop panel.
*
* Hand-built rather than Radix NavigationMenu. The panel here is a plain hover
* region with no roving focus and no collision logic to get wrong, and it is
* the most brand-visible element in the chrome — the animation, the full-bleed
* black panel, the column rhythm. Radix would supply behaviour this does not
* need and constrain the markup that carries the identity.
*
* What it still has to get right, and does:
* - Opens on hover *and* on keyboard focus, so it is reachable without a mouse.
* - Closes on Escape and on focus leaving the group.
* - The top-level item stays a real link, so a sport is one click away rather
* than requiring the panel.
*/
export function MegaMenu({
entries,
className,
}: {
entries: readonly MegaMenuEntry[];
className?: string;
}) {
const [openId, setOpenId] = useState<string | null>(null);
// Escape is bound to the document rather than to a wrapper's `onKeyDown`:
// the panel is dismissible from anywhere while it is open, and hanging key
// handlers off a non-interactive container would be lying about what that
// container is.
useEffect(() => {
if (!openId) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpenId(null);
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [openId]);
return (
<div className={cn('flex items-center gap-5', className)} onMouseLeave={() => setOpenId(null)}>
{entries.map((entry) => {
const open = openId === entry.id;
const hasPanel = entry.columns.length > 0;
return (
<div
key={entry.id}
className="static"
onMouseEnter={() => setOpenId(entry.id)}
onFocus={() => setOpenId(entry.id)}
onBlur={(event) => {
// Only close when focus leaves the whole group, otherwise tabbing
// from the trigger into the panel would shut it immediately.
if (!event.currentTarget.contains(event.relatedTarget)) setOpenId(null);
}}
>
<Link
href={entry.href}
aria-expanded={hasPanel ? open : undefined}
className={cn(
'hover:text-volt-600 focus-visible:ring-ring/50 flex items-center gap-1 py-5 text-xs font-semibold uppercase tracking-widest outline-none transition-colors focus-visible:ring-[3px]',
open && 'text-volt-600',
)}
>
{entry.label}
{hasPanel ? (
<ChevronDown
className={cn('size-3 transition-transform duration-200', open && 'rotate-180')}
/>
) : null}
</Link>
{/*
`border-y`, not `border-t`. On the homepage the panel is black
over a black hero, and without a bottom edge the two surfaces
merge — the menu reads as text floating on the hero rather than as
a panel in front of it.
*/}
{hasPanel && open ? (
<div className="bg-ink-950 absolute inset-x-0 top-full z-40 border-y border-white/15 text-white shadow-2xl">
<div className="max-w-page px-gutter mx-auto grid grid-cols-2 gap-10 py-10 md:grid-cols-4">
{entry.columns.map((column) => (
<div key={column.heading}>
<p className="text-volt-500 text-[0.625rem] font-semibold uppercase tracking-widest">
{column.heading}
</p>
<ul className="mt-4 space-y-2.5">
{column.links.map((link) => (
<li key={link.href}>
<Link
href={link.href}
onClick={() => setOpenId(null)}
className="text-ink-300 hover:text-volt-500 text-sm transition-colors"
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
</div>
) : null}
</div>
);
})}
</div>
);
}
@@ -0,0 +1,77 @@
'use client';
import { Menu } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@sport/ui';
import { Link } from '@/i18n/navigation';
import type { MegaMenuEntry } from './mega-menu';
/**
* The small-screen counterpart to the mega menu.
*
* A Sheet rather than a bespoke drawer: focus trapping, scroll locking, the
* Escape handler and the `aria-modal` wiring are exactly the infrastructure
* worth taking from the registry. What is inside it is ours.
*
* Sections are flattened rather than nested behind accordions — with four
* groups the extra tap costs more than the scroll it saves.
*/
export function MobileNav({ entries }: { entries: readonly MegaMenuEntry[] }) {
const t = useTranslations('nav');
const [open, setOpen] = useState(false);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger
aria-label={t('openMenu')}
className="focus-visible:ring-ring/50 -ml-2 grid size-10 place-items-center outline-none focus-visible:ring-[3px] lg:hidden"
>
<Menu className="size-5" />
</SheetTrigger>
<SheetContent side="left" className="w-[85vw] gap-0 overflow-y-auto sm:max-w-sm">
<SheetHeader className="border-ink-200 border-b">
<SheetTitle className="text-lg font-black uppercase tracking-tighter">
Sport<span className="text-volt-600">.</span>
</SheetTitle>
</SheetHeader>
<nav aria-label={t('ariaMain')} className="px-gutter space-y-8 py-6">
{entries.map((entry) => (
<div key={entry.id}>
<Link
href={entry.href}
onClick={() => setOpen(false)}
className="text-sm font-black uppercase tracking-widest"
>
{entry.label}
</Link>
{entry.columns.length > 0 ? (
<ul className="mt-3 space-y-2">
{entry.columns
.flatMap((column) => column.links)
.map((link) => (
<li key={link.href}>
<Link
href={link.href}
onClick={() => setOpen(false)}
className="text-ink-500 hover:text-ink-950 text-sm"
>
{link.label}
</Link>
</li>
))}
</ul>
) : null}
</div>
))}
</nav>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,134 @@
import Image from 'next/image';
import { useFormatter, useTranslations } from 'next-intl';
import type { ProductListItem } from '@sport/types';
import { Badge, cn } from '@sport/ui';
import { Link } from '@/i18n/navigation';
import { discountPercent, formatMoney } from '@/lib/format';
import { routes } from '@/lib/routes';
/**
* The grid card.
*
* Commerce, not UI: it knows about sale badges, price ranges and colourways,
* none of which mean anything in the admin dashboard. Built by hand because the
* card *is* the catalog — its proportions, the hover crossfade and the
* typographic hierarchy are the brand, and a generic card component would erase
* all three. See docs/architecture.md §4.
*
* Stays a Server Component. The hover swap is pure CSS, so this ships no
* JavaScript at all, and a listing renders 24 of them.
*/
export function ProductCard({
product,
priority = false,
}: {
product: ProductListItem;
priority?: boolean;
}) {
const t = useTranslations('product');
const format = useFormatter();
const { priceRange, primaryImage, hoverImage } = product;
const hasRange = priceRange.min.amount !== priceRange.max.amount;
const discount =
priceRange.compareAtMax && product.isOnSale
? discountPercent(priceRange.min, priceRange.compareAtMax)
: 0;
return (
<article className="group relative">
<div className="bg-ink-100 relative aspect-[4/5] overflow-hidden">
{primaryImage ? (
<>
<Image
src={primaryImage.url}
alt={primaryImage.altText ?? product.name}
fill
// Matches the grid below so the browser never downloads a larger
// file than it renders.
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
className={cn(
'object-cover transition-[opacity,transform] duration-500 ease-[var(--ease-out-quint)]',
'group-hover:scale-[1.03]',
hoverImage && 'group-hover:opacity-0',
)}
placeholder={primaryImage.blurDataUrl ? 'blur' : 'empty'}
blurDataURL={primaryImage.blurDataUrl ?? undefined}
// Only the first row is priority; marking everything priority is
// the same as marking nothing.
priority={priority}
/>
{hoverImage ? (
<Image
src={hoverImage.url}
alt=""
aria-hidden
fill
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
className="scale-[1.03] object-cover opacity-0 transition-opacity duration-500 group-hover:opacity-100"
/>
) : null}
</>
) : (
<div className="bg-ink-200 size-full" />
)}
{discount > 0 ? (
<Badge variant="sale" className="absolute left-3 top-3">
{t('save', { percent: discount })}
</Badge>
) : null}
</div>
<div className="mt-3 space-y-1">
{product.brandName ? (
<p className="text-ink-400 text-[0.625rem] font-semibold uppercase tracking-widest">
{product.brandName}
</p>
) : null}
<h3 className="text-ink-950 text-sm font-medium">
{/*
The link covers the whole card via ::after rather than wrapping it.
Wrapping put the colourway list inside the anchor, which made the
swatches part of the link's accessible name — a screen reader read
the product name followed by every colour.
*/}
<Link href={routes.product(product.slug)} className="after:absolute after:inset-0">
{product.name}
</Link>
</h3>
<p className="flex items-baseline gap-2 text-sm">
<span className={cn('font-semibold', product.isOnSale && 'text-sale')}>
{hasRange ? `${t('from')} ` : ''}
{formatMoney(priceRange.min, format)}
</span>
{priceRange.compareAtMax && product.isOnSale ? (
<span className="text-ink-400 text-xs line-through">
{formatMoney(priceRange.compareAtMax, format)}
</span>
) : null}
</p>
</div>
{product.colorSwatches.length > 1 ? (
<ul className="mt-2 flex items-center gap-1.5">
{product.colorSwatches.slice(0, 5).map((swatch) => (
<li
key={swatch.optionValueId}
className="border-ink-200 size-3 rounded-full border"
style={{ backgroundColor: swatch.swatchHex ?? undefined }}
title={swatch.label}
/>
))}
{product.colorSwatches.length > 5 ? (
<li className="text-ink-400 text-[0.625rem]">+{product.colorSwatches.length - 5}</li>
) : null}
</ul>
) : null}
</article>
);
}
@@ -1,14 +1,16 @@
'use client';
import Image from 'next/image';
import { ShoppingBag } from 'lucide-react';
import { useFormatter, useTranslations } from 'next-intl';
import { useMemo, useState } from 'react';
import { VARIANT_AVAILABILITY, type StorefrontProduct, type StorefrontVariant } from '@sport/types';
import { Badge, Button, cn } from '@sport/ui';
import { Button, cn } from '@sport/ui';
import { discountPercent, formatMoney } from '@/lib/format';
import { ProductGallery } from './product-gallery';
/**
* The PDP interaction surface: gallery and variant selector, sharing one piece
* of state.
@@ -50,16 +52,26 @@ export function ProductDetail({ product }: { product: StorefrontProduct }) {
return forColour.length > 0 ? forColour : product.images;
}, [product.images, selectedColourId]);
const [activeImage, setActiveImage] = useState(0);
const currentImage = gallery[Math.min(activeImage, gallery.length - 1)] ?? null;
const price = selectedVariant?.effectivePrice ?? product.priceRange.min;
const compareAt = selectedVariant?.compareAtPrice ?? product.priceRange.compareAtMax;
/**
* What to strike through, in priority order.
*
* A variant can be discounted two ways and both must show a reference price:
* `compareAtPrice` is the "was" price, and `salePrice` is a markdown off the
* variant's own `price`. Reading only `compareAtPrice` — as this did — left
* every sale set from the admin's variant grid rendering as a lone red number
* with nothing to compare it against.
*/
const compareAt =
selectedVariant?.compareAtPrice ??
(selectedVariant?.isOnSale ? selectedVariant.price : null) ??
product.priceRange.compareAtMax;
const discount = compareAt ? discountPercent(price, compareAt) : 0;
function select(optionKey: string, optionValueId: string) {
setSelection((current) => ({ ...current, [optionKey]: optionValueId }));
if (optionKey === 'colour') setActiveImage(0);
}
/** Values on this axis still reachable given the other choices. */
@@ -92,62 +104,16 @@ export function ProductDetail({ product }: { product: StorefrontProduct }) {
return (
<div className="grid gap-10 lg:grid-cols-2 lg:gap-16">
{/* ---- Gallery ---- */}
<div className="space-y-3">
<div className="bg-ink-100 relative aspect-[4/5] overflow-hidden">
{currentImage ? (
<Image
src={currentImage.url}
alt={currentImage.altText ?? product.name}
fill
sizes="(min-width: 1024px) 50vw, 100vw"
className="object-cover"
placeholder={currentImage.blurDataUrl ? 'blur' : 'empty'}
blurDataURL={currentImage.blurDataUrl ?? undefined}
priority
/>
) : (
<div className="bg-ink-200 size-full" />
)}
<ProductGallery
// Remounts on colourway change so Embla starts at the first photo of
// the new set rather than holding an index into the old one.
key={selectedColourId ?? 'all'}
images={gallery}
productName={product.name}
discount={discount}
saveLabel={t('save', { percent: discount })}
/>
{discount > 0 ? (
<Badge variant="sale" className="absolute left-4 top-4">
{t('save', { percent: discount })}
</Badge>
) : null}
</div>
{gallery.length > 1 ? (
<ul className="grid grid-cols-4 gap-3">
{gallery.map((image, index) => (
<li key={image.id}>
<button
type="button"
onClick={() => setActiveImage(index)}
aria-current={index === activeImage}
className={cn(
'bg-ink-100 relative block aspect-[4/5] w-full overflow-hidden border transition-colors',
index === activeImage
? 'border-ink-950'
: 'hover:border-ink-300 border-transparent',
)}
>
<Image
src={image.url}
alt=""
aria-hidden
fill
sizes="12vw"
className="object-cover"
/>
</button>
</li>
))}
</ul>
) : null}
</div>
{/* ---- Selector ---- */}
<div className="space-y-8 lg:pt-4">
<div>
{product.brand ? (
@@ -162,13 +128,18 @@ export function ProductDetail({ product }: { product: StorefrontProduct }) {
</div>
<div className="flex flex-wrap items-baseline gap-3">
<span className={cn('text-2xl font-semibold', selectedVariant?.isOnSale && 'text-sale')}>
<span className={cn('text-2xl font-semibold', discount > 0 && 'text-sale')}>
{formatMoney(price, format)}
</span>
{compareAt && discount > 0 ? (
<span className="text-ink-400 text-base line-through">
{formatMoney(compareAt, format)}
</span>
<>
<span className="text-ink-400 text-base line-through">
{formatMoney(compareAt, format)}
</span>
<span className="text-sale text-xs font-semibold uppercase tracking-widest">
{t('save', { percent: discount })}
</span>
</>
) : null}
</div>
@@ -201,7 +172,7 @@ export function ProductDetail({ product }: { product: StorefrontProduct }) {
onClick={() => select(option.key, value.id)}
title={value.label}
className={cn(
'relative flex items-center justify-center border text-xs font-medium transition-colors',
'focus-visible:ring-ring/50 relative flex items-center justify-center border text-xs font-medium outline-none transition-colors focus-visible:ring-[3px]',
isColour ? 'size-10 rounded-full' : 'h-11 min-w-14 px-3 uppercase',
selected
? 'border-ink-950 ring-ink-950 ring-1'
@@ -237,6 +208,10 @@ export function ProductDetail({ product }: { product: StorefrontProduct }) {
!selectedVariant || selectedVariant.availability === VARIANT_AVAILABILITY.OUT_OF_STOCK
}
>
{selectedVariant &&
selectedVariant.availability !== VARIANT_AVAILABILITY.OUT_OF_STOCK ? (
<ShoppingBag />
) : null}
{!selectedVariant
? t('selectSizePrompt')
: selectedVariant.availability === VARIANT_AVAILABILITY.OUT_OF_STOCK
@@ -5,6 +5,7 @@ import type { ProductFacets } from '@sport/types';
import { cn } from '@sport/ui';
import { Link } from '@/i18n/navigation';
import { buildListingHref } from '@/lib/listing-href';
/**
* Filter rail, rendered on the server as plain links.
@@ -18,10 +19,16 @@ export async function ProductFilters({
facets,
basePath,
query,
showHeading = true,
}: {
facets: ProductFacets;
basePath: string;
query: ProductListQuery;
/**
* Off inside the mobile sheet, which supplies its own title — otherwise the
* word "Filters" appears twice, once in the sheet header and once here.
*/
showHeading?: boolean;
}) {
const t = await getTranslations('listing');
@@ -37,19 +44,26 @@ export async function ProductFilters({
} else {
current.add(value);
}
return buildHref(basePath, { ...query, [key]: [...current] });
return buildListingHref(basePath, { ...query, [key]: [...current] });
}
return (
<aside className="space-y-8">
<div className="flex items-baseline justify-between">
<h2 className="text-xs font-semibold uppercase tracking-widest">{t('filters')}</h2>
{hasActiveFilters ? (
<Link href={basePath} className="text-ink-500 text-xs underline underline-offset-4">
{t('clearAll')}
</Link>
) : null}
</div>
{showHeading || hasActiveFilters ? (
<div className="flex items-baseline justify-between">
{showHeading ? (
<h2 className="text-xs font-semibold uppercase tracking-widest">{t('filters')}</h2>
) : null}
{hasActiveFilters ? (
<Link
href={basePath}
className="text-ink-500 ml-auto text-xs underline underline-offset-4"
>
{t('clearAll')}
</Link>
) : null}
</div>
) : null}
{facets.colors.length > 0 ? (
<section>
@@ -63,7 +77,7 @@ export async function ProductFilters({
<li key={colour.value}>
<Link
href={toggleHref('colors', colour.value)}
aria-pressed={active}
aria-current={active ? true : undefined}
title={`${colour.label} (${colour.count})`}
className={cn(
'flex size-8 items-center justify-center rounded-full border transition-colors',
@@ -94,7 +108,7 @@ export async function ProductFilters({
<li key={size.value}>
<Link
href={toggleHref('sizes', size.value)}
aria-pressed={active}
aria-current={active ? true : undefined}
className={cn(
'flex h-9 min-w-11 items-center justify-center border px-2 text-xs font-medium uppercase transition-colors',
active
@@ -123,7 +137,7 @@ export async function ProductFilters({
<li key={brand.value}>
<Link
href={toggleHref('brandSlugs', brand.value)}
aria-pressed={active}
aria-current={active ? true : undefined}
className={cn(
'flex items-baseline justify-between text-sm transition-colors',
active ? 'text-ink-950 font-semibold' : 'text-ink-600 hover:text-ink-950',
@@ -141,8 +155,8 @@ export async function ProductFilters({
<section>
<Link
href={buildHref(basePath, { ...query, onSale: query.onSale ? undefined : true })}
aria-pressed={Boolean(query.onSale)}
href={buildListingHref(basePath, { ...query, onSale: query.onSale ? undefined : true })}
aria-current={query.onSale ? true : undefined}
className={cn(
'inline-flex items-center gap-2 text-sm transition-colors',
query.onSale ? 'text-sale font-semibold' : 'text-ink-600 hover:text-ink-950',
@@ -157,25 +171,3 @@ export async function ProductFilters({
</aside>
);
}
/**
* Serialises a query back into a URL.
*
* Only presentation-level filters are emitted — `gender`, `sport`,
* `categorySlug` and `collectionSlug` are implied by the route itself, so
* repeating them in the query string would produce ugly, duplicate-content URLs
* like `/men?gender=MEN`.
*/
function buildHref(basePath: string, query: ProductListQuery): string {
const params = new URLSearchParams();
if (query.q) params.set('q', query.q);
if (query.colors?.length) params.set('colors', query.colors.join(','));
if (query.sizes?.length) params.set('sizes', query.sizes.join(','));
if (query.brandSlugs?.length) params.set('brandSlugs', query.brandSlugs.join(','));
if (query.onSale) params.set('onSale', 'true');
if (query.sort && query.sort !== 'newest') params.set('sort', query.sort);
const search = params.toString();
return search ? `${basePath}?${search}` : basePath;
}
@@ -0,0 +1,175 @@
'use client';
import useEmblaCarousel from 'embla-carousel-react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import Image from 'next/image';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import type { ProductImage } from '@sport/types';
import { Badge, cn } from '@sport/ui';
/**
* The PDP gallery.
*
* Embla rather than a plain image swap, because the mobile behaviour a shopper
* expects here is a swipe, and reimplementing momentum, drag thresholds and
* snap points on top of a `useState` index is exactly the kind of work worth
* taking off the shelf.
*
* Thumbnails stay a plain grid rather than a second carousel — with four to
* eight images there is nothing to scroll, and a nested Embla would add two
* more event handlers for no gain.
*/
export function ProductGallery({
images,
productName,
discount,
saveLabel,
}: {
images: readonly ProductImage[];
productName: string;
discount: number;
saveLabel: string;
}) {
const t = useTranslations('product');
const [emblaRef, embla] = useEmblaCarousel({ loop: false, align: 'start', duration: 22 });
const [selected, setSelected] = useState(0);
const scrollTo = useCallback((index: number) => embla?.scrollTo(index), [embla]);
useEffect(() => {
if (!embla) return;
const sync = () => setSelected(embla.selectedScrollSnap());
sync();
embla.on('select', sync);
// `reInit` is what makes the colourway swap work: swapping to a colour with
// a different number of photos changes the slide count, and without this
// Embla keeps measuring the old set and refuses to scroll to the new ones.
embla.reInit();
return () => {
embla.off('select', sync);
};
}, [embla, images.length]);
if (images.length === 0) {
return <div className="bg-ink-200 aspect-[4/5] w-full" />;
}
const canPrev = selected > 0;
const canNext = selected < images.length - 1;
return (
<div className="space-y-3">
<div className="group relative">
<div className="overflow-hidden" ref={emblaRef}>
<div className="flex touch-pan-y">
{images.map((image, index) => (
<div key={image.id} className="min-w-0 flex-[0_0_100%]">
<div className="bg-ink-100 relative aspect-[4/5] overflow-hidden">
<Image
src={image.url}
alt={image.altText ?? productName}
fill
sizes="(min-width: 1024px) 50vw, 100vw"
className="object-cover"
placeholder={image.blurDataUrl ? 'blur' : 'empty'}
blurDataURL={image.blurDataUrl ?? undefined}
priority={index === 0}
/>
</div>
</div>
))}
</div>
</div>
{discount > 0 ? (
<Badge variant="sale" className="absolute left-4 top-4">
{saveLabel}
</Badge>
) : null}
{images.length > 1 ? (
<>
<GalleryArrow
side="left"
disabled={!canPrev}
label={t('previousImage')}
onClick={() => scrollTo(selected - 1)}
/>
<GalleryArrow
side="right"
disabled={!canNext}
label={t('nextImage')}
onClick={() => scrollTo(selected + 1)}
/>
</>
) : null}
</div>
{images.length > 1 ? (
<ul className="grid grid-cols-4 gap-3">
{images.map((image, index) => (
<li key={image.id}>
<button
type="button"
onClick={() => scrollTo(index)}
aria-label={t('goToImage', { index: index + 1 })}
aria-current={index === selected}
className={cn(
'bg-ink-100 focus-visible:ring-ring/50 relative block aspect-[4/5] w-full overflow-hidden border outline-none transition-colors focus-visible:ring-[3px]',
index === selected ? 'border-ink-950' : 'hover:border-ink-300 border-transparent',
)}
>
<Image
src={image.url}
alt=""
aria-hidden
fill
sizes="12vw"
className="object-cover"
/>
</button>
</li>
))}
</ul>
) : null}
</div>
);
}
function GalleryArrow({
side,
disabled,
label,
onClick,
}: {
side: 'left' | 'right';
disabled: boolean;
label: string;
onClick: () => void;
}) {
const Icon = side === 'left' ? ChevronLeft : ChevronRight;
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
className={cn(
'text-ink-950 focus-visible:ring-ring/50 absolute top-1/2 z-10 grid size-10 -translate-y-1/2 place-items-center bg-white/90 shadow-sm outline-none transition-opacity focus-visible:opacity-100 focus-visible:ring-[3px]',
// Hidden until hover on pointer devices — on touch the swipe is the
// affordance and a pair of floating arrows just covers the photograph.
'opacity-0 group-hover:opacity-100 max-lg:hidden',
'disabled:pointer-events-none disabled:opacity-0',
side === 'left' ? 'left-3' : 'right-3',
)}
>
<Icon className="size-5" />
</button>
);
}
@@ -0,0 +1,118 @@
import { getTranslations } from 'next-intl/server';
import type { ProductListQuery } from '@sport/api-client';
import type { Locale } from '@sport/types';
import { fetchProducts } from '@/lib/catalog';
import { buildListingHref } from '@/lib/listing-href';
import { FilterSheet } from './filter-sheet';
import { LoadMore } from './load-more';
import { ProductFilters } from './product-filters';
import { ProductGrid } from './product-grid';
import { SortMenu } from './sort-menu';
/**
* One listing component behind /men, /women, /sports/*, /collections/* and
* /search.
*
* They are the same query with different presets, so they are the same
* component. Five near-identical page implementations is how filter behaviour
* starts drifting between routes.
*/
export async function ProductListing({
locale,
title,
eyebrow,
description,
query,
basePath,
}: {
locale: Locale;
title: string;
eyebrow?: string;
description?: string | null;
query: ProductListQuery;
basePath: string;
}) {
const t = await getTranslations('listing');
const result = await fetchProducts(locale, query);
// Drives the count badge on the mobile trigger, so the shopper can see a
// filter is active without opening the sheet to look for it.
const activeFilterCount =
(query.colors?.length ?? 0) +
(query.sizes?.length ?? 0) +
(query.brandSlugs?.length ?? 0) +
(query.onSale ? 1 : 0);
return (
<div className="max-w-page px-gutter mx-auto py-12">
<header className="mb-10">
{eyebrow ? (
<p className="text-ink-400 text-xs font-semibold uppercase tracking-widest">{eyebrow}</p>
) : null}
<h1 className="mt-2 text-4xl font-black uppercase sm:text-5xl">{title}</h1>
{description ? <p className="text-ink-500 mt-4 max-w-2xl text-sm">{description}</p> : null}
</header>
{/*
The count and the sort control share a row above the grid rather than
sitting in the header, so on mobile they land next to the filter trigger
instead of pushing the products another screen down.
*/}
<div className="border-ink-200 mb-6 flex items-center justify-between gap-4 border-b pb-4">
<p className="text-ink-400 text-xs uppercase tracking-widest">
{t('resultsCount', { count: result.totalCount })}
</p>
<SortMenu basePath={basePath} query={query} />
</div>
<div className="grid gap-6 lg:grid-cols-[16rem_1fr] lg:gap-10">
{/*
The same rail, mounted twice and shown at one breakpoint each. It is a
Server Component either way — `FilterSheet` only owns open/closed
state, so nothing about filtering moves to the browser.
*/}
<FilterSheet activeCount={activeFilterCount}>
<ProductFilters
facets={result.facets}
basePath={basePath}
query={query}
showHeading={false}
/>
</FilterSheet>
<div className="hidden lg:block">
<ProductFilters facets={result.facets} basePath={basePath} query={query} />
</div>
<div>
<ProductGrid products={result.items} />
{result.pageInfo.hasNextPage && result.pageInfo.nextCursor ? (
<LoadMore
/*
* Keyed on the listing identity so a sort or filter change
* remounts it.
*
* Changing the sort is a soft navigation: the server re-renders
* page 1 in the new order, but this component keeps its position
* in the React tree and therefore keeps the products it appended
* under the *old* query. The result was duplicates — the same
* product visible twice, once in each ordering.
*/
key={buildListingHref(basePath, query)}
locale={locale}
query={query}
basePath={basePath}
initialCursor={result.pageInfo.nextCursor}
initialCount={result.items.length}
totalCount={result.totalCount}
/>
) : null}
</div>
</div>
</div>
);
}
@@ -0,0 +1,119 @@
import { Search, ShoppingBag, User } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import type { NavigationMenu } from '@sport/types';
import { LanguageSwitcher } from '@/components/language-switcher';
import { Link } from '@/i18n/navigation';
import { SPORT_NAV, routes } from '@/lib/routes';
import { MegaMenu, type MegaMenuEntry } from './mega-menu';
import { MobileNav } from './mobile-nav';
/**
* Site chrome. Stays a Server Component: only the mega menu, the mobile drawer
* and the language switcher are interactive, and each is mounted as its own
* small Client Component rather than turning the whole header into one.
*
* `navigation` comes from the API (categories and collections are database
* content, translated in the database). Sports come from the local message
* catalog, because they are a fixed enum in code. That split is deliberate and
* is documented in CategoriesService.getNavigation.
*/
export async function SiteHeader({ navigation }: { navigation: NavigationMenu | null }) {
const t = await getTranslations('nav');
const tSports = await getTranslations('sports');
const sportLinks = SPORT_NAV.map((sport) => ({
label: tSports(sport.slug),
href: routes.sport(sport.slug),
}));
/**
* Each top-level entry from the API gets its children as panel columns.
* Sports are appended as one entry of their own because they are a facet, not
* a category tree.
*/
const entries: MegaMenuEntry[] = [
...(navigation?.primary ?? []).map((item) => ({
id: item.id,
label: item.label,
href: item.href,
columns:
item.children && item.children.length > 0
? [
{
heading: item.label,
links: item.children.map((child) => ({ label: child.label, href: child.href })),
},
]
: [],
})),
{
id: 'sports',
label: t('sports'),
href: routes.sport(SPORT_NAV[0].slug),
columns: [{ heading: t('sports'), links: sportLinks }],
},
];
return (
<header className="border-ink-200 sticky top-0 z-50 border-b bg-white/95 backdrop-blur">
<div className="max-w-page px-gutter mx-auto flex h-16 items-center gap-4">
<MobileNav entries={entries} />
<Link
href={routes.home()}
className="focus-visible:ring-ring/50 text-lg font-black uppercase tracking-tighter outline-none focus-visible:ring-[3px]"
>
Sport<span className="text-volt-600">.</span>
</Link>
<nav aria-label={t('ariaMain')} className="hidden lg:flex">
<MegaMenu entries={entries} />
</nav>
<div className="ml-auto flex items-center gap-1">
<LanguageSwitcher />
<HeaderAction href={routes.search()} label={t('search')}>
<Search className="size-5" />
</HeaderAction>
<HeaderAction href={routes.account()} label={t('account')} className="hidden sm:grid">
<User className="size-5" />
</HeaderAction>
<HeaderAction href={routes.cart()} label={t('cart')}>
<ShoppingBag className="size-5" />
</HeaderAction>
</div>
</div>
</header>
);
}
/**
* Icon-only, so the label moves to `aria-label` and a tooltip — the row has to
* survive a 360px viewport alongside the logo and the language switcher.
*/
function HeaderAction({
href,
label,
className,
children,
}: {
href: string;
label: string;
className?: string;
children: React.ReactNode;
}) {
return (
<Link
href={href}
aria-label={label}
title={label}
className={`hover:text-volt-600 focus-visible:ring-ring/50 grid size-10 place-items-center outline-none transition-colors focus-visible:ring-[3px] ${className ?? ''}`}
>
{children}
</Link>
);
}
@@ -0,0 +1,63 @@
'use client';
import { Check, ChevronDown } from 'lucide-react';
import { useTranslations } from 'next-intl';
import type { ProductListQuery } from '@sport/api-client';
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@sport/ui';
import { Link } from '@/i18n/navigation';
import { PRODUCT_SORTS, buildListingHref } from '@/lib/listing-href';
/**
* Sort order, as a menu of links.
*
* Every item is a real `<a href>` to the same listing with `?sort=` set, which
* keeps sorting shareable, bookmarkable and back-button-correct — the same
* contract the filter rail already honours. The menu is a Client Component only
* because a dropdown needs open/closed state; the navigation itself is ordinary
* and works without the menu ever opening.
*
* Selecting a sort drops the cursor, because a position in one ordering means
* nothing in another.
*/
export function SortMenu({ basePath, query }: { basePath: string; query: ProductListQuery }) {
const t = useTranslations('listing');
const active = query.sort ?? 'newest';
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<span className="text-ink-400 hidden font-normal normal-case sm:inline">
{t('sortBy')}
</span>
{t(`sort.${active}`)}
<ChevronDown />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-52">
{PRODUCT_SORTS.map((sort) => (
<DropdownMenuItem key={sort} asChild>
<Link
href={buildListingHref(basePath, { ...query, sort })}
className="flex items-center justify-between gap-4"
>
{t(`sort.${sort}`)}
{/* Rendered but transparent when inactive, so the label position
does not shift as the selection moves down the list. */}
<Check className={sort === active ? 'opacity-100' : 'opacity-0'} />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -1,68 +0,0 @@
import { getTranslations } from 'next-intl/server';
import type { NavigationMenu } from '@sport/types';
import { LanguageSwitcher } from '@/components/language-switcher';
import { Link } from '@/i18n/navigation';
import { SPORT_NAV, routes } from '@/lib/routes';
/**
* Server Component. It renders no interactive state beyond the language
* switcher, so almost nothing ships to the browser — the mobile menu and cart
* badge will be small Client Components mounted inside it rather than turning
* the whole header into one.
*
* `navigation` comes from the API (categories and collections are database
* content, translated in the database). Sports come from the local message
* catalog, because they are a fixed enum in code. That split is deliberate and
* is documented in CategoriesService.getNavigation.
*/
export async function SiteHeader({ navigation }: { navigation: NavigationMenu | null }) {
const t = await getTranslations('nav');
const tSports = await getTranslations('sports');
return (
<header className="border-ink-200 sticky top-0 z-50 border-b bg-white/95 backdrop-blur">
<div className="max-w-page px-gutter mx-auto flex h-16 items-center gap-6">
<Link href={routes.home()} className="text-lg font-black uppercase tracking-tighter">
Sport<span className="text-volt-600">.</span>
</Link>
<nav aria-label={t('ariaMain')} className="hidden items-center gap-5 lg:flex">
{navigation?.primary.map((item) => (
<Link
key={item.id}
href={item.href}
className="hover:text-volt-600 text-xs font-semibold uppercase tracking-widest"
>
{item.label}
</Link>
))}
{SPORT_NAV.map((sport) => (
<Link
key={sport.slug}
href={routes.sport(sport.slug)}
className="hover:text-volt-600 text-xs font-semibold uppercase tracking-widest"
>
{tSports(sport.slug)}
</Link>
))}
</nav>
<div className="ml-auto flex items-center gap-4 text-xs font-semibold uppercase tracking-widest">
<LanguageSwitcher />
<Link href={routes.search()} className="hover:text-volt-600">
{t('search')}
</Link>
<Link href={routes.account()} className="hover:text-volt-600 hidden sm:inline">
{t('account')}
</Link>
<Link href={routes.cart()} className="hover:text-volt-600">
{t('cart')}
</Link>
</div>
</div>
</header>
);
}
@@ -1,24 +0,0 @@
# feature: account
Profile, addresses and account settings.
## Structure
```
account/
├── components/ # UI specific to this feature
├── hooks/ # React hooks (client-side only)
├── services/ # Calls into @sport/api-client, plus query keys
├── stores/ # Zustand slices, only if this feature owns client state
└── types.ts # View-model types. Domain types come from @sport/types.
```
## Rules
- A feature may import from `@/components`, `@/lib`, `@/hooks` and any
`@sport/*` package.
- A feature must **not** import from another feature's internals. If two
features need the same thing, it moves up to `@/components` or `@/lib`.
Cross-feature imports are what turn a feature folder into a second, worse
module system.
- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API.
@@ -1,24 +0,0 @@
# feature: auth
Sign in, register, password reset, and the session store the rest of the app reads.
## Structure
```
auth/
├── components/ # UI specific to this feature
├── hooks/ # React hooks (client-side only)
├── services/ # Calls into @sport/api-client, plus query keys
├── stores/ # Zustand slices, only if this feature owns client state
└── types.ts # View-model types. Domain types come from @sport/types.
```
## Rules
- A feature may import from `@/components`, `@/lib`, `@/hooks` and any
`@sport/*` package.
- A feature must **not** import from another feature's internals. If two
features need the same thing, it moves up to `@/components` or `@/lib`.
Cross-feature imports are what turn a feature folder into a second, worse
module system.
- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API.
@@ -1,24 +0,0 @@
# feature: cart
Bag drawer and page, line-item mutations, optimistic quantity updates.
## Structure
```
cart/
├── components/ # UI specific to this feature
├── hooks/ # React hooks (client-side only)
├── services/ # Calls into @sport/api-client, plus query keys
├── stores/ # Zustand slices, only if this feature owns client state
└── types.ts # View-model types. Domain types come from @sport/types.
```
## Rules
- A feature may import from `@/components`, `@/lib`, `@/hooks` and any
`@sport/*` package.
- A feature must **not** import from another feature's internals. If two
features need the same thing, it moves up to `@/components` or `@/lib`.
Cross-feature imports are what turn a feature folder into a second, worse
module system.
- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API.
@@ -1,24 +0,0 @@
# feature: category
Category landing pages, breadcrumbs and the facet sidebar.
## Structure
```
category/
├── components/ # UI specific to this feature
├── hooks/ # React hooks (client-side only)
├── services/ # Calls into @sport/api-client, plus query keys
├── stores/ # Zustand slices, only if this feature owns client state
└── types.ts # View-model types. Domain types come from @sport/types.
```
## Rules
- A feature may import from `@/components`, `@/lib`, `@/hooks` and any
`@sport/*` package.
- A feature must **not** import from another feature's internals. If two
features need the same thing, it moves up to `@/components` or `@/lib`.
Cross-feature imports are what turn a feature folder into a second, worse
module system.
- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API.
@@ -1,24 +0,0 @@
# feature: checkout
Multi-step checkout: address, delivery, payment, review.
## Structure
```
checkout/
├── components/ # UI specific to this feature
├── hooks/ # React hooks (client-side only)
├── services/ # Calls into @sport/api-client, plus query keys
├── stores/ # Zustand slices, only if this feature owns client state
└── types.ts # View-model types. Domain types come from @sport/types.
```
## Rules
- A feature may import from `@/components`, `@/lib`, `@/hooks` and any
`@sport/*` package.
- A feature must **not** import from another feature's internals. If two
features need the same thing, it moves up to `@/components` or `@/lib`.
Cross-feature imports are what turn a feature folder into a second, worse
module system.
- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API.
@@ -1,24 +0,0 @@
# feature: collection
Campaign and editorial collection pages.
## Structure
```
collection/
├── components/ # UI specific to this feature
├── hooks/ # React hooks (client-side only)
├── services/ # Calls into @sport/api-client, plus query keys
├── stores/ # Zustand slices, only if this feature owns client state
└── types.ts # View-model types. Domain types come from @sport/types.
```
## Rules
- A feature may import from `@/components`, `@/lib`, `@/hooks` and any
`@sport/*` package.
- A feature must **not** import from another feature's internals. If two
features need the same thing, it moves up to `@/components` or `@/lib`.
Cross-feature imports are what turn a feature folder into a second, worse
module system.
- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API.
@@ -1,24 +0,0 @@
# feature: order
Order confirmation and order history views.
## Structure
```
order/
├── components/ # UI specific to this feature
├── hooks/ # React hooks (client-side only)
├── services/ # Calls into @sport/api-client, plus query keys
├── stores/ # Zustand slices, only if this feature owns client state
└── types.ts # View-model types. Domain types come from @sport/types.
```
## Rules
- A feature may import from `@/components`, `@/lib`, `@/hooks` and any
`@sport/*` package.
- A feature must **not** import from another feature's internals. If two
features need the same thing, it moves up to `@/components` or `@/lib`.
Cross-feature imports are what turn a feature folder into a second, worse
module system.
- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API.
@@ -1,24 +0,0 @@
# feature: product
PDP: gallery, variant selector, price display, add-to-bag. Owns `<ProductCard>`.
## Structure
```
product/
├── components/ # UI specific to this feature
├── hooks/ # React hooks (client-side only)
├── services/ # Calls into @sport/api-client, plus query keys
├── stores/ # Zustand slices, only if this feature owns client state
└── types.ts # View-model types. Domain types come from @sport/types.
```
## Rules
- A feature may import from `@/components`, `@/lib`, `@/hooks` and any
`@sport/*` package.
- A feature must **not** import from another feature's internals. If two
features need the same thing, it moves up to `@/components` or `@/lib`.
Cross-feature imports are what turn a feature folder into a second, worse
module system.
- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API.
@@ -1,121 +0,0 @@
import Image from 'next/image';
import { useFormatter, useTranslations } from 'next-intl';
import type { ProductListItem } from '@sport/types';
import { Badge, cn } from '@sport/ui';
import { Link } from '@/i18n/navigation';
import { formatMoney, discountPercent } from '@/lib/format';
import { routes } from '@/lib/routes';
/**
* The grid card.
*
* Lives in `features/product/` rather than `@sport/ui` on purpose: it knows
* about sale badges, price ranges and colourways, none of which mean anything
* in the admin dashboard. See docs/architecture.md §4.
*/
export function ProductCard({
product,
priority = false,
}: {
product: ProductListItem;
priority?: boolean;
}) {
const t = useTranslations('product');
const format = useFormatter();
const { priceRange, primaryImage, hoverImage } = product;
const hasRange = priceRange.min.amount !== priceRange.max.amount;
const discount =
priceRange.compareAtMax && product.isOnSale
? discountPercent(priceRange.min, priceRange.compareAtMax)
: 0;
return (
<article className="group">
<Link href={routes.product(product.slug)} className="block">
<div className="bg-ink-100 relative aspect-[4/5] overflow-hidden">
{primaryImage ? (
<>
<Image
src={primaryImage.url}
alt={primaryImage.altText ?? product.name}
fill
// Three columns on desktop, two on tablet, one on mobile —
// matching the grid below so the browser never downloads a
// larger file than it renders.
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
className={cn(
'object-cover transition-opacity duration-500',
hoverImage && 'group-hover:opacity-0',
)}
placeholder={primaryImage.blurDataUrl ? 'blur' : 'empty'}
blurDataURL={primaryImage.blurDataUrl ?? undefined}
// Only the first row is priority; marking everything priority
// is the same as marking nothing.
priority={priority}
/>
{hoverImage ? (
<Image
src={hoverImage.url}
alt=""
aria-hidden
fill
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
className="object-cover opacity-0 transition-opacity duration-500 group-hover:opacity-100"
/>
) : null}
</>
) : (
<div className="bg-ink-200 size-full" />
)}
{discount > 0 ? (
<Badge variant="sale" className="absolute left-3 top-3">
{t('save', { percent: discount })}
</Badge>
) : null}
</div>
<div className="mt-3 space-y-1">
{product.brandName ? (
<p className="text-ink-400 text-[0.625rem] font-semibold uppercase tracking-widest">
{product.brandName}
</p>
) : null}
<h3 className="text-ink-950 text-sm font-medium">{product.name}</h3>
<p className="flex items-baseline gap-2 text-sm">
<span className={cn('font-semibold', product.isOnSale && 'text-sale')}>
{hasRange ? `${t('from')} ` : ''}
{formatMoney(priceRange.min, format)}
</span>
{priceRange.compareAtMax && product.isOnSale ? (
<span className="text-ink-400 text-xs line-through">
{formatMoney(priceRange.compareAtMax, format)}
</span>
) : null}
</p>
</div>
</Link>
{product.colorSwatches.length > 1 ? (
<ul className="mt-2 flex items-center gap-1.5" aria-label={t('selectColour')}>
{product.colorSwatches.slice(0, 5).map((swatch) => (
<li
key={swatch.optionValueId}
className="border-ink-200 size-3 rounded-full border"
style={{ backgroundColor: swatch.swatchHex ?? undefined }}
title={swatch.label}
/>
))}
{product.colorSwatches.length > 5 ? (
<li className="text-ink-400 text-[0.625rem]">+{product.colorSwatches.length - 5}</li>
) : null}
</ul>
) : null}
</article>
);
}
@@ -1,56 +0,0 @@
import { getTranslations } from 'next-intl/server';
import type { ProductListQuery } from '@sport/api-client';
import type { Locale } from '@sport/types';
import { fetchProducts } from '@/features/product/services/catalog';
import { ProductFilters } from './product-filters';
import { ProductGrid } from './product-grid';
/**
* One listing component behind /men, /women, /sports/*, /collections/* and
* /search.
*
* They are the same query with different presets, so they are the same
* component. Five near-identical page implementations is how filter behaviour
* starts drifting between routes.
*/
export async function ProductListing({
locale,
title,
eyebrow,
description,
query,
basePath,
}: {
locale: Locale;
title: string;
eyebrow?: string;
description?: string | null;
query: ProductListQuery;
basePath: string;
}) {
const t = await getTranslations('listing');
const result = await fetchProducts(locale, query);
return (
<div className="max-w-page px-gutter mx-auto py-12">
<header className="mb-10">
{eyebrow ? (
<p className="text-ink-400 text-xs font-semibold uppercase tracking-widest">{eyebrow}</p>
) : null}
<h1 className="mt-2 text-4xl font-black uppercase sm:text-5xl">{title}</h1>
{description ? <p className="text-ink-500 mt-4 max-w-2xl text-sm">{description}</p> : null}
<p className="text-ink-400 mt-4 text-xs uppercase tracking-widest">
{t('resultsCount', { count: result.totalCount })}
</p>
</header>
<div className="grid gap-10 lg:grid-cols-[16rem_1fr]">
<ProductFilters facets={result.facets} basePath={basePath} query={query} />
<ProductGrid products={result.items} />
</div>
</div>
);
}
@@ -1,24 +0,0 @@
# feature: search
Search input, suggestions, results and the shared filter state.
## Structure
```
search/
├── components/ # UI specific to this feature
├── hooks/ # React hooks (client-side only)
├── services/ # Calls into @sport/api-client, plus query keys
├── stores/ # Zustand slices, only if this feature owns client state
└── types.ts # View-model types. Domain types come from @sport/types.
```
## Rules
- A feature may import from `@/components`, `@/lib`, `@/hooks` and any
`@sport/*` package.
- A feature must **not** import from another feature's internals. If two
features need the same thing, it moves up to `@/components` or `@/lib`.
Cross-feature imports are what turn a feature folder into a second, worse
module system.
- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API.
@@ -1,24 +0,0 @@
# feature: wishlist
Save-for-later toggles and the wishlist page.
## Structure
```
wishlist/
├── components/ # UI specific to this feature
├── hooks/ # React hooks (client-side only)
├── services/ # Calls into @sport/api-client, plus query keys
├── stores/ # Zustand slices, only if this feature owns client state
└── types.ts # View-model types. Domain types come from @sport/types.
```
## Rules
- A feature may import from `@/components`, `@/lib`, `@/hooks` and any
`@sport/*` package.
- A feature must **not** import from another feature's internals. If two
features need the same thing, it moves up to `@/components` or `@/lib`.
Cross-feature imports are what turn a feature folder into a second, worse
module system.
- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API.
+47
View File
@@ -0,0 +1,47 @@
import type { ProductListQuery } from '@sport/api-client';
/**
* Serialises a listing query back into a URL.
*
* Shared by the filter rail, the sort menu and the "load more" link so the
* three cannot disagree about what a listing URL looks like. They used to build
* hrefs independently, which is how sorting silently drops the filters you had
* applied.
*
* Only presentation-level filters are emitted — `gender`, `sport`,
* `categorySlug` and `collectionSlug` are implied by the route itself, so
* repeating them in the query string would produce ugly, duplicate-content URLs
* like `/men?gender=MEN`.
*
* `cursor` is deliberately opt-in. Changing a filter or the sort order
* invalidates any position in the result set, so those callers omit it and the
* listing restarts from the first page.
*/
export function buildListingHref(
basePath: string,
query: ProductListQuery,
extra?: { cursor?: string | null },
): string {
const params = new URLSearchParams();
if (query.q) params.set('q', query.q);
if (query.colors?.length) params.set('colors', query.colors.join(','));
if (query.sizes?.length) params.set('sizes', query.sizes.join(','));
if (query.brandSlugs?.length) params.set('brandSlugs', query.brandSlugs.join(','));
if (query.onSale) params.set('onSale', 'true');
if (query.sort && query.sort !== 'newest') params.set('sort', query.sort);
if (extra?.cursor) params.set('cursor', extra.cursor);
const search = params.toString();
return search ? `${basePath}?${search}` : basePath;
}
export const PRODUCT_SORTS = [
'newest',
'price_asc',
'price_desc',
'best_selling',
'relevance',
] as const;
export type ProductSort = (typeof PRODUCT_SORTS)[number];
+4
View File
@@ -21,6 +21,10 @@ export function parseListingParams(params: RawSearchParams): ProductListQuery {
categorySlug: single(params['category']),
onSale: single(params['onSale']) === 'true' ? true : undefined,
sort: parseSort(single(params['sort'])),
// Present only when someone followed the "load more" link without
// JavaScript, or a crawler did. The client path appends in place and never
// puts a cursor in the address bar.
cursor: single(params['cursor']),
};
}
+7 -2
View File
@@ -58,7 +58,9 @@
"price": "Price",
"onSale": "On sale only",
"inStock": "In stock only"
}
},
"showing": "Showing {shown} of {total}",
"loadFailed": "Could not load more products."
},
"product": {
"selectColour": "Colour",
@@ -77,7 +79,10 @@
"new": "New",
"from": "From",
"notAvailable": "This combination is not available",
"comingSoon": "Add to bag arrives with the cart milestone."
"comingSoon": "Add to bag arrives with the cart milestone.",
"previousImage": "Previous image",
"nextImage": "Next image",
"goToImage": "Go to image {index}"
},
"search": {
"title": "Search",
+7 -2
View File
@@ -58,7 +58,9 @@
"price": "Giá",
"onSale": "Chỉ hàng giảm giá",
"inStock": "Chỉ hàng còn sẵn"
}
},
"showing": "Đang xem {shown} trên {total}",
"loadFailed": "Không tải thêm được sản phẩm."
},
"product": {
"selectColour": "Màu sắc",
@@ -77,7 +79,10 @@
"new": "Mới",
"from": "Từ",
"notAvailable": "Phiên bản này không có sẵn",
"comingSoon": "Chức năng thêm vào giỏ sẽ có ở giai đoạn giỏ hàng."
"comingSoon": "Chức năng thêm vào giỏ sẽ có ở giai đoạn giỏ hàng.",
"previousImage": "Ảnh trước",
"nextImage": "Ảnh sau",
"goToImage": "Xem ảnh {index}"
},
"search": {
"title": "Tìm kiếm",
+4
View File
@@ -1,5 +1,9 @@
@import 'tailwindcss';
@import '@sport/config/tailwind/theme.css';
/* Supplies the enter/exit utilities Radix-driven overlays animate with
(`animate-in`, `fade-in-0`, `slide-in-from-right`). Registry components
assume these exist; without it Dialog and Sheet appear instantly. */
@import 'tw-animate-css';
/* Tailwind v4 scans the importing app by default; workspace packages must be
registered explicitly or their utility classes get purged. */