@86d-app/products 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 (94) hide show
  1. package/AGENTS.md +65 -0
  2. package/COMPONENTS.md +231 -0
  3. package/README.md +201 -0
  4. package/package.json +46 -0
  5. package/src/__tests__/controllers.test.ts +2227 -0
  6. package/src/__tests__/state.test.ts +138 -0
  7. package/src/admin/components/categories-admin.mdx +3 -0
  8. package/src/admin/components/categories-admin.tsx +449 -0
  9. package/src/admin/components/category-form.mdx +9 -0
  10. package/src/admin/components/category-form.tsx +490 -0
  11. package/src/admin/components/category-list.mdx +75 -0
  12. package/src/admin/components/category-list.tsx +168 -0
  13. package/src/admin/components/collections-admin.mdx +3 -0
  14. package/src/admin/components/collections-admin.tsx +771 -0
  15. package/src/admin/components/index.tsx +8 -0
  16. package/src/admin/components/product-detail.mdx +12 -0
  17. package/src/admin/components/product-detail.tsx +790 -0
  18. package/src/admin/components/product-edit.tsx +60 -0
  19. package/src/admin/components/product-form.tsx +793 -0
  20. package/src/admin/components/product-list.mdx +3 -0
  21. package/src/admin/components/product-list.tsx +1125 -0
  22. package/src/admin/components/product-new.tsx +38 -0
  23. package/src/admin/endpoints/add-collection-product.ts +17 -0
  24. package/src/admin/endpoints/bulk-action.ts +43 -0
  25. package/src/admin/endpoints/create-category.ts +52 -0
  26. package/src/admin/endpoints/create-collection.ts +35 -0
  27. package/src/admin/endpoints/create-product.ts +50 -0
  28. package/src/admin/endpoints/create-variant.ts +45 -0
  29. package/src/admin/endpoints/delete-category.ts +27 -0
  30. package/src/admin/endpoints/delete-collection.ts +12 -0
  31. package/src/admin/endpoints/delete-product.ts +27 -0
  32. package/src/admin/endpoints/delete-variant.ts +27 -0
  33. package/src/admin/endpoints/get-product.ts +23 -0
  34. package/src/admin/endpoints/import-products.ts +47 -0
  35. package/src/admin/endpoints/index.ts +43 -0
  36. package/src/admin/endpoints/list-categories.ts +21 -0
  37. package/src/admin/endpoints/list-collections.ts +20 -0
  38. package/src/admin/endpoints/list-products.ts +25 -0
  39. package/src/admin/endpoints/remove-collection-product.ts +15 -0
  40. package/src/admin/endpoints/update-category.ts +82 -0
  41. package/src/admin/endpoints/update-collection.ts +22 -0
  42. package/src/admin/endpoints/update-product.ts +67 -0
  43. package/src/admin/endpoints/update-variant.ts +41 -0
  44. package/src/controllers.ts +1410 -0
  45. package/src/index.ts +120 -0
  46. package/src/markdown.ts +150 -0
  47. package/src/mdx.d.ts +5 -0
  48. package/src/schema.ts +352 -0
  49. package/src/state.ts +84 -0
  50. package/src/store/components/_hooks.ts +78 -0
  51. package/src/store/components/_types.ts +73 -0
  52. package/src/store/components/_utils.ts +14 -0
  53. package/src/store/components/back-in-stock-notify.tsx +97 -0
  54. package/src/store/components/collection-card.mdx +42 -0
  55. package/src/store/components/collection-card.tsx +12 -0
  56. package/src/store/components/collection-detail.mdx +12 -0
  57. package/src/store/components/collection-detail.tsx +149 -0
  58. package/src/store/components/collection-grid.mdx +9 -0
  59. package/src/store/components/collection-grid.tsx +80 -0
  60. package/src/store/components/featured-products.mdx +9 -0
  61. package/src/store/components/featured-products.tsx +75 -0
  62. package/src/store/components/filter-chip.mdx +25 -0
  63. package/src/store/components/filter-chip.tsx +12 -0
  64. package/src/store/components/index.tsx +39 -0
  65. package/src/store/components/product-card.mdx +69 -0
  66. package/src/store/components/product-card.tsx +71 -0
  67. package/src/store/components/product-detail.mdx +30 -0
  68. package/src/store/components/product-detail.tsx +488 -0
  69. package/src/store/components/product-listing.mdx +7 -0
  70. package/src/store/components/product-listing.tsx +423 -0
  71. package/src/store/components/product-reviews-section.mdx +21 -0
  72. package/src/store/components/product-reviews-section.tsx +372 -0
  73. package/src/store/components/recently-viewed.tsx +100 -0
  74. package/src/store/components/related-products.mdx +6 -0
  75. package/src/store/components/related-products.tsx +62 -0
  76. package/src/store/components/star-display.mdx +18 -0
  77. package/src/store/components/star-display.tsx +27 -0
  78. package/src/store/components/star-picker.mdx +21 -0
  79. package/src/store/components/star-picker.tsx +21 -0
  80. package/src/store/components/stock-badge.mdx +12 -0
  81. package/src/store/components/stock-badge.tsx +19 -0
  82. package/src/store/endpoints/get-category.ts +61 -0
  83. package/src/store/endpoints/get-collection.ts +46 -0
  84. package/src/store/endpoints/get-featured.ts +18 -0
  85. package/src/store/endpoints/get-product.ts +52 -0
  86. package/src/store/endpoints/get-related.ts +20 -0
  87. package/src/store/endpoints/index.ts +23 -0
  88. package/src/store/endpoints/list-categories.ts +13 -0
  89. package/src/store/endpoints/list-collections.ts +22 -0
  90. package/src/store/endpoints/list-products.ts +28 -0
  91. package/src/store/endpoints/search-products.ts +18 -0
  92. package/src/store/endpoints/store-search.ts +111 -0
  93. package/tsconfig.json +9 -0
  94. package/vitest.config.ts +7 -0
