Stage M5 and Stage M6
This commit is contained in:
@@ -36,7 +36,7 @@ export default async function AccountLayout({
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<SiteHeader navigation={navigation} />
|
||||
<SiteHeader navigation={navigation} locale={locale as Locale} />
|
||||
<div className="max-w-page px-gutter mx-auto flex w-full flex-1 gap-12 py-16">
|
||||
<aside className="hidden w-56 shrink-0 md:block">
|
||||
<h2 className="text-ink-400 text-xs font-semibold uppercase tracking-widest">
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
import type { Locale } from '@sport/types';
|
||||
|
||||
import { CheckoutForm } from '@/components/commerce/checkout-form';
|
||||
|
||||
type PageProps = { params: Promise<{ locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'placeholder.checkout' });
|
||||
return { title: t('title') };
|
||||
const t = await getTranslations({ locale, namespace: 'checkout' });
|
||||
return { title: t('title'), robots: { index: false, follow: false } };
|
||||
}
|
||||
|
||||
export default async function CheckoutPage({ params }: PageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('placeholder.checkout');
|
||||
const t = await getTranslations('checkout');
|
||||
|
||||
return (
|
||||
<PageScaffold title={t('title')} description={t('body')} milestone="M5 — cart & checkout" />
|
||||
<div className="max-w-page px-gutter mx-auto py-12">
|
||||
<h1 className="mb-10 text-3xl font-black uppercase sm:text-4xl">{t('title')}</h1>
|
||||
<CheckoutForm locale={locale as Locale} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export default async function CheckoutLayout({
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('placeholder.checkout');
|
||||
const t = await getTranslations('checkout');
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { OrderConfirmation } from '@/components/commerce/order-confirmation';
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ locale: string }>;
|
||||
searchParams: Promise<{ order?: string }>;
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'confirmation' });
|
||||
// Never indexed: the URL is a capability granting access to one order.
|
||||
return { title: t('title'), robots: { index: false, follow: false } };
|
||||
}
|
||||
|
||||
export default async function OrderConfirmationPage({ params, searchParams }: PageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const { order } = await searchParams;
|
||||
|
||||
return (
|
||||
<div className="px-gutter mx-auto max-w-3xl py-12">
|
||||
<OrderConfirmation orderId={order ?? ''} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,27 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { PageScaffold } from '@/components/layout/page-scaffold';
|
||||
import { CartView } from '@/components/commerce/cart-view';
|
||||
|
||||
type PageProps = { params: Promise<{ locale: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'placeholder.cart' });
|
||||
return { title: t('title') };
|
||||
const t = await getTranslations({ locale, namespace: 'cart' });
|
||||
// A bag is per-visitor and has no business in an index.
|
||||
return { title: t('title'), robots: { index: false, follow: true } };
|
||||
}
|
||||
|
||||
export default async function CartPage({ params }: PageProps) {
|
||||
const { locale } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
const t = await getTranslations('placeholder.cart');
|
||||
const t = await getTranslations('cart');
|
||||
|
||||
return (
|
||||
<PageScaffold title={t('title')} description={t('body')} milestone="M5 — cart & checkout" />
|
||||
<div className="max-w-page px-gutter mx-auto py-12">
|
||||
<h1 className="mb-10 text-4xl font-black uppercase sm:text-5xl">{t('title')}</h1>
|
||||
<CartView />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export default async function ShopLayout({
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<SiteHeader navigation={navigation} />
|
||||
<SiteHeader navigation={navigation} locale={locale as Locale} />
|
||||
<main className="flex-1">{children}</main>
|
||||
<SiteFooter navigation={navigation} />
|
||||
</div>
|
||||
|
||||
@@ -31,8 +31,13 @@ export default async function SearchPage({ params, searchParams }: PageProps) {
|
||||
locale={locale as Locale}
|
||||
title={query.q ? t('resultsFor', { query: query.q }) : t('title')}
|
||||
description={query.q ? null : t('noQuery')}
|
||||
query={{ ...query, sort: query.q ? 'relevance' : query.sort }}
|
||||
// Relevance is the real default here, so the sort control has to say so.
|
||||
query={{ ...query, sort: query.q ? (query.sort ?? 'relevance') : query.sort }}
|
||||
basePath="/search"
|
||||
// Only when there is something to rank. An empty query through the search
|
||||
// endpoint returns nothing, whereas the listing shows the catalog — which
|
||||
// is the more useful landing state.
|
||||
search={Boolean(query.q)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
|
||||
|
||||
import { LOCALE_TAGS, type Locale } from '@sport/types';
|
||||
|
||||
import { CartProvider } from '@/features/cart/cart-provider';
|
||||
import { routing } from '@/i18n/routing';
|
||||
|
||||
import '@/styles/globals.css';
|
||||
@@ -66,7 +67,11 @@ export default async function LocaleLayout({
|
||||
return (
|
||||
<html lang={LOCALE_TAGS[locale as Locale]} suppressHydrationWarning>
|
||||
<body className="min-h-screen antialiased">
|
||||
<NextIntlClientProvider>{children}</NextIntlClientProvider>
|
||||
<NextIntlClientProvider>
|
||||
{/* Above both route groups: the bag survives moving from browsing
|
||||
into checkout, which is the one transition it must not lose. */}
|
||||
<CartProvider locale={locale as Locale}>{children}</CartProvider>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { Check, Loader2, ShoppingBag } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { VARIANT_AVAILABILITY, type StorefrontVariant } from '@sport/types';
|
||||
import { Button } from '@sport/ui';
|
||||
|
||||
import { useCart } from '@/features/cart/cart-provider';
|
||||
|
||||
/**
|
||||
* The PDP's primary action.
|
||||
*
|
||||
* Confirms in place rather than navigating away. Sending a shopper to the cart
|
||||
* on every add is the single easiest way to end a browsing session, so the
|
||||
* button reports success and the header badge does the rest.
|
||||
*/
|
||||
export function AddToBag({ variant }: { variant: StorefrontVariant | undefined }) {
|
||||
const t = useTranslations('product');
|
||||
const { addLine, pending, error } = useCart();
|
||||
|
||||
/**
|
||||
* Which variant the confirmation belongs to, rather than a bare boolean.
|
||||
*
|
||||
* Derived comparison, not an effect that resets on change: switching size
|
||||
* must drop the "Added" state instantly, and syncing that through an effect
|
||||
* renders one frame claiming the new variant is already in the bag.
|
||||
*/
|
||||
const [addedFor, setAddedFor] = useState<string | null>(null);
|
||||
const added = addedFor !== null && addedFor === variant?.id;
|
||||
|
||||
const soldOut = variant?.availability === VARIANT_AVAILABILITY.OUT_OF_STOCK;
|
||||
const disabled = !variant || soldOut || pending;
|
||||
|
||||
// The confirmation is transient — it says "just now", not "ever".
|
||||
useEffect(() => {
|
||||
if (!added) return;
|
||||
const timer = setTimeout(() => setAddedFor(null), 2500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [added]);
|
||||
|
||||
async function add() {
|
||||
if (!variant) return;
|
||||
if (await addLine(variant.id, 1)) setAddedFor(variant.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Button size="lg" fullWidth disabled={disabled} onClick={() => void add()}>
|
||||
{pending ? <Loader2 className="animate-spin" /> : added ? <Check /> : null}
|
||||
{!variant
|
||||
? t('selectSizePrompt')
|
||||
: soldOut
|
||||
? t('outOfStock')
|
||||
: added
|
||||
? t('addedToBag')
|
||||
: t('addToBag')}
|
||||
{!pending && !added && variant && !soldOut ? <ShoppingBag /> : null}
|
||||
</Button>
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="text-danger text-center text-xs">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { ShoppingBag } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { useCart } from '@/features/cart/cart-provider';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { routes } from '@/lib/routes';
|
||||
|
||||
/**
|
||||
* The bag icon with its item count.
|
||||
*
|
||||
* Renders the icon immediately and the count only once the cart has loaded —
|
||||
* a badge that flashes "0" before showing "3" reads as an emptied bag.
|
||||
*/
|
||||
export function CartBadge() {
|
||||
const t = useTranslations('nav');
|
||||
const { cart, loading } = useCart();
|
||||
|
||||
const count = cart?.totals.itemCount ?? 0;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={routes.cart()}
|
||||
aria-label={count > 0 ? `${t('cart')} (${count})` : t('cart')}
|
||||
title={t('cart')}
|
||||
className="hover:text-volt-600 focus-visible:ring-ring/50 relative grid size-10 place-items-center outline-none transition-colors focus-visible:ring-[3px]"
|
||||
>
|
||||
<ShoppingBag className="size-5" />
|
||||
{!loading && count > 0 ? (
|
||||
<span className="bg-volt-500 text-ink-950 absolute right-0.5 top-0.5 grid min-w-4 place-items-center rounded-full px-1 text-[0.625rem] font-bold leading-4">
|
||||
{count}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
'use client';
|
||||
|
||||
import { Loader2, Minus, Plus, ShoppingBag, Trash2, TriangleAlert } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useFormatter, useTranslations } from 'next-intl';
|
||||
|
||||
import { CART_NOTICE_REASONS } from '@sport/types';
|
||||
import { Button, Skeleton } from '@sport/ui';
|
||||
|
||||
import { useCart } from '@/features/cart/cart-provider';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { formatMoney } from '@/lib/format';
|
||||
import { routes } from '@/lib/routes';
|
||||
|
||||
/**
|
||||
* The bag.
|
||||
*
|
||||
* Quantity controls act on the server and re-render from its response, so the
|
||||
* number shown is always one the API agreed to. That matters here more than
|
||||
* anywhere: this page's job is to be the total the shopper will actually pay.
|
||||
*/
|
||||
export function CartView() {
|
||||
const t = useTranslations('cart');
|
||||
const format = useFormatter();
|
||||
const { cart, loading, pending, error, setQuantity, removeLine } = useCart();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4" aria-busy="true">
|
||||
<Skeleton className="h-28 w-full" />
|
||||
<Skeleton className="h-28 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!cart || cart.lines.length === 0) {
|
||||
return (
|
||||
<div className="py-24 text-center">
|
||||
<ShoppingBag className="text-ink-300 mx-auto size-10" />
|
||||
<p className="mt-6 text-lg font-medium">{t('empty')}</p>
|
||||
<p className="text-ink-500 mt-2 text-sm">{t('emptyHint')}</p>
|
||||
<Button className="mt-8" asChild>
|
||||
<Link href={routes.men()}>{t('startShopping')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-10 lg:grid-cols-[1fr_22rem] lg:gap-16">
|
||||
<div className="space-y-6">
|
||||
{/*
|
||||
Notices explain a bag that changed by itself. Silently correcting a
|
||||
quantity and showing a different total is how a shopper concludes the
|
||||
site is broken.
|
||||
*/}
|
||||
{cart.notices.length > 0 ? (
|
||||
<ul className="border-warning/40 bg-warning/10 space-y-2 border p-4">
|
||||
{cart.notices.map((notice) => (
|
||||
<li key={`${notice.reason}-${notice.variantId}`} className="flex gap-2 text-sm">
|
||||
<TriangleAlert className="text-warning mt-0.5 size-4 shrink-0" />
|
||||
<span>
|
||||
{notice.reason === CART_NOTICE_REASONS.QUANTITY_REDUCED
|
||||
? t('notice.reduced', {
|
||||
name: notice.productName,
|
||||
quantity: notice.quantity ?? 0,
|
||||
})
|
||||
: notice.reason === CART_NOTICE_REASONS.OUT_OF_STOCK
|
||||
? t('notice.soldOut', { name: notice.productName })
|
||||
: t('notice.unavailable')}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="text-danger text-sm">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<ul className="divide-ink-200 divide-y">
|
||||
{cart.lines.map((line) => (
|
||||
<li key={line.variantId} className="flex gap-4 py-6">
|
||||
<div className="bg-ink-100 relative aspect-[4/5] w-24 shrink-0 overflow-hidden">
|
||||
{line.imageUrl ? (
|
||||
<Image
|
||||
src={line.imageUrl}
|
||||
alt=""
|
||||
aria-hidden
|
||||
fill
|
||||
sizes="96px"
|
||||
className="object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-sm font-medium">
|
||||
<Link href={routes.product(line.productSlug)} className="hover:underline">
|
||||
{line.productName}
|
||||
</Link>
|
||||
</h2>
|
||||
<p className="text-ink-500 mt-1 text-xs">{line.variantTitle}</p>
|
||||
<p className="text-ink-400 mt-0.5 font-mono text-[0.625rem]">{line.sku}</p>
|
||||
|
||||
<div className="mt-3 flex items-center gap-4">
|
||||
<QuantityStepper
|
||||
value={line.quantity}
|
||||
max={line.maxQuantity}
|
||||
disabled={pending}
|
||||
label={t('quantityFor', { name: line.productName })}
|
||||
onChange={(next) => void setQuantity(line.variantId, next)}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => void removeLine(line.variantId)}
|
||||
className="text-ink-500 hover:text-danger focus-visible:ring-ring/50 flex items-center gap-1 text-xs outline-none focus-visible:ring-[3px] disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
{t('remove')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold">{formatMoney(line.lineTotal, format)}</p>
|
||||
{line.compareAtPrice ? (
|
||||
<p className="text-ink-400 text-xs line-through">
|
||||
{formatMoney(
|
||||
{
|
||||
amount: line.compareAtPrice.amount * line.quantity,
|
||||
currency: line.compareAtPrice.currency,
|
||||
},
|
||||
format,
|
||||
)}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-ink-400 mt-1 text-xs">
|
||||
{formatMoney(line.unitPrice, format)} × {line.quantity}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<aside className="border-ink-200 h-fit border p-6 lg:sticky lg:top-24">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest">{t('summary')}</h2>
|
||||
|
||||
<dl className="mt-6 space-y-3 text-sm">
|
||||
<Row label={t('subtotal')} value={formatMoney(cart.totals.subtotal, format)} />
|
||||
{/* Named zeroes rather than hidden rows: the total is always the sum
|
||||
of parts a shopper can see, even before shipping exists (M9). */}
|
||||
<Row label={t('shipping')} value={t('shippingAtCheckout')} muted />
|
||||
<div className="border-ink-200 flex items-baseline justify-between border-t pt-3">
|
||||
<dt className="font-semibold">{t('total')}</dt>
|
||||
<dd className="text-lg font-semibold">{formatMoney(cart.totals.total, format)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<Button size="lg" fullWidth className="mt-6" disabled={pending} asChild>
|
||||
<Link href={routes.checkout()}>
|
||||
{pending ? <Loader2 className="animate-spin" /> : null}
|
||||
{t('checkout')}
|
||||
</Link>
|
||||
</Button>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, muted }: { label: string; value: string; muted?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between">
|
||||
<dt className="text-ink-500">{label}</dt>
|
||||
<dd className={muted ? 'text-ink-400 text-xs' : ''}>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuantityStepper({
|
||||
value,
|
||||
max,
|
||||
disabled,
|
||||
label,
|
||||
onChange,
|
||||
}: {
|
||||
value: number;
|
||||
max: number;
|
||||
disabled: boolean;
|
||||
label: string;
|
||||
onChange: (next: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div role="group" aria-label={label} className="border-ink-200 flex items-center border">
|
||||
<StepButton disabled={disabled || value <= 1} onClick={() => onChange(value - 1)} label="−">
|
||||
<Minus className="size-3.5" />
|
||||
</StepButton>
|
||||
<span className="w-8 text-center text-sm tabular-nums">{value}</span>
|
||||
<StepButton
|
||||
// Capped at what the API said is available, so the stepper cannot ask
|
||||
// for stock that will just be reduced again on the way back.
|
||||
disabled={disabled || value >= max}
|
||||
onClick={() => onChange(value + 1)}
|
||||
label="+"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</StepButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepButton({
|
||||
disabled,
|
||||
onClick,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className="hover:bg-ink-100 focus-visible:ring-ring/50 grid size-9 place-items-center outline-none transition-colors focus-visible:ring-[3px] disabled:opacity-30"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
'use client';
|
||||
|
||||
import { Loader2, Lock } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useFormatter, useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { isApiClientError } from '@sport/api-client';
|
||||
import type { Locale } from '@sport/types';
|
||||
import { Button, Input } from '@sport/ui';
|
||||
|
||||
import { useCart } from '@/features/cart/cart-provider';
|
||||
import { Link, useRouter } from '@/i18n/navigation';
|
||||
import { browserApi } from '@/lib/api';
|
||||
import { formatMoney } from '@/lib/format';
|
||||
import { routes } from '@/lib/routes';
|
||||
|
||||
interface Fields {
|
||||
email: string;
|
||||
fullName: string;
|
||||
phone: string;
|
||||
line1: string;
|
||||
ward: string;
|
||||
district: string;
|
||||
province: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
const EMPTY: Fields = {
|
||||
email: '',
|
||||
fullName: '',
|
||||
phone: '',
|
||||
line1: '',
|
||||
ward: '',
|
||||
district: '',
|
||||
province: '',
|
||||
note: '',
|
||||
};
|
||||
|
||||
/**
|
||||
* Guest checkout.
|
||||
*
|
||||
* Deliberately one page and no account. Every field here is one a courier
|
||||
* actually needs; ward and district are optional because rural Vietnamese
|
||||
* addresses frequently have neither, and rejecting those loses real orders.
|
||||
*
|
||||
* Nothing about money is sent. The server prices the bag it already holds, so
|
||||
* the total on this page and the total charged come from the same computation.
|
||||
*/
|
||||
export function CheckoutForm({ locale }: { locale: Locale }) {
|
||||
const t = useTranslations('checkout');
|
||||
const format = useFormatter();
|
||||
const router = useRouter();
|
||||
const { cart, loading, refresh } = useCart();
|
||||
|
||||
const [fields, setFields] = useState<Fields>(EMPTY);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function set(key: keyof Fields, value: string) {
|
||||
setFields((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const order = await browserApi.commerce.placeOrder(locale, {
|
||||
email: fields.email,
|
||||
shippingAddress: {
|
||||
fullName: fields.fullName,
|
||||
phone: fields.phone,
|
||||
line1: fields.line1,
|
||||
line2: null,
|
||||
ward: fields.ward || null,
|
||||
district: fields.district || null,
|
||||
province: fields.province,
|
||||
countryCode: 'VN',
|
||||
postalCode: null,
|
||||
},
|
||||
customerNote: fields.note || null,
|
||||
});
|
||||
|
||||
// The bag is gone server-side; refreshing clears the badge before the
|
||||
// confirmation page renders behind it.
|
||||
await refresh();
|
||||
// The id alone — see OrderConfirmation for why the email must not be here.
|
||||
router.push(`${routes.orderConfirmation()}?order=${order.id}`);
|
||||
} catch (caught) {
|
||||
setError(isApiClientError(caught) ? caught.message : t('failed'));
|
||||
// A rejected placement usually means the bag changed underneath — pull
|
||||
// the corrected version so the summary explains itself.
|
||||
await refresh();
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <p className="text-ink-500 text-sm">{t('loading')}</p>;
|
||||
}
|
||||
|
||||
if (!cart || cart.lines.length === 0) {
|
||||
return (
|
||||
<div className="py-16 text-center">
|
||||
<p className="text-lg font-medium">{t('emptyBag')}</p>
|
||||
{/* The locale-aware Link, not a plain anchor: `routes.men()` is
|
||||
`/men`, and an English shopper following a bare href lands on the
|
||||
Vietnamese listing. */}
|
||||
<Button className="mt-6" asChild>
|
||||
<Link href={routes.men()}>{t('backToShop')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(event) => void submit(event)}
|
||||
className="grid gap-10 lg:grid-cols-[1fr_22rem] lg:gap-16"
|
||||
>
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest">{t('contact')}</h2>
|
||||
<Field label={t('email')} required>
|
||||
<Input
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={fields.email}
|
||||
onChange={(event) => set('email', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest">{t('delivery')}</h2>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label={t('fullName')} required>
|
||||
<Input
|
||||
autoComplete="name"
|
||||
required
|
||||
value={fields.fullName}
|
||||
onChange={(event) => set('fullName', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('phone')} required hint={t('phoneHint')}>
|
||||
<Input
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
required
|
||||
value={fields.phone}
|
||||
onChange={(event) => set('phone', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label={t('address')} required>
|
||||
<Input
|
||||
autoComplete="address-line1"
|
||||
required
|
||||
value={fields.line1}
|
||||
onChange={(event) => set('line1', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Field label={t('ward')}>
|
||||
<Input value={fields.ward} onChange={(event) => set('ward', event.target.value)} />
|
||||
</Field>
|
||||
<Field label={t('district')}>
|
||||
<Input
|
||||
value={fields.district}
|
||||
onChange={(event) => set('district', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t('province')} required>
|
||||
<Input
|
||||
autoComplete="address-level1"
|
||||
required
|
||||
value={fields.province}
|
||||
onChange={(event) => set('province', event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label={t('note')}>
|
||||
<Input value={fields.note} onChange={(event) => set('note', event.target.value)} />
|
||||
</Field>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside className="border-ink-200 h-fit border p-6 lg:sticky lg:top-8">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest">{t('summary')}</h2>
|
||||
|
||||
<ul className="divide-ink-100 mt-4 divide-y">
|
||||
{cart.lines.map((line) => (
|
||||
<li key={line.variantId} className="flex items-center gap-3 py-3">
|
||||
<div className="bg-ink-100 relative size-12 shrink-0 overflow-hidden">
|
||||
{line.imageUrl ? (
|
||||
<Image
|
||||
src={line.imageUrl}
|
||||
alt=""
|
||||
aria-hidden
|
||||
fill
|
||||
sizes="48px"
|
||||
className="object-cover"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-xs">
|
||||
<p className="truncate font-medium">{line.productName}</p>
|
||||
<p className="text-ink-500">
|
||||
{line.variantTitle} × {line.quantity}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs font-semibold">{formatMoney(line.lineTotal, format)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<dl className="border-ink-200 mt-4 space-y-2 border-t pt-4 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-ink-500">{t('subtotal')}</dt>
|
||||
<dd>{formatMoney(cart.totals.subtotal, format)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-ink-500">{t('shipping')}</dt>
|
||||
<dd className="text-ink-400 text-xs">{t('shippingLater')}</dd>
|
||||
</div>
|
||||
<div className="border-ink-200 flex items-baseline justify-between border-t pt-2">
|
||||
<dt className="font-semibold">{t('total')}</dt>
|
||||
<dd className="text-lg font-semibold">{formatMoney(cart.totals.total, format)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{error ? (
|
||||
<p role="alert" className="text-danger mt-4 text-sm">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<Button type="submit" size="lg" fullWidth className="mt-6" disabled={submitting}>
|
||||
{submitting ? <Loader2 className="animate-spin" /> : <Lock />}
|
||||
{t('placeOrder')}
|
||||
</Button>
|
||||
|
||||
{/* Honest about scope: payment providers arrive with M9. */}
|
||||
<p className="text-ink-400 mt-3 text-center text-xs">{t('paymentLater')}</p>
|
||||
</aside>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
required,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-widest">
|
||||
{label}
|
||||
{required ? <span className="text-danger ml-1">*</span> : null}
|
||||
</span>
|
||||
{children}
|
||||
{hint ? <span className="text-ink-400 block text-xs">{hint}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,8 @@ 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.
|
||||
* The button is a real locale-aware link to `?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.
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import { CheckCircle2, Package } from 'lucide-react';
|
||||
import { useFormatter, useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { isApiClientError } from '@sport/api-client';
|
||||
import type { Order } from '@sport/types';
|
||||
import { Badge, Button, Skeleton } from '@sport/ui';
|
||||
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { browserApi } from '@/lib/api';
|
||||
import { formatMoney } from '@/lib/format';
|
||||
import { routes } from '@/lib/routes';
|
||||
|
||||
/**
|
||||
* Order confirmation, fetched by number + email.
|
||||
*
|
||||
* Re-fetched rather than passed through navigation state: this URL is what a
|
||||
* shopper bookmarks, forwards or opens on their phone, and it has to work in
|
||||
* all three cases.
|
||||
*
|
||||
* The id and nothing else. The first version carried `?order=SP-000003&email=…`
|
||||
* because the API's guest lookup needs both — which put a customer's email into
|
||||
* browser history and into the `Referer` of every outbound request this page
|
||||
* makes. A UUIDv7 is unguessable on its own, so holding the link is the
|
||||
* authorisation and no personal data has to travel in the URL to prove it.
|
||||
*/
|
||||
export function OrderConfirmation({ orderId }: { orderId: string }) {
|
||||
const t = useTranslations('confirmation');
|
||||
const format = useFormatter();
|
||||
|
||||
const [order, setOrder] = useState<Order | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const result = await browserApi.commerce.getPlacedOrder(orderId);
|
||||
if (!cancelled) setOrder(result);
|
||||
} catch (caught) {
|
||||
if (!cancelled) setError(isApiClientError(caught) ? caught.message : t('notFound'));
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [orderId, t]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="py-20 text-center">
|
||||
<p className="text-lg font-medium">{t('notFound')}</p>
|
||||
<Button className="mt-6" asChild>
|
||||
<Link href={routes.home()}>{t('backHome')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!order) {
|
||||
return (
|
||||
<div className="space-y-4" aria-busy="true">
|
||||
<Skeleton className="h-12 w-72" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<header className="text-center">
|
||||
<CheckCircle2 className="text-success mx-auto size-12" />
|
||||
<h1 className="mt-6 text-3xl font-black uppercase sm:text-4xl">{t('title')}</h1>
|
||||
<p className="text-ink-500 mt-3 text-sm">{t('body', { email: order.email })}</p>
|
||||
<p className="mt-6 font-mono text-lg font-semibold">{order.orderNumber}</p>
|
||||
<Badge variant="neutral" className="mt-2">
|
||||
{order.status}
|
||||
</Badge>
|
||||
</header>
|
||||
|
||||
<section className="border-ink-200 border">
|
||||
<h2 className="border-ink-200 border-b px-6 py-4 text-xs font-semibold uppercase tracking-widest">
|
||||
{t('items')}
|
||||
</h2>
|
||||
<ul className="divide-ink-100 divide-y">
|
||||
{order.lines.map((line) => (
|
||||
<li key={line.id} className="flex items-center gap-4 px-6 py-4">
|
||||
<Package className="text-ink-300 size-5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{line.productName}</p>
|
||||
<p className="text-ink-500 text-xs">
|
||||
{line.variantTitle} · {line.sku} × {line.quantity}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm font-semibold">{formatMoney(line.lineTotal, format)}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<dl className="border-ink-200 space-y-2 border-t px-6 py-4 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-ink-500">{t('subtotal')}</dt>
|
||||
<dd>{formatMoney(order.subtotal, format)}</dd>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<dt className="font-semibold">{t('total')}</dt>
|
||||
<dd className="text-lg font-semibold">{formatMoney(order.total, format)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="border-ink-200 border p-6">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest">{t('deliveringTo')}</h2>
|
||||
<address className="text-ink-600 mt-3 text-sm not-italic leading-relaxed">
|
||||
{order.shippingAddress.fullName}
|
||||
<br />
|
||||
{order.shippingAddress.phone}
|
||||
<br />
|
||||
{[
|
||||
order.shippingAddress.line1,
|
||||
order.shippingAddress.ward,
|
||||
order.shippingAddress.district,
|
||||
order.shippingAddress.province,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')}
|
||||
</address>
|
||||
</section>
|
||||
|
||||
<div className="text-center">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={routes.men()}>{t('keepShopping')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
'use client';
|
||||
|
||||
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 { Button, cn } from '@sport/ui';
|
||||
import { cn } from '@sport/ui';
|
||||
|
||||
import { discountPercent, formatMoney } from '@/lib/format';
|
||||
|
||||
import { AddToBag } from './add-to-bag';
|
||||
import { ProductGallery } from './product-gallery';
|
||||
|
||||
/**
|
||||
@@ -201,26 +201,7 @@ export function ProductDetail({ product }: { product: StorefrontProduct }) {
|
||||
<div className="space-y-3">
|
||||
<AvailabilityNote variant={selectedVariant} />
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
fullWidth
|
||||
disabled={
|
||||
!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
|
||||
? 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>
|
||||
<AddToBag variant={selectedVariant} />
|
||||
|
||||
{selectedVariant ? (
|
||||
<p className="text-ink-400 text-center text-xs">
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 { fetchProducts, searchProducts } from '@/lib/catalog';
|
||||
import { buildListingHref } from '@/lib/listing-href';
|
||||
|
||||
import { FilterSheet } from './filter-sheet';
|
||||
@@ -27,6 +27,7 @@ export async function ProductListing({
|
||||
description,
|
||||
query,
|
||||
basePath,
|
||||
search = false,
|
||||
}: {
|
||||
locale: Locale;
|
||||
title: string;
|
||||
@@ -34,9 +35,17 @@ export async function ProductListing({
|
||||
description?: string | null;
|
||||
query: ProductListQuery;
|
||||
basePath: string;
|
||||
/**
|
||||
* Route the fetch through ranked search instead of the listing query.
|
||||
*
|
||||
* Same component, same filters, same facets — only the matching and the
|
||||
* ordering change. Keeping one listing component is what stops search
|
||||
* results and category pages drifting apart.
|
||||
*/
|
||||
search?: boolean;
|
||||
}) {
|
||||
const t = await getTranslations('listing');
|
||||
const result = await fetchProducts(locale, query);
|
||||
const result = search ? await searchProducts(locale, query) : 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.
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
import { Loader2, Search } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { Locale, SearchSuggestion } from '@sport/types';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@sport/ui';
|
||||
|
||||
import { useRouter } from '@/i18n/navigation';
|
||||
import { browserApi } from '@/lib/api';
|
||||
import { routes } from '@/lib/routes';
|
||||
|
||||
/** Below this, suggestions are the whole catalog and help nobody. */
|
||||
const MIN_QUERY_LENGTH = 2;
|
||||
|
||||
/**
|
||||
* Search, as an overlay.
|
||||
*
|
||||
* A dialog rather than an inline field because the header has no room for one
|
||||
* at mobile widths, and because search deserves the full attention of the
|
||||
* screen once invoked.
|
||||
*
|
||||
* The dialog itself is the registry's — focus trapping, scroll locking, the
|
||||
* Escape handler, `aria-modal` and returning focus to the trigger are exactly
|
||||
* the infrastructure worth not writing by hand. What is inside it is ours.
|
||||
*/
|
||||
export function SearchDialog({ locale }: { locale: Locale }) {
|
||||
const t = useTranslations('search');
|
||||
const router = useRouter();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<SearchSuggestion[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Guards against a slow response for an old query overwriting a newer one.
|
||||
const latest = useRef('');
|
||||
|
||||
const term = query.trim();
|
||||
const active = term.length >= MIN_QUERY_LENGTH;
|
||||
|
||||
useEffect(() => {
|
||||
latest.current = term;
|
||||
|
||||
// Returns without touching state: a short query simply stops fetching, and
|
||||
// what is already in state is gated out of the render below instead. State
|
||||
// set synchronously in an effect body costs an extra render pass on every
|
||||
// keystroke.
|
||||
if (!active) return;
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await browserApi.catalog.suggest(locale, term);
|
||||
// The API echoes the query back precisely so a late response can be
|
||||
// discarded rather than flickering stale names into the list.
|
||||
if (result.query.trim() === latest.current) {
|
||||
setSuggestions([...result.suggestions]);
|
||||
}
|
||||
} catch {
|
||||
setSuggestions([]);
|
||||
} finally {
|
||||
if (latest.current === term) setLoading(false);
|
||||
}
|
||||
}, 180);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [term, active, locale]);
|
||||
|
||||
function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
const term = query.trim();
|
||||
if (term.length === 0) return;
|
||||
|
||||
setOpen(false);
|
||||
router.push(routes.search(term));
|
||||
}
|
||||
|
||||
function goToProduct(slug: string) {
|
||||
setOpen(false);
|
||||
router.push(routes.product(slug));
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
// Closing resets, so reopening is never haunted by the last search.
|
||||
if (!next) {
|
||||
setQuery('');
|
||||
setSuggestions([]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogTrigger
|
||||
aria-label={t('open')}
|
||||
title={t('open')}
|
||||
className="hover:text-volt-600 focus-visible:ring-ring/50 grid size-10 place-items-center outline-none transition-colors focus-visible:ring-[3px]"
|
||||
>
|
||||
<Search className="size-5" />
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="top-[15%] max-w-xl translate-y-0 gap-0 p-0">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{t('title')}</DialogTitle>
|
||||
<DialogDescription>{t('dialogHint')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={submit} className="border-ink-200 flex items-center gap-3 border-b px-5">
|
||||
<Search className="text-ink-400 size-5 shrink-0" />
|
||||
{/* No `autoFocus`: Radix moves focus to the first focusable element
|
||||
when the dialog opens, which is this input. Setting it as well
|
||||
fights the dialog's own focus management. */}
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t('placeholder')}
|
||||
aria-label={t('title')}
|
||||
className="placeholder:text-ink-400 h-14 w-full bg-transparent text-base outline-none"
|
||||
/>
|
||||
{loading ? <Loader2 className="text-ink-400 size-4 animate-spin" /> : null}
|
||||
</form>
|
||||
|
||||
{active && suggestions.length > 0 ? (
|
||||
<ul className="max-h-80 overflow-y-auto py-2">
|
||||
{suggestions.map((suggestion) => (
|
||||
<li key={suggestion.productSlug}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => goToProduct(suggestion.productSlug)}
|
||||
className="hover:bg-ink-50 focus-visible:bg-ink-50 w-full px-5 py-2.5 text-left text-sm outline-none"
|
||||
>
|
||||
{suggestion.text}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{active && !loading && suggestions.length === 0 ? (
|
||||
<p className="text-ink-500 px-5 py-6 text-center text-sm">{t('noSuggestions')}</p>
|
||||
) : null}
|
||||
|
||||
<p className="border-ink-200 text-ink-400 border-t px-5 py-3 text-xs">
|
||||
{t('enterToSearch')}
|
||||
</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
import { Search, ShoppingBag, User } from 'lucide-react';
|
||||
import { User } from 'lucide-react';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import type { NavigationMenu } from '@sport/types';
|
||||
import type { Locale, NavigationMenu } from '@sport/types';
|
||||
|
||||
import { LanguageSwitcher } from '@/components/language-switcher';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
import { SPORT_NAV, routes } from '@/lib/routes';
|
||||
|
||||
import { CartBadge } from './cart-badge';
|
||||
import { MegaMenu, type MegaMenuEntry } from './mega-menu';
|
||||
import { MobileNav } from './mobile-nav';
|
||||
import { SearchDialog } from './search-dialog';
|
||||
|
||||
/**
|
||||
* Site chrome. Stays a Server Component: only the mega menu, the mobile drawer
|
||||
@@ -20,7 +22,13 @@ import { MobileNav } from './mobile-nav';
|
||||
* 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 }) {
|
||||
export async function SiteHeader({
|
||||
navigation,
|
||||
locale,
|
||||
}: {
|
||||
navigation: NavigationMenu | null;
|
||||
locale: Locale;
|
||||
}) {
|
||||
const t = await getTranslations('nav');
|
||||
const tSports = await getTranslations('sports');
|
||||
|
||||
@@ -76,15 +84,11 @@ export async function SiteHeader({ navigation }: { navigation: NavigationMenu |
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<LanguageSwitcher />
|
||||
|
||||
<HeaderAction href={routes.search()} label={t('search')}>
|
||||
<Search className="size-5" />
|
||||
</HeaderAction>
|
||||
<SearchDialog locale={locale} />
|
||||
<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>
|
||||
<CartBadge />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { isApiClientError } from '@sport/api-client';
|
||||
import type { Cart, Locale } from '@sport/types';
|
||||
|
||||
import { browserApi } from '@/lib/api';
|
||||
|
||||
interface CartState {
|
||||
readonly cart: Cart | null;
|
||||
/** True until the first load resolves, so the badge can stay quiet. */
|
||||
readonly loading: boolean;
|
||||
readonly pending: boolean;
|
||||
readonly error: string | null;
|
||||
addLine: (variantId: string, quantity?: number) => Promise<boolean>;
|
||||
setQuantity: (variantId: string, quantity: number) => Promise<void>;
|
||||
removeLine: (variantId: string) => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const CartContext = createContext<CartState | null>(null);
|
||||
|
||||
/**
|
||||
* One cart, shared by the header badge, the PDP button and the cart page.
|
||||
*
|
||||
* The server is the only source of truth: every mutation returns the whole
|
||||
* recomputed cart and that response replaces local state wholesale. There is no
|
||||
* optimistic arithmetic here on purpose — the API adjusts quantities against
|
||||
* live stock, so a locally incremented number would regularly disagree with the
|
||||
* total on the next render, which is the one number a shopper actually checks.
|
||||
*
|
||||
* The bag is identified by an httpOnly cookie, so there is nothing to persist
|
||||
* and nothing to hydrate from storage.
|
||||
*/
|
||||
export function CartProvider({ locale, children }: { locale: Locale; children: React.ReactNode }) {
|
||||
const [cart, setCart] = useState<Cart | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const run = useCallback(async (operation: () => Promise<Cart>): Promise<boolean> => {
|
||||
setPending(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
setCart(await operation());
|
||||
return true;
|
||||
} catch (caught) {
|
||||
setError(isApiClientError(caught) ? caught.message : 'Something went wrong.');
|
||||
return false;
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** Re-reads the bag after something outside this provider changed it. */
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setCart(await browserApi.commerce.getCart(locale));
|
||||
} catch {
|
||||
// Same reasoning as the initial load: silence beats a banner.
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [locale]);
|
||||
|
||||
// The first read owns its own lifecycle rather than delegating to `refresh`,
|
||||
// so it can be cancelled if the locale changes mid-flight and it never
|
||||
// resolves into an unmounted provider.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const result = await browserApi.commerce.getCart(locale);
|
||||
if (!cancelled) setCart(result);
|
||||
} catch {
|
||||
// A bag that cannot be read is not worth an error banner on every
|
||||
// page — the badge stays empty and the next action retries.
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [locale]);
|
||||
|
||||
const value = useMemo<CartState>(
|
||||
() => ({
|
||||
cart,
|
||||
loading,
|
||||
pending,
|
||||
error,
|
||||
addLine: (variantId, quantity = 1) =>
|
||||
run(() => browserApi.commerce.addCartLine(locale, { variantId, quantity })),
|
||||
setQuantity: async (variantId, quantity) => {
|
||||
await run(() => browserApi.commerce.updateCartLine(locale, variantId, quantity));
|
||||
},
|
||||
removeLine: async (variantId) => {
|
||||
await run(() => browserApi.commerce.removeCartLine(locale, variantId));
|
||||
},
|
||||
refresh,
|
||||
}),
|
||||
[cart, loading, pending, error, locale, run, refresh],
|
||||
);
|
||||
|
||||
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
||||
}
|
||||
|
||||
export function useCart(): CartState {
|
||||
const context = useContext(CartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useCart must be used inside a CartProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -40,6 +40,28 @@ export async function fetchProducts(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranked search.
|
||||
*
|
||||
* Degrades to an empty result like `fetchProducts` does: a search box that
|
||||
* throws a 500 at a shopper is worse than one that says nothing matched.
|
||||
*/
|
||||
export async function searchProducts(
|
||||
locale: Locale,
|
||||
query: ProductListQuery,
|
||||
): Promise<ProductListResult> {
|
||||
try {
|
||||
return await getServerApi().catalog.searchProducts(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,
|
||||
|
||||
@@ -19,6 +19,7 @@ export const routes = {
|
||||
|
||||
cart: () => '/cart',
|
||||
checkout: () => '/checkout',
|
||||
orderConfirmation: () => '/order-confirmation',
|
||||
|
||||
account: () => '/account',
|
||||
accountProfile: () => '/account/profile',
|
||||
|
||||
@@ -82,13 +82,18 @@
|
||||
"comingSoon": "Add to bag arrives with the cart milestone.",
|
||||
"previousImage": "Previous image",
|
||||
"nextImage": "Next image",
|
||||
"goToImage": "Go to image {index}"
|
||||
"goToImage": "Go to image {index}",
|
||||
"addedToBag": "Added to bag"
|
||||
},
|
||||
"search": {
|
||||
"title": "Search",
|
||||
"placeholder": "Search products, brands, sports",
|
||||
"placeholder": "Search products…",
|
||||
"resultsFor": "Results for “{query}”",
|
||||
"noQuery": "Type something to start searching."
|
||||
"noQuery": "Type something to start searching.",
|
||||
"open": "Search",
|
||||
"dialogHint": "Type to see matching products.",
|
||||
"noSuggestions": "No matching products.",
|
||||
"enterToSearch": "Press Enter to see all results"
|
||||
},
|
||||
"breadcrumbs": {
|
||||
"home": "Home"
|
||||
@@ -126,18 +131,10 @@
|
||||
"planned": "Planned: {milestone}"
|
||||
},
|
||||
"placeholder": {
|
||||
"cart": {
|
||||
"title": "Your bag",
|
||||
"body": "Line items with variant title and live availability. Totals are always recomputed by the API — the client never submits a price."
|
||||
},
|
||||
"blog": {
|
||||
"title": "Journal",
|
||||
"body": "Editorial content served from the CMS module: training guides, drops and athlete stories."
|
||||
},
|
||||
"checkout": {
|
||||
"title": "Checkout",
|
||||
"body": "Contact, shipping address, delivery method, payment. Each step is server-validated and stock is reserved before a payment intent is created."
|
||||
},
|
||||
"account": {
|
||||
"title": "Account",
|
||||
"body": "Overview: recent orders, saved addresses and profile at a glance."
|
||||
@@ -158,5 +155,60 @@
|
||||
"title": "Wishlist",
|
||||
"body": "Saved variants, and the signal source for back-in-stock notifications."
|
||||
}
|
||||
},
|
||||
"cart": {
|
||||
"title": "Bag",
|
||||
"empty": "Your bag is empty.",
|
||||
"emptyHint": "Once you add something it will show up here.",
|
||||
"startShopping": "Start shopping",
|
||||
"summary": "Summary",
|
||||
"subtotal": "Subtotal",
|
||||
"shipping": "Shipping",
|
||||
"shippingAtCheckout": "Calculated later",
|
||||
"total": "Total",
|
||||
"checkout": "Checkout",
|
||||
"remove": "Remove",
|
||||
"quantityFor": "Quantity for {name}",
|
||||
"notice": {
|
||||
"reduced": "{name} was reduced to {quantity} — that is all we have left.",
|
||||
"soldOut": "{name} sold out and was removed from your bag.",
|
||||
"unavailable": "An item is no longer available and was removed from your bag."
|
||||
}
|
||||
},
|
||||
"checkout": {
|
||||
"title": "Checkout",
|
||||
"contact": "Contact",
|
||||
"delivery": "Delivery",
|
||||
"email": "Email",
|
||||
"fullName": "Full name",
|
||||
"phone": "Phone",
|
||||
"phoneHint": "So the courier can reach you",
|
||||
"address": "Street address",
|
||||
"ward": "Ward",
|
||||
"district": "District",
|
||||
"province": "Province or city",
|
||||
"note": "Delivery note",
|
||||
"summary": "Your order",
|
||||
"subtotal": "Subtotal",
|
||||
"shipping": "Shipping",
|
||||
"shippingLater": "Calculated later",
|
||||
"total": "Total",
|
||||
"placeOrder": "Place order",
|
||||
"paymentLater": "Payment options arrive with the payments milestone.",
|
||||
"failed": "We could not place your order.",
|
||||
"loading": "Loading your bag…",
|
||||
"emptyBag": "There is nothing in your bag.",
|
||||
"backToShop": "Back to shopping"
|
||||
},
|
||||
"confirmation": {
|
||||
"title": "Order placed",
|
||||
"body": "We have your order. A confirmation goes to {email} once email lands.",
|
||||
"items": "Items",
|
||||
"subtotal": "Subtotal",
|
||||
"total": "Total",
|
||||
"deliveringTo": "Delivering to",
|
||||
"keepShopping": "Keep shopping",
|
||||
"notFound": "We could not find that order.",
|
||||
"backHome": "Back to home"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,13 +82,18 @@
|
||||
"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}"
|
||||
"goToImage": "Xem ảnh {index}",
|
||||
"addedToBag": "Đã thêm vào giỏ"
|
||||
},
|
||||
"search": {
|
||||
"title": "Tìm kiếm",
|
||||
"placeholder": "Tìm sản phẩm, thương hiệu, môn thể thao",
|
||||
"placeholder": "Tìm sản phẩm…",
|
||||
"resultsFor": "Kết quả cho “{query}”",
|
||||
"noQuery": "Nhập từ khoá để bắt đầu tìm kiếm."
|
||||
"noQuery": "Nhập từ khoá để bắt đầu tìm kiếm.",
|
||||
"open": "Tìm kiếm",
|
||||
"dialogHint": "Nhập để xem sản phẩm phù hợp.",
|
||||
"noSuggestions": "Không có sản phẩm phù hợp.",
|
||||
"enterToSearch": "Nhấn Enter để xem tất cả kết quả"
|
||||
},
|
||||
"breadcrumbs": {
|
||||
"home": "Trang chủ"
|
||||
@@ -126,18 +131,10 @@
|
||||
"planned": "Dự kiến: {milestone}"
|
||||
},
|
||||
"placeholder": {
|
||||
"cart": {
|
||||
"title": "Giỏ hàng",
|
||||
"body": "Danh sách sản phẩm kèm phiên bản và tình trạng còn hàng. Tổng tiền luôn được tính lại ở phía máy chủ — client không bao giờ gửi giá."
|
||||
},
|
||||
"blog": {
|
||||
"title": "Bài viết",
|
||||
"body": "Nội dung biên tập từ module CMS: hướng dẫn tập luyện, bộ sưu tập mới và câu chuyện vận động viên."
|
||||
},
|
||||
"checkout": {
|
||||
"title": "Thanh toán",
|
||||
"body": "Thông tin liên hệ, địa chỉ giao hàng, phương thức vận chuyển và thanh toán. Mỗi bước đều được kiểm tra ở máy chủ và hàng được giữ trước khi tạo yêu cầu thanh toán."
|
||||
},
|
||||
"account": {
|
||||
"title": "Tài khoản",
|
||||
"body": "Tổng quan: đơn hàng gần đây, địa chỉ đã lưu và hồ sơ cá nhân."
|
||||
@@ -158,5 +155,60 @@
|
||||
"title": "Yêu thích",
|
||||
"body": "Sản phẩm đã lưu, đồng thời là nguồn dữ liệu cho thông báo khi có hàng trở lại."
|
||||
}
|
||||
},
|
||||
"cart": {
|
||||
"title": "Giỏ hàng",
|
||||
"empty": "Giỏ hàng đang trống.",
|
||||
"emptyHint": "Sản phẩm bạn thêm sẽ hiện ở đây.",
|
||||
"startShopping": "Bắt đầu mua sắm",
|
||||
"summary": "Tóm tắt",
|
||||
"subtotal": "Tạm tính",
|
||||
"shipping": "Phí vận chuyển",
|
||||
"shippingAtCheckout": "Tính sau",
|
||||
"total": "Tổng cộng",
|
||||
"checkout": "Thanh toán",
|
||||
"remove": "Xoá",
|
||||
"quantityFor": "Số lượng cho {name}",
|
||||
"notice": {
|
||||
"reduced": "{name} đã giảm còn {quantity} — đó là số hàng còn lại.",
|
||||
"soldOut": "{name} đã hết hàng và được bỏ khỏi giỏ.",
|
||||
"unavailable": "Một sản phẩm không còn bán và đã được bỏ khỏi giỏ."
|
||||
}
|
||||
},
|
||||
"checkout": {
|
||||
"title": "Thanh toán",
|
||||
"contact": "Liên hệ",
|
||||
"delivery": "Giao hàng",
|
||||
"email": "Email",
|
||||
"fullName": "Họ và tên",
|
||||
"phone": "Số điện thoại",
|
||||
"phoneHint": "Để shipper liên hệ với bạn",
|
||||
"address": "Địa chỉ",
|
||||
"ward": "Phường/Xã",
|
||||
"district": "Quận/Huyện",
|
||||
"province": "Tỉnh/Thành phố",
|
||||
"note": "Ghi chú giao hàng",
|
||||
"summary": "Đơn hàng của bạn",
|
||||
"subtotal": "Tạm tính",
|
||||
"shipping": "Phí vận chuyển",
|
||||
"shippingLater": "Tính sau",
|
||||
"total": "Tổng cộng",
|
||||
"placeOrder": "Đặt hàng",
|
||||
"paymentLater": "Các hình thức thanh toán sẽ có ở giai đoạn thanh toán.",
|
||||
"failed": "Không đặt được đơn hàng.",
|
||||
"loading": "Đang tải giỏ hàng…",
|
||||
"emptyBag": "Giỏ hàng của bạn đang trống.",
|
||||
"backToShop": "Quay lại mua sắm"
|
||||
},
|
||||
"confirmation": {
|
||||
"title": "Đặt hàng thành công",
|
||||
"body": "Chúng tôi đã nhận đơn của bạn. Email xác nhận sẽ gửi tới {email} khi tính năng email sẵn sàng.",
|
||||
"items": "Sản phẩm",
|
||||
"subtotal": "Tạm tính",
|
||||
"total": "Tổng cộng",
|
||||
"deliveringTo": "Giao đến",
|
||||
"keepShopping": "Tiếp tục mua sắm",
|
||||
"notFound": "Không tìm thấy đơn hàng.",
|
||||
"backHome": "Về trang chủ"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user