keystone-design-bootstrap 1.0.68 → 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 CHANGED
@@ -1,175 +1,117 @@
1
1
  # Keystone Design Bootstrap
2
2
 
3
- A comprehensive design system for Keystone customer websites. Provides themed sections, elements, and utilities for building consistent, server-rendered Next.js sites.
3
+ The shared design system and runtime package powering all Keystone customer websites. Provides themed sections, UI elements, the member portal, server-side API helpers, form handling, Meta Pixel tracking, and the Next.js app shell.
4
4
 
5
- ## Quick Start
5
+ ---
6
6
 
7
- 1. **Set your theme** in `config/index.ts`:
8
- ```typescript
9
- export const config = {
10
- site: { theme: "barelux" } // or "aman", "classic"
11
- }
12
- ```
7
+ ## Documentation
13
8
 
14
- 2. **Import theme CSS** in your `app/globals.css`:
15
- ```css
16
- @import "keystone-design-bootstrap/styles/fonts.css";
17
- @import "keystone-design-bootstrap/styles/theme.css";
18
- @import "keystone-design-bootstrap/styles/typography.css";
19
- @import "keystone-design-bootstrap/styles/style-overrides.barelux.css"; /* Change to match your theme */
20
- ```
9
+ | Doc | Description |
10
+ |---|---|
11
+ | [`docs/architecture.md`](./docs/architecture.md) | Package structure, rendering model, theme system, publishing workflow |
12
+ | [`docs/server-api.md`](./docs/server-api.md) | All data-fetching functions, caching strategy, environment variables |
13
+ | [`docs/navigation-and-layout.md`](./docs/navigation-and-layout.md) | `KeystoneRootLayout`, `SiteConfig`, CTA URL resolution, dynamic nav, mobile sticky footer |
14
+ | [`docs/member-portal.md`](./docs/member-portal.md) | Member portal setup, tabs, auth flow, iframe booking, messaging |
15
+ | [`docs/forms.md`](./docs/forms.md) | Dynamic forms, `ContactSection`, form route, custom form pattern |
16
+ | [`docs/meta-tracking.md`](./docs/meta-tracking.md) | Meta Pixel initialization, automatic events, custom form tracking |
17
+ | [`docs/site-customization.md`](./docs/site-customization.md) | Per-site config, style overrides, component customization hierarchy |
18
+ | [`docs/theme-system.md`](./docs/theme-system.md) | Creating and registering themes, CSS tokens, component variants |
21
19
 
22
- 3. **Run** `npm run dev` and you're ready!
20
+ ---
23
21
 
24
- ## Installation
22
+ ## Quick start (new customer site)
23
+
24
+ ### 1. Environment variables
25
25
 
26
26
  ```bash
27
- npm install @keystone-pzjr/design-bootstrap
27
+ # .env.local
28
+ API_URL=http://localhost:3000/api/v1
29
+ API_KEY=your-api-key-here
28
30
  ```
29
31
 
30
- ## Using the Design System
31
-
32
- ### Elements
33
- Reusable UI components:
32
+ ### 2. Config
34
33
 
35
34
  ```typescript
36
- import { Button, Input, Carousel } from '@keystone-pzjr/design-bootstrap/elements'
35
+ // config/index.ts
36
+ import type { SiteConfig } from 'keystone-design-bootstrap/types';
37
37
 
38
- <Button color="primary" size="md">Click Me</Button>
39
- <Input label="Email" type="email" />
40
- <Carousel items={photos} />
38
+ export const config: SiteConfig = {
39
+ site: { title: "Business Name", description: "", theme: "aman" },
40
+ navigation: { header: […], footer: [[…], […], […], […]] },
41
+ };
41
42
  ```
42
43
 
43
- ### Server API
44
- Async functions for fetching data server-side:
44
+ ### 3. Root layout
45
45
 
