@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.
Files changed (53) hide show
  1. package/AGENTS.md +41 -0
  2. package/COMPONENTS.md +34 -0
  3. package/README.md +192 -0
  4. package/package.json +46 -0
  5. package/src/__tests__/service-impl.test.ts +1436 -0
  6. package/src/admin/components/index.ts +3 -0
  7. package/src/admin/components/index.tsx +3 -0
  8. package/src/admin/components/review-analytics.mdx +3 -0
  9. package/src/admin/components/review-analytics.tsx +221 -0
  10. package/src/admin/components/review-list.mdx +89 -0
  11. package/src/admin/components/review-list.tsx +308 -0
  12. package/src/admin/components/review-moderation.mdx +3 -0
  13. package/src/admin/components/review-moderation.tsx +447 -0
  14. package/src/admin/endpoints/approve-review.ts +19 -0
  15. package/src/admin/endpoints/delete-review.ts +17 -0
  16. package/src/admin/endpoints/get-review.ts +16 -0
  17. package/src/admin/endpoints/index.ts +23 -0
  18. package/src/admin/endpoints/list-review-requests.ts +23 -0
  19. package/src/admin/endpoints/list-reviews.ts +25 -0
  20. package/src/admin/endpoints/reject-review.ts +19 -0
  21. package/src/admin/endpoints/respond-review.ts +22 -0
  22. package/src/admin/endpoints/review-analytics.ts +14 -0
  23. package/src/admin/endpoints/review-request-stats.ts +12 -0
  24. package/src/admin/endpoints/send-review-request.ts +41 -0
  25. package/src/index.ts +73 -0
  26. package/src/mdx.d.ts +5 -0
  27. package/src/schema.ts +37 -0
  28. package/src/service-impl.ts +263 -0
  29. package/src/service.ts +126 -0
  30. package/src/store/components/_hooks.ts +13 -0
  31. package/src/store/components/_utils.ts +16 -0
  32. package/src/store/components/distribution-bars.mdx +21 -0
  33. package/src/store/components/distribution-bars.tsx +13 -0
  34. package/src/store/components/index.tsx +20 -0
  35. package/src/store/components/product-reviews.mdx +52 -0
  36. package/src/store/components/product-reviews.tsx +172 -0
  37. package/src/store/components/review-card.mdx +32 -0
  38. package/src/store/components/review-card.tsx +87 -0
  39. package/src/store/components/review-form.mdx +111 -0
  40. package/src/store/components/review-form.tsx +68 -0
  41. package/src/store/components/reviews-summary.mdx +6 -0
  42. package/src/store/components/reviews-summary.tsx +30 -0
  43. package/src/store/components/star-display.mdx +18 -0
  44. package/src/store/components/star-display.tsx +28 -0
  45. package/src/store/components/star-picker.mdx +21 -0
  46. package/src/store/components/star-picker.tsx +23 -0
  47. package/src/store/endpoints/index.ts +11 -0
  48. package/src/store/endpoints/list-my-reviews.ts +38 -0
  49. package/src/store/endpoints/list-product-reviews.ts +26 -0
  50. package/src/store/endpoints/mark-helpful.ts +16 -0
  51. package/src/store/endpoints/submit-review.ts +33 -0
  52. package/tsconfig.json +9 -0
  53. package/vitest.config.ts +2 -0
