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.
- package/dist/index.js +22 -2
- package/messages/en.json +52 -2
- package/messages/he.json +52 -2
- package/package.json +1 -1
- package/templates/nextjs/base/src/app/checkout/page.tsx +1018 -1017
- 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/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/lib/auth.ts +162 -154
- package/templates/nextjs/base/src/core/lib/birthday.ts +74 -0
- package/templates/nextjs/base/src/core/lib/store-info.ts +10 -0
- 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/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/layout/newsletter-signup.tsx +122 -0
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +87 -83
- package/templates/nextjs/ui-canvas/product/back-in-stock-form.tsx +151 -0
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +373 -352
- package/templates/nextjs/ui-canvas/product/review-form.tsx +129 -11
- package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +127 -96
|
@@ -1,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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
return response
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
*
|
|
109
|
-
*/
|
|
110
|
-
export async function
|
|
111
|
-
await fetch('/api/auth/
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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 ? (
|