@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,1125 @@
1
+ "use client";
2
+
3
+ import { useModuleClient } from "@86d-app/core/client";
4
+ import { useCallback, useRef, useState } from "react";
5
+ import ProductListTemplate from "./product-list.mdx";
6
+
7
+ // ─── Types ────────────────────────────────────────────────────────────────────
8
+
9
+ interface Product {
10
+ id: string;
11
+ name: string;
12
+ slug: string;
13
+ price: number;
14
+ compareAtPrice?: number | null;
15
+ costPrice?: number | null;
16
+ sku?: string | null;
17
+ barcode?: string | null;
18
+ description?: string | null;
19
+ shortDescription?: string | null;
20
+ status: "draft" | "active" | "archived";
21
+ inventory: number;
22
+ trackInventory?: boolean;
23
+ allowBackorder?: boolean;
24
+ isFeatured: boolean;
25
+ images: string[];
26
+ tags: string[];
27
+ categoryId?: string | null;
28
+ weight?: number | null;
29
+ weightUnit?: string | null;
30
+ createdAt: string;
31
+ updatedAt: string;
32
+ }
33
+
34
+ interface Category {
35
+ id: string;
36
+ name: string;
37
+ slug: string;
38
+ }
39
+
40
+ interface ListResult {
41
+ products: Product[];
42
+ total: number;
43
+ page: number;
44
+ limit: number;
45
+ }
46
+
47
+ interface CategoriesResult {
48
+ categories: Category[];
49
+ total: number;
50
+ }
51
+
52
+ interface ImportError {
53
+ row: number;
54
+ field: string;
55
+ message: string;
56
+ }
57
+
58
+ interface ImportResult {
59
+ created: number;
60
+ updated: number;
61
+ errors: ImportError[];
62
+ }
63
+
64
+ // ─── Module Client ───────────────────────────────────────────────────────────
65
+
66
+ function useProductsAdminApi() {
67
+ const client = useModuleClient();
68
+ return {
69
+ listProducts: client.module("products").admin["/admin/products/list"],
70
+ deleteProduct:
71
+ client.module("products").admin["/admin/products/:id/delete"],
72
+ listCategories: client.module("products").admin["/admin/categories/list"],
73
+ importProducts: client.module("products").admin["/admin/products/import"],
74
+ bulkAction: client.module("products").admin["/admin/products/bulk"],
75
+ };
76
+ }
77
+
78
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
79
+
80
+ function formatPrice(cents: number): string {
81
+ return new Intl.NumberFormat("en-US", {
82
+ style: "currency",
83
+ currency: "USD",
84
+ }).format(cents / 100);
85
+ }
86
+
87
+ function formatDate(iso: string): string {
88
+ return new Intl.DateTimeFormat("en-US", {
89
+ month: "short",
90
+ day: "numeric",
91
+ year: "numeric",
92
+ }).format(new Date(iso));
93
+ }
94
+
95
+ const statusStyles: Record<string, string> = {
96
+ draft: "bg-muted text-muted-foreground",
97
+ active:
98
+ "bg-emerald-50 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300",
99
+ archived:
100
+ "bg-yellow-50 text-yellow-700 dark:bg-yellow-950 dark:text-yellow-300",
101
+ };
102
+
103
+ // ─── CSV Utilities ───────────────────────────────────────────────────────────
104
+
105
+ function escapeCsvField(value: string): string {
106
+ if (value.includes(",") || value.includes('"') || value.includes("\n")) {
107
+ return `"${value.replace(/"/g, '""')}"`;
108
+ }
109
+ return value;
110
+ }
111
+
112
+ function downloadCsv(filename: string, rows: string[][]): void {
113
+ const csv = rows.map((row) => row.map(escapeCsvField).join(",")).join("\n");
114
+ const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
115
+ const url = URL.createObjectURL(blob);
116
+ const link = document.createElement("a");
117
+ link.href = url;
118
+ link.download = filename;
119
+ link.click();
120
+ URL.revokeObjectURL(url);
121
+ }
122
+
123
+ function parseCsv(text: string): string[][] {
124
+ const rows: string[][] = [];
125
+ let current = "";
126
+ let inQuotes = false;
127
+ let row: string[] = [];
128
+
129
+ for (let i = 0; i < text.length; i++) {
130
+ const ch = text[i];
131
+ if (inQuotes) {
132
+ if (ch === '"') {
133
+ if (i + 1 < text.length && text[i + 1] === '"') {
134
+ current += '"';
135
+ i++;
136
+ } else {
137
+ inQuotes = false;
138
+ }
139
+ } else {
140
+ current += ch;
141
+ }
142
+ } else if (ch === '"') {
143
+ inQuotes = true;
144
+ } else if (ch === ",") {
145
+ row.push(current.trim());
146
+ current = "";
147
+ } else if (ch === "\n" || ch === "\r") {
148
+ if (ch === "\r" && i + 1 < text.length && text[i + 1] === "\n") {
149
+ i++;
150
+ }
151
+ row.push(current.trim());
152
+ current = "";
153
+ if (row.some((cell) => cell !== "")) {
154
+ rows.push(row);
155
+ }
156
+ row = [];
157
+ } else {
158
+ current += ch;
159
+ }
160
+ }
161
+ // Last row
162
+ row.push(current.trim());
163
+ if (row.some((cell) => cell !== "")) {
164
+ rows.push(row);
165
+ }
166
+ return rows;
167
+ }
168
+
169
+ const CSV_HEADERS = [
170
+ "Name",
171
+ "Slug",
172
+ "SKU",
173
+ "Barcode",
174
+ "Price",
175
+ "Compare At Price",
176
+ "Cost Price",
177
+ "Inventory",
178
+ "Status",
179
+ "Category",
180
+ "Tags",
181
+ "Weight",
182
+ "Weight Unit",
183
+ "Featured",
184
+ "Track Inventory",
185
+ "Allow Backorder",
186
+ "Description",
187
+ "Short Description",
188
+ ];
189
+
190
+ const HEADER_MAP: Record<string, string> = {
191
+ name: "name",
192
+ slug: "slug",
193
+ sku: "sku",
194
+ barcode: "barcode",
195
+ price: "price",
196
+ "compare at price": "compareAtPrice",
197
+ compareatprice: "compareAtPrice",
198
+ compare_at_price: "compareAtPrice",
199
+ "cost price": "costPrice",
200
+ costprice: "costPrice",
201
+ cost_price: "costPrice",
202
+ inventory: "inventory",
203
+ stock: "inventory",
204
+ quantity: "inventory",
205
+ status: "status",
206
+ category: "category",
207
+ tags: "tags",
208
+ weight: "weight",
209
+ "weight unit": "weightUnit",
210
+ weightunit: "weightUnit",
211
+ weight_unit: "weightUnit",
212
+ featured: "featured",
213
+ "is featured": "featured",
214
+ "track inventory": "trackInventory",
215
+ trackinventory: "trackInventory",
216
+ track_inventory: "trackInventory",
217
+ "allow backorder": "allowBackorder",
218
+ allowbackorder: "allowBackorder",
219
+ allow_backorder: "allowBackorder",
220
+ description: "description",
221
+ "short description": "shortDescription",
222
+ shortdescription: "shortDescription",
223
+ short_description: "shortDescription",
224
+ };
225
+
226
+ function rowToProduct(
227
+ headers: string[],
228
+ values: string[],
229
+ // biome-ignore lint/suspicious/noExplicitAny: flexible CSV row parsing
230
+ ): Record<string, any> {
231
+ // biome-ignore lint/suspicious/noExplicitAny: flexible CSV row parsing
232
+ const product: Record<string, any> = {};
233
+
234
+ for (let i = 0; i < headers.length; i++) {
235
+ const header = headers[i].toLowerCase().trim();
236
+ const field = HEADER_MAP[header];
237
+ if (!field || i >= values.length) continue;
238
+
239
+ const val = values[i];
240
+ if (val === "") continue;
241
+
242
+ switch (field) {
243
+ case "price":
244
+ case "compareAtPrice":
245
+ case "costPrice":
246
+ case "weight":
247
+ product[field] = val;
248
+ break;
249
+ case "inventory":
250
+ product[field] = val;
251
+ break;
252
+ case "featured":
253
+ case "trackInventory":
254
+ case "allowBackorder":
255
+ product[field] =
256
+ val.toLowerCase() === "true" ||
257
+ val.toLowerCase() === "yes" ||
258
+ val === "1";
259
+ break;
260
+ case "tags":
261
+ product[field] = val
262
+ .split(/[;|]/)
263
+ .map((t) => t.trim())
264
+ .filter(Boolean);
265
+ break;
266
+ default:
267
+ product[field] = val;
268
+ break;
269
+ }
270
+ }
271
+
272
+ return product;
273
+ }
274
+
275
+ // ─── Import Dialog ───────────────────────────────────────────────────────────
276
+
277
+ function ImportDialog({
278
+ onClose,
279
+ onImport,
280
+ }: {
281
+ onClose: () => void;
282
+ // biome-ignore lint/suspicious/noExplicitAny: CSV row data is untyped
283
+ onImport: (products: Record<string, any>[]) => Promise<ImportResult>;
284
+ }) {
285
+ const fileRef = useRef<HTMLInputElement>(null);
286
+ const [preview, setPreview] = useState<{
287
+ headers: string[];
288
+ // biome-ignore lint/suspicious/noExplicitAny: CSV preview rows are untyped
289
+ rows: Record<string, any>[];
290
+ } | null>(null);
291
+ const [importing, setImporting] = useState(false);
292
+ const [result, setResult] = useState<ImportResult | null>(null);
293
+ const [parseError, setParseError] = useState<string | null>(null);
294
+
295
+ const handleFile = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
296
+ const file = e.target.files?.[0];
297
+ if (!file) return;
298
+
299
+ setParseError(null);
300
+ setResult(null);
301
+
302
+ const reader = new FileReader();
303
+ reader.onload = (ev) => {
304
+ const text = ev.target?.result;
305
+ if (typeof text !== "string") return;
306
+
307
+ const parsed = parseCsv(text);
308
+ if (parsed.length < 2) {
309
+ setParseError("CSV must have at least a header row and one data row.");
310
+ return;
311
+ }
312
+
313
+ const headers = parsed[0];
314
+ const normalizedHeaders = headers.map((h) => h.toLowerCase().trim());
315
+
316
+ // Verify required columns
317
+ const hasName = normalizedHeaders.some((h) => HEADER_MAP[h] === "name");
318
+ const hasPrice = normalizedHeaders.some((h) => HEADER_MAP[h] === "price");
319
+
320
+ if (!hasName || !hasPrice) {
321
+ setParseError('CSV must include at least "Name" and "Price" columns.');
322
+ return;
323
+ }
324
+
325
+ const dataRows = parsed.slice(1);
326
+ const products = dataRows.map((row) => rowToProduct(headers, row));
327
+
328
+ setPreview({ headers, rows: products });
329
+ };
330
+ reader.readAsText(file);
331
+ }, []);
332
+
333
+ const handleImport = useCallback(async () => {
334
+ if (!preview) return;
335
+ setImporting(true);
336
+ try {
337
+ const importResult = await onImport(preview.rows);
338
+ setResult(importResult);
339
+ } finally {
340
+ setImporting(false);
341
+ }
342
+ }, [preview, onImport]);
343
+
344
+ const handleDownloadTemplate = useCallback(() => {
345
+ const sampleRow = [
346
+ "Example Product",
347
+ "example-product",
348
+ "SKU-001",
349
+ "",
350
+ "29.99",
351
+ "39.99",
352
+ "15.00",
353
+ "100",
354
+ "draft",
355
+ "Electronics",
356
+ "tag1;tag2",
357
+ "0.5",
358
+ "kg",
359
+ "false",
360
+ "true",
361
+ "false",
362
+ "A great product for testing",
363
+ "Short desc",
364
+ ];
365
+ downloadCsv("products-template.csv", [CSV_HEADERS, sampleRow]);
366
+ }, []);
367
+
368
+ return (
369
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
370
+ <div className="w-full max-w-2xl rounded-lg border border-border bg-card shadow-xl">
371
+ <div className="flex items-center justify-between border-border border-b px-6 py-4">
372
+ <h2 className="font-semibold text-foreground text-lg">
373
+ Import Products
374
+ </h2>
375
+ <button
376
+ type="button"
377
+ onClick={onClose}
378
+ className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
379
+ >
380
+ <svg
381
+ xmlns="http://www.w3.org/2000/svg"
382
+ width="20"
383
+ height="20"
384
+ viewBox="0 0 24 24"
385
+ fill="none"
386
+ stroke="currentColor"
387
+ strokeWidth="2"
388
+ strokeLinecap="round"
389
+ strokeLinejoin="round"
390
+ aria-hidden="true"
391
+ >
392
+ <path d="M18 6 6 18" />
393
+ <path d="m6 6 12 12" />
394
+ </svg>
395
+ </button>
396
+ </div>
397
+
398
+ <div className="space-y-4 px-6 py-4">
399
+ {!result ? (
400
+ <>
401
+ <div className="space-y-2">
402
+ <p className="text-muted-foreground text-sm">
403
+ Upload a CSV file with product data. Required columns:{" "}
404
+ <strong>Name</strong> and <strong>Price</strong> (in dollars).
405
+ Products with a matching SKU will be updated.
406
+ </p>
407
+ <button
408
+ type="button"
409
+ onClick={handleDownloadTemplate}
410
+ className="text-foreground text-sm underline underline-offset-2 hover:no-underline"
411
+ >
412
+ Download template CSV
413
+ </button>
414
+ </div>
415
+
416
+ <div>
417
+ <input
418
+ ref={fileRef}
419
+ type="file"
420
+ accept=".csv,text/csv"
421
+ onChange={handleFile}
422
+ className="block w-full rounded-md border border-border bg-background px-3 py-2 text-foreground text-sm file:mr-3 file:rounded-md file:border-0 file:bg-muted file:px-3 file:py-1 file:font-medium file:text-foreground file:text-sm"
423
+ />
424
+ </div>
425
+
426
+ {parseError && (
427
+ <p className="text-destructive text-sm">{parseError}</p>
428
+ )}
429
+
430
+ {preview && (
431
+ <div className="space-y-3">
432
+ <p className="font-medium text-foreground text-sm">
433
+ {preview.rows.length}{" "}
434
+ {preview.rows.length === 1 ? "product" : "products"} found
435
+ in CSV
436
+ </p>
437
+
438
+ <div className="max-h-48 overflow-auto rounded-md border border-border">
439
+ <table className="w-full text-xs">
440
+ <thead>
441
+ <tr className="border-border border-b bg-muted/50">
442
+ <th className="px-3 py-2 text-left font-medium text-muted-foreground">
443
+ Row
444
+ </th>
445
+ <th className="px-3 py-2 text-left font-medium text-muted-foreground">
446
+ Name
447
+ </th>
448
+ <th className="px-3 py-2 text-left font-medium text-muted-foreground">
449
+ Price
450
+ </th>
451
+ <th className="px-3 py-2 text-left font-medium text-muted-foreground">
452
+ SKU
453
+ </th>
454
+ <th className="px-3 py-2 text-left font-medium text-muted-foreground">
455
+ Status
456
+ </th>
457
+ </tr>
458
+ </thead>
459
+ <tbody className="divide-y divide-border">
460
+ {preview.rows.slice(0, 10).map((row, i) => (
461
+ <tr key={i}>
462
+ <td className="px-3 py-1.5 text-muted-foreground">
463
+ {i + 1}
464
+ </td>
465
+ <td className="px-3 py-1.5 text-foreground">
466
+ {row.name || "—"}
467
+ </td>
468
+ <td className="px-3 py-1.5 text-foreground">
469
+ ${row.price || "—"}
470
+ </td>
471
+ <td className="px-3 py-1.5 text-muted-foreground">
472
+ {row.sku || "—"}
473
+ </td>
474
+ <td className="px-3 py-1.5 text-muted-foreground">
475
+ {row.status || "draft"}
476
+ </td>
477
+ </tr>
478
+ ))}
479
+ {preview.rows.length > 10 && (
480
+ <tr>
481
+ <td
482
+ colSpan={5}
483
+ className="px-3 py-1.5 text-center text-muted-foreground"
484
+ >
485
+ ...and {preview.rows.length - 10} more
486
+ </td>
487
+ </tr>
488
+ )}
489
+ </tbody>
490
+ </table>
491
+ </div>
492
+
493
+ <button
494
+ type="button"
495
+ onClick={() => void handleImport()}
496
+ disabled={importing}
497
+ className="rounded-md bg-foreground px-4 py-2 font-semibold text-background text-sm transition-opacity hover:opacity-90 disabled:opacity-50"
498
+ >
499
+ {importing
500
+ ? "Importing..."
501
+ : `Import ${preview.rows.length} products`}
502
+ </button>
503
+ </div>
504
+ )}
505
+ </>
506
+ ) : (
507
+ <div className="space-y-3">
508
+ <div className="space-y-1">
509
+ {result.created > 0 && (
510
+ <p className="font-medium text-emerald-600 text-sm dark:text-emerald-400">
511
+ {result.created}{" "}
512
+ {result.created === 1 ? "product" : "products"} created
513
+ </p>
514
+ )}
515
+ {result.updated > 0 && (
516
+ <p className="font-medium text-blue-600 text-sm dark:text-blue-400">
517
+ {result.updated}{" "}
518
+ {result.updated === 1 ? "product" : "products"} updated
519
+ </p>
520
+ )}
521
+ {result.errors.length > 0 && (
522
+ <div>
523
+ <p className="font-medium text-destructive text-sm">
524
+ {result.errors.length}{" "}
525
+ {result.errors.length === 1 ? "error" : "errors"}
526
+ </p>
527
+ <ul className="mt-1 list-inside list-disc text-destructive text-xs">
528
+ {result.errors.map((err, i) => (
529
+ <li key={i}>
530
+ Row {err.row}: {err.message}
531
+ {err.field !== "unknown" && ` (${err.field})`}
532
+ </li>
533
+ ))}
534
+ </ul>
535
+ </div>
536
+ )}
537
+ {result.created === 0 &&
538
+ result.updated === 0 &&
539
+ result.errors.length === 0 && (
540
+ <p className="text-muted-foreground text-sm">
541
+ No products were imported.
542
+ </p>
543
+ )}
544
+ </div>
545
+
546
+ <button
547
+ type="button"
548
+ onClick={onClose}
549
+ className="rounded-md bg-foreground px-4 py-2 font-semibold text-background text-sm transition-opacity hover:opacity-90"
550
+ >
551
+ Done
552
+ </button>
553
+ </div>
554
+ )}
555
+ </div>
556
+ </div>
557
+ </div>
558
+ );
559
+ }
560
+
561
+ // ─── ProductList ──────────────────────────────────────────────────────────────
562
+
563
+ export function ProductList() {
564
+ const api = useProductsAdminApi();
565
+
566
+ const [page, setPage] = useState(1);
567
+ const [search, setSearch] = useState("");
568
+ const [status, setStatus] = useState("");
569
+ const [category, setCategory] = useState("");
570
+ const [deleting, setDeleting] = useState<string | null>(null);
571
+ const [exporting, setExporting] = useState(false);
572
+ const [showImport, setShowImport] = useState(false);
573
+ const [selected, setSelected] = useState<Set<string>>(new Set());
574
+ const [bulkProcessing, setBulkProcessing] = useState(false);
575
+
576
+ const limit = 20;
577
+
578
+ // biome-ignore lint/suspicious/noExplicitAny: casting untyped query result
579
+ const queryInput: any = {
580
+ page: String(page),
581
+ limit: String(limit),
582
+ sort: "createdAt",
583
+ order: "desc",
584
+ };
585
+ if (search) queryInput.search = search;
586
+ if (status) queryInput.status = status;
587
+ if (category) queryInput.category = category;
588
+
589
+ const { data: productsData, isLoading: loading } = api.listProducts.useQuery(
590
+ queryInput,
591
+ ) as { data: ListResult | undefined; isLoading: boolean };
592
+
593
+ const { data: categoriesData } = api.listCategories.useQuery({
594
+ limit: "100",
595
+ }) as { data: CategoriesResult | undefined; isLoading: boolean };
596
+
597
+ const deleteMutation = api.deleteProduct.useMutation({
598
+ onSettled: () => {
599
+ setDeleting(null);
600
+ void api.listProducts.invalidate();
601
+ },
602
+ });
603
+
604
+ const bulkMutation = api.bulkAction.useMutation({
605
+ onSettled: () => {
606
+ setBulkProcessing(false);
607
+ setSelected(new Set());
608
+ void api.listProducts.invalidate();
609
+ },
610
+ });
611
+
612
+ const products = productsData?.products ?? [];
613
+ const total = productsData?.total ?? 0;
614
+ const categories = categoriesData?.categories ?? [];
615
+ const totalPages = Math.ceil(total / limit);
616
+ const allOnPageSelected =
617
+ products.length > 0 && products.every((p) => selected.has(p.id));
618
+
619
+ const toggleSelect = (id: string) => {
620
+ setSelected((prev) => {
621
+ const next = new Set(prev);
622
+ if (next.has(id)) {
623
+ next.delete(id);
624
+ } else {
625
+ next.add(id);
626
+ }
627
+ return next;
628
+ });
629
+ };
630
+
631
+ const toggleSelectAll = () => {
632
+ if (allOnPageSelected) {
633
+ setSelected((prev) => {
634
+ const next = new Set(prev);
635
+ for (const p of products) next.delete(p.id);
636
+ return next;
637
+ });
638
+ } else {
639
+ setSelected((prev) => {
640
+ const next = new Set(prev);
641
+ for (const p of products) next.add(p.id);
642
+ return next;
643
+ });
644
+ }
645
+ };
646
+
647
+ const handleBulkStatus = (newStatus: "draft" | "active" | "archived") => {
648
+ if (selected.size === 0) return;
649
+ setBulkProcessing(true);
650
+ bulkMutation.mutate({
651
+ action: "updateStatus",
652
+ ids: Array.from(selected),
653
+ status: newStatus,
654
+ });
655
+ };
656
+
657
+ const handleBulkDelete = () => {
658
+ if (selected.size === 0) return;
659
+ if (
660
+ !window.confirm(
661
+ `Are you sure you want to delete ${selected.size} ${selected.size === 1 ? "product" : "products"}?`,
662
+ )
663
+ ) {
664
+ return;
665
+ }
666
+ setBulkProcessing(true);
667
+ bulkMutation.mutate({
668
+ action: "delete",
669
+ ids: Array.from(selected),
670
+ });
671
+ };
672
+
673
+ // Build a category lookup for export
674
+ const categoryNameById = new Map<string, string>();
675
+ for (const c of categories) {
676
+ categoryNameById.set(c.id, c.name);
677
+ }
678
+
679
+ const handleDelete = (id: string) => {
680
+ if (!window.confirm("Are you sure you want to delete this product?")) {
681
+ return;
682
+ }
683
+ setDeleting(id);
684
+ deleteMutation.mutate({ params: { id } });
685
+ };
686
+
687
+ const handleExport = useCallback(async () => {
688
+ setExporting(true);
689
+ try {
690
+ const exportQuery: Record<string, string> = { limit: "500" };
691
+ if (search) exportQuery.search = search;
692
+ if (status) exportQuery.status = status;
693
+ if (category) exportQuery.category = category;
694
+
695
+ const result = (await api.listProducts.fetch(exportQuery)) as
696
+ | ListResult
697
+ | undefined;
698
+ const exportProducts = result?.products ?? [];
699
+
700
+ if (exportProducts.length === 0) return;
701
+
702
+ const dataRows = exportProducts.map((p) => [
703
+ p.name,
704
+ p.slug,
705
+ p.sku ?? "",
706
+ p.barcode ?? "",
707
+ (p.price / 100).toFixed(2),
708
+ p.compareAtPrice ? (p.compareAtPrice / 100).toFixed(2) : "",
709
+ p.costPrice ? (p.costPrice / 100).toFixed(2) : "",
710
+ String(p.inventory),
711
+ p.status,
712
+ p.categoryId ? (categoryNameById.get(p.categoryId) ?? "") : "",
713
+ (p.tags ?? []).join(";"),
714
+ p.weight != null ? String(p.weight) : "",
715
+ p.weightUnit ?? "",
716
+ String(p.isFeatured),
717
+ String(p.trackInventory ?? true),
718
+ String(p.allowBackorder ?? false),
719
+ p.description ?? "",
720
+ p.shortDescription ?? "",
721
+ ]);
722
+
723
+ const dateStr = new Date().toISOString().slice(0, 10);
724
+ downloadCsv(`products-${dateStr}.csv`, [CSV_HEADERS, ...dataRows]);
725
+ } finally {
726
+ setExporting(false);
727
+ }
728
+ }, [api.listProducts, search, status, category, categoryNameById]);
729
+
730
+ const handleImport = useCallback(
731
+ // biome-ignore lint/suspicious/noExplicitAny: CSV row data
732
+ async (rows: Record<string, any>[]): Promise<ImportResult> => {
733
+ const result = (await api.importProducts.fetch({
734
+ products: rows,
735
+ })) as ImportResult;
736
+ void api.listProducts.invalidate();
737
+ return result;
738
+ },
739
+ [api.importProducts, api.listProducts],
740
+ );
741
+
742
+ const content = (
743
+ <div>
744
+ {/* Header */}
745
+ <div className="mb-6 flex items-center justify-between">
746
+ <div>
747
+ <h1 className="font-semibold text-foreground text-lg">Products</h1>
748
+ {total > 0 && (
749
+ <p className="mt-1 text-muted-foreground text-sm">
750
+ {total} {total === 1 ? "product" : "products"} total
751
+ </p>
752
+ )}
753
+ </div>
754
+ <div className="flex items-center gap-2">
755
+ <button
756
+ type="button"
757
+ onClick={() => setShowImport(true)}
758
+ className="flex items-center gap-2 rounded-md border border-border px-3 py-2 font-medium text-foreground text-sm transition-colors hover:bg-muted"
759
+ >
760
+ <svg
761
+ xmlns="http://www.w3.org/2000/svg"
762
+ width="16"
763
+ height="16"
764
+ viewBox="0 0 24 24"
765
+ fill="none"
766
+ stroke="currentColor"
767
+ strokeWidth="2"
768
+ strokeLinecap="round"
769
+ strokeLinejoin="round"
770
+ aria-hidden="true"
771
+ >
772
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
773
+ <polyline points="17 8 12 3 7 8" />
774
+ <line x1="12" x2="12" y1="3" y2="15" />
775
+ </svg>
776
+ Import
777
+ </button>
778
+ <button
779
+ type="button"
780
+ disabled={exporting || total === 0}
781
+ onClick={() => void handleExport()}
782
+ className="flex items-center gap-2 rounded-md border border-border px-3 py-2 font-medium text-foreground text-sm transition-colors hover:bg-muted disabled:opacity-50"
783
+ >
784
+ <svg
785
+ xmlns="http://www.w3.org/2000/svg"
786
+ width="16"
787
+ height="16"
788
+ viewBox="0 0 24 24"
789
+ fill="none"
790
+ stroke="currentColor"
791
+ strokeWidth="2"
792
+ strokeLinecap="round"
793
+ strokeLinejoin="round"
794
+ aria-hidden="true"
795
+ >
796
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
797
+ <polyline points="7 10 12 15 17 10" />
798
+ <line x1="12" x2="12" y1="15" y2="3" />
799
+ </svg>
800
+ {exporting ? "Exporting..." : "Export"}
801
+ </button>
802
+ <a
803
+ href="/admin/products/new"
804
+ className="rounded-md bg-foreground px-4 py-2 font-semibold text-background text-sm transition-opacity hover:opacity-90"
805
+ >
806
+ New product
807
+ </a>
808
+ </div>
809
+ </div>
810
+
811
+ {/* Filters */}
812
+ <div className="mb-4 flex flex-wrap items-center gap-3">
813
+ <input
814
+ type="search"
815
+ value={search}
816
+ onChange={(e) => {
817
+ setSearch(e.target.value);
818
+ setPage(1);
819
+ }}
820
+ placeholder="Search products..."
821
+ className="min-w-[200px] flex-1 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"
822
+ />
823
+
824
+ <select
825
+ value={status}
826
+ onChange={(e) => {
827
+ setStatus(e.target.value);
828
+ setPage(1);
829
+ }}
830
+ className="rounded-md border border-border bg-background px-3 py-2 text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
831
+ >
832
+ <option value="">All statuses</option>
833
+ <option value="draft">Draft</option>
834
+ <option value="active">Active</option>
835
+ <option value="archived">Archived</option>
836
+ </select>
837
+
838
+ {categories.length > 0 && (
839
+ <select
840
+ value={category}
841
+ onChange={(e) => {
842
+ setCategory(e.target.value);
843
+ setPage(1);
844
+ }}
845
+ className="rounded-md border border-border bg-background px-3 py-2 text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring"
846
+ >
847
+ <option value="">All categories</option>
848
+ {categories.map((c) => (
849
+ <option key={c.id} value={c.id}>
850
+ {c.name}
851
+ </option>
852
+ ))}
853
+ </select>
854
+ )}
855
+ </div>
856
+
857
+ {/* Bulk Action Bar */}
858
+ {selected.size > 0 && (
859
+ <div className="mb-4 flex items-center gap-3 rounded-lg border border-border bg-muted/50 px-4 py-3">
860
+ <span className="font-medium text-foreground text-sm">
861
+ {selected.size} {selected.size === 1 ? "product" : "products"}{" "}
862
+ selected
863
+ </span>
864
+ <div className="ml-auto flex items-center gap-2">
865
+ <button
866
+ type="button"
867
+ disabled={bulkProcessing}
868
+ onClick={() => handleBulkStatus("active")}
869
+ className="rounded-md bg-emerald-600 px-3 py-1.5 font-medium text-sm text-white transition-opacity hover:opacity-90 disabled:opacity-50"
870
+ >
871
+ Set Active
872
+ </button>
873
+ <button
874
+ type="button"
875
+ disabled={bulkProcessing}
876
+ onClick={() => handleBulkStatus("draft")}
877
+ className="rounded-md border border-border px-3 py-1.5 font-medium text-foreground text-sm transition-colors hover:bg-muted disabled:opacity-50"
878
+ >
879
+ Set Draft
880
+ </button>
881
+ <button
882
+ type="button"
883
+ disabled={bulkProcessing}
884
+ onClick={() => handleBulkStatus("archived")}
885
+ className="rounded-md border border-border px-3 py-1.5 font-medium text-foreground text-sm transition-colors hover:bg-muted disabled:opacity-50"
886
+ >
887
+ Archive
888
+ </button>
889
+ <button
890
+ type="button"
891
+ disabled={bulkProcessing}
892
+ onClick={handleBulkDelete}
893
+ className="rounded-md bg-destructive px-3 py-1.5 font-medium text-destructive-foreground text-sm transition-opacity hover:opacity-90 disabled:opacity-50"
894
+ >
895
+ {bulkProcessing ? "Processing..." : "Delete"}
896
+ </button>
897
+ <button
898
+ type="button"
899
+ onClick={() => setSelected(new Set())}
900
+ className="rounded-md px-3 py-1.5 text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground"
901
+ >
902
+ Cancel
903
+ </button>
904
+ </div>
905
+ </div>
906
+ )}
907
+
908
+ {/* Table */}
909
+ <div className="overflow-hidden rounded-lg border border-border bg-card">
910
+ <div className="overflow-x-auto">
911
+ <table className="w-full text-sm">
912
+ <thead>
913
+ <tr className="border-border border-b bg-muted/50">
914
+ <th className="w-10 px-4 py-3">
915
+ <input
916
+ type="checkbox"
917
+ checked={allOnPageSelected}
918
+ onChange={toggleSelectAll}
919
+ disabled={products.length === 0}
920
+ className="h-4 w-4 rounded border-border text-foreground"
921
+ aria-label="Select all products on this page"
922
+ />
923
+ </th>
924
+ <th className="px-4 py-3 text-left font-medium text-muted-foreground text-xs uppercase tracking-wider">
925
+ Image
926
+ </th>
927
+ <th className="px-4 py-3 text-left font-medium text-muted-foreground text-xs uppercase tracking-wider">
928
+ Name
929
+ </th>
930
+ <th className="px-4 py-3 text-left font-medium text-muted-foreground text-xs uppercase tracking-wider">
931
+ Status
932
+ </th>
933
+ <th className="px-4 py-3 text-left font-medium text-muted-foreground text-xs uppercase tracking-wider">
934
+ Price
935
+ </th>
936
+ <th className="px-4 py-3 text-left font-medium text-muted-foreground text-xs uppercase tracking-wider">
937
+ Inventory
938
+ </th>
939
+ <th className="px-4 py-3 text-left font-medium text-muted-foreground text-xs uppercase tracking-wider">
940
+ Created
941
+ </th>
942
+ <th className="px-4 py-3 text-right font-medium text-muted-foreground text-xs uppercase tracking-wider">
943
+ Actions
944
+ </th>
945
+ </tr>
946
+ </thead>
947
+ <tbody className="divide-y divide-border">
948
+ {loading ? (
949
+ Array.from({ length: 5 }).map((_, i) => (
950
+ <tr key={i}>
951
+ <td className="px-4 py-3">
952
+ <div className="h-4 w-4 animate-pulse rounded bg-muted" />
953
+ </td>
954
+ <td className="px-4 py-3">
955
+ <div className="h-10 w-10 animate-pulse rounded-md bg-muted" />
956
+ </td>
957
+ <td className="px-4 py-3">
958
+ <div className="h-4 w-32 animate-pulse rounded bg-muted" />
959
+ </td>
960
+ <td className="px-4 py-3">
961
+ <div className="h-5 w-16 animate-pulse rounded-full bg-muted" />
962
+ </td>
963
+ <td className="px-4 py-3">
964
+ <div className="h-4 w-16 animate-pulse rounded bg-muted" />
965
+ </td>
966
+ <td className="px-4 py-3">
967
+ <div className="h-4 w-10 animate-pulse rounded bg-muted" />
968
+ </td>
969
+ <td className="px-4 py-3">
970
+ <div className="h-4 w-20 animate-pulse rounded bg-muted" />
971
+ </td>
972
+ <td className="px-4 py-3">
973
+ <div className="h-4 w-16 animate-pulse rounded bg-muted" />
974
+ </td>
975
+ </tr>
976
+ ))
977
+ ) : products.length === 0 ? (
978
+ <tr>
979
+ <td
980
+ colSpan={8}
981
+ className="px-4 py-12 text-center text-muted-foreground"
982
+ >
983
+ <p className="font-medium text-foreground">
984
+ No products found
985
+ </p>
986
+ <p className="mt-1 text-sm">
987
+ Try adjusting your filters or create a new product.
988
+ </p>
989
+ </td>
990
+ </tr>
991
+ ) : (
992
+ products.map((product) => (
993
+ <tr
994
+ key={product.id}
995
+ className={`transition-colors hover:bg-muted/30 ${selected.has(product.id) ? "bg-muted/20" : ""}`}
996
+ >
997
+ <td className="px-4 py-3">
998
+ <input
999
+ type="checkbox"
1000
+ checked={selected.has(product.id)}
1001
+ onChange={() => toggleSelect(product.id)}
1002
+ className="h-4 w-4 rounded border-border text-foreground"
1003
+ aria-label={`Select ${product.name}`}
1004
+ />
1005
+ </td>
1006
+ <td className="px-4 py-3">
1007
+ <div className="h-10 w-10 flex-shrink-0 overflow-hidden rounded-md border border-border bg-muted">
1008
+ {product.images[0] ? (
1009
+ <img
1010
+ src={product.images[0]}
1011
+ alt={product.name}
1012
+ className="h-full w-full object-cover object-center"
1013
+ />
1014
+ ) : (
1015
+ <div className="flex h-full w-full items-center justify-center text-muted-foreground">
1016
+ <svg
1017
+ xmlns="http://www.w3.org/2000/svg"
1018
+ width="16"
1019
+ height="16"
1020
+ viewBox="0 0 24 24"
1021
+ fill="none"
1022
+ stroke="currentColor"
1023
+ strokeWidth="1.5"
1024
+ strokeLinecap="round"
1025
+ strokeLinejoin="round"
1026
+ aria-hidden="true"
1027
+ >
1028
+ <rect width="18" height="18" x="3" y="3" rx="2" />
1029
+ <circle cx="9" cy="9" r="2" />
1030
+ <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
1031
+ </svg>
1032
+ </div>
1033
+ )}
1034
+ </div>
1035
+ </td>
1036
+ <td className="px-4 py-3">
1037
+ <a
1038
+ href={`/admin/products/${product.id}`}
1039
+ className="font-medium text-foreground hover:underline"
1040
+ >
1041
+ {product.name}
1042
+ </a>
1043
+ </td>
1044
+ <td className="px-4 py-3">
1045
+ <span
1046
+ className={`inline-flex rounded-full px-2 py-0.5 font-medium text-xs capitalize ${
1047
+ statusStyles[product.status] ?? statusStyles.draft
1048
+ }`}
1049
+ >
1050
+ {product.status}
1051
+ </span>
1052
+ </td>
1053
+ <td className="px-4 py-3 text-foreground">
1054
+ {formatPrice(product.price)}
1055
+ </td>
1056
+ <td className="px-4 py-3 text-foreground">
1057
+ {product.inventory}
1058
+ </td>
1059
+ <td className="px-4 py-3 text-muted-foreground">
1060
+ {formatDate(product.createdAt)}
1061
+ </td>
1062
+ <td className="px-4 py-3 text-right">
1063
+ <div className="flex items-center justify-end gap-2">
1064
+ <a
1065
+ href={`/admin/products/${product.id}/edit`}
1066
+ className="rounded-md px-2 py-1 text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground"
1067
+ title="Edit product"
1068
+ >
1069
+ Edit
1070
+ </a>
1071
+ <button
1072
+ type="button"
1073
+ onClick={() => handleDelete(product.id)}
1074
+ disabled={deleting === product.id}
1075
+ className="rounded-md px-2 py-1 text-destructive text-xs transition-colors hover:bg-destructive/10 disabled:opacity-50"
1076
+ >
1077
+ {deleting === product.id ? "Deleting..." : "Delete"}
1078
+ </button>
1079
+ </div>
1080
+ </td>
1081
+ </tr>
1082
+ ))
1083
+ )}
1084
+ </tbody>
1085
+ </table>
1086
+ </div>
1087
+ </div>
1088
+
1089
+ {/* Pagination */}
1090
+ {totalPages > 1 && (
1091
+ <div className="mt-4 flex items-center justify-center gap-2">
1092
+ <button
1093
+ type="button"
1094
+ onClick={() => setPage((p) => Math.max(1, p - 1))}
1095
+ disabled={page === 1}
1096
+ className="rounded-md border border-border px-3 py-1.5 text-foreground text-sm hover:bg-muted disabled:opacity-50"
1097
+ >
1098
+ Previous
1099
+ </button>
1100
+ <span className="text-muted-foreground text-sm">
1101
+ Page {page} of {totalPages}
1102
+ </span>
1103
+ <button
1104
+ type="button"
1105
+ onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
1106
+ disabled={page === totalPages}
1107
+ className="rounded-md border border-border px-3 py-1.5 text-foreground text-sm hover:bg-muted disabled:opacity-50"
1108
+ >
1109
+ Next
1110
+ </button>
1111
+ </div>
1112
+ )}
1113
+
1114
+ {/* Import Dialog */}
1115
+ {showImport && (
1116
+ <ImportDialog
1117
+ onClose={() => setShowImport(false)}
1118
+ onImport={handleImport}
1119
+ />
1120
+ )}
1121
+ </div>
1122
+ );
1123
+
1124
+ return <ProductListTemplate content={content} />;
1125
+ }