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