@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,138 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { productsState } from "../state";
|
|
3
|
+
|
|
4
|
+
describe("productsState", () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
// Reset to defaults before each test
|
|
7
|
+
productsState.clearFilters();
|
|
8
|
+
productsState.setSortField("createdAt");
|
|
9
|
+
productsState.setSortOrder("desc");
|
|
10
|
+
productsState.setViewMode("grid");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("initial state", () => {
|
|
14
|
+
it("has correct defaults", () => {
|
|
15
|
+
expect(productsState.activeCategory).toBe("");
|
|
16
|
+
expect(productsState.searchQuery).toBe("");
|
|
17
|
+
expect(productsState.sortField).toBe("createdAt");
|
|
18
|
+
expect(productsState.sortOrder).toBe("desc");
|
|
19
|
+
expect(productsState.viewMode).toBe("grid");
|
|
20
|
+
expect(productsState.minPrice).toBe("");
|
|
21
|
+
expect(productsState.maxPrice).toBe("");
|
|
22
|
+
expect(productsState.inStockOnly).toBe(false);
|
|
23
|
+
expect(productsState.activeTag).toBe("");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("hasActiveFilters is false initially", () => {
|
|
27
|
+
expect(productsState.hasActiveFilters).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("category filter", () => {
|
|
32
|
+
it("sets category", () => {
|
|
33
|
+
productsState.setCategory("electronics");
|
|
34
|
+
expect(productsState.activeCategory).toBe("electronics");
|
|
35
|
+
expect(productsState.hasActiveFilters).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("clears category", () => {
|
|
39
|
+
productsState.setCategory("electronics");
|
|
40
|
+
productsState.setCategory("");
|
|
41
|
+
expect(productsState.activeCategory).toBe("");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("search query", () => {
|
|
46
|
+
it("sets search query", () => {
|
|
47
|
+
productsState.setSearchQuery("headphones");
|
|
48
|
+
expect(productsState.searchQuery).toBe("headphones");
|
|
49
|
+
expect(productsState.hasActiveFilters).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("sort", () => {
|
|
54
|
+
it("sets sort field", () => {
|
|
55
|
+
productsState.setSortField("price");
|
|
56
|
+
expect(productsState.sortField).toBe("price");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("sets sort order", () => {
|
|
60
|
+
productsState.setSortOrder("asc");
|
|
61
|
+
expect(productsState.sortOrder).toBe("asc");
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("view mode", () => {
|
|
66
|
+
it("toggles between grid and list", () => {
|
|
67
|
+
expect(productsState.viewMode).toBe("grid");
|
|
68
|
+
productsState.setViewMode("list");
|
|
69
|
+
expect(productsState.viewMode).toBe("list");
|
|
70
|
+
productsState.setViewMode("grid");
|
|
71
|
+
expect(productsState.viewMode).toBe("grid");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("price range", () => {
|
|
76
|
+
it("sets price range", () => {
|
|
77
|
+
productsState.setPriceRange("10", "100");
|
|
78
|
+
expect(productsState.minPrice).toBe("10");
|
|
79
|
+
expect(productsState.maxPrice).toBe("100");
|
|
80
|
+
expect(productsState.hasActiveFilters).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("detects active filters with only min price", () => {
|
|
84
|
+
productsState.setPriceRange("10", "");
|
|
85
|
+
expect(productsState.hasActiveFilters).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("detects active filters with only max price", () => {
|
|
89
|
+
productsState.setPriceRange("", "100");
|
|
90
|
+
expect(productsState.hasActiveFilters).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("in-stock filter", () => {
|
|
95
|
+
it("sets in-stock only", () => {
|
|
96
|
+
productsState.setInStockOnly(true);
|
|
97
|
+
expect(productsState.inStockOnly).toBe(true);
|
|
98
|
+
expect(productsState.hasActiveFilters).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("tag filter", () => {
|
|
103
|
+
it("sets active tag", () => {
|
|
104
|
+
productsState.setActiveTag("sale");
|
|
105
|
+
expect(productsState.activeTag).toBe("sale");
|
|
106
|
+
expect(productsState.hasActiveFilters).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("clearFilters", () => {
|
|
111
|
+
it("resets all filters but preserves sort and view mode", () => {
|
|
112
|
+
productsState.setCategory("clothing");
|
|
113
|
+
productsState.setSearchQuery("shirt");
|
|
114
|
+
productsState.setPriceRange("10", "50");
|
|
115
|
+
productsState.setInStockOnly(true);
|
|
116
|
+
productsState.setActiveTag("new");
|
|
117
|
+
productsState.setSortField("price");
|
|
118
|
+
productsState.setSortOrder("asc");
|
|
119
|
+
productsState.setViewMode("list");
|
|
120
|
+
|
|
121
|
+
productsState.clearFilters();
|
|
122
|
+
|
|
123
|
+
// Filters should be cleared
|
|
124
|
+
expect(productsState.activeCategory).toBe("");
|
|
125
|
+
expect(productsState.searchQuery).toBe("");
|
|
126
|
+
expect(productsState.minPrice).toBe("");
|
|
127
|
+
expect(productsState.maxPrice).toBe("");
|
|
128
|
+
expect(productsState.inStockOnly).toBe(false);
|
|
129
|
+
expect(productsState.activeTag).toBe("");
|
|
130
|
+
expect(productsState.hasActiveFilters).toBe(false);
|
|
131
|
+
|
|
132
|
+
// Sort and view mode should be preserved
|
|
133
|
+
expect(productsState.sortField).toBe("price");
|
|
134
|
+
expect(productsState.sortOrder).toBe("asc");
|
|
135
|
+
expect(productsState.viewMode).toBe("list");
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useModuleClient } from "@86d-app/core/client";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import CategoriesAdminTemplate from "./categories-admin.mdx";
|
|
6
|
+
|
|
7
|
+
interface Category {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
slug: string;
|
|
11
|
+
description?: string | null;
|
|
12
|
+
parentId?: string | null;
|
|
13
|
+
isVisible: boolean;
|
|
14
|
+
position: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ListResult {
|
|
18
|
+
categories: Category[];
|
|
19
|
+
total: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ─── Module Client ───────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
function useCategoriesAdminApi() {
|
|
25
|
+
const client = useModuleClient();
|
|
26
|
+
return {
|
|
27
|
+
listCategories: client.module("products").admin["/admin/categories/list"],
|
|
28
|
+
createCategory: client.module("products").admin["/admin/categories/create"],
|
|
29
|
+
updateCategory:
|
|
30
|
+
client.module("products").admin["/admin/categories/:id/update"],
|
|
31
|
+
deleteCategory:
|
|
32
|
+
client.module("products").admin["/admin/categories/:id/delete"],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
function extractError(error: Error | null, fallback: string): string {
|
|
39
|
+
if (!error) return fallback;
|
|
40
|
+
// biome-ignore lint/suspicious/noExplicitAny: accessing HTTP error body property
|
|
41
|
+
const body = (error as any)?.body;
|
|
42
|
+
if (typeof body?.error === "string") return body.error;
|
|
43
|
+
if (typeof body?.error?.message === "string") return body.error.message;
|
|
44
|
+
return fallback;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function slugify(str: string): string {
|
|
48
|
+
return str
|
|
49
|
+
.toLowerCase()
|
|
50
|
+
.replace(/[^\w\s-]/g, "")
|
|
51
|
+
.replace(/[\s_]+/g, "-")
|
|
52
|
+
.replace(/^-+|-+$/g, "");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function CategoriesAdmin() {
|
|
56
|
+
const api = useCategoriesAdminApi();
|
|
57
|
+
|
|
58
|
+
const [showForm, setShowForm] = useState(false);
|
|
59
|
+
const [editId, setEditId] = useState<string | null>(null);
|
|
60
|
+
const [form, setForm] = useState({
|
|
61
|
+
name: "",
|
|
62
|
+
slug: "",
|
|
63
|
+
description: "",
|
|
64
|
+
parentId: "",
|
|
65
|
+
isVisible: true,
|
|
66
|
+
});
|
|
67
|
+
const [error, setError] = useState<string | null>(null);
|
|
68
|
+
const [slugEdited, setSlugEdited] = useState(false);
|
|
69
|
+
|
|
70
|
+
const { data: categoriesData, isLoading: loading } =
|
|
71
|
+
api.listCategories.useQuery({
|
|
72
|
+
limit: "100",
|
|
73
|
+
sort: "position",
|
|
74
|
+
order: "asc",
|
|
75
|
+
}) as { data: ListResult | undefined; isLoading: boolean };
|
|
76
|
+
|
|
77
|
+
const categories = categoriesData?.categories ?? [];
|
|
78
|
+
const total = categoriesData?.total ?? 0;
|
|
79
|
+
|
|
80
|
+
const createMutation = api.createCategory.useMutation({
|
|
81
|
+
onSuccess: () => {
|
|
82
|
+
setShowForm(false);
|
|
83
|
+
setEditId(null);
|
|
84
|
+
void api.listCategories.invalidate();
|
|
85
|
+
},
|
|
86
|
+
onError: (err: Error) => {
|
|
87
|
+
setError(extractError(err, "Failed to save category"));
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const updateMutation = api.updateCategory.useMutation({
|
|
92
|
+
onSuccess: () => {
|
|
93
|
+
setShowForm(false);
|
|
94
|
+
setEditId(null);
|
|
95
|
+
void api.listCategories.invalidate();
|
|
96
|
+
},
|
|
97
|
+
onError: (err: Error) => {
|
|
98
|
+
setError(extractError(err, "Failed to save category"));
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const deleteMutation = api.deleteCategory.useMutation({
|
|
103
|
+
onSettled: () => {
|
|
104
|
+
void api.listCategories.invalidate();
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const saving = createMutation.isPending || updateMutation.isPending;
|
|
109
|
+
|
|
110
|
+
const openCreate = () => {
|
|
111
|
+
setForm({
|
|
112
|
+
name: "",
|
|
113
|
+
slug: "",
|
|
114
|
+
description: "",
|
|
115
|
+
parentId: "",
|
|
116
|
+
isVisible: true,
|
|
117
|
+
});
|
|
118
|
+
setSlugEdited(false);
|
|
119
|
+
setEditId(null);
|
|
120
|
+
setError(null);
|
|
121
|
+
setShowForm(true);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const openEdit = (cat: Category) => {
|
|
125
|
+
setForm({
|
|
126
|
+
name: cat.name,
|
|
127
|
+
slug: cat.slug,
|
|
128
|
+
description: cat.description ?? "",
|
|
129
|
+
parentId: cat.parentId ?? "",
|
|
130
|
+
isVisible: cat.isVisible,
|
|
131
|
+
});
|
|
132
|
+
setSlugEdited(true);
|
|
133
|
+
setEditId(cat.id);
|
|
134
|
+
setError(null);
|
|
135
|
+
setShowForm(true);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const handleNameChange = (name: string) => {
|
|
139
|
+
setForm((prev) => ({
|
|
140
|
+
...prev,
|
|
141
|
+
name,
|
|
142
|
+
slug: slugEdited ? prev.slug : slugify(name),
|
|
143
|
+
}));
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const handleSave = (e: React.FormEvent) => {
|
|
147
|
+
e.preventDefault();
|
|
148
|
+
setError(null);
|
|
149
|
+
if (!form.name.trim()) {
|
|
150
|
+
setError("Name is required");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (!form.slug.trim()) {
|
|
154
|
+
setError("Slug is required");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const body = {
|
|
159
|
+
name: form.name.trim(),
|
|
160
|
+
slug: form.slug.trim(),
|
|
161
|
+
description: form.description.trim() || undefined,
|
|
162
|
+
parentId: form.parentId || undefined,
|
|
163
|
+
isVisible: form.isVisible,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
if (editId) {
|
|
167
|
+
updateMutation.mutate({ params: { id: editId }, ...body });
|
|
168
|
+
} else {
|
|
169
|
+
createMutation.mutate(body);
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const handleDelete = (cat: Category) => {
|
|
174
|
+
if (!confirm(`Delete "${cat.name}"? This cannot be undone.`)) return;
|
|
175
|
+
deleteMutation.mutate({ params: { id: cat.id } });
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const content = (
|
|
179
|
+
<div>
|
|
180
|
+
{/* Header */}
|
|
181
|
+
<div className="mb-6 flex items-center justify-between">
|
|
182
|
+
<div>
|
|
183
|
+
<h1 className="font-bold text-2xl text-foreground">Categories</h1>
|
|
184
|
+
<p className="mt-1 text-muted-foreground text-sm">
|
|
185
|
+
{total} {total === 1 ? "category" : "categories"}
|
|
186
|
+
</p>
|
|
187
|
+
</div>
|
|
188
|
+
<button
|
|
189
|
+
type="button"
|
|
190
|
+
onClick={openCreate}
|
|
191
|
+
className="flex items-center gap-2 rounded-md bg-foreground px-4 py-2 font-semibold text-background text-sm transition-opacity hover:opacity-90"
|
|
192
|
+
>
|
|
193
|
+
<svg
|
|
194
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
195
|
+
width="16"
|
|
196
|
+
height="16"
|
|
197
|
+
viewBox="0 0 24 24"
|
|
198
|
+
fill="none"
|
|
199
|
+
stroke="currentColor"
|
|
200
|
+
strokeWidth="2"
|
|
201
|
+
strokeLinecap="round"
|
|
202
|
+
strokeLinejoin="round"
|
|
203
|
+
aria-hidden="true"
|
|
204
|
+
>
|
|
205
|
+
<path d="M5 12h14" />
|
|
206
|
+
<path d="M12 5v14" />
|
|
207
|
+
</svg>
|
|
208
|
+
Add category
|
|
209
|
+
</button>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
{/* Inline form */}
|
|
213
|
+
{showForm && (
|
|
214
|
+
<div className="mb-6 rounded-lg border border-border bg-card p-5">
|
|
215
|
+
<h2 className="mb-4 font-semibold text-foreground text-sm">
|
|
216
|
+
{editId ? "Edit category" : "New category"}
|
|
217
|
+
</h2>
|
|
218
|
+
<form onSubmit={(e) => handleSave(e)} className="space-y-4">
|
|
219
|
+
{error && (
|
|
220
|
+
<p className="rounded-md bg-destructive/10 px-3 py-2 text-destructive text-sm">
|
|
221
|
+
{error}
|
|
222
|
+
</p>
|
|
223
|
+
)}
|
|
224
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
225
|
+
<div>
|
|
226
|
+
<label
|
|
227
|
+
htmlFor="cat-name"
|
|
228
|
+
className="mb-1.5 block font-medium text-foreground text-sm"
|
|
229
|
+
>
|
|
230
|
+
Name <span className="text-destructive">*</span>
|
|
231
|
+
</label>
|
|
232
|
+
<input
|
|
233
|
+
id="cat-name"
|
|
234
|
+
type="text"
|
|
235
|
+
value={form.name}
|
|
236
|
+
onChange={(e) => handleNameChange(e.target.value)}
|
|
237
|
+
placeholder="Category name"
|
|
238
|
+
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"
|
|
239
|
+
required
|
|
240
|
+
/>
|
|
241
|
+
</div>
|
|
242
|
+
<div>
|
|
243
|
+
<label
|
|
244
|
+
htmlFor="cat-slug"
|
|
245
|
+
className="mb-1.5 block font-medium text-foreground text-sm"
|
|
246
|
+
>
|
|
247
|
+
Slug <span className="text-destructive">*</span>
|
|
248
|
+
</label>
|
|
249
|
+
<input
|
|
250
|
+
id="cat-slug"
|
|
251
|
+
type="text"
|
|
252
|
+
value={form.slug}
|
|
253
|
+
onChange={(e) => {
|
|
254
|
+
setSlugEdited(true);
|
|
255
|
+
setForm((p) => ({ ...p, slug: e.target.value }));
|
|
256
|
+
}}
|
|
257
|
+
placeholder="category-slug"
|
|
258
|
+
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"
|
|
259
|
+
required
|
|
260
|
+
/>
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
<div>
|
|
264
|
+
<label
|
|
265
|
+
htmlFor="cat-desc"
|
|
266
|
+
className="mb-1.5 block font-medium text-foreground text-sm"
|
|
267
|
+
>
|
|
268
|
+
Description
|
|
269
|
+
</label>
|
|
270
|
+
<input
|
|
271
|
+
id="cat-desc"
|
|
272
|
+
type="text"
|
|
273
|
+
value={form.description}
|
|
274
|
+
onChange={(e) =>
|
|
275
|
+
setForm((p) => ({ ...p, description: e.target.value }))
|
|
276
|
+
}
|
|
277
|
+
placeholder="Optional description"
|
|
278
|
+
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"
|
|
279
|
+
/>
|
|
280
|
+
</div>
|
|
281
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
282
|
+
<div>
|
|
283
|
+
<label
|
|
284
|
+
htmlFor="cat-parent"
|
|
285
|
+
className="mb-1.5 block font-medium text-foreground text-sm"
|
|
286
|
+
>
|
|
287
|
+
Parent category
|
|
288
|
+
</label>
|
|
289
|
+
<select
|
|
290
|
+
id="cat-parent"
|
|
291
|
+
value={form.parentId}
|
|
292
|
+
onChange={(e) =>
|
|
293
|
+
setForm((p) => ({ ...p, parentId: e.target.value }))
|
|
294
|
+
}
|
|
295
|
+
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"
|
|
296
|
+
>
|
|
297
|
+
<option value="">No parent</option>
|
|
298
|
+
{categories
|
|
299
|
+
.filter((c) => c.id !== editId)
|
|
300
|
+
.map((c) => (
|
|
301
|
+
<option key={c.id} value={c.id}>
|
|
302
|
+
{c.name}
|
|
303
|
+
</option>
|
|
304
|
+
))}
|
|
305
|
+
</select>
|
|
306
|
+
</div>
|
|
307
|
+
<div className="flex items-end">
|
|
308
|
+
<label className="flex items-center gap-2.5 pb-2">
|
|
309
|
+
<input
|
|
310
|
+
type="checkbox"
|
|
311
|
+
checked={form.isVisible}
|
|
312
|
+
onChange={(e) =>
|
|
313
|
+
setForm((p) => ({ ...p, isVisible: e.target.checked }))
|
|
314
|
+
}
|
|
315
|
+
className="h-4 w-4 rounded border-border"
|
|
316
|
+
/>
|
|
317
|
+
<span className="text-foreground text-sm">
|
|
318
|
+
Visible in store
|
|
319
|
+
</span>
|
|
320
|
+
</label>
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
<div className="flex gap-2 pt-1">
|
|
324
|
+
<button
|
|
325
|
+
type="submit"
|
|
326
|
+
disabled={saving}
|
|
327
|
+
className="rounded-md bg-foreground px-4 py-2 font-semibold text-background text-sm transition-opacity hover:opacity-90 disabled:opacity-50"
|
|
328
|
+
>
|
|
329
|
+
{saving
|
|
330
|
+
? "Saving..."
|
|
331
|
+
: editId
|
|
332
|
+
? "Save changes"
|
|
333
|
+
: "Create category"}
|
|
334
|
+
</button>
|
|
335
|
+
<button
|
|
336
|
+
type="button"
|
|
337
|
+
onClick={() => setShowForm(false)}
|
|
338
|
+
className="rounded-md border border-border px-4 py-2 font-medium text-foreground text-sm transition-colors hover:bg-muted"
|
|
339
|
+
>
|
|
340
|
+
Cancel
|
|
341
|
+
</button>
|
|
342
|
+
</div>
|
|
343
|
+
</form>
|
|
344
|
+
</div>
|
|
345
|
+
)}
|
|
346
|
+
|
|
347
|
+
{/* Table */}
|
|
348
|
+
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
|
349
|
+
<table className="w-full">
|
|
350
|
+
<thead>
|
|
351
|
+
<tr className="border-border border-b bg-muted/50">
|
|
352
|
+
<th className="px-4 py-3 text-left font-semibold text-muted-foreground text-xs uppercase tracking-wide">
|
|
353
|
+
Name
|
|
354
|
+
</th>
|
|
355
|
+
<th className="hidden px-4 py-3 text-left font-semibold text-muted-foreground text-xs uppercase tracking-wide sm:table-cell">
|
|
356
|
+
Slug
|
|
357
|
+
</th>
|
|
358
|
+
<th className="hidden px-4 py-3 text-left font-semibold text-muted-foreground text-xs uppercase tracking-wide md:table-cell">
|
|
359
|
+
Parent
|
|
360
|
+
</th>
|
|
361
|
+
<th className="hidden px-4 py-3 text-center font-semibold text-muted-foreground text-xs uppercase tracking-wide lg:table-cell">
|
|
362
|
+
Visible
|
|
363
|
+
</th>
|
|
364
|
+
<th className="px-4 py-3 text-right font-semibold text-muted-foreground text-xs uppercase tracking-wide">
|
|
365
|
+
Actions
|
|
366
|
+
</th>
|
|
367
|
+
</tr>
|
|
368
|
+
</thead>
|
|
369
|
+
<tbody className="divide-y divide-border">
|
|
370
|
+
{loading ? (
|
|
371
|
+
Array.from({ length: 4 }).map((_, i) => (
|
|
372
|
+
<tr key={`skeleton-${i}`}>
|
|
373
|
+
{Array.from({ length: 5 }).map((_, j) => (
|
|
374
|
+
<td key={`skeleton-cell-${j}`} className="px-4 py-3">
|
|
375
|
+
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
|
|
376
|
+
</td>
|
|
377
|
+
))}
|
|
378
|
+
</tr>
|
|
379
|
+
))
|
|
380
|
+
) : categories.length === 0 ? (
|
|
381
|
+
<tr>
|
|
382
|
+
<td colSpan={5} className="px-4 py-12 text-center">
|
|
383
|
+
<p className="font-medium text-foreground text-sm">
|
|
384
|
+
No categories yet
|
|
385
|
+
</p>
|
|
386
|
+
<p className="mt-1 text-muted-foreground text-xs">
|
|
387
|
+
Create your first category to organize your products
|
|
388
|
+
</p>
|
|
389
|
+
<button
|
|
390
|
+
type="button"
|
|
391
|
+
onClick={openCreate}
|
|
392
|
+
className="mt-3 font-medium text-foreground text-sm underline underline-offset-2"
|
|
393
|
+
>
|
|
394
|
+
Add category
|
|
395
|
+
</button>
|
|
396
|
+
</td>
|
|
397
|
+
</tr>
|
|
398
|
+
) : (
|
|
399
|
+
categories.map((cat) => {
|
|
400
|
+
const parent = categories.find((c) => c.id === cat.parentId);
|
|
401
|
+
return (
|
|
402
|
+
<tr
|
|
403
|
+
key={cat.id}
|
|
404
|
+
className="transition-colors hover:bg-muted/30"
|
|
405
|
+
>
|
|
406
|
+
<td className="px-4 py-3 font-medium text-foreground text-sm">
|
|
407
|
+
{cat.name}
|
|
408
|
+
</td>
|
|
409
|
+
<td className="hidden px-4 py-3 font-mono text-muted-foreground text-xs sm:table-cell">
|
|
410
|
+
{cat.slug}
|
|
411
|
+
</td>
|
|
412
|
+
<td className="hidden px-4 py-3 text-muted-foreground text-sm md:table-cell">
|
|
413
|
+
{parent?.name ?? "—"}
|
|
414
|
+
</td>
|
|
415
|
+
<td className="hidden px-4 py-3 text-center lg:table-cell">
|
|
416
|
+
<span
|
|
417
|
+
className={`inline-flex h-2 w-2 rounded-full ${cat.isVisible ? "bg-green-500" : "bg-muted-foreground"}`}
|
|
418
|
+
/>
|
|
419
|
+
</td>
|
|
420
|
+
<td className="px-4 py-3 text-right">
|
|
421
|
+
<div className="flex items-center justify-end gap-2">
|
|
422
|
+
<button
|
|
423
|
+
type="button"
|
|
424
|
+
onClick={() => openEdit(cat)}
|
|
425
|
+
className="rounded-md px-2.5 py-1.5 font-medium text-foreground text-xs transition-colors hover:bg-muted"
|
|
426
|
+
>
|
|
427
|
+
Edit
|
|
428
|
+
</button>
|
|
429
|
+
<button
|
|
430
|
+
type="button"
|
|
431
|
+
onClick={() => handleDelete(cat)}
|
|
432
|
+
className="rounded-md px-2.5 py-1.5 font-medium text-destructive text-xs transition-colors hover:bg-destructive/10"
|
|
433
|
+
>
|
|
434
|
+
Delete
|
|
435
|
+
</button>
|
|
436
|
+
</div>
|
|
437
|
+
</td>
|
|
438
|
+
</tr>
|
|
439
|
+
);
|
|
440
|
+
})
|
|
441
|
+
)}
|
|
442
|
+
</tbody>
|
|
443
|
+
</table>
|
|
444
|
+
</div>
|
|
445
|
+
</div>
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
return <CategoriesAdminTemplate content={content} />;
|
|
449
|
+
}
|