@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,771 @@
1
+ "use client";
2
+
3
+ import { useModuleClient } from "@86d-app/core/client";
4
+ import { useRef, useState } from "react";
5
+ import CollectionsAdminTemplate from "./collections-admin.mdx";
6
+
7
+ interface Collection {
8
+ id: string;
9
+ name: string;
10
+ slug: string;
11
+ description?: string | null;
12
+ image?: string | null;
13
+ isFeatured: boolean;
14
+ isVisible: boolean;
15
+ position: number;
16
+ }
17
+
18
+ interface Product {
19
+ id: string;
20
+ name: string;
21
+ slug: string;
22
+ price: number;
23
+ status: string;
24
+ images: string[];
25
+ }
26
+
27
+ interface ListResult {
28
+ collections: Collection[];
29
+ }
30
+
31
+ interface ProductListResult {
32
+ products: Product[];
33
+ }
34
+
35
+ // ─── Module Client ───────────────────────────────────────────────────────────
36
+
37
+ function useCollectionsAdminApi() {
38
+ const client = useModuleClient();
39
+ return {
40
+ listCollections: client.module("products").admin["/admin/collections/list"],
41
+ createCollection:
42
+ client.module("products").admin["/admin/collections/create"],
43
+ updateCollection:
44
+ client.module("products").admin["/admin/collections/:id/update"],
45
+ deleteCollection:
46
+ client.module("products").admin["/admin/collections/:id/delete"],
47
+ addProduct:
48
+ client.module("products").admin["/admin/collections/:id/products"],
49
+ removeProduct:
50
+ client.module("products").admin[
51
+ "/admin/collections/:id/products/:productId/remove"
52
+ ],
53
+ listProducts: client.module("products").admin["/admin/products/list"],
54
+ };
55
+ }
56
+
57
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
58
+
59
+ function extractError(error: Error | null, fallback: string): string {
60
+ if (!error) return fallback;
61
+ // biome-ignore lint/suspicious/noExplicitAny: accessing HTTP error body property
62
+ const body = (error as any)?.body;
63
+ if (typeof body?.error === "string") return body.error;
64
+ if (typeof body?.error?.message === "string") return body.error.message;
65
+ return fallback;
66
+ }
67
+
68
+ function slugify(str: string): string {
69
+ return str
70
+ .toLowerCase()
71
+ .replace(/[^\w\s-]/g, "")
72
+ .replace(/[\s_]+/g, "-")
73
+ .replace(/^-+|-+$/g, "");
74
+ }
75
+
76
+ function formatPrice(cents: number): string {
77
+ return new Intl.NumberFormat("en-US", {
78
+ style: "currency",
79
+ currency: "USD",
80
+ }).format(cents / 100);
81
+ }
82
+
83
+ export function CollectionsAdmin() {
84
+ const api = useCollectionsAdminApi();
85
+
86
+ const [showForm, setShowForm] = useState(false);
87
+ const [editId, setEditId] = useState<string | null>(null);
88
+ const [form, setForm] = useState({
89
+ name: "",
90
+ slug: "",
91
+ description: "",
92
+ image: "",
93
+ isFeatured: false,
94
+ isVisible: true,
95
+ });
96
+ const [error, setError] = useState<string | null>(null);
97
+ const [slugEdited, setSlugEdited] = useState(false);
98
+ const [manageId, setManageId] = useState<string | null>(null);
99
+ const [productSearch, setProductSearch] = useState("");
100
+
101
+ const { data: collectionsData, isLoading: loading } =
102
+ api.listCollections.useQuery({ limit: "100" }) as {
103
+ data: ListResult | undefined;
104
+ isLoading: boolean;
105
+ };
106
+
107
+ const collections = collectionsData?.collections ?? [];
108
+
109
+ // Products for adding to collections
110
+ const { data: productsData } = api.listProducts.useQuery({
111
+ limit: "200",
112
+ status: "active",
113
+ }) as { data: ProductListResult | undefined };
114
+
115
+ const allProducts = productsData?.products ?? [];
116
+
117
+ const createMutation = api.createCollection.useMutation({
118
+ onSuccess: () => {
119
+ setShowForm(false);
120
+ setEditId(null);
121
+ void api.listCollections.invalidate();
122
+ },
123
+ onError: (err: Error) => {
124
+ setError(extractError(err, "Failed to save collection"));
125
+ },
126
+ });
127
+
128
+ const updateMutation = api.updateCollection.useMutation({
129
+ onSuccess: () => {
130
+ setShowForm(false);
131
+ setEditId(null);
132
+ void api.listCollections.invalidate();
133
+ },
134
+ onError: (err: Error) => {
135
+ setError(extractError(err, "Failed to save collection"));
136
+ },
137
+ });
138
+
139
+ const deleteMutation = api.deleteCollection.useMutation({
140
+ onSettled: () => {
141
+ void api.listCollections.invalidate();
142
+ },
143
+ });
144
+
145
+ const addProductMutation = api.addProduct.useMutation({
146
+ onSuccess: () => {
147
+ setProductSearch("");
148
+ },
149
+ });
150
+
151
+ const removeProductMutation = api.removeProduct.useMutation();
152
+
153
+ const saving = createMutation.isPending || updateMutation.isPending;
154
+
155
+ const openCreate = () => {
156
+ setForm({
157
+ name: "",
158
+ slug: "",
159
+ description: "",
160
+ image: "",
161
+ isFeatured: false,
162
+ isVisible: true,
163
+ });
164
+ setSlugEdited(false);
165
+ setEditId(null);
166
+ setError(null);
167
+ setShowForm(true);
168
+ };
169
+
170
+ const openEdit = (col: Collection) => {
171
+ setForm({
172
+ name: col.name,
173
+ slug: col.slug,
174
+ description: col.description ?? "",
175
+ image: col.image ?? "",
176
+ isFeatured: col.isFeatured,
177
+ isVisible: col.isVisible,
178
+ });
179
+ setSlugEdited(true);
180
+ setEditId(col.id);
181
+ setError(null);
182
+ setShowForm(true);
183
+ };
184
+
185
+ const handleNameChange = (name: string) => {
186
+ setForm((prev) => ({
187
+ ...prev,
188
+ name,
189
+ slug: slugEdited ? prev.slug : slugify(name),
190
+ }));
191
+ };
192
+
193
+ const handleSave = (e: React.FormEvent) => {
194
+ e.preventDefault();
195
+ setError(null);
196
+ if (!form.name.trim()) {
197
+ setError("Name is required");
198
+ return;
199
+ }
200
+ if (!form.slug.trim()) {
201
+ setError("Slug is required");
202
+ return;
203
+ }
204
+
205
+ const body = {
206
+ name: form.name.trim(),
207
+ slug: form.slug.trim(),
208
+ description: form.description.trim() || undefined,
209
+ image: form.image.trim() || undefined,
210
+ isFeatured: form.isFeatured,
211
+ isVisible: form.isVisible,
212
+ };
213
+
214
+ if (editId) {
215
+ updateMutation.mutate({ params: { id: editId }, ...body });
216
+ } else {
217
+ createMutation.mutate(body);
218
+ }
219
+ };
220
+
221
+ const handleDelete = (col: Collection) => {
222
+ if (!confirm(`Delete "${col.name}"? This cannot be undone.`)) return;
223
+ deleteMutation.mutate({ params: { id: col.id } });
224
+ };
225
+
226
+ const handleAddProduct = (productId: string) => {
227
+ if (!manageId) return;
228
+ addProductMutation.mutate({
229
+ params: { id: manageId },
230
+ productId,
231
+ });
232
+ };
233
+
234
+ const handleRemoveProduct = (productId: string) => {
235
+ if (!manageId) return;
236
+ removeProductMutation.mutate({
237
+ params: { id: manageId, productId },
238
+ });
239
+ };
240
+
241
+ const filteredProducts = productSearch
242
+ ? allProducts.filter((p) =>
243
+ p.name.toLowerCase().includes(productSearch.toLowerCase()),
244
+ )
245
+ : allProducts;
246
+
247
+ const content = (
248
+ <div>
249
+ {/* Header */}
250
+ <div className="mb-6 flex items-center justify-between">
251
+ <div>
252
+ <h1 className="font-bold text-2xl text-foreground">Collections</h1>
253
+ <p className="mt-1 text-muted-foreground text-sm">
254
+ Curate product sets for marketing and merchandising
255
+ </p>
256
+ </div>
257
+ <button
258
+ type="button"
259
+ onClick={openCreate}
260
+ className="flex items-center gap-2 rounded-md bg-foreground px-4 py-2 font-semibold text-background text-sm transition-opacity hover:opacity-90"
261
+ >
262
+ <svg
263
+ xmlns="http://www.w3.org/2000/svg"
264
+ width="16"
265
+ height="16"
266
+ viewBox="0 0 24 24"
267
+ fill="none"
268
+ stroke="currentColor"
269
+ strokeWidth="2"
270
+ strokeLinecap="round"
271
+ strokeLinejoin="round"
272
+ aria-hidden="true"
273
+ >
274
+ <path d="M5 12h14" />
275
+ <path d="M12 5v14" />
276
+ </svg>
277
+ Add collection
278
+ </button>
279
+ </div>
280
+
281
+ {/* Inline form */}
282
+ {showForm && (
283
+ <div className="mb-6 rounded-lg border border-border bg-card p-5">
284
+ <h2 className="mb-4 font-semibold text-foreground text-sm">
285
+ {editId ? "Edit collection" : "New collection"}
286
+ </h2>
287
+ <form onSubmit={handleSave} className="space-y-4">
288
+ {error && (
289
+ <p className="rounded-md bg-destructive/10 px-3 py-2 text-destructive text-sm">
290
+ {error}
291
+ </p>
292
+ )}
293
+ <div className="grid gap-4 sm:grid-cols-2">
294
+ <div>
295
+ <label
296
+ htmlFor="col-name"
297
+ className="mb-1.5 block font-medium text-foreground text-sm"
298
+ >
299
+ Name <span className="text-destructive">*</span>
300
+ </label>
301
+ <input
302
+ id="col-name"
303
+ type="text"
304
+ value={form.name}
305
+ onChange={(e) => handleNameChange(e.target.value)}
306
+ placeholder="Collection name"
307
+ className="w-full rounded-md border border-border bg-background px-3 py-2 text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
308
+ required
309
+ />
310
+ </div>
311
+ <div>
312
+ <label
313
+ htmlFor="col-slug"
314
+ className="mb-1.5 block font-medium text-foreground text-sm"
315
+ >
316
+ Slug <span className="text-destructive">*</span>
317
+ </label>
318
+ <input
319
+ id="col-slug"
320
+ type="text"
321
+ value={form.slug}
322
+ onChange={(e) => {
323
+ setSlugEdited(true);
324
+ setForm((p) => ({ ...p, slug: e.target.value }));
325
+ }}
326
+ placeholder="collection-slug"
327
+ className="w-full rounded-md border border-border bg-background px-3 py-2 font-mono text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
328
+ required
329
+ />
330
+ </div>
331
+ </div>
332
+ <div>
333
+ <label
334
+ htmlFor="col-desc"
335
+ className="mb-1.5 block font-medium text-foreground text-sm"
336
+ >
337
+ Description
338
+ </label>
339
+ <textarea
340
+ id="col-desc"
341
+ value={form.description}
342
+ onChange={(e) =>
343
+ setForm((p) => ({ ...p, description: e.target.value }))
344
+ }
345
+ placeholder="Optional description"
346
+ rows={2}
347
+ className="w-full rounded-md border border-border bg-background px-3 py-2 text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
348
+ />
349
+ </div>
350
+ <CollectionImageField
351
+ image={form.image}
352
+ onChange={(url) => setForm((p) => ({ ...p, image: url }))}
353
+ />
354
+ <div className="flex gap-6">
355
+ <label className="flex items-center gap-2.5">
356
+ <input
357
+ type="checkbox"
358
+ checked={form.isVisible}
359
+ onChange={(e) =>
360
+ setForm((p) => ({ ...p, isVisible: e.target.checked }))
361
+ }
362
+ className="h-4 w-4 rounded border-border"
363
+ />
364
+ <span className="text-foreground text-sm">
365
+ Visible in store
366
+ </span>
367
+ </label>
368
+ <label className="flex items-center gap-2.5">
369
+ <input
370
+ type="checkbox"
371
+ checked={form.isFeatured}
372
+ onChange={(e) =>
373
+ setForm((p) => ({ ...p, isFeatured: e.target.checked }))
374
+ }
375
+ className="h-4 w-4 rounded border-border"
376
+ />
377
+ <span className="text-foreground text-sm">Featured</span>
378
+ </label>
379
+ </div>
380
+ <div className="flex gap-2 pt-1">
381
+ <button
382
+ type="submit"
383
+ disabled={saving}
384
+ className="rounded-md bg-foreground px-4 py-2 font-semibold text-background text-sm transition-opacity hover:opacity-90 disabled:opacity-50"
385
+ >
386
+ {saving
387
+ ? "Saving..."
388
+ : editId
389
+ ? "Save changes"
390
+ : "Create collection"}
391
+ </button>
392
+ <button
393
+ type="button"
394
+ onClick={() => setShowForm(false)}
395
+ className="rounded-md border border-border px-4 py-2 font-medium text-foreground text-sm transition-colors hover:bg-muted"
396
+ >
397
+ Cancel
398
+ </button>
399
+ </div>
400
+ </form>
401
+ </div>
402
+ )}
403
+
404
+ {/* Manage products panel */}
405
+ {manageId && (
406
+ <div className="mb-6 rounded-lg border border-border bg-card p-5">
407
+ <div className="mb-4 flex items-center justify-between">
408
+ <h2 className="font-semibold text-foreground text-sm">
409
+ Manage products —{" "}
410
+ {collections.find((c) => c.id === manageId)?.name}
411
+ </h2>
412
+ <button
413
+ type="button"
414
+ onClick={() => setManageId(null)}
415
+ className="rounded-md px-2.5 py-1.5 font-medium text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground"
416
+ >
417
+ Close
418
+ </button>
419
+ </div>
420
+ <div className="relative mb-3">
421
+ <input
422
+ type="search"
423
+ value={productSearch}
424
+ onChange={(e) => setProductSearch(e.target.value)}
425
+ placeholder="Search products to add..."
426
+ className="w-full rounded-md border border-border bg-background px-3 py-2 text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
427
+ />
428
+ </div>
429
+ {filteredProducts.length === 0 ? (
430
+ <p className="py-4 text-center text-muted-foreground text-sm">
431
+ No products found
432
+ </p>
433
+ ) : (
434
+ <div className="max-h-64 space-y-1 overflow-y-auto">
435
+ {filteredProducts.slice(0, 20).map((product) => (
436
+ <div
437
+ key={product.id}
438
+ className="flex items-center justify-between rounded-md px-3 py-2 transition-colors hover:bg-muted/50"
439
+ >
440
+ <div className="flex items-center gap-3">
441
+ {product.images[0] ? (
442
+ <img
443
+ src={product.images[0]}
444
+ alt={product.name}
445
+ className="h-8 w-8 rounded object-cover"
446
+ />
447
+ ) : (
448
+ <div className="flex h-8 w-8 items-center justify-center rounded bg-muted text-muted-foreground">
449
+ <svg
450
+ xmlns="http://www.w3.org/2000/svg"
451
+ width="14"
452
+ height="14"
453
+ viewBox="0 0 24 24"
454
+ fill="none"
455
+ stroke="currentColor"
456
+ strokeWidth="2"
457
+ strokeLinecap="round"
458
+ strokeLinejoin="round"
459
+ aria-hidden="true"
460
+ >
461
+ <rect width="18" height="18" x="3" y="3" rx="2" />
462
+ <circle cx="9" cy="9" r="2" />
463
+ <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
464
+ </svg>
465
+ </div>
466
+ )}
467
+ <div>
468
+ <p className="font-medium text-foreground text-sm">
469
+ {product.name}
470
+ </p>
471
+ <p className="text-muted-foreground text-xs">
472
+ {formatPrice(product.price)}
473
+ </p>
474
+ </div>
475
+ </div>
476
+ <div className="flex gap-1">
477
+ <button
478
+ type="button"
479
+ onClick={() => handleAddProduct(product.id)}
480
+ disabled={addProductMutation.isPending}
481
+ className="rounded-md bg-foreground px-2.5 py-1 font-medium text-background text-xs transition-opacity hover:opacity-90 disabled:opacity-50"
482
+ >
483
+ Add
484
+ </button>
485
+ <button
486
+ type="button"
487
+ onClick={() => handleRemoveProduct(product.id)}
488
+ disabled={removeProductMutation.isPending}
489
+ className="rounded-md border border-border px-2.5 py-1 font-medium text-destructive text-xs transition-colors hover:bg-destructive/10"
490
+ >
491
+ Remove
492
+ </button>
493
+ </div>
494
+ </div>
495
+ ))}
496
+ </div>
497
+ )}
498
+ </div>
499
+ )}
500
+
501
+ {/* Table */}
502
+ <div className="overflow-hidden rounded-lg border border-border bg-card">
503
+ <table className="w-full">
504
+ <thead>
505
+ <tr className="border-border border-b bg-muted/50">
506
+ <th className="px-4 py-3 text-left font-semibold text-muted-foreground text-xs uppercase tracking-wide">
507
+ Name
508
+ </th>
509
+ <th className="hidden px-4 py-3 text-left font-semibold text-muted-foreground text-xs uppercase tracking-wide sm:table-cell">
510
+ Slug
511
+ </th>
512
+ <th className="hidden px-4 py-3 text-center font-semibold text-muted-foreground text-xs uppercase tracking-wide md:table-cell">
513
+ Featured
514
+ </th>
515
+ <th className="hidden px-4 py-3 text-center font-semibold text-muted-foreground text-xs uppercase tracking-wide lg:table-cell">
516
+ Visible
517
+ </th>
518
+ <th className="px-4 py-3 text-right font-semibold text-muted-foreground text-xs uppercase tracking-wide">
519
+ Actions
520
+ </th>
521
+ </tr>
522
+ </thead>
523
+ <tbody className="divide-y divide-border">
524
+ {loading ? (
525
+ Array.from({ length: 4 }).map((_, i) => (
526
+ <tr key={`skeleton-${i}`}>
527
+ {Array.from({ length: 5 }).map((_, j) => (
528
+ <td key={`skeleton-cell-${j}`} className="px-4 py-3">
529
+ <div className="h-4 w-24 animate-pulse rounded bg-muted" />
530
+ </td>
531
+ ))}
532
+ </tr>
533
+ ))
534
+ ) : collections.length === 0 ? (
535
+ <tr>
536
+ <td colSpan={5} className="px-4 py-12 text-center">
537
+ <p className="font-medium text-foreground text-sm">
538
+ No collections yet
539
+ </p>
540
+ <p className="mt-1 text-muted-foreground text-xs">
541
+ Create your first collection to curate product sets
542
+ </p>
543
+ <button
544
+ type="button"
545
+ onClick={openCreate}
546
+ className="mt-3 font-medium text-foreground text-sm underline underline-offset-2"
547
+ >
548
+ Add collection
549
+ </button>
550
+ </td>
551
+ </tr>
552
+ ) : (
553
+ collections.map((col) => (
554
+ <tr
555
+ key={col.id}
556
+ className="transition-colors hover:bg-muted/30"
557
+ >
558
+ <td className="px-4 py-3">
559
+ <div className="flex items-center gap-3">
560
+ {col.image ? (
561
+ <img
562
+ src={col.image}
563
+ alt={col.name}
564
+ className="h-8 w-8 rounded object-cover"
565
+ />
566
+ ) : (
567
+ <div className="flex h-8 w-8 items-center justify-center rounded bg-muted text-muted-foreground">
568
+ <svg
569
+ xmlns="http://www.w3.org/2000/svg"
570
+ width="14"
571
+ height="14"
572
+ viewBox="0 0 24 24"
573
+ fill="none"
574
+ stroke="currentColor"
575
+ strokeWidth="2"
576
+ strokeLinecap="round"
577
+ strokeLinejoin="round"
578
+ aria-hidden="true"
579
+ >
580
+ <rect width="18" height="18" x="3" y="3" rx="2" />
581
+ <path d="M7.5 7.5h.01" />
582
+ <path d="M16.5 7.5h.01" />
583
+ <path d="M7.5 16.5h.01" />
584
+ <path d="M16.5 16.5h.01" />
585
+ </svg>
586
+ </div>
587
+ )}
588
+ <span className="font-medium text-foreground text-sm">
589
+ {col.name}
590
+ </span>
591
+ </div>
592
+ </td>
593
+ <td className="hidden px-4 py-3 font-mono text-muted-foreground text-xs sm:table-cell">
594
+ {col.slug}
595
+ </td>
596
+ <td className="hidden px-4 py-3 text-center md:table-cell">
597
+ <span
598
+ className={`inline-flex h-2 w-2 rounded-full ${col.isFeatured ? "bg-amber-500" : "bg-muted-foreground/30"}`}
599
+ />
600
+ </td>
601
+ <td className="hidden px-4 py-3 text-center lg:table-cell">
602
+ <span
603
+ className={`inline-flex h-2 w-2 rounded-full ${col.isVisible ? "bg-green-500" : "bg-muted-foreground/30"}`}
604
+ />
605
+ </td>
606
+ <td className="px-4 py-3 text-right">
607
+ <div className="flex items-center justify-end gap-2">
608
+ <button
609
+ type="button"
610
+ onClick={() =>
611
+ setManageId(manageId === col.id ? null : col.id)
612
+ }
613
+ className="rounded-md px-2.5 py-1.5 font-medium text-foreground text-xs transition-colors hover:bg-muted"
614
+ >
615
+ Products
616
+ </button>
617
+ <button
618
+ type="button"
619
+ onClick={() => openEdit(col)}
620
+ className="rounded-md px-2.5 py-1.5 font-medium text-foreground text-xs transition-colors hover:bg-muted"
621
+ >
622
+ Edit
623
+ </button>
624
+ <button
625
+ type="button"
626
+ onClick={() => handleDelete(col)}
627
+ className="rounded-md px-2.5 py-1.5 font-medium text-destructive text-xs transition-colors hover:bg-destructive/10"
628
+ >
629
+ Delete
630
+ </button>
631
+ </div>
632
+ </td>
633
+ </tr>
634
+ ))
635
+ )}
636
+ </tbody>
637
+ </table>
638
+ </div>
639
+ </div>
640
+ );
641
+
642
+ return <CollectionsAdminTemplate content={content} />;
643
+ }
644
+
645
+ // ─── Inline image upload for collections ─────────────────────────────────────
646
+
647
+ function CollectionImageField({
648
+ image,
649
+ onChange,
650
+ }: {
651
+ image: string;
652
+ onChange: (url: string) => void;
653
+ }) {
654
+ const [uploading, setUploading] = useState(false);
655
+ const [uploadError, setUploadError] = useState<string | null>(null);
656
+ const inputRef = useRef<HTMLInputElement>(null);
657
+
658
+ const handleFile = async (file: File) => {
659
+ setUploadError(null);
660
+ setUploading(true);
661
+ try {
662
+ const formData = new FormData();
663
+ formData.append("file", file);
664
+ const res = await fetch("/api/upload", {
665
+ method: "POST",
666
+ body: formData,
667
+ });
668
+ if (!res.ok) {
669
+ const data = (await res.json()) as { error?: string };
670
+ throw new Error(data.error ?? "Upload failed");
671
+ }
672
+ const data = (await res.json()) as { url: string };
673
+ onChange(data.url);
674
+ } catch (err) {
675
+ setUploadError(err instanceof Error ? err.message : "Upload failed");
676
+ } finally {
677
+ setUploading(false);
678
+ if (inputRef.current) inputRef.current.value = "";
679
+ }
680
+ };
681
+
682
+ return (
683
+ <div>
684
+ <span className="mb-1.5 block font-medium text-foreground text-sm">
685
+ Cover image
686
+ </span>
687
+ {image ? (
688
+ <div className="flex items-start gap-3">
689
+ <div className="h-20 w-20 overflow-hidden rounded-md border border-border bg-muted">
690
+ <img
691
+ src={image}
692
+ alt="Collection"
693
+ className="h-full w-full object-cover"
694
+ />
695
+ </div>
696
+ <button
697
+ type="button"
698
+ onClick={() => onChange("")}
699
+ className="rounded-md px-2 py-1 text-destructive text-xs hover:bg-destructive/10"
700
+ >
701
+ Remove
702
+ </button>
703
+ </div>
704
+ ) : (
705
+ <button
706
+ type="button"
707
+ onClick={() => inputRef.current?.click()}
708
+ disabled={uploading}
709
+ className="flex h-20 w-20 flex-col items-center justify-center rounded-md border-2 border-border border-dashed text-muted-foreground transition-colors hover:border-muted-foreground hover:bg-muted/30 disabled:opacity-60"
710
+ >
711
+ {uploading ? (
712
+ <svg
713
+ className="h-5 w-5 animate-spin"
714
+ xmlns="http://www.w3.org/2000/svg"
715
+ fill="none"
716
+ viewBox="0 0 24 24"
717
+ aria-hidden="true"
718
+ >
719
+ <circle
720
+ className="opacity-25"
721
+ cx="12"
722
+ cy="12"
723
+ r="10"
724
+ stroke="currentColor"
725
+ strokeWidth="4"
726
+ />
727
+ <path
728
+ className="opacity-75"
729
+ fill="currentColor"
730
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
731
+ />
732
+ </svg>
733
+ ) : (
734
+ <>
735
+ <svg
736
+ xmlns="http://www.w3.org/2000/svg"
737
+ width="18"
738
+ height="18"
739
+ viewBox="0 0 24 24"
740
+ fill="none"
741
+ stroke="currentColor"
742
+ strokeWidth="1.5"
743
+ strokeLinecap="round"
744
+ strokeLinejoin="round"
745
+ aria-hidden="true"
746
+ >
747
+ <rect width="18" height="18" x="3" y="3" rx="2" />
748
+ <circle cx="9" cy="9" r="2" />
749
+ <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
750
+ </svg>
751
+ <span className="mt-0.5 text-2xs">Upload</span>
752
+ </>
753
+ )}
754
+ </button>
755
+ )}
756
+ <input
757
+ ref={inputRef}
758
+ type="file"
759
+ accept="image/jpeg,image/png,image/webp"
760
+ className="hidden"
761
+ onChange={(e) => {
762
+ const file = e.target.files?.[0];
763
+ if (file) void handleFile(file);
764
+ }}
765
+ />
766
+ {uploadError && (
767
+ <p className="mt-1 text-destructive text-xs">{uploadError}</p>
768
+ )}
769
+ </div>
770
+ );
771
+ }