This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent 1e356a2578
commit d9f159a8c3
84 changed files with 7992 additions and 180 deletions
+114
View File
@@ -1,5 +1,7 @@
import { z } from 'zod';
import { LOCALES } from '@sport/types';
import { anyIdSchema } from './common';
import { offsetPageQuerySchema } from './pagination';
@@ -65,3 +67,115 @@ export type ShippingAddressInput = z.output<typeof shippingAddressSchema>;
export type PlaceOrderInput = z.output<typeof placeOrderSchema>;
export type OrderListQuery = z.output<typeof orderListQuerySchema>;
export type UpdateOrderStatusInput = z.output<typeof updateOrderStatusSchema>;
/** A discount code as typed. Normalised to uppercase before it is stored. */
export const applyDiscountCodeSchema = z.object({
code: z.string().trim().min(1).max(40),
});
const discountTranslationSchema = z.object({
name: z.string().trim().min(1).max(120),
description: z.string().trim().max(500).nullish(),
});
/**
* A discount as an operator authors it.
*
* `value` means different things per `type` — whole percent, or minor units —
* so it is range-checked against the type rather than in isolation. A 500%
* discount is a typo, and catching it here is cheaper than in the ledger.
*/
export const discountInputSchema = z
.object({
code: z
.string()
.trim()
.toUpperCase()
.min(3)
.max(40)
.regex(/^[A-Z0-9_-]+$/, 'Use letters, digits, hyphens and underscores')
.nullish(),
trigger: z.enum(['AUTOMATIC', 'CODE']),
type: z.enum(['PERCENTAGE', 'FIXED_AMOUNT']),
scope: z.enum(['ORDER', 'PRODUCT']).default('ORDER'),
value: z.int().min(1),
translations: z.partialRecord(z.enum(LOCALES), discountTranslationSchema),
minSubtotalAmount: z.int().min(0).nullish(),
startsAt: z.iso.datetime().nullish(),
endsAt: z.iso.datetime().nullish(),
isActive: z.boolean().default(true),
usageLimit: z.int().min(1).nullish(),
stackable: z.boolean().default(false),
priority: z.int().min(0).max(1000).default(100),
productIds: z.array(anyIdSchema).default([]),
collectionIds: z.array(anyIdSchema).default([]),
})
.refine((value) => value.type !== 'PERCENTAGE' || value.value <= 100, {
message: 'A percentage discount cannot exceed 100',
path: ['value'],
})
.refine((value) => value.trigger !== 'CODE' || Boolean(value.code), {
message: 'A coupon needs a code',
path: ['code'],
})
.refine(
(value) =>
!value.startsAt || !value.endsAt || new Date(value.startsAt) < new Date(value.endsAt),
{ message: 'The end date must be after the start date', path: ['endsAt'] },
);
export const discountListQuerySchema = offsetPageQuerySchema.extend({
q: z.string().trim().max(120).optional(),
trigger: z.enum(['AUTOMATIC', 'CODE']).optional(),
});
export type ApplyDiscountCodeInput = z.output<typeof applyDiscountCodeSchema>;
export type DiscountInput = z.output<typeof discountInputSchema>;
export type DiscountListQuery = z.output<typeof discountListQuerySchema>;
/**
* A review as a shopper submits it.
*
* `orderLineId` is the whole authorisation story: it names the exact purchased
* item being reviewed. Paired with the order id and the email on that order
* (checked server-side), it is the same proof that already lets a guest look up
* their order — no account required, and no way to review something you did not
* buy.
*/
export const submitReviewSchema = z.object({
orderId: anyIdSchema,
/** The email on the order. Proof, not contact details — never stored here. */
email: z.email().max(255),
orderLineId: anyIdSchema,
rating: z.int().min(1).max(5),
title: z.string().trim().max(140).nullish(),
body: z.string().trim().max(2000).nullish(),
authorName: z.string().trim().min(1).max(120),
});
export const reviewListQuerySchema = offsetPageQuerySchema.extend({
/** Newest first by default; `helpful` is deliberately absent until votes are. */
sort: z.enum(['newest', 'rating_desc', 'rating_asc']).default('newest'),
});
export const adminReviewListQuerySchema = offsetPageQuerySchema.extend({
status: z.enum(['PENDING', 'APPROVED', 'REJECTED']).optional(),
q: z.string().trim().max(120).optional(),
});
/**
* A moderation decision.
*
* `PENDING` is not an accepted target: moderation moves a review out of the
* queue, and letting an operator put one back would make "how long has this
* been waiting" unanswerable.
*/
export const moderateReviewSchema = z.object({
status: z.enum(['APPROVED', 'REJECTED']),
note: z.string().trim().max(500).nullish(),
});
export type SubmitReviewInput = z.output<typeof submitReviewSchema>;
export type ReviewListQuery = z.output<typeof reviewListQuerySchema>;
export type AdminReviewListQuery = z.output<typeof adminReviewListQuerySchema>;
export type ModerateReviewInput = z.output<typeof moderateReviewSchema>;
+52
View File
@@ -0,0 +1,52 @@
import { z } from 'zod';
import { LOCALES } from '@sport/types';
import { anyIdSchema, slugSchema } from './common';
import { offsetPageQuerySchema } from './pagination';
const contentTranslationSchema = z.object({
slug: slugSchema,
title: z.string().trim().min(1).max(255),
excerpt: z.string().trim().max(500).nullish(),
/**
* Markdown, and required.
*
* A page with a title and no body is a broken link with extra steps — it
* renders, gets indexed, and tells a reader nothing.
*/
body: z.string().trim().min(1),
metaTitle: z.string().trim().max(255).nullish(),
metaDescription: z.string().trim().max(500).nullish(),
});
/**
* A page or post as an operator authors it.
*
* `translations` is a partial record: publishing an English-only post is a
* legitimate thing to do, and requiring every locale would mean inventing
* Vietnamese copy to get anything live.
*/
export const contentEntryInputSchema = z
.object({
type: z.enum(['PAGE', 'POST']),
status: z.enum(['DRAFT', 'PUBLISHED']).default('DRAFT'),
coverImageId: anyIdSchema.nullish(),
translations: z.partialRecord(z.enum(LOCALES), contentTranslationSchema),
})
.refine((value) => Object.keys(value.translations).length > 0, {
message: 'Write the entry in at least one language',
path: ['translations'],
});
export const contentListQuerySchema = offsetPageQuerySchema.extend({
type: z.enum(['PAGE', 'POST']).optional(),
status: z.enum(['DRAFT', 'PUBLISHED']).optional(),
q: z.string().trim().max(120).optional(),
});
export const postListQuerySchema = offsetPageQuerySchema;
export type ContentEntryInput = z.output<typeof contentEntryInputSchema>;
export type ContentListQuery = z.output<typeof contentListQuerySchema>;
export type PostListQuery = z.output<typeof postListQuerySchema>;
+1
View File
@@ -18,3 +18,4 @@ export * from './catalog';
export * from './users';
export * from './catalog-admin';
export * from './commerce';
export * from './content';