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:
co-authored by
Cursor
parent
94a64ec712
commit
6c1357ec4d
+154
-3
@@ -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))),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user