@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,790 @@
1
+ "use client";
2
+
3
+ import { useModuleClient } from "@86d-app/core/client";
4
+ import { useState } from "react";
5
+ import ProductDetailTemplate from "./product-detail.mdx";
6
+
7
+ // ─── Types ────────────────────────────────────────────────────────────────────
8
+
9
+ interface ProductVariant {
10
+ id: string;
11
+ productId: string;
12
+ name: string;
13
+ sku: string;
14
+ price: number;
15
+ inventory: number;
16
+ options: Record<string, string>;
17
+ position: number;
18
+ }
19
+
20
+ interface Product {
21
+ id: string;
22
+ name: string;
23
+ slug: string;
24
+ description?: string | null;
25
+ price: number;
26
+ compareAtPrice?: number | null;
27
+ status: "draft" | "active" | "archived";
28
+ inventory: number;
29
+ isFeatured: boolean;
30
+ images: string[];
31
+ tags: string[];
32
+ categoryId?: string | null;
33
+ category?: { id: string; name: string; slug: string } | null;
34
+ variants: ProductVariant[];
35
+ createdAt: string;
36
+ updatedAt: string;
37
+ }
38
+
39
+ interface GetProductResult {
40
+ product?: Product;
41
+ }
42
+
43
+ interface VariantFormData {
44
+ name: string;
45
+ sku: string;
46
+ price: string;
47
+ inventory: string;
48
+ options: Array<{ key: string; value: string }>;
49
+ }
50
+
51
+ const emptyVariantForm: VariantFormData = {
52
+ name: "",
53
+ sku: "",
54
+ price: "",
55
+ inventory: "0",
56
+ options: [{ key: "", value: "" }],
57
+ };
58
+
59
+ // ─── Module Client ───────────────────────────────────────────────────────────
60
+
61
+ function useProductsAdminApi() {
62
+ const client = useModuleClient();
63
+ return {
64
+ getProduct: client.module("products").admin["/admin/products/:id"],
65
+ deleteProduct:
66
+ client.module("products").admin["/admin/products/:id/delete"],
67
+ createVariant:
68
+ client.module("products").admin["/admin/products/:productId/variants"],
69
+ updateVariant:
70
+ client.module("products").admin["/admin/variants/:id/update"],
71
+ deleteVariant:
72
+ client.module("products").admin["/admin/variants/:id/delete"],
73
+ };
74
+ }
75
+
76
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
77
+
78
+ function formatPrice(cents: number): string {
79
+ return new Intl.NumberFormat("en-US", {
80
+ style: "currency",
81
+ currency: "USD",
82
+ }).format(cents / 100);
83
+ }
84
+
85
+ function formatDate(iso: string): string {
86
+ return new Intl.DateTimeFormat("en-US", {
87
+ month: "short",
88
+ day: "numeric",
89
+ year: "numeric",
90
+ }).format(new Date(iso));
91
+ }
92
+
93
+ const statusStyles: Record<string, string> = {
94
+ draft: "bg-muted text-muted-foreground",
95
+ active:
96
+ "bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300",
97
+ archived:
98
+ "bg-yellow-50 text-yellow-700 dark:bg-yellow-950 dark:text-yellow-300",
99
+ };
100
+
101
+ // ─── VariantForm ─────────────────────────────────────────────────────────────
102
+
103
+ function VariantForm({
104
+ initial,
105
+ onSubmit,
106
+ onCancel,
107
+ submitting,
108
+ submitLabel,
109
+ }: {
110
+ initial: VariantFormData;
111
+ onSubmit: (data: VariantFormData) => void;
112
+ onCancel: () => void;
113
+ submitting: boolean;
114
+ submitLabel: string;
115
+ }) {
116
+ const [form, setForm] = useState<VariantFormData>(initial);
117
+
118
+ const updateField = (field: keyof VariantFormData, value: string) => {
119
+ setForm((prev) => ({ ...prev, [field]: value }));
120
+ };
121
+
122
+ const updateOption = (idx: number, field: "key" | "value", value: string) => {
123
+ setForm((prev) => {
124
+ const options = [...prev.options];
125
+ options[idx] = { ...options[idx], [field]: value };
126
+ return { ...prev, options };
127
+ });
128
+ };
129
+
130
+ const addOption = () => {
131
+ setForm((prev) => ({
132
+ ...prev,
133
+ options: [...prev.options, { key: "", value: "" }],
134
+ }));
135
+ };
136
+
137
+ const removeOption = (idx: number) => {
138
+ setForm((prev) => ({
139
+ ...prev,
140
+ options: prev.options.filter((_, i) => i !== idx),
141
+ }));
142
+ };
143
+
144
+ const handleSubmit = (e: React.FormEvent) => {
145
+ e.preventDefault();
146
+ onSubmit(form);
147
+ };
148
+
149
+ return (
150
+ <form onSubmit={handleSubmit} className="space-y-4">
151
+ <div className="grid gap-4 sm:grid-cols-2">
152
+ <label className="block">
153
+ <span className="mb-1 block font-medium text-foreground text-xs">
154
+ Name *
155
+ </span>
156
+ <input
157
+ type="text"
158
+ value={form.name}
159
+ onChange={(e) => updateField("name", e.target.value)}
160
+ required
161
+ className="w-full rounded-md border border-border bg-background px-3 py-2 text-foreground text-sm focus:border-foreground/30 focus:outline-none focus:ring-1 focus:ring-foreground/30"
162
+ placeholder="e.g., Blue / Medium"
163
+ />
164
+ </label>
165
+ <label className="block">
166
+ <span className="mb-1 block font-medium text-foreground text-xs">
167
+ SKU
168
+ </span>
169
+ <input
170
+ type="text"
171
+ value={form.sku}
172
+ onChange={(e) => updateField("sku", e.target.value)}
173
+ className="w-full rounded-md border border-border bg-background px-3 py-2 text-foreground text-sm focus:border-foreground/30 focus:outline-none focus:ring-1 focus:ring-foreground/30"
174
+ placeholder="e.g., PROD-BLU-M"
175
+ />
176
+ </label>
177
+ <label className="block">
178
+ <span className="mb-1 block font-medium text-foreground text-xs">
179
+ Price (cents) *
180
+ </span>
181
+ <input
182
+ type="number"
183
+ value={form.price}
184
+ onChange={(e) => updateField("price", e.target.value)}
185
+ required
186
+ min="1"
187
+ className="w-full rounded-md border border-border bg-background px-3 py-2 text-foreground text-sm focus:border-foreground/30 focus:outline-none focus:ring-1 focus:ring-foreground/30"
188
+ placeholder="e.g., 2999"
189
+ />
190
+ </label>
191
+ <label className="block">
192
+ <span className="mb-1 block font-medium text-foreground text-xs">
193
+ Inventory
194
+ </span>
195
+ <input
196
+ type="number"
197
+ value={form.inventory}
198
+ onChange={(e) => updateField("inventory", e.target.value)}
199
+ min="0"
200
+ className="w-full rounded-md border border-border bg-background px-3 py-2 text-foreground text-sm focus:border-foreground/30 focus:outline-none focus:ring-1 focus:ring-foreground/30"
201
+ />
202
+ </label>
203
+ </div>
204
+
205
+ {/* Options */}
206
+ <div>
207
+ <div className="mb-2 flex items-center justify-between">
208
+ <span className="font-medium text-foreground text-xs">Options</span>
209
+ <button
210
+ type="button"
211
+ onClick={addOption}
212
+ className="text-foreground text-xs underline underline-offset-2 hover:opacity-70"
213
+ >
214
+ + Add option
215
+ </button>
216
+ </div>
217
+ <div className="space-y-2">
218
+ {form.options.map((opt, idx) => (
219
+ <div key={idx} className="flex gap-2">
220
+ <input
221
+ type="text"
222
+ value={opt.key}
223
+ onChange={(e) => updateOption(idx, "key", e.target.value)}
224
+ placeholder="Key (e.g., Size)"
225
+ className="flex-1 rounded-md border border-border bg-background px-3 py-1.5 text-foreground text-sm focus:border-foreground/30 focus:outline-none focus:ring-1 focus:ring-foreground/30"
226
+ />
227
+ <input
228
+ type="text"
229
+ value={opt.value}
230
+ onChange={(e) => updateOption(idx, "value", e.target.value)}
231
+ placeholder="Value (e.g., Medium)"
232
+ className="flex-1 rounded-md border border-border bg-background px-3 py-1.5 text-foreground text-sm focus:border-foreground/30 focus:outline-none focus:ring-1 focus:ring-foreground/30"
233
+ />
234
+ {form.options.length > 1 && (
235
+ <button
236
+ type="button"
237
+ onClick={() => removeOption(idx)}
238
+ className="rounded-md px-2 text-muted-foreground hover:text-destructive"
239
+ >
240
+ <svg
241
+ xmlns="http://www.w3.org/2000/svg"
242
+ width="14"
243
+ height="14"
244
+ viewBox="0 0 24 24"
245
+ fill="none"
246
+ stroke="currentColor"
247
+ strokeWidth="2"
248
+ strokeLinecap="round"
249
+ strokeLinejoin="round"
250
+ aria-hidden="true"
251
+ >
252
+ <path d="M18 6 6 18" />
253
+ <path d="m6 6 12 12" />
254
+ </svg>
255
+ </button>
256
+ )}
257
+ </div>
258
+ ))}
259
+ </div>
260
+ </div>
261
+
262
+ <div className="flex justify-end gap-2 border-border/40 border-t pt-4">
263
+ <button
264
+ type="button"
265
+ onClick={onCancel}
266
+ className="rounded-md border border-border px-4 py-2 font-medium text-foreground text-sm hover:bg-muted"
267
+ >
268
+ Cancel
269
+ </button>
270
+ <button
271
+ type="submit"
272
+ disabled={submitting || !form.name || !form.price}
273
+ className="rounded-md bg-foreground px-4 py-2 font-medium text-background text-sm hover:opacity-90 disabled:opacity-50"
274
+ >
275
+ {submitting ? "Saving..." : submitLabel}
276
+ </button>
277
+ </div>
278
+ </form>
279
+ );
280
+ }
281
+
282
+ // ─── ProductDetail ────────────────────────────────────────────────────────────
283
+
284
+ interface ProductDetailProps {
285
+ productId?: string;
286
+ params?: Record<string, string>;
287
+ }
288
+
289
+ export function ProductDetail(props: ProductDetailProps) {
290
+ const productId = props.productId ?? props.params?.id;
291
+
292
+ const api = useProductsAdminApi();
293
+ const [selectedImage, setSelectedImage] = useState(0);
294
+ const [showVariantForm, setShowVariantForm] = useState(false);
295
+ const [editingVariant, setEditingVariant] = useState<ProductVariant | null>(
296
+ null,
297
+ );
298
+
299
+ const { data: productData, isLoading: loading } = api.getProduct.useQuery(
300
+ { params: { id: productId ?? "" } },
301
+ { enabled: !!productId },
302
+ ) as {
303
+ data: GetProductResult | undefined;
304
+ isLoading: boolean;
305
+ };
306
+
307
+ const deleteMutation = api.deleteProduct.useMutation({
308
+ onSuccess: () => {
309
+ window.location.href = "/admin/products";
310
+ },
311
+ });
312
+
313
+ const createVariantMutation = api.createVariant.useMutation({
314
+ onSuccess: () => {
315
+ setShowVariantForm(false);
316
+ void api.getProduct.invalidate();
317
+ },
318
+ });
319
+
320
+ const updateVariantMutation = api.updateVariant.useMutation({
321
+ onSuccess: () => {
322
+ setEditingVariant(null);
323
+ void api.getProduct.invalidate();
324
+ },
325
+ });
326
+
327
+ const deleteVariantMutation = api.deleteVariant.useMutation({
328
+ onSuccess: () => {
329
+ void api.getProduct.invalidate();
330
+ },
331
+ });
332
+
333
+ const product = productData?.product ?? null;
334
+ const deleting = deleteMutation.isPending;
335
+
336
+ const handleDelete = () => {
337
+ if (!window.confirm("Are you sure you want to delete this product?")) {
338
+ return;
339
+ }
340
+ deleteMutation.mutate({ params: { id: productId } });
341
+ };
342
+
343
+ const handleCreateVariant = (data: VariantFormData) => {
344
+ const options: Record<string, string> = {};
345
+ for (const opt of data.options) {
346
+ if (opt.key.trim() && opt.value.trim()) {
347
+ options[opt.key.trim()] = opt.value.trim();
348
+ }
349
+ }
350
+ createVariantMutation.mutate({
351
+ params: { productId },
352
+ name: data.name,
353
+ sku: data.sku || undefined,
354
+ price: Number(data.price),
355
+ inventory: Number(data.inventory) || 0,
356
+ options,
357
+ });
358
+ };
359
+
360
+ const handleUpdateVariant = (data: VariantFormData) => {
361
+ if (!editingVariant) return;
362
+ const options: Record<string, string> = {};
363
+ for (const opt of data.options) {
364
+ if (opt.key.trim() && opt.value.trim()) {
365
+ options[opt.key.trim()] = opt.value.trim();
366
+ }
367
+ }
368
+ updateVariantMutation.mutate({
369
+ params: { id: editingVariant.id },
370
+ name: data.name,
371
+ sku: data.sku || undefined,
372
+ price: Number(data.price),
373
+ inventory: Number(data.inventory) || 0,
374
+ options,
375
+ });
376
+ };
377
+
378
+ const handleDeleteVariant = (variantId: string) => {
379
+ if (!window.confirm("Delete this variant?")) return;
380
+ deleteVariantMutation.mutate({ params: { id: variantId } });
381
+ };
382
+
383
+ if (!productId) {
384
+ return (
385
+ <div className="rounded-md border border-border bg-muted/30 p-4 text-muted-foreground">
386
+ <p className="font-medium">Product not found</p>
387
+ <p className="mt-1 text-sm">No product ID was provided.</p>
388
+ <a
389
+ href="/admin/products"
390
+ className="mt-3 inline-block text-sm underline"
391
+ >
392
+ Back to products
393
+ </a>
394
+ </div>
395
+ );
396
+ }
397
+
398
+ // Loading skeleton
399
+ if (loading) {
400
+ return (
401
+ <div className="space-y-6">
402
+ <div className="flex items-center justify-between">
403
+ <div className="h-7 w-48 animate-pulse rounded bg-muted" />
404
+ <div className="flex gap-2">
405
+ <div className="h-9 w-16 animate-pulse rounded-md bg-muted" />
406
+ <div className="h-9 w-20 animate-pulse rounded-md bg-muted" />
407
+ </div>
408
+ </div>
409
+ <div className="grid gap-6 lg:grid-cols-3">
410
+ <div className="space-y-4 lg:col-span-2">
411
+ <div className="aspect-square animate-pulse rounded-lg bg-muted" />
412
+ <div className="h-24 animate-pulse rounded-lg bg-muted" />
413
+ </div>
414
+ <div className="space-y-4">
415
+ <div className="h-32 animate-pulse rounded-lg bg-muted" />
416
+ <div className="h-24 animate-pulse rounded-lg bg-muted" />
417
+ </div>
418
+ </div>
419
+ </div>
420
+ );
421
+ }
422
+
423
+ if (!product) {
424
+ return (
425
+ <div className="flex flex-col items-center justify-center py-20 text-center">
426
+ <p className="font-medium text-base text-foreground">
427
+ Product not found
428
+ </p>
429
+ <a
430
+ href="/admin/products"
431
+ className="mt-3 text-foreground text-sm underline underline-offset-2"
432
+ >
433
+ Back to products
434
+ </a>
435
+ </div>
436
+ );
437
+ }
438
+
439
+ const content = (
440
+ <div>
441
+ {/* Header */}
442
+ <div className="mb-6 flex items-center justify-between">
443
+ <div className="flex items-center gap-3">
444
+ <h1 className="font-semibold text-foreground text-lg">
445
+ {product.name}
446
+ </h1>
447
+ <span
448
+ className={`inline-flex rounded-full px-2 py-0.5 font-medium text-xs capitalize ${
449
+ statusStyles[product.status] ?? statusStyles.draft
450
+ }`}
451
+ >
452
+ {product.status}
453
+ </span>
454
+ </div>
455
+ <div className="flex items-center gap-2">
456
+ <a
457
+ href={`/admin/products/${product.id}/edit`}
458
+ className="rounded-md border border-border px-4 py-2 font-medium text-foreground text-sm transition-colors hover:bg-muted"
459
+ >
460
+ Edit
461
+ </a>
462
+ <button
463
+ type="button"
464
+ onClick={() => handleDelete()}
465
+ disabled={deleting}
466
+ className="rounded-md border border-destructive/50 px-4 py-2 font-medium text-destructive text-sm transition-colors hover:bg-destructive/10 disabled:opacity-50"
467
+ >
468
+ {deleting ? "Deleting..." : "Delete"}
469
+ </button>
470
+ </div>
471
+ </div>
472
+
473
+ <div className="grid gap-6 lg:grid-cols-3">
474
+ {/* Main column */}
475
+ <div className="space-y-5 lg:col-span-2">
476
+ {/* Images */}
477
+ <div className="rounded-lg border border-border bg-card p-5">
478
+ <h2 className="mb-4 font-semibold text-foreground text-sm">
479
+ Images
480
+ </h2>
481
+ {product.images.length > 0 ? (
482
+ <div className="space-y-3">
483
+ <div className="aspect-square overflow-hidden rounded-lg border border-border bg-muted">
484
+ <img
485
+ src={product.images[selectedImage]}
486
+ alt={product.name}
487
+ className="h-full w-full object-cover object-center"
488
+ />
489
+ </div>
490
+ {product.images.length > 1 && (
491
+ <div className="flex gap-2">
492
+ {product.images.map((img, i) => (
493
+ <button
494
+ key={i}
495
+ type="button"
496
+ onClick={() => setSelectedImage(i)}
497
+ className={`h-16 w-16 overflow-hidden rounded-md border-2 transition-colors ${
498
+ i === selectedImage
499
+ ? "border-foreground"
500
+ : "border-border hover:border-muted-foreground"
501
+ }`}
502
+ >
503
+ <img
504
+ src={img}
505
+ alt={`${product.name} view ${i + 1}`}
506
+ className="h-full w-full object-cover"
507
+ />
508
+ </button>
509
+ ))}
510
+ </div>
511
+ )}
512
+ </div>
513
+ ) : (
514
+ <div className="flex aspect-video items-center justify-center rounded-lg border border-border bg-muted text-muted-foreground">
515
+ <svg
516
+ xmlns="http://www.w3.org/2000/svg"
517
+ width="40"
518
+ height="40"
519
+ viewBox="0 0 24 24"
520
+ fill="none"
521
+ stroke="currentColor"
522
+ strokeWidth="1.5"
523
+ strokeLinecap="round"
524
+ strokeLinejoin="round"
525
+ aria-hidden="true"
526
+ >
527
+ <rect width="18" height="18" x="3" y="3" rx="2" />
528
+ <circle cx="9" cy="9" r="2" />
529
+ <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
530
+ </svg>
531
+ </div>
532
+ )}
533
+ </div>
534
+
535
+ {/* Description */}
536
+ {product.description && (
537
+ <div className="rounded-lg border border-border bg-card p-5">
538
+ <h2 className="mb-4 font-semibold text-foreground text-sm">
539
+ Description
540
+ </h2>
541
+ <p className="whitespace-pre-wrap text-muted-foreground text-sm leading-relaxed">
542
+ {product.description}
543
+ </p>
544
+ </div>
545
+ )}
546
+
547
+ {/* Variants */}
548
+ <div className="rounded-lg border border-border bg-card p-5">
549
+ <div className="mb-4 flex items-center justify-between">
550
+ <h2 className="font-semibold text-foreground text-sm">
551
+ Variants ({product.variants.length})
552
+ </h2>
553
+ {!showVariantForm && !editingVariant && (
554
+ <button
555
+ type="button"
556
+ onClick={() => setShowVariantForm(true)}
557
+ className="rounded-md bg-foreground px-3 py-1.5 font-medium text-background text-xs hover:opacity-90"
558
+ >
559
+ + Add variant
560
+ </button>
561
+ )}
562
+ </div>
563
+
564
+ {/* Create form */}
565
+ {showVariantForm && (
566
+ <div className="mb-4 rounded-lg border border-border bg-muted/30 p-4">
567
+ <p className="mb-3 font-semibold text-foreground text-sm">
568
+ New variant
569
+ </p>
570
+ <VariantForm
571
+ initial={emptyVariantForm}
572
+ onSubmit={handleCreateVariant}
573
+ onCancel={() => setShowVariantForm(false)}
574
+ submitting={createVariantMutation.isPending}
575
+ submitLabel="Create variant"
576
+ />
577
+ </div>
578
+ )}
579
+
580
+ {/* Edit form */}
581
+ {editingVariant && (
582
+ <div className="mb-4 rounded-lg border border-border bg-muted/30 p-4">
583
+ <p className="mb-3 font-semibold text-foreground text-sm">
584
+ Edit variant: {editingVariant.name}
585
+ </p>
586
+ <VariantForm
587
+ initial={{
588
+ name: editingVariant.name,
589
+ sku: editingVariant.sku || "",
590
+ price: String(editingVariant.price),
591
+ inventory: String(editingVariant.inventory),
592
+ options: Object.entries(editingVariant.options).map(
593
+ ([key, value]) => ({
594
+ key,
595
+ value,
596
+ }),
597
+ ),
598
+ }}
599
+ onSubmit={handleUpdateVariant}
600
+ onCancel={() => setEditingVariant(null)}
601
+ submitting={updateVariantMutation.isPending}
602
+ submitLabel="Save changes"
603
+ />
604
+ </div>
605
+ )}
606
+
607
+ {product.variants.length > 0 ? (
608
+ <div className="overflow-x-auto">
609
+ <table className="w-full text-sm">
610
+ <thead>
611
+ <tr className="border-border border-b">
612
+ <th className="pb-2 text-left font-medium text-muted-foreground text-xs uppercase tracking-wider">
613
+ Name
614
+ </th>
615
+ <th className="pb-2 text-left font-medium text-muted-foreground text-xs uppercase tracking-wider">
616
+ SKU
617
+ </th>
618
+ <th className="pb-2 text-left font-medium text-muted-foreground text-xs uppercase tracking-wider">
619
+ Price
620
+ </th>
621
+ <th className="pb-2 text-left font-medium text-muted-foreground text-xs uppercase tracking-wider">
622
+ Inventory
623
+ </th>
624
+ <th className="pb-2 text-left font-medium text-muted-foreground text-xs uppercase tracking-wider">
625
+ Options
626
+ </th>
627
+ <th className="pb-2 text-right font-medium text-muted-foreground text-xs uppercase tracking-wider">
628
+ Actions
629
+ </th>
630
+ </tr>
631
+ </thead>
632
+ <tbody className="divide-y divide-border">
633
+ {product.variants.map((variant) => (
634
+ <tr key={variant.id}>
635
+ <td className="py-2.5 font-medium text-foreground">
636
+ {variant.name}
637
+ </td>
638
+ <td className="py-2.5 font-mono text-muted-foreground">
639
+ {variant.sku || "—"}
640
+ </td>
641
+ <td className="py-2.5 text-foreground">
642
+ {formatPrice(variant.price)}
643
+ </td>
644
+ <td className="py-2.5 text-foreground">
645
+ {variant.inventory}
646
+ </td>
647
+ <td className="py-2.5">
648
+ <div className="flex flex-wrap gap-1">
649
+ {Object.entries(variant.options).map(
650
+ ([key, value]) => (
651
+ <span
652
+ key={key}
653
+ className="rounded-full border border-border px-2 py-0.5 text-muted-foreground text-xs"
654
+ >
655
+ {key}: {value}
656
+ </span>
657
+ ),
658
+ )}
659
+ </div>
660
+ </td>
661
+ <td className="py-2.5 text-right">
662
+ <div className="flex justify-end gap-1">
663
+ <button
664
+ type="button"
665
+ onClick={() => {
666
+ setShowVariantForm(false);
667
+ setEditingVariant(variant);
668
+ }}
669
+ className="rounded-md px-2 py-1 text-muted-foreground text-xs hover:bg-muted hover:text-foreground"
670
+ >
671
+ Edit
672
+ </button>
673
+ <button
674
+ type="button"
675
+ onClick={() => handleDeleteVariant(variant.id)}
676
+ disabled={deleteVariantMutation.isPending}
677
+ className="rounded-md px-2 py-1 text-muted-foreground text-xs hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
678
+ >
679
+ Delete
680
+ </button>
681
+ </div>
682
+ </td>
683
+ </tr>
684
+ ))}
685
+ </tbody>
686
+ </table>
687
+ </div>
688
+ ) : (
689
+ !showVariantForm && (
690
+ <p className="text-center text-muted-foreground text-sm">
691
+ No variants. Add variants to offer different sizes, colors, or
692
+ options.
693
+ </p>
694
+ )
695
+ )}
696
+ </div>
697
+ </div>
698
+
699
+ {/* Sidebar */}
700
+ <div className="space-y-5">
701
+ {/* Details */}
702
+ <div className="rounded-lg border border-border bg-card p-5">
703
+ <h2 className="mb-4 font-semibold text-foreground text-sm">
704
+ Details
705
+ </h2>
706
+ <dl className="space-y-3">
707
+ <div>
708
+ <dt className="font-medium text-muted-foreground text-xs">
709
+ Price
710
+ </dt>
711
+ <dd className="mt-0.5 font-semibold text-foreground text-sm">
712
+ {formatPrice(product.price)}
713
+ {product.compareAtPrice != null &&
714
+ product.compareAtPrice > product.price && (
715
+ <span className="ml-2 font-normal text-muted-foreground line-through">
716
+ {formatPrice(product.compareAtPrice)}
717
+ </span>
718
+ )}
719
+ </dd>
720
+ </div>
721
+ <div>
722
+ <dt className="font-medium text-muted-foreground text-xs">
723
+ Inventory
724
+ </dt>
725
+ <dd className="mt-0.5 text-foreground text-sm">
726
+ {product.inventory} in stock
727
+ </dd>
728
+ </div>
729
+ {product.category && (
730
+ <div>
731
+ <dt className="font-medium text-muted-foreground text-xs">
732
+ Category
733
+ </dt>
734
+ <dd className="mt-0.5 text-foreground text-sm">
735
+ {product.category.name}
736
+ </dd>
737
+ </div>
738
+ )}
739
+ <div>
740
+ <dt className="font-medium text-muted-foreground text-xs">
741
+ Featured
742
+ </dt>
743
+ <dd className="mt-0.5 text-foreground text-sm">
744
+ {product.isFeatured ? "Yes" : "No"}
745
+ </dd>
746
+ </div>
747
+ <div>
748
+ <dt className="font-medium text-muted-foreground text-xs">
749
+ Created
750
+ </dt>
751
+ <dd className="mt-0.5 text-foreground text-sm">
752
+ {formatDate(product.createdAt)}
753
+ </dd>
754
+ </div>
755
+ <div>
756
+ <dt className="font-medium text-muted-foreground text-xs">
757
+ Updated
758
+ </dt>
759
+ <dd className="mt-0.5 text-foreground text-sm">
760
+ {formatDate(product.updatedAt)}
761
+ </dd>
762
+ </div>
763
+ </dl>
764
+ </div>
765
+
766
+ {/* Tags */}
767
+ {product.tags.length > 0 && (
768
+ <div className="rounded-lg border border-border bg-card p-5">
769
+ <h2 className="mb-4 font-semibold text-foreground text-sm">
770
+ Tags
771
+ </h2>
772
+ <div className="flex flex-wrap gap-1.5">
773
+ {product.tags.map((tag) => (
774
+ <span
775
+ key={tag}
776
+ className="rounded-full border border-border px-2 py-0.5 text-muted-foreground text-xs"
777
+ >
778
+ {tag}
779
+ </span>
780
+ ))}
781
+ </div>
782
+ </div>
783
+ )}
784
+ </div>
785
+ </div>
786
+ </div>
787
+ );
788
+
789
+ return <ProductDetailTemplate content={content} />;
790
+ }