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.
- 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/.env.local.ejs +7 -0
- package/templates/nextjs/base/AGENTS.md.ejs +7 -0
- package/templates/nextjs/base/CLAUDE.md.ejs +7 -0
- package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +8 -2
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +16 -7
- package/templates/nextjs/base/src/app/checkout/page.tsx +1018 -1017
- package/templates/nextjs/base/src/app/error.tsx.ejs +53 -0
- package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +8 -2
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +17 -7
- 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/site-url.ts +42 -9
- 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
|
+
}
|
|
@@ -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
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 : `${
|
|
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() || (
|
|
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
|
+
}
|