@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
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useModuleClient } from "@86d-app/core/client";
|
|
4
|
+
import ReviewAnalyticsTemplate from "./review-analytics.mdx";
|
|
5
|
+
|
|
6
|
+
interface ReviewAnalyticsData {
|
|
7
|
+
totalReviews: number;
|
|
8
|
+
pendingCount: number;
|
|
9
|
+
approvedCount: number;
|
|
10
|
+
rejectedCount: number;
|
|
11
|
+
averageRating: number;
|
|
12
|
+
ratingsDistribution: Record<string, number>;
|
|
13
|
+
withMerchantResponse: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function useReviewsAdminApi() {
|
|
17
|
+
const client = useModuleClient();
|
|
18
|
+
return {
|
|
19
|
+
analytics: client.module("reviews").admin["/admin/reviews/analytics"],
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function StarDisplay({ rating }: { rating: number }) {
|
|
24
|
+
return (
|
|
25
|
+
<span
|
|
26
|
+
role="img"
|
|
27
|
+
className="select-none text-lg leading-none"
|
|
28
|
+
aria-label={`${rating} out of 5 stars`}
|
|
29
|
+
>
|
|
30
|
+
{[1, 2, 3, 4, 5].map((n) => (
|
|
31
|
+
<span
|
|
32
|
+
key={n}
|
|
33
|
+
className={
|
|
34
|
+
n <= Math.round(rating)
|
|
35
|
+
? "text-amber-400"
|
|
36
|
+
: "text-gray-200 dark:text-gray-700"
|
|
37
|
+
}
|
|
38
|
+
>
|
|
39
|
+
★
|
|
40
|
+
</span>
|
|
41
|
+
))}
|
|
42
|
+
</span>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function StatCard({
|
|
47
|
+
label,
|
|
48
|
+
value,
|
|
49
|
+
className,
|
|
50
|
+
}: {
|
|
51
|
+
label: string;
|
|
52
|
+
value: string | number;
|
|
53
|
+
className?: string;
|
|
54
|
+
}) {
|
|
55
|
+
return (
|
|
56
|
+
<div
|
|
57
|
+
className={`rounded-xl border border-border bg-card p-5 ${className ?? ""}`}
|
|
58
|
+
>
|
|
59
|
+
<p className="font-medium text-muted-foreground text-xs uppercase tracking-wider">
|
|
60
|
+
{label}
|
|
61
|
+
</p>
|
|
62
|
+
<p className="mt-1 font-semibold text-2xl text-foreground">{value}</p>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function DistributionBar({
|
|
68
|
+
star,
|
|
69
|
+
count,
|
|
70
|
+
total,
|
|
71
|
+
}: {
|
|
72
|
+
star: number;
|
|
73
|
+
count: number;
|
|
74
|
+
total: number;
|
|
75
|
+
}) {
|
|
76
|
+
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
|
|
77
|
+
return (
|
|
78
|
+
<div className="flex items-center gap-3">
|
|
79
|
+
<span className="w-8 text-right text-muted-foreground text-sm">
|
|
80
|
+
{star}★
|
|
81
|
+
</span>
|
|
82
|
+
<div className="h-2.5 flex-1 overflow-hidden rounded-full bg-muted">
|
|
83
|
+
<div
|
|
84
|
+
className="h-full rounded-full bg-amber-400 transition-all"
|
|
85
|
+
style={{ width: `${pct}%` }}
|
|
86
|
+
/>
|
|
87
|
+
</div>
|
|
88
|
+
<span className="w-10 text-right text-muted-foreground text-xs">
|
|
89
|
+
{count}
|
|
90
|
+
</span>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function ReviewAnalytics() {
|
|
96
|
+
const api = useReviewsAdminApi();
|
|
97
|
+
|
|
98
|
+
const { data, isLoading: loading } = api.analytics.useQuery({}) as {
|
|
99
|
+
data: { analytics: ReviewAnalyticsData } | undefined;
|
|
100
|
+
isLoading: boolean;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
if (loading) {
|
|
104
|
+
return (
|
|
105
|
+
<div className="rounded-xl border border-border bg-card p-6">
|
|
106
|
+
<p className="text-muted-foreground text-sm">Loading analytics...</p>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const analytics = data?.analytics;
|
|
112
|
+
|
|
113
|
+
if (!analytics) {
|
|
114
|
+
return (
|
|
115
|
+
<div className="rounded-xl border border-border bg-card p-6">
|
|
116
|
+
<p className="text-muted-foreground text-sm">
|
|
117
|
+
No analytics data available.
|
|
118
|
+
</p>
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const responseRate =
|
|
124
|
+
analytics.totalReviews > 0
|
|
125
|
+
? Math.round(
|
|
126
|
+
(analytics.withMerchantResponse / analytics.totalReviews) * 100,
|
|
127
|
+
)
|
|
128
|
+
: 0;
|
|
129
|
+
|
|
130
|
+
const content = (
|
|
131
|
+
<div className="space-y-6">
|
|
132
|
+
<div>
|
|
133
|
+
<h2 className="font-semibold text-foreground text-lg">
|
|
134
|
+
Review Analytics
|
|
135
|
+
</h2>
|
|
136
|
+
<p className="text-muted-foreground text-sm">
|
|
137
|
+
Overview of customer reviews and ratings
|
|
138
|
+
</p>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
142
|
+
<StatCard label="Total Reviews" value={analytics.totalReviews} />
|
|
143
|
+
<StatCard label="Average Rating" value={analytics.averageRating} />
|
|
144
|
+
<StatCard label="Pending" value={analytics.pendingCount} />
|
|
145
|
+
<StatCard label="Response Rate" value={`${responseRate}%`} />
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<div className="grid gap-6 lg:grid-cols-2">
|
|
149
|
+
<div className="rounded-xl border border-border bg-card p-5">
|
|
150
|
+
<h3 className="mb-4 font-medium text-foreground text-sm">
|
|
151
|
+
Rating Distribution
|
|
152
|
+
</h3>
|
|
153
|
+
<div className="mb-3 flex items-center gap-2">
|
|
154
|
+
<StarDisplay rating={analytics.averageRating} />
|
|
155
|
+
<span className="font-semibold text-foreground text-xl">
|
|
156
|
+
{analytics.averageRating}
|
|
157
|
+
</span>
|
|
158
|
+
<span className="text-muted-foreground text-sm">out of 5</span>
|
|
159
|
+
</div>
|
|
160
|
+
<div className="space-y-2">
|
|
161
|
+
{[5, 4, 3, 2, 1].map((star) => (
|
|
162
|
+
<DistributionBar
|
|
163
|
+
key={star}
|
|
164
|
+
star={star}
|
|
165
|
+
count={analytics.ratingsDistribution[String(star)] ?? 0}
|
|
166
|
+
total={analytics.totalReviews}
|
|
167
|
+
/>
|
|
168
|
+
))}
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<div className="rounded-xl border border-border bg-card p-5">
|
|
173
|
+
<h3 className="mb-4 font-medium text-foreground text-sm">
|
|
174
|
+
Status Breakdown
|
|
175
|
+
</h3>
|
|
176
|
+
<div className="space-y-4">
|
|
177
|
+
<div className="flex items-center justify-between">
|
|
178
|
+
<div className="flex items-center gap-2">
|
|
179
|
+
<span className="h-2.5 w-2.5 rounded-full bg-emerald-500" />
|
|
180
|
+
<span className="text-foreground text-sm">Approved</span>
|
|
181
|
+
</div>
|
|
182
|
+
<span className="font-medium text-foreground text-sm">
|
|
183
|
+
{analytics.approvedCount}
|
|
184
|
+
</span>
|
|
185
|
+
</div>
|
|
186
|
+
<div className="flex items-center justify-between">
|
|
187
|
+
<div className="flex items-center gap-2">
|
|
188
|
+
<span className="h-2.5 w-2.5 rounded-full bg-yellow-500" />
|
|
189
|
+
<span className="text-foreground text-sm">Pending</span>
|
|
190
|
+
</div>
|
|
191
|
+
<span className="font-medium text-foreground text-sm">
|
|
192
|
+
{analytics.pendingCount}
|
|
193
|
+
</span>
|
|
194
|
+
</div>
|
|
195
|
+
<div className="flex items-center justify-between">
|
|
196
|
+
<div className="flex items-center gap-2">
|
|
197
|
+
<span className="h-2.5 w-2.5 rounded-full bg-red-500" />
|
|
198
|
+
<span className="text-foreground text-sm">Rejected</span>
|
|
199
|
+
</div>
|
|
200
|
+
<span className="font-medium text-foreground text-sm">
|
|
201
|
+
{analytics.rejectedCount}
|
|
202
|
+
</span>
|
|
203
|
+
</div>
|
|
204
|
+
<div className="mt-4 border-border border-t pt-4">
|
|
205
|
+
<div className="flex items-center justify-between">
|
|
206
|
+
<span className="text-muted-foreground text-sm">
|
|
207
|
+
With merchant response
|
|
208
|
+
</span>
|
|
209
|
+
<span className="font-medium text-foreground text-sm">
|
|
210
|
+
{analytics.withMerchantResponse}
|
|
211
|
+
</span>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
return <ReviewAnalyticsTemplate content={content} />;
|
|
221
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<div className="space-y-4">
|
|
2
|
+
<div className="flex items-center justify-between gap-4">
|
|
3
|
+
<h2 className="font-semibold text-foreground text-lg">
|
|
4
|
+
Reviews{" "}
|
|
5
|
+
<span className="font-normal text-muted-foreground text-sm">
|
|
6
|
+
({props.total})
|
|
7
|
+
</span>
|
|
8
|
+
</h2>
|
|
9
|
+
<div className="flex gap-1 rounded-lg border border-border bg-muted/30 p-1">
|
|
10
|
+
{props.statusFilters.map((f) => (
|
|
11
|
+
<button
|
|
12
|
+
key={f.value}
|
|
13
|
+
type="button"
|
|
14
|
+
onClick={() => props.onFilterChange(f.value)}
|
|
15
|
+
className={`rounded-md px-3 py-1 font-medium text-sm transition-colors ${
|
|
16
|
+
props.statusFilter === f.value
|
|
17
|
+
? "bg-background text-foreground shadow-sm"
|
|
18
|
+
: "text-muted-foreground hover:text-foreground"
|
|
19
|
+
}`}
|
|
20
|
+
>
|
|
21
|
+
{f.label}
|
|
22
|
+
</button>
|
|
23
|
+
))}
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
{props.error && (
|
|
28
|
+
<p className="text-destructive text-sm" role="alert">
|
|
29
|
+
{props.error}
|
|
30
|
+
</p>
|
|
31
|
+
)}
|
|
32
|
+
|
|
33
|
+
<div className="overflow-x-auto rounded-lg border border-border">
|
|
34
|
+
<table className="w-full text-sm">
|
|
35
|
+
<thead>
|
|
36
|
+
<tr className="border-border border-b bg-muted/40">
|
|
37
|
+
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
|
38
|
+
Rating
|
|
39
|
+
</th>
|
|
40
|
+
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
|
41
|
+
Author
|
|
42
|
+
</th>
|
|
43
|
+
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
|
44
|
+
Title / Body
|
|
45
|
+
</th>
|
|
46
|
+
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
|
47
|
+
Product ID
|
|
48
|
+
</th>
|
|
49
|
+
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
|
50
|
+
Status
|
|
51
|
+
</th>
|
|
52
|
+
<th className="px-4 py-3 text-left font-medium text-muted-foreground">
|
|
53
|
+
Date
|
|
54
|
+
</th>
|
|
55
|
+
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
|
|
56
|
+
Actions
|
|
57
|
+
</th>
|
|
58
|
+
</tr>
|
|
59
|
+
</thead>
|
|
60
|
+
<tbody>
|
|
61
|
+
{props.tableBody}
|
|
62
|
+
</tbody>
|
|
63
|
+
</table>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div className="flex items-center justify-between">
|
|
67
|
+
<p className="text-muted-foreground text-sm">
|
|
68
|
+
Showing {props.showingFrom}–{props.showingTo} of {props.total}
|
|
69
|
+
</p>
|
|
70
|
+
<div className="flex gap-2">
|
|
71
|
+
<button
|
|
72
|
+
type="button"
|
|
73
|
+
disabled={!props.hasPrev || props.loading}
|
|
74
|
+
onClick={props.onPrevPage}
|
|
75
|
+
className="rounded-lg border border-border px-3 py-1.5 font-medium text-foreground text-sm transition-colors hover:bg-muted disabled:opacity-50"
|
|
76
|
+
>
|
|
77
|
+
Previous
|
|
78
|
+
</button>
|
|
79
|
+
<button
|
|
80
|
+
type="button"
|
|
81
|
+
disabled={!props.hasNext || props.loading}
|
|
82
|
+
onClick={props.onNextPage}
|
|
83
|
+
className="rounded-lg border border-border px-3 py-1.5 font-medium text-foreground text-sm transition-colors hover:bg-muted disabled:opacity-50"
|
|
84
|
+
>
|
|
85
|
+
Next
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useModuleClient } from "@86d-app/core/client";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import ReviewListTemplate from "./review-list.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
|
+
createdAt: string;
|
|
20
|
+
updatedAt: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type StatusFilter = "all" | "pending" | "approved" | "rejected";
|
|
24
|
+
|
|
25
|
+
const STATUS_FILTERS: { label: string; value: StatusFilter }[] = [
|
|
26
|
+
{ label: "All", value: "all" },
|
|
27
|
+
{ label: "Pending", value: "pending" },
|
|
28
|
+
{ label: "Approved", value: "approved" },
|
|
29
|
+
{ label: "Rejected", value: "rejected" },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const PAGE_SIZE = 20;
|
|
33
|
+
|
|
34
|
+
function formatDate(iso: string): string {
|
|
35
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
36
|
+
month: "short",
|
|
37
|
+
day: "numeric",
|
|
38
|
+
year: "numeric",
|
|
39
|
+
}).format(new Date(iso));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function extractError(error: Error | null, fallback: string): string {
|
|
43
|
+
if (!error) return fallback;
|
|
44
|
+
// biome-ignore lint/suspicious/noExplicitAny: accessing HTTP error body property
|
|
45
|
+
const body = (error as any)?.body;
|
|
46
|
+
if (typeof body?.error === "string") return body.error;
|
|
47
|
+
if (typeof body?.error?.message === "string") return body.error.message;
|
|
48
|
+
return fallback;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function useReviewsAdminApi() {
|
|
52
|
+
const client = useModuleClient();
|
|
53
|
+
return {
|
|
54
|
+
listReviews: client.module("reviews").admin["/admin/reviews"],
|
|
55
|
+
approveReview: client.module("reviews").admin["/admin/reviews/:id/approve"],
|
|
56
|
+
rejectReview: client.module("reviews").admin["/admin/reviews/:id/reject"],
|
|
57
|
+
deleteReview: client.module("reviews").admin["/admin/reviews/:id/delete"],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function StarDisplay({ rating }: { rating: number }) {
|
|
62
|
+
return (
|
|
63
|
+
<span
|
|
64
|
+
role="img"
|
|
65
|
+
className="select-none text-sm leading-none"
|
|
66
|
+
aria-label={`${rating} out of 5 stars`}
|
|
67
|
+
>
|
|
68
|
+
{[1, 2, 3, 4, 5].map((n) => (
|
|
69
|
+
<span
|
|
70
|
+
key={n}
|
|
71
|
+
className={
|
|
72
|
+
n <= Math.round(rating)
|
|
73
|
+
? "text-amber-400"
|
|
74
|
+
: "text-muted-foreground/50"
|
|
75
|
+
}
|
|
76
|
+
>
|
|
77
|
+
★
|
|
78
|
+
</span>
|
|
79
|
+
))}
|
|
80
|
+
</span>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function StatusBadge({ status }: { status: Review["status"] }) {
|
|
85
|
+
const styles: Record<Review["status"], string> = {
|
|
86
|
+
pending:
|
|
87
|
+
"bg-yellow-50 text-yellow-700 dark:bg-yellow-950 dark:text-yellow-300",
|
|
88
|
+
approved:
|
|
89
|
+
"bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300",
|
|
90
|
+
rejected: "bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300",
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<span
|
|
95
|
+
className={`inline-block rounded-full px-2 py-0.5 font-medium text-xs capitalize ${styles[status]}`}
|
|
96
|
+
>
|
|
97
|
+
{status}
|
|
98
|
+
</span>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function ReviewList() {
|
|
103
|
+
const api = useReviewsAdminApi();
|
|
104
|
+
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
|
105
|
+
const [skip, setSkip] = useState(0);
|
|
106
|
+
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
|
107
|
+
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
|
108
|
+
const [error, setError] = useState("");
|
|
109
|
+
|
|
110
|
+
const queryInput =
|
|
111
|
+
statusFilter === "all"
|
|
112
|
+
? { take: String(PAGE_SIZE), skip: String(skip) }
|
|
113
|
+
: {
|
|
114
|
+
status: statusFilter,
|
|
115
|
+
take: String(PAGE_SIZE),
|
|
116
|
+
skip: String(skip),
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const { data, isLoading: loading } = api.listReviews.useQuery(queryInput) as {
|
|
120
|
+
data: { reviews: Review[]; total: number } | undefined;
|
|
121
|
+
isLoading: boolean;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const reviews = data?.reviews ?? [];
|
|
125
|
+
const total = data?.total ?? 0;
|
|
126
|
+
|
|
127
|
+
const approveMutation = api.approveReview.useMutation({
|
|
128
|
+
onSettled: () => {
|
|
129
|
+
setActionLoading(null);
|
|
130
|
+
void api.listReviews.invalidate();
|
|
131
|
+
},
|
|
132
|
+
onError: (err: Error) => {
|
|
133
|
+
setError(extractError(err, "Failed to approve review."));
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const rejectMutation = api.rejectReview.useMutation({
|
|
138
|
+
onSettled: () => {
|
|
139
|
+
setActionLoading(null);
|
|
140
|
+
void api.listReviews.invalidate();
|
|
141
|
+
},
|
|
142
|
+
onError: (err: Error) => {
|
|
143
|
+
setError(extractError(err, "Failed to reject review."));
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const deleteMutation = api.deleteReview.useMutation({
|
|
148
|
+
onSettled: () => {
|
|
149
|
+
setActionLoading(null);
|
|
150
|
+
void api.listReviews.invalidate();
|
|
151
|
+
},
|
|
152
|
+
onError: (err: Error) => {
|
|
153
|
+
setError(extractError(err, "Failed to delete review."));
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const handleApprove = (id: string) => {
|
|
158
|
+
setActionLoading(id);
|
|
159
|
+
setError("");
|
|
160
|
+
approveMutation.mutate({ params: { id } });
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const handleReject = (id: string) => {
|
|
164
|
+
setActionLoading(id);
|
|
165
|
+
setError("");
|
|
166
|
+
rejectMutation.mutate({ params: { id } });
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const handleDelete = (id: string) => {
|
|
170
|
+
setActionLoading(id);
|
|
171
|
+
setDeleteConfirm(null);
|
|
172
|
+
setError("");
|
|
173
|
+
deleteMutation.mutate({ params: { id } });
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const handleFilterChange = (filter: StatusFilter) => {
|
|
177
|
+
setStatusFilter(filter);
|
|
178
|
+
setSkip(0);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const hasPrev = skip > 0;
|
|
182
|
+
const hasNext = skip + PAGE_SIZE < total;
|
|
183
|
+
const showingFrom = reviews.length > 0 ? skip + 1 : 0;
|
|
184
|
+
const showingTo = Math.min(skip + PAGE_SIZE, total);
|
|
185
|
+
|
|
186
|
+
const tableBody =
|
|
187
|
+
loading && reviews.length === 0 ? (
|
|
188
|
+
<tr>
|
|
189
|
+
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">
|
|
190
|
+
Loading...
|
|
191
|
+
</td>
|
|
192
|
+
</tr>
|
|
193
|
+
) : reviews.length === 0 ? (
|
|
194
|
+
<tr>
|
|
195
|
+
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">
|
|
196
|
+
No reviews found.
|
|
197
|
+
</td>
|
|
198
|
+
</tr>
|
|
199
|
+
) : (
|
|
200
|
+
reviews.map((review) => (
|
|
201
|
+
<tr
|
|
202
|
+
key={review.id}
|
|
203
|
+
className="border-border border-b last:border-0 hover:bg-muted/20"
|
|
204
|
+
>
|
|
205
|
+
<td className="px-4 py-3">
|
|
206
|
+
<StarDisplay rating={review.rating} />
|
|
207
|
+
</td>
|
|
208
|
+
<td className="px-4 py-3 text-foreground">{review.authorName}</td>
|
|
209
|
+
<td className="max-w-xs px-4 py-3">
|
|
210
|
+
{review.title && (
|
|
211
|
+
<p className="truncate font-medium text-foreground">
|
|
212
|
+
{review.title}
|
|
213
|
+
</p>
|
|
214
|
+
)}
|
|
215
|
+
<p className="truncate text-muted-foreground">{review.body}</p>
|
|
216
|
+
</td>
|
|
217
|
+
<td className="px-4 py-3">
|
|
218
|
+
<code className="rounded bg-muted px-1.5 py-0.5 text-muted-foreground text-xs">
|
|
219
|
+
{review.productId}
|
|
220
|
+
</code>
|
|
221
|
+
</td>
|
|
222
|
+
<td className="px-4 py-3">
|
|
223
|
+
<StatusBadge status={review.status} />
|
|
224
|
+
</td>
|
|
225
|
+
<td className="whitespace-nowrap px-4 py-3 text-muted-foreground">
|
|
226
|
+
{formatDate(review.createdAt)}
|
|
227
|
+
</td>
|
|
228
|
+
<td className="px-4 py-3 text-right">
|
|
229
|
+
{deleteConfirm === review.id ? (
|
|
230
|
+
<span className="inline-flex items-center gap-1.5">
|
|
231
|
+
<span className="text-muted-foreground text-xs">Delete?</span>
|
|
232
|
+
<button
|
|
233
|
+
type="button"
|
|
234
|
+
disabled={actionLoading === review.id}
|
|
235
|
+
onClick={() => handleDelete(review.id)}
|
|
236
|
+
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"
|
|
237
|
+
>
|
|
238
|
+
Confirm
|
|
239
|
+
</button>
|
|
240
|
+
<button
|
|
241
|
+
type="button"
|
|
242
|
+
onClick={() => setDeleteConfirm(null)}
|
|
243
|
+
className="rounded px-2 py-1 font-medium text-muted-foreground text-xs hover:bg-muted"
|
|
244
|
+
>
|
|
245
|
+
Cancel
|
|
246
|
+
</button>
|
|
247
|
+
</span>
|
|
248
|
+
) : (
|
|
249
|
+
<span className="inline-flex items-center gap-1">
|
|
250
|
+
<a
|
|
251
|
+
href={`/admin/reviews/${review.id}`}
|
|
252
|
+
className="rounded px-2 py-1 font-medium text-foreground text-xs hover:bg-muted"
|
|
253
|
+
>
|
|
254
|
+
View
|
|
255
|
+
</a>
|
|
256
|
+
{review.status !== "approved" && (
|
|
257
|
+
<button
|
|
258
|
+
type="button"
|
|
259
|
+
disabled={actionLoading === review.id}
|
|
260
|
+
onClick={() => handleApprove(review.id)}
|
|
261
|
+
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"
|
|
262
|
+
>
|
|
263
|
+
Approve
|
|
264
|
+
</button>
|
|
265
|
+
)}
|
|
266
|
+
{review.status !== "rejected" && (
|
|
267
|
+
<button
|
|
268
|
+
type="button"
|
|
269
|
+
disabled={actionLoading === review.id}
|
|
270
|
+
onClick={() => handleReject(review.id)}
|
|
271
|
+
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"
|
|
272
|
+
>
|
|
273
|
+
Reject
|
|
274
|
+
</button>
|
|
275
|
+
)}
|
|
276
|
+
<button
|
|
277
|
+
type="button"
|
|
278
|
+
disabled={actionLoading === review.id}
|
|
279
|
+
onClick={() => setDeleteConfirm(review.id)}
|
|
280
|
+
className="rounded px-2 py-1 font-medium text-destructive text-xs hover:bg-red-50 disabled:opacity-50 dark:hover:bg-red-950"
|
|
281
|
+
>
|
|
282
|
+
Delete
|
|
283
|
+
</button>
|
|
284
|
+
</span>
|
|
285
|
+
)}
|
|
286
|
+
</td>
|
|
287
|
+
</tr>
|
|
288
|
+
))
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
return (
|
|
292
|
+
<ReviewListTemplate
|
|
293
|
+
total={total}
|
|
294
|
+
statusFilters={STATUS_FILTERS}
|
|
295
|
+
statusFilter={statusFilter}
|
|
296
|
+
onFilterChange={handleFilterChange}
|
|
297
|
+
error={error}
|
|
298
|
+
tableBody={tableBody}
|
|
299
|
+
showingFrom={showingFrom}
|
|
300
|
+
showingTo={showingTo}
|
|
301
|
+
hasPrev={hasPrev}
|
|
302
|
+
hasNext={hasNext}
|
|
303
|
+
loading={loading}
|
|
304
|
+
onPrevPage={() => setSkip((s) => Math.max(0, s - PAGE_SIZE))}
|
|
305
|
+
onNextPage={() => setSkip((s) => s + PAGE_SIZE)}
|
|
306
|
+
/>
|
|
307
|
+
);
|
|
308
|
+
}
|