@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,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,6 @@
1
+ <div className="flex items-center gap-1.5">
2
+ {props.starDisplay}
3
+ <span className="text-muted-foreground text-sm">
4
+ {props.averageFormatted} · {props.countLabel}
5
+ </span>
6
+ </div>
@@ -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
+ );