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