create-brainerce-store 1.74.0 → 1.76.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.
@@ -0,0 +1,222 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Header search with autocomplete (bare skeleton).
5
+ *
6
+ * The header itself is a server component (merchant-configured Content), so
7
+ * the search box is a client island — the same bridge `HeaderAccount` uses.
8
+ *
9
+ * Everything here is BEHAVIOUR and is already correct; only the presentation is
10
+ * yours to write. Keep all of it:
11
+ * - `useSearch()` supplies the suggestions (300ms debounce, 2-char minimum,
12
+ * abort on re-query). This component owns only the input + dropdown.
13
+ * - Submitting navigates to `/products?search=<query>`, which is exactly
14
+ * what `use-product-listing` reads back off the URL.
15
+ * - Picking a product suggestion goes to its PDP; picking a category goes to
16
+ * the listing filtered by `?category=<id>`, because a CategorySuggestion
17
+ * carries `id` + `name` + `productCount` and no slug.
18
+ * - Keyboard: ArrowDown / ArrowUp move the active option, Enter opens it (or
19
+ * submits the raw query when nothing is active), Escape closes the panel.
20
+ * A pointerdown outside the widget closes it.
21
+ *
22
+ * The ARIA wiring (`role="combobox"`, `aria-expanded`, `aria-controls`,
23
+ * `aria-activedescendant`, `role="listbox"`/`"option"`, `aria-selected`) is
24
+ * load-bearing for screen readers — style around it, do not remove it.
25
+ */
26
+
27
+ import * as React from 'react';
28
+ import { useRouter } from '@/core/lib/navigation';
29
+ import { useSearch } from '@/core/hooks/use-search';
30
+ import { useTranslations } from '@/core/lib/translations';
31
+
32
+ interface HeaderSearchProps {
33
+ className?: string;
34
+ }
35
+
36
+ /** One flattened dropdown row — products first, then categories. */
37
+ type Option =
38
+ | { kind: 'product'; id: string; label: string; href: string }
39
+ | { kind: 'category'; id: string; label: string; href: string };
40
+
41
+ export function HeaderSearch({ className }: HeaderSearchProps) {
42
+ const router = useRouter();
43
+ const t = useTranslations('nav');
44
+ const tc = useTranslations('common');
45
+ const tp = useTranslations('products');
46
+
47
+ const [query, setQuery] = React.useState('');
48
+ const [open, setOpen] = React.useState(false);
49
+ const [activeIndex, setActiveIndex] = React.useState(-1);
50
+ const { suggestions, loading } = useSearch(query);
51
+
52
+ const rootRef = React.useRef<HTMLDivElement>(null);
53
+ const listboxId = React.useId();
54
+
55
+ // Products first, then categories — one flat list so the arrow keys can walk
56
+ // the whole panel without caring which section a row came from.
57
+ const options: Option[] = React.useMemo(() => {
58
+ if (!suggestions) return [];
59
+ const rows: Option[] = [];
60
+ for (const product of suggestions.products) {
61
+ rows.push({
62
+ kind: 'product',
63
+ id: product.id,
64
+ label: product.name,
65
+ // `slug` is nullable on a ProductSuggestion — fall back to the id.
66
+ href: `/products/${product.slug || product.id}`,
67
+ });
68
+ }
69
+ for (const category of suggestions.categories) {
70
+ rows.push({
71
+ kind: 'category',
72
+ id: category.id,
73
+ label: category.name,
74
+ // CategorySuggestion carries no slug, so filter the listing by id.
75
+ href: `/products?category=${encodeURIComponent(category.id)}`,
76
+ });
77
+ }
78
+ return rows;
79
+ }, [suggestions]);
80
+
81
+ // A fresh result set invalidates whatever row was highlighted.
82
+ React.useEffect(() => {
83
+ setActiveIndex(-1);
84
+ }, [options]);
85
+
86
+ // Close when the click lands outside the widget. Pointerdown rather than
87
+ // click so the panel is gone before a link underneath activates.
88
+ React.useEffect(() => {
89
+ if (!open) return;
90
+ function onPointerDown(event: PointerEvent) {
91
+ if (!rootRef.current) return;
92
+ if (!rootRef.current.contains(event.target as Node)) setOpen(false);
93
+ }
94
+ document.addEventListener('pointerdown', onPointerDown);
95
+ return () => document.removeEventListener('pointerdown', onPointerDown);
96
+ }, [open]);
97
+
98
+ function go(href: string) {
99
+ setOpen(false);
100
+ setActiveIndex(-1);
101
+ router.push(href);
102
+ }
103
+
104
+ function submit() {
105
+ const trimmed = query.trim();
106
+ if (!trimmed) return;
107
+ go(`/products?search=${encodeURIComponent(trimmed)}`);
108
+ }
109
+
110
+ function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
111
+ if (event.key === 'Escape') {
112
+ setOpen(false);
113
+ setActiveIndex(-1);
114
+ return;
115
+ }
116
+
117
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
118
+ if (options.length === 0) return;
119
+ event.preventDefault();
120
+ setOpen(true);
121
+ setActiveIndex((prev) => {
122
+ const step = event.key === 'ArrowDown' ? 1 : -1;
123
+ const next = prev + step;
124
+ if (next < 0) return options.length - 1;
125
+ if (next >= options.length) return 0;
126
+ return next;
127
+ });
128
+ return;
129
+ }
130
+
131
+ if (event.key === 'Enter') {
132
+ event.preventDefault();
133
+ const active = activeIndex >= 0 ? options[activeIndex] : undefined;
134
+ if (active) {
135
+ go(active.href);
136
+ } else {
137
+ submit();
138
+ }
139
+ }
140
+ }
141
+
142
+ // The panel is only worth showing once the hook has something to say —
143
+ // either rows, a spinner, or an explicit "nothing matched".
144
+ const showPanel = open && (loading || suggestions !== null);
145
+ const activeOptionId = activeIndex >= 0 ? `${listboxId}-${activeIndex}` : undefined;
146
+ const productCount = suggestions?.products.length ?? 0;
147
+
148
+ return (
149
+ <div ref={rootRef} className={className}>
150
+ {/* DESIGN ME — header search. Compose with the shadcn/ui primitives in
151
+ src/components/ui (Input, Command, Popover, Skeleton, ...) + lucide-react
152
+ icons. The panel below wants to overlay the page, so give this wrapper a
153
+ positioning context and the panel an elevated layer. */}
154
+ <form
155
+ role="search"
156
+ onSubmit={(event) => {
157
+ event.preventDefault();
158
+ submit();
159
+ }}
160
+ >
161
+ <label htmlFor={`${listboxId}-input`} className="sr-only">
162
+ {t('search')}
163
+ </label>
164
+ <input
165
+ id={`${listboxId}-input`}
166
+ type="search"
167
+ role="combobox"
168
+ autoComplete="off"
169
+ aria-expanded={showPanel}
170
+ aria-controls={listboxId}
171
+ aria-activedescendant={activeOptionId}
172
+ placeholder={t('searchPlaceholder')}
173
+ value={query}
174
+ onChange={(event) => {
175
+ setQuery(event.target.value);
176
+ setOpen(true);
177
+ }}
178
+ onFocus={() => setOpen(true)}
179
+ onKeyDown={handleKeyDown}
180
+ />
181
+ </form>
182
+
183
+ {showPanel && (
184
+ <div>
185
+ {/* DESIGN ME — suggestions panel. */}
186
+ {loading && options.length === 0 ? (
187
+ <p>{tc('loading')}</p>
188
+ ) : options.length === 0 ? (
189
+ <p>{tc('noResults')}</p>
190
+ ) : (
191
+ <ul id={listboxId} role="listbox" aria-label={t('search')}>
192
+ {options.map((option, index) => (
193
+ <React.Fragment key={`${option.kind}-${option.id}`}>
194
+ {index === 0 && productCount > 0 && (
195
+ <li role="presentation">{tp('pageTitle')}</li>
196
+ )}
197
+ {index === productCount && option.kind === 'category' && (
198
+ <li role="presentation">{t('categories')}</li>
199
+ )}
200
+ <li
201
+ id={`${listboxId}-${index}`}
202
+ role="option"
203
+ aria-selected={index === activeIndex}
204
+ onPointerDown={(event) => {
205
+ // Keep focus in the input so the blur handler cannot
206
+ // close the panel before the navigation runs.
207
+ event.preventDefault();
208
+ go(option.href);
209
+ }}
210
+ onMouseEnter={() => setActiveIndex(index)}
211
+ >
212
+ {option.label}
213
+ </li>
214
+ </React.Fragment>
215
+ ))}
216
+ </ul>
217
+ )}
218
+ </div>
219
+ )}
220
+ </div>
221
+ );
222
+ }
@@ -18,6 +18,7 @@ import * as React from 'react';
18
18
  import { Link } from '@/core/lib/navigation';
