keystone-design-bootstrap 1.0.103 → 1.0.105-dev.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 (68) hide show
  1. package/README.md +4 -4
  2. package/dist/blog-post-vWzW8yFb.d.ts +50 -0
  3. package/dist/contexts/index.d.ts +13 -0
  4. package/dist/contexts/index.js +173 -0
  5. package/dist/contexts/index.js.map +1 -0
  6. package/dist/design_system/components/DynamicFormFields.d.ts +15 -0
  7. package/dist/design_system/components/DynamicFormFields.js +5176 -0
  8. package/dist/design_system/components/DynamicFormFields.js.map +1 -0
  9. package/dist/design_system/elements/index.d.ts +383 -0
  10. package/dist/design_system/elements/index.js +4007 -0
  11. package/dist/design_system/elements/index.js.map +1 -0
  12. package/dist/design_system/logo/keystone-logo.d.ts +6 -0
  13. package/dist/design_system/logo/keystone-logo.js +145 -0
  14. package/dist/design_system/logo/keystone-logo.js.map +1 -0
  15. package/dist/design_system/sections/index.d.ts +233 -0
  16. package/dist/design_system/sections/index.js +20048 -0
  17. package/dist/design_system/sections/index.js.map +1 -0
  18. package/dist/form-C94A_PX_.d.ts +36 -0
  19. package/dist/index.d.ts +86 -0
  20. package/dist/index.js +20646 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/lib/component-registry.d.ts +13 -0
  23. package/dist/lib/component-registry.js +36 -0
  24. package/dist/lib/component-registry.js.map +1 -0
  25. package/dist/lib/hooks/index.d.ts +83 -0
  26. package/dist/lib/hooks/index.js +182 -0
  27. package/dist/lib/hooks/index.js.map +1 -0
  28. package/dist/lib/server-api.d.ts +67 -0
  29. package/dist/lib/server-api.js +195 -0
  30. package/dist/lib/server-api.js.map +1 -0
  31. package/dist/package-DeHKpQp7.d.ts +121 -0
  32. package/dist/photos-CmBdWiuZ.d.ts +27 -0
  33. package/dist/themes/index.d.ts +17 -0
  34. package/dist/themes/index.js +29 -0
  35. package/dist/themes/index.js.map +1 -0
  36. package/dist/tracking/index.d.ts +254 -0
  37. package/dist/tracking/index.js +587 -0
  38. package/dist/tracking/index.js.map +1 -0
  39. package/dist/types/index.d.ts +313 -0
  40. package/dist/types/index.js +1 -0
  41. package/dist/types/index.js.map +1 -0
  42. package/dist/utils/cx.d.ts +15 -0
  43. package/dist/utils/cx.js +18 -0
  44. package/dist/utils/cx.js.map +1 -0
  45. package/dist/utils/gradient-placeholder.d.ts +7 -0
  46. package/dist/utils/gradient-placeholder.js +59 -0
  47. package/dist/utils/gradient-placeholder.js.map +1 -0
  48. package/dist/utils/is-react-component.d.ts +21 -0
  49. package/dist/utils/is-react-component.js +20 -0
  50. package/dist/utils/is-react-component.js.map +1 -0
  51. package/dist/utils/markdown-toc.d.ts +14 -0
  52. package/dist/utils/markdown-toc.js +29 -0
  53. package/dist/utils/markdown-toc.js.map +1 -0
  54. package/dist/utils/phone-helpers.d.ts +24 -0
  55. package/dist/utils/phone-helpers.js +26 -0
  56. package/dist/utils/phone-helpers.js.map +1 -0
  57. package/dist/utils/photo-helpers.d.ts +38 -0
  58. package/dist/utils/photo-helpers.js +41 -0
  59. package/dist/utils/photo-helpers.js.map +1 -0
  60. package/dist/website-photos-Cl1YqAno.d.ts +21 -0
  61. package/package.json +1 -1
  62. package/src/design_system/components/ChatWidget.tsx +66 -10
  63. package/src/design_system/portal/LoginForm.tsx +1 -24
  64. package/src/lib/chat-backend.ts +36 -0
  65. package/src/lib/consumer-session.ts +3 -1
  66. package/src/next/layouts/root-layout.tsx +5 -0
  67. package/src/next/routes/chat.ts +215 -18
  68. package/src/next/routes/consumer-auth.ts +49 -124
