create-brainerce-store 1.67.0 → 1.71.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 +22 -2
- package/messages/en.json +52 -2
- package/messages/he.json +52 -2
- package/package.json +1 -1
- package/templates/nextjs/base/.env.local.ejs +7 -0
- package/templates/nextjs/base/AGENTS.md.ejs +7 -0
- package/templates/nextjs/base/CLAUDE.md.ejs +7 -0
- package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +8 -2
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +16 -7
- package/templates/nextjs/base/src/app/checkout/page.tsx +1018 -1017
- package/templates/nextjs/base/src/app/error.tsx.ejs +53 -0
- package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +8 -2
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +17 -7
- package/templates/nextjs/base/src/app/register/page.tsx +67 -64
- package/templates/nextjs/base/src/components/account/profile-section.tsx +303 -226
- package/templates/nextjs/base/src/components/auth/register-form.tsx +326 -245
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +306 -294
- package/templates/nextjs/base/src/components/checkout/date-picker.tsx +13 -1
- package/templates/nextjs/base/src/components/checkout/datetime-picker.tsx +61 -21
- package/templates/nextjs/base/src/components/shared/birthday-picker.tsx +258 -0
- package/templates/nextjs/base/src/core/lib/auth.ts +162 -154
- package/templates/nextjs/base/src/core/lib/birthday.ts +74 -0
- package/templates/nextjs/base/src/core/lib/site-url.ts +42 -9
- package/templates/nextjs/base/src/core/lib/store-info.ts +10 -0
- package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +143 -0
- package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +18 -2
- package/templates/nextjs/base/src/ui/product/back-in-stock-form.tsx +173 -0
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +484 -455
- package/templates/nextjs/base/src/ui/product/review-form.tsx +136 -12
- package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +139 -108
- package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +155 -142
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +500 -477
- package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +135 -11
- package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +179 -148
- package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +122 -0
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +87 -83
- package/templates/nextjs/ui-canvas/product/back-in-stock-form.tsx +151 -0
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +373 -352
- package/templates/nextjs/ui-canvas/product/review-form.tsx +129 -11
- package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +127 -96
|
@@ -21,6 +21,9 @@ import type { Content } from 'brainerce';
|
|
|
21
21
|
// component). Merchant-configured link columns keep plain <a> since their
|
|
22
22
|
// URLs are arbitrary (may be external).
|
|
23
23
|
import { Link } from '@/core/lib/navigation';
|
|
24
|
+
// Client component — the signup posts from the browser, which is also what
|
|
25
|
+
// satisfies the endpoint's browser-Origin requirement.
|
|
26
|
+
import { NewsletterSignup } from './newsletter-signup';
|
|
24
27
|
|
|
25
28
|
interface SiteFooterProps {
|
|
26
29
|
/** Pre-fetched footer payload (server-side). `null` triggers fallback rendering. */
|
|
@@ -64,8 +67,11 @@ export function SiteFooter({ footer, storeName }: SiteFooterProps) {
|
|
|
64
67
|
if (!data || (columns.length === 0 && !data.copyright && social.length === 0)) {
|
|
65
68
|
return (
|
|
66
69
|
<footer className="border-border bg-muted/30 text-muted-foreground mt-16 border-t py-8 text-sm">
|
|
67
|
-
<div className="mx-auto max-w-7xl px-4 text-center sm:px-6 lg:px-8">
|
|
68
|
-
|
|
70
|
+
<div className="mx-auto max-w-7xl space-y-6 px-4 text-center sm:px-6 lg:px-8">
|
|
71
|
+
<div className="mx-auto max-w-md text-start">
|
|
72
|
+
<NewsletterSignup />
|
|
73
|
+
</div>
|
|
74
|
+
<div>© {year} {brandLabel}. All rights reserved.</div>
|
|
69
75
|
</div>
|
|
70
76
|
</footer>
|
|
71
77
|
);
|
|
@@ -108,6 +114,16 @@ export function SiteFooter({ footer, storeName }: SiteFooterProps) {
|
|
|
108
114
|
})}
|
|
109
115
|
</div>
|
|
110
116
|
) : null}
|
|
117
|
+
|
|
118
|
+
{/*
|
|
119
|
+
Newsletter signup. Remove this block if the merchant does not want
|
|
120
|
+
a mailing list — nothing else depends on it. Confirmed opt-in: the
|
|
121
|
+
address is not subscribed until the recipient clicks the link they
|
|
122
|
+
are mailed, which is why the success copy says "check your email".
|
|
123
|
+
*/}
|
|
124
|
+
<div className="mt-8">
|
|
125
|
+
<NewsletterSignup />
|
|
126
|
+
</div>
|
|
111
127
|
</div>
|
|
112
128
|
|
|
113
129
|
{columns.length > 0 ? (
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Back-in-stock alert — "email me when this is back".
|
|
5
|
+
*
|
|
6
|
+
* ⛔ THIS IS NOT A NEWSLETTER SIGNUP. `stockAlerts.subscribe()` grants no
|
|
7
|
+
* marketing consent, creates no customer account, and the person receives
|
|
8
|
+
* exactly one message: the alert itself, about this item, carrying a link that
|
|
9
|
+
* stops it. That is why the button says "Notify me" and never "Subscribe" —
|
|
10
|
+
* and why a shopper who unsubscribed from the store's marketing can still use
|
|
11
|
+
* it. Never gate it on consent.
|
|
12
|
+
*
|
|
13
|
+
* ⛔ RENDER IT ONLY WHERE IT APPLIES. `canOfferStockAlert()` below is the whole
|
|
14
|
+
* gate. Requests for anything else are silently discarded server-side (the
|
|
15
|
+
* response is uniform on purpose, so it cannot be used to read a store's stock
|
|
16
|
+
* levels), which means a button in the wrong place looks like it worked and
|
|
17
|
+
* does nothing.
|
|
18
|
+
*
|
|
19
|
+
* ⛔ THE RESPONSE CARRIES NO INFORMATION. `{ ok: true }` comes back identically
|
|
20
|
+
* for a new request, a duplicate, a product id that does not exist, an item
|
|
21
|
+
* already in stock, and an address suppressed after a hard bounce. There is
|
|
22
|
+
* nothing to branch on — render one success state and stop.
|
|
23
|
+
*
|
|
24
|
+
* ⛔ DO NOT PROMISE TIMING. Availability is `total - reserved`, so an expiring
|
|
25
|
+
* cart briefly lifts a sold-out item above zero; the alert waits for stock to
|
|
26
|
+
* hold, then goes out in waves sized to the units that came back, oldest
|
|
27
|
+
* request first. A shopper can sit through a restock without hearing. "We'll
|
|
28
|
+
* email you when it's back" is true; "you'll be the first to know" is not.
|
|
29
|
+
*
|
|
30
|
+
* Rate limited to 5 requests / 60s per IP, 25 open alerts per address per store.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { useMemo, useState } from 'react';
|
|
34
|
+
import type { InventoryInfo } from 'brainerce';
|
|
35
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
36
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The gate. Four ways the answer is no, and every one of them matters:
|
|
40
|
+
*
|
|
41
|
+
* - the merchant switched the feature off for this storefront
|
|
42
|
+
* (`storeInfo.stockAlertsEnabled`, read once at boot);
|
|
43
|
+
* - the item is in stock, so there is nothing to wait for;
|
|
44
|
+
* - stock is not TRACKED — UNLIMITED never runs out, DISABLED is not for sale
|
|
45
|
+
* and no restock changes that;
|
|
46
|
+
* - the merchant allows backorders on it, so the storefront can already sell
|
|
47
|
+
* it while out of stock and an alert would tell someone to come back and do
|
|
48
|
+
* what they can already do.
|
|
49
|
+
*/
|
|
50
|
+
export function canOfferStockAlert(
|
|
51
|
+
inventory: InventoryInfo | null | undefined,
|
|
52
|
+
stockAlertsEnabled: boolean | undefined
|
|
53
|
+
): boolean {
|
|
54
|
+
if (stockAlertsEnabled === false) return false;
|
|
55
|
+
if (!inventory) return false;
|
|
56
|
+
if (inventory.trackingMode !== 'TRACKED') return false;
|
|
57
|
+
if (inventory.canPurchase) return false;
|
|
58
|
+
// Older backends omit the field entirely; absent means no backorder.
|
|
59
|
+
return (inventory.backorderMode ?? 'NONE') === 'NONE';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface BackInStockFormProps {
|
|
63
|
+
productId: string;
|
|
64
|
+
/**
|
|
65
|
+
* ⛔ Pass the SELECTED variant on any product with variants. Without it the
|
|
66
|
+
* alert waits on the product as a whole, so a shopper who wanted the medium
|
|
67
|
+
* is mailed when the small returns and arrives to find their size still gone.
|
|
68
|
+
*/
|
|
69
|
+
variantId?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function BackInStockForm({ productId, variantId }: BackInStockFormProps) {
|
|
73
|
+
const t = useTranslations('backInStock');
|
|
74
|
+
const [email, setEmail] = useState('');
|
|
75
|
+
const [honeypot, setHoneypot] = useState('');
|
|
76
|
+
const [loading, setLoading] = useState(false);
|
|
77
|
+
const [done, setDone] = useState(false);
|
|
78
|
+
const [error, setError] = useState<string | null>(null);
|
|
79
|
+
|
|
80
|
+
// Same source the newsletter form uses — <html lang> is set from the active
|
|
81
|
+
// locale, and passing it is what decides the language of the alert email.
|
|
82
|
+
// Omit it on a Hebrew storefront and the shopper gets English.
|
|
83
|
+
const locale = useMemo(() => {
|
|
84
|
+
if (typeof document !== 'undefined') return document.documentElement.lang || undefined;
|
|
85
|
+
return undefined;
|
|
86
|
+
}, []);
|
|
87
|
+
|
|
88
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
89
|
+
e.preventDefault();
|
|
90
|
+
if (loading || !email.trim()) return;
|
|
91
|
+
|
|
92
|
+
setLoading(true);
|
|
93
|
+
setError(null);
|
|
94
|
+
try {
|
|
95
|
+
await getClient().stockAlerts.subscribe({
|
|
96
|
+
email: email.trim(),
|
|
97
|
+
productId,
|
|
98
|
+
variantId,
|
|
99
|
+
locale,
|
|
100
|
+
honeypot,
|
|
101
|
+
});
|
|
102
|
+
setDone(true);
|
|
103
|
+
setEmail('');
|
|
104
|
+
} catch {
|
|
105
|
+
// A 429 lands here too — the copy stays generic rather than explaining
|
|
106
|
+
// the rate limit, which would only tell an abuser where the edge is.
|
|
107
|
+
setError(t('genericError'));
|
|
108
|
+
} finally {
|
|
109
|
+
setLoading(false);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (done) {
|
|
114
|
+
return (
|
|
115
|
+
<div className="border-border space-y-1 rounded border p-4">
|
|
116
|
+
<p className="text-sm font-medium">{t('doneTitle')}</p>
|
|
117
|
+
<p className="text-sm opacity-70">{t('doneBody')}</p>
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<form onSubmit={handleSubmit} className="border-border space-y-2 rounded border p-4">
|
|
124
|
+
<label htmlFor="back-in-stock-email" className="block text-sm font-medium">
|
|
125
|
+
{t('title')}
|
|
126
|
+
</label>
|
|
127
|
+
<p className="text-sm opacity-70">{t('subtitle')}</p>
|
|
128
|
+
|
|
129
|
+
<div className="flex flex-wrap gap-2">
|
|
130
|
+
<input
|
|
131
|
+
id="back-in-stock-email"
|
|
132
|
+
type="email"
|
|
133
|
+
required
|
|
134
|
+
autoComplete="email"
|
|
135
|
+
value={email}
|
|
136
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
137
|
+
placeholder={t('placeholder')}
|
|
138
|
+
className="border-border min-w-0 flex-1 rounded border px-3 py-2 text-sm"
|
|
139
|
+
/>
|
|
140
|
+
<button
|
|
141
|
+
type="submit"
|
|
142
|
+
disabled={loading}
|
|
143
|
+
className="bg-primary text-primary-foreground rounded px-4 py-2 text-sm font-medium transition-opacity hover:opacity-90 disabled:opacity-60"
|
|
144
|
+
>
|
|
145
|
+
{loading ? t('submitting') : t('submit')}
|
|
146
|
+
</button>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
{/*
|
|
150
|
+
Honeypot. Bots complete every text input; a human never sees this one, so
|
|
151
|
+
a non-empty value rejects the request server-side. Positioned off-screen
|
|
152
|
+
rather than `display:none` — some bots skip hidden inputs — and kept out
|
|
153
|
+
of the tab order and the accessibility tree.
|
|
154
|
+
*/}
|
|
155
|
+
<input
|
|
156
|
+
type="text"
|
|
157
|
+
name="company_website"
|
|
158
|
+
tabIndex={-1}
|
|
159
|
+
autoComplete="off"
|
|
160
|
+
aria-hidden="true"
|
|
161
|
+
value={honeypot}
|
|
162
|
+
onChange={(e) => setHoneypot(e.target.value)}
|
|
163
|
+
style={{ position: 'absolute', left: '-9999px', width: 1, height: 1 }}
|
|
164
|
+
/>
|
|
165
|
+
|
|
166
|
+
{error ? (
|
|
167
|
+
<p role="alert" className="text-sm text-red-600">
|
|
168
|
+
{error}
|
|
169
|
+
</p>
|
|
170
|
+
) : null}
|
|
171
|
+
</form>
|
|
172
|
+
);
|
|
173
|
+
}
|