46
46
  ```typescript
47
- import { getServices, getTestimonials, getBlogPosts } from '@keystone-pzjr/design-bootstrap/lib/server-api'
47
+ // app/layout.tsx
48
+ import { KeystoneRootLayout } from 'keystone-design-bootstrap/next/layouts/root-layout';
49
+ import { config } from '@/config';
48
50
 
49
- const services = await getServices()
50
- const testimonials = await getTestimonials()
51
- ```
52
-
53
- ### Sections
54
- Pre-built page components that accept data via props:
55
-
56
- ```typescript
57
- import { HeroHome, TestimonialsHome } from '@keystone-pzjr/design-bootstrap/sections'
58
- import { getTestimonials } from '@keystone-pzjr/design-bootstrap/lib/server-api'
59
-
60
- export default async function HomePage() {
61
- const testimonials = await getTestimonials()
62
- return (
63
- <main>
64
- <HeroHome />
65
- <TestimonialsHome testimonials={testimonials} />
66
- </main>
67
- )
51
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
52
+ return <KeystoneRootLayout config={config}>{children}</KeystoneRootLayout>;
68
53
  }
69
54
  ```
70
55
 
71
- **Available:** `HeroHome`, `ServicesHome`, `TestimonialsHome`, `BlogSection`, `ContactSection`, `HeaderNavigation`, `FooterHome`, and more.
72
-
73
- ## Package Exports
74
-
75
- ```typescript
76
- // Sections (hero, footer, header, testimonials, etc.)
77
- import { HeroHome, FooterHome, TestimonialsHome } from '@keystone-pzjr/design-bootstrap/sections'
78
-
79
- // Elements (buttons, inputs, carousels, etc.)
80
- import { Button, Input, Carousel } from '@keystone-pzjr/design-bootstrap/elements'
81
-
82
- // Hooks
83
- import { useBreakpoint } from '@keystone-pzjr/design-bootstrap/hooks'
84
-
85
- // Theme context
86
- import { ThemeProvider } from '@keystone-pzjr/design-bootstrap/contexts'
56
+ ### 4. CSS imports
87
57
 
88
- // Server API utilities
89
- import { getServices, getTestimonials } from '@keystone-pzjr/design-bootstrap/lib/server-api'
90
-
91
- // Types
92
- import type { Service, Testimonial } from '@keystone-pzjr/design-bootstrap/types'
93
-
94
- // Themes
95
- import { themes } from '@keystone-pzjr/design-bootstrap/themes'
58
+ ```css
59
+ /* app/globals.css */
60
+ @import "keystone-design-bootstrap/styles/fonts.css";
61
+ @import "keystone-design-bootstrap/styles/theme.css";
62
+ @import "keystone-design-bootstrap/styles/typography.css";
63
+ @import "keystone-design-bootstrap/styles/style-overrides.aman.css"; /* match your theme */
64
+ @import "../styles/custom-overrides.css";
96
65
  ```
97
66
 
98
- ## Meta Pixel Tracking
67
+ ---
99
68
 
100
- Meta Pixel is initialised automatically in `KeystoneRootLayout` when the account has a connected Meta integration with a pixel configured. Most events fire without any extra work. The one place you need to add tracking manually is **custom form submissions**.
69
+ ## Package exports reference
101
70
 
102
- ### What fires automatically
103
-
104
- | Event | Trigger |
71
+ | Import path | Contents |
105
72
  |---|---|
106
- | `PageView` | Every page load |
107
- | `ViewContent` | Route changes to `/services`, `/services/:slug`, `/locations`, `/locations/:slug`, `/portal`, `/service-menu`, `/faq`, `/contact` |
108
- | `InitiateCheckout` | Click on any link to the account's external booking URL |
109
- | Portal tab events | Opening Services, Packages, Specials, or Booking tabs in the member portal |
110
-
111
- ### Adding tracking to a custom form
112
-
113
- On successful submission, add two calls:
73
+ | `keystone-design-bootstrap/sections` | All section components |
74
+ | `keystone-design-bootstrap/elements` | UI element components |
75
+ | `keystone-design-bootstrap/portal` | `PortalPage` and portal sub-components |
76
+ | `keystone-design-bootstrap/next/layouts/root-layout` | `KeystoneRootLayout` |
77
+ | `keystone-design-bootstrap/next/routes/consumer-auth` | `createConsumerAuthHandlers` |
78
+ | `keystone-design-bootstrap/next/routes/form` | `createFormRouteHandlers` (re-exported as `POST`) |
79
+ | `keystone-design-bootstrap/lib/server-api` | Server-side data fetching functions |
80
+ | `keystone-design-bootstrap/lib/cta-urls` | `resolveCtaUrls`, `resolvePortalPath`, `isExternalCtaUrl` |
81
+ | `keystone-design-bootstrap/tracking` | `firePixelEvent`, `setPixelUserData` |
82
+ | `keystone-design-bootstrap/types` | TypeScript types |
83
+ | `keystone-design-bootstrap/hooks` | Client-side hooks |
84
+ | `keystone-design-bootstrap/styles/*` | CSS files |
114
85
 
115
- ```tsx
116
- import { firePixelEvent, setPixelUserData } from 'keystone-design-bootstrap/tracking';
117
-
118
- // inside your success handler, after a confirmed API response:
119
- await setPixelUserData({ email: data.email, phone: data.phone });
120
- firePixelEvent('Lead');
121
- ```
86
+ ---
122
87
 
123
- Also submit with `formType: 'lead'` in the POST body — this triggers the server-side CAPI `Lead` event automatically with no extra backend work.
88
+ ## Local development
124
89
 
125
- Both calls are silent no-ops when no Meta Pixel is configured for the site.
90
+ Use `yalc` to test local builds in a customer site:
126
91
 
127
- See `components/sections/VipReferralForm.tsx` in any customer site for a complete working example.
128
-
129
- ## Architecture
130
-
131
- - **Server-first**: Data fetching happens server-side, components render as Server Components where possible
132
- - **Theme variants**: Components automatically select variants based on active theme
133
- - **Tailwind-first**: Use semantic utility classes (`bg-primary`, `text-fg-primary`, `font-display`)
134
- - **Type-safe**: Full TypeScript support throughout
135
- - **CSS variables**: Themes customize via CSS custom properties
136
- - **Self-hosted fonts**: Uses Fontsource for optimized, bundled font loading
137
-
138
- ## Theme System
139
-
140
- **Available themes:** `classic`, `aman`, `barelux`
141
-
142
- ### Themes
92
+ ```bash
93
+ # In this package
94
+ npm run build && yalc publish
143
95
 
144
- - **classic**: Default professional theme, Inter font, neutral colors
145
- - **aman**: Luxury theme with Playfair Display serifs, warm beige, bronze accents
146
- - **barelux**: Modern minimal theme with Poppins, clean lines
96
+ # In the customer site
97
+ yalc update keystone-design-bootstrap
147
98
 
148
- ### Creating a Theme
99
+ # To restore the published npm version
100
+ yalc remove keystone-design-bootstrap && npm install
101
+ ```
149
102
 
150
- See [`docs/theme-system.md`](./docs/theme-system.md) for complete instructions.
103
+ ---
151
104
 
152
- **With AI assistance:**
153
- ```
154
- I am creating a new theme. Here is a link to an example: https://www.example.com
155
- [Attach screenshots of various pages]
105
+ ## Creating a new theme
156
106
 
157
- Please follow the prompt in /docs/ai-prompt-template.md
158
- ```
107
+ See [`docs/theme-system.md`](./docs/theme-system.md) for the full guide.
159
108
 
160
- **Manual steps:**
109
+ Quick checklist:
161
110
  1. Register in `src/themes/index.ts`
162
111
  2. Install fonts via Fontsource
163
112
  3. Create `src/styles/style-overrides.{theme}.css`
164
113
  4. Create component variants (optional)
165
- 5. Add to design gallery app
166
- 6. Pass lint, typecheck, and build
167
-
168
- **Critical rules:**
169
- - Never modify base components or foundation CSS
170
- - Use semantic variables/classes only
171
- - Set BOTH `--color-*` and `--background-*` prefixes
172
- - Match base component props exactly
173
- - Ensure `npm run lint`, `npm run typecheck`, `npm run build` pass with zero errors/warnings
114
+ 5. Add to design gallery
115
+ 6. `npm run lint && npm run typecheck && npm run build` — must all pass
174
116
 
175
- See [`.cursor/rules/theme-creation.mdc`](./.cursor/rules/theme-creation.mdc) for detailed rules
117
+ **Available themes:** `classic`, `aman`, `barelux`, `balance`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keystone-design-bootstrap",
3
- "version": "1.0.68",
3
+ "version": "1.0.69",
4
4
  "description": "Keystone Design Bootstrap - Sections, Elements, and Theme System for customer websites",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -69,6 +69,7 @@
69
69
  "clsx": "^2.1.1",
70
70
  "embla-carousel-react": "^8.6.0",
71
71
  "motion": "^12.23.12",
72
+ "posthog-js": "^1.367.0",
72
73
  "react-aria": "^3.42.0",
73
74
  "react-aria-components": "^1.15.1",
74
75
  "react-markdown": "^10.1.0",
@@ -4,6 +4,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
4
4
  import { X, MessageChatSquare } from '@untitledui/icons';
5
5
  import { Avatar } from '../elements/avatar/avatar';
6
6
  import { cx } from '../../utils/cx';
7
+ import { captureEvent } from '../../tracking/captureEvent';
7
8
 
8
9
  interface Message {
9
10
  id: string;
@@ -197,27 +198,25 @@ export function ChatWidget({
197
198
 
198
199
  if (response.ok) {
199
200
  const result = await response.json();
200
-
201
- // Message sent successfully
201
+ captureEvent('chat_message_sent', { is_authenticated: Boolean(contactId) });
202
+
202
203
  if (result.data?.job_id) {
203
- // Job is processing - poll for the reply
204
204
  pollForAgentReply();
205
205
  } else if (result.data?.status === 'agent_unavailable' || result.data?.status === 'no_auto_reply') {
206
- // No agent reply expected
207
206
  setIsLoading(false);
208
207
  } else {
209
- // Reload messages to get the actual stored message
210
208
  await loadMessages();
211
209
  setIsLoading(false);
212
210
  }
213
211
  } else {
214
- // Remove temp message on error
215
212
  setMessages(prev => prev.filter(m => m.id !== tempMessage.id));
213
+ captureEvent('chat_message_failed', { error: 'send_failed' });
216
214
  console.error('Failed to send message');
217
215
  setIsLoading(false);
218
216
  }
219
217
  } catch (error) {
220
218
  setMessages(prev => prev.filter(m => m.id !== tempMessage.id));
219
+ captureEvent('chat_message_failed', { error: 'network_error' });
221
220
  console.error('Failed to send message:', error);
222
221
  setIsLoading(false);
223
222
  }
@@ -250,7 +249,7 @@ export function ChatWidget({
250
249
  {/* Chat button */}
