@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.
Files changed (94) hide show
  1. package/AGENTS.md +65 -0
  2. package/COMPONENTS.md +231 -0
  3. package/README.md +201 -0
  4. package/package.json +46 -0
  5. package/src/__tests__/controllers.test.ts +2227 -0
  6. package/src/__tests__/state.test.ts +138 -0
  7. package/src/admin/components/categories-admin.mdx +3 -0
  8. package/src/admin/components/categories-admin.tsx +449 -0
  9. package/src/admin/components/category-form.mdx +9 -0
  10. package/src/admin/components/category-form.tsx +490 -0
  11. package/src/admin/components/category-list.mdx +75 -0
  12. package/src/admin/components/category-list.tsx +168 -0
  13. package/src/admin/components/collections-admin.mdx +3 -0
  14. package/src/admin/components/collections-admin.tsx +771 -0
  15. package/src/admin/components/index.tsx +8 -0
  16. package/src/admin/components/product-detail.mdx +12 -0
  17. package/src/admin/components/product-detail.tsx +790 -0
  18. package/src/admin/components/product-edit.tsx +60 -0
  19. package/src/admin/components/product-form.tsx +793 -0
  20. package/src/admin/components/product-list.mdx +3 -0
  21. package/src/admin/components/product-list.tsx +1125 -0
  22. package/src/admin/components/product-new.tsx +38 -0
  23. package/src/admin/endpoints/add-collection-product.ts +17 -0
  24. package/src/admin/endpoints/bulk-action.ts +43 -0
  25. package/src/admin/endpoints/create-category.ts +52 -0
  26. package/src/admin/endpoints/create-collection.ts +35 -0
  27. package/src/admin/endpoints/create-product.ts +50 -0
  28. package/src/admin/endpoints/create-variant.ts +45 -0
  29. package/src/admin/endpoints/delete-category.ts +27 -0
  30. package/src/admin/endpoints/delete-collection.ts +12 -0
  31. package/src/admin/endpoints/delete-product.ts +27 -0
  32. package/src/admin/endpoints/delete-variant.ts +27 -0
  33. package/src/admin/endpoints/get-product.ts +23 -0
  34. package/src/admin/endpoints/import-products.ts +47 -0
  35. package/src/admin/endpoints/index.ts +43 -0
  36. package/src/admin/endpoints/list-categories.ts +21 -0
  37. package/src/admin/endpoints/list-collections.ts +20 -0
  38. package/src/admin/endpoints/list-products.ts +25 -0
  39. package/src/admin/endpoints/remove-collection-product.ts +15 -0
  40. package/src/admin/endpoints/update-category.ts +82 -0
  41. package/src/admin/endpoints/update-collection.ts +22 -0
  42. package/src/admin/endpoints/update-product.ts +67 -0
  43. package/src/admin/endpoints/update-variant.ts +41 -0
  44. package/src/controllers.ts +1410 -0
  45. package/src/index.ts +120 -0
  46. package/src/markdown.ts +150 -0
  47. package/src/mdx.d.ts +5 -0
  48. package/src/schema.ts +352 -0
  49. package/src/state.ts +84 -0
  50. package/src/store/components/_hooks.ts +78 -0
  51. package/src/store/components/_types.ts +73 -0
  52. package/src/store/components/_utils.ts +14 -0
  53. package/src/store/components/back-in-stock-notify.tsx +97 -0
  54. package/src/store/components/collection-card.mdx +42 -0
  55. package/src/store/components/collection-card.tsx +12 -0
  56. package/src/store/components/collection-detail.mdx +12 -0
  57. package/src/store/components/collection-detail.tsx +149 -0
  58. package/src/store/components/collection-grid.mdx +9 -0
  59. package/src/store/components/collection-grid.tsx +80 -0
  60. package/src/store/components/featured-products.mdx +9 -0
  61. package/src/store/components/featured-products.tsx +75 -0
  62. package/src/store/components/filter-chip.mdx +25 -0
  63. package/src/store/components/filter-chip.tsx +12 -0
  64. package/src/store/components/index.tsx +39 -0
  65. package/src/store/components/product-card.mdx +69 -0
  66. package/src/store/components/product-card.tsx +71 -0
  67. package/src/store/components/product-detail.mdx +30 -0
  68. package/src/store/components/product-detail.tsx +488 -0
  69. package/src/store/components/product-listing.mdx +7 -0
  70. package/src/store/components/product-listing.tsx +423 -0
  71. package/src/store/components/product-reviews-section.mdx +21 -0
  72. package/src/store/components/product-reviews-section.tsx +372 -0
  73. package/src/store/components/recently-viewed.tsx +100 -0
  74. package/src/store/components/related-products.mdx +6 -0
  75. package/src/store/components/related-products.tsx +62 -0
  76. package/src/store/components/star-display.mdx +18 -0
  77. package/src/store/components/star-display.tsx +27 -0
  78. package/src/store/components/star-picker.mdx +21 -0
  79. package/src/store/components/star-picker.tsx +21 -0
  80. package/src/store/components/stock-badge.mdx +12 -0
  81. package/src/store/components/stock-badge.tsx +19 -0
  82. package/src/store/endpoints/get-category.ts +61 -0
  83. package/src/store/endpoints/get-collection.ts +46 -0
  84. package/src/store/endpoints/get-featured.ts +18 -0
  85. package/src/store/endpoints/get-product.ts +52 -0
  86. package/src/store/endpoints/get-related.ts +20 -0
  87. package/src/store/endpoints/index.ts +23 -0
  88. package/src/store/endpoints/list-categories.ts +13 -0
  89. package/src/store/endpoints/list-collections.ts +22 -0
  90. package/src/store/endpoints/list-products.ts +28 -0
  91. package/src/store/endpoints/search-products.ts +18 -0
  92. package/src/store/endpoints/store-search.ts +111 -0
  93. package/tsconfig.json +9 -0
  94. 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,3 @@
1
+ <div>
2
+ {props.content}
3
+ </div>
@@ -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
+ }
@@ -0,0 +1,9 @@
1
+ <>
2
+ {props.error && (
3
+ <div className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-destructive text-sm">
4
+ {props.error}
5
+ </div>
6
+ )}
7
+
8
+ {props.formContent}
9
+ </>