19
19
  import type { Content } from 'brainerce';
20
20
  import { HeaderAccount } from './header-account';
21
+ import { HeaderSearch } from './header-search';
21
22
  <% if (i18nEnabled) { %>
22
23
  import { LanguageSwitcher } from '@/ui/layout/language-switcher';
23
24
  <% } %>
@@ -53,6 +54,9 @@ export function SiteHeader({ header, storeName }: SiteHeaderProps) {
53
54
  <LanguageSwitcher />
54
55
  <% } %>
55
56
  <RegionSwitcher />
57
+ {/* Search is a MANDATORY feature, not decoration — the island carries the
58
+ debounce, keyboard navigation and ARIA wiring already. */}
59
+ <HeaderSearch />
56
60
  <HeaderAccount />
57
61
  <Link href="/cart" aria-label="Cart">
58
62
  Cart
@@ -96,6 +100,8 @@ export function SiteHeader({ header, storeName }: SiteHeaderProps) {
96
100
  <% } %>
97
101
  <RegionSwitcher />
98
102
 
103
+ <HeaderSearch />
104
+
99
105
  <HeaderAccount />
100
106
 
101
107
  <Link href="/cart" aria-label="Cart">
@@ -58,6 +58,11 @@ export function ProductCard({ product, className }: ProductCardProps) {
58
58
  const [adding, setAdding] = useState(false);
59
59
  const [added, setAdded] = useState(false);
60
60
 
61
+ // `!== false`, not `=== true`, and that matters for KIT products: a kit
62
+ // carries NO `inventory` object of its own (its availability is derived from
63
+ // its components), so a strict truthy check would render every kit as out of
64
+ // stock. A kit also falls through `isVariable` above and adds by `productId`
65
+ // alone, which is correct — kits take no variantId and no selections.
61
66
  const canPurchase = product.inventory?.canPurchase !== false;
62
67
 
63
68
  async function handleAddToCart(e: React.MouseEvent) {