Files
vmall/apps/mall/mock/api.ts
T
james e540b91491 feat(mall): live group-buying page with open or join intent
The group-buying page lists active activities, the group price against the
catalog price, and each open group with its paid-member count and expiry. A
shopper opens a group or picks one to join; either way the activity SKU goes
into the cart and the intent is carried to checkout, which submits it and hides
the coupon picker for that order. Payment and order detail show the snapshotted
group price.

The group-buying fixtures leave the page; the fixed-data adapter still serves
the domain as the rollback path.
2026-09-18 13:22:47 +00:00

853 lines
29 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,
GroupBuyIntent,
GroupBuyingActivity,
GroupBuyingActivityInput,
GroupBuyingActivityView,
HomeContent,
IntegralOrder,
IntegralProduct,
IntegralProductInput,
Invoice,
InvoiceKind,
Order,
Product,
PublicFlashSaleSession,
RedeemPointsBody,
Shipment,
ShopProfile,
User,
} from "@vmall/shared";
import {
BASE_CURRENCY,
MOCK_BANNERS,
collectiveProducts,
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,
},
];
}
/** 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 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> = {},
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<string, CartItem[]> = {};
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);
}
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,
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 }))),
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()),
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 });
},
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(),
listGroupBuyingActivities: () => unsupported(),
createGroupBuyingActivity: (_body: GroupBuyingActivityInput) => unsupported(),
updateGroupBuyingActivity: (_id: string, _body: GroupBuyingActivityInput) => unsupported(),
deleteGroupBuyingActivity: (_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 };