keystone-design-bootstrap 1.0.55 → 1.0.57
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/dist/design_system/elements/index.js +8 -3
- package/dist/design_system/elements/index.js.map +1 -1
- package/dist/design_system/sections/index.js +203 -106
- package/dist/design_system/sections/index.js.map +1 -1
- package/dist/index.js +303 -247
- package/dist/index.js.map +1 -1
- package/dist/lib/hooks/index.js +72 -0
- package/dist/lib/hooks/index.js.map +1 -1
- package/dist/lib/server-api.js.map +1 -1
- package/dist/utils/phone-helpers.js +26 -0
- package/dist/utils/phone-helpers.js.map +1 -0
- package/package.json +5 -2
- package/src/design_system/components/ChatWidget.tsx +51 -34
- package/src/design_system/components/DynamicFormFields.tsx +1 -24
- package/src/design_system/elements/modal/modal.tsx +54 -35
- package/src/design_system/portal/LoginForm.tsx +358 -0
- package/src/design_system/portal/LoginModalController.tsx +63 -0
- package/src/design_system/portal/LogoutButton.tsx +22 -0
- package/src/design_system/portal/MessageComposer.tsx +92 -0
- package/src/design_system/portal/PortalPage.tsx +754 -0
- package/src/design_system/portal/RowThumbnail.tsx +76 -0
- package/src/design_system/portal/index.ts +5 -0
- package/src/design_system/sections/index.tsx +1 -1
- package/src/design_system/sections/service-menu-section.tsx +7 -108
- package/src/lib/actions.ts +51 -115
- package/src/lib/consumer-session.ts +74 -0
- package/src/lib/hooks/index.ts +2 -0
- package/src/lib/hooks/use-image-cycle.ts +105 -0
- package/src/lib/server-api.ts +7 -6
- package/src/next/routes/chat.ts +30 -58
- package/src/next/routes/consumer-auth.ts +180 -0
- package/src/types/api/consumer.ts +39 -0
- package/src/types/api/offer.ts +1 -1
- package/src/types/api/package.ts +20 -0
- package/src/types/api/service.ts +6 -24
- package/src/types/index.ts +2 -0
- package/src/utils/phone-helpers.ts +27 -0
- package/dist/blog-post-DGjaJ3wf.d.ts +0 -50
- package/dist/contexts/index.d.ts +0 -13
- package/dist/design_system/elements/index.d.ts +0 -372
- package/dist/design_system/logo/keystone-logo.d.ts +0 -6
- package/dist/design_system/sections/index.d.ts +0 -237
- package/dist/form-CpsCONG5.d.ts +0 -151
- package/dist/index.d.ts +0 -76
- package/dist/lib/component-registry.d.ts +0 -13
- package/dist/lib/hooks/index.d.ts +0 -64
- package/dist/lib/server-api.d.ts +0 -43
- package/dist/themes/index.d.ts +0 -16
- package/dist/types/index.d.ts +0 -264
- package/dist/utils/cx.d.ts +0 -15
- package/dist/utils/gradient-placeholder.d.ts +0 -8
- package/dist/utils/is-react-component.d.ts +0 -21
- package/dist/utils/markdown-toc.d.ts +0 -14
- package/dist/utils/photo-helpers.d.ts +0 -37
- package/dist/website-photos-Bm-CBK9g.d.ts +0 -47
package/dist/lib/hooks/index.js
CHANGED
|
@@ -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":";
|
|
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.
|
|
3
|
+
"version": "1.0.57",
|
|
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
|
-
//
|
|
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).
|
|
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
|
|
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;
|
|
144
|
+
const maxAttempts = 30;
|
|
118
145
|
|
|
119
|
-
|
|
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(
|
|
134
|
-
|
|
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);
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
);
|