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
@@ -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
  };
@@ -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
- return new URL(url).pathname;
44
+ pathname = new URL(url).pathname;
39
45
  } catch {
40
- return url.startsWith('/') ? url : null;
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(
@@ -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
- {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>
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
+ }
@@ -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';