keystone-design-bootstrap 1.0.103 → 1.0.104

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 (64) 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 +70 -0
  20. package/dist/index.js +20608 -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/portal/LoginForm.tsx +1 -24
  63. package/src/lib/consumer-session.ts +3 -1
  64. package/src/next/routes/consumer-auth.ts +49 -124
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utils/markdown-toc.ts"],"sourcesContent":["/**\n * Utility functions for extracting table of contents from markdown\n */\n\nexport interface TableOfContentsItem {\n id: string;\n title: string;\n level: number;\n}\n\n/**\n * Generate a URL-friendly ID from a heading text\n */\nfunction slugify(text: string): string {\n return text\n .toLowerCase()\n .trim()\n .replace(/[^\\w\\s-]/g, '') // Remove special characters\n .replace(/[\\s_-]+/g, '-') // Replace spaces and underscores with hyphens\n .replace(/^-+|-+$/g, ''); // Remove leading/trailing hyphens\n}\n\n/**\n * Extract headings from markdown content and generate table of contents\n */\nexport function extractTableOfContents(markdown: string): TableOfContentsItem[] {\n if (!markdown) return [];\n\n const toc: TableOfContentsItem[] = [];\n const lines = markdown.split('\\n');\n\n for (const line of lines) {\n // Match markdown headings (##, ###, etc.)\n const headingMatch = line.match(/^(#{1,6})\\s+(.+)$/);\n if (headingMatch) {\n const level = headingMatch[1].length;\n const title = headingMatch[2].trim();\n \n // Only include h2 and h3 headings in TOC\n if (level >= 2 && level <= 3) {\n const id = slugify(title);\n toc.push({\n id,\n title,\n level,\n });\n }\n }\n }\n\n return toc;\n}\n\n\n"],"mappings":";AAaA,SAAS,QAAQ,MAAsB;AACrC,SAAO,KACJ,YAAY,EACZ,KAAK,EACL,QAAQ,aAAa,EAAE,EACvB,QAAQ,YAAY,GAAG,EACvB,QAAQ,YAAY,EAAE;AAC3B;AAKO,SAAS,uBAAuB,UAAyC;AAC9E,MAAI,CAAC,SAAU,QAAO,CAAC;AAEvB,QAAM,MAA6B,CAAC;AACpC,QAAM,QAAQ,SAAS,MAAM,IAAI;AAEjC,aAAW,QAAQ,OAAO;AAExB,UAAM,eAAe,KAAK,MAAM,mBAAmB;AACnD,QAAI,cAAc;AAChB,YAAM,QAAQ,aAAa,CAAC,EAAE;AAC9B,YAAM,QAAQ,aAAa,CAAC,EAAE,KAAK;AAGnC,UAAI,SAAS,KAAK,SAAS,GAAG;AAC5B,cAAM,KAAK,QAAQ,KAAK;AACxB,YAAI,KAAK;AAAA,UACP;AAAA,UACA;AAAA,UACA;AAAA,QACF,CAAC;AAAA,MACH;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;","names":[]}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * List of countries with their respective country code, flag, phone code, and phone mask.
3
+ */
4
+ declare const countries: ({
5
+ name: string;
6
+ code: string;
7
+ flag: string;
8
+ phoneCode: string;
9
+ phoneMask: string;
10
+ } | {
11
+ name: string;
12
+ code: string;
13
+ flag: string;
14
+ phoneCode: string;
15
+ phoneMask?: undefined;
16
+ })[];
17
+
18
+ type Country = (typeof countries)[0];
19
+ /** Get national-format mask from country by stripping the country code prefix (e.g. "+1 (###) ###-####" → "(###) ###-####"). */
20
+ declare function getNationalMask(country: Country | undefined): string;
21
+ /** Format a raw digit string into a mask pattern where '#' represents one digit. No trailing literals so backspace works naturally. */
22
+ declare function formatDigitsToMask(digits: string, mask: string): string;
23
+
24
+ export { formatDigitsToMask, getNationalMask };
@@ -0,0 +1,26 @@
1
+ // src/utils/phone-helpers.ts
2
+ function getNationalMask(country) {
3
+ if (!(country == null ? void 0 : country.phoneMask)) return "";
4
+ const code = country.phoneCode.startsWith("+") ? country.phoneCode : `+${country.phoneCode}`;
5
+ const escaped = code.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
6
+ return country.phoneMask.replace(new RegExp(`^\\s*${escaped}[\\s-]*`), "").trim();
7
+ }
8
+ function formatDigitsToMask(digits, mask) {
9
+ if (digits.length === 0) return "";
10
+ let i = 0;
11
+ let out = "";
12
+ for (const c of mask) {
13
+ if (c === "#") {
14
+ if (i < digits.length) out += digits[i++];
15
+ else break;
16
+ } else if (i < digits.length) {
17
+ out += c;
18
+ }
19
+ }
20
+ return out;
21
+ }
22
+ export {
23
+ formatDigitsToMask,
24
+ getNationalMask
25
+ };
26
+ //# sourceMappingURL=phone-helpers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utils/phone-helpers.ts"],"sourcesContent":["import type countries from './countries';\n\ntype Country = (typeof countries)[0];\n\n/** Get national-format mask from country by stripping the country code prefix (e.g. \"+1 (###) ###-####\" → \"(###) ###-####\"). */\nexport function getNationalMask(country: Country | undefined): string {\n if (!country?.phoneMask) return '';\n const code = country.phoneCode.startsWith('+') ? country.phoneCode : `+${country.phoneCode}`;\n const escaped = code.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n return country.phoneMask.replace(new RegExp(`^\\\\s*${escaped}[\\\\s-]*`), '').trim();\n}\n\n/** Format a raw digit string into a mask pattern where '#' represents one digit. No trailing literals so backspace works naturally. */\nexport function formatDigitsToMask(digits: string, mask: string): string {\n if (digits.length === 0) return '';\n let i = 0;\n let out = '';\n for (const c of mask) {\n if (c === '#') {\n if (i < digits.length) out += digits[i++];\n else break;\n } else if (i < digits.length) {\n out += c;\n }\n }\n return out;\n}\n"],"mappings":";AAKO,SAAS,gBAAgB,SAAsC;AACpE,MAAI,EAAC,mCAAS,WAAW,QAAO;AAChC,QAAM,OAAO,QAAQ,UAAU,WAAW,GAAG,IAAI,QAAQ,YAAY,IAAI,QAAQ,SAAS;AAC1F,QAAM,UAAU,KAAK,QAAQ,uBAAuB,MAAM;AAC1D,SAAO,QAAQ,UAAU,QAAQ,IAAI,OAAO,QAAQ,OAAO,SAAS,GAAG,EAAE,EAAE,KAAK;AAClF;AAGO,SAAS,mBAAmB,QAAgB,MAAsB;AACvE,MAAI,OAAO,WAAW,EAAG,QAAO;AAChC,MAAI,IAAI;AACR,MAAI,MAAM;AACV,aAAW,KAAK,MAAM;AACpB,QAAI,MAAM,KAAK;AACb,UAAI,IAAI,OAAO,OAAQ,QAAO,OAAO,GAAG;AAAA,UACnC;AAAA,IACP,WAAW,IAAI,OAAO,QAAQ;AAC5B,aAAO;AAAA,IACT;AAAA,EACF;AACA,SAAO;AACT;","names":[]}
@@ -0,0 +1,38 @@
1
+ import { P as PhotoAttachment } from '../photos-CmBdWiuZ.js';
2
+ import { W as WebsitePhotos } from '../website-photos-Cl1YqAno.js';
3
+
4
+ /**
5
+ * Helper functions for extracting photo URLs from photo associations
6
+ */
7
+
8
+ /**
9
+ * True if the URL looks like a video by path extension. Used to avoid using video URLs in img src.
10
+ */
11
+ declare function isVideoUrl(url: string | null | undefined): boolean;
12
+ /**
13
+ * Get the best available photo URL from a photos array
14
+ * Priority: featured photo > first photo > fallback
15
+ */
16
+ declare function getPhotoUrl(photos?: PhotoAttachment[]): string | null;
17
+ /**
18
+ * Get avatar URL for team members or authors.
19
+ * Returns a URL only when there is a real photo in attachments; otherwise null.
20
+ * Callers should use PhotoWithFallback (gradient fallback) or Avatar with initials when null.
21
+ */
22
+ /** Optional fallbackId/name reserved for future use (e.g. deterministic fallback). */
23
+ declare function getAvatarUrl(photos?: PhotoAttachment[], _fallbackId?: number | string, _name?: string): string | null;
24
+ /**
25
+ * Get featured image URL for blog posts
26
+ */
27
+ declare function getFeaturedImageUrl(photos?: PhotoAttachment[]): string | null;
28
+ /**
29
+ * Get logo URL from website_photos API (which aggregates from account_photos)
30
+ *
31
+ * The website_photos API endpoint returns logos from account_photos with photo_type: 'logo',
32
+ * with industry fallback. This is the primary and only source for logos.
33
+ *
34
+ * Returns undefined if no logo is available (for PhotoWithFallback gradient fallback)
35
+ */
36
+ declare function getLogoUrl(websitePhotos?: WebsitePhotos | null): string | undefined;
37
+
38
+ export { PhotoAttachment, getAvatarUrl, getFeaturedImageUrl, getLogoUrl, getPhotoUrl, isVideoUrl };
@@ -0,0 +1,41 @@
1
+ // src/utils/photo-helpers.ts
2
+ var VIDEO_EXTENSIONS = [".mp4", ".mov", ".webm", ".m4v", ".avi", ".mkv", ".flv", ".wmv"];
3
+ function isVideoUrl(url) {
4
+ var _a;
5
+ if (!url || typeof url !== "string") return false;
6
+ const path = (_a = url.split("?")[0]) != null ? _a : "";
7
+ const ext = path.slice(path.lastIndexOf(".")).toLowerCase();
8
+ return VIDEO_EXTENSIONS.includes(ext);
9
+ }
10
+ function getPhotoUrl(photos) {
11
+ if (photos && photos.length > 0) {
12
+ const featuredPhoto = photos.find((pa) => pa.featured);
13
+ const photoToUse = featuredPhoto || photos[0];
14
+ const photo = photoToUse.photo;
15
+ if (photo) {
16
+ return photo.large_url || photo.medium_url || photo.thumbnail_url || photo.original_url || null;
17
+ }
18
+ }
19
+ return null;
20
+ }
21
+ function getAvatarUrl(photos, _fallbackId, _name) {
22
+ return getPhotoUrl(photos);
23
+ }
24
+ function getFeaturedImageUrl(photos) {
25
+ return getPhotoUrl(photos);
26
+ }
27
+ function getLogoUrl(websitePhotos) {
28
+ var _a;
29
+ if ((_a = websitePhotos == null ? void 0 : websitePhotos.logo) == null ? void 0 : _a.url) {
30
+ return websitePhotos.logo.url;
31
+ }
32
+ return void 0;
33
+ }
34
+ export {
35
+ getAvatarUrl,
36
+ getFeaturedImageUrl,
37
+ getLogoUrl,
38
+ getPhotoUrl,
39
+ isVideoUrl
40
+ };
41
+ //# sourceMappingURL=photo-helpers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utils/photo-helpers.ts"],"sourcesContent":["/**\n * Helper functions for extracting photo URLs from photo associations\n */\n\nimport type { PhotoAttachment } from '../types/api/photos';\nimport type { WebsitePhotos } from '../types/api/website-photos';\n\nexport type { PhotoAttachment } from '../types/api/photos';\n\n/** Video file extensions treated as video (match backend ExternalPhotoService.video_url?) */\nconst VIDEO_EXTENSIONS = ['.mp4', '.mov', '.webm', '.m4v', '.avi', '.mkv', '.flv', '.wmv'];\n\n/**\n * True if the URL looks like a video by path extension. Used to avoid using video URLs in img src.\n */\nexport function isVideoUrl(url: string | null | undefined): boolean {\n if (!url || typeof url !== 'string') return false;\n const path = url.split('?')[0] ?? '';\n const ext = path.slice(path.lastIndexOf('.')).toLowerCase();\n return VIDEO_EXTENSIONS.includes(ext);\n}\n\n/**\n * Get the best available photo URL from a photos array\n * Priority: featured photo > first photo > fallback\n */\nexport function getPhotoUrl(\n photos?: PhotoAttachment[]\n): string | null {\n // Priority 1: Featured photo from photos array\n if (photos && photos.length > 0) {\n const featuredPhoto = photos.find(pa => pa.featured);\n const photoToUse = featuredPhoto || photos[0];\n const photo = photoToUse.photo;\n \n if (photo) {\n return photo.large_url || photo.medium_url || photo.thumbnail_url || photo.original_url || null;\n }\n }\n \n // Fallback (gradient or null)\n return null;\n}\n\n/**\n * Get avatar URL for team members or authors.\n * Returns a URL only when there is a real photo in attachments; otherwise null.\n * Callers should use PhotoWithFallback (gradient fallback) or Avatar with initials when null.\n */\n/** Optional fallbackId/name reserved for future use (e.g. deterministic fallback). */\n/* eslint-disable @typescript-eslint/no-unused-vars -- _fallbackId, _name reserved for API */\nexport function getAvatarUrl(\n photos?: PhotoAttachment[],\n _fallbackId?: number | string,\n _name?: string\n): string | null {\n return getPhotoUrl(photos);\n}\n/* eslint-enable @typescript-eslint/no-unused-vars */\n\n/**\n * Get featured image URL for blog posts\n */\nexport function getFeaturedImageUrl(\n photos?: PhotoAttachment[]\n): string | null {\n return getPhotoUrl(photos);\n}\n\n/**\n * Get logo URL from website_photos API (which aggregates from account_photos)\n * \n * The website_photos API endpoint returns logos from account_photos with photo_type: 'logo',\n * with industry fallback. This is the primary and only source for logos.\n * \n * Returns undefined if no logo is available (for PhotoWithFallback gradient fallback)\n */\nexport function getLogoUrl(\n websitePhotos?: WebsitePhotos | null\n): string | undefined {\n if (websitePhotos?.logo?.url) {\n return websitePhotos.logo.url;\n }\n \n return undefined;\n}\n\n"],"mappings":";AAUA,IAAM,mBAAmB,CAAC,QAAQ,QAAQ,SAAS,QAAQ,QAAQ,QAAQ,QAAQ,MAAM;AAKlF,SAAS,WAAW,KAAyC;AAfpE;AAgBE,MAAI,CAAC,OAAO,OAAO,QAAQ,SAAU,QAAO;AAC5C,QAAM,QAAO,SAAI,MAAM,GAAG,EAAE,CAAC,MAAhB,YAAqB;AAClC,QAAM,MAAM,KAAK,MAAM,KAAK,YAAY,GAAG,CAAC,EAAE,YAAY;AAC1D,SAAO,iBAAiB,SAAS,GAAG;AACtC;AAMO,SAAS,YACd,QACe;AAEf,MAAI,UAAU,OAAO,SAAS,GAAG;AAC/B,UAAM,gBAAgB,OAAO,KAAK,QAAM,GAAG,QAAQ;AACnD,UAAM,aAAa,iBAAiB,OAAO,CAAC;AAC5C,UAAM,QAAQ,WAAW;AAEzB,QAAI,OAAO;AACT,aAAO,MAAM,aAAa,MAAM,cAAc,MAAM,iBAAiB,MAAM,gBAAgB;AAAA,IAC7F;AAAA,EACF;AAGA,SAAO;AACT;AASO,SAAS,aACd,QACA,aACA,OACe;AACf,SAAO,YAAY,MAAM;AAC3B;AAMO,SAAS,oBACd,QACe;AACf,SAAO,YAAY,MAAM;AAC3B;AAUO,SAAS,WACd,eACoB;AA/EtB;AAgFE,OAAI,oDAAe,SAAf,mBAAqB,KAAK;AAC5B,WAAO,cAAc,KAAK;AAAA,EAC5B;AAEA,SAAO;AACT;","names":[]}
@@ -0,0 +1,21 @@
1
+ interface WebsitePhoto {
2
+ id: number;
3
+ url: string;
4
+ thumbnail_url?: string;
5
+ medium_url?: string;
6
+ alt: string;
7
+ source: 'account' | 'industry';
8
+ }
9
+ interface WebsitePhotos {
10
+ logo?: WebsitePhoto | null;
11
+ favicon?: WebsitePhoto | null;
12
+ hero?: WebsitePhoto | null;
13
+ contact?: WebsitePhoto | null;
14
+ about?: WebsitePhoto | null;
15
+ careers?: WebsitePhoto | null;
16
+ preview_image?: WebsitePhoto | null;
17
+ stock_photos?: WebsitePhoto[];
18
+ }
19
+ type WebsitePhotosResponse = WebsitePhotos;
20
+
21
+ export type { WebsitePhotos as W, WebsitePhoto as a, WebsitePhotosResponse as b };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keystone-design-bootstrap",
3
- "version": "1.0.103",
3
+ "version": "1.0.104",
4
4
  "description": "Keystone Design Bootstrap - Sections, Elements, and Theme System for customer websites",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -204,30 +204,7 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
204
204
  body: JSON.stringify({ phone: fullPhone, code }),
205
205
  });
