Basic Architecture of Sport Web
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
# hooks/
|
||||
|
||||
Cross-feature React hooks (media queries, debounce, local storage). Anything feature-specific lives under `features/<name>/hooks/`.
|
||||
@@ -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
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# services/
|
||||
|
||||
Cross-feature data access: the React Query client, shared query-key factories and cache-invalidation helpers.
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user