Stage M5 and Stage M6

This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent 7688657d3c
commit 624a6402bf
77 changed files with 4879 additions and 176 deletions
@@ -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)}
/>
);
}
+6 -1
View File
@@ -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;
}
+22
View File
@@ -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,
+1
View File
@@ -19,6 +19,7 @@ export const routes = {
cart: () => '/cart',
checkout: () => '/checkout',
orderConfirmation: () => '/order-confirmation',
account: () => '/account',
accountProfile: () => '/account/profile',
+63 -11
View File
@@ -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"
}
}
+63 -11
View File
@@ -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ủ"
}
}