keystone-design-bootstrap 1.0.55 → 1.0.56

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 (56) hide show
  1. package/dist/design_system/elements/index.js +8 -3
  2. package/dist/design_system/elements/index.js.map +1 -1
  3. package/dist/design_system/sections/index.js +203 -106
  4. package/dist/design_system/sections/index.js.map +1 -1
  5. package/dist/index.js +303 -247
  6. package/dist/index.js.map +1 -1
  7. package/dist/lib/hooks/index.js +72 -0
  8. package/dist/lib/hooks/index.js.map +1 -1
  9. package/dist/lib/server-api.js.map +1 -1
  10. package/dist/utils/phone-helpers.js +26 -0
  11. package/dist/utils/phone-helpers.js.map +1 -0
  12. package/package.json +5 -2
  13. package/src/design_system/components/ChatWidget.tsx +51 -34
  14. package/src/design_system/components/DynamicFormFields.tsx +1 -24
  15. package/src/design_system/elements/modal/modal.tsx +54 -35
  16. package/src/design_system/portal/LoginForm.tsx +339 -0
  17. package/src/design_system/portal/LoginModalController.tsx +63 -0
  18. package/src/design_system/portal/LogoutButton.tsx +23 -0
  19. package/src/design_system/portal/MessageComposer.tsx +84 -0
  20. package/src/design_system/portal/PortalPage.tsx +754 -0
  21. package/src/design_system/portal/RowThumbnail.tsx +76 -0
  22. package/src/design_system/portal/actions.ts +160 -0
  23. package/src/design_system/portal/index.ts +5 -0
  24. package/src/design_system/sections/index.tsx +1 -1
  25. package/src/design_system/sections/service-menu-section.tsx +7 -108
  26. package/src/lib/actions.ts +51 -115
  27. package/src/lib/consumer-session.ts +74 -0
  28. package/src/lib/hooks/index.ts +2 -0
  29. package/src/lib/hooks/use-image-cycle.ts +105 -0
  30. package/src/lib/server-api.ts +7 -6
  31. package/src/next/routes/chat.ts +30 -58
  32. package/src/next/routes/consumer-auth.ts +113 -0
  33. package/src/types/api/consumer.ts +39 -0
  34. package/src/types/api/offer.ts +1 -1
  35. package/src/types/api/package.ts +20 -0
  36. package/src/types/api/service.ts +6 -24
  37. package/src/types/index.ts +2 -0
  38. package/src/utils/phone-helpers.ts +27 -0
  39. package/dist/blog-post-DGjaJ3wf.d.ts +0 -50
  40. package/dist/contexts/index.d.ts +0 -13
  41. package/dist/design_system/elements/index.d.ts +0 -372
  42. package/dist/design_system/logo/keystone-logo.d.ts +0 -6
  43. package/dist/design_system/sections/index.d.ts +0 -237
  44. package/dist/form-CpsCONG5.d.ts +0 -151
  45. package/dist/index.d.ts +0 -76
  46. package/dist/lib/component-registry.d.ts +0 -13
  47. package/dist/lib/hooks/index.d.ts +0 -64
  48. package/dist/lib/server-api.d.ts +0 -43
  49. package/dist/themes/index.d.ts +0 -16
  50. package/dist/types/index.d.ts +0 -264
  51. package/dist/utils/cx.d.ts +0 -15
  52. package/dist/utils/gradient-placeholder.d.ts +0 -8
  53. package/dist/utils/is-react-component.d.ts +0 -21
  54. package/dist/utils/markdown-toc.d.ts +0 -14
  55. package/dist/utils/photo-helpers.d.ts +0 -37
  56. package/dist/website-photos-Bm-CBK9g.d.ts +0 -47
@@ -102,9 +102,81 @@ function useResizeObserver(options) {
102
102
  }
103
103
  }, [onResize, ref, box]);
104
104
  }
105
+
106
+ // src/lib/hooks/use-image-cycle.ts
107
+ import { useState as useState2, useEffect as useEffect2, useMemo } from "react";
108
+ var CYCLE_INTERVAL_MIN_MS = 6e3;
109
+ var CYCLE_INTERVAL_MAX_MS = 8e3;
110
+ var CROSSFADE_DURATION_MS = 600;
111
+ function seedToUnit(seed) {
112
+ let h = 2166136261 >>> 0;
113
+ for (let i = 0; i < seed.length; i++) {
114
+ h ^= seed.charCodeAt(i);
115
+ h = Math.imul(h, 16777619) >>> 0 >>> 0;
116
+ }
117
+ return (h >>> 0) / 4294967296;
118
+ }
119
+ function shuffleWithSeed(array, seed) {
120
+ if (array.length <= 1) return array;
121
+ const arr = [...array];
122
+ let h = 2166136261 >>> 0;
123
+ for (let i = 0; i < seed.length; i++) {
124
+ h ^= seed.charCodeAt(i);
125
+ h = Math.imul(h, 16777619) >>> 0 >>> 0;
126
+ }
127
+ const next = (step) => {
128
+ h = Math.imul(1664525, h + step >>> 0) + 1013904223 >>> 0;
129
+ return (h >>> 0) / 4294967296;
130
+ };
131
+ for (let i = arr.length - 1; i > 0; i--) {
132
+ const j = Math.floor(next(i) * (i + 1));
133
+ [arr[i], arr[j]] = [arr[j], arr[i]];
134
+ }
135
+ return arr;
136
+ }
137
+ function photoUrlFromAttachment(pa) {
138
+ var _a, _b, _c;
139
+ return ((_a = pa.photo) == null ? void 0 : _a.large_url) || ((_b = pa.photo) == null ? void 0 : _b.medium_url) || ((_c = pa.photo) == null ? void 0 : _c.thumbnail_url);
140
+ }
141
+ function photoAltFromAttachment(pa) {
142
+ var _a, _b;
143
+ return ((_a = pa.photo) == null ? void 0 : _a.alt_text) || ((_b = pa.photo) == null ? void 0 : _b.title) || "";
144
+ }
145
+ function useImageCycle(photoAttachments, seed) {
146
+ const list = useMemo(() => {
147
+ const arr = Array.isArray(photoAttachments) && photoAttachments.length > 0 ? photoAttachments : [];
148
+ if (arr.length === 0) return [];
149
+ return shuffleWithSeed(arr, seed).map((pa) => {
150
+ var _a;
151
+ return { url: (_a = photoUrlFromAttachment(pa)) != null ? _a : "", alt: photoAltFromAttachment(pa) };
152
+ }).filter((x) => x.url);
153
+ }, [photoAttachments, seed]);
154
+ const [currentIndex, setCurrentIndex] = useState2(0);
155
+ const [transitioning, setTransitioning] = useState2(false);
156
+ const intervalMs = useMemo(
157
+ () => CYCLE_INTERVAL_MIN_MS + Math.floor(seedToUnit(seed) * (CYCLE_INTERVAL_MAX_MS - CYCLE_INTERVAL_MIN_MS + 1)),
158
+ [seed]
159
+ );
160
+ useEffect2(() => {
161
+ if (list.length <= 1) return;
162
+ const id = setInterval(() => setTransitioning(true), intervalMs);
163
+ return () => clearInterval(id);
164
+ }, [list.length, intervalMs]);
165
+ useEffect2(() => {
166
+ if (!transitioning || list.length <= 1) return;
167
+ const t = setTimeout(() => {
168
+ setCurrentIndex((i) => (i + 1) % list.length);
169
+ setTransitioning(false);
170
+ }, CROSSFADE_DURATION_MS);
171
+ return () => clearTimeout(t);
172
+ }, [transitioning, list.length]);
173
+ const nextIndex = list.length > 1 ? (currentIndex + 1) % list.length : 0;
174
+ return { list, currentIndex, nextIndex, transitioning };
175
+ }
105
176
  export {
106
177
  useBreakpoint,
107
178
  useClipboard,
179
+ useImageCycle,
108
180
  useResizeObserver
109
181
  };
