keystone-design-bootstrap 1.0.68 → 1.0.70

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 (34) hide show
  1. package/README.md +74 -132
  2. package/dist/design_system/sections/index.js +110 -60
  3. package/dist/design_system/sections/index.js.map +1 -1
  4. package/dist/index.js +117 -61
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/server-api.d.ts +9 -1
  7. package/dist/lib/server-api.js +11 -0
  8. package/dist/lib/server-api.js.map +1 -1
  9. package/dist/tracking/index.d.ts +134 -5
  10. package/dist/tracking/index.js +123 -0
  11. package/dist/tracking/index.js.map +1 -1
  12. package/package.json +2 -1
  13. package/src/design_system/components/ChatWidget.tsx +6 -7
  14. package/src/design_system/portal/LoginForm.tsx +21 -2
  15. package/src/design_system/portal/PortalPage.tsx +5 -5
  16. package/src/design_system/portal/PortalTabTracker.tsx +10 -2
  17. package/src/design_system/sections/contact-section-form.aman.tsx +6 -1
  18. package/src/design_system/sections/contact-section-form.balance.tsx +6 -1
  19. package/src/design_system/sections/contact-section-form.barelux.tsx +6 -1
  20. package/src/design_system/sections/contact-section-form.tsx +6 -1
  21. package/src/design_system/sections/email-signup-section.tsx +6 -1
  22. package/src/design_system/sections/header-navigation.aman.tsx +6 -1
  23. package/src/design_system/sections/header-navigation.balance.tsx +6 -1
  24. package/src/design_system/sections/header-navigation.barelux.tsx +6 -1
  25. package/src/design_system/sections/header-navigation.tsx +6 -1
  26. package/src/design_system/sections/job-application-form.aman.tsx +6 -1
  27. package/src/design_system/sections/job-application-form.barelux.tsx +6 -1
  28. package/src/design_system/sections/job-application-form.tsx +6 -1
  29. package/src/lib/server-api.ts +18 -0
  30. package/src/next/layouts/root-layout.tsx +78 -33
  31. package/src/tracking/KeystoneAnalyticsTracker.tsx +41 -0
  32. package/src/tracking/PostHogProvider.tsx +128 -0
  33. package/src/tracking/captureEvent.ts +140 -0
  34. package/src/tracking/index.ts +5 -0
