Stage M1
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
);
|
||||
}
|
||||
+20
-4
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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'] } },
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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|.*\\..*).*)',
|
||||
};
|
||||
Reference in New Issue
Block a user