Polish accessibility and production readiness

This commit is contained in:
2026-08-18 00:54:33 -04:00
parent e008b3b70f
commit 43ec304783
12 changed files with 285 additions and 36 deletions
+3 -1
View File
@@ -1,5 +1,7 @@
export default {
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+3 -1
View File
@@ -1,4 +1,6 @@
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
export default {
const config = {
plugins: ["prettier-plugin-tailwindcss"],
};
export default config;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 KiB

+81 -16
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useRef, useState } from "react";
import { z } from "zod";
const empty = { name: "", email: "", company: "", message: "", website: "" };
@@ -18,6 +18,7 @@ const contactSchema = z.object({
});
export function ContactForm() {
const formRef = useRef<HTMLFormElement>(null);
const [form, setForm] = useState(empty);
const [isPending, setIsPending] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
@@ -31,6 +32,21 @@ export function ContactForm() {
setForm((f) => ({ ...f, [field]: e.target.value }));
}
function focusFirstError(errors: typeof fieldErrors) {
const fields: Array<keyof typeof empty> = [
"name",
"email",
"company",
"message",
];
const firstInvalid = fields.find((field) => errors[field]);
const control = firstInvalid
? formRef.current?.elements.namedItem(firstInvalid)
: null;
if (control instanceof HTMLElement) control.focus();
}
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setServerError(null);
@@ -38,13 +54,15 @@ export function ContactForm() {
const parsed = contactSchema.safeParse(form);
if (!parsed.success) {
const flat = parsed.error.flatten().fieldErrors;
setFieldErrors({
const errors = {
name: flat.name?.[0],
email: flat.email?.[0],
company: flat.company?.[0],
message: flat.message?.[0],
website: flat.website?.[0],
});
};
setFieldErrors(errors);
focusFirstError(errors);
return;
}
@@ -68,13 +86,15 @@ export function ContactForm() {
if (!res.ok || !data.ok) {
if ("fieldErrors" in data && data.fieldErrors) {
setFieldErrors({
const errors = {
name: data.fieldErrors.name?.[0],
email: data.fieldErrors.email?.[0],
company: data.fieldErrors.company?.[0],
message: data.fieldErrors.message?.[0],
website: data.fieldErrors.website?.[0],
});
};
setFieldErrors(errors);
focusFirstError(errors);
}
setServerError(
("message" in data ? data.message : undefined) ??
@@ -94,7 +114,11 @@ export function ContactForm() {
if (isSuccess) {
return (
<div className="border-accent-border bg-accent-bg-soft rounded-xl border p-10 text-center">
<div
role="status"
aria-live="polite"
className="border-accent-border bg-accent-bg-soft rounded-xl border p-10 text-center"
>
<div className="border-accent-border bg-accent-bg text-accent mx-auto flex h-12 w-12 items-center justify-center rounded-full border">
<svg viewBox="0 0 24 24" fill="none" className="h-6 w-6">
<path
@@ -110,9 +134,8 @@ export function ContactForm() {
Message received.
</h3>
<p className="text-muted mx-auto mt-2 max-w-sm text-sm">
Thank you for reaching out. I will get back to you shortly. This
message went straight to Hadlock. No third-party form service handled
it.
Thank you for reaching out. Your message went directly to Hadlock, and
I will get back to you shortly.
</p>
<button
type="button"
@@ -131,39 +154,63 @@ export function ContactForm() {
return (
<form
ref={formRef}
onSubmit={onSubmit}
className="grid gap-5 text-left sm:grid-cols-2"
noValidate
>
<Field label="Name" error={fieldErrors?.name?.[0]}>
<Field label="Name" error={fieldErrors.name} errorId="contact-name-error">
<input
name="name"
type="text"
autoComplete="name"
value={form.name}
onChange={update("name")}
aria-invalid={!!fieldErrors.name}
aria-describedby={fieldErrors.name ? "contact-name-error" : undefined}
className={inputClass}
placeholder="Your name"
/>
</Field>
<Field label="Email" error={fieldErrors?.email?.[0]}>
<Field
label="Email"
error={fieldErrors.email}
errorId="contact-email-error"
>
<input
name="email"
type="email"
autoComplete="email"
spellCheck={false}
value={form.email}
onChange={update("email")}
aria-invalid={!!fieldErrors.email}
aria-describedby={
fieldErrors.email ? "contact-email-error" : undefined
}
className={inputClass}
placeholder="you@company.com"
/>
</Field>
<div className="sm:col-span-2">
<Field label="Company" optional error={fieldErrors?.company?.[0]}>
<Field
label="Company"
optional
error={fieldErrors.company}
errorId="contact-company-error"
>
<input
name="company"
type="text"
autoComplete="organization"
value={form.company}
onChange={update("company")}
aria-invalid={!!fieldErrors.company}
aria-describedby={
fieldErrors.company ? "contact-company-error" : undefined
}
className={inputClass}
placeholder="Where you work (optional)"
/>
@@ -171,11 +218,20 @@ export function ContactForm() {
</div>
<div className="sm:col-span-2">
<Field label="What do you need?" error={fieldErrors?.message?.[0]}>
<Field
label="What do you need?"
error={fieldErrors.message}
errorId="contact-message-error"
>
<textarea
name="message"
rows={5}
value={form.message}
onChange={update("message")}
aria-invalid={!!fieldErrors.message}
aria-describedby={
fieldErrors.message ? "contact-message-error" : undefined
}
className={`${inputClass} resize-y`}
placeholder="Networks, servers, a website, a store, or something that does not exist yet. Tell me what you run and where it hurts."
/>
@@ -187,6 +243,7 @@ export function ContactForm() {
<label>
Website
<input
name="website"
type="text"
tabIndex={-1}
autoComplete="off"
@@ -202,9 +259,13 @@ export function ContactForm() {
disabled={isPending}
className="bg-accent text-on-accent hover:bg-accent-hover rounded-md px-7 py-3.5 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-60"
>
{isPending ? "Sending..." : "Send message"}
{isPending ? "Sending" : "Send message"}
</button>
{serverError && <p className="text-sm text-red-400">{serverError}</p>}
{serverError && (
<p role="alert" className="text-sm text-red-400">
{serverError}
</p>
)}
</div>
</form>
);
@@ -217,11 +278,13 @@ function Field({
label,
optional,
error,
errorId,
children,
}: {
label: string;
optional?: boolean;
error?: string;
errorId?: string;
children: React.ReactNode;
}) {
return (
@@ -234,7 +297,9 @@ function Field({
</span>
{children}
{error && (
<span className="mt-1.5 block text-xs text-red-400">{error}</span>
<span id={errorId} className="mt-1.5 block text-xs text-red-400">
{error}
</span>
)}
</label>
);
+23 -5
View File
@@ -19,6 +19,17 @@ export function MobileNav() {
setIsMounted(true);
}, []);
useEffect(() => {
if (!isOpen) return;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setIsOpen(false);
};
window.addEventListener("keydown", closeOnEscape);
return () => window.removeEventListener("keydown", closeOnEscape);
}, [isOpen]);
return (
<>
<button
@@ -37,21 +48,28 @@ export function MobileNav() {
</button>
{isMounted &&
isOpen &&
createPortal(
<div
className={`bg-bg/60 fixed inset-0 z-40 backdrop-blur-md transition-opacity duration-200 md:hidden ${isOpen ? "opacity-100" : "pointer-events-none opacity-0"}`}
<button
type="button"
aria-label="Close menu"
className="bg-bg/60 fixed inset-0 z-40 backdrop-blur-md md:hidden"
onClick={() => setIsOpen(false)}
aria-hidden="true"
/>,
document.body,
)}
<div
className={`border-border bg-bg/95 absolute inset-x-0 top-full rounded-b-xl border-b backdrop-blur-md transition-all duration-300 md:hidden ${
inert={!isOpen}
aria-hidden={!isOpen}
className={`border-border bg-bg/95 absolute inset-x-0 top-full rounded-b-xl border-b backdrop-blur-md transition-[max-height,opacity] duration-300 md:hidden ${
isOpen ? "max-h-[calc(100vh-4rem)] opacity-100" : "max-h-0 opacity-0"
} overflow-hidden`}
>
<nav className="flex flex-col gap-1 px-4 py-4">
<nav
aria-label="Mobile navigation"
className="flex flex-col gap-1 px-4 py-4"
>
{links.map((link) => (
<a
key={link.href}
+77
View File
@@ -3,6 +3,17 @@ import { z } from "zod";
import { sendContactEmail } from "~/server/mail";
const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000;
const RATE_LIMIT_MAX_REQUESTS = 5;
const RATE_LIMIT_MAX_ENTRIES = 10_000;
type RateLimitEntry = {
count: number;
resetAt: number;
};
const rateLimits = new Map<string, RateLimitEntry>();
const contactSchema = z.object({
name: z.string().trim().min(1, "Please enter your name").max(200),
email: z.string().trim().email("Please enter a valid email").max(320),
@@ -16,7 +27,73 @@ const contactSchema = z.object({
website: z.string().optional(),
});
function getClientIdentifier(req: Request) {
const forwardedFor = req.headers
.get("x-forwarded-for")
?.split(",")[0]
?.trim();
return (
req.headers.get("cf-connecting-ip") ??
forwardedFor ??
req.headers.get("x-real-ip") ??
"unknown"
);
}
function checkRateLimit(identifier: string) {
const now = Date.now();
if (rateLimits.size >= RATE_LIMIT_MAX_ENTRIES) {
for (const [key, entry] of rateLimits) {
if (entry.resetAt <= now) rateLimits.delete(key);
}
while (rateLimits.size >= RATE_LIMIT_MAX_ENTRIES) {
const oldestKey = rateLimits.keys().next().value;
if (!oldestKey) break;
rateLimits.delete(oldestKey);
}
}
const existing = rateLimits.get(identifier);
if (!existing || existing.resetAt <= now) {
rateLimits.set(identifier, {
count: 1,
resetAt: now + RATE_LIMIT_WINDOW_MS,
});
return null;
}
if (existing.count >= RATE_LIMIT_MAX_REQUESTS) {
return Math.ceil((existing.resetAt - now) / 1000);
}
existing.count += 1;
return null;
}
export async function POST(req: Request) {
const retryAfter = checkRateLimit(getClientIdentifier(req));
if (retryAfter !== null) {
return NextResponse.json(
{
ok: false,
message:
"Too many messages sent. Please wait a few minutes and try again.",
},
{
status: 429,
headers: {
"Cache-Control": "no-store",
"Retry-After": String(retryAfter),
},
},
);
}
const body: unknown = await req.json().catch(() => null);
const parsed = contactSchema.safeParse(body);
+26 -1
View File
@@ -1,6 +1,6 @@
import "~/styles/globals.css";
import { type Metadata } from "next";
import { type Metadata, type Viewport } from "next";
import {
Geist,
Geist_Mono,
@@ -15,6 +15,9 @@ import { env } from "~/env";
export const metadata: Metadata = {
metadataBase: new URL("https://hadlock.tech"),
alternates: {
canonical: "/",
},
icons: {
icon: "/branding/icon_blue.svg",
},
@@ -43,7 +46,29 @@ export const metadata: Metadata = {
url: "https://hadlock.tech",
siteName: "Hadlock Technologies",
type: "website",
images: [
{
url: "/hadlock-pond.jpg",
width: 4032,
height: 3024,
alt: "Hadlock Pond at dawn in Fort Ann, New York",
},
],
},
twitter: {
card: "summary_large_image",
title: "Hadlock Technologies — Everything technical, handled",
description:
"One accountable technical partner for networks, servers, cloud, databases, web, and commerce.",
images: ["/hadlock-pond.jpg"],
},
};
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#f4f1ea" },
{ media: "(prefers-color-scheme: dark)", color: "#060a10" },
],
};
const umamiEnabled =
+35 -12
View File
@@ -41,8 +41,14 @@ const services = [
export default function Home() {
return (
<div className="relative min-h-screen overflow-x-clip">
<a
href="#main-content"
className="bg-bg text-text focus-visible:ring-accent sr-only z-[100] rounded-md px-4 py-3 focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus-visible:ring-2"
>
Skip to content
</a>
<Header />
<main>
<main id="main-content">
<Hero />
<Services />
<Approach />
@@ -58,14 +64,18 @@ export default function Home() {
function Wordmark() {
return (
<Link href="/" className="group flex shrink-0 items-center">
<img
<Image
src="/branding/logo_white.svg"
alt="Hadlock Technologies"
width={1487}
height={343}
className="theme-logo-dark h-7 w-auto transition-opacity group-hover:opacity-75"
/>
<img
<Image
src="/branding/logo_blue.svg"
alt="Hadlock Technologies"
width={1487}
height={343}
className="theme-logo-light h-7 w-auto transition-opacity group-hover:opacity-75"
/>
</Link>
@@ -139,14 +149,18 @@ function Hero() {
<div className="from-hero-overlay-start via-hero-overlay-mid to-hero-overlay-end absolute inset-0 -z-10 bg-gradient-to-r" />
<div className="mx-auto flex w-full max-w-6xl flex-1 flex-col items-start justify-center px-6 pt-32 pb-16">
<img
<Image
src="/branding/logo_white.svg"
alt="Hadlock Technologies"
width={1487}
height={343}
className="theme-logo-dark h-24 w-auto"
/>
<img
<Image
src="/branding/logo_blue.svg"
alt="Hadlock Technologies"
width={1487}
height={343}
className="theme-logo-light h-24 w-auto"
/>
@@ -378,20 +392,27 @@ function Projects() {
<div className="relative mx-auto grid max-w-6xl gap-10 px-6 py-16 md:grid-cols-[1.1fr_0.9fr] md:py-24">
<div>
<img
<Image
src="/logo_white.svg"
alt="Racetix"
width={1614}
height={304}
className="theme-logo-dark h-8 w-auto"
/>
<img
<Image
src="/logo_red.svg"
alt="Racetix"
width={1614}
height={304}
className="theme-logo-light h-8 w-auto"
/>
<h3 className="text-text font-display mt-6 max-w-xl text-3xl font-bold tracking-tight uppercase sm:text-4xl">
Ticketing built for short tracks.
</h3>
<p className="text-text-secondary font-display mt-6 max-w-xl text-base leading-relaxed">
Multi-tenant ticketing for short tracks. It owns ticket products,
orders, payments, admission redemptions, and ticket email. Tracks
embed checkout in their own site through the versioned API.
Racetix owns ticket products, orders, payments, admission
redemptions, and ticket email. Tracks embed checkout in their own
site through the versioned API.
</p>
<span className="text-racetix-red font-display mt-8 inline-flex items-center gap-2 text-sm font-bold tracking-widest uppercase transition group-hover:opacity-70">
Explore Racetix
@@ -508,9 +529,11 @@ function Projects() {
<div className="relative mx-auto grid max-w-6xl gap-10 px-6 py-16 md:grid-cols-[1.1fr_0.9fr] md:py-24">
<div>
<img
<Image
src="/racehub/logo_white.svg"
alt="Riverhead Raceway"
width={593}
height={122}
className="h-9 w-auto"
/>
<h3 className="font-racehub mt-6 max-w-xl text-3xl font-bold tracking-tight text-white uppercase sm:text-4xl">
@@ -519,7 +542,7 @@ function Projects() {
<p className="font-racehub mt-4 max-w-xl text-base leading-relaxed text-white/80">
RaceHub is the website and CMS for Long Island&apos;s only auto
racing venue. It publishes events, news, standings, and results,
with live 2026 race data served from the Hotlap API.
with live race data served from the Hotlap API.
</p>
<span className="text-racehub-red font-racehub mt-8 inline-flex items-center gap-2 text-sm font-bold tracking-widest uppercase transition group-hover:opacity-70">
Visit Riverhead Raceway
+11
View File
@@ -0,0 +1,11 @@
import { type MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
},
sitemap: "https://hadlock.tech/sitemap.xml",
};
}
+11
View File
@@ -0,0 +1,11 @@
import { type MetadataRoute } from "next";
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: "https://hadlock.tech",
changeFrequency: "monthly",
priority: 1,
},
];
}
+15
View File
@@ -172,6 +172,11 @@ body {
color: #ffffff;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
}
/* Faint technical grid used behind the hero */
.bg-grid {
background-image:
@@ -218,6 +223,16 @@ body {
}
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
.beenvoice-blob {
animation: none;
}
}
.racehub-checkered {
background-color: var(--racehub-base);
background-image: url("/racehub/checkered.svg");