create-brainerce-store 1.74.0 → 1.75.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 CHANGED
@@ -31,7 +31,7 @@ var require_package = __commonJS({
31
31
  "package.json"(exports2, module2) {
32
32
  module2.exports = {
33
33
  name: "create-brainerce-store",
34
- version: "1.74.0",
34
+ version: "1.75.0",
35
35
  description: "Scaffold a production-ready e-commerce storefront connected to Brainerce",
36
36
  bin: {
37
37
  "create-brainerce-store": "dist/index.js"
@@ -47,7 +47,8 @@ var require_package = __commonJS({
47
47
  dev: "tsup --watch",
48
48
  clean: "rimraf dist",
49
49
  release: "pnpm build && node ../../scripts/publish-workspace-package.js .",
50
- prepublishOnly: "node ../../scripts/check-publishable.js ./package.json"
50
+ "check:template-parity": "node scripts/check-template-parity.js",
51
+ prepublishOnly: "node scripts/check-template-parity.js && node ../../scripts/check-publishable.js ./package.json"
51
52
  },
52
53
  dependencies: {
53
54
  chalk: "^4.1.2",
@@ -264,11 +265,13 @@ var BRAINERCE_RUNTIME_DEPS = Object.freeze({
264
265
  // top level instead of per card. Typing the prop as `CheckoutTender[]` does
265
266
  // not compile against `checkout.tenders`.)
266
267
  //
267
- // 2.1.0 IS NOT PUBLISHED YET (npm latest is 2.0.2). Per the RELEASE ORDER
268
- // rule above, publishing this CLI before the SDK makes the range
269
- // unsatisfiable and kills every scaffold at install. Publish brainerce
270
- // 2.1.0 first, confirm it on the registry, then publish the CLI.
271
- brainerce: "^2.1.0",
268
+ // 2.1.0 was published on 2026-09-03 and the tarball was checked for the
269
+ // methods the template calls, so this range is satisfiable. The RELEASE ORDER
270
+ // rule above still stands for the NEXT bump: publish brainerce first, confirm
271
+ // it on the registry with `npm pack` (not `npm view`, which serves a stale
272
+ // cache), and only then publish the CLI — a floor above the published SDK is
273
+ // unsatisfiable and kills every scaffold at install.
274
+ brainerce: "^2.2.0",
272
275
  "isomorphic-dompurify": "^3.8.0"
273
276
  });
274
277
 
package/messages/en.json CHANGED
@@ -62,7 +62,8 @@
62
62
  "monthNovember": "November",
63
63
  "monthDecember": "December",
64
64
  "giftCard": "Gift card",
65
- "amountDue": "Amount due"
65
+ "amountDue": "Amount due",
66
+ "amountCharged": "Amount charged"
66
67
  },
67
68
  "nav": {
68
69
  "products": "Products",
package/messages/he.json CHANGED
@@ -62,7 +62,8 @@
62
62
  "monthNovember": "נובמבר",
63
63
  "monthDecember": "דצמבר",
64
64
  "giftCard": "כרטיס מתנה",
65
- "amountDue": "לתשלום"
65
+ "amountDue": "לתשלום",
66
+ "amountCharged": "חויב בפועל"
66
67
  },
67
68
  "nav": {
68
69
  "products": "מוצרים",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-brainerce-store",
3
- "version": "1.74.0",
3
+ "version": "1.75.0",
4
4
  "description": "Scaffold a production-ready e-commerce storefront connected to Brainerce",
5
5
  "bin": {
6
6
  "create-brainerce-store": "dist/index.js"
@@ -16,7 +16,8 @@
16
16
  "dev": "tsup --watch",
17
17
  "clean": "rimraf dist",
18
18
  "release": "pnpm build && node ../../scripts/publish-workspace-package.js .",
19
- "prepublishOnly": "node ../../scripts/check-publishable.js ./package.json"
19
+ "check:template-parity": "node scripts/check-template-parity.js",
20
+ "prepublishOnly": "node scripts/check-template-parity.js && node ../../scripts/check-publishable.js ./package.json"
20
21
  },
21
22
  "dependencies": {
22
23
  "chalk": "^4.1.2",
@@ -333,6 +333,43 @@ function ConfirmationLineItems({ order }: { order: Order | null }) {
333
333
  {formatPrice(parseFloat(totalAmount), { currency }) as string}
334
334
  </span>
335
335
  </div>
336
+
337
+ {/* What actually paid.
338
+ This is the first screen a customer sees after handing over money, and
339
+ a total with no gift-card line tells them they paid an amount they did
340
+ not. The total above is what the order was WORTH and stays unreduced —
341
+ tax was calculated on it — so the cards sit below it, followed by what
342
+ the card or wallet was really charged. */}
343
+ {order.tenders && order.tenders.length > 0 && (
344
+ <>
345
+ {order.tenders.map((tender) => (
346
+ <div key={tender.id} className="mt-2 flex items-center justify-between text-sm">
347
+ <span className="text-muted-foreground">
348
+ {tender.giftCard?.codeLast4
349
+ ? `${tc('giftCard')} ····${tender.giftCard.codeLast4}`
350
+ : tc('giftCard')}
351
+ </span>
352
+ <span className="text-primary">
353
+ -{formatPrice(parseFloat(tender.amountBase), { currency }) as string}
354
+ </span>
355
+ </div>
356
+ ))}
357
+ <div className="border-border mt-2 flex items-center justify-between border-t pt-2">
358
+ <span className="text-foreground text-sm font-medium">{tc('amountCharged')}</span>
359
+ <span className="text-foreground text-sm font-semibold">
360
+ {
361
+ formatPrice(
362
+ order.tenders.reduce(
363
+ (due, tn) => due - parseFloat(tn.amountBase),
364
+ parseFloat(totalAmount)
365
+ ),
366
+ { currency }
367
+ ) as string
368
+ }
369
+ </span>
370
+ </div>
371
+ </>
372
+ )}
336
373
  </div>
337
374
  );
338
375
  }
@@ -417,6 +417,40 @@ function OrderFinancialSummary({ order, currency }: { order: Order; currency: st
417
417
  {formatPrice(parseFloat(totalAmount), { currency }) as string}
418
418
  </span>
419
419
  </div>
420
+
421
+ {/* Order history is the record a customer checks a card statement
422
+ against, months later. A total with no gift-card line is the one that
423
+ makes them think they were charged twice. */}
424
+ {order.tenders && order.tenders.length > 0 && (
425
+ <>
426
+ {order.tenders.map((tender) => (
427
+ <div key={tender.id} className="flex items-center justify-between">
428
+ <span className="text-muted-foreground">
429
+ {tender.giftCard?.codeLast4
430
+ ? `${tc('giftCard')} ····${tender.giftCard.codeLast4}`
431
+ : tc('giftCard')}
432
+ </span>
433
+ <span className="text-primary">
434
+ -{formatPrice(parseFloat(tender.amountBase), { currency }) as string}
435
+ </span>
436
+ </div>
437
+ ))}
438
+ <div className="border-border flex items-center justify-between border-t pt-1">
439
+ <span className="text-foreground font-medium">{tc('amountCharged')}</span>
440
+ <span className="text-foreground font-semibold">
441
+ {
442
+ formatPrice(
443
+ order.tenders.reduce(
444
+ (due, tn) => due - parseFloat(tn.amountBase),
445
+ parseFloat(totalAmount)
446
+ ),
447
+ { currency }
448
+ ) as string
449
+ }
450
+ </span>
451
+ </div>
452
+ </>
453
+ )}
420
454
  </div>
421
455
  );
422
456
  }
@@ -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">