Polish accessibility and production readiness
This commit is contained in:
+3
-1
@@ -1,5 +1,7 @@
|
|||||||
export default {
|
const config = {
|
||||||
plugins: {
|
plugins: {
|
||||||
"@tailwindcss/postcss": {},
|
"@tailwindcss/postcss": {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
|||||||
+3
-1
@@ -1,4 +1,6 @@
|
|||||||
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
|
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
|
||||||
export default {
|
const config = {
|
||||||
plugins: ["prettier-plugin-tailwindcss"],
|
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 |
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const empty = { name: "", email: "", company: "", message: "", website: "" };
|
const empty = { name: "", email: "", company: "", message: "", website: "" };
|
||||||
@@ -18,6 +18,7 @@ const contactSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function ContactForm() {
|
export function ContactForm() {
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
const [form, setForm] = useState(empty);
|
const [form, setForm] = useState(empty);
|
||||||
const [isPending, setIsPending] = useState(false);
|
const [isPending, setIsPending] = useState(false);
|
||||||
const [isSuccess, setIsSuccess] = useState(false);
|
const [isSuccess, setIsSuccess] = useState(false);
|
||||||
@@ -31,6 +32,21 @@ export function ContactForm() {
|
|||||||
setForm((f) => ({ ...f, [field]: e.target.value }));
|
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>) {
|
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setServerError(null);
|
setServerError(null);
|
||||||
@@ -38,13 +54,15 @@ export function ContactForm() {
|
|||||||
const parsed = contactSchema.safeParse(form);
|
const parsed = contactSchema.safeParse(form);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
const flat = parsed.error.flatten().fieldErrors;
|
const flat = parsed.error.flatten().fieldErrors;
|
||||||
setFieldErrors({
|
const errors = {
|
||||||
name: flat.name?.[0],
|
name: flat.name?.[0],
|
||||||
email: flat.email?.[0],
|
email: flat.email?.[0],
|
||||||
company: flat.company?.[0],
|
company: flat.company?.[0],
|
||||||
message: flat.message?.[0],
|
message: flat.message?.[0],
|
||||||
website: flat.website?.[0],
|
website: flat.website?.[0],
|
||||||
});
|
};
|
||||||
|
setFieldErrors(errors);
|
||||||
|
focusFirstError(errors);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,13 +86,15 @@ export function ContactForm() {
|
|||||||
|
|
||||||
if (!res.ok || !data.ok) {
|
if (!res.ok || !data.ok) {
|
||||||
if ("fieldErrors" in data && data.fieldErrors) {
|
if ("fieldErrors" in data && data.fieldErrors) {
|
||||||
setFieldErrors({
|
const errors = {
|
||||||
name: data.fieldErrors.name?.[0],
|
name: data.fieldErrors.name?.[0],
|
||||||
email: data.fieldErrors.email?.[0],
|
email: data.fieldErrors.email?.[0],
|
||||||
company: data.fieldErrors.company?.[0],
|
company: data.fieldErrors.company?.[0],
|
||||||
message: data.fieldErrors.message?.[0],
|
message: data.fieldErrors.message?.[0],
|
||||||
website: data.fieldErrors.website?.[0],
|
website: data.fieldErrors.website?.[0],
|
||||||
});
|
};
|
||||||
|
setFieldErrors(errors);
|
||||||
|
focusFirstError(errors);
|
||||||
}
|
}
|
||||||
setServerError(
|
setServerError(
|
||||||
("message" in data ? data.message : undefined) ??
|
("message" in data ? data.message : undefined) ??
|
||||||
@@ -94,7 +114,11 @@ export function ContactForm() {
|
|||||||
|
|
||||||
if (isSuccess) {
|
if (isSuccess) {
|
||||||
return (
|
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">
|
<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">
|
<svg viewBox="0 0 24 24" fill="none" className="h-6 w-6">
|
||||||
<path
|
<path
|
||||||
@@ -110,9 +134,8 @@ export function ContactForm() {
|
|||||||
Message received.
|
Message received.
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-muted mx-auto mt-2 max-w-sm text-sm">
|
<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
|
Thank you for reaching out. Your message went directly to Hadlock, and
|
||||||
message went straight to Hadlock. No third-party form service handled
|
I will get back to you shortly.
|
||||||
it.
|
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -131,39 +154,63 @@ export function ContactForm() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
|
ref={formRef}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
className="grid gap-5 text-left sm:grid-cols-2"
|
className="grid gap-5 text-left sm:grid-cols-2"
|
||||||
noValidate
|
noValidate
|
||||||
>
|
>
|
||||||
<Field label="Name" error={fieldErrors?.name?.[0]}>
|
<Field label="Name" error={fieldErrors.name} errorId="contact-name-error">
|
||||||
<input
|
<input
|
||||||
|
name="name"
|
||||||
type="text"
|
type="text"
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChange={update("name")}
|
onChange={update("name")}
|
||||||
|
aria-invalid={!!fieldErrors.name}
|
||||||
|
aria-describedby={fieldErrors.name ? "contact-name-error" : undefined}
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
placeholder="Your name"
|
placeholder="Your name"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="Email" error={fieldErrors?.email?.[0]}>
|
<Field
|
||||||
|
label="Email"
|
||||||
|
error={fieldErrors.email}
|
||||||
|
errorId="contact-email-error"
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
|
spellCheck={false}
|
||||||
value={form.email}
|
value={form.email}
|
||||||
onChange={update("email")}
|
onChange={update("email")}
|
||||||
|
aria-invalid={!!fieldErrors.email}
|
||||||
|
aria-describedby={
|
||||||
|
fieldErrors.email ? "contact-email-error" : undefined
|
||||||
|
}
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
placeholder="you@company.com"
|
placeholder="you@company.com"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<div className="sm:col-span-2">
|
<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
|
<input
|
||||||
|
name="company"
|
||||||
type="text"
|
type="text"
|
||||||
autoComplete="organization"
|
autoComplete="organization"
|
||||||
value={form.company}
|
value={form.company}
|
||||||
onChange={update("company")}
|
onChange={update("company")}
|
||||||
|
aria-invalid={!!fieldErrors.company}
|
||||||
|
aria-describedby={
|
||||||
|
fieldErrors.company ? "contact-company-error" : undefined
|
||||||
|
}
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
placeholder="Where you work (optional)"
|
placeholder="Where you work (optional)"
|
||||||
/>
|
/>
|
||||||
@@ -171,11 +218,20 @@ export function ContactForm() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sm:col-span-2">
|
<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
|
<textarea
|
||||||
|
name="message"
|
||||||
rows={5}
|
rows={5}
|
||||||
value={form.message}
|
value={form.message}
|
||||||
onChange={update("message")}
|
onChange={update("message")}
|
||||||
|
aria-invalid={!!fieldErrors.message}
|
||||||
|
aria-describedby={
|
||||||
|
fieldErrors.message ? "contact-message-error" : undefined
|
||||||
|
}
|
||||||
className={`${inputClass} resize-y`}
|
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."
|
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>
|
<label>
|
||||||
Website
|
Website
|
||||||
<input
|
<input
|
||||||
|
name="website"
|
||||||
type="text"
|
type="text"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
@@ -202,9 +259,13 @@ export function ContactForm() {
|
|||||||
disabled={isPending}
|
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"
|
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>
|
</button>
|
||||||
{serverError && <p className="text-sm text-red-400">{serverError}</p>}
|
{serverError && (
|
||||||
|
<p role="alert" className="text-sm text-red-400">
|
||||||
|
{serverError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
@@ -217,11 +278,13 @@ function Field({
|
|||||||
label,
|
label,
|
||||||
optional,
|
optional,
|
||||||
error,
|
error,
|
||||||
|
errorId,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
optional?: boolean;
|
optional?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
errorId?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@@ -234,7 +297,9 @@ function Field({
|
|||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
{error && (
|
{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>
|
</label>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,6 +19,17 @@ export function MobileNav() {
|
|||||||
setIsMounted(true);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -37,21 +48,28 @@ export function MobileNav() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isMounted &&
|
{isMounted &&
|
||||||
|
isOpen &&
|
||||||
createPortal(
|
createPortal(
|
||||||
<div
|
<button
|
||||||
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"}`}
|
type="button"
|
||||||
|
aria-label="Close menu"
|
||||||
|
className="bg-bg/60 fixed inset-0 z-40 backdrop-blur-md md:hidden"
|
||||||
onClick={() => setIsOpen(false)}
|
onClick={() => setIsOpen(false)}
|
||||||
aria-hidden="true"
|
|
||||||
/>,
|
/>,
|
||||||
document.body,
|
document.body,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<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"
|
isOpen ? "max-h-[calc(100vh-4rem)] opacity-100" : "max-h-0 opacity-0"
|
||||||
} overflow-hidden`}
|
} 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) => (
|
{links.map((link) => (
|
||||||
<a
|
<a
|
||||||
key={link.href}
|
key={link.href}
|
||||||
|
|||||||
@@ -3,6 +3,17 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import { sendContactEmail } from "~/server/mail";
|
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({
|
const contactSchema = z.object({
|
||||||
name: z.string().trim().min(1, "Please enter your name").max(200),
|
name: z.string().trim().min(1, "Please enter your name").max(200),
|
||||||
email: z.string().trim().email("Please enter a valid email").max(320),
|
email: z.string().trim().email("Please enter a valid email").max(320),
|
||||||
@@ -16,7 +27,73 @@ const contactSchema = z.object({
|
|||||||
website: z.string().optional(),
|
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) {
|
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 body: unknown = await req.json().catch(() => null);
|
||||||
const parsed = contactSchema.safeParse(body);
|
const parsed = contactSchema.safeParse(body);
|
||||||
|
|
||||||
|
|||||||
+26
-1
@@ -1,6 +1,6 @@
|
|||||||
import "~/styles/globals.css";
|
import "~/styles/globals.css";
|
||||||
|
|
||||||
import { type Metadata } from "next";
|
import { type Metadata, type Viewport } from "next";
|
||||||
import {
|
import {
|
||||||
Geist,
|
Geist,
|
||||||
Geist_Mono,
|
Geist_Mono,
|
||||||
@@ -15,6 +15,9 @@ import { env } from "~/env";
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL("https://hadlock.tech"),
|
metadataBase: new URL("https://hadlock.tech"),
|
||||||
|
alternates: {
|
||||||
|
canonical: "/",
|
||||||
|
},
|
||||||
icons: {
|
icons: {
|
||||||
icon: "/branding/icon_blue.svg",
|
icon: "/branding/icon_blue.svg",
|
||||||
},
|
},
|
||||||
@@ -43,7 +46,29 @@ export const metadata: Metadata = {
|
|||||||
url: "https://hadlock.tech",
|
url: "https://hadlock.tech",
|
||||||
siteName: "Hadlock Technologies",
|
siteName: "Hadlock Technologies",
|
||||||
type: "website",
|
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 =
|
const umamiEnabled =
|
||||||
|
|||||||
+35
-12
@@ -41,8 +41,14 @@ const services = [
|
|||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen overflow-x-clip">
|
<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 />
|
<Header />
|
||||||
<main>
|
<main id="main-content">
|
||||||
<Hero />
|
<Hero />
|
||||||
<Services />
|
<Services />
|
||||||
<Approach />
|
<Approach />
|
||||||
@@ -58,14 +64,18 @@ export default function Home() {
|
|||||||
function Wordmark() {
|
function Wordmark() {
|
||||||
return (
|
return (
|
||||||
<Link href="/" className="group flex shrink-0 items-center">
|
<Link href="/" className="group flex shrink-0 items-center">
|
||||||
<img
|
<Image
|
||||||
src="/branding/logo_white.svg"
|
src="/branding/logo_white.svg"
|
||||||
alt="Hadlock Technologies"
|
alt="Hadlock Technologies"
|
||||||
|
width={1487}
|
||||||
|
height={343}
|
||||||
className="theme-logo-dark h-7 w-auto transition-opacity group-hover:opacity-75"
|
className="theme-logo-dark h-7 w-auto transition-opacity group-hover:opacity-75"
|
||||||
/>
|
/>
|
||||||
<img
|
<Image
|
||||||
src="/branding/logo_blue.svg"
|
src="/branding/logo_blue.svg"
|
||||||
alt="Hadlock Technologies"
|
alt="Hadlock Technologies"
|
||||||
|
width={1487}
|
||||||
|
height={343}
|
||||||
className="theme-logo-light h-7 w-auto transition-opacity group-hover:opacity-75"
|
className="theme-logo-light h-7 w-auto transition-opacity group-hover:opacity-75"
|
||||||
/>
|
/>
|
||||||
</Link>
|
</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="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">
|
<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"
|
src="/branding/logo_white.svg"
|
||||||
alt="Hadlock Technologies"
|
alt="Hadlock Technologies"
|
||||||
|
width={1487}
|
||||||
|
height={343}
|
||||||
className="theme-logo-dark h-24 w-auto"
|
className="theme-logo-dark h-24 w-auto"
|
||||||
/>
|
/>
|
||||||
<img
|
<Image
|
||||||
src="/branding/logo_blue.svg"
|
src="/branding/logo_blue.svg"
|
||||||
alt="Hadlock Technologies"
|
alt="Hadlock Technologies"
|
||||||
|
width={1487}
|
||||||
|
height={343}
|
||||||
className="theme-logo-light h-24 w-auto"
|
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 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>
|
<div>
|
||||||
<img
|
<Image
|
||||||
src="/logo_white.svg"
|
src="/logo_white.svg"
|
||||||
alt="Racetix"
|
alt="Racetix"
|
||||||
|
width={1614}
|
||||||
|
height={304}
|
||||||
className="theme-logo-dark h-8 w-auto"
|
className="theme-logo-dark h-8 w-auto"
|
||||||
/>
|
/>
|
||||||
<img
|
<Image
|
||||||
src="/logo_red.svg"
|
src="/logo_red.svg"
|
||||||
alt="Racetix"
|
alt="Racetix"
|
||||||
|
width={1614}
|
||||||
|
height={304}
|
||||||
className="theme-logo-light h-8 w-auto"
|
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">
|
<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,
|
Racetix owns ticket products, orders, payments, admission
|
||||||
orders, payments, admission redemptions, and ticket email. Tracks
|
redemptions, and ticket email. Tracks embed checkout in their own
|
||||||
embed checkout in their own site through the versioned API.
|
site through the versioned API.
|
||||||
</p>
|
</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">
|
<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
|
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 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>
|
<div>
|
||||||
<img
|
<Image
|
||||||
src="/racehub/logo_white.svg"
|
src="/racehub/logo_white.svg"
|
||||||
alt="Riverhead Raceway"
|
alt="Riverhead Raceway"
|
||||||
|
width={593}
|
||||||
|
height={122}
|
||||||
className="h-9 w-auto"
|
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">
|
<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">
|
<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's only auto
|
RaceHub is the website and CMS for Long Island's only auto
|
||||||
racing venue. It publishes events, news, standings, and results,
|
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>
|
</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">
|
<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
|
Visit Riverhead Raceway
|
||||||
|
|||||||
@@ -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",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { type MetadataRoute } from "next";
|
||||||
|
|
||||||
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
url: "https://hadlock.tech",
|
||||||
|
changeFrequency: "monthly",
|
||||||
|
priority: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -172,6 +172,11 @@ body {
|
|||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Faint technical grid used behind the hero */
|
/* Faint technical grid used behind the hero */
|
||||||
.bg-grid {
|
.bg-grid {
|
||||||
background-image:
|
background-image:
|
||||||
@@ -218,6 +223,16 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html {
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beenvoice-blob {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.racehub-checkered {
|
.racehub-checkered {
|
||||||
background-color: var(--racehub-base);
|
background-color: var(--racehub-base);
|
||||||
background-image: url("/racehub/checkered.svg");
|
background-image: url("/racehub/checkered.svg");
|
||||||
|
|||||||
Reference in New Issue
Block a user