@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,172 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useState } from "react";
|
|
4
|
+
import { useReviewsApi } from "./_hooks";
|
|
5
|
+
import { DistributionBars } from "./distribution-bars";
|
|
6
|
+
import ProductReviewsTemplate from "./product-reviews.mdx";
|
|
7
|
+
import { ReviewCard } from "./review-card";
|
|
8
|
+
import { ReviewForm } from "./review-form";
|
|
9
|
+
import { StarDisplay } from "./star-display";
|
|
10
|
+
|
|
11
|
+
interface Review {
|
|
12
|
+
id: string;
|
|
13
|
+
authorName: string;
|
|
14
|
+
rating: number;
|
|
15
|
+
title?: string | undefined;
|
|
16
|
+
body: string;
|
|
17
|
+
isVerifiedPurchase: boolean;
|
|
18
|
+
helpfulCount: number;
|
|
19
|
+
merchantResponse?: string | undefined;
|
|
20
|
+
merchantResponseAt?: string | undefined;
|
|
21
|
+
createdAt: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface RatingSummary {
|
|
25
|
+
average: number;
|
|
26
|
+
count: number;
|
|
27
|
+
distribution: Record<string, number>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ReviewsResponse {
|
|
31
|
+
reviews: Review[];
|
|
32
|
+
summary: RatingSummary;
|
|
33
|
+
total: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const PAGE_SIZE = 10;
|
|
37
|
+
|
|
38
|
+
export function ProductReviews({
|
|
39
|
+
productId,
|
|
40
|
+
title = "Customer Reviews",
|
|
41
|
+
}: {
|
|
42
|
+
productId: string;
|
|
43
|
+
title?: string | undefined;
|
|
44
|
+
}) {
|
|
45
|
+
const api = useReviewsApi();
|
|
46
|
+
|
|
47
|
+
// Initial page via useQuery — eliminates the fetch-on-mount useEffect
|
|
48
|
+
const { data: initialData, isLoading: loading } =
|
|
49
|
+
api.listProductReviews.useQuery({
|
|
50
|
+
params: { productId },
|
|
51
|
+
take: String(PAGE_SIZE),
|
|
52
|
+
skip: "0",
|
|
53
|
+
}) as { data: ReviewsResponse | undefined; isLoading: boolean };
|
|
54
|
+
|
|
55
|
+
// Extra reviews loaded via "Load more"
|
|
56
|
+
const [extraReviews, setExtraReviews] = useState<Review[]>([]);
|
|
57
|
+
const [loadingMore, setLoadingMore] = useState(false);
|
|
58
|
+
const [skip, setSkip] = useState(0);
|
|
59
|
+
const [loadedAll, setLoadedAll] = useState(false);
|
|
60
|
+
const [showForm, setShowForm] = useState(false);
|
|
61
|
+
|
|
62
|
+
const allReviews = [...(initialData?.reviews ?? []), ...extraReviews];
|
|
63
|
+
const hasMore =
|
|
64
|
+
!loadedAll &&
|
|
65
|
+
initialData !== undefined &&
|
|
66
|
+
(initialData.reviews.length === PAGE_SIZE || extraReviews.length > 0);
|
|
67
|
+
|
|
68
|
+
const handleLoadMore = useCallback(async () => {
|
|
69
|
+
const nextSkip = skip === 0 ? PAGE_SIZE : skip + PAGE_SIZE;
|
|
70
|
+
setLoadingMore(true);
|
|
71
|
+
try {
|
|
72
|
+
const fresh = (await api.listProductReviews.fetch({
|
|
73
|
+
params: { productId },
|
|
74
|
+
take: String(PAGE_SIZE),
|
|
75
|
+
skip: String(nextSkip),
|
|
76
|
+
})) as ReviewsResponse;
|
|
77
|
+
setExtraReviews((prev) => [...prev, ...fresh.reviews]);
|
|
78
|
+
setSkip(nextSkip);
|
|
79
|
+
if (fresh.reviews.length < PAGE_SIZE) setLoadedAll(true);
|
|
80
|
+
} catch {
|
|
81
|
+
// silently ignore
|
|
82
|
+
} finally {
|
|
83
|
+
setLoadingMore(false);
|
|
84
|
+
}
|
|
85
|
+
}, [api.listProductReviews, productId, skip]);
|
|
86
|
+
|
|
87
|
+
const handleMarkHelpful = useCallback(
|
|
88
|
+
async (id: string) => {
|
|
89
|
+
await api.markHelpful.mutate({ params: { id } });
|
|
90
|
+
},
|
|
91
|
+
[api.markHelpful],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const handleReviewSubmitted = useCallback(() => {
|
|
95
|
+
setShowForm(false);
|
|
96
|
+
// Invalidate the query to refetch from scratch
|
|
97
|
+
void api.listProductReviews.invalidate();
|
|
98
|
+
setExtraReviews([]);
|
|
99
|
+
setSkip(0);
|
|
100
|
+
setLoadedAll(false);
|
|
101
|
+
}, [api.listProductReviews]);
|
|
102
|
+
|
|
103
|
+
if (loading) {
|
|
104
|
+
return (
|
|
105
|
+
<section className="py-8">
|
|
106
|
+
<div className="mb-6 h-7 w-40 animate-pulse rounded-lg bg-muted" />
|
|
107
|
+
<div className="space-y-4">
|
|
108
|
+
{[1, 2, 3].map((n) => (
|
|
109
|
+
<div key={n} className="space-y-2 border-border border-b pb-4">
|
|
110
|
+
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
|
|
111
|
+
<div className="h-4 w-full animate-pulse rounded bg-muted" />
|
|
112
|
+
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
|
|
113
|
+
</div>
|
|
114
|
+
))}
|
|
115
|
+
</div>
|
|
116
|
+
</section>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const summary = initialData?.summary;
|
|
121
|
+
const noReviews = !summary || summary.count === 0;
|
|
122
|
+
|
|
123
|
+
const reviewListContent =
|
|
124
|
+
allReviews.length === 0 ? (
|
|
125
|
+
<p className="text-muted-foreground text-sm">No approved reviews yet.</p>
|
|
126
|
+
) : (
|
|
127
|
+
<div>
|
|
128
|
+
{allReviews.map((review) => (
|
|
129
|
+
<ReviewCard
|
|
130
|
+
key={review.id}
|
|
131
|
+
review={review}
|
|
132
|
+
onMarkHelpful={handleMarkHelpful}
|
|
133
|
+
/>
|
|
134
|
+
))}
|
|
135
|
+
{hasMore && (
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
onClick={() => void handleLoadMore()}
|
|
139
|
+
disabled={loadingMore}
|
|
140
|
+
className="mt-4 text-primary text-sm underline-offset-4 hover:underline disabled:opacity-60"
|
|
141
|
+
>
|
|
142
|
+
{loadingMore ? "Loading…" : "Load more reviews"}
|
|
143
|
+
</button>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<ProductReviewsTemplate
|
|
150
|
+
title={title}
|
|
151
|
+
summary={summary ?? undefined}
|
|
152
|
+
noReviews={noReviews}
|
|
153
|
+
showForm={showForm}
|
|
154
|
+
onToggleForm={() => setShowForm((v) => !v)}
|
|
155
|
+
formContent={
|
|
156
|
+
<ReviewForm productId={productId} onSuccess={handleReviewSubmitted} />
|
|
157
|
+
}
|
|
158
|
+
distributionBars={
|
|
159
|
+
summary ? (
|
|
160
|
+
<DistributionBars
|
|
161
|
+
distribution={summary.distribution}
|
|
162
|
+
total={summary.count}
|
|
163
|
+
/>
|
|
164
|
+
) : null
|
|
165
|
+
}
|
|
166
|
+
reviewListContent={reviewListContent}
|
|
167
|
+
starDisplay={
|
|
168
|
+
summary ? <StarDisplay rating={summary.average} size="lg" /> : null
|
|
169
|
+
}
|
|
170
|
+
/>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<article className="border-border border-b py-5 last:border-0">
|
|
2
|
+
<div className="mb-2 flex items-start justify-between gap-3">
|
|
3
|
+
<div>
|
|
4
|
+
<div className="flex items-center gap-2">
|
|
5
|
+
{props.starDisplay}
|
|
6
|
+
{props.review.isVerifiedPurchase && (
|
|
7
|
+
<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">
|
|
8
|
+
Verified
|
|
9
|
+
</span>
|
|
10
|
+
)}
|
|
11
|
+
</div>
|
|
12
|
+
{props.review.title && (
|
|
13
|
+
<p className="mt-1 font-medium text-foreground text-sm">
|
|
14
|
+
{props.review.title}
|
|
15
|
+
</p>
|
|
16
|
+
)}
|
|
17
|
+
</div>
|
|
18
|
+
<span className="shrink-0 text-muted-foreground text-xs">
|
|
19
|
+
{props.formatDate(props.review.createdAt)}
|
|
20
|
+
</span>
|
|
21
|
+
</div>
|
|
22
|
+
<p className="text-muted-foreground text-sm leading-relaxed">
|
|
23
|
+
{props.review.body}
|
|
24
|
+
</p>
|
|
25
|
+
<div className="mt-2 flex items-center justify-between">
|
|
26
|
+
<p className="text-muted-foreground/60 text-xs">
|
|
27
|
+
— {props.review.authorName}
|
|
28
|
+
</p>
|
|
29
|
+
{props.helpfulButton}
|
|
30
|
+
</div>
|
|
31
|
+
{props.merchantResponseBlock}
|
|
32
|
+
</article>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { formatDate } from "./_utils";
|
|
5
|
+
import ReviewCardTemplate from "./review-card.mdx";
|
|
6
|
+
import { StarDisplay } from "./star-display";
|
|
7
|
+
|
|
8
|
+
interface Review {
|
|
9
|
+
id: string;
|
|
10
|
+
authorName: string;
|
|
11
|
+
rating: number;
|
|
12
|
+
title?: string | undefined;
|
|
13
|
+
body: string;
|
|
14
|
+
isVerifiedPurchase: boolean;
|
|
15
|
+
helpfulCount: number;
|
|
16
|
+
merchantResponse?: string | undefined;
|
|
17
|
+
merchantResponseAt?: string | undefined;
|
|
18
|
+
createdAt: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ReviewCard({
|
|
22
|
+
review,
|
|
23
|
+
onMarkHelpful,
|
|
24
|
+
}: {
|
|
25
|
+
review: Review;
|
|
26
|
+
onMarkHelpful?: ((id: string) => Promise<void>) | undefined;
|
|
27
|
+
}) {
|
|
28
|
+
const [voted, setVoted] = useState(false);
|
|
29
|
+
const [localCount, setLocalCount] = useState(review.helpfulCount);
|
|
30
|
+
const [loading, setLoading] = useState(false);
|
|
31
|
+
|
|
32
|
+
const handleHelpful = async () => {
|
|
33
|
+
if (voted || loading || !onMarkHelpful) return;
|
|
34
|
+
setLoading(true);
|
|
35
|
+
try {
|
|
36
|
+
await onMarkHelpful(review.id);
|
|
37
|
+
setVoted(true);
|
|
38
|
+
setLocalCount((c) => c + 1);
|
|
39
|
+
} catch {
|
|
40
|
+
// silently ignore
|
|
41
|
+
} finally {
|
|
42
|
+
setLoading(false);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const helpfulButton = onMarkHelpful ? (
|
|
47
|
+
<button
|
|
48
|
+
type="button"
|
|
49
|
+
disabled={voted || loading}
|
|
50
|
+
onClick={() => void handleHelpful()}
|
|
51
|
+
className={`mt-2 inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs transition-colors ${
|
|
52
|
+
voted
|
|
53
|
+
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-300"
|
|
54
|
+
: "border-border text-muted-foreground hover:border-foreground hover:text-foreground"
|
|
55
|
+
} disabled:opacity-60`}
|
|
56
|
+
>
|
|
57
|
+
<span>{voted ? "✓" : "👍"}</span>
|
|
58
|
+
<span>Helpful{localCount > 0 ? ` (${localCount})` : ""}</span>
|
|
59
|
+
</button>
|
|
60
|
+
) : null;
|
|
61
|
+
|
|
62
|
+
const merchantResponseBlock = review.merchantResponse ? (
|
|
63
|
+
<div className="mt-3 rounded-lg border-primary/30 border-l-2 bg-muted/30 py-2 pr-3 pl-3">
|
|
64
|
+
<p className="mb-0.5 font-medium text-foreground text-xs">
|
|
65
|
+
Store Response
|
|
66
|
+
</p>
|
|
67
|
+
<p className="text-muted-foreground text-sm leading-relaxed">
|
|
68
|
+
{review.merchantResponse}
|
|
69
|
+
</p>
|
|
70
|
+
{review.merchantResponseAt && (
|
|
71
|
+
<p className="mt-1 text-muted-foreground/60 text-xs">
|
|
72
|
+
{formatDate(review.merchantResponseAt)}
|
|
73
|
+
</p>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
) : null;
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<ReviewCardTemplate
|
|
80
|
+
review={review}
|
|
81
|
+
formatDate={formatDate}
|
|
82
|
+
starDisplay={<StarDisplay rating={review.rating} size="sm" />}
|
|
83
|
+
helpfulButton={helpfulButton}
|
|
84
|
+
merchantResponseBlock={merchantResponseBlock}
|
|
85
|
+
/>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
{props.success ? (
|
|
2
|
+
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-5 text-center dark:border-emerald-800 dark:bg-emerald-950/30">
|
|
3
|
+
<p className="font-semibold text-emerald-800 dark:text-emerald-200">
|
|
4
|
+
Thank you for your review!
|
|
5
|
+
</p>
|
|
6
|
+
<p className="mt-1 text-emerald-700 text-sm dark:text-emerald-300">
|
|
7
|
+
Your review will appear once approved.
|
|
8
|
+
</p>
|
|
9
|
+
</div>
|
|
10
|
+
) : (
|
|
11
|
+
<form
|
|
12
|
+
onSubmit={props.onSubmit}
|
|
13
|
+
className="space-y-4 rounded-xl border border-border bg-muted/30 p-5"
|
|
14
|
+
>
|
|
15
|
+
<div>
|
|
16
|
+
<p className="mb-1.5 font-medium text-foreground text-sm">
|
|
17
|
+
Your rating <span className="text-destructive">*</span>
|
|
18
|
+
</p>
|
|
19
|
+
{props.starPicker}
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
23
|
+
<div>
|
|
24
|
+
<label
|
|
25
|
+
htmlFor="review-name"
|
|
26
|
+
className="mb-1 block font-medium text-foreground text-sm"
|
|
27
|
+
>
|
|
28
|
+
Name <span className="text-destructive">*</span>
|
|
29
|
+
</label>
|
|
30
|
+
<input
|
|
31
|
+
id="review-name"
|
|
32
|
+
type="text"
|
|
33
|
+
required
|
|
34
|
+
maxLength={200}
|
|
35
|
+
value={props.name}
|
|
36
|
+
onChange={(e) => props.onNameChange(e.target.value)}
|
|
37
|
+
placeholder="Your name"
|
|
38
|
+
className="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-1"
|
|
39
|
+
/>
|
|
40
|
+
</div>
|
|
41
|
+
<div>
|
|
42
|
+
<label
|
|
43
|
+
htmlFor="review-email"
|
|
44
|
+
className="mb-1 block font-medium text-foreground text-sm"
|
|
45
|
+
>
|
|
46
|
+
Email <span className="text-destructive">*</span>
|
|
47
|
+
</label>
|
|
48
|
+
<input
|
|
49
|
+
id="review-email"
|
|
50
|
+
type="email"
|
|
51
|
+
required
|
|
52
|
+
value={props.email}
|
|
53
|
+
onChange={(e) => props.onEmailChange(e.target.value)}
|
|
54
|
+
placeholder="you@example.com"
|
|
55
|
+
className="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-1"
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div>
|
|
61
|
+
<label
|
|
62
|
+
htmlFor="review-title"
|
|
63
|
+
className="mb-1 block font-medium text-foreground text-sm"
|
|
64
|
+
>
|
|
65
|
+
Title
|
|
66
|
+
</label>
|
|
67
|
+
<input
|
|
68
|
+
id="review-title"
|
|
69
|
+
type="text"
|
|
70
|
+
maxLength={500}
|
|
71
|
+
value={props.title}
|
|
72
|
+
onChange={(e) => props.onTitleChange(e.target.value)}
|
|
73
|
+
placeholder="Summary of your experience"
|
|
74
|
+
className="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-1"
|
|
75
|
+
/>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
<div>
|
|
79
|
+
<label
|
|
80
|
+
htmlFor="review-body"
|
|
81
|
+
className="mb-1 block font-medium text-foreground text-sm"
|
|
82
|
+
>
|
|
83
|
+
Review <span className="text-destructive">*</span>
|
|
84
|
+
</label>
|
|
85
|
+
<textarea
|
|
86
|
+
id="review-body"
|
|
87
|
+
required
|
|
88
|
+
maxLength={10000}
|
|
89
|
+
rows={4}
|
|
90
|
+
value={props.body}
|
|
91
|
+
onChange={(e) => props.onBodyChange(e.target.value)}
|
|
92
|
+
placeholder="Share your experience with this product"
|
|
93
|
+
className="w-full resize-y rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-1"
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{props.error && (
|
|
98
|
+
<p className="text-destructive text-sm" role="alert">
|
|
99
|
+
{props.error}
|
|
100
|
+
</p>
|
|
101
|
+
)}
|
|
102
|
+
|
|
103
|
+
<button
|
|
104
|
+
type="submit"
|
|
105
|
+
disabled={props.isLoading}
|
|
106
|
+
className="rounded-lg bg-primary px-5 py-2 font-medium text-primary-foreground text-sm transition-opacity disabled:opacity-60"
|
|
107
|
+
>
|
|
108
|
+
{props.isLoading ? "Submitting…" : "Submit Review"}
|
|
109
|
+
</button>
|
|
110
|
+
</form>
|
|
111
|
+
)}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useReviewsApi } from "./_hooks";
|
|
5
|
+
import { extractError } from "./_utils";
|
|
6
|
+
import ReviewFormTemplate from "./review-form.mdx";
|
|
7
|
+
import { StarPicker } from "./star-picker";
|
|
8
|
+
|
|
9
|
+
export function ReviewForm({
|
|
10
|
+
productId,
|
|
11
|
+
onSuccess,
|
|
12
|
+
}: {
|
|
13
|
+
productId: string;
|
|
14
|
+
onSuccess: () => void;
|
|
15
|
+
}) {
|
|
16
|
+
const api = useReviewsApi();
|
|
17
|
+
const [rating, setRating] = useState(0);
|
|
18
|
+
const [name, setName] = useState("");
|
|
19
|
+
const [email, setEmail] = useState("");
|
|
20
|
+
const [title, setTitle] = useState("");
|
|
21
|
+
const [body, setBody] = useState("");
|
|
22
|
+
const [ratingError, setRatingError] = useState("");
|
|
23
|
+
|
|
24
|
+
const submitMutation = api.submitReview.useMutation({
|
|
25
|
+
onSuccess: () => onSuccess(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
29
|
+
e.preventDefault();
|
|
30
|
+
if (rating === 0) {
|
|
31
|
+
setRatingError("Please select a rating.");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
setRatingError("");
|
|
35
|
+
submitMutation.mutate({
|
|
36
|
+
productId,
|
|
37
|
+
authorName: name,
|
|
38
|
+
authorEmail: email,
|
|
39
|
+
rating,
|
|
40
|
+
title: title.trim() || undefined,
|
|
41
|
+
body,
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const error =
|
|
46
|
+
ratingError ||
|
|
47
|
+
(submitMutation.isError
|
|
48
|
+
? extractError(submitMutation.error, "Failed to submit review.")
|
|
49
|
+
: "");
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<ReviewFormTemplate
|
|
53
|
+
success={submitMutation.isSuccess}
|
|
54
|
+
name={name}
|
|
55
|
+
onNameChange={setName}
|
|
56
|
+
email={email}
|
|
57
|
+
onEmailChange={setEmail}
|
|
58
|
+
title={title}
|
|
59
|
+
onTitleChange={setTitle}
|
|
60
|
+
body={body}
|
|
61
|
+
onBodyChange={setBody}
|
|
62
|
+
onSubmit={handleSubmit}
|
|
63
|
+
error={error}
|
|
64
|
+
isLoading={submitMutation.isPending}
|
|
65
|
+
starPicker={<StarPicker value={rating} onChange={setRating} />}
|
|
66
|
+
/>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useReviewsApi } from "./_hooks";
|
|
4
|
+
import ReviewsSummaryTemplate from "./reviews-summary.mdx";
|
|
5
|
+
import { StarDisplay } from "./star-display";
|
|
6
|
+
|
|
7
|
+
interface ReviewsResponse {
|
|
8
|
+
reviews: unknown[];
|
|
9
|
+
summary: { average: number; count: number };
|
|
10
|
+
total: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ReviewsSummary({ productId }: { productId: string }) {
|
|
14
|
+
const api = useReviewsApi();
|
|
15
|
+
const { data } = api.listProductReviews.useQuery({
|
|
16
|
+
params: { productId },
|
|
17
|
+
take: "1",
|
|
18
|
+
}) as { data: ReviewsResponse | undefined };
|
|
19
|
+
|
|
20
|
+
const summary = data?.summary;
|
|
21
|
+
if (!summary || summary.count === 0) return null;
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<ReviewsSummaryTemplate
|
|
25
|
+
starDisplay={<StarDisplay rating={summary.average} size="sm" />}
|
|
26
|
+
averageFormatted={summary.average.toFixed(1)}
|
|
27
|
+
countLabel={summary.count === 1 ? "1 review" : `${summary.count} reviews`}
|
|
28
|
+
/>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<span
|
|
2
|
+
role="img"
|
|
3
|
+
className={props.sizeClass}
|
|
4
|
+
aria-label={props.ariaLabel}
|
|
5
|
+
>
|
|
6
|
+
{[1, 2, 3, 4, 5].map((n) => (
|
|
7
|
+
<span
|
|
8
|
+
key={n}
|
|
9
|
+
className={
|
|
10
|
+
n <= props.filledCount
|
|
11
|
+
? "text-amber-400"
|
|
12
|
+
: "text-gray-200 dark:text-gray-700"
|
|
13
|
+
}
|
|
14
|
+
>
|
|
15
|
+
★
|
|
16
|
+
</span>
|
|
17
|
+
))}
|
|
18
|
+
</span>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import StarDisplayTemplate from "./star-display.mdx";
|
|
4
|
+
|
|
5
|
+
export function StarDisplay({
|
|
6
|
+
rating,
|
|
7
|
+
size = "md",
|
|
8
|
+
}: {
|
|
9
|
+
rating: number;
|
|
10
|
+
size?: "sm" | "md" | "lg";
|
|
11
|
+
}) {
|
|
12
|
+
const sizeClass =
|
|
13
|
+
size === "sm"
|
|
14
|
+
? "text-sm select-none leading-none"
|
|
15
|
+
: size === "lg"
|
|
16
|
+
? "text-xl select-none leading-none"
|
|
17
|
+
: "text-base select-none leading-none";
|
|
18
|
+
const filledCount = Math.round(rating);
|
|
19
|
+
const ariaLabel = `${rating} out of 5 stars`;
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<StarDisplayTemplate
|
|
23
|
+
sizeClass={sizeClass}
|
|
24
|
+
ariaLabel={ariaLabel}
|
|
25
|
+
filledCount={filledCount}
|
|
26
|
+
/>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<fieldset className="flex gap-0.5 border-0 p-0">
|
|
2
|
+
<legend className="sr-only">Select rating</legend>
|
|
3
|
+
{[1, 2, 3, 4, 5].map((n) => (
|
|
4
|
+
<button
|
|
5
|
+
key={n}
|
|
6
|
+
type="button"
|
|
7
|
+
aria-label={n === 1 ? "1 star" : `${n} stars`}
|
|
8
|
+
aria-pressed={props.value === n}
|
|
9
|
+
className={`text-2xl leading-none transition-colors ${
|
|
10
|
+
n <= (props.hover || props.value)
|
|
11
|
+
? "text-amber-400"
|
|
12
|
+
: "text-gray-200 hover:text-amber-200 dark:text-gray-700"
|
|
13
|
+
}`}
|
|
14
|
+
onClick={() => props.onChange(n)}
|
|
15
|
+
onMouseEnter={() => props.onHover(n)}
|
|
16
|
+
onMouseLeave={() => props.onHover(0)}
|
|
17
|
+
>
|
|
18
|
+
★
|
|
19
|
+
</button>
|
|
20
|
+
))}
|
|
21
|
+
</fieldset>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import StarPickerTemplate from "./star-picker.mdx";
|
|
5
|
+
|
|
6
|
+
export function StarPicker({
|
|
7
|
+
value,
|
|
8
|
+
onChange,
|
|
9
|
+
}: {
|
|
10
|
+
value: number;
|
|
11
|
+
onChange: (n: number) => void;
|
|
12
|
+
}) {
|
|
13
|
+
const [hover, setHover] = useState(0);
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<StarPickerTemplate
|
|
17
|
+
value={value}
|
|
18
|
+
hover={hover}
|
|
19
|
+
onChange={onChange}
|
|
20
|
+
onHover={setHover}
|
|
21
|
+
/>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { listMyReviews } from "./list-my-reviews";
|
|
2
|
+
import { listProductReviews } from "./list-product-reviews";
|
|
3
|
+
import { markHelpful } from "./mark-helpful";
|
|
4
|
+
import { submitReview } from "./submit-review";
|
|
5
|
+
|
|
6
|
+
export const storeEndpoints = {
|
|
7
|
+
"/reviews": submitReview,
|
|
8
|
+
"/reviews/me": listMyReviews,
|
|
9
|
+
"/reviews/products/:productId": listProductReviews,
|
|
10
|
+
"/reviews/:id/helpful": markHelpful,
|
|
11
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createStoreEndpoint, z } from "@86d-app/core";
|
|
2
|
+
import type { ReviewController, ReviewStatus } from "../../service";
|
|
3
|
+
|
|
4
|
+
export const listMyReviews = createStoreEndpoint(
|
|
5
|
+
"/reviews/me",
|
|
6
|
+
{
|
|
7
|
+
method: "GET",
|
|
8
|
+
query: z.object({
|
|
9
|
+
page: z.coerce.number().int().positive().optional().default(1),
|
|
10
|
+
limit: z.coerce.number().int().positive().max(50).optional().default(10),
|
|
11
|
+
status: z.enum(["pending", "approved", "rejected"]).optional(),
|
|
12
|
+
}),
|
|
13
|
+
},
|
|
14
|
+
async (ctx) => {
|
|
15
|
+
const userId = ctx.context.session?.user.id;
|
|
16
|
+
if (!userId) {
|
|
17
|
+
return { error: "Unauthorized", status: 401 };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { page, limit, status } = ctx.query;
|
|
21
|
+
const skip = (page - 1) * limit;
|
|
22
|
+
|
|
23
|
+
const controller = ctx.context.controllers.review as ReviewController;
|
|
24
|
+
const { reviews, total } = await controller.listReviewsByCustomer(userId, {
|
|
25
|
+
status: status as ReviewStatus | undefined,
|
|
26
|
+
take: limit,
|
|
27
|
+
skip,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
reviews,
|
|
32
|
+
total,
|
|
33
|
+
page,
|
|
34
|
+
limit,
|
|
35
|
+
pages: Math.ceil(total / limit),
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createStoreEndpoint, z } from "@86d-app/core";
|
|
2
|
+
import type { ReviewController } from "../../service";
|
|
3
|
+
|
|
4
|
+
export const listProductReviews = createStoreEndpoint(
|
|
5
|
+
"/reviews/products/:productId",
|
|
6
|
+
{
|
|
7
|
+
method: "GET",
|
|
8
|
+
params: z.object({ productId: z.string() }),
|
|
9
|
+
query: z.object({
|
|
10
|
+
take: z.coerce.number().int().min(1).max(100).optional(),
|
|
11
|
+
skip: z.coerce.number().int().min(0).optional(),
|
|
12
|
+
}),
|
|
13
|
+
},
|
|
14
|
+
async (ctx) => {
|
|
15
|
+
const controller = ctx.context.controllers.reviews as ReviewController;
|
|
16
|
+
const [reviews, summary] = await Promise.all([
|
|
17
|
+
controller.listReviewsByProduct(ctx.params.productId, {
|
|
18
|
+
approvedOnly: true,
|
|
19
|
+
take: ctx.query.take ?? 20,
|
|
20
|
+
skip: ctx.query.skip ?? 0,
|
|
21
|
+
}),
|
|
22
|
+
controller.getProductRatingSummary(ctx.params.productId),
|
|
23
|
+
]);
|
|
24
|
+
return { reviews, summary, total: reviews.length };
|
|
25
|
+
},
|
|
26
|
+
);
|