Files
vmall/packages/shared/src/api.ts
T
james d0b6350d2d feat(mall): read the store directory from the API
Wave 5: the store directory, store home and product-page store card stop reading
MOCK_STORES, and the payment and order surfaces name their shop.

- a `shop_profiles` table beside `shops`, so the identity model both consoles
  consume is untouched, with a public `GET /api/shops` and `GET /api/shops/{slug}`
  and an admin `PUT /api/admin/shops/{id}/profile`
- a shop with no profile is still listed, with the fields absent rather than
  invented; the pages guard every block, and a missing logo renders an
  initial-letter placeholder
- `scripts/seed-demo.mjs` upserts a profile per demo shop, since profiles hang
  off shops that script creates
- payment and order pages resolve shop ids to names from one cached shop read,
  retiring the generic "Shop" label
- three things went rather than being faked, following the wave-1 precedent:
  `distanceKm` and its sort (no geo model), the store home's sales/comments
  sorts, and its "best sellers" rail (no sales model)
- `lowestSku` moved out of the fixed-data module into `apps/mall/utils/product.ts`
  and re-exported, so live pages stop importing the mock module for a pure
  helper

Verified: 28 backend tests green including five new shop tests; all three
frontends build; the directory, store home, store card and order cards all render
real data with no distance or sales claims; the fixed-data rollback still renders
the store surfaces with the backend stopped.

Note: `nuxt build` does not typecheck in this repo (no `typescript.typeCheck`,
no `vue-tsc`), which AGENTS.md implies it does. A re-export used here created no
local binding and broke internal callers at runtime while the build stayed green;
`docs/TBD-migrate-wave.md` records the gap.

OpenSpec change: openspec/changes/replace-mock-api-wave-5
2026-09-17 17:13:32 +00:00

288 lines
10 KiB
TypeScript

