create-brainerce-store 1.18.0 → 1.19.0

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 (65) hide show
  1. package/dist/index.js +31 -9
  2. package/messages/en.json +366 -362
  3. package/messages/he.json +366 -362
  4. package/package.json +45 -45
  5. package/templates/nextjs/base/next.config.ts +31 -31
  6. package/templates/nextjs/base/scripts/fetch-store-info.mjs +81 -81
  7. package/templates/nextjs/base/src/app/.well-known/apple-developer-merchantid-domain-association/route.ts +26 -26
  8. package/templates/nextjs/base/src/app/account/layout.tsx +9 -9
  9. package/templates/nextjs/base/src/app/account/page.tsx +122 -122
  10. package/templates/nextjs/base/src/app/api/auth/logout/route.ts +14 -14
  11. package/templates/nextjs/base/src/app/api/auth/me/route.ts +56 -56
  12. package/templates/nextjs/base/src/app/api/auth/oauth-callback/route.ts +59 -59
  13. package/templates/nextjs/base/src/app/api/auth/reset-callback/route.ts +41 -41
  14. package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +77 -77
  15. package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +198 -198
  16. package/templates/nextjs/base/src/app/auth/callback/page.tsx +92 -92
  17. package/templates/nextjs/base/src/app/cart/layout.tsx +9 -9
  18. package/templates/nextjs/base/src/app/cart/page.tsx +204 -204
  19. package/templates/nextjs/base/src/app/checkout/layout.tsx +9 -9
  20. package/templates/nextjs/base/src/app/checkout/page.tsx +860 -860
  21. package/templates/nextjs/base/src/app/forgot-password/page.tsx +112 -112
  22. package/templates/nextjs/base/src/app/layout.tsx.ejs +75 -0
  23. package/templates/nextjs/base/src/app/login/layout.tsx +9 -9
  24. package/templates/nextjs/base/src/app/login/page.tsx +59 -59
  25. package/templates/nextjs/base/src/app/order-confirmation/layout.tsx +9 -9
  26. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +254 -254
  27. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +67 -67
  28. package/templates/nextjs/base/src/app/products/[slug]/product-client-section.tsx +486 -486
  29. package/templates/nextjs/base/src/app/products/layout.tsx +18 -18
  30. package/templates/nextjs/base/src/app/products/page.tsx +431 -431
  31. package/templates/nextjs/base/src/app/register/layout.tsx +9 -9
  32. package/templates/nextjs/base/src/app/register/page.tsx +65 -65
  33. package/templates/nextjs/base/src/app/reset-password/page.tsx +132 -132
  34. package/templates/nextjs/base/src/app/robots.ts +14 -14
  35. package/templates/nextjs/base/src/app/sitemap.ts +25 -25
  36. package/templates/nextjs/base/src/app/verify-email/page.tsx +258 -258
  37. package/templates/nextjs/base/src/components/account/address-book.tsx +432 -432
  38. package/templates/nextjs/base/src/components/account/order-history.tsx +350 -350
  39. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +137 -137
  40. package/templates/nextjs/base/src/components/auth/register-form.tsx +232 -232
  41. package/templates/nextjs/base/src/components/cart/cart-bundle-offer.tsx +247 -111
  42. package/templates/nextjs/base/src/components/cart/cart-item.tsx +153 -153
  43. package/templates/nextjs/base/src/components/cart/cart-upgrade-banner.tsx +142 -142
  44. package/templates/nextjs/base/src/components/cart/free-shipping-bar.tsx +59 -59
  45. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +415 -415
  46. package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +243 -83
  47. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +519 -519
  48. package/templates/nextjs/base/src/components/layout/footer.tsx +41 -41
  49. package/templates/nextjs/base/src/components/layout/header.tsx +336 -336
  50. package/templates/nextjs/base/src/components/layout/language-switcher.tsx.ejs +63 -0
  51. package/templates/nextjs/base/src/components/products/discount-badge.tsx +22 -22
  52. package/templates/nextjs/base/src/components/products/frequently-bought-together.tsx +202 -202
  53. package/templates/nextjs/base/src/components/products/product-card.tsx +218 -218
  54. package/templates/nextjs/base/src/components/products/recommendation-section.tsx +107 -107
  55. package/templates/nextjs/base/src/components/products/stock-badge.tsx +63 -63
  56. package/templates/nextjs/base/src/components/products/variant-selector.tsx +292 -292
  57. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +72 -72
  58. package/templates/nextjs/base/src/i18n.ts.ejs +21 -0
  59. package/templates/nextjs/base/src/lib/auth.ts +149 -149
  60. package/templates/nextjs/base/src/lib/brainerce.ts.ejs +9 -0
  61. package/templates/nextjs/base/src/lib/translations.ts.ejs +31 -0
  62. package/templates/nextjs/base/src/middleware.ts.ejs +81 -0
  63. package/templates/nextjs/base/src/providers/store-provider.tsx.ejs +41 -0
  64. package/templates/nextjs/base/src/lib/translations.ts +0 -11
  65. package/templates/nextjs/base/src/middleware.ts +0 -25
