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.
- package/README.md +74 -132
- package/dist/design_system/sections/index.js +110 -60
- package/dist/design_system/sections/index.js.map +1 -1
- package/dist/index.js +117 -61
- package/dist/index.js.map +1 -1
- package/dist/lib/server-api.d.ts +9 -1
- package/dist/lib/server-api.js +11 -0
- package/dist/lib/server-api.js.map +1 -1
- package/dist/tracking/index.d.ts +134 -5
- package/dist/tracking/index.js +123 -0
- 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 +21 -2
- 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/header-navigation.aman.tsx +6 -1
- package/src/design_system/sections/header-navigation.balance.tsx +6 -1
- package/src/design_system/sections/header-navigation.barelux.tsx +6 -1
- package/src/design_system/sections/header-navigation.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/server-api.ts +18 -0
- package/src/next/layouts/root-layout.tsx +78 -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
|
@@ -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);
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
};
|
package/src/lib/server-api.ts
CHANGED
|
@@ -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
|
-
{
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
+
}
|