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 +74 -132
- package/package.json +2 -1
- package/src/design_system/components/ChatWidget.tsx +6 -7
- package/src/design_system/portal/LoginForm.tsx +21 -2
- package/src/design_system/portal/PortalPage.tsx +5 -5
- package/src/design_system/portal/PortalTabTracker.tsx +10 -2
- package/src/design_system/sections/contact-section-form.aman.tsx +6 -1
- package/src/design_system/sections/contact-section-form.balance.tsx +6 -1
- package/src/design_system/sections/contact-section-form.barelux.tsx +6 -1
- package/src/design_system/sections/contact-section-form.tsx +6 -1
- package/src/design_system/sections/email-signup-section.tsx +6 -1
- package/src/design_system/sections/job-application-form.aman.tsx +6 -1
- package/src/design_system/sections/job-application-form.barelux.tsx +6 -1
- package/src/design_system/sections/job-application-form.tsx +6 -1
- package/src/lib/server-api.ts +18 -0
- package/src/next/layouts/root-layout.tsx +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
package/README.md
CHANGED
|
@@ -1,175 +1,117 @@
|
|
|
1
1
|
# Keystone Design Bootstrap
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
5
|
+
---
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
```typescript
|
|
9
|
-
export const config = {
|
|
10
|
-
site: { theme: "barelux" } // or "aman", "classic"
|
|
11
|
-
}
|
|
12
|
-
```
|
|
7
|
+
## Documentation
|
|
13
8
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
20
|
+
---
|
|
23
21
|
|
|
24
|
-
##
|
|
22
|
+
## Quick start (new customer site)
|
|
23
|
+
|
|
24
|
+
### 1. Environment variables
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
|
-
|
|
27
|
+
# .env.local
|
|
28
|
+
API_URL=http://localhost:3000/api/v1
|
|
29
|
+
API_KEY=your-api-key-here
|
|
28
30
|
```
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
### Elements
|
|
33
|
-
Reusable UI components:
|
|
32
|
+
### 2. Config
|
|
34
33
|
|
|
35
34
|
```typescript
|
|
36
|
-
|
|
35
|
+
// config/index.ts
|
|
36
|
+
import type { SiteConfig } from 'keystone-design-bootstrap/types';
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
export const config: SiteConfig = {
|
|
39
|
+
site: { title: "Business Name", description: "…", theme: "aman" },
|
|
40
|
+
navigation: { header: […], footer: [[…], […], […], […]] },
|
|
41
|
+
};
|
|
41
42
|
```
|
|
42
43
|
|
|
43
|
-
###
|
|
44
|
-
Async functions for fetching data server-side:
|
|
44
|
+
### 3. Root layout
|
|
45
45
|
|
|
46
46
|
```typescript
|
|
47
|
-
|
|
47
|
+
// app/layout.tsx
|
|
48
|
+
import { KeystoneRootLayout } from 'keystone-design-bootstrap/next/layouts/root-layout';
|
|
49
|
+
import { config } from '@/config';
|
|
48
50
|
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
import
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
67
|
+
---
|
|
99
68
|
|
|
100
|
-
|
|
69
|
+
## Package exports reference
|
|
101
70
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
| Event | Trigger |
|
|
71
|
+
| Import path | Contents |
|
|
105
72
|
|---|---|
|
|
106
|
-
| `
|
|
107
|
-
| `
|
|
108
|
-
| `
|
|
109
|
-
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
+
## Local development
|
|
124
89
|
|
|
125
|
-
|
|
90
|
+
Use `yalc` to test local builds in a customer site:
|
|
126
91
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
- **barelux**: Modern minimal theme with Poppins, clean lines
|
|
96
|
+
# In the customer site
|
|
97
|
+
yalc update keystone-design-bootstrap
|
|
147
98
|
|
|
148
|
-
|
|
99
|
+
# To restore the published npm version
|
|
100
|
+
yalc remove keystone-design-bootstrap && npm install
|
|
101
|
+
```
|
|
149
102
|
|
|
150
|
-
|
|
103
|
+
---
|
|
151
104
|
|
|
152
|
-
|
|
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
|
-
|
|
158
|
-
```
|
|
107
|
+
See [`docs/theme-system.md`](./docs/theme-system.md) for the full guide.
|
|
159
108
|
|
|
160
|
-
|
|
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
|
|
166
|
-
6.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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) {
|
|
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
|
|
16
|
+
* Fires tracking events once when a portal tab mounts.
|
|
14
17
|
* Placed at the root of each tab panel so it fires on both direct navigation
|
|
15
18
|
* and post-login redirect to that tab.
|
|
19
|
+
*
|
|
20
|
+
* Fires:
|
|
21
|
+
* - Meta Pixel: the provided `event` (e.g. ViewContent, InitiateCheckout)
|
|
22
|
+
* - PostHog: portal_tab_viewed with the tab name
|
|
16
23
|
*/
|
|
17
|
-
export function PortalTabTracker({ event, params }: Props) {
|
|
24
|
+
export function PortalTabTracker({ event, params, tab }: Props) {
|
|
18
25
|
useEffect(() => {
|
|
19
26
|
firePixelEvent(event, params);
|
|
27
|
+
captureEvent('portal_tab_viewed', { tab });
|
|
20
28
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
21
29
|
}, []);
|
|
22
30
|
|
|
@@ -4,6 +4,7 @@ import React, { useRef, useState } from 'react';
|
|
|
4
4
|
import { Form, Button } from '../elements';
|
|
5
5
|
import { DynamicFormFields } from '../components/DynamicFormFields';
|
|
6
6
|
import { firePixelEvent, setPixelUserData } from '../../tracking/firePixelEvent';
|
|
7
|
+
import { captureEvent } from '../../tracking/captureEvent';
|
|
7
8
|
import type { FormDefinition } from '../../types/api/form';
|
|
8
9
|
import { useFormDefinitions } from '../../next/contexts/form-definitions';
|
|
9
10
|
|
|
@@ -63,14 +64,18 @@ export const ContactSectionForm = ({
|
|
|
63
64
|
onSuccess?.();
|
|
64
65
|
await setPixelUserData({ email: data.email, phone: data.phone });
|
|
65
66
|
firePixelEvent('Lead', undefined, result.eventId);
|
|
67
|
+
captureEvent('form_submitted', { form_type: 'lead', ...(result.eventId && { event_id: result.eventId }) });
|
|
66
68
|
setTimeout(() => setSubmitStatus('idle'), 5000);
|
|
67
69
|
} else {
|
|
70
|
+
const errorMsg = result.error || 'Something went wrong. Please try again.';
|
|
68
71
|
setSubmitStatus('error');
|
|
69
|
-
setStatusMessage(
|
|
72
|
+
setStatusMessage(errorMsg);
|
|
73
|
+
captureEvent('form_failed', { form_type: 'lead', error: errorMsg });
|
|
70
74
|
}
|
|
71
75
|
} catch {
|
|
72
76
|
setSubmitStatus('error');
|
|
73
77
|
setStatusMessage('Network error. Please try again later.');
|
|
78
|
+
captureEvent('form_failed', { form_type: 'lead', error: 'network_error' });
|
|
74
79
|
}
|
|
75
80
|
setIsSubmitting(false);
|
|
76
81
|
};
|
|
@@ -4,6 +4,7 @@ import React, { useRef, useState } from 'react';
|
|
|
4
4
|
import { Form, Button } from '../elements';
|
|
5
5
|
import { DynamicFormFields } from '../components/DynamicFormFields';
|
|
6
6
|
import { firePixelEvent, setPixelUserData } from '../../tracking/firePixelEvent';
|
|
7
|
+
import { captureEvent } from '../../tracking/captureEvent';
|
|
7
8
|
import type { FormDefinition } from '../../types/api/form';
|
|
8
9
|
import { useFormDefinitions } from '../../next/contexts/form-definitions';
|
|
9
10
|
|
|
@@ -63,14 +64,18 @@ export const ContactSectionForm = ({
|
|
|
63
64
|
onSuccess?.();
|
|
64
65
|
await setPixelUserData({ email: data.email, phone: data.phone });
|
|
65
66
|
firePixelEvent('Lead', undefined, result.eventId);
|
|
67
|
+
captureEvent('form_submitted', { form_type: 'lead', ...(result.eventId && { event_id: result.eventId }) });
|
|
66
68
|
setTimeout(() => setSubmitStatus('idle'), 5000);
|
|
67
69
|
} else {
|
|
70
|
+
const errorMsg = result.error || 'Something went wrong. Please try again.';
|
|
68
71
|
setSubmitStatus('error');
|
|
69
|
-
setStatusMessage(
|
|
72
|
+
setStatusMessage(errorMsg);
|
|
73
|
+
captureEvent('form_failed', { form_type: 'lead', error: errorMsg });
|
|
70
74
|
}
|
|
71
75
|
} catch {
|
|
72
76
|
setSubmitStatus('error');
|
|
73
77
|
setStatusMessage('Network error. Please try again later.');
|
|
78
|
+
captureEvent('form_failed', { form_type: 'lead', error: 'network_error' });
|
|
74
79
|
}
|
|
75
80
|
setIsSubmitting(false);
|
|
76
81
|
};
|
|
@@ -4,6 +4,7 @@ import React, { useRef, useState } from 'react';
|
|
|
4
4
|
import { Form, Button } from '../elements';
|
|
5
5
|
import { DynamicFormFields } from '../components/DynamicFormFields';
|
|
6
6
|
import { firePixelEvent, setPixelUserData } from '../../tracking/firePixelEvent';
|
|
7
|
+
import { captureEvent } from '../../tracking/captureEvent';
|
|
7
8
|
import type { FormDefinition } from '../../types/api/form';
|
|
8
9
|
import { useFormDefinitions } from '../../next/contexts/form-definitions';
|
|
9
10
|
|
|
@@ -63,14 +64,18 @@ export const ContactSectionForm = ({
|
|
|
63
64
|
onSuccess?.();
|
|
64
65
|
await setPixelUserData({ email: data.email, phone: data.phone });
|
|
65
66
|
firePixelEvent('Lead', undefined, result.eventId);
|
|
67
|
+
captureEvent('form_submitted', { form_type: 'lead', ...(result.eventId && { event_id: result.eventId }) });
|
|
66
68
|
setTimeout(() => setSubmitStatus('idle'), 5000);
|
|
67
69
|
} else {
|
|
70
|
+
const errorMsg = result.error || 'Something went wrong. Please try again.';
|
|
68
71
|
setSubmitStatus('error');
|
|
69
|
-
setStatusMessage(
|
|
72
|
+
setStatusMessage(errorMsg);
|
|
73
|
+
captureEvent('form_failed', { form_type: 'lead', error: errorMsg });
|
|
70
74
|
}
|
|
71
75
|
} catch {
|
|
72
76
|
setSubmitStatus('error');
|
|
73
77
|
setStatusMessage('Network error. Please try again later.');
|
|
78
|
+
captureEvent('form_failed', { form_type: 'lead', error: 'network_error' });
|
|
74
79
|
}
|
|
75
80
|
setIsSubmitting(false);
|
|
76
81
|
};
|
|
@@ -4,6 +4,7 @@ import React, { useRef, useState } from 'react';
|
|
|
4
4
|
import { Form, Button } from '../elements';
|
|
5
5
|
import { DynamicFormFields } from '../components/DynamicFormFields';
|
|
6
6
|
import { firePixelEvent, setPixelUserData } from '../../tracking/firePixelEvent';
|
|
7
|
+
import { captureEvent } from '../../tracking/captureEvent';
|
|
7
8
|
import type { FormDefinition } from '../../types/api/form';
|
|
8
9
|
import { useFormDefinitions } from '../../next/contexts/form-definitions';
|
|
9
10
|
|
|
@@ -71,14 +72,18 @@ export const ContactSectionForm = ({
|
|
|
71
72
|
onSuccess?.();
|
|
72
73
|
await setPixelUserData({ email: data.email, phone: data.phone });
|
|
73
74
|
firePixelEvent('Lead', undefined, result.eventId);
|
|
75
|
+
captureEvent('form_submitted', { form_type: 'lead', ...(result.eventId && { event_id: result.eventId }) });
|
|
74
76
|
setTimeout(() => setSubmitStatus('idle'), 5000);
|
|
75
77
|
} else {
|
|
78
|
+
const errorMsg = result.error || 'Something went wrong. Please try again.';
|
|
76
79
|
setSubmitStatus('error');
|
|
77
|
-
setStatusMessage(
|
|
80
|
+
setStatusMessage(errorMsg);
|
|
81
|
+
captureEvent('form_failed', { form_type: 'lead', error: errorMsg });
|
|
78
82
|
}
|
|
79
83
|
} catch {
|
|
80
84
|
setSubmitStatus('error');
|
|
81
85
|
setStatusMessage('Network error. Please try again later.');
|
|
86
|
+
captureEvent('form_failed', { form_type: 'lead', error: 'network_error' });
|
|
82
87
|
}
|
|
83
88
|
setIsSubmitting(false);
|
|
84
89
|
};
|
|
@@ -5,6 +5,7 @@ import { Form, Button } from '../elements';
|
|
|
5
5
|
import { DynamicFormFields } from '../components/DynamicFormFields';
|
|
6
6
|
import type { FormDefinition } from '../../types/api/form';
|
|
7
7
|
import { useFormDefinitions } from '../../next/contexts/form-definitions';
|
|
8
|
+
import { captureEvent } from '../../tracking/captureEvent';
|
|
8
9
|
|
|
9
10
|
export interface EmailSignupSectionProps {
|
|
10
11
|
title?: string;
|
|
@@ -54,14 +55,18 @@ export const EmailSignupSection = ({
|
|
|
54
55
|
setSubmitStatus('success');
|
|
55
56
|
setStatusMessage(result.message || successMessage);
|
|
56
57
|
formRef.current?.reset();
|
|
58
|
+
captureEvent('form_submitted', { form_type: 'marketing_list_signup' });
|
|
57
59
|
setTimeout(() => setSubmitStatus('idle'), 6000);
|
|
58
60
|
} else {
|
|
61
|
+
const errorMsg = result.error || 'Something went wrong. Please try again.';
|
|
59
62
|
setSubmitStatus('error');
|
|
60
|
-
setStatusMessage(
|
|
63
|
+
setStatusMessage(errorMsg);
|
|
64
|
+
captureEvent('form_failed', { form_type: 'marketing_list_signup', error: errorMsg });
|
|
61
65
|
}
|
|
62
66
|
} catch {
|
|
63
67
|
setSubmitStatus('error');
|
|
64
68
|
setStatusMessage('Network error. Please try again later.');
|
|
69
|
+
captureEvent('form_failed', { form_type: 'marketing_list_signup', error: 'network_error' });
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
setIsSubmitting(false);
|
|
@@ -5,6 +5,7 @@ import { Form, Button } from '../elements';
|
|
|
5
5
|
import { DynamicFormFields } from '../components/DynamicFormFields';
|
|
6
6
|
import type { FormDefinition } from '../../types/api/form';
|
|
7
7
|
import { useFormDefinitions } from '../../next/contexts/form-definitions';
|
|
8
|
+
import { captureEvent } from '../../tracking/captureEvent';
|
|
8
9
|
|
|
9
10
|
interface JobApplicationFormAmanProps {
|
|
10
11
|
jobSlug: string;
|
|
@@ -47,14 +48,18 @@ export const JobApplicationForm = ({ jobSlug, formDefinition, inline = false }:
|
|
|
47
48
|
setSubmitStatus('success');
|
|
48
49
|
setStatusMessage(result.message || "Thank you for applying! We'll be in touch soon.");
|
|
49
50
|
formRef.current?.reset();
|
|
51
|
+
captureEvent('form_submitted', { form_type: 'job_application' });
|
|
50
52
|
setTimeout(() => setSubmitStatus('idle'), 5000);
|
|
51
53
|
} else {
|
|
54
|
+
const errorMsg = result.error || 'Something went wrong. Please try again.';
|
|
52
55
|
setSubmitStatus('error');
|
|
53
|
-
setStatusMessage(
|
|
56
|
+
setStatusMessage(errorMsg);
|
|
57
|
+
captureEvent('form_failed', { form_type: 'job_application', error: errorMsg });
|
|
54
58
|
}
|
|
55
59
|
} catch {
|
|
56
60
|
setSubmitStatus('error');
|
|
57
61
|
setStatusMessage('Network error. Please try again.');
|
|
62
|
+
captureEvent('form_failed', { form_type: 'job_application', error: 'network_error' });
|
|
58
63
|
}
|
|
59
64
|
setIsSubmitting(false);
|
|
60
65
|
};
|
|
@@ -5,6 +5,7 @@ import { Form, Button } from '../elements';
|
|
|
5
5
|
import { DynamicFormFields } from '../components/DynamicFormFields';
|
|
6
6
|
import type { FormDefinition } from '../../types/api/form';
|
|
7
7
|
import { useFormDefinitions } from '../../next/contexts/form-definitions';
|
|
8
|
+
import { captureEvent } from '../../tracking/captureEvent';
|
|
8
9
|
|
|
9
10
|
interface JobApplicationFormBareluxProps {
|
|
10
11
|
jobSlug: string;
|
|
@@ -47,14 +48,18 @@ export const JobApplicationForm = ({ jobSlug, formDefinition, inline = false }:
|
|
|
47
48
|
setSubmitStatus('success');
|
|
48
49
|
setStatusMessage(result.message || "Thank you for applying! We'll be in touch soon.");
|
|
49
50
|
formRef.current?.reset();
|
|
51
|
+
captureEvent('form_submitted', { form_type: 'job_application' });
|
|
50
52
|
setTimeout(() => setSubmitStatus('idle'), 5000);
|
|
51
53
|
} else {
|
|
54
|
+
const errorMsg = result.error || 'Something went wrong. Please try again.';
|
|
52
55
|
setSubmitStatus('error');
|
|
53
|
-
setStatusMessage(
|
|
56
|
+
setStatusMessage(errorMsg);
|
|
57
|
+
captureEvent('form_failed', { form_type: 'job_application', error: errorMsg });
|
|
54
58
|
}
|
|
55
59
|
} catch {
|
|
56
60
|
setSubmitStatus('error');
|
|
57
61
|
setStatusMessage('Network error. Please try again.');
|
|
62
|
+
captureEvent('form_failed', { form_type: 'job_application', error: 'network_error' });
|
|
58
63
|
}
|
|
59
64
|
setIsSubmitting(false);
|
|
60
65
|
};
|
|
@@ -5,6 +5,7 @@ import { Form, Button } from '../elements';
|
|
|
5
5
|
import { DynamicFormFields } from '../components/DynamicFormFields';
|
|
6
6
|
import type { FormDefinition } from '../../types/api/form';
|
|
7
7
|
import { useFormDefinitions } from '../../next/contexts/form-definitions';
|
|
8
|
+
import { captureEvent } from '../../tracking/captureEvent';
|
|
8
9
|
|
|
9
10
|
interface JobApplicationFormProps {
|
|
10
11
|
jobSlug: string;
|
|
@@ -50,14 +51,18 @@ export const JobApplicationForm = ({ jobSlug, formDefinition, inline = false }:
|
|
|
50
51
|
setSubmitStatus('success');
|
|
51
52
|
setStatusMessage(result.message || "Thank you for applying! We'll be in touch soon.");
|
|
52
53
|
formRef.current?.reset();
|
|
54
|
+
captureEvent('form_submitted', { form_type: 'job_application' });
|
|
53
55
|
setTimeout(() => setSubmitStatus('idle'), 5000);
|
|
54
56
|
} else {
|
|
57
|
+
const errorMsg = result.error || 'Something went wrong. Please try again.';
|
|
55
58
|
setSubmitStatus('error');
|
|
56
|
-
setStatusMessage(
|
|
59
|
+
setStatusMessage(errorMsg);
|
|
60
|
+
captureEvent('form_failed', { form_type: 'job_application', error: errorMsg });
|
|
57
61
|
}
|
|
58
62
|
} catch {
|
|
59
63
|
setSubmitStatus('error');
|
|
60
64
|
setStatusMessage('Network error. Please try again.');
|
|
65
|
+
captureEvent('form_failed', { form_type: 'job_application', error: 'network_error' });
|
|
61
66
|
}
|
|
62
67
|
setIsSubmitting(false);
|
|
63
68
|
};
|
package/src/lib/server-api.ts
CHANGED
|
@@ -78,6 +78,24 @@ export function getMetaPixelId(adsConfig: { meta_pixel_id?: string } | null | un
|
|
|
78
78
|
return str !== '' && str !== 'null' && /^\d+$/.test(str) ? str : null;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
export type AnalyticsConfig = {
|
|
82
|
+
/** PostHog project API key — platform-wide, from POSTHOG_API_KEY env var on the Rails server. */
|
|
83
|
+
posthog_api_key?: string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** Analytics config for customer sites. Returns platform-wide analytics keys (e.g. PostHog). */
|
|
87
|
+
export async function getAnalyticsConfig(): Promise<AnalyticsConfig | null> {
|
|
88
|
+
const data = await serverFetch<AnalyticsConfig>('/public/analytics_config', defaultOptions);
|
|
89
|
+
return data ?? null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Extract PostHog API key from analytics config for use with <PostHogProvider apiKey={...} />. */
|
|
93
|
+
export function getPostHogApiKey(analyticsConfig: AnalyticsConfig | null | undefined): string | null {
|
|
94
|
+
const key = analyticsConfig?.posthog_api_key;
|
|
95
|
+
const str = key != null && key !== '' ? String(key).trim() : '';
|
|
96
|
+
return str !== '' && str !== 'null' ? str : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
81
99
|
export async function getServices(): Promise<Service[] | null> {
|
|
82
100
|
return serverFetch<Service[]>('/public/services', defaultOptions);
|
|
83
101
|
}
|
|
@@ -3,7 +3,7 @@ import type { Metadata } from 'next';
|
|
|
3
3
|
|
|
4
4
|
import { HeaderNavigation, FooterHome } from '../../design_system/sections';
|
|
5
5
|
import { ThemeProvider } from '../../contexts';
|
|
6
|
-
import { MetaPixel, MetaPixelTracker } from '../../tracking';
|
|
6
|
+
import { MetaPixel, MetaPixelTracker, PostHogProvider, KeystoneAnalyticsTracker } from '../../tracking';
|
|
7
7
|
import { ChatWidget } from '../../design_system/components/ChatWidget';
|
|
8
8
|
import { FormDefinitionsProvider } from '../contexts/form-definitions';
|
|
9
9
|
import { KeystoneSSRProvider } from '../providers/ssr-provider';
|
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
getForm,
|
|
17
17
|
getAdsConfig,
|
|
18
18
|
getMetaPixelId,
|
|
19
|
+
getAnalyticsConfig,
|
|
20
|
+
getPostHogApiKey,
|
|
19
21
|
} from '../../lib/server-api';
|
|
20
22
|
|
|
21
23
|
import type { CompanyInformation, Location, NavItem, Service, SiteConfig } from '../../types';
|
|
@@ -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';
|