feat(mall): live flash-sale page and activity price snapshots

The flash-sale page loads active sessions and their items through the shared
client with live sale price, remaining activity stock, sell-through, and a
countdown, and adds the SKU to the existing cart so checkout resolves the
authoritative price. Payment and order detail tag and render the snapshotted
activity unit price.

The flash-sale fixtures leave the page; the fixed-data adapter still serves the
domain as the rollback path.
This commit is contained in:
2026-09-18 12:53:26 +00:00
parent 393e7f7209
commit 2125ae437f
10 changed files with 283 additions and 60 deletions
+57
View File
@@ -15,6 +15,10 @@ import type {
Coupon,
CouponTemplate,
CouponTemplateInput,
FlashSaleItem,
FlashSaleItemInput,
FlashSaleSession,
FlashSaleSessionInput,
HomeContent,
IntegralOrder,
IntegralProduct,
@@ -23,6 +27,7 @@ import type {
InvoiceKind,
Order,
Product,
PublicFlashSaleSession,
RedeemPointsBody,
Shipment,
ShopProfile,
@@ -43,9 +48,11 @@ import {
USER_STATS,
MOCK_ADDRESSES,
defaultAddress,
lowestSku,
mockConvertMinor,
productById,
searchMockProducts,
seckillProducts,
seedOrders,
storeById,
} from "./data";
@@ -150,6 +157,46 @@ function seedPointsProducts(): IntegralProduct[] {
}));
}
/** One synthesized active session covering the fixed seckill fixtures. */
function mockFlashSales(): PublicFlashSaleSession[] {
const now = Date.now();
const items = seckillProducts().map((entry, index) => {
const sku = lowestSku(entry.product);
const stock = sku?.stock ?? 100;
const sold = Math.round((stock * entry.soldPct) / 100);
return {
id: `fsi${index + 1}`,
session_id: "fs1",
sku_id: sku?.id ?? "",
product_id: entry.product.id,
product_slug: entry.product.slug,
product_name: entry.product.name,
image: entry.product.images[0] ?? null,
sku_code: sku?.sku_code ?? "",
sale_price_minor: entry.seckillPriceMinor,
currency: sku?.currency ?? BASE_CURRENCY,
original_price_minor: sku?.price_minor ?? entry.seckillPriceMinor,
original_currency: sku?.currency ?? BASE_CURRENCY,
reserved_stock: Math.max(stock - sold, 0),
sold_count: sold,
per_customer_limit: 2,
};
});
return [
{
id: "fs1",
shop_id: MOCK_STORES[0].id,
label: { en: "Flash sale", zh: "限时秒杀" },
starts_at: new Date(now - 3_600_000).toISOString(),
ends_at: new Date(now + 6 * 3_600_000).toISOString(),
enabled: true,
created_at: "2026-01-01T00:00:00.000Z",
updated_at: "2026-01-01T00:00:00.000Z",
items,
},
];
}
function initialState(): MockState {
const persisted = loadPersisted();
// Coupons and points are session-only, so a restored snapshot re-seeds them.
@@ -397,6 +444,7 @@ export function createMockApi(): ApiClient {
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(),
@@ -630,6 +678,8 @@ export function createMockApi(): ApiClient {
state.pointsProducts.filter((p) => p.published).map((p) => ({ ...p })),
),
listFlashSales: () => Promise.resolve(mockFlashSales()),
listMyRedemptions: () => Promise.resolve(state.redemptions.map((o) => ({ ...o }))),
redeemPoints: (body: RedeemPointsBody) => {
@@ -693,6 +743,13 @@ export function createMockApi(): ApiClient {
createCouponTemplate: (_body: CouponTemplateInput) => unsupported(),
updateCouponTemplate: (_id: string, _body: CouponTemplateInput) => unsupported(),
deleteCouponTemplate: (_id: string) => unsupported(),
listFlashSales: () => unsupported(),
createFlashSale: (_body: FlashSaleSessionInput) => unsupported(),
updateFlashSale: (_id: string, _body: FlashSaleSessionInput) => unsupported(),
deleteFlashSale: (_id: string) => unsupported(),
addFlashSaleItem: (_sessionId: string, _body: FlashSaleItemInput) => unsupported(),
updateFlashSaleItem: (_id: string, _body: FlashSaleItemInput) => unsupported(),
deleteFlashSaleItem: (_id: string) => unsupported(),
},
admin: {
listUsers: () => unsupported(),