Skip to content

Commit 0f45003

Browse files
committed
feat: integrate reCAPTCHA v3 with contact form
- Add react-google-recaptcha-v3 package and provider setup - Implement invisible reCAPTCHA protection for Formspree contact form - Fix async form submission with ref-based approach - Update tests and GitHub Actions for production deployment - Add environment variable configuration for reCAPTCHA site key
1 parent 10e5d7a commit 0f45003

11 files changed

Lines changed: 627 additions & 128 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ jobs:
1212
runs-on: ubuntu-latest
1313
env:
1414
NEXT_PUBLIC_BASE_PATH:
15+
NEXT_PUBLIC_RECAPTCHA_SITE_KEY: ${{ secrets.RECAPTCHA_SITE_KEY }}
1516
steps:
1617
- name: Checkout
1718
uses: actions/checkout@v4

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"test:coverage": "jest --coverage"
1515
},
1616
"dependencies": {
17+
"@formspree/react": "^3.0.0",
1718
"@radix-ui/react-slot": "^1.2.3",
1819
"class-variance-authority": "^0.7.1",
1920
"clsx": "^2.1.1",
@@ -24,6 +25,7 @@
2425
"next": "14.1.0",
2526
"react": "^18",
2627
"react-dom": "^18",
28+
"react-google-recaptcha-v3": "^1.11.0",
2729
"tailwind-merge": "^3.3.0"
2830
},
2931
"devDependencies": {

src/app/layout.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { JetBrains_Mono, Inter } from "next/font/google";
33
import "./globals.css";
44
import { ThemeProvider } from '@/lib/theme'
55
import { ProgressBar } from '@/components/ui';
6+
import { RecaptchaProvider } from '@/components/providers/RecaptchaProvider';
67

78
const jetbrainsMono = JetBrains_Mono({
89
variable: "--font-jetbrains-mono",
@@ -72,9 +73,11 @@ export default function RootLayout({
7273
suppressHydrationWarning={true}
7374
>
7475
<ProgressBar />
75-
<ThemeProvider>
76-
{children}
77-
</ThemeProvider>
76+
<RecaptchaProvider>
77+
<ThemeProvider>
78+
{children}
79+
</ThemeProvider>
80+
</RecaptchaProvider>
7881
</body>
7982
</html>
8083
);
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"use client";
2+
3+
import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3';
4+
5+
interface RecaptchaProviderProps {
6+
children: React.ReactNode;
7+
}
8+
9+
export function RecaptchaProvider({ children }: RecaptchaProviderProps) {
10+
const recaptchaSiteKey = process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY;
11+
12+
if (!recaptchaSiteKey) {
13+
console.warn('reCAPTCHA site key not found. reCAPTCHA will be disabled.');
14+
return <>{children}</>;
15+
}
16+
17+
return (
18+
<GoogleReCaptchaProvider
19+
reCaptchaKey={recaptchaSiteKey}
20+
scriptProps={{
21+
async: false,
22+
defer: false,
23+
appendTo: "head",
24+
nonce: undefined,
25+
}}
26+
>
27+
{children}
28+
</GoogleReCaptchaProvider>
29+
);
30+
}

src/components/sections/ContactSection.tsx

Lines changed: 10 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
"use client";
22

3-
import { useState } from "react";
43
import { motion } from "framer-motion";
5-
import { Heading, Text, Badge } from "@/components/ui";
4+
import { Heading, Text, Badge, ContactForm } from "@/components/ui";
65
import { slideUpVariants, fadeVariants, staggerVariants, sectionVariants } from "@/lib";
76

87
export interface ContactSectionProps {
@@ -18,6 +17,8 @@ export interface ContactSectionProps {
1817
contactInfoTitle?: string;
1918
formTitle?: string;
2019
formDescription?: string;
20+
formspreeId?: string;
21+
successMessage?: string;
2122
}
2223

2324
export function ContactSection({
@@ -33,15 +34,9 @@ export function ContactSection({
3334
contactInfoTitle,
3435
formTitle,
3536
formDescription,
37+
formspreeId,
38+
successMessage,
3639
}: ContactSectionProps = {}) {
37-
const [showMessage, setShowMessage] = useState(false);
38-
39-
const handleSubmit = (e: React.FormEvent) => {
40-
e.preventDefault();
41-
setShowMessage(true);
42-
setTimeout(() => setShowMessage(false), 5000);
43-
};
44-
4540
return (
4641
<motion.section
4742
id="contact"
@@ -212,92 +207,11 @@ export function ContactSection({
212207
</motion.div>
213208
</motion.div>
214209

215-
<motion.div
216-
data-testid="contact-form"
217-
className="bg-white dark:bg-[#21262d] border border-[#d0d7de] dark:border-[#30363d] rounded-lg p-6 shadow-sm"
218-
variants={staggerVariants}
219-
initial="hidden"
220-
animate="visible"
221-
>
222-
<div className="space-y-6">
223-
<Heading as="h3" size="h3" className="font-mono font-medium text-text">
224-
{formTitle}
225-
</Heading>
226-
227-
{showMessage && (
228-
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-md">
229-
<Text size="sm" className="font-sans text-blue-700 dark:text-blue-400">
230-
{formDescription}
231-
</Text>
232-
</div>
233-
)}
234-
235-
<motion.div
236-
variants={staggerVariants}
237-
transition={{ staggerChildren: 0.1, delayChildren: 0.2 }}
238-
>
239-
<form
240-
onSubmit={handleSubmit}
241-
className="space-y-4"
242-
>
243-
<motion.div variants={slideUpVariants}>
244-
<label htmlFor="name" className="block font-sans text-sm font-medium mb-2 text-text">
245-
Name
246-
</label>
247-
<input
248-
type="text"
249-
id="name"
250-
name="name"
251-
data-testid="name-input"
252-
required
253-
className="w-full px-3 py-2 rounded-md font-sans bg-white dark:bg-[#0d1117] border border-[#d0d7de] dark:border-[#30363d] focus:outline-none focus:border-[#0969da] dark:focus:border-[#58a6ff] text-text placeholder:text-[#656d76] dark:placeholder:text-[#8b949e]"
254-
placeholder="Your name"
255-
/>
256-
</motion.div>
257-
258-
<motion.div variants={slideUpVariants}>
259-
<label htmlFor="email" className="block font-sans text-sm font-medium mb-2 text-text">
260-
Email
261-
</label>
262-
<input
263-
type="email"
264-
id="email"
265-
name="email"
266-
data-testid="email-input"
267-
required
268-
className="w-full px-3 py-2 rounded-md font-sans bg-white dark:bg-[#0d1117] border border-[#d0d7de] dark:border-[#30363d] focus:outline-none focus:border-[#0969da] dark:focus:border-[#58a6ff] text-text placeholder:text-[#656d76] dark:placeholder:text-[#8b949e]"
269-
placeholder="your.email@example.com"
270-
/>
271-
</motion.div>
272-
273-
<motion.div variants={slideUpVariants}>
274-
<label htmlFor="message" className="block font-sans text-sm font-medium mb-2 text-text">
275-
Message
276-
</label>
277-
<textarea
278-
id="message"
279-
name="message"
280-
data-testid="message-textarea"
281-
required
282-
rows={5}
283-
className="w-full px-3 py-2 rounded-md font-sans bg-white dark:bg-[#0d1117] border border-[#d0d7de] dark:border-[#30363d] focus:outline-none focus:border-[#0969da] dark:focus:border-[#58a6ff] text-text placeholder:text-[#656d76] dark:placeholder:text-[#8b949e] min-h-[120px] resize-vertical"
284-
placeholder="Tell me about your project..."
285-
/>
286-
</motion.div>
287-
288-
<motion.div variants={slideUpVariants}>
289-
<button
290-
type="submit"
291-
data-testid="submit-button"
292-
className="w-full bg-[#0969da] dark:bg-[#58a6ff] hover:bg-[#0550ae] dark:hover:bg-[#4493f8] text-white dark:text-[#0d1117] px-4 py-2 rounded-md font-medium font-sans transition-colors focus:outline-none focus:ring-2 focus:ring-[#0969da] dark:focus:ring-[#58a6ff] focus:ring-offset-2"
293-
>
294-
Send Message
295-
</button>
296-
</motion.div>
297-
</form>
298-
</motion.div>
299-
</div>
300-
</motion.div>
210+
<ContactForm
211+
formTitle={formTitle}
212+
formspreeId={formspreeId}
213+
successMessage={successMessage}
214+
/>
301215
</div>
302216
</div>
303217
</motion.section>

src/components/sections/__tests__/ContactSection.test.tsx

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,20 @@ jest.mock("@/components/ui", () => ({
66
Heading: ({ children, ...props }: any) => <h2 {...props}>{children}</h2>,
77
Text: ({ children, ...props }: any) => <span {...props}>{children}</span>,
88
Badge: ({ children, ...props }: any) => <div {...props}>{children}</div>,
9+
ContactForm: ({ formTitle, ...props }: any) => (
10+
<div data-testid="contact-form" {...props}>
11+
<h3>{formTitle}</h3>
12+
<form>
13+
<label htmlFor="name">Name</label>
14+
<input data-testid="name-input" id="name" name="name" required />
15+
<label htmlFor="email">Email Address</label>
16+
<input data-testid="email-input" id="email" name="email" type="email" required />
17+
<label htmlFor="message">Message</label>
18+
<textarea data-testid="message-textarea" id="message" name="message" required />
19+
<button data-testid="submit-button" type="submit">Send Message</button>
20+
</form>
21+
</div>
22+
),
923
}));
1024

1125
jest.mock("framer-motion", () => ({
@@ -92,44 +106,31 @@ describe("ContactSection", () => {
92106
expect(screen.getByTestId("submit-button")).toHaveTextContent("Send Message");
93107
});
94108

95-
it("displays form submission message when form is submitted", async () => {
109+
it("renders contact form with proper structure", () => {
96110
render(<ContactSection {...defaultProps} />);
97111

98112
const nameInput = screen.getByTestId("name-input");
99113
const emailInput = screen.getByTestId("email-input");
100114
const messageTextarea = screen.getByTestId("message-textarea");
101115
const form = screen.getByTestId("contact-form").querySelector("form");
102116

103-
fireEvent.change(nameInput, { target: { value: "Test User" } });
104-
fireEvent.change(emailInput, { target: { value: "test@example.com" } });
105-
fireEvent.change(messageTextarea, { target: { value: "Test message" } });
106-
117+
expect(nameInput).toBeInTheDocument();
118+
expect(emailInput).toBeInTheDocument();
119+
expect(messageTextarea).toBeInTheDocument();
107120
expect(form).toBeInTheDocument();
108-
fireEvent.submit(form!);
109-
110-
await waitFor(() => {
111-
expect(screen.getByText("Feature coming soon! This form will be available when the site goes live.")).toBeInTheDocument();
112-
});
113121
});
114122

115-
it("message disappears after timeout", async () => {
116-
jest.useFakeTimers();
117-
render(<ContactSection {...defaultProps} />);
118-
119-
const form = screen.getByTestId("contact-form").querySelector("form");
120-
fireEvent.submit(form!);
121-
122-
await waitFor(() => {
123-
expect(screen.getByText("Feature coming soon! This form will be available when the site goes live.")).toBeInTheDocument();
124-
});
125-
126-
jest.advanceTimersByTime(5000);
123+
it("renders with custom form title", () => {
124+
const customFormTitle = "Custom Form Title";
125+
render(<ContactSection {...defaultProps} formTitle={customFormTitle} />);
127126

128-
await waitFor(() => {
129-
expect(screen.queryByText("Feature coming soon! This form will be available when the site goes live.")).not.toBeInTheDocument();
130-
});
127+
const contactForm = screen.getByTestId("contact-form");
128+
expect(contactForm).toBeInTheDocument();
131129

132-
jest.useRealTimers();
130+
// Check that the custom form title is rendered in the form
131+
const formHeading = contactForm.querySelector("h3");
132+
expect(formHeading).toBeInTheDocument();
133+
expect(formHeading).toHaveTextContent(customFormTitle);
133134
});
134135

135136
it("uses custom section titles when provided", () => {

0 commit comments

Comments
 (0)