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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keystone-design-bootstrap",
3
- "version": "1.0.60",
3
+ "version": "1.0.62",
4
4
  "description": "Keystone Design Bootstrap - Sections, Elements, and Theme System for customer websites",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -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 = 'Sign in' }: { message: string; cta?: string }) {
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="Sign in to view booking options." />;
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' && <SpecialsPanel specials={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="Sign in to view your messages." />
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 {
@@ -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: { 'Content-Type': 'application/json', 'X-API-Key': API_KEY },
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,
@@ -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, eventName: string, params?: Record<string, string>) => void;
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
- if (typeof window === 'undefined') return;
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
  }
@@ -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';