create-brainerce-store 1.7.0 → 1.8.1
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/dist/index.js
CHANGED
|
@@ -31,7 +31,7 @@ var require_package = __commonJS({
|
|
|
31
31
|
"package.json"(exports2, module2) {
|
|
32
32
|
module2.exports = {
|
|
33
33
|
name: "create-brainerce-store",
|
|
34
|
-
version: "1.
|
|
34
|
+
version: "1.8.0",
|
|
35
35
|
description: "Scaffold a production-ready e-commerce storefront connected to Brainerce",
|
|
36
36
|
bin: {
|
|
37
37
|
"create-brainerce-store": "dist/index.js"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { Suspense, useEffect, useState, useCallback } from 'react';
|
|
3
|
+
import { Suspense, useEffect, useState, useCallback, useRef } from 'react';
|
|
4
4
|
import { useSearchParams, useRouter } from 'next/navigation';
|
|
5
5
|
import type { Product } from 'brainerce';
|
|
6
6
|
import type { ProductQueryParams } from 'brainerce';
|
|
@@ -26,9 +26,198 @@ const sortOptions: SortOption[] = [
|
|
|
26
26
|
{ labelKey: 'sortPriceHigh', sortBy: 'price', sortOrder: 'desc' },
|
|
27
27
|
];
|
|
28
28
|
|
|
29
|
-
interface
|
|
29
|
+
interface CategoryNode {
|
|
30
30
|
id: string;
|
|
31
31
|
name: string;
|
|
32
|
+
parentId?: string | null;
|
|
33
|
+
children: CategoryNode[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Collect all descendant IDs (including self) */
|
|
37
|
+
function getAllDescendantIds(node: CategoryNode): string[] {
|
|
38
|
+
const ids = [node.id];
|
|
39
|
+
for (const child of node.children) {
|
|
40
|
+
ids.push(...getAllDescendantIds(child));
|
|
41
|
+
}
|
|
42
|
+
return ids;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Check if a category or any of its descendants matches the selected ID */
|
|
46
|
+
function isActiveInTree(node: CategoryNode, selectedId: string): boolean {
|
|
47
|
+
if (node.id === selectedId) return true;
|
|
48
|
+
return node.children.some((child) => isActiveInTree(child, selectedId));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Chevron down SVG */
|
|
52
|
+
function ChevronDown({ className }: { className?: string }) {
|
|
53
|
+
return (
|
|
54
|
+
<svg
|
|
55
|
+
className={className}
|
|
56
|
+
width="12"
|
|
57
|
+
height="12"
|
|
58
|
+
viewBox="0 0 12 12"
|
|
59
|
+
fill="none"
|
|
60
|
+
stroke="currentColor"
|
|
61
|
+
strokeWidth="2"
|
|
62
|
+
strokeLinecap="round"
|
|
63
|
+
strokeLinejoin="round"
|
|
64
|
+
>
|
|
65
|
+
<path d="M3 4.5L6 7.5L9 4.5" />
|
|
66
|
+
</svg>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Recursive dropdown items for nested categories */
|
|
71
|
+
function CategoryDropdownItems({
|
|
72
|
+
children,
|
|
73
|
+
depth,
|
|
74
|
+
selectedId,
|
|
75
|
+
onSelect,
|
|
76
|
+
}: {
|
|
77
|
+
children: CategoryNode[];
|
|
78
|
+
depth: number;
|
|
79
|
+
selectedId: string;
|
|
80
|
+
onSelect: (id: string) => void;
|
|
81
|
+
}) {
|
|
82
|
+
return (
|
|
83
|
+
<>
|
|
84
|
+
{children.map((child) => (
|
|
85
|
+
<div key={child.id}>
|
|
86
|
+
<button
|
|
87
|
+
onClick={() => onSelect(child.id)}
|
|
88
|
+
className={cn(
|
|
89
|
+
'w-full text-start px-4 py-2 text-sm transition-colors hover:bg-muted',
|
|
90
|
+
selectedId === child.id && 'bg-primary/10 text-primary font-medium'
|
|
91
|
+
)}
|
|
92
|
+
style={{ paddingInlineStart: `${(depth + 1) * 16}px` }}
|
|
93
|
+
>
|
|
94
|
+
{child.name}
|
|
95
|
+
</button>
|
|
96
|
+
{child.children.length > 0 && (
|
|
97
|
+
<CategoryDropdownItems
|
|
98
|
+
children={child.children}
|
|
99
|
+
depth={depth + 1}
|
|
100
|
+
selectedId={selectedId}
|
|
101
|
+
onSelect={onSelect}
|
|
102
|
+
/>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
))}
|
|
106
|
+
</>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Category chip with dropdown for subcategories */
|
|
111
|
+
function CategoryChip({
|
|
112
|
+
category,
|
|
113
|
+
selectedId,
|
|
114
|
+
onSelect,
|
|
115
|
+
tc,
|
|
116
|
+
}: {
|
|
117
|
+
category: CategoryNode;
|
|
118
|
+
selectedId: string;
|
|
119
|
+
onSelect: (id: string) => void;
|
|
120
|
+
tc: (key: string) => string;
|
|
121
|
+
}) {
|
|
122
|
+
const [open, setOpen] = useState(false);
|
|
123
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
124
|
+
const hasChildren = category.children.length > 0;
|
|
125
|
+
const isActive = isActiveInTree(category, selectedId);
|
|
126
|
+
|
|
127
|
+
// Find the display name for the selected subcategory
|
|
128
|
+
function findName(nodes: CategoryNode[], id: string): string | null {
|
|
129
|
+
for (const n of nodes) {
|
|
130
|
+
if (n.id === id) return n.name;
|
|
131
|
+
const found = findName(n.children, id);
|
|
132
|
+
if (found) return found;
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const selectedChildName =
|
|
138
|
+
isActive && selectedId !== category.id ? findName(category.children, selectedId) : null;
|
|
139
|
+
|
|
140
|
+
// Close dropdown on outside click
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (!open) return;
|
|
143
|
+
function handleClick(e: MouseEvent) {
|
|
144
|
+
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
145
|
+
setOpen(false);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
document.addEventListener('mousedown', handleClick);
|
|
149
|
+
return () => document.removeEventListener('mousedown', handleClick);
|
|
150
|
+
}, [open]);
|
|
151
|
+
|
|
152
|
+
if (!hasChildren) {
|
|
153
|
+
return (
|
|
154
|
+
<button
|
|
155
|
+
onClick={() => onSelect(category.id)}
|
|
156
|
+
className={cn(
|
|
157
|
+
'rounded-full border px-3 py-1.5 text-sm transition-colors',
|
|
158
|
+
selectedId === category.id
|
|
159
|
+
? 'bg-primary text-primary-foreground border-primary'
|
|
160
|
+
: 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
|
|
161
|
+
)}
|
|
162
|
+
>
|
|
163
|
+
{category.name}
|
|
164
|
+
</button>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<div ref={ref} className="relative">
|
|
170
|
+
<button
|
|
171
|
+
onClick={() => setOpen((prev) => !prev)}
|
|
172
|
+
className={cn(
|
|
173
|
+
'inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-sm transition-colors',
|
|
174
|
+
isActive
|
|
175
|
+
? 'bg-primary text-primary-foreground border-primary'
|
|
176
|
+
: 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
|
|
177
|
+
)}
|
|
178
|
+
>
|
|
179
|
+
{category.name}
|
|
180
|
+
{selectedChildName && (
|
|
181
|
+
<span className="opacity-80">
|
|
182
|
+
{'·'} {selectedChildName}
|
|
183
|
+
</span>
|
|
184
|
+
)}
|
|
185
|
+
<ChevronDown
|
|
186
|
+
className={cn('transition-transform ms-0.5', open && 'rotate-180')}
|
|
187
|
+
/>
|
|
188
|
+
</button>
|
|
189
|
+
|
|
190
|
+
{open && (
|
|
191
|
+
<div className="bg-background border-border absolute start-0 top-full z-50 mt-1 min-w-[180px] overflow-hidden rounded-lg border shadow-lg">
|
|
192
|
+
{/* "All in [category]" option */}
|
|
193
|
+
<button
|
|
194
|
+
onClick={() => {
|
|
195
|
+
onSelect(category.id);
|
|
196
|
+
setOpen(false);
|
|
197
|
+
}}
|
|
198
|
+
className={cn(
|
|
199
|
+
'w-full text-start px-4 py-2 text-sm font-medium transition-colors hover:bg-muted',
|
|
200
|
+
selectedId === category.id && 'bg-primary/10 text-primary'
|
|
201
|
+
)}
|
|
202
|
+
>
|
|
203
|
+
{tc('all')} {category.name}
|
|
204
|
+
</button>
|
|
205
|
+
<div className="bg-border mx-2 h-px" />
|
|
206
|
+
{/* Recursive children */}
|
|
207
|
+
<div
|
|
208
|
+
onClick={() => setOpen(false)}
|
|
209
|
+
>
|
|
210
|
+
<CategoryDropdownItems
|
|
211
|
+
children={category.children}
|
|
212
|
+
depth={0}
|
|
213
|
+
selectedId={selectedId}
|
|
214
|
+
onSelect={onSelect}
|
|
215
|
+
/>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
);
|
|
32
221
|
}
|
|
33
222
|
|
|
34
223
|
function ProductsContent() {
|
|
@@ -47,18 +236,18 @@ function ProductsContent() {
|
|
|
47
236
|
const [page, setPage] = useState(1);
|
|
48
237
|
const [totalPages, setTotalPages] = useState(1);
|
|
49
238
|
const [total, setTotal] = useState(0);
|
|
50
|
-
const [categories, setCategories] = useState<
|
|
239
|
+
const [categories, setCategories] = useState<CategoryNode[]>([]);
|
|
51
240
|
|
|
52
241
|
const sortIndex = parseInt(sortParam, 10) || 0;
|
|
53
242
|
const currentSort = sortOptions[sortIndex] || sortOptions[0];
|
|
54
243
|
|
|
55
|
-
// Load categories
|
|
244
|
+
// Load categories (keep tree structure)
|
|
56
245
|
useEffect(() => {
|
|
57
246
|
async function loadCategories() {
|
|
58
247
|
try {
|
|
59
248
|
const client = getClient();
|
|
60
249
|
const result = await client.getCategories();
|
|
61
|
-
setCategories(result.categories
|
|
250
|
+
setCategories(result.categories as CategoryNode[]);
|
|
62
251
|
} catch {
|
|
63
252
|
// Categories endpoint may not be available in all modes
|
|
64
253
|
}
|
|
@@ -127,6 +316,10 @@ function ProductsContent() {
|
|
|
127
316
|
router.push(`/products?${params.toString()}`);
|
|
128
317
|
}
|
|
129
318
|
|
|
319
|
+
function handleCategorySelect(id: string) {
|
|
320
|
+
updateParam('category', id);
|
|
321
|
+
}
|
|
322
|
+
|
|
130
323
|
return (
|
|
131
324
|
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
|
132
325
|
{/* Page Header */}
|
|
@@ -158,18 +351,13 @@ function ProductsContent() {
|
|
|
158
351
|
{tc('all')}
|
|
159
352
|
</button>
|
|
160
353
|
{categories.map((cat) => (
|
|
161
|
-
<
|
|
354
|
+
<CategoryChip
|
|
162
355
|
key={cat.id}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
: 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
|
|
169
|
-
)}
|
|
170
|
-
>
|
|
171
|
-
{cat.name}
|
|
172
|
-
</button>
|
|
356
|
+
category={cat}
|
|
357
|
+
selectedId={categoryId}
|
|
358
|
+
onSelect={handleCategorySelect}
|
|
359
|
+
tc={tc as (key: string) => string}
|
|
360
|
+
/>
|
|
173
361
|
))}
|
|
174
362
|
</div>
|
|
175
363
|
)}
|