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.
Files changed (88) hide show
  1. package/dist/index.js +37 -4
  2. package/messages/en.json +30 -1
  3. package/messages/he.json +30 -1
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/AGENTS.md.ejs +63 -16
  6. package/templates/nextjs/base/AI-GUIDE.md +17 -4
  7. package/templates/nextjs/base/CLAUDE.md.ejs +67 -20
  8. package/templates/nextjs/base/TRANSLATIONS.md +207 -207
  9. package/templates/nextjs/base/src/app/account/page.tsx +168 -122
  10. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +10 -1
  11. package/templates/nextjs/base/src/app/checkout/page.tsx +1179 -1074
  12. package/templates/nextjs/base/src/app/layout.tsx.ejs +48 -4
  13. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +98 -3
  14. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +14 -2
  15. package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +226 -0
  16. package/templates/nextjs/base/src/components/account/order-history.tsx +422 -371
  17. package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +85 -85
  18. package/templates/nextjs/base/src/components/account/saved-payment-methods.tsx +104 -0
  19. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +618 -612
  20. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +354 -306
  21. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +141 -74
  22. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +218 -127
  23. package/templates/nextjs/base/src/core/hooks/use-home-data.ts +63 -51
  24. package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +379 -295
  25. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +33 -1
  26. package/templates/nextjs/base/src/core/lib/capabilities.ts +65 -0
  27. package/templates/nextjs/base/src/core/lib/display-price.ts +139 -0
  28. package/templates/nextjs/base/src/core/lib/region.server.ts +112 -0
  29. package/templates/nextjs/base/src/core/lib/region.ts +112 -0
  30. package/templates/nextjs/base/src/core/lib/store-info.ts +19 -0
  31. package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +423 -243
  32. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +174 -164
  33. package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +109 -110
  34. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +219 -176
  35. package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +191 -0
  36. package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +137 -137
  37. package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +108 -0
  38. package/templates/nextjs/base/src/ui/home/home-client.tsx +27 -29
  39. package/templates/nextjs/base/src/ui/layout/header-search.tsx +249 -0
  40. package/templates/nextjs/base/src/ui/layout/region-switcher.tsx +59 -0
  41. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +171 -152
  42. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +197 -191
  43. package/templates/nextjs/base/src/ui/product/product-card.tsx +25 -52
  44. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +12 -3
  45. package/templates/nextjs/base/src/ui/product/product-listing.tsx +460 -446
  46. package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +108 -102
  47. package/templates/nextjs/base/src/ui/product/review-form.tsx +33 -11
  48. package/templates/nextjs/base/src/ui/product/stock-badge.tsx +15 -3
  49. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +48 -4
  50. package/templates/nextjs/designs/atelier/messages-patch/en.json +21 -25
  51. package/templates/nextjs/designs/atelier/messages-patch/he.json +21 -25
  52. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +177 -181
  53. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +168 -158
  54. package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +106 -107
  55. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +229 -184
  56. package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +131 -131
  57. package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +108 -0
  58. package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +10 -1
  59. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +178 -174
  60. package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +4 -6
  61. package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +150 -78
  62. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +79 -46
  63. package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +264 -0
  64. package/templates/nextjs/designs/atelier/ui/layout/region-switcher.tsx +44 -0
  65. package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +5 -0
  66. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +202 -196
  67. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +27 -52
  68. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +12 -3
  69. package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +332 -260
  70. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +101 -95
  71. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +30 -10
  72. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +17 -2
  73. package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +9 -0
  74. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +137 -137
  75. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +98 -99
  76. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +140 -140
  77. package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +142 -0
  78. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +124 -124
  79. package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +104 -0
  80. package/templates/nextjs/ui-canvas/home/home-client.tsx +24 -26
  81. package/templates/nextjs/ui-canvas/layout/region-switcher.tsx +51 -0
  82. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +3 -0
  83. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +174 -168
  84. package/templates/nextjs/ui-canvas/product/product-card.tsx +9 -51
  85. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +5 -3
  86. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +105 -99
  87. package/templates/nextjs/ui-canvas/product/review-form.tsx +9 -1
  88. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +17 -2
@@ -1,46 +1,79 @@
1
- 'use client';
2
-
3
- /** Testimonials band — three quote cards, copy from translation keys. */
4
- import { useTranslations } from '@/core/lib/translations';
5
- import { IconStar, IconQuote } from '@/ui/shared/icons';
6
-
7
- const TESTIMONIALS = [1, 2, 3] as const;
8
-
9
- export function TestimonialsBand() {
10
- const t = useTranslations('home');
11
-
12
- return (
13
- <section className="section-pad wash-sand">
14
- <div className="container-page">
15
- <div className="mb-8 max-w-xl lg:mb-10">
16
- <span className="eyebrow">{t('testimonialsEyebrow')}</span>
17
- <h2 className="text-4xl sm:text-5xl">{t('testimonialsTitle')}</h2>
18
- </div>
19
-
20
- <ul className="grid gap-4 sm:gap-6 lg:grid-cols-3">
21
- {TESTIMONIALS.map((n) => (
22
- <li key={n}>
23
- <figure className={`card card-hover flex h-full flex-col gap-4 p-6 ${n === 2 ? 'lg:rotate-1' : 'lg:-rotate-1'} lg:hover:rotate-0`}>
24
- <span className="text-primary/30" aria-hidden="true">
25
- <IconQuote size={28} />
26
- </span>
27
- <span className="inline-flex text-primary" aria-label="5/5">
28
- {[1, 2, 3, 4, 5].map((s) => (
29
- <IconStar key={s} size={16} filled />
30
- ))}
31
- </span>
32
- <blockquote className="flex-1 leading-7 text-foreground">
33
- “{t(`testimonial${n}Quote`)}”
34
- </blockquote>
35
- <figcaption className="border-t pt-4 text-sm">
36
- <span className="font-semibold text-foreground">{t(`testimonial${n}Name`)}</span>
37
- <span className="text-muted-foreground"> · {t(`testimonial${n}Meta`)}</span>
38
- </figcaption>
39
- </figure>
40
- </li>
41
- ))}
42
- </ul>
43
- </div>
44
- </section>
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