create-brainerce-store 1.72.0 → 1.74.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/dist/index.js +37 -4
- package/messages/en.json +30 -1
- package/messages/he.json +30 -1
- package/package.json +1 -1
- package/templates/nextjs/base/AGENTS.md.ejs +63 -16
- package/templates/nextjs/base/AI-GUIDE.md +17 -4
- package/templates/nextjs/base/CLAUDE.md.ejs +67 -20
- package/templates/nextjs/base/TRANSLATIONS.md +207 -207
- package/templates/nextjs/base/src/app/account/page.tsx +168 -122
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +10 -1
- package/templates/nextjs/base/src/app/checkout/page.tsx +1179 -1074
- package/templates/nextjs/base/src/app/layout.tsx.ejs +48 -4
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +98 -3
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +14 -2
- package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +226 -0
- package/templates/nextjs/base/src/components/account/order-history.tsx +422 -371
- package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +85 -85
- package/templates/nextjs/base/src/components/account/saved-payment-methods.tsx +104 -0
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +618 -612
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +354 -306
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +141 -74
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +218 -127
- package/templates/nextjs/base/src/core/hooks/use-home-data.ts +63 -51
- package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +379 -295
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +33 -1
- package/templates/nextjs/base/src/core/lib/capabilities.ts +65 -0
- package/templates/nextjs/base/src/core/lib/display-price.ts +139 -0
- package/templates/nextjs/base/src/core/lib/region.server.ts +112 -0
- package/templates/nextjs/base/src/core/lib/region.ts +112 -0
- package/templates/nextjs/base/src/core/lib/store-info.ts +19 -0
- package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +423 -243
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +174 -164
- package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +109 -110
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +219 -176
- package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +191 -0
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +137 -137
- package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +108 -0
- package/templates/nextjs/base/src/ui/home/home-client.tsx +27 -29
- package/templates/nextjs/base/src/ui/layout/header-search.tsx +249 -0
- package/templates/nextjs/base/src/ui/layout/region-switcher.tsx +59 -0
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +171 -152
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +197 -191
- package/templates/nextjs/base/src/ui/product/product-card.tsx +25 -52
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +12 -3
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +460 -446
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +108 -102
- package/templates/nextjs/base/src/ui/product/review-form.tsx +33 -11
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +15 -3
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +48 -4
- package/templates/nextjs/designs/atelier/messages-patch/en.json +21 -25
- package/templates/nextjs/designs/atelier/messages-patch/he.json +21 -25
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +177 -181
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +168 -158
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +106 -107
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +229 -184
- package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +131 -131
- package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +108 -0
- package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +10 -1
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +178 -174
- package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +4 -6
- package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +150 -78
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +79 -46
- package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +264 -0
- package/templates/nextjs/designs/atelier/ui/layout/region-switcher.tsx +44 -0
- package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +5 -0
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +202 -196
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +27 -52
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +12 -3
- package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +332 -260
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +101 -95
- package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +30 -10
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +17 -2
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +9 -0
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +137 -137
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +98 -99
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +140 -140
- package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +142 -0
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +124 -124
- package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +104 -0
- package/templates/nextjs/ui-canvas/home/home-client.tsx +24 -26
- package/templates/nextjs/ui-canvas/layout/region-switcher.tsx +51 -0
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +3 -0
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +174 -168
- package/templates/nextjs/ui-canvas/product/product-card.tsx +9 -51
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +5 -3
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +105 -99
- package/templates/nextjs/ui-canvas/product/review-form.tsx +9 -1
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +17 -2
|
@@ -1,46 +1,79 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Social-proof band — the store's best-rated products, from real review data.
|
|
5
|
+
*
|
|
6
|
+
* Never invent testimonials. A storefront that ships hardcoded quotes and star
|
|
7
|
+
* ratings is publishing fake reviews under the merchant's name, which is illegal
|
|
8
|
+
* in most markets. This section renders only what shoppers actually left, and
|
|
9
|
+
* auto-hides entirely until someone has reviewed something.
|
|
10
|
+
*
|
|
11
|
+
* `avgRating` / `reviewCount` already ride on the products the page loaded, so
|
|
12
|
+
* this costs no extra request.
|
|
13
|
+
*/
|
|
14
|
+
import type { Product } from 'brainerce';
|
|
15
|
+
import { Link } from '@/core/lib/navigation';
|
|
16
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
17
|
+
import { IconStar } from '@/ui/shared/icons';
|
|
18
|
+
|
|
19
|
+
const MAX_SHOWN = 3;
|
|
20
|
+
|
|
21
|
+
export function TestimonialsBand({ products }: { products: Product[] }) {
|
|
22
|
+
const t = useTranslations('home');
|
|
23
|
+
|
|
24
|
+
const rated = products
|
|
25
|
+
.filter((p) => (p.reviewCount ?? 0) > 0 && (p.avgRating ?? 0) > 0)
|
|
26
|
+
.sort((a, b) => (b.avgRating ?? 0) - (a.avgRating ?? 0))
|
|
27
|
+
.slice(0, MAX_SHOWN);
|
|
28
|
+
|
|
29
|
+
// Nothing reviewed yet — show nothing rather than filling the gap with fiction.
|
|
30
|
+
if (rated.length === 0) return null;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<section className="section-pad wash-sand">
|
|
34
|
+
<div className="container-page">
|
|
35
|
+
<div className="mb-8 max-w-xl lg:mb-10">
|
|
36
|
+
<span className="eyebrow">{t('ratedEyebrow')}</span>
|
|
37
|
+
<h2 className="text-4xl sm:text-5xl">{t('ratedTitle')}</h2>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<ul className="grid gap-4 sm:gap-6 lg:grid-cols-3">
|
|
41
|
+
{rated.map((product) => {
|
|
42
|
+
const rating = product.avgRating ?? 0;
|
|
43
|
+
const rounded = Math.round(rating);
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<li key={product.id}>
|
|
47
|
+
<Link
|
|
48
|
+
// `slug` is optional on Product; fall back to the id the way
|
|
49
|
+
// the hero does, so a product without one still links somewhere.
|
|
50
|
+
href={`/products/${product.slug || product.id}`}
|
|
51
|
+
className="card card-hover flex h-full flex-col gap-4 p-6"
|
|
52
|
+
>
|
|
53
|
+
<span
|
|
54
|
+
className="inline-flex text-primary"
|
|
55
|
+
aria-label={t('ratedStars', { rating: rating.toFixed(1) })}
|
|
56
|
+
>
|
|
57
|
+
{[1, 2, 3, 4, 5].map((s) => (
|
|
58
|
+
<IconStar key={s} size={16} filled={s <= rounded} />
|
|
59
|
+
))}
|
|
60
|
+
</span>
|
|
61
|
+
|
|
62
|
+
<p className="flex-1 text-lg font-semibold leading-7 text-foreground">
|
|
63
|
+
{product.name}
|
|
64
|
+
</p>
|
|
65
|
+
|
|
66
|
+
<p className="border-t pt-4 text-sm text-muted-foreground">
|
|
67
|
+
<span className="font-semibold text-foreground">{rating.toFixed(1)}</span>
|
|
68
|
+
{' · '}
|
|
69
|
+
{t('ratedCount', { count: String(product.reviewCount ?? 0) })}
|
|
70
|
+
</p>
|
|
71
|
+
</Link>
|
|
72
|
+
</li>
|
|
73
|
+
);
|
|
74
|
+
})}
|
|
75
|
+
</ul>
|
|
76
|
+
</div>
|
|
77
|
+
</section>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Header search with autocomplete — Atelier treatment.
|
|
5
|
+
*
|
|
6
|
+
* Same contract as the base island (see `base/src/ui/layout/header-search.tsx`):
|
|
7
|
+
* `useSearch()` owns the debounce / minimum-length / abort behaviour, this
|
|
8
|
+
* component owns the input and the dropdown. Submitting lands on
|
|
9
|
+
* `/products?search=…`, which `use-product-listing` already reads back.
|
|
10
|
+
*
|
|
11
|
+
* On mobile the field collapses to a single icon button that expands the
|
|
12
|
+
* input over the bar, so the pill row stays uncrowded next to the account and
|
|
13
|
+
* cart controls.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as React from 'react';
|
|
17
|
+
import { useRouter } from '@/core/lib/navigation';
|
|
18
|
+
import { useSearch } from '@/core/hooks/use-search';
|
|
19
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
20
|
+
import { cn } from '@/core/lib/utils';
|
|
21
|
+
import { IconSearch, IconX } from '@/ui/shared/icons';
|
|
22
|
+
|
|
23
|
+
interface HeaderSearchProps {
|
|
24
|
+
className?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One flattened dropdown row — products first, then categories. */
|
|
28
|
+
type Option = {
|
|
29
|
+
kind: 'product' | 'category';
|
|
30
|
+
id: string;
|
|
31
|
+
label: string;
|
|
32
|
+
href: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function HeaderSearch({ className }: HeaderSearchProps) {
|
|
36
|
+
const router = useRouter();
|
|
37
|
+
const t = useTranslations('nav');
|
|
38
|
+
const tc = useTranslations('common');
|
|
39
|
+
const tp = useTranslations('products');
|
|
40
|
+
|
|
41
|
+
const [query, setQuery] = React.useState('');
|
|
42
|
+
const [open, setOpen] = React.useState(false);
|
|
43
|
+
const [expanded, setExpanded] = React.useState(false);
|
|
44
|
+
const [activeIndex, setActiveIndex] = React.useState(-1);
|
|
45
|
+
const { suggestions, loading } = useSearch(query);
|
|
46
|
+
|
|
47
|
+
const rootRef = React.useRef<HTMLDivElement>(null);
|
|
48
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
49
|
+
const listboxId = React.useId();
|
|
50
|
+
|
|
51
|
+
const options: Option[] = React.useMemo(() => {
|
|
52
|
+
if (!suggestions) return [];
|
|
53
|
+
const rows: Option[] = [];
|
|
54
|
+
for (const product of suggestions.products) {
|
|
55
|
+
rows.push({
|
|
56
|
+
kind: 'product',
|
|
57
|
+
id: product.id,
|
|
58
|
+
label: product.name,
|
|
59
|
+
// `slug` is nullable on a ProductSuggestion — fall back to the id,
|
|
60
|
+
// the same way the product cards do.
|
|
61
|
+
href: `/products/${product.slug || product.id}`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
for (const category of suggestions.categories) {
|
|
65
|
+
rows.push({
|
|
66
|
+
kind: 'category',
|
|
67
|
+
id: category.id,
|
|
68
|
+
label: category.name,
|
|
69
|
+
// CategorySuggestion carries no slug, so filter the listing by id.
|
|
70
|
+
href: `/products?category=${encodeURIComponent(category.id)}`,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return rows;
|
|
74
|
+
}, [suggestions]);
|
|
75
|
+
|
|
76
|
+
React.useEffect(() => {
|
|
77
|
+
setActiveIndex(-1);
|
|
78
|
+
}, [options]);
|
|
79
|
+
|
|
80
|
+
React.useEffect(() => {
|
|
81
|
+
if (!open && !expanded) return;
|
|
82
|
+
function onPointerDown(event: PointerEvent) {
|
|
83
|
+
if (!rootRef.current) return;
|
|
84
|
+
if (rootRef.current.contains(event.target as Node)) return;
|
|
85
|
+
setOpen(false);
|
|
86
|
+
setExpanded(false);
|
|
87
|
+
}
|
|
88
|
+
document.addEventListener('pointerdown', onPointerDown);
|
|
89
|
+
return () => document.removeEventListener('pointerdown', onPointerDown);
|
|
90
|
+
}, [open, expanded]);
|
|
91
|
+
|
|
92
|
+
function go(href: string) {
|
|
93
|
+
setOpen(false);
|
|
94
|
+
setExpanded(false);
|
|
95
|
+
setActiveIndex(-1);
|
|
96
|
+
router.push(href);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function submit() {
|
|
100
|
+
const trimmed = query.trim();
|
|
101
|
+
if (!trimmed) return;
|
|
102
|
+
go(`/products?search=${encodeURIComponent(trimmed)}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
|
106
|
+
if (event.key === 'Escape') {
|
|
107
|
+
setOpen(false);
|
|
108
|
+
setActiveIndex(-1);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
113
|
+
if (options.length === 0) return;
|
|
114
|
+
event.preventDefault();
|
|
115
|
+
setOpen(true);
|
|
116
|
+
setActiveIndex((prev) => {
|
|
117
|
+
const step = event.key === 'ArrowDown' ? 1 : -1;
|
|
118
|
+
const next = prev + step;
|
|
119
|
+
if (next < 0) return options.length - 1;
|
|
120
|
+
if (next >= options.length) return 0;
|
|
121
|
+
return next;
|
|
122
|
+
});
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (event.key === 'Enter') {
|
|
127
|
+
event.preventDefault();
|
|
128
|
+
const active = activeIndex >= 0 ? options[activeIndex] : undefined;
|
|
129
|
+
if (active) {
|
|
130
|
+
go(active.href);
|
|
131
|
+
} else {
|
|
132
|
+
submit();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const showPanel = open && (loading || suggestions !== null);
|
|
138
|
+
const activeOptionId = activeIndex >= 0 ? `${listboxId}-${activeIndex}` : undefined;
|
|
139
|
+
const productCount = suggestions?.products.length ?? 0;
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div ref={rootRef} className={cn('relative', className)}>
|
|
143
|
+
{/* Mobile trigger — the field itself is hidden until this is pressed. */}
|
|
144
|
+
<button
|
|
145
|
+
type="button"
|
|
146
|
+
aria-label={t('search')}
|
|
147
|
+
aria-expanded={expanded}
|
|
148
|
+
onClick={() => {
|
|
149
|
+
setExpanded((prev) => !prev);
|
|
150
|
+
// Focus lands after the field is actually in the tree.
|
|
151
|
+
window.setTimeout(() => inputRef.current?.focus(), 0);
|
|
152
|
+
}}
|
|
153
|
+
className={cn(
|
|
154
|
+
'text-foreground hover:bg-secondary flex h-10 w-10 items-center justify-center rounded-full transition-colors sm:hidden',
|
|
155
|
+
expanded && 'bg-secondary'
|
|
156
|
+
)}
|
|
157
|
+
>
|
|
158
|
+
{expanded ? <IconX size={20} /> : <IconSearch size={20} />}
|
|
159
|
+
</button>
|
|
160
|
+
|
|
161
|
+
<form
|
|
162
|
+
role="search"
|
|
163
|
+
onSubmit={(event) => {
|
|
164
|
+
event.preventDefault();
|
|
165
|
+
submit();
|
|
166
|
+
}}
|
|
167
|
+
className={cn(
|
|
168
|
+
// Mobile: an overlay panel under the bar. Desktop: an inline pill.
|
|
169
|
+
expanded ? 'absolute end-0 top-12 z-50 w-[min(18rem,calc(100vw-2rem))]' : 'hidden',
|
|
170
|
+
'sm:relative sm:end-auto sm:top-auto sm:z-auto sm:block sm:w-56 lg:w-64'
|
|
171
|
+
)}
|
|
172
|
+
>
|
|
173
|
+
<label htmlFor={`${listboxId}-input`} className="sr-only">
|
|
174
|
+
{t('search')}
|
|
175
|
+
</label>
|
|
176
|
+
<div className="relative">
|
|
177
|
+
<span
|
|
178
|
+
aria-hidden="true"
|
|
179
|
+
className="text-muted-foreground pointer-events-none absolute start-3 top-1/2 -translate-y-1/2"
|
|
180
|
+
>
|
|
181
|
+
<IconSearch size={16} />
|
|
182
|
+
</span>
|
|
183
|
+
<input
|
|
184
|
+
ref={inputRef}
|
|
185
|
+
id={`${listboxId}-input`}
|
|
186
|
+
type="search"
|
|
187
|
+
role="combobox"
|
|
188
|
+
autoComplete="off"
|
|
189
|
+
aria-expanded={showPanel}
|
|
190
|
+
aria-controls={listboxId}
|
|
191
|
+
aria-activedescendant={activeOptionId}
|
|
192
|
+
placeholder={t('searchPlaceholder')}
|
|
193
|
+
value={query}
|
|
194
|
+
onChange={(event) => {
|
|
195
|
+
setQuery(event.target.value);
|
|
196
|
+
setOpen(true);
|
|
197
|
+
}}
|
|
198
|
+
onFocus={() => setOpen(true)}
|
|
199
|
+
onKeyDown={handleKeyDown}
|
|
200
|
+
className="border-border bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-primary h-10 w-full rounded-full border pe-4 ps-9 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2"
|
|
201
|
+
/>
|
|
202
|
+
</div>
|
|
203
|
+
</form>
|
|
204
|
+
|
|
205
|
+
{showPanel && (
|
|
206
|
+
<div className="bg-background absolute end-0 top-full z-50 mt-2 w-[min(20rem,calc(100vw-2rem))] overflow-hidden rounded-xl border p-1 shadow-lg sm:inset-x-0 sm:w-auto">
|
|
207
|
+
{loading && options.length === 0 ? (
|
|
208
|
+
<p className="text-muted-foreground px-3 py-2.5 text-sm">{tc('loading')}</p>
|
|
209
|
+
) : options.length === 0 ? (
|
|
210
|
+
<p className="text-muted-foreground px-3 py-2.5 text-sm">{tc('noResults')}</p>
|
|
211
|
+
) : (
|
|
212
|
+
<ul
|
|
213
|
+
id={listboxId}
|
|
214
|
+
role="listbox"
|
|
215
|
+
aria-label={t('search')}
|
|
216
|
+
className="max-h-80 overflow-y-auto"
|
|
217
|
+
>
|
|
218
|
+
{options.map((option, index) => (
|
|
219
|
+
<React.Fragment key={`${option.kind}-${option.id}`}>
|
|
220
|
+
{index === 0 && productCount > 0 && (
|
|
221
|
+
<li
|
|
222
|
+
role="presentation"
|
|
223
|
+
className="text-muted-foreground px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wide"
|
|
224
|
+
>
|
|
225
|
+
{tp('pageTitle')}
|
|
226
|
+
</li>
|
|
227
|
+
)}
|
|
228
|
+
{index === productCount && option.kind === 'category' && (
|
|
229
|
+
<li
|
|
230
|
+
role="presentation"
|
|
231
|
+
className="text-muted-foreground px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wide"
|
|
232
|
+
>
|
|
233
|
+
{t('categories')}
|
|
234
|
+
</li>
|
|
235
|
+
)}
|
|
236
|
+
<li
|
|
237
|
+
id={`${listboxId}-${index}`}
|
|
238
|
+
role="option"
|
|
239
|
+
aria-selected={index === activeIndex}
|
|
240
|
+
className={cn(
|
|
241
|
+
'cursor-pointer rounded-lg px-3 py-2.5 text-sm font-medium transition-colors',
|
|
242
|
+
index === activeIndex
|
|
243
|
+
? 'bg-secondary text-foreground'
|
|
244
|
+
: 'text-foreground hover:bg-secondary'
|
|
245
|
+
)}
|
|
246
|
+
onPointerDown={(event) => {
|
|
247
|
+
// Keep focus in the input so the outside-click handler
|
|
248
|
+
// cannot close the panel before the navigation runs.
|
|
249
|
+
event.preventDefault();
|
|
250
|
+
go(option.href);
|
|
251
|
+
}}
|
|
252
|
+
onMouseEnter={() => setActiveIndex(index)}
|
|
253
|
+
>
|
|
254
|
+
{option.label}
|
|
255
|
+
</li>
|
|
256
|
+
</React.Fragment>
|
|
257
|
+
))}
|
|
258
|
+
</ul>
|
|
259
|
+
)}
|
|
260
|
+
</div>
|
|
261
|
+
)}
|
|
262
|
+
</div>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Region / currency switcher.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ RENDERS NOTHING on a single-region store, which is almost every store.
|
|
7
|
+
* `regions.length <= 1` returns null before any markup: an empty or
|
|
8
|
+
* one-option dropdown in the header is worse than no switcher at all, and the
|
|
9
|
+
* whole regions feature is meant to be invisible until a merchant actually
|
|
10
|
+
* adds a second region.
|
|
11
|
+
*
|
|
12
|
+
* ⛔ It also renders nothing when the regions call FAILED. A brand-new store
|
|
13
|
+
* is empty and the call can fail (channel not live yet, backend hiccup); the
|
|
14
|
+
* provider swallows that and leaves `regions` at `[]`, which lands in the same
|
|
15
|
+
* early return. There is no error box and no skeleton, because a shopper has
|
|
16
|
+
* no idea what a region is and nothing here is worth interrupting them for.
|
|
17
|
+
*
|
|
18
|
+
* Why a manual switcher exists at all when the edge already guesses: the guess
|
|
19
|
+
* is an IP, and an IP is wrong for travellers, expats, VPNs and anyone behind
|
|
20
|
+
* a corporate egress. The shopper is the authority on where they are, and
|
|
21
|
+
* their choice is stored in a cookie so the SERVER renders the same prices on
|
|
22
|
+
* the next navigation instead of the client re-pricing after hydration.
|
|
23
|
+
*
|
|
24
|
+
* The labels are store data (`region.name`, `region.currency`), not UI copy,
|
|
25
|
+
* so this component needs no translation keys in any locale.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { useRegion } from '@/core/providers/store-provider';
|
|
29
|
+
import { SelectMenu } from '@/ui/shared/select-menu';
|
|
30
|
+
|
|
31
|
+
export function RegionSwitcher() {
|
|
32
|
+
const { regions, region, setRegion } = useRegion();
|
|
33
|
+
|
|
34
|
+
if (regions.length <= 1 || !region) return null;
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<SelectMenu
|
|
38
|
+
value={region.id}
|
|
39
|
+
options={regions.map((r) => ({ value: r.id, label: `${r.name} (${r.currency})` }))}
|
|
40
|
+
onChange={setRegion}
|
|
41
|
+
ariaLabel="Select region and currency"
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -15,9 +15,11 @@ import { Link } from '@/core/lib/navigation';
|
|
|
15
15
|
import type { Content } from 'brainerce';
|
|
16
16
|
import { HeaderAccount } from './header-account';
|
|
17
17
|
import { HeaderCart } from './header-cart';
|
|
18
|
+
import { HeaderSearch } from './header-search';
|
|
18
19
|
<% if (i18nEnabled) { %>
|
|
19
20
|
import { LanguageSwitcher } from '@/ui/layout/language-switcher';
|
|
20
21
|
<% } %>
|
|
22
|
+
import { RegionSwitcher } from '@/ui/layout/region-switcher';
|
|
21
23
|
import { IconMenu } from '@/ui/shared/icons';
|
|
22
24
|
import { resolveNavUrl } from '@/ui/layout/nav-url';
|
|
23
25
|
|
|
@@ -83,6 +85,8 @@ export function SiteHeader({ header, storeName }: SiteHeaderProps) {
|
|
|
83
85
|
)}
|
|
84
86
|
|
|
85
87
|
<div className="flex items-center gap-1 sm:gap-2">
|
|
88
|
+
{/* Collapses to an icon button below `sm` — see header-search.tsx. */}
|
|
89
|
+
<HeaderSearch />
|
|
86
90
|
{cta ? (
|
|
87
91
|
<a href={resolveNavUrl(cta.url)} className="btn-primary btn-sm hidden lg:inline-flex">
|
|
88
92
|
{cta.label}
|
|
@@ -92,6 +96,7 @@ export function SiteHeader({ header, storeName }: SiteHeaderProps) {
|
|
|
92
96
|
|
|
93
97
|
<LanguageSwitcher />
|
|
94
98
|
<% } %>
|
|
99
|
+
<RegionSwitcher />
|
|
95
100
|
<HeaderAccount />
|
|
96
101
|
<HeaderCart />
|
|
97
102
|
|