206
206
  const result = await res.json().catch(() => ({}));
207
- if (!res.ok) {
208
- setError(result.error || 'That code is invalid or expired.');
209
- captureEvent('portal_login_failed', { step: 'signin', reason: result.code || 'verification_failed' });
210
- return;
211
- }
212
-
213
- // Compatibility path: verify_code may directly authenticate and set the
214
- // session cookie via the route handler (no passwordless_auth needed).
215
- if (result.authenticated) {
216
- await setPixelUserData({ email: result.email || undefined, phone: fullPhone });
217
- firePixelEvent('Lead', undefined, result?.eventId);
218
- captureEvent('portal_login_step_advanced', {
219
- from_step: 'signin',
220
- to_step: 'authenticated',
221
- reason: 'verification_direct_auth',
222
- });
223
- captureEvent('portal_login_completed', { flow: 'signin' });
224
- setResendAvailableAt(null);
225
- setSecondsUntilResend(0);
226
- if (onSuccess) onSuccess(); else router.refresh();
227
- return;
228
- }
229
-
230
- if (!result.verification_token) {
207
+ if (!res.ok || !result.verification_token) {
231
208
  setError(result.error || 'That code is invalid or expired.');
232
209
  captureEvent('portal_login_failed', { step: 'signin', reason: result.code || 'verification_failed' });
233
210
  return;
@@ -8,8 +8,10 @@ export const CONSUMER_TOKEN_COOKIE = 'ks_consumer_token';
8
8
  import type { Consumer, ConversationSummary, Message, ContactSummary } from '../types/api/consumer';
9
9
  export type { Consumer, ConversationSummary, Message, ContactSummary };
10
10
 
11
+ // AUTH_API_URL (when set) points consumer auth/session endpoints at a dedicated
12
+ // auth service (e.g. Heimdal); everything else keeps using API_URL.
11
13
  function getApiUrl(): string {
12
- return process.env.API_URL || 'http://localhost:3000/api/v1';
14
+ return process.env.AUTH_API_URL || process.env.API_URL || 'http://localhost:3000/api/v1';
13
15
  }
14
16
 
15
17
  async function consumerFetch<T>(path: string, token: string): Promise<T | null> {
@@ -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',