This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:22 +07:00
parent 3e5d38ec18
commit 3d6b0e0d4e
145 changed files with 7817 additions and 801 deletions
+3
View File
@@ -1,4 +1,5 @@
import { HttpClient, type HttpClientOptions } from './http-client';
import { createCatalogResource, type CatalogResource } from './resources/catalog';
import { createHealthResource, type HealthResource } from './resources/health';
/**
@@ -10,6 +11,7 @@ import { createHealthResource, type HealthResource } from './resources/health';
export interface ApiClient {
readonly http: HttpClient;
readonly health: HealthResource;
readonly catalog: CatalogResource;
}
export function createApiClient(options: HttpClientOptions): ApiClient {
@@ -18,5 +20,6 @@ export function createApiClient(options: HttpClientOptions): ApiClient {
return {
http,
health: createHealthResource(http),
catalog: createCatalogResource(http),
};
}
+1
View File
@@ -15,4 +15,5 @@ export { ApiClientError, isApiClientError } from './errors';
export { HttpClient } from './http-client';
export type { HttpClientOptions, RequestOptions } from './http-client';
export { createApiClient } from './create-client';
export type { CatalogResource, ProductListQuery } from './resources/catalog';
export type { ApiClient } from './create-client';
@@ -0,0 +1,120 @@
import type {
Brand,
Category,
CategoryNode,
Collection,
Locale,
NavigationMenu,
ProductListResult,
StorefrontProduct,
} from '@sport/types';
import type { HttpClient, RequestOptions } from '../http-client';
/**
* Filters accepted by the listing endpoint. Mirrors `productFilterSchema` in
* @sport/validation — the server re-validates, so this type is a convenience
* for callers, never the enforcement point.
*/
export interface ProductListQuery {
q?: string;
categorySlug?: string;
collectionSlug?: string;
brandSlugs?: readonly string[];
gender?: readonly string[];
sport?: readonly string[];
colors?: readonly string[];
sizes?: readonly string[];
minPrice?: number;
maxPrice?: number;
onSale?: boolean;
inStockOnly?: boolean;
sort?: 'newest' | 'price_asc' | 'price_desc' | 'best_selling' | 'relevance';
cursor?: string | null;
limit?: number;
}
/**
* Cache policy is the *caller's* decision, not this package's — a listing on
* the homepage and the same listing behind a filter want different revalidation.
* Every method therefore accepts RequestOptions and applies no default beyond
* what the endpoint semantically requires.
*/
export interface CatalogResource {
listProducts(
locale: Locale,
query?: ProductListQuery,
options?: RequestOptions,
): Promise<ProductListResult>;
getProduct(locale: Locale, slug: string, options?: RequestOptions): Promise<StorefrontProduct>;
listProductSlugs(
locale: Locale,
options?: RequestOptions,
): Promise<{ slug: string; updatedAt: string }[]>;
listBrands(locale: Locale, options?: RequestOptions): Promise<Brand[]>;
getBrand(locale: Locale, slug: string, options?: RequestOptions): Promise<Brand>;
getCategoryTree(locale: Locale, options?: RequestOptions): Promise<CategoryNode[]>;
getCategory(locale: Locale, slug: string, options?: RequestOptions): Promise<Category>;
listCollections(locale: Locale, options?: RequestOptions): Promise<Collection[]>;
getCollection(locale: Locale, slug: string, options?: RequestOptions): Promise<Collection>;
getNavigation(locale: Locale, options?: RequestOptions): Promise<NavigationMenu>;
}
export function createCatalogResource(http: HttpClient): CatalogResource {
/** Locale rides along on every catalog request; callers never forget it. */
const withLocale = (
locale: Locale,
query: Record<string, unknown> = {},
options?: RequestOptions,
): RequestOptions => ({
...options,
query: { locale, ...(query as RequestOptions['query']), ...options?.query },
});
return {
listProducts: (locale, query = {}, options) =>
http.get<ProductListResult>('/products', withLocale(locale, { ...query }, options)),
getProduct: (locale, slug, options) =>
http.get<StorefrontProduct>(
`/products/${encodeURIComponent(slug)}`,
withLocale(locale, {}, options),
),
listProductSlugs: (locale, options) =>
http.get<{ slug: string; updatedAt: string }[]>(
'/products/slugs',
withLocale(locale, {}, options),
),
listBrands: (locale, options) => http.get<Brand[]>('/brands', withLocale(locale, {}, options)),
getBrand: (locale, slug, options) =>
http.get<Brand>(`/brands/${encodeURIComponent(slug)}`, withLocale(locale, {}, options)),
getCategoryTree: (locale, options) =>
http.get<CategoryNode[]>('/categories', withLocale(locale, {}, options)),
getCategory: (locale, slug, options) =>
http.get<Category>(
`/categories/${encodeURIComponent(slug)}`,
withLocale(locale, {}, options),
),
listCollections: (locale, options) =>
http.get<Collection[]>('/collections', withLocale(locale, {}, options)),
getCollection: (locale, slug, options) =>
http.get<Collection>(
`/collections/${encodeURIComponent(slug)}`,
withLocale(locale, {}, options),
),
getNavigation: (locale, options) =>
http.get<NavigationMenu>('/navigation', withLocale(locale, {}, options)),
};
}
+13 -2
View File
@@ -40,9 +40,20 @@ export const nestConfig = [
{
patterns: [
{
group: ['@/modules/*/*', '!@/modules/*/public'],
/**
* Two things may cross a module boundary and nothing else:
*
* - `<name>.module` — the NestJS module class, so it can be
* listed in another module's `imports`. Wiring the DI graph is
* exactly what a modular monolith is supposed to allow.
* - `<name>/public` — the curated public surface.
*
* Everything else — repositories, mappers, DTOs, internal
* services — stays private.
*/
group: ['@/modules/*/*', '!@/modules/*/public', '!@/modules/*/*.module'],
message:
'Cross-module deep imports are forbidden. Import from `@/modules/<name>/public` instead.',
'Cross-module deep imports are forbidden. Import `@/modules/<name>/public` for behaviour, or `@/modules/<name>/<name>.module` to wire it into your module.',
},
],
},
+4
View File
@@ -15,6 +15,10 @@ export const nextConfig = [
files: [
'src/app/**/{page,layout,template,loading,error,not-found,default,route,global-error,sitemap,robots,opengraph-image,icon,apple-icon,manifest}.{ts,tsx}',
'src/middleware.ts',
// Next.js 16 renamed the middleware convention to `proxy`.
'src/proxy.ts',
// next-intl requires a default export from its request config.
'src/i18n/request.ts',
'next.config.{ts,mjs,js}',
'instrumentation.ts',
],
+45
View File
@@ -0,0 +1,45 @@
import type { Id, Money, Nullable, Slug } from '../primitives';
/**
* Facet counts for a listing page.
*
* Counts are computed against the *current* filter set minus the facet's own
* dimension — so selecting "Black" still shows how many White items exist.
* Anything else produces a filter UI that dead-ends the moment you use it.
*/
export interface ProductFacets {
readonly brands: readonly FacetBucket[];
readonly colors: readonly SwatchFacetBucket[];
readonly sizes: readonly FacetBucket[];
readonly priceRange: Nullable<FacetPriceRange>;
}
/**
* Sport and gender counts are deliberately absent. Those two dimensions are
* navigated by route (`/men`, `/sports/running`), not refined within a page, so
* a count would render nowhere. Counting them needs `unnest()` over the array
* columns — worth doing when there is a UI that displays it, not before.
*/
export interface FacetBucket {
readonly value: string;
readonly label: string;
readonly count: number;
}
export interface SwatchFacetBucket extends FacetBucket {
readonly swatchHex: Nullable<string>;
}
export interface FacetPriceRange {
readonly min: Money;
readonly max: Money;
}
/** Breadcrumb trail rendered on listing and detail pages. */
export interface Breadcrumb {
readonly id: Nullable<Id>;
readonly label: string;
readonly href: string;
readonly slug: Nullable<Slug>;
}
+29
View File
@@ -0,0 +1,29 @@
import type { Id, Slug } from '../primitives';
/**
* The header/footer menu, assembled server-side from the category tree and
* active collections.
*
* It is one cached payload rather than several requests, because navigation is
* rendered on literally every page and is the single highest-traffic read in
* the system.
*/
export interface NavigationMenu {
readonly primary: readonly NavigationItem[];
readonly featuredCollections: readonly NavigationCollection[];
}
export interface NavigationItem {
readonly id: Id;
readonly label: string;
readonly href: string;
readonly slug: Slug;
readonly children: readonly NavigationItem[];
}
export interface NavigationCollection {
readonly id: Id;
readonly label: string;
readonly slug: Slug;
readonly href: string;
}
+19
View File
@@ -1,5 +1,7 @@
import type { Locale } from '../i18n/locale';
import type { Id, IsoDateTime, Metadata, Money, Nullable, Slug } from '../primitives';
import type { Breadcrumb, ProductFacets } from './facets';
import type { ProductImage } from './media';
import type { Brand, Category, Collection, SeoFields } from './taxonomy';
import type { ProductOption, ProductVariant, StorefrontVariant } from './variant';
@@ -92,6 +94,23 @@ export interface StorefrontProduct extends Omit<
readonly variants: readonly StorefrontVariant[];
readonly priceRange: PriceRange;
readonly rating: Nullable<ProductRatingSummary>;
readonly breadcrumbs: readonly Breadcrumb[];
/**
* The slug of this product in every locale.
*
* Required for two things that are otherwise impossible: `hreflang` alternate
* links, and a language switcher that lands on the same product instead of
* 404ing because `/en/products/ao-chay-bo-aero` does not exist.
*/
readonly alternateSlugs: Readonly<Partial<Record<Locale, Slug>>>;
}
/** A listing response: items, page info and the facets for the filter rail. */
export interface ProductListResult {
readonly items: readonly ProductListItem[];
readonly pageInfo: { readonly nextCursor: string | null; readonly hasNextPage: boolean };
readonly totalCount: number;
readonly facets: ProductFacets;
}
/** Trimmed payload for grids — deliberately small, it is fetched 24 at a time. */
+49
View File
@@ -0,0 +1,49 @@
/**
* Supported locales.
*
* Vietnamese is the default: this is a Vietnamese store, and defaulting to the
* market's language means the majority of visitors never pay a redirect.
*
* Adding a locale is deliberately a code change — every message catalog and
* every content translation has to be filled in, and a half-translated locale
* is worse than none.
*/
export const LOCALES = ['vi', 'en'] as const;
export type Locale = (typeof LOCALES)[number];
export const DEFAULT_LOCALE: Locale = 'vi';
export function isLocale(value: string): value is Locale {
return (LOCALES as readonly string[]).includes(value);
}
/** Endonyms — a language is always listed in its own language. */
export const LOCALE_LABELS: Readonly<Record<Locale, string>> = {
vi: 'Tiếng Việt',
en: 'English',
};
/** BCP-47 tags for `Intl.*`, `<html lang>` and `hreflang`. */
export const LOCALE_TAGS: Readonly<Record<Locale, string>> = {
vi: 'vi-VN',
en: 'en-US',
};
/**
* Maps a locale to the database enum. Kept here so the mapping lives with the
* locale definition rather than being duplicated across query builders.
*/
export const LOCALE_TO_DB: Readonly<Record<Locale, 'VI' | 'EN'>> = {
vi: 'VI',
en: 'EN',
};
/**
* Content translated per locale. `Translated<T>` marks the fields that are
* resolved against the requesting locale before leaving the API — the frontend
* never sees a translation table or does fallback logic itself.
*/
export interface Translatable {
readonly locale: Locale;
}
+3
View File
@@ -17,4 +17,7 @@ export * from './catalog/product';
export * from './catalog/variant';
export * from './catalog/taxonomy';
export * from './catalog/media';
export * from './catalog/navigation';
export * from './catalog/facets';
export * from './i18n/locale';
export * from './inventory/stock';
+10
View File
@@ -1,8 +1,18 @@
import { z } from 'zod';
import { LOCALES } from '@sport/types';
import { slugSchema } from './common';
import { cursorPageQuerySchema } from './pagination';
/**
* Every catalog read takes a locale. It is a query parameter rather than only
* an `Accept-Language` header so that a URL is fully self-describing — the same
* link always returns the same language, and CDN cache keys stay correct
* without a `Vary` header that fragments the cache unpredictably.
*/
export const localeSchema = z.enum(LOCALES);
export const genderTargetSchema = z.enum(['MEN', 'WOMEN', 'KIDS', 'UNISEX']);
export const sportTypeSchema = z.enum([