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.
Files changed (29) hide show
  1. package/README.md +74 -132
  2. package/dist/design_system/sections/index.js +42 -11
  3. package/dist/design_system/sections/index.js.map +1 -1
  4. package/dist/index.js +42 -11
  5. package/dist/index.js.map +1 -1
  6. package/dist/tracking/index.d.ts +6 -1
  7. package/dist/tracking/index.js +5 -3
  8. package/dist/tracking/index.js.map +1 -1
  9. package/package.json +2 -1
  10. package/src/design_system/components/ChatWidget.tsx +6 -7
  11. package/src/design_system/portal/LoginForm.tsx +25 -9
  12. package/src/design_system/portal/LoginModalController.tsx +36 -12
  13. package/src/design_system/portal/PortalPage.tsx +5 -5
  14. package/src/design_system/portal/PortalTabTracker.tsx +10 -2
  15. package/src/design_system/sections/contact-section-form.aman.tsx +6 -1
  16. package/src/design_system/sections/contact-section-form.balance.tsx +6 -1
  17. package/src/design_system/sections/contact-section-form.barelux.tsx +6 -1
  18. package/src/design_system/sections/contact-section-form.tsx +6 -1
  19. package/src/design_system/sections/email-signup-section.tsx +6 -1
  20. package/src/design_system/sections/job-application-form.aman.tsx +6 -1
  21. package/src/design_system/sections/job-application-form.barelux.tsx +6 -1
  22. package/src/design_system/sections/job-application-form.tsx +6 -1
  23. package/src/lib/cta-urls.ts +13 -2
  24. package/src/lib/server-api.ts +18 -0
  25. package/src/next/layouts/root-layout.tsx +66 -33
  26. package/src/tracking/KeystoneAnalyticsTracker.tsx +41 -0
  27. package/src/tracking/PostHogProvider.tsx +128 -0
  28. package/src/tracking/captureEvent.ts +140 -0
  29. package/src/tracking/index.ts +5 -0
@@ -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.66",
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
- // Message sent successfully
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-sm text-primary placeholder-quaternary focus:border-brand focus:outline-none focus:ring-1 focus:ring-brand transition-colors';
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) { setError(result.error || 'Signup failed. Please try again.'); return; }
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-sm text-secondary focus:outline-none"
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-sm text-primary placeholder-quaternary bg-transparent focus:outline-none"
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
- * 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
  }
@@ -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 a pixel event once when a portal tab mounts.
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(result.error || 'Something went wrong. Please try again.');
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(result.error || 'Something went wrong. Please try again.');
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(result.error || 'Something went wrong. Please try again.');
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(result.error || 'Something went wrong. Please try again.');
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(result.error || 'Something went wrong. Please try again.');
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(result.error || 'Something went wrong. Please try again.');
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
  };