@@ -5,13 +5,27 @@
5
5
  * // app/api/chat/route.ts
6
6
  * export { GET, POST } from 'keystone-design-bootstrap/next/routes/chat';
7
7
  *
8
- * Supports both anonymous (session-based) and authenticated (contact-driven) flows.
9
- * Both flows hit the same backend endpoint (`/public/messages`); the discriminator
10
- * is whether `identifier` (session string) or `contact_id` (integer) is supplied.
8
+ * Supports TWO backends, selected per site (default: 'rails', so existing
9
+ * consumers are unaffected):
10
+ *
11
+ * createChatRouteHandlers({ backend: 'sor' }) // explicit option
12
+ * CHAT_BACKEND=sor // env override
13
+ * (otherwise derived from API_URL — a `sor` hostname label ⇒ 'sor';
14
+ * see lib/chat-backend.ts)
15
+ *
16
+ * - 'rails' (keystone-mono-proto): anonymous (`identifier`) and authenticated
17
+ * (`contact_id`) flows against `/public/messages`, plus the
18
+ * `action=realtime_token` + `cable_url` flow that feeds ActionCable.
19
+ * - 'sor' (sor-service): anonymous webchat against `/public/chat/*` —
20
+ * `session_id` (first-party cookie) + `{ messages, cursor }`, translated to
21
+ * the widget's existing wire shape. No realtime; the widget's polling
22
+ * fallback covers reply delivery. The `realtime_token` action returns an
23
+ * empty payload (no `cable_url`) so the widget knows realtime is unavailable.
11
24
  *
12
25
  * Env (server-side only):
