Wip stage M4
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user