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.
- package/dist/design_system/sections/index.js +42 -11
- package/dist/design_system/sections/index.js.map +1 -1
- package/dist/index.js +42 -11
- package/dist/index.js.map +1 -1
- package/dist/tracking/index.d.ts +6 -1
- package/dist/tracking/index.js +5 -3
- package/dist/tracking/index.js.map +1 -1
- package/package.json +1 -1
- package/src/design_system/portal/BookIframePanel.tsx +132 -0
- package/src/design_system/portal/LoginForm.tsx +4 -7
- package/src/design_system/portal/LoginModalController.tsx +36 -12
- package/src/design_system/portal/PortalPage.tsx +5 -8
- package/src/design_system/sections/header-navigation.aman.tsx +2 -6
- package/src/design_system/sections/header-navigation.balance.tsx +2 -6
- package/src/design_system/sections/header-navigation.barelux.tsx +2 -6
- package/src/design_system/sections/header-navigation.tsx +2 -6
- package/src/lib/cta-urls.ts +27 -0
package/dist/tracking/index.d.ts
CHANGED
|
@@ -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 };
|
package/dist/tracking/index.js
CHANGED
|
@@ -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
|
-
|
|
102
|
-
|
|
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
|
@@ -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-
|
|
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-
|
|
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-
|
|
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
|
-
*
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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 = () => {
|
package/src/lib/cta-urls.ts
CHANGED
|
@@ -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 {
|