keystone-design-bootstrap 1.0.51 → 1.0.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/design_system/sections/email-signup-section.tsx +115 -0
- package/src/design_system/sections/header-navigation.aman.tsx +8 -3
- package/src/design_system/sections/header-navigation.balance.tsx +2 -0
- package/src/design_system/sections/header-navigation.barelux.tsx +4 -1
- package/src/design_system/sections/index.tsx +4 -0
- package/src/next/contexts/form-definitions.tsx +5 -2
- package/src/next/layouts/root-layout.tsx +5 -1
- package/src/tracking/BookingCtaTracker.tsx +32 -0
- package/src/tracking/MetaPixelTracker.tsx +91 -0
- package/src/tracking/ViewContentTracker.tsx +21 -0
- package/src/tracking/index.ts +6 -0
- package/src/tracking/trackInitiateCheckout.ts +11 -0
- package/src/tracking/trackViewContent.ts +15 -0
- package/src/types/api/form.ts +1 -1
package/package.json
CHANGED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useRef, useState } from 'react';
|
|
4
|
+
import { Form, Button } from '../elements';
|
|
5
|
+
import { DynamicFormFields } from '../components/DynamicFormFields';
|
|
6
|
+
import type { FormDefinition } from '../../types/api/form';
|
|
7
|
+
import { useFormDefinitions } from '../../next/contexts/form-definitions';
|
|
8
|
+
|
|
9
|
+
export interface EmailSignupSectionProps {
|
|
10
|
+
title?: string;
|
|
11
|
+
subtitle?: string;
|
|
12
|
+
buttonText?: string;
|
|
13
|
+
successMessage?: string;
|
|
14
|
+
/** Override the form definition (falls back to context). */
|
|
15
|
+
formDefinition?: FormDefinition | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const EmailSignupSection = ({
|
|
19
|
+
title = "Stay in the loop",
|
|
20
|
+
subtitle = "Subscribe to our newsletter for updates, tips, and exclusive offers.",
|
|
21
|
+
buttonText = "Subscribe",
|
|
22
|
+
successMessage = "You're subscribed! Thank you.",
|
|
23
|
+
formDefinition,
|
|
24
|
+
}: EmailSignupSectionProps) => {
|
|
25
|
+
const { marketingListSignupFormDefinition } = useFormDefinitions();
|
|
26
|
+
const resolvedFormDefinition = formDefinition ?? marketingListSignupFormDefinition;
|
|
27
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
28
|
+
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
|
|
29
|
+
const [statusMessage, setStatusMessage] = useState('');
|
|
30
|
+
const formRef = useRef<HTMLFormElement>(null);
|
|
31
|
+
|
|
32
|
+
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
33
|
+
e.preventDefault();
|
|
34
|
+
setIsSubmitting(true);
|
|
35
|
+
setSubmitStatus('idle');
|
|
36
|
+
setStatusMessage('');
|
|
37
|
+
|
|
38
|
+
const formData = new FormData(e.currentTarget);
|
|
39
|
+
const data: Record<string, string> = { formType: 'marketing_list_signup' };
|
|
40
|
+
formData.forEach((value, key) => {
|
|
41
|
+
if (key.endsWith('_prefix')) return;
|
|
42
|
+
if (typeof value === 'string') data[key] = value;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const response = await fetch('/api/form/', {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: { 'Content-Type': 'application/json' },
|
|
49
|
+
body: JSON.stringify(data),
|
|
50
|
+
});
|
|
51
|
+
const result = await response.json();
|
|
52
|
+
|
|
53
|
+
if (result.success) {
|
|
54
|
+
setSubmitStatus('success');
|
|
55
|
+
setStatusMessage(result.message || successMessage);
|
|
56
|
+
formRef.current?.reset();
|
|
57
|
+
setTimeout(() => setSubmitStatus('idle'), 6000);
|
|
58
|
+
} else {
|
|
59
|
+
setSubmitStatus('error');
|
|
60
|
+
setStatusMessage(result.error || 'Something went wrong. Please try again.');
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
setSubmitStatus('error');
|
|
64
|
+
setStatusMessage('Network error. Please try again later.');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
setIsSubmitting(false);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (!resolvedFormDefinition) return null;
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<section className="bg-secondary py-16 md:py-20">
|
|
74
|
+
<div className="mx-auto max-w-container px-4 md:px-8">
|
|
75
|
+
<div className="mx-auto max-w-xl text-center">
|
|
76
|
+
<h2 className="font-display text-display-sm font-semibold text-primary md:text-display-md">
|
|
77
|
+
{title}
|
|
78
|
+
</h2>
|
|
79
|
+
{subtitle && (
|
|
80
|
+
<p className="mt-4 font-body text-lg text-tertiary">
|
|
81
|
+
{subtitle}
|
|
82
|
+
</p>
|
|
83
|
+
)}
|
|
84
|
+
<Form
|
|
85
|
+
ref={formRef}
|
|
86
|
+
onSubmit={handleSubmit}
|
|
87
|
+
className="mt-8 flex flex-col gap-6 text-left"
|
|
88
|
+
>
|
|
89
|
+
<DynamicFormFields form={resolvedFormDefinition} />
|
|
90
|
+
{submitStatus === 'success' && (
|
|
91
|
+
<div className="rounded-lg bg-success-50 p-4 text-success-700">
|
|
92
|
+
{statusMessage || successMessage}
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
{submitStatus === 'error' && (
|
|
96
|
+
<div className="rounded-lg bg-error-50 p-4 text-error-700">
|
|
97
|
+
{statusMessage}
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
<Button
|
|
101
|
+
type="submit"
|
|
102
|
+
color="primary"
|
|
103
|
+
size="xl"
|
|
104
|
+
isDisabled={isSubmitting}
|
|
105
|
+
isLoading={isSubmitting}
|
|
106
|
+
className="w-full"
|
|
107
|
+
>
|
|
108
|
+
{isSubmitting ? 'Subscribing...' : buttonText}
|
|
109
|
+
</Button>
|
|
110
|
+
</Form>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
</section>
|
|
114
|
+
);
|
|
115
|
+
};
|
|
@@ -30,13 +30,15 @@ export function HeaderNavigation({
|
|
|
30
30
|
// Timeout ref for delayed dropdown closing
|
|
31
31
|
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
32
32
|
|
|
33
|
-
// Track scroll position for header animation
|
|
33
|
+
// Track scroll position for header animation; close dropdown on scroll to
|
|
34
|
+
// avoid a stale fixed `top` value creating a gap between the nav and dropdown.
|
|
34
35
|
React.useEffect(() => {
|
|
35
36
|
const handleScroll = () => {
|
|
36
37
|
setIsScrolled(window.scrollY > 10);
|
|
38
|
+
setActiveDropdown(null);
|
|
37
39
|
};
|
|
38
40
|
|
|
39
|
-
window.addEventListener('scroll', handleScroll);
|
|
41
|
+
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
40
42
|
return () => window.removeEventListener('scroll', handleScroll);
|
|
41
43
|
}, []);
|
|
42
44
|
|
|
@@ -67,7 +69,8 @@ export function HeaderNavigation({
|
|
|
67
69
|
}
|
|
68
70
|
}, []);
|
|
69
71
|
|
|
70
|
-
// Open dropdown immediately
|
|
72
|
+
// Open dropdown immediately; clear it when hovering a childless item so the
|
|
73
|
+
// previous dropdown doesn't linger while a different top-level item is active.
|
|
71
74
|
const handleMouseEnter = useCallback((item: NavItem, e: React.MouseEvent<HTMLDivElement>) => {
|
|
72
75
|
cancelCloseTimeout();
|
|
73
76
|
if (item.children && item.children.length > 0) {
|
|
@@ -77,6 +80,8 @@ export function HeaderNavigation({
|
|
|
77
80
|
setDropdownTop(rect.bottom);
|
|
78
81
|
}
|
|
79
82
|
setActiveDropdown(item.label);
|
|
83
|
+
} else {
|
|
84
|
+
setActiveDropdown(null);
|
|
80
85
|
}
|
|
81
86
|
}, [cancelCloseTimeout]);
|
|
82
87
|
|
|
@@ -54,11 +54,14 @@ export function HeaderNavigation({
|
|
|
54
54
|
}
|
|
55
55
|
}, []);
|
|
56
56
|
|
|
57
|
-
// Open dropdown immediately
|
|
57
|
+
// Open dropdown immediately; clear it when hovering a childless item so the
|
|
58
|
+
// previous dropdown doesn't linger while a different top-level item is active.
|
|
58
59
|
const handleMouseEnter = useCallback((item: NavItem) => {
|
|
59
60
|
cancelCloseTimeout();
|
|
60
61
|
if (item.children && item.children.length > 0) {
|
|
61
62
|
setActiveDropdown(item.label);
|
|
63
|
+
} else {
|
|
64
|
+
setActiveDropdown(null);
|
|
62
65
|
}
|
|
63
66
|
}, [cancelCloseTimeout]);
|
|
64
67
|
|
|
@@ -178,6 +178,10 @@ export const HomeHeroComponent = createThemedExport('home-hero-component', BaseH
|
|
|
178
178
|
// Re-export application form (client component, no theme variants)
|
|
179
179
|
export { JobApplicationForm } from './job-application-form';
|
|
180
180
|
|
|
181
|
+
// Email / newsletter signup section (client component, no theme variants needed)
|
|
182
|
+
export { EmailSignupSection } from './email-signup-section';
|
|
183
|
+
export type { EmailSignupSectionProps } from './email-signup-section';
|
|
184
|
+
|
|
181
185
|
// Service Menu: packages + treatments; nested specials as badges / modal callouts
|
|
182
186
|
export { ServiceMenuSection } from './service-menu-section';
|
|
183
187
|
export type { ServiceMenuSectionProps, PackagePublic } from './service-menu-section';
|
|
@@ -6,21 +6,24 @@ import type { FormDefinition } from '../../types/api/form';
|
|
|
6
6
|
export type FormDefinitionsContextValue = {
|
|
7
7
|
leadFormDefinition: FormDefinition | null;
|
|
8
8
|
jobApplicationFormDefinition: FormDefinition | null;
|
|
9
|
+
marketingListSignupFormDefinition: FormDefinition | null;
|
|
9
10
|
};
|
|
10
11
|
|
|
11
12
|
const FormDefinitionsContext = createContext<FormDefinitionsContextValue>({
|
|
12
13
|
leadFormDefinition: null,
|
|
13
14
|
jobApplicationFormDefinition: null,
|
|
15
|
+
marketingListSignupFormDefinition: null,
|
|
14
16
|
});
|
|
15
17
|
|
|
16
18
|
export function FormDefinitionsProvider(props: {
|
|
17
19
|
leadFormDefinition: FormDefinition | null;
|
|
18
20
|
jobApplicationFormDefinition: FormDefinition | null;
|
|
21
|
+
marketingListSignupFormDefinition: FormDefinition | null;
|
|
19
22
|
children: React.ReactNode;
|
|
20
23
|
}) {
|
|
21
|
-
const { leadFormDefinition, jobApplicationFormDefinition, children } = props;
|
|
24
|
+
const { leadFormDefinition, jobApplicationFormDefinition, marketingListSignupFormDefinition, children } = props;
|
|
22
25
|
return (
|
|
23
|
-
<FormDefinitionsContext.Provider value={{ leadFormDefinition, jobApplicationFormDefinition }}>
|
|
26
|
+
<FormDefinitionsContext.Provider value={{ leadFormDefinition, jobApplicationFormDefinition, marketingListSignupFormDefinition }}>
|
|
24
27
|
{children}
|
|
25
28
|
</FormDefinitionsContext.Provider>
|
|
26
29
|
);
|
|
@@ -3,7 +3,7 @@ import type { Metadata } from 'next';
|
|
|
3
3
|
|
|
4
4
|
import { HeaderNavigation, FooterHome } from '../../design_system/sections';
|
|
5
5
|
import { ThemeProvider } from '../../contexts';
|
|
6
|
-
import { MetaPixel } from '../../tracking';
|
|
6
|
+
import { MetaPixel, MetaPixelTracker } from '../../tracking';
|
|
7
7
|
import { ChatWidget } from '../../design_system/components/ChatWidget';
|
|
8
8
|
import { FormDefinitionsProvider } from '../contexts/form-definitions';
|
|
9
9
|
import { KeystoneSSRProvider } from '../providers/ssr-provider';
|
|
@@ -111,6 +111,7 @@ export async function KeystoneRootLayout(props: {
|
|
|
111
111
|
teamMembersData,
|
|
112
112
|
leadFormDefinition,
|
|
113
113
|
jobApplicationFormDefinition,
|
|
114
|
+
marketingListSignupFormDefinition,
|
|
114
115
|
adsConfig,
|
|
115
116
|
] =
|
|
116
117
|
await Promise.all([
|
|
@@ -121,6 +122,7 @@ export async function KeystoneRootLayout(props: {
|
|
|
121
122
|
getTeamMembers(),
|
|
122
123
|
getForm('lead'),
|
|
123
124
|
getForm('job_application'),
|
|
125
|
+
getForm('marketing_list_signup'),
|
|
124
126
|
getAdsConfig(),
|
|
125
127
|
]);
|
|
126
128
|
|
|
@@ -157,11 +159,13 @@ export async function KeystoneRootLayout(props: {
|
|
|
157
159
|
<html lang="en" data-theme={theme}>
|
|
158
160
|
<body>
|
|
159
161
|
{metaPixelId ? <MetaPixel pixelId={metaPixelId} /> : null}
|
|
162
|
+
{metaPixelId ? <MetaPixelTracker bookingUrl={externalManagementUrl} /> : null}
|
|
160
163
|
<ThemeProvider theme={theme}>
|
|
161
164
|
<KeystoneSSRProvider>
|
|
162
165
|
<FormDefinitionsProvider
|
|
163
166
|
leadFormDefinition={leadFormDefinition ?? null}
|
|
164
167
|
jobApplicationFormDefinition={jobApplicationFormDefinition ?? null}
|
|
168
|
+
marketingListSignupFormDefinition={marketingListSignupFormDefinition ?? null}
|
|
165
169
|
>
|
|
166
170
|
<HeaderNavigation
|
|
167
171
|
config={dynamicConfig}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
import { trackInitiateCheckout } from './trackInitiateCheckout';
|
|
5
|
+
|
|
6
|
+
type Props = {
|
|
7
|
+
bookingUrl: string | null | undefined;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Drop into the root layout to fire Meta Pixel InitiateCheckout whenever a visitor
|
|
12
|
+
* clicks any anchor whose href points to the external booking URL.
|
|
13
|
+
* Uses document-level click delegation — no changes needed to individual buttons.
|
|
14
|
+
*/
|
|
15
|
+
export function BookingCtaTracker({ bookingUrl }: Props) {
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!bookingUrl) return;
|
|
18
|
+
|
|
19
|
+
const handleClick = (e: MouseEvent) => {
|
|
20
|
+
const anchor = (e.target as Element).closest('a');
|
|
21
|
+
if (!anchor) return;
|
|
22
|
+
if (anchor.href && anchor.href.startsWith(bookingUrl)) {
|
|
23
|
+
trackInitiateCheckout();
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
document.addEventListener('click', handleClick);
|
|
28
|
+
return () => document.removeEventListener('click', handleClick);
|
|
29
|
+
}, [bookingUrl]);
|
|
30
|
+
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
import { usePathname } from 'next/navigation';
|
|
5
|
+
import { trackViewContent } from './trackViewContent';
|
|
6
|
+
import { trackInitiateCheckout } from './trackInitiateCheckout';
|
|
7
|
+
|
|
8
|
+
type RouteRule = {
|
|
9
|
+
pattern: RegExp;
|
|
10
|
+
getParams: (match: RegExpMatchArray) => { contentName: string; contentCategory: string };
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// Checked in order — first match wins. More specific patterns come first.
|
|
14
|
+
const ROUTE_RULES: RouteRule[] = [
|
|
15
|
+
{
|
|
16
|
+
pattern: /^\/services\/(.+)$/,
|
|
17
|
+
getParams: ([, slug]) => ({ contentName: slugToTitle(slug), contentCategory: 'Service' }),
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
pattern: /^\/locations\/(.+)$/,
|
|
21
|
+
getParams: ([, slug]) => ({ contentName: slugToTitle(slug), contentCategory: 'Location' }),
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
pattern: /^\/services$/,
|
|
25
|
+
getParams: () => ({ contentName: 'Services', contentCategory: 'Services' }),
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
pattern: /^\/locations$/,
|
|
29
|
+
getParams: () => ({ contentName: 'Locations', contentCategory: 'Locations' }),
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
pattern: /^\/service-menu$/,
|
|
33
|
+
getParams: () => ({ contentName: 'Service Menu', contentCategory: 'Pricing' }),
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
pattern: /^\/faq$/,
|
|
37
|
+
getParams: () => ({ contentName: 'FAQ', contentCategory: 'FAQ' }),
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
pattern: /^\/contact$/,
|
|
41
|
+
getParams: () => ({ contentName: 'Contact', contentCategory: 'Contact' }),
|
|
42
|
+
},
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
function slugToTitle(slug: string): string {
|
|
46
|
+
return slug
|
|
47
|
+
.split('-')
|
|
48
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
49
|
+
.join(' ');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type Props = {
|
|
53
|
+
/** External booking URL. When set, fires InitiateCheckout on any click targeting that URL. */
|
|
54
|
+
bookingUrl?: string | null;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Single client-side tracker placed once in KeystoneRootLayout.
|
|
59
|
+
* - Fires ViewContent on every route change for known page patterns.
|
|
60
|
+
* - Fires InitiateCheckout whenever a visitor clicks a link to the external booking URL.
|
|
61
|
+
*/
|
|
62
|
+
export function MetaPixelTracker({ bookingUrl }: Props) {
|
|
63
|
+
const pathname = usePathname();
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
for (const rule of ROUTE_RULES) {
|
|
67
|
+
const match = pathname.match(rule.pattern);
|
|
68
|
+
if (match) {
|
|
69
|
+
const { contentName, contentCategory } = rule.getParams(match);
|
|
70
|
+
trackViewContent(contentName, contentCategory);
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}, [pathname]);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!bookingUrl) return;
|
|
78
|
+
|
|
79
|
+
const handleClick = (e: MouseEvent) => {
|
|
80
|
+
const anchor = (e.target as Element).closest('a');
|
|
81
|
+
if (anchor?.href?.startsWith(bookingUrl)) {
|
|
82
|
+
trackInitiateCheckout();
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
document.addEventListener('click', handleClick);
|
|
87
|
+
return () => document.removeEventListener('click', handleClick);
|
|
88
|
+
}, [bookingUrl]);
|
|
89
|
+
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
import { trackViewContent } from './trackViewContent';
|
|
5
|
+
|
|
6
|
+
type Props = {
|
|
7
|
+
contentName?: string;
|
|
8
|
+
contentCategory?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Drop into any server-rendered page to fire the Meta Pixel ViewContent event on mount.
|
|
13
|
+
* Renders nothing — purely a tracking side-effect component.
|
|
14
|
+
*/
|
|
15
|
+
export function ViewContentTracker({ contentName, contentCategory }: Props) {
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
trackViewContent(contentName, contentCategory);
|
|
18
|
+
}, [contentName, contentCategory]);
|
|
19
|
+
|
|
20
|
+
return null;
|
|
21
|
+
}
|
package/src/tracking/index.ts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
1
|
export { MetaPixel } from './MetaPixel';
|
|
2
2
|
export type { MetaPixelProps } from './MetaPixel';
|
|
3
3
|
export { trackMetaLead } from './trackMetaLead';
|
|
4
|
+
export { trackViewContent } from './trackViewContent';
|
|
5
|
+
export { trackInitiateCheckout } from './trackInitiateCheckout';
|
|
6
|
+
export { MetaPixelTracker } from './MetaPixelTracker';
|
|
7
|
+
// Kept for custom use — MetaPixelTracker covers the standard cases automatically.
|
|
8
|
+
export { ViewContentTracker } from './ViewContentTracker';
|
|
9
|
+
export { BookingCtaTracker } from './BookingCtaTracker';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
type FbqFn = (method: string, eventName: string, params?: object) => void;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fires the client-side Meta Pixel InitiateCheckout event.
|
|
5
|
+
* Call this when a visitor clicks a booking / scheduling button.
|
|
6
|
+
*/
|
|
7
|
+
export function trackInitiateCheckout(): void {
|
|
8
|
+
if (typeof window === 'undefined') return;
|
|
9
|
+
const fbq = (window as Window & { fbq?: FbqFn }).fbq;
|
|
10
|
+
if (fbq) fbq('track', 'InitiateCheckout');
|
|
11
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
type FbqFn = (method: string, eventName: string, params?: object) => void;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fires the client-side Meta Pixel ViewContent event.
|
|
5
|
+
* Call this on service/offer detail pages so Meta knows which content is most engaging.
|
|
6
|
+
*/
|
|
7
|
+
export function trackViewContent(contentName?: string, contentCategory?: string): void {
|
|
8
|
+
if (typeof window === 'undefined') return;
|
|
9
|
+
const fbq = (window as Window & { fbq?: FbqFn }).fbq;
|
|
10
|
+
if (!fbq) return;
|
|
11
|
+
const params: Record<string, string> = {};
|
|
12
|
+
if (contentName) params.content_name = contentName;
|
|
13
|
+
if (contentCategory) params.content_category = contentCategory;
|
|
14
|
+
fbq('track', 'ViewContent', params);
|
|
15
|
+
}
|
package/src/types/api/form.ts
CHANGED