1567 lines
54 KiB
TypeScript
1567 lines
54 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,
|
|
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,
|
|
HomeContent,
|
|
IntegralOrder,
|
|
IntegralProduct,
|
|
IntegralProductInput,
|
|
Invoice,
|
|
InvoiceKind,
|
|
Order,
|
|
Product,
|
|
PublicFlashSaleSession,
|
|
RedeemPointsBody,
|
|
Review,
|
|
ReviewInput,
|
|
ReviewableItem,
|
|
ReviewSummary,
|
|
Shipment,
|
|
ShopProfile,
|
|
User,
|
|
} 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[];
|
|
addressSeq: number;
|
|
favoriteSeq: number;
|
|
orderSeq: number;
|
|
invoiceSeq: number;
|
|
redemptionSeq: number;
|
|
aftersaleSeq: number;
|
|
aftersaleMessageSeq: number;
|
|
reviewSeq: number;
|
|
}
|
|
// v6: customer reviews joined the persisted rollback state.
|
|
const STORAGE_KEY = "vmall.mock.state.v6";
|
|
|
|
type PersistedState = Pick<
|
|
MockState,
|
|
| "cart"
|
|
| "orders"
|
|
| "shipments"
|
|
| "invoices"
|
|
| "addresses"
|
|
| "favorites"
|
|
| "aftersales"
|
|
| "aftersaleMessages"
|
|
| "reviews"
|
|
| "orderSeq"
|
|
| "invoiceSeq"
|
|
| "addressSeq"
|
|
| "favoriteSeq"
|
|
| "aftersaleSeq"
|
|
| "aftersaleMessageSeq"
|
|
| "reviewSeq"
|
|
>;
|
|
|
|
// 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;
|
|
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;
|
|
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,
|
|
},
|
|
];
|
|
}
|
|
|
|
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: [],
|
|
addressSeq: 100,
|
|
favoriteSeq: 200,
|
|
orderSeq: 100,
|
|
invoiceSeq: 100,
|
|
redemptionSeq: 0,
|
|
aftersaleSeq: 100,
|
|
reviewSeq: 1,
|
|
aftersaleMessageSeq: 100,
|
|
};
|
|
}
|
|
|
|
function unsupported(): never {
|
|
throw new ApiError(
|
|
501,
|
|
"MOCK_UNSUPPORTED",
|
|
"This admin/shop endpoint is not part of the mall mock.",
|
|
);
|
|
}
|
|
|
|
type MockStoreRecord = NonNullable<ReturnType<typeof storeById>>;
|
|
|
|
/** Mock stores carry a nested `rate`; the API exposes flat score fields. */
|
|
function toShopProfile(store: MockStoreRecord): ShopProfile {
|
|
return {
|
|
id: store.id,
|
|
slug: store.slug,
|
|
name: store.name,
|
|
company: store.company,
|
|
region: store.region,
|
|
address: store.address,
|
|
logo: store.logo,
|
|
banner: store.banner,
|
|
notice: store.notice,
|
|
after_sale: store.afterSale,
|
|
score_rating: store.rate.score,
|
|
score_agreement: store.rate.agree,
|
|
score_service: store.rate.service,
|
|
score_speed: store.rate.speed,
|
|
};
|
|
}
|
|
|
|
function skuIndex(): Record<string, { product: Product; skuId: string }> {
|
|
const index: Record<string, { product: Product; skuId: string }> = {};
|
|
for (const p of searchMockProducts({ perPage: 1000 }).items) {
|
|
for (const s of p.skus ?? []) index[s.id] = { product: p, skuId: s.id };
|
|
}
|
|
return index;
|
|
}
|
|
|
|
export function createMockApi(): ApiClient {
|
|
const state = initialState();
|
|
function persist(): void {
|
|
if (!import.meta.client) return;
|
|
try {
|
|
const snapshot: PersistedState = {
|
|
cart: state.cart,
|
|
orders: state.orders,
|
|
shipments: state.shipments,
|
|
invoices: state.invoices,
|
|
orderSeq: state.orderSeq,
|
|
invoiceSeq: state.invoiceSeq,
|
|
addresses: state.addresses,
|
|
addressSeq: state.addressSeq,
|
|
favorites: state.favorites,
|
|
favoriteSeq: state.favoriteSeq,
|
|
aftersales: state.aftersales,
|
|
aftersaleMessages: state.aftersaleMessages,
|
|
reviews: state.reviews,
|
|
aftersaleSeq: state.aftersaleSeq,
|
|
aftersaleMessageSeq: state.aftersaleMessageSeq,
|
|
reviewSeq: state.reviewSeq,
|
|
};
|
|
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,
|
|
},
|
|
];
|
|
}),
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|
|
// 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<string, number>();
|
|
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<AftersaleDetail> => {
|
|
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<Aftersale[]> =>
|
|
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> => aftersaleDetail(id),
|
|
|
|
cancelAftersale: async (id: string): Promise<Aftersale> => {
|
|
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<Aftersale> => {
|
|
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<Aftersale> => {
|
|
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<AftersaleMessage> => {
|
|
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<ReviewSummary> => {
|
|
const visible = state.reviews.filter(
|
|
(review) => review.product_id === productId && review.status === "visible",
|
|
);
|
|
const distribution: Record<string, number> = {};
|
|
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<ReviewableItem[]> =>
|
|
Promise.resolve(reviewableItems()),
|
|
|
|
createReview: async (body: ReviewInput): Promise<Review> => {
|
|
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<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 });
|
|
},
|
|
|
|
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 });
|
|
},
|
|
|
|
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<string, 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(),
|
|
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(),
|
|
},
|
|
};
|
|
}
|
|
|
|
export { defaultAddress };
|