Basic Architecture of Sport Web
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
import { baseConfig } from '@sport/eslint-config/base';
|
||||
|
||||
export default baseConfig;
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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' }),
|
||||
};
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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'"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
Vendored
+34
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { baseConfig } from '@sport/eslint-config/base';
|
||||
|
||||
export default baseConfig;
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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;
|
||||
@@ -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];
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@sport/config/typescript/library.json",
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { reactConfig } from '@sport/eslint-config/react';
|
||||
|
||||
export default reactConfig;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "@sport/config/typescript/react-library.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { baseConfig } from '@sport/eslint-config/base';
|
||||
|
||||
export default baseConfig;
|
||||
@@ -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:"
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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';
|
||||
@@ -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>;
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@sport/config/typescript/library.json",
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user