@86d-app/reviews 0.0.23 → 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.
Files changed (126) hide show
  1. package/dist/modules/reviews/src/__tests__/controllers.test.js +937 -0
  2. package/dist/modules/reviews/src/__tests__/endpoint-security.test.js +438 -0
  3. package/dist/modules/reviews/src/__tests__/new-features.test.js +467 -0
  4. package/dist/modules/reviews/src/__tests__/service-impl.test.js +1042 -0
  5. package/dist/modules/reviews/src/__tests__/store-endpoints.test.js +456 -0
  6. package/dist/{admin/components/index.d.ts → modules/reviews/src/admin/components/index.js} +0 -1
  7. package/dist/modules/reviews/src/admin/components/review-analytics.jsx +141 -0
  8. package/dist/modules/reviews/src/admin/components/review-list.jsx +181 -0
  9. package/dist/modules/reviews/src/admin/components/review-moderation.jsx +297 -0
  10. package/dist/modules/reviews/src/admin/endpoints/approve-review.js +11 -0
  11. package/dist/modules/reviews/src/admin/endpoints/delete-review.js +12 -0
  12. package/dist/modules/reviews/src/admin/endpoints/get-review.js +11 -0
  13. package/dist/modules/reviews/src/admin/endpoints/index.js +26 -0
  14. package/dist/modules/reviews/src/admin/endpoints/list-reports.js +19 -0
  15. package/dist/modules/reviews/src/admin/endpoints/list-review-requests.js +17 -0
  16. package/dist/modules/reviews/src/admin/endpoints/list-reviews.js +19 -0
  17. package/dist/modules/reviews/src/admin/endpoints/reject-review.js +11 -0
  18. package/dist/modules/reviews/src/admin/endpoints/respond-review.js +14 -0
  19. package/dist/modules/reviews/src/admin/endpoints/review-analytics.js +8 -0
  20. package/dist/modules/reviews/src/admin/endpoints/review-request-stats.js +6 -0
  21. package/dist/modules/reviews/src/admin/endpoints/send-review-request.js +31 -0
  22. package/dist/modules/reviews/src/admin/endpoints/update-report.js +14 -0
  23. package/dist/modules/reviews/src/index.js +57 -0
  24. package/dist/modules/reviews/src/schema.js +63 -0
  25. package/dist/modules/reviews/src/service-impl.js +349 -0
  26. package/dist/modules/reviews/src/service.js +1 -0
  27. package/dist/modules/reviews/src/store/components/_hooks.js +10 -0
  28. package/dist/modules/reviews/src/store/components/_utils.js +17 -0
  29. package/dist/modules/reviews/src/store/components/distribution-bars.jsx +5 -0
  30. package/dist/modules/reviews/src/store/components/index.jsx +17 -0
  31. package/dist/modules/reviews/src/store/components/product-reviews.jsx +94 -0
  32. package/dist/modules/reviews/src/store/components/review-card.jsx +44 -0
  33. package/dist/modules/reviews/src/store/components/review-form.jsx +39 -0
  34. package/dist/modules/reviews/src/store/components/reviews-summary.jsx +15 -0
  35. package/dist/modules/reviews/src/store/components/star-display.jsx +12 -0
  36. package/dist/modules/reviews/src/store/components/star-picker.jsx +7 -0
  37. package/dist/modules/reviews/src/store/endpoints/index.js +12 -0
  38. package/dist/modules/reviews/src/store/endpoints/list-my-reviews.js +29 -0
  39. package/dist/modules/reviews/src/store/endpoints/list-product-reviews.js +24 -0
  40. package/dist/modules/reviews/src/store/endpoints/mark-helpful.js +29 -0
  41. package/dist/modules/reviews/src/store/endpoints/report-review.js +30 -0
  42. package/dist/modules/reviews/src/store/endpoints/submit-review.js +46 -0
  43. package/package.json +1 -1
  44. package/src/__tests__/store-endpoints.test.ts +648 -0
  45. package/src/store/components/product-reviews.tsx +38 -6
  46. package/dist/__tests__/controllers.test.d.ts +0 -2
  47. package/dist/__tests__/controllers.test.d.ts.map +0 -1
  48. package/dist/__tests__/endpoint-security.test.d.ts +0 -2
  49. package/dist/__tests__/endpoint-security.test.d.ts.map +0 -1
  50. package/dist/__tests__/new-features.test.d.ts +0 -2
  51. package/dist/__tests__/new-features.test.d.ts.map +0 -1
  52. package/dist/__tests__/service-impl.test.d.ts +0 -2
  53. package/dist/__tests__/service-impl.test.d.ts.map +0 -1
  54. package/dist/admin/components/index.d.ts.map +0 -1
  55. package/dist/admin/components/review-analytics.d.ts +0 -2
  56. package/dist/admin/components/review-analytics.d.ts.map +0 -1
  57. package/dist/admin/components/review-list.d.ts +0 -2
  58. package/dist/admin/components/review-list.d.ts.map +0 -1
  59. package/dist/admin/components/review-moderation.d.ts +0 -6
  60. package/dist/admin/components/review-moderation.d.ts.map +0 -1
  61. package/dist/admin/endpoints/approve-review.d.ts +0 -16
  62. package/dist/admin/endpoints/approve-review.d.ts.map +0 -1
  63. package/dist/admin/endpoints/delete-review.d.ts +0 -16
  64. package/dist/admin/endpoints/delete-review.d.ts.map +0 -1
  65. package/dist/admin/endpoints/get-review.d.ts +0 -16
  66. package/dist/admin/endpoints/get-review.d.ts.map +0 -1
  67. package/dist/admin/endpoints/index.d.ts +0 -167
  68. package/dist/admin/endpoints/index.d.ts.map +0 -1
  69. package/dist/admin/endpoints/list-reports.d.ts +0 -17
  70. package/dist/admin/endpoints/list-reports.d.ts.map +0 -1
  71. package/dist/admin/endpoints/list-review-requests.d.ts +0 -11
  72. package/dist/admin/endpoints/list-review-requests.d.ts.map +0 -1
  73. package/dist/admin/endpoints/list-reviews.d.ts +0 -18
  74. package/dist/admin/endpoints/list-reviews.d.ts.map +0 -1
  75. package/dist/admin/endpoints/reject-review.d.ts +0 -16
  76. package/dist/admin/endpoints/reject-review.d.ts.map +0 -1
  77. package/dist/admin/endpoints/respond-review.d.ts +0 -19
  78. package/dist/admin/endpoints/respond-review.d.ts.map +0 -1
  79. package/dist/admin/endpoints/review-analytics.d.ts +0 -6
  80. package/dist/admin/endpoints/review-analytics.d.ts.map +0 -1
  81. package/dist/admin/endpoints/review-request-stats.d.ts +0 -6
  82. package/dist/admin/endpoints/review-request-stats.d.ts.map +0 -1
  83. package/dist/admin/endpoints/send-review-request.d.ts +0 -23
  84. package/dist/admin/endpoints/send-review-request.d.ts.map +0 -1
  85. package/dist/admin/endpoints/update-report.d.ts +0 -22
  86. package/dist/admin/endpoints/update-report.d.ts.map +0 -1
  87. package/dist/index.d.ts +0 -8
  88. package/dist/index.d.ts.map +0 -1
  89. package/dist/schema.d.ts +0 -136
  90. package/dist/schema.d.ts.map +0 -1
  91. package/dist/service-impl.d.ts +0 -6
  92. package/dist/service-impl.d.ts.map +0 -1
  93. package/dist/service.d.ts +0 -149
  94. package/dist/service.d.ts.map +0 -1
  95. package/dist/store/components/_hooks.d.ts +0 -6
  96. package/dist/store/components/_hooks.d.ts.map +0 -1
  97. package/dist/store/components/_utils.d.ts +0 -3
  98. package/dist/store/components/_utils.d.ts.map +0 -1
  99. package/dist/store/components/distribution-bars.d.ts +0 -5
  100. package/dist/store/components/distribution-bars.d.ts.map +0 -1
  101. package/dist/store/components/index.d.ts +0 -18
  102. package/dist/store/components/index.d.ts.map +0 -1
  103. package/dist/store/components/product-reviews.d.ts +0 -5
  104. package/dist/store/components/product-reviews.d.ts.map +0 -1
  105. package/dist/store/components/review-card.d.ts +0 -18
  106. package/dist/store/components/review-card.d.ts.map +0 -1
  107. package/dist/store/components/review-form.d.ts +0 -5
  108. package/dist/store/components/review-form.d.ts.map +0 -1
  109. package/dist/store/components/reviews-summary.d.ts +0 -4
  110. package/dist/store/components/reviews-summary.d.ts.map +0 -1
  111. package/dist/store/components/star-display.d.ts +0 -5
  112. package/dist/store/components/star-display.d.ts.map +0 -1
  113. package/dist/store/components/star-picker.d.ts +0 -5
  114. package/dist/store/components/star-picker.d.ts.map +0 -1
  115. package/dist/store/endpoints/index.d.ts +0 -116
  116. package/dist/store/endpoints/index.d.ts.map +0 -1
  117. package/dist/store/endpoints/list-my-reviews.d.ts +0 -30
  118. package/dist/store/endpoints/list-my-reviews.d.ts.map +0 -1
  119. package/dist/store/endpoints/list-product-reviews.d.ts +0 -23
  120. package/dist/store/endpoints/list-product-reviews.d.ts.map +0 -1
  121. package/dist/store/endpoints/mark-helpful.d.ts +0 -18
  122. package/dist/store/endpoints/mark-helpful.d.ts.map +0 -1
  123. package/dist/store/endpoints/report-review.d.ts +0 -27
  124. package/dist/store/endpoints/report-review.d.ts.map +0 -1
  125. package/dist/store/endpoints/submit-review.d.ts +0 -25
  126. package/dist/store/endpoints/submit-review.d.ts.map +0 -1
