Stage M7
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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