110
182
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/lib/hooks/use-breakpoint.ts","../../../src/lib/hooks/use-clipboard.ts","../../../src/lib/hooks/use-resize-observer.ts"],"sourcesContent":["\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\nconst screens = {\n sm: \"640px\",\n md: \"768px\",\n lg: \"1024px\",\n xl: \"1280px\",\n \"2xl\": \"1536px\",\n};\n\n/**\n * Checks whether a particular Tailwind CSS viewport size applies.\n *\n * @param size The size to check, which must either be included in Tailwind CSS's\n * list of default screen sizes, or added to the Tailwind CSS config file.\n *\n * @returns A boolean indicating whether the viewport size applies.\n */\nexport const useBreakpoint = (size: \"sm\" | \"md\" | \"lg\" | \"xl\" | \"2xl\") => {\n return useSyncExternalStore(\n (onStoreChange) => {\n if (typeof window === \"undefined\") {\n return () => {};\n }\n const breakpoint = window.matchMedia(`(min-width: ${screens[size]})`);\n breakpoint.addEventListener(\"change\", onStoreChange);\n return () => breakpoint.removeEventListener(\"change\", onStoreChange);\n },\n () => {\n if (typeof window === \"undefined\") {\n return true;\n }\n return window.matchMedia(`(min-width: ${screens[size]})`).matches;\n },\n () => true\n );\n};\n\n","\"use client\";\n\nimport { useCallback, useState } from \"react\";\n\nconst DEFAULT_TIMEOUT = 2000;\n\ntype UseClipboardReturnType = {\n /**\n * The state indicating whether the text has been copied.\n * If a string is provided, it will be used as the identifier for the copied state.\n */\n copied: string | boolean;\n /**\n * Function to copy text to the clipboard using the modern clipboard API.\n * Falls back to the fallback function if the modern API fails.\n *\n * @param {string} text - The text to be copied.\n * @param {string} [id] - Optional identifier to set the copied state.\n * @returns {Promise<Object>} - A promise that resolves to an object containing:\n * - `success` (boolean): Whether the copy operation was successful.\n * - `error` (Error | undefined): The error object if the copy operation failed.\n */\n copy: (text: string, id?: string) => Promise<{ success: boolean; error?: Error }>;\n};\n\n/**\n * Custom hook to copy text to the clipboard.\n *\n * @returns {UseClipboardReturnType} - An object containing the copied state and the copy function.\n */\nexport const useClipboard = (): UseClipboardReturnType => {\n const [copied, setCopied] = useState<string | boolean>(false);\n\n // Fallback function for older browsers\n const fallback = (text: string, id?: string) => {\n try {\n // Textarea to copy the text to the clipboard\n const textArea = document.createElement(\"textarea\");\n textArea.value = text;\n textArea.style.position = \"absolute\";\n textArea.style.left = \"-99999px\";\n\n document.body.appendChild(textArea);\n textArea.select();\n\n const success = document.execCommand(\"copy\");\n textArea.remove();\n\n setCopied(id || true);\n setTimeout(() => setCopied(false), DEFAULT_TIMEOUT);\n\n return success ? { success: true } : { success: false, error: new Error(\"execCommand returned false\") };\n } catch (err) {\n return {\n success: false,\n error: err instanceof Error ? err : new Error(\"Fallback copy failed\"),\n };\n }\n };\n\n const copy = useCallback(async (text: string, id?: string) => {\n if (navigator.clipboard && window.isSecureContext) {\n try {\n await navigator.clipboard.writeText(text);\n\n setCopied(id || true);\n setTimeout(() => setCopied(false), DEFAULT_TIMEOUT);\n\n return { success: true };\n } catch {\n // If modern method fails, try fallback\n return fallback(text, id);\n }\n }\n return fallback(text);\n }, []);\n\n return { copied, copy };\n};\n","import { useEffect } from \"react\";\nimport type { RefObject } from \"@react-types/shared\";\n\n/**\n * Checks if the ResizeObserver API is supported.\n * @returns True if the ResizeObserver API is supported, false otherwise.\n */\nfunction hasResizeObserver() {\n return typeof window.ResizeObserver !== \"undefined\";\n}\n\n/**\n * The options for the useResizeObserver hook.\n */\ntype useResizeObserverOptionsType<T> = {\n /**\n * The ref to the element to observe.\n */\n ref: RefObject<T | undefined | null> | undefined;\n /**\n * The box to observe.\n */\n box?: ResizeObserverBoxOptions;\n /**\n * The callback function to call when the size changes.\n */\n onResize: () => void;\n};\n\n/**\n * A hook that observes the size of an element and calls a callback function when the size changes.\n * @param options - The options for the hook.\n */\nexport function useResizeObserver<T extends Element>(options: useResizeObserverOptionsType<T>) {\n const { ref, box, onResize } = options;\n\n useEffect(() => {\n const element = ref?.current;\n if (!element) {\n return;\n }\n\n if (!hasResizeObserver()) {\n window.addEventListener(\"resize\", onResize, false);\n\n return () => {\n window.removeEventListener(\"resize\", onResize, false);\n };\n } else {\n const resizeObserverInstance = new window.ResizeObserver((entries) => {\n if (!entries.length) {\n return;\n }\n\n onResize();\n });\n\n resizeObserverInstance.observe(element, { box });\n\n return () => {\n if (element) {\n resizeObserverInstance.unobserve(element);\n }\n };\n }\n }, [onResize, ref, box]);\n}\n\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/lib/hooks/use-breakpoint.ts","../../../src/lib/hooks/use-clipboard.ts","../../../src/lib/hooks/use-resize-observer.ts","../../../src/lib/hooks/use-image-cycle.ts"],"sourcesContent":["\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\nconst screens = {\n sm: \"640px\",\n md: \"768px\",\n lg: \"1024px\",\n xl: \"1280px\",\n \"2xl\": \"1536px\",\n};\n\n/**\n * Checks whether a particular Tailwind CSS viewport size applies.\n *\n * @param size The size to check, which must either be included in Tailwind CSS's\n * list of default screen sizes, or added to the Tailwind CSS config file.\n *\n * @returns A boolean indicating whether the viewport size applies.\n */\nexport const useBreakpoint = (size: \"sm\" | \"md\" | \"lg\" | \"xl\" | \"2xl\") => {\n return useSyncExternalStore(\n (onStoreChange) => {\n if (typeof window === \"undefined\") {\n return () => {};\n }\n const breakpoint = window.matchMedia(`(min-width: ${screens[size]})`);\n breakpoint.addEventListener(\"change\", onStoreChange);\n return () => breakpoint.removeEventListener(\"change\", onStoreChange);\n },\n () => {\n if (typeof window === \"undefined\") {\n return true;\n }\n return window.matchMedia(`(min-width: ${screens[size]})`).matches;\n },\n () => true\n );\n};\n\n","\"use client\";\n\nimport { useCallback, useState } from \"react\";\n\nconst DEFAULT_TIMEOUT = 2000;\n\ntype UseClipboardReturnType = {\n /**\n * The state indicating whether the text has been copied.\n * If a string is provided, it will be used as the identifier for the copied state.\n */\n copied: string | boolean;\n /**\n * Function to copy text to the clipboard using the modern clipboard API.\n * Falls back to the fallback function if the modern API fails.\n *\n * @param {string} text - The text to be copied.\n * @param {string} [id] - Optional identifier to set the copied state.\n * @returns {Promise<Object>} - A promise that resolves to an object containing:\n * - `success` (boolean): Whether the copy operation was successful.\n * - `error` (Error | undefined): The error object if the copy operation failed.\n */\n copy: (text: string, id?: string) => Promise<{ success: boolean; error?: Error }>;\n};\n\n/**\n * Custom hook to copy text to the clipboard.\n *\n * @returns {UseClipboardReturnType} - An object containing the copied state and the copy function.\n */\nexport const useClipboard = (): UseClipboardReturnType => {\n const [copied, setCopied] = useState<string | boolean>(false);\n\n // Fallback function for older browsers\n const fallback = (text: string, id?: string) => {\n try {\n // Textarea to copy the text to the clipboard\n const textArea = document.createElement(\"textarea\");\n textArea.value = text;\n textArea.style.position = \"absolute\";\n textArea.style.left = \"-99999px\";\n\n document.body.appendChild(textArea);\n textArea.select();\n\n const success = document.execCommand(\"copy\");\n textArea.remove();\n\n setCopied(id || true);\n setTimeout(() => setCopied(false), DEFAULT_TIMEOUT);\n\n return success ? { success: true } : { success: false, error: new Error(\"execCommand returned false\") };\n } catch (err) {\n return {\n success: false,\n error: err instanceof Error ? err : new Error(\"Fallback copy failed\"),\n };\n }\n };\n\n const copy = useCallback(async (text: string, id?: string) => {\n if (navigator.clipboard && window.isSecureContext) {\n try {\n await navigator.clipboard.writeText(text);\n\n setCopied(id || true);\n setTimeout(() => setCopied(false), DEFAULT_TIMEOUT);\n\n return { success: true };\n } catch {\n // If modern method fails, try fallback\n return fallback(text, id);\n }\n }\n return fallback(text);\n }, []);\n\n return { copied, copy };\n};\n","import { useEffect } from \"react\";\nimport type { RefObject } from \"@react-types/shared\";\n\n/**\n * Checks if the ResizeObserver API is supported.\n * @returns True if the ResizeObserver API is supported, false otherwise.\n */\nfunction hasResizeObserver() {\n return typeof window.ResizeObserver !== \"undefined\";\n}\n\n/**\n * The options for the useResizeObserver hook.\n */\ntype useResizeObserverOptionsType<T> = {\n /**\n * The ref to the element to observe.\n */\n ref: RefObject<T | undefined | null> | undefined;\n /**\n * The box to observe.\n */\n box?: ResizeObserverBoxOptions;\n /**\n * The callback function to call when the size changes.\n */\n onResize: () => void;\n};\n\n/**\n * A hook that observes the size of an element and calls a callback function when the size changes.\n * @param options - The options for the hook.\n */\nexport function useResizeObserver<T extends Element>(options: useResizeObserverOptionsType<T>) {\n const { ref, box, onResize } = options;\n\n useEffect(() => {\n const element = ref?.current;\n if (!element) {\n return;\n }\n\n if (!hasResizeObserver()) {\n window.addEventListener(\"resize\", onResize, false);\n\n return () => {\n window.removeEventListener(\"resize\", onResize, false);\n };\n } else {\n const resizeObserverInstance = new window.ResizeObserver((entries) => {\n if (!entries.length) {\n return;\n }\n\n onResize();\n });\n\n resizeObserverInstance.observe(element, { box });\n\n return () => {\n if (element) {\n resizeObserverInstance.unobserve(element);\n }\n };\n }\n }, [onResize, ref, box]);\n}\n\n","'use client';\n\nimport { useState, useEffect, useMemo } from 'react';\nimport type { PhotoAttachment } from '../../types/api/photos';\n\nconst CYCLE_INTERVAL_MIN_MS = 6000;\nconst CYCLE_INTERVAL_MAX_MS = 8000;\nexport const CROSSFADE_DURATION_MS = 600;\n\nexport interface CycledImage {\n url: string;\n alt: string;\n}\n\nexport interface UseImageCycleResult {\n list: CycledImage[];\n currentIndex: number;\n nextIndex: number;\n transitioning: boolean;\n}\n\n/** Stable value in [0, 1) derived from seed string (FNV-1a hash). Same seed always produces same value. */\nfunction seedToUnit(seed: string): number {\n let h = 2166136261 >>> 0;\n for (let i = 0; i < seed.length; i++) {\n h ^= seed.charCodeAt(i);\n h = (Math.imul(h, 16777619) >>> 0) >>> 0;\n }\n return (h >>> 0) / 4294967296;\n}\n\n/** Seeded Fisher-Yates shuffle. Same seed produces same order (SSR-safe). */\nfunction shuffleWithSeed<T>(array: T[], seed: string): T[] {\n if (array.length <= 1) return array;\n const arr = [...array];\n let h = 2166136261 >>> 0;\n for (let i = 0; i < seed.length; i++) {\n h ^= seed.charCodeAt(i);\n h = (Math.imul(h, 16777619) >>> 0) >>> 0;\n }\n const next = (step: number) => {\n h = (Math.imul(1664525, (h + step) >>> 0) + 1013904223) >>> 0;\n return (h >>> 0) / 4294967296;\n };\n for (let i = arr.length - 1; i > 0; i--) {\n const j = Math.floor(next(i) * (i + 1));\n [arr[i], arr[j]] = [arr[j], arr[i]];\n }\n return arr;\n}\n\nfunction photoUrlFromAttachment(pa: PhotoAttachment): string | undefined {\n return pa.photo?.large_url || pa.photo?.medium_url || pa.photo?.thumbnail_url;\n}\n\nfunction photoAltFromAttachment(pa: PhotoAttachment): string {\n return pa.photo?.alt_text || pa.photo?.title || '';\n}\n\n/**\n * Cycles through a list of photo attachments with a seeded shuffle and crossfade transitions.\n *\n * Each card uses a unique seed so intervals are staggered — cards don't all transition in sync.\n * The shuffle is deterministic (SSR-safe) and the timer only activates when there are 2+ images.\n */\nexport function useImageCycle(\n photoAttachments: PhotoAttachment[] | undefined,\n seed: string\n): UseImageCycleResult {\n const list = useMemo<CycledImage[]>(() => {\n const arr = Array.isArray(photoAttachments) && photoAttachments.length > 0 ? photoAttachments : [];\n if (arr.length === 0) return [];\n return shuffleWithSeed(arr, seed)\n .map((pa) => ({ url: photoUrlFromAttachment(pa) ?? '', alt: photoAltFromAttachment(pa) }))\n .filter((x) => x.url);\n }, [photoAttachments, seed]);\n\n const [currentIndex, setCurrentIndex] = useState(0);\n const [transitioning, setTransitioning] = useState(false);\n\n // Per-card random interval (6–8 s) so cards don't all transition in sync\n const intervalMs = useMemo(\n () => CYCLE_INTERVAL_MIN_MS + Math.floor(seedToUnit(seed) * (CYCLE_INTERVAL_MAX_MS - CYCLE_INTERVAL_MIN_MS + 1)),\n [seed]\n );\n\n useEffect(() => {\n if (list.length <= 1) return;\n const id = setInterval(() => setTransitioning(true), intervalMs);\n return () => clearInterval(id);\n }, [list.length, intervalMs]);\n\n useEffect(() => {\n if (!transitioning || list.length <= 1) return;\n const t = setTimeout(() => {\n setCurrentIndex((i) => (i + 1) % list.length);\n setTransitioning(false);\n }, CROSSFADE_DURATION_MS);\n return () => clearTimeout(t);\n }, [transitioning, list.length]);\n\n const nextIndex = list.length > 1 ? (currentIndex + 1) % list.length : 0;\n\n return { list, currentIndex, nextIndex, transitioning };\n}\n"],"mappings":";AAEA,SAAS,4BAA4B;AAErC,IAAM,UAAU;AAAA,EACZ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,OAAO;AACX;AAUO,IAAM,gBAAgB,CAAC,SAA4C;AACtE,SAAO;AAAA,IACH,CAAC,kBAAkB;AACf,UAAI,OAAO,WAAW,aAAa;AAC/B,eAAO,MAAM;AAAA,QAAC;AAAA,MAClB;AACA,YAAM,aAAa,OAAO,WAAW,eAAe,QAAQ,IAAI,CAAC,GAAG;AACpE,iBAAW,iBAAiB,UAAU,aAAa;AACnD,aAAO,MAAM,WAAW,oBAAoB,UAAU,aAAa;AAAA,IACvE;AAAA,IACA,MAAM;AACF,UAAI,OAAO,WAAW,aAAa;AAC/B,eAAO;AAAA,MACX;AACA,aAAO,OAAO,WAAW,eAAe,QAAQ,IAAI,CAAC,GAAG,EAAE;AAAA,IAC9D;AAAA,IACA,MAAM;AAAA,EACV;AACJ;;;ACpCA,SAAS,aAAa,gBAAgB;AAEtC,IAAM,kBAAkB;AA0BjB,IAAM,eAAe,MAA8B;AACtD,QAAM,CAAC,QAAQ,SAAS,IAAI,SAA2B,KAAK;AAG5D,QAAM,WAAW,CAAC,MAAc,OAAgB;AAC5C,QAAI;AAEA,YAAM,WAAW,SAAS,cAAc,UAAU;AAClD,eAAS,QAAQ;AACjB,eAAS,MAAM,WAAW;AAC1B,eAAS,MAAM,OAAO;AAEtB,eAAS,KAAK,YAAY,QAAQ;AAClC,eAAS,OAAO;AAEhB,YAAM,UAAU,SAAS,YAAY,MAAM;AAC3C,eAAS,OAAO;AAEhB,gBAAU,MAAM,IAAI;AACpB,iBAAW,MAAM,UAAU,KAAK,GAAG,eAAe;AAElD,aAAO,UAAU,EAAE,SAAS,KAAK,IAAI,EAAE,SAAS,OAAO,OAAO,IAAI,MAAM,4BAA4B,EAAE;AAAA,IAC1G,SAAS,KAAK;AACV,aAAO;AAAA,QACH,SAAS;AAAA,QACT,OAAO,eAAe,QAAQ,MAAM,IAAI,MAAM,sBAAsB;AAAA,MACxE;AAAA,IACJ;AAAA,EACJ;AAEA,QAAM,OAAO,YAAY,OAAO,MAAc,OAAgB;AAC1D,QAAI,UAAU,aAAa,OAAO,iBAAiB;AAC/C,UAAI;AACA,cAAM,UAAU,UAAU,UAAU,IAAI;AAExC,kBAAU,MAAM,IAAI;AACpB,mBAAW,MAAM,UAAU,KAAK,GAAG,eAAe;AAElD,eAAO,EAAE,SAAS,KAAK;AAAA,MAC3B,SAAQ;AAEJ,eAAO,SAAS,MAAM,EAAE;AAAA,MAC5B;AAAA,IACJ;AACA,WAAO,SAAS,IAAI;AAAA,EACxB,GAAG,CAAC,CAAC;AAEL,SAAO,EAAE,QAAQ,KAAK;AAC1B;;;AC9EA,SAAS,iBAAiB;AAO1B,SAAS,oBAAoB;AACzB,SAAO,OAAO,OAAO,mBAAmB;AAC5C;AAwBO,SAAS,kBAAqC,SAA0C;AAC3F,QAAM,EAAE,KAAK,KAAK,SAAS,IAAI;AAE/B,YAAU,MAAM;AACZ,UAAM,UAAU,2BAAK;AACrB,QAAI,CAAC,SAAS;AACV;AAAA,IACJ;AAEA,QAAI,CAAC,kBAAkB,GAAG;AACtB,aAAO,iBAAiB,UAAU,UAAU,KAAK;AAEjD,aAAO,MAAM;AACT,eAAO,oBAAoB,UAAU,UAAU,KAAK;AAAA,MACxD;AAAA,IACJ,OAAO;AACH,YAAM,yBAAyB,IAAI,OAAO,eAAe,CAAC,YAAY;AAClE,YAAI,CAAC,QAAQ,QAAQ;AACjB;AAAA,QACJ;AAEA,iBAAS;AAAA,MACb,CAAC;AAED,6BAAuB,QAAQ,SAAS,EAAE,IAAI,CAAC;AAE/C,aAAO,MAAM;AACT,YAAI,SAAS;AACT,iCAAuB,UAAU,OAAO;AAAA,QAC5C;AAAA,MACJ;AAAA,IACJ;AAAA,EACJ,GAAG,CAAC,UAAU,KAAK,GAAG,CAAC;AAC3B;;;AChEA,SAAS,YAAAA,WAAU,aAAAC,YAAW,eAAe;AAG7C,IAAM,wBAAwB;AAC9B,IAAM,wBAAwB;AACvB,IAAM,wBAAwB;AAerC,SAAS,WAAW,MAAsB;AACxC,MAAI,IAAI,eAAe;AACvB,WAAS,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK;AACpC,SAAK,KAAK,WAAW,CAAC;AACtB,QAAK,KAAK,KAAK,GAAG,QAAQ,MAAM,MAAO;AAAA,EACzC;AACA,UAAQ,MAAM,KAAK;AACrB;AAGA,SAAS,gBAAmB,OAAY,MAAmB;AACzD,MAAI,MAAM,UAAU,EAAG,QAAO;AAC9B,QAAM,MAAM,CAAC,GAAG,KAAK;AACrB,MAAI,IAAI,eAAe;AACvB,WAAS,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK;AACpC,SAAK,KAAK,WAAW,CAAC;AACtB,QAAK,KAAK,KAAK,GAAG,QAAQ,MAAM,MAAO;AAAA,EACzC;AACA,QAAM,OAAO,CAAC,SAAiB;AAC7B,QAAK,KAAK,KAAK,SAAU,IAAI,SAAU,CAAC,IAAI,eAAgB;AAC5D,YAAQ,MAAM,KAAK;AAAA,EACrB;AACA,WAAS,IAAI,IAAI,SAAS,GAAG,IAAI,GAAG,KAAK;AACvC,UAAM,IAAI,KAAK,MAAM,KAAK,CAAC,KAAK,IAAI,EAAE;AACtC,KAAC,IAAI,CAAC,GAAG,IAAI,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC,CAAC;AAAA,EACpC;AACA,SAAO;AACT;AAEA,SAAS,uBAAuB,IAAyC;AAnDzE;AAoDE,WAAO,QAAG,UAAH,mBAAU,gBAAa,QAAG,UAAH,mBAAU,iBAAc,QAAG,UAAH,mBAAU;AAClE;AAEA,SAAS,uBAAuB,IAA6B;AAvD7D;AAwDE,WAAO,QAAG,UAAH,mBAAU,eAAY,QAAG,UAAH,mBAAU,UAAS;AAClD;AAQO,SAAS,cACd,kBACA,MACqB;AACrB,QAAM,OAAO,QAAuB,MAAM;AACxC,UAAM,MAAM,MAAM,QAAQ,gBAAgB,KAAK,iBAAiB,SAAS,IAAI,mBAAmB,CAAC;AACjG,QAAI,IAAI,WAAW,EAAG,QAAO,CAAC;AAC9B,WAAO,gBAAgB,KAAK,IAAI,EAC7B,IAAI,CAAC,OAAI;AAzEhB;AAyEoB,eAAE,MAAK,4BAAuB,EAAE,MAAzB,YAA8B,IAAI,KAAK,uBAAuB,EAAE,EAAE;AAAA,KAAE,EACxF,OAAO,CAAC,MAAM,EAAE,GAAG;AAAA,EACxB,GAAG,CAAC,kBAAkB,IAAI,CAAC;AAE3B,QAAM,CAAC,cAAc,eAAe,IAAID,UAAS,CAAC;AAClD,QAAM,CAAC,eAAe,gBAAgB,IAAIA,UAAS,KAAK;AAGxD,QAAM,aAAa;AAAA,IACjB,MAAM,wBAAwB,KAAK,MAAM,WAAW,IAAI,KAAK,wBAAwB,wBAAwB,EAAE;AAAA,IAC/G,CAAC,IAAI;AAAA,EACP;AAEA,EAAAC,WAAU,MAAM;AACd,QAAI,KAAK,UAAU,EAAG;AACtB,UAAM,KAAK,YAAY,MAAM,iBAAiB,IAAI,GAAG,UAAU;AAC/D,WAAO,MAAM,cAAc,EAAE;AAAA,EAC/B,GAAG,CAAC,KAAK,QAAQ,UAAU,CAAC;AAE5B,EAAAA,WAAU,MAAM;AACd,QAAI,CAAC,iBAAiB,KAAK,UAAU,EAAG;AACxC,UAAM,IAAI,WAAW,MAAM;AACzB,sBAAgB,CAAC,OAAO,IAAI,KAAK,KAAK,MAAM;AAC5C,uBAAiB,KAAK;AAAA,IACxB,GAAG,qBAAqB;AACxB,WAAO,MAAM,aAAa,CAAC;AAAA,EAC7B,GAAG,CAAC,eAAe,KAAK,MAAM,CAAC;AAE/B,QAAM,YAAY,KAAK,SAAS,KAAK,eAAe,KAAK,KAAK,SAAS;AAEvE,SAAO,EAAE,MAAM,cAAc,WAAW,cAAc;AACxD;","names":["useState","useEffect"]}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/lib/server-api.ts"],"sourcesContent":["/**\n * Server-side API client for SSR\n * API key is stored securely on the server - never exposed to browser\n */\n\nimport type { CompanyInformation } from '../types/api/company-information';\nimport type { Service } from '../types/api/service';\nimport type { WebsitePhotos } from '../types/api/website-photos';\n\nconst API_URL = process.env.API_URL || 'http://localhost:3000/api/v1';\nconst API_KEY = process.env.API_KEY || '';\n\ninterface FetchOptions {\n cache?: RequestCache;\n revalidate?: number;\n}\n\nasync function serverFetch<T>(endpoint: string, options: FetchOptions = {}): Promise<T | null> {\n const url = `${API_URL}${endpoint}`;\n \n try {\n const fetchOptions: RequestInit & { next?: { revalidate?: number } } = {\n headers: {\n 'X-API-Key': API_KEY,\n 'Content-Type': 'application/json',\n },\n cache: options.cache,\n };\n \n if (options.revalidate) {\n fetchOptions.next = { revalidate: options.revalidate };\n }\n \n const response = await fetch(url, fetchOptions);\n\n if (!response.ok) {\n console.error(`[Server API] Error ${response.status} for ${endpoint}`);\n return null;\n }\n\n const json = await response.json();\n \n // Rails API returns { data: [...], meta: {...} }\n return json.data ?? json;\n } catch (error) {\n console.error(`[Server API] Failed to fetch ${endpoint}:`, error);\n return null;\n }\n}\n\n/**\n * Generic serverApi object for flexible endpoint access\n */\nexport const serverApi = {\n get: <T = unknown>(endpoint: string, options?: FetchOptions): Promise<T | null> => {\n return serverFetch<T>(endpoint, options || { revalidate: 60 });\n }\n};\n\n// Revalidate data every 60 seconds (ISR)\nconst defaultOptions: FetchOptions = { revalidate: 60 };\n\nexport async function getCompanyInformation(): Promise<CompanyInformation | null> {\n return serverFetch<CompanyInformation>('/public/company_information', defaultOptions);\n}\n\n/** Ads config (e.g. Meta Pixel). Returns { meta_pixel_id?: string } or {}. Only present when account has connected Meta Ads. */\nexport async function getAdsConfig(): Promise<{ meta_pixel_id?: string } | null> {\n const data = await serverFetch<{ meta_pixel_id?: string }>('/public/ads_config', defaultOptions);\n return data ?? null;\n}\n\n/** Extract Meta Pixel ID from ads config for use with <MetaPixel pixelId={...} />. Only returns a value when present and valid (numeric). */\nexport function getMetaPixelId(adsConfig: { meta_pixel_id?: string } | null | undefined): string | null {\n const id = adsConfig && typeof adsConfig === 'object' && 'meta_pixel_id' in adsConfig && adsConfig.meta_pixel_id;\n const str = id != null && id !== '' ? String(id).trim() : '';\n return str !== '' && str !== 'null' && /^\\d+$/.test(str) ? str : null;\n}\n\nexport async function getServices() {\n return serverFetch('/public/services', defaultOptions);\n}\n\nexport async function getService(slug: string): Promise<Service | null> {\n return serverFetch<Service>(`/public/services/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getLocations() {\n return serverFetch('/public/locations', defaultOptions);\n}\n\nexport async function getLocation(slug: string) {\n return serverFetch(`/public/locations/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getReviews() {\n return serverFetch('/public/reviews', defaultOptions);\n}\n\nexport async function getFAQs() {\n return serverFetch('/public/faq_questions', defaultOptions);\n}\n\nexport async function getBlogPosts() {\n return serverFetch('/public/blog_posts', defaultOptions);\n}\n\nexport async function getBlogPost(slug: string) {\n return serverFetch(`/public/blog_posts/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getTeamMembers() {\n return serverFetch('/public/team_members', defaultOptions);\n}\n\nexport async function getWebsitePhotos(): Promise<WebsitePhotos | null> {\n return serverFetch<WebsitePhotos>('/public/website_photos', defaultOptions);\n}\n\nexport async function getJobPostings() {\n return serverFetch('/public/job_postings', defaultOptions);\n}\n\nexport async function getJobPosting(slug: string) {\n return serverFetch(`/public/job_postings/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getSocialPosts() {\n return serverFetch('/public/social_posts', defaultOptions);\n}\n\n/** Packages (bundles of service items). */\nexport async function getPackages() {\n return serverFetch('/public/packages', defaultOptions);\n}\n\nexport async function getPackage(slug: string) {\n return serverFetch(`/public/packages/by_slug/${encodeURIComponent(slug)}`, defaultOptions);\n}\n\n// Alias for testimonials (API uses \"reviews\")\nexport async function getTestimonials() {\n return getReviews();\n}\n\n/** Form definition for dynamic form rendering (fields may include optional placeholder). */\nexport async function getForm(formType: string) {\n type FormDefinition = import('../types/api/form').FormDefinition;\n return serverFetch<FormDefinition>(`/public/forms/${encodeURIComponent(formType)}`, defaultOptions);\n}\n\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/lib/server-api.ts"],"sourcesContent":["/**\n * Server-side API client for SSR\n * API key is stored securely on the server - never exposed to browser\n */\n\nimport type { CompanyInformation } from '../types/api/company-information';\nimport type { Service } from '../types/api/service';\nimport type { Package } from '../types/api/package';\nimport type { WebsitePhotos } from '../types/api/website-photos';\n\nconst API_URL = process.env.API_URL || 'http://localhost:3000/api/v1';\nconst API_KEY = process.env.API_KEY || '';\n\ninterface FetchOptions {\n cache?: RequestCache;\n revalidate?: number;\n}\n\nasync function serverFetch<T>(endpoint: string, options: FetchOptions = {}): Promise<T | null> {\n const url = `${API_URL}${endpoint}`;\n \n try {\n const fetchOptions: RequestInit & { next?: { revalidate?: number } } = {\n headers: {\n 'X-API-Key': API_KEY,\n 'Content-Type': 'application/json',\n },\n cache: options.cache,\n };\n \n if (options.revalidate) {\n fetchOptions.next = { revalidate: options.revalidate };\n }\n \n const response = await fetch(url, fetchOptions);\n\n if (!response.ok) {\n console.error(`[Server API] Error ${response.status} for ${endpoint}`);\n return null;\n }\n\n const json = await response.json();\n \n // Rails API returns { data: [...], meta: {...} }\n return json.data ?? json;\n } catch (error) {\n console.error(`[Server API] Failed to fetch ${endpoint}:`, error);\n return null;\n }\n}\n\n/**\n * Generic serverApi object for flexible endpoint access\n */\nexport const serverApi = {\n get: <T = unknown>(endpoint: string, options?: FetchOptions): Promise<T | null> => {\n return serverFetch<T>(endpoint, options || { revalidate: 60 });\n }\n};\n\n// Revalidate data every 60 seconds (ISR)\nconst defaultOptions: FetchOptions = { revalidate: 60 };\n\nexport async function getCompanyInformation(): Promise<CompanyInformation | null> {\n return serverFetch<CompanyInformation>('/public/company_information', defaultOptions);\n}\n\n/** Ads config (e.g. Meta Pixel). Returns { meta_pixel_id?: string } or {}. Only present when account has connected Meta Ads. */\nexport async function getAdsConfig(): Promise<{ meta_pixel_id?: string } | null> {\n const data = await serverFetch<{ meta_pixel_id?: string }>('/public/ads_config', defaultOptions);\n return data ?? null;\n}\n\n/** Extract Meta Pixel ID from ads config for use with <MetaPixel pixelId={...} />. Only returns a value when present and valid (numeric). */\nexport function getMetaPixelId(adsConfig: { meta_pixel_id?: string } | null | undefined): string | null {\n const id = adsConfig && typeof adsConfig === 'object' && 'meta_pixel_id' in adsConfig && adsConfig.meta_pixel_id;\n const str = id != null && id !== '' ? String(id).trim() : '';\n return str !== '' && str !== 'null' && /^\\d+$/.test(str) ? str : null;\n}\n\nexport async function getServices(): Promise<Service[] | null> {\n return serverFetch<Service[]>('/public/services', defaultOptions);\n}\n\nexport async function getService(slug: string): Promise<Service | null> {\n return serverFetch<Service>(`/public/services/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getLocations() {\n return serverFetch('/public/locations', defaultOptions);\n}\n\nexport async function getLocation(slug: string) {\n return serverFetch(`/public/locations/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getReviews() {\n return serverFetch('/public/reviews', defaultOptions);\n}\n\nexport async function getFAQs() {\n return serverFetch('/public/faq_questions', defaultOptions);\n}\n\nexport async function getBlogPosts() {\n return serverFetch('/public/blog_posts', defaultOptions);\n}\n\nexport async function getBlogPost(slug: string) {\n return serverFetch(`/public/blog_posts/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getTeamMembers() {\n return serverFetch('/public/team_members', defaultOptions);\n}\n\nexport async function getWebsitePhotos(): Promise<WebsitePhotos | null> {\n return serverFetch<WebsitePhotos>('/public/website_photos', defaultOptions);\n}\n\nexport async function getJobPostings() {\n return serverFetch('/public/job_postings', defaultOptions);\n}\n\nexport async function getJobPosting(slug: string) {\n return serverFetch(`/public/job_postings/by_slug/${slug}`, defaultOptions);\n}\n\nexport async function getSocialPosts() {\n return serverFetch('/public/social_posts', defaultOptions);\n}\n\n/** Packages (bundles of service items). */\nexport async function getPackages(): Promise<Package[] | null> {\n return serverFetch<Package[]>('/public/packages', defaultOptions);\n}\n\nexport async function getPackage(slug: string): Promise<Package | null> {\n return serverFetch<Package>(`/public/packages/by_slug/${encodeURIComponent(slug)}`, defaultOptions);\n}\n\n// Alias for testimonials (API uses \"reviews\")\nexport async function getTestimonials() {\n return getReviews();\n}\n\n/** Form definition for dynamic form rendering (fields may include optional placeholder). */\nexport async function getForm(formType: string) {\n type FormDefinition = import('../types/api/form').FormDefinition;\n return serverFetch<FormDefinition>(`/public/forms/${encodeURIComponent(formType)}`, defaultOptions);\n}\n\n"],"mappings":"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","names":[]}
@@ -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":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keystone-design-bootstrap",
3
- "version": "1.0.55",
3
+ "version": "1.0.56",
4
4
  "description": "Keystone Design Bootstrap - Sections, Elements, and Theme System for customer websites",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -27,7 +27,10 @@
27
27
  "./styles/*": "./src/styles/*",
28
28
  "./types": "./src/types/index.ts",
29
29
  "./themes": "./src/themes/index.ts",
30
- "./utils/*": "./src/utils/*"
30
+ "./utils/*": "./src/utils/*",
31
+ "./portal": "./src/design_system/portal/index.ts",
32
+ "./lib/consumer-session": "./src/lib/consumer-session.ts",
33
+ "./next/routes/consumer-auth": "./src/next/routes/consumer-auth.ts"
31
34
  },
32
35
  "repository": {
33
36
  "type": "git",
@@ -23,7 +23,17 @@ interface TeamMember {
23
23
  interface ChatWidgetProps {
24
24
  position?: 'bottom-right' | 'bottom-left';
25
25
  primaryColor?: string;
26
+ /**
27
+ * Anonymous session identifier. Used when the visitor is not authenticated.
28
+ * Mutually exclusive with `contactId` — if both are provided, `contactId` wins.
29
+ */
26
30
  sessionId?: string;
31
+ /**
32
+ * Authenticated contact ID. When provided, messages are loaded and sent using
33
+ * the contact-driven flow instead of the anonymous session flow.
34
+ * The `/api/chat` route must support `contact_id` (see `createChatRouteHandlers`).
35
+ */
36
+ contactId?: number;
27
37
  displayName?: string;
28
38
  teamMembers?: TeamMember[];
29
39
  }
@@ -54,6 +64,7 @@ export function ChatWidget({
54
64
  position = 'bottom-right',
55
65
  primaryColor,
56
66
  sessionId: providedSessionId,
67
+ contactId,
57
68
  displayName: providedDisplayName,
58
69
  teamMembers = []
59
70
  }: ChatWidgetProps) {
@@ -65,8 +76,9 @@ export function ChatWidget({
65
76
  const [waitingForReply, setWaitingForReply] = useState(false);
66
77
  const messagesEndRef = useRef<HTMLDivElement>(null);
67
78
 
68
- // Generate or retrieve session ID
79
+ // When authenticated (contactId), skip session management. Otherwise, generate/retrieve session ID.
69
80
  useEffect(() => {
81
+ if (contactId) return;
70
82
  if (providedSessionId) {
71
83
  setSessionId(providedSessionId);
72
84
  } else {
@@ -74,17 +86,21 @@ export function ChatWidget({
74
86
  if (stored) {
75
87
  setSessionId(stored);
76
88
  } else {
77
- const newId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
89
+ const newId = `session_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
78
90
  localStorage.setItem('keystone_chat_session_id', newId);
79
91
  setSessionId(newId);
80
92
  }
81
93
  }
82
- }, [providedSessionId]);
94
+ }, [contactId, providedSessionId]);
83
95
 
84
96
  const loadMessages = useCallback(async () => {
97
+ // Need either a contactId or an active session before fetching.
98
+ if (!contactId && !sessionId) return [];
85
99
  try {
86
- const response = await fetch(`/api/chat/?identifier=${encodeURIComponent(sessionId)}`);
87
-
100
+ const query = contactId
101
+ ? `contact_id=${encodeURIComponent(contactId)}`
102
+ : `identifier=${encodeURIComponent(sessionId)}`;
103
+ const response = await fetch(`/api/chat/?${query}`);
88
104
  if (response.ok) {
89
105
  const result = await response.json();
90
106
  const newMessages = result.data || [];
@@ -95,14 +111,14 @@ export function ChatWidget({
95
111
  console.error('Failed to load messages:', error);
96
112
  }
97
113
  return [];
98
- }, [sessionId]);
114
+ }, [contactId, sessionId]);
99
115
 
100
- // Load message history when opened
116
+ // Load message history when opened (either authenticated or session is ready)
101
117
  useEffect(() => {
102
- if (isOpen && sessionId) {
118
+ if (isOpen && (contactId || sessionId)) {
103
119
  loadMessages();
104
120
  }
105
- }, [isOpen, sessionId, loadMessages]);
121
+ }, [isOpen, contactId, sessionId, loadMessages]);
106
122
 
107
123
  // Auto-scroll to bottom
108
124
  useEffect(() => {
@@ -110,45 +126,49 @@ export function ChatWidget({
110
126
  }, [messages]);
111
127
 
112
128
  // Poll for agent reply (simpler than WebSockets for public widget)
129
+ const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
130
+
131
+ // Clear any in-flight poll on unmount
132
+ useEffect(() => {
133
+ return () => {
134
+ if (pollIntervalRef.current !== null) {
135
+ clearInterval(pollIntervalRef.current);
136
+ }
137
+ };
138
+ }, []);
139
+
113
140
  const pollForAgentReply = () => {
114
141
  setWaitingForReply(true);
115
142
 
116
143
  let attempts = 0;
117
- const maxAttempts = 30; // 30 seconds max
144
+ const maxAttempts = 30;
118
145
 
119
- const pollInterval = setInterval(async () => {
146
+ pollIntervalRef.current = setInterval(async () => {
120
147
  attempts++;
121
148
 
122
149
  try {
123
150
  const newMessages = await loadMessages();
124
151
 
125
- // Clear as soon as the latest message is an agent reply with content (don't rely on count increase — can fail at list cap or with optimistic updates)
126
152
  const latest = newMessages[newMessages.length - 1];
127
153
  const hasAgentReplyWithBody =
128
154
  latest?.sender_type === 'agent' &&
129
155
  latest?.body != null &&
130
156
  String(latest.body).trim() !== '';
131
157
 
132
- if (hasAgentReplyWithBody) {
133
- clearInterval(pollInterval);
134
- setWaitingForReply(false);
135
- setIsLoading(false);
136
- return;
137
- }
138
- // Stop polling after max attempts
139
- if (attempts >= maxAttempts) {
140
- clearInterval(pollInterval);
158
+ if (hasAgentReplyWithBody || attempts >= maxAttempts) {
159
+ clearInterval(pollIntervalRef.current!);
160
+ pollIntervalRef.current = null;
141
161
  setWaitingForReply(false);
142
162
  setIsLoading(false);
143
163
  }
144
164
  } catch (error) {
145
165
  console.error('[ChatWidget] Error polling for messages:', error);
146
166
  }
147
- }, 1000); // Poll every second
167
+ }, 1000);
148
168
  };
149
169
 
150
170
  const sendMessage = async () => {
151
- if (!inputValue.trim() || !sessionId) return;
171
+ if (!inputValue.trim() || (!contactId && !sessionId)) return;
152
172
 
153
173
  const messageText = inputValue.trim();
154
174
  setInputValue('');
@@ -167,15 +187,12 @@ export function ChatWidget({
167
187
  try {
168
188
  const response = await fetch('/api/chat/', {
169
189
  method: 'POST',
170
- headers: {
171
- 'Content-Type': 'application/json'
172
- },
173
- body: JSON.stringify({
174
- identifier: sessionId,
175
- body: messageText,
176
- display_name: providedDisplayName,
177
- page_url: window.location.href
178
- })
190
+ headers: { 'Content-Type': 'application/json' },
191
+ body: JSON.stringify(
192
+ contactId
193
+ ? { contact_id: contactId, body: messageText, display_name: providedDisplayName, page_url: window.location.href }
194
+ : { identifier: sessionId, body: messageText, display_name: providedDisplayName, page_url: window.location.href }
195
+ ),
179
196
  });
180
197
 
181
198
  if (response.ok) {
@@ -206,7 +223,7 @@ export function ChatWidget({
206
223
  }
207
224
  };
208
225
 
209
- const handleKeyPress = (e: React.KeyboardEvent) => {
226
+ const handleKeyDown = (e: React.KeyboardEvent) => {
210
227
  if (e.key === 'Enter' && !e.shiftKey) {
211
228
  e.preventDefault();
212
229
  sendMessage();
@@ -394,7 +411,7 @@ export function ChatWidget({
394
411
  type="text"
395
412
  value={inputValue}
396
413
  onChange={(e) => setInputValue(e.target.value)}
397
- onKeyPress={handleKeyPress}
414
+ onKeyDown={handleKeyDown}
398
415
  placeholder="Type a message..."
399
416
  disabled={isLoading}
400
417
  className="flex-1 rounded-lg border border-secondary bg-primary px-3 py-2.5 text-sm text-primary outline-none ring-brand transition-colors placeholder:text-placeholder focus:border-brand focus:ring-1 disabled:cursor-not-allowed disabled:bg-disabled"
@@ -6,6 +6,7 @@ import remarkGfm from 'remark-gfm';
6
6
  import { Input, InputBase, InputGroup, NativeSelect, Textarea, PrivacyCheckbox } from '../elements';
7
7
  import type { FormDefinition, FormFieldDefinition } from '../../types/api/form';
8
8
  import countries from '../../utils/countries';
9
+ import { getNationalMask, formatDigitsToMask } from '../../utils/phone-helpers';
9
10
 
10
11
  export interface DynamicFormFieldsProps {
11
12
  /** Form definition from API (fields array + optional settings). */
@@ -19,30 +20,6 @@ export interface DynamicFormFieldsProps {
19
20
 
20
21
  const INPUT_TYPES = ['text', 'email', 'tel'] as const;
21
22
 
22
- /** Get national-format mask from country (e.g. "(###) ###-####") by stripping country code from phoneMask. */
23
- function getNationalMask(country: (typeof countries)[0] | undefined): string {
24
- if (!country?.phoneMask) return '';
25
- const code = country.phoneCode.startsWith('+') ? country.phoneCode : `+${country.phoneCode}`;
26
- const escaped = code.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
27
- return country.phoneMask.replace(new RegExp(`^\\s*${escaped}[\\s-]*`), '').trim();
28
- }
29
-
30
- /** Format digit string into mask; # = one digit. No trailing literals so backspace works. */
31
- function formatDigitsToMask(digits: string, mask: string): string {
32
- if (digits.length === 0) return '';
33
- let i = 0;
34
- let out = '';
35
- for (const c of mask) {
36
- if (c === '#') {
37
- if (i < digits.length) out += digits[i++];
38
- else break;
39
- } else if (i < digits.length) {
40
- out += c;
41
- }
42
- }
43
- return out;
44
- }
45
-
46
23
  function allFieldsFlat(fields: FormDefinition['fields']): FormFieldDefinition[] {
47
24
  const out: FormFieldDefinition[] = [];
48
25
  for (const item of fields) {
@@ -13,6 +13,16 @@ export interface ModalProps {
13
13
  titleId?: string;
14
14
  /** Dialog content */
15
15
  children: React.ReactNode;
16
+ /** Optional footer rendered below scrollable content (e.g. branding or action bars). */
17
+ footer?: React.ReactNode;
18
+ /**
19
+ * Hide the built-in header bar and close button. Use when the content provides its own
20
+ * title and close affordance (e.g. a form with its own header). Escape and overlay click
21
+ * still close the dialog.
22
+ */
23
+ hideHeader?: boolean;
24
+ /** Accessible name for the dialog when no visible title is rendered (i.e. when hideHeader is true). */
25
+ ariaLabel?: string;
16
26
  /** Optional className for the overlay (backdrop) */
17
27
  overlayClassName?: string;
18
28
  /** Optional className for the dialog panel */
@@ -33,8 +43,9 @@ const MAX_WIDTH_CLASSES: Record<NonNullable<ModalProps['maxWidth']>, string> = {
33
43
  };
34
44
 
35
45
  /**
36
- * Shared modal (dialog) component. Accessible: focus trap, Escape to close, aria-modal, overlay click to close.
46
+ * Shared modal (dialog) component. Escape to close, aria-modal, overlay click to close, scroll lock, focus return on close.
37
47
  * Use for detail views, confirmations, and other overlay content. Renders in place (use a portal from the consumer if needed).
48
+ * When `hideHeader` is true, provide an `aria-label` via `panelClassName` or ensure children supply a visible heading.
38
49
  */
39
50
  export function Modal({
40
51
  isOpen,
@@ -42,6 +53,9 @@ export function Modal({
42
53
  title,
43
54
  titleId = 'modal-title',
44
55
  children,
56
+ footer,
57
+ hideHeader = false,
58
+ ariaLabel,
45
59
  overlayClassName,
46
60
  panelClassName,
47
61
  maxWidth = 'md',
@@ -52,6 +66,7 @@ export function Modal({
52
66
  useEffect(() => {
53
67
  if (!isOpen) return;
54
68
  const previouslyFocused = document.activeElement as HTMLElement | null;
69
+ // When hideHeader is true there is no close button ref; autoFocus on content handles focus.
55
70
  closeButtonRef.current?.focus();
56
71
  const handleEscape = (e: KeyboardEvent) => {
57
72
  if (e.key === 'Escape') onClose();
@@ -78,7 +93,8 @@ export function Modal({
78
93
  }
79
94
  role="dialog"
80
95
  aria-modal="true"
81
- aria-labelledby={title ? titleId : undefined}
96
+ aria-label={ariaLabel}
97
+ aria-labelledby={!ariaLabel && title && !hideHeader ? titleId : undefined}
82
98
  onClick={(e) => e.target === overlayRef.current && onClose()}
83
99
  >
84
100
  <div
@@ -88,41 +104,44 @@ export function Modal({
88
104
  }
89
105
  onClick={(e) => e.stopPropagation()}
90
106
  >
91
- <div className="flex items-start justify-between gap-4 p-4 md:p-6 border-b border-secondary flex-shrink-0">
92
- {title ? (
93
- <h2
94
- id={titleId}
95
- className="font-display text-lg font-normal text-fg-primary md:text-xl flex-1 min-w-0"
107
+ {!hideHeader && (
108
+ <div className="flex items-start justify-between gap-4 p-4 md:p-6 border-b border-secondary flex-shrink-0">
109
+ {title ? (
110
+ <h2
111
+ id={titleId}
112
+ className="font-display text-lg font-normal text-fg-primary md:text-xl flex-1 min-w-0"
113
+ >
114
+ {title}
115
+ </h2>
116
+ ) : (
117
+ <span className="flex-1" aria-hidden />
118
+ )}
119
+ <button
120
+ ref={closeButtonRef}
121
+ type="button"
122
+ onClick={onClose}
123
+ className="shrink-0 p-1 text-fg-primary hover:text-brand-accent rounded focus:outline-none focus:ring-2 focus:ring-brand-accent"
124
+ aria-label="Close"
96
125
  >
97
- {title}
98
- </h2>
99
- ) : (
100
- <span className="flex-1" aria-hidden />
101
- )}
102
- <button
103
- ref={closeButtonRef}
104
- type="button"
105
- onClick={onClose}
106
- className="shrink-0 p-1 text-fg-primary hover:text-brand-accent rounded focus:outline-none focus:ring-2 focus:ring-brand-accent"
107
- aria-label="Close"
108
- >
109
- <svg
110
- className="w-5 h-5"
111
- fill="none"
112
- stroke="currentColor"
113
- viewBox="0 0 24 24"
114
- aria-hidden
115
- >
116
- <path
117
- strokeLinecap="round"
118
- strokeLinejoin="round"
119
- strokeWidth={2}
120
- d="M6 18L18 6M6 6l12 12"
121
- />
122
- </svg>
123
- </button>
124
- </div>
126
+ <svg
127
+ className="w-5 h-5"
128
+ fill="none"
129
+ stroke="currentColor"
130
+ viewBox="0 0 24 24"
131
+ aria-hidden
132
+ >
133
+ <path
134
+ strokeLinecap="round"
135
+ strokeLinejoin="round"
136
+ strokeWidth={2}
137
+ d="M6 18L18 6M6 6l12 12"
138
+ />
139
+ </svg>
140
+ </button>
141
+ </div>
142
+ )}
125
143
  <div className="overflow-y-auto flex-1 p-4 md:p-6">{children}</div>
144
+ {footer && <div className="flex-shrink-0 border-t border-secondary">{footer}</div>}
126
145
  </div>
127
146
  </div>
128
147
  );