@@ -0,0 +1,447 @@
1
+ "use client";
2
+
3
+ import { useModuleClient } from "@86d-app/core/client";
4
+ import { useState } from "react";
5
+ import ReviewModerationTemplate from "./review-moderation.mdx";
6
+
7
+ interface Review {
8
+ id: string;
9
+ productId: string;
10
+ customerId: string;
11
+ authorName: string;
12
+ authorEmail: string;
13
+ rating: number;
14
+ title?: string;
15
+ body: string;
16
+ status: "pending" | "approved" | "rejected";
17
+ isVerifiedPurchase: boolean;
18
+ helpfulCount: number;
19
+ merchantResponse?: string;
20
+ merchantResponseAt?: string;
21
+ moderationNote?: string;
22
+ createdAt: string;
23
+ updatedAt: string;
24
+ }
25
+
26
+ function formatDate(iso: string): string {
27
+ return new Intl.DateTimeFormat("en-US", {
28
+ month: "short",
29
+ day: "numeric",
30
+ year: "numeric",
31
+ }).format(new Date(iso));
32
+ }
33
+
34
+ function extractError(error: Error | null, fallback: string): string {
35
+ if (!error) return fallback;
36
+ // biome-ignore lint/suspicious/noExplicitAny: accessing HTTP error body property
37
+ const body = (error as any)?.body;
38
+ if (typeof body?.error === "string") return body.error;
39
+ if (typeof body?.error?.message === "string") return body.error.message;
40
+ return fallback;
41
+ }
42
+
43
+ function useReviewsAdminApi() {
44
+ const client = useModuleClient();
45
+ return {
46
+ getReview: client.module("reviews").admin["/admin/reviews/:id"],
47
+ approveReview: client.module("reviews").admin["/admin/reviews/:id/approve"],
48
+ rejectReview: client.module("reviews").admin["/admin/reviews/:id/reject"],
49
+ respondReview: client.module("reviews").admin["/admin/reviews/:id/respond"],
50
+ deleteReview: client.module("reviews").admin["/admin/reviews/:id/delete"],
51
+ };
52
+ }
53
+
54
+ function StarDisplay({ rating }: { rating: number }) {
55
+ return (
56
+ <span
57
+ role="img"
58
+ className="select-none text-base leading-none"
59
+ aria-label={`${rating} out of 5 stars`}
60
+ >
61
+ {[1, 2, 3, 4, 5].map((n) => (
62
+ <span
63
+ key={n}
64
+ className={
65
+ n <= Math.round(rating)
66
+ ? "text-amber-400"
67
+ : "text-gray-200 dark:text-gray-700"
68
+ }
69
+ >
70
+
71
+ </span>
72
+ ))}
73
+ </span>
74
+ );
75
+ }
76
+
77
+ function StatusBadge({ status }: { status: Review["status"] }) {
78
+ const styles: Record<Review["status"], string> = {
79
+ pending:
80
+ "bg-yellow-50 text-yellow-700 dark:bg-yellow-950 dark:text-yellow-300",
81
+ approved:
82
+ "bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300",
83
+ rejected: "bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300",
84
+ };
85
+
86
+ return (
87
+ <span
88
+ className={`inline-block rounded-full px-2 py-0.5 font-medium text-xs capitalize ${styles[status]}`}
89
+ >
90
+ {status}
91
+ </span>
92
+ );
93
+ }
94
+
95
+ export function ReviewModeration(props: {
96
+ reviewId?: string;
97
+ params?: Record<string, string>;
98
+ onAction?: () => void;
99
+ }) {
100
+ const reviewId = props.reviewId ?? props.params?.id ?? "";
101
+ const onAction = props.onAction;
102
+ const api = useReviewsAdminApi();
103
+ const [deleted, setDeleted] = useState(false);
104
+ const [actionLoading, setActionLoading] = useState(false);
105
+ const [deleteConfirm, setDeleteConfirm] = useState(false);
106
+ const [error, setError] = useState("");
107
+ const [responseText, setResponseText] = useState("");
108
+ const [showResponseForm, setShowResponseForm] = useState(false);
109
+
110
+ const {
111
+ data: reviewData,
112
+ isLoading: loading,
113
+ error: queryError,
114
+ } = api.getReview.useQuery({ params: { id: reviewId } }) as {
115
+ data: { review: Review } | undefined;
116
+ isLoading: boolean;
117
+ error: Error | null;
118
+ };
119
+
120
+ const review = deleted ? null : (reviewData?.review ?? null);
121
+ const queryErrorMsg = queryError
122
+ ? "Failed to load review. Please try again."
123
+ : !loading && reviewData && !review && !deleted
124
+ ? "Review not found."
125
+ : "";
126
+
127
+ const approveMutation = api.approveReview.useMutation({
128
+ onSuccess: () => {
129
+ onAction?.();
130
+ },
131
+ onError: (err: Error) => {
132
+ setError(extractError(err, "Failed to approve review."));
133
+ },
134
+ onSettled: () => {
135
+ setActionLoading(false);
136
+ void api.getReview.invalidate();
137
+ },
138
+ });
139
+
140
+ const rejectMutation = api.rejectReview.useMutation({
141
+ onSuccess: () => {
142
+ onAction?.();
143
+ },
144
+ onError: (err: Error) => {
145
+ setError(extractError(err, "Failed to reject review."));
146
+ },
147
+ onSettled: () => {
148
+ setActionLoading(false);
149
+ void api.getReview.invalidate();
150
+ },
151
+ });
152
+
153
+ const respondMutation = api.respondReview.useMutation({
154
+ onSuccess: () => {
155
+ setShowResponseForm(false);
156
+ setResponseText("");
157
+ },
158
+ onError: (err: Error) => {
159
+ setError(extractError(err, "Failed to save response."));
160
+ },
161
+ onSettled: () => {
162
+ setActionLoading(false);
163
+ void api.getReview.invalidate();
164
+ },
165
+ });
166
+
167
+ const deleteMutation = api.deleteReview.useMutation({
168
+ onSuccess: () => {
169
+ setDeleted(true);
170
+ onAction?.();
171
+ },
172
+ onError: (err: Error) => {
173
+ setError(extractError(err, "Failed to delete review."));
174
+ },
175
+ onSettled: () => {
176
+ setActionLoading(false);
177
+ void api.getReview.invalidate();
178
+ },
179
+ });
180
+
181
+ const handleApprove = () => {
182
+ setActionLoading(true);
183
+ setError("");
184
+ approveMutation.mutate({ params: { id: reviewId } });
185
+ };
186
+
187
+ const handleReject = () => {
188
+ setActionLoading(true);
189
+ setError("");
190
+ rejectMutation.mutate({ params: { id: reviewId } });
191
+ };
192
+
193
+ const handleDelete = () => {
194
+ setActionLoading(true);
195
+ setDeleteConfirm(false);
196
+ setError("");
197
+ deleteMutation.mutate({ params: { id: reviewId } });
198
+ };
199
+
200
+ const handleRespond = () => {
201
+ if (!responseText.trim()) return;
202
+ setActionLoading(true);
203
+ setError("");
204
+ respondMutation.mutate({
205
+ params: { id: reviewId },
206
+ body: { response: responseText.trim() },
207
+ });
208
+ };
209
+
210
+ if (loading) {
211
+ return (
212
+ <div className="rounded-xl border border-border bg-card p-6">
213
+ <p className="text-muted-foreground text-sm">Loading review...</p>
214
+ </div>
215
+ );
216
+ }
217
+
218
+ if ((queryErrorMsg || error) && !review) {
219
+ return (
220
+ <div className="rounded-xl border border-border bg-card p-6">
221
+ <p className="text-destructive text-sm" role="alert">
222
+ {queryErrorMsg || error}
223
+ </p>
224
+ </div>
225
+ );
226
+ }
227
+
228
+ if (!review) {
229
+ return (
230
+ <div className="rounded-xl border border-border bg-card p-6">
231
+ <p className="text-muted-foreground text-sm">
232
+ Review has been deleted.
233
+ </p>
234
+ </div>
235
+ );
236
+ }
237
+
238
+ const content = (
239
+ <div className="space-y-5 rounded-xl border border-border bg-card p-6">
240
+ <div className="flex items-start justify-between gap-4">
241
+ <div className="space-y-2">
242
+ <div className="flex items-center gap-3">
243
+ <StarDisplay rating={review.rating} />
244
+ <StatusBadge status={review.status} />
245
+ {review.isVerifiedPurchase && (
246
+ <span className="rounded-full bg-emerald-50 px-2 py-0.5 text-emerald-700 text-xs dark:bg-emerald-950 dark:text-emerald-300">
247
+ Verified Purchase
248
+ </span>
249
+ )}
250
+ </div>
251
+ {review.title && (
252
+ <h3 className="font-semibold text-foreground text-lg">
253
+ {review.title}
254
+ </h3>
255
+ )}
256
+ </div>
257
+ <span className="shrink-0 text-muted-foreground text-xs">
258
+ {formatDate(review.createdAt)}
259
+ </span>
260
+ </div>
261
+
262
+ <p className="text-foreground text-sm leading-relaxed">{review.body}</p>
263
+
264
+ <div className="grid gap-3 rounded-lg border border-border bg-muted/30 p-4 sm:grid-cols-2">
265
+ <div>
266
+ <p className="font-medium text-muted-foreground text-xs">Author</p>
267
+ <p className="mt-0.5 text-foreground text-sm">{review.authorName}</p>
268
+ </div>
269
+ <div>
270
+ <p className="font-medium text-muted-foreground text-xs">Email</p>
271
+ <p className="mt-0.5 text-foreground text-sm">{review.authorEmail}</p>
272
+ </div>
273
+ <div>
274
+ <p className="font-medium text-muted-foreground text-xs">
275
+ Product ID
276
+ </p>
277
+ <p className="mt-0.5">
278
+ <code className="rounded bg-muted px-1.5 py-0.5 text-muted-foreground text-xs">
279
+ {review.productId}
280
+ </code>
281
+ </p>
282
+ </div>
283
+ <div>
284
+ <p className="font-medium text-muted-foreground text-xs">
285
+ Helpful Count
286
+ </p>
287
+ <p className="mt-0.5 text-foreground text-sm">
288
+ {review.helpfulCount}
289
+ </p>
290
+ </div>
291
+ </div>
292
+
293
+ {review.moderationNote && (
294
+ <div className="rounded-lg border border-amber-200 bg-amber-50/50 p-4 dark:border-amber-800 dark:bg-amber-950/20">
295
+ <p className="mb-1 font-medium text-amber-800 text-xs dark:text-amber-300">
296
+ Moderation Note
297
+ </p>
298
+ <p className="text-amber-900 text-sm dark:text-amber-200">
299
+ {review.moderationNote}
300
+ </p>
301
+ </div>
302
+ )}
303
+
304
+ {review.merchantResponse ? (
305
+ <div className="rounded-lg border border-blue-200 bg-blue-50/50 p-4 dark:border-blue-800 dark:bg-blue-950/20">
306
+ <div className="mb-1 flex items-center justify-between">
307
+ <p className="font-medium text-blue-800 text-xs dark:text-blue-300">
308
+ Merchant Response
309
+ </p>
310
+ {review.merchantResponseAt && (
311
+ <span className="text-blue-600 text-xs dark:text-blue-400">
312
+ {formatDate(review.merchantResponseAt)}
313
+ </span>
314
+ )}
315
+ </div>
316
+ <p className="text-blue-900 text-sm dark:text-blue-200">
317
+ {review.merchantResponse}
318
+ </p>
319
+ <button
320
+ type="button"
321
+ onClick={() => {
322
+ setResponseText(review.merchantResponse ?? "");
323
+ setShowResponseForm(true);
324
+ }}
325
+ className="mt-2 text-blue-600 text-xs underline-offset-4 hover:underline dark:text-blue-400"
326
+ >
327
+ Edit response
328
+ </button>
329
+ </div>
330
+ ) : !showResponseForm ? (
331
+ <button
332
+ type="button"
333
+ onClick={() => setShowResponseForm(true)}
334
+ className="rounded-lg border border-border border-dashed px-4 py-3 text-muted-foreground text-sm transition-colors hover:border-foreground hover:text-foreground"
335
+ >
336
+ + Add merchant response
337
+ </button>
338
+ ) : null}
339
+
340
+ {showResponseForm && (
341
+ <div className="space-y-3 rounded-lg border border-border bg-muted/20 p-4">
342
+ <label className="block">
343
+ <span className="mb-1 block font-medium text-foreground text-sm">
344
+ Merchant Response
345
+ </span>
346
+ <textarea
347
+ value={responseText}
348
+ onChange={(e) => setResponseText(e.target.value)}
349
+ rows={4}
350
+ maxLength={5000}
351
+ placeholder="Write your response to this review..."
352
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground text-sm placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
353
+ />
354
+ </label>
355
+ <div className="flex gap-2">
356
+ <button
357
+ type="button"
358
+ disabled={actionLoading || !responseText.trim()}
359
+ onClick={handleRespond}
360
+ className="rounded-lg bg-primary px-4 py-2 font-medium text-primary-foreground text-sm transition-opacity hover:opacity-90 disabled:opacity-50"
361
+ >
362
+ {actionLoading ? "Saving…" : "Save Response"}
363
+ </button>
364
+ <button
365
+ type="button"
366
+ onClick={() => {
367
+ setShowResponseForm(false);
368
+ setResponseText("");
369
+ }}
370
+ className="rounded-lg border border-border px-4 py-2 font-medium text-foreground text-sm transition-colors hover:bg-muted"
371
+ >
372
+ Cancel
373
+ </button>
374
+ </div>
375
+ </div>
376
+ )}
377
+
378
+ {error && (
379
+ <p className="text-destructive text-sm" role="alert">
380
+ {error}
381
+ </p>
382
+ )}
383
+
384
+ {deleteConfirm ? (
385
+ <div className="flex items-center gap-3 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-800 dark:bg-red-950/30">
386
+ <p className="flex-1 text-red-700 text-sm dark:text-red-300">
387
+ Are you sure you want to permanently delete this review?
388
+ </p>
389
+ <button
390
+ type="button"
391
+ disabled={actionLoading}
392
+ onClick={() => {
393
+ handleDelete();
394
+ }}
395
+ className="rounded-lg bg-red-600 px-4 py-2 font-medium text-sm text-white transition-opacity hover:bg-red-700 disabled:opacity-50"
396
+ >
397
+ {actionLoading ? "Deleting…" : "Confirm Delete"}
398
+ </button>
399
+ <button
400
+ type="button"
401
+ onClick={() => setDeleteConfirm(false)}
402
+ className="rounded-lg border border-border px-4 py-2 font-medium text-foreground text-sm transition-colors hover:bg-muted"
403
+ >
404
+ Cancel
405
+ </button>
406
+ </div>
407
+ ) : (
408
+ <div className="flex gap-2">
409
+ {review.status !== "approved" && (
410
+ <button
411
+ type="button"
412
+ disabled={actionLoading}
413
+ onClick={() => {
414
+ handleApprove();
415
+ }}
416
+ className="rounded-lg bg-emerald-600 px-4 py-2 font-medium text-sm text-white transition-opacity hover:bg-emerald-700 disabled:opacity-50"
417
+ >
418
+ {actionLoading ? "Approving…" : "Approve"}
419
+ </button>
420
+ )}
421
+ {review.status !== "rejected" && (
422
+ <button
423
+ type="button"
424
+ disabled={actionLoading}
425
+ onClick={() => {
426
+ handleReject();
427
+ }}
428
+ className="rounded-lg bg-red-600 px-4 py-2 font-medium text-sm text-white transition-opacity hover:bg-red-700 disabled:opacity-50"
429
+ >
430
+ {actionLoading ? "Rejecting…" : "Reject"}
431
+ </button>
432
+ )}
433
+ <button
434
+ type="button"
435
+ disabled={actionLoading}
436
+ onClick={() => setDeleteConfirm(true)}
437
+ className="rounded-lg border border-border px-4 py-2 font-medium text-destructive text-sm transition-colors hover:bg-red-50 disabled:opacity-50 dark:hover:bg-red-950"
438
+ >
439
+ Delete
440
+ </button>
441
+ </div>
442
+ )}
443
+ </div>
444
+ );
445
+
446
+ return <ReviewModerationTemplate content={content} />;
447
+ }
@@ -0,0 +1,19 @@
1
+ import { createAdminEndpoint, z } from "@86d-app/core";
2
+ import type { ReviewController } from "../../service";
3
+
4
+ export const approveReview = createAdminEndpoint(
5
+ "/admin/reviews/:id/approve",
6
+ {
7
+ method: "PUT",
8
+ params: z.object({ id: z.string() }),
9
+ },
10
+ async (ctx) => {
11
+ const controller = ctx.context.controllers.reviews as ReviewController;
12
+ const review = await controller.updateReviewStatus(
13
+ ctx.params.id,
14
+ "approved",
15
+ );
16
+ if (!review) return { error: "Review not found", status: 404 };
17
+ return { review };
18
+ },
19
+ );
@@ -0,0 +1,17 @@
1
+ import { createAdminEndpoint, z } from "@86d-app/core";
2
+ import type { ReviewController } from "../../service";
3
+
4
+ export const deleteReview = createAdminEndpoint(
5
+ "/admin/reviews/:id/delete",
6
+ {
7
+ method: "DELETE",
8
+ params: z.object({ id: z.string() }),
9
+ },
10
+ async (ctx) => {
11
+ const controller = ctx.context.controllers.reviews as ReviewController;
12
+ const existing = await controller.getReview(ctx.params.id);
13
+ if (!existing) return { error: "Review not found", status: 404 };
14
+ const deleted = await controller.deleteReview(ctx.params.id);
15
+ return { deleted };
16
+ },
17
+ );
@@ -0,0 +1,16 @@
1
+ import { createAdminEndpoint, z } from "@86d-app/core";
2
+ import type { ReviewController } from "../../service";
3
+
4
+ export const getReview = createAdminEndpoint(
5
+ "/admin/reviews/:id",
6
+ {
7
+ method: "GET",
8
+ params: z.object({ id: z.string() }),
9
+ },
10
+ async (ctx) => {
11
+ const controller = ctx.context.controllers.reviews as ReviewController;
12
+ const review = await controller.getReview(ctx.params.id);
13
+ if (!review) return { error: "Review not found", status: 404 };
14
+ return { review };
15
+ },
16
+ );
@@ -0,0 +1,23 @@
1
+ import { approveReview } from "./approve-review";
2
+ import { deleteReview } from "./delete-review";
3
+ import { getReview } from "./get-review";
4
+ import { listReviewRequests } from "./list-review-requests";
5
+ import { listReviews } from "./list-reviews";
6
+ import { rejectReview } from "./reject-review";
7
+ import { respondReview } from "./respond-review";
8
+ import { reviewAnalytics } from "./review-analytics";
9
+ import { reviewRequestStats } from "./review-request-stats";
10
+ import { sendReviewRequest } from "./send-review-request";
11
+
12
+ export const adminEndpoints = {
13
+ "/admin/reviews": listReviews,
14
+ "/admin/reviews/analytics": reviewAnalytics,
15
+ "/admin/reviews/requests": listReviewRequests,
16
+ "/admin/reviews/request-stats": reviewRequestStats,
17
+ "/admin/reviews/send-request": sendReviewRequest,
18
+ "/admin/reviews/:id": getReview,
19
+ "/admin/reviews/:id/approve": approveReview,
20
+ "/admin/reviews/:id/reject": rejectReview,
21
+ "/admin/reviews/:id/respond": respondReview,
22
+ "/admin/reviews/:id/delete": deleteReview,
23
+ };
@@ -0,0 +1,23 @@
1
+ import { createAdminEndpoint, z } from "@86d-app/core";
2
+ import type { ReviewController } from "../../service";
3
+
4
+ export const listReviewRequests = createAdminEndpoint(
5
+ "/admin/reviews/requests",
6
+ {
7
+ method: "GET",
8
+ query: z
9
+ .object({
10
+ take: z.coerce.number().optional(),
11
+ skip: z.coerce.number().optional(),
12
+ })
13
+ .optional(),
14
+ },
15
+ async (ctx) => {
16
+ const controller = ctx.context.controllers.reviews as ReviewController;
17
+ const requests = await controller.listReviewRequests({
18
+ take: ctx.query?.take,
19
+ skip: ctx.query?.skip,
20
+ });
21
+ return { requests };
22
+ },
23
+ );
@@ -0,0 +1,25 @@
1
+ import { createAdminEndpoint, z } from "@86d-app/core";
2
+ import type { ReviewController, ReviewStatus } from "../../service";
3
+
4
+ export const listReviews = createAdminEndpoint(
5
+ "/admin/reviews",
6
+ {
7
+ method: "GET",
8
+ query: z.object({
9
+ status: z.enum(["pending", "approved", "rejected"]).optional(),
10
+ productId: z.string().optional(),
11
+ take: z.coerce.number().int().min(1).max(100).optional(),
12
+ skip: z.coerce.number().int().min(0).optional(),
13
+ }),
14
+ },
15
+ async (ctx) => {
16
+ const controller = ctx.context.controllers.reviews as ReviewController;
17
+ const reviews = await controller.listReviews({
18
+ status: ctx.query.status as ReviewStatus | undefined,
19
+ productId: ctx.query.productId,
20
+ take: ctx.query.take ?? 50,
21
+ skip: ctx.query.skip ?? 0,
22
+ });
23
+ return { reviews, total: reviews.length };
24
+ },
25
+ );
@@ -0,0 +1,19 @@
1
+ import { createAdminEndpoint, z } from "@86d-app/core";
2
+ import type { ReviewController } from "../../service";
3
+
4
+ export const rejectReview = createAdminEndpoint(
5
+ "/admin/reviews/:id/reject",
6
+ {
7
+ method: "PUT",
8
+ params: z.object({ id: z.string() }),
9
+ },
10
+ async (ctx) => {
11
+ const controller = ctx.context.controllers.reviews as ReviewController;
12
+ const review = await controller.updateReviewStatus(
13
+ ctx.params.id,
14
+ "rejected",
15
+ );
16
+ if (!review) return { error: "Review not found", status: 404 };
17
+ return { review };
18
+ },
19
+ );
@@ -0,0 +1,22 @@
1
+ import { createAdminEndpoint, sanitizeText, z } from "@86d-app/core";
2
+ import type { ReviewController } from "../../service";
3
+
4
+ export const respondReview = createAdminEndpoint(
5
+ "/admin/reviews/:id/respond",
6
+ {
7
+ method: "POST",
8
+ params: z.object({ id: z.string() }),
9
+ body: z.object({
10
+ response: z.string().min(1).max(5000).transform(sanitizeText),
11
+ }),
12
+ },
13
+ async (ctx) => {
14
+ const controller = ctx.context.controllers.reviews as ReviewController;
15
+ const review = await controller.addMerchantResponse(
16
+ ctx.params.id,
17
+ ctx.body.response,
18
+ );
19
+ if (!review) return { error: "Review not found", status: 404 };
20
+ return { review };
21
+ },
22
+ );
@@ -0,0 +1,14 @@
1
+ import { createAdminEndpoint } from "@86d-app/core";
2
+ import type { ReviewController } from "../../service";
3
+
4
+ export const reviewAnalytics = createAdminEndpoint(
5
+ "/admin/reviews/analytics",
6
+ {
7
+ method: "GET",
8
+ },
9
+ async (ctx) => {
10
+ const controller = ctx.context.controllers.reviews as ReviewController;
11
+ const analytics = await controller.getReviewAnalytics();
12
+ return { analytics };
13
+ },
14
+ );
@@ -0,0 +1,12 @@
1
+ import { createAdminEndpoint } from "@86d-app/core";
2
+ import type { ReviewController } from "../../service";
3
+
4
+ export const reviewRequestStats = createAdminEndpoint(
5
+ "/admin/reviews/request-stats",
6
+ { method: "GET" },
7
+ async (ctx) => {
8
+ const controller = ctx.context.controllers.reviews as ReviewController;
9
+ const stats = await controller.getReviewRequestStats();
10
+ return { stats };
11
+ },
12
+ );
@@ -0,0 +1,41 @@
1
+ import { createAdminEndpoint, z } from "@86d-app/core";
2
+ import type { ReviewController } from "../../service";
3
+
4
+ export const sendReviewRequest = createAdminEndpoint(
5
+ "/admin/reviews/send-request",
6
+ {
7
+ method: "POST",
8
+ body: z.object({
9
+ orderId: z.string(),
10
+ orderNumber: z.string(),
11
+ email: z.string().email(),
12
+ customerName: z.string(),
13
+ items: z.array(
14
+ z.object({
15
+ productId: z.string(),
16
+ name: z.string(),
17
+ }),
18
+ ),
19
+ }),
20
+ },
21
+ async (ctx) => {
22
+ const controller = ctx.context.controllers.reviews as ReviewController;
23
+
24
+ const existing = await controller.getReviewRequest(ctx.body.orderId);
25
+ if (existing) {
26
+ return {
27
+ error: "Review request already sent for this order",
28
+ status: 409,
29
+ };
30
+ }
31
+
32
+ const request = await controller.createReviewRequest({
33
+ orderId: ctx.body.orderId,
34
+ orderNumber: ctx.body.orderNumber,
35
+ email: ctx.body.email,
36
+ customerName: ctx.body.customerName,
37
+ items: ctx.body.items,
38
+ });
39
+ return { request };
40
+ },
41
+ );