create-brainerce-store 1.28.11 → 1.28.15
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 +1 -1
- package/messages/en.json +2 -1
- package/messages/he.json +2 -1
- package/package.json +1 -1
- package/templates/nextjs/base/next.config.ts +4 -0
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +82 -76
- package/templates/nextjs/base/src/app/products/[slug]/product-client-section.tsx +28 -0
- package/templates/nextjs/base/src/app/products/page.tsx +482 -475
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.28.
|
|
34
|
+
version: "1.28.15",
|
|
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/messages/en.json
CHANGED
|
@@ -94,7 +94,8 @@
|
|
|
94
94
|
"frequentlyBoughtTogether": "Frequently Bought Together",
|
|
95
95
|
"addSelectedToCart": "Add Selected to Cart",
|
|
96
96
|
"totalPrice": "Total: {price}",
|
|
97
|
-
"addingAll": "Adding..."
|
|
97
|
+
"addingAll": "Adding...",
|
|
98
|
+
"by": "By"
|
|
98
99
|
},
|
|
99
100
|
"cart": {
|
|
100
101
|
"pageTitle": "Shopping Cart",
|
package/messages/he.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { NextConfig } from 'next';
|
|
2
2
|
|
|
3
3
|
const nextConfig: NextConfig = {
|
|
4
|
+
// isomorphic-dompurify ships jsdom, which at runtime reads stylesheet files
|
|
5
|
+
// from its own package directory. Webpack bundling breaks those relative
|
|
6
|
+
// lookups — loading it externally from node_modules keeps the paths intact.
|
|
7
|
+
serverExternalPackages: ['isomorphic-dompurify'],
|
|
4
8
|
images: {
|
|
5
9
|
// The storefront is a consumer of the Brainerce API — it has to render
|
|
6
10
|
// whatever image URLs the API returns. In practice those URLs can be on
|
|
@@ -1,76 +1,82 @@
|
|
|
1
|
-
import type { Metadata } from 'next';
|
|
2
|
-
import { headers } from 'next/headers';
|
|
3
|
-
import { notFound } from 'next/navigation';
|
|
4
|
-
import { getServerClient } from '@/lib/brainerce';
|
|
5
|
-
import { ProductJsonLd } from '@/components/seo/product-json-ld';
|
|
6
|
-
import { ProductClientSection } from './product-client-section';
|
|
7
|
-
|
|
8
|
-
type Props = {
|
|
9
|
-
params: Promise<{ slug: string }>;
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
// Read the locale the middleware set on this request so the SDK can resolve
|
|
13
|
-
// translated slugs (translations[locale].slug) in addition to the base slug.
|
|
14
|
-
async function getRequestLocale(): Promise<string | undefined> {
|
|
15
|
-
return (await headers()).get('x-locale') ?? undefined;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
19
|
-
const { slug } = await params;
|
|
20
|
-
const locale = await getRequestLocale();
|
|
21
|
-
|
|
22
|
-
try {
|
|
23
|
-
const client = getServerClient();
|
|
24
|
-
const product = await client.getProductBySlug(slug, { locale });
|
|
25
|
-
const imageUrl = product.images?.[0]?.url;
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
},
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
},
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
import { headers } from 'next/headers';
|
|
3
|
+
import { notFound } from 'next/navigation';
|
|
4
|
+
import { getServerClient } from '@/lib/brainerce';
|
|
5
|
+
import { ProductJsonLd } from '@/components/seo/product-json-ld';
|
|
6
|
+
import { ProductClientSection } from './product-client-section';
|
|
7
|
+
|
|
8
|
+
type Props = {
|
|
9
|
+
params: Promise<{ slug: string }>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// Read the locale the middleware set on this request so the SDK can resolve
|
|
13
|
+
// translated slugs (translations[locale].slug) in addition to the base slug.
|
|
14
|
+
async function getRequestLocale(): Promise<string | undefined> {
|
|
15
|
+
return (await headers()).get('x-locale') ?? undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
19
|
+
const { slug } = await params;
|
|
20
|
+
const locale = await getRequestLocale();
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const client = getServerClient();
|
|
24
|
+
const product = await client.getProductBySlug(slug, { locale });
|
|
25
|
+
const imageUrl = product.images?.[0]?.url;
|
|
26
|
+
// Prefer merchant-authored SEO copy; fall back to the visible name/description.
|
|
27
|
+
// Both are served already locale-resolved by the backend.
|
|
28
|
+
const seoTitle = (product as { seoTitle?: string | null }).seoTitle || product.name;
|
|
29
|
+
const seoDescription =
|
|
30
|
+
(product as { seoDescription?: string | null }).seoDescription ||
|
|
31
|
+
product.description?.substring(0, 160) ||
|
|
32
|
+
product.name;
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
title: seoTitle,
|
|
36
|
+
description: seoDescription,
|
|
37
|
+
alternates: {
|
|
38
|
+
canonical: `/products/${slug}`,
|
|
39
|
+
},
|
|
40
|
+
openGraph: {
|
|
41
|
+
title: seoTitle,
|
|
42
|
+
description: seoDescription,
|
|
43
|
+
images: imageUrl ? [{ url: imageUrl, alt: product.name }] : [],
|
|
44
|
+
type: 'website',
|
|
45
|
+
},
|
|
46
|
+
twitter: {
|
|
47
|
+
card: 'summary_large_image',
|
|
48
|
+
title: seoTitle,
|
|
49
|
+
description: seoDescription,
|
|
50
|
+
images: imageUrl ? [imageUrl] : [],
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
} catch {
|
|
54
|
+
return {
|
|
55
|
+
title: 'Product not found',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export default async function ProductDetailPage({ params }: Props) {
|
|
61
|
+
const { slug } = await params;
|
|
62
|
+
const locale = await getRequestLocale();
|
|
63
|
+
|
|
64
|
+
let product;
|
|
65
|
+
try {
|
|
66
|
+
const client = getServerClient();
|
|
67
|
+
product = await client.getProductBySlug(slug, { locale });
|
|
68
|
+
} catch {
|
|
69
|
+
notFound();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
73
|
+
const productUrl = `${baseUrl}/products/${slug}`;
|
|
74
|
+
const currency = process.env.NEXT_PUBLIC_STORE_CURRENCY || 'USD';
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<>
|
|
78
|
+
<ProductJsonLd product={product} url={productUrl} currency={currency} />
|
|
79
|
+
<ProductClientSection product={product} />
|
|
80
|
+
</>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -297,9 +297,37 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
|
|
|
297
297
|
</div>
|
|
298
298
|
)}
|
|
299
299
|
|
|
300
|
+
{/* Brand */}
|
|
301
|
+
{(product as { brands?: Array<{ id: string; name: string }> }).brands &&
|
|
302
|
+
(product as { brands: Array<{ id: string; name: string }> }).brands.length > 0 && (
|
|
303
|
+
<div className="text-muted-foreground text-sm">
|
|
304
|
+
{t('by')}{' '}
|
|
305
|
+
<span className="text-foreground font-medium">
|
|
306
|
+
{(product as { brands: Array<{ id: string; name: string }> }).brands
|
|
307
|
+
.map((b) => b.name)
|
|
308
|
+
.join(', ')}
|
|
309
|
+
</span>
|
|
310
|
+
</div>
|
|
311
|
+
)}
|
|
312
|
+
|
|
300
313
|
{/* Title */}
|
|
301
314
|
<h1 className="text-foreground text-2xl font-bold sm:text-3xl">{product.name}</h1>
|
|
302
315
|
|
|
316
|
+
{/* Tags */}
|
|
317
|
+
{(product as { tags?: Array<{ id: string; name: string }> }).tags &&
|
|
318
|
+
(product as { tags: Array<{ id: string; name: string }> }).tags.length > 0 && (
|
|
319
|
+
<div className="flex flex-wrap gap-1.5">
|
|
320
|
+
{(product as { tags: Array<{ id: string; name: string }> }).tags.map((tag) => (
|
|
321
|
+
<span
|
|
322
|
+
key={tag.id}
|
|
323
|
+
className="border-border text-muted-foreground rounded-full border px-2.5 py-0.5 text-xs"
|
|
324
|
+
>
|
|
325
|
+
#{tag.name}
|
|
326
|
+
</span>
|
|
327
|
+
))}
|
|
328
|
+
</div>
|
|
329
|
+
)}
|
|
330
|
+
|
|
303
331
|
{/* Price */}
|
|
304
332
|
<PriceDisplay
|
|
305
333
|
price={priceInfo.originalPrice}
|
|
@@ -1,475 +1,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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
const
|
|
230
|
-
const
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
const
|
|
234
|
-
const
|
|
235
|
-
const
|
|
236
|
-
const
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
const [
|
|
240
|
-
const [
|
|
241
|
-
const [
|
|
242
|
-
|
|
243
|
-
const
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
if (
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
if (
|
|
318
|
-
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
{
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
</
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
))}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
</div>
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
+
}
|