Templates belong to a shop; claiming copies their terms into a customer-owned snapshot so a later edit or disable cannot rewrite a held coupon. Claim stock is taken with a guarded decrement after locking the template, and a unique (user, template) index makes a duplicate claim a 409. Deleting a template leaves claimed snapshots standing via ON DELETE SET NULL. Checkout accepts at most one owned coupon per generated shop order, locks the selected coupons by primary key after the SKU locks, and resolves eligibility and the discount server-side (ownership, shop, status, window, converted threshold). The realized discount and coupon id land on the order, and a pending-payment cancellation restores the coupon in the same transaction as stock. The shared contract gains the coupon types, claim/list/manage methods, and the checkout coupon map; the fixed-data adapter implements the same surface. Surfaces (shop-admin management, mall coupon pages, checkout selection) and seeding still follow in tasks 3.1-4.2.
343 lines
12 KiB
TypeScript
343 lines
12 KiB
TypeScript
import type {
|
|
AccountSummary,
|
|
Address,
|
|
AddressBookEntry,
|
|
AuthTokens,
|
|
Brand,
|
|
BrandInput,
|
|
Cart,
|
|
Category,
|
|
ContentInputByKind,
|
|
ContentKind,
|
|
Coupon,
|
|
CouponTemplate,
|
|
CouponTemplateInput,
|
|
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;
|
|
brand_id?: string;
|
|
q?: string;
|
|
shop_id?: string;
|
|
/** `price` orders by lowest active SKU price; `sales` by units sold. */
|
|
sort?: "price" | "sales";
|
|
order?: "asc" | "desc";
|
|
}
|
|
|
|
export interface ProductUpsertBody {
|
|
category_id?: string | null;
|
|
brand_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 AddressInput extends Address {
|
|
is_default?: boolean;
|
|
}
|
|
|
|
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>;
|
|
/** The signed-in customer's own money and points balances. */
|
|
getAccountSummary(): Promise<AccountSummary>;
|
|
listProducts(q?: ProductListQuery): Promise<Paged<Product>>;
|
|
getProduct(idOrSlug: string): Promise<Product>;
|
|
listCategories(): Promise<Category[]>;
|
|
listBrands(): Promise<Brand[]>;
|
|
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,
|
|
/** At most one owned coupon per generated shop order. */
|
|
couponByShop?: Record<string, 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>;
|
|
listMyAddresses(): Promise<AddressBookEntry[]>;
|
|
createAddress(address: AddressInput): Promise<AddressBookEntry>;
|
|
updateAddress(id: string, address: AddressInput): Promise<AddressBookEntry>;
|
|
deleteAddress(id: string): Promise<AddressBookEntry[]>;
|
|
setDefaultAddress(id: string): Promise<AddressBookEntry>;
|
|
/** Public: coupons a shopper could claim from this shop right now. */
|
|
listShopCouponTemplates(shopId: string): Promise<CouponTemplate[]>;
|
|
listMyCoupons(): Promise<Coupon[]>;
|
|
claimCoupon(templateId: string): Promise<Coupon>;
|
|
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>;
|
|
listCouponTemplates(): Promise<CouponTemplate[]>;
|
|
createCouponTemplate(body: CouponTemplateInput): Promise<CouponTemplate>;
|
|
updateCouponTemplate(id: string, body: CouponTemplateInput): Promise<CouponTemplate>;
|
|
deleteCouponTemplate(id: string): Promise<void>;
|
|
};
|
|
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"),
|
|
getAccountSummary: () => r("GET", "/me/stats"),
|
|
listProducts: (q = {}) => r("GET", "/products", undefined, { ...q }),
|
|
getProduct: (idOrSlug) => r("GET", `/products/${idOrSlug}`),
|
|
listCategories: () => r("GET", "/categories"),
|
|
listBrands: () => r("GET", "/brands"),
|
|
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, couponByShop = {}) =>
|
|
r("POST", "/orders/checkout", {
|
|
shipping_address: shippingAddress,
|
|
currency,
|
|
coupon_by_shop: couponByShop,
|
|
}),
|
|
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}`),
|
|
listMyAddresses: () => r("GET", "/addresses"),
|
|
createAddress: (address) => r("POST", "/addresses", address),
|
|
updateAddress: (id, address) => r("PUT", `/addresses/${id}`, address),
|
|
deleteAddress: (id) => r("DELETE", `/addresses/${id}`),
|
|
setDefaultAddress: (id) => r("POST", `/addresses/${id}/default`),
|
|
listShopCouponTemplates: (shopId) => r("GET", `/shops/${shopId}/coupon-templates`),
|
|
listMyCoupons: () => r("GET", "/me/coupons"),
|
|
claimCoupon: (templateId) => r("POST", "/me/coupons", { template_id: templateId }),
|
|
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`),
|
|
listCouponTemplates: () => r("GET", "/shop/coupon-templates"),
|
|
createCouponTemplate: (body) => r("POST", "/shop/coupon-templates", body),
|
|
updateCouponTemplate: (id, body) => r("PUT", `/shop/coupon-templates/${id}`, body),
|
|
deleteCouponTemplate: (id) => r("DELETE", `/shop/coupon-templates/${id}`),
|
|
},
|
|
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),
|
|
/** Replaces the whole ordered brand list. */
|
|
getBrands: () => r("GET", "/brands"),
|
|
replaceBrands: (items) => r("PUT", "/admin/brands", items),
|
|
},
|
|
};
|
|
}
|