create-brainerce-store 1.67.0 → 1.71.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) 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/.env.local.ejs +7 -0
  6. package/templates/nextjs/base/AGENTS.md.ejs +7 -0
  7. package/templates/nextjs/base/CLAUDE.md.ejs +7 -0
  8. package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +8 -2
  9. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +16 -7
  10. package/templates/nextjs/base/src/app/checkout/page.tsx +1018 -1017
  11. package/templates/nextjs/base/src/app/error.tsx.ejs +53 -0
  12. package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +8 -2
  13. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +17 -7
  14. package/templates/nextjs/base/src/app/register/page.tsx +67 -64
  15. package/templates/nextjs/base/src/components/account/profile-section.tsx +303 -226
  16. package/templates/nextjs/base/src/components/auth/register-form.tsx +326 -245
  17. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +306 -294
  18. package/templates/nextjs/base/src/components/checkout/date-picker.tsx +13 -1
  19. package/templates/nextjs/base/src/components/checkout/datetime-picker.tsx +61 -21
  20. package/templates/nextjs/base/src/components/shared/birthday-picker.tsx +258 -0
  21. package/templates/nextjs/base/src/core/lib/auth.ts +162 -154
  22. package/templates/nextjs/base/src/core/lib/birthday.ts +74 -0
  23. package/templates/nextjs/base/src/core/lib/site-url.ts +42 -9
  24. package/templates/nextjs/base/src/core/lib/store-info.ts +10 -0
  25. package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +143 -0
  26. package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +18 -2
  27. package/templates/nextjs/base/src/ui/product/back-in-stock-form.tsx +173 -0
  28. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +484 -455
  29. package/templates/nextjs/base/src/ui/product/review-form.tsx +136 -12
  30. package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +139 -108
  31. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +155 -142
  32. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +500 -477
  33. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +135 -11
  34. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +179 -148
  35. package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +122 -0
  36. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +87 -83
  37. package/templates/nextjs/ui-canvas/product/back-in-stock-form.tsx +151 -0
  38. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +373 -352
  39. package/templates/nextjs/ui-canvas/product/review-form.tsx +129 -11
  40. 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
+ }
@@ -22,16 +22,37 @@ import { headers } from 'next/headers';
22
22
  * and must not be forgeable by a request header.
23
23
  *
24
24
  * {@link getRequestOrigin} — the address a request actually ARRIVED on.
25
- * Request headers first, because this is what the backend's origin check
26
- * has to match; claiming the production domain from a preview deployment
27
- * would pass a check that ought to fail.
25
+ * Request headers first unless they name an internal host (localhost,
26
+ * 127.0.0.1, a bind address) and an origin is configured, because an
27
+ * internal host is how the platform's proxy addresses this server, not
28
+ * where the request came from. Claiming the production domain from a
29
+ * preview deployment on a real hostname would still pass a check that
30
+ * ought to fail, so public hostnames keep winning over the env.
28
31
  */
29
32
 
30
33
  const DEV_FALLBACK = 'http://localhost:3000';
31
34
 
32
- function isLoopback(host: string): boolean {
33
- const name = host.split(':')[0].toLowerCase();
34
- return name === 'localhost' || name === '127.0.0.1' || name === '[::1]' || name === '::1';
35
+ /**
36
+ * Hosts that can never be a storefront's public address: loopback names and
37
+ * the 0.0.0.0 bind address. Some hosting proxies (OpenAI Sites among them)
38
+ * forward requests to the app with `Host: localhost:<port>` and no
39
+ * `x-forwarded-host`, so seeing one of these in a request means "the proxy's
40
+ * internal leg", not "the shopper is on localhost".
41
+ */
42
+ function isInternalHost(host: string): boolean {
43
+ const name = host.startsWith('[')
44
+ ? host.slice(1, host.includes(']') ? host.indexOf(']') : host.length)
45
+ : host.split(':')[0];
46
+ const lower = name.toLowerCase();
47
+ return lower === 'localhost' || lower === '127.0.0.1' || lower === '::1' || lower === '0.0.0.0';
48
+ }
49
+
50
+ function isInternalOrigin(origin: string): boolean {
51
+ try {
52
+ return isInternalHost(new URL(origin).host);
53
+ } catch {
54
+ return false;
55
+ }
35
56
  }
36
57
 
37
58
  /**
@@ -44,7 +65,7 @@ function toOrigin(raw: string | undefined): string | null {
44
65
  if (!trimmed) return null;
45
66
  const hasProtocol = /^https?:\/\//i.test(trimmed);
46
67
  const bare = trimmed.replace(/^\/\//, '');
47
- const candidate = hasProtocol ? trimmed : `${isLoopback(bare) ? 'http' : 'https'}://${bare}`;
68
+ const candidate = hasProtocol ? trimmed : `${isInternalHost(bare) ? 'http' : 'https'}://${bare}`;
48
69
  try {
49
70
  return new URL(candidate).origin;
50
71
  } catch {
@@ -112,7 +133,7 @@ function fromForwardedHeaders(h: Headers): string | null {
112
133
  const host = forwardedHost || h.get('host')?.trim();
113
134
  if (!host) return null;
114
135
  const proto =
115
- h.get('x-forwarded-proto')?.split(',')[0]?.trim() || (isLoopback(host) ? 'http' : 'https');
136
+ h.get('x-forwarded-proto')?.split(',')[0]?.trim() || (isInternalHost(host) ? 'http' : 'https');
116
137
  return toOrigin(`${proto}://${host}`);
117
138
  }
118
139
 
@@ -187,11 +208,23 @@ export async function getRequestOrigin(requestHeaders?: Headers): Promise<string
187
208
  // dynamically rather than prerendered against a fallback origin.
188
209
  const h = requestHeaders ?? (await headers());
189
210
  const fromRequest = fromForwardedHeaders(h);
190
- if (fromRequest) return fromRequest;
191
211
 
212
+ // A public hostname from the request wins outright — it is what this
213
+ // request actually arrived on.
214
+ if (fromRequest && !isInternalOrigin(fromRequest)) return fromRequest;
215
+
216
+ // An internal host here does NOT mean the request arrived on localhost. It
217
+ // means the platform's proxy addresses this server by its bind address
218
+ // (`Host: localhost:3000`, no `x-forwarded-host`) — OpenAI Sites does
219
+ // exactly this — and the request's true origin is invisible to us. A
220
+ // configured origin is the only correct answer then. In plain local dev
221
+ // nothing is configured, so the loopback host (with its real port) still
222
+ // wins below.
192
223
  const configured = getSiteUrlFromEnv();
193
224
  if (configured) return configured;
194
225
 
226
+ if (fromRequest) return fromRequest;
227
+
195
228
  warnUnresolved();
196
229
  return DEV_FALLBACK;
197
230
  }
@@ -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
+ }