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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keystone-design-bootstrap",
3
- "version": "1.0.64",
3
+ "version": "1.0.66",
4
4
  "description": "Keystone Design Bootstrap - Sections, Elements, and Theme System for customer websites",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -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
- <div className="rounded-component border border-secondary overflow-hidden" style={{ height: '70vh' }}>
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 tab: Tab = VALID_TABS.includes(params.tab as Tab) ? (params.tab as Tab) : 'services';
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 flex gap-1 overflow-x-auto pb-px scrollbar-none">
690
- {tabs.map((t) => {
691
- const isActive = tab === t.id;
692
- const isGated = !isLoggedIn && (t.id === 'specials' || t.id === 'messages' || t.id === 'book');
693
- const href = `${portalHref}?tab=${t.id}`;
694
- const className = `shrink-0 rounded-interactive px-4 py-2 text-sm font-medium transition-colors whitespace-nowrap ${
695
- isActive ? 'bg-brand-solid text-white' : 'text-secondary hover:bg-secondary hover:text-primary'
696
- }`;
697
- if (isGated) {
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
- <button
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
- </button>
714
+ </Link>
707
715
  );
708
- }
709
- return (
710
- <Link key={t.id} href={href} className={className}>
711
- {t.label}
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) - Left=Contact Us, Right=Book Now */}
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) - matches barelux theme */}
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
  }
@@ -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 {
@@ -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 and user-agent from an incoming Next.js route
3
- * request and returns them as headers to forward to the upstream Rails API.
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
- * 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.
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
- console.debug('[MetaPixel]', event, normalized);
102
- fbq('track', event, Object.keys(normalized).length > 0 ? normalized : undefined);
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
  }
@@ -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';