create-brainerce-store 1.28.13 → 1.28.17

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 (24) hide show
  1. package/dist/index.js +1 -1
  2. package/messages/en.json +390 -389
  3. package/messages/he.json +390 -389
  4. package/package.json +46 -46
  5. package/templates/nextjs/base/next.config.ts +47 -47
  6. package/templates/nextjs/base/src/app/api/auth/logout/route.ts +15 -15
  7. package/templates/nextjs/base/src/app/api/auth/oauth-callback/route.ts +66 -66
  8. package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +76 -76
  9. package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +235 -229
  10. package/templates/nextjs/base/src/app/checkout/page.tsx +975 -975
  11. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +73 -76
  12. package/templates/nextjs/base/src/app/products/[slug]/product-client-section.tsx +529 -501
  13. package/templates/nextjs/base/src/app/products/page.tsx +475 -482
  14. package/templates/nextjs/base/src/app/reset-password/page.tsx +138 -138
  15. package/templates/nextjs/base/src/components/auth/register-form.tsx +245 -245
  16. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +416 -416
  17. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +656 -656
  18. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +88 -88
  19. package/templates/nextjs/base/src/lib/brainerce.ts.ejs +6 -2
  20. package/templates/nextjs/base/src/lib/csrf.ts +11 -11
  21. package/templates/nextjs/base/src/lib/nonce.ts +10 -10
  22. package/templates/nextjs/base/src/lib/safe-redirect.ts +45 -45
  23. package/templates/nextjs/base/src/lib/sanitize-html.ts +93 -93
  24. package/templates/nextjs/base/src/lib/validation.ts +37 -37
