create-brainerce-store 1.68.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.
Files changed (32) hide show
  1. package/dist/index.js +22 -2
  2. package/messages/en.json +52 -2
  3. package/messages/he.json +52 -2
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/src/app/checkout/page.tsx +1018 -1017
  6. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +1 -1
  7. package/templates/nextjs/base/src/app/register/page.tsx +67 -64
  8. package/templates/nextjs/base/src/components/account/profile-section.tsx +303 -226
  9. package/templates/nextjs/base/src/components/auth/register-form.tsx +326 -245
  10. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +306 -294
  11. package/templates/nextjs/base/src/components/checkout/date-picker.tsx +13 -1
  12. package/templates/nextjs/base/src/components/checkout/datetime-picker.tsx +61 -21
  13. package/templates/nextjs/base/src/components/shared/birthday-picker.tsx +258 -0
  14. package/templates/nextjs/base/src/core/lib/auth.ts +162 -154
  15. package/templates/nextjs/base/src/core/lib/birthday.ts +74 -0
  16. package/templates/nextjs/base/src/core/lib/store-info.ts +10 -0
  17. package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +143 -0
  18. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +18 -2
  19. package/templates/nextjs/base/src/ui/product/back-in-stock-form.tsx +173 -0
  20. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +484 -455
  21. package/templates/nextjs/base/src/ui/product/review-form.tsx +136 -12
  22. package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +139 -108
  23. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +155 -142
  24. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +500 -477
  25. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +135 -11
  26. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +179 -148
  27. package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +122 -0
  28. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +87 -83
  29. package/templates/nextjs/ui-canvas/product/back-in-stock-form.tsx +151 -0
  30. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +373 -352
  31. package/templates/nextjs/ui-canvas/product/review-form.tsx +129 -11
  32. package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +127 -96
