keystone-design-bootstrap 1.0.64 → 1.0.66
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/BookIframePanel.tsx +132 -0
- package/src/design_system/portal/PortalPage.tsx +47 -41
- 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 +11 -4
- package/src/design_system/sections/header-navigation.balance.tsx +11 -4
- package/src/design_system/sections/header-navigation.barelux.tsx +11 -4
- package/src/design_system/sections/header-navigation.tsx +11 -4
- package/src/lib/cta-urls.ts +16 -0
- 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
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useEffect, useRef } from 'react';
|
|
4
|
+
|
|
5
|
+
interface BookIframePanelProps {
|
|
6
|
+
bookingHref: string;
|
|
7
|
+
businessName: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function BookIframePanel({ bookingHref, businessName }: BookIframePanelProps) {
|
|
11
|
+
const [hasOpened, setHasOpened] = useState(false);
|
|
12
|
+
const [modalOpen, setModalOpen] = useState(false);
|
|
13
|
+
const [isVisible, setIsVisible] = useState(false);
|
|
14
|
+
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
15
|
+
|
|
16
|
+
const openModal = () => {
|
|
17
|
+
setHasOpened(true);
|
|
18
|
+
setModalOpen(true);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const closeModal = () => {
|
|
22
|
+
setIsVisible(false);
|
|
23
|
+
closeTimerRef.current = setTimeout(() => setModalOpen(false), 300);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Clear any pending close timer on unmount.
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
return () => {
|
|
29
|
+
if (closeTimerRef.current) clearTimeout(closeTimerRef.current);
|
|
30
|
+
};
|
|
31
|
+
}, []);
|
|
32
|
+
|
|
33
|
+
// Animate in after mount — double rAF ensures the transition fires after the
|
|
34
|
+
// element is painted rather than immediately on insertion.
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!modalOpen) return;
|
|
37
|
+
let cancelled = false;
|
|
38
|
+
const id = requestAnimationFrame(() =>
|
|
39
|
+
requestAnimationFrame(() => {
|
|
40
|
+
if (!cancelled) setIsVisible(true);
|
|
41
|
+
})
|
|
42
|
+
);
|
|
43
|
+
return () => {
|
|
44
|
+
cancelled = true;
|
|
45
|
+
cancelAnimationFrame(id);
|
|
46
|
+
};
|
|
47
|
+
}, [modalOpen]);
|
|
48
|
+
|
|
49
|
+
// Prevent body scroll while modal is open.
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
document.body.style.overflow = modalOpen ? 'hidden' : '';
|
|
52
|
+
return () => { document.body.style.overflow = ''; };
|
|
53
|
+
}, [modalOpen]);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<>
|
|
57
|
+
{/* Blurred iframe preview with overlay message */}
|
|
58
|
+
<div className="relative rounded-component border border-secondary overflow-hidden" style={{ height: '70vh' }}>
|
|
59
|
+
{/* iframe — purely visual, non-interactive */}
|
|
60
|
+
<iframe
|
|
61
|
+
src={bookingHref}
|
|
62
|
+
className="w-full h-full pointer-events-none select-none"
|
|
63
|
+
title="Booking preview"
|
|
64
|
+
tabIndex={-1}
|
|
65
|
+
aria-hidden="true"
|
|
66
|
+
/>
|
|
67
|
+
|
|
68
|
+
{/* Backdrop blur + message overlay */}
|
|
69
|
+
<div className="absolute inset-0 flex flex-col items-center justify-center px-6 py-8 backdrop-blur-[3.9px] bg-primary/70">
|
|
70
|
+
<div className="w-full max-w-sm rounded-component border border-secondary bg-primary px-6 py-6 text-center shadow-sm">
|
|
71
|
+
<p className="text-xl font-bold text-primary">Booking Instructions</p>
|
|
72
|
+
<p className="mt-5 text-sm text-secondary leading-relaxed">
|
|
73
|
+
We use a separate platform that may ask for an additional sign in or intake details to fully create your profile and collect your appointment information.
|
|
74
|
+
</p>
|
|
75
|
+
<div className="mt-5 flex flex-wrap items-center justify-center gap-3">
|
|
76
|
+
<button
|
|
77
|
+
onClick={openModal}
|
|
78
|
+
className="inline-flex items-center gap-2 rounded-interactive bg-brand-solid px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-brand-solid_hover"
|
|
79
|
+
>
|
|
80
|
+
Start Booking
|
|
81
|
+
</button>
|
|
82
|
+
<a
|
|
83
|
+
href={bookingHref}
|
|
84
|
+
target="_blank"
|
|
85
|
+
rel="noopener noreferrer"
|
|
86
|
+
className="inline-flex items-center gap-1.5 rounded-interactive border border-secondary bg-secondary px-5 py-2.5 text-sm font-semibold text-primary transition-colors hover:bg-secondary_hover"
|
|
87
|
+
>
|
|
88
|
+
Open in New Tab
|
|
89
|
+
<svg className="size-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
90
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
|
91
|
+
</svg>
|
|
92
|
+
</a>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
{/* Full-screen modal — kept mounted after first open so the iframe session persists on close/reopen */}
|
|
99
|
+
{hasOpened && (
|
|
100
|
+
<div
|
|
101
|
+
className={`fixed inset-0 z-50 flex flex-col bg-primary transition-[opacity,transform] duration-300 ease-out ${
|
|
102
|
+
modalOpen && isVisible
|
|
103
|
+
? 'opacity-100 translate-y-0'
|
|
104
|
+
: 'opacity-0 translate-y-3 pointer-events-none'
|
|
105
|
+
}`}
|
|
106
|
+
>
|
|
107
|
+
{/* Modal header */}
|
|
108
|
+
<div className="flex shrink-0 items-center justify-between border-b border-secondary bg-primary px-4 py-3">
|
|
109
|
+
<span className="text-sm font-semibold text-primary">{businessName} Booking</span>
|
|
110
|
+
<button
|
|
111
|
+
onClick={closeModal}
|
|
112
|
+
className="inline-flex items-center gap-2 rounded-interactive bg-brand-solid px-4 py-1.5 text-sm font-semibold text-white transition-colors hover:bg-brand-solid_hover"
|
|
113
|
+
>
|
|
114
|
+
<svg className="size-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
115
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
116
|
+
</svg>
|
|
117
|
+
Close
|
|
118
|
+
</button>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
{/* iframe fills the rest of the screen */}
|
|
122
|
+
<iframe
|
|
123
|
+
src={bookingHref}
|
|
124
|
+
className="min-h-0 flex-1 w-full"
|
|
125
|
+
title="Book appointment"
|
|
126
|
+
allow="payment"
|
|
127
|
+
/>
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
</>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -7,6 +7,7 @@ import { LoginModalController } from './LoginModalController';
|
|
|
7
7
|
import { MessageComposer } from './MessageComposer';
|
|
8
8
|
import { RowThumbnail } from './RowThumbnail';
|
|
9
9
|
import { PortalTabTracker } from './PortalTabTracker';
|
|
10
|
+
import { BookIframePanel } from './BookIframePanel';
|
|
10
11
|
import {
|
|
11
12
|
CONSUMER_TOKEN_COOKIE,
|
|
12
13
|
fetchConsumerMe,
|
|
@@ -508,11 +509,13 @@ function BookPanel({
|
|
|
508
509
|
bookingLabel,
|
|
509
510
|
bookingAllowsIframe,
|
|
510
511
|
isLoggedIn,
|
|
512
|
+
businessName,
|
|
511
513
|
}: {
|
|
512
514
|
bookingHref: string;
|
|
513
515
|
bookingLabel: string;
|
|
514
516
|
bookingAllowsIframe: boolean;
|
|
515
517
|
isLoggedIn: boolean;
|
|
518
|
+
businessName: string;
|
|
516
519
|
}) {
|
|
517
520
|
if (!isLoggedIn) {
|
|
518
521
|
return <LoginWall message="Continue to view booking options." cta="View Booking Options" />;
|
|
@@ -522,14 +525,7 @@ function BookPanel({
|
|
|
522
525
|
return (
|
|
523
526
|
<>
|
|
524
527
|
<PortalTabTracker event="InitiateCheckout" />
|
|
525
|
-
<
|
|
526
|
-
<iframe
|
|
527
|
-
src={bookingHref}
|
|
528
|
-
className="w-full h-full"
|
|
529
|
-
title="Book appointment"
|
|
530
|
-
allow="payment"
|
|
531
|
-
/>
|
|
532
|
-
</div>
|
|
528
|
+
<BookIframePanel bookingHref={bookingHref} businessName={businessName} />
|
|
533
529
|
</>
|
|
534
530
|
);
|
|
535
531
|
}
|
|
@@ -575,7 +571,7 @@ export async function PortalPage({
|
|
|
575
571
|
contactHref = '/contact',
|
|
576
572
|
}: PortalPageProps) {
|
|
577
573
|
const params = await searchParams ?? {};
|
|
578
|
-
const
|
|
574
|
+
const tabParam = VALID_TABS.includes(params.tab as Tab) ? (params.tab as Tab) : null;
|
|
579
575
|
const contactIdParsed = params.contact ? parseInt(params.contact, 10) : NaN;
|
|
580
576
|
const contactId = Number.isFinite(contactIdParsed) ? contactIdParsed : null;
|
|
581
577
|
|
|
@@ -595,6 +591,19 @@ export async function PortalPage({
|
|
|
595
591
|
const resolvedBookingHref =
|
|
596
592
|
bookingHref ?? companyInformation?.external_management_url ?? null;
|
|
597
593
|
|
|
594
|
+
// Build the tabs array first so the default can simply be whatever is first.
|
|
595
|
+
const tabs: Array<{ id: Tab; label: string }> = [
|
|
596
|
+
...(resolvedBookingHref ? [{ id: 'book' as Tab, label: bookingLabel }] : []),
|
|
597
|
+
{ id: 'services', label: 'Services' },
|
|
598
|
+
{ id: 'packages', label: 'Packages' },
|
|
599
|
+
{ id: 'specials', label: 'Specials' },
|
|
600
|
+
{ id: 'messages', label: 'Messages' },
|
|
601
|
+
...(!resolvedBookingHref ? [{ id: 'book' as Tab, label: bookingLabel }] : []),
|
|
602
|
+
];
|
|
603
|
+
|
|
604
|
+
// Default to the first tab so tab order and default selection are always in sync.
|
|
605
|
+
const tab: Tab = tabParam ?? tabs[0].id;
|
|
606
|
+
|
|
598
607
|
// Auto-detect iframe support by inspecting the booking URL's framing headers.
|
|
599
608
|
// Only runs when the book tab is active to avoid unnecessary requests.
|
|
600
609
|
// Skipped entirely when forceExternalBooking is set.
|
|
@@ -633,14 +642,6 @@ export async function PortalPage({
|
|
|
633
642
|
const packageList = packages ?? [];
|
|
634
643
|
const specials = aggregateSpecials(serviceList, packageList);
|
|
635
644
|
|
|
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
645
|
return (
|
|
645
646
|
<div className="min-h-screen bg-primary">
|
|
646
647
|
{/* Portal Header */}
|
|
@@ -685,33 +686,37 @@ export async function PortalPage({
|
|
|
685
686
|
</div>
|
|
686
687
|
</div>
|
|
687
688
|
|
|
688
|
-
{/* Tabs */}
|
|
689
|
-
<div className="mt-4
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
689
|
+
{/* Tabs — scrollable on mobile; fade on right edge hints at overflow */}
|
|
690
|
+
<div className="relative mt-4">
|
|
691
|
+
<div className="flex gap-1 overflow-x-auto pb-px scrollbar-none">
|
|
692
|
+
{tabs.map((t) => {
|
|
693
|
+
const isActive = tab === t.id;
|
|
694
|
+
const isGated = !isLoggedIn && (t.id === 'specials' || t.id === 'messages' || t.id === 'book');
|
|
695
|
+
const href = `${portalHref}?tab=${t.id}`;
|
|
696
|
+
const className = `shrink-0 rounded-interactive px-4 py-2 text-sm font-medium transition-colors whitespace-nowrap ${
|
|
697
|
+
isActive ? 'bg-brand-solid text-white' : 'text-secondary hover:bg-secondary hover:text-primary'
|
|
698
|
+
}`;
|
|
699
|
+
if (isGated) {
|
|
700
|
+
return (
|
|
701
|
+
<button
|
|
702
|
+
key={t.id}
|
|
703
|
+
data-open-login-modal
|
|
704
|
+
data-login-redirect={href}
|
|
705
|
+
className={className}
|
|
706
|
+
>
|
|
707
|
+
{t.label}
|
|
708
|
+
</button>
|
|
709
|
+
);
|
|
710
|
+
}
|
|
698
711
|
return (
|
|
699
|
-
<
|
|
700
|
-
key={t.id}
|
|
701
|
-
data-open-login-modal
|
|
702
|
-
data-login-redirect={href}
|
|
703
|
-
className={className}
|
|
704
|
-
>
|
|
712
|
+
<Link key={t.id} href={href} className={className}>
|
|
705
713
|
{t.label}
|
|
706
|
-
</
|
|
714
|
+
</Link>
|
|
707
715
|
);
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
</Link>
|
|
713
|
-
);
|
|
714
|
-
})}
|
|
716
|
+
})}
|
|
717
|
+
</div>
|
|
718
|
+
{/* Right-edge fade: visible on small screens only, hints that tabs are scrollable */}
|
|
719
|
+
<div className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-primary to-transparent md:hidden" />
|
|
715
720
|
</div>
|
|
716
721
|
</div>
|
|
717
722
|
</div>
|
|
@@ -750,6 +755,7 @@ export async function PortalPage({
|
|
|
750
755
|
bookingLabel={bookingLabel}
|
|
751
756
|
bookingAllowsIframe={bookingAllowsIframe}
|
|
752
757
|
isLoggedIn={isLoggedIn}
|
|
758
|
+
businessName={businessName}
|
|
753
759
|
/>
|
|
754
760
|
) : (
|
|
755
761
|
<div className="flex flex-col items-center justify-center py-20 text-center">
|
|
@@ -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,12 +3,13 @@
|
|
|
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';
|
|
9
10
|
import type { HeaderComponentProps } from './header-navigation';
|
|
10
11
|
import type { NavItem } from '../../types/config';
|
|
11
|
-
import { resolveCtaUrls, isExternalCtaUrl } from '../../lib/cta-urls';
|
|
12
|
+
import { resolveCtaUrls, isExternalCtaUrl, resolvePortalPath } from '../../lib/cta-urls';
|
|
12
13
|
|
|
13
14
|
// Maximum items to show before "View All" link
|
|
14
15
|
const MAX_DROPDOWN_ITEMS = 3;
|
|
@@ -58,6 +59,12 @@ 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 = resolvePortalPath(companyInformation);
|
|
66
|
+
const isPortalPage = portalPath ? pathname?.startsWith(portalPath) : false;
|
|
67
|
+
|
|
61
68
|
// Cancel any pending close timeout
|
|
62
69
|
const cancelCloseTimeout = useCallback(() => {
|
|
63
70
|
if (closeTimeoutRef.current) {
|
|
@@ -390,8 +397,8 @@ export function HeaderNavigation({
|
|
|
390
397
|
</div>
|
|
391
398
|
)}
|
|
392
399
|
|
|
393
|
-
{/* Sticky Contact Button (Mobile)
|
|
394
|
-
<div className="fixed bottom-0 left-0 right-0 z-40 md:hidden bg-fg-primary">
|
|
400
|
+
{/* Sticky Contact Button (Mobile) — hidden on portal page where it would be redundant */}
|
|
401
|
+
{!isPortalPage && <div className="fixed bottom-0 left-0 right-0 z-40 md:hidden bg-fg-primary">
|
|
395
402
|
<div className="flex gap-0">
|
|
396
403
|
{props?.cta_button?.secondary_label && ctaUrls.hasSecondary && (
|
|
397
404
|
<Button
|
|
@@ -414,7 +421,7 @@ export function HeaderNavigation({
|
|
|
414
421
|
{(props?.cta_button?.secondary_label && ctaUrls.hasSecondary) ? props.cta_button.secondary_label : (props?.cta_button?.label || "Contact")}
|
|
415
422
|
</Button>
|
|
416
423
|
</div>
|
|
417
|
-
</div>
|
|
424
|
+
</div>}
|
|
418
425
|
</>
|
|
419
426
|
);
|
|
420
427
|
}
|
|
@@ -3,11 +3,12 @@
|
|
|
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';
|
|
9
10
|
import type { NavItem } from '../../types/config';
|
|
10
|
-
import { resolveCtaUrls, isExternalCtaUrl } from '../../lib/cta-urls';
|
|
11
|
+
import { resolveCtaUrls, isExternalCtaUrl, resolvePortalPath } from '../../lib/cta-urls';
|
|
11
12
|
|
|
12
13
|
export function HeaderNavigation({
|
|
13
14
|
props,
|
|
@@ -37,6 +38,12 @@ 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 = resolvePortalPath(companyInformation);
|
|
45
|
+
const isPortalPage = portalPath ? pathname?.startsWith(portalPath) : false;
|
|
46
|
+
|
|
40
47
|
const cancelCloseTimeout = useCallback(() => {
|
|
41
48
|
if (closeTimeoutRef.current) {
|
|
42
49
|
clearTimeout(closeTimeoutRef.current);
|
|
@@ -301,8 +308,8 @@ export function HeaderNavigation({
|
|
|
301
308
|
</div>
|
|
302
309
|
)}
|
|
303
310
|
|
|
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)' }}>
|
|
311
|
+
{/* Sticky Bottom Bar (mobile only) — hidden on portal page where it would be redundant */}
|
|
312
|
+
{!isPortalPage && <div className="fixed bottom-0 left-0 right-0 z-40 md:hidden" style={{ backgroundColor: 'rgb(148, 133, 84)' }}>
|
|
306
313
|
<div className="flex gap-0">
|
|
307
314
|
{props?.cta_button?.secondary_label && ctaUrls.hasSecondary && (
|
|
308
315
|
<Button
|
|
@@ -326,7 +333,7 @@ export function HeaderNavigation({
|
|
|
326
333
|
{(props?.cta_button?.secondary_label && ctaUrls.hasSecondary) ? props.cta_button.secondary_label : (props?.cta_button?.label || "Contact")}
|
|
327
334
|
</Button>
|
|
328
335
|
</div>
|
|
329
|
-
</div>
|
|
336
|
+
</div>}
|
|
330
337
|
</>
|
|
331
338
|
);
|
|
332
339
|
}
|
|
@@ -3,11 +3,12 @@
|
|
|
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';
|
|
9
10
|
import type { NavItem } from '../../types/config';
|
|
10
|
-
import { resolveCtaUrls, isExternalCtaUrl } from '../../lib/cta-urls';
|
|
11
|
+
import { resolveCtaUrls, isExternalCtaUrl, resolvePortalPath } from '../../lib/cta-urls';
|
|
11
12
|
|
|
12
13
|
// Maximum items to show before "View All" link
|
|
13
14
|
const MAX_DROPDOWN_ITEMS = 6;
|
|
@@ -43,6 +44,12 @@ 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 = resolvePortalPath(companyInformation);
|
|
51
|
+
const isPortalPage = portalPath ? pathname?.startsWith(portalPath) : false;
|
|
52
|
+
|
|
46
53
|
// Cancel any pending close timeout
|
|
47
54
|
const cancelCloseTimeout = useCallback(() => {
|
|
48
55
|
if (closeTimeoutRef.current) {
|
|
@@ -343,8 +350,8 @@ export function HeaderNavigation({
|
|
|
343
350
|
</div>
|
|
344
351
|
)}
|
|
345
352
|
|
|
346
|
-
{/* Sticky Book/Contact bar (mobile only)
|
|
347
|
-
<div className="fixed bottom-0 left-0 right-0 z-40 md:hidden bg-fg-primary">
|
|
353
|
+
{/* Sticky Book/Contact bar (mobile only) — hidden on portal page where it would be redundant */}
|
|
354
|
+
{!isPortalPage && <div className="fixed bottom-0 left-0 right-0 z-40 md:hidden bg-fg-primary">
|
|
348
355
|
<div className="flex gap-0">
|
|
349
356
|
{props?.cta_button?.secondary_label && ctaUrls.hasSecondary && (
|
|
350
357
|
<Button
|
|
@@ -367,7 +374,7 @@ export function HeaderNavigation({
|
|
|
367
374
|
{(props?.cta_button?.secondary_label && ctaUrls.hasSecondary) ? props.cta_button.secondary_label : (props?.cta_button?.label || "Contact")}
|
|
368
375
|
</Button>
|
|
369
376
|
</div>
|
|
370
|
-
</div>
|
|
377
|
+
</div>}
|
|
371
378
|
</>
|
|
372
379
|
);
|
|
373
380
|
}
|
|
@@ -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";
|
|
@@ -11,7 +12,7 @@ import { getLogoUrl } from '../../utils/photo-helpers';
|
|
|
11
12
|
import type { CompanyInformation } from '../../types/api/company-information';
|
|
12
13
|
import type { WebsitePhotos } from '../../types/api/website-photos';
|
|
13
14
|
import type { NavItem, SiteConfig } from '../../types/config';
|
|
14
|
-
import { resolveCtaUrls, isExternalCtaUrl } from '../../lib/cta-urls';
|
|
15
|
+
import { resolveCtaUrls, isExternalCtaUrl, resolvePortalPath } from '../../lib/cta-urls';
|
|
15
16
|
|
|
16
17
|
export interface HeaderProps {
|
|
17
18
|
logo: {
|
|
@@ -108,6 +109,12 @@ 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 = resolvePortalPath(companyInformation);
|
|
116
|
+
const isPortalPage = portalPath ? pathname?.startsWith(portalPath) : false;
|
|
117
|
+
|
|
111
118
|
const getVariantClasses = () => {
|
|
112
119
|
switch (variant) {
|
|
113
120
|
case 'minimal':
|
|
@@ -366,8 +373,8 @@ export function HeaderNavigation({
|
|
|
366
373
|
</div>
|
|
367
374
|
</header>
|
|
368
375
|
|
|
369
|
-
{/* Sticky Book/Contact bar (mobile only) */}
|
|
370
|
-
<div className="fixed bottom-0 left-0 right-0 z-40 md:hidden bg-fg-primary">
|
|
376
|
+
{/* Sticky Book/Contact bar (mobile only) — hidden on portal page where it would be redundant */}
|
|
377
|
+
{!isPortalPage && <div className="fixed bottom-0 left-0 right-0 z-40 md:hidden bg-fg-primary">
|
|
371
378
|
<div className="flex gap-0">
|
|
372
379
|
{/* Left: Contact Us -> /contact */}
|
|
373
380
|
{cta_button?.secondary_label && ctaUrls.hasSecondary && (
|
|
@@ -392,7 +399,7 @@ export function HeaderNavigation({
|
|
|
392
399
|
{(cta_button?.secondary_label && ctaUrls.hasSecondary) ? cta_button.secondary_label : (cta_button?.label || 'Contact')}
|
|
393
400
|
</Button>
|
|
394
401
|
</div>
|
|
395
|
-
</div>
|
|
402
|
+
</div>}
|
|
396
403
|
</>
|
|
397
404
|
);
|
|
398
405
|
}
|
package/src/lib/cta-urls.ts
CHANGED
|
@@ -25,6 +25,22 @@ export interface ResolvedCtaUrls {
|
|
|
25
25
|
hasSecondary: boolean;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Extracts the pathname from portal_url for use in client-side route comparisons.
|
|
30
|
+
* Handles both absolute URLs (https://domain.com/portal → /portal) and relative paths (/portal).
|
|
31
|
+
*/
|
|
32
|
+
export function resolvePortalPath(
|
|
33
|
+
companyInformation?: CompanyInformation | null
|
|
34
|
+
): string | null {
|
|
35
|
+
const url = companyInformation?.portal_url?.trim();
|
|
36
|
+
if (!url) return null;
|
|
37
|
+
try {
|
|
38
|
+
return new URL(url).pathname;
|
|
39
|
+
} catch {
|
|
40
|
+
return url.startsWith('/') ? url : null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
28
44
|
export function resolveCtaUrls(
|
|
29
45
|
companyInformation?: CompanyInformation | null
|
|
30
46
|
): ResolvedCtaUrls {
|
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';
|