Files
vmall/apps/mall/mock/api.ts
T
james 23955434c6 feat(api): shop coupons with per-shop checkout redemption
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.
2026-09-18 12:03:00 +00:00

631 lines
21 KiB
TypeScript

// Mock API adapter: implements the full @vmall/shared ApiClient surface with
// fixed data + in-memory cart/order state. Pages call $api exactly as they
// would against the live backend, so switching back is a config flip.
import { ApiError } from "@vmall/shared";
import type {
AccountSummary,
Address,
AddressBookEntry,
AddressInput,
ApiClient,
AuthTokens,
Cart,
CartItem,
Coupon,
CouponTemplate,
CouponTemplateInput,
HomeContent,
Invoice,
InvoiceKind,
Order,
Product,
Shipment,
ShopProfile,
User,
} from "@vmall/shared";
import {
BASE_CURRENCY,
MOCK_BANNERS,
MOCK_BRANDS,
MOCK_CATEGORIES,
MOCK_COUPONS,
MOCK_CURRENCIES,
MOCK_PROMOS,
MOCK_QUICK_LINKS,
MOCK_STORES,
MOCK_USER,
USER_STATS,
MOCK_ADDRESSES,
defaultAddress,
mockConvertMinor,
productById,
searchMockProducts,
seedOrders,
storeById,
} from "./data";
interface MockState {
token: string | null;
cart: CartItem[];
orders: Order[];
shipments: Shipment[];
invoices: Invoice[];
addresses: AddressBookEntry[];
/** In-memory only: claims made during this browser session. */
coupons: Coupon[];
addressSeq: number;
orderSeq: number;
invoiceSeq: number;
}
// v3: address book joined the persisted state.
const STORAGE_KEY = "vmall.mock.state.v3";
type PersistedState = Pick<MockState, "cart" | "orders" | "shipments" | "invoices" | "addresses" | "orderSeq" | "invoiceSeq" | "addressSeq">;
// Load cart/order session state persisted by a previous page load (client only).
function loadPersisted(): PersistedState | null {
if (!import.meta.client) return null;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
if (typeof parsed !== "object" || parsed === null) return null;
const p = parsed as Partial<PersistedState>;
if (!Array.isArray(p.cart) || !Array.isArray(p.orders)) return null;
if (!Array.isArray(p.shipments) || !Array.isArray(p.invoices)) return null;
if (typeof p.orderSeq !== "number" || typeof p.invoiceSeq !== "number") return null;
if (!Array.isArray(p.addresses) || typeof p.addressSeq !== "number") return null;
return p as PersistedState;
} catch {
return null;
}
}
/** Mock coupons are global fixtures, so any shop id renders them as its own. */
function mockTemplate(t: (typeof MOCK_COUPONS)[number], shopId: string): CouponTemplate {
return {
id: t.id,
shop_id: shopId,
title: t.title,
amount_minor: t.amountMinor,
threshold_minor: t.thresholdMinor,
currency: t.currency,
stock: 100,
enabled: true,
starts_at: "2026-01-01T00:00:00.000Z",
ends_at: `${t.expiresAt}T23:59:59.000Z`,
created_at: "2026-01-01T00:00:00.000Z",
};
}
function mockOwnedCoupon(t: (typeof MOCK_COUPONS)[number]): Coupon {
const template = mockTemplate(t, MOCK_STORES[0].id);
return {
id: `uc-${t.id}`,
user_id: MOCK_USER.id,
template_id: template.id,
shop_id: template.shop_id,
title: template.title,
amount_minor: template.amount_minor,
threshold_minor: template.threshold_minor,
currency: template.currency,
starts_at: template.starts_at,
ends_at: template.ends_at,
status: "claimed",
order_id: null,
claimed_at: "2026-01-01T00:00:00.000Z",
redeemed_at: null,
};
}
function seedCoupons(): Coupon[] {
return MOCK_COUPONS.map(mockOwnedCoupon);
}
function initialState(): MockState {
const persisted = loadPersisted();
// Coupons are session-only, so a restored snapshot re-seeds them.
if (persisted) return { token: null, ...persisted, coupons: seedCoupons() };
const seed = seedOrders(MOCK_USER.id);
const seededAddresses: AddressBookEntry[] = MOCK_ADDRESSES.map((a, i) => ({
id: a.id,
user_id: MOCK_USER.id,
recipient: a.recipient,
phone: a.phone,
country: "US",
region: a.region,
city: a.city,
line1: a.line1,
postal_code: a.postalCode,
is_default: a.isDefault,
created_at: `2026-08-0${i + 1}T09:00:00.000Z`,
}));
return {
token: null,
cart: [],
orders: seed.orders,
shipments: seed.shipments,
invoices: seed.invoices,
addresses: seededAddresses,
coupons: seedCoupons(),
addressSeq: 100,
orderSeq: 100,
invoiceSeq: 100,
};
}
function unsupported(): never {
throw new ApiError(501, "MOCK_UNSUPPORTED", "This admin/shop endpoint is not part of the mall mock.");
}
type MockStoreRecord = NonNullable<ReturnType<typeof storeById>>;
/** Mock stores carry a nested `rate`; the API exposes flat score fields. */
function toShopProfile(store: MockStoreRecord): ShopProfile {
return {
id: store.id,
slug: store.slug,
name: store.name,
company: store.company,
region: store.region,
address: store.address,
logo: store.logo,
banner: store.banner,
notice: store.notice,
after_sale: store.afterSale,
score_rating: store.rate.score,
score_agreement: store.rate.agree,
score_service: store.rate.service,
score_speed: store.rate.speed,
};
}
function skuIndex(): Record<string, { product: Product; skuId: string }> {
const index: Record<string, { product: Product; skuId: string }> = {};
for (const p of searchMockProducts({ perPage: 1000 }).items) {
for (const s of p.skus ?? []) index[s.id] = { product: p, skuId: s.id };
}
return index;
}
export function createMockApi(): ApiClient {
const state = initialState();
function persist(): void {
if (!import.meta.client) return;
try {
const snapshot: PersistedState = {
cart: state.cart,
orders: state.orders,
shipments: state.shipments,
invoices: state.invoices,
orderSeq: state.orderSeq,
invoiceSeq: state.invoiceSeq,
addresses: state.addresses,
addressSeq: state.addressSeq,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
} catch {
/* storage unavailable: keep in-memory only */
}
}
function tokens(): AuthTokens {
state.token = `mock-token-${Date.now()}`;
return { token: state.token, user: MOCK_USER };
}
function cartSnapshot(): Cart {
return { items: state.cart.map((i) => ({ ...i })) };
}
function nextOrderNo(): string {
state.orderSeq += 1;
return `VM20260917${String(state.orderSeq).padStart(3, "0")}`;
}
return {
register: () => Promise.resolve(tokens()),
login: () => Promise.resolve(tokens()),
me: (): Promise<User> => Promise.resolve(MOCK_USER),
getAccountSummary: (): Promise<AccountSummary> =>
Promise.resolve({
balance_minor: USER_STATS.balanceMinor,
frozen_minor: USER_STATS.frozenMinor,
currency: BASE_CURRENCY,
points: USER_STATS.points,
}),
listProducts: (q = {}) =>
Promise.resolve(
searchMockProducts({
q: q.q,
categoryId: q.category_id,
brandId: q.brand_id,
shopId: q.shop_id,
sort: q.sort,
order: q.order,
page: q.page,
perPage: q.per_page,
}),
),
getProduct: (idOrSlug) => {
const product = productById(idOrSlug);
if (!product) return Promise.reject(new ApiError(404, "NOT_FOUND", "Product not found"));
return Promise.resolve(product);
},
listCategories: () => Promise.resolve(MOCK_CATEGORIES),
listBrands: () => Promise.resolve(MOCK_BRANDS),
listCurrencies: () => Promise.resolve(MOCK_CURRENCIES),
convert: (amountMinor, from, to) =>
Promise.resolve({ amount_minor: mockConvertMinor(amountMinor, from, to), currency: to }),
getCart: () => Promise.resolve(cartSnapshot()),
addCartItem: (skuId, qty) => {
const entry = skuIndex()[skuId];
if (!entry) return Promise.reject(new ApiError(404, "NOT_FOUND", "SKU not found"));
const sku = entry.product.skus?.find((s) => s.id === skuId);
if (!sku || !sku.active) return Promise.reject(new ApiError(400, "SKU_INACTIVE", "SKU is not available"));
const existing = state.cart.find((i) => i.sku_id === skuId);
const nextQty = (existing?.qty ?? 0) + qty;
if (nextQty > sku.stock) return Promise.reject(new ApiError(400, "OUT_OF_STOCK", "Not enough stock"));
if (existing) existing.qty = nextQty;
else {
state.cart.push({
sku_id: skuId,
product_id: entry.product.id,
product_name: entry.product.name,
sku_code: sku.sku_code,
image: entry.product.images[0] ?? null,
unit_price_minor: sku.price_minor,
currency: sku.currency,
qty,
// The live cart view carries these too; see replace-mock-api-wave-3.
shop_id: entry.product.shop_id,
shop_name: storeById(entry.product.shop_id)?.name ?? MOCK_STORES[0].name,
stock: sku.stock,
});
}
persist();
return Promise.resolve(cartSnapshot());
},
updateCartItem: (skuId, qty) => {
const item = state.cart.find((i) => i.sku_id === skuId);
if (!item) return Promise.reject(new ApiError(404, "NOT_FOUND", "Cart item not found"));
if (qty <= 0) state.cart = state.cart.filter((i) => i.sku_id !== skuId);
else item.qty = qty;
persist();
return Promise.resolve(cartSnapshot());
},
removeCartItem: (skuId) => {
state.cart = state.cart.filter((i) => i.sku_id !== skuId);
persist();
return Promise.resolve(cartSnapshot());
},
checkout: (
shippingAddress: Address,
currency: string,
couponByShop: Record<string, string> = {},
) => {
if (state.cart.length === 0) {
return Promise.reject(new ApiError(400, "EMPTY_CART", "Cart is empty"));
}
const byShop: Record<string, CartItem[]> = {};
for (const item of state.cart) {
const product = productById(item.product_id);
const shopId = product?.shop_id ?? "unknown";
(byShop[shopId] ??= []).push(item);
}
const created: Order[] = [];
for (const [shopId, items] of Object.entries(byShop)) {
const subtotal = items.reduce((sum, i) => sum + i.unit_price_minor * i.qty, 0);
const couponId = couponByShop[shopId];
let discount = 0;
if (couponId) {
const coupon = state.coupons.find((c) => c.id === couponId);
if (!coupon || coupon.status !== "claimed" || coupon.shop_id !== shopId) {
return Promise.reject(new ApiError(409, "CONFLICT", "Coupon is not redeemable"));
}
if (subtotal < coupon.threshold_minor) {
return Promise.reject(
new ApiError(409, "CONFLICT", "Order does not reach the coupon threshold"),
);
}
discount = Math.min(coupon.amount_minor, subtotal);
}
const order: Order = {
id: `o-${Date.now()}-${shopId}`,
order_no: nextOrderNo(),
shop_id: shopId,
user_id: MOCK_USER.id,
status: "pending_payment",
currency,
total_minor: subtotal - discount,
discount_minor: discount,
coupon_id: couponId ?? null,
items: items.map((i, k) => ({
id: `oi-${Date.now()}-${k}`,
sku_id: i.sku_id,
product_name: i.product_name,
sku_code: i.sku_code,
unit_price_minor: i.unit_price_minor,
qty: i.qty,
image: i.image,
})),
shipping_address: shippingAddress,
created_at: new Date().toISOString(),
};
if (couponId) {
const coupon = state.coupons.find((c) => c.id === couponId);
if (coupon) {
coupon.status = "redeemed";
coupon.order_id = order.id;
coupon.redeemed_at = order.created_at;
}
}
created.push(order);
}
state.orders = [...created, ...state.orders];
state.cart = [];
persist();
return Promise.resolve(created);
},
listMyOrders: (page = 1) => {
const perPage = 10;
const start = (page - 1) * perPage;
return Promise.resolve({
items: state.orders.slice(start, start + perPage),
total: state.orders.length,
page,
per_page: perPage,
});
},
getOrder: (id) => {
const order = state.orders.find((o) => o.id === id || o.order_no === id);
if (!order) return Promise.reject(new ApiError(404, "NOT_FOUND", "Order not found"));
return Promise.resolve(order);
},
cancelOrder: (id) => {
const order = state.orders.find((o) => o.id === id);
if (!order) return Promise.reject(new ApiError(404, "NOT_FOUND", "Order not found"));
if (order.status !== "pending_payment") {
return Promise.reject(new ApiError(409, "INVALID_STATE", "Only unpaid orders can be cancelled"));
}
order.status = "cancelled";
const coupon = state.coupons.find((c) => c.order_id === order.id);
if (coupon) {
coupon.status = "claimed";
coupon.order_id = null;
coupon.redeemed_at = null;
}
persist();
return Promise.resolve(order);
},
payOrder: (id) => {
const order = state.orders.find((o) => o.id === id);
if (!order) return Promise.reject(new ApiError(404, "NOT_FOUND", "Order not found"));
if (order.status !== "pending_payment") {
return Promise.reject(new ApiError(409, "INVALID_STATE", "Only unpaid orders can be paid"));
}
order.status = "paid";
persist();
return Promise.resolve(order);
},
confirmDelivered: (shipmentId) => {
const shipment = state.shipments.find((s) => s.id === shipmentId);
if (!shipment) return Promise.reject(new ApiError(404, "NOT_FOUND", "Shipment not found"));
shipment.status = "delivered";
shipment.delivered_at = new Date().toISOString();
const order = state.orders.find((o) => o.id === shipment.order_id);
if (order) order.status = "completed";
persist();
return Promise.resolve(shipment);
},
listMyShipments: () => Promise.resolve(state.shipments.map((s) => ({ ...s }))),
requestInvoice: (orderId: string, title: string, taxNo: string | null, kind: InvoiceKind) => {
const order = state.orders.find((o) => o.id === orderId);
if (!order) return Promise.reject(new ApiError(404, "NOT_FOUND", "Order not found"));
state.invoiceSeq += 1;
const invoice: Invoice = {
id: `inv-${Date.now()}`,
invoice_no: `INV20260917${String(state.invoiceSeq).padStart(3, "0")}`,
order_id: order.id,
order_no: order.order_no,
title,
tax_no: taxNo,
kind,
amount_minor: order.total_minor,
currency: order.currency,
status: "requested",
issued_at: null,
created_at: new Date().toISOString(),
};
state.invoices = [invoice, ...state.invoices];
persist();
return Promise.resolve(invoice);
},
listMyInvoices: () => Promise.resolve(state.invoices.map((i) => ({ ...i }))),
// Mirror of the seeded storefront-content rows, so the home page renders
// identically when every domain is configured to fixed data.
getHomeContent: (): Promise<HomeContent> =>
Promise.resolve({
banners: MOCK_BANNERS.map((b, i) => ({
id: `bn${i + 1}`,
image: b.image,
url: b.url,
position: i,
active: true,
})),
promos: MOCK_PROMOS.map((p, i) => ({
id: `pr${i + 1}`,
image: p.image,
url: p.url,
position: i,
active: true,
})),
quick_links: MOCK_QUICK_LINKS.map((q, i) => ({
id: `ql${i + 1}`,
label: q.label,
url: q.url,
glyph: q.glyph,
position: i,
active: true,
})),
floor_adverts: Array.from({ length: 6 }, (_, i) => ({
id: `fa${i + 1}`,
image: `/mock/floor-adv-${i + 1}.svg`,
position: i,
active: true,
})),
}),
listShops: (): Promise<ShopProfile[]> => Promise.resolve(MOCK_STORES.map(toShopProfile)), getShop: (slug) => {
const store = storeById(slug);
if (!store) return Promise.reject(new ApiError(404, "NOT_FOUND", "Shop not found"));
return Promise.resolve(toShopProfile(store));
},
listMyAddresses: () =>
Promise.resolve(
[...state.addresses].sort(
(a, b) => Number(b.is_default) - Number(a.is_default) || b.created_at.localeCompare(a.created_at),
),
),
createAddress: (input: AddressInput) => {
const makeDefault = input.is_default === true || state.addresses.length === 0;
if (makeDefault) for (const a of state.addresses) a.is_default = false;
state.addressSeq += 1;
const entry: AddressBookEntry = {
id: `a-${state.addressSeq}`,
user_id: MOCK_USER.id,
recipient: input.recipient,
phone: input.phone,
country: input.country,
region: input.region,
city: input.city,
line1: input.line1,
postal_code: input.postal_code,
is_default: makeDefault,
created_at: new Date().toISOString(),
};
state.addresses.push(entry);
persist();
return Promise.resolve(entry);
},
updateAddress: (id: string, input: AddressInput) => {
const entry = state.addresses.find((a) => a.id === id);
if (!entry) return Promise.reject(new ApiError(404, "NOT_FOUND", "Address not found"));
if (input.is_default === true && !entry.is_default) {
for (const a of state.addresses) a.is_default = false;
}
entry.recipient = input.recipient;
entry.phone = input.phone;
entry.country = input.country;
entry.region = input.region;
entry.city = input.city;
entry.line1 = input.line1;
entry.postal_code = input.postal_code;
entry.is_default = input.is_default === true || entry.is_default;
persist();
return Promise.resolve({ ...entry });
},
deleteAddress: (id: string) => {
const entry = state.addresses.find((a) => a.id === id);
if (!entry) return Promise.reject(new ApiError(404, "NOT_FOUND", "Address not found"));
const wasDefault = entry.is_default;
state.addresses = state.addresses.filter((a) => a.id !== id);
if (wasDefault && state.addresses.length > 0) {
const latest = state.addresses.reduce((a, b) => (a.created_at > b.created_at ? a : b));
latest.is_default = true;
}
persist();
return Promise.resolve([...state.addresses]);
},
setDefaultAddress: (id: string) => {
const entry = state.addresses.find((a) => a.id === id);
if (!entry) return Promise.reject(new ApiError(404, "NOT_FOUND", "Address not found"));
for (const a of state.addresses) a.is_default = a.id === id;
persist();
return Promise.resolve({ ...entry });
},
listShopCouponTemplates: (shopId: string) =>
Promise.resolve(MOCK_COUPONS.map((t) => mockTemplate(t, shopId))),
listMyCoupons: () => Promise.resolve(state.coupons.map((c) => ({ ...c }))),
claimCoupon: (templateId: string) => {
const template = MOCK_COUPONS.find((t) => t.id === templateId);
if (!template) return Promise.reject(new ApiError(404, "NOT_FOUND", "Coupon not found"));
if (state.coupons.some((c) => c.template_id === templateId)) {
return Promise.reject(new ApiError(409, "CONFLICT", "Coupon already claimed"));
}
const coupon = mockOwnedCoupon(template);
state.coupons.push(coupon);
persist();
return Promise.resolve({ ...coupon });
},
shop: {
getMyShop: () => unsupported(),
listMyProducts: () => unsupported(),
getProduct: () => unsupported(),
createProduct: () => unsupported(),
updateProduct: () => unsupported(),
publish: () => unsupported(),
unpublish: () => unsupported(),
upsertSku: () => unsupported(),
listOrders: () => unsupported(),
getOrder: () => unsupported(),
createShipment: () => unsupported(),
listShipments: () => unsupported(),
markShipped: () => unsupported(),
listInvoices: () => unsupported(),
issueInvoice: () => unsupported(),
listCouponTemplates: () => unsupported(),
createCouponTemplate: (_body: CouponTemplateInput) => unsupported(),
updateCouponTemplate: (_id: string, _body: CouponTemplateInput) => unsupported(),
deleteCouponTemplate: (_id: string) => unsupported(),
},
admin: {
listUsers: () => unsupported(),
setUserRole: () => unsupported(),
listShops: () => unsupported(),
createShop: () => unsupported(),
setShopStatus: () => unsupported(),
listOrders: () => unsupported(),
listCurrencies: () => unsupported(),
upsertCurrency: () => unsupported(),
setRate: () => unsupported(),
getContent: () => unsupported(),
replaceContent: () => unsupported(),
setShopProfile: () => unsupported(),
getBrands: () => unsupported(),
replaceBrands: () => unsupported(),
},
};
}
export { defaultAddress };