@@ -1,431 +1,431 @@
1
- 'use client';
2
-
3
- import { Suspense, useEffect, useState, useCallback, useRef } from 'react';
4
- import { useSearchParams, useRouter } from 'next/navigation';
5
- import type { Product } from 'brainerce';
6
- import type { ProductQueryParams } from 'brainerce';
7
- import { getClient } from '@/lib/brainerce';
8
- import { ProductGrid } from '@/components/products/product-grid';
9
- import { LoadingSpinner } from '@/components/shared/loading-spinner';
10
- import { useTranslations } from '@/lib/translations';
11
- import { cn } from '@/lib/utils';
12
-
13
- const PAGE_SIZE = 20;
14
-
15
- type SortOption = {
16
- labelKey: 'sortNewest' | 'sortNameAZ' | 'sortNameZA' | 'sortPriceLow' | 'sortPriceHigh';
17
- sortBy: ProductQueryParams['sortBy'];
18
- sortOrder: ProductQueryParams['sortOrder'];
19
- };
20
-
21
- const sortOptions: SortOption[] = [
22
- { labelKey: 'sortNewest', sortBy: 'createdAt', sortOrder: 'desc' },
23
- { labelKey: 'sortNameAZ', sortBy: 'name', sortOrder: 'asc' },
24
- { labelKey: 'sortNameZA', sortBy: 'name', sortOrder: 'desc' },
25
- { labelKey: 'sortPriceLow', sortBy: 'price', sortOrder: 'asc' },
26
- { labelKey: 'sortPriceHigh', sortBy: 'price', sortOrder: 'desc' },
27
- ];
28
-
29
- interface CategoryNode {
30
- id: string;
31
- name: string;
32
- image?: string | null;
33
- parentId?: string | null;
34
- children: CategoryNode[];
35
- }
36
-
37
- /** Collect all descendant IDs (including self) */
38
- function getAllDescendantIds(node: CategoryNode): string[] {
39
- const ids = [node.id];
40
- for (const child of node.children) {
41
- ids.push(...getAllDescendantIds(child));
42
- }
43
- return ids;
44
- }
45
-
46
- /** Check if a category or any of its descendants matches the selected ID */
47
- function isActiveInTree(node: CategoryNode, selectedId: string): boolean {
48
- if (node.id === selectedId) return true;
49
- return node.children.some((child) => isActiveInTree(child, selectedId));
50
- }
51
-
52
- /** Chevron down SVG */
53
- function ChevronDown({ className }: { className?: string }) {
54
- return (
55
- <svg
56
- className={className}
57
- width="12"
58
- height="12"
59
- viewBox="0 0 12 12"
60
- fill="none"
61
- stroke="currentColor"
62
- strokeWidth="2"
63
- strokeLinecap="round"
64
- strokeLinejoin="round"
65
- >
66
- <path d="M3 4.5L6 7.5L9 4.5" />
67
- </svg>
68
- );
69
- }
70
-
71
- /** Recursive dropdown items for nested categories */
72
- function CategoryDropdownItems({
73
- items,
74
- depth,
75
- selectedId,
76
- onSelect,
77
- }: {
78
- items: CategoryNode[];
79
- depth: number;
80
- selectedId: string;
81
- onSelect: (id: string) => void;
82
- }) {
83
- return (
84
- <>
85
- {items.map((child) => (
86
- <div key={child.id}>
87
- <button
88
- onClick={() => onSelect(child.id)}
89
- className={cn(
90
- 'hover:bg-muted w-full px-4 py-2 text-start text-sm transition-colors',
91
- selectedId === child.id && 'bg-primary/10 text-primary font-medium'
92
- )}
93
- style={{ paddingInlineStart: `${(depth + 1) * 16}px` }}
94
- >
95
- {child.name}
96
- </button>
97
- {child.children.length > 0 && (
98
- <CategoryDropdownItems
99
- items={child.children}
100
- depth={depth + 1}
101
- selectedId={selectedId}
102
- onSelect={onSelect}
103
- />
104
- )}
105
- </div>
106
- ))}
107
- </>
108
- );
109
- }
110
-
111
- /** Category chip with dropdown for subcategories */
112
- function CategoryChip({
113
- category,
114
- selectedId,
115
- onSelect,
116
- tc,
117
- }: {
118
- category: CategoryNode;
119
- selectedId: string;
120
- onSelect: (id: string) => void;
121
- tc: (key: string) => string;
122
- }) {
123
- const [open, setOpen] = useState(false);
124
- const ref = useRef<HTMLDivElement>(null);
125
- const hasChildren = category.children.length > 0;
126
- const isActive = isActiveInTree(category, selectedId);
127
-
128
- // Find the display name for the selected subcategory
129
- function findName(nodes: CategoryNode[], id: string): string | null {
130
- for (const n of nodes) {
131
- if (n.id === id) return n.name;
132
- const found = findName(n.children, id);
133
- if (found) return found;
134
- }
135
- return null;
136
- }
137
-
138
- const selectedChildName =
139
- isActive && selectedId !== category.id ? findName(category.children, selectedId) : null;
140
-
141
- // Close dropdown on outside click
142
- useEffect(() => {
143
- if (!open) return;
144
- function handleClick(e: MouseEvent) {
145
- if (ref.current && !ref.current.contains(e.target as Node)) {
146
- setOpen(false);
147
- }
148
- }
149
- document.addEventListener('mousedown', handleClick);
150
- return () => document.removeEventListener('mousedown', handleClick);
151
- }, [open]);
152
-
153
- if (!hasChildren) {
154
- return (
155
- <button
156
- onClick={() => onSelect(category.id)}
157
- className={cn(
158
- 'rounded-full border px-3 py-1.5 text-sm transition-colors',
159
- selectedId === category.id
160
- ? 'bg-primary text-primary-foreground border-primary'
161
- : 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
162
- )}
163
- >
164
- {category.name}
165
- </button>
166
- );
167
- }
168
-
169
- return (
170
- <div ref={ref} className="relative">
171
- <button
172
- onClick={() => setOpen((prev) => !prev)}
173
- className={cn(
174
- 'inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-sm transition-colors',
175
- isActive
176
- ? 'bg-primary text-primary-foreground border-primary'
177
- : 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
178
- )}
179
- >
180
- {category.name}
181
- {selectedChildName && (
182
- <span className="opacity-80">
183
- {'·'} {selectedChildName}
184
- </span>
185
- )}
186
- <ChevronDown className={cn('ms-0.5 transition-transform', open && 'rotate-180')} />
187
- </button>
188
-
189
- {open && (
190
- <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">
191
- {/* "All in [category]" option */}
192
- <button
193
- onClick={() => {
194
- onSelect(category.id);
195
- setOpen(false);
196
- }}
197
- className={cn(
198
- 'hover:bg-muted w-full px-4 py-2 text-start text-sm font-medium transition-colors',
199
- selectedId === category.id && 'bg-primary/10 text-primary'
200
- )}
201
- >
202
- {tc('all')} {category.name}
203
- </button>
204
- <div className="bg-border mx-2 h-px" />
205
- {/* Recursive children */}
206
- <div onClick={() => setOpen(false)}>
207
- <CategoryDropdownItems
208
- items={category.children}
209
- depth={0}
210
- selectedId={selectedId}
211
- onSelect={onSelect}
212
- />
213
- </div>
214
- </div>
215
- )}
216
- </div>
217
- );
218
- }
219
-
220
- function ProductsContent() {
221
- const searchParams = useSearchParams();
222
- const router = useRouter();
223
- const t = useTranslations('products');
224
- const tc = useTranslations('common');
225
-
226
- const searchQuery = searchParams.get('search') || '';
227
- const categoryId = searchParams.get('category') || '';
228
- const sortParam = searchParams.get('sort') || '0';
229
-
230
- const [products, setProducts] = useState<Product[]>([]);
231
- const [loading, setLoading] = useState(true);
232
- const [loadingMore, setLoadingMore] = useState(false);
233
- const [page, setPage] = useState(1);
234
- const [totalPages, setTotalPages] = useState(1);
235
- const [total, setTotal] = useState(0);
236
- const [categories, setCategories] = useState<CategoryNode[]>([]);
237
-
238
- const sortIndex = parseInt(sortParam, 10) || 0;
239
- const currentSort = sortOptions[sortIndex] || sortOptions[0];
240
-
241
- // Load categories (keep tree structure)
242
- useEffect(() => {
243
- async function loadCategories() {
244
- try {
245
- const client = getClient();
246
- const result = await client.getCategories();
247
- setCategories(result.categories as CategoryNode[]);
248
- } catch {
249
- // Categories endpoint may not be available in all modes
250
- }
251
- }
252
- loadCategories();
253
- }, []);
254
-
255
- // Load products when filters change
256
- const loadProducts = useCallback(
257
- async (pageNum: number, append: boolean) => {
258
- try {
259
- if (append) {
260
- setLoadingMore(true);
261
- } else {
262
- setLoading(true);
263
- }
264
-
265
- const client = getClient();
266
- const params: ProductQueryParams = {
267
- page: pageNum,
268
- limit: PAGE_SIZE,
269
- sortBy: currentSort.sortBy,
270
- sortOrder: currentSort.sortOrder,
271
- };
272
-
273
- if (searchQuery) params.search = searchQuery;
274
- if (categoryId) params.categories = categoryId;
275
-
276
- const result = await client.getProducts(params);
277
-
278
- if (append) {
279
- setProducts((prev) => [...prev, ...result.data]);
280
- } else {
281
- setProducts(result.data);
282
- }
283
- setTotalPages(result.meta.totalPages);
284
- setTotal(result.meta.total);
285
- setPage(pageNum);
286
- } catch (err) {
287
- console.error('Failed to load products:', err);
288
- } finally {
289
- setLoading(false);
290
- setLoadingMore(false);
291
- }
292
- },
293
- [searchQuery, categoryId, currentSort.sortBy, currentSort.sortOrder]
294
- );
295
-
296
- useEffect(() => {
297
- loadProducts(1, false);
298
- }, [loadProducts]);
299
-
300
- function handleLoadMore() {
301
- if (page < totalPages && !loadingMore) {
302
- loadProducts(page + 1, true);
303
- }
304
- }
305
-
306
- function updateParam(key: string, value: string) {
307
- const params = new URLSearchParams(searchParams.toString());
308
- if (value) {
309
- params.set(key, value);
310
- } else {
311
- params.delete(key);
312
- }
313
- router.push(`/products?${params.toString()}`);
314
- }
315
-
316
- function handleCategorySelect(id: string) {
317
- updateParam('category', id);
318
- }
319
-
320
- return (
321
- <div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
322
- {/* Page Header */}
323
- <div className="mb-8">
324
- <h1 className="text-foreground text-3xl font-bold">
325
- {searchQuery ? `${t('searchPrefix')} "${searchQuery}"` : t('allProducts')}
326
- </h1>
327
- {!loading && (
328
- <p className="text-muted-foreground mt-1 text-sm">
329
- {total} {total === 1 ? tc('product') : tc('products')} {tc('found')}
330
- </p>
331
- )}
332
- </div>
333
-
334
- {/* Filters and Sort */}
335
- <div className="mb-6 flex flex-col items-start gap-4 sm:flex-row sm:items-center">
336
- {/* Category Filter */}
337
- {categories.length > 0 && (
338
- <div className="flex flex-wrap gap-2">
339
- <button
340
- onClick={() => updateParam('category', '')}
341
- className={cn(
342
- 'rounded-full border px-3 py-1.5 text-sm transition-colors',
343
- !categoryId
344
- ? 'bg-primary text-primary-foreground border-primary'
345
- : 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
346
- )}
347
- >
348
- {tc('all')}
349
- </button>
350
- {categories.map((cat) => (
351
- <CategoryChip
352
- key={cat.id}
353
- category={cat}
354
- selectedId={categoryId}
355
- onSelect={handleCategorySelect}
356
- tc={tc as (key: string) => string}
357
- />
358
- ))}
359
- </div>
360
- )}
361
-
362
- {/* Sort */}
363
- <div className="flex items-center gap-2 sm:ms-auto">
364
- <label htmlFor="sort" className="text-muted-foreground whitespace-nowrap text-sm">
365
- {tc('sortBy')}
366
- </label>
367
- <select
368
- id="sort"
369
- value={sortIndex}
370
- onChange={(e) => updateParam('sort', e.target.value)}
371
- className="border-border bg-background text-foreground focus:ring-primary/20 focus:border-primary h-9 rounded border px-3 text-sm focus:outline-none focus:ring-2"
372
- >
373
- {sortOptions.map((opt, idx) => (
374
- <option key={idx} value={idx}>
375
- {t(opt.labelKey)}
376
- </option>
377
- ))}
378
- </select>
379
- </div>
380
- </div>
381
-
382
- {/* Products Grid */}
383
- {loading ? (
384
- <div className="flex items-center justify-center py-20">
385
- <LoadingSpinner size="lg" />
386
- </div>
387
- ) : (
388
- <>
389
- <ProductGrid products={products} />
390
-
391
- {/* Load More */}
392
- {page < totalPages && (
393
- <div className="mt-10 flex justify-center">
394
- <button
395
- onClick={handleLoadMore}
396
- disabled={loadingMore}
397
- className="bg-primary text-primary-foreground inline-flex items-center gap-2 rounded px-6 py-2.5 font-medium transition-opacity hover:opacity-90 disabled:opacity-50"
398
- >
399
- {loadingMore ? (
400
- <>
401
- <LoadingSpinner
402
- size="sm"
403
- className="border-primary-foreground/30 border-t-primary-foreground"
404
- />
405
- {tc('loading')}
406
- </>
407
- ) : (
408
- t('loadMore')
409
- )}
410
- </button>
411
- </div>
412
- )}
413
- </>
414
- )}
415
- </div>
416
- );
417
- }
418
-
419
- export default function ProductsPage() {
420
- return (
421
- <Suspense
422
- fallback={
423
- <div className="flex min-h-[60vh] items-center justify-center">
424
- <LoadingSpinner size="lg" />
425
- </div>
426
- }
427
- >
428
- <ProductsContent />
429
- </Suspense>
430
- );
431
- }
1
+ 'use client';
2
+
3
+ import { Suspense, useEffect, useState, useCallback, useRef } from 'react';
4
+ import { useSearchParams, useRouter } from 'next/navigation';
5
+ import type { Product } from 'brainerce';
6
+ import type { ProductQueryParams } from 'brainerce';
7
+ import { getClient } from '@/lib/brainerce';
8
+ import { ProductGrid } from '@/components/products/product-grid';
9
+ import { LoadingSpinner } from '@/components/shared/loading-spinner';
10
+ import { useTranslations } from '@/lib/translations';
11
+ import { cn } from '@/lib/utils';
12
+
13
+ const PAGE_SIZE = 20;
14
+
15
+ type SortOption = {
16
+ labelKey: 'sortNewest' | 'sortNameAZ' | 'sortNameZA' | 'sortPriceLow' | 'sortPriceHigh';
17
+ sortBy: ProductQueryParams['sortBy'];
18
+ sortOrder: ProductQueryParams['sortOrder'];
19
+ };
20
+
21
+ const sortOptions: SortOption[] = [
22
+ { labelKey: 'sortNewest', sortBy: 'createdAt', sortOrder: 'desc' },
23
+ { labelKey: 'sortNameAZ', sortBy: 'name', sortOrder: 'asc' },
24
+ { labelKey: 'sortNameZA', sortBy: 'name', sortOrder: 'desc' },
25
+ { labelKey: 'sortPriceLow', sortBy: 'price', sortOrder: 'asc' },
26
+ { labelKey: 'sortPriceHigh', sortBy: 'price', sortOrder: 'desc' },
27
+ ];
28
+
29
+ interface CategoryNode {
30
+ id: string;
31
+ name: string;
32
+ image?: string | null;
33
+ parentId?: string | null;
34
+ children: CategoryNode[];
35
+ }
36
+
37
+ /** Collect all descendant IDs (including self) */
38
+ function getAllDescendantIds(node: CategoryNode): string[] {
39
+ const ids = [node.id];
40
+ for (const child of node.children) {
41
+ ids.push(...getAllDescendantIds(child));
42
+ }
43
+ return ids;
44
+ }
45
+
46
+ /** Check if a category or any of its descendants matches the selected ID */
47
+ function isActiveInTree(node: CategoryNode, selectedId: string): boolean {
48
+ if (node.id === selectedId) return true;
49
+ return node.children.some((child) => isActiveInTree(child, selectedId));
50
+ }
51
+
52
+ /** Chevron down SVG */
53
+ function ChevronDown({ className }: { className?: string }) {
54
+ return (
55
+ <svg
56
+ className={className}
57
+ width="12"
58
+ height="12"
59
+ viewBox="0 0 12 12"
60
+ fill="none"
61
+ stroke="currentColor"
62
+ strokeWidth="2"
63
+ strokeLinecap="round"
64
+ strokeLinejoin="round"
65
+ >
66
+ <path d="M3 4.5L6 7.5L9 4.5" />
67
+ </svg>
68
+ );
69
+ }
70
+
71
+ /** Recursive dropdown items for nested categories */
72
+ function CategoryDropdownItems({
73
+ items,
74
+ depth,
75
+ selectedId,
76
+ onSelect,
77
+ }: {
78
+ items: CategoryNode[];
79
+ depth: number;
80
+ selectedId: string;
81
+ onSelect: (id: string) => void;
82
+ }) {
83
+ return (
84
+ <>
85
+ {items.map((child) => (
86
+ <div key={child.id}>
87
+ <button
88
+ onClick={() => onSelect(child.id)}
89
+ className={cn(
90
+ 'hover:bg-muted w-full px-4 py-2 text-start text-sm transition-colors',
91
+ selectedId === child.id && 'bg-primary/10 text-primary font-medium'
92
+ )}
93
+ style={{ paddingInlineStart: `${(depth + 1) * 16}px` }}
94
+ >
95
+ {child.name}
96
+ </button>
97
+ {child.children.length > 0 && (
98
+ <CategoryDropdownItems
99
+ items={child.children}
100
+ depth={depth + 1}
101
+ selectedId={selectedId}
102
+ onSelect={onSelect}
103
+ />
104
+ )}
105
+ </div>
106
+ ))}
107
+ </>
108
+ );
109
+ }
110
+
111
+ /** Category chip with dropdown for subcategories */
112
+ function CategoryChip({
113
+ category,
114
+ selectedId,
115
+ onSelect,
116
+ tc,
117
+ }: {
118
+ category: CategoryNode;
119
+ selectedId: string;
120
+ onSelect: (id: string) => void;
121
+ tc: (key: string) => string;
122
+ }) {
123
+ const [open, setOpen] = useState(false);
124
+ const ref = useRef<HTMLDivElement>(null);
125
+ const hasChildren = category.children.length > 0;
126
+ const isActive = isActiveInTree(category, selectedId);
127
+
128
+ // Find the display name for the selected subcategory
129
+ function findName(nodes: CategoryNode[], id: string): string | null {
130
+ for (const n of nodes) {
131
+ if (n.id === id) return n.name;
132
+ const found = findName(n.children, id);
133
+ if (found) return found;
134
+ }
135
+ return null;
136
+ }
137
+
138
+ const selectedChildName =
139
+ isActive && selectedId !== category.id ? findName(category.children, selectedId) : null;
140
+
141
+ // Close dropdown on outside click
142
+ useEffect(() => {
143
+ if (!open) return;
144
+ function handleClick(e: MouseEvent) {
145
+ if (ref.current && !ref.current.contains(e.target as Node)) {
146
+ setOpen(false);
147
+ }
148
+ }
149
+ document.addEventListener('mousedown', handleClick);
150
+ return () => document.removeEventListener('mousedown', handleClick);
151
+ }, [open]);
152
+
153
+ if (!hasChildren) {
154
+ return (
155
+ <button
156
+ onClick={() => onSelect(category.id)}
157
+ className={cn(
158
+ 'rounded-full border px-3 py-1.5 text-sm transition-colors',
159
+ selectedId === category.id
160
+ ? 'bg-primary text-primary-foreground border-primary'
161
+ : 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
162
+ )}
163
+ >
164
+ {category.name}
165
+ </button>
166
+ );
167
+ }
168
+
169
+ return (
170
+ <div ref={ref} className="relative">
171
+ <button
172
+ onClick={() => setOpen((prev) => !prev)}
173
+ className={cn(
174
+ 'inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-sm transition-colors',
175
+ isActive
176
+ ? 'bg-primary text-primary-foreground border-primary'
177
+ : 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
178
+ )}
179
+ >
180
+ {category.name}
181
+ {selectedChildName && (
182
+ <span className="opacity-80">
183
+ {'·'} {selectedChildName}
184
+ </span>
185
+ )}
186
+ <ChevronDown className={cn('ms-0.5 transition-transform', open && 'rotate-180')} />
187
+ </button>
188
+
189
+ {open && (
190
+ <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">
191
+ {/* "All in [category]" option */}
192
+ <button
193
+ onClick={() => {
194
+ onSelect(category.id);
195
+ setOpen(false);
196
+ }}
197
+ className={cn(
198
+ 'hover:bg-muted w-full px-4 py-2 text-start text-sm font-medium transition-colors',
199
+ selectedId === category.id && 'bg-primary/10 text-primary'
200
+ )}
201
+ >
202
+ {tc('all')} {category.name}
203
+ </button>
204
+ <div className="bg-border mx-2 h-px" />
205
+ {/* Recursive children */}
206
+ <div onClick={() => setOpen(false)}>
207
+ <CategoryDropdownItems
208
+ items={category.children}
209
+ depth={0}
210
+ selectedId={selectedId}
211
+ onSelect={onSelect}
212
+ />
213
+ </div>
214
+ </div>
215
+ )}
216
+ </div>
217
+ );
218
+ }
219
+
220
+ function ProductsContent() {
221
+ const searchParams = useSearchParams();
222
+ const router = useRouter();
223
+ const t = useTranslations('products');
224
+ const tc = useTranslations('common');
225
+
226
+ const searchQuery = searchParams.get('search') || '';
227
+ const categoryId = searchParams.get('category') || '';
228
+ const sortParam = searchParams.get('sort') || '0';
229
+
230
+ const [products, setProducts] = useState<Product[]>([]);
231
+ const [loading, setLoading] = useState(true);
232
+ const [loadingMore, setLoadingMore] = useState(false);
233
+ const [page, setPage] = useState(1);
234
+ const [totalPages, setTotalPages] = useState(1);
235
+ const [total, setTotal] = useState(0);
236
+ const [categories, setCategories] = useState<CategoryNode[]>([]);
237
+
238
+ const sortIndex = parseInt(sortParam, 10) || 0;
239
+ const currentSort = sortOptions[sortIndex] || sortOptions[0];
240
+
241
+ // Load categories (keep tree structure)
242
+ useEffect(() => {
243
+ async function loadCategories() {
244
+ try {
245
+ const client = getClient();
246
+ const result = await client.getCategories();
247
+ setCategories(result.categories as CategoryNode[]);
248
+ } catch {
249
+ // Categories endpoint may not be available in all modes
250
+ }
251
+ }
252
+ loadCategories();
253
+ }, []);
254
+
255
+ // Load products when filters change
256
+ const loadProducts = useCallback(
257
+ async (pageNum: number, append: boolean) => {
258
+ try {
259
+ if (append) {
260
+ setLoadingMore(true);
261
+ } else {
262
+ setLoading(true);
263
+ }
264
+
265
+ const client = getClient();
266
+ const params: ProductQueryParams = {
267
+ page: pageNum,
268
+ limit: PAGE_SIZE,
269
+ sortBy: currentSort.sortBy,
270
+ sortOrder: currentSort.sortOrder,
271
+ };
272
+
273
+ if (searchQuery) params.search = searchQuery;
274
+ if (categoryId) params.categories = categoryId;
275
+
276
+ const result = await client.getProducts(params);
277
+
278
+ if (append) {
279
+ setProducts((prev) => [...prev, ...result.data]);
280
+ } else {
281
+ setProducts(result.data);
282
+ }
283
+ setTotalPages(result.meta.totalPages);
284
+ setTotal(result.meta.total);
285
+ setPage(pageNum);
286
+ } catch (err) {
287
+ console.error('Failed to load products:', err);
288
+ } finally {
289
+ setLoading(false);
290
+ setLoadingMore(false);
291
+ }
292
+ },
293
+ [searchQuery, categoryId, currentSort.sortBy, currentSort.sortOrder]
294
+ );
295
+
296
+ useEffect(() => {
297
+ loadProducts(1, false);
298
+ }, [loadProducts]);
299
+
300
+ function handleLoadMore() {
301
+ if (page < totalPages && !loadingMore) {
302
+ loadProducts(page + 1, true);
303
+ }
304
+ }
305
+
306
+ function updateParam(key: string, value: string) {
307
+ const params = new URLSearchParams(searchParams.toString());
308
+ if (value) {
309
+ params.set(key, value);
310
+ } else {
311
+ params.delete(key);
312
+ }
313
+ router.push(`/products?${params.toString()}`);
314
+ }
315
+
316
+ function handleCategorySelect(id: string) {
317
+ updateParam('category', id);
318
+ }
319
+
320
+ return (
321
+ <div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
322
+ {/* Page Header */}
323
+ <div className="mb-8">
324
+ <h1 className="text-foreground text-3xl font-bold">
325
+ {searchQuery ? `${t('searchPrefix')} "${searchQuery}"` : t('allProducts')}
326
+ </h1>
327
+ {!loading && (
328
+ <p className="text-muted-foreground mt-1 text-sm">
329
+ {total} {total === 1 ? tc('product') : tc('products')} {tc('found')}
330
+ </p>
331
+ )}
332
+ </div>
333
+
334
+ {/* Filters and Sort */}
335
+ <div className="mb-6 flex flex-col items-start gap-4 sm:flex-row sm:items-center">
336
+ {/* Category Filter */}
337
+ {categories.length > 0 && (
338
+ <div className="flex flex-wrap gap-2">
339
+ <button
340
+ onClick={() => updateParam('category', '')}
341
+ className={cn(
342
+ 'rounded-full border px-3 py-1.5 text-sm transition-colors',
343
+ !categoryId
344
+ ? 'bg-primary text-primary-foreground border-primary'
345
+ : 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
346
+ )}
347
+ >
348
+ {tc('all')}
349
+ </button>
350
+ {categories.map((cat) => (
351
+ <CategoryChip
352
+ key={cat.id}
353
+ category={cat}
354
+ selectedId={categoryId}
355
+ onSelect={handleCategorySelect}
356
+ tc={tc as (key: string) => string}
357
+ />
358
+ ))}
359
+ </div>
360
+ )}
361
+
362
+ {/* Sort */}
363
+ <div className="flex items-center gap-2 sm:ms-auto">
364
+ <label htmlFor="sort" className="text-muted-foreground whitespace-nowrap text-sm">
365
+ {tc('sortBy')}
366
+ </label>
367
+ <select
368
+ id="sort"
369
+ value={sortIndex}
370
+ onChange={(e) => updateParam('sort', e.target.value)}
371
+ className="border-border bg-background text-foreground focus:ring-primary/20 focus:border-primary h-9 rounded border px-3 text-sm focus:outline-none focus:ring-2"
372
+ >
373
+ {sortOptions.map((opt, idx) => (
374
+ <option key={idx} value={idx}>
375
+ {t(opt.labelKey)}
376
+ </option>
377
+ ))}
378
+ </select>
379
+ </div>
380
+ </div>
381
+
382
+ {/* Products Grid */}
383
+ {loading ? (
384
+ <div className="flex items-center justify-center py-20">
385
+ <LoadingSpinner size="lg" />
386
+ </div>
387
+ ) : (
388
+ <>
389
+ <ProductGrid products={products} />
390
+
391
+ {/* Load More */}
392
+ {page < totalPages && (
393
+ <div className="mt-10 flex justify-center">
394
+ <button
395
+ onClick={handleLoadMore}
396
+ disabled={loadingMore}
397
+ className="bg-primary text-primary-foreground inline-flex items-center gap-2 rounded px-6 py-2.5 font-medium transition-opacity hover:opacity-90 disabled:opacity-50"
398
+ >
399
+ {loadingMore ? (
400
+ <>
401
+ <LoadingSpinner
402
+ size="sm"
403
+ className="border-primary-foreground/30 border-t-primary-foreground"
404
+ />
405
+ {tc('loading')}
406
+ </>
407
+ ) : (
408
+ t('loadMore')
409
+ )}
410
+ </button>
411
+ </div>
412
+ )}
413
+ </>
414
+ )}
415
+ </div>
416
+ );
417
+ }
418
+
419
+ export default function ProductsPage() {
420
+ return (
421
+ <Suspense
422
+ fallback={
423
+ <div className="flex min-h-[60vh] items-center justify-center">
424
+ <LoadingSpinner size="lg" />
425
+ </div>
426
+ }
427
+ >
428
+ <ProductsContent />
429
+ </Suspense>
430
+ );
431
+ }