Skip to content

Commit 8add330

Browse files
Add Engagement Modal with custom animations and accessibility features (#780)
- Implemented EngagementModal component with props for headline, body, and call-to-action buttons. - Added custom CSS animations for emoji wiggle effect on button hover. - Integrated Framer Motion for smooth modal transitions. - Included accessibility features such as focus trapping and ESC key close functionality. - Added session storage logic to prevent multiple modal displays in a session. - Provided a development-only button to test modal functionality.
1 parent e4c58e6 commit 8add330

File tree

12 files changed

+9997
-42
lines changed

12 files changed

+9997
-42
lines changed

.tailwind-debug.css

Lines changed: 9388 additions & 0 deletions
Large diffs are not rendered by default.

package-lock.json

Lines changed: 40 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"dotenv": "^16.3.1",
3939
"express": "^4.18.2",
4040
"fast-equals": "3.0.3",
41+
"framer-motion": "^12.18.1",
4142
"gray-matter": "^4.0.3",
4243
"lucide-react": "^0.378.0",
4344
"mailchimp-api-v3": "^1.15.0",

src/assets/css/engagement-modal.css

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/* Custom animations */
2+
@keyframes wiggle {
3+
0% {
4+
transform: rotate(0deg);
5+
}
6+
25% {
7+
transform: rotate(10deg);
8+
}
9+
50% {
10+
transform: rotate(0deg);
11+
}
12+
75% {
13+
transform: rotate(-10deg);
14+
}
15+
100% {
16+
transform: rotate(0deg);
17+
}
18+
}
19+
20+
.emoji-wiggle {
21+
display: inline-block;
22+
opacity: 0;
23+
margin-left: 0.5rem;
24+
vertical-align: middle;
25+
transition: opacity 0.3s ease;
26+
}
27+
28+
.cta-button:hover .emoji-wiggle {
29+
opacity: 1;
30+
animation: wiggle 0.5s ease-in-out infinite;
31+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/* Custom animations */
2+
@keyframes wiggle {
3+
0% {
4+
transform: rotate(0deg);
5+
}
6+
25% {
7+
transform: rotate(10deg);
8+
}
9+
50% {
10+
transform: rotate(0deg);
11+
}
12+
75% {
13+
transform: rotate(-10deg);
14+
}
15+
100% {
16+
transform: rotate(0deg);
17+
}
18+
}
19+
20+
.emoji {
21+
display: inline-block;
22+
opacity: 0;
23+
margin-left: 0.5rem;
24+
vertical-align: middle;
25+
transition: opacity 0.3s ease;
26+
}
27+
28+
.button:hover .emoji {
29+
opacity: 1;
30+
animation: wiggle 0.5s ease-in-out infinite;
31+
}

src/components/EngagementModal.tsx

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import React, { useEffect, useRef, useState } from "react";
2+
import { motion, AnimatePresence } from "framer-motion";
3+
import Link from "next/link";
4+
import styles from "./EngagementModal.module.css";
5+
6+
interface EngagementModalProps {
7+
headline: string;
8+
body: string;
9+
cta1: { label: string; href: string };
10+
cta2: { label: string; href: string };
11+
forceShow?: boolean;
12+
}
13+
14+
interface CustomWindow extends Window {
15+
openEngagementModal?: () => void;
16+
}
17+
18+
declare let window: CustomWindow;
19+
20+
const MODAL_FLAG = "vwc_engagement_modal_shown";
21+
22+
export const EngagementModal: React.FC<EngagementModalProps> = ({
23+
headline,
24+
body,
25+
cta1,
26+
cta2,
27+
forceShow,
28+
}) => {
29+
const [open, setOpen] = useState(false);
30+
const modalRef = useRef<HTMLDivElement>(null);
31+
// Add a ref to track if modal has been shown this session
32+
const hasInitialized = useRef(false);
33+
34+
// Expose method to manually open modal for debugging
35+
// This can be called from browser console: window.openEngagementModal()
36+
// Comment out or remove before pushing to production
37+
useEffect(() => {
38+
return () => {
39+
if (typeof window !== "undefined") {
40+
delete window.openEngagementModal;
41+
}
42+
};
43+
}, []);
44+
45+
useEffect(() => {
46+
if (typeof window === "undefined" || hasInitialized.current) return;
47+
48+
// Mark that we've initialized
49+
hasInitialized.current = true;
50+
51+
if (forceShow) {
52+
const timer = setTimeout(() => setOpen(true), 3000);
53+
return () => clearTimeout(timer);
54+
}
55+
56+
if (sessionStorage.getItem(MODAL_FLAG)) return;
57+
58+
const timer = setTimeout(() => {
59+
setOpen(true);
60+
sessionStorage.setItem(MODAL_FLAG, "1");
61+
}, 3000);
62+
63+
return () => clearTimeout(timer);
64+
}, [forceShow]);
65+
66+
// Accessibility: close on ESC
67+
useEffect(() => {
68+
if (!open) return;
69+
const onKeyDown = (e: KeyboardEvent) => {
70+
if (e.key === "Escape") setOpen(false);
71+
};
72+
window.addEventListener("keydown", onKeyDown);
73+
return () => window.removeEventListener("keydown", onKeyDown);
74+
}, [open]);
75+
76+
// Accessibility: focus trap
77+
useEffect(() => {
78+
if (!open || !modalRef.current) return;
79+
const focusable = modalRef.current.querySelectorAll<HTMLElement>(
80+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
81+
);
82+
if (focusable.length) focusable[0].focus();
83+
}, [open]);
84+
85+
// Dismiss on click outside
86+
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
87+
if (e.target === e.currentTarget) setOpen(false);
88+
};
89+
90+
return (
91+
<>
92+
{/* Development-only testing button - Remove or comment out before pushing to production */}
93+
{false && process.env.NODE_ENV === "development" && (
94+
<div className={styles.testModalButtonContainer}>
95+
<button
96+
type="button"
97+
className="tw-rounded tw-bg-secondary tw-px-4 tw-py-2 tw-text-white tw-shadow-lg"
98+
onClick={() => setOpen(true)}
99+
>
100+
Test Modal
101+
</button>
102+
</div>
103+
)}
104+
105+
<AnimatePresence>
106+
{open && (
107+
<motion.div
108+
className="tw-fixed tw-inset-0 tw-z-50 tw-flex tw-items-center tw-justify-center tw-bg-black/50 tw-p-2 sm:tw-p-6"
109+
initial={{ opacity: 0 }}
110+
animate={{ opacity: 1 }}
111+
exit={{ opacity: 0 }}
112+
onClick={handleBackdropClick}
113+
aria-modal="true"
114+
role="dialog"
115+
>
116+
<motion.div
117+
ref={modalRef}
118+
className="tw-relative tw-flex tw-max-h-[95vh] tw-min-h-[520px] tw-w-full tw-max-w-2xl tw-flex-col tw-items-center tw-gap-6 tw-overflow-y-auto tw-rounded-2xl tw-border-4 tw-border-secondary tw-bg-white tw-p-6 tw-shadow-2xl tw-outline-none sm:tw-min-h-[600px] sm:tw-max-w-3xl sm:tw-p-16"
119+
initial={{ scale: 0.95, opacity: 0 }}
120+
animate={{ scale: 1, opacity: 1 }}
121+
exit={{ scale: 0.95, opacity: 0 }}
122+
tabIndex={-1}
123+
>
124+
<button
125+
type="button"
126+
aria-label="Close"
127+
className="tw-absolute tw-right-6 tw-top-6 tw-text-4xl tw-text-secondary hover:tw-text-primary focus:tw-outline-none"
128+
onClick={() => setOpen(false)}
129+
>
130+
&times;
131+
</button>
132+
<h2 className="tw-mb-2 tw-mt-4 tw-text-center tw-text-4xl tw-font-extrabold tw-text-primary sm:tw-text-5xl">
133+
{headline}
134+
</h2>
135+
<div className="tw-mb-2 tw-flex tw-justify-center">
136+
<Link
137+
href="/"
138+
className="tw-inline-block tw-h-48 tw-w-auto sm:tw-h-64"
139+
>
140+
<img
141+
src="https://res.cloudinary.com/vetswhocode/image/upload/v1627489569/flag_ohssvk.gif"
142+
alt="Animated Flag Logo"
143+
className="tw-h-full tw-w-auto"
144+
/>
145+
</Link>
146+
</div>
147+
<p className="tw-mb-4 tw-text-center tw-text-xl tw-text-secondary sm:tw-text-2xl">
148+
{body}
149+
</p>
150+
<div className="tw-mt-4 tw-flex tw-w-full tw-flex-col tw-gap-4 sm:tw-flex-row">
151+
<a
152+
href={cta1.href}
153+
className={`${styles.button} tw-group tw-w-full tw-rounded tw-bg-primary tw-px-8 tw-py-4 tw-text-center tw-text-lg tw-font-semibold tw-text-white tw-transition hover:tw-bg-secondary hover:tw-text-white focus:tw-ring-2 focus:tw-ring-primary`}
154+
>
155+
<span className="tw-group-hover:tw-mr-2 tw-inline-block tw-transition-all">
156+
{cta1.label}
157+
</span>
158+
<span className={`${styles.emoji}`} aria-hidden="true">
159+
💖
160+
</span>
161+
</a>
162+
{cta2.href.startsWith("#") ? (
163+
<Link
164+
href={cta2.href}
165+
className={`${styles.button} tw-group tw-w-full tw-rounded tw-bg-secondary tw-px-8 tw-py-4 tw-text-center tw-text-lg tw-font-semibold tw-text-white tw-transition hover:tw-bg-primary hover:tw-text-white focus:tw-ring-2 focus:tw-ring-secondary`}
166+
passHref
167+
onClick={(e) => {
168+
e.preventDefault();
169+
setOpen(false);
170+
const targetId = cta2.href.substring(1);
171+
const targetElement = document.getElementById(targetId);
172+
if (targetElement) {
173+
setTimeout(() => {
174+
targetElement.scrollIntoView({
175+
behavior: "smooth",
176+
block: "start",
177+
});
178+
}, 300);
179+
}
180+
}}
181+
>
182+
<span className="tw-group-hover:tw-mr-2 tw-inline-block tw-transition-all">
183+
{cta2.label}
184+
</span>
185+
<span className={`${styles.emoji}`} aria-hidden="true">
186+
🚀
187+
</span>
188+
</Link>
189+
) : (
190+
<a
191+
href={cta2.href}
192+
className={`${styles.button} tw-group tw-w-full tw-rounded tw-bg-secondary tw-px-8 tw-py-4 tw-text-center tw-text-lg tw-font-semibold tw-text-white tw-transition hover:tw-bg-primary hover:tw-text-white focus:tw-ring-2 focus:tw-ring-secondary`}
193+
>
194+
<span className="tw-group-hover:tw-mr-2 tw-inline-block tw-transition-all">
195+
{cta2.label}
196+
</span>
197+
<span className={`${styles.emoji}`} aria-hidden="true">
198+
🚀
199+
</span>
200+
</a>
201+
)}
202+
</div>
203+
</motion.div>
204+
</motion.div>
205+
)}
206+
</AnimatePresence>
207+
</>
208+
);
209+
};
210+
211+
export default EngagementModal;

0 commit comments

Comments
 (0)