@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,423 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { useProductsApi } from "./_hooks";
5
+ import type { Category, Product } from "./_types";
6
+ import { FilterChip } from "./filter-chip";
7
+ import { ProductCard } from "./product-card";
8
+ import ProductListingTemplate from "./product-listing.mdx";
9
+
10
+ export interface ProductListingProps {
11
+ initialCategory?: string;
12
+ initialSearch?: string;
13
+ pageSize?: number;
14
+ }
15
+
16
+ export function ProductListing({
17
+ initialCategory = "",
18
+ initialSearch = "",
19
+ pageSize = 12,
20
+ }: ProductListingProps) {
21
+ const api = useProductsApi();
22
+
23
+ const [page, setPage] = useState(1);
24
+ const [category, setCategory] = useState(initialCategory);
25
+ const [search, setSearch] = useState(initialSearch);
26
+ const [sort, setSort] = useState<"name" | "price" | "createdAt">("createdAt");
27
+ const [order, setOrder] = useState<"asc" | "desc">("desc");
28
+ const [minPrice, setMinPrice] = useState("");
29
+ const [maxPrice, setMaxPrice] = useState("");
30
+ const [inStock, setInStock] = useState(false);
31
+ const [tag, setTag] = useState("");
32
+ const [showFilters, setShowFilters] = useState(false);
33
+
34
+ const queryInput: Record<string, string> = {
35
+ page: String(page),
36
+ limit: String(pageSize),
37
+ sort,
38
+ order,
39
+ };
40
+ if (category) queryInput.category = category;
41
+ if (search) queryInput.search = search;
42
+ if (minPrice) queryInput.minPrice = String(parseInt(minPrice, 10) * 100);
43
+ if (maxPrice) queryInput.maxPrice = String(parseInt(maxPrice, 10) * 100);
44
+ if (inStock) queryInput.inStock = "true";
45
+ if (tag) queryInput.tag = tag;
46
+
47
+ const { data: productsData, isLoading } = api.listProducts.useQuery(
48
+ queryInput,
49
+ ) as {
50
+ data: { products: Product[]; total: number } | undefined;
51
+ isLoading: boolean;
52
+ };
53
+
54
+ const { data: categoriesData } = api.listCategories.useQuery() as {
55
+ data: { categories: Category[] } | undefined;
56
+ };
57
+
58
+ const products = productsData?.products ?? [];
59
+ const total = productsData?.total ?? 0;
60
+ const categories = categoriesData?.categories ?? [];
61
+ const totalPages = Math.ceil(total / pageSize);
62
+
63
+ const hasActiveFilters =
64
+ !!search || !!category || !!minPrice || !!maxPrice || inStock || !!tag;
65
+
66
+ const clearAllFilters = () => {
67
+ setSearch("");
68
+ setCategory("");
69
+ setMinPrice("");
70
+ setMaxPrice("");
71
+ setInStock(false);
72
+ setTag("");
73
+ setPage(1);
74
+ };
75
+
76
+ const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
77
+ e.preventDefault();
78
+ setPage(1);
79
+ };
80
+
81
+ const filtersBar = (
82
+ <div className="mb-4 flex flex-wrap items-center gap-2.5">
83
+ <form
84
+ onSubmit={handleSearch}
85
+ className="flex min-w-[180px] flex-1 items-center"
86
+ >
87
+ <div className="relative flex-1">
88
+ <svg
89
+ xmlns="http://www.w3.org/2000/svg"
90
+ width="14"
91
+ height="14"
92
+ viewBox="0 0 24 24"
93
+ fill="none"
94
+ stroke="currentColor"
95
+ strokeWidth="2"
96
+ strokeLinecap="round"
97
+ strokeLinejoin="round"
98
+ className="absolute top-1/2 left-3 -translate-y-1/2 text-muted-foreground"
99
+ aria-hidden="true"
100
+ >
101
+ <circle cx="11" cy="11" r="8" />
102
+ <path d="m21 21-4.3-4.3" />
103
+ </svg>
104
+ <input
105
+ type="search"
106
+ value={search}
107
+ onChange={(e) => {
108
+ setSearch(e.target.value);
109
+ setPage(1);
110
+ }}
111
+ placeholder="Search…"
112
+ className="h-9 w-full rounded-md border border-border bg-background pr-3 pl-8 text-foreground text-sm placeholder:text-muted-foreground/60 focus:border-foreground/20 focus:outline-none focus:ring-1 focus:ring-foreground/10"
113
+ />
114
+ </div>
115
+ </form>
116
+
117
+ {categories.length > 0 && (
118
+ <select
119
+ value={category}
120
+ onChange={(e) => {
121
+ setCategory(e.target.value);
122
+ setPage(1);
123
+ }}
124
+ className="h-9 rounded-md border border-border bg-background px-2.5 text-foreground text-sm focus:border-foreground/20 focus:outline-none focus:ring-1 focus:ring-foreground/10"
125
+ >
126
+ <option value="">All categories</option>
127
+ {categories.map((c) => (
128
+ <option key={c.id} value={c.id}>
129
+ {c.name}
130
+ </option>
131
+ ))}
132
+ </select>
133
+ )}
134
+
135
+ <select
136
+ value={`${sort}:${order}`}
137
+ onChange={(e) => {
138
+ const [s, o] = e.target.value.split(":");
139
+ setSort(s as "name" | "price" | "createdAt");
140
+ setOrder(o as "asc" | "desc");
141
+ setPage(1);
142
+ }}
143
+ className="h-9 rounded-md border border-border bg-background px-2.5 text-foreground text-sm focus:border-foreground/20 focus:outline-none focus:ring-1 focus:ring-foreground/10"
144
+ >
145
+ <option value="createdAt:desc">Newest</option>
146
+ <option value="createdAt:asc">Oldest</option>
147
+ <option value="price:asc">Price: Low → High</option>
148
+ <option value="price:desc">Price: High → Low</option>
149
+ <option value="name:asc">A → Z</option>
150
+ <option value="name:desc">Z → A</option>
151
+ </select>
152
+
153
+ <button
154
+ type="button"
155
+ onClick={() => setShowFilters((v) => !v)}
156
+ className={`flex h-9 items-center gap-1.5 rounded-md border px-2.5 text-sm transition-colors ${
157
+ showFilters || hasActiveFilters
158
+ ? "border-foreground/30 bg-foreground/5 text-foreground"
159
+ : "border-border text-muted-foreground hover:text-foreground"
160
+ }`}
161
+ >
162
+ <svg
163
+ xmlns="http://www.w3.org/2000/svg"
164
+ width="14"
165
+ height="14"
166
+ viewBox="0 0 24 24"
167
+ fill="none"
168
+ stroke="currentColor"
169
+ strokeWidth="2"
170
+ strokeLinecap="round"
171
+ strokeLinejoin="round"
172
+ aria-hidden="true"
173
+ >
174
+ <polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
175
+ </svg>
176
+ Filters
177
+ {hasActiveFilters && (
178
+ <span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-foreground px-1 text-2xs text-background">
179
+ {
180
+ [search, category, minPrice, maxPrice, inStock, tag].filter(
181
+ Boolean,
182
+ ).length
183
+ }
184
+ </span>
185
+ )}
186
+ </button>
187
+
188
+ {total > 0 && (
189
+ <span className="text-muted-foreground text-xs tabular-nums">
190
+ {total} {total === 1 ? "product" : "products"}
191
+ </span>
192
+ )}
193
+ </div>
194
+ );
195
+
196
+ const filterPanel = showFilters ? (
197
+ <div className="mb-6 rounded-lg border border-border bg-muted/30 p-4">
198
+ <div className="flex flex-wrap items-end gap-4">
199
+ <div className="flex items-end gap-2">
200
+ <div>
201
+ <label
202
+ htmlFor="minPrice"
203
+ className="mb-1 block text-muted-foreground text-xs"
204
+ >
205
+ Min price
206
+ </label>
207
+ <div className="relative">
208
+ <span className="absolute top-1/2 left-2.5 -translate-y-1/2 text-muted-foreground text-xs">
209
+ $
210
+ </span>
211
+ <input
212
+ id="minPrice"
213
+ type="number"
214
+ min="0"
215
+ value={minPrice}
216
+ onChange={(e) => {
217
+ setMinPrice(e.target.value);
218
+ setPage(1);
219
+ }}
220
+ placeholder="0"
221
+ className="h-8 w-20 rounded-md border border-border bg-background pr-2 pl-6 text-foreground text-sm tabular-nums focus:border-foreground/20 focus:outline-none focus:ring-1 focus:ring-foreground/10"
222
+ />
223
+ </div>
224
+ </div>
225
+ <span className="pb-1.5 text-muted-foreground text-xs">–</span>
226
+ <div>
227
+ <label
228
+ htmlFor="maxPrice"
229
+ className="mb-1 block text-muted-foreground text-xs"
230
+ >
231
+ Max price
232
+ </label>
233
+ <div className="relative">
234
+ <span className="absolute top-1/2 left-2.5 -translate-y-1/2 text-muted-foreground text-xs">
235
+ $
236
+ </span>
237
+ <input
238
+ id="maxPrice"
239
+ type="number"
240
+ min="0"
241
+ value={maxPrice}
242
+ onChange={(e) => {
243
+ setMaxPrice(e.target.value);
244
+ setPage(1);
245
+ }}
246
+ placeholder="Any"
247
+ className="h-8 w-20 rounded-md border border-border bg-background pr-2 pl-6 text-foreground text-sm tabular-nums focus:border-foreground/20 focus:outline-none focus:ring-1 focus:ring-foreground/10"
248
+ />
249
+ </div>
250
+ </div>
251
+ </div>
252
+
253
+ <label
254
+ htmlFor="inStockToggle"
255
+ className="flex h-8 cursor-pointer items-center gap-2 rounded-md border border-border bg-background px-2.5"
256
+ >
257
+ <input
258
+ id="inStockToggle"
259
+ type="checkbox"
260
+ checked={inStock}
261
+ onChange={(e) => {
262
+ setInStock(e.target.checked);
263
+ setPage(1);
264
+ }}
265
+ className="accent-foreground"
266
+ />
267
+ <span className="text-foreground text-sm">In stock only</span>
268
+ </label>
269
+
270
+ <div>
271
+ <label
272
+ htmlFor="tagFilter"
273
+ className="mb-1 block text-muted-foreground text-xs"
274
+ >
275
+ Tag
276
+ </label>
277
+ <input
278
+ id="tagFilter"
279
+ type="text"
280
+ value={tag}
281
+ onChange={(e) => {
282
+ setTag(e.target.value);
283
+ setPage(1);
284
+ }}
285
+ placeholder="e.g. sale"
286
+ className="h-8 w-28 rounded-md border border-border bg-background px-2.5 text-foreground text-sm placeholder:text-muted-foreground/50 focus:border-foreground/20 focus:outline-none focus:ring-1 focus:ring-foreground/10"
287
+ />
288
+ </div>
289
+
290
+ {hasActiveFilters && (
291
+ <button
292
+ type="button"
293
+ onClick={clearAllFilters}
294
+ className="h-8 rounded-md border border-border px-2.5 text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground"
295
+ >
296
+ Clear all
297
+ </button>
298
+ )}
299
+ </div>
300
+ </div>
301
+ ) : null;
302
+
303
+ const filterChips =
304
+ hasActiveFilters && !showFilters ? (
305
+ <div className="mb-4 flex flex-wrap items-center gap-1.5">
306
+ {category && (
307
+ <FilterChip
308
+ label={`Category: ${categories.find((c) => c.id === category)?.name ?? category}`}
309
+ onRemove={() => {
310
+ setCategory("");
311
+ setPage(1);
312
+ }}
313
+ />
314
+ )}
315
+ {(minPrice || maxPrice) && (
316
+ <FilterChip
317
+ label={`Price: ${minPrice ? `$${minPrice}` : "$0"} – ${maxPrice ? `$${maxPrice}` : "any"}`}
318
+ onRemove={() => {
319
+ setMinPrice("");
320
+ setMaxPrice("");
321
+ setPage(1);
322
+ }}
323
+ />
324
+ )}
325
+ {inStock && (
326
+ <FilterChip
327
+ label="In stock"
328
+ onRemove={() => {
329
+ setInStock(false);
330
+ setPage(1);
331
+ }}
332
+ />
333
+ )}
334
+ {tag && (
335
+ <FilterChip
336
+ label={`Tag: ${tag}`}
337
+ onRemove={() => {
338
+ setTag("");
339
+ setPage(1);
340
+ }}
341
+ />
342
+ )}
343
+ <button
344
+ type="button"
345
+ onClick={clearAllFilters}
346
+ className="text-muted-foreground text-xs transition-colors hover:text-foreground"
347
+ >
348
+ Clear all
349
+ </button>
350
+ </div>
351
+ ) : null;
352
+
353
+ const gridContent = isLoading ? (
354
+ <div className="grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-3 lg:grid-cols-4">
355
+ {Array.from({ length: pageSize }).map((_, i) => (
356
+ <div key={i}>
357
+ <div className="aspect-[3/4] animate-pulse rounded-lg bg-muted" />
358
+ <div className="mt-3 space-y-1.5">
359
+ <div className="h-3.5 w-3/4 animate-pulse rounded bg-muted-foreground/10" />
360
+ <div className="h-3.5 w-1/3 animate-pulse rounded bg-muted-foreground/10" />
361
+ </div>
362
+ </div>
363
+ ))}
364
+ </div>
365
+ ) : products.length === 0 ? (
366
+ <div className="flex flex-col items-center justify-center py-20 text-center">
367
+ <p className="font-medium text-foreground text-sm">No products found</p>
368
+ <p className="mt-1 text-muted-foreground text-sm">
369
+ Try adjusting your search or filters
370
+ </p>
371
+ {hasActiveFilters && (
372
+ <button
373
+ type="button"
374
+ onClick={clearAllFilters}
375
+ className="mt-4 rounded-full border border-border px-4 py-1.5 text-foreground text-xs transition-colors hover:bg-muted"
376
+ >
377
+ Clear filters
378
+ </button>
379
+ )}
380
+ </div>
381
+ ) : (
382
+ <div className="grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-3 lg:grid-cols-4">
383
+ {products.map((product) => (
384
+ <ProductCard key={product.id} product={product} />
385
+ ))}
386
+ </div>
387
+ );
388
+
389
+ const pagination =
390
+ totalPages > 1 ? (
391
+ <div className="mt-12 flex items-center justify-center gap-2">
392
+ <button
393
+ type="button"
394
+ onClick={() => setPage((p) => Math.max(1, p - 1))}
395
+ disabled={page === 1}
396
+ className="h-8 rounded-md border border-border px-3 text-foreground text-xs transition-colors hover:bg-muted disabled:opacity-30"
397
+ >
398
+ Previous
399
+ </button>
400
+ <span className="min-w-15 text-center text-muted-foreground text-xs tabular-nums">
401
+ {page} / {totalPages}
402
+ </span>
403
+ <button
404
+ type="button"
405
+ onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
406
+ disabled={page === totalPages}
407
+ className="h-8 rounded-md border border-border px-3 text-foreground text-xs transition-colors hover:bg-muted disabled:opacity-30"
408
+ >
409
+ Next
410
+ </button>
411
+ </div>
412
+ ) : null;
413
+
414
+ return (
415
+ <ProductListingTemplate
416
+ filtersBar={filtersBar}
417
+ filterPanel={filterPanel}
418
+ filterChips={filterChips}
419
+ gridContent={gridContent}
420
+ pagination={pagination}
421
+ />
422
+ );
423
+ }
@@ -0,0 +1,21 @@
1
+ <section id="reviews" className="border-border/50 border-t py-10">
2
+ {/* Header */}
3
+ <div className="mb-6 flex flex-wrap items-center justify-between gap-4">
4
+ <div>
5
+ <h2 className="font-display font-semibold text-foreground text-lg tracking-tight sm:text-xl">
6
+ Customer Reviews
7
+ </h2>
8
+ {props.summaryDisplay}
9
+ </div>
10
+ {props.toggleFormButton}
11
+ </div>
12
+
13
+ {/* Review form */}
14
+ {props.formContent}
15
+
16
+ {/* Empty state */}
17
+ {props.emptyState}
18
+
19
+ {/* Distribution + reviews list */}
20
+ {props.reviewsContent}
21
+ </section>