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
|
@@ -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
|
@@ -7,6 +7,7 @@ import { cx } from '../../utils/cx';
|
|
|
7
7
|
import { captureEvent } from '../../tracking/captureEvent';
|
|
8
8
|
import { useRealtimeReplyOrchestrator, type RealtimeSubscriptionData } from '../chat/useRealtimeReplyOrchestrator';
|
|
9
9
|
import { error as logError } from '../../tracking/logging';
|
|
10
|
+
import { RAILS_BACKEND, SOR_BACKEND, type ChatBackend } from '../../lib/chat-backend';
|
|
10
11
|
|
|
11
12
|
interface Message {
|
|
12
13
|
id: string;
|
|
@@ -25,6 +26,18 @@ interface TeamMember {
|
|
|
25
26
|
|
|
26
27
|
interface ChatWidgetProps {
|
|
27
28
|
position?: 'bottom-right' | 'bottom-left';
|
|
29
|
+
/**
|
|
30
|
+
* Which backend the site's `/api/chat` proxy talks to. The two backends have
|
|
31
|
+
* architecturally different reply delivery: 'rails' (keystone-mono-proto)
|
|
32
|
+
* pushes replies over ActionCable realtime; 'sor' (sor-service) has no
|
|
33
|
+
* realtime channel and the widget polls for the reply instead. The paths are
|
|
34
|
+
* mutually exclusive — a 'sor' widget never probes/subscribes realtime, a
|
|
35
|
+
* 'rails' widget never polls.
|
|
36
|
+
*
|
|
37
|
+
* TODO(webchat): default is 'rails' while keystone-mono-proto is still a live
|
|
38
|
+
* backend; flip the default to 'sor' once the Rails backend is scrapped.
|
|
39
|
+
*/
|
|
40
|
+
chatBackend?: ChatBackend;
|
|
28
41
|
primaryColor?: string;
|
|
29
42
|
/**
|
|
30
43
|
* Anonymous session identifier. Used when the visitor is not authenticated.
|
|
@@ -66,6 +79,7 @@ const formatTime = (isoString: string) => {
|
|
|
66
79
|
export function ChatWidget({
|
|
67
80
|
position = 'bottom-right',
|
|
68
81
|
primaryColor,
|
|
82
|
+
chatBackend = RAILS_BACKEND,
|
|
69
83
|
sessionId: providedSessionId,
|
|
70
84
|
contactId,
|
|
71
85
|
displayName: providedDisplayName,
|
|
@@ -146,7 +160,10 @@ export function ChatWidget({
|
|
|
146
160
|
);
|
|
147
161
|
}, []);
|
|
148
162
|
|
|
163
|
+
const isSorBackend = chatBackend === SOR_BACKEND;
|
|
164
|
+
|
|
149
165
|
const fetchRealtimeData = useCallback(async (): Promise<RealtimeSubscriptionData | null> => {
|
|
166
|
+
if (isSorBackend) return null; // sor has no realtime channel — never probe
|
|
150
167
|
if (!contactId && !sessionId) return null;
|
|
151
168
|
const query = contactId
|
|
152
169
|
? `contact_id=${encodeURIComponent(contactId)}`
|
|
@@ -159,7 +176,7 @@ export function ChatWidget({
|
|
|
159
176
|
const cableUrl = result?.data?.cable_url as string | undefined;
|
|
160
177
|
if (!token || !contactIdForStream || !cableUrl) return null;
|
|
161
178
|
return { token, contact_id: contactIdForStream, cable_url: cableUrl };
|
|
162
|
-
}, [contactId, sessionId]);
|
|
179
|
+
}, [contactId, isSorBackend, sessionId]);
|
|
163
180
|
|
|
164
181
|
const {
|
|
165
182
|
beginReplyWait,
|
|
@@ -179,13 +196,48 @@ export function ChatWidget({
|
|
|
179
196
|
|
|
180
197
|
const hasPersistedMessages = messages.some((message) => !String(message.id).startsWith('temp_'));
|
|
181
198
|
|
|
199
|
+
// rails reply delivery: ActionCable realtime subscription.
|
|
182
200
|
useEffect(() => {
|
|
201
|
+
if (isSorBackend) return;
|
|
183
202
|
if (!isOpen || (!contactId && !sessionId)) return;
|
|
184
203
|
// Anonymous sessions do not have a contact row until the first message is sent.
|
|
185
204
|
// Ignore optimistic temp rows to avoid pre-contact token probes (404 noise).
|
|
186
205
|
if (!contactId && !hasPersistedMessages) return;
|
|
187
206
|
ensureRealtimeSubscription();
|
|
188
|
-
}, [contactId, ensureRealtimeSubscription, hasPersistedMessages, isOpen, sessionId]);
|
|
207
|
+
}, [contactId, ensureRealtimeSubscription, hasPersistedMessages, isOpen, isSorBackend, sessionId]);
|
|
208
|
+
|
|
209
|
+
// sor reply delivery: no realtime channel exists, so while waiting for the
|
|
210
|
+
// agent reply, poll the thread and resolve as soon as an agent message with
|
|
211
|
+
// a body lands (or after a timeout). Never runs for the rails backend.
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
if (!isSorBackend || !waitingForReply) return;
|
|
214
|
+
let cancelled = false;
|
|
215
|
+
let inFlight = false; // don't stack requests when a poll outlives the interval
|
|
216
|
+
let attempts = 0;
|
|
217
|
+
const maxAttempts = 40; // ~60s at 1.5s
|
|
218
|
+
const interval = setInterval(async () => {
|
|
219
|
+
if (inFlight) return;
|
|
220
|
+
inFlight = true;
|
|
221
|
+
attempts += 1;
|
|
222
|
+
try {
|
|
223
|
+
const msgs = await loadMessages();
|
|
224
|
+
if (cancelled) return;
|
|
225
|
+
if (hasAgentReplyWithBody(msgs) || attempts >= maxAttempts) {
|
|
226
|
+
clearInterval(interval);
|
|
227
|
+
setWaitingForReply(false);
|
|
228
|
+
setIsLoading(false);
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
// transient — keep polling until maxAttempts
|
|
232
|
+
} finally {
|
|
233
|
+
inFlight = false;
|
|
234
|
+
}
|
|
235
|
+
}, 1500);
|
|
236
|
+
return () => {
|
|
237
|
+
cancelled = true;
|
|
238
|
+
clearInterval(interval);
|
|
239
|
+
};
|
|
240
|
+
}, [isSorBackend, waitingForReply, loadMessages, hasAgentReplyWithBody]);
|
|
189
241
|
|
|
190
242
|
const sendMessage = async () => {
|
|
191
243
|
if (!inputValue.trim() || (!contactId && !sessionId)) return;
|
|
@@ -220,15 +272,19 @@ export function ChatWidget({
|
|
|
220
272
|
captureEvent('chat_message_sent', { is_authenticated: Boolean(contactId) });
|
|
221
273
|
|
|
222
274
|
if (result.data?.job_id) {
|
|
275
|
+
// setWaitingForReply arms the sor polling effect; rails additionally
|
|
276
|
+
// subscribes to the realtime channel for push delivery.
|
|
223
277
|
setWaitingForReply(true);
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
278
|
+
if (!isSorBackend) {
|
|
279
|
+
const realtimeFromSend = result.data?.realtime_token && result.data?.contact_id && result.data?.cable_url
|
|
280
|
+
? {
|
|
281
|
+
token: result.data.realtime_token,
|
|
282
|
+
contact_id: result.data.contact_id,
|
|
283
|
+
cable_url: result.data.cable_url,
|
|
284
|
+
}
|
|
285
|
+
: null;
|
|
286
|
+
beginReplyWait({ realtimeData: realtimeFromSend });
|
|
287
|
+
}
|
|
232
288
|
} else if (result.data?.status === 'agent_unavailable' || result.data?.status === 'no_auto_reply') {
|
|
233
289
|
setIsLoading(false);
|
|
234
290
|
setWaitingForReply(false);
|
|
@@ -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;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which chat backend the site's /api/chat proxy targets.
|
|
3
|
+
*
|
|
4
|
+
* The two backends have architecturally different webchat contracts:
|
|
5
|
+
* - 'rails' (keystone-mono-proto): /public/messages + ActionCable realtime.
|
|
6
|
+
* - 'sor' (sor-service): /public/chat/* + widget long-polling.
|
|
7
|
+
*
|
|
8
|
+
* Resolution order:
|
|
9
|
+
* 1. CHAT_BACKEND env ('rails' | 'sor') — explicit override; needed for
|
|
10
|
+
* custom domains or local dev, where the API_URL hostname carries no signal.
|
|
11
|
+
* 2. Derived from API_URL: a hostname containing a `sor` label
|
|
12
|
+
* (sor.localkeystone.com, sor.dev.localkeystone.com, …) ⇒ 'sor'.
|
|
13
|
+
* 3. Default 'rails'.
|
|
14
|
+
*
|
|
15
|
+
* TODO(webchat): default is 'rails' while keystone-mono-proto is still a live
|
|
16
|
+
* backend; flip the default to 'sor' once the Rails backend is scrapped.
|
|
17
|
+
*/
|
|
18
|
+
import { serverError } from './server-log';
|
|
19
|
+
|
|
20
|
+
export const RAILS_BACKEND = 'rails' as const;
|
|
21
|
+
export const SOR_BACKEND = 'sor' as const;
|
|
22
|
+
export type ChatBackend = typeof RAILS_BACKEND | typeof SOR_BACKEND;
|
|
23
|
+
|
|
24
|
+
export function resolveChatBackend(): ChatBackend {
|
|
25
|
+
const explicit = process.env.CHAT_BACKEND;
|
|
26
|
+
if (explicit === SOR_BACKEND || explicit === RAILS_BACKEND) return explicit;
|
|
27
|
+
|
|
28
|
+
const apiUrl = process.env.API_URL || '';
|
|
29
|
+
try {
|
|
30
|
+
const host = new URL(apiUrl).hostname;
|
|
31
|
+
if (host.split('.').includes(SOR_BACKEND)) return SOR_BACKEND;
|
|
32
|
+
} catch (error) {
|
|
33
|
+
serverError('CHAT_BACKEND_API_URL_PARSE_ERROR', error, { apiUrl });
|
|
34
|
+
}
|
|
35
|
+
return RAILS_BACKEND;
|
|
36
|
+
}
|
|
@@ -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,6 +11,7 @@ import {
|
|
|
11
11
|
KeystoneAnalyticsTracker,
|
|
12
12
|
} from '../../tracking';
|
|
13
13
|
import { ChatWidget } from '../../design_system/components/ChatWidget';
|
|
14
|
+
import { resolveChatBackend } from '../../lib/chat-backend';
|
|
14
15
|
import { FormDefinitionsProvider } from '../contexts/form-definitions';
|
|
15
16
|
import { KeystoneSSRProvider } from '../providers/ssr-provider';
|
|
16
17
|
import {
|
|
@@ -182,6 +183,9 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
|
|
182
183
|
const portalUrl = ci?.portal_url?.trim() || null;
|
|
183
184
|
const bookingHref = portalUrl ?? externalManagementUrl ?? null;
|
|
184
185
|
const chatEnabled = Boolean(ci?.chat_enabled);
|
|
186
|
+
// Which backend the site's /api/chat proxy targets — same resolution as the
|
|
187
|
+
// route handlers (CHAT_BACKEND override, else derived from API_URL).
|
|
188
|
+
const chatBackend = resolveChatBackend();
|
|
185
189
|
|
|
186
190
|
const headerOverrides = options?.headerOverrides;
|
|
187
191
|
const posthogHost = options?.posthogHost?.trim() || undefined;
|
|
@@ -229,6 +233,7 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
|
|
229
233
|
{chatEnabled ? (
|
|
230
234
|
<ChatWidget
|
|
231
235
|
position={options?.chatPosition || 'bottom-right'}
|
|
236
|
+
chatBackend={chatBackend}
|
|
232
237
|
teamMembers={teamMembers}
|
|
233
238
|
/>
|
|
234
239
|
) : null}
|