Stage M7
This commit is contained in:
@@ -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>;
|
||||
|
||||
@@ -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>;
|
||||
@@ -18,3 +18,4 @@ export * from './catalog';
|
||||
export * from './users';
|
||||
export * from './catalog-admin';
|
||||
export * from './commerce';
|
||||
export * from './content';
|
||||
|
||||
Reference in New Issue
Block a user