Compare commits
2 Commits
7d9c0014ed
...
228240ec2d
| Author | SHA1 | Date | |
|---|---|---|---|
| 228240ec2d | |||
| 87f0443b31 |
@@ -11,15 +11,41 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install
|
||||
- name: Install Frontend Dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build with Vite
|
||||
|
||||
run: npx vite build
|
||||
- name: Build Frontend
|
||||
run: npm run build
|
||||
|
||||
- name: Sync Files
|
||||
- name: Install Contact API Dependencies
|
||||
run: npm ci --prefix contact-api
|
||||
|
||||
- name: Build Contact API
|
||||
run: npm run build --prefix contact-api
|
||||
|
||||
- name: Sync Frontend Files
|
||||
run: |
|
||||
mkdir -p /var/www/jody/dist
|
||||
rm -rf /var/www/jody/dist/*
|
||||
cp -r dist/* /var/www/jody/dist/
|
||||
cp -r dist/* /var/www/jody/dist/
|
||||
|
||||
- name: Sync Contact API Runtime Files
|
||||
run: |
|
||||
mkdir -p /var/www/jody/contact-api
|
||||
rm -rf /var/www/jody/contact-api/dist
|
||||
cp -r contact-api/dist /var/www/jody/contact-api/
|
||||
cp contact-api/package.json /var/www/jody/contact-api/
|
||||
cp contact-api/package-lock.json /var/www/jody/contact-api/
|
||||
|
||||
- name: Install Contact API Production Dependencies
|
||||
run: |
|
||||
cd /var/www/jody/contact-api
|
||||
npm ci --omit=dev
|
||||
|
||||
- name: Restart Contact API
|
||||
run: |
|
||||
systemctl restart jody-contact-api
|
||||
systemctl is-active --quiet jody-contact-api
|
||||
|
||||
- name: Health Check Contact API
|
||||
run: curl --fail --silent http://127.0.0.1:8787/health
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,6 +7,7 @@ pnpm-debug.log*
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
contact-api/dist/
|
||||
|
||||
# Vite cache
|
||||
.vite/
|
||||
@@ -20,6 +21,7 @@ build/
|
||||
# Environment variables
|
||||
.env
|
||||
.env.*.local
|
||||
contact-api/.env
|
||||
|
||||
# OS generated
|
||||
.DS_Store
|
||||
|
||||
28
README.md
28
README.md
@@ -59,9 +59,33 @@ npm run dev
|
||||
|
||||
```
|
||||
|
||||
## Contact Form Setup
|
||||
|
||||
### Frontend env
|
||||
Create `.env` in the project root:
|
||||
|
||||
```bash
|
||||
VITE_CONTACT_API_URL=/api/contact
|
||||
VITE_TURNSTILE_SITE_KEY=your_turnstile_site_key
|
||||
```
|
||||
|
||||
### Backend API
|
||||
The contact backend lives in `contact-api/`.
|
||||
|
||||
```bash
|
||||
cd contact-api
|
||||
npm i
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Create `contact-api/.env` from `contact-api/.env.example` and set your real Turnstile + SMTP credentials.
|
||||
|
||||
### VPS service files
|
||||
- systemd unit template: `ops/jody-contact-api.service`
|
||||
- Nginx reverse proxy snippet: `ops/nginx-contact-api.conf`
|
||||
|
||||
## Author
|
||||
|
||||
### Jody Holt
|
||||
### Frontend Developer • Passion Pioneer
|
||||
#### [GitHub](https://github.com/Ricearoni1245) • [LinkedIn](https://www.linkedin.com/in/jody-holt-9b19b0256) • [Email](mailto:jholt1008@gmail.com)
|
||||
|
||||
#### [GitHub](https://github.com/Ricearoni1245) • [LinkedIn](https://www.linkedin.com/in/jody-holt-9b19b0256)
|
||||
|
||||
BIN
contact-api-release.tgz
Normal file
BIN
contact-api-release.tgz
Normal file
Binary file not shown.
1755
contact-api/package-lock.json
generated
Normal file
1755
contact-api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
contact-api/package.json
Normal file
29
contact-api/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "contact-api",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^7.5.1",
|
||||
"helmet": "^8.1.0",
|
||||
"nodemailer": "^7.0.10",
|
||||
"pino-http": "^11.0.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/nodemailer": "^7.0.2",
|
||||
"tsx": "^4.20.5",
|
||||
"typescript": "~5.9.3"
|
||||
}
|
||||
}
|
||||
41
contact-api/src/config.ts
Normal file
41
contact-api/src/config.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import dotenv from "dotenv";
|
||||
import { z } from "zod";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const boolFromEnv = z
|
||||
.enum(["true", "false"])
|
||||
.transform((value) => value === "true");
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||
PORT: z.coerce.number().int().positive().default(8787),
|
||||
CONTACT_ALLOWED_ORIGIN: z.string().url(),
|
||||
TURNSTILE_SECRET_KEY: z.string().min(1),
|
||||
TURNSTILE_EXPECTED_HOSTNAME: z.string().min(1),
|
||||
TURNSTILE_EXPECTED_ACTION: z.string().min(1).default("contact_form"),
|
||||
SMTP_HOST: z.string().min(1),
|
||||
SMTP_PORT: z.coerce.number().int().positive().default(587),
|
||||
SMTP_SECURE: boolFromEnv.default("false"),
|
||||
SMTP_REQUIRE_TLS: boolFromEnv.default("true"),
|
||||
SMTP_USER: z.string().min(1),
|
||||
SMTP_PASS: z.string().min(1),
|
||||
MAIL_FROM_NAME: z.string().min(1).default("Portfolio Contact"),
|
||||
MAIL_FROM_ADDRESS: z.string().email(),
|
||||
MAIL_TO_ADDRESS: z.string().email(),
|
||||
MAIL_SUBJECT_PREFIX: z.string().min(1).default("[Portfolio Contact]"),
|
||||
RATE_LIMIT_WINDOW_MS: z.coerce.number().int().positive().default(600000),
|
||||
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(5),
|
||||
HONEYPOT_FIELD: z.string().min(1).default("website"),
|
||||
MIN_SUBMIT_TIME_MS: z.coerce.number().int().nonnegative().default(3000),
|
||||
});
|
||||
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
|
||||
if (!parsed.success) {
|
||||
console.error("Invalid contact-api environment configuration:");
|
||||
console.error(parsed.error.flatten().fieldErrors);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
export const config = parsed.data;
|
||||
138
contact-api/src/index.ts
Normal file
138
contact-api/src/index.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import cors from "cors";
|
||||
import express, { type NextFunction, type Request, type Response } from "express";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import helmet from "helmet";
|
||||
import { pinoHttp } from "pino-http";
|
||||
import { config } from "./config.js";
|
||||
import { sendContactEmail, verifyMailerConnection } from "./mailer.js";
|
||||
import { verifyTurnstileToken } from "./turnstile.js";
|
||||
import { contactRequestSchema } from "./validation.js";
|
||||
|
||||
type ApiErrorResponse = {
|
||||
ok: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
const app = express();
|
||||
app.set("trust proxy", 1);
|
||||
|
||||
app.use(
|
||||
pinoHttp({
|
||||
level: config.NODE_ENV === "production" ? "info" : "debug",
|
||||
redact: {
|
||||
paths: ["req.headers.authorization"],
|
||||
remove: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
app.use(helmet());
|
||||
app.use(
|
||||
cors({
|
||||
origin(origin, callback) {
|
||||
if (!origin || origin === config.CONTACT_ALLOWED_ORIGIN) {
|
||||
callback(null, true);
|
||||
return;
|
||||
}
|
||||
|
||||
callback(new Error("Origin not allowed"));
|
||||
},
|
||||
methods: ["GET", "POST", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type"],
|
||||
}),
|
||||
);
|
||||
|
||||
app.use(express.json({ limit: "16kb" }));
|
||||
|
||||
app.use((err: Error, _req: Request, res: Response<ApiErrorResponse>, next: NextFunction) => {
|
||||
if (err.message === "Origin not allowed") {
|
||||
res.status(403).json({ ok: false, error: "Origin not allowed." });
|
||||
return;
|
||||
}
|
||||
|
||||
next(err);
|
||||
});
|
||||
|
||||
const contactLimiter = rateLimit({
|
||||
windowMs: config.RATE_LIMIT_WINDOW_MS,
|
||||
max: config.RATE_LIMIT_MAX,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: {
|
||||
ok: false,
|
||||
error: "Too many requests. Please try again later.",
|
||||
},
|
||||
});
|
||||
|
||||
app.get("/health", (_req, res) => {
|
||||
res.status(200).json({ ok: true });
|
||||
});
|
||||
|
||||
app.post("/contact", contactLimiter, async (req: Request, res: Response) => {
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
|
||||
const honeypotRaw = body[config.HONEYPOT_FIELD];
|
||||
if (typeof honeypotRaw === "string" && honeypotRaw.trim().length > 0) {
|
||||
req.log.warn({ event: "contact_honeypot_triggered" }, "Contact form blocked by honeypot field");
|
||||
res.status(400).json({ ok: false, error: "Invalid submission." });
|
||||
return;
|
||||
}
|
||||
|
||||
const startedAt = Number(body.startedAt);
|
||||
if (!Number.isFinite(startedAt) || Date.now() - startedAt < config.MIN_SUBMIT_TIME_MS) {
|
||||
req.log.warn({ event: "contact_submit_too_fast" }, "Contact form rejected by min submit time");
|
||||
res.status(400).json({ ok: false, error: "Please wait a moment and try again." });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = contactRequestSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
req.log.warn({ event: "contact_validation_failed", issues: parsed.error.issues }, "Invalid contact payload");
|
||||
res.status(400).json({ ok: false, error: "Please review your form entries and try again." });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const turnstileResult = await verifyTurnstileToken(parsed.data.turnstileToken, req.ip);
|
||||
if (!turnstileResult.ok) {
|
||||
req.log.warn(
|
||||
{ event: "turnstile_failed", reason: turnstileResult.reason },
|
||||
"Turnstile verification failed",
|
||||
);
|
||||
res.status(403).json({ ok: false, error: "Human verification failed. Please try again." });
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
req.log.error({ err: error }, "Turnstile verification request failed");
|
||||
res.status(502).json({ ok: false, error: "Unable to verify your request right now." });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sendContactEmail(parsed.data, req.ip ?? "");
|
||||
req.log.info({ event: "contact_sent" }, "Contact email sent");
|
||||
res.status(200).json({ ok: true, message: "Message sent." });
|
||||
} catch (error) {
|
||||
req.log.error({ err: error }, "Failed to send contact email");
|
||||
res.status(502).json({ ok: false, error: "Unable to send message. Please try again soon." });
|
||||
}
|
||||
});
|
||||
|
||||
app.use((err: Error, req: Request, res: Response<ApiErrorResponse>, _next: NextFunction) => {
|
||||
void _next;
|
||||
req.log.error({ err }, "Unhandled contact-api error");
|
||||
res.status(500).json({ ok: false, error: "Internal server error." });
|
||||
});
|
||||
|
||||
const startServer = async (): Promise<void> => {
|
||||
await verifyMailerConnection();
|
||||
|
||||
app.listen(config.PORT, () => {
|
||||
console.log(`contact-api listening on port ${config.PORT}`);
|
||||
});
|
||||
};
|
||||
|
||||
startServer().catch((error) => {
|
||||
console.error("Failed to start contact-api", error);
|
||||
process.exit(1);
|
||||
});
|
||||
65
contact-api/src/mailer.ts
Normal file
65
contact-api/src/mailer.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { config } from "./config.js";
|
||||
import type { ContactRequest } from "./validation.js";
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.SMTP_HOST,
|
||||
port: config.SMTP_PORT,
|
||||
secure: config.SMTP_SECURE,
|
||||
requireTLS: config.SMTP_REQUIRE_TLS,
|
||||
auth: {
|
||||
user: config.SMTP_USER,
|
||||
pass: config.SMTP_PASS,
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: true,
|
||||
},
|
||||
});
|
||||
|
||||
const escapeHtml = (value: string): string =>
|
||||
value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
const newlineToBreaks = (value: string): string =>
|
||||
escapeHtml(value).replace(/\n/g, "<br>");
|
||||
|
||||
export async function verifyMailerConnection(): Promise<void> {
|
||||
await transporter.verify();
|
||||
}
|
||||
|
||||
export async function sendContactEmail(payload: ContactRequest, sourceIp: string): Promise<void> {
|
||||
const receivedAt = new Date().toISOString();
|
||||
const subject = `${config.MAIL_SUBJECT_PREFIX} ${payload.name}`;
|
||||
|
||||
const textBody = [
|
||||
`Name: ${payload.name}`,
|
||||
`Email: ${payload.email}`,
|
||||
`Source IP: ${sourceIp || "unknown"}`,
|
||||
`Received At: ${receivedAt}`,
|
||||
"",
|
||||
"Message:",
|
||||
payload.message,
|
||||
].join("\n");
|
||||
|
||||
const htmlBody = `
|
||||
<h2>Portfolio Contact Submission</h2>
|
||||
<p><strong>Name:</strong> ${escapeHtml(payload.name)}</p>
|
||||
<p><strong>Email:</strong> ${escapeHtml(payload.email)}</p>
|
||||
<p><strong>Source IP:</strong> ${escapeHtml(sourceIp || "unknown")}</p>
|
||||
<p><strong>Received At:</strong> ${escapeHtml(receivedAt)}</p>
|
||||
<p><strong>Message:</strong><br>${newlineToBreaks(payload.message)}</p>
|
||||
`;
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"${config.MAIL_FROM_NAME}" <${config.MAIL_FROM_ADDRESS}>`,
|
||||
to: config.MAIL_TO_ADDRESS,
|
||||
replyTo: payload.email,
|
||||
subject,
|
||||
text: textBody,
|
||||
html: htmlBody,
|
||||
});
|
||||
}
|
||||
48
contact-api/src/turnstile.ts
Normal file
48
contact-api/src/turnstile.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { config } from "./config.js";
|
||||
|
||||
type TurnstileVerifyResponse = {
|
||||
success: boolean;
|
||||
hostname?: string;
|
||||
action?: string;
|
||||
"error-codes"?: string[];
|
||||
};
|
||||
|
||||
export async function verifyTurnstileToken(
|
||||
token: string,
|
||||
remoteIp?: string,
|
||||
): Promise<{ ok: boolean; reason?: string }> {
|
||||
const body = new URLSearchParams({
|
||||
secret: config.TURNSTILE_SECRET_KEY,
|
||||
response: token,
|
||||
});
|
||||
|
||||
if (remoteIp) {
|
||||
body.append("remoteip", remoteIp);
|
||||
}
|
||||
|
||||
const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { ok: false, reason: `turnstile_http_${response.status}` };
|
||||
}
|
||||
|
||||
const result = (await response.json()) as TurnstileVerifyResponse;
|
||||
|
||||
if (!result.success) {
|
||||
const codes = result["error-codes"]?.join(",") ?? "verification_failed";
|
||||
return { ok: false, reason: codes };
|
||||
}
|
||||
|
||||
if (result.hostname !== config.TURNSTILE_EXPECTED_HOSTNAME) {
|
||||
return { ok: false, reason: "hostname_mismatch" };
|
||||
}
|
||||
|
||||
if (result.action !== config.TURNSTILE_EXPECTED_ACTION) {
|
||||
return { ok: false, reason: "action_mismatch" };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
12
contact-api/src/validation.ts
Normal file
12
contact-api/src/validation.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const contactRequestSchema = z.object({
|
||||
name: z.string().trim().min(2).max(80),
|
||||
email: z.string().trim().email().max(320),
|
||||
message: z.string().trim().min(20).max(2000),
|
||||
website: z.string().optional().default(""),
|
||||
startedAt: z.coerce.number().int().positive(),
|
||||
turnstileToken: z.string().trim().min(1),
|
||||
});
|
||||
|
||||
export type ContactRequest = z.infer<typeof contactRequestSchema>;
|
||||
18
contact-api/tsconfig.json
Normal file
18
contact-api/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"],
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -8,6 +8,11 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script
|
||||
src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
|
||||
async
|
||||
defer
|
||||
></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
18
ops/jody-contact-api.service
Normal file
18
ops/jody-contact-api.service
Normal file
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=Jody Portfolio Contact API
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
Group=www-data
|
||||
WorkingDirectory=/var/www/jody/contact-api
|
||||
EnvironmentFile=/etc/jody/contact-api.env
|
||||
ExecStart=/usr/bin/node /var/www/jody/contact-api/dist/index.js
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
9
ops/nginx-contact-api.conf
Normal file
9
ops/nginx-contact-api.conf
Normal file
@@ -0,0 +1,9 @@
|
||||
# Place inside your HTTPS server block for the portfolio domain.
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:8787/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"build:api": "npm run build --prefix contact-api",
|
||||
"build:all": "npm run build && npm run build:api",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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
342
src/components/Contact.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// =====================================
|
||||
import React from "react";
|
||||
|
||||
|
||||
export function Placeholder({ title }: { title: string }) {
|
||||
@@ -15,4 +14,4 @@ Card {i + 1}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
30
src/types/turnstile.d.ts
vendored
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user