create-brainerce-store 1.68.0 → 1.72.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/README.md +31 -10
- package/dist/index.js +197 -105
- package/messages/en.json +63 -3
- package/messages/he.json +63 -3
- package/package.json +1 -1
- package/templates/nextjs/base/TRANSLATIONS.md +14 -7
- package/templates/nextjs/base/src/app/checkout/page.tsx +1074 -1017
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +21 -2
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +1 -1
- package/templates/nextjs/base/src/app/register/page.tsx +67 -64
- package/templates/nextjs/base/src/components/account/order-history.tsx +25 -39
- package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +30 -11
- 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/hooks/use-cart-page.ts +71 -2
- package/templates/nextjs/base/src/core/lib/auth.ts +155 -154
- package/templates/nextjs/base/src/core/lib/birthday.ts +74 -0
- package/templates/nextjs/base/src/core/lib/brainerce.ts.ejs +5 -16
- package/templates/nextjs/base/src/core/lib/store-info.ts +10 -0
- package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +3 -6
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +19 -1
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +42 -6
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +52 -10
- 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/cart/cart-drawer.tsx +21 -3
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +19 -1
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +44 -7
- package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +52 -10
- 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/cart/cart-item.tsx +15 -1
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +38 -4
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +54 -11
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useEffect, useCallback } from 'react';
|
|
3
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
4
|
import { Clock } from 'lucide-react';
|
|
5
5
|
import type { ReservationInfo } from 'brainerce';
|
|
6
6
|
import { useTranslations } from '@/core/lib/translations';
|
|
@@ -8,12 +8,28 @@ import { cn } from '@/core/lib/utils';
|
|
|
8
8
|
|
|
9
9
|
interface ReservationCountdownProps {
|
|
10
10
|
reservation: ReservationInfo;
|
|
11
|
+
/**
|
|
12
|
+
* Fired once per reservation window when the timer reaches zero, including
|
|
13
|
+
* when the page opens on an already-expired reservation. The handler is
|
|
14
|
+
* expected to refresh the cart and gate checkout: this component only
|
|
15
|
+
* displays, it never decides what is still purchasable. Wire it to
|
|
16
|
+
* `useCartPage().onReservationExpired`.
|
|
17
|
+
*/
|
|
18
|
+
onExpire?: () => void;
|
|
11
19
|
className?: string;
|
|
12
20
|
}
|
|
13
21
|
|
|
14
|
-
export function ReservationCountdown({
|
|
22
|
+
export function ReservationCountdown({
|
|
23
|
+
reservation,
|
|
24
|
+
onExpire,
|
|
25
|
+
className,
|
|
26
|
+
}: ReservationCountdownProps) {
|
|
15
27
|
const t = useTranslations('reservation');
|
|
16
|
-
|
|
28
|
+
// `null` means "not measured yet". Server-rendered HTML must not compute a
|
|
29
|
+
// time, or it disagrees with the client on hydration; starting at 0 instead
|
|
30
|
+
// would render the expired banner for one frame on a perfectly live
|
|
31
|
+
// reservation, and would fire onExpire on every mount.
|
|
32
|
+
const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null);
|
|
17
33
|
|
|
18
34
|
const calculateRemaining = useCallback(() => {
|
|
19
35
|
if (!reservation.expiresAt) return 0;
|
|
@@ -22,8 +38,29 @@ export function ReservationCountdown({ reservation, className }: ReservationCoun
|
|
|
22
38
|
return Math.max(0, Math.floor((expiresAtMs - nowMs) / 1000));
|
|
23
39
|
}, [reservation.expiresAt]);
|
|
24
40
|
|
|
41
|
+
// Report each expiry window at most once. Keyed on `expiresAt` rather than a
|
|
42
|
+
// boolean, so the cart refresh that expiry triggers (which remounts this
|
|
43
|
+
// component) cannot bounce straight back into a second report.
|
|
44
|
+
const expiresAt = reservation.expiresAt;
|
|
45
|
+
const reportedExpiryRef = useRef<string | null>(null);
|
|
46
|
+
const onExpireRef = useRef(onExpire);
|
|
47
|
+
// Kept in a ref, and synced in an effect rather than during render, so a
|
|
48
|
+
// caller passing an inline arrow does not restart the timer every render.
|
|
25
49
|
useEffect(() => {
|
|
26
|
-
|
|
50
|
+
onExpireRef.current = onExpire;
|
|
51
|
+
}, [onExpire]);
|
|
52
|
+
|
|
53
|
+
const reportExpired = useCallback(() => {
|
|
54
|
+
if (!expiresAt) return;
|
|
55
|
+
if (reportedExpiryRef.current === expiresAt) return;
|
|
56
|
+
reportedExpiryRef.current = expiresAt;
|
|
57
|
+
onExpireRef.current?.();
|
|
58
|
+
}, [expiresAt]);
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const initial = calculateRemaining();
|
|
62
|
+
setRemainingSeconds(initial);
|
|
63
|
+
if (initial <= 0) reportExpired();
|
|
27
64
|
|
|
28
65
|
const interval = setInterval(() => {
|
|
29
66
|
const remaining = calculateRemaining();
|
|
@@ -31,18 +68,20 @@ export function ReservationCountdown({ reservation, className }: ReservationCoun
|
|
|
31
68
|
|
|
32
69
|
if (remaining <= 0) {
|
|
33
70
|
clearInterval(interval);
|
|
71
|
+
reportExpired();
|
|
34
72
|
}
|
|
35
73
|
}, 1000);
|
|
36
74
|
|
|
37
75
|
return () => clearInterval(interval);
|
|
38
|
-
}, [calculateRemaining]);
|
|
76
|
+
}, [calculateRemaining, reportExpired]);
|
|
39
77
|
|
|
40
78
|
if (!reservation.hasReservation) return null;
|
|
41
79
|
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const
|
|
80
|
+
const displaySeconds = remainingSeconds ?? 0;
|
|
81
|
+
const minutes = Math.floor(displaySeconds / 60);
|
|
82
|
+
const seconds = displaySeconds % 60;
|
|
83
|
+
const isExpired = remainingSeconds !== null && remainingSeconds <= 0;
|
|
84
|
+
const isUrgent = displaySeconds > 0 && displaySeconds < 120;
|
|
46
85
|
|
|
47
86
|
const displayMessage = reservation.countdownMessage
|
|
48
87
|
? reservation.countdownMessage.replace(
|
|
@@ -77,7 +116,10 @@ export function ReservationCountdown({ reservation, className }: ReservationCoun
|
|
|
77
116
|
|
|
78
117
|
<div className="flex-1">
|
|
79
118
|
{isExpired ? (
|
|
80
|
-
|
|
119
|
+
<>
|
|
120
|
+
<p className="font-medium">{t('expired')}</p>
|
|
121
|
+
<p className="text-xs opacity-90">{t('expiredHint')}</p>
|
|
122
|
+
</>
|
|
81
123
|
) : displayMessage ? (
|
|
82
124
|
<p>{displayMessage}</p>
|
|
83
125
|
) : (
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Newsletter signup — confirmed opt-in.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ THIS DOES NOT SUBSCRIBE ANYONE. `marketing.subscribe()` creates the contact
|
|
7
|
+
* and mails them a confirmation link; the address is unmailable, and invisible
|
|
8
|
+
* to every campaign audience, until the recipient clicks it. That is why the
|
|
9
|
+
* success state below says "check your email" and never "you're subscribed" —
|
|
10
|
+
* the second is untrue until a click lands in a mailbox this code cannot see,
|
|
11
|
+
* and most people never click.
|
|
12
|
+
*
|
|
13
|
+
* ⛔ THE RESPONSE CARRIES NO INFORMATION. `{ ok: true }` comes back identically
|
|
14
|
+
* for a brand-new address, one that confirmed months ago, one still inside its
|
|
15
|
+
* 24-hour resend cooldown, and one suppressed after a hard bounce. That is
|
|
16
|
+
* deliberate: a response that distinguished them would turn this public form
|
|
17
|
+
* into a way to test whether a given person shops here. There is nothing to
|
|
18
|
+
* branch on — render one success state and stop.
|
|
19
|
+
*
|
|
20
|
+
* Naming the spam folder is not padding. A filtered confirmation is the single
|
|
21
|
+
* commonest reason a signup never becomes a subscriber, and the resend cooldown
|
|
22
|
+
* means no second copy arrives for 24 hours.
|
|
23
|
+
*
|
|
24
|
+
* **Discount codes.** Subscribing mints nothing. For a "10% off your first
|
|
25
|
+
* order" offer, the merchant creates a coupon in the dashboard with the
|
|
26
|
+
* `customer_first_order` condition and you render that fixed code in the
|
|
27
|
+
* success state — see the commented line below. Show it immediately: it must
|
|
28
|
+
* not wait for the confirmation click, or the shopper loses the reason they
|
|
29
|
+
* filled the form in.
|
|
30
|
+
*
|
|
31
|
+
* Rate limited to 3 requests / 60s per IP.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { useMemo, useState } from 'react';
|
|
35
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
36
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
37
|
+
|
|
38
|
+
export function NewsletterSignup() {
|
|
39
|
+
const t = useTranslations('newsletter');
|
|
40
|
+
const [email, setEmail] = useState('');
|
|
41
|
+
const [honeypot, setHoneypot] = useState('');
|
|
42
|
+
const [loading, setLoading] = useState(false);
|
|
43
|
+
const [done, setDone] = useState(false);
|
|
44
|
+
const [error, setError] = useState<string | null>(null);
|
|
45
|
+
|
|
46
|
+
// Same source the contact form uses — <html lang> is set from the active
|
|
47
|
+
// locale, and passing it is what decides the language of the confirmation
|
|
48
|
+
// email. Omit it on a Hebrew storefront and the shopper gets English.
|
|
49
|
+
const locale = useMemo(() => {
|
|
50
|
+
if (typeof document !== 'undefined') return document.documentElement.lang || undefined;
|
|
51
|
+
return undefined;
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
if (loading || !email.trim()) return;
|
|
57
|
+
|
|
58
|
+
setLoading(true);
|
|
59
|
+
setError(null);
|
|
60
|
+
try {
|
|
61
|
+
await getClient().marketing.subscribe({
|
|
62
|
+
email: email.trim(),
|
|
63
|
+
locale,
|
|
64
|
+
source: 'footer',
|
|
65
|
+
honeypot,
|
|
66
|
+
});
|
|
67
|
+
setDone(true);
|
|
68
|
+
setEmail('');
|
|
69
|
+
} catch {
|
|
70
|
+
// A 429 lands here too — the copy stays generic rather than explaining
|
|
71
|
+
// the rate limit, which would only tell an abuser where the edge is.
|
|
72
|
+
setError(t('genericError'));
|
|
73
|
+
} finally {
|
|
74
|
+
setLoading(false);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (done) {
|
|
79
|
+
return (
|
|
80
|
+
<div className="space-y-1">
|
|
81
|
+
<p className="text-sm font-medium">{t('checkEmailTitle')}</p>
|
|
82
|
+
<p className="text-sm opacity-70">{t('checkEmailBody')}</p>
|
|
83
|
+
{/*
|
|
84
|
+
Merchant offering a signup discount? Render the coupon code here, now
|
|
85
|
+
— not after the confirmation click, which may never come:
|
|
86
|
+
<p className="text-sm font-medium">{t('discountCode', { code: 'WELCOME10' })}</p>
|
|
87
|
+
*/}
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<form onSubmit={handleSubmit} className="space-y-2">
|
|
94
|
+
<label htmlFor="newsletter-email" className="block text-sm font-medium">
|
|
95
|
+
{t('title')}
|
|
96
|
+
</label>
|
|
97
|
+
<p className="text-sm opacity-70">{t('subtitle')}</p>
|
|
98
|
+
|
|
99
|
+
<div className="flex flex-wrap gap-2">
|
|
100
|
+
<input
|
|
101
|
+
id="newsletter-email"
|
|
102
|
+
type="email"
|
|
103
|
+
required
|
|
104
|
+
autoComplete="email"
|
|
105
|
+
value={email}
|
|
106
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
107
|
+
placeholder={t('placeholder')}
|
|
108
|
+
className="border-border min-w-0 flex-1 rounded border px-3 py-2 text-sm"
|
|
109
|
+
/>
|
|
110
|
+
<button
|
|
111
|
+
type="submit"
|
|
112
|
+
disabled={loading}
|
|
113
|
+
className="bg-primary text-primary-foreground rounded px-4 py-2 text-sm font-medium transition-opacity hover:opacity-90 disabled:opacity-60"
|
|
114
|
+
>
|
|
115
|
+
{loading ? t('submitting') : t('submit')}
|
|
116
|
+
</button>
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
{/*
|
|
120
|
+
Honeypot. Bots complete every text input; a human never sees this one, so
|
|
121
|
+
a non-empty value rejects the request server-side. Positioned off-screen
|
|
122
|
+
rather than `display:none` — some bots skip hidden inputs — and kept out
|
|
123
|
+
of the tab order and the accessibility tree.
|
|
124
|
+
*/}
|
|
125
|
+
<input
|
|
126
|
+
type="text"
|
|
127
|
+
name="company_website"
|
|
128
|
+
tabIndex={-1}
|
|
129
|
+
autoComplete="off"
|
|
130
|
+
aria-hidden="true"
|
|
131
|
+
value={honeypot}
|
|
132
|
+
onChange={(e) => setHoneypot(e.target.value)}
|
|
133
|
+
style={{ position: 'absolute', left: '-9999px', width: 1, height: 1 }}
|
|
134
|
+
/>
|
|
135
|
+
|
|
136
|
+
{error ? (
|
|
137
|
+
<p role="alert" className="text-sm text-red-600">
|
|
138
|
+
{error}
|
|
139
|
+
</p>
|
|
140
|
+
) : null}
|
|
141
|
+
</form>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -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
|
+
}
|