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
+24
View File
@@ -13,6 +13,10 @@ import type {
CouponTemplate,
CouponTemplateInput,
Currency,
FlashSaleItem,
FlashSaleItemInput,
FlashSaleSession,
FlashSaleSessionInput,
HomeContent,
IntegralOrder,
IntegralProduct,
@@ -25,9 +29,11 @@ import type {
Paged,
Product,
ProductStatus,
PublicFlashSaleSession,
RedeemPointsBody,
Shipment,
Shop,
ShopFlashSaleSession,
ShopProfile,
ShopProfileInput,
Sku,
@@ -208,6 +214,8 @@ export interface ApiClient {
listPointsProducts(): Promise<IntegralProduct[]>;
listMyRedemptions(): Promise<IntegralOrder[]>;
redeemPoints(body: RedeemPointsBody): Promise<IntegralOrder>;
/** Public: active flash-sale sessions with their purchasable items. */
listFlashSales(): Promise<PublicFlashSaleSession[]>;
shop: {
getMyShop(): Promise<Shop>;
listMyProducts(q?: ShopProductQuery): Promise<Paged<Product>>;
@@ -233,6 +241,13 @@ export interface ApiClient {
createCouponTemplate(body: CouponTemplateInput): Promise<CouponTemplate>;
updateCouponTemplate(id: string, body: CouponTemplateInput): Promise<CouponTemplate>;
deleteCouponTemplate(id: string): Promise<void>;
listFlashSales(): Promise<ShopFlashSaleSession[]>;
createFlashSale(body: FlashSaleSessionInput): Promise<FlashSaleSession>;
updateFlashSale(id: string, body: FlashSaleSessionInput): Promise<FlashSaleSession>;
deleteFlashSale(id: string): Promise<void>;
addFlashSaleItem(sessionId: string, body: FlashSaleItemInput): Promise<FlashSaleItem>;
updateFlashSaleItem(id: string, body: FlashSaleItemInput): Promise<FlashSaleItem>;
deleteFlashSaleItem(id: string): Promise<void>;
};
admin: {
listUsers(page?: number): Promise<Paged<User>>;
@@ -303,6 +318,7 @@ export function createApi(opts: ApiClientOptions): ApiClient {
listPointsProducts: () => r("GET", "/points/products"),
listMyRedemptions: () => r("GET", "/points/redemptions"),
redeemPoints: (body) => r("POST", "/points/redemptions", body),
listFlashSales: () => r("GET", "/flash-sales"),
shop: {
getMyShop: () => r("GET", "/shop/profile"),
listMyProducts: (q = {}) => r("GET", "/shop/products", undefined, { ...q }),
@@ -328,6 +344,14 @@ export function createApi(opts: ApiClientOptions): ApiClient {
createCouponTemplate: (body) => r("POST", "/shop/coupon-templates", body),
updateCouponTemplate: (id, body) => r("PUT", `/shop/coupon-templates/${id}`, body),
deleteCouponTemplate: (id) => r("DELETE", `/shop/coupon-templates/${id}`),
listFlashSales: () => r("GET", "/shop/flash-sales"),
createFlashSale: (body) => r("POST", "/shop/flash-sales", body),
updateFlashSale: (id, body) => r("PUT", `/shop/flash-sales/${id}`, body),
deleteFlashSale: (id) => r("DELETE", `/shop/flash-sales/${id}`),
addFlashSaleItem: (sessionId, body) =>
r("POST", `/shop/flash-sales/${sessionId}/items`, body),
updateFlashSaleItem: (id, body) => r("PUT", `/shop/flash-sale-items/${id}`, body),
deleteFlashSaleItem: (id) => r("DELETE", `/shop/flash-sale-items/${id}`),
},
admin: {
listUsers: (page = 1) => r("GET", "/admin/users", undefined, { page }),
+88
View File
@@ -145,6 +145,8 @@ export interface OrderItem {
unit_price_minor: number;
qty: number;
image: string | null;
/** Set when this line received flash-sale pricing. */
flash_sale_item_id: string | null;
}
export interface Order {
@@ -310,6 +312,92 @@ export interface RedeemPointsBody {
shipping_address: Address;
}
// ---- flash sales ----
/** Shop-owned timed session. */
export interface FlashSaleSession {
id: string;
shop_id: string;
label: LocalizedText;
starts_at: string;
ends_at: string;
enabled: boolean;
created_at: string;
updated_at: string;
}
export interface FlashSaleSessionInput {
label: LocalizedText;
starts_at: string;
ends_at: string;
enabled?: boolean;
}
export interface FlashSaleItemInput {
sku_id: string;
sale_price_minor: number;
currency: string;
reserved_stock: number;
per_customer_limit: number;
}
export interface FlashSaleItem {
id: string;
session_id: string;
sku_id: string;
sale_price_minor: number;
currency: string;
/** Remaining activity inventory. */
reserved_stock: number;
sold_count: number;
per_customer_limit: number;
created_at: string;
updated_at: string;
}
/** Shop-side item joined with its SKU and catalog price. */
export interface ShopFlashSaleItem {
id: string;
session_id: string;
sku_id: string;
sku_code: string;
product_name: LocalizedText;
sale_price_minor: number;
currency: string;
original_price_minor: number;
original_currency: string;
reserved_stock: number;
sold_count: number;
per_customer_limit: number;
}
export interface ShopFlashSaleSession extends FlashSaleSession {
items: ShopFlashSaleItem[];
}
/** Public discovery item carrying everything needed to add it to the cart. */
export interface PublicFlashSaleItem {
id: string;
session_id: string;
sku_id: string;
product_id: string;
product_slug: string;
product_name: LocalizedText;
image: string | null;
sku_code: string;
sale_price_minor: number;
currency: string;
original_price_minor: number;
original_currency: string;
reserved_stock: number;
sold_count: number;
per_customer_limit: number;
}
export interface PublicFlashSaleSession extends FlashSaleSession {
items: PublicFlashSaleItem[];
}
export type ShipmentStatus = "pending" | "shipped" | "delivered";export interface Shipment {
id: string;
shipment_no: string;