keystone-design-bootstrap 1.0.65 → 1.0.68

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.
@@ -46,7 +46,12 @@ declare function setPixelUserData(userData: PixelUserData): Promise<void>;
46
46
  * Automatically applies any stored user identity before firing so that Meta
47
47
  * can match events to known users across the entire session.
48
48
  * Silently no-ops if fbq is not loaded (pixel not configured for this site).
49
+ *
50
+ * @param eventId - Optional server-side event ID for browser/server deduplication.
51
+ * Pass the `eventId` returned by the form submission API so Meta can match and
52
+ * deduplicate the browser Lead event against the server-side CAPI Lead event.
53
+ * Format: fbq('track', event, params, { eventID: eventId })
49
54
  */
50
- declare function firePixelEvent(event: PixelEvent, params?: PixelEventParams): void;
55
+ declare function firePixelEvent(event: PixelEvent, params?: PixelEventParams, eventId?: string): void;
51
56
 
52
57
  export { MetaPixel, type MetaPixelProps, MetaPixelTracker, type PixelEvent, type PixelEventParams, type PixelUserData, firePixelEvent, setPixelUserData };
@@ -88,7 +88,7 @@ async function setPixelUserData(userData) {
88
88
  getRegisteredPixelIds().forEach((id) => fbq("init", id, hashed));
89
89
  }
90
90
  }
