keystone-design-bootstrap 1.0.66 → 1.0.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -132
- package/dist/design_system/sections/index.js +42 -11
- package/dist/design_system/sections/index.js.map +1 -1
- package/dist/index.js +42 -11
- package/dist/index.js.map +1 -1
- package/dist/tracking/index.d.ts +6 -1
- package/dist/tracking/index.js +5 -3
- package/dist/tracking/index.js.map +1 -1
- package/package.json +2 -1
- package/src/design_system/components/ChatWidget.tsx +6 -7
- package/src/design_system/portal/LoginForm.tsx +25 -9
- package/src/design_system/portal/LoginModalController.tsx +36 -12
- package/src/design_system/portal/PortalPage.tsx +5 -5
- package/src/design_system/portal/PortalTabTracker.tsx +10 -2
- package/src/design_system/sections/contact-section-form.aman.tsx +6 -1
- package/src/design_system/sections/contact-section-form.balance.tsx +6 -1
- package/src/design_system/sections/contact-section-form.barelux.tsx +6 -1
- package/src/design_system/sections/contact-section-form.tsx +6 -1
- package/src/design_system/sections/email-signup-section.tsx +6 -1
- package/src/design_system/sections/job-application-form.aman.tsx +6 -1
- package/src/design_system/sections/job-application-form.barelux.tsx +6 -1
- package/src/design_system/sections/job-application-form.tsx +6 -1
- package/src/lib/cta-urls.ts +13 -2
- package/src/lib/server-api.ts +18 -0
- package/src/next/layouts/root-layout.tsx +66 -33
- package/src/tracking/KeystoneAnalyticsTracker.tsx +41 -0
- package/src/tracking/PostHogProvider.tsx +128 -0
- package/src/tracking/captureEvent.ts +140 -0
- package/src/tracking/index.ts +5 -0
|
@@ -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/cta-urls.ts
CHANGED
|
@@ -28,17 +28,28 @@ export interface ResolvedCtaUrls {
|
|
|
28
28
|
/**
|
|
29
29
|
* Extracts the pathname from portal_url for use in client-side route comparisons.
|
|
30
30
|
* Handles both absolute URLs (https://domain.com/portal → /portal) and relative paths (/portal).
|
|
31
|
+
*
|
|
32
|
+
* Returns null when:
|
|
33
|
+
* - portal_url is not set
|
|
34
|
+
* - the URL cannot be resolved to a non-root pathname (would otherwise match every page)
|
|
31
35
|
*/
|
|
32
36
|
export function resolvePortalPath(
|
|
33
37
|
companyInformation?: CompanyInformation | null
|
|
34
38
|
): string | null {
|
|
35
39
|
const url = companyInformation?.portal_url?.trim();
|
|
36
40
|
if (!url) return null;
|
|
41
|
+
|
|
42
|
+
let pathname: string | null = null;
|
|
37
43
|
try {
|
|
38
|
-
|
|
44
|
+
pathname = new URL(url).pathname;
|
|
39
45
|
} catch {
|
|
40
|
-
|
|
46
|
+
pathname = url.startsWith('/') ? url : null;
|
|
41
47
|
}
|
|
48
|
+
|
|
49
|
+
if (!pathname || pathname === '/') return null;
|
|
50
|
+
|
|
51
|
+
// Normalize trailing slash so startsWith comparisons are consistent.
|
|
52
|
+
return pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
|
42
53
|
}
|
|
43
54
|
|
|
44
55
|
export function resolveCtaUrls(
|
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';
|
|
@@ -35,6 +37,13 @@ export type KeystoneRootLayoutOptions = {
|
|
|
35
37
|
headerOverrides?: KeystoneRootLayoutHeaderOverrides;
|
|
36
38
|
/** Chat widget position */
|
|
37
39
|
chatPosition?: 'bottom-right' | 'bottom-left';
|
|
40
|
+
/**
|
|
41
|
+
* PostHog ingest host. Defaults to `https://us.i.posthog.com`.
|
|
42
|
+
* Override when self-hosting or using the EU cloud (`https://eu.i.posthog.com`).
|
|
43
|
+
* The API key is fetched automatically from the Rails analytics_config endpoint
|
|
44
|
+
* (set POSTHOG_API_KEY on the Rails server — no client-side env var needed).
|
|
45
|
+
*/
|
|
46
|
+
posthogHost?: string;
|
|
38
47
|
};
|
|
39
48
|
|
|
40
49
|
function buildNavigationWithDynamicData(
|
|
@@ -91,7 +100,7 @@ function buildNavigationWithDynamicData(
|
|
|
91
100
|
* Note: Next's `export const metadata` must remain in the site/app.
|
|
92
101
|
* This component focuses on:
|
|
93
102
|
* - fetching shared data
|
|
94
|
-
* - injecting Meta Pixel
|
|
103
|
+
* - injecting Meta Pixel (from ads_config) and PostHog (from analytics_config)
|
|
95
104
|
* - applying theme
|
|
96
105
|
* - rendering Header + Footer
|
|
97
106
|
* - rendering ChatWidget gated by `companyInformation.chat_enabled`
|
|
@@ -113,6 +122,7 @@ export async function KeystoneRootLayout(props: {
|
|
|
113
122
|
jobApplicationFormDefinition,
|
|
114
123
|
marketingListSignupFormDefinition,
|
|
115
124
|
adsConfig,
|
|
125
|
+
analyticsConfig,
|
|
116
126
|
] =
|
|
117
127
|
await Promise.all([
|
|
118
128
|
getCompanyInformation(),
|
|
@@ -124,9 +134,11 @@ export async function KeystoneRootLayout(props: {
|
|
|
124
134
|
getForm('job_application'),
|
|
125
135
|
getForm('marketing_list_signup'),
|
|
126
136
|
getAdsConfig(),
|
|
137
|
+
getAnalyticsConfig(),
|
|
127
138
|
]);
|
|
128
139
|
|
|
129
140
|
const metaPixelId = getMetaPixelId(adsConfig);
|
|
141
|
+
const posthogApiKey = getPostHogApiKey(analyticsConfig);
|
|
130
142
|
|
|
131
143
|
const services = Array.isArray(servicesData) ? servicesData : [];
|
|
132
144
|
const locations = Array.isArray(locationsData) ? locationsData : [];
|
|
@@ -137,12 +149,15 @@ export async function KeystoneRootLayout(props: {
|
|
|
137
149
|
const theme = config.site.theme;
|
|
138
150
|
|
|
139
151
|
const ci = companyInformation as CompanyInformation | null;
|
|
152
|
+
const accountId = ci?.id ?? undefined;
|
|
153
|
+
const accountName = ci?.company_name ?? undefined;
|
|
140
154
|
const externalManagementUrl = ci?.external_management_url?.trim() || null;
|
|
141
155
|
const portalUrl = ci?.portal_url?.trim() || null;
|
|
142
156
|
const bookingHref = portalUrl ?? externalManagementUrl ?? null;
|
|
143
157
|
const chatEnabled = Boolean(ci?.chat_enabled);
|
|
144
158
|
|
|
145
159
|
const headerOverrides = options?.headerOverrides;
|
|
160
|
+
const posthogHost = options?.posthogHost?.trim() || undefined;
|
|
146
161
|
const headerProps = {
|
|
147
162
|
logo: {
|
|
148
163
|
href: headerOverrides?.logoHref || '/',
|
|
@@ -154,40 +169,58 @@ export async function KeystoneRootLayout(props: {
|
|
|
154
169
|
},
|
|
155
170
|
};
|
|
156
171
|
|
|
172
|
+
const bodyContent = (
|
|
173
|
+
<>
|
|
174
|
+
{metaPixelId ? <MetaPixel pixelId={metaPixelId} /> : null}
|
|
175
|
+
{metaPixelId ? <MetaPixelTracker bookingUrl={externalManagementUrl} /> : null}
|
|
176
|
+
<ThemeProvider theme={theme}>
|
|
177
|
+
<KeystoneSSRProvider>
|
|
178
|
+
<FormDefinitionsProvider
|
|
179
|
+
leadFormDefinition={leadFormDefinition ?? null}
|
|
180
|
+
jobApplicationFormDefinition={jobApplicationFormDefinition ?? null}
|
|
181
|
+
marketingListSignupFormDefinition={marketingListSignupFormDefinition ?? null}
|
|
182
|
+
>
|
|
183
|
+
<HeaderNavigation
|
|
184
|
+
config={dynamicConfig}
|
|
185
|
+
companyInformation={companyInformation}
|
|
186
|
+
websitePhotos={websitePhotos}
|
|
187
|
+
props={headerProps}
|
|
188
|
+
logoText={headerOverrides?.logoText}
|
|
189
|
+
/>
|
|
190
|
+
{children}
|
|
191
|
+
<FooterHome
|
|
192
|
+
config={dynamicConfig}
|
|
193
|
+
companyInformation={companyInformation}
|
|
194
|
+
websitePhotos={websitePhotos}
|
|
195
|
+
/>
|
|
196
|
+
{chatEnabled ? (
|
|
197
|
+
<ChatWidget
|
|
198
|
+
position={options?.chatPosition || 'bottom-right'}
|
|
199
|
+
teamMembers={teamMembers}
|
|
200
|
+
/>
|
|
201
|
+
) : null}
|
|
202
|
+
</FormDefinitionsProvider>
|
|
203
|
+
</KeystoneSSRProvider>
|
|
204
|
+
</ThemeProvider>
|
|
205
|
+
</>
|
|
206
|
+
);
|
|
207
|
+
|
|
157
208
|
return (
|
|
158
209
|
<html lang="en" data-theme={theme}>
|
|
159
210
|
<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>
|
|
211
|
+
{posthogApiKey ? (
|
|
212
|
+
<PostHogProvider
|
|
213
|
+
apiKey={posthogApiKey}
|
|
214
|
+
apiHost={posthogHost}
|
|
215
|
+
accountId={accountId}
|
|
216
|
+
accountName={accountName}
|
|
217
|
+
>
|
|
218
|
+
<KeystoneAnalyticsTracker bookingUrl={bookingHref} />
|
|
219
|
+
{bodyContent}
|
|
220
|
+
</PostHogProvider>
|
|
221
|
+
) : (
|
|
222
|
+
bodyContent
|
|
223
|
+
)}
|
|
191
224
|
</body>
|
|
192
225
|
</html>
|
|
193
226
|
);
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import posthog from 'posthog-js';
|
|
4
|
+
import { PostHogProvider as PHProvider } from 'posthog-js/react';
|
|
5
|
+
import { useEffect, Suspense } from 'react';
|
|
6
|
+
import { usePathname, useSearchParams } from 'next/navigation';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_HOST = 'https://us.i.posthog.com';
|
|
9
|
+
|
|
10
|
+
export type PostHogProviderProps = {
|
|
11
|
+
apiKey: string;
|
|
12
|
+
apiHost?: string;
|
|
13
|
+
/** Keystone account ID — attached to every event as a super property. */
|
|
14
|
+
accountId?: number;
|
|
15
|
+
/** Keystone account name (company_name) — attached to every event as a super property. */
|
|
16
|
+
accountName?: string;
|
|
17
|
+
children: React.ReactNode;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Page name resolution
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
type PageInfo = { page_name: string; page_slug?: string };
|
|
25
|
+
|
|
26
|
+
function resolvePageInfo(pathname: string): PageInfo {
|
|
27
|
+
if (pathname === '/') return { page_name: 'home' };
|
|
28
|
+
|
|
29
|
+
const patterns: Array<[RegExp, (m: RegExpMatchArray) => PageInfo]> = [
|
|
30
|
+
[/^\/services\/(.+)$/, ([, slug]) => ({ page_name: 'service_detail', page_slug: slug })],
|
|
31
|
+
[/^\/locations\/(.+)$/, ([, slug]) => ({ page_name: 'location_detail', page_slug: slug })],
|
|
32
|
+
[/^\/blog\/(.+)$/, ([, slug]) => ({ page_name: 'blog_post', page_slug: slug })],
|
|
33
|
+
[/^\/jobs\/(.+)$/, ([, slug]) => ({ page_name: 'job_detail', page_slug: slug })],
|
|
34
|
+
[/^\/packages\/(.+)$/, ([, slug]) => ({ page_name: 'package_detail', page_slug: slug })],
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
for (const [pattern, resolve] of patterns) {
|
|
38
|
+
const match = pathname.match(pattern);
|
|
39
|
+
if (match) return resolve(match);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const staticNames: Record<string, string> = {
|
|
43
|
+
'/services': 'services',
|
|
44
|
+
'/locations': 'locations',
|
|
45
|
+
'/contact': 'contact',
|
|
46
|
+
'/about': 'about',
|
|
47
|
+
'/blog': 'blog',
|
|
48
|
+
'/portal': 'portal',
|
|
49
|
+
'/gallery': 'gallery',
|
|
50
|
+
'/team': 'team',
|
|
51
|
+
'/faq': 'faq',
|
|
52
|
+
'/reviews': 'reviews',
|
|
53
|
+
'/jobs': 'jobs',
|
|
54
|
+
'/packages': 'packages',
|
|
55
|
+
'/service-menu': 'service_menu',
|
|
56
|
+
'/privacy-policy': 'privacy_policy',
|
|
57
|
+
'/terms': 'terms_of_service',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return { page_name: staticNames[pathname] ?? 'unknown' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Pageview tracker — must be wrapped in <Suspense> (useSearchParams)
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
function PostHogPageviewTracker() {
|
|
68
|
+
const pathname = usePathname();
|
|
69
|
+
const searchParams = useSearchParams();
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!pathname) return;
|
|
73
|
+
|
|
74
|
+
const search = searchParams?.toString();
|
|
75
|
+
const url = window.location.origin + pathname + (search ? `?${search}` : '');
|
|
76
|
+
const { page_name, page_slug } = resolvePageInfo(pathname);
|
|
77
|
+
|
|
78
|
+
posthog.capture('$pageview', {
|
|
79
|
+
$current_url: url,
|
|
80
|
+
page_name,
|
|
81
|
+
page_path: pathname,
|
|
82
|
+
...(page_slug && { page_slug }),
|
|
83
|
+
});
|
|
84
|
+
}, [pathname, searchParams]);
|
|
85
|
+
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Provider
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Initialises PostHog, registers account-level super properties, and fires
|
|
95
|
+
* an enriched `$pageview` on every App Router navigation.
|
|
96
|
+
*
|
|
97
|
+
* Super properties attached to every event automatically:
|
|
98
|
+
* - account_id (Keystone account ID)
|
|
99
|
+
* - account_name (company_name)
|
|
100
|
+
* - site_domain (window.location.hostname)
|
|
101
|
+
*
|
|
102
|
+
* Mount once in the root layout body. One project key covers all customer
|
|
103
|
+
* sites — filter by account_name or site_domain in the PostHog dashboard.
|
|
104
|
+
*/
|
|
105
|
+
export function PostHogProvider({ apiKey, apiHost, accountId, accountName, children }: PostHogProviderProps) {
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
posthog.init(apiKey, {
|
|
108
|
+
api_host: apiHost ?? DEFAULT_HOST,
|
|
109
|
+
person_profiles: 'identified_only',
|
|
110
|
+
capture_pageview: false,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
posthog.register({
|
|
114
|
+
...(accountId !== undefined && { account_id: accountId }),
|
|
115
|
+
...(accountName && { account_name: accountName }),
|
|
116
|
+
site_domain: window.location.hostname,
|
|
117
|
+
});
|
|
118
|
+
}, [apiKey, apiHost, accountId, accountName]);
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<PHProvider client={posthog}>
|
|
122
|
+
<Suspense fallback={null}>
|
|
123
|
+
<PostHogPageviewTracker />
|
|
124
|
+
</Suspense>
|
|
125
|
+
{children}
|
|
126
|
+
</PHProvider>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostHog event capture — Keystone customer sites.
|
|
3
|
+
*
|
|
4
|
+
* ## Naming convention
|
|
5
|
+
* All events use snake_case `object_action` format.
|
|
6
|
+
* Properties use snake_case as well.
|
|
7
|
+
*
|
|
8
|
+
* ## Event taxonomy
|
|
9
|
+
* Add new events here: define the name in `KsEventName` and its required
|
|
10
|
+
* properties in `KsEventProperties`. Every callsite is then type-checked.
|
|
11
|
+
*
|
|
12
|
+
* ## Usage
|
|
13
|
+
*
|
|
14
|
+
* import { captureEvent } from 'keystone-design-bootstrap/tracking';
|
|
15
|
+
*
|
|
16
|
+
* captureEvent('form_submitted', { form_type: 'lead' });
|
|
17
|
+
* captureEvent('booking_cta_clicked', { source_path: '/services/massage', booking_url: url });
|
|
18
|
+
*
|
|
19
|
+
* All calls are safe no-ops when PostHog has not been initialised (e.g. no
|
|
20
|
+
* POSTHOG_API_KEY configured on the server).
|
|
21
|
+
*
|
|
22
|
+
* ## Super properties
|
|
23
|
+
* account_id, account_name, and site_domain are registered as super properties
|
|
24
|
+
* by PostHogProvider and are automatically attached to every event — you do not
|
|
25
|
+
* need to include them in individual captureEvent calls.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import posthog from 'posthog-js';
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Event taxonomy
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
export type KsEventName =
|
|
35
|
+
// Navigation
|
|
36
|
+
| 'page_viewed'
|
|
37
|
+
// Booking / conversion
|
|
38
|
+
| 'booking_cta_clicked'
|
|
39
|
+
// Forms
|
|
40
|
+
| 'form_submitted'
|
|
41
|
+
| 'form_failed'
|
|
42
|
+
// Chat widget
|
|
43
|
+
| 'chat_opened'
|
|
44
|
+
| 'chat_message_sent'
|
|
45
|
+
| 'chat_message_failed'
|
|
46
|
+
// Member portal — tab navigation
|
|
47
|
+
| 'portal_tab_viewed'
|
|
48
|
+
// Member portal — authentication flow
|
|
49
|
+
| 'portal_login_started'
|
|
50
|
+
| 'portal_login_identified'
|
|
51
|
+
| 'portal_login_completed'
|
|
52
|
+
| 'portal_login_failed';
|
|
53
|
+
|
|
54
|
+
export type KsEventProperties = {
|
|
55
|
+
/** Fired on every page navigation. $pageview is also fired for PostHog web analytics. */
|
|
56
|
+
page_viewed: {
|
|
57
|
+
page_name: string;
|
|
58
|
+
page_path: string;
|
|
59
|
+
page_slug?: string;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Fired when a visitor clicks any CTA that links to the external booking URL. */
|
|
63
|
+
booking_cta_clicked: {
|
|
64
|
+
source_path: string;
|
|
65
|
+
booking_url: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Fired when a Keystone form is successfully submitted. */
|
|
69
|
+
form_submitted: {
|
|
70
|
+
/** One of: lead | job_application | marketing_list_signup */
|
|
71
|
+
form_type: string;
|
|
72
|
+
/** Server-generated event ID for CAPI deduplication (when present). */
|
|
73
|
+
event_id?: string;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Fired when a form submission fails (validation error or network error). */
|
|
77
|
+
form_failed: {
|
|
78
|
+
form_type: string;
|
|
79
|
+
error: string;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Fired when the chat widget is first opened by the visitor. */
|
|
83
|
+
chat_opened: Record<string, never>;
|
|
84
|
+
|
|
85
|
+
/** Fired when a chat message is successfully sent. */
|
|
86
|
+
chat_message_sent: {
|
|
87
|
+
/** Whether the visitor is authenticated (contactId present). */
|
|
88
|
+
is_authenticated: boolean;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/** Fired when a chat message fails to send. */
|
|
92
|
+
chat_message_failed: {
|
|
93
|
+
error: string;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** Fired when a member portal tab is opened. */
|
|
97
|
+
portal_tab_viewed: {
|
|
98
|
+
tab: string;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** Fired when the portal login modal is opened / login flow starts. */
|
|
102
|
+
portal_login_started: Record<string, never>;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Fired after the identifier step resolves — we know whether the user
|
|
106
|
+
* already has an account.
|
|
107
|
+
*/
|
|
108
|
+
portal_login_identified: {
|
|
109
|
+
method: 'email' | 'phone' | 'email_and_phone';
|
|
110
|
+
user_exists: boolean;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** Fired after the user successfully signs in or creates an account. */
|
|
114
|
+
portal_login_completed: {
|
|
115
|
+
flow: 'signin' | 'signup';
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** Fired when any step of the login flow returns an error. */
|
|
119
|
+
portal_login_failed: {
|
|
120
|
+
step: 'identifier' | 'signin' | 'signup';
|
|
121
|
+
reason: string;
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Capture helper
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Captures a typed Keystone analytics event via PostHog.
|
|
131
|
+
* Safe no-op when PostHog has not been initialised.
|
|
132
|
+
*/
|
|
133
|
+
export function captureEvent<E extends KsEventName>(
|
|
134
|
+
event: E,
|
|
135
|
+
...args: KsEventProperties[E] extends Record<string, never>
|
|
136
|
+
? []
|
|
137
|
+
: [properties: KsEventProperties[E]]
|
|
138
|
+
): void {
|
|
139
|
+
posthog.capture(event, args[0] as Record<string, unknown>);
|
|
140
|
+
}
|
package/src/tracking/index.ts
CHANGED
|
@@ -23,3 +23,8 @@ export type { MetaPixelProps } from './MetaPixel';
|
|
|
23
23
|
export { MetaPixelTracker } from './MetaPixelTracker';
|
|
24
24
|
export { firePixelEvent, setPixelUserData } from './firePixelEvent';
|
|
25
25
|
export type { PixelEvent, PixelEventParams, PixelUserData } from './firePixelEvent';
|
|
26
|
+
export { PostHogProvider } from './PostHogProvider';
|
|
27
|
+
export type { PostHogProviderProps } from './PostHogProvider';
|
|
28
|
+
export { KeystoneAnalyticsTracker } from './KeystoneAnalyticsTracker';
|
|
29
|
+
export { captureEvent } from './captureEvent';
|
|
30
|
+
export type { KsEventName, KsEventProperties } from './captureEvent';
|