Stage M5 and Stage M6

This commit is contained in:
Nông Đức Huy
2026-08-13 23:20:23 +07:00
parent 7688657d3c
commit 624a6402bf
77 changed files with 4879 additions and 176 deletions
+10
View File
@@ -3,6 +3,12 @@ import { createAdminResource, type AdminResource } from './resources/admin';
import { createAuthResource, type AuthResource } from './resources/auth';
import { createCatalogResource, type CatalogResource } from './resources/catalog';
import { createCatalogAdminResource, type CatalogAdminResource } from './resources/catalog-admin';
import {
createCommerceResource,
createOrdersAdminResource,
type CommerceResource,
type OrdersAdminResource,
} from './resources/commerce';
import { createHealthResource, type HealthResource } from './resources/health';
/**
@@ -18,6 +24,8 @@ export interface ApiClient {
readonly auth: AuthResource;
readonly admin: AdminResource;
readonly catalogAdmin: CatalogAdminResource;
readonly commerce: CommerceResource;
readonly ordersAdmin: OrdersAdminResource;
}
export function createApiClient(options: HttpClientOptions): ApiClient {
@@ -30,5 +38,7 @@ export function createApiClient(options: HttpClientOptions): ApiClient {
auth: createAuthResource(http),
admin: createAdminResource(http),
catalogAdmin: createCatalogAdminResource(http),
commerce: createCommerceResource(http),
ordersAdmin: createOrdersAdminResource(http),
};
}
+6
View File
@@ -30,4 +30,10 @@ export type {
StockAdjustment,
VariantPatch,
} from './resources/catalog-admin';
export type {
CommerceResource,
OrderListParams,
OrdersAdminResource,
PlaceOrderInput,
} from './resources/commerce';
export type { ApiClient } from './create-client';
@@ -6,6 +6,7 @@ import type {
Locale,
NavigationMenu,
ProductListResult,
SearchSuggestions,
StorefrontProduct,
} from '@sport/types';
@@ -46,6 +47,16 @@ export interface CatalogResource {
query?: ProductListQuery,
options?: RequestOptions,
): Promise<ProductListResult>;
/**
* Ranked search. Same result shape as a listing, so the same components
* render it — only the ordering and the matching differ.
*/
searchProducts(
locale: Locale,
query?: ProductListQuery,
options?: RequestOptions,
): Promise<ProductListResult>;
suggest(locale: Locale, query: string, options?: RequestOptions): Promise<SearchSuggestions>;
getProduct(locale: Locale, slug: string, options?: RequestOptions): Promise<StorefrontProduct>;
listProductSlugs(
locale: Locale,
@@ -79,6 +90,15 @@ export function createCatalogResource(http: HttpClient): CatalogResource {
listProducts: (locale, query = {}, options) =>
http.get<ProductListResult>('/products', withLocale(locale, { ...query }, options)),
searchProducts: (locale, query = {}, options) =>
http.get<ProductListResult>('/search', withLocale(locale, { ...query }, options)),
suggest: (locale, query, options) =>
http.get<SearchSuggestions>(
'/search/suggestions',
withLocale(locale, { q: query }, { ...options, cache: 'no-store' }),
),
getProduct: (locale, slug, options) =>
http.get<StorefrontProduct>(
`/products/${encodeURIComponent(slug)}`,
@@ -0,0 +1,150 @@
import type {
Cart,
Locale,
OffsetPaginated,
Order,
OrderListItem,
ShippingAddress,
} from '@sport/types';
import type { HttpClient, RequestOptions } from '../http-client';
/**
* Mirrors `placeOrderSchema` in @sport/validation.
*
* Declared here rather than imported because this package depends on
* @sport/types alone — pulling in Zod would put a validation runtime into every
* consumer, including a future React Native app. The server re-validates, so
* this type is a convenience for callers and never the enforcement point.
*/
export interface PlaceOrderInput {
email: string;
shippingAddress: ShippingAddress;
customerNote?: string | null;
}
export interface OrderListParams {
page?: number;
perPage?: number;
q?: string;
status?: 'PENDING' | 'CONFIRMED' | 'FULFILLED' | 'COMPLETED' | 'CANCELLED';
}
/**
* Cart, checkout and order access.
*
* Every cart call must send credentials: the bag is identified by an httpOnly
* cookie, so a request without it silently starts a new, empty cart. The
* `credentials` option below is the difference between "add to bag works" and
* "add to bag appears to work".
*/
export interface CommerceResource {
getCart(locale: Locale, options?: RequestOptions): Promise<Cart>;
addCartLine(
locale: Locale,
input: { variantId: string; quantity?: number },
options?: RequestOptions,
): Promise<Cart>;
updateCartLine(
locale: Locale,
variantId: string,
quantity: number,
options?: RequestOptions,
): Promise<Cart>;
removeCartLine(locale: Locale, variantId: string, options?: RequestOptions): Promise<Cart>;
getCheckoutQuote(locale: Locale, options?: RequestOptions): Promise<Cart>;
placeOrder(locale: Locale, input: PlaceOrderInput, options?: RequestOptions): Promise<Order>;
lookupOrder(orderNumber: string, email: string, options?: RequestOptions): Promise<Order>;
getPlacedOrder(id: string, options?: RequestOptions): Promise<Order>;
}
export interface OrdersAdminResource {
listOrders(
params?: OrderListParams,
options?: RequestOptions,
): Promise<OffsetPaginated<OrderListItem>>;
getOrder(id: string, options?: RequestOptions): Promise<Order>;
updateOrderStatus(
id: string,
status: OrderListParams['status'],
reason?: string | null,
options?: RequestOptions,
): Promise<Order>;
}
export function createCommerceResource(http: HttpClient): CommerceResource {
/**
* `credentials: 'include'` on every call, not just the mutations.
*
* The cart cookie is httpOnly and same-origin, and a GET without credentials
* reads a different (empty) bag than the POST that filled it — a failure that
* looks like "the cart randomly empties" rather than a missing option.
*/
const cartOptions = (locale: Locale, options?: RequestOptions): RequestOptions => ({
...options,
credentials: 'include',
query: { locale, ...options?.query },
// A bag is per-visitor state; caching it anywhere would serve someone
// else's.
cache: 'no-store',
});
return {
getCart: (locale, options) => http.get<Cart>('/cart', cartOptions(locale, options)),
addCartLine: (locale, input, options) =>
http.post<Cart>('/cart/lines', input, cartOptions(locale, options)),
updateCartLine: (locale, variantId, quantity, options) =>
http.patch<Cart>(
`/cart/lines/${encodeURIComponent(variantId)}`,
{ quantity },
cartOptions(locale, options),
),
removeCartLine: (locale, variantId, options) =>
http.delete<Cart>(
`/cart/lines/${encodeURIComponent(variantId)}`,
cartOptions(locale, options),
),
getCheckoutQuote: (locale, options) =>
http.get<Cart>('/checkout/quote', cartOptions(locale, options)),
placeOrder: (locale, input, options) =>
http.post<Order>('/checkout/orders', input, cartOptions(locale, options)),
getPlacedOrder: (id, options) =>
http.get<Order>(`/checkout/orders/${encodeURIComponent(id)}`, {
...options,
cache: 'no-store',
}),
lookupOrder: (orderNumber, email, options) =>
http.get<Order>('/checkout/orders/lookup', {
...options,
query: { orderNumber, email },
cache: 'no-store',
}),
};
}
export function createOrdersAdminResource(http: HttpClient): OrdersAdminResource {
return {
listOrders: (params = {}, options) =>
http.get<OffsetPaginated<OrderListItem>>('/admin/orders', {
...options,
query: { ...params, ...options?.query },
}),
getOrder: (id, options) => http.get<Order>(`/admin/orders/${encodeURIComponent(id)}`, options),
updateOrderStatus: (id, status, reason, options) =>
http.patch<Order>(
`/admin/orders/${encodeURIComponent(id)}/status`,
{ status, reason: reason ?? null },
options,
),
};
}
+13
View File
@@ -0,0 +1,13 @@
import type { Slug } from '../primitives';
export interface SearchSuggestion {
/** The product name, as it will be shown in the dropdown. */
readonly text: string;
readonly productSlug: Slug;
}
export interface SearchSuggestions {
/** Echoed back so a late response can be discarded against the current input. */
readonly query: string;
readonly suggestions: readonly SearchSuggestion[];
}
+64
View File
@@ -0,0 +1,64 @@
import type { Id, Money, Nullable } from '../primitives';
/**
* The cart as the storefront sees it.
*
* Every price here is computed by the API from the live catalog on each read —
* the stored cart holds nothing but variant ids and quantities (ADR-0010). That
* is what makes a thirty-day-old cart safe: it cannot carry a stale price into
* an order, and a tampered payload cannot underpay.
*/
export interface Cart {
readonly id: Id;
readonly lines: readonly CartLine[];
readonly totals: CartTotals;
/** Lines dropped since the cart was last seen, so the UI can explain itself. */
readonly notices: readonly CartNotice[];
readonly updatedAt: string;
}
export interface CartLine {
readonly variantId: Id;
readonly productName: string;
readonly productSlug: string;
readonly variantTitle: string;
readonly sku: string;
readonly imageUrl: Nullable<string>;
readonly unitPrice: Money;
/** The pre-markdown price when this line is discounted, for a strikethrough. */
readonly compareAtPrice: Nullable<Money>;
readonly quantity: number;
readonly lineTotal: Money;
/** What the shopper may still raise this line to, given stock. */
readonly maxQuantity: number;
}
export interface CartTotals {
readonly itemCount: number;
readonly subtotal: Money;
readonly discount: Money;
readonly shipping: Money;
readonly tax: Money;
readonly total: Money;
}
export const CART_NOTICE_REASONS = {
/** The variant was archived or the product unpublished since it was added. */
UNAVAILABLE: 'UNAVAILABLE',
/** Quantity was lowered to what is actually in stock. */
QUANTITY_REDUCED: 'QUANTITY_REDUCED',
/** Nothing left; the line was removed entirely. */
OUT_OF_STOCK: 'OUT_OF_STOCK',
/** The price moved while the cart was sitting there. */
PRICE_CHANGED: 'PRICE_CHANGED',
} as const;
export type CartNoticeReason = (typeof CART_NOTICE_REASONS)[keyof typeof CART_NOTICE_REASONS];
export interface CartNotice {
readonly reason: CartNoticeReason;
readonly variantId: Id;
readonly productName: string;
readonly previousQuantity: Nullable<number>;
readonly quantity: Nullable<number>;
}
+101
View File
@@ -0,0 +1,101 @@
import type { CurrencyCode, Id, Money, Nullable } from '../primitives';
export const ORDER_STATUSES = {
PENDING: 'PENDING',
CONFIRMED: 'CONFIRMED',
FULFILLED: 'FULFILLED',
COMPLETED: 'COMPLETED',
CANCELLED: 'CANCELLED',
} as const;
export type OrderStatus = (typeof ORDER_STATUSES)[keyof typeof ORDER_STATUSES];
export const PAYMENT_STATUSES = {
UNPAID: 'UNPAID',
PAID: 'PAID',
PARTIALLY_REFUNDED: 'PARTIALLY_REFUNDED',
REFUNDED: 'REFUNDED',
} as const;
export type PaymentStatus = (typeof PAYMENT_STATUSES)[keyof typeof PAYMENT_STATUSES];
export const FULFILLMENT_STATUSES = {
UNFULFILLED: 'UNFULFILLED',
PARTIALLY_FULFILLED: 'PARTIALLY_FULFILLED',
FULFILLED: 'FULFILLED',
} as const;
export type FulfillmentStatus = (typeof FULFILLMENT_STATUSES)[keyof typeof FULFILLMENT_STATUSES];
export interface ShippingAddress {
readonly fullName: string;
readonly phone: string;
readonly line1: string;
readonly line2: Nullable<string>;
readonly ward: Nullable<string>;
readonly district: Nullable<string>;
readonly province: string;
readonly countryCode: string;
readonly postalCode: Nullable<string>;
}
/**
* A placed order.
*
* Every field is a snapshot taken when the order was placed. Nothing here is
* joined from the catalog at read time, which is why an order stays readable
* after a product is renamed, repriced or archived.
*/
export interface Order {
readonly id: Id;
/** Formatted for display, e.g. "SP-000123". */
readonly orderNumber: string;
readonly status: OrderStatus;
readonly paymentStatus: PaymentStatus;
readonly fulfillmentStatus: FulfillmentStatus;
readonly email: string;
readonly phone: string;
readonly shippingAddress: ShippingAddress;
readonly customerNote: Nullable<string>;
readonly currency: CurrencyCode;
readonly subtotal: Money;
readonly discount: Money;
readonly shipping: Money;
readonly tax: Money;
readonly total: Money;
readonly lines: readonly OrderLine[];
readonly placedAt: string;
readonly confirmedAt: Nullable<string>;
readonly cancelledAt: Nullable<string>;
readonly cancelReason: Nullable<string>;
}
export interface OrderLine {
readonly id: Id;
readonly variantId: Nullable<Id>;
readonly productName: string;
readonly variantTitle: string;
readonly sku: string;
readonly imageUrl: Nullable<string>;
readonly unitPrice: Money;
readonly quantity: number;
readonly lineTotal: Money;
}
/** Row shape for the admin order table. */
export interface OrderListItem {
readonly id: Id;
readonly orderNumber: string;
readonly status: OrderStatus;
readonly paymentStatus: PaymentStatus;
readonly fulfillmentStatus: FulfillmentStatus;
readonly email: string;
readonly customerName: string;
readonly itemCount: number;
readonly total: Money;
readonly placedAt: string;
}
+3
View File
@@ -21,6 +21,9 @@ export * from './catalog/taxonomy';
export * from './catalog/media';
export * from './catalog/navigation';
export * from './catalog/facets';
export * from './catalog/search';
export * from './catalog/admin';
export * from './i18n/locale';
export * from './inventory/stock';
export * from './commerce/cart';
export * from './commerce/order';
+67
View File
@@ -0,0 +1,67 @@
import { z } from 'zod';
import { anyIdSchema } from './common';
import { offsetPageQuerySchema } from './pagination';
/**
* Cart and checkout input.
*
* A cart line carries a variant and a quantity and nothing else. There is
* deliberately no price field anywhere in this file: prices are the API's to
* compute, and accepting one from the client would make every total a
* negotiation.
*/
/** One line cannot exceed this, regardless of stock — it is a shop, not a wholesaler. */
export const MAX_LINE_QUANTITY = 20;
export const addCartLineSchema = z.object({
variantId: anyIdSchema,
quantity: z.coerce.number().int().min(1).max(MAX_LINE_QUANTITY).default(1),
});
export const updateCartLineSchema = z.object({
/** Zero removes the line, which is what a quantity stepper reaching 0 means. */
quantity: z.coerce.number().int().min(0).max(MAX_LINE_QUANTITY),
});
/** Vietnamese addresses: ward and district are optional because rural
* addresses frequently have neither, and rejecting those loses real orders. */
export const shippingAddressSchema = z.object({
fullName: z.string().trim().min(1).max(160),
phone: z
.string()
.trim()
.regex(/^(?:\+84|0)\d{8,10}$/, 'Enter a Vietnamese phone number'),
line1: z.string().trim().min(1).max(255),
line2: z.string().trim().max(255).nullish(),
ward: z.string().trim().max(120).nullish(),
district: z.string().trim().max(120).nullish(),
province: z.string().trim().min(1).max(120),
countryCode: z.string().trim().length(2).default('VN'),
postalCode: z.string().trim().max(20).nullish(),
});
export const placeOrderSchema = z.object({
email: z.string().trim().toLowerCase().email().max(255),
shippingAddress: shippingAddressSchema,
customerNote: z.string().trim().max(1000).nullish(),
});
export const orderListQuerySchema = offsetPageQuerySchema.extend({
q: z.string().trim().max(120).optional(),
status: z.enum(['PENDING', 'CONFIRMED', 'FULFILLED', 'COMPLETED', 'CANCELLED']).optional(),
});
export const updateOrderStatusSchema = z.object({
status: z.enum(['PENDING', 'CONFIRMED', 'FULFILLED', 'COMPLETED', 'CANCELLED']),
/** Required when cancelling: "why" is the whole value of the audit entry. */
reason: z.string().trim().max(500).nullish(),
});
export type AddCartLineInput = z.output<typeof addCartLineSchema>;
export type UpdateCartLineInput = z.output<typeof updateCartLineSchema>;
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>;
+1
View File
@@ -17,3 +17,4 @@ export * from './auth';
export * from './catalog';
export * from './users';
export * from './catalog-admin';
export * from './commerce';