@@ -0,0 +1,181 @@
1
+ "use client";
2
+ import { useModuleClient } from "@86d-app/core/client";
3
+ import { useState } from "react";
4
+ import ReviewListTemplate from "./review-list.mdx";
5
+ const STATUS_FILTERS = [
6
+ { label: "All", value: "all" },
7
+ { label: "Pending", value: "pending" },
8
+ { label: "Approved", value: "approved" },
9
+ { label: "Rejected", value: "rejected" },
10
+ ];
11
+ const PAGE_SIZE = 20;
12
+ function formatDate(iso) {
13
+ return new Intl.DateTimeFormat("en-US", {
14
+ month: "short",
15
+ day: "numeric",
16
+ year: "numeric",
17
+ }).format(new Date(iso));
18
+ }
19
+ function extractError(error, fallback) {
20
+ if (!error)
21
+ return fallback;
22
+ const body = error.body;
23
+ if (typeof body?.error === "string")
24
+ return body.error;
25
+ if (typeof body?.error?.message === "string")
26
+ return body.error.message;
27
+ return fallback;
28
+ }
29
+ function useReviewsAdminApi() {
30
+ const client = useModuleClient();
31
+ return {
32
+ listReviews: client.module("reviews").admin["/admin/reviews"],
33
+ approveReview: client.module("reviews").admin["/admin/reviews/:id/approve"],
34
+ rejectReview: client.module("reviews").admin["/admin/reviews/:id/reject"],
35
+ deleteReview: client.module("reviews").admin["/admin/reviews/:id/delete"],
36
+ };
37
+ }
38
+ function StarDisplay({ rating }) {
39
+ return (<span role="img" className="select-none text-sm leading-none" aria-label={`${rating} out of 5 stars`}>
40
+ {[1, 2, 3, 4, 5].map((n) => (<span key={n} className={n <= Math.round(rating)
41
+ ? "text-amber-400"
42
+ : "text-muted-foreground/50"}>
43
+
44
+ </span>))}
45
+ </span>);
46
+ }
47
+ function StatusBadge({ status }) {
48
+ const styles = {
49
+ pending: "bg-yellow-50 text-yellow-700 dark:bg-yellow-950 dark:text-yellow-300",
50
+ approved: "bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300",
51
+ rejected: "bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300",
52
+ };
53
+ return (<span className={`inline-block rounded-full px-2 py-0.5 font-medium text-xs capitalize ${styles[status]}`}>
54
+ {status}
55
+ </span>);
56
+ }
57
+ export function ReviewList() {
58
+ const api = useReviewsAdminApi();
59
+ const [statusFilter, setStatusFilter] = useState("all");
60
+ const [skip, setSkip] = useState(0);
61
+ const [actionLoading, setActionLoading] = useState(null);
62
+ const [deleteConfirm, setDeleteConfirm] = useState(null);
63
+ const [error, setError] = useState("");
64
+ const queryInput = statusFilter === "all"
65
+ ? { take: String(PAGE_SIZE), skip: String(skip) }
66
+ : {
67
+ status: statusFilter,
68
+ take: String(PAGE_SIZE),
69
+ skip: String(skip),
70
+ };
71
+ const { data, isLoading: loading } = api.listReviews.useQuery(queryInput);
72
+ const reviews = data?.reviews ?? [];
73
+ const total = data?.total ?? 0;
74
+ const approveMutation = api.approveReview.useMutation({
75
+ onSettled: () => {
76
+ setActionLoading(null);
77
+ void api.listReviews.invalidate();
78
+ },
79
+ onError: (err) => {
80
+ setError(extractError(err, "Failed to approve review."));
81
+ },
82
+ });
83
+ const rejectMutation = api.rejectReview.useMutation({
84
+ onSettled: () => {
85
+ setActionLoading(null);
86
+ void api.listReviews.invalidate();
87
+ },
88
+ onError: (err) => {
89
+ setError(extractError(err, "Failed to reject review."));
90
+ },
91
+ });
92
+ const deleteMutation = api.deleteReview.useMutation({
93
+ onSettled: () => {
94
+ setActionLoading(null);
95
+ void api.listReviews.invalidate();
96
+ },
97
+ onError: (err) => {
98
+ setError(extractError(err, "Failed to delete review."));
99
+ },
100
+ });
101
+ const handleApprove = (id) => {
102
+ setActionLoading(id);
103
+ setError("");
104
+ approveMutation.mutate({ params: { id } });
105
+ };
106
+ const handleReject = (id) => {
107
+ setActionLoading(id);
108
+ setError("");
109
+ rejectMutation.mutate({ params: { id } });
110
+ };
111
+ const handleDelete = (id) => {
112
+ setActionLoading(id);
113
+ setDeleteConfirm(null);
114
+ setError("");
115
+ deleteMutation.mutate({ params: { id } });
116
+ };
117
+ const handleFilterChange = (filter) => {
118
+ setStatusFilter(filter);
119
+ setSkip(0);
120
+ };
121
+ const hasPrev = skip > 0;
122
+ const hasNext = skip + PAGE_SIZE < total;
123
+ const showingFrom = reviews.length > 0 ? skip + 1 : 0;
124
+ const showingTo = Math.min(skip + PAGE_SIZE, total);
125
+ const tableBody = loading && reviews.length === 0 ? (<tr>
126
+ <td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">
127
+ Loading...
128
+ </td>
129
+ </tr>) : reviews.length === 0 ? (<tr>
130
+ <td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">
131
+ No reviews found.
132
+ </td>
133
+ </tr>) : (reviews.map((review) => (<tr key={review.id} className="border-border border-b last:border-0 hover:bg-muted/20">
134
+ <td className="px-4 py-3">
135
+ <StarDisplay rating={review.rating}/>
136
+ </td>
137
+ <td className="px-4 py-3 text-foreground">{review.authorName}</td>
138
+ <td className="max-w-xs px-4 py-3">
139
+ {review.title && (<p className="truncate font-medium text-foreground">
140
+ {review.title}
141
+ </p>)}
142
+ <p className="truncate text-muted-foreground">{review.body}</p>
143
+ </td>
144
+ <td className="px-4 py-3">
145
+ <code className="rounded bg-muted px-1.5 py-0.5 text-muted-foreground text-xs">
146
+ {review.productId}
147
+ </code>
148
+ </td>
149
+ <td className="px-4 py-3">
150
+ <StatusBadge status={review.status}/>
151
+ </td>
152
+ <td className="whitespace-nowrap px-4 py-3 text-muted-foreground">
153
+ {formatDate(review.createdAt)}
154
+ </td>
155
+ <td className="px-4 py-3 text-right">
156
+ {deleteConfirm === review.id ? (<span className="inline-flex items-center gap-1.5">
157
+ <span className="text-muted-foreground text-xs">Delete?</span>
158
+ <button type="button" disabled={actionLoading === review.id} onClick={() => handleDelete(review.id)} className="rounded px-2 py-1 font-medium text-red-600 text-xs hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950">
159
+ Confirm
160
+ </button>
161
+ <button type="button" onClick={() => setDeleteConfirm(null)} className="rounded px-2 py-1 font-medium text-muted-foreground text-xs hover:bg-muted">
162
+ Cancel
163
+ </button>
164
+ </span>) : (<span className="inline-flex items-center gap-1">
165
+ <a href={`/admin/reviews/${review.id}`} className="rounded px-2 py-1 font-medium text-foreground text-xs hover:bg-muted">
166
+ View
167
+ </a>
168
+ {review.status !== "approved" && (<button type="button" disabled={actionLoading === review.id} onClick={() => handleApprove(review.id)} className="rounded px-2 py-1 font-medium text-emerald-600 text-xs hover:bg-emerald-50 disabled:opacity-50 dark:text-emerald-400 dark:hover:bg-emerald-950">
169
+ Approve
170
+ </button>)}
171
+ {review.status !== "rejected" && (<button type="button" disabled={actionLoading === review.id} onClick={() => handleReject(review.id)} className="rounded px-2 py-1 font-medium text-red-600 text-xs hover:bg-red-50 disabled:opacity-50 dark:text-red-400 dark:hover:bg-red-950">
172
+ Reject
173
+ </button>)}
174
+ <button type="button" disabled={actionLoading === review.id} onClick={() => setDeleteConfirm(review.id)} className="rounded px-2 py-1 font-medium text-destructive text-xs hover:bg-red-50 disabled:opacity-50 dark:hover:bg-red-950">
175
+ Delete
176
+ </button>
177
+ </span>)}
178
+ </td>
179
+ </tr>)));
180
+ return (<ReviewListTemplate total={total} statusFilters={STATUS_FILTERS} statusFilter={statusFilter} onFilterChange={handleFilterChange} error={error} tableBody={tableBody} showingFrom={showingFrom} showingTo={showingTo} hasPrev={hasPrev} hasNext={hasNext} loading={loading} onPrevPage={() => setSkip((s) => Math.max(0, s - PAGE_SIZE))} onNextPage={() => setSkip((s) => s + PAGE_SIZE)}/>);
181
+ }
@@ -0,0 +1,297 @@
1
+ "use client";
2
+ import { useModuleClient } from "@86d-app/core/client";
3
+ import { useState } from "react";
4
+ import ReviewModerationTemplate from "./review-moderation.mdx";
5
+ function formatDate(iso) {
6
+ return new Intl.DateTimeFormat("en-US", {
7
+ month: "short",
8
+ day: "numeric",
9
+ year: "numeric",
10
+ }).format(new Date(iso));
11
+ }
12
+ function extractError(error, fallback) {
13
+ if (!error)
14
+ return fallback;
15
+ const body = error.body;
16
+ if (typeof body?.error === "string")
17
+ return body.error;
18
+ if (typeof body?.error?.message === "string")
19
+ return body.error.message;
20
+ return fallback;
21
+ }
22
+ function useReviewsAdminApi() {
23
+ const client = useModuleClient();
24
+ return {
25
+ getReview: client.module("reviews").admin["/admin/reviews/:id"],
26
+ approveReview: client.module("reviews").admin["/admin/reviews/:id/approve"],
27
+ rejectReview: client.module("reviews").admin["/admin/reviews/:id/reject"],
28
+ respondReview: client.module("reviews").admin["/admin/reviews/:id/respond"],
29
+ deleteReview: client.module("reviews").admin["/admin/reviews/:id/delete"],
30
+ };
31
+ }
32
+ function StarDisplay({ rating }) {
33
+ return (<span role="img" className="select-none text-base leading-none" aria-label={`${rating} out of 5 stars`}>
34
+ {[1, 2, 3, 4, 5].map((n) => (<span key={n} className={n <= Math.round(rating)
35
+ ? "text-amber-400"
36
+ : "text-gray-200 dark:text-gray-700"}>
37
+
38
+ </span>))}
39
+ </span>);
40
+ }
41
+ function StatusBadge({ status }) {
42
+ const styles = {
43
+ pending: "bg-yellow-50 text-yellow-700 dark:bg-yellow-950 dark:text-yellow-300",
44
+ approved: "bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300",
45
+ rejected: "bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300",
46
+ };
47
+ return (<span className={`inline-block rounded-full px-2 py-0.5 font-medium text-xs capitalize ${styles[status]}`}>
48
+ {status}
49
+ </span>);
50
+ }
51
+ export function ReviewModeration(props) {
52
+ const reviewId = props.reviewId ?? props.params?.id ?? "";
53
+ const onAction = props.onAction;
54
+ const api = useReviewsAdminApi();
55
+ const [deleted, setDeleted] = useState(false);
56
+ const [actionLoading, setActionLoading] = useState(false);
57
+ const [deleteConfirm, setDeleteConfirm] = useState(false);
58
+ const [error, setError] = useState("");
59
+ const [responseText, setResponseText] = useState("");
60
+ const [showResponseForm, setShowResponseForm] = useState(false);
61
+ const { data: reviewData, isLoading: loading, error: queryError, } = api.getReview.useQuery({ params: { id: reviewId } });
62
+ const review = deleted ? null : (reviewData?.review ?? null);
63
+ const queryErrorMsg = queryError
64
+ ? "Failed to load review. Please try again."
65
+ : !loading && reviewData && !review && !deleted
66
+ ? "Review not found."
67
+ : "";
68
+ const approveMutation = api.approveReview.useMutation({
69
+ onSuccess: () => {
70
+ onAction?.();
71
+ },
72
+ onError: (err) => {
73
+ setError(extractError(err, "Failed to approve review."));
74
+ },
75
+ onSettled: () => {
76
+ setActionLoading(false);
77
+ void api.getReview.invalidate();
78
+ },
79
+ });
80
+ const rejectMutation = api.rejectReview.useMutation({
81
+ onSuccess: () => {
82
+ onAction?.();
83
+ },
84
+ onError: (err) => {
85
+ setError(extractError(err, "Failed to reject review."));
86
+ },
87
+ onSettled: () => {
88
+ setActionLoading(false);
89
+ void api.getReview.invalidate();
90
+ },
91
+ });
92
+ const respondMutation = api.respondReview.useMutation({
93
+ onSuccess: () => {
94
+ setShowResponseForm(false);
95
+ setResponseText("");
96
+ },
97
+ onError: (err) => {
98
+ setError(extractError(err, "Failed to save response."));
99
+ },
100
+ onSettled: () => {
101
+ setActionLoading(false);
102
+ void api.getReview.invalidate();
103
+ },
104
+ });
105
+ const deleteMutation = api.deleteReview.useMutation({
106
+ onSuccess: () => {
107
+ setDeleted(true);
108
+ onAction?.();
109
+ },
110
+ onError: (err) => {
111
+ setError(extractError(err, "Failed to delete review."));
112
+ },
113
+ onSettled: () => {
114
+ setActionLoading(false);
115
+ void api.getReview.invalidate();
116
+ },
117
+ });
118
+ const handleApprove = () => {
119
+ setActionLoading(true);
120
+ setError("");
121
+ approveMutation.mutate({ params: { id: reviewId } });
122
+ };
123
+ const handleReject = () => {
124
+ setActionLoading(true);
125
+ setError("");
126
+ rejectMutation.mutate({ params: { id: reviewId } });
127
+ };
128
+ const handleDelete = () => {
129
+ setActionLoading(true);
130
+ setDeleteConfirm(false);
131
+ setError("");
132
+ deleteMutation.mutate({ params: { id: reviewId } });
133
+ };
134
+ const handleRespond = () => {
135
+ if (!responseText.trim())
136
+ return;
137
+ setActionLoading(true);
138
+ setError("");
139
+ respondMutation.mutate({
140
+ params: { id: reviewId },
141
+ body: { response: responseText.trim() },
142
+ });
143
+ };
144
+ if (loading) {
145
+ return (<div className="rounded-xl border border-border bg-card p-6">
146
+ <p className="text-muted-foreground text-sm">Loading review...</p>
147
+ </div>);
148
+ }
149
+ if ((queryErrorMsg || error) && !review) {
150
+ return (<div className="rounded-xl border border-border bg-card p-6">
151
+ <p className="text-destructive text-sm" role="alert">
152
+ {queryErrorMsg || error}
153
+ </p>
154
+ </div>);
155
+ }
156
+ if (!review) {
157
+ return (<div className="rounded-xl border border-border bg-card p-6">
158
+ <p className="text-muted-foreground text-sm">
159
+ Review has been deleted.
160
+ </p>
161
+ </div>);
162
+ }
163
+ const content = (<div className="space-y-5 rounded-xl border border-border bg-card p-6">
164
+ <div className="flex items-start justify-between gap-4">
165
+ <div className="space-y-2">
166
+ <div className="flex items-center gap-3">
167
+ <StarDisplay rating={review.rating}/>
168
+ <StatusBadge status={review.status}/>
169
+ {review.isVerifiedPurchase && (<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">
170
+ Verified Purchase
171
+ </span>)}
172
+ </div>
173
+ {review.title && (<h3 className="font-semibold text-foreground text-lg">
174
+ {review.title}
175
+ </h3>)}
176
+ </div>
177
+ <span className="shrink-0 text-muted-foreground text-xs">
178
+ {formatDate(review.createdAt)}
179
+ </span>
180
+ </div>
181
+
182
+ <p className="text-foreground text-sm leading-relaxed">{review.body}</p>
183
+
184
+ <div className="grid gap-3 rounded-lg border border-border bg-muted/30 p-4 sm:grid-cols-2">
185
+ <div>
186
+ <p className="font-medium text-muted-foreground text-xs">Author</p>
187
+ <p className="mt-0.5 text-foreground text-sm">{review.authorName}</p>
188
+ </div>
189
+ <div>
190
+ <p className="font-medium text-muted-foreground text-xs">Email</p>
191
+ <p className="mt-0.5 text-foreground text-sm">{review.authorEmail}</p>
192
+ </div>
193
+ <div>
194
+ <p className="font-medium text-muted-foreground text-xs">
195
+ Product ID
196
+ </p>
197
+ <p className="mt-0.5">
198
+ <code className="rounded bg-muted px-1.5 py-0.5 text-muted-foreground text-xs">
199
+ {review.productId}
200
+ </code>
201
+ </p>
202
+ </div>
203
+ <div>
204
+ <p className="font-medium text-muted-foreground text-xs">
205
+ Helpful Count
206
+ </p>
207
+ <p className="mt-0.5 text-foreground text-sm">
208
+ {review.helpfulCount}
209
+ </p>
210
+ </div>
211
+ </div>
212
+
213
+ {review.moderationNote && (<div className="rounded-lg border border-amber-200 bg-amber-50/50 p-4 dark:border-amber-800 dark:bg-amber-950/20">
214
+ <p className="mb-1 font-medium text-amber-800 text-xs dark:text-amber-300">
215
+ Moderation Note
216
+ </p>
217
+ <p className="text-amber-900 text-sm dark:text-amber-200">
218
+ {review.moderationNote}
219
+ </p>
220
+ </div>)}
221
+
222
+ {review.merchantResponse ? (<div className="rounded-lg border border-blue-200 bg-blue-50/50 p-4 dark:border-blue-800 dark:bg-blue-950/20">
223
+ <div className="mb-1 flex items-center justify-between">
224
+ <p className="font-medium text-blue-800 text-xs dark:text-blue-300">
225
+ Merchant Response
226
+ </p>
227
+ {review.merchantResponseAt && (<span className="text-blue-600 text-xs dark:text-blue-400">
228
+ {formatDate(review.merchantResponseAt)}
229
+ </span>)}
230
+ </div>
231
+ <p className="text-blue-900 text-sm dark:text-blue-200">
232
+ {review.merchantResponse}
233
+ </p>
234
+ <button type="button" onClick={() => {
235
+ setResponseText(review.merchantResponse ?? "");
236
+ setShowResponseForm(true);
237
+ }} className="mt-2 text-blue-600 text-xs underline-offset-4 hover:underline dark:text-blue-400">
238
+ Edit response
239
+ </button>
240
+ </div>) : !showResponseForm ? (<button type="button" onClick={() => setShowResponseForm(true)} 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">
241
+ + Add merchant response
242
+ </button>) : null}
243
+
244
+ {showResponseForm && (<div className="space-y-3 rounded-lg border border-border bg-muted/20 p-4">
245
+ <label className="block">
246
+ <span className="mb-1 block font-medium text-foreground text-sm">
247
+ Merchant Response
248
+ </span>
249
+ <textarea value={responseText} onChange={(e) => setResponseText(e.target.value)} rows={4} maxLength={5000} placeholder="Write your response to this review..." 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"/>
250
+ </label>
251
+ <div className="flex gap-2">
252
+ <button type="button" disabled={actionLoading || !responseText.trim()} onClick={handleRespond} className="rounded-lg bg-primary px-4 py-2 font-medium text-primary-foreground text-sm transition-opacity hover:opacity-90 disabled:opacity-50">
253
+ {actionLoading ? "Saving…" : "Save Response"}
254
+ </button>
255
+ <button type="button" onClick={() => {
256
+ setShowResponseForm(false);
257
+ setResponseText("");
258
+ }} className="rounded-lg border border-border px-4 py-2 font-medium text-foreground text-sm transition-colors hover:bg-muted">
259
+ Cancel
260
+ </button>
261
+ </div>
262
+ </div>)}
263
+
264
+ {error && (<p className="text-destructive text-sm" role="alert">
265
+ {error}
266
+ </p>)}
267
+
268
+ {deleteConfirm ? (<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">
269
+ <p className="flex-1 text-red-700 text-sm dark:text-red-300">
270
+ Are you sure you want to permanently delete this review?
271
+ </p>
272
+ <button type="button" disabled={actionLoading} onClick={() => {
273
+ handleDelete();
274
+ }} 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">
275
+ {actionLoading ? "Deleting…" : "Confirm Delete"}
276
+ </button>
277
+ <button type="button" onClick={() => setDeleteConfirm(false)} className="rounded-lg border border-border px-4 py-2 font-medium text-foreground text-sm transition-colors hover:bg-muted">
278
+ Cancel
279
+ </button>
280
+ </div>) : (<div className="flex gap-2">
281
+ {review.status !== "approved" && (<button type="button" disabled={actionLoading} onClick={() => {
282
+ handleApprove();
283
+ }} 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">
284
+ {actionLoading ? "Approving…" : "Approve"}
285
+ </button>)}
286
+ {review.status !== "rejected" && (<button type="button" disabled={actionLoading} onClick={() => {
287
+ handleReject();
288
+ }} 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">
289
+ {actionLoading ? "Rejecting…" : "Reject"}
290
+ </button>)}
291
+ <button type="button" disabled={actionLoading} onClick={() => setDeleteConfirm(true)} 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">
292
+ Delete
293
+ </button>
294
+ </div>)}
295
+ </div>);
296
+ return <ReviewModerationTemplate content={content}/>;
297
+ }
@@ -0,0 +1,11 @@
1
+ import { createAdminEndpoint, z } from "@86d-app/core";
2
+ export const approveReview = createAdminEndpoint("/admin/reviews/:id/approve", {
3
+ method: "PUT",
4
+ params: z.object({ id: z.string() }),
5
+ }, async (ctx) => {
6
+ const controller = ctx.context.controllers.reviews;
7
+ const review = await controller.updateReviewStatus(ctx.params.id, "approved");
8
+ if (!review)
9
+ return { error: "Review not found", status: 404 };
10
+ return { review };
11
+ });
@@ -0,0 +1,12 @@
1
+ import { createAdminEndpoint, z } from "@86d-app/core";
2
+ export const deleteReview = createAdminEndpoint("/admin/reviews/:id/delete", {
3
+ method: "DELETE",
4
+ params: z.object({ id: z.string() }),
5
+ }, async (ctx) => {
6
+ const controller = ctx.context.controllers.reviews;
7
+ const existing = await controller.getReview(ctx.params.id);
8
+ if (!existing)
9
+ return { error: "Review not found", status: 404 };
10
+ const deleted = await controller.deleteReview(ctx.params.id);
11
+ return { deleted };
12
+ });
@@ -0,0 +1,11 @@
1
+ import { createAdminEndpoint, z } from "@86d-app/core";
2
+ export const getReview = createAdminEndpoint("/admin/reviews/:id", {
3
+ method: "GET",
4
+ params: z.object({ id: z.string() }),
5
+ }, async (ctx) => {
6
+ const controller = ctx.context.controllers.reviews;
7
+ const review = await controller.getReview(ctx.params.id);
8
+ if (!review)
9
+ return { error: "Review not found", status: 404 };
10
+ return { review };
11
+ });
@@ -0,0 +1,26 @@
1
+ import { approveReview } from "./approve-review";
2
+ import { deleteReview } from "./delete-review";
3
+ import { getReview } from "./get-review";
4
+ import { listReports } from "./list-reports";
5
+ import { listReviewRequests } from "./list-review-requests";
6
+ import { listReviews } from "./list-reviews";
7
+ import { rejectReview } from "./reject-review";
8
+ import { respondReview } from "./respond-review";
9
+ import { reviewAnalytics } from "./review-analytics";
10
+ import { reviewRequestStats } from "./review-request-stats";
11
+ import { sendReviewRequest } from "./send-review-request";
12
+ import { updateReport } from "./update-report";
13
+ export const adminEndpoints = {
14
+ "/admin/reviews": listReviews,
15
+ "/admin/reviews/analytics": reviewAnalytics,
16
+ "/admin/reviews/reports": listReports,
17
+ "/admin/reviews/reports/:id/update": updateReport,
18
+ "/admin/reviews/requests": listReviewRequests,
19
+ "/admin/reviews/request-stats": reviewRequestStats,
20
+ "/admin/reviews/send-request": sendReviewRequest,
21
+ "/admin/reviews/:id": getReview,
22
+ "/admin/reviews/:id/approve": approveReview,
23
+ "/admin/reviews/:id/reject": rejectReview,
24
+ "/admin/reviews/:id/respond": respondReview,
25
+ "/admin/reviews/:id/delete": deleteReview,
26
+ };
@@ -0,0 +1,19 @@
1
+ import { createAdminEndpoint, z } from "@86d-app/core";
2
+ export const listReports = createAdminEndpoint("/admin/reviews/reports", {
3
+ method: "GET",
4
+ query: z.object({
5
+ status: z.enum(["pending", "resolved", "dismissed"]).optional(),
6
+ reviewId: z.string().max(200).optional(),
7
+ take: z.coerce.number().int().min(1).max(100).optional(),
8
+ skip: z.coerce.number().int().min(0).optional(),
9
+ }),
10
+ }, async (ctx) => {
11
+ const controller = ctx.context.controllers.reviews;
12
+ const reports = await controller.listReports({
13
+ status: ctx.query.status,
14
+ reviewId: ctx.query.reviewId,
15
+ take: ctx.query.take ?? 50,
16
+ skip: ctx.query.skip ?? 0,
17
+ });
18
+ return { reports };
19
+ });
@@ -0,0 +1,17 @@
1
+ import { createAdminEndpoint, z } from "@86d-app/core";
2
+ export const listReviewRequests = createAdminEndpoint("/admin/reviews/requests", {
3
+ method: "GET",
4
+ query: z
5
+ .object({
6
+ take: z.coerce.number().optional(),
7
+ skip: z.coerce.number().optional(),
8
+ })
9
+ .optional(),
10
+ }, async (ctx) => {
11
+ const controller = ctx.context.controllers.reviews;
12
+ const requests = await controller.listReviewRequests({
13
+ take: ctx.query?.take,
14
+ skip: ctx.query?.skip,
15
+ });
16
+ return { requests };
17
+ });
@@ -0,0 +1,19 @@
1
+ import { createAdminEndpoint, z } from "@86d-app/core";
2
+ export const listReviews = createAdminEndpoint("/admin/reviews", {
3
+ method: "GET",
4
+ query: z.object({
5
+ status: z.enum(["pending", "approved", "rejected"]).optional(),
6
+ productId: z.string().optional(),
7
+ take: z.coerce.number().int().min(1).max(100).optional(),
8
+ skip: z.coerce.number().int().min(0).optional(),
9
+ }),
10
+ }, async (ctx) => {
11
+ const controller = ctx.context.controllers.reviews;
12
+ const reviews = await controller.listReviews({
13
+ status: ctx.query.status,
14
+ productId: ctx.query.productId,
15
+ take: ctx.query.take ?? 50,
16
+ skip: ctx.query.skip ?? 0,
17
+ });
18
+ return { reviews, total: reviews.length };
19
+ });
@@ -0,0 +1,11 @@
1
+ import { createAdminEndpoint, z } from "@86d-app/core";
2
+ export const rejectReview = createAdminEndpoint("/admin/reviews/:id/reject", {
3
+ method: "PUT",
4
+ params: z.object({ id: z.string() }),
5
+ }, async (ctx) => {
6
+ const controller = ctx.context.controllers.reviews;
7
+ const review = await controller.updateReviewStatus(ctx.params.id, "rejected");
8
+ if (!review)
9
+ return { error: "Review not found", status: 404 };
10
+ return { review };
11
+ });
@@ -0,0 +1,14 @@
1
+ import { createAdminEndpoint, sanitizeText, z } from "@86d-app/core";
2
+ export const respondReview = createAdminEndpoint("/admin/reviews/:id/respond", {
3
+ method: "POST",
4
+ params: z.object({ id: z.string() }),
5
+ body: z.object({
6
+ response: z.string().min(1).max(5000).transform(sanitizeText),
7
+ }),
8
+ }, async (ctx) => {
9
+ const controller = ctx.context.controllers.reviews;
10
+ const review = await controller.addMerchantResponse(ctx.params.id, ctx.body.response);
11
+ if (!review)
12
+ return { error: "Review not found", status: 404 };
13
+ return { review };
14
+ });
@@ -0,0 +1,8 @@
1
+ import { createAdminEndpoint } from "@86d-app/core";
2
+ export const reviewAnalytics = createAdminEndpoint("/admin/reviews/analytics", {
3
+ method: "GET",
4
+ }, async (ctx) => {
5
+ const controller = ctx.context.controllers.reviews;
6
+ const analytics = await controller.getReviewAnalytics();
7
+ return { analytics };
8
+ });
@@ -0,0 +1,6 @@
1
+ import { createAdminEndpoint } from "@86d-app/core";
2
+ export const reviewRequestStats = createAdminEndpoint("/admin/reviews/request-stats", { method: "GET" }, async (ctx) => {
3
+ const controller = ctx.context.controllers.reviews;
4
+ const stats = await controller.getReviewRequestStats();
5
+ return { stats };
6
+ });