@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.
- package/AGENTS.md +65 -0
- package/COMPONENTS.md +231 -0
- package/README.md +201 -0
- package/package.json +46 -0
- package/src/__tests__/controllers.test.ts +2227 -0
- package/src/__tests__/state.test.ts +138 -0
- package/src/admin/components/categories-admin.mdx +3 -0
- package/src/admin/components/categories-admin.tsx +449 -0
- package/src/admin/components/category-form.mdx +9 -0
- package/src/admin/components/category-form.tsx +490 -0
- package/src/admin/components/category-list.mdx +75 -0
- package/src/admin/components/category-list.tsx +168 -0
- package/src/admin/components/collections-admin.mdx +3 -0
- package/src/admin/components/collections-admin.tsx +771 -0
- package/src/admin/components/index.tsx +8 -0
- package/src/admin/components/product-detail.mdx +12 -0
- package/src/admin/components/product-detail.tsx +790 -0
- package/src/admin/components/product-edit.tsx +60 -0
- package/src/admin/components/product-form.tsx +793 -0
- package/src/admin/components/product-list.mdx +3 -0
- package/src/admin/components/product-list.tsx +1125 -0
- package/src/admin/components/product-new.tsx +38 -0
- package/src/admin/endpoints/add-collection-product.ts +17 -0
- package/src/admin/endpoints/bulk-action.ts +43 -0
- package/src/admin/endpoints/create-category.ts +52 -0
- package/src/admin/endpoints/create-collection.ts +35 -0
- package/src/admin/endpoints/create-product.ts +50 -0
- package/src/admin/endpoints/create-variant.ts +45 -0
- package/src/admin/endpoints/delete-category.ts +27 -0
- package/src/admin/endpoints/delete-collection.ts +12 -0
- package/src/admin/endpoints/delete-product.ts +27 -0
- package/src/admin/endpoints/delete-variant.ts +27 -0
- package/src/admin/endpoints/get-product.ts +23 -0
- package/src/admin/endpoints/import-products.ts +47 -0
- package/src/admin/endpoints/index.ts +43 -0
- package/src/admin/endpoints/list-categories.ts +21 -0
- package/src/admin/endpoints/list-collections.ts +20 -0
- package/src/admin/endpoints/list-products.ts +25 -0
- package/src/admin/endpoints/remove-collection-product.ts +15 -0
- package/src/admin/endpoints/update-category.ts +82 -0
- package/src/admin/endpoints/update-collection.ts +22 -0
- package/src/admin/endpoints/update-product.ts +67 -0
- package/src/admin/endpoints/update-variant.ts +41 -0
- package/src/controllers.ts +1410 -0
- package/src/index.ts +120 -0
- package/src/markdown.ts +150 -0
- package/src/mdx.d.ts +5 -0
- package/src/schema.ts +352 -0
- package/src/state.ts +84 -0
- package/src/store/components/_hooks.ts +78 -0
- package/src/store/components/_types.ts +73 -0
- package/src/store/components/_utils.ts +14 -0
- package/src/store/components/back-in-stock-notify.tsx +97 -0
- package/src/store/components/collection-card.mdx +42 -0
- package/src/store/components/collection-card.tsx +12 -0
- package/src/store/components/collection-detail.mdx +12 -0
- package/src/store/components/collection-detail.tsx +149 -0
- package/src/store/components/collection-grid.mdx +9 -0
- package/src/store/components/collection-grid.tsx +80 -0
- package/src/store/components/featured-products.mdx +9 -0
- package/src/store/components/featured-products.tsx +75 -0
- package/src/store/components/filter-chip.mdx +25 -0
- package/src/store/components/filter-chip.tsx +12 -0
- package/src/store/components/index.tsx +39 -0
- package/src/store/components/product-card.mdx +69 -0
- package/src/store/components/product-card.tsx +71 -0
- package/src/store/components/product-detail.mdx +30 -0
- package/src/store/components/product-detail.tsx +488 -0
- package/src/store/components/product-listing.mdx +7 -0
- package/src/store/components/product-listing.tsx +423 -0
- package/src/store/components/product-reviews-section.mdx +21 -0
- package/src/store/components/product-reviews-section.tsx +372 -0
- package/src/store/components/recently-viewed.tsx +100 -0
- package/src/store/components/related-products.mdx +6 -0
- package/src/store/components/related-products.tsx +62 -0
- package/src/store/components/star-display.mdx +18 -0
- package/src/store/components/star-display.tsx +27 -0
- package/src/store/components/star-picker.mdx +21 -0
- package/src/store/components/star-picker.tsx +21 -0
- package/src/store/components/stock-badge.mdx +12 -0
- package/src/store/components/stock-badge.tsx +19 -0
- package/src/store/endpoints/get-category.ts +61 -0
- package/src/store/endpoints/get-collection.ts +46 -0
- package/src/store/endpoints/get-featured.ts +18 -0
- package/src/store/endpoints/get-product.ts +52 -0
- package/src/store/endpoints/get-related.ts +20 -0
- package/src/store/endpoints/index.ts +23 -0
- package/src/store/endpoints/list-categories.ts +13 -0
- package/src/store/endpoints/list-collections.ts +22 -0
- package/src/store/endpoints/list-products.ts +28 -0
- package/src/store/endpoints/search-products.ts +18 -0
- package/src/store/endpoints/store-search.ts +111 -0
- package/tsconfig.json +9 -0
- 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
|
+
}
|