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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keystone-design-bootstrap",
3
- "version": "1.0.51",
3
+ "version": "1.0.54",
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,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
 
@@ -51,6 +51,8 @@ export function HeaderNavigation({
51
51
  cancelCloseTimeout();
52
52
  if (item.children && item.children.length > 0) {
53
53
  setActiveDropdown(item.label);
54
+ } else {
55
+ setActiveDropdown(null);
54
56
  }
55
57
  }, [cancelCloseTimeout]);
56
58
 
@@ -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
+ }
@@ -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
+ }
@@ -28,4 +28,4 @@ export interface FormDefinition {
28
28
  updated_at?: string;
29
29
  }
30
30
 
31
- export type FormType = 'lead' | 'job_application';
31
+ export type FormType = 'lead' | 'job_application' | 'marketing_list_signup';