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