@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,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
|
+
}
|