Platform-owned points products and a redemption order lifecycle kept separate from cash orders. Redeeming locks the published product, reserves stock, creates the order, debits points through the archived customer-accounts ledger with the order as reference, and snapshots the line in one transaction; a failure leaves no order, no stock change, and no ledger entry. Fulfilment moves only from pending_fulfillment, and customers see only their own redemptions. Demo seeding credits the demo customer through the same guarded credit path with reason seed, once, so no balance is ever written absolutely. Surfaces (admin console, mall points page) and product seeding follow.
724 lines
24 KiB
TypeScript
724 lines
24 KiB
TypeScript
// Mock API adapter: implements the full @vmall/shared ApiClient surface with
|
|
// fixed data + in-memory cart/order state. Pages call $api exactly as they
|
|
// would against the live backend, so switching back is a config flip.
|
|
|
|
import { ApiError } from "@vmall/shared";
|
|
import type {
|
|
AccountSummary,
|
|
Address,
|
|
AddressBookEntry,
|
|
AddressInput,
|
|
ApiClient,
|
|
AuthTokens,
|
|
Cart,
|
|
CartItem,
|
|
Coupon,
|
|
CouponTemplate,
|
|
CouponTemplateInput,
|
|
HomeContent,
|
|
IntegralOrder,
|
|
IntegralProduct,
|
|
IntegralProductInput,
|
|
Invoice,
|
|
InvoiceKind,
|
|
Order,
|
|
Product,
|
|
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,
|
|
mockConvertMinor,
|
|
productById,
|
|
searchMockProducts,
|
|
seedOrders,
|
|
storeById,
|
|
} from "./data";
|
|
|
|
interface MockState {
|
|
token: string | null;
|
|
cart: CartItem[];
|
|
orders: Order[];
|
|
shipments: Shipment[];
|
|
invoices: Invoice[];
|
|
addresses: AddressBookEntry[];
|
|
/** In-memory only: claims made during this browser session. */
|
|
coupons: Coupon[];
|
|
/** 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",
|
|
}));
|
|
}
|
|
|
|
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,
|
|
})),
|
|
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 })),
|
|
),
|
|
|
|
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(),
|
|
},
|
|
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 };
|