keystone-design-bootstrap 1.0.64 → 1.0.65
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 +31 -0
- package/package.json +1 -1
- package/src/design_system/portal/PortalPage.tsx +42 -33
- package/src/design_system/sections/contact-section-form.aman.tsx +1 -1
- package/src/design_system/sections/contact-section-form.balance.tsx +1 -1
- package/src/design_system/sections/contact-section-form.barelux.tsx +1 -1
- package/src/design_system/sections/contact-section-form.tsx +1 -1
- package/src/design_system/sections/header-navigation.aman.tsx +14 -3
- package/src/design_system/sections/header-navigation.balance.tsx +14 -3
- package/src/design_system/sections/header-navigation.barelux.tsx +14 -3
- package/src/design_system/sections/header-navigation.tsx +14 -3
- package/src/next/routes/form.ts +16 -0
- package/src/next/routes/proxy-headers.ts +26 -4
- package/src/tracking/firePixelEvent.ts +11 -3
- package/src/tracking/index.ts +20 -0
package/README.md
CHANGED
|
@@ -95,6 +95,37 @@ import type { Service, Testimonial } from '@keystone-pzjr/design-bootstrap/types
|
|
|
95
95
|
import { themes } from '@keystone-pzjr/design-bootstrap/themes'
|
|
96
96
|
```
|
|
97
97
|
|
|
98
|
+
## Meta Pixel Tracking
|
|
99
|
+
|
|
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**.
|
|
101
|
+
|
|
102
|
+
### What fires automatically
|
|
103
|
+
|
|
104
|
+
| Event | Trigger |
|
|
105
|
+
|---|---|
|
|
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:
|
|
114
|
+
|
|
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
|
+
```
|
|
122
|
+
|
|
123
|
+
Also submit with `formType: 'lead'` in the POST body — this triggers the server-side CAPI `Lead` event automatically with no extra backend work.
|
|
124
|
+
|
|
125
|
+
Both calls are silent no-ops when no Meta Pixel is configured for the site.
|
|
126
|
+
|
|
127
|
+
See `components/sections/VipReferralForm.tsx` in any customer site for a complete working example.
|
|
128
|
+
|
|
98
129
|
## Architecture
|
|
99
130
|
|
|
100
131
|
- **Server-first**: Data fetching happens server-side, components render as Server Components where possible
|
package/package.json
CHANGED
|
@@ -575,7 +575,7 @@ export async function PortalPage({
|
|
|
575
575
|
contactHref = '/contact',
|
|
576
576
|
}: PortalPageProps) {
|
|
577
577
|
const params = await searchParams ?? {};
|
|
578
|
-
const
|
|
578
|
+
const tabParam = VALID_TABS.includes(params.tab as Tab) ? (params.tab as Tab) : null;
|
|
579
579
|
const contactIdParsed = params.contact ? parseInt(params.contact, 10) : NaN;
|
|
580
580
|
const contactId = Number.isFinite(contactIdParsed) ? contactIdParsed : null;
|
|
581
581
|
|
|
@@ -595,6 +595,19 @@ export async function PortalPage({
|
|
|
595
595
|
const resolvedBookingHref =
|
|
596
596
|
bookingHref ?? companyInformation?.external_management_url ?? null;
|
|
597
597
|
|
|
598
|
+
// Build the tabs array first so the default can simply be whatever is first.
|
|
599
|
+
const tabs: Array<{ id: Tab; label: string }> = [
|
|
600
|
+
...(resolvedBookingHref ? [{ id: 'book' as Tab, label: bookingLabel }] : []),
|
|
601
|
+
{ id: 'services', label: 'Services' },
|
|
602
|
+
{ id: 'packages', label: 'Packages' },
|
|
603
|
+
{ id: 'specials', label: 'Specials' },
|
|
604
|
+
{ id: 'messages', label: 'Messages' },
|
|
605
|
+
...(!resolvedBookingHref ? [{ id: 'book' as Tab, label: bookingLabel }] : []),
|
|
606
|
+
];
|
|
607
|
+
|
|
608
|
+
// Default to the first tab so tab order and default selection are always in sync.
|
|
609
|
+
const tab: Tab = tabParam ?? tabs[0].id;
|
|
610
|
+
|
|
598
611
|
// Auto-detect iframe support by inspecting the booking URL's framing headers.
|
|
599
612
|
// Only runs when the book tab is active to avoid unnecessary requests.
|
|
600
613
|
// Skipped entirely when forceExternalBooking is set.
|
|
@@ -633,14 +646,6 @@ export async function PortalPage({
|
|
|
633
646
|
const packageList = packages ?? [];
|
|
634
647
|
const specials = aggregateSpecials(serviceList, packageList);
|
|
635
648
|
|
|
636
|
-
const tabs: Array<{ id: Tab; label: string }> = [
|
|
637
|
-
{ id: 'services', label: 'Services' },
|
|
638
|
-
{ id: 'packages', label: 'Packages' },
|
|
639
|
-
{ id: 'specials', label: 'Specials' },
|
|
640
|
-
{ id: 'messages', label: 'Messages' },
|
|
641
|
-
{ id: 'book', label: bookingLabel },
|
|
642
|
-
];
|
|
643
|
-
|
|
644
649
|
return (
|
|
645
650
|
<div className="min-h-screen bg-primary">
|
|
646
651
|
{/* Portal Header */}
|
|
@@ -685,33 +690,37 @@ export async function PortalPage({
|
|
|
685
690
|
</div>
|
|
686
691
|
</div>
|
|
687
692
|
|
|
688
|
-
{/* Tabs */}
|
|
689
|
-
<div className="mt-4
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
693
|
+
{/* Tabs — scrollable on mobile; fade on right edge hints at overflow */}
|
|
694
|
+
<div className="relative mt-4">
|
|
695
|
+
<div className="flex gap-1 overflow-x-auto pb-px scrollbar-none">
|
|
696
|
+
{tabs.map((t) => {
|
|
697
|
+
const isActive = tab === t.id;
|
|
698
|
+
const isGated = !isLoggedIn && (t.id === 'specials' || t.id === 'messages' || t.id === 'book');
|
|
699
|
+
const href = `${portalHref}?tab=${t.id}`;
|
|
700
|
+
const className = `shrink-0 rounded-interactive px-4 py-2 text-sm font-medium transition-colors whitespace-nowrap ${
|
|
701
|
+
isActive ? 'bg-brand-solid text-white' : 'text-secondary hover:bg-secondary hover:text-primary'
|
|
702
|
+
}`;
|
|
703
|
+
if (isGated) {
|
|
704
|
+
return (
|
|
705
|
+
<button
|
|
706
|
+
key={t.id}
|
|
707
|
+
data-open-login-modal
|
|
708
|
+
data-login-redirect={href}
|
|
709
|
+
className={className}
|
|
710
|
+
>
|
|
711
|
+
{t.label}
|
|
712
|
+
</button>
|
|
713
|
+
);
|
|
714
|
+
}
|
|
698
715
|
return (
|
|
699
|
-
<
|
|
700
|
-
key={t.id}
|
|
701
|
-
data-open-login-modal
|
|
702
|
-
data-login-redirect={href}
|
|
703
|
-
className={className}
|
|
704
|
-
>
|
|
716
|
+
<Link key={t.id} href={href} className={className}>
|
|
705
717
|
{t.label}
|
|
706
|
-
</
|
|
718
|
+
</Link>
|
|
707
719
|
);
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
</Link>
|
|
713
|
-
);
|
|
714
|
-
})}
|
|
720
|
+
})}
|
|
721
|
+
</div>
|
|
722
|
+
{/* Right-edge fade: visible on small screens only, hints that tabs are scrollable */}
|
|
723
|
+
<div className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-primary to-transparent md:hidden" />
|
|
715
724
|
</div>
|
|
716
725
|
</div>
|
|
717
726
|
</div>
|
|
@@ -62,7 +62,7 @@ export const ContactSectionForm = ({
|
|
|
62
62
|
formRef.current?.reset();
|
|
63
63
|
onSuccess?.();
|
|
64
64
|
await setPixelUserData({ email: data.email, phone: data.phone });
|
|
65
|
-
firePixelEvent('Lead');
|
|
65
|
+
firePixelEvent('Lead', undefined, result.eventId);
|
|
66
66
|
setTimeout(() => setSubmitStatus('idle'), 5000);
|
|
67
67
|
} else {
|
|
68
68
|
setSubmitStatus('error');
|
|
@@ -62,7 +62,7 @@ export const ContactSectionForm = ({
|
|
|
62
62
|
formRef.current?.reset();
|
|
63
63
|
onSuccess?.();
|
|
64
64
|
await setPixelUserData({ email: data.email, phone: data.phone });
|
|
65
|
-
firePixelEvent('Lead');
|
|
65
|
+
firePixelEvent('Lead', undefined, result.eventId);
|
|
66
66
|
setTimeout(() => setSubmitStatus('idle'), 5000);
|
|
67
67
|
} else {
|
|
68
68
|
setSubmitStatus('error');
|
|
@@ -62,7 +62,7 @@ export const ContactSectionForm = ({
|
|
|
62
62
|
formRef.current?.reset();
|
|
63
63
|
onSuccess?.();
|
|
64
64
|
await setPixelUserData({ email: data.email, phone: data.phone });
|
|
65
|
-
firePixelEvent('Lead');
|
|
65
|
+
firePixelEvent('Lead', undefined, result.eventId);
|
|
66
66
|
setTimeout(() => setSubmitStatus('idle'), 5000);
|
|
67
67
|
} else {
|
|
68
68
|
setSubmitStatus('error');
|
|
@@ -70,7 +70,7 @@ export const ContactSectionForm = ({
|
|
|
70
70
|
formRef.current?.reset();
|
|
71
71
|
onSuccess?.();
|
|
72
72
|
await setPixelUserData({ email: data.email, phone: data.phone });
|
|
73
|
-
firePixelEvent('Lead');
|
|
73
|
+
firePixelEvent('Lead', undefined, result.eventId);
|
|
74
74
|
setTimeout(() => setSubmitStatus('idle'), 5000);
|
|
75
75
|
} else {
|
|
76
76
|
setSubmitStatus('error');
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import React, { useState, useRef, useCallback } from 'react';
|
|
4
4
|
import Link from 'next/link';
|
|
5
5
|
import Image from 'next/image';
|
|
6
|
+
import { usePathname } from 'next/navigation';
|
|
6
7
|
import { Button } from '../elements';
|
|
7
8
|
import { cx } from '../../utils/cx';
|
|
8
9
|
import { getLogoUrl } from '../../utils/photo-helpers';
|
|
@@ -58,6 +59,16 @@ export function HeaderNavigation({
|
|
|
58
59
|
const navigation = navigationOverride || config?.navigation?.header || [];
|
|
59
60
|
const ctaUrls = resolveCtaUrls(companyInformation);
|
|
60
61
|
|
|
62
|
+
// Hide the sticky bottom bar when the user is already on the portal page —
|
|
63
|
+
// the portal has its own Book Now tab so the bar is redundant and confusing.
|
|
64
|
+
const pathname = usePathname();
|
|
65
|
+
const portalPath = (() => {
|
|
66
|
+
const url = companyInformation?.portal_url?.trim();
|
|
67
|
+
if (!url) return null;
|
|
68
|
+
try { return new URL(url).pathname; } catch { return url.startsWith('/') ? url : null; }
|
|
69
|
+
})();
|
|
70
|
+
const isPortalPage = portalPath ? pathname?.startsWith(portalPath) : false;
|
|
71
|
+
|
|
61
72
|
// Cancel any pending close timeout
|
|
62
73
|
const cancelCloseTimeout = useCallback(() => {
|
|
63
74
|
if (closeTimeoutRef.current) {
|
|
@@ -390,8 +401,8 @@ export function HeaderNavigation({
|
|
|
390
401
|
</div>
|
|
391
402
|
)}
|
|
392
403
|
|
|
393
|
-
{/* Sticky Contact Button (Mobile)
|
|
394
|
-
<div className="fixed bottom-0 left-0 right-0 z-40 md:hidden bg-fg-primary">
|
|
404
|
+
{/* Sticky Contact Button (Mobile) — hidden on portal page where it would be redundant */}
|
|
405
|
+
{!isPortalPage && <div className="fixed bottom-0 left-0 right-0 z-40 md:hidden bg-fg-primary">
|
|
395
406
|
<div className="flex gap-0">
|
|
396
407
|
{props?.cta_button?.secondary_label && ctaUrls.hasSecondary && (
|
|
397
408
|
<Button
|
|
@@ -414,7 +425,7 @@ export function HeaderNavigation({
|
|
|
414
425
|
{(props?.cta_button?.secondary_label && ctaUrls.hasSecondary) ? props.cta_button.secondary_label : (props?.cta_button?.label || "Contact")}
|
|
415
426
|
</Button>
|
|
416
427
|
</div>
|
|
417
|
-
</div>
|
|
428
|
+
</div>}
|
|
418
429
|
</>
|
|
419
430
|
);
|
|
420
431
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import React, { useState, useRef, useCallback } from 'react';
|
|
4
4
|
import Link from 'next/link';
|
|
5
5
|
import Image from 'next/image';
|
|
6
|
+
import { usePathname } from 'next/navigation';
|
|
6
7
|
import { Button } from '../elements';
|
|
7
8
|
import { getLogoUrl } from '../../utils/photo-helpers';
|
|
8
9
|
import type { HeaderComponentProps } from './header-navigation';
|
|
@@ -37,6 +38,16 @@ export function HeaderNavigation({
|
|
|
37
38
|
const navigation = navigationOverride || config?.navigation?.header || [];
|
|
38
39
|
const ctaUrls = resolveCtaUrls(companyInformation);
|
|
39
40
|
|
|
41
|
+
// Hide the sticky bottom bar when the user is already on the portal page —
|
|
42
|
+
// the portal has its own Book Now tab so the bar is redundant and confusing.
|
|
43
|
+
const pathname = usePathname();
|
|
44
|
+
const portalPath = (() => {
|
|
45
|
+
const url = companyInformation?.portal_url?.trim();
|
|
46
|
+
if (!url) return null;
|
|
47
|
+
try { return new URL(url).pathname; } catch { return url.startsWith('/') ? url : null; }
|
|
48
|
+
})();
|
|
49
|
+
const isPortalPage = portalPath ? pathname?.startsWith(portalPath) : false;
|
|
50
|
+
|
|
40
51
|
const cancelCloseTimeout = useCallback(() => {
|
|
41
52
|
if (closeTimeoutRef.current) {
|
|
42
53
|
clearTimeout(closeTimeoutRef.current);
|
|
@@ -301,8 +312,8 @@ export function HeaderNavigation({
|
|
|
301
312
|
</div>
|
|
302
313
|
)}
|
|
303
314
|
|
|
304
|
-
{/* Sticky Bottom Bar (mobile only) */}
|
|
305
|
-
<div className="fixed bottom-0 left-0 right-0 z-40 md:hidden" style={{ backgroundColor: 'rgb(148, 133, 84)' }}>
|
|
315
|
+
{/* Sticky Bottom Bar (mobile only) — hidden on portal page where it would be redundant */}
|
|
316
|
+
{!isPortalPage && <div className="fixed bottom-0 left-0 right-0 z-40 md:hidden" style={{ backgroundColor: 'rgb(148, 133, 84)' }}>
|
|
306
317
|
<div className="flex gap-0">
|
|
307
318
|
{props?.cta_button?.secondary_label && ctaUrls.hasSecondary && (
|
|
308
319
|
<Button
|
|
@@ -326,7 +337,7 @@ export function HeaderNavigation({
|
|
|
326
337
|
{(props?.cta_button?.secondary_label && ctaUrls.hasSecondary) ? props.cta_button.secondary_label : (props?.cta_button?.label || "Contact")}
|
|
327
338
|
</Button>
|
|
328
339
|
</div>
|
|
329
|
-
</div>
|
|
340
|
+
</div>}
|
|
330
341
|
</>
|
|
331
342
|
);
|
|
332
343
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import React, { useState, useRef, useCallback } from 'react';
|
|
4
4
|
import Link from 'next/link';
|
|
5
5
|
import Image from 'next/image';
|
|
6
|
+
import { usePathname } from 'next/navigation';
|
|
6
7
|
import { Button } from '../elements';
|
|
7
8
|
import { getLogoUrl } from '../../utils/photo-helpers';
|
|
8
9
|
import type { HeaderComponentProps } from './header-navigation';
|
|
@@ -43,6 +44,16 @@ export function HeaderNavigation({
|
|
|
43
44
|
const navigation = navigationOverride || config?.navigation?.header || [];
|
|
44
45
|
const ctaUrls = resolveCtaUrls(companyInformation);
|
|
45
46
|
|
|
47
|
+
// Hide the sticky bottom bar when the user is already on the portal page —
|
|
48
|
+
// the portal has its own Book Now tab so the bar is redundant and confusing.
|
|
49
|
+
const pathname = usePathname();
|
|
50
|
+
const portalPath = (() => {
|
|
51
|
+
const url = companyInformation?.portal_url?.trim();
|
|
52
|
+
if (!url) return null;
|
|
53
|
+
try { return new URL(url).pathname; } catch { return url.startsWith('/') ? url : null; }
|
|
54
|
+
})();
|
|
55
|
+
const isPortalPage = portalPath ? pathname?.startsWith(portalPath) : false;
|
|
56
|
+
|
|
46
57
|
// Cancel any pending close timeout
|
|
47
58
|
const cancelCloseTimeout = useCallback(() => {
|
|
48
59
|
if (closeTimeoutRef.current) {
|
|
@@ -343,8 +354,8 @@ export function HeaderNavigation({
|
|
|
343
354
|
</div>
|
|
344
355
|
)}
|
|
345
356
|
|
|
346
|
-
{/* Sticky Book/Contact bar (mobile only)
|
|
347
|
-
<div className="fixed bottom-0 left-0 right-0 z-40 md:hidden bg-fg-primary">
|
|
357
|
+
{/* Sticky Book/Contact bar (mobile only) — hidden on portal page where it would be redundant */}
|
|
358
|
+
{!isPortalPage && <div className="fixed bottom-0 left-0 right-0 z-40 md:hidden bg-fg-primary">
|
|
348
359
|
<div className="flex gap-0">
|
|
349
360
|
{props?.cta_button?.secondary_label && ctaUrls.hasSecondary && (
|
|
350
361
|
<Button
|
|
@@ -367,7 +378,7 @@ export function HeaderNavigation({
|
|
|
367
378
|
{(props?.cta_button?.secondary_label && ctaUrls.hasSecondary) ? props.cta_button.secondary_label : (props?.cta_button?.label || "Contact")}
|
|
368
379
|
</Button>
|
|
369
380
|
</div>
|
|
370
|
-
</div>
|
|
381
|
+
</div>}
|
|
371
382
|
</>
|
|
372
383
|
);
|
|
373
384
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import React, { useRef, useState } from 'react';
|
|
3
3
|
import Link from 'next/link';
|
|
4
|
+
import { usePathname } from 'next/navigation';
|
|
4
5
|
import Image from 'next/image';
|
|
5
6
|
import { ChevronDown } from "@untitledui/icons";
|
|
6
7
|
import { Button as AriaButton, Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Popover as AriaPopover } from "react-aria-components";
|
|
@@ -108,6 +109,16 @@ export function HeaderNavigation({
|
|
|
108
109
|
|
|
109
110
|
const dynamicNavigation = navigation;
|
|
110
111
|
|
|
112
|
+
// Hide the sticky bottom bar when the user is already on the portal page —
|
|
113
|
+
// the portal has its own Book Now tab so the bar is redundant and confusing.
|
|
114
|
+
const pathname = usePathname();
|
|
115
|
+
const portalPath = (() => {
|
|
116
|
+
const url = companyInformation?.portal_url?.trim();
|
|
117
|
+
if (!url) return null;
|
|
118
|
+
try { return new URL(url).pathname; } catch { return url.startsWith('/') ? url : null; }
|
|
119
|
+
})();
|
|
120
|
+
const isPortalPage = portalPath ? pathname?.startsWith(portalPath) : false;
|
|
121
|
+
|
|
111
122
|
const getVariantClasses = () => {
|
|
112
123
|
switch (variant) {
|
|
113
124
|
case 'minimal':
|
|
@@ -366,8 +377,8 @@ export function HeaderNavigation({
|
|
|
366
377
|
</div>
|
|
367
378
|
</header>
|
|
368
379
|
|
|
369
|
-
{/* Sticky Book/Contact bar (mobile only) */}
|
|
370
|
-
<div className="fixed bottom-0 left-0 right-0 z-40 md:hidden bg-fg-primary">
|
|
380
|
+
{/* Sticky Book/Contact bar (mobile only) — hidden on portal page where it would be redundant */}
|
|
381
|
+
{!isPortalPage && <div className="fixed bottom-0 left-0 right-0 z-40 md:hidden bg-fg-primary">
|
|
371
382
|
<div className="flex gap-0">
|
|
372
383
|
{/* Left: Contact Us -> /contact */}
|
|
373
384
|
{cta_button?.secondary_label && ctaUrls.hasSecondary && (
|
|
@@ -392,7 +403,7 @@ export function HeaderNavigation({
|
|
|
392
403
|
{(cta_button?.secondary_label && ctaUrls.hasSecondary) ? cta_button.secondary_label : (cta_button?.label || 'Contact')}
|
|
393
404
|
</Button>
|
|
394
405
|
</div>
|
|
395
|
-
</div>
|
|
406
|
+
</div>}
|
|
396
407
|
</>
|
|
397
408
|
);
|
|
398
409
|
}
|
package/src/next/routes/form.ts
CHANGED
|
@@ -8,6 +8,22 @@
|
|
|
8
8
|
* Env (server-side only):
|
|
9
9
|
* - API_URL (default: http://localhost:3000/api/v1)
|
|
10
10
|
* - API_KEY
|
|
11
|
+
*
|
|
12
|
+
* ## Meta Pixel + CAPI tracking for custom forms
|
|
13
|
+
*
|
|
14
|
+
* POST body must include `formType: 'lead'` for conversion tracking to fire:
|
|
15
|
+
* - Server-side: the API automatically fires a CAPI Lead event (no extra work needed).
|
|
16
|
+
* - Client-side: the response includes `eventId` for browser/server deduplication.
|
|
17
|
+
* After a successful response, call these two functions from 'keystone-design-bootstrap/tracking':
|
|
18
|
+
*
|
|
19
|
+
* const result = await response.json();
|
|
20
|
+
* if (result.success) {
|
|
21
|
+
* await setPixelUserData({ email, phone }); // hash + store identity for the session
|
|
22
|
+
* firePixelEvent('Lead', undefined, result.eventId); // fire fbq('track', 'Lead') with server event ID for dedup
|
|
23
|
+
* }
|
|
24
|
+
*
|
|
25
|
+
* Both tracking calls are silent no-ops when no Meta Pixel is configured for the site.
|
|
26
|
+
* Non-lead form types (e.g. 'job_application') do not fire any tracking events.
|
|
11
27
|
*/
|
|
12
28
|
|
|
13
29
|
// IMPORTANT:
|
|
@@ -1,22 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Extracts the real client IP
|
|
3
|
-
* request and returns them as headers to forward
|
|
2
|
+
* Extracts the real client IP, user-agent, and Meta browser cookies (_fbp / _fbc)
|
|
3
|
+
* from an incoming Next.js route request and returns them as headers to forward
|
|
4
|
+
* to the upstream Rails API.
|
|
4
5
|
*
|
|
5
6
|
* Cloudflare and other load-balancers set x-real-ip (or x-forwarded-for) on
|
|
6
7
|
* inbound requests before they reach the Next.js function. Without this, the
|
|
7
8
|
* Rails API sees the Next.js server IP instead of the real browser IP, which
|
|
8
9
|
* produces inaccurate server-side CAPI signals.
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
11
|
+
* _fbp (Meta Browser ID) and _fbc (Meta Click ID) are first-party cookies set by
|
|
12
|
+
* the Meta Pixel. Forwarding them allows the API to store them on the Contact
|
|
13
|
+
* record so they can be included in all subsequent CAPI events — even those fired
|
|
14
|
+
* from background jobs that have no live HTTP request.
|
|
15
|
+
*
|
|
16
|
+
* Convention:
|
|
17
|
+
* X-Real-Client-IP — real browser IP
|
|
18
|
+
* X-Real-Client-UA — real browser user-agent
|
|
19
|
+
* X-Meta-FBP — value of the _fbp cookie
|
|
20
|
+
* X-Meta-FBC — value of the _fbc cookie (or built from fbclid param)
|
|
12
21
|
*/
|
|
13
22
|
export function clientContextHeaders(request: Request): Record<string, string> {
|
|
14
23
|
const ip =
|
|
15
24
|
request.headers.get('x-real-ip') ||
|
|
16
25
|
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
|
|
17
26
|
const ua = request.headers.get('user-agent');
|
|
27
|
+
|
|
28
|
+
const cookieHeader = request.headers.get('cookie') || '';
|
|
29
|
+
const cookies = Object.fromEntries(
|
|
30
|
+
cookieHeader.split(';').map((c) => {
|
|
31
|
+
const [k, ...v] = c.trim().split('=');
|
|
32
|
+
return [k, v.join('=')];
|
|
33
|
+
})
|
|
34
|
+
);
|
|
35
|
+
const fbp = cookies['_fbp'];
|
|
36
|
+
const fbc = cookies['_fbc'];
|
|
37
|
+
|
|
18
38
|
const headers: Record<string, string> = {};
|
|
19
39
|
if (ip) headers['X-Real-Client-IP'] = ip;
|
|
20
40
|
if (ua) headers['X-Real-Client-UA'] = ua;
|
|
41
|
+
if (fbp) headers['X-Meta-FBP'] = fbp;
|
|
42
|
+
if (fbc) headers['X-Meta-FBC'] = fbc;
|
|
21
43
|
return headers;
|
|
22
44
|
}
|
|
@@ -82,8 +82,13 @@ export async function setPixelUserData(userData: PixelUserData): Promise<void> {
|
|
|
82
82
|
* Automatically applies any stored user identity before firing so that Meta
|
|
83
83
|
* can match events to known users across the entire session.
|
|
84
84
|
* Silently no-ops if fbq is not loaded (pixel not configured for this site).
|
|
85
|
+
*
|
|
86
|
+
* @param eventId - Optional server-side event ID for browser/server deduplication.
|
|
87
|
+
* Pass the `eventId` returned by the form submission API so Meta can match and
|
|
88
|
+
* deduplicate the browser Lead event against the server-side CAPI Lead event.
|
|
89
|
+
* Format: fbq('track', event, params, { eventID: eventId })
|
|
85
90
|
*/
|
|
86
|
-
export function firePixelEvent(event: PixelEvent, params?: PixelEventParams): void {
|
|
91
|
+
export function firePixelEvent(event: PixelEvent, params?: PixelEventParams, eventId?: string): void {
|
|
87
92
|
const fbq = getFbq();
|
|
88
93
|
if (!fbq) {
|
|
89
94
|
console.debug('[MetaPixel] skipped — fbq not loaded', { event });
|
|
@@ -98,6 +103,9 @@ export function firePixelEvent(event: PixelEvent, params?: PixelEventParams): vo
|
|
|
98
103
|
if (params?.contentName) normalized.content_name = params.contentName;
|
|
99
104
|
if (params?.contentCategory) normalized.content_category = params.contentCategory;
|
|
100
105
|
|
|
101
|
-
|
|
102
|
-
|
|
106
|
+
const customData = Object.keys(normalized).length > 0 ? normalized : undefined;
|
|
107
|
+
const eventData = eventId ? { eventID: eventId } : undefined;
|
|
108
|
+
|
|
109
|
+
console.debug('[MetaPixel]', event, normalized, eventId ? { eventID: eventId } : '');
|
|
110
|
+
fbq('track', event, customData, eventData);
|
|
103
111
|
}
|
package/src/tracking/index.ts
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Meta Pixel tracking utilities.
|
|
3
|
+
*
|
|
4
|
+
* Most events (PageView, ViewContent, InitiateCheckout) fire automatically via
|
|
5
|
+
* MetaPixel + MetaPixelTracker, which are mounted in KeystoneRootLayout.
|
|
6
|
+
*
|
|
7
|
+
* For custom forms, add two calls in your success handler:
|
|
8
|
+
*
|
|
9
|
+
* import { firePixelEvent, setPixelUserData } from 'keystone-design-bootstrap/tracking';
|
|
10
|
+
*
|
|
11
|
+
* await setPixelUserData({ email, phone }); // hash + store identity for the session
|
|
12
|
+
* firePixelEvent('Lead'); // fire fbq('track', 'Lead')
|
|
13
|
+
*
|
|
14
|
+
* Also make sure the form submits with `formType: 'lead'` so the server-side CAPI
|
|
15
|
+
* Lead event fires automatically. See 'keystone-design-bootstrap/next/routes/form'
|
|
16
|
+
* for the full tracking contract.
|
|
17
|
+
*
|
|
18
|
+
* All calls are silent no-ops when no Meta Pixel is configured for the site.
|
|
19
|
+
*/
|
|
20
|
+
|
|
1
21
|
export { MetaPixel } from './MetaPixel';
|
|
2
22
|
export type { MetaPixelProps } from './MetaPixel';
|
|
3
23
|
export { MetaPixelTracker } from './MetaPixelTracker';
|