@@ -1,482 +1,475 @@
1
- 'use client';
2
-
3
- import { Suspense, useEffect, useState, useCallback, useRef } from 'react';
4
- import { useSearchParams, useParams } from 'next/navigation';
5
- import { useRouter } from '@/lib/navigation';
6
- import type { Product } from 'brainerce';
7
- import type { ProductQueryParams } from 'brainerce';
8
- import { getClient } from '@/lib/brainerce';
9
- import { ProductGrid } from '@/components/products/product-grid';
10
- import { LoadingSpinner } from '@/components/shared/loading-spinner';
11
- import { useTranslations } from '@/lib/translations';
12
- import { cn } from '@/lib/utils';
13
-
14
- const PAGE_SIZE = 20;
15
-
16
- type SortOption = {
17
- labelKey: 'sortNewest' | 'sortNameAZ' | 'sortNameZA' | 'sortPriceLow' | 'sortPriceHigh';
18
- sortBy: ProductQueryParams['sortBy'];
19
- sortOrder: ProductQueryParams['sortOrder'];
20
- };
21
-
22
- const sortOptions: SortOption[] = [
23
- { labelKey: 'sortNewest', sortBy: 'createdAt', sortOrder: 'desc' },
24
- { labelKey: 'sortNameAZ', sortBy: 'name', sortOrder: 'asc' },
25
- { labelKey: 'sortNameZA', sortBy: 'name', sortOrder: 'desc' },
26
- { labelKey: 'sortPriceLow', sortBy: 'price', sortOrder: 'asc' },
27
- { labelKey: 'sortPriceHigh', sortBy: 'price', sortOrder: 'desc' },
28
- ];
29
-
30
- interface CategoryNode {
31
- id: string;
32
- name: string;
33
- image?: string | null;
34
- parentId?: string | null;
35
- children: CategoryNode[];
36
- }
37
-
38
- /** Collect all descendant IDs (including self) */
39
- function getAllDescendantIds(node: CategoryNode): string[] {
40
- const ids = [node.id];
41
- for (const child of node.children) {
42
- ids.push(...getAllDescendantIds(child));
43
- }
44
- return ids;
45
- }
46
-
47
- /** Check if a category or any of its descendants matches the selected ID */
48
- function isActiveInTree(node: CategoryNode, selectedId: string): boolean {
49
- if (node.id === selectedId) return true;
50
- return node.children.some((child) => isActiveInTree(child, selectedId));
51
- }
52
-
53
- /** Chevron down SVG */
54
- function ChevronDown({ className }: { className?: string }) {
55
- return (
56
- <svg
57
- className={className}
58
- width="12"
59
- height="12"
60
- viewBox="0 0 12 12"
61
- fill="none"
62
- stroke="currentColor"
63
- strokeWidth="2"
64
- strokeLinecap="round"
65
- strokeLinejoin="round"
66
- >
67
- <path d="M3 4.5L6 7.5L9 4.5" />
68
- </svg>
69
- );
70
- }
71
-
72
- /** Recursive dropdown items for nested categories */
73
- function CategoryDropdownItems({
74
- items,
75
- depth,
76
- selectedId,
77
- onSelect,
78
- }: {
79
- items: CategoryNode[];
80
- depth: number;
81
- selectedId: string;
82
- onSelect: (id: string) => void;
83
- }) {
84
- return (
85
- <>
86
- {items.map((child) => (
87
- <div key={child.id}>
88
- <button
89
- onClick={() => onSelect(child.id)}
90
- className={cn(
91
- 'hover:bg-muted w-full px-4 py-2 text-start text-sm transition-colors',
92
- selectedId === child.id && 'bg-primary/10 text-primary font-medium'
93
- )}
94
- style={{ paddingInlineStart: `${(depth + 1) * 16}px` }}
95
- >
96
- {child.name}
97
- </button>
98
- {child.children.length > 0 && (
99
- <CategoryDropdownItems
100
- items={child.children}
101
- depth={depth + 1}
102
- selectedId={selectedId}
103
- onSelect={onSelect}
104
- />
105
- )}
106
- </div>
107
- ))}
108
- </>
109
- );
110
- }
111
-
112
- /** Category chip with dropdown for subcategories */
113
- function CategoryChip({
114
- category,
115
- selectedId,
116
- onSelect,
117
- tc,
118
- }: {
119
- category: CategoryNode;
120
- selectedId: string;
121
- onSelect: (id: string) => void;
122
- tc: (key: string) => string;
123
- }) {
124
- const [open, setOpen] = useState(false);
125
- const ref = useRef<HTMLDivElement>(null);
126
- const hasChildren = category.children.length > 0;
127
- const isActive = isActiveInTree(category, selectedId);
128
-
129
- // Find the display name for the selected subcategory
130
- function findName(nodes: CategoryNode[], id: string): string | null {
131
- for (const n of nodes) {
132
- if (n.id === id) return n.name;
133
- const found = findName(n.children, id);
134
- if (found) return found;
135
- }
136
- return null;
137
- }
138
-
139
- const selectedChildName =
140
- isActive && selectedId !== category.id ? findName(category.children, selectedId) : null;
141
-
142
- // Close dropdown on outside click
143
- useEffect(() => {
144
- if (!open) return;
145
- function handleClick(e: MouseEvent) {
146
- if (ref.current && !ref.current.contains(e.target as Node)) {
147
- setOpen(false);
148
- }
149
- }
150
- document.addEventListener('mousedown', handleClick);
151
- return () => document.removeEventListener('mousedown', handleClick);
152
- }, [open]);
153
-
154
- if (!hasChildren) {
155
- return (
156
- <button
157
- onClick={() => onSelect(category.id)}
158
- className={cn(
159
- 'rounded-full border px-3 py-1.5 text-sm transition-colors',
160
- selectedId === category.id
161
- ? 'bg-primary text-primary-foreground border-primary'
162
- : 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
163
- )}
164
- >
165
- {category.name}
166
- </button>
167
- );
168
- }
169
-
170
- return (
171
- <div ref={ref} className="relative">
172
- <button
173
- onClick={() => setOpen((prev) => !prev)}
174
- className={cn(
175
- 'inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-sm transition-colors',
176
- isActive
177
- ? 'bg-primary text-primary-foreground border-primary'
178
- : 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
179
- )}
180
- >
181
- {category.name}
182
- {selectedChildName && (
183
- <span className="opacity-80">
184
- {'·'} {selectedChildName}
185
- </span>
186
- )}
187
- <ChevronDown className={cn('ms-0.5 transition-transform', open && 'rotate-180')} />
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
- 'hover:bg-muted w-full px-4 py-2 text-start text-sm font-medium transition-colors',
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 onClick={() => setOpen(false)}>
208
- <CategoryDropdownItems
209
- items={category.children}
210
- depth={0}
211
- selectedId={selectedId}
212
- onSelect={onSelect}
213
- />
214
- </div>
215
- </div>
216
- )}
217
- </div>
218
- );
219
- }
220
-
221
- function ProductsContent() {
222
- const searchParams = useSearchParams();
223
- const router = useRouter();
224
- // When i18n is enabled this file lives under `app/[locale]/products/` (the
225
- // scaffold script moves it). `useParams().locale` is populated then and
226
- // undefined otherwise, so passing it to the SDK is a no-op for single-locale
227
- // stores and avoids the StoreProvider-setLocale timing race for multi-locale.
228
- const routeParams = useParams<{ locale?: string }>();
229
- const locale = (routeParams?.locale as string | undefined) || undefined;
230
- const t = useTranslations('products');
231
- const tc = useTranslations('common');
232
-
233
- const searchQuery = searchParams.get('search') || '';
234
- const categoryId = searchParams.get('category') || '';
235
- const brandId = searchParams.get('brand') || '';
236
- const tagId = searchParams.get('tag') || '';
237
- const sortParam = searchParams.get('sort') || '0';
238
-
239
- const [products, setProducts] = useState<Product[]>([]);
240
- const [loading, setLoading] = useState(true);
241
- const [loadingMore, setLoadingMore] = useState(false);
242
- const [page, setPage] = useState(1);
243
- const [totalPages, setTotalPages] = useState(1);
244
- const [total, setTotal] = useState(0);
245
- const [categories, setCategories] = useState<CategoryNode[]>([]);
246
- const [brands, setBrands] = useState<Array<{ id: string; name: string }>>([]);
247
- const [tags, setTags] = useState<Array<{ id: string; name: string }>>([]);
248
-
249
- const sortIndex = parseInt(sortParam, 10) || 0;
250
- const currentSort = sortOptions[sortIndex] || sortOptions[0];
251
-
252
- // Load categories, brands, and tags
253
- useEffect(() => {
254
- async function loadFilters() {
255
- const client = getClient();
256
- const [catRes, brandRes, tagRes] = await Promise.allSettled([
257
- client.getCategories({ locale }),
258
- client.getBrands({ locale }),
259
- client.getTags({ locale }),
260
- ]);
261
- if (catRes.status === 'fulfilled') setCategories(catRes.value.categories as CategoryNode[]);
262
- if (brandRes.status === 'fulfilled') setBrands(brandRes.value.brands);
263
- if (tagRes.status === 'fulfilled') setTags(tagRes.value.tags);
264
- }
265
- loadFilters();
266
- }, [locale]);
267
-
268
- // Load products when filters change
269
- const loadProducts = useCallback(
270
- async (pageNum: number, append: boolean) => {
271
- try {
272
- if (append) {
273
- setLoadingMore(true);
274
- } else {
275
- setLoading(true);
276
- }
277
-
278
- const client = getClient();
279
- const params: ProductQueryParams = {
280
- page: pageNum,
281
- limit: PAGE_SIZE,
282
- sortBy: currentSort.sortBy,
283
- sortOrder: currentSort.sortOrder,
284
- locale,
285
- };
286
-
287
- if (searchQuery) params.search = searchQuery;
288
- if (categoryId) params.categories = categoryId;
289
- if (brandId) params.brands = brandId;
290
- if (tagId) params.tags = tagId;
291
-
292
- const result = await client.getProducts(params);
293
-
294
- if (append) {
295
- setProducts((prev) => [...prev, ...result.data]);
296
- } else {
297
- setProducts(result.data);
298
- }
299
- setTotalPages(result.meta.totalPages);
300
- setTotal(result.meta.total);
301
- setPage(pageNum);
302
- } catch (err) {
303
- console.error('Failed to load products:', err);
304
- } finally {
305
- setLoading(false);
306
- setLoadingMore(false);
307
- }
308
- },
309
- [searchQuery, categoryId, brandId, tagId, currentSort.sortBy, currentSort.sortOrder, locale]
310
- );
311
-
312
- useEffect(() => {
313
- loadProducts(1, false);
314
- }, [loadProducts]);
315
-
316
- function handleLoadMore() {
317
- if (page < totalPages && !loadingMore) {
318
- loadProducts(page + 1, true);
319
- }
320
- }
321
-
322
- function updateParam(key: string, value: string) {
323
- const params = new URLSearchParams(searchParams.toString());
324
- if (value) {
325
- params.set(key, value);
326
- } else {
327
- params.delete(key);
328
- }
329
- router.push(`/products?${params.toString()}`);
330
- }
331
-
332
- function handleCategorySelect(id: string) {
333
- updateParam('category', id);
334
- }
335
-
336
- return (
337
- <div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
338
- {/* Page Header */}
339
- <div className="mb-8">
340
- <h1 className="text-foreground text-3xl font-bold">
341
- {searchQuery ? `${t('searchPrefix')} "${searchQuery}"` : t('allProducts')}
342
- </h1>
343
- {!loading && (
344
- <p className="text-muted-foreground mt-1 text-sm">
345
- {total} {total === 1 ? tc('product') : tc('products')} {tc('found')}
346
- </p>
347
- )}
348
- </div>
349
-
350
- {/* Filters and Sort */}
351
- <div className="mb-6 flex flex-col gap-4">
352
- {/* Category Filter */}
353
- {categories.length > 0 && (
354
- <div className="flex flex-wrap gap-2">
355
- <button
356
- onClick={() => updateParam('category', '')}
357
- className={cn(
358
- 'rounded-full border px-3 py-1.5 text-sm transition-colors',
359
- !categoryId
360
- ? 'bg-primary text-primary-foreground border-primary'
361
- : 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
362
- )}
363
- >
364
- {tc('all')}
365
- </button>
366
- {categories.map((cat) => (
367
- <CategoryChip
368
- key={cat.id}
369
- category={cat}
370
- selectedId={categoryId}
371
- onSelect={handleCategorySelect}
372
- tc={tc as (key: string) => string}
373
- />
374
- ))}
375
- </div>
376
- )}
377
-
378
- {/* Brand, Tag & Sort row */}
379
- <div className="flex flex-wrap items-center gap-3">
380
- {/* Brand Filter */}
381
- {brands.length > 0 && (
382
- <select
383
- value={brandId}
384
- onChange={(e) => updateParam('brand', e.target.value)}
385
- 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"
386
- >
387
- <option value="">{t('allBrands')}</option>
388
- {brands.map((b) => (
389
- <option key={b.id} value={b.id}>
390
- {b.name}
391
- </option>
392
- ))}
393
- </select>
394
- )}
395
-
396
- {/* Tag Filter */}
397
- {tags.length > 0 && (
398
- <select
399
- value={tagId}
400
- onChange={(e) => updateParam('tag', e.target.value)}
401
- 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"
402
- >
403
- <option value="">{t('allTags')}</option>
404
- {tags.map((tg) => (
405
- <option key={tg.id} value={tg.id}>
406
- {tg.name}
407
- </option>
408
- ))}
409
- </select>
410
- )}
411
-
412
- {/* Sort */}
413
- <div className="flex items-center gap-2 sm:ms-auto">
414
- <label htmlFor="sort" className="text-muted-foreground whitespace-nowrap text-sm">
415
- {tc('sortBy')}
416
- </label>
417
- <select
418
- id="sort"
419
- value={sortIndex}
420
- onChange={(e) => updateParam('sort', e.target.value)}
421
- 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"
422
- >
423
- {sortOptions.map((opt, idx) => (
424
- <option key={idx} value={idx}>
425
- {t(opt.labelKey)}
426
- </option>
427
- ))}
428
- </select>
429
- </div>
430
- </div>
431
- </div>
432
-
433
- {/* Products Grid */}
434
- {loading ? (
435
- <div className="flex items-center justify-center py-20">
436
- <LoadingSpinner size="lg" />
437
- </div>
438
- ) : (
439
- <>
440
- <ProductGrid products={products} />
441
-
442
- {/* Load More */}
443
- {page < totalPages && (
444
- <div className="mt-10 flex justify-center">
445
- <button
446
- onClick={handleLoadMore}
447
- disabled={loadingMore}
448
- 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"
449
- >
450
- {loadingMore ? (
451
- <>
452
- <LoadingSpinner
453
- size="sm"
454
- className="border-primary-foreground/30 border-t-primary-foreground"
455
- />
456
- {tc('loading')}
457
- </>
458
- ) : (
459
- t('loadMore')
460
- )}
461
- </button>
462
- </div>
463
- )}
464
- </>
465
- )}
466
- </div>
467
- );
468
- }
469
-
470
- export default function ProductsPage() {
471
- return (
472
- <Suspense
473
- fallback={
474
- <div className="flex min-h-[60vh] items-center justify-center">
475
- <LoadingSpinner size="lg" />
476
- </div>
477
- }
478
- >
479
- <ProductsContent />
480
- </Suspense>
481
- );
482
- }
1
+ 'use client';
2
+
3
+ import { Suspense, useEffect, useState, useCallback, useRef } from 'react';
4
+ import { useSearchParams } from 'next/navigation';
5
+ import { useRouter } from '@/lib/navigation';
6
+ import type { Product } from 'brainerce';
7
+ import type { ProductQueryParams } from 'brainerce';
8
+ import { getClient } from '@/lib/brainerce';
9
+ import { ProductGrid } from '@/components/products/product-grid';
10
+ import { LoadingSpinner } from '@/components/shared/loading-spinner';
11
+ import { useTranslations } from '@/lib/translations';
12
+ import { cn } from '@/lib/utils';
13
+
14
+ const PAGE_SIZE = 20;
15
+
16
+ type SortOption = {
17
+ labelKey: 'sortNewest' | 'sortNameAZ' | 'sortNameZA' | 'sortPriceLow' | 'sortPriceHigh';
18
+ sortBy: ProductQueryParams['sortBy'];
19
+ sortOrder: ProductQueryParams['sortOrder'];
20
+ };
21
+
22
+ const sortOptions: SortOption[] = [
23
+ { labelKey: 'sortNewest', sortBy: 'createdAt', sortOrder: 'desc' },
24
+ { labelKey: 'sortNameAZ', sortBy: 'name', sortOrder: 'asc' },
25
+ { labelKey: 'sortNameZA', sortBy: 'name', sortOrder: 'desc' },
26
+ { labelKey: 'sortPriceLow', sortBy: 'price', sortOrder: 'asc' },
27
+ { labelKey: 'sortPriceHigh', sortBy: 'price', sortOrder: 'desc' },
28
+ ];
29
+
30
+ interface CategoryNode {
31
+ id: string;
32
+ name: string;
33
+ image?: string | null;
34
+ parentId?: string | null;
35
+ children: CategoryNode[];
36
+ }
37
+
38
+ /** Collect all descendant IDs (including self) */
39
+ function getAllDescendantIds(node: CategoryNode): string[] {
40
+ const ids = [node.id];
41
+ for (const child of node.children) {
42
+ ids.push(...getAllDescendantIds(child));
43
+ }
44
+ return ids;
45
+ }
46
+
47
+ /** Check if a category or any of its descendants matches the selected ID */
48
+ function isActiveInTree(node: CategoryNode, selectedId: string): boolean {
49
+ if (node.id === selectedId) return true;
50
+ return node.children.some((child) => isActiveInTree(child, selectedId));
51
+ }
52
+
53
+ /** Chevron down SVG */
54
+ function ChevronDown({ className }: { className?: string }) {
55
+ return (
56
+ <svg
57
+ className={className}
58
+ width="12"
59
+ height="12"
60
+ viewBox="0 0 12 12"
61
+ fill="none"
62
+ stroke="currentColor"
63
+ strokeWidth="2"
64
+ strokeLinecap="round"
65
+ strokeLinejoin="round"
66
+ >
67
+ <path d="M3 4.5L6 7.5L9 4.5" />
68
+ </svg>
69
+ );
70
+ }
71
+
72
+ /** Recursive dropdown items for nested categories */
73
+ function CategoryDropdownItems({
74
+ items,
75
+ depth,
76
+ selectedId,
77
+ onSelect,
78
+ }: {
79
+ items: CategoryNode[];
80
+ depth: number;
81
+ selectedId: string;
82
+ onSelect: (id: string) => void;
83
+ }) {
84
+ return (
85
+ <>
86
+ {items.map((child) => (
87
+ <div key={child.id}>
88
+ <button
89
+ onClick={() => onSelect(child.id)}
90
+ className={cn(
91
+ 'hover:bg-muted w-full px-4 py-2 text-start text-sm transition-colors',
92
+ selectedId === child.id && 'bg-primary/10 text-primary font-medium'
93
+ )}
94
+ style={{ paddingInlineStart: `${(depth + 1) * 16}px` }}
95
+ >
96
+ {child.name}
97
+ </button>
98
+ {child.children.length > 0 && (
99
+ <CategoryDropdownItems
100
+ items={child.children}
101
+ depth={depth + 1}
102
+ selectedId={selectedId}
103
+ onSelect={onSelect}
104
+ />
105
+ )}
106
+ </div>
107
+ ))}
108
+ </>
109
+ );
110
+ }
111
+
112
+ /** Category chip with dropdown for subcategories */
113
+ function CategoryChip({
114
+ category,
115
+ selectedId,
116
+ onSelect,
117
+ tc,
118
+ }: {
119
+ category: CategoryNode;
120
+ selectedId: string;
121
+ onSelect: (id: string) => void;
122
+ tc: (key: string) => string;
123
+ }) {
124
+ const [open, setOpen] = useState(false);
125
+ const ref = useRef<HTMLDivElement>(null);
126
+ const hasChildren = category.children.length > 0;
127
+ const isActive = isActiveInTree(category, selectedId);
128
+
129
+ // Find the display name for the selected subcategory
130
+ function findName(nodes: CategoryNode[], id: string): string | null {
131
+ for (const n of nodes) {
132
+ if (n.id === id) return n.name;
133
+ const found = findName(n.children, id);
134
+ if (found) return found;
135
+ }
136
+ return null;
137
+ }
138
+
139
+ const selectedChildName =
140
+ isActive && selectedId !== category.id ? findName(category.children, selectedId) : null;
141
+
142
+ // Close dropdown on outside click
143
+ useEffect(() => {
144
+ if (!open) return;
145
+ function handleClick(e: MouseEvent) {
146
+ if (ref.current && !ref.current.contains(e.target as Node)) {
147
+ setOpen(false);
148
+ }
149
+ }
150
+ document.addEventListener('mousedown', handleClick);
151
+ return () => document.removeEventListener('mousedown', handleClick);
152
+ }, [open]);
153
+
154
+ if (!hasChildren) {
155
+ return (
156
+ <button
157
+ onClick={() => onSelect(category.id)}
158
+ className={cn(
159
+ 'rounded-full border px-3 py-1.5 text-sm transition-colors',
160
+ selectedId === category.id
161
+ ? 'bg-primary text-primary-foreground border-primary'
162
+ : 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
163
+ )}
164
+ >
165
+ {category.name}
166
+ </button>
167
+ );
168
+ }
169
+
170
+ return (
171
+ <div ref={ref} className="relative">
172
+ <button
173
+ onClick={() => setOpen((prev) => !prev)}
174
+ className={cn(
175
+ 'inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-sm transition-colors',
176
+ isActive
177
+ ? 'bg-primary text-primary-foreground border-primary'
178
+ : 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
179
+ )}
180
+ >
181
+ {category.name}
182
+ {selectedChildName && (
183
+ <span className="opacity-80">
184
+ {'·'} {selectedChildName}
185
+ </span>
186
+ )}
187
+ <ChevronDown className={cn('ms-0.5 transition-transform', open && 'rotate-180')} />
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
+ 'hover:bg-muted w-full px-4 py-2 text-start text-sm font-medium transition-colors',
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 onClick={() => setOpen(false)}>
208
+ <CategoryDropdownItems
209
+ items={category.children}
210
+ depth={0}
211
+ selectedId={selectedId}
212
+ onSelect={onSelect}
213
+ />
214
+ </div>
215
+ </div>
216
+ )}
217
+ </div>
218
+ );
219
+ }
220
+
221
+ function ProductsContent() {
222
+ const searchParams = useSearchParams();
223
+ const router = useRouter();
224
+ const t = useTranslations('products');
225
+ const tc = useTranslations('common');
226
+
227
+ const searchQuery = searchParams.get('search') || '';
228
+ const categoryId = searchParams.get('category') || '';
229
+ const brandId = searchParams.get('brand') || '';
230
+ const tagId = searchParams.get('tag') || '';
231
+ const sortParam = searchParams.get('sort') || '0';
232
+
233
+ const [products, setProducts] = useState<Product[]>([]);
234
+ const [loading, setLoading] = useState(true);
235
+ const [loadingMore, setLoadingMore] = useState(false);
236
+ const [page, setPage] = useState(1);
237
+ const [totalPages, setTotalPages] = useState(1);
238
+ const [total, setTotal] = useState(0);
239
+ const [categories, setCategories] = useState<CategoryNode[]>([]);
240
+ const [brands, setBrands] = useState<Array<{ id: string; name: string }>>([]);
241
+ const [tags, setTags] = useState<Array<{ id: string; name: string }>>([]);
242
+
243
+ const sortIndex = parseInt(sortParam, 10) || 0;
244
+ const currentSort = sortOptions[sortIndex] || sortOptions[0];
245
+
246
+ // Load categories, brands, and tags
247
+ useEffect(() => {
248
+ async function loadFilters() {
249
+ const client = getClient();
250
+ const [catRes, brandRes, tagRes] = await Promise.allSettled([
251
+ client.getCategories(),
252
+ client.getBrands(),
253
+ client.getTags(),
254
+ ]);
255
+ if (catRes.status === 'fulfilled') setCategories(catRes.value.categories as CategoryNode[]);
256
+ if (brandRes.status === 'fulfilled') setBrands(brandRes.value.brands);
257
+ if (tagRes.status === 'fulfilled') setTags(tagRes.value.tags);
258
+ }
259
+ loadFilters();
260
+ }, []);
261
+
262
+ // Load products when filters change
263
+ const loadProducts = useCallback(
264
+ async (pageNum: number, append: boolean) => {
265
+ try {
266
+ if (append) {
267
+ setLoadingMore(true);
268
+ } else {
269
+ setLoading(true);
270
+ }
271
+
272
+ const client = getClient();
273
+ const params: ProductQueryParams = {
274
+ page: pageNum,
275
+ limit: PAGE_SIZE,
276
+ sortBy: currentSort.sortBy,
277
+ sortOrder: currentSort.sortOrder,
278
+ };
279
+
280
+ if (searchQuery) params.search = searchQuery;
281
+ if (categoryId) params.categories = categoryId;
282
+ if (brandId) params.brands = brandId;
283
+ if (tagId) params.tags = tagId;
284
+
285
+ const result = await client.getProducts(params);
286
+
287
+ if (append) {
288
+ setProducts((prev) => [...prev, ...result.data]);
289
+ } else {
290
+ setProducts(result.data);
291
+ }
292
+ setTotalPages(result.meta.totalPages);
293
+ setTotal(result.meta.total);
294
+ setPage(pageNum);
295
+ } catch (err) {
296
+ console.error('Failed to load products:', err);
297
+ } finally {
298
+ setLoading(false);
299
+ setLoadingMore(false);
300
+ }
301
+ },
302
+ [searchQuery, categoryId, brandId, tagId, currentSort.sortBy, currentSort.sortOrder]
303
+ );
304
+
305
+ useEffect(() => {
306
+ loadProducts(1, false);
307
+ }, [loadProducts]);
308
+
309
+ function handleLoadMore() {
310
+ if (page < totalPages && !loadingMore) {
311
+ loadProducts(page + 1, true);
312
+ }
313
+ }
314
+
315
+ function updateParam(key: string, value: string) {
316
+ const params = new URLSearchParams(searchParams.toString());
317
+ if (value) {
318
+ params.set(key, value);
319
+ } else {
320
+ params.delete(key);
321
+ }
322
+ router.push(`/products?${params.toString()}`);
323
+ }
324
+
325
+ function handleCategorySelect(id: string) {
326
+ updateParam('category', id);
327
+ }
328
+
329
+ return (
330
+ <div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
331
+ {/* Page Header */}
332
+ <div className="mb-8">
333
+ <h1 className="text-foreground text-3xl font-bold">
334
+ {searchQuery ? `${t('searchPrefix')} "${searchQuery}"` : t('allProducts')}
335
+ </h1>
336
+ {!loading && (
337
+ <p className="text-muted-foreground mt-1 text-sm">
338
+ {total} {total === 1 ? tc('product') : tc('products')} {tc('found')}
339
+ </p>
340
+ )}
341
+ </div>
342
+
343
+ {/* Filters and Sort */}
344
+ <div className="mb-6 flex flex-col gap-4">
345
+ {/* Category Filter */}
346
+ {categories.length > 0 && (
347
+ <div className="flex flex-wrap gap-2">
348
+ <button
349
+ onClick={() => updateParam('category', '')}
350
+ className={cn(
351
+ 'rounded-full border px-3 py-1.5 text-sm transition-colors',
352
+ !categoryId
353
+ ? 'bg-primary text-primary-foreground border-primary'
354
+ : 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
355
+ )}
356
+ >
357
+ {tc('all')}
358
+ </button>
359
+ {categories.map((cat) => (
360
+ <CategoryChip
361
+ key={cat.id}
362
+ category={cat}
363
+ selectedId={categoryId}
364
+ onSelect={handleCategorySelect}
365
+ tc={tc as (key: string) => string}
366
+ />
367
+ ))}
368
+ </div>
369
+ )}
370
+
371
+ {/* Brand, Tag & Sort row */}
372
+ <div className="flex flex-wrap items-center gap-3">
373
+ {/* Brand Filter */}
374
+ {brands.length > 0 && (
375
+ <select
376
+ value={brandId}
377
+ onChange={(e) => updateParam('brand', e.target.value)}
378
+ 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"
379
+ >
380
+ <option value="">{t('allBrands')}</option>
381
+ {brands.map((b) => (
382
+ <option key={b.id} value={b.id}>
383
+ {b.name}
384
+ </option>
385
+ ))}
386
+ </select>
387
+ )}
388
+
389
+ {/* Tag Filter */}
390
+ {tags.length > 0 && (
391
+ <select
392
+ value={tagId}
393
+ onChange={(e) => updateParam('tag', e.target.value)}
394
+ 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"
395
+ >
396
+ <option value="">{t('allTags')}</option>
397
+ {tags.map((tg) => (
398
+ <option key={tg.id} value={tg.id}>
399
+ {tg.name}
400
+ </option>
401
+ ))}
402
+ </select>
403
+ )}
404
+
405
+ {/* Sort */}
406
+ <div className="flex items-center gap-2 sm:ms-auto">
407
+ <label htmlFor="sort" className="text-muted-foreground whitespace-nowrap text-sm">
408
+ {tc('sortBy')}
409
+ </label>
410
+ <select
411
+ id="sort"
412
+ value={sortIndex}
413
+ onChange={(e) => updateParam('sort', e.target.value)}
414
+ 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"
415
+ >
416
+ {sortOptions.map((opt, idx) => (
417
+ <option key={idx} value={idx}>
418
+ {t(opt.labelKey)}
419
+ </option>
420
+ ))}
421
+ </select>
422
+ </div>
423
+ </div>
424
+ </div>
425
+
426
+ {/* Products Grid */}
427
+ {loading ? (
428
+ <div className="flex items-center justify-center py-20">
429
+ <LoadingSpinner size="lg" />
430
+ </div>
431
+ ) : (
432
+ <>
433
+ <ProductGrid products={products} />
434
+
435
+ {/* Load More */}
436
+ {page < totalPages && (
437
+ <div className="mt-10 flex justify-center">
438
+ <button
439
+ onClick={handleLoadMore}
440
+ disabled={loadingMore}
441
+ 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"
442
+ >
443
+ {loadingMore ? (
444
+ <>
445
+ <LoadingSpinner
446
+ size="sm"
447
+ className="border-primary-foreground/30 border-t-primary-foreground"
448
+ />
449
+ {tc('loading')}
450
+ </>
451
+ ) : (
452
+ t('loadMore')
453
+ )}
454
+ </button>
455
+ </div>
456
+ )}
457
+ </>
458
+ )}
459
+ </div>
460
+ );
461
+ }
462
+
463
+ export default function ProductsPage() {
464
+ return (
465
+ <Suspense
466
+ fallback={
467
+ <div className="flex min-h-[60vh] items-center justify-center">
468
+ <LoadingSpinner size="lg" />
469
+ </div>
470
+ }
471
+ >
472
+ <ProductsContent />
473
+ </Suspense>
474
+ );
475
+ }