keystone-design-bootstrap 1.0.66 → 1.0.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -132
- 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 +2 -1
- package/src/design_system/components/ChatWidget.tsx +6 -7
- package/src/design_system/portal/LoginForm.tsx +25 -9
- package/src/design_system/portal/LoginModalController.tsx +36 -12
- package/src/design_system/portal/PortalPage.tsx +5 -5
- package/src/design_system/portal/PortalTabTracker.tsx +10 -2
- package/src/design_system/sections/contact-section-form.aman.tsx +6 -1
- package/src/design_system/sections/contact-section-form.balance.tsx +6 -1
- package/src/design_system/sections/contact-section-form.barelux.tsx +6 -1
- package/src/design_system/sections/contact-section-form.tsx +6 -1
- package/src/design_system/sections/email-signup-section.tsx +6 -1
- package/src/design_system/sections/job-application-form.aman.tsx +6 -1
- package/src/design_system/sections/job-application-form.barelux.tsx +6 -1
- package/src/design_system/sections/job-application-form.tsx +6 -1
- package/src/lib/cta-urls.ts +13 -2
- package/src/lib/server-api.ts +18 -0
- package/src/next/layouts/root-layout.tsx +66 -33
- package/src/tracking/KeystoneAnalyticsTracker.tsx +41 -0
- package/src/tracking/PostHogProvider.tsx +128 -0
- package/src/tracking/captureEvent.ts +140 -0
- package/src/tracking/index.ts +5 -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "keystone-design-bootstrap",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.69",
|
|
4
4
|
"description": "Keystone Design Bootstrap - Sections, Elements, and Theme System for customer websites",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
"clsx": "^2.1.1",
|
|
70
70
|
"embla-carousel-react": "^8.6.0",
|
|
71
71
|
"motion": "^12.23.12",
|
|
72
|
+
"posthog-js": "^1.367.0",
|
|
72
73
|
"react-aria": "^3.42.0",
|
|
73
74
|
"react-aria-components": "^1.15.1",
|
|
74
75
|
"react-markdown": "^10.1.0",
|
|
@@ -4,6 +4,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
|
4
4
|
import { X, MessageChatSquare } from '@untitledui/icons';
|
|
5
5
|
import { Avatar } from '../elements/avatar/avatar';
|
|
6
6
|
import { cx } from '../../utils/cx';
|
|
7
|
+
import { captureEvent } from '../../tracking/captureEvent';
|
|
7
8
|
|
|
8
9
|
interface Message {
|
|
9
10
|
id: string;
|
|
@@ -197,27 +198,25 @@ export function ChatWidget({
|
|
|
197
198
|
|
|
198
199
|
if (response.ok) {
|
|
199
200
|
const result = await response.json();
|
|
200
|
-
|
|
201
|
-
|
|
201
|
+
captureEvent('chat_message_sent', { is_authenticated: Boolean(contactId) });
|
|
202
|
+
|
|
202
203
|
if (result.data?.job_id) {
|
|
203
|
-
// Job is processing - poll for the reply
|
|
204
204
|
pollForAgentReply();
|
|
205
205
|
} else if (result.data?.status === 'agent_unavailable' || result.data?.status === 'no_auto_reply') {
|
|
206
|
-
// No agent reply expected
|
|
207
206
|
setIsLoading(false);
|
|
208
207
|
} else {
|
|
209
|
-
// Reload messages to get the actual stored message
|
|
210
208
|
await loadMessages();
|
|
211
209
|
setIsLoading(false);
|
|
212
210
|
}
|
|
213
211
|
} else {
|
|
214
|
-
// Remove temp message on error
|
|
215
212
|
setMessages(prev => prev.filter(m => m.id !== tempMessage.id));
|
|
213
|
+
captureEvent('chat_message_failed', { error: 'send_failed' });
|
|
216
214
|
console.error('Failed to send message');
|
|
217
215
|
setIsLoading(false);
|
|
218
216
|
}
|
|
219
217
|
} catch (error) {
|
|
220
218
|
setMessages(prev => prev.filter(m => m.id !== tempMessage.id));
|
|
219
|
+
captureEvent('chat_message_failed', { error: 'network_error' });
|
|
221
220
|
console.error('Failed to send message:', error);
|
|
222
221
|
setIsLoading(false);
|
|
223
222
|
}
|
|
@@ -250,7 +249,7 @@ export function ChatWidget({
|
|
|
250
249
|
{/* Chat button */}
|
|
251
250
|
{!isOpen && (
|
|
252
251
|
<button
|
|
253
|
-
onClick={() => setIsOpen(true)}
|
|
252
|
+
onClick={() => { setIsOpen(true); captureEvent('chat_opened'); }}
|
|
254
253
|
className={cx(
|
|
255
254
|
"flex size-15 items-center justify-center rounded-full border-none text-white shadow-xl transition-transform duration-200 hover:scale-105 focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand",
|
|
256
255
|
widgetBrandClass
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import React, { useState, useMemo } from 'react';
|
|
3
|
+
import React, { useState, useMemo, useEffect } from 'react';
|
|
4
4
|
import { useRouter } from 'next/navigation';
|
|
5
5
|
import { countries } from '../../utils/countries';
|
|
6
6
|
import { getNationalMask, formatDigitsToMask } from '../../utils/phone-helpers';
|
|
7
7
|
import { firePixelEvent, setPixelUserData } from '../../tracking/firePixelEvent';
|
|
8
|
+
import { captureEvent } from '../../tracking/captureEvent';
|
|
8
9
|
type Step = 'identifier' | 'returning' | 'new';
|
|
9
10
|
|
|
10
11
|
interface LoginFormProps {
|
|
@@ -12,8 +13,9 @@ interface LoginFormProps {
|
|
|
12
13
|
onClose?: () => void;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
// text-base (16px) prevents iOS from auto-zooming when the input is focused.
|
|
15
17
|
const inputClass =
|
|
16
|
-
'block w-full rounded-input border border-primary bg-primary px-3 py-2.5 text-
|
|
18
|
+
'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
19
|
const labelClass = 'block text-sm text-secondary mb-1';
|
|
18
20
|
|
|
19
21
|
function isValidEmail(value: string): boolean {
|
|
@@ -24,6 +26,10 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
|
|
|
24
26
|
const router = useRouter();
|
|
25
27
|
const [step, setStep] = useState<Step>('identifier');
|
|
26
28
|
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
captureEvent('portal_login_started');
|
|
31
|
+
}, []);
|
|
32
|
+
|
|
27
33
|
// Identifier step state
|
|
28
34
|
const [email, setEmail] = useState('');
|
|
29
35
|
const [phoneValue, setPhoneValue] = useState(''); // formatted national number
|
|
@@ -72,19 +78,24 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
|
|
|
72
78
|
body: JSON.stringify({ email: emailVal, phone: fullPhone }),
|
|
73
79
|
});
|
|
74
80
|
const result = await res.json().catch(() => ({ exists: null }));
|
|
81
|
+
const method = emailVal && fullPhone ? 'email_and_phone' : emailVal ? 'email' : 'phone';
|
|
75
82
|
if (result.exists === true) {
|
|
76
83
|
await setPixelUserData({ email: emailVal, phone: fullPhone });
|
|
77
84
|
firePixelEvent('Lead');
|
|
85
|
+
captureEvent('portal_login_identified', { method, user_exists: true });
|
|
78
86
|
setWelcomeName(result.firstName ?? null);
|
|
79
87
|
setStep(result.hasPassword === false ? 'new' : 'returning');
|
|
80
88
|
} else if (result.exists === false) {
|
|
81
89
|
await setPixelUserData({ email: emailVal, phone: fullPhone });
|
|
82
90
|
firePixelEvent('Lead');
|
|
91
|
+
captureEvent('portal_login_identified', { method, user_exists: false });
|
|
83
92
|
setStep('new');
|
|
84
93
|
} else {
|
|
94
|
+
captureEvent('portal_login_failed', { step: 'identifier', reason: 'unknown_error' });
|
|
85
95
|
setError('Something went wrong. Please check your connection and try again.');
|
|
86
96
|
}
|
|
87
97
|
} catch {
|
|
98
|
+
captureEvent('portal_login_failed', { step: 'identifier', reason: 'network_error' });
|
|
88
99
|
setError('Something went wrong. Please check your connection and try again.');
|
|
89
100
|
} finally {
|
|
90
101
|
setLoading(false);
|
|
@@ -108,11 +119,14 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
|
|
|
108
119
|
setError(null);
|
|
109
120
|
return;
|
|
110
121
|
}
|
|
122
|
+
captureEvent('portal_login_failed', { step: 'signin', reason: result.code || 'invalid_credentials' });
|
|
111
123
|
setError(result.error || 'Login failed. Please try again.');
|
|
112
124
|
return;
|
|
113
125
|
}
|
|
126
|
+
captureEvent('portal_login_completed', { flow: 'signin' });
|
|
114
127
|
if (onSuccess) onSuccess(); else router.refresh();
|
|
115
128
|
} catch {
|
|
129
|
+
captureEvent('portal_login_failed', { step: 'signin', reason: 'network_error' });
|
|
116
130
|
setError('Something went wrong. Please check your connection and try again.');
|
|
117
131
|
} finally {
|
|
118
132
|
setLoading(false);
|
|
@@ -138,9 +152,15 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
|
|
|
138
152
|
}),
|
|
139
153
|
});
|
|
140
154
|
const result = await res.json().catch(() => ({}));
|
|
141
|
-
if (!res.ok) {
|
|
155
|
+
if (!res.ok) {
|
|
156
|
+
captureEvent('portal_login_failed', { step: 'signup', reason: result.error || 'signup_failed' });
|
|
157
|
+
setError(result.error || 'Signup failed. Please try again.');
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
captureEvent('portal_login_completed', { flow: 'signup' });
|
|
142
161
|
if (onSuccess) onSuccess(); else router.refresh();
|
|
143
162
|
} catch {
|
|
163
|
+
captureEvent('portal_login_failed', { step: 'signup', reason: 'network_error' });
|
|
144
164
|
setError('Something went wrong. Please check your connection and try again.');
|
|
145
165
|
} finally {
|
|
146
166
|
setLoading(false);
|
|
@@ -206,7 +226,6 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
|
|
|
206
226
|
placeholder="you@example.com"
|
|
207
227
|
className={inputClass}
|
|
208
228
|
autoComplete="email"
|
|
209
|
-
autoFocus
|
|
210
229
|
/>
|
|
211
230
|
</div>
|
|
212
231
|
<div className="flex items-center gap-3">
|
|
@@ -220,7 +239,7 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
|
|
|
220
239
|
<select
|
|
221
240
|
value={selectedCountry}
|
|
222
241
|
onChange={(e) => { setSelectedCountry(e.target.value); setPhoneValue(''); }}
|
|
223
|
-
className="border-r border-secondary bg-secondary px-2 py-2.5 text-
|
|
242
|
+
className="border-r border-secondary bg-secondary px-2 py-2.5 text-base text-secondary focus:outline-none"
|
|
224
243
|
aria-label="Country code"
|
|
225
244
|
>
|
|
226
245
|
{countryOptions.map((opt) => (
|
|
@@ -232,7 +251,7 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
|
|
|
232
251
|
value={phoneValue}
|
|
233
252
|
onChange={handlePhoneChange}
|
|
234
253
|
placeholder={nationalPlaceholder}
|
|
235
|
-
className="flex-1 px-3 py-2.5 text-
|
|
254
|
+
className="flex-1 px-3 py-2.5 text-base text-primary placeholder-quaternary bg-transparent focus:outline-none"
|
|
236
255
|
/>
|
|
237
256
|
</div>
|
|
238
257
|
</div>
|
|
@@ -261,7 +280,6 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
|
|
|
261
280
|
className={inputClass}
|
|
262
281
|
required
|
|
263
282
|
autoComplete="current-password"
|
|
264
|
-
autoFocus
|
|
265
283
|
/>
|
|
266
284
|
</div>
|
|
267
285
|
<div className="pt-1">
|
|
@@ -298,7 +316,6 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
|
|
|
298
316
|
placeholder="Jane"
|
|
299
317
|
className={inputClass}
|
|
300
318
|
autoComplete="given-name"
|
|
301
|
-
autoFocus
|
|
302
319
|
/>
|
|
303
320
|
</div>
|
|
304
321
|
<div>
|
|
@@ -324,7 +341,6 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
|
|
|
324
341
|
className={inputClass}
|
|
325
342
|
required
|
|
326
343
|
autoComplete="new-password"
|
|
327
|
-
autoFocus={!!welcomeName}
|
|
328
344
|
/>
|
|
329
345
|
</div>
|
|
330
346
|
<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
|
}
|
|
@@ -267,7 +267,7 @@ function ServicesPanel({
|
|
|
267
267
|
|
|
268
268
|
return (
|
|
269
269
|
<>
|
|
270
|
-
<PortalTabTracker event="ViewContent" params={{ contentName: 'Services', contentCategory: 'Services' }} />
|
|
270
|
+
<PortalTabTracker event="ViewContent" params={{ contentName: 'Services', contentCategory: 'Services' }} tab="services" />
|
|
271
271
|
<div className="divide-y divide-tertiary rounded-component border border-secondary bg-primary overflow-hidden">
|
|
272
272
|
{activeServices.map((service) => (
|
|
273
273
|
<details key={service.id} className="group">
|
|
@@ -321,7 +321,7 @@ function PackagesPanel({
|
|
|
321
321
|
|
|
322
322
|
return (
|
|
323
323
|
<>
|
|
324
|
-
<PortalTabTracker event="ViewContent" params={{ contentName: 'Packages', contentCategory: 'Packages' }} />
|
|
324
|
+
<PortalTabTracker event="ViewContent" params={{ contentName: 'Packages', contentCategory: 'Packages' }} tab="packages" />
|
|
325
325
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
326
326
|
{packages.map((pkg) => {
|
|
327
327
|
const activeOffers = (pkg.offers ?? []).filter((o) => o.active !== false && !o.expired);
|
|
@@ -406,7 +406,7 @@ function SpecialsPanel({ specials }: { specials: SpecialItem[] }) {
|
|
|
406
406
|
|
|
407
407
|
return (
|
|
408
408
|
<>
|
|
409
|
-
<PortalTabTracker event="ViewContent" params={{ contentName: 'Specials', contentCategory: 'Specials' }} />
|
|
409
|
+
<PortalTabTracker event="ViewContent" params={{ contentName: 'Specials', contentCategory: 'Specials' }} tab="specials" />
|
|
410
410
|
<div className="space-y-3">
|
|
411
411
|
{specials.map((special) => (
|
|
412
412
|
<div key={special.id} className="group flex items-start gap-3 rounded-component border border-secondary bg-primary px-4 py-4">
|
|
@@ -524,7 +524,7 @@ function BookPanel({
|
|
|
524
524
|
if (bookingAllowsIframe) {
|
|
525
525
|
return (
|
|
526
526
|
<>
|
|
527
|
-
<PortalTabTracker event="InitiateCheckout" />
|
|
527
|
+
<PortalTabTracker event="InitiateCheckout" tab="booking" />
|
|
528
528
|
<BookIframePanel bookingHref={bookingHref} businessName={businessName} />
|
|
529
529
|
</>
|
|
530
530
|
);
|
|
@@ -532,7 +532,7 @@ function BookPanel({
|
|
|
532
532
|
|
|
533
533
|
return (
|
|
534
534
|
<>
|
|
535
|
-
<PortalTabTracker event="InitiateCheckout" />
|
|
535
|
+
<PortalTabTracker event="InitiateCheckout" tab="booking" />
|
|
536
536
|
<div className="flex flex-col items-center justify-center py-20 text-center">
|
|
537
537
|
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-secondary">
|
|
538
538
|
<svg className="size-5 text-quaternary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
@@ -2,21 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect } from 'react';
|
|
4
4
|
import { firePixelEvent } from '../../tracking/firePixelEvent';
|
|
5
|
+
import { captureEvent } from '../../tracking/captureEvent';
|
|
5
6
|
import type { PixelEvent, PixelEventParams } from '../../tracking/firePixelEvent';
|
|
6
7
|
|
|
7
8
|
interface Props {
|
|
8
9
|
event: PixelEvent;
|
|
9
10
|
params?: PixelEventParams;
|
|
11
|
+
/** Human-readable tab name for PostHog (e.g. 'booking', 'appointments', 'membership'). */
|
|
12
|
+
tab: string;
|
|
10
13
|
}
|
|
11
14
|
|
|
12
15
|
/**
|
|
13
|
-
* Fires
|
|
16
|
+
* Fires tracking events once when a portal tab mounts.
|
|
14
17
|
* Placed at the root of each tab panel so it fires on both direct navigation
|
|
15
18
|
* and post-login redirect to that tab.
|
|
19
|
+
*
|
|
20
|
+
* Fires:
|
|
21
|
+
* - Meta Pixel: the provided `event` (e.g. ViewContent, InitiateCheckout)
|
|
22
|
+
* - PostHog: portal_tab_viewed with the tab name
|
|
16
23
|
*/
|
|
17
|
-
export function PortalTabTracker({ event, params }: Props) {
|
|
24
|
+
export function PortalTabTracker({ event, params, tab }: Props) {
|
|
18
25
|
useEffect(() => {
|
|
19
26
|
firePixelEvent(event, params);
|
|
27
|
+
captureEvent('portal_tab_viewed', { tab });
|
|
20
28
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
21
29
|
}, []);
|
|
22
30
|
|
|
@@ -4,6 +4,7 @@ import React, { useRef, useState } from 'react';
|
|
|
4
4
|
import { Form, Button } from '../elements';
|
|
5
5
|
import { DynamicFormFields } from '../components/DynamicFormFields';
|
|
6
6
|
import { firePixelEvent, setPixelUserData } from '../../tracking/firePixelEvent';
|
|
7
|
+
import { captureEvent } from '../../tracking/captureEvent';
|
|
7
8
|
import type { FormDefinition } from '../../types/api/form';
|
|
8
9
|
import { useFormDefinitions } from '../../next/contexts/form-definitions';
|
|
9
10
|
|
|
@@ -63,14 +64,18 @@ export const ContactSectionForm = ({
|
|
|
63
64
|
onSuccess?.();
|
|
64
65
|
await setPixelUserData({ email: data.email, phone: data.phone });
|
|
65
66
|
firePixelEvent('Lead', undefined, result.eventId);
|
|
67
|
+
captureEvent('form_submitted', { form_type: 'lead', ...(result.eventId && { event_id: result.eventId }) });
|
|
66
68
|
setTimeout(() => setSubmitStatus('idle'), 5000);
|
|
67
69
|
} else {
|
|
70
|
+
const errorMsg = result.error || 'Something went wrong. Please try again.';
|
|
68
71
|
setSubmitStatus('error');
|
|
69
|
-
setStatusMessage(
|
|
72
|
+
setStatusMessage(errorMsg);
|
|
73
|
+
captureEvent('form_failed', { form_type: 'lead', error: errorMsg });
|
|
70
74
|
}
|
|
71
75
|
} catch {
|
|
72
76
|
setSubmitStatus('error');
|
|
73
77
|
setStatusMessage('Network error. Please try again later.');
|
|
78
|
+
captureEvent('form_failed', { form_type: 'lead', error: 'network_error' });
|
|
74
79
|
}
|
|
75
80
|
setIsSubmitting(false);
|
|
76
81
|
};
|
|
@@ -4,6 +4,7 @@ import React, { useRef, useState } from 'react';
|
|
|
4
4
|
import { Form, Button } from '../elements';
|
|
5
5
|
import { DynamicFormFields } from '../components/DynamicFormFields';
|
|
6
6
|
import { firePixelEvent, setPixelUserData } from '../../tracking/firePixelEvent';
|
|
7
|
+
import { captureEvent } from '../../tracking/captureEvent';
|
|
7
8
|
import type { FormDefinition } from '../../types/api/form';
|
|
8
9
|
import { useFormDefinitions } from '../../next/contexts/form-definitions';
|
|
9
10
|
|
|
@@ -63,14 +64,18 @@ export const ContactSectionForm = ({
|
|
|
63
64
|
onSuccess?.();
|
|
64
65
|
await setPixelUserData({ email: data.email, phone: data.phone });
|
|
65
66
|
firePixelEvent('Lead', undefined, result.eventId);
|
|
67
|
+
captureEvent('form_submitted', { form_type: 'lead', ...(result.eventId && { event_id: result.eventId }) });
|
|
66
68
|
setTimeout(() => setSubmitStatus('idle'), 5000);
|
|
67
69
|
} else {
|
|
70
|
+
const errorMsg = result.error || 'Something went wrong. Please try again.';
|
|
68
71
|
setSubmitStatus('error');
|
|
69
|
-
setStatusMessage(
|
|
72
|
+
setStatusMessage(errorMsg);
|
|
73
|
+
captureEvent('form_failed', { form_type: 'lead', error: errorMsg });
|
|
70
74
|
}
|
|
71
75
|
} catch {
|
|
72
76
|
setSubmitStatus('error');
|
|
73
77
|
setStatusMessage('Network error. Please try again later.');
|
|
78
|
+
captureEvent('form_failed', { form_type: 'lead', error: 'network_error' });
|
|
74
79
|
}
|
|
75
80
|
setIsSubmitting(false);
|
|
76
81
|
};
|
|
@@ -4,6 +4,7 @@ import React, { useRef, useState } from 'react';
|
|
|
4
4
|
import { Form, Button } from '../elements';
|
|
5
5
|
import { DynamicFormFields } from '../components/DynamicFormFields';
|
|
6
6
|
import { firePixelEvent, setPixelUserData } from '../../tracking/firePixelEvent';
|
|
7
|
+
import { captureEvent } from '../../tracking/captureEvent';
|
|
7
8
|
import type { FormDefinition } from '../../types/api/form';
|
|
8
9
|
import { useFormDefinitions } from '../../next/contexts/form-definitions';
|
|
9
10
|
|
|
@@ -63,14 +64,18 @@ export const ContactSectionForm = ({
|
|
|
63
64
|
onSuccess?.();
|
|
64
65
|
await setPixelUserData({ email: data.email, phone: data.phone });
|
|
65
66
|
firePixelEvent('Lead', undefined, result.eventId);
|
|
67
|
+
captureEvent('form_submitted', { form_type: 'lead', ...(result.eventId && { event_id: result.eventId }) });
|
|
66
68
|
setTimeout(() => setSubmitStatus('idle'), 5000);
|
|
67
69
|
} else {
|
|
70
|
+
const errorMsg = result.error || 'Something went wrong. Please try again.';
|
|
68
71
|
setSubmitStatus('error');
|
|
69
|
-
setStatusMessage(
|
|
72
|
+
setStatusMessage(errorMsg);
|
|
73
|
+
captureEvent('form_failed', { form_type: 'lead', error: errorMsg });
|
|
70
74
|
}
|
|
71
75
|
} catch {
|
|
72
76
|
setSubmitStatus('error');
|
|
73
77
|
setStatusMessage('Network error. Please try again later.');
|
|
78
|
+
captureEvent('form_failed', { form_type: 'lead', error: 'network_error' });
|
|
74
79
|
}
|
|
75
80
|
setIsSubmitting(false);
|
|
76
81
|
};
|
|
@@ -4,6 +4,7 @@ import React, { useRef, useState } from 'react';
|
|
|
4
4
|
import { Form, Button } from '../elements';
|
|
5
5
|
import { DynamicFormFields } from '../components/DynamicFormFields';
|
|
6
6
|
import { firePixelEvent, setPixelUserData } from '../../tracking/firePixelEvent';
|
|
7
|
+
import { captureEvent } from '../../tracking/captureEvent';
|
|
7
8
|
import type { FormDefinition } from '../../types/api/form';
|
|
8
9
|
import { useFormDefinitions } from '../../next/contexts/form-definitions';
|
|
9
10
|
|
|
@@ -71,14 +72,18 @@ export const ContactSectionForm = ({
|
|
|
71
72
|
onSuccess?.();
|
|
72
73
|
await setPixelUserData({ email: data.email, phone: data.phone });
|
|
73
74
|
firePixelEvent('Lead', undefined, result.eventId);
|
|
75
|
+
captureEvent('form_submitted', { form_type: 'lead', ...(result.eventId && { event_id: result.eventId }) });
|
|
74
76
|
setTimeout(() => setSubmitStatus('idle'), 5000);
|
|
75
77
|
} else {
|
|
78
|
+
const errorMsg = result.error || 'Something went wrong. Please try again.';
|
|
76
79
|
setSubmitStatus('error');
|
|
77
|
-
setStatusMessage(
|
|
80
|
+
setStatusMessage(errorMsg);
|
|
81
|
+
captureEvent('form_failed', { form_type: 'lead', error: errorMsg });
|
|
78
82
|
}
|
|
79
83
|
} catch {
|
|
80
84
|
setSubmitStatus('error');
|
|
81
85
|
setStatusMessage('Network error. Please try again later.');
|
|
86
|
+
captureEvent('form_failed', { form_type: 'lead', error: 'network_error' });
|
|
82
87
|
}
|
|
83
88
|
setIsSubmitting(false);
|
|
84
89
|
};
|
|
@@ -5,6 +5,7 @@ import { Form, Button } from '../elements';
|
|
|
5
5
|
import { DynamicFormFields } from '../components/DynamicFormFields';
|
|
6
6
|
import type { FormDefinition } from '../../types/api/form';
|
|
7
7
|
import { useFormDefinitions } from '../../next/contexts/form-definitions';
|
|
8
|
+
import { captureEvent } from '../../tracking/captureEvent';
|
|
8
9
|
|
|
9
10
|
export interface EmailSignupSectionProps {
|
|
10
11
|
title?: string;
|
|
@@ -54,14 +55,18 @@ export const EmailSignupSection = ({
|
|
|
54
55
|
setSubmitStatus('success');
|
|
55
56
|
setStatusMessage(result.message || successMessage);
|
|
56
57
|
formRef.current?.reset();
|
|
58
|
+
captureEvent('form_submitted', { form_type: 'marketing_list_signup' });
|
|
57
59
|
setTimeout(() => setSubmitStatus('idle'), 6000);
|
|
58
60
|
} else {
|
|
61
|
+
const errorMsg = result.error || 'Something went wrong. Please try again.';
|
|
59
62
|
setSubmitStatus('error');
|
|
60
|
-
setStatusMessage(
|
|
63
|
+
setStatusMessage(errorMsg);
|
|
64
|
+
captureEvent('form_failed', { form_type: 'marketing_list_signup', error: errorMsg });
|
|
61
65
|
}
|
|
62
66
|
} catch {
|
|
63
67
|
setSubmitStatus('error');
|
|
64
68
|
setStatusMessage('Network error. Please try again later.');
|
|
69
|
+
captureEvent('form_failed', { form_type: 'marketing_list_signup', error: 'network_error' });
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
setIsSubmitting(false);
|
|
@@ -5,6 +5,7 @@ import { Form, Button } from '../elements';
|
|
|
5
5
|
import { DynamicFormFields } from '../components/DynamicFormFields';
|
|
6
6
|
import type { FormDefinition } from '../../types/api/form';
|
|
7
7
|
import { useFormDefinitions } from '../../next/contexts/form-definitions';
|
|
8
|
+
import { captureEvent } from '../../tracking/captureEvent';
|
|
8
9
|
|
|
9
10
|
interface JobApplicationFormAmanProps {
|
|
10
11
|
jobSlug: string;
|
|
@@ -47,14 +48,18 @@ export const JobApplicationForm = ({ jobSlug, formDefinition, inline = false }:
|
|
|
47
48
|
setSubmitStatus('success');
|
|
48
49
|
setStatusMessage(result.message || "Thank you for applying! We'll be in touch soon.");
|
|
49
50
|
formRef.current?.reset();
|
|
51
|
+
captureEvent('form_submitted', { form_type: 'job_application' });
|
|
50
52
|
setTimeout(() => setSubmitStatus('idle'), 5000);
|
|
51
53
|
} else {
|
|
54
|
+
const errorMsg = result.error || 'Something went wrong. Please try again.';
|
|
52
55
|
setSubmitStatus('error');
|
|
53
|
-
setStatusMessage(
|
|
56
|
+
setStatusMessage(errorMsg);
|
|
57
|
+
captureEvent('form_failed', { form_type: 'job_application', error: errorMsg });
|
|
54
58
|
}
|
|
55
59
|
} catch {
|
|
56
60
|
setSubmitStatus('error');
|
|
57
61
|
setStatusMessage('Network error. Please try again.');
|
|
62
|
+
captureEvent('form_failed', { form_type: 'job_application', error: 'network_error' });
|
|
58
63
|
}
|
|
59
64
|
setIsSubmitting(false);
|
|
60
65
|
};
|