This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:22 +07:00
parent 3e5d38ec18
commit 3d6b0e0d4e
145 changed files with 7817 additions and 801 deletions
@@ -1,15 +0,0 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
export const metadata: Metadata = { title: 'Addresses' };
export default function AddressesPage() {
return (
<PageScaffold
title="Addresses"
description="Address book with Vietnamese province/district/ward selection, stored with administrative codes for shipping-provider integration."
milestone="M8 — customer account"
/>
);
}
@@ -1,43 +0,0 @@
import Link from 'next/link';
import { SiteFooter } from '@/components/layout/site-footer';
import { SiteHeader } from '@/components/layout/site-header';
import { routes } from '@/lib/routes';
const ACCOUNT_NAV = [
{ href: routes.accountProfile(), label: 'Profile' },
{ href: routes.accountOrders(), label: 'Orders' },
{ href: routes.accountAddresses(), label: 'Addresses' },
{ href: routes.accountWishlist(), label: 'Wishlist' },
] as const;
/**
* Everything under /account requires a signed-in customer. That check will live
* in middleware (a cheap cookie presence test) plus a server-side verification
* here — never in a Client Component, which can be bypassed.
*/
export default function AccountLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen flex-col">
<SiteHeader />
<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">Account</h2>
<nav className="mt-4 space-y-1">
{ACCOUNT_NAV.map((item) => (
<Link
key={item.href}
href={item.href}
className="text-ink-600 hover:text-ink-950 block py-1.5 text-sm"
>
{item.label}
</Link>
))}
</nav>
</aside>
<div className="min-w-0 flex-1">{children}</div>
</div>
<SiteFooter />
</div>
);
}
@@ -1,15 +0,0 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
export const metadata: Metadata = { title: 'Orders' };
export default function OrdersPage() {
return (
<PageScaffold
title="Orders"
description="Order history and detail. Every line renders the snapshot taken at purchase time, never the live catalog."
milestone="M8 — customer account"
/>
);
}
@@ -1,15 +0,0 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
export const metadata: Metadata = { title: 'Account' };
export default function AccountPage() {
return (
<PageScaffold
title="Account"
description="Overview: recent orders, saved addresses and profile at a glance."
milestone="M8 — customer account"
/>
);
}
@@ -1,15 +0,0 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
export const metadata: Metadata = { title: 'Profile' };
export default function ProfilePage() {
return (
<PageScaffold
title="Profile"
description="Name, email, phone, password and marketing preferences."
milestone="M8 — customer account"
/>
);
}
@@ -1,15 +0,0 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
export const metadata: Metadata = { title: 'Wishlist' };
export default function WishlistPage() {
return (
<PageScaffold
title="Wishlist"
description="Saved variants, and the signal source for back-in-stock notifications."
milestone="M8 — customer account"
/>
);
}
@@ -1,15 +0,0 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
export const metadata: Metadata = { title: 'Checkout' };
export default function CheckoutPage() {
return (
<PageScaffold
title="Checkout"
description="Contact, shipping address, delivery method, payment. Each step is server-validated; stock is reserved before a payment intent is created."
milestone="M5 — cart & checkout"
/>
);
}
@@ -1,16 +0,0 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
export const metadata: Metadata = { title: 'Journal' };
export default function BlogPage() {
return (
<PageScaffold
eyebrow="Journal"
title="Journal"
description="Editorial content served from the CMS module: training guides, drops, athlete stories."
milestone="M7 — content"
/>
);
}
@@ -1,16 +0,0 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
export const metadata: Metadata = { title: 'Your bag' };
export default function CartPage() {
return (
<PageScaffold
eyebrow="Bag"
title="Your bag"
description="Line items with variant title and live availability. Totals are always recomputed by the API — the client never submits a price."
milestone="M5 — cart & checkout"
/>
);
}
@@ -1,23 +0,0 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
type PageProps = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
return { title: slug };
}
export default async function CollectionPage({ params }: PageProps) {
const { slug } = await params;
return (
<PageScaffold
eyebrow="Collection"
title={slug}
description="Editorial collection page: campaign banner, curated copy and the shared product grid."
milestone="M4 — storefront catalog"
/>
);
}
-17
View File
@@ -1,17 +0,0 @@
import { SiteFooter } from '@/components/layout/site-footer';
import { SiteHeader } from '@/components/layout/site-header';
/**
* Chrome shared by every browsing route. Checkout deliberately sits in its own
* route group with a stripped-back layout — removing navigation from checkout
* is one of the highest-leverage conversion decisions there is.
*/
export default function ShopLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen flex-col">
<SiteHeader />
<main className="flex-1">{children}</main>
<SiteFooter />
</div>
);
}
@@ -1,16 +0,0 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
export const metadata: Metadata = { title: 'Men' };
export default function MenPage() {
return (
<PageScaffold
eyebrow="Shop"
title="Men"
description="Gender-facet listing. Same product-grid feature as every other listing page, preset with gender=MEN."
milestone="M4 — storefront catalog"
/>
);
}
-15
View File
@@ -1,15 +0,0 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
export const metadata: Metadata = { title: 'Sport Store — Performance sportswear' };
export default function HomePage() {
return (
<PageScaffold
title="Built for the work"
description="Homepage: hero, featured collections, new arrivals and sport entry points. Assembled from CMS-driven blocks so merchandisers can reorder the page without a deploy."
milestone="M4 — storefront catalog"
/>
);
}
@@ -1,33 +0,0 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
type PageProps = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
// Replaced with a real fetch (title, description, OG image) once the catalog
// endpoints exist; PDP metadata is the single most important SEO surface here.
return { title: slug };
}
/**
* Product detail page.
*
* The page renders a Product, but everything purchasable on it is a
* ProductVariant: the colour swatches select an option value, the size buttons
* select another, and together they resolve to exactly one variant id with its
* own price and stock. The "Add to bag" button submits that variant id.
*/
export default async function ProductPage({ params }: PageProps) {
const { slug } = await params;
return (
<PageScaffold
eyebrow="Product"
title={slug}
description="Gallery, variant selector (colour → size → variant id), price with sale handling, availability, size guide and specifications."
milestone="M4 — storefront catalog"
/>
);
}
@@ -1,16 +0,0 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
export const metadata: Metadata = { title: 'Search' };
export default function SearchPage() {
return (
<PageScaffold
eyebrow="Search"
title="Search"
description="Full-text search over the catalog with the same facets as category listings. Backed by PostgreSQL FTS behind a SearchProvider interface."
milestone="M6 — search"
/>
);
}
@@ -1,43 +0,0 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { PageScaffold } from '@/components/layout/page-scaffold';
import { SPORT_NAV, isSportSlug } from '@/lib/routes';
type PageProps = { params: Promise<{ sport: string }> };
/**
* The sport facets are a fixed, small set, so the routes are pre-rendered at
* build time. Category pages, which are database-driven, will use generateStaticParams
* against the API instead.
*/
export function generateStaticParams() {
return SPORT_NAV.map((sport) => ({ sport: sport.slug }));
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { sport } = await params;
const match = SPORT_NAV.find((entry) => entry.slug === sport);
return { title: match ? match.label : 'Sports' };
}
export default async function SportPage({ params }: PageProps) {
const { sport } = await params;
// Unknown facet is a 404, not an empty grid: empty results for a nonexistent
// URL are an SEO liability and hide typos in internal links.
if (!isSportSlug(sport)) {
notFound();
}
const label = SPORT_NAV.find((entry) => entry.slug === sport)?.label ?? sport;
return (
<PageScaffold
eyebrow="Sports"
title={label}
description="Sport-facet listing. Same product-grid feature as every other listing page, preset with this sport."
milestone="M4 — storefront catalog"
/>
);
}
@@ -1,16 +0,0 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
export const metadata: Metadata = { title: 'Women' };
export default function WomenPage() {
return (
<PageScaffold
eyebrow="Shop"
title="Women"
description="Gender-facet listing. Same product-grid feature as every other listing page, preset with gender=WOMEN."
milestone="M4 — storefront catalog"
/>
);
}
@@ -0,0 +1,23 @@
import type { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold';
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.addresses' });
return { title: t('title') };
}
export default async function AddressesPage({ params }: PageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('placeholder.addresses');
return (
<PageScaffold title={t('title')} description={t('body')} milestone="M8 — customer account" />
);
}
@@ -0,0 +1,62 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import type { Locale } from '@sport/types';
import { SiteFooter } from '@/components/layout/site-footer';
import { SiteHeader } from '@/components/layout/site-header';
import { fetchNavigation } from '@/features/product/services/catalog';
import { Link } from '@/i18n/navigation';
import { routes } from '@/lib/routes';
/**
* Everything under /account requires a signed-in customer. Enforcement will be
* layered: the proxy checks for a session cookie, this layout verifies the
* token server-side, and the API re-checks on every request. Only the last one
* is real security; the first two are UX.
*/
export default async function AccountLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('account');
const navigation = await fetchNavigation(locale as Locale);
const items = [
{ href: routes.accountProfile(), label: t('profile') },
{ href: routes.accountOrders(), label: t('orders') },
{ href: routes.accountAddresses(), label: t('addresses') },
{ href: routes.accountWishlist(), label: t('wishlist') },
];
return (
<div className="flex min-h-screen flex-col">
<SiteHeader navigation={navigation} />
<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">
{t('title')}
</h2>
<nav className="mt-4 space-y-1">
{items.map((item) => (
<Link
key={item.href}
href={item.href}
className="text-ink-600 hover:text-ink-950 block py-1.5 text-sm"
>
{item.label}
</Link>
))}
</nav>
</aside>
<div className="min-w-0 flex-1">{children}</div>
</div>
<SiteFooter navigation={navigation} />
</div>
);
}
@@ -0,0 +1,23 @@
import type { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold';
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.orders' });
return { title: t('title') };
}
export default async function OrdersPage({ params }: PageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('placeholder.orders');
return (
<PageScaffold title={t('title')} description={t('body')} milestone="M8 — customer account" />
);
}
@@ -0,0 +1,23 @@
import type { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold';
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.account' });
return { title: t('title') };
}
export default async function AccountPage({ params }: PageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('placeholder.account');
return (
<PageScaffold title={t('title')} description={t('body')} milestone="M8 — customer account" />
);
}
@@ -0,0 +1,23 @@
import type { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold';
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.profile' });
return { title: t('title') };
}
export default async function ProfilePage({ params }: PageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('placeholder.profile');
return (
<PageScaffold title={t('title')} description={t('body')} milestone="M8 — customer account" />
);
}
@@ -0,0 +1,23 @@
import type { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold';
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.wishlist' });
return { title: t('title') };
}
export default async function WishlistPage({ params }: PageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('placeholder.wishlist');
return (
<PageScaffold title={t('title')} description={t('body')} milestone="M8 — customer account" />
);
}
@@ -0,0 +1,23 @@
import type { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold';
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') };
}
export default async function CheckoutPage({ params }: PageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('placeholder.checkout');
return (
<PageScaffold title={t('title')} description={t('body')} milestone="M5 — cart & checkout" />
);
}
@@ -1,9 +1,25 @@
import Link from 'next/link';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { Link } from '@/i18n/navigation';
import { routes } from '@/lib/routes';
/** Minimal chrome: no nav, no footer links, nothing competing with completion. */
export default function CheckoutLayout({ children }: { children: React.ReactNode }) {
/**
* Minimal chrome: no navigation, no footer links, nothing competing with
* completion. Removing distractions from checkout is one of the
* highest-leverage conversion decisions available.
*/
export default async function CheckoutLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('placeholder.checkout');
return (
<div className="flex min-h-screen flex-col">
<header className="border-ink-200 border-b">
@@ -12,7 +28,7 @@ export default function CheckoutLayout({ children }: { children: React.ReactNode
Sport<span className="text-volt-600">.</span>
</Link>
<span className="text-ink-400 text-xs font-semibold uppercase tracking-widest">
Secure checkout
{t('title')}
</span>
</div>
</header>
@@ -0,0 +1,21 @@
import type { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold';
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') };
}
export default async function BlogPage({ params }: PageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('placeholder.blog');
return <PageScaffold title={t('title')} description={t('body')} milestone="M7 — content" />;
}
@@ -0,0 +1,23 @@
import type { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { PageScaffold } from '@/components/layout/page-scaffold';
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') };
}
export default async function CartPage({ params }: PageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('placeholder.cart');
return (
<PageScaffold title={t('title')} description={t('body')} milestone="M5 — cart & checkout" />
);
}
@@ -0,0 +1,56 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { setRequestLocale } from 'next-intl/server';
import type { Locale } from '@sport/types';
import { ProductListing } from '@/features/product/components/product-listing';
import { CATALOG_CACHE, getServerApi } from '@/lib/api';
import { parseListingParams, type RawSearchParams } from '@/lib/search-params';
type PageProps = {
params: Promise<{ locale: string; slug: string }>;
searchParams: Promise<RawSearchParams>;
};
async function loadCollection(locale: Locale, slug: string) {
try {
return await getServerApi().catalog.getCollection(locale, slug, CATALOG_CACHE.collections);
} catch {
return null;
}
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale, slug } = await params;
const collection = await loadCollection(locale as Locale, slug);
if (!collection) return {};
return {
title: collection.seo.metaTitle ?? collection.name,
description: collection.seo.metaDescription ?? collection.description ?? undefined,
};
}
export default async function CollectionPage({ params, searchParams }: PageProps) {
const { locale, slug } = await params;
setRequestLocale(locale);
const collection = await loadCollection(locale as Locale, slug);
if (!collection) {
notFound();
}
const query = parseListingParams(await searchParams);
return (
<ProductListing
locale={locale as Locale}
eyebrow="Collection"
title={collection.name}
description={collection.description}
query={{ ...query, collectionSlug: collection.slug }}
basePath={`/collections/${collection.slug}`}
/>
);
}
@@ -0,0 +1,39 @@
import { setRequestLocale } from 'next-intl/server';
import type { Locale } from '@sport/types';
import { SiteFooter } from '@/components/layout/site-footer';
import { SiteHeader } from '@/components/layout/site-header';
import { fetchNavigation } from '@/features/product/services/catalog';
/**
* Chrome shared by every browsing route.
*
* Navigation is fetched once here rather than per page: it renders on every
* screen, and the API response is cached both in Redis and by Next's fetch
* cache, so this is one warm read per revalidation window.
*
* Checkout deliberately sits in its own route group with a stripped-back
* layout — removing navigation from checkout is one of the highest-leverage
* conversion decisions there is.
*/
export default async function ShopLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const navigation = await fetchNavigation(locale as Locale);
return (
<div className="flex min-h-screen flex-col">
<SiteHeader navigation={navigation} />
<main className="flex-1">{children}</main>
<SiteFooter navigation={navigation} />
</div>
);
}
@@ -0,0 +1,35 @@
import type { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import type { Locale } from '@sport/types';
import { ProductListing } from '@/features/product/components/product-listing';
import { parseListingParams, type RawSearchParams } from '@/lib/search-params';
type PageProps = {
params: Promise<{ locale: string }>;
searchParams: Promise<RawSearchParams>;
};
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'nav' });
return { title: t('men') };
}
export default async function MenPage({ params, searchParams }: PageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('nav');
const query = parseListingParams(await searchParams);
return (
<ProductListing
locale={locale as Locale}
title={t('men')}
query={{ ...query, gender: ['MEN', 'UNISEX'] }}
basePath="/men"
/>
);
}
@@ -0,0 +1,105 @@
import { getTranslations, setRequestLocale } from 'next-intl/server';
import type { Locale } from '@sport/types';
import { Button } from '@sport/ui';
import { ProductGrid } from '@/features/product/components/product-grid';
import { fetchProducts } from '@/features/product/services/catalog';
import { Link } from '@/i18n/navigation';
import { SPORT_NAV, routes } from '@/lib/routes';
type PageProps = { params: Promise<{ locale: string }> };
export default async function HomePage({ params }: PageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('home');
const tSports = await getTranslations('sports');
// Two independent shelves, fetched in parallel. Both are cached reads, so
// this costs one round-trip rather than two sequential ones.
const [newArrivals, onSale] = await Promise.all([
fetchProducts(locale as Locale, { sort: 'newest', limit: 8 }),
fetchProducts(locale as Locale, { onSale: true, sort: 'newest', limit: 4 }),
]);
return (
<>
<section className="border-ink-200 bg-ink-950 border-b text-white">
<div className="max-w-page px-gutter mx-auto py-24 sm:py-32">
<p className="text-volt-500 text-xs font-semibold uppercase tracking-widest">
{t('heroEyebrow')}
</p>
<h1 className="mt-4 max-w-3xl text-5xl font-black uppercase leading-[0.95] sm:text-7xl">
{t('heroTitle')}
</h1>
<p className="text-ink-300 mt-6 max-w-xl text-base">{t('heroBody')}</p>
<div className="mt-10 flex flex-wrap gap-3">
<Link href={routes.collection('new-arrivals')}>
<Button variant="accent" size="lg">
{t('heroCta')}
</Button>
</Link>
<Link href={routes.men()}>
<Button
variant="outline"
size="lg"
className="hover:text-ink-950 border-white text-white hover:bg-white"
>
{t('heroSecondary')}
</Button>
</Link>
</div>
</div>
</section>
<section className="max-w-page px-gutter mx-auto py-16">
<h2 className="text-ink-400 text-xs font-semibold uppercase tracking-widest">
{t('shopBySport')}
</h2>
<ul className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{SPORT_NAV.map((sport) => (
<li key={sport.slug}>
<Link
href={routes.sport(sport.slug)}
className="border-ink-200 hover:border-ink-950 hover:bg-ink-950 flex h-24 items-end border p-4 text-sm font-semibold uppercase tracking-wide transition-colors hover:text-white"
>
{tSports(sport.slug)}
</Link>
</li>
))}
</ul>
</section>
<section className="max-w-page px-gutter mx-auto pb-16">
<div className="mb-8 flex items-baseline justify-between">
<h2 className="text-2xl font-black uppercase">{t('newArrivals')}</h2>
<Link
href={routes.collection('new-arrivals')}
className="text-xs font-semibold uppercase tracking-widest underline underline-offset-4"
>
{t('viewAll')}
</Link>
</div>
<ProductGrid products={newArrivals.items} />
</section>
{onSale.items.length > 0 ? (
<section className="max-w-page px-gutter mx-auto pb-16">
<div className="mb-8 flex items-baseline justify-between">
<h2 className="text-2xl font-black uppercase">{t('onSale')}</h2>
<Link
href={routes.collection('sale')}
className="text-xs font-semibold uppercase tracking-widest underline underline-offset-4"
>
{t('viewAll')}
</Link>
</div>
<ProductGrid products={onSale.items} />
</section>
) : null}
</>
);
}
@@ -0,0 +1,135 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { LOCALES, type Locale } from '@sport/types';
import { ProductDetail } from '@/features/product/components/product-detail';
import { fetchProduct } from '@/features/product/services/catalog';
import { Link, redirect } from '@/i18n/navigation';
import { getServerApi } from '@/lib/api';
import { routes } from '@/lib/routes';
type PageProps = { params: Promise<{ locale: string; slug: string }> };
/**
* Pre-renders every product in every locale at build time.
*
* Slugs differ per locale by design, so this cannot be a cartesian product of
* locales × slugs — each locale is asked for its own slug list.
*/
export async function generateStaticParams() {
const api = getServerApi();
const perLocale = await Promise.all(
LOCALES.map(async (locale) => {
try {
const slugs = await api.catalog.listProductSlugs(locale, { cache: 'no-store' });
return slugs.map(({ slug }) => ({ locale, slug }));
} catch {
// A build must not fail because the API was briefly unavailable —
// those pages simply render on demand instead.
return [];
}
}),
);
return perLocale.flat();
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale, slug } = await params;
const product = await fetchProduct(locale as Locale, slug);
if (!product) return {};
/**
* hreflang alternates, built from the per-locale slugs the API returns.
*
* Without these, `/products/ao-chay-bo-aero` and `/en/products/aero-run-tee`
* look like two unrelated pages to a search engine — or worse, like duplicate
* content competing with each other.
*/
const languages = Object.fromEntries(
// `?? {}` guards against a version-skewed API during a rolling deploy —
// missing hreflang is a degraded page, a thrown error is a broken build.
Object.entries(product.alternateSlugs ?? {}).map(([alternateLocale, alternateSlug]) => [
alternateLocale,
alternateLocale === 'vi'
? `/products/${alternateSlug}`
: `/${alternateLocale}/products/${alternateSlug}`,
]),
);
return {
title: product.seo.metaTitle ?? product.name,
description: product.seo.metaDescription ?? product.shortDescription ?? undefined,
alternates: {
canonical:
locale === 'vi' ? `/products/${product.slug}` : `/${locale}/products/${product.slug}`,
languages,
},
openGraph: {
title: product.name,
description: product.shortDescription ?? undefined,
images: product.images[0] ? [{ url: product.images[0].url }] : undefined,
type: 'website',
},
};
}
export default async function ProductPage({ params }: PageProps) {
const { locale, slug } = await params;
setRequestLocale(locale);
const product = await fetchProduct(locale as Locale, slug);
if (!product) {
notFound();
}
/**
* The API resolves a slug from any locale, so `/en/products/<vi-slug>` finds
* the right product. Redirect it to the canonical URL for this locale rather
* than serving the same page under two addresses — that is what keeps the
* language switcher working without creating duplicate content.
*/
if (product.slug !== slug) {
// The locale-aware `redirect` from @/i18n/navigation, NOT the one from
// next/navigation: the latter drops the locale prefix, so switching to
// English on a product page would silently bounce back to Vietnamese.
redirect({ href: routes.product(product.slug), locale });
}
const t = await getTranslations('breadcrumbs');
return (
<div className="max-w-page px-gutter mx-auto py-8">
<nav aria-label="Breadcrumb" className="text-ink-500 mb-8 text-xs">
<ol className="flex flex-wrap items-center gap-2">
<li>
<Link href={routes.home()} className="hover:text-ink-950">
{t('home')}
</Link>
</li>
{product.breadcrumbs.map((crumb) => (
<li key={crumb.slug ?? crumb.label} className="flex items-center gap-2">
<span aria-hidden className="text-ink-300">
/
</span>
<Link href={crumb.href} className="hover:text-ink-950">
{crumb.label}
</Link>
</li>
))}
<li className="flex items-center gap-2">
<span aria-hidden className="text-ink-300">
/
</span>
<span className="text-ink-950">{product.name}</span>
</li>
</ol>
</nav>
<ProductDetail product={product} />
</div>
);
}
@@ -0,0 +1,38 @@
import type { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import type { Locale } from '@sport/types';
import { ProductListing } from '@/features/product/components/product-listing';
import { parseListingParams, type RawSearchParams } from '@/lib/search-params';
type PageProps = {
params: Promise<{ locale: string }>;
searchParams: Promise<RawSearchParams>;
};
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'search' });
// Search result pages must never be indexed: they generate unbounded
// near-duplicate URLs and dilute the crawl budget for real pages.
return { title: t('title'), robots: { index: false, follow: true } };
}
export default async function SearchPage({ params, searchParams }: PageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('search');
const query = parseListingParams(await searchParams);
return (
<ProductListing
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 }}
basePath="/search"
/>
);
}
@@ -0,0 +1,59 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import type { Locale } from '@sport/types';
import { ProductListing } from '@/features/product/components/product-listing';
import { routing } from '@/i18n/routing';
import { SPORT_NAV, isSportSlug } from '@/lib/routes';
import { parseListingParams, type RawSearchParams } from '@/lib/search-params';
type PageProps = {
params: Promise<{ locale: string; sport: string }>;
searchParams: Promise<RawSearchParams>;
};
/**
* Sports are a fixed enum, so every locale × sport combination is pre-rendered
* at build time. Category pages, which are database-driven, will resolve their
* params from the API instead.
*/
export function generateStaticParams() {
return routing.locales.flatMap((locale) =>
SPORT_NAV.map((sport) => ({ locale, sport: sport.slug })),
);
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale, sport } = await params;
if (!isSportSlug(sport)) return {};
const t = await getTranslations({ locale, namespace: 'sports' });
return { title: t(sport) };
}
export default async function SportPage({ params, searchParams }: PageProps) {
const { locale, sport } = await params;
setRequestLocale(locale);
// An unknown facet is a 404, not an empty grid: empty results for a
// nonexistent URL are an SEO liability and hide typos in internal links.
if (!isSportSlug(sport)) {
notFound();
}
const t = await getTranslations('sports');
const tNav = await getTranslations('nav');
const query = parseListingParams(await searchParams);
return (
<ProductListing
locale={locale as Locale}
eyebrow={tNav('sports')}
title={t(sport)}
query={{ ...query, sport: [sport.toUpperCase()] }}
basePath={`/sports/${sport}`}
/>
);
}
@@ -0,0 +1,35 @@
import type { Metadata } from 'next';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import type { Locale } from '@sport/types';
import { ProductListing } from '@/features/product/components/product-listing';
import { parseListingParams, type RawSearchParams } from '@/lib/search-params';
type PageProps = {
params: Promise<{ locale: string }>;
searchParams: Promise<RawSearchParams>;
};
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'nav' });
return { title: t('women') };
}
export default async function WomenPage({ params, searchParams }: PageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations('nav');
const query = parseListingParams(await searchParams);
return (
<ProductListing
locale={locale as Locale}
title={t('women')}
query={{ ...query, gender: ['WOMEN', 'UNISEX'] }}
basePath="/women"
/>
);
}
@@ -0,0 +1,73 @@
import type { Metadata, Viewport } from 'next';
import { notFound } from 'next/navigation';
import { hasLocale, NextIntlClientProvider } from 'next-intl';
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { LOCALE_TAGS, type Locale } from '@sport/types';
import { routing } from '@/i18n/routing';
import '@/styles/globals.css';
/**
* This is the root layout — there is no `app/layout.tsx`. Every route lives
* under `[locale]`, so the `<html lang>` attribute can be set from the resolved
* locale rather than hard-coded, which screen readers and translation tools
* both depend on.
*/
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'meta' });
return {
title: { default: t('defaultTitle'), template: `%s | ${t('siteName')}` },
description: t('defaultDescription'),
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'),
// Tells search engines these are translations of one another rather than
// duplicate content — the single most important i18n SEO signal.
alternates: {
canonical: '/',
languages: { vi: '/', en: '/en' },
},
};
}
export const viewport: Viewport = {
themeColor: '#111111',
width: 'device-width',
initialScale: 1,
};
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!hasLocale(routing.locales, locale)) {
notFound();
}
// Required for static rendering: without it every page under this layout
// opts into dynamic rendering the moment it reads a translation.
setRequestLocale(locale);
return (
<html lang={LOCALE_TAGS[locale as Locale]} suppressHydrationWarning>
<body className="min-h-screen antialiased">
<NextIntlClientProvider>{children}</NextIntlClientProvider>
</body>
</html>
);
}
@@ -0,0 +1,21 @@
import { useTranslations } from 'next-intl';
import { Button } from '@sport/ui';
import { Link } from '@/i18n/navigation';
import { routes } from '@/lib/routes';
export default function NotFound() {
const t = useTranslations('notFound');
return (
<div className="max-w-page px-gutter mx-auto flex min-h-screen flex-col items-center justify-center text-center">
<p className="text-ink-400 text-xs font-semibold uppercase tracking-widest">{t('code')}</p>
<h1 className="mt-4 text-5xl font-black uppercase">{t('title')}</h1>
<p className="text-ink-500 mt-4 max-w-md">{t('body')}</p>
<Link href={routes.home()} className="mt-8">
<Button>{t('cta')}</Button>
</Link>
</div>
);
}
-27
View File
@@ -1,27 +0,0 @@
import type { Metadata, Viewport } from 'next';
import '@/styles/globals.css';
export const metadata: Metadata = {
title: {
default: 'Sport Store — Performance sportswear',
template: '%s | Sport Store',
},
description:
'Performance sportswear for running, football, training, gym, badminton and lifestyle.',
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'),
};
export const viewport: Viewport = {
themeColor: '#111111',
width: 'device-width',
initialScale: 1,
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="vi" suppressHydrationWarning>
<body className="min-h-screen antialiased">{children}</body>
</html>
);
}
-18
View File
@@ -1,18 +0,0 @@
import Link from 'next/link';
import { Button } from '@sport/ui';
export default function NotFound() {
return (
<div className="max-w-page px-gutter mx-auto flex min-h-screen flex-col items-center justify-center text-center">
<p className="text-ink-400 text-xs font-semibold uppercase tracking-widest">404</p>
<h1 className="mt-4 text-5xl font-black uppercase">Nothing here</h1>
<p className="text-ink-500 mt-4 max-w-md">
That page has moved or never existed. The gear is still where you left it.
</p>
<Link href="/" className="mt-8">
<Button>Back to shop</Button>
</Link>
</div>
);
}
@@ -0,0 +1,69 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import { useTransition } from 'react';
import { LOCALE_LABELS, LOCALES, type Locale } from '@sport/types';
import { cn } from '@sport/ui';
import { usePathname, useRouter } from '@/i18n/navigation';
/**
* Switches locale while staying on the current page.
*
* `usePathname` from `@/i18n/navigation` returns the path *without* the locale
* prefix, so re-pushing it under a different locale lands on the same page in
* the other language — rather than dumping the user on the homepage, which is
* what a plain `<a href="/en">` does and is the usual way this is got wrong.
*
* Product pages are the interesting case: slugs are localised, so switching on
* `/products/ao-chay-bo-aero` produces `/en/products/ao-chay-bo-aero`. That is
* intentional — the API resolves a slug in any locale, and the PDP then
* redirects to the canonical `/en/products/aero-run-tee`. One redirect keeps
* this component simple and keeps every cross-locale link working.
*/
export function LanguageSwitcher({ className }: { className?: string }) {
const t = useTranslations('nav');
const activeLocale = useLocale() as Locale;
const router = useRouter();
const pathname = usePathname();
const [isPending, startTransition] = useTransition();
function switchTo(locale: Locale) {
if (locale === activeLocale) return;
// `replace`, not `push`: a language switch is a correction, not a
// navigation step. Leaving it in history makes the back button toggle
// languages instead of going back.
startTransition(() => {
router.replace(pathname, { locale });
});
}
return (
<div
className={cn('flex items-center gap-1', isPending && 'opacity-60', className)}
role="group"
aria-label={t('changeLanguage')}
>
{LOCALES.map((locale) => (
<button
key={locale}
type="button"
onClick={() => switchTo(locale)}
aria-current={locale === activeLocale ? 'true' : undefined}
lang={locale}
title={LOCALE_LABELS[locale]}
className={cn(
'px-1.5 py-1 text-xs font-semibold uppercase tracking-widest transition-colors',
locale === activeLocale
? 'text-ink-950 underline underline-offset-4'
: 'text-ink-400 hover:text-ink-950',
)}
>
{locale}
</button>
))}
</div>
);
}
@@ -1,11 +1,13 @@
import { useTranslations } from 'next-intl';
import { Badge } from '@sport/ui';
/**
* Temporary scaffold used by every route in the skeleton.
* Placeholder for routes whose feature has not been built yet.
*
* It exists so the route tree, layouts and navigation are real and clickable
* before any feature is built — and so it is obvious at a glance which screens
* are still placeholders. Each page deletes this as its feature lands.
* It exists so the route tree, layouts and navigation are real and clickable,
* and so it is obvious at a glance which screens are still stubs. Each page
* deletes this as its milestone lands — the catalog pages already have.
*/
export function PageScaffold({
eyebrow,
@@ -18,6 +20,8 @@ export function PageScaffold({
description: string;
milestone: string;
}) {
const t = useTranslations('milestone');
return (
<div className="max-w-page px-gutter mx-auto py-20">
{eyebrow ? (
@@ -28,7 +32,7 @@ export function PageScaffold({
<p className="text-ink-500 mt-5 max-w-2xl text-base">{description}</p>
<div className="mt-8">
<Badge variant="outline">Planned: {milestone}</Badge>
<Badge variant="outline">{t('planned', { milestone })}</Badge>
</div>
</div>
);
@@ -1,8 +1,14 @@
import Link from 'next/link';
import { getTranslations } from 'next-intl/server';
import type { NavigationMenu } from '@sport/types';
import { Link } from '@/i18n/navigation';
import { SPORT_NAV, routes } from '@/lib/routes';
export function SiteFooter() {
export async function SiteFooter({ navigation }: { navigation: NavigationMenu | null }) {
const t = await getTranslations('footer');
const tSports = await getTranslations('sports');
return (
<footer className="border-ink-200 bg-ink-950 text-ink-100 mt-24 border-t">
<div className="max-w-page px-gutter mx-auto grid gap-10 py-16 sm:grid-cols-2 lg:grid-cols-4">
@@ -10,56 +16,64 @@ export function SiteFooter() {
<p className="text-lg font-black uppercase tracking-tighter text-white">
Sport<span className="text-volt-500">.</span>
</p>
<p className="text-ink-400 mt-3 max-w-xs text-sm">
Performance sportswear, built for training days and the ones after.
</p>
<p className="text-ink-400 mt-3 max-w-xs text-sm">{t('tagline')}</p>
</div>
<nav aria-label="Shop">
<h2 className="text-xs font-semibold uppercase tracking-widest text-white">Shop</h2>
<nav aria-label={t('shop')}>
<h2 className="text-xs font-semibold uppercase tracking-widest text-white">
{t('shop')}
</h2>
<ul className="text-ink-400 mt-4 space-y-2 text-sm">
<li>
<Link href={routes.men()} className="hover:text-white">
Men
</Link>
</li>
<li>
<Link href={routes.women()} className="hover:text-white">
Women
</Link>
</li>
</ul>
</nav>
<nav aria-label="Sports">
<h2 className="text-xs font-semibold uppercase tracking-widest text-white">Sports</h2>
<ul className="text-ink-400 mt-4 space-y-2 text-sm">
{SPORT_NAV.map((sport) => (
<li key={sport.slug}>
<Link href={routes.sport(sport.slug)} className="hover:text-white">
{sport.label}
{navigation?.primary.map((item) => (
<li key={item.id}>
<Link href={item.href} className="hover:text-white">
{item.label}
</Link>
</li>
))}
{navigation?.featuredCollections.map((collection) => (
<li key={collection.id}>
<Link href={collection.href} className="hover:text-white">
{collection.label}
</Link>
</li>
))}
</ul>
</nav>
<nav aria-label="Account">
<h2 className="text-xs font-semibold uppercase tracking-widest text-white">Account</h2>
<nav aria-label={t('sports')}>
<h2 className="text-xs font-semibold uppercase tracking-widest text-white">
{t('sports')}
</h2>
<ul className="text-ink-400 mt-4 space-y-2 text-sm">
{SPORT_NAV.map((sport) => (
<li key={sport.slug}>
<Link href={routes.sport(sport.slug)} className="hover:text-white">
{tSports(sport.slug)}
</Link>
</li>
))}
</ul>
</nav>
<nav aria-label={t('account')}>
<h2 className="text-xs font-semibold uppercase tracking-widest text-white">
{t('account')}
</h2>
<ul className="text-ink-400 mt-4 space-y-2 text-sm">
<li>
<Link href={routes.accountOrders()} className="hover:text-white">
Orders
{t('orders')}
</Link>
</li>
<li>
<Link href={routes.accountWishlist()} className="hover:text-white">
Wishlist
{t('wishlist')}
</Link>
</li>
<li>
<Link href={routes.blog()} className="hover:text-white">
Journal
{t('journal')}
</Link>
</li>
</ul>
@@ -67,7 +81,7 @@ export function SiteFooter() {
</div>
<div className="border-ink-800 text-ink-500 border-t py-6 text-center text-xs">
© {new Date().getFullYear()} Sport Store.
{t('rights', { year: new Date().getFullYear() })}
</div>
</footer>
);
@@ -1,53 +1,65 @@
import Link from 'next/link';
import { getTranslations } from 'next-intl/server';
import type { NavigationMenu } from '@sport/types';
import { LanguageSwitcher } from '@/components/language-switcher';
import { Link } from '@/i18n/navigation';
import { SPORT_NAV, routes } from '@/lib/routes';
/**
* Server Component. It renders no interactive state, so none of it ships to the
* browser — the mobile menu and cart badge will be small Client Components
* mounted inside it rather than turning the whole header into one.
* Server Component. It renders no interactive state beyond the language
* switcher, so almost nothing ships to the browser — the mobile menu and cart
* badge will be small Client Components mounted inside it rather than turning
* the whole header into one.
*
* `navigation` comes from the API (categories and collections are database
* content, translated in the database). Sports come from the local message
* catalog, because they are a fixed enum in code. That split is deliberate and
* is documented in CategoriesService.getNavigation.
*/
export function SiteHeader() {
export async function SiteHeader({ navigation }: { navigation: NavigationMenu | null }) {
const t = await getTranslations('nav');
const tSports = await getTranslations('sports');
return (
<header className="border-ink-200 sticky top-0 z-50 border-b bg-white/95 backdrop-blur">
<div className="max-w-page px-gutter mx-auto flex h-16 items-center gap-8">
<div className="max-w-page px-gutter mx-auto flex h-16 items-center gap-6">
<Link href={routes.home()} className="text-lg font-black uppercase tracking-tighter">
Sport<span className="text-volt-600">.</span>
</Link>
<nav aria-label="Main" className="hidden items-center gap-6 md:flex">
<Link
href={routes.men()}
className="hover:text-volt-600 text-xs font-semibold uppercase tracking-widest"
>
Men
</Link>
<Link
href={routes.women()}
className="hover:text-volt-600 text-xs font-semibold uppercase tracking-widest"
>
Women
</Link>
<nav aria-label={t('ariaMain')} className="hidden items-center gap-5 lg:flex">
{navigation?.primary.map((item) => (
<Link
key={item.id}
href={item.href}
className="hover:text-volt-600 text-xs font-semibold uppercase tracking-widest"
>
{item.label}
</Link>
))}
{SPORT_NAV.map((sport) => (
<Link
key={sport.slug}
href={routes.sport(sport.slug)}
className="hover:text-volt-600 text-xs font-semibold uppercase tracking-widest"
>
{sport.label}
{tSports(sport.slug)}
</Link>
))}
</nav>
<div className="ml-auto flex items-center gap-5 text-xs font-semibold uppercase tracking-widest">
<div className="ml-auto flex items-center gap-4 text-xs font-semibold uppercase tracking-widest">
<LanguageSwitcher />
<Link href={routes.search()} className="hover:text-volt-600">
Search
{t('search')}
</Link>
<Link href={routes.account()} className="hover:text-volt-600">
Account
<Link href={routes.account()} className="hover:text-volt-600 hidden sm:inline">
{t('account')}
</Link>
<Link href={routes.cart()} className="hover:text-volt-600">
Cart
{t('cart')}
</Link>
</div>
</div>
@@ -0,0 +1,121 @@
import Image from 'next/image';
import { useFormatter, useTranslations } from 'next-intl';
import type { ProductListItem } from '@sport/types';
import { Badge, cn } from '@sport/ui';
import { Link } from '@/i18n/navigation';
import { formatMoney, discountPercent } from '@/lib/format';
import { routes } from '@/lib/routes';
/**
* The grid card.
*
* Lives in `features/product/` rather than `@sport/ui` on purpose: it knows
* about sale badges, price ranges and colourways, none of which mean anything
* in the admin dashboard. See docs/architecture.md §4.
*/
export function ProductCard({
product,
priority = false,
}: {
product: ProductListItem;
priority?: boolean;
}) {
const t = useTranslations('product');
const format = useFormatter();
const { priceRange, primaryImage, hoverImage } = product;
const hasRange = priceRange.min.amount !== priceRange.max.amount;
const discount =
priceRange.compareAtMax && product.isOnSale
? discountPercent(priceRange.min, priceRange.compareAtMax)
: 0;
return (
<article className="group">
<Link href={routes.product(product.slug)} className="block">
<div className="bg-ink-100 relative aspect-[4/5] overflow-hidden">
{primaryImage ? (
<>
<Image
src={primaryImage.url}
alt={primaryImage.altText ?? product.name}
fill
// Three columns on desktop, two on tablet, one on mobile —
// matching the grid below so the browser never downloads a
// larger file than it renders.
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
className={cn(
'object-cover transition-opacity duration-500',
hoverImage && 'group-hover:opacity-0',
)}
placeholder={primaryImage.blurDataUrl ? 'blur' : 'empty'}
blurDataURL={primaryImage.blurDataUrl ?? undefined}
// Only the first row is priority; marking everything priority
// is the same as marking nothing.
priority={priority}
/>
{hoverImage ? (
<Image
src={hoverImage.url}
alt=""
aria-hidden
fill
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
className="object-cover opacity-0 transition-opacity duration-500 group-hover:opacity-100"
/>
) : null}
</>
) : (
<div className="bg-ink-200 size-full" />
)}
{discount > 0 ? (
<Badge variant="sale" className="absolute left-3 top-3">
{t('save', { percent: discount })}
</Badge>
) : null}
</div>
<div className="mt-3 space-y-1">
{product.brandName ? (
<p className="text-ink-400 text-[0.625rem] font-semibold uppercase tracking-widest">
{product.brandName}
</p>
) : null}
<h3 className="text-ink-950 text-sm font-medium">{product.name}</h3>
<p className="flex items-baseline gap-2 text-sm">
<span className={cn('font-semibold', product.isOnSale && 'text-sale')}>
{hasRange ? `${t('from')} ` : ''}
{formatMoney(priceRange.min, format)}
</span>
{priceRange.compareAtMax && product.isOnSale ? (
<span className="text-ink-400 text-xs line-through">
{formatMoney(priceRange.compareAtMax, format)}
</span>
) : null}
</p>
</div>
</Link>
{product.colorSwatches.length > 1 ? (
<ul className="mt-2 flex items-center gap-1.5" aria-label={t('selectColour')}>
{product.colorSwatches.slice(0, 5).map((swatch) => (
<li
key={swatch.optionValueId}
className="border-ink-200 size-3 rounded-full border"
style={{ backgroundColor: swatch.swatchHex ?? undefined }}
title={swatch.label}
/>
))}
{product.colorSwatches.length > 5 ? (
<li className="text-ink-400 text-[0.625rem]">+{product.colorSwatches.length - 5}</li>
) : null}
</ul>
) : null}
</article>
);
}
@@ -0,0 +1,336 @@
'use client';
import Image from 'next/image';
import { useFormatter, useTranslations } from 'next-intl';
import { useMemo, useState } from 'react';
import { VARIANT_AVAILABILITY, type StorefrontProduct, type StorefrontVariant } from '@sport/types';
import { Badge, Button, cn } from '@sport/ui';
import { discountPercent, formatMoney } from '@/lib/format';
/**
* The PDP interaction surface: gallery and variant selector, sharing one piece
* of state.
*
* They are a single component because they are a single interaction. Picking
* "Black" must swap the gallery to the black photography *and* narrow the size
* options *and* update the price — splitting that across two components would
* mean lifting state into a context for no benefit.
*
* This is ADR-0003 made visible: the shopper picks a value on each axis, and
* those choices together resolve to exactly one ProductVariant with its own
* SKU, price and stock. There is no product-level price to fall back on.
*/
export function ProductDetail({ product }: { product: StorefrontProduct }) {
const t = useTranslations('product');
const format = useFormatter();
const [selection, setSelection] = useState<Record<string, string>>(() =>
defaultSelection(product),
);
const selectedVariant = useMemo(
() => findVariant(product.variants, selection),
[product.variants, selection],
);
const selectedColourId = selection['colour'];
/**
* Images tagged with the selected colour, falling back to the full set.
*
* `ProductImage.optionValueId` is what makes this possible — it is why images
* are linked to an option value rather than dumped in one flat list.
*/
const gallery = useMemo(() => {
if (!selectedColourId) return product.images;
const forColour = product.images.filter((image) => image.optionValueId === selectedColourId);
return forColour.length > 0 ? forColour : product.images;
}, [product.images, selectedColourId]);
const [activeImage, setActiveImage] = useState(0);
const currentImage = gallery[Math.min(activeImage, gallery.length - 1)] ?? null;
const price = selectedVariant?.effectivePrice ?? product.priceRange.min;
const compareAt = selectedVariant?.compareAtPrice ?? product.priceRange.compareAtMax;
const discount = compareAt ? discountPercent(price, compareAt) : 0;
function select(optionKey: string, optionValueId: string) {
setSelection((current) => ({ ...current, [optionKey]: optionValueId }));
if (optionKey === 'colour') setActiveImage(0);
}
/** Values on this axis still reachable given the other choices. */
function reachableValues(optionKey: string): Set<string> {
const others = Object.entries(selection).filter(([key]) => key !== optionKey);
return new Set(
product.variants
.filter((variant) => matchesAll(variant, others))
.flatMap((variant) =>
variant.optionValues
.filter((ov) => ov.optionKey === optionKey)
.map((ov) => ov.optionValueId),
),
);
}
function isStocked(optionKey: string, optionValueId: string): boolean {
const others = Object.entries(selection).filter(([key]) => key !== optionKey);
return product.variants.some(
(variant) =>
variant.availability !== VARIANT_AVAILABILITY.OUT_OF_STOCK &&
variant.optionValues.some(
(ov) => ov.optionKey === optionKey && ov.optionValueId === optionValueId,
) &&
matchesAll(variant, others),
);
}
return (
<div className="grid gap-10 lg:grid-cols-2 lg:gap-16">
{/* ---- Gallery ---- */}
<div className="space-y-3">
<div className="bg-ink-100 relative aspect-[4/5] overflow-hidden">
{currentImage ? (
<Image
src={currentImage.url}
alt={currentImage.altText ?? product.name}
fill
sizes="(min-width: 1024px) 50vw, 100vw"
className="object-cover"
placeholder={currentImage.blurDataUrl ? 'blur' : 'empty'}
blurDataURL={currentImage.blurDataUrl ?? undefined}
priority
/>
) : (
<div className="bg-ink-200 size-full" />
)}
{discount > 0 ? (
<Badge variant="sale" className="absolute left-4 top-4">
{t('save', { percent: discount })}
</Badge>
) : null}
</div>
{gallery.length > 1 ? (
<ul className="grid grid-cols-4 gap-3">
{gallery.map((image, index) => (
<li key={image.id}>
<button
type="button"
onClick={() => setActiveImage(index)}
aria-current={index === activeImage}
className={cn(
'bg-ink-100 relative block aspect-[4/5] w-full overflow-hidden border transition-colors',
index === activeImage
? 'border-ink-950'
: 'hover:border-ink-300 border-transparent',
)}
>
<Image
src={image.url}
alt=""
aria-hidden
fill
sizes="12vw"
className="object-cover"
/>
</button>
</li>
))}
</ul>
) : null}
</div>
{/* ---- Selector ---- */}
<div className="space-y-8 lg:pt-4">
<div>
{product.brand ? (
<p className="text-ink-400 text-xs font-semibold uppercase tracking-widest">
{product.brand.name}
</p>
) : null}
<h1 className="mt-2 text-3xl font-black uppercase sm:text-4xl">{product.name}</h1>
{product.shortDescription ? (
<p className="text-ink-500 mt-3 text-sm">{product.shortDescription}</p>
) : null}
</div>
<div className="flex flex-wrap items-baseline gap-3">
<span className={cn('text-2xl font-semibold', selectedVariant?.isOnSale && 'text-sale')}>
{formatMoney(price, format)}
</span>
{compareAt && discount > 0 ? (
<span className="text-ink-400 text-base line-through">
{formatMoney(compareAt, format)}
</span>
) : null}
</div>
{product.options.map((option) => {
const reachable = reachableValues(option.key);
const isColour = option.key === 'colour';
const selectedLabel = option.values.find((v) => v.id === selection[option.key])?.label;
return (
<fieldset key={option.id}>
<legend className="mb-3 flex w-full items-baseline justify-between text-xs font-semibold uppercase tracking-widest">
<span>{option.name}</span>
<span className="text-ink-500 font-normal normal-case tracking-normal">
{selectedLabel}
</span>
</legend>
<div className="flex flex-wrap gap-2">
{option.values.map((value) => {
const selected = selection[option.key] === value.id;
const reachableValue = reachable.has(value.id);
const stocked = reachableValue && isStocked(option.key, value.id);
return (
<button
key={value.id}
type="button"
disabled={!reachableValue}
aria-pressed={selected}
onClick={() => select(option.key, value.id)}
title={value.label}
className={cn(
'relative flex items-center justify-center border text-xs font-medium transition-colors',
isColour ? 'size-10 rounded-full' : 'h-11 min-w-14 px-3 uppercase',
selected
? 'border-ink-950 ring-ink-950 ring-1'
: 'border-ink-200 hover:border-ink-400',
!reachableValue && 'cursor-not-allowed opacity-30',
// Reachable but sold out reads differently from
// impossible: struck through, still selectable, so
// stock is discoverable rather than hidden.
reachableValue && !stocked && 'text-ink-400 line-through',
)}
style={
isColour && value.swatchHex
? { backgroundColor: value.swatchHex }
: undefined
}
>
{isColour ? <span className="sr-only">{value.label}</span> : value.label}
</button>
);
})}
</div>
</fieldset>
);
})}
<div className="space-y-3">
<AvailabilityNote variant={selectedVariant} />
<Button
size="lg"
fullWidth
disabled={
!selectedVariant || selectedVariant.availability === VARIANT_AVAILABILITY.OUT_OF_STOCK
}
>
{!selectedVariant
? t('selectSizePrompt')
: selectedVariant.availability === VARIANT_AVAILABILITY.OUT_OF_STOCK
? t('outOfStock')
: t('addToBag')}
</Button>
{/* Honest about scope: the button is real, the cart is not yet. */}
<p className="text-ink-400 text-center text-xs">{t('comingSoon')}</p>
{selectedVariant ? (
<p className="text-ink-400 text-center text-xs">
{t('sku')}: {selectedVariant.sku}
</p>
) : null}
</div>
{product.description ? (
<section className="border-ink-200 border-t pt-6">
<h2 className="text-xs font-semibold uppercase tracking-widest">{t('details')}</h2>
<p className="text-ink-600 mt-3 text-sm leading-relaxed">{product.description}</p>
</section>
) : null}
{product.attributes.length > 0 ? (
<section className="border-ink-200 border-t pt-6">
<h2 className="text-xs font-semibold uppercase tracking-widest">
{t('specifications')}
</h2>
<dl className="divide-ink-100 mt-3 divide-y text-sm">
{product.attributes.map((attribute) => (
<div key={attribute.id} className="flex justify-between gap-6 py-2">
<dt className="text-ink-500">{attribute.label}</dt>
<dd className="text-ink-950 text-right">{attribute.value}</dd>
</div>
))}
</dl>
</section>
) : null}
</div>
</div>
);
}
function AvailabilityNote({ variant }: { variant: StorefrontVariant | undefined }) {
const t = useTranslations('product');
if (!variant) return null;
const tone: Record<string, string> = {
[VARIANT_AVAILABILITY.IN_STOCK]: 'text-success',
[VARIANT_AVAILABILITY.LOW_STOCK]: 'text-warning',
[VARIANT_AVAILABILITY.OUT_OF_STOCK]: 'text-danger',
[VARIANT_AVAILABILITY.PREORDER]: 'text-ink-500',
};
const label: Record<string, string> = {
[VARIANT_AVAILABILITY.IN_STOCK]: t('inStock'),
[VARIANT_AVAILABILITY.LOW_STOCK]: t('lowStock'),
[VARIANT_AVAILABILITY.OUT_OF_STOCK]: t('outOfStock'),
[VARIANT_AVAILABILITY.PREORDER]: t('inStock'),
};
return (
<p className={cn('text-xs font-medium', tone[variant.availability])}>
{label[variant.availability]}
</p>
);
}
function matchesAll(variant: StorefrontVariant, pairs: [string, string][]): boolean {
return pairs.every(([key, value]) =>
variant.optionValues.some((ov) => ov.optionKey === key && ov.optionValueId === value),
);
}
/** Preselects the first combination that is actually buyable. */
function defaultSelection(product: StorefrontProduct): Record<string, string> {
const firstAvailable =
product.variants.find((v) => v.availability !== VARIANT_AVAILABILITY.OUT_OF_STOCK) ??
product.variants[0];
if (!firstAvailable) return {};
return Object.fromEntries(
firstAvailable.optionValues.map((ov) => [ov.optionKey, ov.optionValueId]),
);
}
function findVariant(
variants: readonly StorefrontVariant[],
selection: Record<string, string>,
): StorefrontVariant | undefined {
const entries = Object.entries(selection);
if (entries.length === 0) return undefined;
return variants.find((variant) => matchesAll(variant, entries));
}
@@ -0,0 +1,181 @@
import { getTranslations } from 'next-intl/server';
import type { ProductListQuery } from '@sport/api-client';
import type { ProductFacets } from '@sport/types';
import { cn } from '@sport/ui';
import { Link } from '@/i18n/navigation';
/**
* Filter rail, rendered on the server as plain links.
*
* No client JavaScript: each facet is a URL. That makes every filter
* combination shareable, bookmarkable, back-button-correct and crawlable, and
* it means the rail works before hydration. A client-side filter store would be
* more code for strictly less capability.
*/
export async function ProductFilters({
facets,
basePath,
query,
}: {
facets: ProductFacets;
basePath: string;
query: ProductListQuery;
}) {
const t = await getTranslations('listing');
const hasActiveFilters = Boolean(
query.colors?.length || query.sizes?.length || query.brandSlugs?.length || query.onSale,
);
/** Toggling a value in a multi-select facet, expressed as the resulting URL. */
function toggleHref(key: 'colors' | 'sizes' | 'brandSlugs', value: string): string {
const current = new Set(query[key] ?? []);
if (current.has(value)) {
current.delete(value);
} else {
current.add(value);
}
return buildHref(basePath, { ...query, [key]: [...current] });
}
return (
<aside className="space-y-8">
<div className="flex items-baseline justify-between">
<h2 className="text-xs font-semibold uppercase tracking-widest">{t('filters')}</h2>
{hasActiveFilters ? (
<Link href={basePath} className="text-ink-500 text-xs underline underline-offset-4">
{t('clearAll')}
</Link>
) : null}
</div>
{facets.colors.length > 0 ? (
<section>
<h3 className="text-ink-500 mb-3 text-xs font-semibold uppercase tracking-widest">
{t('facet.colour')}
</h3>
<ul className="flex flex-wrap gap-2">
{facets.colors.map((colour) => {
const active = query.colors?.includes(colour.value) ?? false;
return (
<li key={colour.value}>
<Link
href={toggleHref('colors', colour.value)}
aria-pressed={active}
title={`${colour.label} (${colour.count})`}
className={cn(
'flex size-8 items-center justify-center rounded-full border transition-colors',
active
? 'border-ink-950 ring-ink-950 ring-1'
: 'border-ink-200 hover:border-ink-400',
)}
style={{ backgroundColor: colour.swatchHex ?? undefined }}
>
<span className="sr-only">{colour.label}</span>
</Link>
</li>
);
})}
</ul>
</section>
) : null}
{facets.sizes.length > 0 ? (
<section>
<h3 className="text-ink-500 mb-3 text-xs font-semibold uppercase tracking-widest">
{t('facet.size')}
</h3>
<ul className="flex flex-wrap gap-2">
{facets.sizes.map((size) => {
const active = query.sizes?.includes(size.value) ?? false;
return (
<li key={size.value}>
<Link
href={toggleHref('sizes', size.value)}
aria-pressed={active}
className={cn(
'flex h-9 min-w-11 items-center justify-center border px-2 text-xs font-medium uppercase transition-colors',
active
? 'border-ink-950 bg-ink-950 text-white'
: 'border-ink-200 hover:border-ink-400',
)}
>
{size.label}
</Link>
</li>
);
})}
</ul>
</section>
) : null}
{facets.brands.length > 0 ? (
<section>
<h3 className="text-ink-500 mb-3 text-xs font-semibold uppercase tracking-widest">
{t('facet.brand')}
</h3>
<ul className="space-y-1.5">
{facets.brands.map((brand) => {
const active = query.brandSlugs?.includes(brand.value) ?? false;
return (
<li key={brand.value}>
<Link
href={toggleHref('brandSlugs', brand.value)}
aria-pressed={active}
className={cn(
'flex items-baseline justify-between text-sm transition-colors',
active ? 'text-ink-950 font-semibold' : 'text-ink-600 hover:text-ink-950',
)}
>
<span>{brand.label}</span>
<span className="text-ink-400 text-xs">{brand.count}</span>
</Link>
</li>
);
})}
</ul>
</section>
) : null}
<section>
<Link
href={buildHref(basePath, { ...query, onSale: query.onSale ? undefined : true })}
aria-pressed={Boolean(query.onSale)}
className={cn(
'inline-flex items-center gap-2 text-sm transition-colors',
query.onSale ? 'text-sale font-semibold' : 'text-ink-600 hover:text-ink-950',
)}
>
<span
className={cn('size-4 border', query.onSale ? 'border-sale bg-sale' : 'border-ink-300')}
/>
{t('facet.onSale')}
</Link>
</section>
</aside>
);
}
/**
* Serialises a query back into a URL.
*
* Only presentation-level filters are emitted — `gender`, `sport`,
* `categorySlug` and `collectionSlug` are implied by the route itself, so
* repeating them in the query string would produce ugly, duplicate-content URLs
* like `/men?gender=MEN`.
*/
function buildHref(basePath: string, query: ProductListQuery): string {
const params = new URLSearchParams();
if (query.q) params.set('q', query.q);
if (query.colors?.length) params.set('colors', query.colors.join(','));
if (query.sizes?.length) params.set('sizes', query.sizes.join(','));
if (query.brandSlugs?.length) params.set('brandSlugs', query.brandSlugs.join(','));
if (query.onSale) params.set('onSale', 'true');
if (query.sort && query.sort !== 'newest') params.set('sort', query.sort);
const search = params.toString();
return search ? `${basePath}?${search}` : basePath;
}
@@ -0,0 +1,27 @@
import { useTranslations } from 'next-intl';
import type { ProductListItem } from '@sport/types';
import { ProductCard } from './product-card';
export function ProductGrid({ products }: { products: readonly ProductListItem[] }) {
const t = useTranslations('listing');
if (products.length === 0) {
return (
<div className="py-24 text-center">
<p className="text-lg font-medium">{t('empty')}</p>
<p className="text-ink-500 mt-2 text-sm">{t('emptyHint')}</p>
</div>
);
}
return (
<div className="grid grid-cols-2 gap-x-4 gap-y-10 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{products.map((product, index) => (
// The first row is above the fold on every viewport we support.
<ProductCard key={product.id} product={product} priority={index < 4} />
))}
</div>
);
}
@@ -0,0 +1,56 @@
import { getTranslations } from 'next-intl/server';
import type { ProductListQuery } from '@sport/api-client';
import type { Locale } from '@sport/types';
import { fetchProducts } from '@/features/product/services/catalog';
import { ProductFilters } from './product-filters';
import { ProductGrid } from './product-grid';
/**
* One listing component behind /men, /women, /sports/*, /collections/* and
* /search.
*
* They are the same query with different presets, so they are the same
* component. Five near-identical page implementations is how filter behaviour
* starts drifting between routes.
*/
export async function ProductListing({
locale,
title,
eyebrow,
description,
query,
basePath,
}: {
locale: Locale;
title: string;
eyebrow?: string;
description?: string | null;
query: ProductListQuery;
basePath: string;
}) {
const t = await getTranslations('listing');
const result = await fetchProducts(locale, query);
return (
<div className="max-w-page px-gutter mx-auto py-12">
<header className="mb-10">
{eyebrow ? (
<p className="text-ink-400 text-xs font-semibold uppercase tracking-widest">{eyebrow}</p>
) : null}
<h1 className="mt-2 text-4xl font-black uppercase sm:text-5xl">{title}</h1>
{description ? <p className="text-ink-500 mt-4 max-w-2xl text-sm">{description}</p> : null}
<p className="text-ink-400 mt-4 text-xs uppercase tracking-widest">
{t('resultsCount', { count: result.totalCount })}
</p>
</header>
<div className="grid gap-10 lg:grid-cols-[16rem_1fr]">
<ProductFilters facets={result.facets} basePath={basePath} query={query} />
<ProductGrid products={result.items} />
</div>
</div>
);
}
@@ -0,0 +1,58 @@
import type { ProductListQuery } from '@sport/api-client';
import { isApiClientError } from '@sport/api-client';
import type { Locale, NavigationMenu, ProductListResult, StorefrontProduct } from '@sport/types';
import { CATALOG_CACHE, getServerApi } from '@/lib/api';
/**
* Server-side catalog reads.
*
* Every page goes through these rather than calling the client directly, so
* caching policy and failure behaviour are decided once. The distinction that
* matters: navigation and listings **degrade** (an empty shelf is better than
* an error page), while a product detail page **fails loudly** so Next can
* render a real 404 and search engines get the right status code.
*/
export async function fetchNavigation(locale: Locale): Promise<NavigationMenu | null> {
try {
return await getServerApi().catalog.getNavigation(locale, CATALOG_CACHE.navigation);
} catch {
// The header must render even if the catalog service is down. Losing the
// menu is survivable; losing every page is not.
return null;
}
}
export async function fetchProducts(
locale: Locale,
query: ProductListQuery,
): Promise<ProductListResult> {
try {
return await getServerApi().catalog.listProducts(locale, query, CATALOG_CACHE.listing);
} catch {
return {
items: [],
pageInfo: { nextCursor: null, hasNextPage: false },
totalCount: 0,
facets: { brands: [], colors: [], sizes: [], priceRange: null },
};
}
}
/** Returns null for a genuine 404 and rethrows anything else. */
export async function fetchProduct(
locale: Locale,
slug: string,
): Promise<StorefrontProduct | null> {
try {
return await getServerApi().catalog.getProduct(locale, slug, CATALOG_CACHE.product);
} catch (error) {
if (isApiClientError(error) && error.status === 404) {
return null;
}
// A 500 or a network failure is not a missing product — surfacing it as a
// 404 would tell search engines to drop a page that still exists.
throw error;
}
}
+12
View File
@@ -0,0 +1,12 @@
import { createNavigation } from 'next-intl/navigation';
import { routing } from './routing';
/**
* Locale-aware replacements for `next/link` and the navigation hooks.
*
* Always import `Link` from here rather than `next/link`: these variants add
* the locale prefix automatically, so a link written once works in both
* languages and a language switch preserves the current page.
*/
export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing);
+27
View File
@@ -0,0 +1,27 @@
import { hasLocale } from 'next-intl';
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
/**
* Resolves the locale and loads its message catalog for every server render.
*
* Catalogs are imported dynamically so a request only ever ships the messages
* for the locale being rendered — the other language is never in the bundle.
*/
export default getRequestConfig(async ({ requestLocale }) => {
const requested = await requestLocale;
const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale;
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
// Money and dates are formatted from the same locale everywhere, so a
// component can never accidentally format VND with an en-US grouping.
formats: {
number: {
currency: { style: 'currency', currency: 'VND', maximumFractionDigits: 0 },
},
},
};
});
+25
View File
@@ -0,0 +1,25 @@
import { defineRouting } from 'next-intl/routing';
import { DEFAULT_LOCALE, LOCALES } from '@sport/types';
/**
* Locale routing.
*
* `localePrefix: 'as-needed'` means Vietnamese — the default and the market's
* language — is served from clean URLs (`/men`, `/products/ao-chay-bo-aero`)
* while English is prefixed (`/en/men`). The majority of visitors therefore
* never see a locale segment or pay a redirect, and English still gets its own
* indexable URL space.
*
* The locale list itself comes from @sport/types, so the API, the database enum
* and the frontend cannot drift apart.
*/
export const routing = defineRouting({
locales: LOCALES,
defaultLocale: DEFAULT_LOCALE,
localePrefix: 'as-needed',
// Cookie is set only on an explicit switch; detection otherwise falls back to
// Accept-Language. A sticky cookie that overrides a shared link is a bug —
// sending someone /en/... should show them English.
localeDetection: true,
});
+23 -5
View File
@@ -1,12 +1,12 @@
import { createApiClient } from '@sport/api-client';
import { createApiClient, type RequestOptions } from '@sport/api-client';
import { clientEnv, getServerEnv } from './env';
/**
* Two clients, because the two runtimes have different needs:
*
* - `serverApi` runs inside React Server Components and route handlers. In
* Docker it reaches the API container over the internal network, skipping
* - `getServerApi()` runs inside React Server Components and route handlers.
* In Docker it reaches the API container over the internal network, skipping
* the public hostname and TLS entirely.
* - `browserApi` runs in the browser, hits the public API and carries the
* access token.
@@ -17,8 +17,8 @@ import { clientEnv, getServerEnv } from './env';
export function getServerApi() {
return createApiClient({
baseUrl: getServerEnv().API_INTERNAL_URL,
// Server-side requests are anonymous by default. Authenticated server
// fetches pass the token explicitly, per request, once auth lands.
// Server-side reads are anonymous. Authenticated server fetches will pass
// the token explicitly per request once auth lands.
});
}
@@ -26,3 +26,21 @@ export const browserApi = createApiClient({
baseUrl: clientEnv.NEXT_PUBLIC_API_URL,
getAccessToken: () => null, // wired to the auth store in the auth milestone
});
/**
* Caching policy for catalog reads, in one place.
*
* Tags let a future admin write invalidate exactly what changed via
* `revalidateTag` instead of waiting out a TTL. The durations are the
* *frontend's* policy and are deliberately independent of the API's own Redis
* TTLs — two layers, two decisions.
*/
export const CATALOG_CACHE: Record<
'navigation' | 'listing' | 'product' | 'collections',
RequestOptions
> = {
navigation: { next: { revalidate: 900, tags: ['navigation'] } },
listing: { next: { revalidate: 60, tags: ['products'] } },
product: { next: { revalidate: 300, tags: ['products'] } },
collections: { next: { revalidate: 300, tags: ['collections'] } },
};
+21 -22
View File
@@ -1,31 +1,30 @@
import type { useFormatter } from 'next-intl';
import { MINOR_UNIT_SCALE, type Money } from '@sport/types';
const formatters = new Map<string, Intl.NumberFormat>();
type Formatter = ReturnType<typeof useFormatter>;
/**
* Money is stored and transferred as an integer in minor units; it is converted
* to a display string exactly here and nowhere else. No component ever does its
* own division — that is how rounding bugs reach a checkout total.
* Money is stored and transferred as an integer in minor units; it becomes a
* display string exactly here and nowhere else. No component does its own
* division — that is how rounding bugs reach a checkout total.
*
* The formatter comes from next-intl so grouping and currency placement follow
* the active locale: `690.000 ₫` in Vietnamese, `₫690,000` in English.
*/
export function formatMoney(money: Money, locale = 'vi-VN'): string {
const cacheKey = `${locale}:${money.currency}`;
let formatter = formatters.get(cacheKey);
export function formatMoney(money: Money, format: Formatter): string {
const scale = MINOR_UNIT_SCALE[money.currency];
if (!formatter) {
const scale = MINOR_UNIT_SCALE[money.currency];
formatter = new Intl.NumberFormat(locale, {
style: 'currency',
currency: money.currency,
minimumFractionDigits: scale,
maximumFractionDigits: scale,
});
formatters.set(cacheKey, formatter);
}
return formatter.format(money.amount / 10 ** MINOR_UNIT_SCALE[money.currency]);
return format.number(money.amount / 10 ** scale, {
style: 'currency',
currency: money.currency,
minimumFractionDigits: scale,
maximumFractionDigits: scale,
});
}
export function formatDiscountPercent(price: Money, compareAt: Money): number {
if (compareAt.amount <= 0) return 0;
return Math.round(((compareAt.amount - price.amount) / compareAt.amount) * 100);
/** Whole-percent discount, rounded down so "50%" is never an overstatement. */
export function discountPercent(price: Money, compareAt: Money): number {
if (compareAt.amount <= 0 || price.amount >= compareAt.amount) return 0;
return Math.floor(((compareAt.amount - price.amount) / compareAt.amount) * 100);
}
+48
View File
@@ -0,0 +1,48 @@
import type { ProductListQuery } from '@sport/api-client';
/**
* Parses listing filters out of a Next.js `searchParams` object.
*
* Kept deliberately permissive: unknown or malformed values are dropped rather
* than rejected, because a listing page reached from a stale bookmark should
* render products, not an error. The API re-validates everything anyway
* (@sport/validation), so this is convenience, not enforcement.
*/
export type RawSearchParams = Record<string, string | string[] | undefined>;
const SORTS = ['newest', 'price_asc', 'price_desc', 'best_selling', 'relevance'] as const;
export function parseListingParams(params: RawSearchParams): ProductListQuery {
return {
q: single(params['q']),
colors: list(params['colors']),
sizes: list(params['sizes']),
brandSlugs: list(params['brandSlugs']),
categorySlug: single(params['category']),
onSale: single(params['onSale']) === 'true' ? true : undefined,
sort: parseSort(single(params['sort'])),
};
}
function single(value: string | string[] | undefined): string | undefined {
const raw = Array.isArray(value) ? value[0] : value;
return raw && raw.length > 0 ? raw : undefined;
}
/** Accepts both `?colors=a,b` and repeated `?colors=a&colors=b`. */
function list(value: string | string[] | undefined): string[] | undefined {
if (!value) return undefined;
const items = (Array.isArray(value) ? value : [value])
.flatMap((entry) => entry.split(','))
.map((entry) => entry.trim())
.filter(Boolean);
return items.length > 0 ? items : undefined;
}
function parseSort(value: string | undefined): ProductListQuery['sort'] {
return SORTS.includes(value as (typeof SORTS)[number])
? (value as ProductListQuery['sort'])
: undefined;
}
+157
View File
@@ -0,0 +1,157 @@
{
"meta": {
"siteName": "Sport Store",
"defaultTitle": "Sport Store — Performance sportswear",
"defaultDescription": "Performance sportswear for running, football, training, gym, badminton and lifestyle."
},
"nav": {
"ariaMain": "Main",
"men": "Men",
"women": "Women",
"sports": "Sports",
"search": "Search",
"account": "Account",
"cart": "Bag",
"openMenu": "Open menu",
"closeMenu": "Close menu",
"language": "Language",
"changeLanguage": "Change language"
},
"sports": {
"running": "Running",
"football": "Football",
"training": "Training",
"gym": "Gym",
"badminton": "Badminton",
"lifestyle": "Lifestyle"
},
"home": {
"heroEyebrow": "New season",
"heroTitle": "Built for the work",
"heroBody": "Kit that holds up on the fifth rep and the fifteenth kilometre. No shortcuts, no filler.",
"heroCta": "Shop new arrivals",
"heroSecondary": "Browse all",
"newArrivals": "New arrivals",
"shopBySport": "Shop by sport",
"onSale": "On sale",
"viewAll": "View all"
},
"listing": {
"resultsCount": "{count, plural, =0 {No products} one {# product} other {# products}}",
"empty": "Nothing matches those filters.",
"emptyHint": "Try removing a filter or two.",
"loadMore": "Load more",
"filters": "Filters",
"clearAll": "Clear all",
"sortBy": "Sort by",
"sort": {
"newest": "Newest",
"price_asc": "Price: low to high",
"price_desc": "Price: high to low",
"best_selling": "Best selling",
"relevance": "Relevance"
},
"facet": {
"brand": "Brand",
"colour": "Colour",
"size": "Size",
"price": "Price",
"onSale": "On sale only",
"inStock": "In stock only"
}
},
"product": {
"selectColour": "Colour",
"selectSize": "Size",
"selectSizePrompt": "Choose a size",
"addToBag": "Add to bag",
"outOfStock": "Out of stock",
"lowStock": "Only a few left",
"inStock": "In stock",
"sku": "SKU",
"details": "Details",
"specifications": "Specifications",
"sizeGuide": "Size guide",
"save": "Save {percent}%",
"sale": "Sale",
"new": "New",
"from": "From",
"notAvailable": "This combination is not available",
"comingSoon": "Add to bag arrives with the cart milestone."
},
"search": {
"title": "Search",
"placeholder": "Search products, brands, sports",
"resultsFor": "Results for “{query}”",
"noQuery": "Type something to start searching."
},
"breadcrumbs": {
"home": "Home"
},
"footer": {
"tagline": "Performance sportswear, built for training days and the ones after.",
"shop": "Shop",
"sports": "Sports",
"account": "Account",
"orders": "Orders",
"wishlist": "Wishlist",
"journal": "Journal",
"rights": "© {year} Sport Store."
},
"account": {
"title": "Account",
"profile": "Profile",
"orders": "Orders",
"addresses": "Addresses",
"wishlist": "Wishlist"
},
"notFound": {
"code": "404",
"title": "Nothing here",
"body": "That page has moved or never existed. The gear is still where you left it.",
"cta": "Back to shop"
},
"error": {
"title": "Something went wrong",
"body": "We could not load this page. Please try again.",
"retry": "Try again",
"offline": "We could not reach the store. Check your connection and try again."
},
"milestone": {
"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."
},
"profile": {
"title": "Profile",
"body": "Name, email, phone, password and marketing preferences."
},
"orders": {
"title": "Orders",
"body": "Order history and detail. Every line renders the snapshot taken at purchase time, never the live catalog."
},
"addresses": {
"title": "Addresses",
"body": "Address book with Vietnamese province, district and ward selection, stored with administrative codes for shipping integrations."
},
"wishlist": {
"title": "Wishlist",
"body": "Saved variants, and the signal source for back-in-stock notifications."
}
}
}
+157
View File
@@ -0,0 +1,157 @@
{
"meta": {
"siteName": "Sport Store",
"defaultTitle": "Sport Store — Trang phục thể thao hiệu suất cao",
"defaultDescription": "Trang phục thể thao cho chạy bộ, bóng đá, tập luyện, gym, cầu lông và phong cách hằng ngày."
},
"nav": {
"ariaMain": "Điều hướng chính",
"men": "Nam",
"women": "Nữ",
"sports": "Môn thể thao",
"search": "Tìm kiếm",
"account": "Tài khoản",
"cart": "Giỏ hàng",
"openMenu": "Mở menu",
"closeMenu": "Đóng menu",
"language": "Ngôn ngữ",
"changeLanguage": "Đổi ngôn ngữ"
},
"sports": {
"running": "Chạy bộ",
"football": "Bóng đá",
"training": "Tập luyện",
"gym": "Gym",
"badminton": "Cầu lông",
"lifestyle": "Hằng ngày"
},
"home": {
"heroEyebrow": "Mùa mới",
"heroTitle": "Sinh ra để tập luyện",
"heroBody": "Trang phục bền bỉ từ hiệp thứ năm đến cây số thứ mười lăm. Không đường tắt, không thừa thãi.",
"heroCta": "Xem hàng mới về",
"heroSecondary": "Xem tất cả",
"newArrivals": "Hàng mới về",
"shopBySport": "Mua theo môn",
"onSale": "Đang giảm giá",
"viewAll": "Xem tất cả"
},
"listing": {
"resultsCount": "{count, plural, =0 {Không có sản phẩm} other {# sản phẩm}}",
"empty": "Không có sản phẩm nào phù hợp.",
"emptyHint": "Thử bỏ bớt một vài bộ lọc.",
"loadMore": "Xem thêm",
"filters": "Bộ lọc",
"clearAll": "Xoá tất cả",
"sortBy": "Sắp xếp",
"sort": {
"newest": "Mới nhất",
"price_asc": "Giá: thấp đến cao",
"price_desc": "Giá: cao đến thấp",
"best_selling": "Bán chạy nhất",
"relevance": "Liên quan nhất"
},
"facet": {
"brand": "Thương hiệu",
"colour": "Màu sắc",
"size": "Kích cỡ",
"price": "Giá",
"onSale": "Chỉ hàng giảm giá",
"inStock": "Chỉ hàng còn sẵn"
}
},
"product": {
"selectColour": "Màu sắc",
"selectSize": "Kích cỡ",
"selectSizePrompt": "Chọn kích cỡ",
"addToBag": "Thêm vào giỏ",
"outOfStock": "Hết hàng",
"lowStock": "Sắp hết hàng",
"inStock": "Còn hàng",
"sku": "Mã SKU",
"details": "Chi tiết",
"specifications": "Thông số",
"sizeGuide": "Hướng dẫn chọn size",
"save": "Giảm {percent}%",
"sale": "Giảm giá",
"new": "Mới",
"from": "Từ",
"notAvailable": "Phiên bản này không có sẵn",
"comingSoon": "Chức năng thêm vào giỏ sẽ có ở giai đoạn giỏ hàng."
},
"search": {
"title": "Tìm kiếm",
"placeholder": "Tìm sản phẩm, thương hiệu, môn thể thao",
"resultsFor": "Kết quả cho “{query}”",
"noQuery": "Nhập từ khoá để bắt đầu tìm kiếm."
},
"breadcrumbs": {
"home": "Trang chủ"
},
"footer": {
"tagline": "Trang phục thể thao bền bỉ, cho ngày tập và cả những ngày sau đó.",
"shop": "Mua sắm",
"sports": "Môn thể thao",
"account": "Tài khoản",
"orders": "Đơn hàng",
"wishlist": "Yêu thích",
"journal": "Bài viết",
"rights": "© {year} Sport Store."
},
"account": {
"title": "Tài khoản",
"profile": "Hồ sơ",
"orders": "Đơn hàng",
"addresses": "Địa chỉ",
"wishlist": "Yêu thích"
},
"notFound": {
"code": "404",
"title": "Không tìm thấy",
"body": "Trang này đã được chuyển hoặc chưa từng tồn tại. Đồ tập vẫn ở nguyên chỗ cũ.",
"cta": "Quay lại cửa hàng"
},
"error": {
"title": "Đã có lỗi xảy ra",
"body": "Không thể tải trang này. Vui lòng thử lại.",
"retry": "Thử lại",
"offline": "Không kết nối được tới cửa hàng. Kiểm tra kết nối và thử lại."
},
"milestone": {
"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."
},
"profile": {
"title": "Hồ sơ",
"body": "Họ tên, email, số điện thoại, mật khẩu và tuỳ chọn nhận thông tin."
},
"orders": {
"title": "Đơn hàng",
"body": "Lịch sử và chi tiết đơn hàng. Mỗi dòng hiển thị thông tin tại thời điểm mua, không lấy từ catalog hiện tại."
},
"addresses": {
"title": "Địa chỉ",
"body": "Sổ địa chỉ với lựa chọn tỉnh/thành, quận/huyện và phường/xã, lưu kèm mã hành chính để tích hợp đơn vị vận chuyển."
},
"wishlist": {
"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."
}
}
}
+30
View File
@@ -0,0 +1,30 @@
import type { NextRequest } from 'next/server';
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
/**
* Locale negotiation at the edge.
*
* Note the filename: Next.js 16 renamed the `middleware` convention to `proxy`.
* `middleware.ts` still works but is deprecated, so this uses the current name.
*
* Responsibilities are deliberately narrow — resolve a locale, rewrite or
* redirect, and nothing else. Proxy code runs on every request and may be
* deployed to a CDN edge, so it must not reach for the database or shared
* application state.
*/
const handleI18nRouting = createMiddleware(routing);
export function proxy(request: NextRequest) {
return handleI18nRouting(request);
}
export const config = {
/**
* Everything except API routes, Next internals and files with an extension.
* Matching static assets would add a proxy hop to every image and font for
* no benefit.
*/
matcher: '/((?!api|_next|_vercel|.*\\..*).*)',
};