@86d-app/reviews 0.0.3
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/AGENTS.md +41 -0
- package/COMPONENTS.md +34 -0
- package/README.md +192 -0
- package/package.json +46 -0
- package/src/__tests__/service-impl.test.ts +1436 -0
- package/src/admin/components/index.ts +3 -0
- package/src/admin/components/index.tsx +3 -0
- package/src/admin/components/review-analytics.mdx +3 -0
- package/src/admin/components/review-analytics.tsx +221 -0
- package/src/admin/components/review-list.mdx +89 -0
- package/src/admin/components/review-list.tsx +308 -0
- package/src/admin/components/review-moderation.mdx +3 -0
- package/src/admin/components/review-moderation.tsx +447 -0
- package/src/admin/endpoints/approve-review.ts +19 -0
- package/src/admin/endpoints/delete-review.ts +17 -0
- package/src/admin/endpoints/get-review.ts +16 -0
- package/src/admin/endpoints/index.ts +23 -0
- package/src/admin/endpoints/list-review-requests.ts +23 -0
- package/src/admin/endpoints/list-reviews.ts +25 -0
- package/src/admin/endpoints/reject-review.ts +19 -0
- package/src/admin/endpoints/respond-review.ts +22 -0
- package/src/admin/endpoints/review-analytics.ts +14 -0
- package/src/admin/endpoints/review-request-stats.ts +12 -0
- package/src/admin/endpoints/send-review-request.ts +41 -0
- package/src/index.ts +73 -0
- package/src/mdx.d.ts +5 -0
- package/src/schema.ts +37 -0
- package/src/service-impl.ts +263 -0
- package/src/service.ts +126 -0
- package/src/store/components/_hooks.ts +13 -0
- package/src/store/components/_utils.ts +16 -0
- package/src/store/components/distribution-bars.mdx +21 -0
- package/src/store/components/distribution-bars.tsx +13 -0
- package/src/store/components/index.tsx +20 -0
- package/src/store/components/product-reviews.mdx +52 -0
- package/src/store/components/product-reviews.tsx +172 -0
- package/src/store/components/review-card.mdx +32 -0
- package/src/store/components/review-card.tsx +87 -0
- package/src/store/components/review-form.mdx +111 -0
- package/src/store/components/review-form.tsx +68 -0
- package/src/store/components/reviews-summary.mdx +6 -0
- package/src/store/components/reviews-summary.tsx +30 -0
- package/src/store/components/star-display.mdx +18 -0
- package/src/store/components/star-display.tsx +28 -0
- package/src/store/components/star-picker.mdx +21 -0
- package/src/store/components/star-picker.tsx +23 -0
- package/src/store/endpoints/index.ts +11 -0
- package/src/store/endpoints/list-my-reviews.ts +38 -0
- package/src/store/endpoints/list-product-reviews.ts +26 -0
- package/src/store/endpoints/mark-helpful.ts +16 -0
- package/src/store/endpoints/submit-review.ts +33 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +2 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Module, ModuleConfig, ModuleContext } from "@86d-app/core";
|
|
2
|
+
import { adminEndpoints } from "./admin/endpoints";
|
|
3
|
+
import { reviewsSchema } from "./schema";
|
|
4
|
+
import { createReviewController } from "./service-impl";
|
|
5
|
+
import { storeEndpoints } from "./store/endpoints";
|
|
6
|
+
|
|
7
|
+
export type {
|
|
8
|
+
RatingSummary,
|
|
9
|
+
Review,
|
|
10
|
+
ReviewAnalytics,
|
|
11
|
+
ReviewController,
|
|
12
|
+
ReviewRequest,
|
|
13
|
+
ReviewRequestStats,
|
|
14
|
+
ReviewStatus,
|
|
15
|
+
} from "./service";
|
|
16
|
+
|
|
17
|
+
export interface ReviewsOptions extends ModuleConfig {
|
|
18
|
+
/** Auto-approve reviews (no moderation queue) */
|
|
19
|
+
autoApprove?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default function reviews(options?: ReviewsOptions): Module {
|
|
23
|
+
return {
|
|
24
|
+
id: "reviews",
|
|
25
|
+
version: "0.0.1",
|
|
26
|
+
schema: reviewsSchema,
|
|
27
|
+
exports: {
|
|
28
|
+
read: ["productRating", "reviewCount"],
|
|
29
|
+
},
|
|
30
|
+
events: {
|
|
31
|
+
emits: [
|
|
32
|
+
"review.submitted",
|
|
33
|
+
"review.approved",
|
|
34
|
+
"review.rejected",
|
|
35
|
+
"review.responded",
|
|
36
|
+
"review.requested",
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
init: async (ctx: ModuleContext) => {
|
|
40
|
+
const controller = createReviewController(ctx.data, {
|
|
41
|
+
autoApprove: options?.autoApprove === "true",
|
|
42
|
+
});
|
|
43
|
+
return { controllers: { reviews: controller } };
|
|
44
|
+
},
|
|
45
|
+
endpoints: {
|
|
46
|
+
store: storeEndpoints,
|
|
47
|
+
admin: adminEndpoints,
|
|
48
|
+
},
|
|
49
|
+
admin: {
|
|
50
|
+
pages: [
|
|
51
|
+
{
|
|
52
|
+
path: "/admin/reviews",
|
|
53
|
+
component: "ReviewList",
|
|
54
|
+
label: "Reviews",
|
|
55
|
+
icon: "Star",
|
|
56
|
+
group: "Marketing",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
path: "/admin/reviews/analytics",
|
|
60
|
+
component: "ReviewAnalytics",
|
|
61
|
+
label: "Review Analytics",
|
|
62
|
+
icon: "ChartBar",
|
|
63
|
+
group: "Marketing",
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
path: "/admin/reviews/:id",
|
|
67
|
+
component: "ReviewModeration",
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
options,
|
|
72
|
+
};
|
|
73
|
+
}
|
package/src/mdx.d.ts
ADDED
package/src/schema.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ModuleSchema } from "@86d-app/core";
|
|
2
|
+
|
|
3
|
+
export const reviewsSchema = {
|
|
4
|
+
review: {
|
|
5
|
+
fields: {
|
|
6
|
+
id: { type: "string", required: true },
|
|
7
|
+
productId: { type: "string", required: true },
|
|
8
|
+
customerId: { type: "string", required: false },
|
|
9
|
+
authorName: { type: "string", required: true },
|
|
10
|
+
authorEmail: { type: "string", required: true },
|
|
11
|
+
rating: { type: "number", required: true },
|
|
12
|
+
title: { type: "string", required: false },
|
|
13
|
+
body: { type: "string", required: true },
|
|
14
|
+
status: { type: "string", required: true, defaultValue: "pending" },
|
|
15
|
+
isVerifiedPurchase: {
|
|
16
|
+
type: "boolean",
|
|
17
|
+
required: true,
|
|
18
|
+
defaultValue: false,
|
|
19
|
+
},
|
|
20
|
+
helpfulCount: { type: "number", required: true, defaultValue: 0 },
|
|
21
|
+
merchantResponse: { type: "string", required: false },
|
|
22
|
+
merchantResponseAt: { type: "date", required: false },
|
|
23
|
+
moderationNote: { type: "string", required: false },
|
|
24
|
+
createdAt: {
|
|
25
|
+
type: "date",
|
|
26
|
+
required: true,
|
|
27
|
+
defaultValue: () => new Date(),
|
|
28
|
+
},
|
|
29
|
+
updatedAt: {
|
|
30
|
+
type: "date",
|
|
31
|
+
required: true,
|
|
32
|
+
defaultValue: () => new Date(),
|
|
33
|
+
onUpdate: () => new Date(),
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
} satisfies ModuleSchema;
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import type { ModuleDataService } from "@86d-app/core";
|
|
2
|
+
import type {
|
|
3
|
+
Review,
|
|
4
|
+
ReviewController,
|
|
5
|
+
ReviewRequest,
|
|
6
|
+
ReviewStatus,
|
|
7
|
+
} from "./service";
|
|
8
|
+
|
|
9
|
+
export function createReviewController(
|
|
10
|
+
data: ModuleDataService,
|
|
11
|
+
options?: { autoApprove?: boolean },
|
|
12
|
+
): ReviewController {
|
|
13
|
+
return {
|
|
14
|
+
async createReview(params) {
|
|
15
|
+
const id = crypto.randomUUID();
|
|
16
|
+
const now = new Date();
|
|
17
|
+
const status: ReviewStatus =
|
|
18
|
+
options?.autoApprove === true ? "approved" : "pending";
|
|
19
|
+
const review: Review = {
|
|
20
|
+
id,
|
|
21
|
+
productId: params.productId,
|
|
22
|
+
customerId: params.customerId,
|
|
23
|
+
authorName: params.authorName,
|
|
24
|
+
authorEmail: params.authorEmail,
|
|
25
|
+
rating: params.rating,
|
|
26
|
+
title: params.title,
|
|
27
|
+
body: params.body,
|
|
28
|
+
status,
|
|
29
|
+
isVerifiedPurchase: params.isVerifiedPurchase ?? false,
|
|
30
|
+
helpfulCount: 0,
|
|
31
|
+
createdAt: now,
|
|
32
|
+
updatedAt: now,
|
|
33
|
+
};
|
|
34
|
+
// biome-ignore lint/suspicious/noExplicitAny: ModuleDataService requires any
|
|
35
|
+
await data.upsert("review", id, review as Record<string, any>);
|
|
36
|
+
return review;
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
async getReview(id) {
|
|
40
|
+
const raw = await data.get("review", id);
|
|
41
|
+
if (!raw) return null;
|
|
42
|
+
return raw as unknown as Review;
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
async listReviewsByProduct(productId, params) {
|
|
46
|
+
// biome-ignore lint/suspicious/noExplicitAny: JSONB where filter
|
|
47
|
+
const where: Record<string, any> = { productId };
|
|
48
|
+
if (params?.approvedOnly) where.status = "approved";
|
|
49
|
+
|
|
50
|
+
const all = await data.findMany("review", {
|
|
51
|
+
where,
|
|
52
|
+
...(params?.take !== undefined ? { take: params.take } : {}),
|
|
53
|
+
...(params?.skip !== undefined ? { skip: params.skip } : {}),
|
|
54
|
+
});
|
|
55
|
+
return all as unknown as Review[];
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
async listReviews(params) {
|
|
59
|
+
// biome-ignore lint/suspicious/noExplicitAny: JSONB where filter
|
|
60
|
+
const where: Record<string, any> = {};
|
|
61
|
+
if (params?.productId) where.productId = params.productId;
|
|
62
|
+
if (params?.status) where.status = params.status;
|
|
63
|
+
|
|
64
|
+
const all = await data.findMany("review", {
|
|
65
|
+
...(Object.keys(where).length > 0 ? { where } : {}),
|
|
66
|
+
...(params?.take !== undefined ? { take: params.take } : {}),
|
|
67
|
+
...(params?.skip !== undefined ? { skip: params.skip } : {}),
|
|
68
|
+
});
|
|
69
|
+
return all as unknown as Review[];
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
async updateReviewStatus(id, status, moderationNote) {
|
|
73
|
+
const existing = await data.get("review", id);
|
|
74
|
+
if (!existing) return null;
|
|
75
|
+
const review = existing as unknown as Review;
|
|
76
|
+
const updated: Review = {
|
|
77
|
+
...review,
|
|
78
|
+
status,
|
|
79
|
+
updatedAt: new Date(),
|
|
80
|
+
...(moderationNote !== undefined ? { moderationNote } : {}),
|
|
81
|
+
};
|
|
82
|
+
// biome-ignore lint/suspicious/noExplicitAny: ModuleDataService requires any
|
|
83
|
+
await data.upsert("review", id, updated as Record<string, any>);
|
|
84
|
+
return updated;
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
async deleteReview(id) {
|
|
88
|
+
const existing = await data.get("review", id);
|
|
89
|
+
if (!existing) return false;
|
|
90
|
+
await data.delete("review", id);
|
|
91
|
+
return true;
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
async addMerchantResponse(id, response) {
|
|
95
|
+
const existing = await data.get("review", id);
|
|
96
|
+
if (!existing) return null;
|
|
97
|
+
const review = existing as unknown as Review;
|
|
98
|
+
const updated: Review = {
|
|
99
|
+
...review,
|
|
100
|
+
merchantResponse: response,
|
|
101
|
+
merchantResponseAt: new Date(),
|
|
102
|
+
updatedAt: new Date(),
|
|
103
|
+
};
|
|
104
|
+
// biome-ignore lint/suspicious/noExplicitAny: ModuleDataService requires any
|
|
105
|
+
await data.upsert("review", id, updated as Record<string, any>);
|
|
106
|
+
return updated;
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
async markHelpful(id) {
|
|
110
|
+
const existing = await data.get("review", id);
|
|
111
|
+
if (!existing) return null;
|
|
112
|
+
const review = existing as unknown as Review;
|
|
113
|
+
const updated: Review = {
|
|
114
|
+
...review,
|
|
115
|
+
helpfulCount: review.helpfulCount + 1,
|
|
116
|
+
updatedAt: new Date(),
|
|
117
|
+
};
|
|
118
|
+
// biome-ignore lint/suspicious/noExplicitAny: ModuleDataService requires any
|
|
119
|
+
await data.upsert("review", id, updated as Record<string, any>);
|
|
120
|
+
return updated;
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
async getReviewAnalytics() {
|
|
124
|
+
const all = await data.findMany("review", {});
|
|
125
|
+
const reviews = all as unknown as Review[];
|
|
126
|
+
|
|
127
|
+
const distribution: Record<string, number> = {
|
|
128
|
+
"1": 0,
|
|
129
|
+
"2": 0,
|
|
130
|
+
"3": 0,
|
|
131
|
+
"4": 0,
|
|
132
|
+
"5": 0,
|
|
133
|
+
};
|
|
134
|
+
let pendingCount = 0;
|
|
135
|
+
let approvedCount = 0;
|
|
136
|
+
let rejectedCount = 0;
|
|
137
|
+
let ratingTotal = 0;
|
|
138
|
+
let withMerchantResponse = 0;
|
|
139
|
+
|
|
140
|
+
for (const r of reviews) {
|
|
141
|
+
if (r.status === "pending") pendingCount++;
|
|
142
|
+
else if (r.status === "approved") approvedCount++;
|
|
143
|
+
else if (r.status === "rejected") rejectedCount++;
|
|
144
|
+
|
|
145
|
+
ratingTotal += r.rating;
|
|
146
|
+
const key = String(r.rating);
|
|
147
|
+
if (key in distribution) {
|
|
148
|
+
distribution[key] = (distribution[key] ?? 0) + 1;
|
|
149
|
+
}
|
|
150
|
+
if (r.merchantResponse) withMerchantResponse++;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
totalReviews: reviews.length,
|
|
155
|
+
pendingCount,
|
|
156
|
+
approvedCount,
|
|
157
|
+
rejectedCount,
|
|
158
|
+
averageRating:
|
|
159
|
+
reviews.length > 0
|
|
160
|
+
? Math.round((ratingTotal / reviews.length) * 10) / 10
|
|
161
|
+
: 0,
|
|
162
|
+
ratingsDistribution: distribution,
|
|
163
|
+
withMerchantResponse,
|
|
164
|
+
};
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
async getProductRatingSummary(productId) {
|
|
168
|
+
const all = await data.findMany("review", {
|
|
169
|
+
where: { productId, status: "approved" },
|
|
170
|
+
});
|
|
171
|
+
const reviews = all as unknown as Review[];
|
|
172
|
+
if (reviews.length === 0) {
|
|
173
|
+
return {
|
|
174
|
+
average: 0,
|
|
175
|
+
count: 0,
|
|
176
|
+
distribution: { "1": 0, "2": 0, "3": 0, "4": 0, "5": 0 },
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const distribution: Record<string, number> = {
|
|
180
|
+
"1": 0,
|
|
181
|
+
"2": 0,
|
|
182
|
+
"3": 0,
|
|
183
|
+
"4": 0,
|
|
184
|
+
"5": 0,
|
|
185
|
+
};
|
|
186
|
+
let total = 0;
|
|
187
|
+
for (const r of reviews) {
|
|
188
|
+
total += r.rating;
|
|
189
|
+
const key = String(r.rating);
|
|
190
|
+
if (key in distribution) {
|
|
191
|
+
distribution[key] = (distribution[key] ?? 0) + 1;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const average = Math.round((total / reviews.length) * 10) / 10;
|
|
195
|
+
return { average, count: reviews.length, distribution };
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
async createReviewRequest(params) {
|
|
199
|
+
const id = crypto.randomUUID();
|
|
200
|
+
const request: ReviewRequest = {
|
|
201
|
+
id,
|
|
202
|
+
orderId: params.orderId,
|
|
203
|
+
orderNumber: params.orderNumber,
|
|
204
|
+
email: params.email,
|
|
205
|
+
customerName: params.customerName,
|
|
206
|
+
items: params.items,
|
|
207
|
+
sentAt: new Date(),
|
|
208
|
+
};
|
|
209
|
+
// biome-ignore lint/suspicious/noExplicitAny: ModuleDataService requires any
|
|
210
|
+
await data.upsert("reviewRequest", id, request as Record<string, any>);
|
|
211
|
+
return request;
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
async getReviewRequest(orderId) {
|
|
215
|
+
const all = await data.findMany("reviewRequest", {
|
|
216
|
+
where: { orderId },
|
|
217
|
+
take: 1,
|
|
218
|
+
});
|
|
219
|
+
const requests = all as unknown as ReviewRequest[];
|
|
220
|
+
return requests.length > 0 ? requests[0] : null;
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
async listReviewRequests(params) {
|
|
224
|
+
const all = await data.findMany("reviewRequest", {
|
|
225
|
+
...(params?.take !== undefined ? { take: params.take } : {}),
|
|
226
|
+
...(params?.skip !== undefined ? { skip: params.skip } : {}),
|
|
227
|
+
});
|
|
228
|
+
return all as unknown as ReviewRequest[];
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
async listReviewsByCustomer(customerId, params) {
|
|
232
|
+
// biome-ignore lint/suspicious/noExplicitAny: JSONB where filter
|
|
233
|
+
const where: Record<string, any> = { customerId };
|
|
234
|
+
if (params?.status) where.status = params.status;
|
|
235
|
+
|
|
236
|
+
const all = await data.findMany("review", {
|
|
237
|
+
where,
|
|
238
|
+
});
|
|
239
|
+
const reviews = all as unknown as Review[];
|
|
240
|
+
|
|
241
|
+
const filtered = reviews.slice(
|
|
242
|
+
params?.skip ?? 0,
|
|
243
|
+
params?.skip !== undefined && params?.take !== undefined
|
|
244
|
+
? params.skip + params.take
|
|
245
|
+
: params?.take !== undefined
|
|
246
|
+
? params.take
|
|
247
|
+
: undefined,
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
return { reviews: filtered, total: reviews.length };
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
async getReviewRequestStats() {
|
|
254
|
+
const all = await data.findMany("reviewRequest", {});
|
|
255
|
+
const requests = all as unknown as ReviewRequest[];
|
|
256
|
+
const uniqueOrders = new Set(requests.map((r) => r.orderId));
|
|
257
|
+
return {
|
|
258
|
+
totalSent: requests.length,
|
|
259
|
+
uniqueOrders: uniqueOrders.size,
|
|
260
|
+
};
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { ModuleController } from "@86d-app/core";
|
|
2
|
+
|
|
3
|
+
export type ReviewStatus = "pending" | "approved" | "rejected";
|
|
4
|
+
|
|
5
|
+
export interface Review {
|
|
6
|
+
id: string;
|
|
7
|
+
productId: string;
|
|
8
|
+
customerId?: string | undefined;
|
|
9
|
+
authorName: string;
|
|
10
|
+
authorEmail: string;
|
|
11
|
+
rating: number;
|
|
12
|
+
title?: string | undefined;
|
|
13
|
+
body: string;
|
|
14
|
+
status: ReviewStatus;
|
|
15
|
+
isVerifiedPurchase: boolean;
|
|
16
|
+
helpfulCount: number;
|
|
17
|
+
merchantResponse?: string | undefined;
|
|
18
|
+
merchantResponseAt?: Date | undefined;
|
|
19
|
+
moderationNote?: string | undefined;
|
|
20
|
+
createdAt: Date;
|
|
21
|
+
updatedAt: Date;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RatingSummary {
|
|
25
|
+
average: number;
|
|
26
|
+
count: number;
|
|
27
|
+
distribution: Record<string, number>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ReviewController extends ModuleController {
|
|
31
|
+
createReview(params: {
|
|
32
|
+
productId: string;
|
|
33
|
+
authorName: string;
|
|
34
|
+
authorEmail: string;
|
|
35
|
+
rating: number;
|
|
36
|
+
title?: string | undefined;
|
|
37
|
+
body: string;
|
|
38
|
+
customerId?: string | undefined;
|
|
39
|
+
isVerifiedPurchase?: boolean | undefined;
|
|
40
|
+
}): Promise<Review>;
|
|
41
|
+
|
|
42
|
+
getReview(id: string): Promise<Review | null>;
|
|
43
|
+
|
|
44
|
+
listReviewsByProduct(
|
|
45
|
+
productId: string,
|
|
46
|
+
params?: {
|
|
47
|
+
approvedOnly?: boolean | undefined;
|
|
48
|
+
take?: number | undefined;
|
|
49
|
+
skip?: number | undefined;
|
|
50
|
+
},
|
|
51
|
+
): Promise<Review[]>;
|
|
52
|
+
|
|
53
|
+
listReviews(params?: {
|
|
54
|
+
productId?: string | undefined;
|
|
55
|
+
status?: ReviewStatus | undefined;
|
|
56
|
+
take?: number | undefined;
|
|
57
|
+
skip?: number | undefined;
|
|
58
|
+
}): Promise<Review[]>;
|
|
59
|
+
|
|
60
|
+
updateReviewStatus(
|
|
61
|
+
id: string,
|
|
62
|
+
status: ReviewStatus,
|
|
63
|
+
moderationNote?: string | undefined,
|
|
64
|
+
): Promise<Review | null>;
|
|
65
|
+
|
|
66
|
+
deleteReview(id: string): Promise<boolean>;
|
|
67
|
+
|
|
68
|
+
getProductRatingSummary(productId: string): Promise<RatingSummary>;
|
|
69
|
+
|
|
70
|
+
addMerchantResponse(id: string, response: string): Promise<Review | null>;
|
|
71
|
+
|
|
72
|
+
markHelpful(id: string): Promise<Review | null>;
|
|
73
|
+
|
|
74
|
+
getReviewAnalytics(): Promise<ReviewAnalytics>;
|
|
75
|
+
|
|
76
|
+
createReviewRequest(params: {
|
|
77
|
+
orderId: string;
|
|
78
|
+
orderNumber: string;
|
|
79
|
+
email: string;
|
|
80
|
+
customerName: string;
|
|
81
|
+
items: Array<{ productId: string; name: string }>;
|
|
82
|
+
}): Promise<ReviewRequest>;
|
|
83
|
+
|
|
84
|
+
getReviewRequest(orderId: string): Promise<ReviewRequest | null>;
|
|
85
|
+
|
|
86
|
+
listReviewRequests(params?: {
|
|
87
|
+
take?: number | undefined;
|
|
88
|
+
skip?: number | undefined;
|
|
89
|
+
}): Promise<ReviewRequest[]>;
|
|
90
|
+
|
|
91
|
+
getReviewRequestStats(): Promise<ReviewRequestStats>;
|
|
92
|
+
|
|
93
|
+
listReviewsByCustomer(
|
|
94
|
+
customerId: string,
|
|
95
|
+
params?: {
|
|
96
|
+
status?: ReviewStatus | undefined;
|
|
97
|
+
take?: number | undefined;
|
|
98
|
+
skip?: number | undefined;
|
|
99
|
+
},
|
|
100
|
+
): Promise<{ reviews: Review[]; total: number }>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ReviewAnalytics {
|
|
104
|
+
totalReviews: number;
|
|
105
|
+
pendingCount: number;
|
|
106
|
+
approvedCount: number;
|
|
107
|
+
rejectedCount: number;
|
|
108
|
+
averageRating: number;
|
|
109
|
+
ratingsDistribution: Record<string, number>;
|
|
110
|
+
withMerchantResponse: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface ReviewRequest {
|
|
114
|
+
id: string;
|
|
115
|
+
orderId: string;
|
|
116
|
+
orderNumber: string;
|
|
117
|
+
email: string;
|
|
118
|
+
customerName: string;
|
|
119
|
+
items: Array<{ productId: string; name: string }>;
|
|
120
|
+
sentAt: Date;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface ReviewRequestStats {
|
|
124
|
+
totalSent: number;
|
|
125
|
+
uniqueOrders: number;
|
|
126
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useModuleClient } from "@86d-app/core/client";
|
|
4
|
+
|
|
5
|
+
export function useReviewsApi() {
|
|
6
|
+
const client = useModuleClient();
|
|
7
|
+
return {
|
|
8
|
+
submitReview: client.module("reviews").store["/reviews"],
|
|
9
|
+
listProductReviews:
|
|
10
|
+
client.module("reviews").store["/reviews/products/:productId"],
|
|
11
|
+
markHelpful: client.module("reviews").store["/reviews/:id/helpful"],
|
|
12
|
+
};
|
|
13
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function formatDate(iso: string): string {
|
|
2
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
3
|
+
month: "short",
|
|
4
|
+
day: "numeric",
|
|
5
|
+
year: "numeric",
|
|
6
|
+
}).format(new Date(iso));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function extractError(error: Error | null, fallback: string): string {
|
|
10
|
+
if (!error) return fallback;
|
|
11
|
+
// biome-ignore lint/suspicious/noExplicitAny: accessing HTTP error body property
|
|
12
|
+
const body = (error as any)?.body;
|
|
13
|
+
if (typeof body?.error === "string") return body.error;
|
|
14
|
+
if (typeof body?.error?.message === "string") return body.error.message;
|
|
15
|
+
return fallback;
|
|
16
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<div className="space-y-1.5">
|
|
2
|
+
{[5, 4, 3, 2, 1].map((n) => {
|
|
3
|
+
const count = props.distribution[String(n)] ?? 0;
|
|
4
|
+
const pct = props.total > 0 ? (count / props.total) * 100 : 0;
|
|
5
|
+
return (
|
|
6
|
+
<div key={n} className="flex items-center gap-2 text-sm">
|
|
7
|
+
<span className="w-3 text-right text-muted-foreground">{n}</span>
|
|
8
|
+
<span className="text-amber-400 text-xs">★</span>
|
|
9
|
+
<div className="h-2 flex-1 overflow-hidden rounded-full bg-muted">
|
|
10
|
+
<div
|
|
11
|
+
className="h-full rounded-full bg-amber-400 transition-all"
|
|
12
|
+
style={{ width: `${pct}%` }}
|
|
13
|
+
/>
|
|
14
|
+
</div>
|
|
15
|
+
<span className="w-5 text-right text-muted-foreground/70 text-xs">
|
|
16
|
+
{count}
|
|
17
|
+
</span>
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
})}
|
|
21
|
+
</div>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import DistributionBarsTemplate from "./distribution-bars.mdx";
|
|
4
|
+
|
|
5
|
+
export function DistributionBars({
|
|
6
|
+
distribution,
|
|
7
|
+
total,
|
|
8
|
+
}: {
|
|
9
|
+
distribution: Record<string, number>;
|
|
10
|
+
total: number;
|
|
11
|
+
}) {
|
|
12
|
+
return <DistributionBarsTemplate distribution={distribution} total={total} />;
|
|
13
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { MDXComponents } from "mdx/types";
|
|
4
|
+
import { DistributionBars } from "./distribution-bars";
|
|
5
|
+
import { ProductReviews } from "./product-reviews";
|
|
6
|
+
import { ReviewCard } from "./review-card";
|
|
7
|
+
import { ReviewForm } from "./review-form";
|
|
8
|
+
import { ReviewsSummary } from "./reviews-summary";
|
|
9
|
+
import { StarDisplay } from "./star-display";
|
|
10
|
+
import { StarPicker } from "./star-picker";
|
|
11
|
+
|
|
12
|
+
export default {
|
|
13
|
+
ReviewsSummary,
|
|
14
|
+
ProductReviews,
|
|
15
|
+
StarDisplay,
|
|
16
|
+
StarPicker,
|
|
17
|
+
ReviewCard,
|
|
18
|
+
ReviewForm,
|
|
19
|
+
DistributionBars,
|
|
20
|
+
} satisfies MDXComponents;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<section className="py-8">
|
|
2
|
+
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
|
3
|
+
<div>
|
|
4
|
+
<h2 className="font-semibold text-foreground text-xl">{props.title}</h2>
|
|
5
|
+
{!props.noReviews && props.summary && (
|
|
6
|
+
<div className="mt-1 flex items-center gap-2">
|
|
7
|
+
{props.starDisplay}
|
|
8
|
+
<span className="font-medium text-foreground text-lg">
|
|
9
|
+
{props.summary.average.toFixed(1)}
|
|
10
|
+
</span>
|
|
11
|
+
<span className="text-muted-foreground text-sm">
|
|
12
|
+
({props.summary.count} review{props.summary.count !== 1 ? "s" : ""})
|
|
13
|
+
</span>
|
|
14
|
+
</div>
|
|
15
|
+
)}
|
|
16
|
+
</div>
|
|
17
|
+
<button
|
|
18
|
+
type="button"
|
|
19
|
+
onClick={props.onToggleForm}
|
|
20
|
+
className="rounded-lg border border-border bg-background px-4 py-2 font-medium text-foreground text-sm transition-colors hover:bg-muted"
|
|
21
|
+
>
|
|
22
|
+
{props.showForm ? "Cancel" : "Write a Review"}
|
|
23
|
+
</button>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
{props.showForm && (
|
|
27
|
+
<div className="mb-8">
|
|
28
|
+
{props.formContent}
|
|
29
|
+
</div>
|
|
30
|
+
)}
|
|
31
|
+
|
|
32
|
+
{props.noReviews && !props.showForm && (
|
|
33
|
+
<div className="rounded-xl border border-border bg-muted/30 py-12 text-center">
|
|
34
|
+
<p className="font-medium text-foreground text-sm">No reviews yet</p>
|
|
35
|
+
<p className="mt-1 text-muted-foreground text-sm">
|
|
36
|
+
Be the first to review this product.
|
|
37
|
+
</p>
|
|
38
|
+
</div>
|
|
39
|
+
)}
|
|
40
|
+
|
|
41
|
+
{!props.noReviews && props.summary && (
|
|
42
|
+
<div className="mb-6 flex flex-col gap-5 sm:flex-row sm:items-start">
|
|
43
|
+
<div className="sm:w-48">
|
|
44
|
+
{props.distributionBars}
|
|
45
|
+
</div>
|
|
46
|
+
<div className="hidden h-auto w-px bg-border sm:block" />
|
|
47
|
+
<div className="flex-1">
|
|
48
|
+
{props.reviewListContent}
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
)}
|
|
52
|
+
</section>
|