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.
Files changed (50) hide show
  1. package/README.md +31 -10
  2. package/dist/index.js +197 -105
  3. package/messages/en.json +63 -3
  4. package/messages/he.json +63 -3
  5. package/package.json +1 -1
  6. package/templates/nextjs/base/TRANSLATIONS.md +14 -7
  7. package/templates/nextjs/base/src/app/checkout/page.tsx +1074 -1017
  8. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +21 -2
  9. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +1 -1
  10. package/templates/nextjs/base/src/app/register/page.tsx +67 -64
  11. package/templates/nextjs/base/src/components/account/order-history.tsx +25 -39
  12. package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +30 -11
  13. package/templates/nextjs/base/src/components/account/profile-section.tsx +303 -226
  14. package/templates/nextjs/base/src/components/auth/register-form.tsx +326 -245
  15. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +306 -294
  16. package/templates/nextjs/base/src/components/checkout/date-picker.tsx +13 -1
  17. package/templates/nextjs/base/src/components/checkout/datetime-picker.tsx +61 -21
  18. package/templates/nextjs/base/src/components/shared/birthday-picker.tsx +258 -0
  19. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +71 -2
  20. package/templates/nextjs/base/src/core/lib/auth.ts +155 -154
  21. package/templates/nextjs/base/src/core/lib/birthday.ts +74 -0
  22. package/templates/nextjs/base/src/core/lib/brainerce.ts.ejs +5 -16
  23. package/templates/nextjs/base/src/core/lib/store-info.ts +10 -0
  24. package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +3 -6
  25. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +19 -1
  26. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +42 -6
  27. package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +52 -10
  28. package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +143 -0
  29. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +18 -2
  30. package/templates/nextjs/base/src/ui/product/back-in-stock-form.tsx +173 -0
  31. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +484 -455
  32. package/templates/nextjs/base/src/ui/product/review-form.tsx +136 -12
  33. package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +139 -108
  34. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +21 -3
  35. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +19 -1
  36. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +44 -7
  37. package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +52 -10
  38. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +155 -142
  39. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +500 -477
  40. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +135 -11
  41. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +179 -148
  42. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +15 -1
  43. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +38 -4
  44. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +54 -11
  45. package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +122 -0
  46. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +87 -83
  47. package/templates/nextjs/ui-canvas/product/back-in-stock-form.tsx +151 -0
  48. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +373 -352
  49. package/templates/nextjs/ui-canvas/product/review-form.tsx +129 -11
  50. 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 { cart, cartLoading, refreshCart, itemCount, cartRecs, upgrades, bundles } = useCartPage();
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
- {cart.reservation?.hasReservation && <ReservationCountdown reservation={cart.reservation} />}
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
- <Link href="/checkout">{t('proceedToCheckout')}</Link>
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 via data-state).
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({ reservation, className }: ReservationCountdownProps) {
29
+ export function ReservationCountdown({
30
+ reservation,
31
+ onExpire,
32
+ className,
33
+ }: ReservationCountdownProps) {
21
34
  const t = useTranslations('reservation');
22
- const [remainingSeconds, setRemainingSeconds] = useState<number>(0);
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
- setRemainingSeconds(calculateRemaining());
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 minutes = Math.floor(remainingSeconds / 60);
49
- const seconds = remainingSeconds % 60;
50
- const isExpired = remainingSeconds <= 0;
51
- const isUrgent = remainingSeconds > 0 && remainingSeconds < 120;
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
- <p>{t('expired')}</p>
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 type { Content } from 'brainerce';
19
-
20
- interface SiteFooterProps {
21
- /** Pre-fetched footer payload (server-side). `null` triggers fallback rendering. */
22
- footer: Content<'FOOTER'> | null;
23
- /** Store name shown in the brand block + fallback copyright. */
24
- storeName?: string;
25
- }
26
-
27
- export function SiteFooter({ footer, storeName }: SiteFooterProps) {
28
- const data = footer?.data;
29
- const columns = data?.columns ?? [];
30
- const social = data?.social ?? [];
31
- const year = new Date().getFullYear();
32
- const brandLabel = storeName ?? 'Store';
33
-
34
- if (!data || (columns.length === 0 && !data.copyright && social.length === 0)) {
35
- return (
36
- <footer>
37
- {/* 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. */}
38
- <p>
39
- © {year} {brandLabel}. All rights reserved.
40
- </p>
41
- </footer>
42
- );
43
- }
44
-
45
- return (
46
- <footer>
47
- {/* 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. */}
48
- <div>
49
- <Link href="/">{brandLabel}</Link>
50
- {social.length > 0 ? (
51
- <ul className="flex flex-wrap items-center gap-2">
52
- {social.map((entry, idx) => (
53
- <li key={`${entry.platform}-${idx}`}>
54
- <a href={entry.url} target="_blank" rel="noopener noreferrer" aria-label={entry.platform}>
55
- {entry.platform}
56
- </a>
57
- </li>
58
- ))}
59
- </ul>
60
- ) : null}
61
- </div>
62
-
63
- {columns.length > 0 ? (
64
- <div className="flex flex-wrap gap-8">
65
- {columns.map((col, idx) => (
66
- <nav key={`${col.title}-${idx}`} aria-label={col.title}>
67
- <h3>{col.title}</h3>
68
- <ul>
69
- {col.links.map((link, linkIdx) => (
70
- <li key={`${link.url}-${linkIdx}`}>
71
- <a href={link.url}>{link.label}</a>
72
- </li>
73
- ))}
74
- </ul>
75
- </nav>
76
- ))}
77
- </div>
78
- ) : null}
79
-
80
- <p>{data.copyright ?? `© ${year} ${brandLabel}. All rights reserved.`}</p>
81
- </footer>
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
+ }