@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,793 @@
1
+ "use client";
2
+
3
+ import { useModuleClient } from "@86d-app/core/client";
4
+ import { useCallback, useEffect, useRef, useState } from "react";
5
+
6
+ // ─── Types ────────────────────────────────────────────────────────────────────
7
+
8
+ interface Category {
9
+ id: string;
10
+ name: string;
11
+ }
12
+
13
+ interface Product {
14
+ name: string;
15
+ slug: string;
16
+ description?: string | null;
17
+ shortDescription?: string | null;
18
+ price: number;
19
+ compareAtPrice?: number | null;
20
+ sku?: string | null;
21
+ inventory: number;
22
+ trackInventory: boolean;
23
+ allowBackorder: boolean;
24
+ status: "draft" | "active" | "archived";
25
+ categoryId?: string | null;
26
+ isFeatured: boolean;
27
+ tags: string[];
28
+ images: string[];
29
+ }
30
+
31
+ interface ProductFormData {
32
+ name: string;
33
+ slug: string;
34
+ description: string;
35
+ shortDescription: string;
36
+ price: string;
37
+ compareAtPrice: string;
38
+ sku: string;
39
+ inventory: string;
40
+ trackInventory: boolean;
41
+ allowBackorder: boolean;
42
+ status: "draft" | "active" | "archived";
43
+ categoryId: string;
44
+ isFeatured: boolean;
45
+ tags: string;
46
+ images: string[];
47
+ }
48
+
49
+ interface ProductFormProps {
50
+ productId?: string;
51
+ onNavigate: (path: string) => void;
52
+ }
53
+
54
+ interface CategoriesResult {
55
+ categories: Category[];
56
+ }
57
+
58
+ // ─── Module Client ───────────────────────────────────────────────────────────
59
+
60
+ function useProductsAdminApi() {
61
+ const client = useModuleClient();
62
+ return {
63
+ listCategories: client.module("products").admin["/admin/categories/list"],
64
+ getProduct: client.module("products").admin["/admin/products/:id"],
65
+ createProduct: client.module("products").admin["/admin/products/create"],
66
+ updateProduct:
67
+ client.module("products").admin["/admin/products/:id/update"],
68
+ listProducts: client.module("products").admin["/admin/products/list"],
69
+ };
70
+ }
71
+
72
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
73
+
74
+ function extractError(error: Error | null, fallback: string): string {
75
+ if (!error) return fallback;
76
+ // biome-ignore lint/suspicious/noExplicitAny: accessing HTTP error body property
77
+ const body = (error as any)?.body;
78
+ if (typeof body?.error === "string") return body.error;
79
+ if (typeof body?.error?.message === "string") return body.error.message;
80
+ return fallback;
81
+ }
82
+
83
+ function slugify(str: string): string {
84
+ return str
85
+ .toLowerCase()
86
+ .replace(/[^\w\s-]/g, "")
87
+ .replace(/[\s_]+/g, "-")
88
+ .replace(/^-+|-+$/g, "");
89
+ }
90
+
91
+ const defaultForm: ProductFormData = {
92
+ name: "",
93
+ slug: "",
94
+ description: "",
95
+ shortDescription: "",
96
+ price: "",
97
+ compareAtPrice: "",
98
+ sku: "",
99
+ inventory: "0",
100
+ trackInventory: true,
101
+ allowBackorder: false,
102
+ status: "draft",
103
+ categoryId: "",
104
+ isFeatured: false,
105
+ tags: "",
106
+ images: [],
107
+ };
108
+
109
+ // ─── ImageUpload (self-contained) ────────────────────────────────────────────
110
+
111
+ function ImageUpload({
112
+ images,
113
+ onChange,
114
+ max = 10,
115
+ }: {
116
+ images: string[];
117
+ onChange: (images: string[]) => void;
118
+ max?: number;
119
+ }) {
120
+ const [uploading, setUploading] = useState(false);
121
+ const [error, setError] = useState<string | null>(null);
122
+ const [dragOver, setDragOver] = useState(false);
123
+ const inputRef = useRef<HTMLInputElement>(null);
124
+
125
+ const uploadFile = useCallback(async (file: File): Promise<string | null> => {
126
+ const formData = new FormData();
127
+ formData.append("file", file);
128
+ const res = await fetch("/api/upload", { method: "POST", body: formData });
129
+ if (!res.ok) {
130
+ const data = (await res.json()) as { error?: string };
131
+ throw new Error(data.error ?? "Upload failed");
132
+ }
133
+ const data = (await res.json()) as { url: string };
134
+ return data.url;
135
+ }, []);
136
+
137
+ const handleFiles = useCallback(
138
+ async (files: FileList | File[]) => {
139
+ const remaining = max - images.length;
140
+ if (remaining <= 0) {
141
+ setError(`Maximum ${max} images allowed`);
142
+ return;
143
+ }
144
+ const toUpload = Array.from(files).slice(0, remaining);
145
+ setError(null);
146
+ setUploading(true);
147
+ try {
148
+ const urls: string[] = [];
149
+ for (const file of toUpload) {
150
+ const url = await uploadFile(file);
151
+ if (url) urls.push(url);
152
+ }
153
+ onChange([...images, ...urls]);
154
+ } catch (err) {
155
+ setError(err instanceof Error ? err.message : "Upload failed");
156
+ } finally {
157
+ setUploading(false);
158
+ if (inputRef.current) inputRef.current.value = "";
159
+ }
160
+ },
161
+ [images, max, onChange, uploadFile],
162
+ );
163
+
164
+ const handleDrop = useCallback(
165
+ (e: React.DragEvent) => {
166
+ e.preventDefault();
167
+ setDragOver(false);
168
+ if (e.dataTransfer.files.length > 0) {
169
+ void handleFiles(e.dataTransfer.files);
170
+ }
171
+ },
172
+ [handleFiles],
173
+ );
174
+
175
+ const handleRemove = useCallback(
176
+ (index: number) => {
177
+ onChange(images.filter((_, i) => i !== index));
178
+ },
179
+ [images, onChange],
180
+ );
181
+
182
+ return (
183
+ <div>
184
+ <span className="mb-1.5 block font-medium text-foreground text-sm">
185
+ Images
186
+ </span>
187
+
188
+ {images.length > 0 && (
189
+ <div className="mb-3 grid grid-cols-4 gap-2 sm:grid-cols-5">
190
+ {images.map((url, i) => (
191
+ <div key={url} className="group relative">
192
+ <div className="aspect-square overflow-hidden rounded-md border border-border bg-muted">
193
+ <img
194
+ src={url}
195
+ alt={`Upload ${i + 1}`}
196
+ className="h-full w-full object-cover"
197
+ />
198
+ </div>
199
+ <button
200
+ type="button"
201
+ onClick={() => handleRemove(i)}
202
+ className="absolute top-1 right-1 rounded bg-destructive/90 p-0.5 text-white opacity-0 shadow-sm transition-opacity hover:bg-destructive group-hover:opacity-100"
203
+ title="Remove"
204
+ >
205
+ <svg
206
+ xmlns="http://www.w3.org/2000/svg"
207
+ width="12"
208
+ height="12"
209
+ viewBox="0 0 24 24"
210
+ fill="none"
211
+ stroke="currentColor"
212
+ strokeWidth="2"
213
+ strokeLinecap="round"
214
+ strokeLinejoin="round"
215
+ aria-hidden="true"
216
+ >
217
+ <path d="M18 6 6 18" />
218
+ <path d="m6 6 12 12" />
219
+ </svg>
220
+ </button>
221
+ {i === 0 && (
222
+ <span className="absolute bottom-1 left-1 rounded bg-foreground/80 px-1 py-0.5 font-medium text-2xs text-background">
223
+ Primary
224
+ </span>
225
+ )}
226
+ </div>
227
+ ))}
228
+ </div>
229
+ )}
230
+
231
+ {images.length < max && (
232
+ <button
233
+ type="button"
234
+ onDragOver={(e) => {
235
+ e.preventDefault();
236
+ setDragOver(true);
237
+ }}
238
+ onDragLeave={() => setDragOver(false)}
239
+ onDrop={handleDrop}
240
+ className={`flex w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed px-4 py-6 transition-colors ${
241
+ dragOver
242
+ ? "border-foreground/50 bg-muted/50"
243
+ : "border-border hover:border-muted-foreground hover:bg-muted/30"
244
+ } ${uploading ? "pointer-events-none opacity-60" : ""}`}
245
+ onClick={() => inputRef.current?.click()}
246
+ >
247
+ {uploading ? (
248
+ <span className="text-muted-foreground text-sm">Uploading...</span>
249
+ ) : (
250
+ <>
251
+ <svg
252
+ xmlns="http://www.w3.org/2000/svg"
253
+ width="24"
254
+ height="24"
255
+ viewBox="0 0 24 24"
256
+ fill="none"
257
+ stroke="currentColor"
258
+ strokeWidth="1.5"
259
+ strokeLinecap="round"
260
+ strokeLinejoin="round"
261
+ className="mb-2 text-muted-foreground"
262
+ aria-hidden="true"
263
+ >
264
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
265
+ <polyline points="17 8 12 3 7 8" />
266
+ <line x1="12" y1="3" x2="12" y2="15" />
267
+ </svg>
268
+ <p className="text-muted-foreground text-sm">
269
+ Drop images here or click to browse
270
+ </p>
271
+ <p className="mt-1 text-muted-foreground/70 text-xs">
272
+ JPEG, PNG, WebP up to 4.5 MB
273
+ </p>
274
+ </>
275
+ )}
276
+ </button>
277
+ )}
278
+
279
+ <input
280
+ ref={inputRef}
281
+ type="file"
282
+ accept="image/jpeg,image/png,image/webp"
283
+ multiple
284
+ className="hidden"
285
+ onChange={(e) => {
286
+ if (e.target.files && e.target.files.length > 0) {
287
+ void handleFiles(e.target.files);
288
+ }
289
+ }}
290
+ />
291
+
292
+ {error && <p className="mt-1.5 text-destructive text-xs">{error}</p>}
293
+ </div>
294
+ );
295
+ }
296
+
297
+ // ─── ProductForm ─────────────────────────────────────────────────────────────
298
+
299
+ export function ProductForm({ productId, onNavigate }: ProductFormProps) {
300
+ const api = useProductsAdminApi();
301
+ const isEditing = Boolean(productId);
302
+
303
+ const [form, setForm] = useState<ProductFormData>(defaultForm);
304
+ const [error, setError] = useState<string | null>(null);
305
+ const [slugEdited, setSlugEdited] = useState(false);
306
+
307
+ const { data: categoriesData } = api.listCategories.useQuery({
308
+ limit: "100",
309
+ }) as { data: CategoriesResult | undefined; isLoading: boolean };
310
+
311
+ const categories = categoriesData?.categories ?? [];
312
+
313
+ interface ProductResult {
314
+ product?: Product | undefined;
315
+ }
316
+ const { data: productData, isLoading: loading } = api.getProduct.useQuery(
317
+ productId ? { params: { id: productId } } : undefined,
318
+ ) as {
319
+ data: ProductResult | undefined;
320
+ isLoading: boolean;
321
+ };
322
+
323
+ const hydrated = useRef(false);
324
+ useEffect(() => {
325
+ if (!productData?.product || hydrated.current) return;
326
+ hydrated.current = true;
327
+ const p = productData.product;
328
+ setForm({
329
+ name: p.name,
330
+ slug: p.slug,
331
+ description: p.description ?? "",
332
+ shortDescription: p.shortDescription ?? "",
333
+ price: String(p.price / 100),
334
+ compareAtPrice: p.compareAtPrice ? String(p.compareAtPrice / 100) : "",
335
+ sku: p.sku ?? "",
336
+ inventory: String(p.inventory),
337
+ trackInventory: p.trackInventory,
338
+ allowBackorder: p.allowBackorder,
339
+ status: p.status,
340
+ categoryId: p.categoryId ?? "",
341
+ isFeatured: p.isFeatured,
342
+ tags: p.tags.join(", "),
343
+ images: p.images ?? [],
344
+ });
345
+ setSlugEdited(true);
346
+ }, [productData]);
347
+
348
+ const createMutation = api.createProduct.useMutation({
349
+ onSuccess: () => {
350
+ void api.listProducts.invalidate();
351
+ onNavigate("/admin/products");
352
+ },
353
+ onError: (err: Error) => {
354
+ setError(extractError(err, "Failed to save product"));
355
+ },
356
+ });
357
+
358
+ const updateMutation = api.updateProduct.useMutation({
359
+ onSuccess: () => {
360
+ void api.listProducts.invalidate();
361
+ void api.getProduct.invalidate();
362
+ onNavigate("/admin/products");
363
+ },
364
+ onError: (err: Error) => {
365
+ setError(extractError(err, "Failed to save product"));
366
+ },
367
+ });
368
+
369
+ const saving = createMutation.isPending || updateMutation.isPending;
370
+
371
+ const setField = useCallback(
372
+ <K extends keyof ProductFormData>(field: K, value: ProductFormData[K]) => {
373
+ setForm((prev) => {
374
+ const next = { ...prev, [field]: value };
375
+ if (field === "name" && !slugEdited) {
376
+ next.slug = slugify(value as string);
377
+ }
378
+ return next;
379
+ });
380
+ },
381
+ [slugEdited],
382
+ );
383
+
384
+ const handleSubmit = (e: React.FormEvent) => {
385
+ e.preventDefault();
386
+ setError(null);
387
+
388
+ if (!form.name.trim()) {
389
+ setError("Name is required");
390
+ return;
391
+ }
392
+ if (!form.slug.trim()) {
393
+ setError("Slug is required");
394
+ return;
395
+ }
396
+ const price = Math.round(Number.parseFloat(form.price) * 100);
397
+ if (Number.isNaN(price) || price <= 0) {
398
+ setError("Price must be a positive number");
399
+ return;
400
+ }
401
+
402
+ const body = {
403
+ name: form.name.trim(),
404
+ slug: form.slug.trim(),
405
+ description: form.description.trim() || undefined,
406
+ shortDescription: form.shortDescription.trim() || undefined,
407
+ price,
408
+ compareAtPrice: form.compareAtPrice
409
+ ? Math.round(Number.parseFloat(form.compareAtPrice) * 100)
410
+ : undefined,
411
+ sku: form.sku.trim() || undefined,
412
+ inventory: Number.parseInt(form.inventory, 10) || 0,
413
+ trackInventory: form.trackInventory,
414
+ allowBackorder: form.allowBackorder,
415
+ status: form.status,
416
+ categoryId: form.categoryId || undefined,
417
+ isFeatured: form.isFeatured,
418
+ tags: form.tags
419
+ .split(",")
420
+ .map((t) => t.trim())
421
+ .filter(Boolean),
422
+ images: form.images,
423
+ };
424
+
425
+ if (isEditing && productId) {
426
+ updateMutation.mutate({ params: { id: productId }, ...body });
427
+ } else {
428
+ createMutation.mutate(body);
429
+ }
430
+ };
431
+
432
+ if (loading && isEditing) {
433
+ return (
434
+ <div className="space-y-4">
435
+ {Array.from({ length: 6 }).map((_, i) => (
436
+ <div
437
+ key={`skel-${i}`}
438
+ className="h-12 animate-pulse rounded-md bg-muted"
439
+ />
440
+ ))}
441
+ </div>
442
+ );
443
+ }
444
+
445
+ return (
446
+ <form onSubmit={(e) => handleSubmit(e)} className="space-y-6">
447
+ {error && (
448
+ <div className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-destructive text-sm">
449
+ {error}
450
+ </div>
451
+ )}
452
+
453
+ <div className="grid gap-6 lg:grid-cols-3">
454
+ {/* Main column */}
455
+ <div className="space-y-5 lg:col-span-2">
456
+ {/* Basic info */}
457
+ <div className="rounded-lg border border-border bg-card p-5">
458
+ <h2 className="mb-4 font-semibold text-foreground text-sm">
459
+ Product details
460
+ </h2>
461
+ <div className="space-y-4">
462
+ <div>
463
+ <label
464
+ htmlFor="pf-name"
465
+ className="mb-1.5 block font-medium text-foreground text-sm"
466
+ >
467
+ Name <span className="text-destructive">*</span>
468
+ </label>
469
+ <input
470
+ id="pf-name"
471
+ type="text"
472
+ value={form.name}
473
+ onChange={(e) => setField("name", e.target.value)}
474
+ placeholder="Product name"
475
+ 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"
476
+ required
477
+ />
478
+ </div>
479
+
480
+ <div>
481
+ <label
482
+ htmlFor="pf-slug"
483
+ className="mb-1.5 block font-medium text-foreground text-sm"
484
+ >
485
+ Slug <span className="text-destructive">*</span>
486
+ </label>
487
+ <input
488
+ id="pf-slug"
489
+ type="text"
490
+ value={form.slug}
491
+ onChange={(e) => {
492
+ setSlugEdited(true);
493
+ setField("slug", e.target.value);
494
+ }}
495
+ placeholder="product-slug"
496
+ 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"
497
+ required
498
+ />
499
+ </div>
500
+
501
+ <div>
502
+ <label
503
+ htmlFor="pf-short-desc"
504
+ className="mb-1.5 block font-medium text-foreground text-sm"
505
+ >
506
+ Short description
507
+ </label>
508
+ <input
509
+ id="pf-short-desc"
510
+ type="text"
511
+ value={form.shortDescription}
512
+ onChange={(e) => setField("shortDescription", e.target.value)}
513
+ placeholder="Brief product description"
514
+ 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"
515
+ />
516
+ </div>
517
+
518
+ <div>
519
+ <label
520
+ htmlFor="pf-description"
521
+ className="mb-1.5 block font-medium text-foreground text-sm"
522
+ >
523
+ Description
524
+ </label>
525
+ <textarea
526
+ id="pf-description"
527
+ value={form.description}
528
+ onChange={(e) => setField("description", e.target.value)}
529
+ placeholder="Full product description"
530
+ rows={5}
531
+ className="w-full resize-y 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"
532
+ />
533
+ </div>
534
+ </div>
535
+ </div>
536
+
537
+ {/* Images */}
538
+ <div className="rounded-lg border border-border bg-card p-5">
539
+ <h2 className="mb-4 font-semibold text-foreground text-sm">
540
+ Media
541
+ </h2>
542
+ <ImageUpload
543
+ images={form.images}
544
+ onChange={(images) => setForm((prev) => ({ ...prev, images }))}
545
+ max={10}
546
+ />
547
+ </div>
548
+
549
+ {/* Pricing */}
550
+ <div className="rounded-lg border border-border bg-card p-5">
551
+ <h2 className="mb-4 font-semibold text-foreground text-sm">
552
+ Pricing
553
+ </h2>
554
+ <div className="grid gap-4 sm:grid-cols-2">
555
+ <div>
556
+ <label
557
+ htmlFor="pf-price"
558
+ className="mb-1.5 block font-medium text-foreground text-sm"
559
+ >
560
+ Price (USD) <span className="text-destructive">*</span>
561
+ </label>
562
+ <div className="relative">
563
+ <span className="absolute top-1/2 left-3 -translate-y-1/2 text-muted-foreground text-sm">
564
+ $
565
+ </span>
566
+ <input
567
+ id="pf-price"
568
+ type="number"
569
+ min="0"
570
+ step="0.01"
571
+ value={form.price}
572
+ onChange={(e) => setField("price", e.target.value)}
573
+ placeholder="0.00"
574
+ className="w-full rounded-md border border-border bg-background py-2 pr-3 pl-7 text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
575
+ required
576
+ />
577
+ </div>
578
+ </div>
579
+
580
+ <div>
581
+ <label
582
+ htmlFor="pf-compare-price"
583
+ className="mb-1.5 block font-medium text-foreground text-sm"
584
+ >
585
+ Compare-at price
586
+ </label>
587
+ <div className="relative">
588
+ <span className="absolute top-1/2 left-3 -translate-y-1/2 text-muted-foreground text-sm">
589
+ $
590
+ </span>
591
+ <input
592
+ id="pf-compare-price"
593
+ type="number"
594
+ min="0"
595
+ step="0.01"
596
+ value={form.compareAtPrice}
597
+ onChange={(e) => setField("compareAtPrice", e.target.value)}
598
+ placeholder="0.00"
599
+ className="w-full rounded-md border border-border bg-background py-2 pr-3 pl-7 text-foreground text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
600
+ />
601
+ </div>
602
+ <p className="mt-1 text-muted-foreground text-xs">
603
+ Shows a strikethrough price on the storefront
604
+ </p>
605
+ </div>
606
+ </div>
607
+ </div>
608
+
609
+ {/* Inventory */}
610
+ <div className="rounded-lg border border-border bg-card p-5">
611
+ <h2 className="mb-4 font-semibold text-foreground text-sm">
612
+ Inventory
613
+ </h2>
614
+ <div className="space-y-4">
615
+ <div className="grid gap-4 sm:grid-cols-2">
616
+ <div>
617
+ <label
618
+ htmlFor="pf-sku"
619
+ className="mb-1.5 block font-medium text-foreground text-sm"
620
+ >
621
+ SKU
622
+ </label>
623
+ <input
624
+ id="pf-sku"
625
+ type="text"
626
+ value={form.sku}
627
+ onChange={(e) => setField("sku", e.target.value)}
628
+ placeholder="SKU-001"
629
+ 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"
630
+ />
631
+ </div>
632
+
633
+ <div>
634
+ <label
635
+ htmlFor="pf-inventory"
636
+ className="mb-1.5 block font-medium text-foreground text-sm"
637
+ >
638
+ Quantity
639
+ </label>
640
+ <input
641
+ id="pf-inventory"
642
+ type="number"
643
+ min="0"
644
+ value={form.inventory}
645
+ onChange={(e) => setField("inventory", e.target.value)}
646
+ className="w-full rounded-md border border-border bg-background px-3 py-2 text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
647
+ />
648
+ </div>
649
+ </div>
650
+
651
+ <div className="space-y-2">
652
+ <label className="flex items-center gap-2.5">
653
+ <input
654
+ type="checkbox"
655
+ checked={form.trackInventory}
656
+ onChange={(e) =>
657
+ setField("trackInventory", e.target.checked)
658
+ }
659
+ className="h-4 w-4 rounded border-border"
660
+ />
661
+ <span className="text-foreground text-sm">
662
+ Track inventory
663
+ </span>
664
+ </label>
665
+ <label className="flex items-center gap-2.5">
666
+ <input
667
+ type="checkbox"
668
+ checked={form.allowBackorder}
669
+ onChange={(e) =>
670
+ setField("allowBackorder", e.target.checked)
671
+ }
672
+ className="h-4 w-4 rounded border-border"
673
+ />
674
+ <span className="text-foreground text-sm">
675
+ Allow backorders
676
+ </span>
677
+ </label>
678
+ </div>
679
+ </div>
680
+ </div>
681
+ </div>
682
+
683
+ {/* Sidebar column */}
684
+ <div className="space-y-5">
685
+ {/* Status */}
686
+ <div className="rounded-lg border border-border bg-card p-5">
687
+ <h2 className="mb-4 font-semibold text-foreground text-sm">
688
+ Status
689
+ </h2>
690
+ <select
691
+ value={form.status}
692
+ onChange={(e) =>
693
+ setField("status", e.target.value as ProductFormData["status"])
694
+ }
695
+ className="w-full rounded-md border border-border bg-background px-3 py-2 text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
696
+ >
697
+ <option value="draft">Draft</option>
698
+ <option value="active">Active</option>
699
+ <option value="archived">Archived</option>
700
+ </select>
701
+ <p className="mt-2 text-muted-foreground text-xs">
702
+ Only active products are visible in the store.
703
+ </p>
704
+ </div>
705
+
706
+ {/* Organization */}
707
+ <div className="rounded-lg border border-border bg-card p-5">
708
+ <h2 className="mb-4 font-semibold text-foreground text-sm">
709
+ Organization
710
+ </h2>
711
+ <div className="space-y-4">
712
+ <div>
713
+ <label
714
+ htmlFor="pf-category"
715
+ className="mb-1.5 block font-medium text-foreground text-sm"
716
+ >
717
+ Category
718
+ </label>
719
+ <select
720
+ id="pf-category"
721
+ value={form.categoryId}
722
+ onChange={(e) => setField("categoryId", e.target.value)}
723
+ className="w-full rounded-md border border-border bg-background px-3 py-2 text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
724
+ >
725
+ <option value="">No category</option>
726
+ {categories.map((c) => (
727
+ <option key={c.id} value={c.id}>
728
+ {c.name}
729
+ </option>
730
+ ))}
731
+ </select>
732
+ </div>
733
+
734
+ <div>
735
+ <label
736
+ htmlFor="pf-tags"
737
+ className="mb-1.5 block font-medium text-foreground text-sm"
738
+ >
739
+ Tags
740
+ </label>
741
+ <input
742
+ id="pf-tags"
743
+ type="text"
744
+ value={form.tags}
745
+ onChange={(e) => setField("tags", e.target.value)}
746
+ placeholder="tag1, tag2, tag3"
747
+ 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"
748
+ />
749
+ <p className="mt-1 text-muted-foreground text-xs">
750
+ Comma-separated
751
+ </p>
752
+ </div>
753
+
754
+ <label className="flex items-center gap-2.5">
755
+ <input
756
+ type="checkbox"
757
+ checked={form.isFeatured}
758
+ onChange={(e) => setField("isFeatured", e.target.checked)}
759
+ className="h-4 w-4 rounded border-border"
760
+ />
761
+ <span className="text-foreground text-sm">
762
+ Featured product
763
+ </span>
764
+ </label>
765
+ </div>
766
+ </div>
767
+
768
+ {/* Actions */}
769
+ <div className="flex flex-col gap-2">
770
+ <button
771
+ type="submit"
772
+ disabled={saving}
773
+ className="w-full rounded-md bg-foreground px-4 py-2.5 font-semibold text-background text-sm transition-opacity hover:opacity-90 disabled:opacity-50"
774
+ >
775
+ {saving
776
+ ? "Saving\u2026"
777
+ : isEditing
778
+ ? "Save changes"
779
+ : "Create product"}
780
+ </button>
781
+ <button
782
+ type="button"
783
+ onClick={() => onNavigate("/admin/products")}
784
+ className="w-full rounded-md border border-border px-4 py-2.5 text-center font-medium text-foreground text-sm transition-colors hover:bg-muted"
785
+ >
786
+ Cancel
787
+ </button>
788
+ </div>
789
+ </div>
790
+ </div>
791
+ </form>
792
+ );
793
+ }