import type {
Address,
AuthTokens,
Cart,
Category,
ContentInputByKind,
ContentKind,
Currency,
HomeContent,
Invoice,
InvoiceKind,
LocalizedText,
Order,
OrderStatus,
Paged,
Product,
ProductStatus,
Shipment,
Shop,
ShopProfile,
ShopProfileInput,
Sku,
User,
} from "./types";
export interface ApiClientOptions {
baseUrl: string;
getToken?: () => string | null;
}
export class ApiError extends Error {
code: string;
status: number;
constructor(status: number, code: string, message: string) {
super(message);
this.status = status;
this.code = code;
}
}
// ---- request bodies ----
export interface ProductListQuery {
page?: number;
per_page?: number;
category_id?: string;
q?: string;
shop_id?: string;
/** `price` orders by each product's lowest active SKU price. */
sort?: "price";
order?: "asc" | "desc";
}
export interface ProductUpsertBody {
category_id?: string | null;
slug: string;
name: LocalizedText;
description?: LocalizedText;
images?: string[];
}
export interface SkuUpsertBody {
sku_code: string;
attributes?: Record<string, string>;
price_minor: number;
currency: string;
stock: number;
active?: boolean;
}
export interface ShipmentItemBody {
order_item_id: string;
qty: number;
}
export interface CurrencyUpsertBody {
code: string;
name: LocalizedText;
symbol: string;
exponent: number;
rate_to_base: string;
enabled: boolean;
}
export interface ShopProductQuery {
page?: number;
per_page?: number;
status?: ProductStatus;
}
export interface ShopOrderQuery {
page?: number;
per_page?: number;
status?: OrderStatus;
}
export interface ConvertResult {
amount_minor: number;
currency: string;
}
type Query = Record<string, string | number | boolean | undefined>;
async function request<T>(
opts: ApiClientOptions,
method: string,
path: string,
body?: unknown,
query?: Query,
): Promise<T> {
const url = new URL(opts.baseUrl + path);
if (query) {
for (const [k, v] of Object.entries(query)) {
if (v !== undefined && v !== null && v !== "") url.searchParams.set(k, String(v));
}
}
const headers: Record<string, string> = { "content-type": "application/json" };
const token = opts.getToken?.();
if (token) headers.authorization = `Bearer ${token}`;
const res = await fetch(url.toString(), {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) {
let code = "UNKNOWN";
let message = res.statusText;
try {
const data = (await res.json()) as { error?: { code?: string; message?: string } };
code = data?.error?.code ?? code;
message = data?.error?.message ?? message;
} catch {
/* non-JSON error body */
}
throw new ApiError(res.status, code, message);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export interface ApiClient {
register(email: string, password: string, displayName: string): Promise<AuthTokens>;
login(email: string, password: string): Promise<AuthTokens>;
me(): Promise<User>;
listProducts(q?: ProductListQuery): Promise<Paged<Product>>;
getProduct(idOrSlug: string): Promise<Product>;
listCategories(): Promise<Category[]>;
listCurrencies(): Promise<Currency[]>;
convert(amountMinor: number, from: string, to: string): Promise<ConvertResult>;
getCart(): Promise<Cart>;
addCartItem(skuId: string, qty: number): Promise<Cart>;
updateCartItem(skuId: string, qty: number): Promise<Cart>;
removeCartItem(skuId: string): Promise<Cart>;
checkout(shippingAddress: Address, currency: string): Promise<Order[]>;
listMyOrders(page?: number): Promise<Paged<Order>>;
getOrder(id: string): Promise<Order>;
cancelOrder(id: string): Promise<Order>;
payOrder(id: string): Promise<Order>;
confirmDelivered(shipmentId: string): Promise<Shipment>;
listMyShipments(): Promise<Shipment[]>;
requestInvoice(
orderId: string,
title: string,
taxNo: string | null,
kind: InvoiceKind,
): Promise<Invoice>;
listMyInvoices(): Promise<Invoice[]>;
/** Public home-page marketing content: banners, promos, quick links, floor adverts. */
getHomeContent(): Promise<HomeContent>;
/** Public store directory: active shops with whatever profile they have. */
listShops(): Promise<ShopProfile[]>;
getShop(slug: string): Promise<ShopProfile>;
shop: {
getMyShop(): Promise<Shop>;
listMyProducts(q?: ShopProductQuery): Promise<Paged<Product>>;
getProduct(id: string): Promise<Product>;
createProduct(body: ProductUpsertBody): Promise<Product>;
updateProduct(id: string, body: ProductUpsertBody): Promise<Product>;
publish(id: string): Promise<Product>;
unpublish(id: string): Promise<Product>;
upsertSku(productId: string, body: SkuUpsertBody): Promise<Sku>;
listOrders(q?: ShopOrderQuery): Promise<Paged<Order>>;
getOrder(id: string): Promise<Order>;
createShipment(
orderId: string,
carrier: string,
trackingNo: string,
items: ShipmentItemBody[],
): Promise<Shipment>;
listShipments(): Promise<Shipment[]>;
markShipped(id: string): Promise<Shipment>;
listInvoices(): Promise<Invoice[]>;
issueInvoice(id: string): Promise<Invoice>;
};
admin: {
listUsers(page?: number): Promise<Paged<User>>;
setUserRole(id: string, role: string, shopId: string | null): Promise<User>;
listShops(): Promise<Shop[]>;
createShop(name: LocalizedText, slug: string): Promise<Shop>;
setShopStatus(id: string, status: "active" | "suspended"): Promise<Shop>;
listOrders(page?: number): Promise<Paged<Order>>;
listCurrencies(): Promise<Currency[]>;
upsertCurrency(body: CurrencyUpsertBody): Promise<Currency>;
setRate(code: string, rateToBase: string): Promise<Currency>;
/** Every content kind, inactive rows included. */
getContent(): Promise<HomeContent>;
/** Replaces one kind from an ordered list; positions follow the array order. */
replaceContent<K extends ContentKind>(
kind: K,
items: ContentInputByKind[K],
): Promise<HomeContent>;
};
}
export function createApi(opts: ApiClientOptions): ApiClient {
const r = <T>(m: string, p: string, b?: unknown, q?: Query): Promise<T> =>
request<T>(opts, m, p, b, q);
return {
register: (email, password, displayName) =>
r("POST", "/auth/register", { email, password, display_name: displayName }),
login: (email, password) => r("POST", "/auth/login", { email, password }),
me: () => r("GET", "/auth/me"),
listProducts: (q = {}) => r("GET", "/products", undefined, { ...q }),
getProduct: (idOrSlug) => r("GET", `/products/${idOrSlug}`),
listCategories: () => r("GET", "/categories"),
listCurrencies: () => r("GET", "/currencies"),
convert: (amountMinor, from, to) =>
r("GET", "/currencies/convert", undefined, { amount_minor: amountMinor, from, to }),
getCart: () => r("GET", "/cart"),
addCartItem: (skuId, qty) => r("POST", "/cart/items", { sku_id: skuId, qty }),
updateCartItem: (skuId, qty) => r("PUT", `/cart/items/${skuId}`, { qty }),
removeCartItem: (skuId) => r("DELETE", `/cart/items/${skuId}`),
checkout: (shippingAddress, currency) =>
r("POST", "/orders/checkout", { shipping_address: shippingAddress, currency }),
listMyOrders: (page = 1) => r("GET", "/orders", undefined, { page }),
getOrder: (id) => r("GET", `/orders/${id}`),
cancelOrder: (id) => r("POST", `/orders/${id}/cancel`),
payOrder: (id) => r("POST", `/orders/${id}/pay`),
confirmDelivered: (shipmentId) => r("POST", `/shipments/${shipmentId}/confirm-delivered`),
listMyShipments: () => r("GET", "/shipments"),
requestInvoice: (orderId, title, taxNo, kind) =>
r("POST", `/orders/${orderId}/invoice`, { title, tax_no: taxNo, kind }),
listMyInvoices: () => r("GET", "/invoices"),
getHomeContent: () => r("GET", "/content/home"),
listShops: () => r("GET", "/shops"),
getShop: (slug) => r("GET", `/shops/${slug}`),
shop: {
getMyShop: () => r("GET", "/shop/profile"),
listMyProducts: (q = {}) => r("GET", "/shop/products", undefined, { ...q }),
getProduct: (id) => r("GET", `/shop/products/${id}`),
createProduct: (body) => r("POST", "/shop/products", body),
updateProduct: (id, body) => r("PUT", `/shop/products/${id}`, body),
publish: (id) => r("POST", `/shop/products/${id}/publish`),
unpublish: (id) => r("POST", `/shop/products/${id}/unpublish`),
upsertSku: (productId, body) => r("POST", `/shop/products/${productId}/skus`, body),
listOrders: (q = {}) => r("GET", "/shop/orders", undefined, { ...q }),
getOrder: (id) => r("GET", `/shop/orders/${id}`),
createShipment: (orderId, carrier, trackingNo, items) =>
r("POST", `/shop/orders/${orderId}/shipments`, {
carrier,
tracking_no: trackingNo,
items,
}),
listShipments: () => r("GET", "/shop/shipments"),
markShipped: (id) => r("POST", `/shop/shipments/${id}/ship`),
listInvoices: () => r("GET", "/shop/invoices"),
issueInvoice: (id) => r("POST", `/shop/invoices/${id}/issue`),
},
admin: {
listUsers: (page = 1) => r("GET", "/admin/users", undefined, { page }),
setUserRole: (id, role, shopId) =>
r("PUT", `/admin/users/${id}/role`, { role, shop_id: shopId }),
listShops: () => r("GET", "/admin/shops"),
createShop: (name, slug) => r("POST", "/admin/shops", { name, slug }),
setShopStatus: (id, status) => r("PUT", `/admin/shops/${id}/status`, { status }),
listOrders: (page = 1) => r("GET", "/admin/orders", undefined, { page }),
listCurrencies: () => r("GET", "/admin/currencies"),
upsertCurrency: (body) => r("POST", "/admin/currencies", body),
setRate: (code, rateToBase) => r("PUT", `/admin/currencies/${code}/rate`, {
rate_to_base: rateToBase,
}),
getContent: () => r("GET", "/admin/content"),
replaceContent: (kind, items) => r("PUT", `/admin/content/${kind}`, items),
setShopProfile: (id, body) => r("PUT", `/admin/shops/${id}/profile`, body),
},
};
}