The flash-sale page loads active sessions and their items through the shared client with live sale price, remaining activity stock, sell-through, and a countdown, and adds the SKU to the existing cart so checkout resolves the authoritative price. Payment and order detail tag and render the snapshotted activity unit price. The flash-sale fixtures leave the page; the fixed-data adapter still serves the domain as the rollback path.
781 lines
26 KiB
TypeScript
781 lines
26 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,
|
|
FlashSaleItem,
|
|
FlashSaleItemInput,
|
|
FlashSaleSession,
|
|
FlashSaleSessionInput,
|
|
HomeContent,
|
|
IntegralOrder,
|
|
IntegralProduct,
|
|
IntegralProductInput,
|
|
Invoice,
|
|
InvoiceKind,
|
|
Order,
|
|
Product,
|
|
PublicFlashSaleSession,
|
|
RedeemPointsBody,
|
|
Shipment,
|
|
ShopProfile,
|
|
User,
|
|
} from "@vmall/shared";
|
|
import {
|
|
BASE_CURRENCY,
|
|
MOCK_BANNERS,
|
|
MOCK_BRANDS,
|
|
MOCK_CATEGORIES,
|
|
MOCK_COUPONS,
|
|
MOCK_CURRENCIES,
|
|
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[];
|
|
/** In-memory points catalog and redemptions for the fixed-data path. */
|
|
pointsProducts: IntegralProduct[];
|
|
redemptions: IntegralOrder[];
|
|
addressSeq: number;
|
|
orderSeq: number;
|
|
invoiceSeq: number;
|
|
redemptionSeq: 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 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,
|
|
},
|
|
];
|
|
}
|
|
|
|
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 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(),
|
|
pointsProducts: seedPointsProducts(),
|
|
redemptions: [],
|
|
addressSeq: 100,
|
|
orderSeq: 100,
|
|
invoiceSeq: 100,
|
|
redemptionSeq: 0,
|
|
};
|
|
}
|
|
|
|
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,
|
|
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 }))),
|
|
|
|
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 });
|
|
},
|
|
|
|
listPointsProducts: () =>
|
|
Promise.resolve(
|
|
state.pointsProducts.filter((p) => p.published).map((p) => ({ ...p })),
|
|
),
|
|
|
|
listFlashSales: () => Promise.resolve(mockFlashSales()),
|
|
|
|
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 });
|
|
},
|
|
|
|
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(),
|
|
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(),
|
|
},
|
|
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(),
|
|
},
|
|
};
|
|
}
|
|
|
|
export { defaultAddress };
|