// 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, Aftersale, AftersaleApplyBody, AftersaleDetail, AftersaleMessage, AftersaleMessageBody, AftersaleReturnTrackingBody, AftersaleStatus, ApiClient, AuthTokens, Cart, CartItem, Coupon, CouponTemplate, CouponTemplateInput, Favorite, FavoriteListQuery, FavoriteProductSummary, FlashSaleItem, FlashSaleItemInput, FlashSaleSession, FlashSaleSessionInput, GroupBuyIntent, GroupBuyingActivity, GroupBuyingActivityInput, GroupBuyingActivityView, GrowthLogEntry, HomeContent, IntegralOrder, IntegralProduct, IntegralProductInput, Invoice, InvoiceKind, MarkAllReadResult, MemberLevel, MemberLevelInput, MembershipStatus, MerchantApplication, MerchantApplicationInput, MerchantApplicationQuery, MerchantApprovalResult, MerchantCategoryRef, Message, MessageDeleteResult, MessageKind, MessageListQuery, NextMemberLevel, Order, Paged, Product, PublicFlashSaleSession, RedeemPointsBody, Review, ReviewInput, ReviewableItem, ReviewSummary, Shipment, ShopProfile, UnreadCount, User, WalletAccountKind, WalletEntry, WalletRechargeResult, WalletSummary, WalletWithdrawal, WithdrawalAccountDetails, WithdrawalStatus, } from "@vmall/shared"; import { BASE_CURRENCY, MOCK_BANNERS, collectiveProducts, MOCK_BRANDS, MOCK_CATEGORIES, MOCK_COUPONS, MOCK_CURRENCIES, MOCK_FAVORITES, INTEGRAL_PRODUCTS, MOCK_PROMOS, MOCK_QUICK_LINKS, MOCK_STORES, MOCK_USER, USER_STATS, MOCK_ADDRESSES, defaultAddress, lowestSku, mockConvertMinor, productById, searchMockProducts, seckillProducts, 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[]; /** Persisted customer favorites for fixed-adapter reload parity. */ favorites: Favorite[]; /** Persisted customer aftersales for fixed-adapter reload parity. */ aftersales: Aftersale[]; aftersaleMessages: AftersaleMessage[]; /** Persisted customer reviews for fixed-adapter reload parity. */ reviews: Review[]; /** In-memory points catalog and redemptions for the fixed-data path. */ pointsProducts: IntegralProduct[]; redemptions: IntegralOrder[]; /** Buyer wallet balances, ledger and withdrawal history. */ walletAvailableMinor: number; walletFrozenMinor: number; walletEntries: WalletEntry[]; walletWithdrawals: WalletWithdrawal[]; /** Merchant onboarding applications, newest first, for the fixed-adapter path. */ merchantApplications: MerchantApplication[]; /** Buyer growth ledger; the level itself is re-derived from these entries. */ growthLogs: GrowthLogEntry[]; /** Buyer system messages; soft deletion is a mock-only marker. */ messages: MockMessage[]; addressSeq: number; favoriteSeq: number; orderSeq: number; invoiceSeq: number; redemptionSeq: number; aftersaleSeq: number; aftersaleMessageSeq: number; reviewSeq: number; walletEntrySeq: number; walletWithdrawalSeq: number; merchantApplicationSeq: number; growthLogSeq: number; messageSeq: number; } /** A message plus the soft-delete marker the shared contract never exposes. */ interface MockMessage extends Message { deleted_at: string | null; } // v9: membership growth logs and buyer messages joined the persisted rollback state. const STORAGE_KEY = "vmall.mock.state.v9"; type PersistedState = Pick< MockState, | "cart" | "orders" | "shipments" | "invoices" | "addresses" | "favorites" | "aftersales" | "aftersaleMessages" | "reviews" | "orderSeq" | "invoiceSeq" | "addressSeq" | "favoriteSeq" | "aftersaleSeq" | "aftersaleMessageSeq" | "reviewSeq" | "walletAvailableMinor" | "walletFrozenMinor" | "walletEntries" | "walletWithdrawals" | "walletEntrySeq" | "walletWithdrawalSeq" | "merchantApplications" | "merchantApplicationSeq" | "growthLogs" | "messages" | "growthLogSeq" | "messageSeq" >; // 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; 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; if (!Array.isArray(p.favorites) || typeof p.favoriteSeq !== "number") return null; if (!Array.isArray(p.aftersales) || !Array.isArray(p.aftersaleMessages)) return null; if (typeof p.aftersaleSeq !== "number" || typeof p.aftersaleMessageSeq !== "number") return null; if (!Array.isArray(p.reviews) || typeof p.reviewSeq !== "number") return null; if (typeof p.walletAvailableMinor !== "number" || typeof p.walletFrozenMinor !== "number") return null; if (!Array.isArray(p.walletEntries) || !Array.isArray(p.walletWithdrawals)) return null; if (typeof p.walletEntrySeq !== "number" || typeof p.walletWithdrawalSeq !== "number") return null; if (!Array.isArray(p.merchantApplications) || typeof p.merchantApplicationSeq !== "number") return null; if (!Array.isArray(p.growthLogs) || typeof p.growthLogSeq !== "number") return null; if (!Array.isArray(p.messages) || typeof p.messageSeq !== "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 clampPage(page?: number): number { return Math.max(1, page ?? 1); } function clampPerPage(perPage?: number): number { return Math.min(100, Math.max(1, perPage ?? 20)); } function productSummary(product: Product): FavoriteProductSummary { const sku = lowestSku(product); return { id: product.id, shop_id: product.shop_id, slug: product.slug, name: product.name, image: product.images[0] ?? null, price_minor: sku?.price_minor ?? null, currency: sku?.currency ?? null, }; } function hydrateProductFavorite(id: string, createdAt: string, product: Product): Favorite { return { kind: "product", id, user_id: MOCK_USER.id, created_at: createdAt, product: productSummary(product), }; } function hydrateShopFavorite(id: string, createdAt: string, store: MockStoreRecord): Favorite { return { kind: "shop", id, user_id: MOCK_USER.id, created_at: createdAt, shop: toShopProfile(store), }; } function seedFavorites(): Favorite[] { const rows: Favorite[] = []; for (const item of MOCK_FAVORITES) { const created = `${item.createdAt}T09:00:00.000Z`; if (item.kind === "product") { const product = productById(item.refId); if (product && product.status === "published") { rows.push(hydrateProductFavorite(item.id, created, product)); } } else { const store = storeById(item.refId); if (store) rows.push(hydrateShopFavorite(item.id, created, store)); } } return rows; } function seedPointsProducts(): IntegralProduct[] { return INTEGRAL_PRODUCTS.map((p) => ({ id: p.id, name: p.name, subtitle: null, content: null, image: p.image, points_price: p.points, stock: p.stock, published: true, recommend: false, position: 0, created_at: "2026-01-01T00:00:00.000Z", updated_at: "2026-01-01T00:00:00.000Z", })); } /** One synthesized active session covering the fixed seckill fixtures. */ function mockFlashSales(): PublicFlashSaleSession[] { const now = Date.now(); const items = seckillProducts().map((entry, index) => { const sku = lowestSku(entry.product); const stock = sku?.stock ?? 100; const sold = Math.round((stock * entry.soldPct) / 100); return { id: `fsi${index + 1}`, session_id: "fs1", sku_id: sku?.id ?? "", product_id: entry.product.id, product_slug: entry.product.slug, product_name: entry.product.name, image: entry.product.images[0] ?? null, sku_code: sku?.sku_code ?? "", sale_price_minor: entry.seckillPriceMinor, currency: sku?.currency ?? BASE_CURRENCY, original_price_minor: sku?.price_minor ?? entry.seckillPriceMinor, original_currency: sku?.currency ?? BASE_CURRENCY, reserved_stock: Math.max(stock - sold, 0), sold_count: sold, per_customer_limit: 2, }; }); return [ { id: "fs1", shop_id: MOCK_STORES[0].id, label: { en: "Flash sale", zh: "限时秒杀" }, starts_at: new Date(now - 3_600_000).toISOString(), ends_at: new Date(now + 6 * 3_600_000).toISOString(), enabled: true, created_at: "2026-01-01T00:00:00.000Z", updated_at: "2026-01-01T00:00:00.000Z", items, }, ]; } /** Synthesized activities from the fixed collective fixtures. */ function mockGroupBuying(): GroupBuyingActivityView[] { const now = Date.now(); return collectiveProducts().map((entry, index) => { const sku = lowestSku(entry.product); const price = sku?.price_minor ?? 1000; return { id: `gb${index + 1}`, shop_id: entry.product.shop_id, sku_id: sku?.id ?? "", name: { en: `${entry.need}-person group`, zh: `${entry.need}人团` }, description: null, image: entry.product.images[0] ?? null, group_price_minor: Math.max(1, Math.round(price * 0.8)), currency: sku?.currency ?? BASE_CURRENCY, required_members: entry.need, starts_at: new Date(now - 3_600_000).toISOString(), ends_at: new Date(now + 7 * 24 * 3_600_000).toISOString(), group_lifetime_hours: 24, enabled: true, created_at: "2026-01-01T00:00:00.000Z", updated_at: "2026-01-01T00:00:00.000Z", sku_code: sku?.sku_code ?? "", product_id: entry.product.id, product_slug: entry.product.slug, product_name: entry.product.name, original_price_minor: price, original_currency: sku?.currency ?? BASE_CURRENCY, open_groups: [ { id: `g${index + 1}`, paid_member_count: Math.min(1, entry.need - 1), required_members: entry.need, expires_at: new Date(now + 6 * 3_600_000).toISOString(), }, ], }; }); } function seedAftersales(orders: Order[]): Aftersale[] { const item = (orderId: string, itemId: string) => { const order = orders.find((entry) => entry.id === orderId); const line = order?.items.find((entry) => entry.id === itemId); return order && line ? { order, line } : null; }; const rows: Aftersale[] = []; const pending = item("o1", "o1-it1"); const approved = item("o1", "o1-it2"); const refunded = item("o2", "o2-it1"); const rejected = item("o2", "o2-it2"); if (pending) rows.push({ id: "as-demo-pending", order_id: pending.order.id, order_item_id: pending.line.id, shop_id: pending.order.shop_id, user_id: MOCK_USER.id, kind: "refund_only", status: "pending", reason: { en: "The item arrived with a visible issue.", zh: "商品到货后发现明显问题。" }, amount_minor: Math.min(500, pending.line.unit_price_minor * pending.line.qty), currency: pending.order.currency, evidence: ["https://example.com/aftersale/demo-evidence.jpg"], reopened: false, return_carrier: null, return_tracking_no: null, created_at: "2026-09-10T12:00:00.000Z", updated_at: "2026-09-10T12:00:00.000Z", }); if (approved) rows.push({ id: "as-demo-approved", order_id: approved.order.id, order_item_id: approved.line.id, shop_id: approved.order.shop_id, user_id: MOCK_USER.id, kind: "return_refund", status: "approved", reason: { en: "The item needs to be returned.", zh: "商品需要退回。" }, amount_minor: approved.line.unit_price_minor * approved.line.qty, currency: approved.order.currency, evidence: [], reopened: false, return_carrier: null, return_tracking_no: null, created_at: "2026-09-10T13:00:00.000Z", updated_at: "2026-09-11T09:00:00.000Z", }); if (refunded) rows.push({ id: "as-demo-refunded", order_id: refunded.order.id, order_item_id: refunded.line.id, shop_id: refunded.order.shop_id, user_id: MOCK_USER.id, kind: "refund_only", status: "refunded", reason: { en: "The product did not match the listing.", zh: "商品与描述不符。" }, amount_minor: refunded.line.unit_price_minor * refunded.line.qty, currency: refunded.order.currency, evidence: [], reopened: false, return_carrier: null, return_tracking_no: null, created_at: "2026-09-12T17:00:00.000Z", updated_at: "2026-09-14T09:00:00.000Z", }); if (rejected) rows.push({ id: "as-demo-rejected", order_id: rejected.order.id, order_item_id: rejected.line.id, shop_id: rejected.order.shop_id, user_id: MOCK_USER.id, kind: "return_refund", status: "rejected", reason: { en: "Please review this return request.", zh: "请重新审核本次退货申请。" }, amount_minor: rejected.line.unit_price_minor * rejected.line.qty, currency: rejected.order.currency, evidence: [], reopened: false, return_carrier: null, return_tracking_no: null, created_at: "2026-09-12T18:00:00.000Z", updated_at: "2026-09-13T09:00:00.000Z", }); return rows; } function seedAftersaleMessages(aftersales: Aftersale[]): AftersaleMessage[] { const refunded = aftersales.find((row) => row.id === "as-demo-refunded"); return refunded ? [ { id: "asm-demo-1", aftersale_id: refunded.id, author_role: "buyer", author_id: MOCK_USER.id, content: { en: "Please help review this refund.", zh: "请帮忙审核退款。" }, evidence: [], created_at: "2026-09-12T17:30:00.000Z", }, ] : []; } function seedReviews(orders: Order[]): Review[] { const order = orders.find((entry) => entry.id === "o2"); const item = order?.items.find((entry) => entry.id === "o2-it1"); const product = item ? skuIndex()[item.sku_id]?.product : undefined; if (!order || !item || !product) return []; return [ { id: "rv-demo-1", order_item_id: item.id, order_id: order.id, product_id: product.id, shop_id: order.shop_id, user_id: MOCK_USER.id, rating: 5, content: { en: "Excellent quality and a smooth shopping experience.", zh: "质量很好,购物体验很顺畅。", }, images: ["https://example.com/reviews/demo-product.jpg"], reply: { en: "Thank you for your support!", zh: "感谢您的支持!" }, reply_at: "2026-09-14T12:00:00.000Z", status: "visible", created_at: "2026-09-14T10:00:00.000Z", reviewer_name: MOCK_USER.display_name, }, ]; } interface WalletSeed { walletAvailableMinor: number; walletFrozenMinor: number; walletEntries: WalletEntry[]; walletWithdrawals: WalletWithdrawal[]; walletEntrySeq: number; walletWithdrawalSeq: number; } /** * Deterministic opening wallet. Movements replay the same paired-entry shape the * live backend writes, and end exactly on the fixture balances in USER_STATS. */ function seedWallet(): WalletSeed { const entries: WalletEntry[] = []; const withdrawals: WalletWithdrawal[] = []; let available = 0; let frozen = 0; let entrySeq = 0; let withdrawalSeq = 0; function move( account: WalletAccountKind, deltaMinor: number, reason: string, referenceType: string | null, referenceId: string | null, createdAt: string, ): void { entrySeq += 1; if (account === "available") available += deltaMinor; else frozen += deltaMinor; entries.push({ id: `we-${entrySeq}`, account_kind: account, delta_minor: deltaMinor, balance_minor: account === "available" ? available : frozen, reason, reference_type: referenceType, reference_id: referenceId, created_at: createdAt, }); } function withdrawal( amountMinor: number, status: Exclude, reviewNote: string, reviewedAt: string, createdAt: string, ): WalletWithdrawal { withdrawalSeq += 1; const row: WalletWithdrawal = { id: `wd-${withdrawalSeq}`, user_id: MOCK_USER.id, user_email: MOCK_USER.email, amount_minor: amountMinor, currency: BASE_CURRENCY, account_details: { method: "bank", account: "**** 4321", holder: MOCK_USER.display_name }, status, review_note: reviewNote, reviewed_at: reviewedAt, created_at: createdAt, }; withdrawals.push(row); return row; } move("available", 50000, "opening_balance", null, null, "2026-08-20T09:00:00.000Z"); const approved = withdrawal( 20000, "approved", "Paid out to the registered bank account.", "2026-09-03T09:00:00.000Z", "2026-09-02T09:00:00.000Z", ); move( "available", -approved.amount_minor, "wallet_withdrawal_freeze", "wallet_withdrawal", approved.id, "2026-09-02T09:00:05.000Z", ); move( "frozen", approved.amount_minor, "wallet_withdrawal_freeze", "wallet_withdrawal", approved.id, "2026-09-02T09:00:05.000Z", ); move( "frozen", -approved.amount_minor, "wallet_withdrawal_approved", "wallet_withdrawal", approved.id, "2026-09-03T09:00:00.000Z", ); move("available", -19000, "order_payment", "order", "o1", "2026-09-05T10:00:00.000Z"); const rejected = withdrawal( 5000, "rejected", "Account holder name does not match our records.", "2026-09-13T09:00:00.000Z", "2026-09-12T09:00:00.000Z", ); move( "available", -rejected.amount_minor, "wallet_withdrawal_freeze", "wallet_withdrawal", rejected.id, "2026-09-12T09:00:05.000Z", ); move( "frozen", rejected.amount_minor, "wallet_withdrawal_freeze", "wallet_withdrawal", rejected.id, "2026-09-12T09:00:05.000Z", ); move( "frozen", -rejected.amount_minor, "wallet_withdrawal_rejected", "wallet_withdrawal", rejected.id, "2026-09-13T09:00:00.000Z", ); move( "available", rejected.amount_minor, "wallet_withdrawal_rejected", "wallet_withdrawal", rejected.id, "2026-09-13T09:00:00.000Z", ); move( "available", 1800, "aftersale_refund", "aftersale", "as-demo-refunded", "2026-09-14T09:00:00.000Z", ); return { walletAvailableMinor: available, walletFrozenMinor: frozen, walletEntries: entries, walletWithdrawals: withdrawals, walletEntrySeq: entrySeq, walletWithdrawalSeq: withdrawalSeq, }; } /** Mirrors the backend's default page size for `/wallet/entries`. */ const WALLET_ENTRIES_PER_PAGE = 20; // ---- membership / messaging fixtures ---- /** Deterministic level catalog, as a platform admin would seed it. */ const MOCK_MEMBER_LEVELS: MemberLevel[] = [ { id: "ml-1", name: { en: "Bronze", zh: "青铜会员" }, icon: "I", growth_threshold: 100, benefits: { en: "Standard customer support.", zh: "标准客户服务。" }, created_at: "2026-01-01T00:00:00.000Z", updated_at: "2026-01-01T00:00:00.000Z", }, { id: "ml-2", name: { en: "Silver", zh: "白银会员" }, icon: "II", growth_threshold: 500, benefits: { en: "Faster support and member-only coupons.", zh: "优先客服与会员专享优惠券。" }, created_at: "2026-01-01T00:00:00.000Z", updated_at: "2026-01-01T00:00:00.000Z", }, { id: "ml-3", name: { en: "Gold", zh: "黄金会员" }, icon: "III", growth_threshold: 2000, benefits: { en: "Priority shipping and exclusive offers.", zh: "优先发货与专属活动权益。" }, created_at: "2026-01-01T00:00:00.000Z", updated_at: "2026-01-01T00:00:00.000Z", }, ]; /** Mirrors the backend's default page size for `/membership/growth-logs`. */ const GROWTH_LOGS_PER_PAGE = 20; /** Mirrors the backend's default page size for `/messages`. */ const MESSAGES_PER_PAGE = 20; interface GrowthLogSeed { growthLogs: GrowthLogEntry[]; growthLogSeq: number; } interface MessageSeed { messages: MockMessage[]; messageSeq: number; } /** The level whose threshold is the highest one at or below the growth total. */ function levelAtOrBelow(growthTotal: number): MemberLevel | null { let best: MemberLevel | null = null; for (const level of MOCK_MEMBER_LEVELS) { if (level.growth_threshold <= growthTotal) { if (!best || level.growth_threshold > best.growth_threshold) best = level; } } return best; } /** The lowest threshold above the growth total, i.e. the next upgrade. */ function levelAbove(growthTotal: number): MemberLevel | null { let best: MemberLevel | null = null; for (const level of MOCK_MEMBER_LEVELS) { if (level.growth_threshold > growthTotal) { if (!best || level.growth_threshold < best.growth_threshold) best = level; } } return best; } function copyLevel(level: MemberLevel): MemberLevel { return { ...level, name: { ...level.name }, benefits: { ...level.benefits } }; } /** Strip the mock-only soft-delete marker before it crosses the contract. */ function toMessage(row: MockMessage): Message { return { id: row.id, kind: row.kind, title: { ...row.title }, body: { ...row.body }, reference_type: row.reference_type, reference_id: row.reference_id, status: row.status, read_at: row.read_at, created_at: row.created_at, }; } /** * Deterministic growth ledger ending at 620 growth, exactly like the live * backend's one-entry-per-completed-order shape. */ function seedGrowthLogs(orders: Order[]): GrowthLogSeed { const logs: GrowthLogEntry[] = []; let seq = 0; let total = 0; function accrue( delta: number, referenceType: string | null, referenceId: string | null, createdAt: string, ): void { seq += 1; total += delta; logs.push({ id: `ge-${seq}`, delta, growth_total: total, reason: "order_complete", reference_type: referenceType, reference_id: referenceId, created_at: createdAt, }); } accrue(200, "order", orders[0]?.id ?? null, "2026-09-11T10:05:00.000Z"); accrue(300, "order", orders[1]?.id ?? null, "2026-09-13T16:05:00.000Z"); accrue(120, null, null, "2026-09-14T09:00:00.000Z"); return { growthLogs: logs, growthLogSeq: seq }; } /** One system message per fixture event, carrying the same bilingual copy. */ function seedMessages(orders: Order[]): MessageSeed { const shippedOrder = orders[0]; const paidOrder = orders[1] ?? orders[0]; const rows: MockMessage[] = []; let seq = 0; function copy(kind: MessageKind, orderNo: string): Pick { if (kind === "order_paid") return { title: { en: "Payment received", zh: "付款成功" }, body: { en: `Order ${orderNo} is paid and awaiting shipment.`, zh: `订单 ${orderNo} 已付款,等待发货。`, }, }; if (kind === "order_shipped") return { title: { en: "Order shipped", zh: "订单已发货" }, body: { en: `Order ${orderNo} has been dispatched.`, zh: `订单 ${orderNo} 已发货。` }, }; return { title: { en: "Refund completed", zh: "退款完成" }, body: { en: `Your refund for order ${orderNo} has been issued.`, zh: `订单 ${orderNo} 的退款已完成。`, }, }; } function add( kind: MessageKind, referenceType: string, referenceId: string | null, orderNo: string, status: Message["status"], createdAt: string, ): void { seq += 1; rows.push({ id: `msg-${seq}`, kind, ...copy(kind, orderNo), reference_type: referenceType, reference_id: referenceId, status, read_at: status === "read" ? createdAt : null, created_at: createdAt, deleted_at: null, }); } if (shippedOrder) { add( "order_shipped", "order", shippedOrder.id, shippedOrder.order_no, "read", "2026-09-10T18:00:00.000Z", ); add("order_paid", "order", shippedOrder.id, shippedOrder.order_no, "read", "2026-09-10T10:05:00.000Z"); } if (paidOrder) { add("order_paid", "order", paidOrder.id, paidOrder.order_no, "unread", "2026-09-12T16:00:00.000Z"); } add( "refund_completed", "aftersale", "as-demo-refunded", paidOrder?.order_no ?? "—", "unread", "2026-09-14T09:00:00.000Z", ); return { messages: rows, messageSeq: seq }; } /** Mirrors `clamp_per_page` for the admin merchant application queue. */ const MERCHANT_APPLICATIONS_PER_PAGE = 20; /** * Fixed clock for merchant onboarding fixtures: sequence `n` renders as one * hour after the base instant, so repeated sessions stay deterministic. */ const MERCHANT_BASE_MS = Date.parse("2026-09-18T10:00:00.000Z"); function merchantTime(seq: number): string { return new Date(MERCHANT_BASE_MS + seq * 3_600_000).toISOString(); } /** * Opening merchant fixture: one rejected personal application, so the fixed * adapter can show the rejection reason and the re-apply action before the * first submission. A fresh submit then becomes the pending application, and a * second submit hits the same 409 the live backend returns. */ function seedMerchantApplications(): MerchantApplication[] { const category = MOCK_CATEGORIES[0]; if (!category) return []; return [ { id: "ma-1", user_id: MOCK_USER.id, applicant_email: MOCK_USER.email, entity_type: "personal", real_name: MOCK_USER.display_name, company_name: null, business_license_no: null, category_ids: [category.id], categories: [{ id: category.id, name: { ...category.name } }], contact: { name: MOCK_USER.display_name, phone: "555-0130", email: MOCK_USER.email, address: "1 Market Street, San Francisco, CA", }, qualification: { identity_document_url: "https://example.com/merchant/id-demo.jpg", }, status: "rejected", rejection_reason: "The identity document image is not legible. Please re-apply with a clear scan.", reviewed_at: merchantTime(1), created_shop_id: null, created_at: merchantTime(0), updated_at: merchantTime(1), }, ]; } function initialState(): MockState { const persisted = loadPersisted(); // Coupons and points are session-only, so a restored snapshot re-seeds them. if (persisted) { return { token: null, ...persisted, coupons: seedCoupons(), pointsProducts: seedPointsProducts(), redemptions: [], redemptionSeq: 0, }; } const seed = seedOrders(MOCK_USER.id); const aftersales = seedAftersales(seed.orders); for (const row of aftersales) { if (row.status !== "refunded") continue; const order = seed.orders.find((entry) => entry.id === row.order_id); if (order) order.refund_total_minor += row.amount_minor; } 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(), favorites: seedFavorites(), aftersales, reviews: seedReviews(seed.orders), aftersaleMessages: seedAftersaleMessages(aftersales), pointsProducts: seedPointsProducts(), redemptions: [], ...seedWallet(), ...seedGrowthLogs(seed.orders), ...seedMessages(seed.orders), merchantApplications: seedMerchantApplications(), addressSeq: 100, favoriteSeq: 200, orderSeq: 100, invoiceSeq: 100, redemptionSeq: 0, aftersaleSeq: 100, reviewSeq: 1, aftersaleMessageSeq: 100, merchantApplicationSeq: 1, }; } function unsupported(): never { throw new ApiError( 501, "MOCK_UNSUPPORTED", "This admin/shop endpoint is not part of the mall mock.", ); } type MockStoreRecord = NonNullable>; /** 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 { const index: Record = {}; 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, favorites: state.favorites, favoriteSeq: state.favoriteSeq, aftersales: state.aftersales, aftersaleMessages: state.aftersaleMessages, reviews: state.reviews, aftersaleSeq: state.aftersaleSeq, aftersaleMessageSeq: state.aftersaleMessageSeq, reviewSeq: state.reviewSeq, walletAvailableMinor: state.walletAvailableMinor, walletFrozenMinor: state.walletFrozenMinor, walletEntries: state.walletEntries, walletWithdrawals: state.walletWithdrawals, walletEntrySeq: state.walletEntrySeq, walletWithdrawalSeq: state.walletWithdrawalSeq, merchantApplications: state.merchantApplications, merchantApplicationSeq: state.merchantApplicationSeq, growthLogs: state.growthLogs, messages: state.messages, growthLogSeq: state.growthLogSeq, messageSeq: state.messageSeq, }; 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")}`; } function findAftersale(id: string): Aftersale { const row = state.aftersales.find((entry) => entry.id === id && entry.user_id === MOCK_USER.id); if (!row) throw new ApiError(404, "NOT_FOUND", "Aftersale not found"); return row; } function findOrderItem( orderItemId: string, ): { order: Order; item: Order["items"][number] } | null { for (const order of state.orders) { const item = order.items.find((entry) => entry.id === orderItemId); if (item) return { order, item }; } return null; } function eligibleOrder(order: Order): boolean { const statusEligible = ["paid", "fulfilling", "shipped", "completed"].includes(order.status); const windowEnd = Date.parse(order.created_at) + 15 * 24 * 60 * 60 * 1000; return statusEligible && Date.now() <= windowEnd; } function remainingFor(orderItemId: string, lineAmount: number): number { const refunded = state.aftersales .filter((row) => row.order_item_id === orderItemId && row.status === "refunded") .reduce((sum, row) => sum + row.amount_minor, 0); return Math.max(0, lineAmount - refunded); } function copyAftersale(row: Aftersale): Aftersale { return { ...row, reason: { ...row.reason }, evidence: [...row.evidence] }; } function aftersaleDetail(id: string): AftersaleDetail { const row = findAftersale(id); const found = findOrderItem(row.order_item_id); if (!found || found.order.id !== row.order_id) throw new ApiError(404, "NOT_FOUND", "Aftersale item not found"); return { ...copyAftersale(row), item: { product_name: { ...found.item.product_name }, sku_code: found.item.sku_code, image: found.item.image, unit_price_minor: found.item.unit_price_minor, qty: found.item.qty, }, messages: state.aftersaleMessages .filter((message) => message.aftersale_id === row.id) .sort((a, b) => a.created_at.localeCompare(b.created_at)) .map((message) => ({ ...message, content: { ...message.content }, evidence: [...message.evidence], })), remaining_refundable_minor: remainingFor( row.order_item_id, found.item.unit_price_minor * found.item.qty, ), }; } function copyReview(row: Review): Review { return { ...row, content: { ...row.content }, images: [...row.images], reply: row.reply ? { ...row.reply } : null, }; } function reviewProduct(found: { item: Order["items"][number] }): Product | null { return skuIndex()[found.item.sku_id]?.product ?? null; } function reviewableItems(): ReviewableItem[] { return state.orders .filter((order) => order.user_id === MOCK_USER.id && order.status === "completed") .sort((a, b) => b.created_at.localeCompare(a.created_at)) .flatMap((order) => order.items.flatMap((item) => { if (state.reviews.some((review) => review.order_item_id === item.id)) return []; const product = reviewProduct({ item }); if (!product) return []; return [ { order_item_id: item.id, order_id: order.id, order_no: order.order_no, product_id: product.id, product_name: { ...item.product_name }, sku_code: item.sku_code, image: item.image, created_at: order.created_at, }, ]; }), ); } function copyMerchantApplication(row: MerchantApplication): MerchantApplication { return { ...row, category_ids: [...row.category_ids], categories: row.categories.map((category) => ({ id: category.id, name: { ...category.name }, })), contact: { ...row.contact }, qualification: { ...row.qualification, ...(row.qualification.extra_materials ? { extra_materials: [...row.qualification.extra_materials] } : {}), }, }; } function merchantCategoryRefs(ids: string[]): MerchantCategoryRef[] { return ids.map((id) => { const category = MOCK_CATEGORIES.find((entry) => entry.id === id); if (!category) throw new ApiError(400, "BAD_REQUEST", "unknown operating category"); return { id: category.id, name: { ...category.name } }; }); } function merchantRequired(value: string | undefined, field: string): string { const trimmed = (value ?? "").trim(); if (!trimmed) throw new ApiError(400, "BAD_REQUEST", `${field} is required`); return trimmed; } function merchantUrl(value: string | undefined, field: string): string { const trimmed = (value ?? "").trim(); const ok = (trimmed.startsWith("http://") || trimmed.startsWith("https://")) && trimmed.length > "https://".length && !/\s/.test(trimmed); if (!ok) throw new ApiError(400, "BAD_REQUEST", `${field} must be an http(s) URL`); return trimmed; } interface NormalizedMerchantApplication { entity_type: MerchantApplication["entity_type"]; real_name: string | null; company_name: string | null; business_license_no: string | null; category_ids: string[]; categories: MerchantCategoryRef[]; contact: MerchantApplication["contact"]; qualification: MerchantApplication["qualification"]; } /** Mirrors the live service's validation before the fixed adapter stores a row. */ function normalizeMerchantInput(body: MerchantApplicationInput): NormalizedMerchantApplication { if (body.category_ids.length === 0) { throw new ApiError(400, "BAD_REQUEST", "at least one operating category is required"); } const categoryIds = [...body.category_ids]; const categories = merchantCategoryRefs(categoryIds); const contactName = merchantRequired(body.contact.name, "contact.name"); const contactPhone = merchantRequired(body.contact.phone, "contact.phone"); const email = merchantRequired(body.contact.email, "contact.email").toLowerCase(); if (!email.includes("@") || email.startsWith("@") || email.endsWith("@")) { throw new ApiError(400, "BAD_REQUEST", "contact.email is invalid"); } const address = body.contact.address?.trim(); const contact: MerchantApplication["contact"] = { name: contactName, phone: contactPhone, email, ...(address ? { address } : {}), }; const extra = (body.qualification.extra_materials ?? []).map((url) => merchantUrl(url, "qualification.extra_materials"), ); if (body.entity_type === "personal") { return { entity_type: "personal", real_name: merchantRequired(body.real_name, "real_name"), company_name: null, business_license_no: null, category_ids: categoryIds, categories, contact, qualification: { identity_document_url: merchantUrl( body.qualification.identity_document_url, "qualification.identity_document_url", ), ...(extra.length ? { extra_materials: extra } : {}), }, }; } const businessLicenseNo = merchantRequired( body.qualification.business_license_no, "qualification.business_license_no", ); return { entity_type: "enterprise", real_name: null, company_name: merchantRequired(body.company_name, "company_name"), business_license_no: businessLicenseNo, category_ids: categoryIds, categories, contact, qualification: { business_license_url: merchantUrl( body.qualification.business_license_url, "qualification.business_license_url", ), business_license_no: businessLicenseNo, ...(extra.length ? { extra_materials: extra } : {}), }, }; } function findMerchantApplication(id: string): MerchantApplication { const row = state.merchantApplications.find((entry) => entry.id === id); if (!row) throw new ApiError(404, "NOT_FOUND", "Merchant application not found"); return row; } function merchantHistory(): MerchantApplication[] { return [...state.merchantApplications].sort((a, b) => b.created_at.localeCompare(a.created_at), ); } return { register: () => Promise.resolve(tokens()), login: () => Promise.resolve(tokens()), me: (): Promise => Promise.resolve(MOCK_USER), getAccountSummary: (): Promise => 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 = {}, groupBuy?: GroupBuyIntent, ) => { if (state.cart.length === 0) { return Promise.reject(new ApiError(400, "EMPTY_CART", "Cart is empty")); } // A group-buy intent repriced exactly one SKU at the activity price. const activity = groupBuy ? mockGroupBuying().find((entry) => entry.id === groupBuy.activity_id) : undefined; if (groupBuy && !activity) { return Promise.reject(new ApiError(409, "CONFLICT", "Group activity is not available")); } const cartItems = state.cart.map((item) => activity && groupBuy && item.sku_id === groupBuy.sku_id ? { ...item, unit_price_minor: activity.group_price_minor } : item, ); const byShop: Record = {}; for (const item of cartItems) { 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 groupHere = activity && groupBuy && items.some((i) => i.sku_id === groupBuy.sku_id); const couponId = couponByShop[shopId]; let discount = 0; if (couponId && groupHere) { return Promise.reject( new ApiError(409, "CONFLICT", "A coupon cannot be combined with group pricing"), ); } 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); } // Same deterministic rule as quoteShipping: flat fee under the free threshold. const shippingFee = subtotal - discount >= 20000 ? 0 : 599; 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 + shippingFee, discount_minor: discount, shipping_fee_minor: shippingFee, refund_total_minor: 0, coupon_id: couponId ?? null, group_activity_id: groupHere ? activity.id : null, group_id: groupHere ? `g-${Date.now()}` : 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, flash_sale_item_id: null, })), 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 }))), // Deterministic fixture rule: a flat per-shop fee, free at/over 200 major. quoteShipping: (_address, currency) => { const byShop = new Map(); for (const item of state.cart) { byShop.set(item.shop_id, (byShop.get(item.shop_id) ?? 0) + item.unit_price_minor * item.qty); } const shops = [...byShop.entries()].map(([shop_id, subtotal]) => ({ shop_id, fee_minor: subtotal >= 20000 ? 0 : 599, })); return Promise.resolve({ currency, shops, total_minor: shops.reduce((sum, s) => sum + s.fee_minor, 0), }); }, listShippingCompanies: () => Promise.resolve([ { code: "sf-express", name: { en: "SF Express", zh: "顺丰速运" }, active: true }, { code: "zto", name: { en: "ZTO Express", zh: "中通快递" }, active: true }, { code: "ups", name: { en: "UPS", zh: "联合包裹" }, active: true }, ]), 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 }))), applyForAftersale: async (body: AftersaleApplyBody): Promise => { if (body.kind !== "refund_only" && body.kind !== "return_refund") { throw new ApiError(400, "BAD_REQUEST", "Invalid aftersale kind"); } if (!body.reason.en?.trim() || !body.reason.zh?.trim()) { throw new ApiError(400, "BAD_REQUEST", "reason needs non-empty en and zh"); } if (!Number.isInteger(body.amount_minor) || body.amount_minor <= 0) { throw new ApiError(400, "BAD_REQUEST", "amount_minor must be positive"); } const evidence = (body.evidence ?? []).map((url) => url.trim()); if (evidence.some((url) => !url)) throw new ApiError(400, "BAD_REQUEST", "evidence URLs must be non-empty"); const found = findOrderItem(body.order_item_id); if (!found || found.order.user_id !== MOCK_USER.id) throw new ApiError(404, "NOT_FOUND", "order item"); if (!eligibleOrder(found.order)) throw new ApiError(409, "CONFLICT", "order item is not eligible for after-sale"); const active = state.aftersales.some( (row) => row.order_item_id === body.order_item_id && ["pending", "approved", "buyer_shipping", "merchant_confirmed"].includes(row.status), ); if (active) throw new ApiError(409, "CONFLICT", "an active aftersale already exists for this item"); const remaining = remainingFor( body.order_item_id, found.item.unit_price_minor * found.item.qty, ); if (body.amount_minor > remaining) throw new ApiError(409, "CONFLICT", "amount exceeds the remaining refundable balance"); state.aftersaleSeq += 1; const now = new Date().toISOString(); const row: Aftersale = { id: `as-${state.aftersaleSeq}`, order_id: found.order.id, order_item_id: body.order_item_id, shop_id: found.order.shop_id, user_id: MOCK_USER.id, kind: body.kind, status: "pending", reason: { en: body.reason.en.trim(), zh: body.reason.zh.trim() }, amount_minor: body.amount_minor, currency: found.order.currency, evidence, reopened: false, return_carrier: null, return_tracking_no: null, created_at: now, updated_at: now, }; state.aftersales = [row, ...state.aftersales]; persist(); return aftersaleDetail(row.id); }, listMyAftersales: async (): Promise => state.aftersales .filter((row) => row.user_id === MOCK_USER.id) .sort((a, b) => b.created_at.localeCompare(a.created_at)) .map(copyAftersale), getAftersale: async (id: string): Promise => aftersaleDetail(id), cancelAftersale: async (id: string): Promise => { const row = findAftersale(id); if (!["pending", "approved", "buyer_shipping", "merchant_confirmed"].includes(row.status)) { throw new ApiError(409, "CONFLICT", "aftersale cannot be cancelled"); } row.status = "cancelled"; row.updated_at = new Date().toISOString(); persist(); return copyAftersale(row); }, reopenAftersale: async (id: string): Promise => { const row = findAftersale(id); if (row.status !== "rejected" || row.reopened) { throw new ApiError(409, "CONFLICT", "aftersale cannot be reopened"); } row.status = "pending"; row.reopened = true; row.updated_at = new Date().toISOString(); persist(); return copyAftersale(row); }, submitAftersaleReturnTracking: async ( id: string, body: AftersaleReturnTrackingBody, ): Promise => { const carrier = body.carrier.trim(); const trackingNo = body.tracking_no.trim(); if (!carrier || !trackingNo) throw new ApiError(400, "BAD_REQUEST", "carrier and tracking_no are required"); const row = findAftersale(id); if (row.kind !== "return_refund") throw new ApiError(409, "CONFLICT", "not a return-refund aftersale"); if (row.status !== "approved") throw new ApiError(409, "CONFLICT", "aftersale is not awaiting return shipping"); row.status = "buyer_shipping"; row.return_carrier = carrier; row.return_tracking_no = trackingNo; row.updated_at = new Date().toISOString(); persist(); return copyAftersale(row); }, addAftersaleMessage: async ( id: string, body: AftersaleMessageBody, ): Promise => { const row = findAftersale(id); const en = body.content.en?.trim() ?? ""; const zh = body.content.zh?.trim() ?? ""; if (!en && !zh) throw new ApiError(400, "BAD_REQUEST", "content needs text"); const evidence = (body.evidence ?? []).map((url) => url.trim()); if (evidence.some((url) => !url)) throw new ApiError(400, "BAD_REQUEST", "evidence URLs must be non-empty"); state.aftersaleMessageSeq += 1; const now = new Date().toISOString(); const message: AftersaleMessage = { id: `asm-${state.aftersaleMessageSeq}`, aftersale_id: row.id, author_role: "buyer", author_id: MOCK_USER.id, content: { en: en || zh, zh: zh || en }, evidence, created_at: now, }; state.aftersaleMessages.push(message); row.updated_at = now; persist(); return { ...message, content: { ...message.content }, evidence: [...message.evidence] }; }, listProductReviews: (productId: string, page = 1) => { const visible = state.reviews .filter((review) => review.product_id === productId && review.status === "visible") .sort((a, b) => b.created_at.localeCompare(a.created_at)); const currentPage = clampPage(page); const perPage = clampPerPage(); const start = (currentPage - 1) * perPage; return Promise.resolve({ items: visible.slice(start, start + perPage).map(copyReview), total: visible.length, page: currentPage, per_page: perPage, }); }, getProductReviewSummary: (productId: string): Promise => { const visible = state.reviews.filter( (review) => review.product_id === productId && review.status === "visible", ); const distribution: Record = {}; let total = 0; for (const review of visible) { const key = String(review.rating); distribution[key] = (distribution[key] ?? 0) + 1; total += review.rating; } return Promise.resolve({ count: visible.length, avg_rating: visible.length ? Math.round((total / visible.length) * 10) / 10 : 0, distribution, }); }, listReviewableItems: (): Promise => Promise.resolve(reviewableItems()), createReview: async (body: ReviewInput): Promise => { if (!Number.isInteger(body.rating) || body.rating < 1 || body.rating > 5) { throw new ApiError(400, "BAD_REQUEST", "rating must be between 1 and 5"); } const en = body.content.en?.trim() ?? ""; const zh = body.content.zh?.trim() ?? ""; if (!en && !zh) { throw new ApiError(400, "BAD_REQUEST", "content needs text in at least one locale"); } const found = findOrderItem(body.order_item_id); if (!found || found.order.user_id !== MOCK_USER.id || found.order.status !== "completed") { throw new ApiError(409, "CONFLICT", "order line is not reviewable"); } if (state.reviews.some((review) => review.order_item_id === body.order_item_id)) { throw new ApiError(409, "CONFLICT", "order line already reviewed"); } const product = reviewProduct(found); if (!product) throw new ApiError(409, "CONFLICT", "order line is not reviewable"); state.reviewSeq += 1; const row: Review = { id: `rv-${state.reviewSeq}`, order_item_id: body.order_item_id, order_id: found.order.id, product_id: product.id, shop_id: found.order.shop_id, user_id: MOCK_USER.id, rating: body.rating, content: { ...(body.content.en !== undefined ? { en: body.content.en } : {}), ...(body.content.zh !== undefined ? { zh: body.content.zh } : {}), }, images: [...(body.images ?? [])], reply: null, reply_at: null, status: "visible", created_at: new Date().toISOString(), reviewer_name: MOCK_USER.display_name, }; state.reviews = [row, ...state.reviews]; persist(); return copyReview(row); }, // Mirror of the seeded storefront-content rows, so the home page renders // identically when every domain is configured to fixed data. getHomeContent: (): Promise => 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 => 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 }); }, listFavorites: (q: FavoriteListQuery) => { const page = clampPage(q.page); const perPage = clampPerPage(q.per_page); const visible = state.favorites .map((row) => { if (row.kind === "product") { const product = productById(row.product.id); if (!product || product.status !== "published") return null; return hydrateProductFavorite(row.id, row.created_at, product); } const store = storeById(row.shop.id); if (!store) return null; return hydrateShopFavorite(row.id, row.created_at, store); }) .filter((row): row is Favorite => row !== null) .filter((row) => row.kind === q.kind) .filter((row) => { if (!q.target_id) return true; return row.kind === "product" ? row.product.id === q.target_id : row.shop.id === q.target_id; }) .sort((a, b) => b.created_at.localeCompare(a.created_at)); const total = visible.length; const start = (page - 1) * perPage; return Promise.resolve({ items: visible.slice(start, start + perPage), total, page, per_page: perPage, }); }, addProductFavorite: (productId: string) => { const product = productById(productId); if (!product || product.status !== "published") { return Promise.reject(new ApiError(404, "NOT_FOUND", "product")); } const existing = state.favorites.find( (row) => row.kind === "product" && row.product.id === productId, ); if (existing) { return Promise.resolve(hydrateProductFavorite(existing.id, existing.created_at, product)); } state.favoriteSeq += 1; const row = hydrateProductFavorite( `f-${state.favoriteSeq}`, new Date().toISOString(), product, ); state.favorites.push(row); persist(); return Promise.resolve(row); }, removeProductFavorite: (productId: string) => { state.favorites = state.favorites.filter( (row) => !(row.kind === "product" && row.product.id === productId), ); persist(); return Promise.resolve(); }, addShopFavorite: (shopId: string) => { const store = storeById(shopId); if (!store) return Promise.reject(new ApiError(404, "NOT_FOUND", "shop")); const existing = state.favorites.find((row) => row.kind === "shop" && row.shop.id === shopId); if (existing) { return Promise.resolve(hydrateShopFavorite(existing.id, existing.created_at, store)); } state.favoriteSeq += 1; const row = hydrateShopFavorite(`f-${state.favoriteSeq}`, new Date().toISOString(), store); state.favorites.push(row); persist(); return Promise.resolve(row); }, removeShopFavorite: (shopId: string) => { state.favorites = state.favorites.filter( (row) => !(row.kind === "shop" && row.shop.id === shopId), ); persist(); return Promise.resolve(); }, 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 }); }, listPointsProducts: () => Promise.resolve(state.pointsProducts.filter((p) => p.published).map((p) => ({ ...p }))), listFlashSales: () => Promise.resolve(mockFlashSales()), listGroupBuyingActivities: () => Promise.resolve(mockGroupBuying()), listMyRedemptions: () => Promise.resolve(state.redemptions.map((o) => ({ ...o }))), redeemPoints: (body: RedeemPointsBody) => { const product = state.pointsProducts.find((p) => p.id === body.product_id && p.published); if (!product) { return Promise.reject(new ApiError(404, "NOT_FOUND", "Points product not found")); } if (body.qty <= 0 || body.qty > product.stock) { return Promise.reject(new ApiError(409, "CONFLICT", "Insufficient points stock")); } const total = product.points_price * body.qty; if (total > USER_STATS.points) { return Promise.reject(new ApiError(409, "CONFLICT", "Insufficient points")); } state.redemptionSeq += 1; const order: IntegralOrder = { id: `po-${state.redemptionSeq}`, order_no: `PM${String(state.redemptionSeq).padStart(8, "0")}`, user_id: MOCK_USER.id, status: "pending_fulfillment", total_points: total, shipping_address: body.shipping_address, items: [ { id: `poi-${state.redemptionSeq}`, order_id: `po-${state.redemptionSeq}`, product_id: product.id, name: product.name, image: product.image, points_price: product.points_price, qty: body.qty, }, ], created_at: new Date().toISOString(), }; product.stock -= body.qty; state.redemptions = [order, ...state.redemptions]; persist(); return Promise.resolve({ ...order }); }, getWallet: (): Promise => Promise.resolve({ available_minor: state.walletAvailableMinor, frozen_minor: state.walletFrozenMinor, currency: BASE_CURRENCY, }), listWalletEntries: (page = 1) => { const currentPage = clampPage(page); const ordered = [...state.walletEntries].sort((a, b) => b.created_at.localeCompare(a.created_at), ); const start = (currentPage - 1) * WALLET_ENTRIES_PER_PAGE; return Promise.resolve({ items: ordered .slice(start, start + WALLET_ENTRIES_PER_PAGE) .map((entry) => ({ ...entry })), total: ordered.length, page: currentPage, per_page: WALLET_ENTRIES_PER_PAGE, }); }, rechargeWallet: (amountMinor: number): Promise => { if (!Number.isInteger(amountMinor) || amountMinor <= 0) { return Promise.reject(new ApiError(400, "BAD_REQUEST", "amount_minor must be positive")); } const now = new Date().toISOString(); state.walletEntrySeq += 1; const rechargeId = `wr-${state.walletEntrySeq}`; state.walletAvailableMinor += amountMinor; state.walletEntries.push({ id: `we-${state.walletEntrySeq}`, account_kind: "available", delta_minor: amountMinor, balance_minor: state.walletAvailableMinor, reason: "wallet_recharge", reference_type: "wallet_recharge", reference_id: rechargeId, created_at: now, }); persist(); return Promise.resolve({ id: rechargeId, demo: true, amount_minor: amountMinor, currency: BASE_CURRENCY, status: "credited", available_minor: state.walletAvailableMinor, created_at: now, }); }, applyWithdrawal: ( amountMinor: number, details: WithdrawalAccountDetails, ): Promise => { if (!Number.isInteger(amountMinor) || amountMinor <= 0) { return Promise.reject(new ApiError(400, "BAD_REQUEST", "amount_minor must be positive")); } const method = details.method.trim(); const account = details.account.trim(); if (!method || !account) { return Promise.reject( new ApiError(400, "BAD_REQUEST", "account_details.method and account are required"), ); } if (amountMinor > state.walletAvailableMinor) { // The live backend's guarded debit turns an uncovered amount into a 409. return Promise.reject(new ApiError(409, "CONFLICT", "insufficient available balance")); } const holder = details.holder?.trim(); state.walletWithdrawalSeq += 1; const now = new Date().toISOString(); const row: WalletWithdrawal = { id: `wd-${state.walletWithdrawalSeq}`, user_id: MOCK_USER.id, user_email: MOCK_USER.email, amount_minor: amountMinor, currency: BASE_CURRENCY, account_details: { method, account, ...(holder ? { holder } : {}) }, status: "pending", review_note: null, reviewed_at: null, created_at: now, }; state.walletWithdrawals = [row, ...state.walletWithdrawals]; // Freeze: one paired two-sided movement, exactly like the live service. state.walletEntrySeq += 1; state.walletAvailableMinor -= amountMinor; state.walletEntries.push({ id: `we-${state.walletEntrySeq}`, account_kind: "available", delta_minor: -amountMinor, balance_minor: state.walletAvailableMinor, reason: "wallet_withdrawal_freeze", reference_type: "wallet_withdrawal", reference_id: row.id, created_at: now, }); state.walletEntrySeq += 1; state.walletFrozenMinor += amountMinor; state.walletEntries.push({ id: `we-${state.walletEntrySeq}`, account_kind: "frozen", delta_minor: amountMinor, balance_minor: state.walletFrozenMinor, reason: "wallet_withdrawal_freeze", reference_type: "wallet_withdrawal", reference_id: row.id, created_at: now, }); persist(); return Promise.resolve({ ...row, account_details: { ...row.account_details } }); }, listMyWithdrawals: () => Promise.resolve( [...state.walletWithdrawals] .sort((a, b) => b.created_at.localeCompare(a.created_at)) .map((row) => ({ ...row, account_details: { ...row.account_details } })), ), submitMerchantApplication: (body: MerchantApplicationInput): Promise => { const normalized = normalizeMerchantInput(body); const live = state.merchantApplications.some( (row) => row.user_id === MOCK_USER.id && (row.status === "pending" || row.status === "approved"), ); if (live) { return Promise.reject( new ApiError(409, "CONFLICT", "an active merchant application already exists"), ); } state.merchantApplicationSeq += 1; const now = merchantTime(state.merchantApplicationSeq + 1); const row: MerchantApplication = { id: `ma-${state.merchantApplicationSeq}`, user_id: MOCK_USER.id, applicant_email: MOCK_USER.email, entity_type: normalized.entity_type, real_name: normalized.real_name, company_name: normalized.company_name, business_license_no: normalized.business_license_no, category_ids: normalized.category_ids, categories: normalized.categories, contact: normalized.contact, qualification: normalized.qualification, status: "pending", rejection_reason: null, reviewed_at: null, created_shop_id: null, created_at: now, updated_at: now, }; state.merchantApplications = [row, ...state.merchantApplications]; persist(); return Promise.resolve(copyMerchantApplication(row)); }, getMyMerchantApplications: (): Promise => Promise.resolve( merchantHistory() .filter((row) => row.user_id === MOCK_USER.id) .map(copyMerchantApplication), ), getMembership: (): Promise => { // Re-derived from the ledger on every read, exactly like the live service. const growthTotal = state.growthLogs.reduce((sum, entry) => sum + entry.delta, 0); const level = levelAtOrBelow(growthTotal); const next = levelAbove(growthTotal); const nextLevel: NextMemberLevel | null = next ? { id: next.id, name: { ...next.name }, icon: next.icon, growth_threshold: next.growth_threshold, remaining: next.growth_threshold - growthTotal, } : null; return Promise.resolve({ level: level ? copyLevel(level) : null, growth_total: growthTotal, next_level: nextLevel, }); }, listGrowthLogs: (page = 1): Promise> => { const currentPage = clampPage(page); const ordered = [...state.growthLogs].sort((a, b) => b.created_at.localeCompare(a.created_at), ); const start = (currentPage - 1) * GROWTH_LOGS_PER_PAGE; return Promise.resolve({ items: ordered.slice(start, start + GROWTH_LOGS_PER_PAGE).map((entry) => ({ ...entry })), total: ordered.length, page: currentPage, per_page: GROWTH_LOGS_PER_PAGE, }); }, listMessages: (q: MessageListQuery = {}): Promise> => { const currentPage = clampPage(q.page); const perPage = clampPerPage(q.per_page ?? MESSAGES_PER_PAGE); const unreadOnly = q.unread_only ?? false; const visible = state.messages .filter((row) => row.deleted_at === null && (!unreadOnly || row.status === "unread")) .sort((a, b) => b.created_at.localeCompare(a.created_at) || b.id.localeCompare(a.id)); const start = (currentPage - 1) * perPage; return Promise.resolve({ items: visible.slice(start, start + perPage).map(toMessage), total: visible.length, page: currentPage, per_page: perPage, }); }, markMessageRead: (id: string): Promise => { const row = state.messages.find((entry) => entry.id === id); if (!row) return Promise.reject(new ApiError(404, "NOT_FOUND", "message")); // Guarded like the live UPDATE: only an unread, non-deleted row flips. if (row.deleted_at === null && row.status === "unread") { row.status = "read"; row.read_at = new Date().toISOString(); persist(); } return Promise.resolve(toMessage(row)); }, markAllMessagesRead: (): Promise => { const now = new Date().toISOString(); let updated = 0; for (const row of state.messages) { if (row.deleted_at !== null || row.status !== "unread") continue; row.status = "read"; row.read_at = now; updated += 1; } if (updated > 0) persist(); return Promise.resolve({ updated }); }, deleteMessage: (id: string): Promise => { const row = state.messages.find((entry) => entry.id === id); if (!row) return Promise.reject(new ApiError(404, "NOT_FOUND", "message")); // Soft delete is idempotent: a repeat reports that nothing flipped. if (row.deleted_at !== null) return Promise.resolve({ id, deleted: false }); row.deleted_at = new Date().toISOString(); persist(); return Promise.resolve({ id, deleted: true }); }, getUnreadCount: (): Promise => Promise.resolve({ unread: state.messages.filter((row) => row.deleted_at === null && row.status === "unread") .length, }), shop: { getMyShop: () => unsupported(), updateMyProfile: () => 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(), listFlashSales: () => unsupported(), createFlashSale: (_body: FlashSaleSessionInput) => unsupported(), updateFlashSale: (_id: string, _body: FlashSaleSessionInput) => unsupported(), deleteFlashSale: (_id: string) => unsupported(), addFlashSaleItem: (_sessionId: string, _body: FlashSaleItemInput) => unsupported(), updateFlashSaleItem: (_id: string, _body: FlashSaleItemInput) => unsupported(), deleteFlashSaleItem: (_id: string) => unsupported(), listGroupBuyingActivities: () => unsupported(), createGroupBuyingActivity: (_body: GroupBuyingActivityInput) => unsupported(), updateGroupBuyingActivity: (_id: string, _body: GroupBuyingActivityInput) => unsupported(), deleteGroupBuyingActivity: (_id: string) => unsupported(), listAftersales: (_status?: AftersaleStatus) => unsupported(), getAftersale: (_id: string) => unsupported(), approveAftersale: (_id: string) => unsupported(), rejectAftersale: (_id: string) => unsupported(), confirmAftersaleReceipt: (_id: string) => unsupported(), refundAftersale: (_id: string) => unsupported(), addAftersaleMessage: (_id: string, _body: AftersaleMessageBody) => unsupported(), listFreightTemplates: () => unsupported(), createFreightTemplate: () => unsupported(), updateFreightTemplate: () => unsupported(), deleteFreightTemplate: () => unsupported(), listReviews: (_page?: number) => unsupported(), replyReview: (_id: string, _content: Record) => unsupported(), listShopSettlementStatements: () => unsupported(), getShopSettlementStatement: () => unsupported(), generateShopSettlementStatement: () => 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(), listPointsProducts: () => unsupported(), createPointsProduct: (_body: IntegralProductInput) => unsupported(), updatePointsProduct: (_id: string, _body: IntegralProductInput) => unsupported(), setPointsProductPublished: (_id: string, _published: boolean) => unsupported(), listPointsRedemptions: (_page?: number) => unsupported(), fulfillRedemption: (_id: string) => unsupported(), cancelRedemption: (_id: string) => unsupported(), listAftersales: (_status?: AftersaleStatus) => unsupported(), getAftersale: (_id: string) => unsupported(), arbitrateAftersale: (_id: string, _outcome: "refund" | "reject") => unsupported(), listReviews: (_page?: number) => unsupported(), hideReview: (_id: string) => unsupported(), deleteReview: (_id: string) => unsupported(), listWithdrawalApplications: () => unsupported(), reviewWithdrawal: () => unsupported(), getCommissionRate: () => unsupported(), setCommissionRate: () => unsupported(), listSettlementStatements: () => unsupported(), getSettlementStatement: () => unsupported(), generateSettlementStatement: () => unsupported(), confirmSettlementStatement: () => unsupported(), listMemberLevels: () => unsupported(), createMemberLevel: (_body: MemberLevelInput) => unsupported(), updateMemberLevel: (_id: string, _body: MemberLevelInput) => unsupported(), deleteMemberLevel: (_id: string) => unsupported(), listMerchantApplications: async ( q: MerchantApplicationQuery = {}, ): Promise> => { const page = clampPage(q.page); const perPage = clampPerPage(q.per_page ?? MERCHANT_APPLICATIONS_PER_PAGE); const filtered = merchantHistory().filter((row) => !q.status || row.status === q.status); const start = (page - 1) * perPage; return { items: filtered.slice(start, start + perPage).map(copyMerchantApplication), total: filtered.length, page, per_page: perPage, }; }, getMerchantApplication: async (id: string): Promise => copyMerchantApplication(findMerchantApplication(id)), approveMerchantApplication: async (id: string): Promise => { const row = findMerchantApplication(id); if (row.status !== "pending") { throw new ApiError(409, "CONFLICT", "application was already reviewed"); } state.merchantApplicationSeq += 1; const reviewedAt = merchantTime(state.merchantApplicationSeq + 1); const shopId = `s-merchant-${state.merchantApplicationSeq}`; row.status = "approved"; row.reviewed_at = reviewedAt; row.created_shop_id = shopId; row.updated_at = reviewedAt; persist(); return { application: copyMerchantApplication(row), credentials: { email: `shop-owner+${row.id}@vmall.local`, initial_password: "Vmall-Owner-2026", shop_id: shopId, shop_slug: `merchant-shop-${state.merchantApplicationSeq}`, }, }; }, rejectMerchantApplication: async ( id: string, reason: string, ): Promise => { const trimmed = reason.trim(); if (!trimmed) throw new ApiError(400, "BAD_REQUEST", "rejection reason is required"); const row = findMerchantApplication(id); if (row.status !== "pending") { throw new ApiError(409, "CONFLICT", "application was already reviewed"); } state.merchantApplicationSeq += 1; const reviewedAt = merchantTime(state.merchantApplicationSeq + 1); row.status = "rejected"; row.rejection_reason = trimmed; row.reviewed_at = reviewedAt; row.updated_at = reviewedAt; persist(); return copyMerchantApplication(row); }, }, }; } export { defaultAddress };