91
- function firePixelEvent(event, params) {
91
+ function firePixelEvent(event, params, eventId) {
92
92
  const fbq = getFbq();
93
93
  if (!fbq) {
94
94
  console.debug("[MetaPixel] skipped \u2014 fbq not loaded", { event });
@@ -98,8 +98,10 @@ function firePixelEvent(event, params) {
98
98
  const normalized = {};
99
99
  if (params == null ? void 0 : params.contentName) normalized.content_name = params.contentName;
100
100
  if (params == null ? void 0 : params.contentCategory) normalized.content_category = params.contentCategory;
101
- console.debug("[MetaPixel]", event, normalized);
102
- fbq("track", event, Object.keys(normalized).length > 0 ? normalized : void 0);
101
+ const customData = Object.keys(normalized).length > 0 ? normalized : void 0;
102
+ const eventData = eventId ? { eventID: eventId } : void 0;
103
+ console.debug("[MetaPixel]", event, normalized, eventId ? { eventID: eventId } : "");
104
+ fbq("track", event, customData, eventData);
103
105
  }
104
106
 
105
107
  // src/tracking/MetaPixelTracker.tsx
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/tracking/MetaPixel.tsx","../../src/tracking/MetaPixelTracker.tsx","../../src/tracking/firePixelEvent.ts"],"sourcesContent":["'use client';\n\nimport Script from 'next/script';\n\nconst FBEVENTS_URL = 'https://connect.facebook.net/en_US/fbevents.js';\n\nconst PIXEL_SCRIPT = (pixelId: string) => `\n !function(f,b,e,v,n,t,s)\n {if(f.fbq)return;n=f.fbq=function(){n.callMethod?\n n.callMethod.apply(n,arguments):n.queue.push(arguments)};\n if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';\n n.queue=[];t=b.createElement(e);t.async=!0;\n t.src=v;s=b.getElementsByTagName(e)[0];\n s.parentNode.insertBefore(t,s)}(window, document,'script','${FBEVENTS_URL}');\n fbq('init', '${pixelId.replace(/'/g, \"\\\\'\")}');\n fbq('track', 'PageView');\n window.__ks_pixel_ids = window.__ks_pixel_ids || [];\n if (window.__ks_pixel_ids.indexOf('${pixelId.replace(/'/g, \"\\\\'\")}') === -1) {\n window.__ks_pixel_ids.push('${pixelId.replace(/'/g, \"\\\\'\")}');\n }\n`;\n\nexport type MetaPixelProps = {\n /** Meta Pixel ID. When null/undefined, nothing is rendered. */\n pixelId: string | null | undefined;\n};\n\n/**\n * Renders the Meta (Facebook) Pixel base code: loads fbevents.js, initializes\n * the pixel, and tracks PageView. Use in the root layout when ads config\n * provides a meta_pixel_id.\n */\nexport function MetaPixel({ pixelId }: MetaPixelProps) {\n const raw = typeof pixelId === 'string' ? pixelId.trim() : '';\n const id = raw && raw !== 'null' && /^\\d+$/.test(raw) ? raw : '';\n if (!id) {\n return null;\n }\n\n return (\n <>\n <Script\n id=\"meta-pixel\"\n strategy=\"afterInteractive\"\n dangerouslySetInnerHTML={{ __html: PIXEL_SCRIPT(id) }}\n />\n <noscript>\n {/* eslint-disable-next-line @next/next/no-img-element -- 1x1 tracking pixel for no-JS fallback; next/image not applicable in noscript */}\n <img\n height={1}\n width={1}\n style={{ display: 'none' }}\n src={`https://www.facebook.com/tr?id=${id}&ev=PageView&noscript=1`}\n alt=\"\"\n />\n </noscript>\n </>\n );\n}\n","'use client';\n\nimport { useEffect } from 'react';\nimport { usePathname } from 'next/navigation';\nimport { firePixelEvent } from './firePixelEvent';\n\ntype RouteRule = {\n pattern: RegExp;\n getParams: (match: RegExpMatchArray) => { contentName: string; contentCategory: string };\n};\n\nfunction slugToTitle(slug: string): string {\n return slug\n .split('-')\n .map((w) => w.charAt(0).toUpperCase() + w.slice(1))\n .join(' ');\n}\n\n// Checked in order — first match wins. More specific patterns come first.\nconst ROUTE_RULES: RouteRule[] = [\n {\n pattern: /^\\/services\\/(.+)$/,\n getParams: ([, slug]) => ({ contentName: slugToTitle(slug), contentCategory: 'Service' }),\n },\n {\n pattern: /^\\/locations\\/(.+)$/,\n getParams: ([, slug]) => ({ contentName: slugToTitle(slug), contentCategory: 'Location' }),\n },\n {\n pattern: /^\\/services$/,\n getParams: () => ({ contentName: 'Services', contentCategory: 'Services' }),\n },\n {\n pattern: /^\\/locations$/,\n getParams: () => ({ contentName: 'Locations', contentCategory: 'Locations' }),\n },\n {\n pattern: /^\\/portal$/,\n getParams: () => ({ contentName: 'Member Portal', contentCategory: 'Pricing' }),\n },\n {\n pattern: /^\\/service-menu$/,\n getParams: () => ({ contentName: 'Service Menu', contentCategory: 'Pricing' }),\n },\n {\n pattern: /^\\/faq$/,\n getParams: () => ({ contentName: 'FAQ', contentCategory: 'FAQ' }),\n },\n {\n pattern: /^\\/contact$/,\n getParams: () => ({ contentName: 'Contact', contentCategory: 'Contact' }),\n },\n];\n\ntype Props = {\n /** External booking URL. When set, fires InitiateCheckout on any click targeting that URL. */\n bookingUrl?: string | null;\n};\n\n/**\n * Single client-side tracker placed once in KeystoneRootLayout.\n * - Fires ViewContent on every route change for known page patterns.\n * - Fires InitiateCheckout whenever a visitor clicks a link to the external booking URL.\n *\n * Portal booking tab tracking is handled directly inside PortalPage via PortalTabTracker\n * since intent is only established when the Booking tab is explicitly opened.\n */\nexport function MetaPixelTracker({ bookingUrl }: Props) {\n const pathname = usePathname();\n\n useEffect(() => {\n for (const rule of ROUTE_RULES) {\n const match = pathname.match(rule.pattern);\n if (match) {\n const { contentName, contentCategory } = rule.getParams(match);\n firePixelEvent('ViewContent', { contentName, contentCategory });\n break;\n }\n }\n }, [pathname]);\n\n useEffect(() => {\n if (!bookingUrl) return;\n\n const handleClick = (e: MouseEvent) => {\n const anchor = (e.target as Element).closest('a');\n if (anchor?.href?.startsWith(bookingUrl)) {\n firePixelEvent('InitiateCheckout');\n }\n };\n\n document.addEventListener('click', handleClick);\n return () => document.removeEventListener('click', handleClick);\n }, [bookingUrl]);\n\n return null;\n}\n","type FbqFn = (method: string, ...args: unknown[]) => void;\n\nexport type PixelEvent = 'PageView' | 'ViewContent' | 'InitiateCheckout' | 'Lead';\n\nexport interface PixelEventParams {\n contentName?: string;\n contentCategory?: string;\n}\n\n/** Raw (unhashed) user identifiers for advanced matching. */\nexport interface PixelUserData {\n email?: string | null;\n phone?: string | null;\n}\n\n// Hashed user data stored in sessionStorage for the duration of the browsing session.\n// Keyed by a short namespace to avoid collisions.\nconst STORAGE_KEY = 'ks_pud';\n\nasync function sha256Hex(str: string): Promise<string> {\n const buffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));\n return Array.from(new Uint8Array(buffer))\n .map((b) => b.toString(16).padStart(2, '0'))\n .join('');\n}\n\nfunction getFbq(): FbqFn | undefined {\n if (typeof window === 'undefined') return undefined;\n return (window as Window & { fbq?: FbqFn }).fbq;\n}\n\n/** Read the pixel IDs registered by MetaPixel via the inline init script. */\nfunction getRegisteredPixelIds(): string[] {\n return (window as Window & { __ks_pixel_ids?: string[] }).__ks_pixel_ids ?? [];\n}\n\n/** Apply stored hashed user data to every configured Meta Pixel. */\nfunction applyStoredUserData(fbq: FbqFn): void {\n try {\n const raw = sessionStorage.getItem(STORAGE_KEY);\n if (!raw) return;\n const hashed = JSON.parse(raw) as Record<string, string>;\n if (Object.keys(hashed).length === 0) return;\n getRegisteredPixelIds().forEach((id) => fbq('init', id, hashed));\n } catch {\n // sessionStorage unavailable or JSON malformed — safe to ignore\n }\n}\n\n/**\n * Hash and store user identifiers so they are automatically included in all\n * subsequent pixel events for this browser session. Call this as soon as\n * identity is known (e.g. contact form submission, portal login step 1).\n */\nexport async function setPixelUserData(userData: PixelUserData): Promise<void> {\n const hashed: Record<string, string> = {};\n\n if (userData.email) {\n hashed.em = await sha256Hex(userData.email.trim().toLowerCase());\n }\n if (userData.phone) {\n const digits = userData.phone.replace(/\\D/g, '');\n if (digits) hashed.ph = await sha256Hex(digits);\n }\n\n if (Object.keys(hashed).length === 0) return;\n\n try {\n sessionStorage.setItem(STORAGE_KEY, JSON.stringify(hashed));\n } catch {\n // sessionStorage unavailable — still apply to fbq for this page load\n }\n\n const fbq = getFbq();\n if (fbq) {\n getRegisteredPixelIds().forEach((id) => fbq('init', id, hashed));\n }\n}\n\n/**\n * Single entry point for all client-side Meta Pixel event fires.\n * Automatically applies any stored user identity before firing so that Meta\n * can match events to known users across the entire session.\n * Silently no-ops if fbq is not loaded (pixel not configured for this site).\n */\nexport function firePixelEvent(event: PixelEvent, params?: PixelEventParams): void {\n const fbq = getFbq();\n if (!fbq) {\n console.debug('[MetaPixel] skipped — fbq not loaded', { event });\n return;\n }\n\n // Re-apply stored identity before every event so user data is included\n // even on events that fire after a client-side navigation (PageView, ViewContent, etc.)\n applyStoredUserData(fbq);\n\n const normalized: Record<string, string> = {};\n if (params?.contentName) normalized.content_name = params.contentName;\n if (params?.contentCategory) normalized.content_category = params.contentCategory;\n\n console.debug('[MetaPixel]', event, normalized);\n fbq('track', event, Object.keys(normalized).length > 0 ? normalized : undefined);\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/tracking/MetaPixel.tsx","../../src/tracking/MetaPixelTracker.tsx","../../src/tracking/firePixelEvent.ts"],"sourcesContent":["'use client';\n\nimport Script from 'next/script';\n\nconst FBEVENTS_URL = 'https://connect.facebook.net/en_US/fbevents.js';\n\nconst PIXEL_SCRIPT = (pixelId: string) => `\n !function(f,b,e,v,n,t,s)\n {if(f.fbq)return;n=f.fbq=function(){n.callMethod?\n n.callMethod.apply(n,arguments):n.queue.push(arguments)};\n if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';\n n.queue=[];t=b.createElement(e);t.async=!0;\n t.src=v;s=b.getElementsByTagName(e)[0];\n s.parentNode.insertBefore(t,s)}(window, document,'script','${FBEVENTS_URL}');\n fbq('init', '${pixelId.replace(/'/g, \"\\\\'\")}');\n fbq('track', 'PageView');\n window.__ks_pixel_ids = window.__ks_pixel_ids || [];\n if (window.__ks_pixel_ids.indexOf('${pixelId.replace(/'/g, \"\\\\'\")}') === -1) {\n window.__ks_pixel_ids.push('${pixelId.replace(/'/g, \"\\\\'\")}');\n }\n`;\n\nexport type MetaPixelProps = {\n /** Meta Pixel ID. When null/undefined, nothing is rendered. */\n pixelId: string | null | undefined;\n};\n\n/**\n * Renders the Meta (Facebook) Pixel base code: loads fbevents.js, initializes\n * the pixel, and tracks PageView. Use in the root layout when ads config\n * provides a meta_pixel_id.\n */\nexport function MetaPixel({ pixelId }: MetaPixelProps) {\n const raw = typeof pixelId === 'string' ? pixelId.trim() : '';\n const id = raw && raw !== 'null' && /^\\d+$/.test(raw) ? raw : '';\n if (!id) {\n return null;\n }\n\n return (\n <>\n <Script\n id=\"meta-pixel\"\n strategy=\"afterInteractive\"\n dangerouslySetInnerHTML={{ __html: PIXEL_SCRIPT(id) }}\n />\n <noscript>\n {/* eslint-disable-next-line @next/next/no-img-element -- 1x1 tracking pixel for no-JS fallback; next/image not applicable in noscript */}\n <img\n height={1}\n width={1}\n style={{ display: 'none' }}\n src={`https://www.facebook.com/tr?id=${id}&ev=PageView&noscript=1`}\n alt=\"\"\n />\n </noscript>\n </>\n );\n}\n","'use client';\n\nimport { useEffect } from 'react';\nimport { usePathname } from 'next/navigation';\nimport { firePixelEvent } from './firePixelEvent';\n\ntype RouteRule = {\n pattern: RegExp;\n getParams: (match: RegExpMatchArray) => { contentName: string; contentCategory: string };\n};\n\nfunction slugToTitle(slug: string): string {\n return slug\n .split('-')\n .map((w) => w.charAt(0).toUpperCase() + w.slice(1))\n .join(' ');\n}\n\n// Checked in order — first match wins. More specific patterns come first.\nconst ROUTE_RULES: RouteRule[] = [\n {\n pattern: /^\\/services\\/(.+)$/,\n getParams: ([, slug]) => ({ contentName: slugToTitle(slug), contentCategory: 'Service' }),\n },\n {\n pattern: /^\\/locations\\/(.+)$/,\n getParams: ([, slug]) => ({ contentName: slugToTitle(slug), contentCategory: 'Location' }),\n },\n {\n pattern: /^\\/services$/,\n getParams: () => ({ contentName: 'Services', contentCategory: 'Services' }),\n },\n {\n pattern: /^\\/locations$/,\n getParams: () => ({ contentName: 'Locations', contentCategory: 'Locations' }),\n },\n {\n pattern: /^\\/portal$/,\n getParams: () => ({ contentName: 'Member Portal', contentCategory: 'Pricing' }),\n },\n {\n pattern: /^\\/service-menu$/,\n getParams: () => ({ contentName: 'Service Menu', contentCategory: 'Pricing' }),\n },\n {\n pattern: /^\\/faq$/,\n getParams: () => ({ contentName: 'FAQ', contentCategory: 'FAQ' }),\n },\n {\n pattern: /^\\/contact$/,\n getParams: () => ({ contentName: 'Contact', contentCategory: 'Contact' }),\n },\n];\n\ntype Props = {\n /** External booking URL. When set, fires InitiateCheckout on any click targeting that URL. */\n bookingUrl?: string | null;\n};\n\n/**\n * Single client-side tracker placed once in KeystoneRootLayout.\n * - Fires ViewContent on every route change for known page patterns.\n * - Fires InitiateCheckout whenever a visitor clicks a link to the external booking URL.\n *\n * Portal booking tab tracking is handled directly inside PortalPage via PortalTabTracker\n * since intent is only established when the Booking tab is explicitly opened.\n */\nexport function MetaPixelTracker({ bookingUrl }: Props) {\n const pathname = usePathname();\n\n useEffect(() => {\n for (const rule of ROUTE_RULES) {\n const match = pathname.match(rule.pattern);\n if (match) {\n const { contentName, contentCategory } = rule.getParams(match);\n firePixelEvent('ViewContent', { contentName, contentCategory });\n break;\n }\n }\n }, [pathname]);\n\n useEffect(() => {\n if (!bookingUrl) return;\n\n const handleClick = (e: MouseEvent) => {\n const anchor = (e.target as Element).closest('a');\n if (anchor?.href?.startsWith(bookingUrl)) {\n firePixelEvent('InitiateCheckout');\n }\n };\n\n document.addEventListener('click', handleClick);\n return () => document.removeEventListener('click', handleClick);\n }, [bookingUrl]);\n\n return null;\n}\n","type FbqFn = (method: string, ...args: unknown[]) => void;\n\nexport type PixelEvent = 'PageView' | 'ViewContent' | 'InitiateCheckout' | 'Lead';\n\nexport interface PixelEventParams {\n contentName?: string;\n contentCategory?: string;\n}\n\n/** Raw (unhashed) user identifiers for advanced matching. */\nexport interface PixelUserData {\n email?: string | null;\n phone?: string | null;\n}\n\n// Hashed user data stored in sessionStorage for the duration of the browsing session.\n// Keyed by a short namespace to avoid collisions.\nconst STORAGE_KEY = 'ks_pud';\n\nasync function sha256Hex(str: string): Promise<string> {\n const buffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));\n return Array.from(new Uint8Array(buffer))\n .map((b) => b.toString(16).padStart(2, '0'))\n .join('');\n}\n\nfunction getFbq(): FbqFn | undefined {\n if (typeof window === 'undefined') return undefined;\n return (window as Window & { fbq?: FbqFn }).fbq;\n}\n\n/** Read the pixel IDs registered by MetaPixel via the inline init script. */\nfunction getRegisteredPixelIds(): string[] {\n return (window as Window & { __ks_pixel_ids?: string[] }).__ks_pixel_ids ?? [];\n}\n\n/** Apply stored hashed user data to every configured Meta Pixel. */\nfunction applyStoredUserData(fbq: FbqFn): void {\n try {\n const raw = sessionStorage.getItem(STORAGE_KEY);\n if (!raw) return;\n const hashed = JSON.parse(raw) as Record<string, string>;\n if (Object.keys(hashed).length === 0) return;\n getRegisteredPixelIds().forEach((id) => fbq('init', id, hashed));\n } catch {\n // sessionStorage unavailable or JSON malformed — safe to ignore\n }\n}\n\n/**\n * Hash and store user identifiers so they are automatically included in all\n * subsequent pixel events for this browser session. Call this as soon as\n * identity is known (e.g. contact form submission, portal login step 1).\n */\nexport async function setPixelUserData(userData: PixelUserData): Promise<void> {\n const hashed: Record<string, string> = {};\n\n if (userData.email) {\n hashed.em = await sha256Hex(userData.email.trim().toLowerCase());\n }\n if (userData.phone) {\n const digits = userData.phone.replace(/\\D/g, '');\n if (digits) hashed.ph = await sha256Hex(digits);\n }\n\n if (Object.keys(hashed).length === 0) return;\n\n try {\n sessionStorage.setItem(STORAGE_KEY, JSON.stringify(hashed));\n } catch {\n // sessionStorage unavailable — still apply to fbq for this page load\n }\n\n const fbq = getFbq();\n if (fbq) {\n getRegisteredPixelIds().forEach((id) => fbq('init', id, hashed));\n }\n}\n\n/**\n * Single entry point for all client-side Meta Pixel event fires.\n * Automatically applies any stored user identity before firing so that Meta\n * can match events to known users across the entire session.\n * Silently no-ops if fbq is not loaded (pixel not configured for this site).\n *\n * @param eventId - Optional server-side event ID for browser/server deduplication.\n * Pass the `eventId` returned by the form submission API so Meta can match and\n * deduplicate the browser Lead event against the server-side CAPI Lead event.\n * Format: fbq('track', event, params, { eventID: eventId })\n */\nexport function firePixelEvent(event: PixelEvent, params?: PixelEventParams, eventId?: string): void {\n const fbq = getFbq();\n if (!fbq) {\n console.debug('[MetaPixel] skipped — fbq not loaded', { event });\n return;\n }\n\n // Re-apply stored identity before every event so user data is included\n // even on events that fire after a client-side navigation (PageView, ViewContent, etc.)\n applyStoredUserData(fbq);\n\n const normalized: Record<string, string> = {};\n if (params?.contentName) normalized.content_name = params.contentName;\n if (params?.contentCategory) normalized.content_category = params.contentCategory;\n\n const customData = Object.keys(normalized).length > 0 ? normalized : undefined;\n const eventData = eventId ? { eventID: eventId } : undefined;\n\n console.debug('[MetaPixel]', event, normalized, eventId ? { eventID: eventId } : '');\n fbq('track', event, customData, eventData);\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keystone-design-bootstrap",
3
- "version": "1.0.65",
3
+ "version": "1.0.68",
4
4
  "description": "Keystone Design Bootstrap - Sections, Elements, and Theme System for customer websites",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,132 @@
1
+ 'use client';
2
+
3
+ import React, { useState, useEffect, useRef } from 'react';
4
+
5
+ interface BookIframePanelProps {
6
+ bookingHref: string;
7
+ businessName: string;
8
+ }
9
+
10
+ export function BookIframePanel({ bookingHref, businessName }: BookIframePanelProps) {
11
+ const [hasOpened, setHasOpened] = useState(false);
12
+ const [modalOpen, setModalOpen] = useState(false);
13
+ const [isVisible, setIsVisible] = useState(false);
14
+ const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
15
+
16
+ const openModal = () => {
17
+ setHasOpened(true);
18
+ setModalOpen(true);
19
+ };
20
+
21
+ const closeModal = () => {
22
+ setIsVisible(false);
23
+ closeTimerRef.current = setTimeout(() => setModalOpen(false), 300);
24
+ };
25
+
26
+ // Clear any pending close timer on unmount.
27
+ useEffect(() => {
28
+ return () => {
29
+ if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
30
+ };
31
+ }, []);
32
+
33
+ // Animate in after mount — double rAF ensures the transition fires after the
34
+ // element is painted rather than immediately on insertion.
35
+ useEffect(() => {
36
+ if (!modalOpen) return;
37
+ let cancelled = false;
38
+ const id = requestAnimationFrame(() =>
39
+ requestAnimationFrame(() => {
40
+ if (!cancelled) setIsVisible(true);
41
+ })
42
+ );
43
+ return () => {
44
+ cancelled = true;
45
+ cancelAnimationFrame(id);
46
+ };
47
+ }, [modalOpen]);
48
+
49
+ // Prevent body scroll while modal is open.
50
+ useEffect(() => {
51
+ document.body.style.overflow = modalOpen ? 'hidden' : '';
52
+ return () => { document.body.style.overflow = ''; };
53
+ }, [modalOpen]);
54
+
55
+ return (
56
+ <>
57
+ {/* Blurred iframe preview with overlay message */}
58
+ <div className="relative rounded-component border border-secondary overflow-hidden" style={{ height: '70vh' }}>
59
+ {/* iframe — purely visual, non-interactive */}
60
+ <iframe
61
+ src={bookingHref}
62
+ className="w-full h-full pointer-events-none select-none"
63
+ title="Booking preview"
64
+ tabIndex={-1}
65
+ aria-hidden="true"
66
+ />
67
+
68
+ {/* Backdrop blur + message overlay */}
69
+ <div className="absolute inset-0 flex flex-col items-center justify-center px-6 py-8 backdrop-blur-[3.9px] bg-primary/70">
70
+ <div className="w-full max-w-sm rounded-component border border-secondary bg-primary px-6 py-6 text-center shadow-sm">
71
+ <p className="text-xl font-bold text-primary">Booking Instructions</p>
72
+ <p className="mt-5 text-sm text-secondary leading-relaxed">
73
+ We use a separate platform that may ask for an additional sign in or intake details to fully create your profile and collect your appointment information.
74
+ </p>
75
+ <div className="mt-5 flex flex-wrap items-center justify-center gap-3">
76
+ <button
77
+ onClick={openModal}
78
+ className="inline-flex items-center gap-2 rounded-interactive bg-brand-solid px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-brand-solid_hover"
79
+ >
80
+ Start Booking
81
+ </button>
82
+ <a
83
+ href={bookingHref}
84
+ target="_blank"
85
+ rel="noopener noreferrer"
86
+ className="inline-flex items-center gap-1.5 rounded-interactive border border-secondary bg-secondary px-5 py-2.5 text-sm font-semibold text-primary transition-colors hover:bg-secondary_hover"
87
+ >
88
+ Open in New Tab
89
+ <svg className="size-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
90
+ <path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
91
+ </svg>
92
+ </a>
93
+ </div>
94
+ </div>
95
+ </div>
96
+ </div>
97
+
98
+ {/* Full-screen modal — kept mounted after first open so the iframe session persists on close/reopen */}
99
+ {hasOpened && (
100
+ <div
101
+ className={`fixed inset-0 z-50 flex flex-col bg-primary transition-[opacity,transform] duration-300 ease-out ${
102
+ modalOpen && isVisible
103
+ ? 'opacity-100 translate-y-0'
104
+ : 'opacity-0 translate-y-3 pointer-events-none'
105
+ }`}
106
+ >
107
+ {/* Modal header */}
108
+ <div className="flex shrink-0 items-center justify-between border-b border-secondary bg-primary px-4 py-3">
109
+ <span className="text-sm font-semibold text-primary">{businessName} Booking</span>
110
+ <button
111
+ onClick={closeModal}
112
+ className="inline-flex items-center gap-2 rounded-interactive bg-brand-solid px-4 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-brand-solid_hover"
113
+ >
114
+ <svg className="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
115
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
116
+ </svg>
117
+ Close
118
+ </button>
119
+ </div>
120
+
121
+ {/* iframe fills the rest of the screen */}
122
+ <iframe
123
+ src={bookingHref}
124
+ className="min-h-0 flex-1 w-full"
125
+ title="Book appointment"
126
+ allow="payment"
127
+ />
128
+ </div>
129
+ )}
130
+ </>
131
+ );
132
+ }
@@ -12,8 +12,9 @@ interface LoginFormProps {
12
12
  onClose?: () => void;
13
13
  }
14
14
 
15
+ // text-base (16px) prevents iOS from auto-zooming when the input is focused.
15
16
  const inputClass =
16
- 'block w-full rounded-input border border-primary bg-primary px-3 py-2.5 text-sm text-primary placeholder-quaternary focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand transition-colors';
17
+ 'block w-full rounded-input border border-primary bg-primary px-3 py-2.5 text-base text-primary placeholder-quaternary focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand transition-colors';
17
18
  const labelClass = 'block text-sm text-secondary mb-1';
18
19
 
19
20
  function isValidEmail(value: string): boolean {
@@ -206,7 +207,6 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
206
207
  placeholder="you@example.com"
207
208
  className={inputClass}
208
209
  autoComplete="email"
209
- autoFocus
210
210
  />
211
211
  </div>
212
212
  <div className="flex items-center gap-3">
@@ -220,7 +220,7 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
220
220
  <select
221
221
  value={selectedCountry}
222
222
  onChange={(e) => { setSelectedCountry(e.target.value); setPhoneValue(''); }}
223
- className="border-r border-secondary bg-secondary px-2 py-2.5 text-sm text-secondary focus:outline-none"
223
+ className="border-r border-secondary bg-secondary px-2 py-2.5 text-base text-secondary focus:outline-none"
224
224
  aria-label="Country code"
225
225
  >
226
226
  {countryOptions.map((opt) => (
@@ -232,7 +232,7 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
232
232
  value={phoneValue}
233
233
  onChange={handlePhoneChange}
234
234
  placeholder={nationalPlaceholder}
235
- className="flex-1 px-3 py-2.5 text-sm text-primary placeholder-quaternary bg-transparent focus:outline-none"
235
+ className="flex-1 px-3 py-2.5 text-base text-primary placeholder-quaternary bg-transparent focus:outline-none"
236
236
  />
237
237
  </div>
238
238
  </div>
@@ -261,7 +261,6 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
261
261
  className={inputClass}
262
262
  required
263
263
  autoComplete="current-password"
264
- autoFocus
265
264
  />
266
265
  </div>
267
266
  <div className="pt-1">
@@ -298,7 +297,6 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
298
297
  placeholder="Jane"
299
298
  className={inputClass}
300
299
  autoComplete="given-name"
301
- autoFocus
302
300
  />
303
301
  </div>
304
302
  <div>
@@ -324,7 +322,6 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
324
322
  className={inputClass}
325
323
  required
326
324
  autoComplete="new-password"
327
- autoFocus={!!welcomeName}
328
325
  />
329
326
  </div>
330
327
  <div>
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import React, { useState, useEffect } from 'react';
3
+ import React, { useState, useEffect, useTransition } from 'react';
4
4
  import { useRouter } from 'next/navigation';
5
5
  import { Modal } from '../elements/modal/modal';
6
6
  import { LoginForm } from './LoginForm';
@@ -19,11 +19,17 @@ const keystoneFooter = (
19
19
  /**
20
20
  * Renders a single login modal for the entire portal.
21
21
  * Any server-rendered element with `data-open-login-modal` will open it on click.
22
- * After successful auth the server component re-renders with the new cookie.
22
+ *
23
+ * After successful auth, the modal switches to a loading state and stays open
24
+ * until the router transition (router.refresh / router.push) has fully completed
25
+ * and the server component has re-rendered with the new session. This prevents
26
+ * the brief flash of logged-out content that would otherwise appear.
23
27
  */
24
28
  export function LoginModalController() {
25
29
  const [open, setOpen] = useState(false);
26
30
  const [redirectUrl, setRedirectUrl] = useState<string | null>(null);
31
+ const [refreshing, setRefreshing] = useState(false);
32
+ const [isPending, startTransition] = useTransition();
27
33
  const router = useRouter();
28
34
 
29
35
  useEffect(() => {
@@ -40,24 +46,42 @@ export function LoginModalController() {
40
46
  }, []);
41
47
 
42
48
  const handleSuccess = () => {
43
- setOpen(false);
44
- if (redirectUrl) {
45
- router.push(redirectUrl);
46
- setRedirectUrl(null);
47
- } else {
48
- router.refresh();
49
- }
49
+ setRefreshing(true);
50
+ startTransition(() => {
51
+ if (redirectUrl) {
52
+ router.push(redirectUrl);
53
+ setRedirectUrl(null);
54
+ } else {
55
+ router.refresh();
56
+ }
57
+ });
50
58
  };
51
59
 
60
+ // Close the modal once the router transition has fully completed and the
61
+ // server component has re-rendered with the new authenticated state.
62
+ useEffect(() => {
63
+ if (refreshing && !isPending) {
64
+ setOpen(false);
65
+ setRefreshing(false);
66
+ }
67
+ }, [refreshing, isPending]);
68
+
52
69
  return (
53
70
  <Modal
54
71
  isOpen={open}
55
- onClose={() => setOpen(false)}
72
+ onClose={refreshing ? () => {} : () => setOpen(false)}
56
73
  hideHeader
57
74
  ariaLabel="Sign in or create account"
58
- footer={keystoneFooter}
75
+ footer={refreshing ? undefined : keystoneFooter}
59
76
  >
60
- <LoginForm onSuccess={handleSuccess} onClose={() => setOpen(false)} />
77
+ {refreshing ? (
78
+ <div className="flex flex-col items-center justify-center py-12 gap-3">
79
+ <div className="h-6 w-6 animate-spin rounded-full border-2 border-brand border-t-transparent" />
80
+ <p className="text-sm text-tertiary">Signing you in…</p>
81
+ </div>
82
+ ) : (
83
+ <LoginForm onSuccess={handleSuccess} onClose={() => setOpen(false)} />
84
+ )}
61
85
  </Modal>
62
86
  );
63
87
  }
@@ -7,6 +7,7 @@ import { LoginModalController } from './LoginModalController';
7
7
  import { MessageComposer } from './MessageComposer';
8
8
  import { RowThumbnail } from './RowThumbnail';
9
9
  import { PortalTabTracker } from './PortalTabTracker';
10
+ import { BookIframePanel } from './BookIframePanel';
10
11
  import {
11
12
  CONSUMER_TOKEN_COOKIE,
12
13
  fetchConsumerMe,
@@ -508,11 +509,13 @@ function BookPanel({
508
509
  bookingLabel,
509
510
  bookingAllowsIframe,
510
511
  isLoggedIn,
512
+ businessName,
511
513
  }: {
512
514
  bookingHref: string;
513
515
  bookingLabel: string;
514
516
  bookingAllowsIframe: boolean;
515
517
  isLoggedIn: boolean;
518
+ businessName: string;
516
519
  }) {
517
520
  if (!isLoggedIn) {
518
521
  return <LoginWall message="Continue to view booking options." cta="View Booking Options" />;
@@ -522,14 +525,7 @@ function BookPanel({
522
525
  return (
523
526
  <>
524
527
  <PortalTabTracker event="InitiateCheckout" />
525
- <div className="rounded-component border border-secondary overflow-hidden" style={{ height: '70vh' }}>
526
- <iframe
527
- src={bookingHref}
528
- className="w-full h-full"
529
- title="Book appointment"
530
- allow="payment"
531
- />
532
- </div>
528
+ <BookIframePanel bookingHref={bookingHref} businessName={businessName} />
533
529
  </>
534
530
  );
535
531
  }
@@ -759,6 +755,7 @@ export async function PortalPage({
759
755
  bookingLabel={bookingLabel}
760
756
  bookingAllowsIframe={bookingAllowsIframe}
761
757
  isLoggedIn={isLoggedIn}
758
+ businessName={businessName}
762
759
  />
763
760
  ) : (
764
761
  <div className="flex flex-col items-center justify-center py-20 text-center">
@@ -9,7 +9,7 @@ import { cx } from '../../utils/cx';
9
9
  import { getLogoUrl } from '../../utils/photo-helpers';
10
10
  import type { HeaderComponentProps } from './header-navigation';
11
11
  import type { NavItem } from '../../types/config';
12
- import { resolveCtaUrls, isExternalCtaUrl } from '../../lib/cta-urls';
12
+ import { resolveCtaUrls, isExternalCtaUrl, resolvePortalPath } from '../../lib/cta-urls';
13
13
 
14
14
  // Maximum items to show before "View All" link
15
15
  const MAX_DROPDOWN_ITEMS = 3;
@@ -62,11 +62,7 @@ export function HeaderNavigation({
62
62
  // Hide the sticky bottom bar when the user is already on the portal page —
63
63
  // the portal has its own Book Now tab so the bar is redundant and confusing.
64
64
  const pathname = usePathname();
65
- const portalPath = (() => {
66
- const url = companyInformation?.portal_url?.trim();
67
- if (!url) return null;
68
- try { return new URL(url).pathname; } catch { return url.startsWith('/') ? url : null; }
69
- })();
65
+ const portalPath = resolvePortalPath(companyInformation);
70
66
  const isPortalPage = portalPath ? pathname?.startsWith(portalPath) : false;
71
67
 
72
68
  // Cancel any pending close timeout
@@ -8,7 +8,7 @@ import { Button } from '../elements';
8
8
  import { getLogoUrl } from '../../utils/photo-helpers';
9
9
  import type { HeaderComponentProps } from './header-navigation';
10
10
  import type { NavItem } from '../../types/config';
11
- import { resolveCtaUrls, isExternalCtaUrl } from '../../lib/cta-urls';
11
+ import { resolveCtaUrls, isExternalCtaUrl, resolvePortalPath } from '../../lib/cta-urls';
12
12
 
13
13
  export function HeaderNavigation({
14
14
  props,
@@ -41,11 +41,7 @@ export function HeaderNavigation({
41
41
  // Hide the sticky bottom bar when the user is already on the portal page —
42
42
  // the portal has its own Book Now tab so the bar is redundant and confusing.
43
43
  const pathname = usePathname();
44
- const portalPath = (() => {
45
- const url = companyInformation?.portal_url?.trim();
46
- if (!url) return null;
47
- try { return new URL(url).pathname; } catch { return url.startsWith('/') ? url : null; }
48
- })();
44
+ const portalPath = resolvePortalPath(companyInformation);
49
45
  const isPortalPage = portalPath ? pathname?.startsWith(portalPath) : false;
50
46
 
51
47
  const cancelCloseTimeout = useCallback(() => {
@@ -8,7 +8,7 @@ import { Button } from '../elements';
8
8
  import { getLogoUrl } from '../../utils/photo-helpers';
9
9
  import type { HeaderComponentProps } from './header-navigation';
10
10
  import type { NavItem } from '../../types/config';
11
- import { resolveCtaUrls, isExternalCtaUrl } from '../../lib/cta-urls';
11
+ import { resolveCtaUrls, isExternalCtaUrl, resolvePortalPath } from '../../lib/cta-urls';
12
12
 
13
13
  // Maximum items to show before "View All" link
14
14
  const MAX_DROPDOWN_ITEMS = 6;
@@ -47,11 +47,7 @@ export function HeaderNavigation({
47
47
  // Hide the sticky bottom bar when the user is already on the portal page —
48
48
  // the portal has its own Book Now tab so the bar is redundant and confusing.
49
49
  const pathname = usePathname();
50
- const portalPath = (() => {
51
- const url = companyInformation?.portal_url?.trim();
52
- if (!url) return null;
53
- try { return new URL(url).pathname; } catch { return url.startsWith('/') ? url : null; }
54
- })();
50
+ const portalPath = resolvePortalPath(companyInformation);
55
51
  const isPortalPage = portalPath ? pathname?.startsWith(portalPath) : false;
56
52
 
57
53
  // Cancel any pending close timeout
@@ -12,7 +12,7 @@ import { getLogoUrl } from '../../utils/photo-helpers';
12
12
  import type { CompanyInformation } from '../../types/api/company-information';
13
13
  import type { WebsitePhotos } from '../../types/api/website-photos';
14
14
  import type { NavItem, SiteConfig } from '../../types/config';
15
- import { resolveCtaUrls, isExternalCtaUrl } from '../../lib/cta-urls';
15
+ import { resolveCtaUrls, isExternalCtaUrl, resolvePortalPath } from '../../lib/cta-urls';
16
16
 
17
17
  export interface HeaderProps {
18
18
  logo: {
@@ -112,11 +112,7 @@ export function HeaderNavigation({
112
112
  // Hide the sticky bottom bar when the user is already on the portal page —
113
113
  // the portal has its own Book Now tab so the bar is redundant and confusing.
114
114
  const pathname = usePathname();
115
- const portalPath = (() => {
116
- const url = companyInformation?.portal_url?.trim();
117
- if (!url) return null;
118
- try { return new URL(url).pathname; } catch { return url.startsWith('/') ? url : null; }
119
- })();
115
+ const portalPath = resolvePortalPath(companyInformation);
120
116
  const isPortalPage = portalPath ? pathname?.startsWith(portalPath) : false;
121
117
 
122
118
  const getVariantClasses = () => {
@@ -25,6 +25,33 @@ export interface ResolvedCtaUrls {
25
25
  hasSecondary: boolean;
26
26
  }
27
27
 
28
+ /**
29
+ * Extracts the pathname from portal_url for use in client-side route comparisons.
30
+ * Handles both absolute URLs (https://domain.com/portal → /portal) and relative paths (/portal).
31
+ *
32
+ * Returns null when:
33
+ * - portal_url is not set
34
+ * - the URL cannot be resolved to a non-root pathname (would otherwise match every page)
35
+ */
36
+ export function resolvePortalPath(
37
+ companyInformation?: CompanyInformation | null
38
+ ): string | null {
39
+ const url = companyInformation?.portal_url?.trim();
40
+ if (!url) return null;
41
+
42
+ let pathname: string | null = null;
43
+ try {
44
+ pathname = new URL(url).pathname;
45
+ } catch {
46
+ pathname = url.startsWith('/') ? url : null;
47
+ }
48
+
49
+ if (!pathname || pathname === '/') return null;
50
+
51
+ // Normalize trailing slash so startsWith comparisons are consistent.
52
+ return pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
53
+ }
54
+
28
55
  export function resolveCtaUrls(
29
56
  companyInformation?: CompanyInformation | null
30
57
  ): ResolvedCtaUrls {