251
250
  {!isOpen && (
252
251
  <button
253
- onClick={() => setIsOpen(true)}
252
+ onClick={() => { setIsOpen(true); captureEvent('chat_opened'); }}
254
253
  className={cx(
255
254
  "flex size-15 items-center justify-center rounded-full border-none text-white shadow-xl transition-transform duration-200 hover:scale-105 focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand",
256
255
  widgetBrandClass
@@ -1,10 +1,11 @@
1
1
  'use client';
2
2
 
3
- import React, { useState, useMemo } from 'react';
3
+ import React, { useState, useMemo, useEffect } from 'react';
4
4
  import { useRouter } from 'next/navigation';
5
5
  import { countries } from '../../utils/countries';
6
6
  import { getNationalMask, formatDigitsToMask } from '../../utils/phone-helpers';
7
7
  import { firePixelEvent, setPixelUserData } from '../../tracking/firePixelEvent';
8
+ import { captureEvent } from '../../tracking/captureEvent';
8
9
  type Step = 'identifier' | 'returning' | 'new';
9
10
 
10
11
  interface LoginFormProps {
@@ -25,6 +26,10 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
25
26
  const router = useRouter();
26
27
  const [step, setStep] = useState<Step>('identifier');
27
28
 
29
+ useEffect(() => {
30
+ captureEvent('portal_login_started');
31
+ }, []);
32
+
28
33
  // Identifier step state
29
34
  const [email, setEmail] = useState('');
30
35
  const [phoneValue, setPhoneValue] = useState(''); // formatted national number
@@ -73,19 +78,24 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
73
78
  body: JSON.stringify({ email: emailVal, phone: fullPhone }),
74
79
  });
75
80
  const result = await res.json().catch(() => ({ exists: null }));
81
+ const method = emailVal && fullPhone ? 'email_and_phone' : emailVal ? 'email' : 'phone';
76
82
  if (result.exists === true) {
77
83
  await setPixelUserData({ email: emailVal, phone: fullPhone });
78
84
  firePixelEvent('Lead');
85
+ captureEvent('portal_login_identified', { method, user_exists: true });
79
86
  setWelcomeName(result.firstName ?? null);
80
87
  setStep(result.hasPassword === false ? 'new' : 'returning');
81
88
  } else if (result.exists === false) {
82
89
  await setPixelUserData({ email: emailVal, phone: fullPhone });
83
90
  firePixelEvent('Lead');
91
+ captureEvent('portal_login_identified', { method, user_exists: false });
84
92
  setStep('new');
85
93
  } else {
94
+ captureEvent('portal_login_failed', { step: 'identifier', reason: 'unknown_error' });
86
95
  setError('Something went wrong. Please check your connection and try again.');
87
96
  }
88
97
  } catch {
98
+ captureEvent('portal_login_failed', { step: 'identifier', reason: 'network_error' });
89
99
  setError('Something went wrong. Please check your connection and try again.');
90
100
  } finally {
91
101
  setLoading(false);
@@ -109,11 +119,14 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
109
119
  setError(null);
110
120
  return;
111
121
  }
122
+ captureEvent('portal_login_failed', { step: 'signin', reason: result.code || 'invalid_credentials' });
112
123
  setError(result.error || 'Login failed. Please try again.');
113
124
  return;
114
125
  }
126
+ captureEvent('portal_login_completed', { flow: 'signin' });
115
127
  if (onSuccess) onSuccess(); else router.refresh();
116
128
  } catch {
129
+ captureEvent('portal_login_failed', { step: 'signin', reason: 'network_error' });
117
130
  setError('Something went wrong. Please check your connection and try again.');
118
131
  } finally {
119
132
  setLoading(false);
@@ -139,9 +152,15 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
139
152
  }),
