From 9088a7cd324933d24ee0eb158df0842eb7e647db Mon Sep 17 00:00:00 2001 From: denshooter <44590296+denshooter@users.noreply.github.com> Date: Sat, 22 Feb 2025 22:29:23 +0100 Subject: [PATCH] Dev (#44) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🚀 refactor: simplify deployment process in workflow file * 🚀 chore: add IMAGE_NAME to GITHUB_ENV for deployment workflow * ✨ chore: simplify deployment logging in workflow file * 🚀 fix: correct container name in deployment script logic * 🚀 refactor: rename job and streamline deployment steps * Update README.md * ✨ fix: prevent multiple form submissions in Contact component * ✨ feat: honeypot and timestamp checks to form submission * ✨ refactor: simplify contact form and improve UI elements --- Dockerfile | 3 - app/__tests__/components/Contact.test.tsx | 32 ++-- app/components/Contact.tsx | 178 +++++++++++++++++----- 3 files changed, 163 insertions(+), 50 deletions(-) diff --git a/Dockerfile b/Dockerfile index 24ba3e3..8ffb440 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,9 +19,6 @@ COPY .env .env # Build the Next.js application RUN npm run build -# Set environmental variable for production mode -ENV NODE_ENV=production - # Expose the port the app runs on EXPOSE 3000 diff --git a/app/__tests__/components/Contact.test.tsx b/app/__tests__/components/Contact.test.tsx index a853957..5c13912 100644 --- a/app/__tests__/components/Contact.test.tsx +++ b/app/__tests__/components/Contact.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import Contact from '@/app/components/Contact'; import '@testing-library/jest-dom'; @@ -10,20 +10,34 @@ global.fetch = jest.fn(() => ) as jest.Mock; describe('Contact', () => { + beforeAll(() => { + jest.useFakeTimers('modern'); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + it('renders the contact form', () => { render(); - expect(screen.getByPlaceholderText('Name')).toBeInTheDocument(); - expect(screen.getByPlaceholderText('Email')).toBeInTheDocument(); - expect(screen.getByPlaceholderText('Message')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Your Name')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('you@example.com')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Your Message...')).toBeInTheDocument(); + expect(screen.getByLabelText('I accept the privacy policy.')).toBeInTheDocument(); }); it('submits the form', async () => { render(); - fireEvent.change(screen.getByPlaceholderText('Name'), { target: { value: 'John Doe' } }); - fireEvent.change(screen.getByPlaceholderText('Email'), { target: { value: 'john@example.com' } }); - fireEvent.change(screen.getByPlaceholderText('Message'), { target: { value: 'Hello!' } }); - fireEvent.click(screen.getByText('Send')); - expect(await screen.findByText('Email sent')).toBeInTheDocument(); + // Fast forward time to ensure the timestamp check passes + jest.advanceTimersByTime(3000); + + fireEvent.change(screen.getByPlaceholderText('Your Name'), { target: { value: 'John Doe' } }); + fireEvent.change(screen.getByPlaceholderText('you@example.com'), { target: { value: 'john@example.com' } }); + fireEvent.change(screen.getByPlaceholderText('Your Message...'), { target: { value: 'Hello!' } }); + fireEvent.click(screen.getByLabelText('I accept the privacy policy.')); + fireEvent.click(screen.getByText('Send Message')); + + await waitFor(() => expect(screen.getByText('Email sent')).toBeInTheDocument()); }); }); \ No newline at end of file diff --git a/app/components/Contact.tsx b/app/components/Contact.tsx index 3898dc8..b7a7109 100644 --- a/app/components/Contact.tsx +++ b/app/components/Contact.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useState } from "react"; import { sendEmail } from "@/app/utils/send-email"; +import Link from "next/link"; export type ContactFormData = { name: string; @@ -18,11 +19,14 @@ export default function Contact() { message: "", type: "success", }); + // Record the time when the form is rendered + const [formLoadedTimestamp, setFormLoadedTimestamp] = useState(Date.now()); useEffect(() => { + setFormLoadedTimestamp(Date.now()); setTimeout(() => { setIsVisible(true); - }, 350); // Delay to start the animation after Projects + }, 350); }, []); async function onSubmit(e: React.FormEvent) { @@ -31,72 +35,170 @@ export default function Contact() { const form = e.currentTarget as HTMLFormElement; const formData = new FormData(form); + // Honeypot check + const honeypot = formData.get("hp-field"); + if (honeypot) { + setBanner({ + show: true, + message: "Bot detected", + type: "error", + }); + setTimeout(() => setBanner((prev) => ({ ...prev, show: false })), 3000); + return; + } + + // Time-based anti-bot check + const timestampStr = formData.get("timestamp") as string; + const timestamp = parseInt(timestampStr, 10); + if (Date.now() - timestamp < 3000) { + setBanner({ + show: true, + message: "Please take your time filling out the form.", + type: "error", + }); + setTimeout(() => setBanner((prev) => ({ ...prev, show: false })), 3000); + return; + } + const data: ContactFormData = { name: formData.get("name") as string, email: formData.get("email") as string, message: formData.get("message") as string, }; - // Convert FormData to a plain object const jsonData = JSON.stringify(data); - const response = await sendEmail(jsonData); - if (response.success) { - form.reset(); - } + const submitButton = form.querySelector("button[type='submit']"); + if (submitButton) { + submitButton.setAttribute("disabled", "true"); + submitButton.textContent = "Sending..."; - setBanner({ - show: true, - message: response.message, - type: response.success ? "success" : "error", - }); - setTimeout(() => { - setBanner((prev) => ({ ...prev, show: false })); - }, 3000); + const response = await sendEmail(jsonData); + if (response.success) { + form.reset(); + submitButton.textContent = "Sent!"; + setTimeout(() => { + submitButton.removeAttribute("disabled"); + submitButton.textContent = "Send Message"; + }, 2000); + } + setBanner({ + show: true, + message: response.message, + type: response.success ? "success" : "error", + }); + setTimeout(() => setBanner((prev) => ({ ...prev, show: false })), 3000); + } } + return (
-

- Contact Me +

+ Get in Touch

-
+
{banner.show && (
{banner.message}
)} -
+ + {/* Honeypot field */} + {/* Hidden timestamp field */} - + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+