@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,490 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useModuleClient } from "@86d-app/core/client";
|
|
4
|
+
import { useCallback, useRef, useState } from "react";
|
|
5
|
+
import CategoryFormTemplate from "./category-form.mdx";
|
|
6
|
+
|
|
7
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
interface Category {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
slug: string;
|
|
13
|
+
description?: string | null;
|
|
14
|
+
parentId?: string | null;
|
|
15
|
+
image?: string | null;
|
|
16
|
+
position: number;
|
|
17
|
+
isVisible: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface CategoryFormData {
|
|
21
|
+
name: string;
|
|
22
|
+
slug: string;
|
|
23
|
+
description: string;
|
|
24
|
+
parentId: string;
|
|
25
|
+
image: string;
|
|
26
|
+
position: string;
|
|
27
|
+
isVisible: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface CategoryFormProps {
|
|
31
|
+
categoryId?: string;
|
|
32
|
+
onSuccess?: () => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface CategoriesResult {
|
|
36
|
+
categories: Category[];
|
|
37
|
+
total: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── Module Client ───────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
function useCategoriesAdminApi() {
|
|
43
|
+
const client = useModuleClient();
|
|
44
|
+
return {
|
|
45
|
+
listCategories: client.module("products").admin["/admin/categories/list"],
|
|
46
|
+
createCategory: client.module("products").admin["/admin/categories/create"],
|
|
47
|
+
updateCategory:
|
|
48
|
+
client.module("products").admin["/admin/categories/:id/update"],
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
function extractError(error: Error | null, fallback: string): string {
|
|
55
|
+
if (!error) return fallback;
|
|
56
|
+
// biome-ignore lint/suspicious/noExplicitAny: accessing HTTP error body property
|
|
57
|
+
const body = (error as any)?.body;
|
|
58
|
+
if (typeof body?.error === "string") return body.error;
|
|
59
|
+
if (typeof body?.error?.message === "string") return body.error.message;
|
|
60
|
+
return fallback;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function slugify(str: string): string {
|
|
64
|
+
return str
|
|
65
|
+
.toLowerCase()
|
|
66
|
+
.replace(/[^\w\s-]/g, "")
|
|
67
|
+
.replace(/[\s_]+/g, "-")
|
|
68
|
+
.replace(/^-+|-+$/g, "");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const defaultForm: CategoryFormData = {
|
|
72
|
+
name: "",
|
|
73
|
+
slug: "",
|
|
74
|
+
description: "",
|
|
75
|
+
parentId: "",
|
|
76
|
+
image: "",
|
|
77
|
+
position: "0",
|
|
78
|
+
isVisible: true,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// ─── CategoryForm ─────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
export function CategoryForm({ categoryId, onSuccess }: CategoryFormProps) {
|
|
84
|
+
const api = useCategoriesAdminApi();
|
|
85
|
+
const isEditing = Boolean(categoryId);
|
|
86
|
+
|
|
87
|
+
const [form, setForm] = useState<CategoryFormData>(defaultForm);
|
|
88
|
+
const [initialized, setInitialized] = useState(false);
|
|
89
|
+
const [error, setError] = useState<string | null>(null);
|
|
90
|
+
const [slugEdited, setSlugEdited] = useState(false);
|
|
91
|
+
|
|
92
|
+
// Fetch all categories (also provides the editing category data)
|
|
93
|
+
const { data: categoriesData, isLoading: loading } =
|
|
94
|
+
api.listCategories.useQuery({
|
|
95
|
+
limit: "100",
|
|
96
|
+
}) as { data: CategoriesResult | undefined; isLoading: boolean };
|
|
97
|
+
|
|
98
|
+
const categories = (categoriesData?.categories ?? []).filter(
|
|
99
|
+
(c) => c.id !== categoryId,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Populate form when editing and data arrives
|
|
103
|
+
if (isEditing && categoryId && categoriesData && !initialized) {
|
|
104
|
+
const c = categoriesData.categories.find(
|
|
105
|
+
(cat: Category) => cat.id === categoryId,
|
|
106
|
+
);
|
|
107
|
+
if (c) {
|
|
108
|
+
setForm({
|
|
109
|
+
name: c.name,
|
|
110
|
+
slug: c.slug,
|
|
111
|
+
description: c.description ?? "",
|
|
112
|
+
parentId: c.parentId ?? "",
|
|
113
|
+
image: c.image ?? "",
|
|
114
|
+
position: String(c.position),
|
|
115
|
+
isVisible: c.isVisible,
|
|
116
|
+
});
|
|
117
|
+
setSlugEdited(true);
|
|
118
|
+
}
|
|
119
|
+
setInitialized(true);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const createMutation = api.createCategory.useMutation({
|
|
123
|
+
onSuccess: () => {
|
|
124
|
+
void api.listCategories.invalidate();
|
|
125
|
+
onSuccess?.();
|
|
126
|
+
},
|
|
127
|
+
onError: (err: Error) => {
|
|
128
|
+
setError(extractError(err, "Failed to save category"));
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const updateMutation = api.updateCategory.useMutation({
|
|
133
|
+
onSuccess: () => {
|
|
134
|
+
void api.listCategories.invalidate();
|
|
135
|
+
onSuccess?.();
|
|
136
|
+
},
|
|
137
|
+
onError: (err: Error) => {
|
|
138
|
+
setError(extractError(err, "Failed to save category"));
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const saving = createMutation.isPending || updateMutation.isPending;
|
|
143
|
+
|
|
144
|
+
const setField = useCallback(
|
|
145
|
+
<K extends keyof CategoryFormData>(
|
|
146
|
+
field: K,
|
|
147
|
+
value: CategoryFormData[K],
|
|
148
|
+
) => {
|
|
149
|
+
setForm((prev) => {
|
|
150
|
+
const next = { ...prev, [field]: value };
|
|
151
|
+
if (field === "name" && !slugEdited) {
|
|
152
|
+
next.slug = slugify(value as string);
|
|
153
|
+
}
|
|
154
|
+
return next;
|
|
155
|
+
});
|
|
156
|
+
},
|
|
157
|
+
[slugEdited],
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
161
|
+
e.preventDefault();
|
|
162
|
+
setError(null);
|
|
163
|
+
|
|
164
|
+
if (!form.name.trim()) {
|
|
165
|
+
setError("Name is required");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (!form.slug.trim()) {
|
|
169
|
+
setError("Slug is required");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const body = {
|
|
174
|
+
name: form.name.trim(),
|
|
175
|
+
slug: form.slug.trim(),
|
|
176
|
+
description: form.description.trim() || undefined,
|
|
177
|
+
parentId: form.parentId || undefined,
|
|
178
|
+
image: form.image.trim() || undefined,
|
|
179
|
+
position: Number.parseInt(form.position, 10) || 0,
|
|
180
|
+
isVisible: form.isVisible,
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
if (isEditing && categoryId) {
|
|
184
|
+
updateMutation.mutate({ params: { id: categoryId }, ...body });
|
|
185
|
+
} else {
|
|
186
|
+
createMutation.mutate(body);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
if (loading) {
|
|
191
|
+
return (
|
|
192
|
+
<div className="space-y-4">
|
|
193
|
+
{Array.from({ length: 5 }).map((_, i) => (
|
|
194
|
+
<div key={i} className="h-12 animate-pulse rounded-md bg-muted" />
|
|
195
|
+
))}
|
|
196
|
+
</div>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const formContent = (
|
|
201
|
+
<>
|
|
202
|
+
<div className="rounded-lg border border-border bg-card p-5">
|
|
203
|
+
<h2 className="mb-4 font-semibold text-foreground text-sm">
|
|
204
|
+
{isEditing ? "Edit category" : "New category"}
|
|
205
|
+
</h2>
|
|
206
|
+
<div className="space-y-4">
|
|
207
|
+
{/* Name */}
|
|
208
|
+
<div>
|
|
209
|
+
<label
|
|
210
|
+
htmlFor="cat-name"
|
|
211
|
+
className="mb-1.5 block font-medium text-foreground text-sm"
|
|
212
|
+
>
|
|
213
|
+
Name <span className="text-destructive">*</span>
|
|
214
|
+
</label>
|
|
215
|
+
<input
|
|
216
|
+
id="cat-name"
|
|
217
|
+
type="text"
|
|
218
|
+
value={form.name}
|
|
219
|
+
onChange={(e) => setField("name", e.target.value)}
|
|
220
|
+
placeholder="Category name"
|
|
221
|
+
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"
|
|
222
|
+
required
|
|
223
|
+
/>
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
{/* Slug */}
|
|
227
|
+
<div>
|
|
228
|
+
<label
|
|
229
|
+
htmlFor="cat-slug"
|
|
230
|
+
className="mb-1.5 block font-medium text-foreground text-sm"
|
|
231
|
+
>
|
|
232
|
+
Slug <span className="text-destructive">*</span>
|
|
233
|
+
</label>
|
|
234
|
+
<input
|
|
235
|
+
id="cat-slug"
|
|
236
|
+
type="text"
|
|
237
|
+
value={form.slug}
|
|
238
|
+
onChange={(e) => {
|
|
239
|
+
setSlugEdited(true);
|
|
240
|
+
setField("slug", e.target.value);
|
|
241
|
+
}}
|
|
242
|
+
placeholder="category-slug"
|
|
243
|
+
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"
|
|
244
|
+
required
|
|
245
|
+
/>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
{/* Description */}
|
|
249
|
+
<div>
|
|
250
|
+
<label
|
|
251
|
+
htmlFor="cat-desc"
|
|
252
|
+
className="mb-1.5 block font-medium text-foreground text-sm"
|
|
253
|
+
>
|
|
254
|
+
Description
|
|
255
|
+
</label>
|
|
256
|
+
<textarea
|
|
257
|
+
id="cat-desc"
|
|
258
|
+
value={form.description}
|
|
259
|
+
onChange={(e) => setField("description", e.target.value)}
|
|
260
|
+
placeholder="Category description"
|
|
261
|
+
rows={3}
|
|
262
|
+
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"
|
|
263
|
+
/>
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
{/* Parent category */}
|
|
267
|
+
<div>
|
|
268
|
+
<label
|
|
269
|
+
htmlFor="cat-parent"
|
|
270
|
+
className="mb-1.5 block font-medium text-foreground text-sm"
|
|
271
|
+
>
|
|
272
|
+
Parent category
|
|
273
|
+
</label>
|
|
274
|
+
<select
|
|
275
|
+
id="cat-parent"
|
|
276
|
+
value={form.parentId}
|
|
277
|
+
onChange={(e) => setField("parentId", e.target.value)}
|
|
278
|
+
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"
|
|
279
|
+
>
|
|
280
|
+
<option value="">None</option>
|
|
281
|
+
{categories.map((c) => (
|
|
282
|
+
<option key={c.id} value={c.id}>
|
|
283
|
+
{c.name}
|
|
284
|
+
</option>
|
|
285
|
+
))}
|
|
286
|
+
</select>
|
|
287
|
+
</div>
|
|
288
|
+
|
|
289
|
+
{/* Image */}
|
|
290
|
+
<CategoryImageField
|
|
291
|
+
image={form.image}
|
|
292
|
+
onChange={(url) => setField("image", url)}
|
|
293
|
+
/>
|
|
294
|
+
|
|
295
|
+
{/* Position */}
|
|
296
|
+
<div>
|
|
297
|
+
<label
|
|
298
|
+
htmlFor="cat-position"
|
|
299
|
+
className="mb-1.5 block font-medium text-foreground text-sm"
|
|
300
|
+
>
|
|
301
|
+
Position
|
|
302
|
+
</label>
|
|
303
|
+
<input
|
|
304
|
+
id="cat-position"
|
|
305
|
+
type="number"
|
|
306
|
+
min="0"
|
|
307
|
+
value={form.position}
|
|
308
|
+
onChange={(e) => setField("position", e.target.value)}
|
|
309
|
+
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"
|
|
310
|
+
/>
|
|
311
|
+
<p className="mt-1 text-muted-foreground text-xs">
|
|
312
|
+
Lower numbers appear first
|
|
313
|
+
</p>
|
|
314
|
+
</div>
|
|
315
|
+
|
|
316
|
+
{/* Visible */}
|
|
317
|
+
<label className="flex items-center gap-2.5">
|
|
318
|
+
<input
|
|
319
|
+
type="checkbox"
|
|
320
|
+
checked={form.isVisible}
|
|
321
|
+
onChange={(e) => setField("isVisible", e.target.checked)}
|
|
322
|
+
className="h-4 w-4 rounded border-border"
|
|
323
|
+
/>
|
|
324
|
+
<span className="text-foreground text-sm">
|
|
325
|
+
Visible on storefront
|
|
326
|
+
</span>
|
|
327
|
+
</label>
|
|
328
|
+
</div>
|
|
329
|
+
</div>
|
|
330
|
+
|
|
331
|
+
{/* Actions */}
|
|
332
|
+
<div className="flex gap-2">
|
|
333
|
+
<button
|
|
334
|
+
type="submit"
|
|
335
|
+
disabled={saving}
|
|
336
|
+
className="rounded-md bg-foreground px-4 py-2.5 font-semibold text-background text-sm transition-opacity hover:opacity-90 disabled:opacity-50"
|
|
337
|
+
>
|
|
338
|
+
{saving
|
|
339
|
+
? "Saving..."
|
|
340
|
+
: isEditing
|
|
341
|
+
? "Save changes"
|
|
342
|
+
: "Create category"}
|
|
343
|
+
</button>
|
|
344
|
+
{onSuccess && (
|
|
345
|
+
<button
|
|
346
|
+
type="button"
|
|
347
|
+
onClick={onSuccess}
|
|
348
|
+
className="rounded-md border border-border px-4 py-2.5 font-medium text-foreground text-sm transition-colors hover:bg-muted"
|
|
349
|
+
>
|
|
350
|
+
Cancel
|
|
351
|
+
</button>
|
|
352
|
+
)}
|
|
353
|
+
</div>
|
|
354
|
+
</>
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
return (
|
|
358
|
+
<form onSubmit={(e) => handleSubmit(e)} className="space-y-5">
|
|
359
|
+
<CategoryFormTemplate error={error} formContent={formContent} />
|
|
360
|
+
</form>
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ─── Inline image upload for categories ──────────────────────────────────────
|
|
365
|
+
|
|
366
|
+
function CategoryImageField({
|
|
367
|
+
image,
|
|
368
|
+
onChange,
|
|
369
|
+
}: {
|
|
370
|
+
image: string;
|
|
371
|
+
onChange: (url: string) => void;
|
|
372
|
+
}) {
|
|
373
|
+
const [uploading, setUploading] = useState(false);
|
|
374
|
+
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
375
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
376
|
+
|
|
377
|
+
const handleFile = async (file: File) => {
|
|
378
|
+
setUploadError(null);
|
|
379
|
+
setUploading(true);
|
|
380
|
+
try {
|
|
381
|
+
const formData = new FormData();
|
|
382
|
+
formData.append("file", file);
|
|
383
|
+
const res = await fetch("/api/upload", {
|
|
384
|
+
method: "POST",
|
|
385
|
+
body: formData,
|
|
386
|
+
});
|
|
387
|
+
if (!res.ok) {
|
|
388
|
+
const data = (await res.json()) as { error?: string };
|
|
389
|
+
throw new Error(data.error ?? "Upload failed");
|
|
390
|
+
}
|
|
391
|
+
const data = (await res.json()) as { url: string };
|
|
392
|
+
onChange(data.url);
|
|
393
|
+
} catch (err) {
|
|
394
|
+
setUploadError(err instanceof Error ? err.message : "Upload failed");
|
|
395
|
+
} finally {
|
|
396
|
+
setUploading(false);
|
|
397
|
+
if (inputRef.current) inputRef.current.value = "";
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
return (
|
|
402
|
+
<div>
|
|
403
|
+
<span className="mb-1.5 block font-medium text-foreground text-sm">
|
|
404
|
+
Image
|
|
405
|
+
</span>
|
|
406
|
+
{image ? (
|
|
407
|
+
<div className="flex items-start gap-3">
|
|
408
|
+
<div className="h-20 w-20 overflow-hidden rounded-md border border-border bg-muted">
|
|
409
|
+
<img
|
|
410
|
+
src={image}
|
|
411
|
+
alt="Category"
|
|
412
|
+
className="h-full w-full object-cover"
|
|
413
|
+
/>
|
|
414
|
+
</div>
|
|
415
|
+
<button
|
|
416
|
+
type="button"
|
|
417
|
+
onClick={() => onChange("")}
|
|
418
|
+
className="rounded-md px-2 py-1 text-destructive text-xs hover:bg-destructive/10"
|
|
419
|
+
>
|
|
420
|
+
Remove
|
|
421
|
+
</button>
|
|
422
|
+
</div>
|
|
423
|
+
) : (
|
|
424
|
+
<button
|
|
425
|
+
type="button"
|
|
426
|
+
onClick={() => inputRef.current?.click()}
|
|
427
|
+
disabled={uploading}
|
|
428
|
+
className="flex h-20 w-20 flex-col items-center justify-center rounded-md border-2 border-border border-dashed text-muted-foreground transition-colors hover:border-muted-foreground hover:bg-muted/30 disabled:opacity-60"
|
|
429
|
+
>
|
|
430
|
+
{uploading ? (
|
|
431
|
+
<svg
|
|
432
|
+
className="h-5 w-5 animate-spin"
|
|
433
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
434
|
+
fill="none"
|
|
435
|
+
viewBox="0 0 24 24"
|
|
436
|
+
aria-hidden="true"
|
|
437
|
+
>
|
|
438
|
+
<circle
|
|
439
|
+
className="opacity-25"
|
|
440
|
+
cx="12"
|
|
441
|
+
cy="12"
|
|
442
|
+
r="10"
|
|
443
|
+
stroke="currentColor"
|
|
444
|
+
strokeWidth="4"
|
|
445
|
+
/>
|
|
446
|
+
<path
|
|
447
|
+
className="opacity-75"
|
|
448
|
+
fill="currentColor"
|
|
449
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
|
450
|
+
/>
|
|
451
|
+
</svg>
|
|
452
|
+
) : (
|
|
453
|
+
<>
|
|
454
|
+
<svg
|
|
455
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
456
|
+
width="18"
|
|
457
|
+
height="18"
|
|
458
|
+
viewBox="0 0 24 24"
|
|
459
|
+
fill="none"
|
|
460
|
+
stroke="currentColor"
|
|
461
|
+
strokeWidth="1.5"
|
|
462
|
+
strokeLinecap="round"
|
|
463
|
+
strokeLinejoin="round"
|
|
464
|
+
aria-hidden="true"
|
|
465
|
+
>
|
|
466
|
+
<rect width="18" height="18" x="3" y="3" rx="2" />
|
|
467
|
+
<circle cx="9" cy="9" r="2" />
|
|
468
|
+
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
|
469
|
+
</svg>
|
|
470
|
+
<span className="mt-0.5 text-2xs">Upload</span>
|
|
471
|
+
</>
|
|
472
|
+
)}
|
|
473
|
+
</button>
|
|
474
|
+
)}
|
|
475
|
+
<input
|
|
476
|
+
ref={inputRef}
|
|
477
|
+
type="file"
|
|
478
|
+
accept="image/jpeg,image/png,image/webp"
|
|
479
|
+
className="hidden"
|
|
480
|
+
onChange={(e) => {
|
|
481
|
+
const file = e.target.files?.[0];
|
|
482
|
+
if (file) void handleFile(file);
|
|
483
|
+
}}
|
|
484
|
+
/>
|
|
485
|
+
{uploadError && (
|
|
486
|
+
<p className="mt-1 text-destructive text-xs">{uploadError}</p>
|
|
487
|
+
)}
|
|
488
|
+
</div>
|
|
489
|
+
);
|
|
490
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<div>
|
|
2
|
+
<div className="mb-6 flex items-center justify-between">
|
|
3
|
+
<div>
|
|
4
|
+
<h1 className="font-semibold text-foreground text-lg">Categories</h1>
|
|
5
|
+
{props.total > 0 && (
|
|
6
|
+
<p className="mt-1 text-muted-foreground text-sm">
|
|
7
|
+
{props.total} {props.total === 1 ? "category" : "categories"} total
|
|
8
|
+
</p>
|
|
9
|
+
)}
|
|
10
|
+
</div>
|
|
11
|
+
<button
|
|
12
|
+
type="button"
|
|
13
|
+
onClick={props.onCreateNew}
|
|
14
|
+
className="rounded-md bg-foreground px-4 py-2 font-semibold text-background text-sm transition-opacity hover:opacity-90"
|
|
15
|
+
>
|
|
16
|
+
New category
|
|
17
|
+
</button>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
|
21
|
+
<div className="overflow-x-auto">
|
|
22
|
+
<table className="w-full text-sm">
|
|
23
|
+
<thead>
|
|
24
|
+
<tr className="border-border border-b bg-muted/50">
|
|
25
|
+
<th className="px-4 py-3 text-left font-medium text-muted-foreground text-xs uppercase tracking-wider">
|
|
26
|
+
Name
|
|
27
|
+
</th>
|
|
28
|
+
<th className="px-4 py-3 text-left font-medium text-muted-foreground text-xs uppercase tracking-wider">
|
|
29
|
+
Slug
|
|
30
|
+
</th>
|
|
31
|
+
<th className="px-4 py-3 text-left font-medium text-muted-foreground text-xs uppercase tracking-wider">
|
|
32
|
+
Visible
|
|
33
|
+
</th>
|
|
34
|
+
<th className="px-4 py-3 text-left font-medium text-muted-foreground text-xs uppercase tracking-wider">
|
|
35
|
+
Position
|
|
36
|
+
</th>
|
|
37
|
+
<th className="px-4 py-3 text-left font-medium text-muted-foreground text-xs uppercase tracking-wider">
|
|
38
|
+
Products
|
|
39
|
+
</th>
|
|
40
|
+
<th className="px-4 py-3 text-right font-medium text-muted-foreground text-xs uppercase tracking-wider">
|
|
41
|
+
Actions
|
|
42
|
+
</th>
|
|
43
|
+
</tr>
|
|
44
|
+
</thead>
|
|
45
|
+
<tbody className="divide-y divide-border">
|
|
46
|
+
{props.tableBody}
|
|
47
|
+
</tbody>
|
|
48
|
+
</table>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
{props.totalPages > 1 && (
|
|
53
|
+
<div className="mt-4 flex items-center justify-center gap-2">
|
|
54
|
+
<button
|
|
55
|
+
type="button"
|
|
56
|
+
onClick={props.onPrevPage}
|
|
57
|
+
disabled={props.page === 1}
|
|
58
|
+
className="rounded-md border border-border px-3 py-1.5 text-foreground text-sm hover:bg-muted disabled:opacity-50"
|
|
59
|
+
>
|
|
60
|
+
Previous
|
|
61
|
+
</button>
|
|
62
|
+
<span className="text-muted-foreground text-sm">
|
|
63
|
+
Page {props.page} of {props.totalPages}
|
|
64
|
+
</span>
|
|
65
|
+
<button
|
|
66
|
+
type="button"
|
|
67
|
+
onClick={props.onNextPage}
|
|
68
|
+
disabled={props.page === props.totalPages}
|
|
69
|
+
className="rounded-md border border-border px-3 py-1.5 text-foreground text-sm hover:bg-muted disabled:opacity-50"
|
|
70
|
+
>
|
|
71
|
+
Next
|
|
72
|
+
</button>
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|