feat(reviews): order-line reviews with merchant reply and platform moderation (add-product-reviews)

This commit is contained in:
Chengdong Zhang
2026-09-24 16:38:05 +08:00
parent 473d19d089
commit c532f87b03
34 changed files with 2055 additions and 36 deletions
+166 -2
View File
@@ -43,6 +43,10 @@ import type {
Product,
PublicFlashSaleSession,
RedeemPointsBody,
Review,
ReviewInput,
ReviewableItem,
ReviewSummary,
Shipment,
ShopProfile,
User,
@@ -87,6 +91,8 @@ interface MockState {
/** 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[];
@@ -97,9 +103,10 @@ interface MockState {
redemptionSeq: number;
aftersaleSeq: number;
aftersaleMessageSeq: number;
reviewSeq: number;
}
// v5: customer aftersales joined the persisted rollback state.
const STORAGE_KEY = "vmall.mock.state.v5";
// v6: customer reviews joined the persisted rollback state.
const STORAGE_KEY = "vmall.mock.state.v6";
type PersistedState = Pick<
MockState,
@@ -111,12 +118,14 @@ type PersistedState = Pick<
| "favorites"
| "aftersales"
| "aftersaleMessages"
| "reviews"
| "orderSeq"
| "invoiceSeq"
| "addressSeq"
| "favoriteSeq"
| "aftersaleSeq"
| "aftersaleMessageSeq"
| "reviewSeq"
>;
// Load cart/order session state persisted by a previous page load (client only).
@@ -136,6 +145,7 @@ function loadPersisted(): PersistedState | 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;
@@ -445,6 +455,34 @@ function seedAftersaleMessages(aftersales: Aftersale[]): AftersaleMessage[] {
: [];
}
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.
@@ -488,6 +526,7 @@ function initialState(): MockState {
coupons: seedCoupons(),
favorites: seedFavorites(),
aftersales,
reviews: seedReviews(seed.orders),
aftersaleMessages: seedAftersaleMessages(aftersales),
pointsProducts: seedPointsProducts(),
redemptions: [],
@@ -497,6 +536,7 @@ function initialState(): MockState {
invoiceSeq: 100,
redemptionSeq: 0,
aftersaleSeq: 100,
reviewSeq: 1,
aftersaleMessageSeq: 100,
};
}
@@ -557,8 +597,10 @@ export function createMockApi(): ApiClient {
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 {
@@ -641,6 +683,44 @@ export function createMockApi(): ApiClient {
};
}
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()),
@@ -1066,6 +1146,85 @@ export function createMockApi(): ApiClient {
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> =>
@@ -1369,6 +1528,8 @@ export function createMockApi(): ApiClient {
createFreightTemplate: () => unsupported(),
updateFreightTemplate: () => unsupported(),
deleteFreightTemplate: () => unsupported(),
listReviews: (_page?: number) => unsupported(),
replyReview: (_id: string, _content: Record<string, string>) => unsupported(),
},
admin: {
listUsers: () => unsupported(),
@@ -1395,6 +1556,9 @@ export function createMockApi(): ApiClient {
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(),
},
};
}