@86d-app/reviews 0.0.22 → 0.0.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/modules/reviews/src/__tests__/controllers.test.js +937 -0
- package/dist/modules/reviews/src/__tests__/endpoint-security.test.js +438 -0
- package/dist/modules/reviews/src/__tests__/new-features.test.js +467 -0
- package/dist/modules/reviews/src/__tests__/service-impl.test.js +1042 -0
- package/dist/modules/reviews/src/__tests__/store-endpoints.test.js +456 -0
- package/dist/{admin/components/index.d.ts → modules/reviews/src/admin/components/index.js} +0 -1
- package/dist/modules/reviews/src/admin/components/review-analytics.jsx +141 -0
- package/dist/modules/reviews/src/admin/components/review-list.jsx +181 -0
- package/dist/modules/reviews/src/admin/components/review-moderation.jsx +297 -0
- package/dist/modules/reviews/src/admin/endpoints/approve-review.js +11 -0
- package/dist/modules/reviews/src/admin/endpoints/delete-review.js +12 -0
- package/dist/modules/reviews/src/admin/endpoints/get-review.js +11 -0
- package/dist/modules/reviews/src/admin/endpoints/index.js +26 -0
- package/dist/modules/reviews/src/admin/endpoints/list-reports.js +19 -0
- package/dist/modules/reviews/src/admin/endpoints/list-review-requests.js +17 -0
- package/dist/modules/reviews/src/admin/endpoints/list-reviews.js +19 -0
- package/dist/modules/reviews/src/admin/endpoints/reject-review.js +11 -0
- package/dist/modules/reviews/src/admin/endpoints/respond-review.js +14 -0
- package/dist/modules/reviews/src/admin/endpoints/review-analytics.js +8 -0
- package/dist/modules/reviews/src/admin/endpoints/review-request-stats.js +6 -0
- package/dist/modules/reviews/src/admin/endpoints/send-review-request.js +31 -0
- package/dist/modules/reviews/src/admin/endpoints/update-report.js +14 -0
- package/dist/modules/reviews/src/index.js +57 -0
- package/dist/modules/reviews/src/schema.js +63 -0
- package/dist/modules/reviews/src/service-impl.js +349 -0
- package/dist/modules/reviews/src/service.js +1 -0
- package/dist/modules/reviews/src/store/components/_hooks.js +10 -0
- package/dist/modules/reviews/src/store/components/_utils.js +17 -0
- package/dist/modules/reviews/src/store/components/distribution-bars.jsx +5 -0
- package/dist/modules/reviews/src/store/components/index.jsx +17 -0
- package/dist/modules/reviews/src/store/components/product-reviews.jsx +94 -0
- package/dist/modules/reviews/src/store/components/review-card.jsx +44 -0
- package/dist/modules/reviews/src/store/components/review-form.jsx +39 -0
- package/dist/modules/reviews/src/store/components/reviews-summary.jsx +15 -0
- package/dist/modules/reviews/src/store/components/star-display.jsx +12 -0
- package/dist/modules/reviews/src/store/components/star-picker.jsx +7 -0
- package/dist/modules/reviews/src/store/endpoints/index.js +12 -0
- package/dist/modules/reviews/src/store/endpoints/list-my-reviews.js +29 -0
- package/dist/modules/reviews/src/store/endpoints/list-product-reviews.js +24 -0
- package/dist/modules/reviews/src/store/endpoints/mark-helpful.js +29 -0
- package/dist/modules/reviews/src/store/endpoints/report-review.js +30 -0
- package/dist/modules/reviews/src/store/endpoints/submit-review.js +46 -0
- package/package.json +1 -1
- package/src/__tests__/store-endpoints.test.ts +648 -0
- package/src/admin/components/review-list.tsx +3 -2
- package/src/admin/components/review-moderation.tsx +3 -2
- package/src/service-impl.ts +17 -26
- package/src/service.ts +18 -18
- package/src/store/components/_utils.ts +3 -2
- package/src/store/components/product-reviews.tsx +38 -6
- package/dist/__tests__/controllers.test.d.ts +0 -2
- package/dist/__tests__/controllers.test.d.ts.map +0 -1
- package/dist/__tests__/endpoint-security.test.d.ts +0 -2
- package/dist/__tests__/endpoint-security.test.d.ts.map +0 -1
- package/dist/__tests__/new-features.test.d.ts +0 -2
- package/dist/__tests__/new-features.test.d.ts.map +0 -1
- package/dist/__tests__/service-impl.test.d.ts +0 -2
- package/dist/__tests__/service-impl.test.d.ts.map +0 -1
- package/dist/admin/components/index.d.ts.map +0 -1
- package/dist/admin/components/review-analytics.d.ts +0 -2
- package/dist/admin/components/review-analytics.d.ts.map +0 -1
- package/dist/admin/components/review-list.d.ts +0 -2
- package/dist/admin/components/review-list.d.ts.map +0 -1
- package/dist/admin/components/review-moderation.d.ts +0 -6
- package/dist/admin/components/review-moderation.d.ts.map +0 -1
- package/dist/admin/endpoints/approve-review.d.ts +0 -16
- package/dist/admin/endpoints/approve-review.d.ts.map +0 -1
- package/dist/admin/endpoints/delete-review.d.ts +0 -16
- package/dist/admin/endpoints/delete-review.d.ts.map +0 -1
- package/dist/admin/endpoints/get-review.d.ts +0 -16
- package/dist/admin/endpoints/get-review.d.ts.map +0 -1
- package/dist/admin/endpoints/index.d.ts +0 -167
- package/dist/admin/endpoints/index.d.ts.map +0 -1
- package/dist/admin/endpoints/list-reports.d.ts +0 -17
- package/dist/admin/endpoints/list-reports.d.ts.map +0 -1
- package/dist/admin/endpoints/list-review-requests.d.ts +0 -11
- package/dist/admin/endpoints/list-review-requests.d.ts.map +0 -1
- package/dist/admin/endpoints/list-reviews.d.ts +0 -18
- package/dist/admin/endpoints/list-reviews.d.ts.map +0 -1
- package/dist/admin/endpoints/reject-review.d.ts +0 -16
- package/dist/admin/endpoints/reject-review.d.ts.map +0 -1
- package/dist/admin/endpoints/respond-review.d.ts +0 -19
- package/dist/admin/endpoints/respond-review.d.ts.map +0 -1
- package/dist/admin/endpoints/review-analytics.d.ts +0 -6
- package/dist/admin/endpoints/review-analytics.d.ts.map +0 -1
- package/dist/admin/endpoints/review-request-stats.d.ts +0 -6
- package/dist/admin/endpoints/review-request-stats.d.ts.map +0 -1
- package/dist/admin/endpoints/send-review-request.d.ts +0 -23
- package/dist/admin/endpoints/send-review-request.d.ts.map +0 -1
- package/dist/admin/endpoints/update-report.d.ts +0 -22
- package/dist/admin/endpoints/update-report.d.ts.map +0 -1
- package/dist/index.d.ts +0 -8
- package/dist/index.d.ts.map +0 -1
- package/dist/schema.d.ts +0 -136
- package/dist/schema.d.ts.map +0 -1
- package/dist/service-impl.d.ts +0 -6
- package/dist/service-impl.d.ts.map +0 -1
- package/dist/service.d.ts +0 -149
- package/dist/service.d.ts.map +0 -1
- package/dist/store/components/_hooks.d.ts +0 -6
- package/dist/store/components/_hooks.d.ts.map +0 -1
- package/dist/store/components/_utils.d.ts +0 -3
- package/dist/store/components/_utils.d.ts.map +0 -1
- package/dist/store/components/distribution-bars.d.ts +0 -5
- package/dist/store/components/distribution-bars.d.ts.map +0 -1
- package/dist/store/components/index.d.ts +0 -18
- package/dist/store/components/index.d.ts.map +0 -1
- package/dist/store/components/product-reviews.d.ts +0 -5
- package/dist/store/components/product-reviews.d.ts.map +0 -1
- package/dist/store/components/review-card.d.ts +0 -18
- package/dist/store/components/review-card.d.ts.map +0 -1
- package/dist/store/components/review-form.d.ts +0 -5
- package/dist/store/components/review-form.d.ts.map +0 -1
- package/dist/store/components/reviews-summary.d.ts +0 -4
- package/dist/store/components/reviews-summary.d.ts.map +0 -1
- package/dist/store/components/star-display.d.ts +0 -5
- package/dist/store/components/star-display.d.ts.map +0 -1
- package/dist/store/components/star-picker.d.ts +0 -5
- package/dist/store/components/star-picker.d.ts.map +0 -1
- package/dist/store/endpoints/index.d.ts +0 -116
- package/dist/store/endpoints/index.d.ts.map +0 -1
- package/dist/store/endpoints/list-my-reviews.d.ts +0 -30
- package/dist/store/endpoints/list-my-reviews.d.ts.map +0 -1
- package/dist/store/endpoints/list-product-reviews.d.ts +0 -23
- package/dist/store/endpoints/list-product-reviews.d.ts.map +0 -1
- package/dist/store/endpoints/mark-helpful.d.ts +0 -18
- package/dist/store/endpoints/mark-helpful.d.ts.map +0 -1
- package/dist/store/endpoints/report-review.d.ts +0 -27
- package/dist/store/endpoints/report-review.d.ts.map +0 -1
- package/dist/store/endpoints/submit-review.d.ts +0 -25
- package/dist/store/endpoints/submit-review.d.ts.map +0 -1
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createAdminEndpoint, z } from "@86d-app/core";
|
|
2
|
+
export const sendReviewRequest = createAdminEndpoint("/admin/reviews/send-request", {
|
|
3
|
+
method: "POST",
|
|
4
|
+
body: z.object({
|
|
5
|
+
orderId: z.string(),
|
|
6
|
+
orderNumber: z.string(),
|
|
7
|
+
email: z.string().email(),
|
|
8
|
+
customerName: z.string(),
|
|
9
|
+
items: z.array(z.object({
|
|
10
|
+
productId: z.string(),
|
|
11
|
+
name: z.string(),
|
|
12
|
+
})),
|
|
13
|
+
}),
|
|
14
|
+
}, async (ctx) => {
|
|
15
|
+
const controller = ctx.context.controllers.reviews;
|
|
16
|
+
const existing = await controller.getReviewRequest(ctx.body.orderId);
|
|
17
|
+
if (existing) {
|
|
18
|
+
return {
|
|
19
|
+
error: "Review request already sent for this order",
|
|
20
|
+
status: 409,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const request = await controller.createReviewRequest({
|
|
24
|
+
orderId: ctx.body.orderId,
|
|
25
|
+
orderNumber: ctx.body.orderNumber,
|
|
26
|
+
email: ctx.body.email,
|
|
27
|
+
customerName: ctx.body.customerName,
|
|
28
|
+
items: ctx.body.items,
|
|
29
|
+
});
|
|
30
|
+
return { request };
|
|
31
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createAdminEndpoint, z } from "@86d-app/core";
|
|
2
|
+
export const updateReport = createAdminEndpoint("/admin/reviews/reports/:id/update", {
|
|
3
|
+
method: "PUT",
|
|
4
|
+
params: z.object({ id: z.string() }),
|
|
5
|
+
body: z.object({
|
|
6
|
+
status: z.enum(["resolved", "dismissed"]),
|
|
7
|
+
}),
|
|
8
|
+
}, async (ctx) => {
|
|
9
|
+
const controller = ctx.context.controllers.reviews;
|
|
10
|
+
const report = await controller.updateReportStatus(ctx.params.id, ctx.body.status);
|
|
11
|
+
if (!report)
|
|
12
|
+
return { error: "Report not found", status: 404 };
|
|
13
|
+
return { report };
|
|
14
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { adminEndpoints } from "./admin/endpoints";
|
|
2
|
+
import { reviewsSchema } from "./schema";
|
|
3
|
+
import { createReviewController } from "./service-impl";
|
|
4
|
+
import { storeEndpoints } from "./store/endpoints";
|
|
5
|
+
export default function reviews(options) {
|
|
6
|
+
return {
|
|
7
|
+
id: "reviews",
|
|
8
|
+
version: "0.0.2",
|
|
9
|
+
schema: reviewsSchema,
|
|
10
|
+
exports: {
|
|
11
|
+
read: ["productRating", "reviewCount"],
|
|
12
|
+
},
|
|
13
|
+
events: {
|
|
14
|
+
emits: [
|
|
15
|
+
"review.submitted",
|
|
16
|
+
"review.approved",
|
|
17
|
+
"review.rejected",
|
|
18
|
+
"review.responded",
|
|
19
|
+
"review.requested",
|
|
20
|
+
"review.reported",
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
init: async (ctx) => {
|
|
24
|
+
const controller = createReviewController(ctx.data, {
|
|
25
|
+
autoApprove: options?.autoApprove === "true",
|
|
26
|
+
});
|
|
27
|
+
return { controllers: { reviews: controller } };
|
|
28
|
+
},
|
|
29
|
+
endpoints: {
|
|
30
|
+
store: storeEndpoints,
|
|
31
|
+
admin: adminEndpoints,
|
|
32
|
+
},
|
|
33
|
+
admin: {
|
|
34
|
+
pages: [
|
|
35
|
+
{
|
|
36
|
+
path: "/admin/reviews",
|
|
37
|
+
component: "ReviewList",
|
|
38
|
+
label: "Reviews",
|
|
39
|
+
icon: "Star",
|
|
40
|
+
group: "Marketing",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
path: "/admin/reviews/analytics",
|
|
44
|
+
component: "ReviewAnalytics",
|
|
45
|
+
label: "Review Analytics",
|
|
46
|
+
icon: "ChartBar",
|
|
47
|
+
group: "Marketing",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
path: "/admin/reviews/:id",
|
|
51
|
+
component: "ReviewModeration",
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
options,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export const reviewsSchema = {
|
|
2
|
+
review: {
|
|
3
|
+
fields: {
|
|
4
|
+
id: { type: "string", required: true },
|
|
5
|
+
productId: { type: "string", required: true },
|
|
6
|
+
customerId: { type: "string", required: false },
|
|
7
|
+
authorName: { type: "string", required: true },
|
|
8
|
+
authorEmail: { type: "string", required: true },
|
|
9
|
+
rating: { type: "number", required: true },
|
|
10
|
+
title: { type: "string", required: false },
|
|
11
|
+
body: { type: "string", required: true },
|
|
12
|
+
status: { type: "string", required: true, defaultValue: "pending" },
|
|
13
|
+
isVerifiedPurchase: {
|
|
14
|
+
type: "boolean",
|
|
15
|
+
required: true,
|
|
16
|
+
defaultValue: false,
|
|
17
|
+
},
|
|
18
|
+
helpfulCount: { type: "number", required: true, defaultValue: 0 },
|
|
19
|
+
images: { type: "json", required: false },
|
|
20
|
+
merchantResponse: { type: "string", required: false },
|
|
21
|
+
merchantResponseAt: { type: "date", required: false },
|
|
22
|
+
moderationNote: { type: "string", required: false },
|
|
23
|
+
createdAt: {
|
|
24
|
+
type: "date",
|
|
25
|
+
required: true,
|
|
26
|
+
defaultValue: () => new Date(),
|
|
27
|
+
},
|
|
28
|
+
updatedAt: {
|
|
29
|
+
type: "date",
|
|
30
|
+
required: true,
|
|
31
|
+
defaultValue: () => new Date(),
|
|
32
|
+
onUpdate: () => new Date(),
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
reviewVote: {
|
|
37
|
+
fields: {
|
|
38
|
+
id: { type: "string", required: true },
|
|
39
|
+
reviewId: { type: "string", required: true },
|
|
40
|
+
voterId: { type: "string", required: true },
|
|
41
|
+
createdAt: {
|
|
42
|
+
type: "date",
|
|
43
|
+
required: true,
|
|
44
|
+
defaultValue: () => new Date(),
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
reviewReport: {
|
|
49
|
+
fields: {
|
|
50
|
+
id: { type: "string", required: true },
|
|
51
|
+
reviewId: { type: "string", required: true },
|
|
52
|
+
reporterId: { type: "string", required: false },
|
|
53
|
+
reason: { type: "string", required: true },
|
|
54
|
+
details: { type: "string", required: false },
|
|
55
|
+
status: { type: "string", required: true, defaultValue: "pending" },
|
|
56
|
+
createdAt: {
|
|
57
|
+
type: "date",
|
|
58
|
+
required: true,
|
|
59
|
+
defaultValue: () => new Date(),
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
};
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
function sortReviews(reviews, sortBy) {
|
|
2
|
+
const sorted = [...reviews];
|
|
3
|
+
switch (sortBy) {
|
|
4
|
+
case "recent":
|
|
5
|
+
return sorted.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
6
|
+
case "oldest":
|
|
7
|
+
return sorted.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
|
8
|
+
case "highest":
|
|
9
|
+
return sorted.sort((a, b) => b.rating - a.rating);
|
|
10
|
+
case "lowest":
|
|
11
|
+
return sorted.sort((a, b) => a.rating - b.rating);
|
|
12
|
+
case "helpful":
|
|
13
|
+
return sorted.sort((a, b) => b.helpfulCount - a.helpfulCount);
|
|
14
|
+
default:
|
|
15
|
+
return sorted;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function createReviewController(data, options) {
|
|
19
|
+
return {
|
|
20
|
+
async createReview(params) {
|
|
21
|
+
const id = crypto.randomUUID();
|
|
22
|
+
const now = new Date();
|
|
23
|
+
const status = options?.autoApprove === true ? "approved" : "pending";
|
|
24
|
+
const review = {
|
|
25
|
+
id,
|
|
26
|
+
productId: params.productId,
|
|
27
|
+
customerId: params.customerId,
|
|
28
|
+
authorName: params.authorName,
|
|
29
|
+
authorEmail: params.authorEmail,
|
|
30
|
+
rating: params.rating,
|
|
31
|
+
title: params.title,
|
|
32
|
+
body: params.body,
|
|
33
|
+
status,
|
|
34
|
+
isVerifiedPurchase: params.isVerifiedPurchase ?? false,
|
|
35
|
+
helpfulCount: 0,
|
|
36
|
+
...(params.images && params.images.length > 0
|
|
37
|
+
? { images: params.images }
|
|
38
|
+
: {}),
|
|
39
|
+
createdAt: now,
|
|
40
|
+
updatedAt: now,
|
|
41
|
+
};
|
|
42
|
+
await data.upsert("review", id, review);
|
|
43
|
+
return review;
|
|
44
|
+
},
|
|
45
|
+
async getReview(id) {
|
|
46
|
+
const raw = await data.get("review", id);
|
|
47
|
+
if (!raw)
|
|
48
|
+
return null;
|
|
49
|
+
return raw;
|
|
50
|
+
},
|
|
51
|
+
async listReviewsByProduct(productId, params) {
|
|
52
|
+
const where = { productId };
|
|
53
|
+
if (params?.approvedOnly)
|
|
54
|
+
where.status = "approved";
|
|
55
|
+
const all = await data.findMany("review", {
|
|
56
|
+
where,
|
|
57
|
+
});
|
|
58
|
+
let reviews = all;
|
|
59
|
+
if (params?.sortBy) {
|
|
60
|
+
reviews = sortReviews(reviews, params.sortBy);
|
|
61
|
+
}
|
|
62
|
+
const skip = params?.skip ?? 0;
|
|
63
|
+
const take = params?.take;
|
|
64
|
+
if (skip > 0 || take !== undefined) {
|
|
65
|
+
reviews = reviews.slice(skip, take !== undefined ? skip + take : undefined);
|
|
66
|
+
}
|
|
67
|
+
return reviews;
|
|
68
|
+
},
|
|
69
|
+
async listReviews(params) {
|
|
70
|
+
const where = {};
|
|
71
|
+
if (params?.productId)
|
|
72
|
+
where.productId = params.productId;
|
|
73
|
+
if (params?.status)
|
|
74
|
+
where.status = params.status;
|
|
75
|
+
const all = await data.findMany("review", {
|
|
76
|
+
...(Object.keys(where).length > 0 ? { where } : {}),
|
|
77
|
+
...(params?.take !== undefined ? { take: params.take } : {}),
|
|
78
|
+
...(params?.skip !== undefined ? { skip: params.skip } : {}),
|
|
79
|
+
});
|
|
80
|
+
return all;
|
|
81
|
+
},
|
|
82
|
+
async updateReviewStatus(id, status, moderationNote) {
|
|
83
|
+
const existing = await data.get("review", id);
|
|
84
|
+
if (!existing)
|
|
85
|
+
return null;
|
|
86
|
+
const review = existing;
|
|
87
|
+
const updated = {
|
|
88
|
+
...review,
|
|
89
|
+
status,
|
|
90
|
+
updatedAt: new Date(),
|
|
91
|
+
...(moderationNote !== undefined ? { moderationNote } : {}),
|
|
92
|
+
};
|
|
93
|
+
await data.upsert("review", id, updated);
|
|
94
|
+
return updated;
|
|
95
|
+
},
|
|
96
|
+
async deleteReview(id) {
|
|
97
|
+
const existing = await data.get("review", id);
|
|
98
|
+
if (!existing)
|
|
99
|
+
return false;
|
|
100
|
+
await data.delete("review", id);
|
|
101
|
+
return true;
|
|
102
|
+
},
|
|
103
|
+
async addMerchantResponse(id, response) {
|
|
104
|
+
const existing = await data.get("review", id);
|
|
105
|
+
if (!existing)
|
|
106
|
+
return null;
|
|
107
|
+
const review = existing;
|
|
108
|
+
const updated = {
|
|
109
|
+
...review,
|
|
110
|
+
merchantResponse: response,
|
|
111
|
+
merchantResponseAt: new Date(),
|
|
112
|
+
updatedAt: new Date(),
|
|
113
|
+
};
|
|
114
|
+
await data.upsert("review", id, updated);
|
|
115
|
+
return updated;
|
|
116
|
+
},
|
|
117
|
+
async markHelpful(id) {
|
|
118
|
+
const existing = await data.get("review", id);
|
|
119
|
+
if (!existing)
|
|
120
|
+
return null;
|
|
121
|
+
const review = existing;
|
|
122
|
+
const updated = {
|
|
123
|
+
...review,
|
|
124
|
+
helpfulCount: review.helpfulCount + 1,
|
|
125
|
+
updatedAt: new Date(),
|
|
126
|
+
};
|
|
127
|
+
await data.upsert("review", id, updated);
|
|
128
|
+
return updated;
|
|
129
|
+
},
|
|
130
|
+
async voteHelpful(reviewId, voterId) {
|
|
131
|
+
const existing = await data.get("review", reviewId);
|
|
132
|
+
if (!existing)
|
|
133
|
+
return null;
|
|
134
|
+
const review = existing;
|
|
135
|
+
// Check if voter already voted
|
|
136
|
+
const existingVotes = await data.findMany("reviewVote", {
|
|
137
|
+
where: { reviewId, voterId },
|
|
138
|
+
take: 1,
|
|
139
|
+
});
|
|
140
|
+
const votes = existingVotes;
|
|
141
|
+
if (votes.length > 0) {
|
|
142
|
+
return { review, alreadyVoted: true };
|
|
143
|
+
}
|
|
144
|
+
// Record the vote
|
|
145
|
+
const voteId = crypto.randomUUID();
|
|
146
|
+
const vote = {
|
|
147
|
+
id: voteId,
|
|
148
|
+
reviewId,
|
|
149
|
+
voterId,
|
|
150
|
+
createdAt: new Date(),
|
|
151
|
+
};
|
|
152
|
+
await data.upsert("reviewVote", voteId, vote);
|
|
153
|
+
// Increment helpful count
|
|
154
|
+
const updated = {
|
|
155
|
+
...review,
|
|
156
|
+
helpfulCount: review.helpfulCount + 1,
|
|
157
|
+
updatedAt: new Date(),
|
|
158
|
+
};
|
|
159
|
+
await data.upsert("review", reviewId, updated);
|
|
160
|
+
return { review: updated, alreadyVoted: false };
|
|
161
|
+
},
|
|
162
|
+
async getReviewAnalytics() {
|
|
163
|
+
const all = await data.findMany("review", {});
|
|
164
|
+
const reviews = all;
|
|
165
|
+
const distribution = {
|
|
166
|
+
"1": 0,
|
|
167
|
+
"2": 0,
|
|
168
|
+
"3": 0,
|
|
169
|
+
"4": 0,
|
|
170
|
+
"5": 0,
|
|
171
|
+
};
|
|
172
|
+
let pendingCount = 0;
|
|
173
|
+
let approvedCount = 0;
|
|
174
|
+
let rejectedCount = 0;
|
|
175
|
+
let ratingTotal = 0;
|
|
176
|
+
let withMerchantResponse = 0;
|
|
177
|
+
for (const r of reviews) {
|
|
178
|
+
if (r.status === "pending")
|
|
179
|
+
pendingCount++;
|
|
180
|
+
else if (r.status === "approved")
|
|
181
|
+
approvedCount++;
|
|
182
|
+
else if (r.status === "rejected")
|
|
183
|
+
rejectedCount++;
|
|
184
|
+
ratingTotal += r.rating;
|
|
185
|
+
const key = String(r.rating);
|
|
186
|
+
if (key in distribution) {
|
|
187
|
+
distribution[key] = (distribution[key] ?? 0) + 1;
|
|
188
|
+
}
|
|
189
|
+
if (r.merchantResponse)
|
|
190
|
+
withMerchantResponse++;
|
|
191
|
+
}
|
|
192
|
+
// Count reported reviews
|
|
193
|
+
const allReports = await data.findMany("reviewReport", {
|
|
194
|
+
where: { status: "pending" },
|
|
195
|
+
});
|
|
196
|
+
const reports = allReports;
|
|
197
|
+
const reportedReviewIds = new Set(reports.map((r) => r.reviewId));
|
|
198
|
+
return {
|
|
199
|
+
totalReviews: reviews.length,
|
|
200
|
+
pendingCount,
|
|
201
|
+
approvedCount,
|
|
202
|
+
rejectedCount,
|
|
203
|
+
averageRating: reviews.length > 0
|
|
204
|
+
? Math.round((ratingTotal / reviews.length) * 10) / 10
|
|
205
|
+
: 0,
|
|
206
|
+
ratingsDistribution: distribution,
|
|
207
|
+
withMerchantResponse,
|
|
208
|
+
reportedCount: reportedReviewIds.size,
|
|
209
|
+
};
|
|
210
|
+
},
|
|
211
|
+
async getProductRatingSummary(productId) {
|
|
212
|
+
const all = await data.findMany("review", {
|
|
213
|
+
where: { productId, status: "approved" },
|
|
214
|
+
});
|
|
215
|
+
const reviews = all;
|
|
216
|
+
if (reviews.length === 0) {
|
|
217
|
+
return {
|
|
218
|
+
average: 0,
|
|
219
|
+
count: 0,
|
|
220
|
+
distribution: { "1": 0, "2": 0, "3": 0, "4": 0, "5": 0 },
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const distribution = {
|
|
224
|
+
"1": 0,
|
|
225
|
+
"2": 0,
|
|
226
|
+
"3": 0,
|
|
227
|
+
"4": 0,
|
|
228
|
+
"5": 0,
|
|
229
|
+
};
|
|
230
|
+
let total = 0;
|
|
231
|
+
for (const r of reviews) {
|
|
232
|
+
total += r.rating;
|
|
233
|
+
const key = String(r.rating);
|
|
234
|
+
if (key in distribution) {
|
|
235
|
+
distribution[key] = (distribution[key] ?? 0) + 1;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const average = Math.round((total / reviews.length) * 10) / 10;
|
|
239
|
+
return { average, count: reviews.length, distribution };
|
|
240
|
+
},
|
|
241
|
+
async createReviewRequest(params) {
|
|
242
|
+
const id = crypto.randomUUID();
|
|
243
|
+
const request = {
|
|
244
|
+
id,
|
|
245
|
+
orderId: params.orderId,
|
|
246
|
+
orderNumber: params.orderNumber,
|
|
247
|
+
email: params.email,
|
|
248
|
+
customerName: params.customerName,
|
|
249
|
+
items: params.items,
|
|
250
|
+
sentAt: new Date(),
|
|
251
|
+
};
|
|
252
|
+
await data.upsert("reviewRequest", id, request);
|
|
253
|
+
return request;
|
|
254
|
+
},
|
|
255
|
+
async getReviewRequest(orderId) {
|
|
256
|
+
const all = await data.findMany("reviewRequest", {
|
|
257
|
+
where: { orderId },
|
|
258
|
+
take: 1,
|
|
259
|
+
});
|
|
260
|
+
const requests = all;
|
|
261
|
+
return requests.length > 0 ? requests[0] : null;
|
|
262
|
+
},
|
|
263
|
+
async listReviewRequests(params) {
|
|
264
|
+
const all = await data.findMany("reviewRequest", {
|
|
265
|
+
...(params?.take !== undefined ? { take: params.take } : {}),
|
|
266
|
+
...(params?.skip !== undefined ? { skip: params.skip } : {}),
|
|
267
|
+
});
|
|
268
|
+
return all;
|
|
269
|
+
},
|
|
270
|
+
async listReviewsByCustomer(customerId, params) {
|
|
271
|
+
const where = { customerId };
|
|
272
|
+
if (params?.status)
|
|
273
|
+
where.status = params.status;
|
|
274
|
+
const all = await data.findMany("review", {
|
|
275
|
+
where,
|
|
276
|
+
});
|
|
277
|
+
const reviews = all;
|
|
278
|
+
const filtered = reviews.slice(params?.skip ?? 0, params?.skip !== undefined && params?.take !== undefined
|
|
279
|
+
? params.skip + params.take
|
|
280
|
+
: params?.take !== undefined
|
|
281
|
+
? params.take
|
|
282
|
+
: undefined);
|
|
283
|
+
return { reviews: filtered, total: reviews.length };
|
|
284
|
+
},
|
|
285
|
+
async hasReviewedProduct(customerId, productId) {
|
|
286
|
+
const all = await data.findMany("review", {
|
|
287
|
+
where: { customerId, productId },
|
|
288
|
+
take: 1,
|
|
289
|
+
});
|
|
290
|
+
const reviews = all;
|
|
291
|
+
return reviews.length > 0;
|
|
292
|
+
},
|
|
293
|
+
async reportReview(params) {
|
|
294
|
+
const id = crypto.randomUUID();
|
|
295
|
+
const report = {
|
|
296
|
+
id,
|
|
297
|
+
reviewId: params.reviewId,
|
|
298
|
+
reporterId: params.reporterId,
|
|
299
|
+
reason: params.reason,
|
|
300
|
+
details: params.details,
|
|
301
|
+
status: "pending",
|
|
302
|
+
createdAt: new Date(),
|
|
303
|
+
};
|
|
304
|
+
await data.upsert("reviewReport", id, report);
|
|
305
|
+
return report;
|
|
306
|
+
},
|
|
307
|
+
async listReports(params) {
|
|
308
|
+
const where = {};
|
|
309
|
+
if (params?.status)
|
|
310
|
+
where.status = params.status;
|
|
311
|
+
if (params?.reviewId)
|
|
312
|
+
where.reviewId = params.reviewId;
|
|
313
|
+
const all = await data.findMany("reviewReport", {
|
|
314
|
+
...(Object.keys(where).length > 0 ? { where } : {}),
|
|
315
|
+
...(params?.take !== undefined ? { take: params.take } : {}),
|
|
316
|
+
...(params?.skip !== undefined ? { skip: params.skip } : {}),
|
|
317
|
+
});
|
|
318
|
+
return all;
|
|
319
|
+
},
|
|
320
|
+
async updateReportStatus(id, status) {
|
|
321
|
+
const existing = await data.get("reviewReport", id);
|
|
322
|
+
if (!existing)
|
|
323
|
+
return null;
|
|
324
|
+
const report = existing;
|
|
325
|
+
const updated = {
|
|
326
|
+
...report,
|
|
327
|
+
status,
|
|
328
|
+
};
|
|
329
|
+
await data.upsert("reviewReport", id, updated);
|
|
330
|
+
return updated;
|
|
331
|
+
},
|
|
332
|
+
async getReportCount(reviewId) {
|
|
333
|
+
const all = await data.findMany("reviewReport", {
|
|
334
|
+
where: { reviewId, status: "pending" },
|
|
335
|
+
});
|
|
336
|
+
const reports = all;
|
|
337
|
+
return reports.length;
|
|
338
|
+
},
|
|
339
|
+
async getReviewRequestStats() {
|
|
340
|
+
const all = await data.findMany("reviewRequest", {});
|
|
341
|
+
const requests = all;
|
|
342
|
+
const uniqueOrders = new Set(requests.map((r) => r.orderId));
|
|
343
|
+
return {
|
|
344
|
+
totalSent: requests.length,
|
|
345
|
+
uniqueOrders: uniqueOrders.size,
|
|
346
|
+
};
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useModuleClient } from "@86d-app/core/client";
|
|
3
|
+
export function useReviewsApi() {
|
|
4
|
+
const client = useModuleClient();
|
|
5
|
+
return {
|
|
6
|
+
submitReview: client.module("reviews").store["/reviews"],
|
|
7
|
+
listProductReviews: client.module("reviews").store["/reviews/products/:productId"],
|
|
8
|
+
markHelpful: client.module("reviews").store["/reviews/:id/helpful"],
|
|
9
|
+
};
|
|
10
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function formatDate(iso) {
|
|
2
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
3
|
+
month: "short",
|
|
4
|
+
day: "numeric",
|
|
5
|
+
year: "numeric",
|
|
6
|
+
}).format(new Date(iso));
|
|
7
|
+
}
|
|
8
|
+
export function extractError(error, fallback) {
|
|
9
|
+
if (!error)
|
|
10
|
+
return fallback;
|
|
11
|
+
const body = error.body;
|
|
12
|
+
if (typeof body?.error === "string")
|
|
13
|
+
return body.error;
|
|
14
|
+
if (typeof body?.error?.message === "string")
|
|
15
|
+
return body.error.message;
|
|
16
|
+
return fallback;
|
|
17
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { DistributionBars } from "./distribution-bars";
|
|
3
|
+
import { ProductReviews } from "./product-reviews";
|
|
4
|
+
import { ReviewCard } from "./review-card";
|
|
5
|
+
import { ReviewForm } from "./review-form";
|
|
6
|
+
import { ReviewsSummary } from "./reviews-summary";
|
|
7
|
+
import { StarDisplay } from "./star-display";
|
|
8
|
+
import { StarPicker } from "./star-picker";
|
|
9
|
+
export default {
|
|
10
|
+
ReviewsSummary,
|
|
11
|
+
ProductReviews,
|
|
12
|
+
StarDisplay,
|
|
13
|
+
StarPicker,
|
|
14
|
+
ReviewCard,
|
|
15
|
+
ReviewForm,
|
|
16
|
+
DistributionBars,
|
|
17
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useState } from "react";
|
|
3
|
+
import { useReviewsApi } from "./_hooks";
|
|
4
|
+
import { DistributionBars } from "./distribution-bars";
|
|
5
|
+
import ProductReviewsTemplate from "./product-reviews.mdx";
|
|
6
|
+
import { ReviewCard } from "./review-card";
|
|
7
|
+
import { ReviewForm } from "./review-form";
|
|
8
|
+
import { StarDisplay } from "./star-display";
|
|
9
|
+
const PAGE_SIZE = 10;
|
|
10
|
+
export function ProductReviews({ productId, title = "Customer Reviews", }) {
|
|
11
|
+
const api = useReviewsApi();
|
|
12
|
+
// Initial page via useQuery — eliminates the fetch-on-mount useEffect
|
|
13
|
+
const { data: initialData, isLoading: loading, isError: queryError, refetch, } = api.listProductReviews.useQuery({
|
|
14
|
+
params: { productId },
|
|
15
|
+
take: String(PAGE_SIZE),
|
|
16
|
+
skip: "0",
|
|
17
|
+
});
|
|
18
|
+
// Extra reviews loaded via "Load more"
|
|
19
|
+
const [extraReviews, setExtraReviews] = useState([]);
|
|
20
|
+
const [loadingMore, setLoadingMore] = useState(false);
|
|
21
|
+
const [skip, setSkip] = useState(0);
|
|
22
|
+
const [loadedAll, setLoadedAll] = useState(false);
|
|
23
|
+
const [showForm, setShowForm] = useState(false);
|
|
24
|
+
const allReviews = [...(initialData?.reviews ?? []), ...extraReviews];
|
|
25
|
+
const hasMore = !loadedAll &&
|
|
26
|
+
initialData !== undefined &&
|
|
27
|
+
(initialData.reviews.length === PAGE_SIZE || extraReviews.length > 0);
|
|
28
|
+
const handleLoadMore = useCallback(async () => {
|
|
29
|
+
const nextSkip = skip === 0 ? PAGE_SIZE : skip + PAGE_SIZE;
|
|
30
|
+
setLoadingMore(true);
|
|
31
|
+
try {
|
|
32
|
+
const fresh = (await api.listProductReviews.fetch({
|
|
33
|
+
params: { productId },
|
|
34
|
+
take: String(PAGE_SIZE),
|
|
35
|
+
skip: String(nextSkip),
|
|
36
|
+
}));
|
|
37
|
+
setExtraReviews((prev) => [...prev, ...fresh.reviews]);
|
|
38
|
+
setSkip(nextSkip);
|
|
39
|
+
if (fresh.reviews.length < PAGE_SIZE)
|
|
40
|
+
setLoadedAll(true);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// silently ignore
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
setLoadingMore(false);
|
|
47
|
+
}
|
|
48
|
+
}, [api.listProductReviews, productId, skip]);
|
|
49
|
+
const handleMarkHelpful = useCallback(async (id) => {
|
|
50
|
+
await api.markHelpful.mutate({ params: { id } });
|
|
51
|
+
}, [api.markHelpful]);
|
|
52
|
+
const handleReviewSubmitted = useCallback(() => {
|
|
53
|
+
setShowForm(false);
|
|
54
|
+
// Invalidate the query to refetch from scratch
|
|
55
|
+
void api.listProductReviews.invalidate();
|
|
56
|
+
setExtraReviews([]);
|
|
57
|
+
setSkip(0);
|
|
58
|
+
setLoadedAll(false);
|
|
59
|
+
}, [api.listProductReviews]);
|
|
60
|
+
if (loading) {
|
|
61
|
+
return (<section className="py-8">
|
|
62
|
+
<div className="mb-6 h-7 w-40 animate-pulse rounded-lg bg-muted"/>
|
|
63
|
+
<div className="space-y-4">
|
|
64
|
+
{[1, 2, 3].map((n) => (<div key={n} className="space-y-2 border-border border-b pb-4">
|
|
65
|
+
<div className="h-4 w-24 animate-pulse rounded bg-muted"/>
|
|
66
|
+
<div className="h-4 w-full animate-pulse rounded bg-muted"/>
|
|
67
|
+
<div className="h-4 w-3/4 animate-pulse rounded bg-muted"/>
|
|
68
|
+
</div>))}
|
|
69
|
+
</div>
|
|
70
|
+
</section>);
|
|
71
|
+
}
|
|
72
|
+
if (queryError) {
|
|
73
|
+
return (<section className="py-8">
|
|
74
|
+
<h2 className="mb-4 font-semibold text-foreground text-lg">{title}</h2>
|
|
75
|
+
<div className="rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-6 text-center" role="alert">
|
|
76
|
+
<p className="font-medium text-destructive text-sm">
|
|
77
|
+
Failed to load reviews
|
|
78
|
+
</p>
|
|
79
|
+
<button type="button" onClick={() => refetch()} className="mt-2 font-medium text-destructive text-sm underline underline-offset-4">
|
|
80
|
+
Try again
|
|
81
|
+
</button>
|
|
82
|
+
</div>
|
|
83
|
+
</section>);
|
|
84
|
+
}
|
|
85
|
+
const summary = initialData?.summary;
|
|
86
|
+
const noReviews = !summary || summary.count === 0;
|
|
87
|
+
const reviewListContent = allReviews.length === 0 ? (<p className="text-muted-foreground text-sm">No approved reviews yet.</p>) : (<div>
|
|
88
|
+
{allReviews.map((review) => (<ReviewCard key={review.id} review={review} onMarkHelpful={handleMarkHelpful}/>))}
|
|
89
|
+
{hasMore && (<button type="button" onClick={() => void handleLoadMore()} disabled={loadingMore} className="mt-4 text-primary text-sm underline-offset-4 hover:underline disabled:opacity-60">
|
|
90
|
+
{loadingMore ? "Loading…" : "Load more reviews"}
|
|
91
|
+
</button>)}
|
|
92
|
+
</div>);
|
|
93
|
+
return (<ProductReviewsTemplate title={title} summary={summary ?? undefined} noReviews={noReviews} showForm={showForm} onToggleForm={() => setShowForm((v) => !v)} formContent={<ReviewForm productId={productId} onSuccess={handleReviewSubmitted}/>} distributionBars={summary ? (<DistributionBars distribution={summary.distribution} total={summary.count}/>) : null} reviewListContent={reviewListContent} starDisplay={summary ? <StarDisplay rating={summary.average} size="lg"/> : null}/>);
|
|
94
|
+
}
|