create-brainerce-store 1.51.0 → 1.52.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/README.md +72 -0
- package/dist/index.js +9 -68
- package/messages/en.json +32 -0
- package/messages/he.json +32 -0
- package/package.json +1 -1
- package/templates/nextjs/base/.claude/commands/design.md +9 -0
- package/templates/nextjs/base/AI-GUIDE.md +52 -3
- package/templates/nextjs/base/CLAUDE.md +10 -0
- package/templates/nextjs/base/package.json.ejs +52 -39
- package/templates/nextjs/base/src/app/globals.css +36 -30
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +14 -10
- package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -0
- package/templates/nextjs/base/src/components/ui/badge.tsx +32 -0
- package/templates/nextjs/base/src/components/ui/button.tsx +49 -0
- package/templates/nextjs/base/src/components/ui/card.tsx +55 -0
- package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -0
- package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -0
- package/templates/nextjs/base/src/components/ui/input.tsx +22 -0
- package/templates/nextjs/base/src/components/ui/label.tsx +21 -0
- package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -0
- package/templates/nextjs/base/src/components/ui/select.tsx +153 -0
- package/templates/nextjs/base/src/components/ui/separator.tsx +26 -0
- package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -0
- package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -0
- package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -0
- package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -0
- package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -0
- package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +17 -11
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +2 -8
- package/templates/nextjs/base/src/ui/cart/cart-nudges.tsx +28 -39
- package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +3 -1
- package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +8 -23
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +18 -27
- package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +10 -21
- package/templates/nextjs/base/src/ui/cart/free-shipping-bar.tsx +2 -3
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +95 -103
- package/templates/nextjs/base/src/ui/home/hero-section.tsx +4 -6
- package/templates/nextjs/base/src/ui/layout/announcement-bar.tsx.ejs +116 -125
- package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -103
- package/templates/nextjs/base/src/ui/layout/header-account.tsx +2 -17
- package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -70
- package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +144 -164
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +152 -182
- package/templates/nextjs/base/src/ui/product/customization-fields.tsx +456 -444
- package/templates/nextjs/base/src/ui/product/discount-badge.tsx +18 -22
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +15 -27
- package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -175
- package/templates/nextjs/base/src/ui/product/price-display.tsx +3 -2
- package/templates/nextjs/base/src/ui/product/product-card.tsx +238 -271
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +455 -470
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +96 -79
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +102 -107
- package/templates/nextjs/base/src/ui/product/review-form.tsx +44 -43
- package/templates/nextjs/{designs/passepartout → base/src}/ui/product/reviews-section.tsx.ejs +108 -96
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +61 -63
- package/templates/nextjs/base/tailwind.config.ts +69 -42
- package/templates/nextjs/designs/{passepartout → atelier}/app-overlay/layout.tsx.ejs +23 -25
- package/templates/nextjs/designs/atelier/globals.css +396 -0
- package/templates/nextjs/designs/atelier/messages-patch/en.json +99 -0
- package/templates/nextjs/designs/atelier/messages-patch/he.json +99 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/cart-bundle-offer.tsx +110 -111
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/cart-upgrade-banner.tsx +123 -141
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -0
- package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/free-shipping-bar.tsx +77 -62
- package/templates/nextjs/designs/{passepartout → atelier}/ui/cart/reservation-countdown.tsx +89 -103
- package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -0
- package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +148 -0
- package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -0
- package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +73 -0
- package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -0
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +148 -0
- package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -0
- package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -0
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -0
- package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -0
- package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -0
- package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -0
- package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -0
- package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -0
- package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -0
- package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -0
- package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -0
- package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -0
- package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/product/discount-badge.tsx +17 -22
- package/templates/nextjs/designs/{passepartout → atelier}/ui/product/frequently-bought-together.tsx +196 -205
- package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -0
- package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -0
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +260 -0
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +471 -0
- package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +47 -0
- package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -0
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/product/review-form.tsx +267 -294
- package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -0
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -0
- package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -0
- package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -0
- package/templates/nextjs/designs/{passepartout → atelier}/ui/shared/fly-to-cart.ts +86 -85
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +269 -0
- package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +64 -0
- package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -0
- package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -0
- package/templates/nextjs/themes/luxury/globals.css +405 -399
- package/templates/nextjs/themes/minimal/globals.css +36 -30
- package/templates/nextjs/themes/playful/globals.css +406 -400
- package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +2 -2
- package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +2 -0
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +2 -0
- package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +1 -1
- package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +1 -1
- package/templates/nextjs/ui-canvas/home/hero-section.tsx +1 -1
- package/templates/nextjs/ui-canvas/home/home-client.tsx +1 -1
- package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +1 -1
- package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +2 -2
- package/templates/nextjs/ui-canvas/layout/header-account.tsx +1 -1
- package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +2 -0
- package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +2 -0
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +2 -2
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +2 -2
- package/templates/nextjs/ui-canvas/product/customization-fields.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/discount-badge.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/price-display.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/product-card.tsx +1 -1
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +1 -1
- package/templates/nextjs/ui-canvas/product/product-grid.tsx +1 -1
- package/templates/nextjs/ui-canvas/product/product-listing.tsx +1 -1
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +4 -0
- package/templates/nextjs/ui-canvas/product/review-form.tsx +29 -32
- package/templates/nextjs/ui-canvas/product/{reviews-section.tsx → reviews-section.tsx.ejs} +26 -8
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +2 -0
- package/templates/nextjs/ui-canvas/product/variant-selector.tsx +2 -0
- package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +2 -0
- package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +2 -0
- package/templates/nextjs/base/src/ui/product/reviews-section.tsx +0 -85
- package/templates/nextjs/designs/passepartout/globals.css +0 -412
- package/templates/nextjs/designs/passepartout/messages-patch/en.json +0 -87
- package/templates/nextjs/designs/passepartout/messages-patch/he.json +0 -87
- package/templates/nextjs/designs/passepartout/ui/cart/cart-nudges.tsx +0 -39
- package/templates/nextjs/designs/passepartout/ui/cart/cart-view.tsx +0 -149
- package/templates/nextjs/designs/passepartout/ui/home/home-client.tsx +0 -401
- package/templates/nextjs/designs/passepartout/ui/layout/announcement-bar.tsx +0 -139
- package/templates/nextjs/designs/passepartout/ui/layout/cart-link.tsx +0 -45
- package/templates/nextjs/designs/passepartout/ui/layout/faq-section.tsx +0 -103
- package/templates/nextjs/designs/passepartout/ui/layout/site-footer.tsx +0 -159
- package/templates/nextjs/designs/passepartout/ui/layout/site-header.tsx.ejs +0 -155
- package/templates/nextjs/designs/passepartout/ui/layout/trust-icons.tsx +0 -60
- package/templates/nextjs/designs/passepartout/ui/layout/whatsapp-button.tsx +0 -34
- package/templates/nextjs/designs/passepartout/ui/product/price-display.tsx +0 -111
- package/templates/nextjs/designs/passepartout/ui/product/product-card.tsx +0 -265
- package/templates/nextjs/designs/passepartout/ui/product/product-client-section.tsx +0 -667
- package/templates/nextjs/designs/passepartout/ui/product/product-grid.tsx +0 -35
- package/templates/nextjs/designs/passepartout/ui/product/product-listing.tsx +0 -427
- package/templates/nextjs/designs/passepartout/ui/product/recommendation-section.tsx +0 -113
- package/templates/nextjs/designs/passepartout/ui/product/stock-badge.tsx +0 -52
- package/templates/nextjs/designs/passepartout/ui/product/variant-selector.tsx +0 -307
- package/templates/nextjs/designs/passepartout/ui/shared/loading-spinner.tsx +0 -33
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import type { Product } from 'brainerce';
|
|
4
|
-
import { useTranslations } from '@/core/lib/translations';
|
|
5
|
-
import { ProductCard } from '@/ui/product/product-card';
|
|
6
|
-
import { cn } from '@/core/lib/utils';
|
|
7
|
-
|
|
8
|
-
interface ProductGridProps {
|
|
9
|
-
products: Product[];
|
|
10
|
-
className?: string;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function ProductGrid({ products, className }: ProductGridProps) {
|
|
14
|
-
const t = useTranslations('products');
|
|
15
|
-
if (products.length === 0) {
|
|
16
|
-
return (
|
|
17
|
-
<div className="py-16 text-center">
|
|
18
|
-
<p className="text-muted-foreground text-lg">{t('noProducts')}</p>
|
|
19
|
-
</div>
|
|
20
|
-
);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
return (
|
|
24
|
-
<div
|
|
25
|
-
className={cn(
|
|
26
|
-
'grid grid-cols-2 gap-x-4 gap-y-12 lg:grid-cols-4 lg:gap-x-6 lg:gap-y-16',
|
|
27
|
-
className
|
|
28
|
-
)}
|
|
29
|
-
>
|
|
30
|
-
{products.map((product) => (
|
|
31
|
-
<ProductCard key={product.id} product={product} />
|
|
32
|
-
))}
|
|
33
|
-
</div>
|
|
34
|
-
);
|
|
35
|
-
}
|
|
@@ -1,427 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useEffect, useState, useRef } from 'react';
|
|
4
|
-
import {
|
|
5
|
-
useProductListing,
|
|
6
|
-
sortOptions,
|
|
7
|
-
isActiveInTree,
|
|
8
|
-
type CategoryNode,
|
|
9
|
-
} from '@/core/hooks/use-product-listing';
|
|
10
|
-
import { ProductGrid } from '@/ui/product/product-grid';
|
|
11
|
-
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
12
|
-
import { useTranslations } from '@/core/lib/translations';
|
|
13
|
-
import { cn } from '@/core/lib/utils';
|
|
14
|
-
|
|
15
|
-
/** Chevron down SVG */
|
|
16
|
-
function ChevronDown({ className }: { className?: string }) {
|
|
17
|
-
return (
|
|
18
|
-
<svg
|
|
19
|
-
className={className}
|
|
20
|
-
width="12"
|
|
21
|
-
height="12"
|
|
22
|
-
viewBox="0 0 12 12"
|
|
23
|
-
fill="none"
|
|
24
|
-
stroke="currentColor"
|
|
25
|
-
strokeWidth="2"
|
|
26
|
-
strokeLinecap="round"
|
|
27
|
-
strokeLinejoin="round"
|
|
28
|
-
>
|
|
29
|
-
<path d="M3 4.5L6 7.5L9 4.5" />
|
|
30
|
-
</svg>
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Recursive dropdown items for nested categories */
|
|
35
|
-
function CategoryDropdownItems({
|
|
36
|
-
items,
|
|
37
|
-
depth,
|
|
38
|
-
selectedId,
|
|
39
|
-
onSelect,
|
|
40
|
-
}: {
|
|
41
|
-
items: CategoryNode[];
|
|
42
|
-
depth: number;
|
|
43
|
-
selectedId: string;
|
|
44
|
-
onSelect: (id: string) => void;
|
|
45
|
-
}) {
|
|
46
|
-
return (
|
|
47
|
-
<>
|
|
48
|
-
{items.map((child) => (
|
|
49
|
-
<div key={child.id}>
|
|
50
|
-
<button
|
|
51
|
-
onClick={() => onSelect(child.id)}
|
|
52
|
-
className={cn(
|
|
53
|
-
'hover:bg-muted w-full px-4 py-2 text-start text-sm transition-colors',
|
|
54
|
-
selectedId === child.id && 'bg-muted text-foreground font-medium'
|
|
55
|
-
)}
|
|
56
|
-
style={{ paddingInlineStart: `${(depth + 1) * 16}px` }}
|
|
57
|
-
>
|
|
58
|
-
{child.name}
|
|
59
|
-
</button>
|
|
60
|
-
{child.children.length > 0 && (
|
|
61
|
-
<CategoryDropdownItems
|
|
62
|
-
items={child.children}
|
|
63
|
-
depth={depth + 1}
|
|
64
|
-
selectedId={selectedId}
|
|
65
|
-
onSelect={onSelect}
|
|
66
|
-
/>
|
|
67
|
-
)}
|
|
68
|
-
</div>
|
|
69
|
-
))}
|
|
70
|
-
</>
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Category chip with dropdown for subcategories */
|
|
75
|
-
function CategoryChip({
|
|
76
|
-
category,
|
|
77
|
-
selectedId,
|
|
78
|
-
onSelect,
|
|
79
|
-
tc,
|
|
80
|
-
}: {
|
|
81
|
-
category: CategoryNode;
|
|
82
|
-
selectedId: string;
|
|
83
|
-
onSelect: (id: string) => void;
|
|
84
|
-
tc: (key: string) => string;
|
|
85
|
-
}) {
|
|
86
|
-
const [open, setOpen] = useState(false);
|
|
87
|
-
const ref = useRef<HTMLDivElement>(null);
|
|
88
|
-
const hasChildren = category.children.length > 0;
|
|
89
|
-
const isActive = isActiveInTree(category, selectedId);
|
|
90
|
-
|
|
91
|
-
// Find the display name for the selected subcategory
|
|
92
|
-
function findName(nodes: CategoryNode[], id: string): string | null {
|
|
93
|
-
for (const n of nodes) {
|
|
94
|
-
if (n.id === id) return n.name;
|
|
95
|
-
const found = findName(n.children, id);
|
|
96
|
-
if (found) return found;
|
|
97
|
-
}
|
|
98
|
-
return null;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const selectedChildName =
|
|
102
|
-
isActive && selectedId !== category.id ? findName(category.children, selectedId) : null;
|
|
103
|
-
|
|
104
|
-
// Close dropdown on outside click
|
|
105
|
-
useEffect(() => {
|
|
106
|
-
if (!open) return;
|
|
107
|
-
function handleClick(e: MouseEvent) {
|
|
108
|
-
if (ref.current && !ref.current.contains(e.target as Node)) {
|
|
109
|
-
setOpen(false);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
document.addEventListener('mousedown', handleClick);
|
|
113
|
-
return () => document.removeEventListener('mousedown', handleClick);
|
|
114
|
-
}, [open]);
|
|
115
|
-
|
|
116
|
-
if (!hasChildren) {
|
|
117
|
-
return (
|
|
118
|
-
<button
|
|
119
|
-
onClick={() => onSelect(category.id)}
|
|
120
|
-
className={cn(
|
|
121
|
-
'border px-3 py-1.5 text-xs transition-colors',
|
|
122
|
-
selectedId === category.id
|
|
123
|
-
? 'bg-primary text-primary-foreground border-primary'
|
|
124
|
-
: 'border-border text-muted-foreground hover:border-foreground hover:text-foreground'
|
|
125
|
-
)}
|
|
126
|
-
>
|
|
127
|
-
{category.name}
|
|
128
|
-
</button>
|
|
129
|
-
);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
return (
|
|
133
|
-
<div ref={ref} className="relative">
|
|
134
|
-
<button
|
|
135
|
-
onClick={() => setOpen((prev) => !prev)}
|
|
136
|
-
className={cn(
|
|
137
|
-
'inline-flex items-center gap-1 border px-3 py-1.5 text-xs transition-colors',
|
|
138
|
-
isActive
|
|
139
|
-
? 'bg-primary text-primary-foreground border-primary'
|
|
140
|
-
: 'border-border text-muted-foreground hover:border-foreground hover:text-foreground'
|
|
141
|
-
)}
|
|
142
|
-
>
|
|
143
|
-
{category.name}
|
|
144
|
-
{selectedChildName && (
|
|
145
|
-
<span className="opacity-80">
|
|
146
|
-
{'·'} {selectedChildName}
|
|
147
|
-
</span>
|
|
148
|
-
)}
|
|
149
|
-
<ChevronDown className={cn('ms-0.5 transition-transform', open && 'rotate-180')} />
|
|
150
|
-
</button>
|
|
151
|
-
|
|
152
|
-
{open && (
|
|
153
|
-
<div className="bg-surface border-border absolute start-0 top-full z-50 mt-1 min-w-[180px] overflow-hidden border">
|
|
154
|
-
{/* "All in [category]" option */}
|
|
155
|
-
<button
|
|
156
|
-
onClick={() => {
|
|
157
|
-
onSelect(category.id);
|
|
158
|
-
setOpen(false);
|
|
159
|
-
}}
|
|
160
|
-
className={cn(
|
|
161
|
-
'hover:bg-muted w-full px-4 py-2 text-start text-sm font-medium transition-colors',
|
|
162
|
-
selectedId === category.id && 'bg-muted text-foreground'
|
|
163
|
-
)}
|
|
164
|
-
>
|
|
165
|
-
{tc('all')} {category.name}
|
|
166
|
-
</button>
|
|
167
|
-
<div className="bg-border mx-2 h-px" />
|
|
168
|
-
{/* Recursive children */}
|
|
169
|
-
<div onClick={() => setOpen(false)}>
|
|
170
|
-
<CategoryDropdownItems
|
|
171
|
-
items={category.children}
|
|
172
|
-
depth={0}
|
|
173
|
-
selectedId={selectedId}
|
|
174
|
-
onSelect={onSelect}
|
|
175
|
-
/>
|
|
176
|
-
</div>
|
|
177
|
-
</div>
|
|
178
|
-
)}
|
|
179
|
-
</div>
|
|
180
|
-
);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Product listing page (filters + sort + grid + load-more). All data and
|
|
185
|
-
* URL-state behavior comes from `useProductListing`; this component is
|
|
186
|
-
* presentation only. Must be rendered inside `<Suspense>` (URL params).
|
|
187
|
-
*/
|
|
188
|
-
export function ProductListing() {
|
|
189
|
-
const t = useTranslations('products');
|
|
190
|
-
const tc = useTranslations('common');
|
|
191
|
-
|
|
192
|
-
const {
|
|
193
|
-
products,
|
|
194
|
-
loading,
|
|
195
|
-
loadingMore,
|
|
196
|
-
page,
|
|
197
|
-
totalPages,
|
|
198
|
-
total,
|
|
199
|
-
categories,
|
|
200
|
-
brands,
|
|
201
|
-
tags,
|
|
202
|
-
metafieldDefs,
|
|
203
|
-
searchQuery,
|
|
204
|
-
categoryId,
|
|
205
|
-
brandId,
|
|
206
|
-
tagId,
|
|
207
|
-
sortIndex,
|
|
208
|
-
handleLoadMore,
|
|
209
|
-
updateParam,
|
|
210
|
-
handleCategorySelect,
|
|
211
|
-
getSelectedMetafieldValues,
|
|
212
|
-
setMetafieldValue,
|
|
213
|
-
} = useProductListing();
|
|
214
|
-
|
|
215
|
-
return (
|
|
216
|
-
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
|
217
|
-
{/* Page Header */}
|
|
218
|
-
<div className="mb-10 pt-6">
|
|
219
|
-
<h1 className="text-foreground font-serif text-3xl font-light">
|
|
220
|
-
{searchQuery ? `${t('searchPrefix')} "${searchQuery}"` : t('allProducts')}
|
|
221
|
-
</h1>
|
|
222
|
-
{!loading && (
|
|
223
|
-
<p className="text-muted-foreground tabular mt-2 text-[0.6875rem] tracking-[0.08em]">
|
|
224
|
-
{total} {total === 1 ? tc('product') : tc('products')} {tc('found')}
|
|
225
|
-
</p>
|
|
226
|
-
)}
|
|
227
|
-
<div className="border-border mt-6 border-b" />
|
|
228
|
-
</div>
|
|
229
|
-
|
|
230
|
-
{/* Filters and Sort */}
|
|
231
|
-
<div className="mb-6 flex flex-col gap-4">
|
|
232
|
-
{/* Category Filter */}
|
|
233
|
-
{categories.length > 0 && (
|
|
234
|
-
<div className="flex flex-wrap gap-2">
|
|
235
|
-
<button
|
|
236
|
-
onClick={() => updateParam('category', '')}
|
|
237
|
-
className={cn(
|
|
238
|
-
'border px-3 py-1.5 text-xs transition-colors',
|
|
239
|
-
!categoryId
|
|
240
|
-
? 'bg-primary text-primary-foreground border-primary'
|
|
241
|
-
: 'border-border text-muted-foreground hover:border-foreground hover:text-foreground'
|
|
242
|
-
)}
|
|
243
|
-
>
|
|
244
|
-
{tc('all')}
|
|
245
|
-
</button>
|
|
246
|
-
{categories.map((cat) => (
|
|
247
|
-
<CategoryChip
|
|
248
|
-
key={cat.id}
|
|
249
|
-
category={cat}
|
|
250
|
-
selectedId={categoryId}
|
|
251
|
-
onSelect={handleCategorySelect}
|
|
252
|
-
tc={tc as (key: string) => string}
|
|
253
|
-
/>
|
|
254
|
-
))}
|
|
255
|
-
</div>
|
|
256
|
-
)}
|
|
257
|
-
|
|
258
|
-
{/* Brand, Tag & Sort row */}
|
|
259
|
-
<div className="flex flex-wrap items-center gap-3">
|
|
260
|
-
{/* Brand Filter */}
|
|
261
|
-
{brands.length > 0 && (
|
|
262
|
-
<select
|
|
263
|
-
value={brandId}
|
|
264
|
-
onChange={(e) => updateParam('brand', e.target.value)}
|
|
265
|
-
className="border-border bg-surface text-foreground focus:border-foreground h-9 border px-3 text-sm focus:outline-none"
|
|
266
|
-
>
|
|
267
|
-
<option value="">{t('allBrands')}</option>
|
|
268
|
-
{brands.map((b) => (
|
|
269
|
-
<option key={b.id} value={b.id}>
|
|
270
|
-
{b.name}
|
|
271
|
-
</option>
|
|
272
|
-
))}
|
|
273
|
-
</select>
|
|
274
|
-
)}
|
|
275
|
-
|
|
276
|
-
{/* Tag Filter */}
|
|
277
|
-
{tags.length > 0 && (
|
|
278
|
-
<select
|
|
279
|
-
value={tagId}
|
|
280
|
-
onChange={(e) => updateParam('tag', e.target.value)}
|
|
281
|
-
className="border-border bg-surface text-foreground focus:border-foreground h-9 border px-3 text-sm focus:outline-none"
|
|
282
|
-
>
|
|
283
|
-
<option value="">{t('allTags')}</option>
|
|
284
|
-
{tags.map((tg) => (
|
|
285
|
-
<option key={tg.id} value={tg.id}>
|
|
286
|
-
{tg.name}
|
|
287
|
-
</option>
|
|
288
|
-
))}
|
|
289
|
-
</select>
|
|
290
|
-
)}
|
|
291
|
-
|
|
292
|
-
{/* Custom-field (metafield) facets — rendered per definition type.
|
|
293
|
-
Only definitions flagged `filterable: true` and published to
|
|
294
|
-
this sales channel reach this list (see `metafieldDefs` filter
|
|
295
|
-
in the hook). The merchant's display name (`def.name`) is shown
|
|
296
|
-
as-is — `enumValues` come from the merchant's dashboard config. */}
|
|
297
|
-
{metafieldDefs.map((def) => {
|
|
298
|
-
const selected = getSelectedMetafieldValues(def.key);
|
|
299
|
-
|
|
300
|
-
if (def.type === 'BOOLEAN') {
|
|
301
|
-
const isOn = selected.includes('true');
|
|
302
|
-
return (
|
|
303
|
-
<label
|
|
304
|
-
key={def.id}
|
|
305
|
-
className="border-border text-foreground hover:border-foreground inline-flex h-9 cursor-pointer items-center gap-2 border px-3 text-sm transition-colors"
|
|
306
|
-
>
|
|
307
|
-
<input
|
|
308
|
-
type="checkbox"
|
|
309
|
-
checked={isOn}
|
|
310
|
-
onChange={(e) => setMetafieldValue(def.key, e.target.checked ? ['true'] : [])}
|
|
311
|
-
className="accent-primary h-4 w-4"
|
|
312
|
-
/>
|
|
313
|
-
{def.name}
|
|
314
|
-
</label>
|
|
315
|
-
);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
if (def.type === 'SELECT') {
|
|
319
|
-
return (
|
|
320
|
-
<select
|
|
321
|
-
key={def.id}
|
|
322
|
-
value={selected[0] || ''}
|
|
323
|
-
onChange={(e) =>
|
|
324
|
-
setMetafieldValue(def.key, e.target.value ? [e.target.value] : [])
|
|
325
|
-
}
|
|
326
|
-
aria-label={def.name}
|
|
327
|
-
className="border-border bg-surface text-foreground focus:border-foreground h-9 border px-3 text-sm focus:outline-none"
|
|
328
|
-
>
|
|
329
|
-
<option value="">{`${tc('all')} ${def.name}`}</option>
|
|
330
|
-
{(def.enumValues || []).map((v) => (
|
|
331
|
-
<option key={v.value} value={v.value}>
|
|
332
|
-
{v.label || v.value}
|
|
333
|
-
</option>
|
|
334
|
-
))}
|
|
335
|
-
</select>
|
|
336
|
-
);
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// MULTI_SELECT — chip toggles. Each click adds/removes one value.
|
|
340
|
-
return (
|
|
341
|
-
<div
|
|
342
|
-
key={def.id}
|
|
343
|
-
className="border-border inline-flex items-center gap-1 border px-2 py-1"
|
|
344
|
-
aria-label={def.name}
|
|
345
|
-
>
|
|
346
|
-
<span className="text-muted-foreground px-1 text-xs">{def.name}:</span>
|
|
347
|
-
{(def.enumValues || []).map((v) => {
|
|
348
|
-
const active = selected.includes(v.value);
|
|
349
|
-
return (
|
|
350
|
-
<button
|
|
351
|
-
key={v.value}
|
|
352
|
-
type="button"
|
|
353
|
-
onClick={() =>
|
|
354
|
-
setMetafieldValue(
|
|
355
|
-
def.key,
|
|
356
|
-
active ? selected.filter((s) => s !== v.value) : [...selected, v.value]
|
|
357
|
-
)
|
|
358
|
-
}
|
|
359
|
-
className={cn(
|
|
360
|
-
'border px-2 py-0.5 text-xs transition-colors',
|
|
361
|
-
active
|
|
362
|
-
? 'bg-primary text-primary-foreground border-primary'
|
|
363
|
-
: 'border-border text-muted-foreground hover:border-foreground hover:text-foreground'
|
|
364
|
-
)}
|
|
365
|
-
>
|
|
366
|
-
{v.label || v.value}
|
|
367
|
-
</button>
|
|
368
|
-
);
|
|
369
|
-
})}
|
|
370
|
-
</div>
|
|
371
|
-
);
|
|
372
|
-
})}
|
|
373
|
-
|
|
374
|
-
{/* Sort */}
|
|
375
|
-
<div className="flex items-center gap-2 sm:ms-auto">
|
|
376
|
-
<label htmlFor="sort" className="text-muted-foreground whitespace-nowrap text-sm">
|
|
377
|
-
{tc('sortBy')}
|
|
378
|
-
</label>
|
|
379
|
-
<select
|
|
380
|
-
id="sort"
|
|
381
|
-
value={sortIndex}
|
|
382
|
-
onChange={(e) => updateParam('sort', e.target.value)}
|
|
383
|
-
className="border-border bg-surface text-foreground focus:border-foreground h-9 border px-3 text-sm focus:outline-none"
|
|
384
|
-
>
|
|
385
|
-
{sortOptions.map((opt, idx) => (
|
|
386
|
-
<option key={idx} value={idx}>
|
|
387
|
-
{t(opt.labelKey)}
|
|
388
|
-
</option>
|
|
389
|
-
))}
|
|
390
|
-
</select>
|
|
391
|
-
</div>
|
|
392
|
-
</div>
|
|
393
|
-
</div>
|
|
394
|
-
|
|
395
|
-
{/* Products Grid */}
|
|
396
|
-
{loading ? (
|
|
397
|
-
<div className="flex items-center justify-center py-20">
|
|
398
|
-
<LoadingSpinner size="lg" />
|
|
399
|
-
</div>
|
|
400
|
-
) : (
|
|
401
|
-
<>
|
|
402
|
-
<ProductGrid products={products} />
|
|
403
|
-
|
|
404
|
-
{/* Load More */}
|
|
405
|
-
{page < totalPages && (
|
|
406
|
-
<div className="mt-10 flex justify-center">
|
|
407
|
-
<button
|
|
408
|
-
onClick={handleLoadMore}
|
|
409
|
-
disabled={loadingMore}
|
|
410
|
-
className="bg-primary text-primary-foreground inline-flex items-center gap-2 px-6 py-2.5 text-[0.8125rem] font-semibold transition-colors disabled:opacity-50"
|
|
411
|
-
>
|
|
412
|
-
{loadingMore ? (
|
|
413
|
-
<>
|
|
414
|
-
<LoadingSpinner size="sm" className="bg-primary-foreground" />
|
|
415
|
-
{tc('loading')}
|
|
416
|
-
</>
|
|
417
|
-
) : (
|
|
418
|
-
t('loadMore')
|
|
419
|
-
)}
|
|
420
|
-
</button>
|
|
421
|
-
</div>
|
|
422
|
-
)}
|
|
423
|
-
</>
|
|
424
|
-
)}
|
|
425
|
-
</div>
|
|
426
|
-
);
|
|
427
|
-
}
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useEffect, useState } from 'react';
|
|
4
|
-
import { Link } from '@/core/lib/navigation';
|
|
5
|
-
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
6
|
-
import type { ProductRecommendation } from 'brainerce';
|
|
7
|
-
import { PriceDisplay } from '@/ui/product/price-display';
|
|
8
|
-
import { cn } from '@/core/lib/utils';
|
|
9
|
-
|
|
10
|
-
interface RecommendationCardProps {
|
|
11
|
-
item: ProductRecommendation;
|
|
12
|
-
className?: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
function RecommendationCard({ item, className }: RecommendationCardProps) {
|
|
16
|
-
const firstImage = item.images?.[0];
|
|
17
|
-
const imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url || null;
|
|
18
|
-
const slug = item.slug || item.id;
|
|
19
|
-
const basePrice = parseFloat(item.basePrice);
|
|
20
|
-
const salePrice = item.salePrice ? parseFloat(item.salePrice) : undefined;
|
|
21
|
-
const isOnSale = salePrice != null && salePrice < basePrice;
|
|
22
|
-
|
|
23
|
-
return (
|
|
24
|
-
<Link href={`/products/${slug}`} className={cn('group block', className)}>
|
|
25
|
-
<div className="bg-surface border-border relative aspect-square overflow-hidden border">
|
|
26
|
-
{imageUrl ? (
|
|
27
|
-
<Image
|
|
28
|
-
src={imageUrl}
|
|
29
|
-
alt={item.name}
|
|
30
|
-
fill
|
|
31
|
-
sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
|
|
32
|
-
className="object-cover transition-transform duration-700 group-hover:scale-[1.03]"
|
|
33
|
-
/>
|
|
34
|
-
) : (
|
|
35
|
-
<div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
|
|
36
|
-
<svg className="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
37
|
-
<path
|
|
38
|
-
strokeLinecap="round"
|
|
39
|
-
strokeLinejoin="round"
|
|
40
|
-
strokeWidth={1.5}
|
|
41
|
-
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
42
|
-
/>
|
|
43
|
-
</svg>
|
|
44
|
-
</div>
|
|
45
|
-
)}
|
|
46
|
-
</div>
|
|
47
|
-
<div className="space-y-1.5 pt-3 text-start">
|
|
48
|
-
<h3 className="text-foreground line-clamp-2 font-serif text-sm font-normal">
|
|
49
|
-
{item.name}
|
|
50
|
-
</h3>
|
|
51
|
-
<PriceDisplay price={basePrice} salePrice={isOnSale ? salePrice : undefined} size="sm" />
|
|
52
|
-
</div>
|
|
53
|
-
</Link>
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
interface RecommendationSectionProps {
|
|
58
|
-
title: string;
|
|
59
|
-
items: ProductRecommendation[];
|
|
60
|
-
className?: string;
|
|
61
|
-
/** Catalog number line above the title, e.g. 'מס׳ 03'. */
|
|
62
|
-
kicker?: string;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function RecommendationSection({
|
|
66
|
-
title,
|
|
67
|
-
items,
|
|
68
|
-
className,
|
|
69
|
-
kicker,
|
|
70
|
-
}: RecommendationSectionProps) {
|
|
71
|
-
if (items.length === 0) return null;
|
|
72
|
-
|
|
73
|
-
return (
|
|
74
|
-
<div className={cn('', className)}>
|
|
75
|
-
<div>
|
|
76
|
-
{kicker ? <p className="placard tabular">{kicker}</p> : null}
|
|
77
|
-
<h2 className="text-foreground mt-1 font-serif text-xl font-normal">{title}</h2>
|
|
78
|
-
</div>
|
|
79
|
-
<div className="border-border mb-8 mt-5 border-b" />
|
|
80
|
-
<div className="grid grid-cols-2 gap-x-4 gap-y-10 sm:grid-cols-3 lg:grid-cols-4">
|
|
81
|
-
{items.map((item) => (
|
|
82
|
-
<RecommendationCard key={item.id} item={item} />
|
|
83
|
-
))}
|
|
84
|
-
</div>
|
|
85
|
-
</div>
|
|
86
|
-
);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
interface CartRecommendationSectionProps {
|
|
90
|
-
title: string;
|
|
91
|
-
items: ProductRecommendation[];
|
|
92
|
-
className?: string;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export function CartRecommendationSection({
|
|
96
|
-
title,
|
|
97
|
-
items,
|
|
98
|
-
className,
|
|
99
|
-
}: CartRecommendationSectionProps) {
|
|
100
|
-
if (items.length === 0) return null;
|
|
101
|
-
|
|
102
|
-
return (
|
|
103
|
-
<div className={cn('', className)}>
|
|
104
|
-
<h2 className="text-foreground font-serif text-lg font-normal">{title}</h2>
|
|
105
|
-
<div className="border-border mb-6 mt-4 border-b" />
|
|
106
|
-
<div className="grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-4">
|
|
107
|
-
{items.map((item) => (
|
|
108
|
-
<RecommendationCard key={item.id} item={item} />
|
|
109
|
-
))}
|
|
110
|
-
</div>
|
|
111
|
-
</div>
|
|
112
|
-
);
|
|
113
|
-
}
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import type { InventoryInfo } from 'brainerce';
|
|
4
|
-
import { cn } from '@/core/lib/utils';
|
|
5
|
-
import { useTranslations } from '@/core/lib/translations';
|
|
6
|
-
|
|
7
|
-
interface StockBadgeProps {
|
|
8
|
-
inventory: InventoryInfo | null | undefined;
|
|
9
|
-
lowStockThreshold?: number;
|
|
10
|
-
className?: string;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Stock as a museum placard caption — silence means available.
|
|
15
|
-
* The placard only speaks when scarcity or absence needs saying:
|
|
16
|
-
* low stock gets a quiet terracotta dot, absence gets terracotta text.
|
|
17
|
-
*/
|
|
18
|
-
export function StockBadge({ inventory, lowStockThreshold = 5, className }: StockBadgeProps) {
|
|
19
|
-
const t = useTranslations('productDetail');
|
|
20
|
-
|
|
21
|
-
const caption = 'text-[0.6875rem] tracking-[0.08em]';
|
|
22
|
-
|
|
23
|
-
if (!inventory) {
|
|
24
|
-
return <span className={cn('text-destructive', caption, className)}>{t('outOfStock')}</span>;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const { trackingMode, inStock, available } = inventory;
|
|
28
|
-
|
|
29
|
-
if (trackingMode === 'DISABLED') {
|
|
30
|
-
return <span className={cn('text-destructive', caption, className)}>{t('unavailable')}</span>;
|
|
31
|
-
}
|
|
32
|
-
if (!inStock) {
|
|
33
|
-
return <span className={cn('text-destructive', caption, className)}>{t('outOfStock')}</span>;
|
|
34
|
-
}
|
|
35
|
-
if (trackingMode === 'TRACKED' && available <= lowStockThreshold) {
|
|
36
|
-
return (
|
|
37
|
-
<span
|
|
38
|
-
className={cn(
|
|
39
|
-
'text-muted-foreground inline-flex items-center gap-1.5',
|
|
40
|
-
caption,
|
|
41
|
-
className
|
|
42
|
-
)}
|
|
43
|
-
>
|
|
44
|
-
<span aria-hidden="true" className="bg-destructive h-1.5 w-1.5 rounded-full" />
|
|
45
|
-
{t('onlyLeft').replace('{available}', String(available))}
|
|
46
|
-
</span>
|
|
47
|
-
);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// In stock, no scarcity — the placard stays silent.
|
|
51
|
-
return null;
|
|
52
|
-
}
|