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 +10 -7
- package/messages/en.json +2 -1
- package/messages/he.json +2 -1
- package/package.json +3 -2
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +37 -0
- package/templates/nextjs/base/src/components/account/order-history.tsx +34 -0
- package/templates/nextjs/ui-canvas/layout/header-search.tsx +222 -0
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +6 -0
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
|
|
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
package/messages/he.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-brainerce-store",
|
|
3
|
-
"version": "1.
|
|
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
|
-
"
|
|
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">
|