feat(reviews): order-line reviews with merchant reply and platform moderation (add-product-reviews)
This commit is contained in:
+166
-2
@@ -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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user