@@ -0,0 +1,372 @@
1
+ "use client";
2
+
3
+ import { useCallback, useState } from "react";
4
+ import { useReviewsApi } from "./_hooks";
5
+ import type { Review, ReviewsResponse } from "./_types";
6
+ import { formatDate } from "./_utils";
7
+ import ProductReviewsSectionTemplate from "./product-reviews-section.mdx";
8
+ import { StarDisplay } from "./star-display";
9
+ import { StarPicker } from "./star-picker";
10
+
11
+ const REVIEWS_PAGE_SIZE = 10;
12
+
13
+ export interface ProductReviewsSectionProps {
14
+ productId: string;
15
+ }
16
+
17
+ export function ProductReviewsSection({
18
+ productId,
19
+ }: ProductReviewsSectionProps) {
20
+ const reviewsApi = useReviewsApi();
21
+
22
+ const { data: initialData, isLoading: loading } =
23
+ reviewsApi.listProductReviews.useQuery({
24
+ params: { productId },
25
+ take: String(REVIEWS_PAGE_SIZE),
26
+ skip: "0",
27
+ }) as { data: ReviewsResponse | undefined; isLoading: boolean };
28
+
29
+ const [extraReviews, setExtraReviews] = useState<Review[]>([]);
30
+ const [loadingMore, setLoadingMore] = useState(false);
31
+ const [skip, setSkip] = useState(0);
32
+ const [loadedAll, setLoadedAll] = useState(false);
33
+ const [showForm, setShowForm] = useState(false);
34
+
35
+ const [rating, setRating] = useState(0);
36
+ const [reviewName, setReviewName] = useState("");
37
+ const [reviewEmail, setReviewEmail] = useState("");
38
+ const [reviewTitle, setReviewTitle] = useState("");
39
+ const [reviewBody, setReviewBody] = useState("");
40
+ const [ratingError, setRatingError] = useState("");
41
+
42
+ const allReviews = [...(initialData?.reviews ?? []), ...extraReviews];
43
+ const summary = initialData?.summary ?? null;
44
+ const hasMore =
45
+ !loadedAll &&
46
+ initialData !== undefined &&
47
+ (initialData.reviews.length === REVIEWS_PAGE_SIZE ||
48
+ extraReviews.length > 0);
49
+
50
+ const submitMutation = reviewsApi.submitReview.useMutation({
51
+ onSuccess: () => {
52
+ setShowForm(false);
53
+ setRating(0);
54
+ setReviewName("");
55
+ setReviewEmail("");
56
+ setReviewTitle("");
57
+ setReviewBody("");
58
+ void reviewsApi.listProductReviews.invalidate();
59
+ setExtraReviews([]);
60
+ setSkip(0);
61
+ setLoadedAll(false);
62
+ },
63
+ });
64
+
65
+ const handleLoadMore = useCallback(async () => {
66
+ const nextSkip = skip === 0 ? REVIEWS_PAGE_SIZE : skip + REVIEWS_PAGE_SIZE;
67
+ setLoadingMore(true);
68
+ try {
69
+ const fresh = (await reviewsApi.listProductReviews.fetch({
70
+ params: { productId },
71
+ take: String(REVIEWS_PAGE_SIZE),
72
+ skip: String(nextSkip),
73
+ })) as ReviewsResponse;
74
+ setExtraReviews((prev) => [...prev, ...fresh.reviews]);
75
+ setSkip(nextSkip);
76
+ if (fresh.reviews.length < REVIEWS_PAGE_SIZE) setLoadedAll(true);
77
+ } catch {
78
+ // silently ignore
79
+ } finally {
80
+ setLoadingMore(false);
81
+ }
82
+ }, [reviewsApi.listProductReviews, productId, skip]);
83
+
84
+ const handleSubmitReview = (e: React.FormEvent) => {
85
+ e.preventDefault();
86
+ if (rating === 0) {
87
+ setRatingError("Please select a rating.");
88
+ return;
89
+ }
90
+ setRatingError("");
91
+ submitMutation.mutate({
92
+ productId,
93
+ authorName: reviewName,
94
+ authorEmail: reviewEmail,
95
+ rating,
96
+ title: reviewTitle.trim() || undefined,
97
+ body: reviewBody,
98
+ });
99
+ };
100
+
101
+ if (loading) {
102
+ return (
103
+ <section id="reviews" className="border-border/50 border-t py-10">
104
+ <div className="mb-6 h-7 w-40 animate-pulse rounded-lg bg-muted" />
105
+ <div className="space-y-4">
106
+ {[1, 2, 3].map((n) => (
107
+ <div key={n} className="space-y-2 border-border border-b pb-4">
108
+ <div className="h-4 w-24 animate-pulse rounded bg-muted" />
109
+ <div className="h-4 w-full animate-pulse rounded bg-muted" />
110
+ <div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
111
+ </div>
112
+ ))}
113
+ </div>
114
+ </section>
115
+ );
116
+ }
117
+
118
+ const noReviews = summary === null || summary.count === 0;
119
+ const submitError =
120
+ ratingError || (submitMutation.isError ? "Failed to submit review." : "");
121
+
122
+ // --- Pre-computed JSX blocks for template ---
123
+
124
+ const summaryDisplay =
125
+ !noReviews && summary ? (
126
+ <div className="mt-1 flex items-center gap-2">
127
+ <StarDisplay rating={summary.average} size="lg" />
128
+ <span className="font-medium text-foreground">
129
+ {summary.average.toFixed(1)}
130
+ </span>
131
+ <span className="text-muted-foreground text-sm">
132
+ ({summary.count} review{summary.count !== 1 ? "s" : ""})
133
+ </span>
134
+ </div>
135
+ ) : null;
136
+
137
+ const toggleFormButton = (
138
+ <button
139
+ type="button"
140
+ onClick={() => setShowForm((v) => !v)}
141
+ className="rounded-md border border-border bg-background px-4 py-2 font-medium text-foreground text-sm transition-colors hover:bg-muted"
142
+ >
143
+ {showForm ? "Cancel" : "Write a Review"}
144
+ </button>
145
+ );
146
+
147
+ let formContent: React.ReactNode = null;
148
+ if (showForm) {
149
+ formContent = (
150
+ <div className="mb-8">
151
+ {submitMutation.isSuccess ? (
152
+ <div className="rounded-lg border border-emerald-200 bg-emerald-50 p-5 text-center dark:border-emerald-800 dark:bg-emerald-950/30">
153
+ <p className="font-semibold text-emerald-800 dark:text-emerald-200">
154
+ Thank you for your review!
155
+ </p>
156
+ <p className="mt-1 text-emerald-700 text-sm dark:text-emerald-300">
157
+ Your review will appear once approved.
158
+ </p>
159
+ </div>
160
+ ) : (
161
+ <form
162
+ onSubmit={handleSubmitReview}
163
+ className="space-y-4 rounded-lg border border-border bg-muted/30 p-5"
164
+ >
165
+ <div>
166
+ <p className="mb-1.5 font-medium text-foreground text-sm">
167
+ Your rating <span className="text-destructive">*</span>
168
+ </p>
169
+ <StarPicker value={rating} onChange={setRating} />
170
+ </div>
171
+
172
+ <div className="grid gap-4 sm:grid-cols-2">
173
+ <div>
174
+ <label
175
+ htmlFor="pdp-review-name"
176
+ className="mb-1 block font-medium text-foreground text-sm"
177
+ >
178
+ Name <span className="text-destructive">*</span>
179
+ </label>
180
+ <input
181
+ id="pdp-review-name"
182
+ type="text"
183
+ required
184
+ maxLength={200}
185
+ value={reviewName}
186
+ onChange={(e) => setReviewName(e.target.value)}
187
+ placeholder="Your name"
188
+ className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:border-foreground/20 focus:ring-1 focus:ring-foreground/10"
189
+ />
190
+ </div>
191
+ <div>
192
+ <label
193
+ htmlFor="pdp-review-email"
194
+ className="mb-1 block font-medium text-foreground text-sm"
195
+ >
196
+ Email <span className="text-destructive">*</span>
197
+ </label>
198
+ <input
199
+ id="pdp-review-email"
200
+ type="email"
201
+ required
202
+ value={reviewEmail}
203
+ onChange={(e) => setReviewEmail(e.target.value)}
204
+ placeholder="you@example.com"
205
+ className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:border-foreground/20 focus:ring-1 focus:ring-foreground/10"
206
+ />
207
+ </div>
208
+ </div>
209
+
210
+ <div>
211
+ <label
212
+ htmlFor="pdp-review-title"
213
+ className="mb-1 block font-medium text-foreground text-sm"
214
+ >
215
+ Title
216
+ </label>
217
+ <input
218
+ id="pdp-review-title"
219
+ type="text"
220
+ maxLength={500}
221
+ value={reviewTitle}
222
+ onChange={(e) => setReviewTitle(e.target.value)}
223
+ placeholder="Summary of your experience"
224
+ className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:border-foreground/20 focus:ring-1 focus:ring-foreground/10"
225
+ />
226
+ </div>
227
+
228
+ <div>
229
+ <label
230
+ htmlFor="pdp-review-body"
231
+ className="mb-1 block font-medium text-foreground text-sm"
232
+ >
233
+ Review <span className="text-destructive">*</span>
234
+ </label>
235
+ <textarea
236
+ id="pdp-review-body"
237
+ required
238
+ maxLength={10000}
239
+ rows={4}
240
+ value={reviewBody}
241
+ onChange={(e) => setReviewBody(e.target.value)}
242
+ placeholder="Share your experience with this product"
243
+ className="w-full resize-y rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:border-foreground/20 focus:ring-1 focus:ring-foreground/10"
244
+ />
245
+ </div>
246
+
247
+ {submitError && (
248
+ <p className="text-destructive text-sm" role="alert">
249
+ {submitError}
250
+ </p>
251
+ )}
252
+
253
+ <button
254
+ type="submit"
255
+ disabled={submitMutation.isPending}
256
+ className="rounded-md bg-foreground px-5 py-2 font-medium text-background text-sm transition-opacity hover:opacity-85 disabled:opacity-50"
257
+ >
258
+ {submitMutation.isPending ? "Submitting…" : "Submit Review"}
259
+ </button>
260
+ </form>
261
+ )}
262
+ </div>
263
+ );
264
+ }
265
+
266
+ const emptyState =
267
+ noReviews && !showForm ? (
268
+ <div className="rounded-lg border border-border bg-muted/30 py-12 text-center">
269
+ <p className="font-medium text-foreground text-sm">No reviews yet</p>
270
+ <p className="mt-1 text-muted-foreground text-sm">
271
+ Be the first to review this product.
272
+ </p>
273
+ </div>
274
+ ) : null;
275
+
276
+ let reviewsContent: React.ReactNode = null;
277
+ if (!noReviews && summary) {
278
+ reviewsContent = (
279
+ <div className="flex flex-col gap-5 sm:flex-row sm:items-start">
280
+ <div className="sm:w-48">
281
+ <div className="space-y-1.5">
282
+ {[5, 4, 3, 2, 1].map((n) => {
283
+ const count = summary.distribution[String(n)] ?? 0;
284
+ const pct = summary.count > 0 ? (count / summary.count) * 100 : 0;
285
+ return (
286
+ <div key={n} className="flex items-center gap-2 text-sm">
287
+ <span className="w-3 text-right text-muted-foreground">
288
+ {n}
289
+ </span>
290
+ <span className="text-amber-400 text-xs">★</span>
291
+ <div className="h-2 flex-1 overflow-hidden rounded-full bg-muted">
292
+ <div
293
+ className="h-full rounded-full bg-amber-400 transition-all"
294
+ style={{ width: `${pct}%` }}
295
+ />
296
+ </div>
297
+ <span className="w-5 text-right text-muted-foreground/70 text-xs">
298
+ {count}
299
+ </span>
300
+ </div>
301
+ );
302
+ })}
303
+ </div>
304
+ </div>
305
+ <div className="hidden h-auto w-px bg-border sm:block" />
306
+ <div className="flex-1">
307
+ {allReviews.length === 0 ? (
308
+ <p className="text-muted-foreground text-sm">
309
+ No approved reviews yet.
310
+ </p>
311
+ ) : (
312
+ <div>
313
+ {allReviews.map((review) => (
314
+ <article
315
+ key={review.id}
316
+ className="border-border border-b py-5 last:border-0"
317
+ >
318
+ <div className="mb-2 flex items-start justify-between gap-3">
319
+ <div>
320
+ <div className="flex items-center gap-2">
321
+ <StarDisplay rating={review.rating} size="sm" />
322
+ {review.isVerifiedPurchase && (
323
+ <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">
324
+ Verified
325
+ </span>
326
+ )}
327
+ </div>
328
+ {review.title && (
329
+ <p className="mt-1 font-medium text-foreground text-sm">
330
+ {review.title}
331
+ </p>
332
+ )}
333
+ </div>
334
+ <span className="shrink-0 text-muted-foreground text-xs">
335
+ {formatDate(review.createdAt)}
336
+ </span>
337
+ </div>
338
+ <p className="text-muted-foreground text-sm leading-relaxed">
339
+ {review.body}
340
+ </p>
341
+ <p className="mt-2 text-muted-foreground/60 text-xs">
342
+ — {review.authorName}
343
+ </p>
344
+ </article>
345
+ ))}
346
+ {hasMore && (
347
+ <button
348
+ type="button"
349
+ onClick={() => void handleLoadMore()}
350
+ disabled={loadingMore}
351
+ className="mt-4 text-foreground text-sm underline-offset-4 hover:underline disabled:opacity-60"
352
+ >
353
+ {loadingMore ? "Loading…" : "Load more reviews"}
354
+ </button>
355
+ )}
356
+ </div>
357
+ )}
358
+ </div>
359
+ </div>
360
+ );
361
+ }
362
+
363
+ return (
364
+ <ProductReviewsSectionTemplate
365
+ summaryDisplay={summaryDisplay}
366
+ toggleFormButton={toggleFormButton}
367
+ formContent={formContent}
368
+ emptyState={emptyState}
369
+ reviewsContent={reviewsContent}
370
+ />
371
+ );
372
+ }
@@ -0,0 +1,100 @@
1
+ "use client";
2
+
3
+ import { useAnalyticsApi } from "./_hooks";
4
+ import { formatPrice } from "./_utils";
5
+
6
+ interface RecentlyViewedItem {
7
+ productId: string;
8
+ name: string;
9
+ slug: string;
10
+ price: number;
11
+ image?: string | undefined;
12
+ viewedAt: string;
13
+ }
14
+
15
+ export interface RecentlyViewedProductsProps {
16
+ /** Current product ID to exclude from the list. */
17
+ excludeProductId?: string;
18
+ /** Maximum items to show (default: 6). */
19
+ limit?: number;
20
+ /** Section title (default: "Recently viewed"). */
21
+ title?: string;
22
+ /** Analytics session ID for anonymous visitors. */
23
+ sessionId?: string;
24
+ }
25
+
26
+ export function RecentlyViewedProducts({
27
+ excludeProductId,
28
+ limit = 6,
29
+ title = "Recently viewed",
30
+ sessionId,
31
+ }: RecentlyViewedProductsProps) {
32
+ const api = useAnalyticsApi();
33
+
34
+ const { data, isLoading } = api.recentlyViewed.useQuery({
35
+ excludeProductId,
36
+ sessionId,
37
+ limit: String(limit),
38
+ }) as {
39
+ data: { items: RecentlyViewedItem[] } | undefined;
40
+ isLoading: boolean;
41
+ };
42
+
43
+ const items = data?.items ?? [];
44
+
45
+ if (isLoading || items.length === 0) return null;
46
+
47
+ return (
48
+ <section className="border-border/50 border-t py-12 sm:py-14">
49
+ <h2 className="mb-6 font-display font-semibold text-foreground text-lg tracking-tight sm:text-xl">
50
+ {title}
51
+ </h2>
52
+ <div className="scrollbar-none flex gap-4 overflow-x-auto pb-2">
53
+ {items.map((item) => (
54
+ <a
55
+ key={item.productId}
56
+ href={`/products/${item.slug}`}
57
+ className="group flex w-36 flex-none flex-col sm:w-44"
58
+ >
59
+ <div className="aspect-[3/4] overflow-hidden rounded-lg bg-muted">
60
+ {item.image ? (
61
+ <img
62
+ src={item.image}
63
+ alt={item.name}
64
+ className="h-full w-full object-cover object-center transition-transform duration-300 group-hover:scale-[1.03]"
65
+ />
66
+ ) : (
67
+ <div className="flex h-full w-full items-center justify-center text-muted-foreground/30">
68
+ <svg
69
+ xmlns="http://www.w3.org/2000/svg"
70
+ width="24"
71
+ height="24"
72
+ viewBox="0 0 24 24"
73
+ fill="none"
74
+ stroke="currentColor"
75
+ strokeWidth="1.5"
76
+ strokeLinecap="round"
77
+ strokeLinejoin="round"
78
+ aria-hidden="true"
79
+ >
80
+ <rect width="18" height="18" x="3" y="3" rx="2" />
81
+ <circle cx="9" cy="9" r="2" />
82
+ <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
83
+ </svg>
84
+ </div>
85
+ )}
86
+ </div>
87
+ <div className="mt-2.5">
88
+ <p className="truncate text-foreground text-sm transition-colors group-hover:text-foreground/80">
89
+ {item.name}
90
+ </p>
91
+ <p className="mt-0.5 font-medium text-muted-foreground text-xs tabular-nums">
92
+ {formatPrice(item.price)}
93
+ </p>
94
+ </div>
95
+ </a>
96
+ ))}
97
+ </div>
98
+ </section>
99
+ );
100
+ }
@@ -0,0 +1,6 @@
1
+ <section className="border-border/50 border-t py-12 sm:py-14">
2
+ <h2 className="mb-6 font-display font-semibold text-foreground text-lg tracking-tight sm:text-xl">
3
+ {props.title}
4
+ </h2>
5
+ {props.gridContent}
6
+ </section>
@@ -0,0 +1,62 @@
1
+ "use client";
2
+
3
+ import { useProductsApi } from "./_hooks";
4
+ import { ProductCard } from "./product-card";
5
+ import RelatedProductsTemplate from "./related-products.mdx";
6
+
7
+ export interface RelatedProductsProps {
8
+ productId: string;
9
+ limit?: number;
10
+ title?: string;
11
+ }
12
+
13
+ export function RelatedProducts({
14
+ productId,
15
+ limit = 4,
16
+ title = "You might also like",
17
+ }: RelatedProductsProps) {
18
+ const api = useProductsApi();
19
+
20
+ const { data, isLoading } = api.getRelatedProducts.useQuery({
21
+ params: { id: productId },
22
+ limit: String(limit),
23
+ }) as {
24
+ data: { products: import("./_types").Product[] } | undefined;
25
+ isLoading: boolean;
26
+ };
27
+
28
+ const products = data?.products ?? [];
29
+
30
+ if (isLoading) {
31
+ return (
32
+ <section className="border-border/50 border-t py-12 sm:py-14">
33
+ <h2 className="mb-6 font-display font-semibold text-foreground text-lg tracking-tight sm:text-xl">
34
+ {title}
35
+ </h2>
36
+ <div className="grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-3 lg:grid-cols-4">
37
+ {Array.from({ length: limit }).map((_, i) => (
38
+ <div key={i}>
39
+ <div className="aspect-[3/4] animate-pulse rounded-lg bg-muted" />
40
+ <div className="mt-3 space-y-1.5">
41
+ <div className="h-3.5 w-3/4 animate-pulse rounded bg-muted-foreground/10" />
42
+ <div className="h-3.5 w-1/3 animate-pulse rounded bg-muted-foreground/10" />
43
+ </div>
44
+ </div>
45
+ ))}
46
+ </div>
47
+ </section>
48
+ );
49
+ }
50
+
51
+ if (products.length === 0) return null;
52
+
53
+ const gridContent = (
54
+ <div className="grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-3 lg:grid-cols-4">
55
+ {products.map((product) => (
56
+ <ProductCard key={product.id} product={product} />
57
+ ))}
58
+ </div>
59
+ );
60
+
61
+ return <RelatedProductsTemplate title={title} gridContent={gridContent} />;
62
+ }
@@ -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,27 @@
1
+ "use client";
2
+
3
+ import StarDisplayTemplate from "./star-display.mdx";
4
+
5
+ export interface StarDisplayProps {
6
+ rating: number;
7
+ size?: "sm" | "md" | "lg";
8
+ }
9
+
10
+ export function StarDisplay({ rating, size = "md" }: StarDisplayProps) {
11
+ const sizeClass =
12
+ size === "sm"
13
+ ? "text-sm select-none leading-none"
14
+ : size === "lg"
15
+ ? "text-xl select-none leading-none"
16
+ : "text-base select-none leading-none";
17
+ const filledCount = Math.round(rating);
18
+ const ariaLabel = `${rating} out of 5 stars`;
19
+
20
+ return (
21
+ <StarDisplayTemplate
22
+ sizeClass={sizeClass}
23
+ ariaLabel={ariaLabel}
24
+ filledCount={filledCount}
25
+ />
26
+ );
27
+ }
@@ -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} star${n !== 1 ? "s" : ""}`}
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,21 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import StarPickerTemplate from "./star-picker.mdx";
5
+
6
+ export interface StarPickerProps {
7
+ value: number;
8
+ onChange: (n: number) => void;
9
+ }
10
+
11
+ export function StarPicker({ value, onChange }: StarPickerProps) {
12
+ const [hover, setHover] = useState(0);
13
+ return (
14
+ <StarPickerTemplate
15
+ value={value}
16
+ hover={hover}
17
+ onChange={onChange}
18
+ onHover={setHover}
19
+ />
20
+ );
21
+ }
@@ -0,0 +1,12 @@
1
+ <span className={`inline-flex items-center gap-1 text-xs ${
2
+ props.status === "out" ? "text-muted-foreground" :
3
+ props.status === "low" ? "text-amber-600 dark:text-amber-400" :
4
+ "text-emerald-600 dark:text-emerald-400"
5
+ }`}>
6
+ <span className={`inline-block h-1.5 w-1.5 rounded-full ${
7
+ props.status === "out" ? "bg-muted-foreground/40" :
8
+ props.status === "low" ? "bg-amber-500" :
9
+ "bg-emerald-500"
10
+ }`} />
11
+ {props.label}
12
+ </span>
@@ -0,0 +1,19 @@
1
+ "use client";
2
+
3
+ import StockBadgeTemplate from "./stock-badge.mdx";
4
+
5
+ export interface StockBadgeProps {
6
+ inventory: number;
7
+ }
8
+
9
+ export function StockBadge({ inventory }: StockBadgeProps) {
10
+ const status = inventory <= 0 ? "out" : inventory <= 5 ? "low" : "in";
11
+ const label =
12
+ inventory <= 0
13
+ ? "Out of stock"
14
+ : inventory <= 5
15
+ ? `Only ${inventory} left`
16
+ : "In stock";
17
+
18
+ return <StockBadgeTemplate status={status} label={label} />;
19
+ }