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,
),
};
}