keystone-design-bootstrap 1.0.51 → 1.0.53
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 +3 -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
|
);
|
|
@@ -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
|
|
|
@@ -162,6 +164,7 @@ export async function KeystoneRootLayout(props: {
|
|
|
162
164
|
<FormDefinitionsProvider
|
|
163
165
|
leadFormDefinition={leadFormDefinition ?? null}
|
|
164
166
|
jobApplicationFormDefinition={jobApplicationFormDefinition ?? null}
|
|
167
|
+
marketingListSignupFormDefinition={marketingListSignupFormDefinition ?? null}
|
|
165
168
|
>
|
|
166
169
|
<HeaderNavigation
|
|
167
170
|
config={dynamicConfig}
|
package/src/types/api/form.ts
CHANGED