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.
- package/README.md +4 -4
- package/dist/blog-post-vWzW8yFb.d.ts +50 -0
- package/dist/contexts/index.d.ts +13 -0
- package/dist/contexts/index.js +173 -0
- package/dist/contexts/index.js.map +1 -0
- package/dist/design_system/components/DynamicFormFields.d.ts +15 -0
- package/dist/design_system/components/DynamicFormFields.js +5176 -0
- package/dist/design_system/components/DynamicFormFields.js.map +1 -0
- package/dist/design_system/elements/index.d.ts +383 -0
- package/dist/design_system/elements/index.js +4007 -0
- package/dist/design_system/elements/index.js.map +1 -0
- package/dist/design_system/logo/keystone-logo.d.ts +6 -0
- package/dist/design_system/logo/keystone-logo.js +145 -0
- package/dist/design_system/logo/keystone-logo.js.map +1 -0
- package/dist/design_system/sections/index.d.ts +233 -0
- package/dist/design_system/sections/index.js +20048 -0
- package/dist/design_system/sections/index.js.map +1 -0
- package/dist/form-C94A_PX_.d.ts +36 -0
- package/dist/index.d.ts +86 -0
- package/dist/index.js +20646 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/component-registry.d.ts +13 -0
- package/dist/lib/component-registry.js +36 -0
- package/dist/lib/component-registry.js.map +1 -0
- package/dist/lib/hooks/index.d.ts +83 -0
- package/dist/lib/hooks/index.js +182 -0
- package/dist/lib/hooks/index.js.map +1 -0
- package/dist/lib/server-api.d.ts +67 -0
- package/dist/lib/server-api.js +195 -0
- package/dist/lib/server-api.js.map +1 -0
- package/dist/package-DeHKpQp7.d.ts +121 -0
- package/dist/photos-CmBdWiuZ.d.ts +27 -0
- package/dist/themes/index.d.ts +17 -0
- package/dist/themes/index.js +29 -0
- package/dist/themes/index.js.map +1 -0
- package/dist/tracking/index.d.ts +254 -0
- package/dist/tracking/index.js +587 -0
- package/dist/tracking/index.js.map +1 -0
- package/dist/types/index.d.ts +313 -0
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/cx.d.ts +15 -0
- package/dist/utils/cx.js +18 -0
- package/dist/utils/cx.js.map +1 -0
- package/dist/utils/gradient-placeholder.d.ts +7 -0
- package/dist/utils/gradient-placeholder.js +59 -0
- package/dist/utils/gradient-placeholder.js.map +1 -0
- package/dist/utils/is-react-component.d.ts +21 -0
- package/dist/utils/is-react-component.js +20 -0
- package/dist/utils/is-react-component.js.map +1 -0
- package/dist/utils/markdown-toc.d.ts +14 -0
- package/dist/utils/markdown-toc.js +29 -0
- package/dist/utils/markdown-toc.js.map +1 -0
- package/dist/utils/phone-helpers.d.ts +24 -0
- package/dist/utils/phone-helpers.js +26 -0
- package/dist/utils/phone-helpers.js.map +1 -0
- package/dist/utils/photo-helpers.d.ts +38 -0
- package/dist/utils/photo-helpers.js +41 -0
- package/dist/utils/photo-helpers.js.map +1 -0
- package/dist/website-photos-Cl1YqAno.d.ts +21 -0
- package/package.json +1 -1
- package/src/design_system/components/ChatWidget.tsx +66 -10
- package/src/design_system/portal/LoginForm.tsx +1 -24
- package/src/lib/chat-backend.ts +36 -0
- package/src/lib/consumer-session.ts +3 -1
- package/src/next/layouts/root-layout.tsx +5 -0
- package/src/next/routes/chat.ts +215 -18
- package/src/next/routes/consumer-auth.ts +49 -124
package/src/next/routes/chat.ts
CHANGED
|
@@ -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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
.split('
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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 =
|
|
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
|
|
51
|
-
return
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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(() => ({}))
|
|
137
|
-
const data =
|
|
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:
|
|
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
|
|
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(() => ({}))
|
|
156
|
+
const json = await res.json().catch(() => ({}));
|
|
210
157
|
if (!res.ok) {
|
|
211
|
-
return NR.json(
|
|
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
|
|
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(`${
|
|
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(() => ({}))
|
|
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:
|
|
248
|
-
code:
|
|
249
|
-
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(`${
|
|
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(() => ({}))
|
|
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(`${
|
|
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(() => ({}))
|
|
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
|
|
266
|
+
{ error: upstreamError(json).error || 'Authentication failed. Please try again.' },
|
|
342
267
|
{ status: res.status }
|
|
343
268
|
);
|
|
344
269
|
}
|
|
345
270
|
|
|
346
|
-
const token =
|
|
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',
|