feat: testing contact form features

This commit is contained in:
2026-02-18 21:34:16 -06:00
parent 7d9c0014ed
commit 87f0443b31
30 changed files with 2625 additions and 50 deletions

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Navbar } from "./components/Navbar";
import { Section } from "./components/Section";
import { Hero } from "./components/Hero";
@@ -7,9 +7,10 @@ import { Projects } from "./components/Projects";
import { Resume } from "./components/Resume";
import { Footer } from "./components/Footer";
import { AboutMe } from "./components/AboutMe";
import { Contact } from "./components/Contact";
export default function App() {
const sections = useMemo(() => ["home", "about", "projects", "experience"], []);
const sections = useMemo(() => ["home", "about", "projects", "experience", "contact"], []);
const refs = useRef<Record<string, HTMLElement | null>>({});
const [active, setActive] = useState<string>(sections[0]);
@@ -55,6 +56,8 @@ return (
<Section id="projects"><Projects /></Section>
<GradientBand />
<Section id="experience"><Resume /></Section>
<GradientBand />
<Section id="contact"><Contact /></Section>
</main>

View File

@@ -1,4 +1,3 @@
import React from "react";
import aboutImg from "../assets/img/about-img.png";
export function AboutMe() {

342
src/components/Contact.tsx Normal file
View File

@@ -0,0 +1,342 @@
import { useEffect, useRef, useState, type ChangeEvent, type FormEvent } from "react";
type FormState = {
name: string;
email: string;
message: string;
website: string;
};
type ToastTone = "info" | "success" | "error";
type ToastState = {
id: number;
tone: ToastTone;
message: string;
};
type ContactApiResponse = {
ok: boolean;
message?: string;
error?: string;
};
const TURNSTILE_ACTION = "contact_form";
const HONEYPOT_FIELD = "website";
const API_URL = import.meta.env.VITE_CONTACT_API_URL || "/api/contact";
const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY;
const initialForm: FormState = {
name: "",
email: "",
message: "",
website: "",
};
const validateForm = (form: FormState): string | null => {
const name = form.name.trim();
const email = form.email.trim();
const message = form.message.trim();
if (name.length < 2 || name.length > 80) {
return "Please enter a name between 2 and 80 characters.";
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return "Please enter a valid email address.";
}
if (message.length < 20 || message.length > 2000) {
return "Your message should be between 20 and 2000 characters.";
}
return null;
};
const toastToneClasses: Record<ToastTone, string> = {
info: "border-primary/60",
success: "border-primary/70",
error: "border-contrast/80",
};
export function Contact() {
const [form, setForm] = useState<FormState>(initialForm);
const [token, setToken] = useState("");
const [submitting, setSubmitting] = useState(false);
const [toast, setToast] = useState<ToastState | null>(null);
const [turnstileReady, setTurnstileReady] = useState(false);
const startedAtRef = useRef<number>(Date.now());
const turnstileContainerRef = useRef<HTMLDivElement | null>(null);
const turnstileWidgetIdRef = useRef<string | number | null>(null);
const toastTimerRef = useRef<number | null>(null);
const showToast = (tone: ToastTone, message: string): void => {
setToast({ id: Date.now(), tone, message });
};
const resetTurnstile = (): void => {
setToken("");
if (window.turnstile && turnstileWidgetIdRef.current !== null) {
window.turnstile.reset(turnstileWidgetIdRef.current);
}
};
useEffect(() => {
if (!TURNSTILE_SITE_KEY || !turnstileContainerRef.current) {
return;
}
let cancelled = false;
const renderTurnstile = (): void => {
if (cancelled || !turnstileContainerRef.current || turnstileWidgetIdRef.current !== null) {
return;
}
if (!window.turnstile) {
window.setTimeout(renderTurnstile, 120);
return;
}
const widgetId = window.turnstile.render(turnstileContainerRef.current, {
sitekey: TURNSTILE_SITE_KEY,
action: TURNSTILE_ACTION,
callback: (value: string) => {
setToken(value);
},
"expired-callback": () => {
setToken("");
},
"error-callback": () => {
setToken("");
},
});
turnstileWidgetIdRef.current = widgetId;
setTurnstileReady(true);
};
renderTurnstile();
return () => {
cancelled = true;
if (window.turnstile && turnstileWidgetIdRef.current !== null) {
window.turnstile.remove(turnstileWidgetIdRef.current);
turnstileWidgetIdRef.current = null;
}
};
}, []);
useEffect(() => {
if (!toast) {
return;
}
if (toastTimerRef.current !== null) {
window.clearTimeout(toastTimerRef.current);
}
toastTimerRef.current = window.setTimeout(() => {
setToast(null);
toastTimerRef.current = null;
}, 5000);
return () => {
if (toastTimerRef.current !== null) {
window.clearTimeout(toastTimerRef.current);
}
};
}, [toast]);
const handleChange = (
event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
): void => {
const { name, value } = event.currentTarget;
setForm((previous) => ({ ...previous, [name]: value }));
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
if (submitting) {
return;
}
if (!TURNSTILE_SITE_KEY) {
showToast("error", "Turnstile is not configured yet.");
return;
}
const validationError = validateForm(form);
if (validationError) {
showToast("error", validationError);
return;
}
if (!token) {
showToast("error", "Please complete human verification before submitting.");
return;
}
setSubmitting(true);
showToast("info", "Sending your message...");
const payload = {
name: form.name.trim(),
email: form.email.trim(),
message: form.message.trim(),
[HONEYPOT_FIELD]: form.website,
startedAt: startedAtRef.current,
turnstileToken: token,
};
try {
const response = await fetch(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
let responseBody: ContactApiResponse | null = null;
try {
responseBody = (await response.json()) as ContactApiResponse;
} catch {
responseBody = null;
}
if (!response.ok || !responseBody?.ok) {
throw new Error(responseBody?.error || "Unable to send message right now.");
}
setForm(initialForm);
startedAtRef.current = Date.now();
resetTurnstile();
showToast("success", responseBody.message || "Message sent.");
} catch (error) {
const message = error instanceof Error ? error.message : "Unable to send message right now.";
showToast("error", message);
resetTurnstile();
} finally {
setSubmitting(false);
}
};
return (
<section className="mx-auto max-w-5xl px-4 py-16 md:py-24 anim-fade-in">
<div className="mb-8 text-center">
<h2 className="font-title text-3xl md:text-4xl font-extrabold tracking-wide text-text">
Contact
</h2>
<p className="mt-2 text-text/75 font-main text-sm md:text-base">
Send me a message and I&apos;ll reply from my personal inbox.
</p>
</div>
<div className="mx-auto max-w-3xl rounded-2xl border border-secondary bg-secondary/20 p-5 md:p-8 backdrop-blur">
<form onSubmit={handleSubmit} noValidate className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text/90">Name</span>
<input
type="text"
name="name"
value={form.name}
onChange={handleChange}
maxLength={80}
required
className="w-full rounded-lg border border-secondary bg-bg/70 px-4 py-3 text-text placeholder:text-text/50 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/40 anim-base"
placeholder="Your name"
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text/90">Email</span>
<input
type="email"
name="email"
value={form.email}
onChange={handleChange}
maxLength={320}
required
className="w-full rounded-lg border border-secondary bg-bg/70 px-4 py-3 text-text placeholder:text-text/50 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/40 anim-base"
placeholder="you@domain.com"
/>
</label>
</div>
<label className="block">
<span className="mb-2 block text-sm font-semibold text-text/90">Message</span>
<textarea
name="message"
value={form.message}
onChange={handleChange}
minLength={20}
maxLength={2000}
required
className="min-h-[160px] w-full rounded-lg border border-secondary bg-bg/70 px-4 py-3 text-text placeholder:text-text/50 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/40 anim-base"
placeholder="How can I help?"
/>
</label>
<label className="pointer-events-none absolute -left-[9999px] top-auto h-px w-px overflow-hidden" aria-hidden>
Website
<input
type="text"
name={HONEYPOT_FIELD}
value={form.website}
onChange={handleChange}
tabIndex={-1}
autoComplete="off"
/>
</label>
<div className="space-y-2">
<div ref={turnstileContainerRef} className="min-h-[66px]" />
{!TURNSTILE_SITE_KEY && (
<p className="text-xs text-contrast">Set `VITE_TURNSTILE_SITE_KEY` to enable submissions.</p>
)}
{TURNSTILE_SITE_KEY && !turnstileReady && (
<p className="text-xs text-text/65">Loading human verification...</p>
)}
</div>
<div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-text/65">Protected by Turnstile, rate limits, and server-side validation.</p>
<button
type="submit"
disabled={submitting}
className="rounded-lg border border-primary bg-primary/15 px-5 py-2.5 text-sm font-semibold text-text hover:bg-primary/25 disabled:cursor-not-allowed disabled:opacity-60 anim-base"
>
{submitting ? "Sending..." : "Send Message"}
</button>
</div>
</form>
</div>
{toast && (
<div className="fixed bottom-5 right-5 z-[80] max-w-sm">
<div
className={`rounded-xl border bg-secondary/95 px-4 py-3 text-sm text-text shadow-xl backdrop-blur ${toastToneClasses[toast.tone]} anim-pop-in`}
>
<div className="flex items-start justify-between gap-3">
<p className="font-main leading-snug">{toast.message}</p>
<button
type="button"
onClick={() => setToast(null)}
className="rounded px-2 text-text/70 hover:text-primary anim-base"
aria-label="Dismiss notification"
>
×
</button>
</div>
</div>
</div>
)}
</section>
);
}

View File

@@ -1,16 +1,11 @@
import React from "react";
import githubIcon from "../assets/img/github-icon.png";
import linkedInIcon from "../assets/img/linkedin-icon.png";
import emailIcon from "../assets/img/email-icon.png";
import facebookIcon from "../assets/img/facebook-icon.png";
import phoneIcon from "../assets/img/phone-icon.png";
const defaultSocials = [
{ label: "GitHub", href: "https://github.com/Ricearoni1245", icon: githubIcon },
{ label: "LinkedIn", href: "https://www.linkedin.com/in/jody-holt-9b19b0256", icon: linkedInIcon },
{ label: "Facebook", href: "https://www.facebook.com/jody.holt.7161/", icon: facebookIcon },
{ label: "Email", href: "mailto:jholt1008@gmail.com", icon: emailIcon },
{ label: "Phone", href: "tel:8066542813", icon: phoneIcon },
];
type Social = { label: string; href: string; icon?: string };
@@ -55,6 +50,12 @@ export function Footer({
>
Experience
</button>
<button
className="text-text hover:text-primary anim-base"
onClick={() => document.getElementById("contact")?.scrollIntoView({ behavior: "smooth" })}
>
Contact
</button>
</nav>
<div className="flex items-center gap-4 text-text md:justify-end">
@@ -62,8 +63,8 @@ export function Footer({
<a
key={s.label}
href={s.href}
target={s.href.startsWith("mailto:") || s.href.startsWith("tel:") ? undefined : "_blank"}
rel={s.href.startsWith("mailto:") || s.href.startsWith("tel:") ? undefined : "noopener noreferrer"}
target="_blank"
rel="noopener noreferrer"
aria-label={s.label}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-secondary hover:border-primary hover:text-primary anim-base"
title={s.label}

View File

@@ -1,19 +1,14 @@
import React from "react";
import profileImage from "../assets/img/Jody.png";
import jodyMobile from "../assets/img/Jody-mobile.png";
import { useTheme } from "../hooks/useTheme";
import githubIcon from "../assets/img/github-icon.png";
import linkedInIcon from "../assets/img/linkedin-icon.png";
import emailIcon from "../assets/img/email-icon.png";
import facebookIcon from "../assets/img/facebook-icon.png";
import phoneIcon from "../assets/img/phone-icon.png";
const socialLinks = [
{ label: "GitHub", href: "https://github.com/Ricearoni1245", icon: githubIcon },
{ label: "LinkedIn", href: "https://www.linkedin.com/in/jody-holt-9b19b0256", icon: linkedInIcon },
{ label: "Facebook", href: "https://www.facebook.com/jody.holt.7161/", icon: facebookIcon },
{ label: "Email", href: "mailto:jholt1008@gmail.com", icon: emailIcon },
{ label: "Phone", href: "tel:8066542813", icon: phoneIcon },
];
export function Hero() {
const { theme } = useTheme(); // "a" | "b" | "c" | "d" | "e"
@@ -57,8 +52,8 @@ export function Hero() {
<a
key={a.label}
href={a.href}
target={a.href.startsWith("mailto:") || a.href.startsWith("tel:") ? undefined : "_blank"}
rel={a.href.startsWith("mailto:") || a.href.startsWith("tel:") ? undefined : "noopener noreferrer"}
target="_blank"
rel="noopener noreferrer"
aria-label={a.label}
className="inline-flex h-12 w-12 items-center justify-center rounded-lg
border border-secondary/70 bg-secondary/20 text-text anim-base icon-hover
@@ -127,8 +122,8 @@ export function Hero() {
<a
key={a.label}
href={a.href}
target={a.href.startsWith("mailto:") || a.href.startsWith("tel:") ? undefined : "_blank"}
rel={a.href.startsWith("mailto:") || a.href.startsWith("tel:") ? undefined : "noopener noreferrer"}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center rounded-xl border
border-secondary/70 bg-secondary/20 text-text transition h-10 w-10
sm:h-12 sm:w-12 md:h-14 md:w-14 lg:h-16 lg:w-16 hover:border-primary

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react";
import { useState } from "react";
import { ThemeToggle } from "./ThemeToggle";
export function Navbar({ onNav }: { onNav: (id: string) => void }) {
@@ -8,6 +8,7 @@ export function Navbar({ onNav }: { onNav: (id: string) => void }) {
{ id: "about", label: "About" },
{ id: "projects", label: "Projects" },
{ id: "experience", label: "Resume" },
{ id: "contact", label: "Contact" },
];
const handleNav = (id: string) => {

View File

@@ -1,5 +1,4 @@
// =====================================
import React from "react";
export function Placeholder({ title }: { title: string }) {
@@ -15,4 +14,4 @@ Card {i + 1}
</div>
</div>
);
}
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import { useState, useEffect } from "react";
import skymoneycover from "../assets/img/Skymoney-cover-img.jpg";
import skymoneycoverMobile from "../assets/img/skymoney-mobile-cover-img.jpg";
import millercover from "../assets/img/500nmain-cover-img.jpg";

View File

@@ -1,4 +1,3 @@
import React from "react";
import { resumeData } from "../data/resume";
export function Resume() {
@@ -10,14 +9,6 @@ export function Resume() {
<h2 className="text-4xl md:text-5xl font-extrabold font-title text-text mb-3">Resume</h2>
<div className="flex flex-wrap justify-center gap-3 text-sm text-text/70">
<span>{contactInfo.location}</span>
<span className="hidden sm:inline text-primary"></span>
<a href={`tel:${contactInfo.phone.replace(/\./g, "")}`} className="hover:text-primary anim-base">
{contactInfo.phone}
</a>
<span className="hidden sm:inline text-primary"></span>
<a href={`mailto:${contactInfo.email}`} className="hover:text-primary anim-base">
{contactInfo.email}
</a>
{contactInfo.website && (
<>
<span className="hidden sm:inline text-primary"></span>

View File

@@ -1,10 +1,10 @@
import React from "react";
import type { PropsWithChildren } from "react";
export function Section({ id, children }: React.PropsWithChildren<{ id: string }>) {
export function Section({ id, children }: PropsWithChildren<{ id: string }>) {
return (
<section id={id} className="scroll-mt-24">
{children}
</section>
);
}
}

View File

@@ -1,5 +1,5 @@
// ThemeToggle.tsx
import React, { useState } from "react";
import { useState } from "react";
import { useTheme } from "../hooks/useTheme";
// Actual primary colors for each theme
@@ -27,7 +27,7 @@ export function ThemeToggle({ compact = false }: { compact?: boolean }) {
root.classList.add("hero-xfade");
// 3) switch theme (your existing logic)
setTheme(next as any);
setTheme(next);
setOpen(false);
// 4) remove crossfade flag after the animation

View File

@@ -2,8 +2,6 @@ export type ResumeData = {
contactInfo: {
name?: string;
location: string;
phone: string;
email: string;
website?: string;
linkedin?: string;
};
@@ -20,8 +18,6 @@ export const resumeData: ResumeData = {
contactInfo: {
name: "Jody Holt",
location: "Amarillo, TX",
phone: "806.654.2813",
email: "jholt1008@gmail.com",
website: "https://www.jodyholt.com",
linkedin: "https://www.linkedin.com/in/jody-holt-cis",
},

30
src/types/turnstile.d.ts vendored Normal file
View File

@@ -0,0 +1,30 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_CONTACT_API_URL?: string;
readonly VITE_TURNSTILE_SITE_KEY?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
type TurnstileWidgetId = string | number;
type TurnstileRenderOptions = {
sitekey: string;
action?: string;
callback?: (token: string) => void;
"expired-callback"?: () => void;
"error-callback"?: () => void;
};
type TurnstileApi = {
render: (container: string | HTMLElement, options: TurnstileRenderOptions) => TurnstileWidgetId;
reset: (widgetId?: TurnstileWidgetId) => void;
remove: (widgetId: TurnstileWidgetId) => void;
};
interface Window {
turnstile?: TurnstileApi;
}