13
- * - API_URL (default: http://localhost:3000/api/v1)
14
- * - API_KEY
26
+ * - API_URL (default: http://localhost:3000/api/v1) — backend base, includes /api/v1
27
+ * - API_KEY — business public key (X-API-Key)
28
+ * - CHAT_BACKEND ('rails' | 'sor', default 'rails')
15
29
  */
16
30
 
17
31
  // IMPORTANT:
@@ -23,28 +37,31 @@
23
37
 
24
38
  import { clientContextHeaders } from './proxy-headers';
25
39
  import { serverError } from '../../lib/server-log';
40
+ import { resolveChatBackend, SOR_BACKEND, type ChatBackend } from '../../lib/chat-backend';
26
41
 
27
42
  const API_URL = process.env.API_URL || 'http://localhost:3000/api/v1';
28
43
  const API_KEY = process.env.API_KEY || '';
29
44
  const CONSUMER_TOKEN_COOKIE = 'ks_consumer_token';
45
+ const SESSION_COOKIE = 'ks_chat_session';
46
+
47
+ export type { ChatBackend };
30
48
 
31
49
  type JsonResponder = (body: unknown, init?: ResponseInit) => Response;
32
50
 
33
- function cookieValue(cookieHeader: string | null, key: string): string | null {
34
- if (!cookieHeader) return null;
35
- const token = cookieHeader
36
- .split(';')
37
- .map((part) => part.trim())
38
- .find((part) => part.startsWith(`${key}=`))
39
- ?.split('=')
40
- .slice(1)
41
- .join('=');
42
- return token ? decodeURIComponent(token) : null;
51
+ function readCookie(request: Request, name: string): string | null {
52
+ const header = request.headers.get('cookie') || '';
53
+ for (const part of header.split(';')) {
54
+ const [k, ...v] = part.trim().split('=');
55
+ if (k === name) return decodeURIComponent(v.join('='));
56
+ }
57
+ return null;
43
58
  }
44
59
 
45
- export function createChatRouteHandlers(deps?: { NextResponse?: { json: JsonResponder } }) {
46
- const json: JsonResponder = deps?.NextResponse?.json ?? ((body, init) => Response.json(body, init));
60
+ /* ------------------------------------------------------------------------ *
61
+ * rails backend (keystone-mono-proto)
62
+ * ------------------------------------------------------------------------ */
47
63
 
64
+ function createRailsHandlers(json: JsonResponder) {
48
65
  return {
49
66
  // POST /api/chat — send a message (session-based or contact-driven)
50
67
  POST: async (request: Request): Promise<Response> => {
@@ -103,7 +120,7 @@ export function createChatRouteHandlers(deps?: { NextResponse?: { json: JsonResp
103
120
 
104
121
  const response = contactId
105
122
  ? await (async () => {
106
- const consumerToken = cookieValue(request.headers.get('cookie'), CONSUMER_TOKEN_COOKIE);
123
+ const consumerToken = readCookie(request, CONSUMER_TOKEN_COOKIE);
107
124
  if (!consumerToken) {
108
125
  return new Response(JSON.stringify({ error: 'Unauthorized' }), {
109
126
  status: 401,
@@ -163,3 +180,183 @@ export function createChatRouteHandlers(deps?: { NextResponse?: { json: JsonResp
163
180
  },
164
181
  };
165
182
  }
183
+
184
+ /* ------------------------------------------------------------------------ *
185
+ * sor backend (sor-service)
186
+ * ------------------------------------------------------------------------ */
187
+
188
+ /**
189
+ * Real browser signals to forward upstream. sor-service reads the visitor IP
190
+ * from X-Forwarded-For, the user-agent from User-Agent, and fbp/fbc from the
191
+ * POST body — so we set the headers and return fbp/fbc for the body.
192
+ */
193
+ function clientContext(request: Request): {
194
+ headers: Record<string, string>;
195
+ fbp?: string;
196
+ fbc?: string;
197
+ } {
198
+ const ip =
199
+ request.headers.get('x-real-ip') ||
200
+ request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
201
+ const ua = request.headers.get('user-agent') || undefined;
202
+ const headers: Record<string, string> = {};
203
+ if (ip) headers['X-Forwarded-For'] = ip;
204
+ if (ua) headers['User-Agent'] = ua;
205
+ return {
206
+ headers,
207
+ fbp: readCookie(request, '_fbp') || undefined,
208
+ fbc: readCookie(request, '_fbc') || undefined,
209
+ };
210
+ }
211
+
212
+ function sessionCookieHeader(sessionId: string): string {
213
+ // First-party (set by the site's own origin via this proxy), so it survives
214
+ // third-party-cookie blocking. 1-year, lax, http-only.
215
+ return `${SESSION_COOKIE}=${encodeURIComponent(sessionId)}; Path=/; Max-Age=31536000; SameSite=Lax; HttpOnly`;
216
+ }
217
+
218
+ interface SorMessage {
219
+ id: string;
220
+ direction?: string;
221
+ sender_type?: string;
222
+ body?: string | null;
223
+ created_at?: string | null;
224
+ }
225
+
226
+ /** Translate a sor-service message to the widget's flat shape. */
227
+ function toWidgetMessage(m: SorMessage) {
228
+ const isAgent = m.direction === 'OUTBOUND' || (!!m.sender_type && m.sender_type !== 'CONSUMER');
229
+ return {
230
+ id: m.id,
231
+ body: m.body ?? '',
232
+ sender_type: isAgent ? 'agent' : 'contact',
233
+ sender_display_name: isAgent ? 'Assistant' : 'You',
234
+ created_at: m.created_at ?? new Date().toISOString(),
235
+ };
236
+ }
237
+
238
+ function createSorHandlers(json: JsonResponder) {
239
+ return {
240
+ // POST /api/chat — send a message (anonymous session flow)
241
+ POST: async (request: Request): Promise<Response> => {
242
+ try {
243
+ const body = await request.json();
244
+ const {
245
+ identifier,
246
+ session_id,
247
+ body: messageBody,
248
+ display_name,
249
+ email,
250
+ phone,
251
+ first_name,
252
+ last_name,
253
+ page_url,
254
+ } = body;
255
+
256
+ if (!messageBody || !String(messageBody).trim()) {
257
+ return json({ success: false, error: 'Message body is required.' }, { status: 400 });
258
+ }
259
+ const sessionId = identifier || session_id || readCookie(request, SESSION_COOKIE);
260
+ if (!sessionId) {
261
+ return json({ success: false, error: 'identifier (session) is required.' }, { status: 400 });
262
+ }
263
+
264
+ const ctx = clientContext(request);
265
+ const response = await fetch(`${API_URL}/public/chat/messages`, {
266
+ method: 'POST',
267
+ headers: {
268
+ 'Content-Type': 'application/json',
269
+ 'X-API-Key': API_KEY,
270
+ ...ctx.headers,
271
+ },
272
+ body: JSON.stringify({
273
+ session_id: sessionId,
274
+ body: messageBody,
275
+ display_name,
276
+ email,
277
+ phone,
278
+ first_name,
279
+ last_name,
280
+ page_url,
281
+ fbp: ctx.fbp,
282
+ fbc: ctx.fbc,
283
+ }),
284
+ });
285
+
286
+ const data = await response.json().catch(() => ({}));
287
+ if (!response.ok) {
288
+ return json(
289
+ { success: false, error: data?.message || 'Failed to send message. Please try again.' },
290
+ { status: response.status }
291
+ );
292
+ }
293
+
294
+ // Return a truthy `job_id` so the widget long-polls for the async AI
295
+ // reply — sentinel fallback because a falsy job_id would silently skip
296
+ // the reply wait (sor always dispatches the AI async).
297
+ const cursor = data?.data?.cursor ?? null;
298
+ return json(
299
+ { success: true, data: { job_id: cursor ?? 'pending', cursor } },
300
+ { headers: { 'Set-Cookie': sessionCookieHeader(sessionId) } }
301
+ );
302
+ } catch (error) {
303
+ serverError('CHAT_ROUTE_POST_ERROR', error);
304
+ return json({ success: false, error: 'Network error. Please try again later.' }, { status: 500 });
305
+ }
306
+ },
307
+
308
+ // GET /api/chat?identifier=... — load message history (flat array, widget shape)
309
+ GET: async (request: Request): Promise<Response> => {
310
+ try {
311
+ const { searchParams } = new URL(request.url);
312
+
313
+ // No ActionCable on sor-service: answer the widget's realtime probe with
314
+ // an empty payload (no cable_url) so it falls back to polling.
315
+ if (searchParams.get('action') === 'realtime_token') {
316
+ return json({ success: true, data: {} });
317
+ }
318
+
319
+ const sessionId =
320
+ searchParams.get('identifier') ||
321
+ searchParams.get('session_id') ||
322
+ readCookie(request, SESSION_COOKIE);
323
+
324
+ if (!sessionId) {
325
+ return json({ success: true, data: [] });
326
+ }
327
+
328
+ const query = new URLSearchParams({ session_id: sessionId, wait: '0' });
329
+ const since = searchParams.get('since');
330
+ if (since) query.set('since', since);
331
+
332
+ const response = await fetch(`${API_URL}/public/chat/messages?${query.toString()}`, {
333
+ headers: { 'X-API-Key': API_KEY },
334
+ });
335
+
336
+ const data = await response.json().catch(() => ({}));
337
+ if (!response.ok) {
338
+ return json(
339
+ { success: false, error: data?.message || 'Failed to load messages.' },
340
+ { status: response.status }
341
+ );
342
+ }
343
+
344
+ const messages = (data?.data?.messages ?? []) as SorMessage[];
345
+ return json({ success: true, data: messages.map(toWidgetMessage) });
346
+ } catch (error) {
347
+ serverError('CHAT_ROUTE_GET_ERROR', error);
348
+ return json({ success: false, error: 'Network error. Please try again later.' }, { status: 500 });
349
+ }
350
+ },
351
+ };
352
+ }
353
+
354
+ export function createChatRouteHandlers(deps?: {
355
+ NextResponse?: { json: JsonResponder };
356
+ backend?: ChatBackend;
357
+ }) {
358
+ const json: JsonResponder = deps?.NextResponse?.json ?? ((body, init) => Response.json(body, init));
359
+ const backend: ChatBackend = deps?.backend ?? resolveChatBackend();
360
+
361
+ return backend === SOR_BACKEND ? createSorHandlers(json) : createRailsHandlers(json);
362
+ }
@@ -11,9 +11,9 @@
11
11
  *
12
12
  * Env (server-side only):
13
13
  * - API_URL (default: http://localhost:3000/api/v1)
14
+ * - AUTH_API_URL (optional) — overrides API_URL for consumer auth/session endpoints.
15
+ * Set this when auth lives on a separate service (e.g. Heimdal) from the data API.
14
16
  * - API_KEY
15
- * - CONSUMER_AUTH_URL (optional; overrides passwordless routes base, e.g.
16
- * https://www.keystone.app/api/consumer)
17
17
  */
18
18
 
19
19
  import { CONSUMER_TOKEN_COOKIE } from '../../lib/consumer-session';
@@ -25,19 +25,13 @@ type NextResponseLike = { json: (body: unknown, init?: ResponseInit) => any };
25
25
  const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days
26
26
 
27
27
  function getApiUrl(): string {
28
- return process.env.API_URL || 'http://localhost:3000/api/v1';
28
+ return process.env.AUTH_API_URL || process.env.API_URL || 'http://localhost:3000/api/v1';
29
29
  }
30
30
 
31
31
  function getApiKey(): string {
32
32
  return process.env.API_KEY || '';
33
33
  }
34
34
 
35
- function getPasswordlessBaseUrl(): string {
36
- const override = (process.env.CONSUMER_AUTH_URL || '').trim();
37
- if (override) return override.replace(/\/+$/, '');
38
- return `${getApiUrl().replace(/\/+$/, '')}/consumer/auth`;
39
- }
40
-
41
35
  function apiHeaders(request?: Request): Record<string, string> {
42
36
  const key = getApiKey();
43
37
  return {
@@ -47,69 +41,22 @@ function apiHeaders(request?: Request): Record<string, string> {
47
41
  };
48
42
  }
49
43
 
50
- function responseData(json: Record<string, unknown>): Record<string, unknown> {
51
- return (json.data as Record<string, unknown>) || json;
52
- }
53
-
54
- function readStringField(obj: Record<string, unknown>, key: string): string | null {
55
- const value = obj[key];
56
- return typeof value === 'string' && value.length > 0 ? value : null;
57
- }
58
-
59
- function readNestedStringField(
60
- obj: Record<string, unknown>,
61
- parentKey: string,
62
- key: string
63
- ): string | null {
64
- const parent = obj[parentKey];
65
- if (!parent || typeof parent !== 'object') return null;
66
- const value = (parent as Record<string, unknown>)[key];
67
- return typeof value === 'string' && value.length > 0 ? value : null;
44
+ function asString(value: unknown): string | undefined {
45
+ return typeof value === 'string' && value ? value : undefined;
68
46
  }
69
47
 
70
- function extractAuthToken(data: Record<string, unknown>): string | null {
71
- const direct =
72
- readStringField(data, 'token') ||
73
- readStringField(data, 'auth_token') ||
74
- readStringField(data, 'access_token') ||
75
- readStringField(data, 'session_token') ||
76
- readStringField(data, 'jwt') ||
77
- readNestedStringField(data, 'consumer', 'token') ||
78
- readNestedStringField(data, 'session', 'token');
79
- if (direct) return direct;
80
-
81
- // Compatibility fallback: some backends rename token fields without notice.
82
- // Walk a few levels and accept any non-empty string on keys containing "token"/"jwt".
83
- const queue: Array<{ value: unknown; depth: number }> = [{ value: data, depth: 0 }];
84
- const visited = new Set<object>();
85
- while (queue.length > 0) {
86
- const next = queue.shift();
87
- if (!next) break;
88
- const { value, depth } = next;
89
- if (!value || typeof value !== 'object') continue;
90
- if (visited.has(value as object)) continue;
91
- visited.add(value as object);
92
-
93
- const entries = Object.entries(value as Record<string, unknown>);
94
- for (const [key, candidate] of entries) {
95
- // Never treat verification tokens as auth/session JWTs.
96
- if (/verification[_-]?token/i.test(key)) {
97
- if (depth < 4 && candidate && typeof candidate === 'object') {
98
- queue.push({ value: candidate, depth: depth + 1 });
99
- }
100
- continue;
101
- }
102
- const isTokenLikeKey = /token|jwt/i.test(key);
103
- if (isTokenLikeKey && typeof candidate === 'string' && candidate.length > 0) {
104
- return candidate;
105
- }
106
- if (depth < 4 && candidate && typeof candidate === 'object') {
107
- queue.push({ value: candidate, depth: depth + 1 });
108
- }
109
- }
110
- }
111
-
112
- return null;
48
+ // Rails returns errors at the top level ({ error, code }); FastAPI backends
49
+ // (Heimdal) nest them under `detail`, where `error` is either a string or a
50
+ // { code, message } object for validation failures. Read all shapes, but only
51
+ // ever surface strings.
52
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
53
+ function upstreamError(json: any): { error?: string; code?: string; retry_in_seconds?: number } {
54
+ const err = json.error ?? json.detail?.error;
55
+ return {
56
+ error: asString(err) || asString(err?.message),
57
+ code: asString(json.code) || asString(json.detail?.code) || asString(err?.code),
58
+ retry_in_seconds: json.retry_in_seconds ?? json.detail?.retry_in_seconds,
59
+ };
113
60
  }
114
61
 
115
62
  // POST /api/consumer/initiate
@@ -133,8 +80,8 @@ async function handleInitiate(request: Request, NR: NextResponseLike): Promise<R
133
80
  if (res.status === 404) return NR.json({ exists: false });
134
81
  if (!res.ok) return NR.json({ exists: null });
135
82
 
136
- const json = await res.json().catch(() => ({})) as Record<string, unknown>;
137
- const data = responseData(json);
83
+ const json = await res.json().catch(() => ({}));
84
+ const data = json.data as Record<string, unknown> | undefined;
138
85
  return NR.json({
139
86
  exists: true,
140
87
  firstName: data?.first_name ?? undefined,
@@ -163,14 +110,14 @@ async function handleLogin(request: Request, NR: NextResponseLike): Promise<Resp
163
110
 
164
111
  const json = await res.json().catch(() => ({}));
165
112
  if (!res.ok) {
113
+ const err = upstreamError(json);
166
114
  return NR.json(
167
- { error: json.error || 'Login failed. Please try again.', code: json.code },
115
+ { error: err.error || 'Login failed. Please try again.', code: err.code },
168
116
  { status: res.status }
169
117
  );
170
118
  }
171
119
 
172
- const data = responseData(json);
173
- const token = extractAuthToken(data);
120
+ const token = json.data?.token;
174
121
  if (!token) return NR.json({ error: 'No token received.' }, { status: 500 });
175
122
 
176
123
  const response = NR.json({});
@@ -206,16 +153,18 @@ async function handleSignup(request: Request, NR: NextResponseLike): Promise<Res
206
153
  }),
207
154
  });
208
155
 
209
- const json = await res.json().catch(() => ({})) as Record<string, unknown>;
156
+ const json = await res.json().catch(() => ({}));
210
157
  if (!res.ok) {
211
- return NR.json({ error: json.error || 'Signup failed. Please try again.' }, { status: res.status });
158
+ return NR.json(
159
+ { error: upstreamError(json).error || 'Signup failed. Please try again.' },
160
+ { status: res.status }
161
+ );
212
162
  }
213
163
 
214
- const data = responseData(json);
215
- const token = extractAuthToken(data);
164
+ const token = json.data?.token;
216
165
  if (!token) return NR.json({ error: 'No token received.' }, { status: 500 });
217
166
 
218
- const response = NR.json({ claimed: data?.claimed ?? false });
167
+ const response = NR.json({ claimed: json.data?.claimed ?? false });
219
168
  response.cookies.set(CONSUMER_TOKEN_COOKIE, token, {
220
169
  httpOnly: true,
221
170
  secure: process.env.NODE_ENV === 'production',
@@ -233,27 +182,27 @@ async function handleSendCode(request: Request, NR: NextResponseLike): Promise<R
233
182
  const { phone } = body;
234
183
  if (!phone) return NR.json({ error: 'Phone is required.' }, { status: 422 });
235
184
 
236
- const res = await fetch(`${getPasswordlessBaseUrl()}/send_code`, {
185
+ const res = await fetch(`${getApiUrl()}/consumer/auth/send_code`, {
237
186
  method: 'POST',
238
187
  headers: apiHeaders(request),
239
188
  body: JSON.stringify({ phone }),
240
189
  });
241
190
 
242
- const json = await res.json().catch(() => ({})) as Record<string, unknown>;
243
- const data = responseData(json);
191
+ const json = await res.json().catch(() => ({}));
244
192
  if (!res.ok) {
193
+ const err = upstreamError(json);
245
194
  return NR.json(
246
195
  {
247
- error: (json as Record<string, unknown>).error || 'Failed to send verification code. Please try again.',
248
- code: (json as Record<string, unknown>).code,
249
- retry_in_seconds: (json as Record<string, unknown>).retry_in_seconds,
196
+ error: err.error || 'Failed to send verification code. Please try again.',
197
+ code: err.code,
198
+ retry_in_seconds: err.retry_in_seconds,
250
199
  },
251
200
  { status: res.status }
252
201
  );
253
202
  }
254
203
 
255
204
  return NR.json({
256
- resend_available_at: data?.resend_available_at ?? null,
205
+ resend_available_at: json.data?.resend_available_at ?? null,
257
206
  });
258
207
  }
259
208
 
@@ -264,49 +213,26 @@ async function handleVerifyCode(request: Request, NR: NextResponseLike): Promise
264
213
  const { phone, code } = body;
265
214
  if (!phone || !code) return NR.json({ error: 'Phone and code are required.' }, { status: 422 });
266
215
 
267
- const res = await fetch(`${getPasswordlessBaseUrl()}/verify_code`, {
216
+ const res = await fetch(`${getApiUrl()}/consumer/auth/verify_code`, {
268
217
  method: 'POST',
269
218
  headers: apiHeaders(request),
270
219
  body: JSON.stringify({ phone, code }),
271
220
  });
272
221
 
273
- const json = await res.json().catch(() => ({})) as Record<string, unknown>;
274
- const data = responseData(json);
222
+ const json = await res.json().catch(() => ({}));
275
223
  if (!res.ok) {
224
+ const err = upstreamError(json);
276
225
  return NR.json(
277
- {
278
- error: (json as Record<string, unknown>).error || 'Verification failed. Please try again.',
279
- code: (json as Record<string, unknown>).code,
280
- },
226
+ { error: err.error || 'Verification failed. Please try again.', code: err.code },
281
227
  { status: res.status }
282
228
  );
283
229
  }
284
230
 
285
- // Some surfaces return a direct session token on verify.
286
- const directToken = extractAuthToken(data);
287
- if (directToken) {
288
- const response = NR.json({
289
- authenticated: true,
290
- first_name: data?.first_name ?? '',
291
- last_name: data?.last_name ?? '',
292
- email: data?.email ?? '',
293
- eventId: data?.event_id ?? undefined,
294
- });
295
- response.cookies.set(CONSUMER_TOKEN_COOKIE, directToken, {
296
- httpOnly: true,
297
- secure: process.env.NODE_ENV === 'production',
298
- sameSite: 'lax',
299
- path: '/',
300
- maxAge: COOKIE_MAX_AGE,
301
- });
302
- return response;
303
- }
304
-
305
231
  return NR.json({
306
- verification_token: data?.verification_token,
307
- first_name: data?.first_name ?? '',
308
- last_name: data?.last_name ?? '',
309
- email: data?.email ?? '',
232
+ verification_token: json.data?.verification_token,
233
+ first_name: json.data?.first_name ?? '',
234
+ last_name: json.data?.last_name ?? '',
235
+ email: json.data?.email ?? '',
310
236
  });
311
237
  }
312
238
 
@@ -322,7 +248,7 @@ async function handlePasswordlessAuth(request: Request, NR: NextResponseLike): P
322
248
  return NR.json({ error: 'First name, last name, and email are required.' }, { status: 422 });
323
249
  }
324
250
 
325
- const res = await fetch(`${getPasswordlessBaseUrl()}/passwordless_auth`, {
251
+ const res = await fetch(`${getApiUrl()}/consumer/auth/passwordless_auth`, {
326
252
  method: 'POST',
327
253
  headers: apiHeaders(request),
328
254
  body: JSON.stringify({
@@ -334,21 +260,20 @@ async function handlePasswordlessAuth(request: Request, NR: NextResponseLike): P
334
260
  }),
335
261
  });
336
262
 
337
- const json = await res.json().catch(() => ({})) as Record<string, unknown>;
338
- const data = responseData(json);
263
+ const json = await res.json().catch(() => ({}));
339
264
  if (!res.ok) {
340
265
  return NR.json(
341
- { error: (json as Record<string, unknown>).error || 'Authentication failed. Please try again.' },
266
+ { error: upstreamError(json).error || 'Authentication failed. Please try again.' },
342
267
  { status: res.status }
343
268
  );
344
269
  }
345
270
 
346
- const token = extractAuthToken(data);
271
+ const token = json.data?.token;
347
272
  if (!token) return NR.json({ error: 'No token received.' }, { status: 500 });
348
273
 
349
274
  // Surface the server-side Lead event_id so the browser can fire a single deduped
350
275
  // Lead (fbq track with { eventID }) that Meta matches against the CAPI Lead event.
351
- const response = NR.json({ eventId: data?.event_id ?? undefined });
276
+ const response = NR.json({ eventId: json.data?.event_id ?? undefined });
352
277
  response.cookies.set(CONSUMER_TOKEN_COOKIE, token, {
353
278
  httpOnly: true,
354
279
  secure: process.env.NODE_ENV === 'production',