Stage M5 and Stage M6
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
@@ -17,3 +17,4 @@ export * from './auth';
|
||||
export * from './catalog';
|
||||
export * from './users';
|
||||
export * from './catalog-admin';
|
||||
export * from './commerce';
|
||||
|
||||
Reference in New Issue
Block a user