@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,488 @@
1
+ "use client";
2
+
3
+ import { useStoreContext } from "@86d-app/core/client";
4
+ import { useEffect, useRef, useState } from "react";
5
+ import {
6
+ useCartMutation,
7
+ useProductsApi,
8
+ useReviewsApi,
9
+ useTrack,
10
+ } from "./_hooks";
11
+ import type {
12
+ ProductVariant,
13
+ ProductWithVariants,
14
+ ReviewsResponse,
15
+ } from "./_types";
16
+ import { formatPrice } from "./_utils";
17
+ import { BackInStockNotify } from "./back-in-stock-notify";
18
+ import ProductDetailTemplate from "./product-detail.mdx";
19
+ import { ProductReviewsSection } from "./product-reviews-section";
20
+ import { RecentlyViewedProducts } from "./recently-viewed";
21
+ import { RelatedProducts } from "./related-products";
22
+ import { StarDisplay } from "./star-display";
23
+ import { StockBadge } from "./stock-badge";
24
+
25
+ export interface ProductDetailProps {
26
+ slug?: string;
27
+ params?: Record<string, string>;
28
+ }
29
+
30
+ export function ProductDetail(props: ProductDetailProps) {
31
+ const slug = props.slug ?? props.params?.slug;
32
+ const api = useProductsApi();
33
+ const cartApi = useCartMutation();
34
+ const reviewsApi = useReviewsApi();
35
+ const track = useTrack();
36
+ // biome-ignore lint/suspicious/noExplicitAny: store context shape varies per app
37
+ const store = useStoreContext<{ cart: any }>();
38
+
39
+ const { data, isLoading } = api.getProduct.useQuery(
40
+ { params: { id: slug ?? "" } },
41
+ { enabled: !!slug },
42
+ ) as {
43
+ data: { product: ProductWithVariants } | undefined;
44
+ isLoading: boolean;
45
+ };
46
+
47
+ const product = data?.product ?? null;
48
+
49
+ const { data: reviewsSummaryData } = reviewsApi.listProductReviews.useQuery(
50
+ product
51
+ ? {
52
+ params: { productId: product.id },
53
+ take: "1",
54
+ }
55
+ : undefined,
56
+ ) as { data: ReviewsResponse | undefined };
57
+ const reviewSummary = reviewsSummaryData?.summary;
58
+
59
+ const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(
60
+ null,
61
+ );
62
+ const [selectedImage, setSelectedImage] = useState(0);
63
+ const [qty, setQty] = useState(1);
64
+ const [added, setAdded] = useState(false);
65
+
66
+ const trackedRef = useRef<string | null>(null);
67
+ useEffect(() => {
68
+ if (product && trackedRef.current !== product.id) {
69
+ trackedRef.current = product.id;
70
+ track({
71
+ type: "productView",
72
+ productId: product.id,
73
+ data: {
74
+ name: product.name,
75
+ slug: product.slug,
76
+ price: product.price,
77
+ image: product.images[0],
78
+ },
79
+ });
80
+ }
81
+ }, [product, track]);
82
+
83
+ const firstVariant = product?.variants?.[0] ?? null;
84
+ useEffect(() => {
85
+ if (product && firstVariant) {
86
+ setSelectedVariant(firstVariant);
87
+ }
88
+ }, [product?.id]);
89
+
90
+ const addToCartMutation = cartApi.addToCart.useMutation({
91
+ onSuccess: () => {
92
+ void cartApi.getCart.invalidate();
93
+ store.cart.openDrawer();
94
+ setAdded(true);
95
+ setTimeout(() => setAdded(false), 2000);
96
+ if (product) {
97
+ track({
98
+ type: "addToCart",
99
+ productId: product.id,
100
+ value: selectedVariant?.price ?? product.price,
101
+ data: {
102
+ name: product.name,
103
+ quantity: qty,
104
+ variantId: selectedVariant?.id,
105
+ },
106
+ });
107
+ }
108
+ },
109
+ });
110
+
111
+ if (!slug) {
112
+ return (
113
+ <div className="rounded-md border border-border bg-muted/30 p-4 text-muted-foreground">
114
+ <p className="font-medium">Product not found</p>
115
+ <p className="mt-1 text-sm">No product was specified.</p>
116
+ <a href="/products" className="mt-3 inline-block text-sm underline">
117
+ Back to products
118
+ </a>
119
+ </div>
120
+ );
121
+ }
122
+
123
+ if (isLoading) {
124
+ return (
125
+ <div className="py-6">
126
+ <div className="grid gap-8 lg:grid-cols-2 lg:gap-12">
127
+ <div className="aspect-square animate-pulse rounded-lg bg-muted" />
128
+ <div className="space-y-4 py-2">
129
+ <div className="h-3 w-20 animate-pulse rounded bg-muted" />
130
+ <div className="h-7 w-2/3 animate-pulse rounded bg-muted" />
131
+ <div className="h-6 w-24 animate-pulse rounded bg-muted" />
132
+ <div className="h-20 animate-pulse rounded bg-muted" />
133
+ </div>
134
+ </div>
135
+ </div>
136
+ );
137
+ }
138
+
139
+ if (!product) {
140
+ return (
141
+ <div className="flex flex-col items-center justify-center py-24 text-center">
142
+ <p className="font-medium text-foreground text-sm">Product not found</p>
143
+ <a
144
+ href="/products"
145
+ className="mt-2 text-muted-foreground text-sm transition-colors hover:text-foreground"
146
+ >
147
+ Back to products
148
+ </a>
149
+ </div>
150
+ );
151
+ }
152
+
153
+ const optionKeys: string[] = [];
154
+ const optionValues: Record<string, string[]> = {};
155
+ for (const v of product.variants) {
156
+ for (const [key, value] of Object.entries(v.options)) {
157
+ if (!optionValues[key]) {
158
+ optionKeys.push(key);
159
+ optionValues[key] = [];
160
+ }
161
+ if (!optionValues[key].includes(value)) {
162
+ optionValues[key].push(value);
163
+ }
164
+ }
165
+ }
166
+
167
+ const selectedOptions: Record<string, string> = {};
168
+ if (selectedVariant) {
169
+ for (const [key, value] of Object.entries(selectedVariant.options)) {
170
+ selectedOptions[key] = value;
171
+ }
172
+ }
173
+
174
+ const handleOptionChange = (key: string, value: string) => {
175
+ const newOptions = { ...selectedOptions, [key]: value };
176
+ const match = product.variants.find((v) =>
177
+ Object.entries(newOptions).every(([k, val]) => v.options[k] === val),
178
+ );
179
+ if (match) {
180
+ setSelectedVariant(match);
181
+ }
182
+ };
183
+
184
+ const displayPrice = selectedVariant?.price ?? product.price;
185
+ const comparePrice =
186
+ selectedVariant?.compareAtPrice ?? product.compareAtPrice;
187
+ const hasDiscount = comparePrice != null && comparePrice > displayPrice;
188
+ const inStock = (selectedVariant?.inventory ?? product.inventory) > 0;
189
+
190
+ const handleAddToCart = () => {
191
+ addToCartMutation.mutate({
192
+ productId: product.id,
193
+ variantId: selectedVariant?.id ?? undefined,
194
+ quantity: qty,
195
+ price: displayPrice,
196
+ productName: product.name,
197
+ productSlug: product.slug,
198
+ productImage: product.images[0],
199
+ variantName: selectedVariant?.name,
200
+ variantOptions: selectedVariant?.options,
201
+ });
202
+ };
203
+
204
+ // --- Pre-computed JSX blocks for template ---
205
+
206
+ const breadcrumbs = (
207
+ <nav className="mb-6 flex items-center gap-1.5 text-muted-foreground text-xs">
208
+ <a href="/" className="transition-colors hover:text-foreground">
209
+ Home
210
+ </a>
211
+ <span className="text-border">/</span>
212
+ <a href="/products" className="transition-colors hover:text-foreground">
213
+ Products
214
+ </a>
215
+ <span className="text-border">/</span>
216
+ <span className="truncate text-foreground">{product.name}</span>
217
+ </nav>
218
+ );
219
+
220
+ const imageGallery = (
221
+ <div className="space-y-2.5">
222
+ <div className="aspect-square overflow-hidden rounded-lg bg-muted">
223
+ {product.images[selectedImage] ? (
224
+ <img
225
+ src={product.images[selectedImage]}
226
+ alt={product.name}
227
+ className="h-full w-full object-cover object-center"
228
+ />
229
+ ) : (
230
+ <div className="flex h-full w-full items-center justify-center text-muted-foreground/30">
231
+ <svg
232
+ xmlns="http://www.w3.org/2000/svg"
233
+ width="40"
234
+ height="40"
235
+ viewBox="0 0 24 24"
236
+ fill="none"
237
+ stroke="currentColor"
238
+ strokeWidth="1.5"
239
+ strokeLinecap="round"
240
+ strokeLinejoin="round"
241
+ aria-hidden="true"
242
+ >
243
+ <rect width="18" height="18" x="3" y="3" rx="2" />
244
+ <circle cx="9" cy="9" r="2" />
245
+ <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
246
+ </svg>
247
+ </div>
248
+ )}
249
+ </div>
250
+ {product.images.length > 1 && (
251
+ <div className="flex gap-1.5">
252
+ {product.images.map((img, i) => (
253
+ <button
254
+ key={i}
255
+ type="button"
256
+ onClick={() => setSelectedImage(i)}
257
+ className={`h-14 w-14 overflow-hidden rounded-md transition-all ${
258
+ i === selectedImage
259
+ ? "ring-1.5 ring-foreground ring-offset-1 ring-offset-background"
260
+ : "opacity-50 hover:opacity-80"
261
+ }`}
262
+ >
263
+ <img
264
+ src={img}
265
+ alt={`${product.name} view ${i + 1}`}
266
+ className="h-full w-full object-cover"
267
+ />
268
+ </button>
269
+ ))}
270
+ </div>
271
+ )}
272
+ </div>
273
+ );
274
+
275
+ const categoryLink = product.category ? (
276
+ <a
277
+ href={`/products?category=${product.category.id}`}
278
+ className="w-fit text-muted-foreground text-xs transition-colors hover:text-foreground"
279
+ >
280
+ {product.category.name}
281
+ </a>
282
+ ) : null;
283
+
284
+ const reviewSummaryLink =
285
+ reviewSummary && reviewSummary.count > 0 ? (
286
+ <a
287
+ href="#reviews"
288
+ className="flex items-center gap-1.5 transition-opacity hover:opacity-80"
289
+ >
290
+ <StarDisplay rating={reviewSummary.average} size="sm" />
291
+ <span className="text-muted-foreground text-sm">
292
+ {reviewSummary.average.toFixed(1)} ({reviewSummary.count})
293
+ </span>
294
+ </a>
295
+ ) : null;
296
+
297
+ const priceBlock = (
298
+ <div className="flex items-center gap-2.5">
299
+ <span className="font-display text-foreground text-xl tabular-nums sm:text-2xl">
300
+ {formatPrice(displayPrice)}
301
+ </span>
302
+ {hasDiscount && (
303
+ <>
304
+ <span className="text-muted-foreground text-sm tabular-nums line-through">
305
+ {formatPrice(comparePrice as number)}
306
+ </span>
307
+ <span className="rounded-full bg-foreground px-2 py-0.5 font-medium text-2xs text-background tabular-nums">
308
+ −{Math.round((1 - displayPrice / (comparePrice as number)) * 100)}%
309
+ </span>
310
+ </>
311
+ )}
312
+ </div>
313
+ );
314
+
315
+ const stockBadge = (
316
+ <StockBadge inventory={selectedVariant?.inventory ?? product.inventory} />
317
+ );
318
+
319
+ const shortDescription = product.shortDescription ? (
320
+ <p className="text-muted-foreground text-sm leading-relaxed">
321
+ {product.shortDescription}
322
+ </p>
323
+ ) : null;
324
+
325
+ let variantSelector: React.ReactNode = null;
326
+ if (product.variants.length > 0 && optionKeys.length > 0) {
327
+ variantSelector = (
328
+ <div className="space-y-3.5">
329
+ {optionKeys.map((key) => (
330
+ <div key={key} className="space-y-1.5">
331
+ <p className="text-foreground text-xs">
332
+ {key}
333
+ {selectedOptions[key] && (
334
+ <span className="ml-1.5 text-muted-foreground">
335
+ {selectedOptions[key]}
336
+ </span>
337
+ )}
338
+ </p>
339
+ <div className="flex flex-wrap gap-1.5">
340
+ {(optionValues[key] ?? []).map((value) => {
341
+ const isSelected = selectedOptions[key] === value;
342
+ const wouldMatch = product.variants.some(
343
+ (v) =>
344
+ v.options[key] === value &&
345
+ Object.entries(selectedOptions).every(
346
+ ([k, val]) => k === key || v.options[k] === val,
347
+ ),
348
+ );
349
+ return (
350
+ <button
351
+ key={value}
352
+ type="button"
353
+ onClick={() => handleOptionChange(key, value)}
354
+ disabled={!wouldMatch}
355
+ className={`rounded-md border px-3 py-1.5 text-sm transition-all ${
356
+ isSelected
357
+ ? "border-foreground bg-foreground text-background"
358
+ : wouldMatch
359
+ ? "border-border text-foreground hover:border-foreground/40"
360
+ : "border-border/40 text-muted-foreground/30 line-through"
361
+ }`}
362
+ >
363
+ {value}
364
+ </button>
365
+ );
366
+ })}
367
+ </div>
368
+ </div>
369
+ ))}
370
+ </div>
371
+ );
372
+ } else if (product.variants.length > 0 && optionKeys.length === 0) {
373
+ variantSelector = (
374
+ <div className="space-y-1.5">
375
+ <p className="text-foreground text-xs">
376
+ {selectedVariant ? selectedVariant.name : "Select option"}
377
+ </p>
378
+ <div className="flex flex-wrap gap-1.5">
379
+ {product.variants.map((v) => (
380
+ <button
381
+ key={v.id}
382
+ type="button"
383
+ onClick={() => setSelectedVariant(v)}
384
+ className={`rounded-md border px-3 py-1.5 text-sm transition-all ${
385
+ selectedVariant?.id === v.id
386
+ ? "border-foreground bg-foreground text-background"
387
+ : "border-border text-foreground hover:border-foreground/40"
388
+ }`}
389
+ >
390
+ {v.name}
391
+ </button>
392
+ ))}
393
+ </div>
394
+ </div>
395
+ );
396
+ }
397
+
398
+ const addToCartBlock = (
399
+ <div className="mt-1 flex items-center gap-2.5">
400
+ <div className="flex items-center rounded-md border border-border">
401
+ <button
402
+ type="button"
403
+ onClick={() => setQty((q) => Math.max(1, q - 1))}
404
+ className="flex h-10 w-10 items-center justify-center text-muted-foreground text-sm transition-colors hover:text-foreground"
405
+ >
406
+
407
+ </button>
408
+ <span className="min-w-8 text-center text-foreground text-sm tabular-nums">
409
+ {qty}
410
+ </span>
411
+ <button
412
+ type="button"
413
+ onClick={() => setQty((q) => q + 1)}
414
+ className="flex h-10 w-10 items-center justify-center text-muted-foreground text-sm transition-colors hover:text-foreground"
415
+ >
416
+ +
417
+ </button>
418
+ </div>
419
+ <button
420
+ type="button"
421
+ onClick={handleAddToCart}
422
+ disabled={addToCartMutation.isPending || !inStock}
423
+ className="flex-1 rounded-md bg-foreground py-2.5 font-medium text-background text-sm transition-opacity hover:opacity-85 active:opacity-75 disabled:opacity-40"
424
+ >
425
+ {!inStock
426
+ ? "Sold out"
427
+ : added
428
+ ? "Added!"
429
+ : addToCartMutation.isPending
430
+ ? "Adding…"
431
+ : "Add to cart"}
432
+ </button>
433
+ </div>
434
+ );
435
+
436
+ const outOfStockNotice = !inStock ? (
437
+ <BackInStockNotify
438
+ productId={product.id}
439
+ variantId={selectedVariant?.id}
440
+ productName={product.name}
441
+ />
442
+ ) : null;
443
+
444
+ const descriptionBlock = product.description ? (
445
+ <div className="mt-2 border-border/50 border-t pt-5">
446
+ <p className="mb-2 font-medium text-foreground text-xs">Description</p>
447
+ <p className="whitespace-pre-wrap text-muted-foreground text-sm leading-relaxed">
448
+ {product.description}
449
+ </p>
450
+ </div>
451
+ ) : null;
452
+
453
+ const tagsBlock =
454
+ product.tags.length > 0 ? (
455
+ <div className="flex flex-wrap gap-1">
456
+ {product.tags.map((t) => (
457
+ <a
458
+ key={t}
459
+ href={`/products?tag=${encodeURIComponent(t)}`}
460
+ className="rounded-md bg-muted px-2 py-0.5 text-muted-foreground text-xs transition-colors hover:bg-muted-foreground/20 hover:text-foreground"
461
+ >
462
+ {t}
463
+ </a>
464
+ ))}
465
+ </div>
466
+ ) : null;
467
+
468
+ return (
469
+ <ProductDetailTemplate
470
+ breadcrumbs={breadcrumbs}
471
+ imageGallery={imageGallery}
472
+ categoryLink={categoryLink}
473
+ name={product.name}
474
+ reviewSummaryLink={reviewSummaryLink}
475
+ priceBlock={priceBlock}
476
+ stockBadge={stockBadge}
477
+ shortDescription={shortDescription}
478
+ variantSelector={variantSelector}
479
+ addToCartBlock={addToCartBlock}
480
+ outOfStockNotice={outOfStockNotice}
481
+ descriptionBlock={descriptionBlock}
482
+ tagsBlock={tagsBlock}
483
+ reviewsSection={<ProductReviewsSection productId={product.id} />}
484
+ relatedProducts={<RelatedProducts productId={product.id} />}
485
+ recentlyViewed={<RecentlyViewedProducts excludeProductId={product.id} />}
486
+ />
487
+ );
488
+ }
@@ -0,0 +1,7 @@
1
+ <div className="py-4">
2
+ {props.filtersBar}
3
+ {props.filterPanel}
4
+ {props.filterChips}
5
+ {props.gridContent}
6
+ {props.pagination}
7
+ </div>