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
+3
View File
@@ -0,0 +1,3 @@
import { baseConfig } from '@sport/eslint-config/base';
export default baseConfig;
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@sport/api-client",
"version": "0.0.0",
"private": true,
"description": "The single sanctioned way for any frontend to talk to the REST API.",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
"clean": "rm -rf dist .turbo *.tsbuildinfo",
"lint": "eslint src",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@sport/types": "workspace:*"
},
"devDependencies": {
"@sport/config": "workspace:*",
"@sport/eslint-config": "workspace:*",
"@types/node": "^22.19.0",
"eslint": "catalog:",
"typescript": "catalog:"
}
}
+22
View File
@@ -0,0 +1,22 @@
import { HttpClient, type HttpClientOptions } from './http-client';
import { createHealthResource, type HealthResource } from './resources/health';
/**
* Resource modules are added here as the backend grows — one file per bounded
* context under `src/resources/`, mirroring the NestJS module names exactly.
* Keep them thin: URL + types, no business logic and no caching policy (that is
* the calling app's decision).
*/
export interface ApiClient {
readonly http: HttpClient;
readonly health: HealthResource;
}
export function createApiClient(options: HttpClientOptions): ApiClient {
const http = new HttpClient(options);
return {
http,
health: createHealthResource(http),
};
}
+64
View File
@@ -0,0 +1,64 @@
import {
API_ERROR_CODES,
type ApiErrorBody,
type ApiErrorCode,
type ApiFieldErrors,
} from '@sport/types';
/**
* Every failure — HTTP error, network failure, malformed body — surfaces as
* this one class. Callers never have to inspect a Response object.
*/
export class ApiClientError extends Error {
readonly code: ApiErrorCode;
readonly status: number;
readonly fields?: ApiFieldErrors;
readonly requestId?: string;
constructor(params: {
code: ApiErrorCode;
message: string;
status: number;
fields?: ApiFieldErrors;
requestId?: string;
cause?: unknown;
}) {
super(params.message, { cause: params.cause });
this.name = 'ApiClientError';
this.code = params.code;
this.status = params.status;
this.fields = params.fields;
this.requestId = params.requestId;
}
static fromBody(body: ApiErrorBody, status: number, requestId?: string): ApiClientError {
return new ApiClientError({
code: body.code,
message: body.message,
status,
fields: body.fields,
requestId,
});
}
static network(cause: unknown): ApiClientError {
return new ApiClientError({
code: API_ERROR_CODES.SERVICE_UNAVAILABLE,
message: 'Could not reach the server. Please check your connection and try again.',
status: 0,
cause,
});
}
get isAuthError(): boolean {
return this.status === 401;
}
get isValidationError(): boolean {
return this.code === API_ERROR_CODES.VALIDATION_FAILED;
}
}
export function isApiClientError(error: unknown): error is ApiClientError {
return error instanceof ApiClientError;
}
+156
View File
@@ -0,0 +1,156 @@
import { API_ERROR_CODES, type ApiResponse } from '@sport/types';
import { ApiClientError } from './errors';
export interface HttpClientOptions {
/** Origin only, e.g. `http://localhost:4000`. The version prefix is added here. */
baseUrl: string;
/** Defaults to `v1`. */
apiVersion?: string;
/** Resolved per request so a rotated token is picked up without re-creating the client. */
getAccessToken?: () => string | null | undefined | Promise<string | null | undefined>;
/** Invoked once on 401 so the app can refresh or redirect. Return true to retry. */
onUnauthorized?: () => boolean | Promise<boolean>;
defaultHeaders?: Record<string, string>;
timeoutMs?: number;
/** Injectable for tests and for runtimes with a patched fetch (Next.js). */
fetchImpl?: typeof fetch;
}
export interface RequestOptions {
query?: Record<string, string | number | boolean | null | undefined | readonly string[]>;
headers?: Record<string, string>;
signal?: AbortSignal;
/** Forwarded verbatim to Next.js's extended fetch. Ignored elsewhere. */
next?: { revalidate?: number | false; tags?: string[] };
cache?: RequestCache;
/** Send cookies (used by the refresh-token flow). */
credentials?: RequestCredentials;
}
type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
const DEFAULT_TIMEOUT_MS = 15_000;
export class HttpClient {
private readonly baseUrl: string;
private readonly apiVersion: string;
private readonly options: HttpClientOptions;
private readonly fetchImpl: typeof fetch;
constructor(options: HttpClientOptions) {
this.options = options;
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
this.apiVersion = options.apiVersion ?? 'v1';
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
}
get<T>(path: string, options?: RequestOptions): Promise<T> {
return this.request<T>('GET', path, undefined, options);
}
post<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T> {
return this.request<T>('POST', path, body, options);
}
patch<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T> {
return this.request<T>('PATCH', path, body, options);
}
put<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T> {
return this.request<T>('PUT', path, body, options);
}
delete<T>(path: string, options?: RequestOptions): Promise<T> {
return this.request<T>('DELETE', path, undefined, options);
}
private async request<T>(
method: Method,
path: string,
body?: unknown,
options?: RequestOptions,
isRetry = false,
): Promise<T> {
const url = this.buildUrl(path, options?.query);
const headers = new Headers({
Accept: 'application/json',
...this.options.defaultHeaders,
...options?.headers,
});
if (body !== undefined) {
headers.set('Content-Type', 'application/json');
}
const token = await this.options.getAccessToken?.();
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
const timeout = AbortSignal.timeout(this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
const signal = options?.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
let response: Response;
try {
response = await this.fetchImpl(url, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal,
credentials: options?.credentials ?? 'include',
...(options?.cache ? { cache: options.cache } : {}),
...(options?.next ? { next: options.next } : {}),
} as RequestInit);
} catch (cause) {
throw ApiClientError.network(cause);
}
if (response.status === 401 && !isRetry && this.options.onUnauthorized) {
const shouldRetry = await this.options.onUnauthorized();
if (shouldRetry) {
return this.request<T>(method, path, body, options, true);
}
}
if (response.status === 204) {
return undefined as T;
}
const requestId = response.headers.get('x-request-id') ?? undefined;
let payload: ApiResponse<T>;
try {
payload = (await response.json()) as ApiResponse<T>;
} catch (cause) {
throw new ApiClientError({
code: API_ERROR_CODES.INTERNAL_ERROR,
message: 'The server returned an unreadable response.',
status: response.status,
requestId,
cause,
});
}
if (!payload.success) {
throw ApiClientError.fromBody(payload.error, response.status, payload.meta?.requestId);
}
return payload.data;
}
private buildUrl(path: string, query?: RequestOptions['query']): string {
const normalized = path.startsWith('/') ? path : `/${path}`;
const url = new URL(`${this.baseUrl}/api/${this.apiVersion}${normalized}`);
for (const [key, value] of Object.entries(query ?? {})) {
if (value === undefined || value === null || value === '') continue;
if (Array.isArray(value)) {
if (value.length > 0) url.searchParams.set(key, value.join(','));
} else {
url.searchParams.set(key, String(value));
}
}
return url.toString();
}
}
+18
View File
@@ -0,0 +1,18 @@
/**
* @sport/api-client
*
* Storefront and admin reach the backend through this package and nothing else.
* No `fetch('/api/...')` calls scattered through components, no duplicated URL
* building, no per-app error handling. When the API version bumps or a payload
* changes, exactly one package needs updating.
*
* It is deliberately runtime-agnostic (plain `fetch`, no React) so the same
* client works in React Server Components, route handlers, client components
* and, later, a React Native app.
*/
export { ApiClientError, isApiClientError } from './errors';
export { HttpClient } from './http-client';
export type { HttpClientOptions, RequestOptions } from './http-client';
export { createApiClient } from './create-client';
export type { ApiClient } from './create-client';
@@ -0,0 +1,28 @@
import type { HttpClient } from '../http-client';
export interface HealthCheckResult {
status: 'ok' | 'degraded';
uptimeSeconds: number;
version: string;
environment: string;
dependencies: {
database: DependencyStatus;
redis: DependencyStatus;
};
}
export interface DependencyStatus {
status: 'up' | 'down';
latencyMs: number | null;
error?: string;
}
export interface HealthResource {
check(): Promise<HealthCheckResult>;
}
export function createHealthResource(http: HttpClient): HealthResource {
return {
check: () => http.get<HealthCheckResult>('/health', { cache: 'no-store' }),
};
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "@sport/config/typescript/library.json",
"compilerOptions": {
"lib": ["ES2023", "DOM"],
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@sport/config",
"version": "0.0.0",
"private": true,
"description": "Build-time configuration shared across the workspace (TypeScript bases, Tailwind theme). Contains NO runtime code.",
"files": [
"typescript",
"tailwind"
],
"exports": {
"./typescript/base.json": "./typescript/base.json",
"./typescript/library.json": "./typescript/library.json",
"./typescript/react-library.json": "./typescript/react-library.json",
"./typescript/nextjs.json": "./typescript/nextjs.json",
"./typescript/nestjs.json": "./typescript/nestjs.json",
"./tailwind/theme.css": "./tailwind/theme.css"
},
"scripts": {
"lint": "echo 'no lint target'",
"typecheck": "echo 'no typecheck target'"
}
}
+96
View File
@@ -0,0 +1,96 @@
/**
* Sport Store — shared design tokens (Tailwind CSS v4, CSS-first configuration).
*
* This file is the SINGLE source of truth for the visual language of both the
* storefront and the admin dashboard. Apps import it after `@import "tailwindcss"`.
*
* Direction: modern, premium, minimal, sport-oriented (Nike / Gymshark spirit).
* High-contrast neutrals, one energetic accent, generous whitespace, tight type.
*/
@theme {
/* ---- Brand ------------------------------------------------------------ */
/* Near-black ink, not pure black: prints and photographs better. */
--color-ink-50: oklch(0.98 0.002 260);
--color-ink-100: oklch(0.95 0.003 260);
--color-ink-200: oklch(0.89 0.004 260);
--color-ink-300: oklch(0.78 0.005 260);
--color-ink-400: oklch(0.63 0.006 260);
--color-ink-500: oklch(0.51 0.007 260);
--color-ink-600: oklch(0.41 0.008 260);
--color-ink-700: oklch(0.32 0.009 260);
--color-ink-800: oklch(0.23 0.01 260);
--color-ink-900: oklch(0.16 0.011 260);
--color-ink-950: oklch(0.11 0.012 260);
/* Energetic accent — "volt". Used sparingly: CTAs, price, sale badges. */
--color-volt-50: oklch(0.97 0.06 125);
--color-volt-100: oklch(0.94 0.11 125);
--color-volt-200: oklch(0.9 0.16 125);
--color-volt-300: oklch(0.86 0.2 125);
--color-volt-400: oklch(0.82 0.23 125);
--color-volt-500: oklch(0.76 0.24 125);
--color-volt-600: oklch(0.66 0.21 125);
--color-volt-700: oklch(0.54 0.17 125);
--color-volt-800: oklch(0.43 0.13 125);
--color-volt-900: oklch(0.35 0.1 125);
/* Semantic */
--color-sale: oklch(0.58 0.21 27);
--color-success: oklch(0.65 0.16 155);
--color-warning: oklch(0.78 0.15 80);
--color-danger: oklch(0.58 0.22 27);
/* ---- Typography ------------------------------------------------------- */
--font-sans: var(--font-inter), ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-display: var(--font-display), var(--font-inter), ui-sans-serif, system-ui, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
--tracking-display: -0.03em;
/* ---- Layout ----------------------------------------------------------- */
--spacing-gutter: 1.25rem;
--container-page: 90rem;
--radius-card: 0.25rem;
--radius-pill: 999px;
/* ---- Motion ----------------------------------------------------------- */
--ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1);
--animate-rise: rise 0.5s var(--ease-out-quint) both;
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(0.75rem);
}
to {
opacity: 1;
transform: none;
}
}
@layer base {
:root {
color-scheme: light;
}
html {
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* Sport/streetwear headings: tight, uppercase-capable, no letterspacing drift. */
h1,
h2,
h3 {
letter-spacing: var(--tracking-display);
text-wrap: balance;
}
::selection {
background-color: var(--color-volt-300);
color: var(--color-ink-950);
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Sport Store — Base",
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "Bundler",
"moduleDetection": "force",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"allowUnreachableCode": false,
"useUnknownInCatchVariables": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"incremental": true,
"composite": false,
},
"exclude": ["node_modules", "dist", "build", ".next", ".turbo", "coverage"],
}
+13
View File
@@ -0,0 +1,13 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Sport Store — Compiled Library (CJS + d.ts)",
"extends": "./base.json",
// NOTE: `outDir` and `rootDir` are deliberately NOT set here. Relative paths
// in an extended config resolve against the file that declares them, so they
// would point inside packages/config. Each package sets its own.
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
"verbatimModuleSyntax": false,
},
}
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Sport Store — NestJS App",
"extends": "./base.json",
"compilerOptions": {
"module": "CommonJS",
"moduleResolution": "Node",
// NestJS DI relies on `emitDecoratorMetadata`, which needs the *value* of a
// constructor parameter's type to survive compilation. `verbatimModuleSyntax`
// erases such imports and silently breaks injection — it must stay off here.
"verbatimModuleSyntax": false,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strictPropertyInitialization": false,
// `outDir`/`rootDir` are set by the app — see library.json for why.
"declaration": false,
"declarationMap": false,
},
}
+17
View File
@@ -0,0 +1,17 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Sport Store — Next.js App",
"extends": "./base.json",
"compilerOptions": {
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"jsx": "preserve",
"module": "ESNext",
"moduleResolution": "Bundler",
"allowJs": true,
"noEmit": true,
"declaration": false,
"declarationMap": false,
"sourceMap": false,
"plugins": [{ "name": "next" }],
},
}
@@ -0,0 +1,13 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Sport Store — React Source Library (consumed via transpilePackages)",
"extends": "./base.json",
"compilerOptions": {
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"noEmit": true,
"declaration": false,
"declarationMap": false,
"sourceMap": false,
},
}
+102
View File
@@ -0,0 +1,102 @@
import js from '@eslint/js';
import prettier from 'eslint-config-prettier';
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript';
import importX from 'eslint-plugin-import-x';
import turbo from 'eslint-plugin-turbo';
import globals from 'globals';
import tseslint from 'typescript-eslint';
/**
* Base flat config: TypeScript rules + import hygiene + the architectural
* boundary rules that every package in the workspace must obey.
*
* @type {import("eslint").Linter.Config[]}
*/
export const baseConfig = [
{
ignores: [
'**/dist/**',
'**/build/**',
'**/.next/**',
'**/.turbo/**',
'**/coverage/**',
'**/node_modules/**',
'**/generated/**',
],
},
js.configs.recommended,
...tseslint.configs.recommended,
importX.flatConfigs.recommended,
importX.flatConfigs.typescript,
turbo.configs['flat/recommended'],
{
languageOptions: {
ecmaVersion: 2023,
globals: { ...globals.node, ...globals.es2021 },
},
settings: {
// TypeScript resolver first: it understands `.ts` extensionless imports,
// path aliases and workspace `exports` maps. The node resolver is the
// fallback for plain JS config files.
'import-x/resolver-next': [
createTypeScriptImportResolver({ alwaysTryTypes: true, project: ['*/tsconfig.json'] }),
importX.createNodeResolver(),
],
},
rules: {
// --- Type safety -------------------------------------------------
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/consistent-type-imports': [
'error',
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
],
// `import type` statements stay with their source group rather than being
// herded to the bottom of the file, where they lose the context of what
// they belong to.
'@typescript-eslint/no-import-type-side-effects': 'error',
'@typescript-eslint/no-non-null-assertion': 'warn',
// --- Import hygiene ----------------------------------------------
'import-x/no-default-export': 'error',
'import-x/order': [
'error',
{
groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'],
pathGroups: [{ pattern: '@sport/**', group: 'internal', position: 'before' }],
pathGroupsExcludedImportTypes: ['type'],
'newlines-between': 'always',
alphabetize: { order: 'asc', caseInsensitive: true },
},
],
'import-x/no-cycle': ['error', { maxDepth: 4 }],
// --- General ------------------------------------------------------
eqeqeq: ['error', 'smart'],
'no-console': ['error', { allow: ['warn', 'error'] }],
'prefer-const': 'error',
'object-shorthand': 'error',
},
},
{
// Config files and scripts are allowed default exports and console output.
files: ['**/*.config.{js,mjs,ts}', '**/scripts/**', '**/*.cjs'],
rules: {
'import-x/no-default-export': 'off',
'no-console': 'off',
},
},
{
files: ['**/*.{test,spec}.{ts,tsx}', '**/test/**'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'no-console': 'off',
},
},
prettier,
];
export default baseConfig;
+59
View File
@@ -0,0 +1,59 @@
import { baseConfig } from './base.js';
/**
* NestJS config.
*
* The two rules that matter architecturally:
* 1. Only the persistence layer may import PrismaService / Redis directly.
* 2. A module must not deep-import another module's internals — cross-module
* access goes through the other module's public surface (`<module>/public`).
*
* @type {import("eslint").Linter.Config[]}
*/
export const nestConfig = [
...baseConfig,
{
files: ['**/*.ts'],
rules: {
/**
* OFF, and it must stay off.
*
* Nest resolves constructor dependencies from `emitDecoratorMetadata`,
* which requires the parameter's type to survive as a *value* import.
* This rule cannot tell an injected class from a pure type, so its
* autofix silently rewrites `import { PrismaService }` into
* `import { type PrismaService }` — which compiles cleanly and then
* fails at runtime with "Nest can't resolve dependencies".
*
* The same reasoning is why `verbatimModuleSyntax` is disabled in
* @sport/config/typescript/nestjs.json.
*/
'@typescript-eslint/consistent-type-imports': 'off',
// Nest relies on parameter decorators and class-based DI.
'@typescript-eslint/no-extraneous-class': 'off',
'@typescript-eslint/no-empty-object-type': 'off',
'no-console': 'error',
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@/modules/*/*', '!@/modules/*/public'],
message:
'Cross-module deep imports are forbidden. Import from `@/modules/<name>/public` instead.',
},
],
},
],
},
},
{
// Decorated DTO / entity classes legitimately have empty bodies.
files: ['**/*.dto.ts', '**/*.entity.ts'],
rules: { '@typescript-eslint/no-extraneous-class': 'off' },
},
];
export default nestConfig;
+56
View File
@@ -0,0 +1,56 @@
import { reactConfig } from './react.js';
/**
* Next.js App Router config.
*
* Note: the App Router *requires* default exports for `page`/`layout`/`route`
* and friends, so the workspace-wide `no-default-export` rule is relaxed for
* those files only — everywhere else named exports remain mandatory.
*
* @type {import("eslint").Linter.Config[]}
*/
export const nextConfig = [
...reactConfig,
{
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.config.{ts,mjs,js}',
'instrumentation.ts',
],
rules: {
'import-x/no-default-export': 'off',
},
},
{
files: ['src/**/*.{ts,tsx}'],
rules: {
// The storefront and admin must reach the backend only through the
// generated API client — never with ad-hoc fetch calls or a DB driver.
'no-restricted-imports': [
'error',
{
paths: [
{
name: '@prisma/client',
message:
'Frontend apps must never talk to the database. Use @sport/api-client instead.',
},
{
name: 'ioredis',
message: 'Frontend apps must never talk to Redis. Use @sport/api-client instead.',
},
],
patterns: [
{
group: ['@sport/api/*', '@sport/api'],
message: 'Frontend apps must not import backend code. Use @sport/api-client.',
},
],
},
],
},
},
];
export default nextConfig;
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@sport/eslint-config",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Shared ESLint flat configs for the workspace.",
"exports": {
"./base": "./base.js",
"./react": "./react.js",
"./next": "./next.js",
"./nest": "./nest.js"
},
"scripts": {
"lint": "echo 'no lint target'",
"typecheck": "echo 'no typecheck target'"
},
"dependencies": {
"@eslint/js": "^9.39.0",
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-import-x": "^4.16.1",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-turbo": "^2.10.9",
"globals": "^16.5.0",
"typescript-eslint": "^8.67.0"
},
"devDependencies": {
"eslint": "catalog:",
"typescript": "catalog:"
}
}
+34
View File
@@ -0,0 +1,34 @@
import globals from 'globals';
import a11y from 'eslint-plugin-jsx-a11y';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import { baseConfig } from './base.js';
/** @type {import("eslint").Linter.Config[]} */
export const reactConfig = [
...baseConfig,
{
files: ['**/*.{ts,tsx}'],
...react.configs.flat.recommended,
languageOptions: {
...react.configs.flat.recommended.languageOptions,
globals: { ...globals.browser, ...globals.serviceworker },
},
settings: { react: { version: 'detect' } },
},
{
files: ['**/*.{ts,tsx}'],
plugins: { 'react-hooks': reactHooks, 'jsx-a11y': a11y },
rules: {
...reactHooks.configs.recommended.rules,
...a11y.flatConfigs.recommended.rules,
'react/react-in-jsx-scope': 'off',
'react/prop-types': 'off',
'react/jsx-curly-brace-presence': ['error', { props: 'never', children: 'never' }],
'react/self-closing-comp': 'error',
},
},
];
export default reactConfig;
+3
View File
@@ -0,0 +1,3 @@
import { baseConfig } from '@sport/eslint-config/base';
export default baseConfig;
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@sport/types",
"version": "0.0.0",
"private": true,
"description": "Framework-free domain and API contract types shared by api, storefront and admin.",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
"clean": "rm -rf dist .turbo *.tsbuildinfo",
"lint": "eslint src",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"@sport/config": "workspace:*",
"@sport/eslint-config": "workspace:*",
"eslint": "catalog:",
"typescript": "catalog:"
}
}
+45
View File
@@ -0,0 +1,45 @@
import type { IsoDateTime } from '../primitives';
import type { ApiErrorCode } from './error-codes';
/**
* Every REST response uses one of these two shapes. No exceptions, including
* 500s — the global exception filter guarantees it. Clients can therefore
* branch on `success` alone and never on the HTTP status code.
*/
export interface ApiMeta {
/** Correlates client logs, server logs and traces. Echoed as `x-request-id`. */
readonly requestId: string;
readonly timestamp: IsoDateTime;
}
export interface ApiSuccessResponse<TData> {
readonly success: true;
readonly data: TData;
readonly meta: ApiMeta;
}
/** Field-level validation problems, keyed by dotted path (`items.0.quantity`). */
export type ApiFieldErrors = Readonly<Record<string, readonly string[]>>;
export interface ApiErrorBody {
readonly code: ApiErrorCode;
/** Safe to show to end users; already localised by the backend. */
readonly message: string;
readonly fields?: ApiFieldErrors;
/** Present only outside production. */
readonly stack?: string;
}
export interface ApiErrorResponse {
readonly success: false;
readonly error: ApiErrorBody;
readonly meta: ApiMeta;
}
export type ApiResponse<TData> = ApiSuccessResponse<TData> | ApiErrorResponse;
export function isApiError<T>(response: ApiResponse<T>): response is ApiErrorResponse {
return response.success === false;
}
+61
View File
@@ -0,0 +1,61 @@
/**
* Stable, machine-readable error codes.
*
* Contract: a code is NEVER renamed or repurposed once shipped — clients,
* mobile apps and partner integrations branch on these strings. Adding a new
* code is always safe; changing one is a breaking API change.
*
* Format: `<DOMAIN>_<CONDITION>`.
*/
export const API_ERROR_CODES = {
// --- Generic / transport ------------------------------------------------
BAD_REQUEST: 'BAD_REQUEST',
VALIDATION_FAILED: 'VALIDATION_FAILED',
NOT_FOUND: 'NOT_FOUND',
CONFLICT: 'CONFLICT',
RATE_LIMITED: 'RATE_LIMITED',
INTERNAL_ERROR: 'INTERNAL_ERROR',
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
// --- Authentication / authorization -------------------------------------
UNAUTHENTICATED: 'UNAUTHENTICATED',
INVALID_CREDENTIALS: 'INVALID_CREDENTIALS',
TOKEN_EXPIRED: 'TOKEN_EXPIRED',
TOKEN_INVALID: 'TOKEN_INVALID',
FORBIDDEN: 'FORBIDDEN',
PERMISSION_DENIED: 'PERMISSION_DENIED',
ACCOUNT_DISABLED: 'ACCOUNT_DISABLED',
// --- Catalog -------------------------------------------------------------
PRODUCT_NOT_FOUND: 'PRODUCT_NOT_FOUND',
VARIANT_NOT_FOUND: 'VARIANT_NOT_FOUND',
VARIANT_UNAVAILABLE: 'VARIANT_UNAVAILABLE',
// --- Inventory -----------------------------------------------------------
INSUFFICIENT_STOCK: 'INSUFFICIENT_STOCK',
RESERVATION_EXPIRED: 'RESERVATION_EXPIRED',
// --- Cart / checkout / order --------------------------------------------
CART_NOT_FOUND: 'CART_NOT_FOUND',
CART_EMPTY: 'CART_EMPTY',
CHECKOUT_EXPIRED: 'CHECKOUT_EXPIRED',
ORDER_NOT_FOUND: 'ORDER_NOT_FOUND',
ORDER_NOT_CANCELLABLE: 'ORDER_NOT_CANCELLABLE',
// --- Promotions ----------------------------------------------------------
COUPON_INVALID: 'COUPON_INVALID',
COUPON_EXPIRED: 'COUPON_EXPIRED',
COUPON_USAGE_EXCEEDED: 'COUPON_USAGE_EXCEEDED',
// --- Payment -------------------------------------------------------------
PAYMENT_FAILED: 'PAYMENT_FAILED',
PAYMENT_PROVIDER_ERROR: 'PAYMENT_PROVIDER_ERROR',
PAYMENT_SIGNATURE_INVALID: 'PAYMENT_SIGNATURE_INVALID',
// --- Media ---------------------------------------------------------------
UPLOAD_REJECTED: 'UPLOAD_REJECTED',
FILE_TOO_LARGE: 'FILE_TOO_LARGE',
UNSUPPORTED_MEDIA_TYPE: 'UNSUPPORTED_MEDIA_TYPE',
} as const;
export type ApiErrorCode = (typeof API_ERROR_CODES)[keyof typeof API_ERROR_CODES];
+49
View File
@@ -0,0 +1,49 @@
/**
* Two pagination strategies, chosen per endpoint and never mixed:
*
* - OFFSET — admin tables, where "page 7 of 42" is a real requirement.
* - CURSOR — storefront listings and infinite scroll, where correctness under
* concurrent writes matters more than random access.
*/
export interface OffsetPageQuery {
page?: number;
perPage?: number;
}
export interface OffsetPageInfo {
readonly page: number;
readonly perPage: number;
readonly totalItems: number;
readonly totalPages: number;
readonly hasNextPage: boolean;
}
export interface OffsetPaginated<T> {
readonly items: readonly T[];
readonly pageInfo: OffsetPageInfo;
}
export interface CursorPageQuery {
cursor?: string | null;
limit?: number;
}
export interface CursorPageInfo {
readonly nextCursor: string | null;
readonly hasNextPage: boolean;
}
export interface CursorPaginated<T> {
readonly items: readonly T[];
readonly pageInfo: CursorPageInfo;
}
export type SortDirection = 'asc' | 'desc';
export const PAGINATION_DEFAULTS = {
perPage: 24,
maxPerPage: 100,
cursorLimit: 24,
maxCursorLimit: 100,
} as const;
+38
View File
@@ -0,0 +1,38 @@
/**
* Two distinct actor populations live in this system and they must never be
* merged into one table or one token audience:
*
* - CUSTOMER — self-registered shoppers, authenticated on the storefront.
* - STAFF / ADMIN / SUPER_ADMIN — back-office operators, authenticated on the
* admin dashboard, always subject to RBAC.
*
* A customer token is rejected by admin endpoints purely on audience, before
* any permission check runs. That is a defence-in-depth boundary, not an
* optimisation.
*/
export const USER_TYPES = {
CUSTOMER: 'CUSTOMER',
STAFF: 'STAFF',
ADMIN: 'ADMIN',
SUPER_ADMIN: 'SUPER_ADMIN',
} as const;
export type UserType = (typeof USER_TYPES)[keyof typeof USER_TYPES];
export const BACK_OFFICE_USER_TYPES: readonly UserType[] = [
USER_TYPES.STAFF,
USER_TYPES.ADMIN,
USER_TYPES.SUPER_ADMIN,
];
export function isBackOfficeUser(type: UserType): boolean {
return BACK_OFFICE_USER_TYPES.includes(type);
}
/** Token audience — encoded in the JWT and validated per guard. */
export const TOKEN_AUDIENCES = {
STOREFRONT: 'storefront',
ADMIN: 'admin',
} as const;
export type TokenAudience = (typeof TOKEN_AUDIENCES)[keyof typeof TOKEN_AUDIENCES];
+104
View File
@@ -0,0 +1,104 @@
/**
* RBAC permission catalog — `<resource>.<action>`.
*
* Authorization is expressed ONLY as permission checks. There is deliberately
* no `if (user.role === 'ADMIN')` anywhere in the codebase: roles are data
* (rows in the database, editable by a SUPER_ADMIN), permissions are code.
*
* Adding a capability = add a constant here + attach it to a role in the seed.
* The admin UI reads the same catalog to render menus, so a permission the
* user lacks never renders a dead-end screen.
*/
export const PERMISSIONS = {
// Catalog
PRODUCT_READ: 'product.read',
PRODUCT_CREATE: 'product.create',
PRODUCT_UPDATE: 'product.update',
PRODUCT_DELETE: 'product.delete',
PRODUCT_PUBLISH: 'product.publish',
CATEGORY_READ: 'category.read',
CATEGORY_MANAGE: 'category.manage',
COLLECTION_READ: 'collection.read',
COLLECTION_MANAGE: 'collection.manage',
BRAND_READ: 'brand.read',
BRAND_MANAGE: 'brand.manage',
// Inventory
INVENTORY_READ: 'inventory.read',
INVENTORY_UPDATE: 'inventory.update',
// Sales
ORDER_READ: 'order.read',
ORDER_UPDATE: 'order.update',
ORDER_CANCEL: 'order.cancel',
ORDER_REFUND: 'order.refund',
PAYMENT_READ: 'payment.read',
PAYMENT_REFUND: 'payment.refund',
// Customers
CUSTOMER_READ: 'customer.read',
CUSTOMER_UPDATE: 'customer.update',
CUSTOMER_DELETE: 'customer.delete',
// Marketing
PROMOTION_MANAGE: 'promotion.manage',
COUPON_MANAGE: 'coupon.manage',
REVIEW_MODERATE: 'review.moderate',
// Content
CMS_READ: 'cms.read',
CMS_MANAGE: 'cms.manage',
MEDIA_READ: 'media.read',
MEDIA_UPLOAD: 'media.upload',
MEDIA_DELETE: 'media.delete',
// Platform administration
USER_READ: 'user.read',
USER_MANAGE: 'user.manage',
ROLE_READ: 'role.read',
ROLE_MANAGE: 'role.manage',
SETTINGS_MANAGE: 'settings.manage',
AUDIT_LOG_READ: 'audit_log.read',
} as const;
export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS];
export const ALL_PERMISSIONS: readonly Permission[] = Object.values(PERMISSIONS);
/**
* Seed roles. These are *starting data*, not hard-coded authorization: an
* operator can create new roles at runtime without a deploy.
*/
export const SYSTEM_ROLES = {
SUPER_ADMIN: 'super_admin',
ADMIN: 'admin',
CATALOG_MANAGER: 'catalog_manager',
ORDER_MANAGER: 'order_manager',
SUPPORT_AGENT: 'support_agent',
CUSTOMER: 'customer',
} as const;
export type SystemRole = (typeof SYSTEM_ROLES)[keyof typeof SYSTEM_ROLES];
export function hasPermission(
granted: readonly Permission[] | undefined,
required: Permission,
): boolean {
return granted?.includes(required) ?? false;
}
export function hasAllPermissions(
granted: readonly Permission[] | undefined,
required: readonly Permission[],
): boolean {
return required.every((permission) => hasPermission(granted, permission));
}
export function hasAnyPermission(
granted: readonly Permission[] | undefined,
required: readonly Permission[],
): boolean {
return required.some((permission) => hasPermission(granted, permission));
}
+40
View File
@@ -0,0 +1,40 @@
import type { Id, IsoDateTime } from '../primitives';
import type { TokenAudience, UserType } from './actors';
import type { Permission } from './permissions';
/**
* Access token: short-lived (minutes), stateless, carries the permission set so
* that guards need zero database round-trips on the hot path.
*
* Refresh token: long-lived (days), opaque to the client, ROTATED on every use
* and persisted server-side so it can be revoked. Reuse of an already-rotated
* refresh token invalidates the whole family (theft detection).
*/
export interface AccessTokenClaims {
/** Subject — the User id. */
readonly sub: Id;
readonly aud: TokenAudience;
readonly type: UserType;
readonly permissions: readonly Permission[];
/** Session/token family id, so a single device can be signed out. */
readonly sid: Id;
readonly iat: number;
readonly exp: number;
}
export interface AuthTokens {
readonly accessToken: string;
readonly accessTokenExpiresAt: IsoDateTime;
/** Delivered as an httpOnly, Secure, SameSite=Lax cookie — never in the body. */
readonly refreshTokenExpiresAt: IsoDateTime;
}
/** The shape every guard attaches to the request. */
export interface AuthenticatedActor {
readonly userId: Id;
readonly userType: UserType;
readonly audience: TokenAudience;
readonly permissions: readonly Permission[];
readonly sessionId: Id;
}
+50
View File
@@ -0,0 +1,50 @@
import type { Id, Nullable } from '../primitives';
/**
* Binary data NEVER touches PostgreSQL. The database stores an object key plus
* metadata; bytes live in R2/S3 and are served through the CDN.
*
* `url` is derived at read time from `storageKey` + the public media base URL,
* so swapping bucket, CDN domain or provider is a config change and not a
* data migration.
*/
export const MEDIA_KINDS = {
IMAGE: 'IMAGE',
VIDEO: 'VIDEO',
DOCUMENT: 'DOCUMENT',
} as const;
export type MediaKind = (typeof MEDIA_KINDS)[keyof typeof MEDIA_KINDS];
export interface MediaAsset {
readonly id: Id;
readonly kind: MediaKind;
/** Path inside the bucket, e.g. `products/2026/01/xy7.webp`. Never a full URL. */
readonly storageKey: string;
readonly url: string;
readonly mimeType: string;
readonly sizeBytes: number;
readonly width: Nullable<number>;
readonly height: Nullable<number>;
/** Tiny base64 LQIP so grids never flash empty. */
readonly blurDataUrl: Nullable<string>;
readonly altText: Nullable<string>;
}
export interface ImageRef {
readonly id: Id;
readonly url: string;
readonly altText: Nullable<string>;
readonly width: Nullable<number>;
readonly height: Nullable<number>;
readonly blurDataUrl: Nullable<string>;
}
export interface ProductImage extends ImageRef {
readonly position: number;
/**
* When set, this image belongs to a specific option value (usually a colour),
* which is how the gallery swaps when a shopper picks "Black" vs "White".
*/
readonly optionValueId: Nullable<Id>;
}
+127
View File
@@ -0,0 +1,127 @@
import type { Id, IsoDateTime, Metadata, Money, Nullable, Slug } from '../primitives';
import type { ProductImage } from './media';
import type { Brand, Category, Collection, SeoFields } from './taxonomy';
import type { ProductOption, ProductVariant, StorefrontVariant } from './variant';
export const PRODUCT_STATUSES = {
DRAFT: 'DRAFT',
ACTIVE: 'ACTIVE',
ARCHIVED: 'ARCHIVED',
} as const;
export type ProductStatus = (typeof PRODUCT_STATUSES)[keyof typeof PRODUCT_STATUSES];
/** Merchandising axis for /men, /women, /kids. Not a category — a facet. */
export const GENDER_TARGETS = {
MEN: 'MEN',
WOMEN: 'WOMEN',
KIDS: 'KIDS',
UNISEX: 'UNISEX',
} as const;
export type GenderTarget = (typeof GENDER_TARGETS)[keyof typeof GENDER_TARGETS];
/** Facet behind /sports/running, /sports/gym, … */
export const SPORT_TYPES = {
RUNNING: 'RUNNING',
FOOTBALL: 'FOOTBALL',
TRAINING: 'TRAINING',
GYM: 'GYM',
BADMINTON: 'BADMINTON',
LIFESTYLE: 'LIFESTYLE',
} as const;
export type SportType = (typeof SPORT_TYPES)[keyof typeof SPORT_TYPES];
/**
* Product is the *marketing* entity: what has a page, a name and a URL.
* It is never the thing you buy — a ProductVariant is. Product therefore holds
* no SKU, no stock and no single price.
*/
export interface Product {
readonly id: Id;
readonly name: string;
readonly slug: Slug;
readonly description: Nullable<string>;
readonly shortDescription: Nullable<string>;
readonly status: ProductStatus;
readonly publishedAt: Nullable<IsoDateTime>;
readonly brandId: Nullable<Id>;
readonly primaryCategoryId: Nullable<Id>;
readonly genderTargets: readonly GenderTarget[];
readonly sportTypes: readonly SportType[];
readonly options: readonly ProductOption[];
readonly variants: readonly ProductVariant[];
readonly images: readonly ProductImage[];
readonly attributes: readonly ProductAttribute[];
readonly seo: SeoFields;
readonly metadata: Metadata;
readonly createdAt: IsoDateTime;
readonly updatedAt: IsoDateTime;
}
/**
* Free-form specification rows ("Material: 92% polyester", "Fit: Slim").
* Deliberately generic: merchandisers add specs without a schema migration.
* Anything that must be *filtered on* becomes a first-class facet instead.
*/
export interface ProductAttribute {
readonly id: Id;
readonly key: string;
readonly label: string;
readonly value: string;
readonly group: Nullable<string>;
readonly position: number;
readonly isFilterable: boolean;
}
/** Full PDP payload. */
export interface StorefrontProduct extends Omit<
Product,
'variants' | 'status' | 'metadata' | 'createdAt' | 'updatedAt'
> {
readonly brand: Nullable<Brand>;
readonly primaryCategory: Nullable<Category>;
readonly collections: readonly Collection[];
readonly variants: readonly StorefrontVariant[];
readonly priceRange: PriceRange;
readonly rating: Nullable<ProductRatingSummary>;
}
/** Trimmed payload for grids — deliberately small, it is fetched 24 at a time. */
export interface ProductListItem {
readonly id: Id;
readonly name: string;
readonly slug: Slug;
readonly brandName: Nullable<string>;
readonly primaryImage: Nullable<ProductImage>;
readonly hoverImage: Nullable<ProductImage>;
readonly priceRange: PriceRange;
readonly isOnSale: boolean;
readonly colorSwatches: readonly ColorSwatch[];
readonly rating: Nullable<ProductRatingSummary>;
}
export interface ColorSwatch {
readonly optionValueId: Id;
readonly label: string;
readonly swatchHex: Nullable<string>;
readonly swatchImageUrl: Nullable<string>;
}
export interface PriceRange {
readonly min: Money;
readonly max: Money;
readonly compareAtMax: Nullable<Money>;
}
export interface ProductRatingSummary {
readonly average: number;
readonly count: number;
}
+71
View File
@@ -0,0 +1,71 @@
import type { Id, IsoDateTime, Metadata, Nullable, Slug } from '../primitives';
import type { ImageRef } from './media';
/**
* Three orthogonal ways to group products. Keeping them separate is what lets
* `/men/running` and `/collections/summer-drop` coexist without either one
* hijacking the other's URL space.
*
* - Category — the permanent, hierarchical merchandising tree
* (Men > Running > Shoes). One product has one primary category.
* - Collection— an editorial / campaign grouping, possibly rule-based and
* time-boxed (New Arrivals, Summer Drop, Sale). Many-to-many.
* - Brand — the manufacturer. One product has exactly one.
*/
export interface Category {
readonly id: Id;
readonly parentId: Nullable<Id>;
readonly name: string;
readonly slug: Slug;
readonly description: Nullable<string>;
readonly image: Nullable<ImageRef>;
/** Materialised path (`men/running/shoes`) — one query for a whole subtree. */
readonly path: string;
readonly depth: number;
readonly position: number;
readonly isActive: boolean;
readonly seo: SeoFields;
}
export interface CategoryNode extends Category {
readonly children: readonly CategoryNode[];
}
export const COLLECTION_TYPES = {
MANUAL: 'MANUAL',
/** Membership derived from rules (e.g. "price < 500k AND tag = sale"). */
AUTOMATED: 'AUTOMATED',
} as const;
export type CollectionType = (typeof COLLECTION_TYPES)[keyof typeof COLLECTION_TYPES];
export interface Collection {
readonly id: Id;
readonly name: string;
readonly slug: Slug;
readonly type: CollectionType;
readonly description: Nullable<string>;
readonly banner: Nullable<ImageRef>;
readonly startsAt: Nullable<IsoDateTime>;
readonly endsAt: Nullable<IsoDateTime>;
readonly isActive: boolean;
readonly seo: SeoFields;
}
export interface Brand {
readonly id: Id;
readonly name: string;
readonly slug: Slug;
readonly logo: Nullable<ImageRef>;
readonly description: Nullable<string>;
readonly isActive: boolean;
readonly seo: SeoFields;
}
export interface SeoFields {
readonly metaTitle: Nullable<string>;
readonly metaDescription: Nullable<string>;
readonly metadata?: Metadata;
}
+116
View File
@@ -0,0 +1,116 @@
import type { Id, IsoDateTime, Money, Nullable } from '../primitives';
/**
* THE central catalog decision.
*
* Size and colour are NOT fields on Product. A Product owns an ordered list of
* ProductOptions (Colour, Size, …), each option owns ordered ProductOptionValues
* (Black/White, S/M/L), and every purchasable combination is a ProductVariant
* with its own SKU, price and stock.
*
* Running Shirt (Product)
* ├── Option "Colour" → Black, White
* ├── Option "Size" → S, M, L
* └── Variants: Black/S, Black/M, Black/L, White/S, White/M, White/L
*
* Why this and not `sizes: string[]` on Product:
* - Stock, price, barcode and weight are per combination in the real world.
* - Order lines must reference an immutable, sellable unit (the variant id).
* - A third option (width, length, fit) is additive instead of a migration.
* - Marketplaces (Shopee/Lazada/TikTok) and ERP/POS all model variants this
* way, so integrations map 1:1 instead of needing a translation layer.
*/
export interface ProductOption {
readonly id: Id;
/** Display name shown to shoppers: "Colour", "Size". */
readonly name: string;
/** Stable machine key: `colour`, `size`. Used by URL params and integrations. */
readonly key: string;
readonly position: number;
readonly values: readonly ProductOptionValue[];
}
export interface ProductOptionValue {
readonly id: Id;
readonly optionId: Id;
/** "Black", "M". */
readonly label: string;
readonly value: string;
readonly position: number;
/** Swatch hex / image for colour-type options. */
readonly swatchHex: Nullable<string>;
readonly swatchImageUrl: Nullable<string>;
}
export const VARIANT_STATUSES = {
ACTIVE: 'ACTIVE',
/** Still referenced by past orders, no longer sellable. Never hard-deleted. */
ARCHIVED: 'ARCHIVED',
} as const;
export type VariantStatus = (typeof VARIANT_STATUSES)[keyof typeof VARIANT_STATUSES];
export interface ProductVariant {
readonly id: Id;
readonly productId: Id;
/** Unique across the whole catalog. The identifier ERP/POS/marketplaces use. */
readonly sku: string;
readonly barcode: Nullable<string>;
/** Denormalised for display and order snapshots: "Black / M". */
readonly title: string;
readonly price: Money;
/** Non-null only while on promotion; the effective price is derived. */
readonly salePrice: Nullable<Money>;
/** What it "was" — for the struck-through reference price. */
readonly compareAtPrice: Nullable<Money>;
/** Landed cost. Admin-only; never serialised to the storefront. */
readonly costPrice?: Nullable<Money>;
/** Grams. Required by every shipping-rate API. */
readonly weightGrams: Nullable<number>;
readonly dimensions: Nullable<VariantDimensions>;
/** Which option value this variant resolves to for each of the product's options. */
readonly optionValues: readonly VariantOptionValueRef[];
readonly status: VariantStatus;
readonly position: number;
readonly createdAt: IsoDateTime;
readonly updatedAt: IsoDateTime;
}
export interface VariantOptionValueRef {
readonly optionId: Id;
readonly optionKey: string;
readonly optionValueId: Id;
readonly label: string;
}
export interface VariantDimensions {
readonly lengthMm: number;
readonly widthMm: number;
readonly heightMm: number;
}
/** What the storefront actually renders on a product card / PDP selector. */
export interface StorefrontVariant extends Omit<
ProductVariant,
'costPrice' | 'createdAt' | 'updatedAt'
> {
readonly effectivePrice: Money;
readonly isOnSale: boolean;
readonly availability: VariantAvailability;
}
export const VARIANT_AVAILABILITY = {
IN_STOCK: 'IN_STOCK',
LOW_STOCK: 'LOW_STOCK',
OUT_OF_STOCK: 'OUT_OF_STOCK',
PREORDER: 'PREORDER',
} as const;
export type VariantAvailability = (typeof VARIANT_AVAILABILITY)[keyof typeof VARIANT_AVAILABILITY];
+20
View File
@@ -0,0 +1,20 @@
/**
* @sport/types — the shared contract between backend and frontends.
*
* HARD RULE: this package must stay framework-free. No NestJS, no React, no
* Prisma, no Zod, no runtime dependencies at all beyond plain TypeScript.
* Anything that needs a runtime belongs in @sport/validation or @sport/api-client.
*/
export * from './primitives';
export * from './api/envelope';
export * from './api/error-codes';
export * from './api/pagination';
export * from './auth/actors';
export * from './auth/permissions';
export * from './auth/tokens';
export * from './catalog/product';
export * from './catalog/variant';
export * from './catalog/taxonomy';
export * from './catalog/media';
export * from './inventory/stock';
+63
View File
@@ -0,0 +1,63 @@
import type { Id, IsoDateTime, Nullable } from '../primitives';
/**
* Inventory is tracked per (variant, location). Even with a single warehouse on
* day one, the location dimension exists from the start — retrofitting it after
* orders exist is one of the most expensive migrations in e-commerce.
*
* Availability is always computed, never stored:
* available = onHand - reserved
*
* `reserved` is what checkout holds while a payment is in flight, which is what
* prevents overselling the last size M.
*/
export interface StockLevel {
readonly variantId: Id;
readonly locationId: Id;
readonly onHand: number;
readonly reserved: number;
readonly available: number;
readonly reorderPoint: Nullable<number>;
readonly updatedAt: IsoDateTime;
}
export interface InventoryLocation {
readonly id: Id;
readonly name: string;
readonly code: string;
readonly isDefault: boolean;
readonly isActive: boolean;
}
/**
* Every quantity change is an append-only movement. The stock level is a
* projection of this ledger, which is what makes discrepancies auditable and
* makes a future extraction of Inventory into its own service tractable.
*/
export const STOCK_MOVEMENT_REASONS = {
PURCHASE_RECEIPT: 'PURCHASE_RECEIPT',
SALE: 'SALE',
RETURN: 'RETURN',
MANUAL_ADJUSTMENT: 'MANUAL_ADJUSTMENT',
STOCK_TAKE: 'STOCK_TAKE',
TRANSFER_IN: 'TRANSFER_IN',
TRANSFER_OUT: 'TRANSFER_OUT',
DAMAGE: 'DAMAGE',
} as const;
export type StockMovementReason =
(typeof STOCK_MOVEMENT_REASONS)[keyof typeof STOCK_MOVEMENT_REASONS];
export interface StockMovement {
readonly id: Id;
readonly variantId: Id;
readonly locationId: Id;
/** Signed: negative for outbound. */
readonly quantityDelta: number;
readonly reason: StockMovementReason;
/** Order id, return id, purchase-order id… */
readonly referenceId: Nullable<Id>;
readonly note: Nullable<string>;
readonly createdByUserId: Nullable<Id>;
readonly createdAt: IsoDateTime;
}
+31
View File
@@ -0,0 +1,31 @@
/** Opaque-ish branded aliases. Cheap documentation, zero runtime cost. */
export type Id = string;
export type Slug = string;
/** Always serialised as ISO-8601 UTC across the API boundary. */
export type IsoDateTime = string;
/** ISO-4217. The store launches VND-only but the type never assumes that. */
export type CurrencyCode = 'VND' | 'USD';
/**
* Money is transferred as an INTEGER in the currency's minor unit, never as a
* float. VND has no minor unit (minorUnitScale = 0), USD has two.
*
* Rationale: floating point money bugs are unfixable after the fact, and the
* database stores the same integer, so no conversion happens anywhere.
*/
export interface Money {
readonly amount: number;
readonly currency: CurrencyCode;
}
export const MINOR_UNIT_SCALE: Readonly<Record<CurrencyCode, number>> = {
VND: 0,
USD: 2,
};
/** Generic key/value bag for extensible, non-queried data. */
export type Metadata = Record<string, string | number | boolean | null>;
export type Nullable<T> = T | null;
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@sport/config/typescript/library.json",
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"],
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
}
}
+3
View File
@@ -0,0 +1,3 @@
import { reactConfig } from '@sport/eslint-config/react';
export default reactConfig;
+31
View File
@@ -0,0 +1,31 @@
{
"name": "@sport/ui",
"version": "0.0.0",
"private": true,
"description": "Shared, presentational React design-system primitives. Consumed as source via Next.js transpilePackages.",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"lint": "eslint src",
"typecheck": "tsc -p tsconfig.json --noEmit",
"clean": "rm -rf .turbo *.tsbuildinfo"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@sport/config": "workspace:*",
"@sport/eslint-config": "workspace:*",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"eslint": "catalog:",
"react": "catalog:",
"typescript": "catalog:"
},
"peerDependencies": {
"react": "^19.0.0"
}
}
+27
View File
@@ -0,0 +1,27 @@
/**
* @sport/ui — the design system.
*
* WHAT BELONGS HERE
* Presentational primitives that are identical in the storefront and the
* admin: buttons, inputs, badges, skeletons, typography, layout helpers.
*
* WHAT MUST NEVER BE HERE
* - Data fetching or any @sport/api-client import.
* - App state (cart, auth session, filters).
* - Domain components. A `<ProductCard>` knows about pricing, sale badges and
* variant swatches — that is storefront business UI and lives in
* `apps/storefront/src/features/product/`. Sharing it would couple two apps
* that must be free to diverge.
*
* The test: if the component would be meaningless in the admin dashboard, it
* does not go in this package.
*/
export { cn } from './lib/cn';
export { Button, buttonVariants } from './primitives/button';
export type { ButtonProps } from './primitives/button';
export { Badge, badgeVariants } from './primitives/badge';
export type { BadgeProps } from './primitives/badge';
export { Input } from './primitives/input';
export type { InputProps } from './primitives/input';
export { Skeleton } from './primitives/skeleton';
+7
View File
@@ -0,0 +1,7 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
/** Conditional classes + last-wins conflict resolution for Tailwind utilities. */
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
+29
View File
@@ -0,0 +1,29 @@
import { cva, type VariantProps } from 'class-variance-authority';
import type { HTMLAttributes } from 'react';
import { cn } from '../lib/cn';
export const badgeVariants = cva(
'inline-flex items-center gap-1 px-2 py-1 text-[0.625rem] font-semibold uppercase tracking-widest',
{
variants: {
variant: {
neutral: 'bg-ink-100 text-ink-700',
solid: 'bg-ink-950 text-white',
sale: 'bg-sale text-white',
new: 'bg-volt-500 text-ink-950',
success: 'bg-success text-white',
warning: 'bg-warning text-ink-950',
outline: 'border border-ink-300 text-ink-700',
},
},
defaultVariants: { variant: 'neutral' },
},
);
export interface BadgeProps
extends HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {}
export function Badge({ className, variant, ...props }: BadgeProps) {
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
}
+56
View File
@@ -0,0 +1,56 @@
import { cva, type VariantProps } from 'class-variance-authority';
import type { ButtonHTMLAttributes, Ref } from 'react';
import { cn } from '../lib/cn';
export const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium transition-colors ' +
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ink-900 focus-visible:ring-offset-2 ' +
'disabled:pointer-events-none disabled:opacity-40',
{
variants: {
variant: {
primary: 'bg-ink-950 text-white hover:bg-ink-800',
secondary: 'bg-ink-100 text-ink-950 hover:bg-ink-200',
outline:
'border border-ink-950 bg-transparent text-ink-950 hover:bg-ink-950 hover:text-white',
ghost: 'bg-transparent text-ink-950 hover:bg-ink-100',
accent: 'bg-volt-500 text-ink-950 hover:bg-volt-400',
danger: 'bg-danger text-white hover:opacity-90',
},
size: {
sm: 'h-9 px-4 text-xs uppercase tracking-wide',
md: 'h-11 px-6 text-sm uppercase tracking-wide',
lg: 'h-14 px-8 text-sm uppercase tracking-widest',
icon: 'size-10',
},
shape: {
square: 'rounded-none',
rounded: 'rounded-card',
pill: 'rounded-pill',
},
fullWidth: {
true: 'w-full',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
shape: 'square',
},
},
);
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
ref?: Ref<HTMLButtonElement>;
}
export function Button({ className, variant, size, shape, fullWidth, ...props }: ButtonProps) {
return (
<button
className={cn(buttonVariants({ variant, size, shape, fullWidth }), className)}
{...props}
/>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { InputHTMLAttributes, Ref } from 'react';
import { cn } from '../lib/cn';
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
invalid?: boolean;
ref?: Ref<HTMLInputElement>;
}
export function Input({ className, invalid, ...props }: InputProps) {
return (
<input
aria-invalid={invalid || undefined}
className={cn(
'border-ink-300 text-ink-950 h-11 w-full border bg-white px-4 text-sm transition-colors',
'placeholder:text-ink-400 focus:border-ink-950 focus:outline-none',
'disabled:bg-ink-50 disabled:cursor-not-allowed',
invalid && 'border-danger focus:border-danger',
className,
)}
{...props}
/>
);
}
+7
View File
@@ -0,0 +1,7 @@
import type { HTMLAttributes } from 'react';
import { cn } from '../lib/cn';
export function Skeleton({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('bg-ink-100 animate-pulse', className)} {...props} />;
}
+5
View File
@@ -0,0 +1,5 @@
{
"extends": "@sport/config/typescript/react-library.json",
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules"]
}
+3
View File
@@ -0,0 +1,3 @@
import { baseConfig } from '@sport/eslint-config/base';
export default baseConfig;
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@sport/validation",
"version": "0.0.0",
"private": true,
"description": "Zod schemas shared by the API (request validation) and the frontends (form validation).",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
"clean": "rm -rf dist .turbo *.tsbuildinfo",
"lint": "eslint src",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@sport/types": "workspace:*",
"zod": "catalog:"
},
"devDependencies": {
"@sport/config": "workspace:*",
"@sport/eslint-config": "workspace:*",
"eslint": "catalog:",
"typescript": "catalog:"
}
}
+32
View File
@@ -0,0 +1,32 @@
import { z } from 'zod';
import { emailSchema, phoneSchema } from './common';
/**
* Password policy lives here so the storefront's signup form, the admin's
* "reset user password" dialog and the API all enforce byte-identical rules.
*/
export const passwordSchema = z
.string()
.min(10, 'Use at least 10 characters')
.max(128)
.refine((value) => /[a-z]/.test(value), 'Needs a lowercase letter')
.refine((value) => /[A-Z]/.test(value), 'Needs an uppercase letter')
.refine((value) => /\d/.test(value), 'Needs a number');
export const loginSchema = z.object({
email: emailSchema,
password: z.string().min(1, 'Password is required'),
});
export const registerSchema = z.object({
email: emailSchema,
password: passwordSchema,
firstName: z.string().trim().min(1).max(80),
lastName: z.string().trim().min(1).max(80),
phone: phoneSchema.optional(),
acceptsMarketing: z.boolean().default(false),
});
export type LoginInput = z.infer<typeof loginSchema>;
export type RegisterInput = z.infer<typeof registerSchema>;
+51
View File
@@ -0,0 +1,51 @@
import { z } from 'zod';
import { slugSchema } from './common';
import { cursorPageQuerySchema } from './pagination';
export const genderTargetSchema = z.enum(['MEN', 'WOMEN', 'KIDS', 'UNISEX']);
export const sportTypeSchema = z.enum([
'RUNNING',
'FOOTBALL',
'TRAINING',
'GYM',
'BADMINTON',
'LIFESTYLE',
]);
export const productSortSchema = z
.enum(['newest', 'price_asc', 'price_desc', 'best_selling', 'relevance'])
.default('newest');
/** Repeatable query params arrive as `?size=M&size=L` or a single `?size=M`. */
const csvList = <T extends z.ZodTypeAny>(item: T) =>
z.preprocess(
(value) => (typeof value === 'string' ? value.split(',').filter(Boolean) : value),
z.array(item).optional(),
);
/**
* The one filter contract shared by /men, /sports/[slug], /collections/[slug]
* and /search. Every listing page is the same query with different presets,
* which is why there is exactly one schema instead of four.
*/
export const productFilterSchema = cursorPageQuerySchema.extend({
q: z.string().trim().max(120).optional(),
categorySlug: slugSchema.optional(),
collectionSlug: slugSchema.optional(),
brandSlugs: csvList(slugSchema),
gender: csvList(genderTargetSchema),
sport: csvList(sportTypeSchema),
/** Option-value filters, e.g. colour=black,white & size=m,l */
colors: csvList(z.string().max(60)),
sizes: csvList(z.string().max(20)),
minPrice: z.coerce.number().int().min(0).optional(),
maxPrice: z.coerce.number().int().min(0).optional(),
onSale: z.coerce.boolean().optional(),
inStockOnly: z.coerce.boolean().optional(),
sort: productSortSchema,
});
export type ProductFilterInput = z.input<typeof productFilterSchema>;
export type ProductFilter = z.output<typeof productFilterSchema>;
+39
View File
@@ -0,0 +1,39 @@
import { z } from 'zod';
export const idSchema = z.uuid({ version: 'v7' }).describe('UUID v7 identifier');
/** Accept any UUID version on read paths — legacy/imported rows may differ. */
export const anyIdSchema = z.uuid();
export const slugSchema = z
.string()
.min(1)
.max(160)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Must be lowercase, hyphen-separated');
export const emailSchema = z.email().max(255).toLowerCase().trim();
/** Vietnamese mobile numbers, stored E.164. */
export const phoneSchema = z
.string()
.trim()
.regex(/^(?:\+84|0)(?:3|5|7|8|9)\d{8}$/, 'Invalid Vietnamese phone number');
export const currencySchema = z.enum(['VND', 'USD']);
/** Money always crosses the wire as an integer in minor units. */
export const moneySchema = z.object({
amount: z.int().min(0),
currency: currencySchema,
});
export const skuSchema = z
.string()
.trim()
.min(3)
.max(64)
.regex(/^[A-Z0-9][A-Z0-9._-]*$/, 'SKU must be uppercase alphanumeric with . _ -');
export const isoDateTimeSchema = z.iso.datetime({ offset: true });
export type Money = z.infer<typeof moneySchema>;
+17
View File
@@ -0,0 +1,17 @@
/**
* @sport/validation — one schema, validated twice.
*
* The API validates inbound requests with these schemas; the storefront and
* admin validate the same forms client-side with the *same* objects. A rule
* change can therefore never drift between client and server.
*
* Scope discipline: this package contains SHAPE and FORMAT rules only
* (required, max length, email, positive integer). Business rules that need
* database state — "coupon still has uses left", "variant is in stock" — live
* in the backend service layer. Do not smuggle them in here.
*/
export * from './common';
export * from './pagination';
export * from './auth';
export * from './catalog';
+30
View File
@@ -0,0 +1,30 @@
import { z } from 'zod';
import { PAGINATION_DEFAULTS } from '@sport/types';
/** Query strings arrive as strings; coerce once, here, and nowhere else. */
export const offsetPageQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
perPage: z.coerce
.number()
.int()
.min(1)
.max(PAGINATION_DEFAULTS.maxPerPage)
.default(PAGINATION_DEFAULTS.perPage),
});
export const cursorPageQuerySchema = z.object({
cursor: z.string().min(1).nullish(),
limit: z.coerce
.number()
.int()
.min(1)
.max(PAGINATION_DEFAULTS.maxCursorLimit)
.default(PAGINATION_DEFAULTS.cursorLimit),
});
export const sortDirectionSchema = z.enum(['asc', 'desc']).default('desc');
export type OffsetPageQueryInput = z.input<typeof offsetPageQuerySchema>;
export type OffsetPageQuery = z.output<typeof offsetPageQuerySchema>;
export type CursorPageQuery = z.output<typeof cursorPageQuerySchema>;
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@sport/config/typescript/library.json",
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"],
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
}
}