140
153
  });
141
154
  const result = await res.json().catch(() => ({}));
142
- if (!res.ok) { setError(result.error || 'Signup failed. Please try again.'); return; }
155
+ if (!res.ok) {
156
+ captureEvent('portal_login_failed', { step: 'signup', reason: result.error || 'signup_failed' });
157
+ setError(result.error || 'Signup failed. Please try again.');
158
+ return;
159
+ }
160
+ captureEvent('portal_login_completed', { flow: 'signup' });
143
161
  if (onSuccess) onSuccess(); else router.refresh();
144
162
  } catch {
163
+ captureEvent('portal_login_failed', { step: 'signup', reason: 'network_error' });
145
164
  setError('Something went wrong. Please check your connection and try again.');
146
165
  } finally {
147
166
  setLoading(false);
@@ -267,7 +267,7 @@ function ServicesPanel({
267
267
 
268
268
  return (
269
269
  <>
270
- <PortalTabTracker event="ViewContent" params={{ contentName: 'Services', contentCategory: 'Services' }} />
270
+ <PortalTabTracker event="ViewContent" params={{ contentName: 'Services', contentCategory: 'Services' }} tab="services" />
271
271
  <div className="divide-y divide-tertiary rounded-component border border-secondary bg-primary overflow-hidden">
272
272
  {activeServices.map((service) => (
273
273
  <details key={service.id} className="group">
@@ -321,7 +321,7 @@ function PackagesPanel({
321
321
 
322
322
  return (
323
323
  <>
324
- <PortalTabTracker event="ViewContent" params={{ contentName: 'Packages', contentCategory: 'Packages' }} />
324
+ <PortalTabTracker event="ViewContent" params={{ contentName: 'Packages', contentCategory: 'Packages' }} tab="packages" />
325
325
  <div className="grid gap-4 sm:grid-cols-2">
326
326
  {packages.map((pkg) => {
327
327
  const activeOffers = (pkg.offers ?? []).filter((o) => o.active !== false && !o.expired);
@@ -406,7 +406,7 @@ function SpecialsPanel({ specials }: { specials: SpecialItem[] }) {
406
406
 
407
407
  return (
408
408
  <>
409
- <PortalTabTracker event="ViewContent" params={{ contentName: 'Specials', contentCategory: 'Specials' }} />
409
+ <PortalTabTracker event="ViewContent" params={{ contentName: 'Specials', contentCategory: 'Specials' }} tab="specials" />
410
410
  <div className="space-y-3">
411
411
  {specials.map((special) => (
412
412
  <div key={special.id} className="group flex items-start gap-3 rounded-component border border-secondary bg-primary px-4 py-4">
@@ -524,7 +524,7 @@ function BookPanel({
524
524
  if (bookingAllowsIframe) {
525
525
  return (
526
526
  <>
527
- <PortalTabTracker event="InitiateCheckout" />
527
+ <PortalTabTracker event="InitiateCheckout" tab="booking" />
528
528
  <BookIframePanel bookingHref={bookingHref} businessName={businessName} />
529
529
  </>
530
530
  );
@@ -532,7 +532,7 @@ function BookPanel({
532
532
 
533
533
  return (
534
534
  <>
535
- <PortalTabTracker event="InitiateCheckout" />
535
+ <PortalTabTracker event="InitiateCheckout" tab="booking" />
536
536
  <div className="flex flex-col items-center justify-center py-20 text-center">
537
537
  <div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-secondary">
538
538
  <svg className="size-5 text-quaternary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
@@ -2,21 +2,29 @@
2
2
 
3
3
  import { useEffect } from 'react';
4
4
  import { firePixelEvent } from '../../tracking/firePixelEvent';
5
+ import { captureEvent } from '../../tracking/captureEvent';
5
6
  import type { PixelEvent, PixelEventParams } from '../../tracking/firePixelEvent';
6
7
 
7
8
  interface Props {
8
9
  event: PixelEvent;
9
10
  params?: PixelEventParams;
11
+ /** Human-readable tab name for PostHog (e.g. 'booking', 'appointments', 'membership'). */
12
+ tab: string;
10
13
  }
11
14
 
12
15
  /**
13
- * Fires a pixel event once when a portal tab mounts.
16
+ * Fires tracking events once when a portal tab mounts.
14
17
  * Placed at the root of each tab panel so it fires on both direct navigation
15
18
  * and post-login redirect to that tab.
19
+ *
20
+ * Fires:
21
+ * - Meta Pixel: the provided `event` (e.g. ViewContent, InitiateCheckout)
22
+ * - PostHog: portal_tab_viewed with the tab name
16
23
  */
17
- export function PortalTabTracker({ event, params }: Props) {
24
+ export function PortalTabTracker({ event, params, tab }: Props) {
18
25
  useEffect(() => {
19
26
  firePixelEvent(event, params);
27
+ captureEvent('portal_tab_viewed', { tab });
20
28
  // eslint-disable-next-line react-hooks/exhaustive-deps
21
29
  }, []);
22
30
 
@@ -4,6 +4,7 @@ import React, { useRef, useState } from 'react';
4
4
  import { Form, Button } from '../elements';
5
5
  import { DynamicFormFields } from '../components/DynamicFormFields';
6
6
  import { firePixelEvent, setPixelUserData } from '../../tracking/firePixelEvent';
7
+ import { captureEvent } from '../../tracking/captureEvent';
7
8
  import type { FormDefinition } from '../../types/api/form';
8
9
  import { useFormDefinitions } from '../../next/contexts/form-definitions';
9
10
 
@@ -63,14 +64,18 @@ export const ContactSectionForm = ({
63
64
  onSuccess?.();
64
65
  await setPixelUserData({ email: data.email, phone: data.phone });
65
66
  firePixelEvent('Lead', undefined, result.eventId);
67
+ captureEvent('form_submitted', { form_type: 'lead', ...(result.eventId && { event_id: result.eventId }) });
66
68
  setTimeout(() => setSubmitStatus('idle'), 5000);
67
69
  } else {
70
+ const errorMsg = result.error || 'Something went wrong. Please try again.';
68
71
  setSubmitStatus('error');
69
- setStatusMessage(result.error || 'Something went wrong. Please try again.');
72
+ setStatusMessage(errorMsg);
73
+ captureEvent('form_failed', { form_type: 'lead', error: errorMsg });
70
74
  }
71
75
  } catch {
72
76
  setSubmitStatus('error');
73
77
  setStatusMessage('Network error. Please try again later.');
78
+ captureEvent('form_failed', { form_type: 'lead', error: 'network_error' });
74
79
  }
75
80
  setIsSubmitting(false);
76
81
  };
@@ -4,6 +4,7 @@ import React, { useRef, useState } from 'react';
4
4
  import { Form, Button } from '../elements';
5
5
  import { DynamicFormFields } from '../components/DynamicFormFields';
6
6
  import { firePixelEvent, setPixelUserData } from '../../tracking/firePixelEvent';
7
+ import { captureEvent } from '../../tracking/captureEvent';
7
8
  import type { FormDefinition } from '../../types/api/form';
8
9
  import { useFormDefinitions } from '../../next/contexts/form-definitions';
9
10
 
@@ -63,14 +64,18 @@ export const ContactSectionForm = ({
63
64
  onSuccess?.();
64
65
  await setPixelUserData({ email: data.email, phone: data.phone });
65
66
  firePixelEvent('Lead', undefined, result.eventId);
67
+ captureEvent('form_submitted', { form_type: 'lead', ...(result.eventId && { event_id: result.eventId }) });
66
68
  setTimeout(() => setSubmitStatus('idle'), 5000);
67
69
  } else {
70
+ const errorMsg = result.error || 'Something went wrong. Please try again.';
68
71
  setSubmitStatus('error');
69
- setStatusMessage(result.error || 'Something went wrong. Please try again.');
72
+ setStatusMessage(errorMsg);
73
+ captureEvent('form_failed', { form_type: 'lead', error: errorMsg });
70
74
  }
71
75
  } catch {
72
76
  setSubmitStatus('error');
73
77
  setStatusMessage('Network error. Please try again later.');
78
+ captureEvent('form_failed', { form_type: 'lead', error: 'network_error' });
74
79
  }
75
80
  setIsSubmitting(false);
76
81
  };
@@ -4,6 +4,7 @@ import React, { useRef, useState } from 'react';
4
4
  import { Form, Button } from '../elements';
5
5
  import { DynamicFormFields } from '../components/DynamicFormFields';
6
6
  import { firePixelEvent, setPixelUserData } from '../../tracking/firePixelEvent';
7
+ import { captureEvent } from '../../tracking/captureEvent';
7
8
  import type { FormDefinition } from '../../types/api/form';
8
9
  import { useFormDefinitions } from '../../next/contexts/form-definitions';
9
10
 
@@ -63,14 +64,18 @@ export const ContactSectionForm = ({
63
64
  onSuccess?.();
64
65
  await setPixelUserData({ email: data.email, phone: data.phone });
65
66
  firePixelEvent('Lead', undefined, result.eventId);
67
+ captureEvent('form_submitted', { form_type: 'lead', ...(result.eventId && { event_id: result.eventId }) });
66
68
  setTimeout(() => setSubmitStatus('idle'), 5000);
67
69
  } else {
70
+ const errorMsg = result.error || 'Something went wrong. Please try again.';
68
71
  setSubmitStatus('error');
69
- setStatusMessage(result.error || 'Something went wrong. Please try again.');
72
+ setStatusMessage(errorMsg);
73
+ captureEvent('form_failed', { form_type: 'lead', error: errorMsg });
70
74
  }
71
75
  } catch {
72
76
  setSubmitStatus('error');
73
77
  setStatusMessage('Network error. Please try again later.');
78
+ captureEvent('form_failed', { form_type: 'lead', error: 'network_error' });
74
79
  }
75
80
  setIsSubmitting(false);
76
81
  };
@@ -4,6 +4,7 @@ import React, { useRef, useState } from 'react';
4
4
  import { Form, Button } from '../elements';
5
5
  import { DynamicFormFields } from '../components/DynamicFormFields';
6
6
  import { firePixelEvent, setPixelUserData } from '../../tracking/firePixelEvent';
7
+ import { captureEvent } from '../../tracking/captureEvent';
7
8
  import type { FormDefinition } from '../../types/api/form';
8
9
  import { useFormDefinitions } from '../../next/contexts/form-definitions';
9
10
 
@@ -71,14 +72,18 @@ export const ContactSectionForm = ({
71
72
  onSuccess?.();
72
73
  await setPixelUserData({ email: data.email, phone: data.phone });
73
74
  firePixelEvent('Lead', undefined, result.eventId);
75
+ captureEvent('form_submitted', { form_type: 'lead', ...(result.eventId && { event_id: result.eventId }) });
74
76
  setTimeout(() => setSubmitStatus('idle'), 5000);
75
77
  } else {
78
+ const errorMsg = result.error || 'Something went wrong. Please try again.';
76
79
  setSubmitStatus('error');
77
- setStatusMessage(result.error || 'Something went wrong. Please try again.');
80
+ setStatusMessage(errorMsg);
81
+ captureEvent('form_failed', { form_type: 'lead', error: errorMsg });
78
82
  }
79
83
  } catch {
80
84
  setSubmitStatus('error');
81
85
  setStatusMessage('Network error. Please try again later.');
86
+ captureEvent('form_failed', { form_type: 'lead', error: 'network_error' });
82
87
  }
83
88
  setIsSubmitting(false);
84
89
  };
@@ -5,6 +5,7 @@ import { Form, Button } from '../elements';
5
5
  import { DynamicFormFields } from '../components/DynamicFormFields';
6
6
  import type { FormDefinition } from '../../types/api/form';
7
7
  import { useFormDefinitions } from '../../next/contexts/form-definitions';
8
+ import { captureEvent } from '../../tracking/captureEvent';
8
9
 
9
10
  export interface EmailSignupSectionProps {
10
11
  title?: string;
@@ -54,14 +55,18 @@ export const EmailSignupSection = ({
54
55
  setSubmitStatus('success');
55
56
  setStatusMessage(result.message || successMessage);
56
57
  formRef.current?.reset();
58
+ captureEvent('form_submitted', { form_type: 'marketing_list_signup' });
57
59
  setTimeout(() => setSubmitStatus('idle'), 6000);
58
60
  } else {
61
+ const errorMsg = result.error || 'Something went wrong. Please try again.';
59
62
  setSubmitStatus('error');
60
- setStatusMessage(result.error || 'Something went wrong. Please try again.');
63
+ setStatusMessage(errorMsg);
64
+ captureEvent('form_failed', { form_type: 'marketing_list_signup', error: errorMsg });
61
65
  }
62
66
  } catch {
63
67
  setSubmitStatus('error');
64
68
  setStatusMessage('Network error. Please try again later.');
69
+ captureEvent('form_failed', { form_type: 'marketing_list_signup', error: 'network_error' });
65
70
  }
66
71
 
67
72
  setIsSubmitting(false);
@@ -5,6 +5,7 @@ import { Form, Button } from '../elements';
5
5
  import { DynamicFormFields } from '../components/DynamicFormFields';
6
6
  import type { FormDefinition } from '../../types/api/form';
7
7
  import { useFormDefinitions } from '../../next/contexts/form-definitions';
8
+ import { captureEvent } from '../../tracking/captureEvent';
8
9
 
9
10
  interface JobApplicationFormAmanProps {
10
11
  jobSlug: string;
@@ -47,14 +48,18 @@ export const JobApplicationForm = ({ jobSlug, formDefinition, inline = false }:
47
48
  setSubmitStatus('success');
48
49
  setStatusMessage(result.message || "Thank you for applying! We'll be in touch soon.");
49
50
  formRef.current?.reset();
51
+ captureEvent('form_submitted', { form_type: 'job_application' });
50
52
  setTimeout(() => setSubmitStatus('idle'), 5000);
51
53
  } else {
54
+ const errorMsg = result.error || 'Something went wrong. Please try again.';
52
55
  setSubmitStatus('error');
53
- setStatusMessage(result.error || 'Something went wrong. Please try again.');
56
+ setStatusMessage(errorMsg);
57
+ captureEvent('form_failed', { form_type: 'job_application', error: errorMsg });
54
58
  }
55
59
  } catch {
56
60
  setSubmitStatus('error');
57
61
  setStatusMessage('Network error. Please try again.');
62
+ captureEvent('form_failed', { form_type: 'job_application', error: 'network_error' });
58
63
  }
59
64
  setIsSubmitting(false);
60
65
  };
@@ -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';
@@ -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';