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
+26
View File
@@ -9,7 +9,23 @@ import {
type CommerceResource,
type OrdersAdminResource,
} from './resources/commerce';
import {
createContentAdminResource,
createContentResource,
type ContentAdminResource,
type ContentResource,
} from './resources/content';
import { createHealthResource, type HealthResource } from './resources/health';
import {
createPromotionsAdminResource,
type PromotionsAdminResource,
} from './resources/promotions-admin';
import {
createReviewsAdminResource,
createReviewsResource,
type ReviewsAdminResource,
type ReviewsResource,
} from './resources/reviews';
/**
* Resource modules are added here as the backend grows — one file per bounded
@@ -26,6 +42,11 @@ export interface ApiClient {
readonly catalogAdmin: CatalogAdminResource;
readonly commerce: CommerceResource;
readonly ordersAdmin: OrdersAdminResource;
readonly promotionsAdmin: PromotionsAdminResource;
readonly reviews: ReviewsResource;
readonly reviewsAdmin: ReviewsAdminResource;
readonly content: ContentResource;
readonly contentAdmin: ContentAdminResource;
}
export function createApiClient(options: HttpClientOptions): ApiClient {
@@ -40,5 +61,10 @@ export function createApiClient(options: HttpClientOptions): ApiClient {
catalogAdmin: createCatalogAdminResource(http),
commerce: createCommerceResource(http),
ordersAdmin: createOrdersAdminResource(http),
promotionsAdmin: createPromotionsAdminResource(http),
reviews: createReviewsResource(http),
reviewsAdmin: createReviewsAdminResource(http),
content: createContentResource(http),
contentAdmin: createContentAdminResource(http),
};
}
+22
View File
@@ -36,4 +36,26 @@ export type {
OrdersAdminResource,
PlaceOrderInput,
} from './resources/commerce';
export type {
DiscountListParams,
DiscountPayload,
PromotionsAdminResource,
} from './resources/promotions-admin';
export type {
AdminReviewListParams,
ProductReviews,
ReviewCredentials,
ReviewListParams,
ReviewsAdminResource,
ReviewsResource,
SubmitReviewPayload,
} from './resources/reviews';
export type {
AdminContentListParams,
ContentAdminResource,
ContentEntryPayload,
ContentResource,
ContentTranslationPayload,
PostListParams,
} from './resources/content';
export type { ApiClient } from './create-client';
@@ -52,6 +52,8 @@ export interface CommerceResource {
options?: RequestOptions,
): Promise<Cart>;
removeCartLine(locale: Locale, variantId: string, options?: RequestOptions): Promise<Cart>;
applyDiscountCode(locale: Locale, code: string, options?: RequestOptions): Promise<Cart>;
removeDiscountCode(locale: Locale, code: string, options?: RequestOptions): Promise<Cart>;
getCheckoutQuote(locale: Locale, options?: RequestOptions): Promise<Cart>;
/**
@@ -119,6 +121,15 @@ export function createCommerceResource(http: HttpClient): CommerceResource {
cartOptions(locale, options),
),
applyDiscountCode: (locale, code, options) =>
http.post<Cart>('/cart/discounts', { code }, cartOptions(locale, options)),
removeDiscountCode: (locale, code, options) =>
http.delete<Cart>(
`/cart/discounts/${encodeURIComponent(code)}`,
cartOptions(locale, options),
),
getCheckoutQuote: (locale, options) =>
http.get<Cart>('/checkout/quote', cartOptions(locale, options)),
@@ -0,0 +1,116 @@
import type {
AdminContentEntry,
ContentDetail,
ContentStatus,
ContentSummary,
ContentType,
Locale,
OffsetPaginated,
} from '@sport/types';
import type { HttpClient, RequestOptions } from '../http-client';
export interface PostListParams {
page?: number;
perPage?: number;
}
export interface AdminContentListParams {
page?: number;
perPage?: number;
type?: ContentType;
status?: ContentStatus;
q?: string;
}
export interface ContentTranslationPayload {
slug: string;
title: string;
excerpt?: string | null;
body: string;
metaTitle?: string | null;
metaDescription?: string | null;
}
export interface ContentEntryPayload {
type: ContentType;
status?: ContentStatus;
coverImageId?: string | null;
translations: Record<string, ContentTranslationPayload>;
}
/** Editorial content: pages and posts. Published entries only. */
export interface ContentResource {
listPosts(
locale: Locale,
params?: PostListParams,
options?: RequestOptions,
): Promise<OffsetPaginated<ContentSummary>>;
getPost(locale: Locale, slug: string, options?: RequestOptions): Promise<ContentDetail>;
getPage(locale: Locale, slug: string, options?: RequestOptions): Promise<ContentDetail>;
listPostSlugs(
locale: Locale,
options?: RequestOptions,
): Promise<{ slug: string; title: string; updatedAt: string }[]>;
listPageSlugs(
locale: Locale,
options?: RequestOptions,
): Promise<{ slug: string; title: string; updatedAt: string }[]>;
}
export interface ContentAdminResource {
list(params?: AdminContentListParams): Promise<OffsetPaginated<AdminContentEntry>>;
getById(id: string): Promise<AdminContentEntry>;
create(payload: ContentEntryPayload): Promise<AdminContentEntry>;
update(id: string, payload: ContentEntryPayload): Promise<AdminContentEntry>;
remove(id: string): Promise<void>;
}
export function createContentResource(http: HttpClient): ContentResource {
return {
listPosts: (locale, params = {}, options = {}) =>
http.get<OffsetPaginated<ContentSummary>>('/content/posts', {
...options,
query: { locale, ...params },
}),
getPost: (locale, slug, options = {}) =>
http.get<ContentDetail>(`/content/posts/${encodeURIComponent(slug)}`, {
...options,
query: { locale },
}),
getPage: (locale, slug, options = {}) =>
http.get<ContentDetail>(`/content/pages/${encodeURIComponent(slug)}`, {
...options,
query: { locale },
}),
listPostSlugs: (locale, options = {}) =>
http.get<{ slug: string; title: string; updatedAt: string }[]>('/content/posts/slugs', {
...options,
query: { locale },
}),
listPageSlugs: (locale, options = {}) =>
http.get<{ slug: string; title: string; updatedAt: string }[]>('/content/pages/slugs', {
...options,
query: { locale },
}),
};
}
export function createContentAdminResource(http: HttpClient): ContentAdminResource {
// Never cached: an editor who just saved a draft must see the draft.
const uncached = { cache: 'no-store' } as const;
return {
list: (params = {}) =>
http.get<OffsetPaginated<AdminContentEntry>>('/admin/content', {
...uncached,
query: { ...params },
}),
getById: (id) =>
http.get<AdminContentEntry>(`/admin/content/${encodeURIComponent(id)}`, uncached),
create: (payload) => http.post<AdminContentEntry>('/admin/content', payload, uncached),
update: (id, payload) =>
http.patch<AdminContentEntry>(`/admin/content/${encodeURIComponent(id)}`, payload, uncached),
remove: (id) => http.delete<void>(`/admin/content/${encodeURIComponent(id)}`, uncached),
};
}
@@ -0,0 +1,70 @@
import type { AdminDiscount, OffsetPaginated } from '@sport/types';
import type { HttpClient } from '../http-client';
export interface DiscountListParams {
page?: number;
perPage?: number;
q?: string;
trigger?: 'AUTOMATIC' | 'CODE';
}
/**
* A discount as the admin form submits it.
*
* Deliberately not `DiscountInput` from `@sport/validation`: that is the type
* *after* parsing, with defaults already applied, so using it here would force
* every caller to supply fields the schema is perfectly happy to fill in.
*/
export interface DiscountPayload {
code?: string | null;
trigger: 'AUTOMATIC' | 'CODE';
type: 'PERCENTAGE' | 'FIXED_AMOUNT';
scope?: 'ORDER' | 'PRODUCT';
value: number;
translations: Record<string, { name: string; description?: string | null }>;
minSubtotalAmount?: number | null;
startsAt?: string | null;
endsAt?: string | null;
isActive?: boolean;
usageLimit?: number | null;
stackable?: boolean;
priority?: number;
productIds?: string[];
collectionIds?: string[];
}
/**
* Promotions and coupons, which are one resource.
*
* There is no `coupons` resource next to this one, and that is the point: a
* coupon is a promotion that needs a code typed (ADR-0020). Two client
* resources would reintroduce the split the data model exists to avoid.
*/
export interface PromotionsAdminResource {
list(params?: DiscountListParams): Promise<OffsetPaginated<AdminDiscount>>;
getById(id: string): Promise<AdminDiscount>;
create(payload: DiscountPayload): Promise<AdminDiscount>;
update(id: string, payload: DiscountPayload): Promise<AdminDiscount>;
remove(id: string): Promise<void>;
}
export function createPromotionsAdminResource(http: HttpClient): PromotionsAdminResource {
// Never cached. An operator who just switched a promotion off must see it
// off — a stale list here is a discount they believe they stopped giving.
const uncached = { cache: 'no-store' } as const;
return {
list: (params = {}) =>
http.get<OffsetPaginated<AdminDiscount>>('/admin/discounts', {
...uncached,
query: { ...params },
}),
getById: (id) =>
http.get<AdminDiscount>(`/admin/discounts/${encodeURIComponent(id)}`, uncached),
create: (payload) => http.post<AdminDiscount>('/admin/discounts', payload, uncached),
update: (id, payload) =>
http.patch<AdminDiscount>(`/admin/discounts/${encodeURIComponent(id)}`, payload, uncached),
remove: (id) => http.delete<void>(`/admin/discounts/${encodeURIComponent(id)}`, uncached),
};
}
@@ -0,0 +1,108 @@
import type {
AdminReview,
Locale,
OffsetPaginated,
ReviewSummary,
ReviewableItem,
StorefrontReview,
} from '@sport/types';
import type { HttpClient, RequestOptions } from '../http-client';
export interface ReviewListParams {
page?: number;
perPage?: number;
sort?: 'newest' | 'rating_desc' | 'rating_asc';
}
export type ProductReviews = OffsetPaginated<StorefrontReview> & { summary: ReviewSummary };
/** Proof of purchase: the order id from the confirmation URL plus its email. */
export interface ReviewCredentials {
orderId: string;
email: string;
}
export interface SubmitReviewPayload extends ReviewCredentials {
orderLineId: string;
rating: number;
title?: string | null;
body?: string | null;
authorName: string;
}
export interface AdminReviewListParams {
page?: number;
perPage?: number;
status?: 'PENDING' | 'APPROVED' | 'REJECTED';
q?: string;
}
export interface ReviewsResource {
/** Approved reviews for a product, with the summary the header needs. */
listForProduct(
productId: string,
params?: ReviewListParams,
options?: RequestOptions,
): Promise<ProductReviews>;
/**
* What an order entitles its buyer to review.
*
* A POST because the email is proof of ownership — in a query string it would
* land in server logs, browser history, and the Referer header sent with
* every image on the page.
*/
listReviewable(
credentials: ReviewCredentials,
locale?: Locale,
): Promise<readonly ReviewableItem[]>;
submit(payload: SubmitReviewPayload, locale?: Locale): Promise<readonly ReviewableItem[]>;
}
export interface ReviewsAdminResource {
list(params?: AdminReviewListParams): Promise<OffsetPaginated<AdminReview>>;
moderate(
id: string,
decision: { status: 'APPROVED' | 'REJECTED'; note?: string | null },
): Promise<AdminReview>;
}
export function createReviewsResource(http: HttpClient): ReviewsResource {
return {
listForProduct: (productId, params = {}, options = {}) =>
http.get<ProductReviews>(`/reviews/product/${encodeURIComponent(productId)}`, {
...options,
query: { ...params },
}),
listReviewable: (credentials, locale) =>
http.post<readonly ReviewableItem[]>('/reviews/reviewable', credentials, {
cache: 'no-store',
query: locale ? { locale } : undefined,
}),
submit: (payload, locale) =>
http.post<readonly ReviewableItem[]>('/reviews', payload, {
cache: 'no-store',
query: locale ? { locale } : undefined,
}),
};
}
export function createReviewsAdminResource(http: HttpClient): ReviewsAdminResource {
// A moderation queue must never be served from cache: two operators working
// the same queue would otherwise both see, and both action, the same review.
const uncached = { cache: 'no-store' } as const;
return {
list: (params = {}) =>
http.get<OffsetPaginated<AdminReview>>('/admin/reviews', {
...uncached,
query: { ...params },
}),
moderate: (id, decision) =>
http.patch<AdminReview>(`/admin/reviews/${encodeURIComponent(id)}`, decision, uncached),
};
}
+5 -1
View File
@@ -43,8 +43,12 @@ export const PERMISSIONS = {
CUSTOMER_DELETE: 'customer.delete',
// Marketing
//
// There is no separate `coupon.manage`. A coupon is a promotion that needs a
// code typed (ADR-0020) — one entity, one permission. A second permission
// would imply a separation the data model deliberately does not have, and
// would let a role manage half of one screen.
PROMOTION_MANAGE: 'promotion.manage',
COUPON_MANAGE: 'coupon.manage',
REVIEW_MODERATE: 'review.moderate',
// Content
+6
View File
@@ -1,5 +1,7 @@
import type { Id, Money, Nullable } from '../primitives';
import type { AppliedDiscount, RejectedDiscount } from './discount';
/**
* The cart as the storefront sees it.
*
@@ -14,6 +16,10 @@ export interface Cart {
readonly totals: CartTotals;
/** Lines dropped since the cart was last seen, so the UI can explain itself. */
readonly notices: readonly CartNotice[];
/** Discounts currently taking money off, automatic and coded alike. */
readonly discounts: readonly AppliedDiscount[];
/** Codes the shopper entered that did not apply, and why. */
readonly rejectedDiscounts: readonly RejectedDiscount[];
readonly updatedAt: string;
}
+71
View File
@@ -0,0 +1,71 @@
import type { Id, Money, Nullable } from '../primitives';
export const DISCOUNT_TRIGGERS = { AUTOMATIC: 'AUTOMATIC', CODE: 'CODE' } as const;
export type DiscountTrigger = (typeof DISCOUNT_TRIGGERS)[keyof typeof DISCOUNT_TRIGGERS];
export const DISCOUNT_TYPES = {
PERCENTAGE: 'PERCENTAGE',
FIXED_AMOUNT: 'FIXED_AMOUNT',
} as const;
export type DiscountType = (typeof DISCOUNT_TYPES)[keyof typeof DISCOUNT_TYPES];
export const DISCOUNT_SCOPES = { ORDER: 'ORDER', PRODUCT: 'PRODUCT' } as const;
export type DiscountScope = (typeof DISCOUNT_SCOPES)[keyof typeof DISCOUNT_SCOPES];
/**
* A discount as the shopper sees it once applied.
*
* `amount` is what this discount actually took off *this* cart — computed by
* the API, never by the client, and re-computed at checkout. The rule that
* produced it is deliberately not exposed: a shopper needs the name and the
* saving, not the engine's reasoning.
*/
export interface AppliedDiscount {
readonly id: Id;
readonly code: Nullable<string>;
readonly name: string;
readonly amount: Money;
}
/** Why a code the shopper typed did not work. */
export const DISCOUNT_REJECTIONS = {
NOT_FOUND: 'NOT_FOUND',
EXPIRED: 'EXPIRED',
NOT_STARTED: 'NOT_STARTED',
USAGE_LIMIT_REACHED: 'USAGE_LIMIT_REACHED',
MINIMUM_NOT_MET: 'MINIMUM_NOT_MET',
NOTHING_ELIGIBLE: 'NOTHING_ELIGIBLE',
NOT_COMBINABLE: 'NOT_COMBINABLE',
} as const;
export type DiscountRejectionReason =
(typeof DISCOUNT_REJECTIONS)[keyof typeof DISCOUNT_REJECTIONS];
export interface RejectedDiscount {
readonly code: string;
readonly reason: DiscountRejectionReason;
/** Present for MINIMUM_NOT_MET, so the UI can say how much more is needed. */
readonly minimumSubtotal: Nullable<Money>;
}
/** Admin view. Everything the engine reads, plus its redemption count. */
export interface AdminDiscount {
readonly id: Id;
readonly code: Nullable<string>;
readonly trigger: DiscountTrigger;
readonly type: DiscountType;
readonly scope: DiscountScope;
readonly value: number;
readonly translations: Readonly<Record<string, { name: string; description: Nullable<string> }>>;
readonly minSubtotalAmount: Nullable<number>;
readonly startsAt: Nullable<string>;
readonly endsAt: Nullable<string>;
readonly isActive: boolean;
readonly usageLimit: Nullable<number>;
readonly usageCount: number;
readonly stackable: boolean;
readonly priority: number;
readonly productIds: readonly Id[];
readonly collectionIds: readonly Id[];
readonly createdAt: string;
}
+89
View File
@@ -0,0 +1,89 @@
import type { Id, Nullable, Slug } from '../primitives';
export const REVIEW_STATUSES = {
PENDING: 'PENDING',
APPROVED: 'APPROVED',
REJECTED: 'REJECTED',
} as const;
export type ReviewStatus = (typeof REVIEW_STATUSES)[keyof typeof REVIEW_STATUSES];
export const REVIEW_RATING_MIN = 1;
export const REVIEW_RATING_MAX = 5;
/** A published review, as a shopper sees it. */
export interface StorefrontReview {
readonly id: Id;
readonly rating: number;
readonly title: Nullable<string>;
readonly body: Nullable<string>;
readonly authorName: string;
readonly createdAt: string;
/**
* Always true, and sent anyway.
*
* Every review in this system is anchored to an order line, so there is no
* such thing as an unverified one. The field exists because the badge is
* worth showing, and because a future import of legacy reviews would need to
* say so honestly rather than by omission.
*/
readonly isVerifiedPurchase: boolean;
}
/**
* How a product's ratings are distributed.
*
* Sent alongside the average because they answer different questions: a 3.0
* average made of straight 3s is a mediocre product, and a 3.0 made of 5s and
* 1s is a product with a sizing problem. The bar chart is what makes that
* visible, and it cannot be reconstructed from the average.
*/
export interface ReviewDistribution {
readonly rating: number;
readonly count: number;
}
export interface ReviewSummary {
readonly average: number;
readonly count: number;
readonly distribution: readonly ReviewDistribution[];
}
/**
* An item a shopper is entitled to review, resolved from one order.
*
* `reviewId` non-null means they already did — the form renders as a thank-you
* rather than disappearing, so the action they took is still visible.
*/
export interface ReviewableItem {
readonly orderLineId: Id;
readonly productId: Id;
readonly productSlug: Slug;
readonly productName: string;
readonly variantTitle: string;
readonly imageUrl: Nullable<string>;
readonly reviewId: Nullable<Id>;
readonly reviewStatus: Nullable<ReviewStatus>;
readonly rating: Nullable<number>;
}
/** A review in the moderation queue, with everything needed to judge it. */
export interface AdminReview {
readonly id: Id;
readonly status: ReviewStatus;
readonly rating: number;
readonly title: Nullable<string>;
readonly body: Nullable<string>;
readonly authorName: string;
readonly productId: Id;
readonly productName: string;
readonly productSlug: Slug;
readonly variantTitle: string;
/** The order it came from — the moderator's link back to the purchase. */
readonly orderNumber: number;
readonly orderId: Id;
readonly moderationNote: Nullable<string>;
readonly moderatedAt: Nullable<string>;
readonly moderatedByName: Nullable<string>;
readonly createdAt: string;
}
+68
View File
@@ -0,0 +1,68 @@
import type { Locale } from '../i18n/locale';
import type { Id, Nullable, Slug } from '../primitives';
export const CONTENT_TYPES = {
PAGE: 'PAGE',
POST: 'POST',
} as const;
export type ContentType = (typeof CONTENT_TYPES)[keyof typeof CONTENT_TYPES];
export const CONTENT_STATUSES = {
DRAFT: 'DRAFT',
PUBLISHED: 'PUBLISHED',
} as const;
export type ContentStatus = (typeof CONTENT_STATUSES)[keyof typeof CONTENT_STATUSES];
/** A post as it appears in the feed — no body, because a list does not need one. */
export interface ContentSummary {
readonly id: Id;
readonly type: ContentType;
readonly slug: Slug;
readonly title: string;
readonly excerpt: Nullable<string>;
readonly coverImageUrl: Nullable<string>;
readonly publishedAt: Nullable<string>;
}
/** A page or post as a reader sees it. */
export interface ContentDetail extends ContentSummary {
/** Markdown. Rendered to React elements, never injected as HTML. */
readonly body: string;
readonly authorName: Nullable<string>;
readonly seo: {
readonly metaTitle: Nullable<string>;
readonly metaDescription: Nullable<string>;
};
/**
* This entry's slug in every locale.
*
* Same reason products carry them: without these the language switcher
* 404s, because `/en/blog/<vi-slug>` is not a page.
*/
readonly alternateSlugs: Readonly<Partial<Record<Locale, Slug>>>;
}
export interface ContentTranslationFields {
readonly slug: string;
readonly title: string;
readonly excerpt: Nullable<string>;
readonly body: string;
readonly metaTitle: Nullable<string>;
readonly metaDescription: Nullable<string>;
}
/** A page or post as an operator edits it — every locale, published or not. */
export interface AdminContentEntry {
readonly id: Id;
readonly type: ContentType;
readonly status: ContentStatus;
readonly publishedAt: Nullable<string>;
readonly coverImageId: Nullable<Id>;
readonly coverImageUrl: Nullable<string>;
readonly authorName: Nullable<string>;
readonly translations: Readonly<Record<string, ContentTranslationFields>>;
readonly createdAt: string;
readonly updatedAt: string;
}
+3
View File
@@ -26,4 +26,7 @@ export * from './catalog/admin';
export * from './i18n/locale';
export * from './inventory/stock';
export * from './commerce/cart';
export * from './commerce/discount';
export * from './commerce/order';
export * from './commerce/review';
export * from './content/entry';
+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';