@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
package/src/index.ts ADDED
@@ -0,0 +1,73 @@
1
+ import type { Module, ModuleConfig, ModuleContext } from "@86d-app/core";
2
+ import { adminEndpoints } from "./admin/endpoints";
3
+ import { reviewsSchema } from "./schema";
4
+ import { createReviewController } from "./service-impl";
5
+ import { storeEndpoints } from "./store/endpoints";
6
+
7
+ export type {
8
+ RatingSummary,
9
+ Review,
10
+ ReviewAnalytics,
11
+ ReviewController,
12
+ ReviewRequest,
13
+ ReviewRequestStats,
14
+ ReviewStatus,
15
+ } from "./service";
16
+
17
+ export interface ReviewsOptions extends ModuleConfig {
18
+ /** Auto-approve reviews (no moderation queue) */
19
+ autoApprove?: string;
20
+ }
21
+
22
+ export default function reviews(options?: ReviewsOptions): Module {
23
+ return {
24
+ id: "reviews",
25
+ version: "0.0.1",
26
+ schema: reviewsSchema,
27
+ exports: {
28
+ read: ["productRating", "reviewCount"],
29
+ },
30
+ events: {
31
+ emits: [
32
+ "review.submitted",
33
+ "review.approved",
34
+ "review.rejected",
35
+ "review.responded",
36
+ "review.requested",
37
+ ],
38
+ },
39
+ init: async (ctx: ModuleContext) => {
40
+ const controller = createReviewController(ctx.data, {
41
+ autoApprove: options?.autoApprove === "true",
42
+ });
43
+ return { controllers: { reviews: controller } };
44
+ },
45
+ endpoints: {
46
+ store: storeEndpoints,
47
+ admin: adminEndpoints,
48
+ },
49
+ admin: {
50
+ pages: [
51
+ {
52
+ path: "/admin/reviews",
53
+ component: "ReviewList",
54
+ label: "Reviews",
55
+ icon: "Star",
56
+ group: "Marketing",
57
+ },
58
+ {
59
+ path: "/admin/reviews/analytics",
60
+ component: "ReviewAnalytics",
61
+ label: "Review Analytics",
62
+ icon: "ChartBar",
63
+ group: "Marketing",
64
+ },
65
+ {
66
+ path: "/admin/reviews/:id",
67
+ component: "ReviewModeration",
68
+ },
69
+ ],
70
+ },
71
+ options,
72
+ };
73
+ }
package/src/mdx.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ declare module "*.mdx" {
2
+ import type { ComponentType } from "react";
3
+ const Component: ComponentType<Record<string, unknown>>;
4
+ export default Component;
5
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,37 @@
1
+ import type { ModuleSchema } from "@86d-app/core";
2
+
3
+ export const reviewsSchema = {
4
+ review: {
5
+ fields: {
6
+ id: { type: "string", required: true },
7
+ productId: { type: "string", required: true },
8
+ customerId: { type: "string", required: false },
9
+ authorName: { type: "string", required: true },
10
+ authorEmail: { type: "string", required: true },
11
+ rating: { type: "number", required: true },
12
+ title: { type: "string", required: false },
13
+ body: { type: "string", required: true },
14
+ status: { type: "string", required: true, defaultValue: "pending" },
15
+ isVerifiedPurchase: {
16
+ type: "boolean",
17
+ required: true,
18
+ defaultValue: false,
19
+ },
20
+ helpfulCount: { type: "number", required: true, defaultValue: 0 },
21
+ merchantResponse: { type: "string", required: false },
22
+ merchantResponseAt: { type: "date", required: false },
23
+ moderationNote: { type: "string", required: false },
24
+ createdAt: {
25
+ type: "date",
26
+ required: true,
27
+ defaultValue: () => new Date(),
28
+ },
29
+ updatedAt: {
30
+ type: "date",
31
+ required: true,
32
+ defaultValue: () => new Date(),
33
+ onUpdate: () => new Date(),
34
+ },
35
+ },
36
+ },
37
+ } satisfies ModuleSchema;
@@ -0,0 +1,263 @@
1
+ import type { ModuleDataService } from "@86d-app/core";
2
+ import type {
3
+ Review,
4
+ ReviewController,
5
+ ReviewRequest,
6
+ ReviewStatus,
7
+ } from "./service";
8
+
9
+ export function createReviewController(
10
+ data: ModuleDataService,
11
+ options?: { autoApprove?: boolean },
12
+ ): ReviewController {
13
+ return {
14
+ async createReview(params) {
15
+ const id = crypto.randomUUID();
16
+ const now = new Date();
17
+ const status: ReviewStatus =
18
+ options?.autoApprove === true ? "approved" : "pending";
19
+ const review: Review = {
20
+ id,
21
+ productId: params.productId,
22
+ customerId: params.customerId,
23
+ authorName: params.authorName,
24
+ authorEmail: params.authorEmail,
25
+ rating: params.rating,
26
+ title: params.title,
27
+ body: params.body,
28
+ status,
29
+ isVerifiedPurchase: params.isVerifiedPurchase ?? false,
30
+ helpfulCount: 0,
31
+ createdAt: now,
32
+ updatedAt: now,
33
+ };
34
+ // biome-ignore lint/suspicious/noExplicitAny: ModuleDataService requires any
35
+ await data.upsert("review", id, review as Record<string, any>);
36
+ return review;
37
+ },
38
+
39
+ async getReview(id) {
40
+ const raw = await data.get("review", id);
41
+ if (!raw) return null;
42
+ return raw as unknown as Review;
43
+ },
44
+
45
+ async listReviewsByProduct(productId, params) {
46
+ // biome-ignore lint/suspicious/noExplicitAny: JSONB where filter
47
+ const where: Record<string, any> = { productId };
48
+ if (params?.approvedOnly) where.status = "approved";
49
+
50
+ const all = await data.findMany("review", {
51
+ where,
52
+ ...(params?.take !== undefined ? { take: params.take } : {}),
53
+ ...(params?.skip !== undefined ? { skip: params.skip } : {}),
54
+ });
55
+ return all as unknown as Review[];
56
+ },
57
+
58
+ async listReviews(params) {
59
+ // biome-ignore lint/suspicious/noExplicitAny: JSONB where filter
60
+ const where: Record<string, any> = {};
61
+ if (params?.productId) where.productId = params.productId;
62
+ if (params?.status) where.status = params.status;
63
+
64
+ const all = await data.findMany("review", {
65
+ ...(Object.keys(where).length > 0 ? { where } : {}),
66
+ ...(params?.take !== undefined ? { take: params.take } : {}),
67
+ ...(params?.skip !== undefined ? { skip: params.skip } : {}),
68
+ });
69
+ return all as unknown as Review[];
70
+ },
71
+
72
+ async updateReviewStatus(id, status, moderationNote) {
73
+ const existing = await data.get("review", id);
74
+ if (!existing) return null;
75
+ const review = existing as unknown as Review;
76
+ const updated: Review = {
77
+ ...review,
78
+ status,
79
+ updatedAt: new Date(),
80
+ ...(moderationNote !== undefined ? { moderationNote } : {}),
81
+ };
82
+ // biome-ignore lint/suspicious/noExplicitAny: ModuleDataService requires any
83
+ await data.upsert("review", id, updated as Record<string, any>);
84
+ return updated;
85
+ },
86
+
87
+ async deleteReview(id) {
88
+ const existing = await data.get("review", id);
89
+ if (!existing) return false;
90
+ await data.delete("review", id);
91
+ return true;
92
+ },
93
+
94
+ async addMerchantResponse(id, response) {
95
+ const existing = await data.get("review", id);
96
+ if (!existing) return null;
97
+ const review = existing as unknown as Review;
98
+ const updated: Review = {
99
+ ...review,
100
+ merchantResponse: response,
101
+ merchantResponseAt: new Date(),
102
+ updatedAt: new Date(),
103
+ };
104
+ // biome-ignore lint/suspicious/noExplicitAny: ModuleDataService requires any
105
+ await data.upsert("review", id, updated as Record<string, any>);
106
+ return updated;
107
+ },
108
+
109
+ async markHelpful(id) {
110
+ const existing = await data.get("review", id);
111
+ if (!existing) return null;
112
+ const review = existing as unknown as Review;
113
+ const updated: Review = {
114
+ ...review,
115
+ helpfulCount: review.helpfulCount + 1,
116
+ updatedAt: new Date(),
117
+ };
118
+ // biome-ignore lint/suspicious/noExplicitAny: ModuleDataService requires any
119
+ await data.upsert("review", id, updated as Record<string, any>);
120
+ return updated;
121
+ },
122
+
123
+ async getReviewAnalytics() {
124
+ const all = await data.findMany("review", {});
125
+ const reviews = all as unknown as Review[];
126
+
127
+ const distribution: Record<string, number> = {
128
+ "1": 0,
129
+ "2": 0,
130
+ "3": 0,
131
+ "4": 0,
132
+ "5": 0,
133
+ };
134
+ let pendingCount = 0;
135
+ let approvedCount = 0;
136
+ let rejectedCount = 0;
137
+ let ratingTotal = 0;
138
+ let withMerchantResponse = 0;
139
+
140
+ for (const r of reviews) {
141
+ if (r.status === "pending") pendingCount++;
142
+ else if (r.status === "approved") approvedCount++;
143
+ else if (r.status === "rejected") rejectedCount++;
144
+
145
+ ratingTotal += r.rating;
146
+ const key = String(r.rating);
147
+ if (key in distribution) {
148
+ distribution[key] = (distribution[key] ?? 0) + 1;
149
+ }
150
+ if (r.merchantResponse) withMerchantResponse++;
151
+ }
152
+
153
+ return {
154
+ totalReviews: reviews.length,
155
+ pendingCount,
156
+ approvedCount,
157
+ rejectedCount,
158
+ averageRating:
159
+ reviews.length > 0
160
+ ? Math.round((ratingTotal / reviews.length) * 10) / 10
161
+ : 0,
162
+ ratingsDistribution: distribution,
163
+ withMerchantResponse,
164
+ };
165
+ },
166
+
167
+ async getProductRatingSummary(productId) {
168
+ const all = await data.findMany("review", {
169
+ where: { productId, status: "approved" },
170
+ });
171
+ const reviews = all as unknown as Review[];
172
+ if (reviews.length === 0) {
173
+ return {
174
+ average: 0,
175
+ count: 0,
176
+ distribution: { "1": 0, "2": 0, "3": 0, "4": 0, "5": 0 },
177
+ };
178
+ }
179
+ const distribution: Record<string, number> = {
180
+ "1": 0,
181
+ "2": 0,
182
+ "3": 0,
183
+ "4": 0,
184
+ "5": 0,
185
+ };
186
+ let total = 0;
187
+ for (const r of reviews) {
188
+ total += r.rating;
189
+ const key = String(r.rating);
190
+ if (key in distribution) {
191
+ distribution[key] = (distribution[key] ?? 0) + 1;
192
+ }
193
+ }
194
+ const average = Math.round((total / reviews.length) * 10) / 10;
195
+ return { average, count: reviews.length, distribution };
196
+ },
197
+
198
+ async createReviewRequest(params) {
199
+ const id = crypto.randomUUID();
200
+ const request: ReviewRequest = {
201
+ id,
202
+ orderId: params.orderId,
203
+ orderNumber: params.orderNumber,
204
+ email: params.email,
205
+ customerName: params.customerName,
206
+ items: params.items,
207
+ sentAt: new Date(),
208
+ };
209
+ // biome-ignore lint/suspicious/noExplicitAny: ModuleDataService requires any
210
+ await data.upsert("reviewRequest", id, request as Record<string, any>);
211
+ return request;
212
+ },
213
+
214
+ async getReviewRequest(orderId) {
215
+ const all = await data.findMany("reviewRequest", {
216
+ where: { orderId },
217
+ take: 1,
218
+ });
219
+ const requests = all as unknown as ReviewRequest[];
220
+ return requests.length > 0 ? requests[0] : null;
221
+ },
222
+
223
+ async listReviewRequests(params) {
224
+ const all = await data.findMany("reviewRequest", {
225
+ ...(params?.take !== undefined ? { take: params.take } : {}),
226
+ ...(params?.skip !== undefined ? { skip: params.skip } : {}),
227
+ });
228
+ return all as unknown as ReviewRequest[];
229
+ },
230
+
231
+ async listReviewsByCustomer(customerId, params) {
232
+ // biome-ignore lint/suspicious/noExplicitAny: JSONB where filter
233
+ const where: Record<string, any> = { customerId };
234
+ if (params?.status) where.status = params.status;
235
+
236
+ const all = await data.findMany("review", {
237
+ where,
238
+ });
239
+ const reviews = all as unknown as Review[];
240
+
241
+ const filtered = reviews.slice(
242
+ params?.skip ?? 0,
243
+ params?.skip !== undefined && params?.take !== undefined
244
+ ? params.skip + params.take
245
+ : params?.take !== undefined
246
+ ? params.take
247
+ : undefined,
248
+ );
249
+
250
+ return { reviews: filtered, total: reviews.length };
251
+ },
252
+
253
+ async getReviewRequestStats() {
254
+ const all = await data.findMany("reviewRequest", {});
255
+ const requests = all as unknown as ReviewRequest[];
256
+ const uniqueOrders = new Set(requests.map((r) => r.orderId));
257
+ return {
258
+ totalSent: requests.length,
259
+ uniqueOrders: uniqueOrders.size,
260
+ };
261
+ },
262
+ };
263
+ }
package/src/service.ts ADDED
@@ -0,0 +1,126 @@
1
+ import type { ModuleController } from "@86d-app/core";
2
+
3
+ export type ReviewStatus = "pending" | "approved" | "rejected";
4
+
5
+ export interface Review {
6
+ id: string;
7
+ productId: string;
8
+ customerId?: string | undefined;
9
+ authorName: string;
10
+ authorEmail: string;
11
+ rating: number;
12
+ title?: string | undefined;
13
+ body: string;
14
+ status: ReviewStatus;
15
+ isVerifiedPurchase: boolean;
16
+ helpfulCount: number;
17
+ merchantResponse?: string | undefined;
18
+ merchantResponseAt?: Date | undefined;
19
+ moderationNote?: string | undefined;
20
+ createdAt: Date;
21
+ updatedAt: Date;
22
+ }
23
+
24
+ export interface RatingSummary {
25
+ average: number;
26
+ count: number;
27
+ distribution: Record<string, number>;
28
+ }
29
+
30
+ export interface ReviewController extends ModuleController {
31
+ createReview(params: {
32
+ productId: string;
33
+ authorName: string;
34
+ authorEmail: string;
35
+ rating: number;
36
+ title?: string | undefined;
37
+ body: string;
38
+ customerId?: string | undefined;
39
+ isVerifiedPurchase?: boolean | undefined;
40
+ }): Promise<Review>;
41
+
42
+ getReview(id: string): Promise<Review | null>;
43
+
44
+ listReviewsByProduct(
45
+ productId: string,
46
+ params?: {
47
+ approvedOnly?: boolean | undefined;
48
+ take?: number | undefined;
49
+ skip?: number | undefined;
50
+ },
51
+ ): Promise<Review[]>;
52
+
53
+ listReviews(params?: {
54
+ productId?: string | undefined;
55
+ status?: ReviewStatus | undefined;
56
+ take?: number | undefined;
57
+ skip?: number | undefined;
58
+ }): Promise<Review[]>;
59
+
60
+ updateReviewStatus(
61
+ id: string,
62
+ status: ReviewStatus,
63
+ moderationNote?: string | undefined,
64
+ ): Promise<Review | null>;
65
+
66
+ deleteReview(id: string): Promise<boolean>;
67
+
68
+ getProductRatingSummary(productId: string): Promise<RatingSummary>;
69
+
70
+ addMerchantResponse(id: string, response: string): Promise<Review | null>;
71
+
72
+ markHelpful(id: string): Promise<Review | null>;
73
+
74
+ getReviewAnalytics(): Promise<ReviewAnalytics>;
75
+
76
+ createReviewRequest(params: {
77
+ orderId: string;
78
+ orderNumber: string;
79
+ email: string;
80
+ customerName: string;
81
+ items: Array<{ productId: string; name: string }>;
82
+ }): Promise<ReviewRequest>;
83
+
84
+ getReviewRequest(orderId: string): Promise<ReviewRequest | null>;
85
+
86
+ listReviewRequests(params?: {
87
+ take?: number | undefined;
88
+ skip?: number | undefined;
89
+ }): Promise<ReviewRequest[]>;
90
+
91
+ getReviewRequestStats(): Promise<ReviewRequestStats>;
92
+
93
+ listReviewsByCustomer(
94
+ customerId: string,
95
+ params?: {
96
+ status?: ReviewStatus | undefined;
97
+ take?: number | undefined;
98
+ skip?: number | undefined;
99
+ },
100
+ ): Promise<{ reviews: Review[]; total: number }>;
101
+ }
102
+
103
+ export interface ReviewAnalytics {
104
+ totalReviews: number;
105
+ pendingCount: number;
106
+ approvedCount: number;
107
+ rejectedCount: number;
108
+ averageRating: number;
109
+ ratingsDistribution: Record<string, number>;
110
+ withMerchantResponse: number;
111
+ }
112
+
113
+ export interface ReviewRequest {
114
+ id: string;
115
+ orderId: string;
116
+ orderNumber: string;
117
+ email: string;
118
+ customerName: string;
119
+ items: Array<{ productId: string; name: string }>;
120
+ sentAt: Date;
121
+ }
122
+
123
+ export interface ReviewRequestStats {
124
+ totalSent: number;
125
+ uniqueOrders: number;
126
+ }
@@ -0,0 +1,13 @@
1
+ "use client";
2
+
3
+ import { useModuleClient } from "@86d-app/core/client";
4
+
5
+ export function useReviewsApi() {
6
+ const client = useModuleClient();
7
+ return {
8
+ submitReview: client.module("reviews").store["/reviews"],
9
+ listProductReviews:
10
+ client.module("reviews").store["/reviews/products/:productId"],
11
+ markHelpful: client.module("reviews").store["/reviews/:id/helpful"],
12
+ };
13
+ }
@@ -0,0 +1,16 @@
1
+ export function formatDate(iso: string): string {
2
+ return new Intl.DateTimeFormat("en-US", {
3
+ month: "short",
4
+ day: "numeric",
5
+ year: "numeric",
6
+ }).format(new Date(iso));
7
+ }
8
+
9
+ export function extractError(error: Error | null, fallback: string): string {
10
+ if (!error) return fallback;
11
+ // biome-ignore lint/suspicious/noExplicitAny: accessing HTTP error body property
12
+ const body = (error as any)?.body;
13
+ if (typeof body?.error === "string") return body.error;
14
+ if (typeof body?.error?.message === "string") return body.error.message;
15
+ return fallback;
16
+ }
@@ -0,0 +1,21 @@
1
+ <div className="space-y-1.5">
2
+ {[5, 4, 3, 2, 1].map((n) => {
3
+ const count = props.distribution[String(n)] ?? 0;
4
+ const pct = props.total > 0 ? (count / props.total) * 100 : 0;
5
+ return (
6
+ <div key={n} className="flex items-center gap-2 text-sm">
7
+ <span className="w-3 text-right text-muted-foreground">{n}</span>
8
+ <span className="text-amber-400 text-xs">★</span>
9
+ <div className="h-2 flex-1 overflow-hidden rounded-full bg-muted">
10
+ <div
11
+ className="h-full rounded-full bg-amber-400 transition-all"
12
+ style={{ width: `${pct}%` }}
13
+ />
14
+ </div>
15
+ <span className="w-5 text-right text-muted-foreground/70 text-xs">
16
+ {count}
17
+ </span>
18
+ </div>
19
+ );
20
+ })}
21
+ </div>
@@ -0,0 +1,13 @@
1
+ "use client";
2
+
3
+ import DistributionBarsTemplate from "./distribution-bars.mdx";
4
+
5
+ export function DistributionBars({
6
+ distribution,
7
+ total,
8
+ }: {
9
+ distribution: Record<string, number>;
10
+ total: number;
11
+ }) {
12
+ return <DistributionBarsTemplate distribution={distribution} total={total} />;
13
+ }
@@ -0,0 +1,20 @@
1
+ "use client";
2
+
3
+ import type { MDXComponents } from "mdx/types";
4
+ import { DistributionBars } from "./distribution-bars";
5
+ import { ProductReviews } from "./product-reviews";
6
+ import { ReviewCard } from "./review-card";
7
+ import { ReviewForm } from "./review-form";
8
+ import { ReviewsSummary } from "./reviews-summary";
9
+ import { StarDisplay } from "./star-display";
10
+ import { StarPicker } from "./star-picker";
11
+
12
+ export default {
13
+ ReviewsSummary,
14
+ ProductReviews,
15
+ StarDisplay,
16
+ StarPicker,
17
+ ReviewCard,
18
+ ReviewForm,
19
+ DistributionBars,
20
+ } satisfies MDXComponents;
@@ -0,0 +1,52 @@
1
+ <section className="py-8">
2
+ <div className="mb-6 flex flex-wrap items-center justify-between gap-4">
3
+ <div>
4
+ <h2 className="font-semibold text-foreground text-xl">{props.title}</h2>
5
+ {!props.noReviews && props.summary && (
6
+ <div className="mt-1 flex items-center gap-2">
7
+ {props.starDisplay}
8
+ <span className="font-medium text-foreground text-lg">
9
+ {props.summary.average.toFixed(1)}
10
+ </span>
11
+ <span className="text-muted-foreground text-sm">
12
+ ({props.summary.count} review{props.summary.count !== 1 ? "s" : ""})
13
+ </span>
14
+ </div>
15
+ )}
16
+ </div>
17
+ <button
18
+ type="button"
19
+ onClick={props.onToggleForm}
20
+ className="rounded-lg border border-border bg-background px-4 py-2 font-medium text-foreground text-sm transition-colors hover:bg-muted"
21
+ >
22
+ {props.showForm ? "Cancel" : "Write a Review"}
23
+ </button>
24
+ </div>
25
+
26
+ {props.showForm && (
27
+ <div className="mb-8">
28
+ {props.formContent}
29
+ </div>
30
+ )}
31
+
32
+ {props.noReviews && !props.showForm && (
33
+ <div className="rounded-xl border border-border bg-muted/30 py-12 text-center">
34
+ <p className="font-medium text-foreground text-sm">No reviews yet</p>
35
+ <p className="mt-1 text-muted-foreground text-sm">
36
+ Be the first to review this product.
37
+ </p>
38
+ </div>
39
+ )}
40
+
41
+ {!props.noReviews && props.summary && (
42
+ <div className="mb-6 flex flex-col gap-5 sm:flex-row sm:items-start">
43
+ <div className="sm:w-48">
44
+ {props.distributionBars}
45
+ </div>
46
+ <div className="hidden h-auto w-px bg-border sm:block" />
47
+ <div className="flex-1">
48
+ {props.reviewListContent}
49
+ </div>
50
+ </div>
51
+ )}
52
+ </section>