create-brainerce-store 1.62.0 → 1.64.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.
- package/LICENSE +21 -21
- package/README.md +71 -71
- package/dist/index.js +26 -3
- package/messages/en.json +502 -502
- package/messages/he.json +502 -502
- package/package.json +1 -1
- package/templates/nextjs/base/.claude/commands/design.md +60 -60
- package/templates/nextjs/base/.env.local.ejs +24 -24
- package/templates/nextjs/base/.eslintrc.json +57 -57
- package/templates/nextjs/base/.mcp.json +8 -8
- package/templates/nextjs/base/AI-GUIDE.md +174 -174
- package/templates/nextjs/base/TRANSLATIONS.md +200 -200
- package/templates/nextjs/base/package.json.ejs +52 -52
- package/templates/nextjs/base/src/app/agents.md/route.ts +87 -0
- package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +308 -262
- package/templates/nextjs/base/src/app/blog/page.tsx.ejs +276 -276
- package/templates/nextjs/base/src/app/blog/rss.xml/route.ts +70 -0
- package/templates/nextjs/base/src/app/cart/page.tsx +7 -7
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +29 -3
- package/templates/nextjs/base/src/app/checkout/page.tsx +12 -0
- package/templates/nextjs/base/src/app/contact/layout.tsx.ejs +14 -0
- package/templates/nextjs/base/src/app/globals.css +36 -36
- package/templates/nextjs/base/src/app/indexnow-key.txt/route.ts +26 -26
- package/templates/nextjs/base/src/app/layout.tsx.ejs +30 -0
- package/templates/nextjs/base/src/app/llms.txt/route.ts +28 -5
- package/templates/nextjs/base/src/app/not-found.tsx.ejs +35 -0
- package/templates/nextjs/base/src/app/opengraph-image.tsx +109 -0
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +295 -278
- package/templates/nextjs/base/src/app/order-status/page.tsx +121 -121
- package/templates/nextjs/base/src/app/page.tsx +64 -64
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +14 -2
- package/templates/nextjs/base/src/app/products/page.tsx +19 -19
- package/templates/nextjs/base/src/app/robots.ts +73 -14
- package/templates/nextjs/base/src/app/sitemap.ts +89 -66
- package/templates/nextjs/base/src/components/account/order-history.tsx +385 -385
- package/templates/nextjs/base/src/components/brainerce-bot.tsx +70 -70
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +612 -612
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +292 -292
- package/templates/nextjs/base/src/components/checkout/date-picker.tsx +309 -309
- package/templates/nextjs/base/src/components/checkout/datetime-picker.tsx +144 -144
- package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +242 -242
- package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +198 -198
- package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +125 -125
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +74 -74
- package/templates/nextjs/base/src/components/seo/article-json-ld.tsx +59 -59
- package/templates/nextjs/base/src/components/seo/breadcrumbs.tsx +37 -37
- package/templates/nextjs/base/src/components/seo/category-json-ld.tsx +61 -61
- package/templates/nextjs/base/src/components/seo/organization-json-ld.tsx +97 -97
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +23 -131
- package/templates/nextjs/base/src/components/tracking-bootstrap.tsx +62 -0
- package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -53
- package/templates/nextjs/base/src/components/ui/badge.tsx +32 -32
- package/templates/nextjs/base/src/components/ui/button.tsx +49 -49
- package/templates/nextjs/base/src/components/ui/card.tsx +55 -55
- package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -28
- package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -107
- package/templates/nextjs/base/src/components/ui/input.tsx +22 -22
- package/templates/nextjs/base/src/components/ui/label.tsx +21 -21
- package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -38
- package/templates/nextjs/base/src/components/ui/select.tsx +153 -153
- package/templates/nextjs/base/src/components/ui/separator.tsx +26 -26
- package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -125
- package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -7
- package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -55
- package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -21
- package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -32
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +58 -58
- package/templates/nextjs/base/src/core/hooks/use-home-data.ts +51 -51
- package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +295 -295
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +20 -2
- package/templates/nextjs/base/src/core/lib/image-hosts.ts +7 -7
- package/templates/nextjs/base/src/core/lib/product-options.ts +121 -121
- package/templates/nextjs/base/src/core/lib/resolve-currency.ts +20 -20
- package/templates/nextjs/base/src/core/lib/safe-redirect.ts +38 -38
- package/templates/nextjs/base/src/core/lib/seo.ts +46 -46
- package/templates/nextjs/base/src/core/lib/store-info.ts +17 -0
- package/templates/nextjs/base/src/core/lib/tracking.ts +171 -0
- package/templates/nextjs/base/src/core/lib/use-currency.ts +19 -19
- package/templates/nextjs/base/src/core/lib/utils.ts +21 -21
- package/templates/nextjs/base/src/middleware.ts.ejs +54 -2
- package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +117 -117
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +146 -146
- package/templates/nextjs/base/src/ui/cart/cart-nudges.tsx +28 -28
- package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +110 -110
- package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +126 -126
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +140 -140
- package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +134 -134
- package/templates/nextjs/base/src/ui/cart/free-shipping-bar.tsx +61 -61
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +95 -95
- package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +22 -20
- package/templates/nextjs/base/src/ui/home/featured-products-section.tsx +24 -24
- package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -27
- package/templates/nextjs/base/src/ui/home/home-client.tsx +29 -29
- package/templates/nextjs/base/src/ui/layout/announcement-bar.tsx.ejs +116 -116
- package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -90
- package/templates/nextjs/base/src/ui/layout/header-account.tsx +40 -40
- package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -80
- package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +144 -144
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +152 -152
- package/templates/nextjs/base/src/ui/product/customization-fields.tsx +457 -457
- package/templates/nextjs/base/src/ui/product/discount-badge.tsx +18 -18
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +191 -191
- package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -206
- package/templates/nextjs/base/src/ui/product/price-display.tsx +62 -62
- package/templates/nextjs/base/src/ui/product/product-card.tsx +7 -2
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +455 -455
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +446 -446
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +102 -102
- package/templates/nextjs/base/src/ui/product/review-form.tsx +294 -294
- package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +108 -108
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +61 -61
- package/templates/nextjs/base/src/ui/product/variant-selector.tsx +291 -291
- package/templates/nextjs/base/src/ui/shared/cdn-image.tsx +26 -26
- package/templates/nextjs/base/tailwind.config.ts +69 -69
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +30 -0
- package/templates/nextjs/designs/atelier/globals.css +402 -402
- package/templates/nextjs/designs/atelier/messages-patch/en.json +100 -100
- package/templates/nextjs/designs/atelier/messages-patch/he.json +100 -100
- package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +110 -110
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -163
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -140
- package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -34
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -107
- package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +123 -123
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -147
- package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -142
- package/templates/nextjs/designs/atelier/ui/cart/free-shipping-bar.tsx +77 -77
- package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +89 -89
- package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -34
- package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +151 -151
- package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -27
- package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +75 -75
- package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -36
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +174 -174
- package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -54
- package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -78
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -46
- package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -118
- package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -96
- package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -36
- package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -36
- package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -137
- package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -14
- package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -34
- package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -141
- package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -138
- package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -466
- package/templates/nextjs/designs/atelier/ui/product/discount-badge.tsx +17 -17
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +196 -196
- package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -193
- package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -69
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +13 -11
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +477 -477
- package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +42 -42
- package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -260
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -95
- package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +267 -267
- package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -148
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -66
- package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -206
- package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -29
- package/templates/nextjs/designs/atelier/ui/shared/fly-to-cart.ts +86 -86
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +276 -276
- package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +63 -63
- package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -71
- package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -125
- package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +89 -89
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +123 -123
- package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +29 -27
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +99 -99
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +105 -105
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +106 -106
- package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +119 -119
- package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +54 -54
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +81 -81
- package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +21 -19
- package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +23 -23
- package/templates/nextjs/ui-canvas/home/hero-section.tsx +21 -21
- package/templates/nextjs/ui-canvas/home/home-client.tsx +26 -26
- package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +101 -101
- package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +71 -71
- package/templates/nextjs/ui-canvas/layout/header-account.tsx +31 -31
- package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +76 -76
- package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +36 -36
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +82 -82
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +119 -119
- package/templates/nextjs/ui-canvas/product/customization-fields.tsx +445 -445
- package/templates/nextjs/ui-canvas/product/discount-badge.tsx +19 -19
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +168 -168
- package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +165 -165
- package/templates/nextjs/ui-canvas/product/price-display.tsx +52 -52
- package/templates/nextjs/ui-canvas/product/product-card.tsx +7 -2
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +352 -352
- package/templates/nextjs/ui-canvas/product/product-grid.tsx +27 -27
- package/templates/nextjs/ui-canvas/product/product-listing.tsx +224 -224
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +99 -99
- package/templates/nextjs/ui-canvas/product/review-form.tsx +266 -266
- package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +96 -96
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +45 -45
- package/templates/nextjs/ui-canvas/product/variant-selector.tsx +212 -212
- package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +31 -31
- package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +27 -27
- package/templates/nextjs/base/tsconfig.tsbuildinfo +0 -1
|
@@ -1,446 +1,446 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useEffect, useState, useRef } from 'react';
|
|
4
|
-
import { ChevronDown } from 'lucide-react';
|
|
5
|
-
import {
|
|
6
|
-
useProductListing,
|
|
7
|
-
sortOptions,
|
|
8
|
-
isActiveInTree,
|
|
9
|
-
type CategoryNode,
|
|
10
|
-
} from '@/core/hooks/use-product-listing';
|
|
11
|
-
import { ProductGrid } from '@/ui/product/product-grid';
|
|
12
|
-
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
13
|
-
import { Button } from '@/components/ui/button';
|
|
14
|
-
import { Checkbox } from '@/components/ui/checkbox';
|
|
15
|
-
import { Label } from '@/components/ui/label';
|
|
16
|
-
import {
|
|
17
|
-
Select,
|
|
18
|
-
SelectContent,
|
|
19
|
-
SelectItem,
|
|
20
|
-
SelectTrigger,
|
|
21
|
-
SelectValue,
|
|
22
|
-
} from '@/components/ui/select';
|
|
23
|
-
import { useTranslations } from '@/core/lib/translations';
|
|
24
|
-
import { cn } from '@/core/lib/utils';
|
|
25
|
-
|
|
26
|
-
/** Radix Select items can't carry an empty-string value, so "no filter"
|
|
27
|
-
* round-trips through this sentinel and is written back as ''. */
|
|
28
|
-
const ALL = '__all__';
|
|
29
|
-
|
|
30
|
-
/** Recursive dropdown items for nested categories */
|
|
31
|
-
function CategoryDropdownItems({
|
|
32
|
-
items,
|
|
33
|
-
depth,
|
|
34
|
-
selectedId,
|
|
35
|
-
onSelect,
|
|
36
|
-
}: {
|
|
37
|
-
items: CategoryNode[];
|
|
38
|
-
depth: number;
|
|
39
|
-
selectedId: string;
|
|
40
|
-
onSelect: (id: string) => void;
|
|
41
|
-
}) {
|
|
42
|
-
return (
|
|
43
|
-
<>
|
|
44
|
-
{items.map((child) => (
|
|
45
|
-
<div key={child.id}>
|
|
46
|
-
<button
|
|
47
|
-
onClick={() => onSelect(child.id)}
|
|
48
|
-
className={cn(
|
|
49
|
-
'hover:bg-muted w-full px-4 py-2 text-start text-sm transition-colors',
|
|
50
|
-
selectedId === child.id && 'bg-primary/10 text-primary font-medium'
|
|
51
|
-
)}
|
|
52
|
-
style={{ paddingInlineStart: `${(depth + 1) * 16}px` }}
|
|
53
|
-
>
|
|
54
|
-
{child.name}
|
|
55
|
-
</button>
|
|
56
|
-
{child.children.length > 0 && (
|
|
57
|
-
<CategoryDropdownItems
|
|
58
|
-
items={child.children}
|
|
59
|
-
depth={depth + 1}
|
|
60
|
-
selectedId={selectedId}
|
|
61
|
-
onSelect={onSelect}
|
|
62
|
-
/>
|
|
63
|
-
)}
|
|
64
|
-
</div>
|
|
65
|
-
))}
|
|
66
|
-
</>
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** Category chip with dropdown for subcategories */
|
|
71
|
-
function CategoryChip({
|
|
72
|
-
category,
|
|
73
|
-
selectedId,
|
|
74
|
-
onSelect,
|
|
75
|
-
tc,
|
|
76
|
-
}: {
|
|
77
|
-
category: CategoryNode;
|
|
78
|
-
selectedId: string;
|
|
79
|
-
onSelect: (id: string) => void;
|
|
80
|
-
tc: (key: string) => string;
|
|
81
|
-
}) {
|
|
82
|
-
const [open, setOpen] = useState(false);
|
|
83
|
-
const ref = useRef<HTMLDivElement>(null);
|
|
84
|
-
const hasChildren = category.children.length > 0;
|
|
85
|
-
const isActive = isActiveInTree(category, selectedId);
|
|
86
|
-
|
|
87
|
-
// Find the display name for the selected subcategory
|
|
88
|
-
function findName(nodes: CategoryNode[], id: string): string | null {
|
|
89
|
-
for (const n of nodes) {
|
|
90
|
-
if (n.id === id) return n.name;
|
|
91
|
-
const found = findName(n.children, id);
|
|
92
|
-
if (found) return found;
|
|
93
|
-
}
|
|
94
|
-
return null;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const selectedChildName =
|
|
98
|
-
isActive && selectedId !== category.id ? findName(category.children, selectedId) : null;
|
|
99
|
-
|
|
100
|
-
// Close dropdown on outside click
|
|
101
|
-
useEffect(() => {
|
|
102
|
-
if (!open) return;
|
|
103
|
-
function handleClick(e: MouseEvent) {
|
|
104
|
-
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
105
|
-
setOpen(false);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
document.addEventListener('mousedown', handleClick);
|
|
109
|
-
return () => document.removeEventListener('mousedown', handleClick);
|
|
110
|
-
}, [open]);
|
|
111
|
-
|
|
112
|
-
if (!hasChildren) {
|
|
113
|
-
return (
|
|
114
|
-
<button
|
|
115
|
-
onClick={() => onSelect(category.id)}
|
|
116
|
-
className={cn(
|
|
117
|
-
'rounded-full border px-3 py-1.5 text-sm transition-colors',
|
|
118
|
-
selectedId === category.id
|
|
119
|
-
? 'bg-primary text-primary-foreground border-primary'
|
|
120
|
-
: 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
|
|
121
|
-
)}
|
|
122
|
-
>
|
|
123
|
-
{category.name}
|
|
124
|
-
</button>
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
return (
|
|
129
|
-
<div ref={ref} className="relative">
|
|
130
|
-
<button
|
|
131
|
-
onClick={() => setOpen((prev) => !prev)}
|
|
132
|
-
className={cn(
|
|
133
|
-
'inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-sm transition-colors',
|
|
134
|
-
isActive
|
|
135
|
-
? 'bg-primary text-primary-foreground border-primary'
|
|
136
|
-
: 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
|
|
137
|
-
)}
|
|
138
|
-
>
|
|
139
|
-
{category.name}
|
|
140
|
-
{selectedChildName && (
|
|
141
|
-
<span className="opacity-80">
|
|
142
|
-
{'·'} {selectedChildName}
|
|
143
|
-
</span>
|
|
144
|
-
)}
|
|
145
|
-
<ChevronDown
|
|
146
|
-
className={cn('ms-0.5 h-3 w-3 transition-transform', open && 'rotate-180')}
|
|
147
|
-
aria-hidden="true"
|
|
148
|
-
/>
|
|
149
|
-
</button>
|
|
150
|
-
|
|
151
|
-
{open && (
|
|
152
|
-
<div className="bg-popover text-popover-foreground border-border absolute start-0 top-full z-50 mt-1 min-w-[180px] overflow-hidden rounded-lg border shadow-lg">
|
|
153
|
-
{/* "All in [category]" option */}
|
|
154
|
-
<button
|
|
155
|
-
onClick={() => {
|
|
156
|
-
onSelect(category.id);
|
|
157
|
-
setOpen(false);
|
|
158
|
-
}}
|
|
159
|
-
className={cn(
|
|
160
|
-
'hover:bg-muted w-full px-4 py-2 text-start text-sm font-medium transition-colors',
|
|
161
|
-
selectedId === category.id && 'bg-primary/10 text-primary'
|
|
162
|
-
)}
|
|
163
|
-
>
|
|
164
|
-
{tc('all')} {category.name}
|
|
165
|
-
</button>
|
|
166
|
-
<div className="bg-border mx-2 h-px" />
|
|
167
|
-
{/* Recursive children */}
|
|
168
|
-
<div onClick={() => setOpen(false)}>
|
|
169
|
-
<CategoryDropdownItems
|
|
170
|
-
items={category.children}
|
|
171
|
-
depth={0}
|
|
172
|
-
selectedId={selectedId}
|
|
173
|
-
onSelect={onSelect}
|
|
174
|
-
/>
|
|
175
|
-
</div>
|
|
176
|
-
</div>
|
|
177
|
-
)}
|
|
178
|
-
</div>
|
|
179
|
-
);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Product listing page (filters + sort + grid + load-more). All data and
|
|
184
|
-
* URL-state behavior comes from `useProductListing`; this component is
|
|
185
|
-
* presentation only. Must be rendered inside `<Suspense>` (URL params).
|
|
186
|
-
*/
|
|
187
|
-
export function ProductListing() {
|
|
188
|
-
const t = useTranslations('products');
|
|
189
|
-
const tc = useTranslations('common');
|
|
190
|
-
|
|
191
|
-
const {
|
|
192
|
-
products,
|
|
193
|
-
loading,
|
|
194
|
-
loadingMore,
|
|
195
|
-
page,
|
|
196
|
-
totalPages,
|
|
197
|
-
total,
|
|
198
|
-
categories,
|
|
199
|
-
brands,
|
|
200
|
-
tags,
|
|
201
|
-
metafieldDefs,
|
|
202
|
-
searchQuery,
|
|
203
|
-
categoryId,
|
|
204
|
-
brandId,
|
|
205
|
-
tagId,
|
|
206
|
-
sortIndex,
|
|
207
|
-
handleLoadMore,
|
|
208
|
-
updateParam,
|
|
209
|
-
handleCategorySelect,
|
|
210
|
-
getSelectedMetafieldValues,
|
|
211
|
-
setMetafieldValue,
|
|
212
|
-
} = useProductListing();
|
|
213
|
-
|
|
214
|
-
return (
|
|
215
|
-
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
|
216
|
-
{/* Page Header */}
|
|
217
|
-
<div className="mb-8">
|
|
218
|
-
<h1 className="text-foreground text-3xl font-bold">
|
|
219
|
-
{searchQuery ? `${t('searchPrefix')} "${searchQuery}"` : t('allProducts')}
|
|
220
|
-
</h1>
|
|
221
|
-
{!loading && (
|
|
222
|
-
<p className="text-muted-foreground mt-1 text-sm">
|
|
223
|
-
{total} {total === 1 ? tc('product') : tc('products')} {tc('found')}
|
|
224
|
-
</p>
|
|
225
|
-
)}
|
|
226
|
-
</div>
|
|
227
|
-
|
|
228
|
-
{/* Filters and Sort */}
|
|
229
|
-
<div className="mb-6 flex flex-col gap-4">
|
|
230
|
-
{/* Category Filter */}
|
|
231
|
-
{categories.length > 0 && (
|
|
232
|
-
<div className="flex flex-wrap gap-2">
|
|
233
|
-
<button
|
|
234
|
-
onClick={() => updateParam('category', '')}
|
|
235
|
-
className={cn(
|
|
236
|
-
'rounded-full border px-3 py-1.5 text-sm transition-colors',
|
|
237
|
-
!categoryId
|
|
238
|
-
? 'bg-primary text-primary-foreground border-primary'
|
|
239
|
-
: 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
|
|
240
|
-
)}
|
|
241
|
-
>
|
|
242
|
-
{tc('all')}
|
|
243
|
-
</button>
|
|
244
|
-
{categories.map((cat) => (
|
|
245
|
-
<CategoryChip
|
|
246
|
-
key={cat.id}
|
|
247
|
-
category={cat}
|
|
248
|
-
selectedId={categoryId}
|
|
249
|
-
onSelect={handleCategorySelect}
|
|
250
|
-
tc={tc as (key: string) => string}
|
|
251
|
-
/>
|
|
252
|
-
))}
|
|
253
|
-
</div>
|
|
254
|
-
)}
|
|
255
|
-
|
|
256
|
-
{/* Brand, Tag & Sort row */}
|
|
257
|
-
<div className="flex flex-wrap items-center gap-3">
|
|
258
|
-
{/* Brand Filter */}
|
|
259
|
-
{brands.length > 0 && (
|
|
260
|
-
<Select
|
|
261
|
-
value={brandId || ALL}
|
|
262
|
-
onValueChange={(v) => updateParam('brand', v === ALL ? '' : v)}
|
|
263
|
-
>
|
|
264
|
-
<SelectTrigger className="h-9 w-auto min-w-[8rem] rounded" aria-label={t('allBrands')}>
|
|
265
|
-
<SelectValue />
|
|
266
|
-
</SelectTrigger>
|
|
267
|
-
<SelectContent>
|
|
268
|
-
<SelectItem value={ALL}>{t('allBrands')}</SelectItem>
|
|
269
|
-
{brands.map((b) => (
|
|
270
|
-
<SelectItem key={b.id} value={b.id}>
|
|
271
|
-
{b.name}
|
|
272
|
-
</SelectItem>
|
|
273
|
-
))}
|
|
274
|
-
</SelectContent>
|
|
275
|
-
</Select>
|
|
276
|
-
)}
|
|
277
|
-
|
|
278
|
-
{/* Tag Filter */}
|
|
279
|
-
{tags.length > 0 && (
|
|
280
|
-
<Select
|
|
281
|
-
value={tagId || ALL}
|
|
282
|
-
onValueChange={(v) => updateParam('tag', v === ALL ? '' : v)}
|
|
283
|
-
>
|
|
284
|
-
<SelectTrigger className="h-9 w-auto min-w-[8rem] rounded" aria-label={t('allTags')}>
|
|
285
|
-
<SelectValue />
|
|
286
|
-
</SelectTrigger>
|
|
287
|
-
<SelectContent>
|
|
288
|
-
<SelectItem value={ALL}>{t('allTags')}</SelectItem>
|
|
289
|
-
{tags.map((tg) => (
|
|
290
|
-
<SelectItem key={tg.id} value={tg.id}>
|
|
291
|
-
{tg.name}
|
|
292
|
-
</SelectItem>
|
|
293
|
-
))}
|
|
294
|
-
</SelectContent>
|
|
295
|
-
</Select>
|
|
296
|
-
)}
|
|
297
|
-
|
|
298
|
-
{/* Custom-field (metafield) facets — rendered per definition type.
|
|
299
|
-
Only definitions flagged `filterable: true` and published to
|
|
300
|
-
this sales channel reach this list (see `metafieldDefs` filter
|
|
301
|
-
in the hook). The merchant's display name (`def.name`) is shown
|
|
302
|
-
as-is — `enumValues` come from the merchant's dashboard config. */}
|
|
303
|
-
{metafieldDefs.map((def) => {
|
|
304
|
-
const selected = getSelectedMetafieldValues(def.key);
|
|
305
|
-
|
|
306
|
-
if (def.type === 'BOOLEAN') {
|
|
307
|
-
const isOn = selected.includes('true');
|
|
308
|
-
return (
|
|
309
|
-
<Label
|
|
310
|
-
key={def.id}
|
|
311
|
-
className="border-border text-foreground hover:border-primary inline-flex h-9 cursor-pointer items-center gap-2 rounded border px-3 text-sm font-normal transition-colors"
|
|
312
|
-
>
|
|
313
|
-
<Checkbox
|
|
314
|
-
checked={isOn}
|
|
315
|
-
onCheckedChange={(checked) =>
|
|
316
|
-
setMetafieldValue(def.key, checked === true ? ['true'] : [])
|
|
317
|
-
}
|
|
318
|
-
/>
|
|
319
|
-
{def.name}
|
|
320
|
-
</Label>
|
|
321
|
-
);
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
if (def.type === 'SELECT') {
|
|
325
|
-
return (
|
|
326
|
-
<Select
|
|
327
|
-
key={def.id}
|
|
328
|
-
value={selected[0] || ALL}
|
|
329
|
-
onValueChange={(v) => setMetafieldValue(def.key, v === ALL ? [] : [v])}
|
|
330
|
-
>
|
|
331
|
-
<SelectTrigger
|
|
332
|
-
className="h-9 w-auto min-w-[8rem] rounded"
|
|
333
|
-
aria-label={def.name}
|
|
334
|
-
>
|
|
335
|
-
<SelectValue />
|
|
336
|
-
</SelectTrigger>
|
|
337
|
-
<SelectContent>
|
|
338
|
-
<SelectItem value={ALL}>{`${tc('all')} ${def.name}`}</SelectItem>
|
|
339
|
-
{(def.enumValues || []).map((v) => (
|
|
340
|
-
<SelectItem key={v.value} value={v.value}>
|
|
341
|
-
{v.label || v.value}
|
|
342
|
-
</SelectItem>
|
|
343
|
-
))}
|
|
344
|
-
</SelectContent>
|
|
345
|
-
</Select>
|
|
346
|
-
);
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// MULTI_SELECT — chip toggles. Each click adds/removes one value.
|
|
350
|
-
return (
|
|
351
|
-
<div
|
|
352
|
-
key={def.id}
|
|
353
|
-
className="border-border inline-flex items-center gap-1 rounded border px-2 py-1"
|
|
354
|
-
aria-label={def.name}
|
|
355
|
-
>
|
|
356
|
-
<span className="text-muted-foreground px-1 text-xs">{def.name}:</span>
|
|
357
|
-
{(def.enumValues || []).map((v) => {
|
|
358
|
-
const active = selected.includes(v.value);
|
|
359
|
-
return (
|
|
360
|
-
<button
|
|
361
|
-
key={v.value}
|
|
362
|
-
type="button"
|
|
363
|
-
onClick={() =>
|
|
364
|
-
setMetafieldValue(
|
|
365
|
-
def.key,
|
|
366
|
-
active ? selected.filter((s) => s !== v.value) : [...selected, v.value]
|
|
367
|
-
)
|
|
368
|
-
}
|
|
369
|
-
className={cn(
|
|
370
|
-
'rounded-full border px-2 py-0.5 text-xs transition-colors',
|
|
371
|
-
active
|
|
372
|
-
? 'bg-primary text-primary-foreground border-primary'
|
|
373
|
-
: 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
|
|
374
|
-
)}
|
|
375
|
-
>
|
|
376
|
-
{v.label || v.value}
|
|
377
|
-
</button>
|
|
378
|
-
);
|
|
379
|
-
})}
|
|
380
|
-
</div>
|
|
381
|
-
);
|
|
382
|
-
})}
|
|
383
|
-
|
|
384
|
-
{/* Sort */}
|
|
385
|
-
<div className="flex items-center gap-2 sm:ms-auto">
|
|
386
|
-
<Label
|
|
387
|
-
htmlFor="sort"
|
|
388
|
-
className="text-muted-foreground whitespace-nowrap font-normal"
|
|
389
|
-
>
|
|
390
|
-
{tc('sortBy')}
|
|
391
|
-
</Label>
|
|
392
|
-
<Select
|
|
393
|
-
value={String(sortIndex)}
|
|
394
|
-
onValueChange={(v) => updateParam('sort', v)}
|
|
395
|
-
>
|
|
396
|
-
<SelectTrigger id="sort" className="h-9 w-auto min-w-[8rem] rounded">
|
|
397
|
-
<SelectValue />
|
|
398
|
-
</SelectTrigger>
|
|
399
|
-
<SelectContent>
|
|
400
|
-
{sortOptions.map((opt, idx) => (
|
|
401
|
-
<SelectItem key={idx} value={String(idx)}>
|
|
402
|
-
{t(opt.labelKey)}
|
|
403
|
-
</SelectItem>
|
|
404
|
-
))}
|
|
405
|
-
</SelectContent>
|
|
406
|
-
</Select>
|
|
407
|
-
</div>
|
|
408
|
-
</div>
|
|
409
|
-
</div>
|
|
410
|
-
|
|
411
|
-
{/* Products Grid */}
|
|
412
|
-
{loading ? (
|
|
413
|
-
<div className="flex items-center justify-center py-20">
|
|
414
|
-
<LoadingSpinner size="lg" />
|
|
415
|
-
</div>
|
|
416
|
-
) : (
|
|
417
|
-
<>
|
|
418
|
-
<ProductGrid products={products} />
|
|
419
|
-
|
|
420
|
-
{/* Load More */}
|
|
421
|
-
{page < totalPages && (
|
|
422
|
-
<div className="mt-10 flex justify-center">
|
|
423
|
-
<Button
|
|
424
|
-
onClick={handleLoadMore}
|
|
425
|
-
disabled={loadingMore}
|
|
426
|
-
className="rounded px-6 py-2.5"
|
|
427
|
-
>
|
|
428
|
-
{loadingMore ? (
|
|
429
|
-
<>
|
|
430
|
-
<LoadingSpinner
|
|
431
|
-
size="sm"
|
|
432
|
-
className="border-primary-foreground/30 border-t-primary-foreground"
|
|
433
|
-
/>
|
|
434
|
-
{tc('loading')}
|
|
435
|
-
</>
|
|
436
|
-
) : (
|
|
437
|
-
t('loadMore')
|
|
438
|
-
)}
|
|
439
|
-
</Button>
|
|
440
|
-
</div>
|
|
441
|
-
)}
|
|
442
|
-
</>
|
|
443
|
-
)}
|
|
444
|
-
</div>
|
|
445
|
-
);
|
|
446
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useRef } from 'react';
|
|
4
|
+
import { ChevronDown } from 'lucide-react';
|
|
5
|
+
import {
|
|
6
|
+
useProductListing,
|
|
7
|
+
sortOptions,
|
|
8
|
+
isActiveInTree,
|
|
9
|
+
type CategoryNode,
|
|
10
|
+
} from '@/core/hooks/use-product-listing';
|
|
11
|
+
import { ProductGrid } from '@/ui/product/product-grid';
|
|
12
|
+
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
13
|
+
import { Button } from '@/components/ui/button';
|
|
14
|
+
import { Checkbox } from '@/components/ui/checkbox';
|
|
15
|
+
import { Label } from '@/components/ui/label';
|
|
16
|
+
import {
|
|
17
|
+
Select,
|
|
18
|
+
SelectContent,
|
|
19
|
+
SelectItem,
|
|
20
|
+
SelectTrigger,
|
|
21
|
+
SelectValue,
|
|
22
|
+
} from '@/components/ui/select';
|
|
23
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
24
|
+
import { cn } from '@/core/lib/utils';
|
|
25
|
+
|
|
26
|
+
/** Radix Select items can't carry an empty-string value, so "no filter"
|
|
27
|
+
* round-trips through this sentinel and is written back as ''. */
|
|
28
|
+
const ALL = '__all__';
|
|
29
|
+
|
|
30
|
+
/** Recursive dropdown items for nested categories */
|
|
31
|
+
function CategoryDropdownItems({
|
|
32
|
+
items,
|
|
33
|
+
depth,
|
|
34
|
+
selectedId,
|
|
35
|
+
onSelect,
|
|
36
|
+
}: {
|
|
37
|
+
items: CategoryNode[];
|
|
38
|
+
depth: number;
|
|
39
|
+
selectedId: string;
|
|
40
|
+
onSelect: (id: string) => void;
|
|
41
|
+
}) {
|
|
42
|
+
return (
|
|
43
|
+
<>
|
|
44
|
+
{items.map((child) => (
|
|
45
|
+
<div key={child.id}>
|
|
46
|
+
<button
|
|
47
|
+
onClick={() => onSelect(child.id)}
|
|
48
|
+
className={cn(
|
|
49
|
+
'hover:bg-muted w-full px-4 py-2 text-start text-sm transition-colors',
|
|
50
|
+
selectedId === child.id && 'bg-primary/10 text-primary font-medium'
|
|
51
|
+
)}
|
|
52
|
+
style={{ paddingInlineStart: `${(depth + 1) * 16}px` }}
|
|
53
|
+
>
|
|
54
|
+
{child.name}
|
|
55
|
+
</button>
|
|
56
|
+
{child.children.length > 0 && (
|
|
57
|
+
<CategoryDropdownItems
|
|
58
|
+
items={child.children}
|
|
59
|
+
depth={depth + 1}
|
|
60
|
+
selectedId={selectedId}
|
|
61
|
+
onSelect={onSelect}
|
|
62
|
+
/>
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
))}
|
|
66
|
+
</>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Category chip with dropdown for subcategories */
|
|
71
|
+
function CategoryChip({
|
|
72
|
+
category,
|
|
73
|
+
selectedId,
|
|
74
|
+
onSelect,
|
|
75
|
+
tc,
|
|
76
|
+
}: {
|
|
77
|
+
category: CategoryNode;
|
|
78
|
+
selectedId: string;
|
|
79
|
+
onSelect: (id: string) => void;
|
|
80
|
+
tc: (key: string) => string;
|
|
81
|
+
}) {
|
|
82
|
+
const [open, setOpen] = useState(false);
|
|
83
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
84
|
+
const hasChildren = category.children.length > 0;
|
|
85
|
+
const isActive = isActiveInTree(category, selectedId);
|
|
86
|
+
|
|
87
|
+
// Find the display name for the selected subcategory
|
|
88
|
+
function findName(nodes: CategoryNode[], id: string): string | null {
|
|
89
|
+
for (const n of nodes) {
|
|
90
|
+
if (n.id === id) return n.name;
|
|
91
|
+
const found = findName(n.children, id);
|
|
92
|
+
if (found) return found;
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const selectedChildName =
|
|
98
|
+
isActive && selectedId !== category.id ? findName(category.children, selectedId) : null;
|
|
99
|
+
|
|
100
|
+
// Close dropdown on outside click
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (!open) return;
|
|
103
|
+
function handleClick(e: MouseEvent) {
|
|
104
|
+
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
105
|
+
setOpen(false);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
document.addEventListener('mousedown', handleClick);
|
|
109
|
+
return () => document.removeEventListener('mousedown', handleClick);
|
|
110
|
+
}, [open]);
|
|
111
|
+
|
|
112
|
+
if (!hasChildren) {
|
|
113
|
+
return (
|
|
114
|
+
<button
|
|
115
|
+
onClick={() => onSelect(category.id)}
|
|
116
|
+
className={cn(
|
|
117
|
+
'rounded-full border px-3 py-1.5 text-sm transition-colors',
|
|
118
|
+
selectedId === category.id
|
|
119
|
+
? 'bg-primary text-primary-foreground border-primary'
|
|
120
|
+
: 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
|
|
121
|
+
)}
|
|
122
|
+
>
|
|
123
|
+
{category.name}
|
|
124
|
+
</button>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div ref={ref} className="relative">
|
|
130
|
+
<button
|
|
131
|
+
onClick={() => setOpen((prev) => !prev)}
|
|
132
|
+
className={cn(
|
|
133
|
+
'inline-flex items-center gap-1 rounded-full border px-3 py-1.5 text-sm transition-colors',
|
|
134
|
+
isActive
|
|
135
|
+
? 'bg-primary text-primary-foreground border-primary'
|
|
136
|
+
: 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
|
|
137
|
+
)}
|
|
138
|
+
>
|
|
139
|
+
{category.name}
|
|
140
|
+
{selectedChildName && (
|
|
141
|
+
<span className="opacity-80">
|
|
142
|
+
{'·'} {selectedChildName}
|
|
143
|
+
</span>
|
|
144
|
+
)}
|
|
145
|
+
<ChevronDown
|
|
146
|
+
className={cn('ms-0.5 h-3 w-3 transition-transform', open && 'rotate-180')}
|
|
147
|
+
aria-hidden="true"
|
|
148
|
+
/>
|
|
149
|
+
</button>
|
|
150
|
+
|
|
151
|
+
{open && (
|
|
152
|
+
<div className="bg-popover text-popover-foreground border-border absolute start-0 top-full z-50 mt-1 min-w-[180px] overflow-hidden rounded-lg border shadow-lg">
|
|
153
|
+
{/* "All in [category]" option */}
|
|
154
|
+
<button
|
|
155
|
+
onClick={() => {
|
|
156
|
+
onSelect(category.id);
|
|
157
|
+
setOpen(false);
|
|
158
|
+
}}
|
|
159
|
+
className={cn(
|
|
160
|
+
'hover:bg-muted w-full px-4 py-2 text-start text-sm font-medium transition-colors',
|
|
161
|
+
selectedId === category.id && 'bg-primary/10 text-primary'
|
|
162
|
+
)}
|
|
163
|
+
>
|
|
164
|
+
{tc('all')} {category.name}
|
|
165
|
+
</button>
|
|
166
|
+
<div className="bg-border mx-2 h-px" />
|
|
167
|
+
{/* Recursive children */}
|
|
168
|
+
<div onClick={() => setOpen(false)}>
|
|
169
|
+
<CategoryDropdownItems
|
|
170
|
+
items={category.children}
|
|
171
|
+
depth={0}
|
|
172
|
+
selectedId={selectedId}
|
|
173
|
+
onSelect={onSelect}
|
|
174
|
+
/>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Product listing page (filters + sort + grid + load-more). All data and
|
|
184
|
+
* URL-state behavior comes from `useProductListing`; this component is
|
|
185
|
+
* presentation only. Must be rendered inside `<Suspense>` (URL params).
|
|
186
|
+
*/
|
|
187
|
+
export function ProductListing() {
|
|
188
|
+
const t = useTranslations('products');
|
|
189
|
+
const tc = useTranslations('common');
|
|
190
|
+
|
|
191
|
+
const {
|
|
192
|
+
products,
|
|
193
|
+
loading,
|
|
194
|
+
loadingMore,
|
|
195
|
+
page,
|
|
196
|
+
totalPages,
|
|
197
|
+
total,
|
|
198
|
+
categories,
|
|
199
|
+
brands,
|
|
200
|
+
tags,
|
|
201
|
+
metafieldDefs,
|
|
202
|
+
searchQuery,
|
|
203
|
+
categoryId,
|
|
204
|
+
brandId,
|
|
205
|
+
tagId,
|
|
206
|
+
sortIndex,
|
|
207
|
+
handleLoadMore,
|
|
208
|
+
updateParam,
|
|
209
|
+
handleCategorySelect,
|
|
210
|
+
getSelectedMetafieldValues,
|
|
211
|
+
setMetafieldValue,
|
|
212
|
+
} = useProductListing();
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
|
216
|
+
{/* Page Header */}
|
|
217
|
+
<div className="mb-8">
|
|
218
|
+
<h1 className="text-foreground text-3xl font-bold">
|
|
219
|
+
{searchQuery ? `${t('searchPrefix')} "${searchQuery}"` : t('allProducts')}
|
|
220
|
+
</h1>
|
|
221
|
+
{!loading && (
|
|
222
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
223
|
+
{total} {total === 1 ? tc('product') : tc('products')} {tc('found')}
|
|
224
|
+
</p>
|
|
225
|
+
)}
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
{/* Filters and Sort */}
|
|
229
|
+
<div className="mb-6 flex flex-col gap-4">
|
|
230
|
+
{/* Category Filter */}
|
|
231
|
+
{categories.length > 0 && (
|
|
232
|
+
<div className="flex flex-wrap gap-2">
|
|
233
|
+
<button
|
|
234
|
+
onClick={() => updateParam('category', '')}
|
|
235
|
+
className={cn(
|
|
236
|
+
'rounded-full border px-3 py-1.5 text-sm transition-colors',
|
|
237
|
+
!categoryId
|
|
238
|
+
? 'bg-primary text-primary-foreground border-primary'
|
|
239
|
+
: 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
|
|
240
|
+
)}
|
|
241
|
+
>
|
|
242
|
+
{tc('all')}
|
|
243
|
+
</button>
|
|
244
|
+
{categories.map((cat) => (
|
|
245
|
+
<CategoryChip
|
|
246
|
+
key={cat.id}
|
|
247
|
+
category={cat}
|
|
248
|
+
selectedId={categoryId}
|
|
249
|
+
onSelect={handleCategorySelect}
|
|
250
|
+
tc={tc as (key: string) => string}
|
|
251
|
+
/>
|
|
252
|
+
))}
|
|
253
|
+
</div>
|
|
254
|
+
)}
|
|
255
|
+
|
|
256
|
+
{/* Brand, Tag & Sort row */}
|
|
257
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
258
|
+
{/* Brand Filter */}
|
|
259
|
+
{brands.length > 0 && (
|
|
260
|
+
<Select
|
|
261
|
+
value={brandId || ALL}
|
|
262
|
+
onValueChange={(v) => updateParam('brand', v === ALL ? '' : v)}
|
|
263
|
+
>
|
|
264
|
+
<SelectTrigger className="h-9 w-auto min-w-[8rem] rounded" aria-label={t('allBrands')}>
|
|
265
|
+
<SelectValue />
|
|
266
|
+
</SelectTrigger>
|
|
267
|
+
<SelectContent>
|
|
268
|
+
<SelectItem value={ALL}>{t('allBrands')}</SelectItem>
|
|
269
|
+
{brands.map((b) => (
|
|
270
|
+
<SelectItem key={b.id} value={b.id}>
|
|
271
|
+
{b.name}
|
|
272
|
+
</SelectItem>
|
|
273
|
+
))}
|
|
274
|
+
</SelectContent>
|
|
275
|
+
</Select>
|
|
276
|
+
)}
|
|
277
|
+
|
|
278
|
+
{/* Tag Filter */}
|
|
279
|
+
{tags.length > 0 && (
|
|
280
|
+
<Select
|
|
281
|
+
value={tagId || ALL}
|
|
282
|
+
onValueChange={(v) => updateParam('tag', v === ALL ? '' : v)}
|
|
283
|
+
>
|
|
284
|
+
<SelectTrigger className="h-9 w-auto min-w-[8rem] rounded" aria-label={t('allTags')}>
|
|
285
|
+
<SelectValue />
|
|
286
|
+
</SelectTrigger>
|
|
287
|
+
<SelectContent>
|
|
288
|
+
<SelectItem value={ALL}>{t('allTags')}</SelectItem>
|
|
289
|
+
{tags.map((tg) => (
|
|
290
|
+
<SelectItem key={tg.id} value={tg.id}>
|
|
291
|
+
{tg.name}
|
|
292
|
+
</SelectItem>
|
|
293
|
+
))}
|
|
294
|
+
</SelectContent>
|
|
295
|
+
</Select>
|
|
296
|
+
)}
|
|
297
|
+
|
|
298
|
+
{/* Custom-field (metafield) facets — rendered per definition type.
|
|
299
|
+
Only definitions flagged `filterable: true` and published to
|
|
300
|
+
this sales channel reach this list (see `metafieldDefs` filter
|
|
301
|
+
in the hook). The merchant's display name (`def.name`) is shown
|
|
302
|
+
as-is — `enumValues` come from the merchant's dashboard config. */}
|
|
303
|
+
{metafieldDefs.map((def) => {
|
|
304
|
+
const selected = getSelectedMetafieldValues(def.key);
|
|
305
|
+
|
|
306
|
+
if (def.type === 'BOOLEAN') {
|
|
307
|
+
const isOn = selected.includes('true');
|
|
308
|
+
return (
|
|
309
|
+
<Label
|
|
310
|
+
key={def.id}
|
|
311
|
+
className="border-border text-foreground hover:border-primary inline-flex h-9 cursor-pointer items-center gap-2 rounded border px-3 text-sm font-normal transition-colors"
|
|
312
|
+
>
|
|
313
|
+
<Checkbox
|
|
314
|
+
checked={isOn}
|
|
315
|
+
onCheckedChange={(checked) =>
|
|
316
|
+
setMetafieldValue(def.key, checked === true ? ['true'] : [])
|
|
317
|
+
}
|
|
318
|
+
/>
|
|
319
|
+
{def.name}
|
|
320
|
+
</Label>
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (def.type === 'SELECT') {
|
|
325
|
+
return (
|
|
326
|
+
<Select
|
|
327
|
+
key={def.id}
|
|
328
|
+
value={selected[0] || ALL}
|
|
329
|
+
onValueChange={(v) => setMetafieldValue(def.key, v === ALL ? [] : [v])}
|
|
330
|
+
>
|
|
331
|
+
<SelectTrigger
|
|
332
|
+
className="h-9 w-auto min-w-[8rem] rounded"
|
|
333
|
+
aria-label={def.name}
|
|
334
|
+
>
|
|
335
|
+
<SelectValue />
|
|
336
|
+
</SelectTrigger>
|
|
337
|
+
<SelectContent>
|
|
338
|
+
<SelectItem value={ALL}>{`${tc('all')} ${def.name}`}</SelectItem>
|
|
339
|
+
{(def.enumValues || []).map((v) => (
|
|
340
|
+
<SelectItem key={v.value} value={v.value}>
|
|
341
|
+
{v.label || v.value}
|
|
342
|
+
</SelectItem>
|
|
343
|
+
))}
|
|
344
|
+
</SelectContent>
|
|
345
|
+
</Select>
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// MULTI_SELECT — chip toggles. Each click adds/removes one value.
|
|
350
|
+
return (
|
|
351
|
+
<div
|
|
352
|
+
key={def.id}
|
|
353
|
+
className="border-border inline-flex items-center gap-1 rounded border px-2 py-1"
|
|
354
|
+
aria-label={def.name}
|
|
355
|
+
>
|
|
356
|
+
<span className="text-muted-foreground px-1 text-xs">{def.name}:</span>
|
|
357
|
+
{(def.enumValues || []).map((v) => {
|
|
358
|
+
const active = selected.includes(v.value);
|
|
359
|
+
return (
|
|
360
|
+
<button
|
|
361
|
+
key={v.value}
|
|
362
|
+
type="button"
|
|
363
|
+
onClick={() =>
|
|
364
|
+
setMetafieldValue(
|
|
365
|
+
def.key,
|
|
366
|
+
active ? selected.filter((s) => s !== v.value) : [...selected, v.value]
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
className={cn(
|
|
370
|
+
'rounded-full border px-2 py-0.5 text-xs transition-colors',
|
|
371
|
+
active
|
|
372
|
+
? 'bg-primary text-primary-foreground border-primary'
|
|
373
|
+
: 'border-border text-muted-foreground hover:border-primary hover:text-foreground'
|
|
374
|
+
)}
|
|
375
|
+
>
|
|
376
|
+
{v.label || v.value}
|
|
377
|
+
</button>
|
|
378
|
+
);
|
|
379
|
+
})}
|
|
380
|
+
</div>
|
|
381
|
+
);
|
|
382
|
+
})}
|
|
383
|
+
|
|
384
|
+
{/* Sort */}
|
|
385
|
+
<div className="flex items-center gap-2 sm:ms-auto">
|
|
386
|
+
<Label
|
|
387
|
+
htmlFor="sort"
|
|
388
|
+
className="text-muted-foreground whitespace-nowrap font-normal"
|
|
389
|
+
>
|
|
390
|
+
{tc('sortBy')}
|
|
391
|
+
</Label>
|
|
392
|
+
<Select
|
|
393
|
+
value={String(sortIndex)}
|
|
394
|
+
onValueChange={(v) => updateParam('sort', v)}
|
|
395
|
+
>
|
|
396
|
+
<SelectTrigger id="sort" className="h-9 w-auto min-w-[8rem] rounded">
|
|
397
|
+
<SelectValue />
|
|
398
|
+
</SelectTrigger>
|
|
399
|
+
<SelectContent>
|
|
400
|
+
{sortOptions.map((opt, idx) => (
|
|
401
|
+
<SelectItem key={idx} value={String(idx)}>
|
|
402
|
+
{t(opt.labelKey)}
|
|
403
|
+
</SelectItem>
|
|
404
|
+
))}
|
|
405
|
+
</SelectContent>
|
|
406
|
+
</Select>
|
|
407
|
+
</div>
|
|
408
|
+
</div>
|
|
409
|
+
</div>
|
|
410
|
+
|
|
411
|
+
{/* Products Grid */}
|
|
412
|
+
{loading ? (
|
|
413
|
+
<div className="flex items-center justify-center py-20">
|
|
414
|
+
<LoadingSpinner size="lg" />
|
|
415
|
+
</div>
|
|
416
|
+
) : (
|
|
417
|
+
<>
|
|
418
|
+
<ProductGrid products={products} />
|
|
419
|
+
|
|
420
|
+
{/* Load More */}
|
|
421
|
+
{page < totalPages && (
|
|
422
|
+
<div className="mt-10 flex justify-center">
|
|
423
|
+
<Button
|
|
424
|
+
onClick={handleLoadMore}
|
|
425
|
+
disabled={loadingMore}
|
|
426
|
+
className="rounded px-6 py-2.5"
|
|
427
|
+
>
|
|
428
|
+
{loadingMore ? (
|
|
429
|
+
<>
|
|
430
|
+
<LoadingSpinner
|
|
431
|
+
size="sm"
|
|
432
|
+
className="border-primary-foreground/30 border-t-primary-foreground"
|
|
433
|
+
/>
|
|
434
|
+
{tc('loading')}
|
|
435
|
+
</>
|
|
436
|
+
) : (
|
|
437
|
+
t('loadMore')
|
|
438
|
+
)}
|
|
439
|
+
</Button>
|
|
440
|
+
</div>
|
|
441
|
+
)}
|
|
442
|
+
</>
|
|
443
|
+
)}
|
|
444
|
+
</div>
|
|
445
|
+
);
|
|
446
|
+
}
|