Basic Architecture of Sport Web

This commit is contained in:
Nông Đức Huy
2026-08-11 13:37:25 +07:00
commit 8032fff6ac
262 changed files with 20348 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# ---------------------------------------------------------------------------
# apps/storefront
#
# NEXT_PUBLIC_* variables are inlined into the JavaScript bundle and are
# therefore PUBLIC. Never put a secret behind that prefix.
# ---------------------------------------------------------------------------
# Used by the browser. In production this is the public API hostname.
NEXT_PUBLIC_API_URL=http://localhost:4000
NEXT_PUBLIC_SITE_URL=http://localhost:3000
# Used by Server Components / route handlers only. Inside Docker this points at
# the API container directly, skipping the public round-trip.
API_INTERNAL_URL=http://localhost:4000
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+3
View File
@@ -0,0 +1,3 @@
import { nextConfig } from '@sport/eslint-config/next';
export default nextConfig;
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+34
View File
@@ -0,0 +1,34 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
reactStrictMode: true,
/**
* Workspace packages ship TypeScript source rather than a build artefact, so
* Next compiles them with the app. No watch-and-rebuild step during local
* development, and dead code is tree-shaken per app.
*/
transpilePackages: ['@sport/ui'],
// Compile-time checked <Link href> values — a typo becomes a build failure.
typedRoutes: true,
images: {
// Media is served from R2/CDN. Locally that is MinIO.
remotePatterns: [
{ protocol: 'http', hostname: 'localhost', port: '9000' },
{ protocol: 'https', hostname: '**.r2.dev' },
{ protocol: 'https', hostname: 'cdn.sport-store.local' },
],
formats: ['image/avif', 'image/webp'],
},
// Standalone output keeps the production image small (no node_modules copy).
output: 'standalone',
experimental: {
optimizePackageImports: ['@sport/ui'],
},
};
export default nextConfig;
+37
View File
@@ -0,0 +1,37 @@
{
"name": "@sport/storefront",
"version": "0.0.0",
"private": true,
"description": "Customer-facing Next.js storefront.",
"scripts": {
"dev": "next dev --port 3000",
"build": "next build",
"start": "next start --port 3000",
"lint": "eslint src",
"typecheck": "tsc -p tsconfig.json --noEmit",
"clean": "rm -rf .next .turbo *.tsbuildinfo"
},
"dependencies": {
"@sport/api-client": "workspace:*",
"@sport/types": "workspace:*",
"@sport/ui": "workspace:*",
"@sport/validation": "workspace:*",
"@tanstack/react-query": "^5.101.4",
"next": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"zustand": "^5.0.14",
"zod": "catalog:"
},
"devDependencies": {
"@sport/config": "workspace:*",
"@sport/eslint-config": "workspace:*",
"@tailwindcss/postcss": "catalog:",
"@types/node": "^22.19.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"eslint": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"
}
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default config;
@@ -0,0 +1,15 @@
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"
/>
);
}
@@ -0,0 +1,43 @@
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>
);
}
@@ -0,0 +1,15 @@
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"
/>
);
}
@@ -0,0 +1,15 @@
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"
/>
);
}
@@ -0,0 +1,15 @@
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"
/>
);
}
@@ -0,0 +1,15 @@
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"
/>
);
}
@@ -0,0 +1,15 @@
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"
/>
);
}
@@ -0,0 +1,22 @@
import Link from 'next/link';
import { routes } from '@/lib/routes';
/** Minimal chrome: no nav, no footer links, nothing competing with completion. */
export default function CheckoutLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen flex-col">
<header className="border-ink-200 border-b">
<div className="px-gutter mx-auto flex h-16 max-w-4xl items-center justify-between">
<Link href={routes.home()} className="text-lg font-black uppercase tracking-tighter">
Sport<span className="text-volt-600">.</span>
</Link>
<span className="text-ink-400 text-xs font-semibold uppercase tracking-widest">
Secure checkout
</span>
</div>
</header>
<main className="flex-1">{children}</main>
</div>
);
}
@@ -0,0 +1,16 @@
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"
/>
);
}
@@ -0,0 +1,16 @@
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"
/>
);
}
@@ -0,0 +1,23 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
type PageProps = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
return { title: slug };
}
export default async function CollectionPage({ params }: PageProps) {
const { slug } = await params;
return (
<PageScaffold
eyebrow="Collection"
title={slug}
description="Editorial collection page: campaign banner, curated copy and the shared product grid."
milestone="M4 — storefront catalog"
/>
);
}
+17
View File
@@ -0,0 +1,17 @@
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>
);
}
@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
import { PageScaffold } from '@/components/layout/page-scaffold';
export const metadata: Metadata = { title: 'Men' };
export default function MenPage() {
return (
<PageScaffold
eyebrow="Shop"
title="Men"
description="Gender-facet listing. Same product-grid feature as every other listing page, preset with gender=MEN."
milestone="M4 — storefront catalog"
/>
);
}
+15
View File
@@ -0,0 +1,15 @@
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"
/>
);
}
@@ -0,0 +1,33 @@
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"
/>
);
}
@@ -0,0 +1,16 @@
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"
/>
);
}
@@ -0,0 +1,43 @@
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"
/>
);
}
@@ -0,0 +1,16 @@
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"
/>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { Metadata, Viewport } from 'next';
import '@/styles/globals.css';
export const metadata: Metadata = {
title: {
default: 'Sport Store — Performance sportswear',
template: '%s | Sport Store',
},
description:
'Performance sportswear for running, football, training, gym, badminton and lifestyle.',
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'),
};
export const viewport: Viewport = {
themeColor: '#111111',
width: 'device-width',
initialScale: 1,
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="vi" suppressHydrationWarning>
<body className="min-h-screen antialiased">{children}</body>
</html>
);
}
+18
View File
@@ -0,0 +1,18 @@
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,35 @@
import { Badge } from '@sport/ui';
/**
* Temporary scaffold used by every route in the skeleton.
*
* 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.
*/
export function PageScaffold({
eyebrow,
title,
description,
milestone,
}: {
eyebrow?: string;
title: string;
description: string;
milestone: string;
}) {
return (
<div className="max-w-page px-gutter mx-auto py-20">
{eyebrow ? (
<p className="text-ink-400 text-xs font-semibold uppercase tracking-widest">{eyebrow}</p>
) : null}
<h1 className="mt-3 text-4xl font-black uppercase sm:text-6xl">{title}</h1>
<p className="text-ink-500 mt-5 max-w-2xl text-base">{description}</p>
<div className="mt-8">
<Badge variant="outline">Planned: {milestone}</Badge>
</div>
</div>
);
}
@@ -0,0 +1,74 @@
import Link from 'next/link';
import { SPORT_NAV, routes } from '@/lib/routes';
export function SiteFooter() {
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">
<div>
<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>
</div>
<nav aria-label="Shop">
<h2 className="text-xs font-semibold uppercase tracking-widest text-white">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}
</Link>
</li>
))}
</ul>
</nav>
<nav aria-label="Account">
<h2 className="text-xs font-semibold uppercase tracking-widest text-white">Account</h2>
<ul className="text-ink-400 mt-4 space-y-2 text-sm">
<li>
<Link href={routes.accountOrders()} className="hover:text-white">
Orders
</Link>
</li>
<li>
<Link href={routes.accountWishlist()} className="hover:text-white">
Wishlist
</Link>
</li>
<li>
<Link href={routes.blog()} className="hover:text-white">
Journal
</Link>
</li>
</ul>
</nav>
</div>
<div className="border-ink-800 text-ink-500 border-t py-6 text-center text-xs">
© {new Date().getFullYear()} Sport Store.
</div>
</footer>
);
}
@@ -0,0 +1,56 @@
import Link from 'next/link';
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.
*/
export function SiteHeader() {
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">
<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>
{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}
</Link>
))}
</nav>
<div className="ml-auto flex items-center gap-5 text-xs font-semibold uppercase tracking-widest">
<Link href={routes.search()} className="hover:text-volt-600">
Search
</Link>
<Link href={routes.account()} className="hover:text-volt-600">
Account
</Link>
<Link href={routes.cart()} className="hover:text-volt-600">
Cart
</Link>
</div>
</div>
</header>
);
}
@@ -0,0 +1,24 @@
# feature: account
Profile, addresses and account settings.
## Structure
```
account/
├── components/ # UI specific to this feature
├── hooks/ # React hooks (client-side only)
├── services/ # Calls into @sport/api-client, plus query keys
├── stores/ # Zustand slices, only if this feature owns client state
└── types.ts # View-model types. Domain types come from @sport/types.
```
## Rules
- A feature may import from `@/components`, `@/lib`, `@/hooks` and any
`@sport/*` package.
- A feature must **not** import from another feature's internals. If two
features need the same thing, it moves up to `@/components` or `@/lib`.
Cross-feature imports are what turn a feature folder into a second, worse
module system.
- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API.
@@ -0,0 +1,24 @@
# feature: auth
Sign in, register, password reset, and the session store the rest of the app reads.
## Structure
```
auth/
├── components/ # UI specific to this feature
├── hooks/ # React hooks (client-side only)
├── services/ # Calls into @sport/api-client, plus query keys
├── stores/ # Zustand slices, only if this feature owns client state
└── types.ts # View-model types. Domain types come from @sport/types.
```
## Rules
- A feature may import from `@/components`, `@/lib`, `@/hooks` and any
`@sport/*` package.
- A feature must **not** import from another feature's internals. If two
features need the same thing, it moves up to `@/components` or `@/lib`.
Cross-feature imports are what turn a feature folder into a second, worse
module system.
- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API.
@@ -0,0 +1,24 @@
# feature: cart
Bag drawer and page, line-item mutations, optimistic quantity updates.
## Structure
```
cart/
├── components/ # UI specific to this feature
├── hooks/ # React hooks (client-side only)
├── services/ # Calls into @sport/api-client, plus query keys
├── stores/ # Zustand slices, only if this feature owns client state
└── types.ts # View-model types. Domain types come from @sport/types.
```
## Rules
- A feature may import from `@/components`, `@/lib`, `@/hooks` and any
`@sport/*` package.
- A feature must **not** import from another feature's internals. If two
features need the same thing, it moves up to `@/components` or `@/lib`.
Cross-feature imports are what turn a feature folder into a second, worse
module system.
- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API.
@@ -0,0 +1,24 @@
# feature: category
Category landing pages, breadcrumbs and the facet sidebar.
## Structure
```
category/
├── components/ # UI specific to this feature
├── hooks/ # React hooks (client-side only)
├── services/ # Calls into @sport/api-client, plus query keys
├── stores/ # Zustand slices, only if this feature owns client state
└── types.ts # View-model types. Domain types come from @sport/types.
```
## Rules
- A feature may import from `@/components`, `@/lib`, `@/hooks` and any
`@sport/*` package.
- A feature must **not** import from another feature's internals. If two
features need the same thing, it moves up to `@/components` or `@/lib`.
Cross-feature imports are what turn a feature folder into a second, worse
module system.
- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API.
@@ -0,0 +1,24 @@
# feature: checkout
Multi-step checkout: address, delivery, payment, review.
## Structure
```
checkout/
├── components/ # UI specific to this feature
├── hooks/ # React hooks (client-side only)
├── services/ # Calls into @sport/api-client, plus query keys
├── stores/ # Zustand slices, only if this feature owns client state
└── types.ts # View-model types. Domain types come from @sport/types.
```
## Rules
- A feature may import from `@/components`, `@/lib`, `@/hooks` and any
`@sport/*` package.
- A feature must **not** import from another feature's internals. If two
features need the same thing, it moves up to `@/components` or `@/lib`.
Cross-feature imports are what turn a feature folder into a second, worse
module system.
- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API.
@@ -0,0 +1,24 @@
# feature: collection
Campaign and editorial collection pages.
## Structure
```
collection/
├── components/ # UI specific to this feature
├── hooks/ # React hooks (client-side only)
├── services/ # Calls into @sport/api-client, plus query keys
├── stores/ # Zustand slices, only if this feature owns client state
└── types.ts # View-model types. Domain types come from @sport/types.
```
## Rules
- A feature may import from `@/components`, `@/lib`, `@/hooks` and any
`@sport/*` package.
- A feature must **not** import from another feature's internals. If two
features need the same thing, it moves up to `@/components` or `@/lib`.
Cross-feature imports are what turn a feature folder into a second, worse
module system.
- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API.
@@ -0,0 +1,24 @@
# feature: order
Order confirmation and order history views.
## Structure
```
order/
├── components/ # UI specific to this feature
├── hooks/ # React hooks (client-side only)
├── services/ # Calls into @sport/api-client, plus query keys
├── stores/ # Zustand slices, only if this feature owns client state
└── types.ts # View-model types. Domain types come from @sport/types.
```
## Rules
- A feature may import from `@/components`, `@/lib`, `@/hooks` and any
`@sport/*` package.
- A feature must **not** import from another feature's internals. If two
features need the same thing, it moves up to `@/components` or `@/lib`.
Cross-feature imports are what turn a feature folder into a second, worse
module system.
- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API.
@@ -0,0 +1,24 @@
# feature: product
PDP: gallery, variant selector, price display, add-to-bag. Owns `<ProductCard>`.
## Structure
```
product/
├── components/ # UI specific to this feature
├── hooks/ # React hooks (client-side only)
├── services/ # Calls into @sport/api-client, plus query keys
├── stores/ # Zustand slices, only if this feature owns client state
└── types.ts # View-model types. Domain types come from @sport/types.
```
## Rules
- A feature may import from `@/components`, `@/lib`, `@/hooks` and any
`@sport/*` package.
- A feature must **not** import from another feature's internals. If two
features need the same thing, it moves up to `@/components` or `@/lib`.
Cross-feature imports are what turn a feature folder into a second, worse
module system.
- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API.
@@ -0,0 +1,24 @@
# feature: search
Search input, suggestions, results and the shared filter state.
## Structure
```
search/
├── components/ # UI specific to this feature
├── hooks/ # React hooks (client-side only)
├── services/ # Calls into @sport/api-client, plus query keys
├── stores/ # Zustand slices, only if this feature owns client state
└── types.ts # View-model types. Domain types come from @sport/types.
```
## Rules
- A feature may import from `@/components`, `@/lib`, `@/hooks` and any
`@sport/*` package.
- A feature must **not** import from another feature's internals. If two
features need the same thing, it moves up to `@/components` or `@/lib`.
Cross-feature imports are what turn a feature folder into a second, worse
module system.
- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API.
@@ -0,0 +1,24 @@
# feature: wishlist
Save-for-later toggles and the wishlist page.
## Structure
```
wishlist/
├── components/ # UI specific to this feature
├── hooks/ # React hooks (client-side only)
├── services/ # Calls into @sport/api-client, plus query keys
├── stores/ # Zustand slices, only if this feature owns client state
└── types.ts # View-model types. Domain types come from @sport/types.
```
## Rules
- A feature may import from `@/components`, `@/lib`, `@/hooks` and any
`@sport/*` package.
- A feature must **not** import from another feature's internals. If two
features need the same thing, it moves up to `@/components` or `@/lib`.
Cross-feature imports are what turn a feature folder into a second, worse
module system.
- Data fetching goes through `@sport/api-client`. No raw `fetch` to the API.
+3
View File
@@ -0,0 +1,3 @@
# hooks/
Cross-feature React hooks (media queries, debounce, local storage). Anything feature-specific lives under `features/<name>/hooks/`.
+28
View File
@@ -0,0 +1,28 @@
import { createApiClient } 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
* the public hostname and TLS entirely.
* - `browserApi` runs in the browser, hits the public API and carries the
* access token.
*
* Both are the same typed client from @sport/api-client. No component anywhere
* calls `fetch` against the API directly.
*/
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.
});
}
export const browserApi = createApiClient({
baseUrl: clientEnv.NEXT_PUBLIC_API_URL,
getAccessToken: () => null, // wired to the auth store in the auth milestone
});
+32
View File
@@ -0,0 +1,32 @@
import { z } from 'zod';
/**
* Client-visible configuration, validated at module load.
*
* Next.js inlines `process.env.NEXT_PUBLIC_*` at build time, which means the
* literal reference below cannot be shortened to a dynamic lookup — that is why
* each key is written out in full.
*/
const clientEnvSchema = z.object({
NEXT_PUBLIC_API_URL: z.url(),
NEXT_PUBLIC_SITE_URL: z.url(),
});
export const clientEnv = clientEnvSchema.parse({
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
});
/**
* Server-only configuration. Importing this from a Client Component is a build
* error, which is exactly the guardrail we want.
*/
export function getServerEnv() {
return z
.object({
API_INTERNAL_URL: z.url(),
})
.parse({
API_INTERNAL_URL: process.env.API_INTERNAL_URL ?? clientEnv.NEXT_PUBLIC_API_URL,
});
}
+31
View File
@@ -0,0 +1,31 @@
import { MINOR_UNIT_SCALE, type Money } from '@sport/types';
const formatters = new Map<string, Intl.NumberFormat>();
/**
* 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.
*/
export function formatMoney(money: Money, locale = 'vi-VN'): string {
const cacheKey = `${locale}:${money.currency}`;
let formatter = formatters.get(cacheKey);
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]);
}
export function formatDiscountPercent(price: Money, compareAt: Money): number {
if (compareAt.amount <= 0) return 0;
return Math.round(((compareAt.amount - price.amount) / compareAt.amount) * 100);
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Every internal URL is built here.
*
* Hard-coded template strings scattered through components are how a URL
* structure change becomes a week of broken links. With `typedRoutes` on, these
* helpers are also checked against the actual route tree at build time.
*/
export const routes = {
home: () => '/',
men: () => '/men',
women: () => '/women',
sport: (slug: string) => `/sports/${slug}`,
product: (slug: string) => `/products/${slug}`,
collection: (slug: string) => `/collections/${slug}`,
search: (query?: string) => (query ? `/search?q=${encodeURIComponent(query)}` : '/search'),
cart: () => '/cart',
checkout: () => '/checkout',
account: () => '/account',
accountProfile: () => '/account/profile',
accountOrders: () => '/account/orders',
accountAddresses: () => '/account/addresses',
accountWishlist: () => '/account/wishlist',
blog: () => '/blog',
} as const;
/** The sport facets that back `/sports/[sport]`. */
export const SPORT_NAV = [
{ slug: 'running', label: 'Running' },
{ slug: 'football', label: 'Football' },
{ slug: 'training', label: 'Training' },
{ slug: 'gym', label: 'Gym' },
{ slug: 'badminton', label: 'Badminton' },
{ slug: 'lifestyle', label: 'Lifestyle' },
] as const;
export type SportSlug = (typeof SPORT_NAV)[number]['slug'];
export function isSportSlug(value: string): value is SportSlug {
return SPORT_NAV.some((sport) => sport.slug === value);
}
+3
View File
@@ -0,0 +1,3 @@
# services/
Cross-feature data access: the React Query client, shared query-key factories and cache-invalidation helpers.
+3
View File
@@ -0,0 +1,3 @@
# stores/
Global client state (Zustand): cart drawer visibility, auth session, recently viewed. Server data belongs in React Query, not here — duplicating it is the most common source of stale UI.
+25
View File
@@ -0,0 +1,25 @@
@import 'tailwindcss';
@import '@sport/config/tailwind/theme.css';
/* Tailwind v4 scans the importing app by default; workspace packages must be
registered explicitly or their utility classes get purged. */
@source "../../../../packages/ui/src";
:root {
/* No web font is loaded yet — the design system reads --font-inter, so the
stack is defined once here and swapping in a real font later touches only
this line plus a next/font import. */
--font-inter: 'Inter', ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
--font-display: var(--font-inter);
}
html,
body {
height: 100%;
}
body {
background-color: var(--color-white);
color: var(--color-ink-950);
font-family: var(--font-sans);
}
+3
View File
@@ -0,0 +1,3 @@
# types/
View-model types local to the storefront. Domain and API types are imported from `@sport/types` and are never redeclared here.
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "@sport/config/typescript/nextjs.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": [
"next-env.d.ts",
"src/**/*.ts",
"src/**/*.tsx",
".next/types/**/*.ts",
"*.ts",
"*.mjs"
],
"exclude": ["node_modules", ".next"]
}