This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent 1e356a2578
commit d9f159a8c3
84 changed files with 7992 additions and 180 deletions
@@ -0,0 +1,112 @@
import type { Metadata } from 'next';
import Image from 'next/image';
import { notFound } from 'next/navigation';
import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server';
import type { ContentDetail, Locale } from '@sport/types';
import { Markdown } from '@/components/commerce/markdown';
import { Link, redirect } from '@/i18n/navigation';
import { getServerApi } from '@/lib/api';
import { routes } from '@/lib/routes';
type PageProps = { params: Promise<{ locale: string; slug: string }> };
async function fetchPost(locale: Locale, slug: string): Promise<ContentDetail | null> {
try {
return await getServerApi().content.getPost(locale, slug, { next: { revalidate: 300 } });
} catch {
return null;
}
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale, slug } = await params;
const post = await fetchPost(locale as Locale, slug);
if (!post) return {};
const languages = Object.fromEntries(
Object.entries(post.alternateSlugs ?? {}).map(([alt, altSlug]) => [
alt,
alt === 'vi' ? `/blog/${altSlug}` : `/${alt}/blog/${altSlug}`,
]),
);
return {
title: post.seo.metaTitle ?? post.title,
description: post.seo.metaDescription ?? undefined,
alternates: {
canonical: locale === 'vi' ? `/blog/${post.slug}` : `/${locale}/blog/${post.slug}`,
languages,
},
openGraph: {
title: post.title,
description: post.seo.metaDescription ?? undefined,
images: post.coverImageUrl ? [{ url: post.coverImageUrl }] : undefined,
type: 'article',
publishedTime: post.publishedAt ?? undefined,
},
};
}
export default async function PostPage({ params }: PageProps) {
const { locale, slug } = await params;
setRequestLocale(locale);
const post = await fetchPost(locale as Locale, slug);
if (!post) notFound();
// The API resolves a slug from any locale, so `/en/blog/<vi-slug>` finds the
// post. Send it to this locale's canonical URL rather than serving one entry
// at two addresses. The locale-aware redirect, never next/navigation's.
if (post.slug !== slug) {
redirect({ href: routes.post(post.slug), locale });
}
const t = await getTranslations('blog');
const format = await getFormatter();
return (
<article className="max-w-page px-gutter mx-auto py-12">
<Link href={routes.blog()} className="text-ink-500 hover:text-ink-950 text-xs">
← {t('backToJournal')}
</Link>
<header className="mt-6 max-w-prose">
{post.publishedAt ? (
<time
dateTime={post.publishedAt}
className="text-ink-400 text-xs uppercase tracking-widest"
>
{format.dateTime(new Date(post.publishedAt), { dateStyle: 'long' })}
</time>
) : null}
<h1 className="mt-3 text-3xl font-black uppercase leading-tight tracking-tight sm:text-4xl">
{post.title}
</h1>
{post.authorName ? (
<p className="text-ink-500 mt-3 text-sm">{t('by', { name: post.authorName })}</p>
) : null}
</header>
{post.coverImageUrl ? (
<div className="bg-ink-100 relative mt-8 aspect-[16/9] w-full overflow-hidden">
<Image
src={post.coverImageUrl}
alt=""
fill
sizes="(min-width: 1280px) 1200px, 100vw"
priority
className="object-cover"
/>
</div>
) : null}
<div className="mt-10">
<Markdown>{post.body}</Markdown>
</div>
</article>
);
}
@@ -1,21 +1,87 @@
import type { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import Image from 'next/image';
import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold';
import type { ContentSummary, Locale } from '@sport/types';
import { Link } from '@/i18n/navigation';
import { getServerApi } from '@/lib/api';
import { routes } from '@/lib/routes';
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.blog' });
return { title: t('title') };
const t = await getTranslations({ locale, namespace: 'blog' });
return { title: t('title'), description: t('intro') };
}
export default async function BlogPage({ params }: PageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('placeholder.blog');
const t = await getTranslations('blog');
const format = await getFormatter();
return <PageScaffold title={t('title')} description={t('body')} milestone="M7 — content" />;
let posts: readonly ContentSummary[] = [];
try {
const result = await getServerApi().content.listPosts(
locale as Locale,
{ perPage: 24 },
{ next: { revalidate: 300 } },
);
posts = result.items;
} catch {
// An empty journal beats an error page; the rest of the store still works.
}
return (
<div className="max-w-page px-gutter mx-auto py-12">
<header className="max-w-2xl">
<h1 className="text-3xl font-black uppercase tracking-tight sm:text-4xl">{t('title')}</h1>
<p className="text-ink-500 mt-3 text-sm">{t('intro')}</p>
</header>
{posts.length === 0 ? (
<p className="text-ink-500 mt-12 text-sm">{t('empty')}</p>
) : (
<ul className="mt-12 grid gap-x-6 gap-y-12 sm:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<li key={post.id}>
<Link href={routes.post(post.slug)} className="group block">
<div className="bg-ink-100 relative aspect-[4/3] overflow-hidden">
{post.coverImageUrl ? (
<Image
src={post.coverImageUrl}
alt=""
fill
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
className="object-cover transition-transform duration-500 group-hover:scale-105"
/>
) : null}
</div>
{post.publishedAt ? (
<time
dateTime={post.publishedAt}
className="text-ink-400 mt-4 block text-xs uppercase tracking-widest"
>
{format.dateTime(new Date(post.publishedAt), { dateStyle: 'medium' })}
</time>
) : null}
<h2 className="mt-2 text-base font-bold leading-snug group-hover:underline">
{post.title}
</h2>
{post.excerpt ? (
<p className="text-ink-500 mt-2 line-clamp-3 text-sm">{post.excerpt}</p>
) : null}
</Link>
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,63 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { setRequestLocale } from 'next-intl/server';
import type { ContentDetail, Locale } from '@sport/types';
import { Markdown } from '@/components/commerce/markdown';
import { redirect } from '@/i18n/navigation';
import { getServerApi } from '@/lib/api';
import { routes } from '@/lib/routes';
type PageProps = { params: Promise<{ locale: string; slug: string }> };
async function fetchPage(locale: Locale, slug: string): Promise<ContentDetail | null> {
try {
return await getServerApi().content.getPage(locale, slug, { next: { revalidate: 300 } });
} catch {
return null;
}
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale, slug } = await params;
const page = await fetchPage(locale as Locale, slug);
if (!page) return {};
const languages = Object.fromEntries(
Object.entries(page.alternateSlugs ?? {}).map(([alt, altSlug]) => [
alt,
alt === 'vi' ? `/pages/${altSlug}` : `/${alt}/pages/${altSlug}`,
]),
);
return {
title: page.seo.metaTitle ?? page.title,
description: page.seo.metaDescription ?? undefined,
alternates: {
canonical: locale === 'vi' ? `/pages/${page.slug}` : `/${locale}/pages/${page.slug}`,
languages,
},
};
}
export default async function ContentPage({ params }: PageProps) {
const { locale, slug } = await params;
setRequestLocale(locale);
const page = await fetchPage(locale as Locale, slug);
if (!page) notFound();
if (page.slug !== slug) {
redirect({ href: routes.page(page.slug), locale });
}
return (
<div className="max-w-page px-gutter mx-auto py-12">
<h1 className="text-3xl font-black uppercase tracking-tight sm:text-4xl">{page.title}</h1>
<div className="mt-8">
<Markdown>{page.body}</Markdown>
</div>
</div>
);
}
@@ -5,6 +5,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server';
import { LOCALES, type Locale } from '@sport/types';
import { ProductDetail } from '@/components/commerce/product-detail';
import { ProductReviews } from '@/components/commerce/product-reviews';
import { Link, redirect } from '@/i18n/navigation';
import { getServerApi } from '@/lib/api';
import { fetchProduct } from '@/lib/catalog';
@@ -130,6 +131,8 @@ export default async function ProductPage({ params }: PageProps) {
</nav>
<ProductDetail product={product} />
<ProductReviews productId={product.id} />
</div>
);
}
@@ -12,6 +12,8 @@ import { Link } from '@/i18n/navigation';
import { formatMoney } from '@/lib/format';
import { routes } from '@/lib/routes';
import { DiscountForm } from './discount-form';
/**
* The bag.
*
@@ -151,8 +153,18 @@ export function CartView() {
<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>
<div className="border-ink-200 mt-6 border-b pb-6">
<DiscountForm applied={cart.discounts} rejected={cart.rejectedDiscounts} />
</div>
<dl className="mt-6 space-y-3 text-sm">
<Row label={t('subtotal')} value={formatMoney(cart.totals.subtotal, format)} />
{cart.totals.discount.amount > 0 ? (
<div className="flex items-baseline justify-between">
<dt className="text-ink-500">{t('discount')}</dt>
<dd className="text-success">−{formatMoney(cart.totals.discount, format)}</dd>
</div>
) : null}
{/* 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 />
@@ -237,6 +237,12 @@ export function CheckoutForm({ locale }: { locale: Locale }) {
<dt className="text-ink-500">{t('subtotal')}</dt>
<dd>{formatMoney(cart.totals.subtotal, format)}</dd>
</div>
{cart.totals.discount.amount > 0 ? (
<div className="flex justify-between">
<dt className="text-ink-500">{t('discount')}</dt>
<dd className="text-success">−{formatMoney(cart.totals.discount, format)}</dd>
</div>
) : null}
<div className="flex justify-between">
<dt className="text-ink-500">{t('shipping')}</dt>
<dd className="text-ink-400 text-xs">{t('shippingLater')}</dd>
@@ -0,0 +1,109 @@
'use client';
import { Loader2, Tag, X } from 'lucide-react';
import { useFormatter, useTranslations } from 'next-intl';
import { useState } from 'react';
import { DISCOUNT_REJECTIONS, type AppliedDiscount, type RejectedDiscount } from '@sport/types';
import { Button, Input } from '@sport/ui';
import { useCart } from '@/features/cart/cart-provider';
import { formatMoney } from '@/lib/format';
/**
* Discount codes on the bag.
*
* Applied discounts are listed with what each one took off, because "you saved
* 258.000 ₫" is the thing a shopper is actually checking. Automatic promotions
* appear in the same list — from the customer's side there is no difference
* between an offer they earned and one they typed for.
*/
export function DiscountForm({
applied,
rejected,
}: {
applied: readonly AppliedDiscount[];
rejected: readonly RejectedDiscount[];
}) {
const t = useTranslations('cart');
const format = useFormatter();
const { applyCode, removeCode, pending } = useCart();
const [code, setCode] = useState('');
async function submit(event: React.FormEvent) {
event.preventDefault();
const trimmed = code.trim();
if (!trimmed) return;
const updated = await applyCode(trimmed);
/**
* Cleared only when the code actually applied.
*
* The request succeeding is not the same as the discount applying — an
* expired or fully-claimed code returns 200 with a rejection. Clearing on
* the HTTP result wiped the box while the error explaining it was still on
* screen, leaving nothing to correct.
*/
const accepted = updated?.discounts.some(
(discount) => discount.code?.toUpperCase() === trimmed.toUpperCase(),
);
if (accepted) setCode('');
}
return (
<div className="space-y-3">
{applied.length > 0 ? (
<ul className="space-y-2">
{applied.map((discount) => (
<li key={discount.id} className="flex items-center gap-2 text-sm">
<Tag className="text-success size-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{discount.name}</span>
<span className="text-success font-medium">
−{formatMoney(discount.amount, format)}
</span>
{discount.code ? (
<button
type="button"
disabled={pending}
onClick={() => void removeCode(discount.code as string)}
aria-label={t('removeCode', { code: discount.code })}
className="text-ink-400 hover:text-danger focus-visible:ring-ring/50 outline-none focus-visible:ring-[3px] disabled:opacity-50"
>
<X className="size-3.5" />
</button>
) : null}
</li>
))}
</ul>
) : null}
{rejected.map((rejection) => (
<p key={rejection.code} role="alert" className="text-danger text-xs">
{rejection.reason === DISCOUNT_REJECTIONS.MINIMUM_NOT_MET && rejection.minimumSubtotal
? t('discountError.minimum', {
code: rejection.code,
amount: formatMoney(rejection.minimumSubtotal, format),
})
: t(`discountError.${rejection.reason}`, { code: rejection.code })}
</p>
))}
<form onSubmit={(event) => void submit(event)} className="flex gap-2">
<Input
value={code}
onChange={(event) => setCode(event.target.value)}
placeholder={t('discountPlaceholder')}
aria-label={t('discountLabel')}
className="h-10 uppercase"
/>
<Button type="submit" variant="secondary" size="sm" disabled={pending || !code.trim()}>
{pending ? <Loader2 className="animate-spin" /> : null}
{t('applyCode')}
</Button>
</form>
</div>
);
}
@@ -0,0 +1,113 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Link } from '@/i18n/navigation';
/**
* Renders editorial Markdown.
*
* `react-markdown` parses to React elements rather than to an HTML string, so
* there is no `dangerouslySetInnerHTML` anywhere in this path. That makes
* injection structurally impossible instead of sanitised-away: a `<script>` in
* a post body is escaped text, not a tag, because it never becomes markup.
*
* Elements are mapped explicitly rather than inheriting browser defaults. The
* storefront has a deliberate type scale, and unstyled `<h2>`/`<p>` from a CMS
* body is exactly how editorial pages end up looking like a different website.
*/
export function Markdown({ children }: { children: string }) {
return (
<div className="max-w-prose">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children: content }) => (
<h2 className="mt-10 text-2xl font-black uppercase tracking-tight first:mt-0">
{content}
</h2>
),
// Demoted one level: the page already renders the title as <h1>, and
// a second <h1> in the body breaks the document outline.
h2: ({ children: content }) => (
<h3 className="mt-8 text-lg font-bold first:mt-0">{content}</h3>
),
h3: ({ children: content }) => (
<h4 className="mt-6 text-base font-semibold first:mt-0">{content}</h4>
),
p: ({ children: content }) => (
<p className="text-ink-700 mt-4 text-sm leading-relaxed first:mt-0">{content}</p>
),
ul: ({ children: content }) => (
<ul className="text-ink-700 mt-4 list-disc space-y-1.5 pl-5 text-sm">{content}</ul>
),
ol: ({ children: content }) => (
<ol className="text-ink-700 mt-4 list-decimal space-y-1.5 pl-5 text-sm">{content}</ol>
),
blockquote: ({ children: content }) => (
<blockquote className="border-ink-950 text-ink-600 mt-6 border-l-2 pl-4 italic">
{content}
</blockquote>
),
strong: ({ children: content }) => (
<strong className="text-ink-950 font-semibold">{content}</strong>
),
hr: () => <hr className="border-ink-200 mt-8" />,
code: ({ children: content }) => (
<code className="bg-ink-100 rounded px-1.5 py-0.5 font-mono text-xs">{content}</code>
),
table: ({ children: content }) => (
<div className="mt-6 overflow-x-auto">
<table className="w-full text-left text-sm">{content}</table>
</div>
),
th: ({ children: content }) => (
<th className="border-ink-200 border-b px-3 py-2 text-xs font-semibold uppercase tracking-widest">
{content}
</th>
),
td: ({ children: content }) => (
<td className="border-ink-100 text-ink-700 border-b px-3 py-2">{content}</td>
),
a: ({ href, children: content }) => {
const target = href ?? '';
/**
* Internal links go through the locale-aware `Link`.
*
* A raw `<a href="/men">` inside a Vietnamese post drops the locale
* prefix and bounces the reader to the default language — the same
* bug already fixed three times elsewhere in this storefront.
*/
if (target.startsWith('/')) {
return (
<Link href={target} className="underline underline-offset-2">
{content}
</Link>
);
}
return (
<a
href={target}
// Editorial copy is operator-authored, but `noopener` costs
// nothing and `nofollow` keeps a compromised account from
// handing out the store's link equity.
target="_blank"
rel="noopener noreferrer nofollow"
className="underline underline-offset-2"
>
{content}
</a>
);
},
// Images are deliberately unsupported in bodies: they would bypass
// the media library, hotlink to arbitrary hosts, and arrive without
// dimensions, which is a layout shift on every page they appear on.
img: () => null,
}}
>
{children}
</ReactMarkdown>
</div>
);
}
@@ -13,6 +13,8 @@ import { browserApi } from '@/lib/api';
import { formatMoney } from '@/lib/format';
import { routes } from '@/lib/routes';
import { ReviewForm } from './review-form';
/**
* Order confirmation, fetched by number + email.
*
@@ -131,6 +133,8 @@ export function OrderConfirmation({ orderId }: { orderId: string }) {
</address>
</section>
<ReviewForm orderId={orderId} email={order.email} />
<div className="text-center">
<Button variant="outline" asChild>
<Link href={routes.men()}>{t('keepShopping')}</Link>
@@ -0,0 +1,142 @@
import { Star } from 'lucide-react';
import { getFormatter, getTranslations } from 'next-intl/server';
import type { ProductReviews as ProductReviewsPayload } from '@sport/api-client';
import { cn } from '@sport/ui';
import { getServerApi } from '@/lib/api';
/**
* Reviews for one product.
*
* A server component on purpose: reviews are the text a product page is
* *about* as far as a search engine is concerned, and fetching them on the
* client would leave them out of the HTML entirely. It also means no loading
* spinner in the middle of the page.
*
* Degrades to nothing if the call fails — a product page missing its reviews
* is a smaller failure than a product page that 500s.
*/
export async function ProductReviews({ productId }: { productId: string }) {
const t = await getTranslations('reviews');
const format = await getFormatter();
let payload: ProductReviewsPayload;
try {
payload = await getServerApi().reviews.listForProduct(
productId,
{ perPage: 10 },
// 60s, matching the product listing. Reviews are moderated, so a new one
// appearing a minute late is not a problem worth a cache miss per view.
{ next: { revalidate: 60 } },
);
} catch {
return null;
}
const { summary, items } = payload;
return (
<section id="reviews" className="border-ink-200 mt-16 border-t pt-10">
<h2 className="text-xl font-black uppercase tracking-tight">{t('heading')}</h2>
{summary.count === 0 ? (
<p className="text-ink-500 mt-4 text-sm">{t('empty')}</p>
) : (
<>
<div className="mt-6 flex flex-col gap-8 sm:flex-row sm:items-start">
<div className="shrink-0">
<div className="flex items-baseline gap-2">
<span className="text-5xl font-black tabular-nums">
{format.number(summary.average, { minimumFractionDigits: 1 })}
</span>
<span className="text-ink-400 text-sm">/ 5</span>
</div>
<Stars rating={Math.round(summary.average)} className="mt-2" />
<p className="text-ink-500 mt-2 text-xs">{t('count', { count: summary.count })}</p>
</div>
{/*
The distribution, not just the average. A 3.0 built from straight
3s and a 3.0 built from 5s and 1s are different products, and the
bars are the only thing that says which one this is.
*/}
<ul className="w-full max-w-sm space-y-1.5">
{summary.distribution.map((bucket) => {
const share = summary.count === 0 ? 0 : (bucket.count / summary.count) * 100;
return (
<li key={bucket.rating} className="flex items-center gap-3 text-xs">
<span className="text-ink-500 w-8 shrink-0 tabular-nums">{bucket.rating}★</span>
<span
className="bg-ink-100 h-1.5 flex-1 overflow-hidden rounded-full"
// The bar is decoration; the number beside it is the data.
aria-hidden
>
<span
className="bg-ink-950 block h-full rounded-full"
style={{ width: `${share}%` }}
/>
</span>
<span className="text-ink-500 w-6 shrink-0 text-right tabular-nums">
{bucket.count}
</span>
</li>
);
})}
</ul>
</div>
<ul className="divide-ink-100 mt-10 divide-y">
{items.map((review) => (
<li key={review.id} className="py-6">
<div className="flex flex-wrap items-center gap-3">
<Stars rating={review.rating} />
{review.title ? <h3 className="text-sm font-semibold">{review.title}</h3> : null}
</div>
<p className="text-ink-500 mt-1.5 text-xs">
{review.authorName}
{' · '}
{format.dateTime(new Date(review.createdAt), { dateStyle: 'medium' })}
{review.isVerifiedPurchase ? (
<>
{' · '}
<span className="text-ink-950 font-medium">{t('verified')}</span>
</>
) : null}
</p>
{review.body ? (
<p className="text-ink-700 mt-3 whitespace-pre-line text-sm leading-relaxed">
{review.body}
</p>
) : null}
</li>
))}
</ul>
{payload.pageInfo.totalItems > items.length ? (
<p className="text-ink-500 mt-4 text-xs">
{t('showing', { shown: items.length, total: payload.pageInfo.totalItems })}
</p>
) : null}
</>
)}
</section>
);
}
export function Stars({ rating, className }: { rating: number; className?: string }) {
return (
<span className={cn('flex items-center gap-0.5', className)} aria-label={`${rating}/5`}>
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
aria-hidden
className={cn('size-4', star <= rating ? 'fill-ink-950 text-ink-950' : 'text-ink-300')}
/>
))}
</span>
);
}
@@ -0,0 +1,232 @@
'use client';
import { Star } from 'lucide-react';
import { useLocale, useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { isApiClientError } from '@sport/api-client';
import type { Locale, ReviewableItem } from '@sport/types';
import { Button, Input, cn } from '@sport/ui';
import { browserApi } from '@/lib/api';
/**
* Rate the items on one order.
*
* Lives on the confirmation page because that URL is the only order-scoped page
* a guest can reach — it is what the shopper bookmarks and what the receipt
* email links to. The email is taken from the order rather than typed: holding
* the link already proves ownership (see OrderConfirmation), so asking for it
* again would be theatre that also puts the address back on screen.
*/
export function ReviewForm({ orderId, email }: { orderId: string; email: string }) {
const t = useTranslations('reviewForm');
const locale = useLocale() as Locale;
const [items, setItems] = useState<readonly ReviewableItem[] | null>(null);
useEffect(() => {
let cancelled = false;
browserApi.reviews
.listReviewable({ orderId, email }, locale)
.then((result) => {
if (!cancelled) setItems(result);
})
.catch(() => {
// Silent: a shopper who cannot review is shown nothing, not an error.
// The order itself rendering is what matters on this page.
if (!cancelled) setItems([]);
});
return () => {
cancelled = true;
};
}, [orderId, email, locale]);
if (!items || items.length === 0) return null;
return (
<section className="border-ink-200 border p-6">
<h2 className="text-xs font-semibold uppercase tracking-widest">{t('heading')}</h2>
<p className="text-ink-500 mt-2 text-sm">{t('intro')}</p>
<ul className="divide-ink-100 mt-4 divide-y">
{items.map((item) => (
<ItemReview
key={item.orderLineId}
item={item}
orderId={orderId}
email={email}
locale={locale}
onSubmitted={setItems}
/>
))}
</ul>
</section>
);
}
function ItemReview({
item,
orderId,
email,
locale,
onSubmitted,
}: {
item: ReviewableItem;
orderId: string;
email: string;
locale: Locale;
onSubmitted: (items: readonly ReviewableItem[]) => void;
}) {
const t = useTranslations('reviewForm');
const [rating, setRating] = useState(0);
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [authorName, setAuthorName] = useState('');
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
async function submit() {
setError(null);
setSaving(true);
try {
const updated = await browserApi.reviews.submit(
{
orderId,
email,
orderLineId: item.orderLineId,
rating,
title: title.trim() || null,
body: body.trim() || null,
authorName: authorName.trim(),
},
locale,
);
onSubmitted(updated);
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : t('failed'));
} finally {
setSaving(false);
}
}
return (
<li className="py-4">
<p className="text-sm font-medium">{item.productName}</p>
<p className="text-ink-500 text-xs">{item.variantTitle}</p>
{item.reviewId ? (
/*
Already reviewed: shown as a receipt rather than removed from the
list. A form that silently vanishes after submitting looks like it
failed — and "waiting for approval" is information the shopper needs,
otherwise they will wonder why their review is not on the page.
*/
<div className="mt-3 flex items-center gap-3">
<StarRating value={item.rating ?? 0} readOnly />
<span className="text-ink-500 text-xs">
{item.reviewStatus === 'APPROVED' ? t('published') : t('pending')}
</span>
</div>
) : (
<div className="mt-3 space-y-3">
<StarRating value={rating} onChange={setRating} />
{/* The rest of the form appears only once a rating is chosen —
a star click is the whole commitment for most people, and four
empty fields up front is what stops them making it. */}
{rating > 0 ? (
<div className="space-y-3">
<Input
value={authorName}
onChange={(event) => setAuthorName(event.target.value)}
placeholder={t('namePlaceholder')}
aria-label={t('namePlaceholder')}
maxLength={120}
/>
<Input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder={t('titlePlaceholder')}
aria-label={t('titlePlaceholder')}
maxLength={140}
/>
<textarea
value={body}
onChange={(event) => setBody(event.target.value)}
placeholder={t('bodyPlaceholder')}
aria-label={t('bodyPlaceholder')}
maxLength={2000}
rows={3}
className="border-ink-200 focus:border-ink-950 w-full border px-3 py-2 text-sm outline-none"
/>
{error ? (
<p role="alert" className="text-danger text-xs">
{error}
</p>
) : null}
<Button size="sm" disabled={saving || !authorName.trim()} onClick={submit}>
{saving ? t('sending') : t('submit')}
</Button>
</div>
) : null}
</div>
)}
</li>
);
}
function StarRating({
value,
onChange,
readOnly,
}: {
value: number;
onChange?: (value: number) => void;
readOnly?: boolean;
}) {
const t = useTranslations('reviewForm');
return (
<div className="flex items-center gap-1" role={readOnly ? 'img' : 'radiogroup'}>
{[1, 2, 3, 4, 5].map((star) => {
const filled = star <= value;
if (readOnly) {
return (
<Star
key={star}
aria-hidden
className={cn('size-5', filled ? 'fill-ink-950 text-ink-950' : 'text-ink-300')}
/>
);
}
return (
<button
key={star}
type="button"
role="radio"
aria-checked={value === star}
aria-label={t('stars', { count: star })}
onClick={() => onChange?.(star)}
className="p-0.5"
>
<Star
className={cn(
'size-6 transition-colors',
filled ? 'fill-ink-950 text-ink-950' : 'text-ink-300 hover:text-ink-500',
)}
/>
</button>
);
})}
</div>
);
}
@@ -1,13 +1,29 @@
import { getTranslations } from 'next-intl/server';
import { getLocale, getTranslations } from 'next-intl/server';
import type { NavigationMenu } from '@sport/types';
import type { Locale, NavigationMenu } from '@sport/types';
import { Link } from '@/i18n/navigation';
import { getServerApi } from '@/lib/api';
import { SPORT_NAV, routes } from '@/lib/routes';
export async function SiteFooter({ navigation }: { navigation: NavigationMenu | null }) {
const t = await getTranslations('footer');
const tSports = await getTranslations('sports');
const locale = (await getLocale()) as Locale;
/**
* Published pages, listed here because this is the only thing that makes
* them reachable — a policy page nobody can navigate to is a page that does
* not exist. Degrades to nothing rather than taking the footer down with it.
*/
let pages: { slug: string; title: string }[] = [];
try {
pages = [
...(await getServerApi().content.listPageSlugs(locale, { next: { revalidate: 300 } })),
];
} catch {
// The footer renders on every page; a content outage must not break it.
}
return (
<footer className="border-ink-200 bg-ink-950 text-ink-100 mt-24 border-t">
@@ -80,8 +96,24 @@ export async function SiteFooter({ navigation }: { navigation: NavigationMenu |
</nav>
</div>
<div className="border-ink-800 text-ink-500 border-t py-6 text-center text-xs">
{t('rights', { year: new Date().getFullYear() })}
<div className="border-ink-800 border-t py-6">
{pages.length > 0 ? (
<nav aria-label={t('policies')} className="mb-4">
<ul className="text-ink-400 flex flex-wrap justify-center gap-x-6 gap-y-2 text-xs">
{pages.map((page) => (
<li key={page.slug}>
<Link href={routes.page(page.slug)} className="hover:text-white">
{page.title}
</Link>
</li>
))}
</ul>
</nav>
) : null}
<p className="text-ink-500 text-center text-xs">
{t('rights', { year: new Date().getFullYear() })}
</p>
</div>
</footer>
);
@@ -16,6 +16,9 @@ interface CartState {
addLine: (variantId: string, quantity?: number) => Promise<boolean>;
setQuantity: (variantId: string, quantity: number) => Promise<void>;
removeLine: (variantId: string) => Promise<void>;
/** Resolves to the updated cart, or null if the request itself failed. */
applyCode: (code: string) => Promise<Cart | null>;
removeCode: (code: string) => Promise<void>;
refresh: () => Promise<void>;
}
@@ -55,6 +58,28 @@ export function CartProvider({ locale, children }: { locale: Locale; children: R
}, []);
/** Re-reads the bag after something outside this provider changed it. */
/**
* Like `run`, but hands the caller the cart it produced.
*
* A discount code needs this: the request succeeding and the code *applying*
* are different outcomes, and only the returned cart can tell them apart.
*/
const runReturning = useCallback(async (operation: () => Promise<Cart>): Promise<Cart | null> => {
setPending(true);
setError(null);
try {
const result = await operation();
setCart(result);
return result;
} catch (caught) {
setError(isApiClientError(caught) ? caught.message : 'Something went wrong.');
return null;
} finally {
setPending(false);
}
}, []);
const refresh = useCallback(async () => {
try {
setCart(await browserApi.commerce.getCart(locale));
@@ -103,9 +128,13 @@ export function CartProvider({ locale, children }: { locale: Locale; children: R
removeLine: async (variantId) => {
await run(() => browserApi.commerce.removeCartLine(locale, variantId));
},
applyCode: (code) => runReturning(() => browserApi.commerce.applyDiscountCode(locale, code)),
removeCode: async (code) => {
await run(() => browserApi.commerce.removeDiscountCode(locale, code));
},
refresh,
}),
[cart, loading, pending, error, locale, run, refresh],
[cart, loading, pending, error, locale, run, runReturning, refresh],
);
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
+2
View File
@@ -28,6 +28,8 @@ export const routes = {
accountWishlist: () => '/account/wishlist',
blog: () => '/blog',
post: (slug: string) => `/blog/${slug}`,
page: (slug: string) => `/pages/${slug}`,
} as const;
/** The sport facets that back `/sports/[sport]`. */
+46 -2
View File
@@ -106,7 +106,8 @@
"orders": "Orders",
"wishlist": "Wishlist",
"journal": "Journal",
"rights": "© {year} Sport Store."
"rights": "© {year} Sport Store.",
"policies": "Information"
},
"account": {
"title": "Account",
@@ -173,6 +174,21 @@
"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."
},
"discountPlaceholder": "Discount code",
"discountLabel": "Discount code",
"applyCode": "Apply",
"discount": "Discount",
"removeCode": "Remove code {code}",
"discountError": {
"NOT_FOUND": "We don't recognise {code}.",
"EXPIRED": "{code} has expired.",
"NOT_STARTED": "{code} is not active yet.",
"USAGE_LIMIT_REACHED": "{code} has been fully claimed.",
"MINIMUM_NOT_MET": "{code} needs a larger order.",
"NOTHING_ELIGIBLE": "{code} does not apply to anything in your bag.",
"NOT_COMBINABLE": "{code} cannot be combined with your current offer.",
"minimum": "{code} needs a subtotal of at least {amount}."
}
},
"checkout": {
@@ -198,7 +214,8 @@
"failed": "We could not place your order.",
"loading": "Loading your bag…",
"emptyBag": "There is nothing in your bag.",
"backToShop": "Back to shopping"
"backToShop": "Back to shopping",
"discount": "Discount"
},
"confirmation": {
"title": "Order placed",
@@ -210,5 +227,32 @@
"keepShopping": "Keep shopping",
"notFound": "We could not find that order.",
"backHome": "Back to home"
},
"reviews": {
"heading": "Reviews",
"empty": "No reviews yet. Buy it and be the first.",
"count": "{count, plural, one {# review} other {# reviews}}",
"verified": "Verified purchase",
"showing": "Showing {shown} of {total}."
},
"reviewForm": {
"heading": "Rate what you bought",
"intro": "Reviews are checked before they appear on the product page.",
"namePlaceholder": "The name to show on your review",
"titlePlaceholder": "Sum it up (optional)",
"bodyPlaceholder": "How did it fit, wear, wash? (optional)",
"submit": "Submit review",
"sending": "Sending…",
"failed": "Could not send your review. Try again.",
"pending": "Waiting to be published",
"published": "Published",
"stars": "{count, plural, one {# star} other {# stars}}"
},
"blog": {
"title": "Journal",
"intro": "Training notes, kit guides and what we are working on.",
"empty": "Nothing published yet.",
"backToJournal": "Back to the journal",
"by": "By {name}"
}
}
+46 -2
View File
@@ -106,7 +106,8 @@
"orders": "Đơn hàng",
"wishlist": "Yêu thích",
"journal": "Bài viết",
"rights": "© {year} Sport Store."
"rights": "© {year} Sport Store.",
"policies": "Thông tin"
},
"account": {
"title": "Tài khoản",
@@ -173,6 +174,21 @@
"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ỏ."
},
"discountPlaceholder": "Mã giảm giá",
"discountLabel": "Mã giảm giá",
"applyCode": "Áp dụng",
"discount": "Giảm giá",
"removeCode": "Bỏ mã {code}",
"discountError": {
"NOT_FOUND": "Không tìm thấy mã {code}.",
"EXPIRED": "Mã {code} đã hết hạn.",
"NOT_STARTED": "Mã {code} chưa có hiệu lực.",
"USAGE_LIMIT_REACHED": "Mã {code} đã hết lượt sử dụng.",
"MINIMUM_NOT_MET": "Mã {code} cần đơn hàng lớn hơn.",
"NOTHING_ELIGIBLE": "Mã {code} không áp dụng cho sản phẩm trong giỏ.",
"NOT_COMBINABLE": "Mã {code} không dùng chung với ưu đãi hiện tại.",
"minimum": "Mã {code} cần đơn tối thiểu {amount}."
}
},
"checkout": {
@@ -198,7 +214,8 @@
"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"
"backToShop": "Quay lại mua sắm",
"discount": "Giảm giá"
},
"confirmation": {
"title": "Đặt hàng thành công",
@@ -210,5 +227,32 @@
"keepShopping": "Tiếp tục mua sắm",
"notFound": "Không tìm thấy đơn hàng.",
"backHome": "Về trang chủ"
},
"reviews": {
"heading": "Đánh giá",
"empty": "Chưa có đánh giá nào. Hãy là người đầu tiên.",
"count": "{count, plural, other {# đánh giá}}",
"verified": "Đã mua hàng",
"showing": "Đang hiển thị {shown} trên {total}."
},
"reviewForm": {
"heading": "Đánh giá sản phẩm bạn đã mua",
"intro": "Đánh giá được kiểm duyệt trước khi hiển thị trên trang sản phẩm.",
"namePlaceholder": "Tên hiển thị trên đánh giá",
"titlePlaceholder": "Tiêu đề ngắn (không bắt buộc)",
"bodyPlaceholder": "Form dáng, chất vải, độ bền thế nào? (không bắt buộc)",
"submit": "Gửi đánh giá",
"sending": "Đang gửi…",
"failed": "Không gửi được đánh giá. Vui lòng thử lại.",
"pending": "Đang chờ duyệt",
"published": "Đã hiển thị",
"stars": "{count, plural, other {# sao}}"
},
"blog": {
"title": "Bài viết",
"intro": "Ghi chép tập luyện, hướng dẫn chọn đồ và những gì chúng tôi đang làm.",
"empty": "Chưa có bài viết nào.",
"backToJournal": "Quay lại danh sách bài viết",
"by": "Bởi {name}"
}
}