Wip stage M4
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
+42
-67
@@ -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
|
||||
+28
-36
@@ -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.
|
||||
@@ -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];
|
||||
@@ -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']),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user