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
@@ -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,336 +0,0 @@
'use client';
import Image from 'next/image';
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 { discountPercent, formatMoney } from '@/lib/format';
/**
* The PDP interaction surface: gallery and variant selector, sharing one piece
* of state.
*
* They are a single component because they are a single interaction. Picking
* "Black" must swap the gallery to the black photography *and* narrow the size
* options *and* update the price — splitting that across two components would
* mean lifting state into a context for no benefit.
*
* This is ADR-0003 made visible: the shopper picks a value on each axis, and
* those choices together resolve to exactly one ProductVariant with its own
* SKU, price and stock. There is no product-level price to fall back on.
*/
export function ProductDetail({ product }: { product: StorefrontProduct }) {
const t = useTranslations('product');
const format = useFormatter();
const [selection, setSelection] = useState<Record<string, string>>(() =>
defaultSelection(product),
);
const selectedVariant = useMemo(
() => findVariant(product.variants, selection),
[product.variants, selection],
);
const selectedColourId = selection['colour'];
/**
* Images tagged with the selected colour, falling back to the full set.
*
* `ProductImage.optionValueId` is what makes this possible — it is why images
* are linked to an option value rather than dumped in one flat list.
*/
const gallery = useMemo(() => {
if (!selectedColourId) return product.images;
const forColour = product.images.filter((image) => image.optionValueId === selectedColourId);
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;
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. */
function reachableValues(optionKey: string): Set<string> {
const others = Object.entries(selection).filter(([key]) => key !== optionKey);
return new Set(
product.variants
.filter((variant) => matchesAll(variant, others))
.flatMap((variant) =>
variant.optionValues
.filter((ov) => ov.optionKey === optionKey)
.map((ov) => ov.optionValueId),
),
);
}
function isStocked(optionKey: string, optionValueId: string): boolean {
const others = Object.entries(selection).filter(([key]) => key !== optionKey);
return product.variants.some(
(variant) =>
variant.availability !== VARIANT_AVAILABILITY.OUT_OF_STOCK &&
variant.optionValues.some(
(ov) => ov.optionKey === optionKey && ov.optionValueId === optionValueId,
) &&
matchesAll(variant, others),
);
}
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" />
)}
{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 ? (
<p className="text-ink-400 text-xs font-semibold uppercase tracking-widest">
{product.brand.name}
</p>
) : null}
<h1 className="mt-2 text-3xl font-black uppercase sm:text-4xl">{product.name}</h1>
{product.shortDescription ? (
<p className="text-ink-500 mt-3 text-sm">{product.shortDescription}</p>
) : null}
</div>
<div className="flex flex-wrap items-baseline gap-3">
<span className={cn('text-2xl font-semibold', selectedVariant?.isOnSale && 'text-sale')}>
{formatMoney(price, format)}
</span>
{compareAt && discount > 0 ? (
<span className="text-ink-400 text-base line-through">
{formatMoney(compareAt, format)}
</span>
) : null}
</div>
{product.options.map((option) => {
const reachable = reachableValues(option.key);
const isColour = option.key === 'colour';
const selectedLabel = option.values.find((v) => v.id === selection[option.key])?.label;
return (
<fieldset key={option.id}>
<legend className="mb-3 flex w-full items-baseline justify-between text-xs font-semibold uppercase tracking-widest">
<span>{option.name}</span>
<span className="text-ink-500 font-normal normal-case tracking-normal">
{selectedLabel}
</span>
</legend>
<div className="flex flex-wrap gap-2">
{option.values.map((value) => {
const selected = selection[option.key] === value.id;
const reachableValue = reachable.has(value.id);
const stocked = reachableValue && isStocked(option.key, value.id);
return (
<button
key={value.id}
type="button"
disabled={!reachableValue}
aria-pressed={selected}
onClick={() => select(option.key, value.id)}
title={value.label}
className={cn(
'relative flex items-center justify-center border text-xs font-medium transition-colors',
isColour ? 'size-10 rounded-full' : 'h-11 min-w-14 px-3 uppercase',
selected
? 'border-ink-950 ring-ink-950 ring-1'
: 'border-ink-200 hover:border-ink-400',
!reachableValue && 'cursor-not-allowed opacity-30',
// Reachable but sold out reads differently from
// impossible: struck through, still selectable, so
// stock is discoverable rather than hidden.
reachableValue && !stocked && 'text-ink-400 line-through',
)}
style={
isColour && value.swatchHex
? { backgroundColor: value.swatchHex }
: undefined
}
>
{isColour ? <span className="sr-only">{value.label}</span> : value.label}
</button>
);
})}
</div>
</fieldset>
);
})}
<div className="space-y-3">
<AvailabilityNote variant={selectedVariant} />
<Button
size="lg"
fullWidth
disabled={
!selectedVariant || selectedVariant.availability === VARIANT_AVAILABILITY.OUT_OF_STOCK
}
>
{!selectedVariant
? t('selectSizePrompt')
: selectedVariant.availability === VARIANT_AVAILABILITY.OUT_OF_STOCK
? t('outOfStock')
: t('addToBag')}
</Button>
{/* Honest about scope: the button is real, the cart is not yet. */}
<p className="text-ink-400 text-center text-xs">{t('comingSoon')}</p>
{selectedVariant ? (
<p className="text-ink-400 text-center text-xs">
{t('sku')}: {selectedVariant.sku}
</p>
) : null}
</div>
{product.description ? (
<section className="border-ink-200 border-t pt-6">
<h2 className="text-xs font-semibold uppercase tracking-widest">{t('details')}</h2>
<p className="text-ink-600 mt-3 text-sm leading-relaxed">{product.description}</p>
</section>
) : null}
{product.attributes.length > 0 ? (
<section className="border-ink-200 border-t pt-6">
<h2 className="text-xs font-semibold uppercase tracking-widest">
{t('specifications')}
</h2>
<dl className="divide-ink-100 mt-3 divide-y text-sm">
{product.attributes.map((attribute) => (
<div key={attribute.id} className="flex justify-between gap-6 py-2">
<dt className="text-ink-500">{attribute.label}</dt>
<dd className="text-ink-950 text-right">{attribute.value}</dd>
</div>
))}
</dl>
</section>
) : null}
</div>
</div>
);
}
function AvailabilityNote({ variant }: { variant: StorefrontVariant | undefined }) {
const t = useTranslations('product');
if (!variant) return null;
const tone: Record<string, string> = {
[VARIANT_AVAILABILITY.IN_STOCK]: 'text-success',
[VARIANT_AVAILABILITY.LOW_STOCK]: 'text-warning',
[VARIANT_AVAILABILITY.OUT_OF_STOCK]: 'text-danger',
[VARIANT_AVAILABILITY.PREORDER]: 'text-ink-500',
};
const label: Record<string, string> = {
[VARIANT_AVAILABILITY.IN_STOCK]: t('inStock'),
[VARIANT_AVAILABILITY.LOW_STOCK]: t('lowStock'),
[VARIANT_AVAILABILITY.OUT_OF_STOCK]: t('outOfStock'),
[VARIANT_AVAILABILITY.PREORDER]: t('inStock'),
};
return (
<p className={cn('text-xs font-medium', tone[variant.availability])}>
{label[variant.availability]}
</p>
);
}
function matchesAll(variant: StorefrontVariant, pairs: [string, string][]): boolean {
return pairs.every(([key, value]) =>
variant.optionValues.some((ov) => ov.optionKey === key && ov.optionValueId === value),
);
}
/** Preselects the first combination that is actually buyable. */
function defaultSelection(product: StorefrontProduct): Record<string, string> {
const firstAvailable =
product.variants.find((v) => v.availability !== VARIANT_AVAILABILITY.OUT_OF_STOCK) ??
product.variants[0];
if (!firstAvailable) return {};
return Object.fromEntries(
firstAvailable.optionValues.map((ov) => [ov.optionKey, ov.optionValueId]),
);
}
function findVariant(
variants: readonly StorefrontVariant[],
selection: Record<string, string>,
): StorefrontVariant | undefined {
const entries = Object.entries(selection);
if (entries.length === 0) return undefined;
return variants.find((variant) => matchesAll(variant, entries));
}
@@ -1,181 +0,0 @@
import { getTranslations } from 'next-intl/server';
import type { ProductListQuery } from '@sport/api-client';
import type { ProductFacets } from '@sport/types';
import { cn } from '@sport/ui';
import { Link } from '@/i18n/navigation';
/**
* Filter rail, rendered on the server as plain links.
*
* No client JavaScript: each facet is a URL. That makes every filter
* combination shareable, bookmarkable, back-button-correct and crawlable, and
* it means the rail works before hydration. A client-side filter store would be
* more code for strictly less capability.
*/
export async function ProductFilters({
facets,
basePath,
query,
}: {
facets: ProductFacets;
basePath: string;
query: ProductListQuery;
}) {
const t = await getTranslations('listing');
const hasActiveFilters = Boolean(
query.colors?.length || query.sizes?.length || query.brandSlugs?.length || query.onSale,
);
/** Toggling a value in a multi-select facet, expressed as the resulting URL. */
function toggleHref(key: 'colors' | 'sizes' | 'brandSlugs', value: string): string {
const current = new Set(query[key] ?? []);
if (current.has(value)) {
current.delete(value);
} else {
current.add(value);
}
return buildHref(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>
{facets.colors.length > 0 ? (
<section>
<h3 className="text-ink-500 mb-3 text-xs font-semibold uppercase tracking-widest">
{t('facet.colour')}
</h3>
<ul className="flex flex-wrap gap-2">
{facets.colors.map((colour) => {
const active = query.colors?.includes(colour.value) ?? false;
return (
<li key={colour.value}>
<Link
href={toggleHref('colors', colour.value)}
aria-pressed={active}
title={`${colour.label} (${colour.count})`}
className={cn(
'flex size-8 items-center justify-center rounded-full border transition-colors',
active
? 'border-ink-950 ring-ink-950 ring-1'
: 'border-ink-200 hover:border-ink-400',
)}
style={{ backgroundColor: colour.swatchHex ?? undefined }}
>
<span className="sr-only">{colour.label}</span>
</Link>
</li>
);
})}
</ul>
</section>
) : null}
{facets.sizes.length > 0 ? (
<section>
<h3 className="text-ink-500 mb-3 text-xs font-semibold uppercase tracking-widest">
{t('facet.size')}
</h3>
<ul className="flex flex-wrap gap-2">
{facets.sizes.map((size) => {
const active = query.sizes?.includes(size.value) ?? false;
return (
<li key={size.value}>
<Link
href={toggleHref('sizes', size.value)}
aria-pressed={active}
className={cn(
'flex h-9 min-w-11 items-center justify-center border px-2 text-xs font-medium uppercase transition-colors',
active
? 'border-ink-950 bg-ink-950 text-white'
: 'border-ink-200 hover:border-ink-400',
)}
>
{size.label}
</Link>
</li>
);
})}
</ul>
</section>
) : null}
{facets.brands.length > 0 ? (
<section>
<h3 className="text-ink-500 mb-3 text-xs font-semibold uppercase tracking-widest">
{t('facet.brand')}
</h3>
<ul className="space-y-1.5">
{facets.brands.map((brand) => {
const active = query.brandSlugs?.includes(brand.value) ?? false;
return (
<li key={brand.value}>
<Link
href={toggleHref('brandSlugs', brand.value)}
aria-pressed={active}
className={cn(
'flex items-baseline justify-between text-sm transition-colors',
active ? 'text-ink-950 font-semibold' : 'text-ink-600 hover:text-ink-950',
)}
>
<span>{brand.label}</span>
<span className="text-ink-400 text-xs">{brand.count}</span>
</Link>
</li>
);
})}
</ul>
</section>
) : null}
<section>
<Link
href={buildHref(basePath, { ...query, onSale: query.onSale ? undefined : true })}
aria-pressed={Boolean(query.onSale)}
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',
)}
>
<span
className={cn('size-4 border', query.onSale ? 'border-sale bg-sale' : 'border-ink-300')}
/>
{t('facet.onSale')}
</Link>
</section>
</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;
}
@@ -1,27 +0,0 @@
import { useTranslations } from 'next-intl';
import type { ProductListItem } from '@sport/types';
import { ProductCard } from './product-card';
export function ProductGrid({ products }: { products: readonly ProductListItem[] }) {
const t = useTranslations('listing');
if (products.length === 0) {
return (
<div className="py-24 text-center">
<p className="text-lg font-medium">{t('empty')}</p>
<p className="text-ink-500 mt-2 text-sm">{t('emptyHint')}</p>
</div>
);
}
return (
<div className="grid grid-cols-2 gap-x-4 gap-y-10 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{products.map((product, index) => (
// The first row is above the fold on every viewport we support.
<ProductCard key={product.id} product={product} priority={index < 4} />
))}
</div>
);
}
@@ -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,58 +0,0 @@
import type { ProductListQuery } from '@sport/api-client';
import { isApiClientError } from '@sport/api-client';
import type { Locale, NavigationMenu, ProductListResult, StorefrontProduct } from '@sport/types';
import { CATALOG_CACHE, getServerApi } from '@/lib/api';
/**
* Server-side catalog reads.
*
* Every page goes through these rather than calling the client directly, so
* caching policy and failure behaviour are decided once. The distinction that
* matters: navigation and listings **degrade** (an empty shelf is better than
* an error page), while a product detail page **fails loudly** so Next can
* render a real 404 and search engines get the right status code.
*/
export async function fetchNavigation(locale: Locale): Promise<NavigationMenu | null> {
try {
return await getServerApi().catalog.getNavigation(locale, CATALOG_CACHE.navigation);
} catch {
// The header must render even if the catalog service is down. Losing the
// menu is survivable; losing every page is not.
return null;
}
}
export async function fetchProducts(
locale: Locale,
query: ProductListQuery,
): Promise<ProductListResult> {
try {
return await getServerApi().catalog.listProducts(locale, query, CATALOG_CACHE.listing);
} catch {
return {
items: [],
pageInfo: { nextCursor: null, hasNextPage: false },
totalCount: 0,
facets: { brands: [], colors: [], sizes: [], priceRange: null },
};
}
}
/** Returns null for a genuine 404 and rethrows anything else. */
export async function fetchProduct(
locale: Locale,
slug: string,
): Promise<StorefrontProduct | null> {
try {
return await getServerApi().catalog.getProduct(locale, slug, CATALOG_CACHE.product);
} catch (error) {
if (isApiClientError(error) && error.status === 404) {
return null;
}
// A 500 or a network failure is not a missing product — surfacing it as a
// 404 would tell search engines to drop a page that still exists.
throw error;
}
}
@@ -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.