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.6.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
  {
2
2
  "name": "create-brainerce-store",
3
- "version": "1.7.0",
3
+ "version": "1.8.1",
4
4
  "description": "Scaffold a production-ready e-commerce storefront connected to Brainerce",
5
5
  "bin": {
6
6
  "create-brainerce-store": "dist/index.js"
@@ -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 CategoryFilter {
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<CategoryFilter[]>([]);
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.map((c) => ({ id: c.id, name: c.name })));
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
- <button
354
+ <CategoryChip
162
355
  key={cat.id}
163
- onClick={() => updateParam('category', cat.id)}
164
- className={cn(
165
- 'rounded-full border px-3 py-1.5 text-sm transition-colors',
166
- categoryId === cat.id
167
- ? 'bg-primary text-primary-foreground border-primary'
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
  )}