keystone-design-bootstrap 1.0.60 → 1.0.62
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/dist/design_system/sections/index.js +48 -2
- package/dist/design_system/sections/index.js.map +1 -1
- package/dist/index.js +48 -2
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/design_system/portal/LoginForm.tsx +5 -1
- package/src/design_system/portal/PortalPage.tsx +10 -4
- package/src/design_system/sections/contact-section-form.aman.tsx +2 -1
- package/src/design_system/sections/contact-section-form.balance.tsx +2 -1
- package/src/design_system/sections/contact-section-form.barelux.tsx +2 -1
- package/src/design_system/sections/contact-section-form.tsx +2 -1
- package/src/next/routes/chat.ts +7 -1
- package/src/next/routes/consumer-auth.ts +6 -4
- package/src/next/routes/form.ts +3 -0
- package/src/next/routes/proxy-headers.ts +22 -0
- package/src/tracking/MetaPixel.tsx +4 -0
- package/src/tracking/firePixelEvent.ts +80 -3
- package/src/tracking/index.ts +2 -2
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@ import React, { useState, useMemo } 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
8
|
type Step = 'identifier' | 'returning' | 'new';
|
|
9
9
|
|
|
10
10
|
interface LoginFormProps {
|
|
@@ -73,9 +73,13 @@ export function LoginForm({ onSuccess, onClose }: LoginFormProps) {
|
|
|
73
73
|
});
|
|
74
74
|
const result = await res.json().catch(() => ({ exists: null }));
|
|
75
75
|
if (result.exists === true) {
|
|
76
|
+
await setPixelUserData({ email: emailVal, phone: fullPhone });
|
|
77
|
+
firePixelEvent('Lead');
|
|
76
78
|
setWelcomeName(result.firstName ?? null);
|
|
77
79
|
setStep(result.hasPassword === false ? 'new' : 'returning');
|
|
78
80
|
} else if (result.exists === false) {
|
|
81
|
+
await setPixelUserData({ email: emailVal, phone: fullPhone });
|
|
82
|
+
firePixelEvent('Lead');
|
|
79
83
|
setStep('new');
|
|
80
84
|
} else {
|
|
81
85
|
setError('Something went wrong. Please check your connection and try again.');
|
|
@@ -154,7 +154,7 @@ function LockButton({ label = 'View price' }: { label?: string }) {
|
|
|
154
154
|
);
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
function LoginWall({ message, cta
|
|
157
|
+
function LoginWall({ message, cta }: { message: string; cta: string }) {
|
|
158
158
|
return (
|
|
159
159
|
<div className="flex flex-col items-center justify-center py-20 text-center">
|
|
160
160
|
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-secondary">
|
|
@@ -515,7 +515,7 @@ function BookPanel({
|
|
|
515
515
|
isLoggedIn: boolean;
|
|
516
516
|
}) {
|
|
517
517
|
if (!isLoggedIn) {
|
|
518
|
-
return <LoginWall message="
|
|
518
|
+
return <LoginWall message="Continue to view booking options." cta="View Booking Options" />;
|
|
519
519
|
}
|
|
520
520
|
|
|
521
521
|
if (bookingAllowsIframe) {
|
|
@@ -724,7 +724,13 @@ export async function PortalPage({
|
|
|
724
724
|
{tab === 'packages' && (
|
|
725
725
|
<PackagesPanel packages={packageList} isLoggedIn={isLoggedIn} portalHref={portalHref} />
|
|
726
726
|
)}
|
|
727
|
-
{tab === 'specials' &&
|
|
727
|
+
{tab === 'specials' && (
|
|
728
|
+
isLoggedIn ? (
|
|
729
|
+
<SpecialsPanel specials={specials} />
|
|
730
|
+
) : (
|
|
731
|
+
<LoginWall message="Continue to view specials." cta="View Specials" />
|
|
732
|
+
)
|
|
733
|
+
)}
|
|
728
734
|
{tab === 'messages' && (
|
|
729
735
|
isLoggedIn ? (
|
|
730
736
|
<MessagesPanel
|
|
@@ -734,7 +740,7 @@ export async function PortalPage({
|
|
|
734
740
|
businessName={businessName}
|
|
735
741
|
/>
|
|
736
742
|
) : (
|
|
737
|
-
<LoginWall message="
|
|
743
|
+
<LoginWall message="Continue to view your messages." cta="View Messages" />
|
|
738
744
|
)
|
|
739
745
|
)}
|
|
740
746
|
{tab === 'book' && (
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import React, { useRef, useState } from 'react';
|
|
4
4
|
import { Form, Button } from '../elements';
|
|
5
5
|
import { DynamicFormFields } from '../components/DynamicFormFields';
|
|
6
|
-
import { firePixelEvent } from '../../tracking/firePixelEvent';
|
|
6
|
+
import { firePixelEvent, setPixelUserData } from '../../tracking/firePixelEvent';
|
|
7
7
|
import type { FormDefinition } from '../../types/api/form';
|
|
8
8
|
import { useFormDefinitions } from '../../next/contexts/form-definitions';
|
|
9
9
|
|
|
@@ -61,6 +61,7 @@ export const ContactSectionForm = ({
|
|
|
61
61
|
setStatusMessage(result.message || successMessage);
|
|
62
62
|
formRef.current?.reset();
|
|
63
63
|
onSuccess?.();
|
|
64
|
+
await setPixelUserData({ email: data.email, phone: data.phone });
|
|
64
65
|
firePixelEvent('Lead');
|
|
65
66
|
setTimeout(() => setSubmitStatus('idle'), 5000);
|
|
66
67
|
} else {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import React, { useRef, useState } from 'react';
|
|
4
4
|
import { Form, Button } from '../elements';
|
|
5
5
|
import { DynamicFormFields } from '../components/DynamicFormFields';
|
|
6
|
-
import { firePixelEvent } from '../../tracking/firePixelEvent';
|
|
6
|
+
import { firePixelEvent, setPixelUserData } from '../../tracking/firePixelEvent';
|
|
7
7
|
import type { FormDefinition } from '../../types/api/form';
|
|
8
8
|
import { useFormDefinitions } from '../../next/contexts/form-definitions';
|
|
9
9
|
|
|
@@ -61,6 +61,7 @@ export const ContactSectionForm = ({
|
|
|
61
61
|
setStatusMessage(result.message || successMessage);
|
|
62
62
|
formRef.current?.reset();
|
|
63
63
|
onSuccess?.();
|
|
64
|
+
await setPixelUserData({ email: data.email, phone: data.phone });
|
|
64
65
|
firePixelEvent('Lead');
|
|
65
66
|
setTimeout(() => setSubmitStatus('idle'), 5000);
|
|
66
67
|
} else {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import React, { useRef, useState } from 'react';
|
|
4
4
|
import { Form, Button } from '../elements';
|
|
5
5
|
import { DynamicFormFields } from '../components/DynamicFormFields';
|
|
6
|
-
import { firePixelEvent } from '../../tracking/firePixelEvent';
|
|
6
|
+
import { firePixelEvent, setPixelUserData } from '../../tracking/firePixelEvent';
|
|
7
7
|
import type { FormDefinition } from '../../types/api/form';
|
|
8
8
|
import { useFormDefinitions } from '../../next/contexts/form-definitions';
|
|
9
9
|
|
|
@@ -61,6 +61,7 @@ export const ContactSectionForm = ({
|
|
|
61
61
|
setStatusMessage(result.message || successMessage);
|
|
62
62
|
formRef.current?.reset();
|
|
63
63
|
onSuccess?.();
|
|
64
|
+
await setPixelUserData({ email: data.email, phone: data.phone });
|
|
64
65
|
firePixelEvent('Lead');
|
|
65
66
|
setTimeout(() => setSubmitStatus('idle'), 5000);
|
|
66
67
|
} else {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import React, { useRef, useState } from 'react';
|
|
4
4
|
import { Form, Button } from '../elements';
|
|
5
5
|
import { DynamicFormFields } from '../components/DynamicFormFields';
|
|
6
|
-
import { firePixelEvent } from '../../tracking/firePixelEvent';
|
|
6
|
+
import { firePixelEvent, setPixelUserData } from '../../tracking/firePixelEvent';
|
|
7
7
|
import type { FormDefinition } from '../../types/api/form';
|
|
8
8
|
import { useFormDefinitions } from '../../next/contexts/form-definitions';
|
|
9
9
|
|
|
@@ -69,6 +69,7 @@ export const ContactSectionForm = ({
|
|
|
69
69
|
setStatusMessage(result.message || successMessage);
|
|
70
70
|
formRef.current?.reset();
|
|
71
71
|
onSuccess?.();
|
|
72
|
+
await setPixelUserData({ email: data.email, phone: data.phone });
|
|
72
73
|
firePixelEvent('Lead');
|
|
73
74
|
setTimeout(() => setSubmitStatus('idle'), 5000);
|
|
74
75
|
} else {
|
package/src/next/routes/chat.ts
CHANGED
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
// for route handlers. Instead, export a factory so the consuming app can pass in its
|
|
22
22
|
// own `NextResponse` (from its own `next/server`) while we keep the core logic shared.
|
|
23
23
|
|
|
24
|
+
import { clientContextHeaders } from './proxy-headers';
|
|
25
|
+
|
|
24
26
|
const API_URL = process.env.API_URL || 'http://localhost:3000/api/v1';
|
|
25
27
|
const API_KEY = process.env.API_KEY || '';
|
|
26
28
|
|
|
@@ -48,7 +50,11 @@ export function createChatRouteHandlers(deps?: { NextResponse?: { json: JsonResp
|
|
|
48
50
|
|
|
49
51
|
const response = await fetch(`${API_URL}/public/messages`, {
|
|
50
52
|
method: 'POST',
|
|
51
|
-
headers: {
|
|
53
|
+
headers: {
|
|
54
|
+
'Content-Type': 'application/json',
|
|
55
|
+
'X-API-Key': API_KEY,
|
|
56
|
+
...clientContextHeaders(request),
|
|
57
|
+
},
|
|
52
58
|
body: JSON.stringify({ identifier, contact_id, body: messageBody, display_name, page_url }),
|
|
53
59
|
});
|
|
54
60
|
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { CONSUMER_TOKEN_COOKIE } from '../../lib/consumer-session';
|
|
18
|
+
import { clientContextHeaders } from './proxy-headers';
|
|
18
19
|
|
|
19
20
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
20
21
|
type NextResponseLike = { json: (body: unknown, init?: ResponseInit) => any };
|
|
@@ -29,11 +30,12 @@ function getApiKey(): string {
|
|
|
29
30
|
return process.env.API_KEY || '';
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
function apiHeaders(): Record<string, string> {
|
|
33
|
+
function apiHeaders(request?: Request): Record<string, string> {
|
|
33
34
|
const key = getApiKey();
|
|
34
35
|
return {
|
|
35
36
|
'Content-Type': 'application/json',
|
|
36
37
|
...(key ? { 'X-API-Key': key } : {}),
|
|
38
|
+
...(request ? clientContextHeaders(request) : {}),
|
|
37
39
|
};
|
|
38
40
|
}
|
|
39
41
|
|
|
@@ -51,7 +53,7 @@ async function handleInitiate(request: Request, NR: NextResponseLike): Promise<R
|
|
|
51
53
|
try {
|
|
52
54
|
const res = await fetch(`${getApiUrl()}/consumer/auth/initiate`, {
|
|
53
55
|
method: 'POST',
|
|
54
|
-
headers: apiHeaders(),
|
|
56
|
+
headers: apiHeaders(request),
|
|
55
57
|
body: JSON.stringify({ email: email || undefined, phone: phone || undefined }),
|
|
56
58
|
});
|
|
57
59
|
|
|
@@ -82,7 +84,7 @@ async function handleLogin(request: Request, NR: NextResponseLike): Promise<Resp
|
|
|
82
84
|
|
|
83
85
|
const res = await fetch(`${getApiUrl()}/consumer/auth/login`, {
|
|
84
86
|
method: 'POST',
|
|
85
|
-
headers: apiHeaders(),
|
|
87
|
+
headers: apiHeaders(request),
|
|
86
88
|
body: JSON.stringify({ email: email || undefined, phone: phone || undefined, password }),
|
|
87
89
|
});
|
|
88
90
|
|
|
@@ -119,7 +121,7 @@ async function handleSignup(request: Request, NR: NextResponseLike): Promise<Res
|
|
|
119
121
|
|
|
120
122
|
const res = await fetch(`${getApiUrl()}/consumer/auth/signup`, {
|
|
121
123
|
method: 'POST',
|
|
122
|
-
headers: apiHeaders(),
|
|
124
|
+
headers: apiHeaders(request),
|
|
123
125
|
body: JSON.stringify({
|
|
124
126
|
email: email || undefined,
|
|
125
127
|
phone: phone || undefined,
|
package/src/next/routes/form.ts
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
// Export a factory so the consuming app can pass its own `NextResponse` (from its own
|
|
16
16
|
// `next/server`) to avoid type identity conflicts when used as a local file dependency.
|
|
17
17
|
|
|
18
|
+
import { clientContextHeaders } from './proxy-headers';
|
|
19
|
+
|
|
18
20
|
const API_URL = process.env.API_URL || 'http://localhost:3000/api/v1';
|
|
19
21
|
const API_KEY = process.env.API_KEY || '';
|
|
20
22
|
|
|
@@ -38,6 +40,7 @@ export function createFormRouteHandlers(deps?: { NextResponse?: { json: JsonResp
|
|
|
38
40
|
headers: {
|
|
39
41
|
'Content-Type': 'application/json',
|
|
40
42
|
'X-API-Key': API_KEY,
|
|
43
|
+
...clientContextHeaders(request),
|
|
41
44
|
},
|
|
42
45
|
body: JSON.stringify(body),
|
|
43
46
|
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extracts the real client IP and user-agent from an incoming Next.js route
|
|
3
|
+
* request and returns them as headers to forward to the upstream Rails API.
|
|
4
|
+
*
|
|
5
|
+
* Cloudflare and other load-balancers set x-real-ip (or x-forwarded-for) on
|
|
6
|
+
* inbound requests before they reach the Next.js function. Without this, the
|
|
7
|
+
* Rails API sees the Next.js server IP instead of the real browser IP, which
|
|
8
|
+
* produces inaccurate server-side CAPI signals.
|
|
9
|
+
*
|
|
10
|
+
* Convention: forwarded as X-Real-Client-IP / X-Real-Client-UA so that Rails
|
|
11
|
+
* can read them explicitly without conflicting with its own proxy middleware.
|
|
12
|
+
*/
|
|
13
|
+
export function clientContextHeaders(request: Request): Record<string, string> {
|
|
14
|
+
const ip =
|
|
15
|
+
request.headers.get('x-real-ip') ||
|
|
16
|
+
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
|
|
17
|
+
const ua = request.headers.get('user-agent');
|
|
18
|
+
const headers: Record<string, string> = {};
|
|
19
|
+
if (ip) headers['X-Real-Client-IP'] = ip;
|
|
20
|
+
if (ua) headers['X-Real-Client-UA'] = ua;
|
|
21
|
+
return headers;
|
|
22
|
+
}
|
|
@@ -14,6 +14,10 @@ const PIXEL_SCRIPT = (pixelId: string) => `
|
|
|
14
14
|
s.parentNode.insertBefore(t,s)}(window, document,'script','${FBEVENTS_URL}');
|
|
15
15
|
fbq('init', '${pixelId.replace(/'/g, "\\'")}');
|
|
16
16
|
fbq('track', 'PageView');
|
|
17
|
+
window.__ks_pixel_ids = window.__ks_pixel_ids || [];
|
|
18
|
+
if (window.__ks_pixel_ids.indexOf('${pixelId.replace(/'/g, "\\'")}') === -1) {
|
|
19
|
+
window.__ks_pixel_ids.push('${pixelId.replace(/'/g, "\\'")}');
|
|
20
|
+
}
|
|
17
21
|
`;
|
|
18
22
|
|
|
19
23
|
export type MetaPixelProps = {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
type FbqFn = (method: string,
|
|
1
|
+
type FbqFn = (method: string, ...args: unknown[]) => void;
|
|
2
2
|
|
|
3
3
|
export type PixelEvent = 'PageView' | 'ViewContent' | 'InitiateCheckout' | 'Lead';
|
|
4
4
|
|
|
@@ -7,20 +7,97 @@ export interface PixelEventParams {
|
|
|
7
7
|
contentCategory?: string;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
/** Raw (unhashed) user identifiers for advanced matching. */
|
|
11
|
+
export interface PixelUserData {
|
|
12
|
+
email?: string | null;
|
|
13
|
+
phone?: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Hashed user data stored in sessionStorage for the duration of the browsing session.
|
|
17
|
+
// Keyed by a short namespace to avoid collisions.
|
|
18
|
+
const STORAGE_KEY = 'ks_pud';
|
|
19
|
+
|
|
20
|
+
async function sha256Hex(str: string): Promise<string> {
|
|
21
|
+
const buffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
|
|
22
|
+
return Array.from(new Uint8Array(buffer))
|
|
23
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
24
|
+
.join('');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getFbq(): FbqFn | undefined {
|
|
28
|
+
if (typeof window === 'undefined') return undefined;
|
|
29
|
+
return (window as Window & { fbq?: FbqFn }).fbq;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Read the pixel IDs registered by MetaPixel via the inline init script. */
|
|
33
|
+
function getRegisteredPixelIds(): string[] {
|
|
34
|
+
return (window as Window & { __ks_pixel_ids?: string[] }).__ks_pixel_ids ?? [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Apply stored hashed user data to every configured Meta Pixel. */
|
|
38
|
+
function applyStoredUserData(fbq: FbqFn): void {
|
|
39
|
+
try {
|
|
40
|
+
const raw = sessionStorage.getItem(STORAGE_KEY);
|
|
41
|
+
if (!raw) return;
|
|
42
|
+
const hashed = JSON.parse(raw) as Record<string, string>;
|
|
43
|
+
if (Object.keys(hashed).length === 0) return;
|
|
44
|
+
getRegisteredPixelIds().forEach((id) => fbq('init', id, hashed));
|
|
45
|
+
} catch {
|
|
46
|
+
// sessionStorage unavailable or JSON malformed — safe to ignore
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Hash and store user identifiers so they are automatically included in all
|
|
52
|
+
* subsequent pixel events for this browser session. Call this as soon as
|
|
53
|
+
* identity is known (e.g. contact form submission, portal login step 1).
|
|
54
|
+
*/
|
|
55
|
+
export async function setPixelUserData(userData: PixelUserData): Promise<void> {
|
|
56
|
+
const hashed: Record<string, string> = {};
|
|
57
|
+
|
|
58
|
+
if (userData.email) {
|
|
59
|
+
hashed.em = await sha256Hex(userData.email.trim().toLowerCase());
|
|
60
|
+
}
|
|
61
|
+
if (userData.phone) {
|
|
62
|
+
const digits = userData.phone.replace(/\D/g, '');
|
|
63
|
+
if (digits) hashed.ph = await sha256Hex(digits);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (Object.keys(hashed).length === 0) return;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(hashed));
|
|
70
|
+
} catch {
|
|
71
|
+
// sessionStorage unavailable — still apply to fbq for this page load
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const fbq = getFbq();
|
|
75
|
+
if (fbq) {
|
|
76
|
+
getRegisteredPixelIds().forEach((id) => fbq('init', id, hashed));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
10
80
|
/**
|
|
11
81
|
* Single entry point for all client-side Meta Pixel event fires.
|
|
82
|
+
* Automatically applies any stored user identity before firing so that Meta
|
|
83
|
+
* can match events to known users across the entire session.
|
|
12
84
|
* Silently no-ops if fbq is not loaded (pixel not configured for this site).
|
|
13
85
|
*/
|
|
14
86
|
export function firePixelEvent(event: PixelEvent, params?: PixelEventParams): void {
|
|
15
|
-
|
|
16
|
-
const fbq = (window as Window & { fbq?: FbqFn }).fbq;
|
|
87
|
+
const fbq = getFbq();
|
|
17
88
|
if (!fbq) {
|
|
18
89
|
console.debug('[MetaPixel] skipped — fbq not loaded', { event });
|
|
19
90
|
return;
|
|
20
91
|
}
|
|
92
|
+
|
|
93
|
+
// Re-apply stored identity before every event so user data is included
|
|
94
|
+
// even on events that fire after a client-side navigation (PageView, ViewContent, etc.)
|
|
95
|
+
applyStoredUserData(fbq);
|
|
96
|
+
|
|
21
97
|
const normalized: Record<string, string> = {};
|
|
22
98
|
if (params?.contentName) normalized.content_name = params.contentName;
|
|
23
99
|
if (params?.contentCategory) normalized.content_category = params.contentCategory;
|
|
100
|
+
|
|
24
101
|
console.debug('[MetaPixel]', event, normalized);
|
|
25
102
|
fbq('track', event, Object.keys(normalized).length > 0 ? normalized : undefined);
|
|
26
103
|
}
|
package/src/tracking/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { MetaPixel } from './MetaPixel';
|
|
2
2
|
export type { MetaPixelProps } from './MetaPixel';
|
|
3
3
|
export { MetaPixelTracker } from './MetaPixelTracker';
|
|
4
|
-
export { firePixelEvent } from './firePixelEvent';
|
|
5
|
-
export type { PixelEvent, PixelEventParams } from './firePixelEvent';
|
|
4
|
+
export { firePixelEvent, setPixelUserData } from './firePixelEvent';
|
|
5
|
+
export type { PixelEvent, PixelEventParams, PixelUserData } from './firePixelEvent';
|