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/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"
}
}