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
|
@@ -17,7 +17,20 @@ import { useTranslations } from '@/core/lib/translations';
|
|
|
17
17
|
export function CartView() {
|
|
18
18
|
const t = useTranslations('cart');
|
|
19
19
|
const tc = useTranslations('common');
|
|
20
|
-
const
|
|
20
|
+
const tr = useTranslations('reservation');
|
|
21
|
+
const {
|
|
22
|
+
cart,
|
|
23
|
+
cartLoading,
|
|
24
|
+
refreshCart,
|
|
25
|
+
itemCount,
|
|
26
|
+
cartRecs,
|
|
27
|
+
upgrades,
|
|
28
|
+
bundles,
|
|
29
|
+
reservationExpired,
|
|
30
|
+
unavailableItems,
|
|
31
|
+
canProceedToCheckout,
|
|
32
|
+
onReservationExpired,
|
|
33
|
+
} = useCartPage();
|
|
21
34
|
|
|
22
35
|
if (cartLoading) {
|
|
23
36
|
return <LoadingSpinner size="lg" />;
|
|
@@ -42,8 +55,11 @@ export function CartView() {
|
|
|
42
55
|
{t('title')} ({itemCount} {itemCount === 1 ? tc('item') : tc('items')})
|
|
43
56
|
</h1>
|
|
44
57
|
|
|
45
|
-
{/* Reservation countdown
|
|
46
|
-
|
|
58
|
+
{/* Reservation countdown. onExpire refreshes the cart and closes the
|
|
59
|
+
checkout gate below. Passing it is what makes expiry mean anything. */}
|
|
60
|
+
{cart.reservation?.hasReservation && (
|
|
61
|
+
<ReservationCountdown reservation={cart.reservation} onExpire={onReservationExpired} />
|
|
62
|
+
)}
|
|
47
63
|
|
|
48
64
|
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
|
49
65
|
{/* Cart Items */}
|
|
@@ -91,7 +107,25 @@ export function CartView() {
|
|
|
91
107
|
<FreeShippingBar />
|
|
92
108
|
<CartSummary />
|
|
93
109
|
|
|
94
|
-
|
|
110
|
+
{/* Proceed to checkout. When the gate is closed this must be a real
|
|
111
|
+
disabled control, never a styled-down link: an anchor ignores
|
|
112
|
+
`disabled` and would still navigate. */}
|
|
113
|
+
{canProceedToCheckout ? (
|
|
114
|
+
<Link href="/checkout">{t('proceedToCheckout')}</Link>
|
|
115
|
+
) : (
|
|
116
|
+
<>
|
|
117
|
+
<button type="button" disabled>
|
|
118
|
+
{t('proceedToCheckout')}
|
|
119
|
+
</button>
|
|
120
|
+
<p data-state="blocked">
|
|
121
|
+
{unavailableItems.length > 0
|
|
122
|
+
? t('unavailableItemsHint')
|
|
123
|
+
: reservationExpired
|
|
124
|
+
? tr('expiredHint')
|
|
125
|
+
: null}
|
|
126
|
+
</p>
|
|
127
|
+
</>
|
|
128
|
+
)}
|
|
95
129
|
|
|
96
130
|
<Link href="/products">{tc('continueShopping')}</Link>
|
|
97
131
|
</aside>
|
|
@@ -1,25 +1,42 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useEffect, useCallback } from 'react';
|
|
3
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
4
|
import type { ReservationInfo } from 'brainerce';
|
|
5
5
|
import { useTranslations } from '@/core/lib/translations';
|
|
6
6
|
import { cn } from '@/core/lib/utils';
|
|
7
7
|
|
|
8
8
|
interface ReservationCountdownProps {
|
|
9
9
|
reservation: ReservationInfo;
|
|
10
|
+
/**
|
|
11
|
+
* Fired once per reservation window when the timer reaches zero, including
|
|
12
|
+
* when the page opens on an already-expired reservation. Wired to
|
|
13
|
+
* `useCartPage().onReservationExpired`, which refreshes the cart and blocks
|
|
14
|
+
* checkout. Keep the prop when you redesign this: the gate lives in the
|
|
15
|
+
* hook, but nothing tells it about the expiry unless this still calls it.
|
|
16
|
+
*/
|
|
17
|
+
onExpire?: () => void;
|
|
10
18
|
className?: string;
|
|
11
19
|
}
|
|
12
20
|
|
|
13
21
|
/**
|
|
14
22
|
* DESIGN ME — stock-reservation countdown on the cart/checkout pages; the
|
|
15
23
|
* `reservation` prop comes from useCartPage().cart.reservation. Keep the
|
|
16
|
-
* ticking logic and the expired/urgent states (exposed
|
|
24
|
+
* ticking logic, the `onExpire` call and the expired/urgent states (exposed
|
|
25
|
+
* via data-state).
|
|
17
26
|
*
|
|
18
27
|
* Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
|
|
19
28
|
*/
|
|
20
|
-
export function ReservationCountdown({
|
|
29
|
+
export function ReservationCountdown({
|
|
30
|
+
reservation,
|
|
31
|
+
onExpire,
|
|
32
|
+
className,
|
|
33
|
+
}: ReservationCountdownProps) {
|
|
21
34
|
const t = useTranslations('reservation');
|
|
22
|
-
|
|
35
|
+
// `null` means "not measured yet". Server-rendered HTML must not compute a
|
|
36
|
+
// time, or it disagrees with the client on hydration; starting at 0 instead
|
|
37
|
+
// would render the expired state for one frame on a perfectly live
|
|
38
|
+
// reservation, and would fire onExpire on every mount.
|
|
39
|
+
const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null);
|
|
23
40
|
|
|
24
41
|
const calculateRemaining = useCallback(() => {
|
|
25
42
|
if (!reservation.expiresAt) return 0;
|
|
@@ -28,8 +45,29 @@ export function ReservationCountdown({ reservation, className }: ReservationCoun
|
|
|
28
45
|
return Math.max(0, Math.floor((expiresAtMs - nowMs) / 1000));
|
|
29
46
|
}, [reservation.expiresAt]);
|
|
30
47
|
|
|
48
|
+
// Report each expiry window at most once. Keyed on `expiresAt` rather than a
|
|
49
|
+
// boolean, so the cart refresh that expiry triggers (which remounts this
|
|
50
|
+
// component) cannot bounce straight back into a second report.
|
|
51
|
+
const expiresAt = reservation.expiresAt;
|
|
52
|
+
const reportedExpiryRef = useRef<string | null>(null);
|
|
53
|
+
const onExpireRef = useRef(onExpire);
|
|
54
|
+
// Kept in a ref, and synced in an effect rather than during render, so a
|
|
55
|
+
// caller passing an inline arrow does not restart the timer every render.
|
|
31
56
|
useEffect(() => {
|
|
32
|
-
|
|
57
|
+
onExpireRef.current = onExpire;
|
|
58
|
+
}, [onExpire]);
|
|
59
|
+
|
|
60
|
+
const reportExpired = useCallback(() => {
|
|
61
|
+
if (!expiresAt) return;
|
|
62
|
+
if (reportedExpiryRef.current === expiresAt) return;
|
|
63
|
+
reportedExpiryRef.current = expiresAt;
|
|
64
|
+
onExpireRef.current?.();
|
|
65
|
+
}, [expiresAt]);
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const initial = calculateRemaining();
|
|
69
|
+
setRemainingSeconds(initial);
|
|
70
|
+
if (initial <= 0) reportExpired();
|
|
33
71
|
|
|
34
72
|
const interval = setInterval(() => {
|
|
35
73
|
const remaining = calculateRemaining();
|
|
@@ -37,18 +75,20 @@ export function ReservationCountdown({ reservation, className }: ReservationCoun
|
|
|
37
75
|
|
|
38
76
|
if (remaining <= 0) {
|
|
39
77
|
clearInterval(interval);
|
|
78
|
+
reportExpired();
|
|
40
79
|
}
|
|
41
80
|
}, 1000);
|
|
42
81
|
|
|
43
82
|
return () => clearInterval(interval);
|
|
44
|
-
}, [calculateRemaining]);
|
|
83
|
+
}, [calculateRemaining, reportExpired]);
|
|
45
84
|
|
|
46
85
|
if (!reservation.hasReservation) return null;
|
|
47
86
|
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
const
|
|
87
|
+
const displaySeconds = remainingSeconds ?? 0;
|
|
88
|
+
const minutes = Math.floor(displaySeconds / 60);
|
|
89
|
+
const seconds = displaySeconds % 60;
|
|
90
|
+
const isExpired = remainingSeconds !== null && remainingSeconds <= 0;
|
|
91
|
+
const isUrgent = displaySeconds > 0 && displaySeconds < 120;
|
|
52
92
|
|
|
53
93
|
const displayMessage = reservation.countdownMessage
|
|
54
94
|
? reservation.countdownMessage.replace(
|
|
@@ -64,7 +104,10 @@ export function ReservationCountdown({ reservation, className }: ReservationCoun
|
|
|
64
104
|
className={cn(className)}
|
|
65
105
|
>
|
|
66
106
|
{isExpired ? (
|
|
67
|
-
|
|
107
|
+
<>
|
|
108
|
+
<p>{t('expired')}</p>
|
|
109
|
+
<p>{t('expiredHint')}</p>
|
|
110
|
+
</>
|
|
68
111
|
) : displayMessage ? (
|
|
69
112
|
<p>{displayMessage}</p>
|
|
70
113
|
) : (
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Newsletter signup (bare skeleton) — 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. Keep the success
|
|
9
|
+
* copy as "check your email" — never "you're subscribed", which is untrue until
|
|
10
|
+
* a click lands in a mailbox this code cannot see. Style this freely; do not
|
|
11
|
+
* change that sentence.
|
|
12
|
+
*
|
|
13
|
+
* ⛔ THE RESPONSE CARRIES NO INFORMATION. `{ ok: true }` comes back identically
|
|
14
|
+
* for a new address, one that already confirmed, one inside its 24-hour resend
|
|
15
|
+
* cooldown, and one suppressed after a hard bounce — otherwise this public form
|
|
16
|
+
* becomes a way to test who shops here. There is no branch to add.
|
|
17
|
+
*
|
|
18
|
+
* ⛔ KEEP THE HONEYPOT. It is the bot filter; deleting the input silently opens
|
|
19
|
+
* the form up.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { useMemo, useState } from 'react';
|
|
23
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
24
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
25
|
+
|
|
26
|
+
export function NewsletterSignup() {
|
|
27
|
+
const t = useTranslations('newsletter');
|
|
28
|
+
const [email, setEmail] = useState('');
|
|
29
|
+
const [honeypot, setHoneypot] = useState('');
|
|
30
|
+
const [loading, setLoading] = useState(false);
|
|
31
|
+
const [done, setDone] = useState(false);
|
|
32
|
+
const [error, setError] = useState<string | null>(null);
|
|
33
|
+
|
|
34
|
+
// <html lang> carries the active locale, and passing it decides the language
|
|
35
|
+
// of the confirmation email. Omit it and a Hebrew shopper gets English.
|
|
36
|
+
const locale = useMemo(() => {
|
|
37
|
+
if (typeof document !== 'undefined') return document.documentElement.lang || undefined;
|
|
38
|
+
return undefined;
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
42
|
+
e.preventDefault();
|
|
43
|
+
if (loading || !email.trim()) return;
|
|
44
|
+
|
|
45
|
+
setLoading(true);
|
|
46
|
+
setError(null);
|
|
47
|
+
try {
|
|
48
|
+
await getClient().marketing.subscribe({
|
|
49
|
+
email: email.trim(),
|
|
50
|
+
locale,
|
|
51
|
+
source: 'footer',
|
|
52
|
+
honeypot,
|
|
53
|
+
});
|
|
54
|
+
setDone(true);
|
|
55
|
+
setEmail('');
|
|
56
|
+
} catch {
|
|
57
|
+
// A 429 lands here too — the copy stays generic rather than naming the
|
|
58
|
+
// rate limit, which would only tell an abuser where the edge is.
|
|
59
|
+
setError(t('genericError'));
|
|
60
|
+
} finally {
|
|
61
|
+
setLoading(false);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (done) {
|
|
66
|
+
return (
|
|
67
|
+
<div>
|
|
68
|
+
{/* DESIGN ME — newsletter success state. Do NOT reword to "you're subscribed": the address is not on the list until the recipient clicks the emailed link. Naming the spam folder is deliberate — a filtered confirmation is the commonest reason a signup never converts, and no second copy is sent for 24 hours. */}
|
|
69
|
+
<p>{t('checkEmailTitle')}</p>
|
|
70
|
+
<p>{t('checkEmailBody')}</p>
|
|
71
|
+
{/*
|
|
72
|
+
Merchant offering a signup discount? Render the coupon code here, now
|
|
73
|
+
— not after the confirmation click, which may never come:
|
|
74
|
+
<p>{t('discountCode', { code: 'WELCOME10' })}</p>
|
|
75
|
+
The code comes from a dashboard coupon with the `customer_first_order`
|
|
76
|
+
condition; subscribing mints nothing on its own.
|
|
77
|
+
*/}
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<form onSubmit={handleSubmit}>
|
|
84
|
+
{/* DESIGN ME — newsletter signup: label, description, email input + submit button. Compose with the shadcn/ui primitives in src/components/ui (Button, Input, ...) + lucide-react icons. */}
|
|
85
|
+
<label htmlFor="newsletter-email">{t('title')}</label>
|
|
86
|
+
<p>{t('subtitle')}</p>
|
|
87
|
+
|
|
88
|
+
<input
|
|
89
|
+
id="newsletter-email"
|
|
90
|
+
type="email"
|
|
91
|
+
required
|
|
92
|
+
autoComplete="email"
|
|
93
|
+
value={email}
|
|
94
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
95
|
+
placeholder={t('placeholder')}
|
|
96
|
+
/>
|
|
97
|
+
<button type="submit" disabled={loading}>
|
|
98
|
+
{loading ? t('submitting') : t('submit')}
|
|
99
|
+
</button>
|
|
100
|
+
|
|
101
|
+
{/*
|
|
102
|
+
Honeypot — keep it. Bots complete every text input; a human never sees
|
|
103
|
+
this one, so a non-empty value rejects the request server-side. Off-screen
|
|
104
|
+
rather than `display:none` (some bots skip hidden inputs), out of the tab
|
|
105
|
+
order and out of the accessibility tree. Style it however you like as long
|
|
106
|
+
as it stays invisible and focusable-by-nobody.
|
|
107
|
+
*/}
|
|
108
|
+
<input
|
|
109
|
+
type="text"
|
|
110
|
+
name="company_website"
|
|
111
|
+
tabIndex={-1}
|
|
112
|
+
autoComplete="off"
|
|
113
|
+
aria-hidden="true"
|
|
114
|
+
value={honeypot}
|
|
115
|
+
onChange={(e) => setHoneypot(e.target.value)}
|
|
116
|
+
style={{ position: 'absolute', left: '-9999px', width: 1, height: 1 }}
|
|
117
|
+
/>
|
|
118
|
+
|
|
119
|
+
{error ? <p role="alert">{error}</p> : null}
|
|
120
|
+
</form>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -1,83 +1,87 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Storefront footer (bare skeleton) — fully driven by the merchant's
|
|
3
|
-
* dashboard configuration.
|
|
4
|
-
*
|
|
5
|
-
* The merchant configures the footer in the Brainerce dashboard under
|
|
6
|
-
* Sell → Content → Footer
|
|
7
|
-
*
|
|
8
|
-
* Renders a brand block (store-name + social links), the merchant's link
|
|
9
|
-
* columns, and a copyright bar.
|
|
10
|
-
*
|
|
11
|
-
* API contract:
|
|
12
|
-
* GET → client.content.footer.get('main', locale) → Content<'FOOTER'> | null
|
|
13
|
-
* Returns null on 404 — we render a minimal fallback so the layout never
|
|
14
|
-
* crashes when the merchant hasn't seeded the footer yet.
|
|
15
|
-
*/
|
|
16
|
-
import * as React from 'react';
|
|
17
|
-
import { Link } from '@/core/lib/navigation';
|
|
18
|
-
import
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
footer
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Storefront footer (bare skeleton) — fully driven by the merchant's
|
|
3
|
+
* dashboard configuration.
|
|
4
|
+
*
|
|
5
|
+
* The merchant configures the footer in the Brainerce dashboard under
|
|
6
|
+
* Sell → Content → Footer
|
|
7
|
+
*
|
|
8
|
+
* Renders a brand block (store-name + social links), the merchant's link
|
|
9
|
+
* columns, and a copyright bar.
|
|
10
|
+
*
|
|
11
|
+
* API contract:
|
|
12
|
+
* GET → client.content.footer.get('main', locale) → Content<'FOOTER'> | null
|
|
13
|
+
* Returns null on 404 — we render a minimal fallback so the layout never
|
|
14
|
+
* crashes when the merchant hasn't seeded the footer yet.
|
|
15
|
+
*/
|
|
16
|
+
import * as React from 'react';
|
|
17
|
+
import { Link } from '@/core/lib/navigation';
|
|
18
|
+
import { NewsletterSignup } from './newsletter-signup';
|
|
19
|
+
import type { Content } from 'brainerce';
|
|
20
|
+
|
|
21
|
+
interface SiteFooterProps {
|
|
22
|
+
/** Pre-fetched footer payload (server-side). `null` triggers fallback rendering. */
|
|
23
|
+
footer: Content<'FOOTER'> | null;
|
|
24
|
+
/** Store name shown in the brand block + fallback copyright. */
|
|
25
|
+
storeName?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function SiteFooter({ footer, storeName }: SiteFooterProps) {
|
|
29
|
+
const data = footer?.data;
|
|
30
|
+
const columns = data?.columns ?? [];
|
|
31
|
+
const social = data?.social ?? [];
|
|
32
|
+
const year = new Date().getFullYear();
|
|
33
|
+
const brandLabel = storeName ?? 'Store';
|
|
34
|
+
|
|
35
|
+
if (!data || (columns.length === 0 && !data.copyright && social.length === 0)) {
|
|
36
|
+
return (
|
|
37
|
+
<footer>
|
|
38
|
+
{/* DESIGN ME — footer (fallback branch): copyright only; Content<'FOOTER'> pre-fetched in the layout. Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
|
|
39
|
+
<p>
|
|
40
|
+
© {year} {brandLabel}. All rights reserved.
|
|
41
|
+
</p>
|
|
42
|
+
<NewsletterSignup />
|
|
43
|
+
</footer>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<footer>
|
|
49
|
+
{/* DESIGN ME — footer: brand block, merchant link columns and social links (Content<'FOOTER'> pre-fetched in the layout), copyright bar. Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
|
|
50
|
+
<div>
|
|
51
|
+
<Link href="/">{brandLabel}</Link>
|
|
52
|
+
{social.length > 0 ? (
|
|
53
|
+
<ul className="flex flex-wrap items-center gap-2">
|
|
54
|
+
{social.map((entry, idx) => (
|
|
55
|
+
<li key={`${entry.platform}-${idx}`}>
|
|
56
|
+
<a href={entry.url} target="_blank" rel="noopener noreferrer" aria-label={entry.platform}>
|
|
57
|
+
{entry.platform}
|
|
58
|
+
</a>
|
|
59
|
+
</li>
|
|
60
|
+
))}
|
|
61
|
+
</ul>
|
|
62
|
+
) : null}
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
{columns.length > 0 ? (
|
|
66
|
+
<div className="flex flex-wrap gap-8">
|
|
67
|
+
{columns.map((col, idx) => (
|
|
68
|
+
<nav key={`${col.title}-${idx}`} aria-label={col.title}>
|
|
69
|
+
<h3>{col.title}</h3>
|
|
70
|
+
<ul>
|
|
71
|
+
{col.links.map((link, linkIdx) => (
|
|
72
|
+
<li key={`${link.url}-${linkIdx}`}>
|
|
73
|
+
<a href={link.url}>{link.label}</a>
|
|
74
|
+
</li>
|
|
75
|
+
))}
|
|
76
|
+
</ul>
|
|
77
|
+
</nav>
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
) : null}
|
|
81
|
+
|
|
82
|
+
<NewsletterSignup />
|
|
83
|
+
|
|
84
|
+
<p>{data.copyright ?? `© ${year} ${brandLabel}. All rights reserved.`}</p>
|
|
85
|
+
</footer>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Back-in-stock alert (bare skeleton) — "email me when this is back".
|
|
5
|
+
*
|
|
6
|
+
* ⛔ THIS IS NOT A NEWSLETTER SIGNUP. `stockAlerts.subscribe()` grants no
|
|
7
|
+
* marketing consent and creates no account; the person gets exactly one message,
|
|
8
|
+
* about this item, with a link that stops it. Label it "Notify me", never
|
|
9
|
+
* "Subscribe", and never hide it from someone who unsubscribed from marketing —
|
|
10
|
+
* there is no consent here to gate on. Style this freely; do not change that.
|
|
11
|
+
*
|
|
12
|
+
* ⛔ RENDER IT ONLY WHERE `canOfferStockAlert()` SAYS SO. Requests for anything
|
|
13
|
+
* else are silently discarded server-side, so a button in the wrong place looks
|
|
14
|
+
* like it worked and does nothing.
|
|
15
|
+
*
|
|
16
|
+
* ⛔ THE RESPONSE CARRIES NO INFORMATION. `{ ok: true }` comes back identically
|
|
17
|
+
* for a new request, a duplicate, an unknown product and an in-stock item —
|
|
18
|
+
* otherwise this public form becomes a way to read the store's stock levels.
|
|
19
|
+
* There is no branch to add.
|
|
20
|
+
*
|
|
21
|
+
* ⛔ DO NOT PROMISE TIMING. Stock has to hold for a few minutes, then alerts go
|
|
22
|
+
* out in waves sized to the units that returned, oldest request first. A shopper
|
|
23
|
+
* can sit through a restock without hearing. "We'll email you when it's back" is
|
|
24
|
+
* true; "you'll be the first to know" is not.
|
|
25
|
+
*
|
|
26
|
+
* ⛔ KEEP THE HONEYPOT. It is the bot filter; deleting the input silently opens
|
|
27
|
+
* the form up.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { useMemo, useState } from 'react';
|
|
31
|
+
import type { InventoryInfo } from 'brainerce';
|
|
32
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
33
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The gate. Four ways the answer is no: the merchant switched the feature off
|
|
37
|
+
* for this storefront, the item is in stock, stock is not TRACKED (UNLIMITED
|
|
38
|
+
* never runs out, DISABLED is not for sale), or backorders are allowed — in
|
|
39
|
+
* which case the storefront can already sell it and an alert would tell someone
|
|
40
|
+
* to come back and do what they can already do.
|
|
41
|
+
*/
|
|
42
|
+
export function canOfferStockAlert(
|
|
43
|
+
inventory: InventoryInfo | null | undefined,
|
|
44
|
+
stockAlertsEnabled: boolean | undefined
|
|
45
|
+
): boolean {
|
|
46
|
+
if (stockAlertsEnabled === false) return false;
|
|
47
|
+
if (!inventory) return false;
|
|
48
|
+
if (inventory.trackingMode !== 'TRACKED') return false;
|
|
49
|
+
if (inventory.canPurchase) return false;
|
|
50
|
+
// Older backends omit the field entirely; absent means no backorder.
|
|
51
|
+
return (inventory.backorderMode ?? 'NONE') === 'NONE';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface BackInStockFormProps {
|
|
55
|
+
productId: string;
|
|
56
|
+
/**
|
|
57
|
+
* ⛔ Pass the SELECTED variant on any product with variants. Without it the
|
|
58
|
+
* alert waits on the product as a whole, so a shopper who wanted the medium is
|
|
59
|
+
* mailed when the small returns.
|
|
60
|
+
*/
|
|
61
|
+
variantId?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function BackInStockForm({ productId, variantId }: BackInStockFormProps) {
|
|
65
|
+
const t = useTranslations('backInStock');
|
|
66
|
+
const [email, setEmail] = useState('');
|
|
67
|
+
const [honeypot, setHoneypot] = useState('');
|
|
68
|
+
const [loading, setLoading] = useState(false);
|
|
69
|
+
const [done, setDone] = useState(false);
|
|
70
|
+
const [error, setError] = useState<string | null>(null);
|
|
71
|
+
|
|
72
|
+
// <html lang> carries the active locale, and passing it decides the language
|
|
73
|
+
// of the alert email. Omit it and a Hebrew shopper gets English.
|
|
74
|
+
const locale = useMemo(() => {
|
|
75
|
+
if (typeof document !== 'undefined') return document.documentElement.lang || undefined;
|
|
76
|
+
return undefined;
|
|
77
|
+
}, []);
|
|
78
|
+
|
|
79
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
if (loading || !email.trim()) return;
|
|
82
|
+
|
|
83
|
+
setLoading(true);
|
|
84
|
+
setError(null);
|
|
85
|
+
try {
|
|
86
|
+
await getClient().stockAlerts.subscribe({
|
|
87
|
+
email: email.trim(),
|
|
88
|
+
productId,
|
|
89
|
+
variantId,
|
|
90
|
+
locale,
|
|
91
|
+
honeypot,
|
|
92
|
+
});
|
|
93
|
+
setDone(true);
|
|
94
|
+
setEmail('');
|
|
95
|
+
} catch {
|
|
96
|
+
// A 429 lands here too — keep the copy generic rather than naming the
|
|
97
|
+
// rate limit, which would only tell an abuser where the edge is.
|
|
98
|
+
setError(t('genericError'));
|
|
99
|
+
} finally {
|
|
100
|
+
setLoading(false);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (done) {
|
|
105
|
+
return (
|
|
106
|
+
<div>
|
|
107
|
+
<p>{t('doneTitle')}</p>
|
|
108
|
+
<p>{t('doneBody')}</p>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<form onSubmit={handleSubmit}>
|
|
115
|
+
<label htmlFor="back-in-stock-email">{t('title')}</label>
|
|
116
|
+
<p>{t('subtitle')}</p>
|
|
117
|
+
|
|
118
|
+
<input
|
|
119
|
+
id="back-in-stock-email"
|
|
120
|
+
type="email"
|
|
121
|
+
required
|
|
122
|
+
autoComplete="email"
|
|
123
|
+
value={email}
|
|
124
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
125
|
+
placeholder={t('placeholder')}
|
|
126
|
+
/>
|
|
127
|
+
<button type="submit" disabled={loading}>
|
|
128
|
+
{loading ? t('submitting') : t('submit')}
|
|
129
|
+
</button>
|
|
130
|
+
|
|
131
|
+
{/*
|
|
132
|
+
Honeypot. Bots complete every text input; a human never sees this one, so
|
|
133
|
+
a non-empty value rejects the request server-side. Off-screen rather than
|
|
134
|
+
`display:none` — some bots skip hidden inputs — and out of the tab order
|
|
135
|
+
and the accessibility tree.
|
|
136
|
+
*/}
|
|
137
|
+
<input
|
|
138
|
+
type="text"
|
|
139
|
+
name="company_website"
|
|
140
|
+
tabIndex={-1}
|
|
141
|
+
autoComplete="off"
|
|
142
|
+
aria-hidden="true"
|
|
143
|
+
value={honeypot}
|
|
144
|
+
onChange={(e) => setHoneypot(e.target.value)}
|
|
145
|
+
style={{ position: 'absolute', left: '-9999px', width: 1, height: 1 }}
|
|
146
|
+
/>
|
|
147
|
+
|
|
148
|
+
{error ? <p role="alert">{error}</p> : null}
|
|
149
|
+
</form>
|
|
150
|
+
);
|
|
151
|
+
}
|