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 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.65",
4
4
  "description": "Keystone Design Bootstrap - Sections, Elements, and Theme System for customer websites",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -575,7 +575,7 @@ export async function PortalPage({
575
575
  contactHref = '/contact',
576
576
  }: PortalPageProps) {
577
577
  const params = await searchParams ?? {};
578
- const tab: Tab = VALID_TABS.includes(params.tab as Tab) ? (params.tab as Tab) : 'services';
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 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) {
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
- <button
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
- </button>
718
+ </Link>
707
719
  );
708
- }
709
- return (
710
- <Link key={t.id} href={href} className={className}>
711
- {t.label}
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) - Left=Contact Us, Right=Book Now */}
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) - matches barelux theme */}
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
  }
@@ -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';