@@ -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);
@@ -57,7 +57,12 @@ export function HeaderNavigation({
57
57
 
58
58
  // Use navigation from config or override
59
59
  const navigation = navigationOverride || config?.navigation?.header || [];
60
- const ctaUrls = resolveCtaUrls(companyInformation);
60
+ const resolvedCtaUrls = resolveCtaUrls(companyInformation);
61
+ const ctaUrls = {
62
+ primaryHref: props?.cta_button?.secondary_href ?? resolvedCtaUrls.primaryHref,
63
+ secondaryHref: props?.cta_button?.href ?? resolvedCtaUrls.secondaryHref,
64
+ hasSecondary: props?.cta_button?.secondary_href != null || resolvedCtaUrls.hasSecondary,
65
+ };
61
66
 
62
67
  // Hide the sticky bottom bar when the user is already on the portal page —
63
68
  // the portal has its own Book Now tab so the bar is redundant and confusing.
@@ -36,7 +36,12 @@ export function HeaderNavigation({
36
36
  const companyName = logoTextOverride || companyInformation?.company_name || props?.logo?.text || '';
37
37
 
38
38
  const navigation = navigationOverride || config?.navigation?.header || [];
39
- const ctaUrls = resolveCtaUrls(companyInformation);
39
+ const resolvedCtaUrls = resolveCtaUrls(companyInformation);
40
+ const ctaUrls = {
41
+ primaryHref: props?.cta_button?.secondary_href ?? resolvedCtaUrls.primaryHref,
42
+ secondaryHref: props?.cta_button?.href ?? resolvedCtaUrls.secondaryHref,
43
+ hasSecondary: props?.cta_button?.secondary_href != null || resolvedCtaUrls.hasSecondary,
44
+ };
40
45
 
41
46
  // Hide the sticky bottom bar when the user is already on the portal page —
42
47
  // the portal has its own Book Now tab so the bar is redundant and confusing.
@@ -42,7 +42,12 @@ export function HeaderNavigation({
42
42
 
43
43
  // Use navigation from config or override
44
44
  const navigation = navigationOverride || config?.navigation?.header || [];
45
- const ctaUrls = resolveCtaUrls(companyInformation);
45
+ const resolvedCtaUrls = resolveCtaUrls(companyInformation);
46
+ const ctaUrls = {
47
+ primaryHref: props?.cta_button?.secondary_href ?? resolvedCtaUrls.primaryHref,
48
+ secondaryHref: props?.cta_button?.href ?? resolvedCtaUrls.secondaryHref,
49
+ hasSecondary: props?.cta_button?.secondary_href != null || resolvedCtaUrls.hasSecondary,
50
+ };
46
51
 
47
52
  // Hide the sticky bottom bar when the user is already on the portal page —
48
53
  // the portal has its own Book Now tab so the bar is redundant and confusing.
@@ -99,7 +99,12 @@ export function HeaderNavigation({
99
99
  const logoImage = logoImageOverride || getLogoUrl(websitePhotos) || props?.logo?.image;
100
100
  const logoText = logoTextOverride || companyInformation?.company_name || props?.logo?.text || '';
101
101
  const cta_button = props?.cta_button;
102
- const ctaUrls = resolveCtaUrls(companyInformation);
102
+ const resolvedCtaUrls = resolveCtaUrls(companyInformation);
103
+ const ctaUrls = {
104
+ primaryHref: cta_button?.secondary_href ?? resolvedCtaUrls.primaryHref,
105
+ secondaryHref: cta_button?.href ?? resolvedCtaUrls.secondaryHref,
106
+ hasSecondary: cta_button?.secondary_href != null || resolvedCtaUrls.hasSecondary,
107
+ };
103
108
 
104
109
  const logo = {
105
110
  text: logoText || '',
@@ -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
  };
@@ -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 JobApplicationFormBareluxProps {
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
  };
@@ -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 JobApplicationFormProps {
10
11
  jobSlug: string;
@@ -50,14 +51,18 @@ export const JobApplicationForm = ({ jobSlug, formDefinition, inline = false }:
50
51
  setSubmitStatus('success');
51
52
  setStatusMessage(result.message || "Thank you for applying! We'll be in touch soon.");
52
53
  formRef.current?.reset();
54
+ captureEvent('form_submitted', { form_type: 'job_application' });
53
55
  setTimeout(() => setSubmitStatus('idle'), 5000);
54
56
  } else {
57
+ const errorMsg = result.error || 'Something went wrong. Please try again.';
55
58
  setSubmitStatus('error');
56
- setStatusMessage(result.error || 'Something went wrong. Please try again.');
59
+ setStatusMessage(errorMsg);
60
+ captureEvent('form_failed', { form_type: 'job_application', error: errorMsg });
57
61
  }
58
62
  } catch {
59
63
  setSubmitStatus('error');
60
64
  setStatusMessage('Network error. Please try again.');
65
+ captureEvent('form_failed', { form_type: 'job_application', error: 'network_error' });
61
66
  }
62
67
  setIsSubmitting(false);
63
68
  };
@@ -78,6 +78,24 @@ export function getMetaPixelId(adsConfig: { meta_pixel_id?: string } | null | un
78
78
  return str !== '' && str !== 'null' && /^\d+$/.test(str) ? str : null;
79
79
  }
80
80
 
81
+ export type AnalyticsConfig = {
82
+ /** PostHog project API key — platform-wide, from POSTHOG_API_KEY env var on the Rails server. */
83
+ posthog_api_key?: string;
84
+ };
85
+
86
+ /** Analytics config for customer sites. Returns platform-wide analytics keys (e.g. PostHog). */
87
+ export async function getAnalyticsConfig(): Promise<AnalyticsConfig | null> {
88
+ const data = await serverFetch<AnalyticsConfig>('/public/analytics_config', defaultOptions);
89
+ return data ?? null;
90
+ }
91
+
92
+ /** Extract PostHog API key from analytics config for use with <PostHogProvider apiKey={...} />. */
93
+ export function getPostHogApiKey(analyticsConfig: AnalyticsConfig | null | undefined): string | null {
94
+ const key = analyticsConfig?.posthog_api_key;
95
+ const str = key != null && key !== '' ? String(key).trim() : '';
96
+ return str !== '' && str !== 'null' ? str : null;
97
+ }
98
+
81
99
  export async function getServices(): Promise<Service[] | null> {
82
100
  return serverFetch<Service[]>('/public/services', defaultOptions);
83
101
  }
@@ -3,7 +3,7 @@ import type { Metadata } from 'next';
3
3
 
4
4
  import { HeaderNavigation, FooterHome } from '../../design_system/sections';
5
5
  import { ThemeProvider } from '../../contexts';
6
- import { MetaPixel, MetaPixelTracker } from '../../tracking';
6
+ import { MetaPixel, MetaPixelTracker, PostHogProvider, KeystoneAnalyticsTracker } from '../../tracking';
7
7
  import { ChatWidget } from '../../design_system/components/ChatWidget';
8
8
  import { FormDefinitionsProvider } from '../contexts/form-definitions';
9
9
  import { KeystoneSSRProvider } from '../providers/ssr-provider';
@@ -16,6 +16,8 @@ import {
16
16
  getForm,
17
17
  getAdsConfig,
18
18
  getMetaPixelId,
19
+ getAnalyticsConfig,
20
+ getPostHogApiKey,
19
21
  } from '../../lib/server-api';
20
22
 
21
23
  import type { CompanyInformation, Location, NavItem, Service, SiteConfig } from '../../types';
@@ -26,8 +28,18 @@ export type KeystoneRootLayoutHeaderOverrides = {
26
28
  logoText?: string;
27
29
  /** Overrides the primary CTA button label (default: "Contact Us") */
28
30
  ctaLabel?: string;
31
+ /**
32
+ * Overrides the href for the left ("Contact Us") header button.
33
+ * Defaults to `/contact`. Only relevant when two CTA buttons are shown.
34
+ */
35
+ ctaHref?: string;
29
36
  /** Overrides the secondary CTA button label (default: "Book Now" when a booking/portal URL is configured) */
30
37
  secondaryLabel?: string;
38
+ /**
39
+ * Overrides the href for the right ("Book Now") header button.
40
+ * When set, forces both CTA buttons to appear even if no external booking/portal URL is configured.
41
+ */
42
+ secondaryHref?: string;
31
43
  };
32
44
 
33
45
  export type KeystoneRootLayoutOptions = {
@@ -35,6 +47,13 @@ export type KeystoneRootLayoutOptions = {
35
47
  headerOverrides?: KeystoneRootLayoutHeaderOverrides;
36
48
  /** Chat widget position */
37
49
  chatPosition?: 'bottom-right' | 'bottom-left';
50
+ /**
51
+ * PostHog ingest host. Defaults to `https://us.i.posthog.com`.
52
+ * Override when self-hosting or using the EU cloud (`https://eu.i.posthog.com`).
53
+ * The API key is fetched automatically from the Rails analytics_config endpoint
54
+ * (set POSTHOG_API_KEY on the Rails server — no client-side env var needed).
55
+ */
56
+ posthogHost?: string;
38
57
  };
39
58
 
40
59
  function buildNavigationWithDynamicData(
@@ -91,7 +110,7 @@ function buildNavigationWithDynamicData(
91
110
  * Note: Next's `export const metadata` must remain in the site/app.
92
111
  * This component focuses on:
93
112
  * - fetching shared data
94
- * - injecting Meta Pixel
113
+ * - injecting Meta Pixel (from ads_config) and PostHog (from analytics_config)
95
114
  * - applying theme
96
115
  * - rendering Header + Footer
97
116
  * - rendering ChatWidget gated by `companyInformation.chat_enabled`
@@ -113,6 +132,7 @@ export async function KeystoneRootLayout(props: {
113
132
  jobApplicationFormDefinition,
114
133
  marketingListSignupFormDefinition,
115
134
  adsConfig,
135
+ analyticsConfig,
116
136
  ] =
117
137
  await Promise.all([
118
138
  getCompanyInformation(),
@@ -124,9 +144,11 @@ export async function KeystoneRootLayout(props: {
124
144
  getForm('job_application'),
125
145
  getForm('marketing_list_signup'),
126
146
  getAdsConfig(),
147
+ getAnalyticsConfig(),
127
148
  ]);
128
149
 
129
150
  const metaPixelId = getMetaPixelId(adsConfig);
151
+ const posthogApiKey = getPostHogApiKey(analyticsConfig);
130
152
 
131
153
  const services = Array.isArray(servicesData) ? servicesData : [];
132
154
  const locations = Array.isArray(locationsData) ? locationsData : [];
@@ -137,12 +159,15 @@ export async function KeystoneRootLayout(props: {
137
159
  const theme = config.site.theme;
138
160
 
139
161
  const ci = companyInformation as CompanyInformation | null;
162
+ const accountId = ci?.id ?? undefined;
163
+ const accountName = ci?.company_name ?? undefined;
140
164
  const externalManagementUrl = ci?.external_management_url?.trim() || null;
141
165
  const portalUrl = ci?.portal_url?.trim() || null;
142
166
  const bookingHref = portalUrl ?? externalManagementUrl ?? null;
143
167
  const chatEnabled = Boolean(ci?.chat_enabled);
144
168
 
145
169
  const headerOverrides = options?.headerOverrides;
170
+ const posthogHost = options?.posthogHost?.trim() || undefined;
146
171
  const headerProps = {
147
172
  logo: {
148
173
  href: headerOverrides?.logoHref || '/',
@@ -150,44 +175,64 @@ export async function KeystoneRootLayout(props: {
150
175
  },
151
176
  cta_button: {
152
177
  label: headerOverrides?.ctaLabel || 'Contact Us',
178
+ ...(headerOverrides?.ctaHref != null && { href: headerOverrides.ctaHref }),
153
179
  secondary_label: headerOverrides?.secondaryLabel ?? (bookingHref ? 'Book Now' : undefined),
180
+ ...(headerOverrides?.secondaryHref != null && { secondary_href: headerOverrides.secondaryHref }),
154
181
  },
155
182
  };
156
183
 
184
+ const bodyContent = (
185
+ <>
186
+ {metaPixelId ? <MetaPixel pixelId={metaPixelId} /> : null}
187
+ {metaPixelId ? <MetaPixelTracker bookingUrl={externalManagementUrl} /> : null}
188
+ <ThemeProvider theme={theme}>
189
+ <KeystoneSSRProvider>
190
+ <FormDefinitionsProvider
191
+ leadFormDefinition={leadFormDefinition ?? null}
192
+ jobApplicationFormDefinition={jobApplicationFormDefinition ?? null}
193
+ marketingListSignupFormDefinition={marketingListSignupFormDefinition ?? null}
194
+ >
195
+ <HeaderNavigation
196
+ config={dynamicConfig}
197
+ companyInformation={companyInformation}
198
+ websitePhotos={websitePhotos}
199
+ props={headerProps}
200
+ logoText={headerOverrides?.logoText}
201
+ />
202
+ {children}
203
+ <FooterHome
204
+ config={dynamicConfig}
205
+ companyInformation={companyInformation}
206
+ websitePhotos={websitePhotos}
207
+ />
208
+ {chatEnabled ? (
209
+ <ChatWidget
210
+ position={options?.chatPosition || 'bottom-right'}
211
+ teamMembers={teamMembers}
212
+ />
213
+ ) : null}
214
+ </FormDefinitionsProvider>
215
+ </KeystoneSSRProvider>
216
+ </ThemeProvider>
217
+ </>
218
+ );
219
+
157
220
  return (
158
221
  <html lang="en" data-theme={theme}>
159
222
  <body>
160
- {metaPixelId ? <MetaPixel pixelId={metaPixelId} /> : null}
161
- {metaPixelId ? <MetaPixelTracker bookingUrl={externalManagementUrl} /> : null}
162
- <ThemeProvider theme={theme}>
163
- <KeystoneSSRProvider>
164
- <FormDefinitionsProvider
165
- leadFormDefinition={leadFormDefinition ?? null}
166
- jobApplicationFormDefinition={jobApplicationFormDefinition ?? null}
167
- marketingListSignupFormDefinition={marketingListSignupFormDefinition ?? null}
168
- >
169
- <HeaderNavigation
170
- config={dynamicConfig}
171
- companyInformation={companyInformation}
172
- websitePhotos={websitePhotos}
173
- props={headerProps}
174
- logoText={headerOverrides?.logoText}
175
- />
176
- {children}
177
- <FooterHome
178
- config={dynamicConfig}
179
- companyInformation={companyInformation}
180
- websitePhotos={websitePhotos}
181
- />
182
- {chatEnabled ? (
183
- <ChatWidget
184
- position={options?.chatPosition || 'bottom-right'}
185
- teamMembers={teamMembers}
186
- />
187
- ) : null}
188
- </FormDefinitionsProvider>
189
- </KeystoneSSRProvider>
190
- </ThemeProvider>
223
+ {posthogApiKey ? (
224
+ <PostHogProvider
225
+ apiKey={posthogApiKey}
226
+ apiHost={posthogHost}
227
+ accountId={accountId}
228
+ accountName={accountName}
229
+ >
230
+ <KeystoneAnalyticsTracker bookingUrl={bookingHref} />
231
+ {bodyContent}
232
+ </PostHogProvider>
233
+ ) : (
234
+ bodyContent
235
+ )}
191
236
  </body>
192
237
  </html>
193
238
  );
@@ -0,0 +1,41 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+ import { usePathname } from 'next/navigation';
5
+ import { captureEvent } from './captureEvent';
6
+
7
+ type Props = {
8
+ /** External booking / portal URL. When set, fires booking_cta_clicked on any click to that URL. */
9
+ bookingUrl?: string | null;
10
+ };
11
+
12
+ /**
13
+ * Page-level PostHog event tracker. Mount once inside PostHogProvider in
14
+ * KeystoneRootLayout alongside MetaPixelTracker.
15
+ *
16
+ * Responsibilities:
17
+ * - booking_cta_clicked: fires when any link to the booking URL is clicked,
18
+ * capturing the page the visitor was on when they clicked.
19
+ */
20
+ export function KeystoneAnalyticsTracker({ bookingUrl }: Props) {
21
+ const pathname = usePathname();
22
+
23
+ useEffect(() => {
24
+ if (!bookingUrl) return;
25
+
26
+ const handleClick = (e: MouseEvent) => {
27
+ const anchor = (e.target as Element).closest('a');
28
+ if (anchor?.href?.startsWith(bookingUrl)) {
29
+ captureEvent('booking_cta_clicked', {
30
+ source_path: pathname,
31
+ booking_url: bookingUrl,
32
+ });
33
+ }
34
+ };
35
+
36
+ document.addEventListener('click', handleClick);
37
+ return () => document.removeEventListener('click', handleClick);
38
+ }, [bookingUrl, pathname]);
39
+
40
+ return null;
41
+ }