@@ -1,154 +1,162 @@
1
- /**
2
- * Client-side auth helpers that call the BFF proxy API routes.
3
- * All mutating requests include the CSRF header.
4
- * The token is managed server-side via httpOnly cookies — never exposed to JS.
5
- */
6
-
7
- // Read either env var name. The new one is preferred; the old one is a soft
8
- // alias kept for backwards compatibility — both are accepted by the SDK.
9
- const CONNECTION_ID =
10
- process.env.NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID ||
11
- process.env.NEXT_PUBLIC_BRAINERCE_CONNECTION_ID ||
12
- '';
13
-
14
- const CSRF_HEADERS: Record<string, string> = {
15
- 'Content-Type': 'application/json',
16
- 'X-Requested-With': 'brainerce',
17
- };
18
-
19
- interface LoginResult {
20
- customer: {
21
- id: string;
22
- email: string;
23
- firstName?: string;
24
- lastName?: string;
25
- emailVerified: boolean;
26
- };
27
- expiresAt: string;
28
- requiresVerification?: boolean;
29
- }
30
-
31
- interface RegisterResult {
32
- customer: {
33
- id: string;
34
- email: string;
35
- firstName?: string;
36
- lastName?: string;
37
- emailVerified: boolean;
38
- };
39
- expiresAt: string;
40
- requiresVerification?: boolean;
41
- }
42
-
43
- interface AuthStatus {
44
- isLoggedIn: boolean;
45
- customer?: {
46
- id: string;
47
- email: string;
48
- firstName?: string;
49
- lastName?: string;
50
- phone?: string;
51
- emailVerified: boolean;
52
- };
53
- error?: string;
54
- }
55
-
56
- interface VerifyEmailResult {
57
- verified: boolean;
58
- message?: string;
59
- }
60
-
61
- async function handleResponse<T>(response: Response): Promise<T> {
62
- const data = await response.json();
63
- if (!response.ok) {
64
- throw new Error(data.message || data.error || `Request failed (${response.status})`);
65
- }
66
- return data as T;
67
- }
68
-
69
- /**
70
- * Login via BFF proxy. The proxy sets the httpOnly cookie on success.
71
- */
72
- export async function proxyLogin(email: string, password: string): Promise<LoginResult> {
73
- const response = await fetch(`/api/store/api/vc/${CONNECTION_ID}/customers/login`, {
74
- method: 'POST',
75
- headers: CSRF_HEADERS,
76
- body: JSON.stringify({ email, password }),
77
- });
78
- return handleResponse<LoginResult>(response);
79
- }
80
-
81
- /**
82
- * Register via BFF proxy. The proxy sets the httpOnly cookie on success.
83
- */
84
- export async function proxyRegister(data: {
85
- firstName: string;
86
- lastName: string;
87
- email: string;
88
- password: string;
89
- acceptsMarketing?: boolean;
90
- }): Promise<RegisterResult> {
91
- const response = await fetch(`/api/store/api/vc/${CONNECTION_ID}/customers/register`, {
92
- method: 'POST',
93
- headers: CSRF_HEADERS,
94
- body: JSON.stringify(data),
95
- });
96
- return handleResponse<RegisterResult>(response);
97
- }
98
-
99
- /**
100
- * Check auth status. Reads httpOnly cookie server-side and validates with backend.
101
- */
102
- export async function checkAuthStatus(): Promise<AuthStatus> {
103
- const response = await fetch('/api/auth/me');
104
- return response.json();
105
- }
106
-
107
- /**
108
- * Logout. Clears httpOnly auth cookies server-side.
109
- */
110
- export async function proxyLogout(): Promise<void> {
111
- await fetch('/api/auth/logout', {
112
- method: 'POST',
113
- headers: { 'X-Requested-With': 'brainerce' },
114
- });
115
- }
116
-
117
- /**
118
- * Verify email via BFF proxy. The auth token is in the httpOnly cookie (set during login/register).
119
- * The proxy adds the Authorization header automatically.
120
- */
121
- export async function proxyVerifyEmail(code: string): Promise<VerifyEmailResult> {
122
- const response = await fetch(`/api/store/api/vc/${CONNECTION_ID}/customers/verify-email`, {
123
- method: 'POST',
124
- headers: CSRF_HEADERS,
125
- body: JSON.stringify({ code }),
126
- });
127
- return handleResponse<VerifyEmailResult>(response);
128
- }
129
-
130
- /**
131
- * Resend verification email via BFF proxy.
132
- * Uses the auth token from the httpOnly cookie.
133
- */
134
- export async function proxyResendVerification(): Promise<{ message: string }> {
135
- const response = await fetch(`/api/store/api/vc/${CONNECTION_ID}/customers/resend-verification`, {
136
- method: 'POST',
137
- headers: CSRF_HEADERS,
138
- });
139
- return handleResponse<{ message: string }>(response);
140
- }
141
-
142
- /**
143
- * Reset password via BFF proxy.
144
- * The reset token is in an httpOnly cookie (set by /api/auth/reset-callback when the user
145
- * clicked the email link). The proxy reads it server-side — the token never reaches client JS.
146
- */
147
- export async function proxyResetPassword(newPassword: string): Promise<{ message: string }> {
148
- const response = await fetch('/api/auth/reset-password', {
149
- method: 'POST',
150
- headers: CSRF_HEADERS,
151
- body: JSON.stringify({ newPassword }),
152
- });
153
- return handleResponse<{ message: string }>(response);
154
- }
1
+ /**
2
+ * Client-side auth helpers that call the BFF proxy API routes.
3
+ * All mutating requests include the CSRF header.
4
+ * The token is managed server-side via httpOnly cookies — never exposed to JS.
5
+ */
6
+
7
+ // Read either env var name. The new one is preferred; the old one is a soft
8
+ // alias kept for backwards compatibility — both are accepted by the SDK.
9
+ const CONNECTION_ID =
10
+ process.env.NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID ||
11
+ process.env.NEXT_PUBLIC_BRAINERCE_CONNECTION_ID ||
12
+ '';
13
+
14
+ const CSRF_HEADERS: Record<string, string> = {
15
+ 'Content-Type': 'application/json',
16
+ 'X-Requested-With': 'brainerce',
17
+ };
18
+
19
+ interface LoginResult {
20
+ customer: {
21
+ id: string;
22
+ email: string;
23
+ firstName?: string;
24
+ lastName?: string;
25
+ emailVerified: boolean;
26
+ };
27
+ expiresAt: string;
28
+ requiresVerification?: boolean;
29
+ }
30
+
31
+ interface RegisterResult {
32
+ customer: {
33
+ id: string;
34
+ email: string;
35
+ firstName?: string;
36
+ lastName?: string;
37
+ emailVerified: boolean;
38
+ };
39
+ expiresAt: string;
40
+ requiresVerification?: boolean;
41
+ }
42
+
43
+ interface AuthStatus {
44
+ isLoggedIn: boolean;
45
+ customer?: {
46
+ id: string;
47
+ email: string;
48
+ firstName?: string;
49
+ lastName?: string;
50
+ phone?: string;
51
+ emailVerified: boolean;
52
+ };
53
+ error?: string;
54
+ }
55
+
56
+ interface VerifyEmailResult {
57
+ verified: boolean;
58
+ message?: string;
59
+ }
60
+
61
+ async function handleResponse<T>(response: Response): Promise<T> {
62
+ const data = await response.json();
63
+ if (!response.ok) {
64
+ throw new Error(data.message || data.error || `Request failed (${response.status})`);
65
+ }
66
+ return data as T;
67
+ }
68
+
69
+ /**
70
+ * Login via BFF proxy. The proxy sets the httpOnly cookie on success.
71
+ */
72
+ export async function proxyLogin(email: string, password: string): Promise<LoginResult> {
73
+ const response = await fetch(`/api/store/api/vc/${CONNECTION_ID}/customers/login`, {
74
+ method: 'POST',
75
+ headers: CSRF_HEADERS,
76
+ body: JSON.stringify({ email, password }),
77
+ });
78
+ return handleResponse<LoginResult>(response);
79
+ }
80
+
81
+ /**
82
+ * Register via BFF proxy. The proxy sets the httpOnly cookie on success.
83
+ */
84
+ export async function proxyRegister(data: {
85
+ firstName: string;
86
+ lastName: string;
87
+ email: string;
88
+ password: string;
89
+ acceptsMarketing?: boolean;
90
+ /**
91
+ * Birthday month (1-12) and day (1-31), never a year. Powers the loyalty
92
+ * birthday gift. Send both or neither: one without the other is rejected
93
+ * with HTTP 400, and so is a day the month does not have. Required only when
94
+ * `getStoreInfo().requireBirthday` is true for this sales channel.
95
+ */
96
+ birthMonth?: number;
97
+ birthDay?: number;
98
+ }): Promise<RegisterResult> {
99
+ const response = await fetch(`/api/store/api/vc/${CONNECTION_ID}/customers/register`, {
100
+ method: 'POST',
101
+ headers: CSRF_HEADERS,
102
+ body: JSON.stringify(data),
103
+ });
104
+ return handleResponse<RegisterResult>(response);
105
+ }
106
+
107
+ /**
108
+ * Check auth status. Reads httpOnly cookie server-side and validates with backend.
109
+ */
110
+ export async function checkAuthStatus(): Promise<AuthStatus> {
111
+ const response = await fetch('/api/auth/me');
112
+ return response.json();
113
+ }
114
+
115
+ /**
116
+ * Logout. Clears httpOnly auth cookies server-side.
117
+ */
118
+ export async function proxyLogout(): Promise<void> {
119
+ await fetch('/api/auth/logout', {
120
+ method: 'POST',
121
+ headers: { 'X-Requested-With': 'brainerce' },
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Verify email via BFF proxy. The auth token is in the httpOnly cookie (set during login/register).
127
+ * The proxy adds the Authorization header automatically.
128
+ */
129
+ export async function proxyVerifyEmail(code: string): Promise<VerifyEmailResult> {
130
+ const response = await fetch(`/api/store/api/vc/${CONNECTION_ID}/customers/verify-email`, {
131
+ method: 'POST',
132
+ headers: CSRF_HEADERS,
133
+ body: JSON.stringify({ code }),
134
+ });
135
+ return handleResponse<VerifyEmailResult>(response);
136
+ }
137
+
138
+ /**
139
+ * Resend verification email via BFF proxy.
140
+ * Uses the auth token from the httpOnly cookie.
141
+ */
142
+ export async function proxyResendVerification(): Promise<{ message: string }> {
143
+ const response = await fetch(`/api/store/api/vc/${CONNECTION_ID}/customers/resend-verification`, {
144
+ method: 'POST',
145
+ headers: CSRF_HEADERS,
146
+ });
147
+ return handleResponse<{ message: string }>(response);
148
+ }
149
+
150
+ /**
151
+ * Reset password via BFF proxy.
152
+ * The reset token is in an httpOnly cookie (set by /api/auth/reset-callback when the user
153
+ * clicked the email link). The proxy reads it server-side — the token never reaches client JS.
154
+ */
155
+ export async function proxyResetPassword(newPassword: string): Promise<{ message: string }> {
156
+ const response = await fetch('/api/auth/reset-password', {
157
+ method: 'POST',
158
+ headers: CSRF_HEADERS,
159
+ body: JSON.stringify({ newPassword }),
160
+ });
161
+ return handleResponse<{ message: string }>(response);
162
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Birthday helpers for the loyalty birthday gift.
3
+ *
4
+ * Brainerce stores a birthday as a MONTH and a DAY and never a year, so there
5
+ * is no age on file and nothing here needs a date library or a date picker.
6
+ * The platform mints a one-time coupon and emails it ahead of the day, which
7
+ * only works if the storefront actually collects the two values.
8
+ *
9
+ * Both `updateMyProfile()` and `registerCustomer()` reject a month sent without
10
+ * a day (and the reverse) with HTTP 400, and reject a day the month does not
11
+ * have, so every form that collects a birthday validates with these helpers
12
+ * before it submits.
13
+ */
14
+
15
+ /**
16
+ * Translation keys for the month names, index 0 = January. They live in the
17
+ * `common` namespace so the account form and the signup form share one list
18
+ * instead of each carrying its own twelve keys.
19
+ */
20
+ export const BIRTH_MONTH_KEYS = [
21
+ 'monthJanuary',
22
+ 'monthFebruary',
23
+ 'monthMarch',
24
+ 'monthApril',
25
+ 'monthMay',
26
+ 'monthJune',
27
+ 'monthJuly',
28
+ 'monthAugust',
29
+ 'monthSeptember',
30
+ 'monthOctober',
31
+ 'monthNovember',
32
+ 'monthDecember',
33
+ ] as const;
34
+
35
+ /**
36
+ * How many days each month offers.
37
+ *
38
+ * February gets 29, not 28: the 29th IS a valid birthday and the platform
39
+ * celebrates it on 28 February in years that do not have one. No year is ever
40
+ * stored, so there is no leap-year arithmetic to do here.
41
+ */
42
+ const DAYS_IN_BIRTH_MONTH = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
43
+
44
+ /** Highest valid day for a birthday month (1-12). */
45
+ export function daysInBirthMonth(month: number): number {
46
+ return DAYS_IN_BIRTH_MONTH[month - 1] ?? 31;
47
+ }
48
+
49
+ /**
50
+ * Day numbers to render in the day grid. With no month chosen yet the full
51
+ * 1-31 range is offered; picking a month narrows it right away.
52
+ */
53
+ export function birthDayOptions(month: number | null): number[] {
54
+ const count = month ? daysInBirthMonth(month) : 31;
55
+ return Array.from({ length: count }, (_, index) => index + 1);
56
+ }
57
+
58
+ /**
59
+ * Translation key for a stored month, or null when the value is outside 1-12.
60
+ * Guards the display path so a bad value renders nothing rather than a raw
61
+ * `common.` key path.
62
+ */
63
+ export function birthMonthKey(month: number): string | null {
64
+ return BIRTH_MONTH_KEYS[month - 1] ?? null;
65
+ }
66
+
67
+ /**
68
+ * Read a stored form value into the number the API wants, or null when no
69
+ * birthday is set.
70
+ */
71
+ export function toBirthdayNumber(value: string): number | null {
72
+ const parsed = Number(value);
73
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
74
+ }
@@ -21,6 +21,15 @@ export interface PublicStoreInfo {
21
21
  contactPhone?: string | null;
22
22
  socialLinks?: Record<string, string> | null;
23
23
  requireEmailVerification?: boolean;
24
+ /**
25
+ * Whether the merchant made the birthday mandatory at registration on this
26
+ * sales channel. Render the signup form's month and day fields as required
27
+ * when true and block the submit while either is empty, because the backend
28
+ * rejects a register call without both with HTTP 400. Absent means optional.
29
+ * Nothing else in the storefront enforces it, so treat it purely as a
30
+ * rendering hint.
31
+ */
32
+ requireBirthday?: boolean;
24
33
  upsell?: StoreInfo['upsell'];
25
34
  i18n?: StoreInfo['i18n'];
26
35
  /** Real flat-rate/free shipping zones — feeds Product JSON-LD `shippingDetails`. Public by design (merchants display shipping rates openly). */
@@ -59,6 +68,7 @@ export function pickPublicStoreInfo(raw: StoreInfo): PublicStoreInfo {
59
68
  contactPhone: raw.contactPhone ?? null,
60
69
  socialLinks: raw.socialLinks ?? null,
61
70
  requireEmailVerification: raw.requireEmailVerification,
71
+ requireBirthday: raw.requireBirthday,
62
72
  upsell: raw.upsell,
63
73
  i18n: raw.i18n,
64
74
  shipping: raw.shipping,
@@ -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
- © {year} {brandLabel}. All rights reserved.
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 ? (