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.
- package/dist/index.js +44 -7
- package/messages/en.json +10 -1
- package/messages/he.json +10 -1
- package/package.json +4 -2
- package/templates/nextjs/base/.env.local.ejs +62 -52
- package/templates/nextjs/base/AGENTS.md.ejs +189 -161
- package/templates/nextjs/base/CLAUDE.md.ejs +200 -172
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +37 -0
- package/templates/nextjs/base/src/app/page.tsx +75 -65
- package/templates/nextjs/base/src/app/register/page.tsx +74 -67
- package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +79 -7
- package/templates/nextjs/base/src/components/account/order-history.tsx +34 -0
- package/templates/nextjs/base/src/components/auth/register-form.tsx +358 -326
- package/templates/nextjs/base/src/components/referral/referral-greeting.tsx +120 -0
- package/templates/nextjs/base/src/core/lib/auth.ts +162 -155
- package/templates/nextjs/base/src/core/lib/referral.ts +95 -0
- package/templates/nextjs/base/src/ui/product/product-card.tsx +5 -0
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +5 -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/templates/nextjs/ui-canvas/product/product-card.tsx +5 -0
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +382 -375
|
@@ -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) {
|