feat: persist customer product and shop favorites through the live API

Replace mall fixture favorites with customer-scoped endpoints, and send signed-out shoppers back to the page they left after sign-in.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Chengdong Zhang
2026-09-21 18:51:45 +08:00
co-authored by Cursor
parent 94a64ec712
commit 6c1357ec4d
34 changed files with 1783 additions and 119 deletions
+154 -3
View File
@@ -15,6 +15,9 @@ import type {
Coupon,
CouponTemplate,
CouponTemplateInput,
Favorite,
FavoriteListQuery,
FavoriteProductSummary,
FlashSaleItem,
FlashSaleItemInput,
FlashSaleSession,
@@ -45,6 +48,7 @@ import {
MOCK_CATEGORIES,
MOCK_COUPONS,
MOCK_CURRENCIES,
MOCK_FAVORITES,
INTEGRAL_PRODUCTS,
MOCK_PROMOS,
MOCK_QUICK_LINKS,
@@ -71,19 +75,26 @@ interface MockState {
addresses: AddressBookEntry[];
/** In-memory only: claims made during this browser session. */
coupons: Coupon[];
/** Persisted customer favorites for fixed-adapter reload parity. */
favorites: Favorite[];
/** 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;
}
// v3: address book joined the persisted state.
const STORAGE_KEY = "vmall.mock.state.v3";
// v4: customer favorites joined the persisted rollback state.
const STORAGE_KEY = "vmall.mock.state.v4";
type PersistedState = Pick<MockState, "cart" | "orders" | "shipments" | "invoices" | "addresses" | "orderSeq" | "invoiceSeq" | "addressSeq">;
type PersistedState = Pick<
MockState,
"cart" | "orders" | "shipments" | "invoices" | "addresses" | "favorites" |
"orderSeq" | "invoiceSeq" | "addressSeq" | "favoriteSeq"
>;
// Load cart/order session state persisted by a previous page load (client only).
function loadPersisted(): PersistedState | null {
@@ -98,6 +109,7 @@ function loadPersisted(): PersistedState | 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;
return p as PersistedState;
} catch {
return null;
@@ -145,6 +157,64 @@ 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,
@@ -277,9 +347,11 @@ function initialState(): MockState {
invoices: seed.invoices,
addresses: seededAddresses,
coupons: seedCoupons(),
favorites: seedFavorites(),
pointsProducts: seedPointsProducts(),
redemptions: [],
addressSeq: 100,
favoriteSeq: 200,
orderSeq: 100,
invoiceSeq: 100,
redemptionSeq: 0,
@@ -335,6 +407,8 @@ export function createMockApi(): ApiClient {
invoiceSeq: state.invoiceSeq,
addresses: state.addresses,
addressSeq: state.addressSeq,
favorites: state.favorites,
favoriteSeq: state.favoriteSeq,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot));
} catch {
@@ -722,6 +796,83 @@ export function createMockApi(): ApiClient {
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))),