seedproject-web/app/src/pages/contact.astro
Carlos Arias 43ae1e2015 Baseline: full site build (brand, pages, DB, agent-editable)
Establishes the deploy baseline on main so the admin agent's publish/rollback
has a clean starting point. Everything built to date: sumi-e brand system,
homepage, projects (DB-driven case studies), resume, about, services +
website-design detail, contact form + DB, changelog, favicon + share card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DoFYZY9gkGPNDqZ7NuEa9a
2026-07-23 20:21:28 +00:00

205 lines
7.4 KiB
Text

---
import BaseLayout from "../layouts/BaseLayout.astro";
import { SITE, authors } from "../lib/blog-data.js";
const author = authors[0];
// Kept in sync with SUBJECTS in api/public/controllers/contact.php. The server
// is authoritative — an unknown slug is coerced to "other" there — so a drift
// here degrades gracefully rather than breaking.
const subjects = [
{ value: "automation", label: "Automation" },
{ value: "website-design", label: "Website Design" },
{ value: "digital-marketing", label: "Digital Marketing" },
{ value: "fractional-cto", label: "Fractional CTO" },
{ value: "other", label: "Other" },
];
---
<BaseLayout
title={`Contact — ${SITE.name}`}
description="Start a conversation about agentic AI, automation, or a fractional CTO engagement."
canonical="/contact"
>
<section class="mx-auto w-full max-w-6xl px-5 pt-20 pb-24 md:pt-28">
<p class="font-mono text-xs tracking-[0.18em] text-muted-foreground uppercase">
{author.jobTitle}
</p>
<h1
class="mt-6 max-w-[16ch] font-serif text-[clamp(2.4rem,6vw,4rem)] leading-[1.05] tracking-tight text-balance"
>
Let&rsquo;s build something that <span class="ca-brushed">runs itself.</span>
</h1>
<p class="mt-6 max-w-[54ch] text-base leading-relaxed text-muted-foreground">
Automation, an autonomous system, or a fractional CTO engagement — tell me what
you&rsquo;re working on and I&rsquo;ll get back to you.
</p>
<div class="mt-14 grid gap-14 lg:grid-cols-[1.4fr_0.6fr]">
<!-- ---------- form ---------- -->
<form data-contact class="ca-form" novalidate>
<div class="ca-field">
<label for="cf-name">Name</label>
<input id="cf-name" name="name" type="text" autocomplete="name" required maxlength="160" />
<p class="ca-field-error" data-error="name" hidden></p>
</div>
<div class="ca-field">
<label for="cf-email">Email</label>
<input id="cf-email" name="email" type="email" autocomplete="email" required maxlength="255" />
<p class="ca-field-error" data-error="email" hidden></p>
</div>
<div class="ca-field">
<label for="cf-subject">What&rsquo;s this about?</label>
<div class="ca-select">
<select id="cf-subject" name="subject" required>
{subjects.map((s) => <option value={s.value}>{s.label}</option>)}
</select>
<span class="ca-select-caret" aria-hidden="true">&darr;</span>
</div>
</div>
<div class="ca-field">
<label for="cf-message">Message</label>
<textarea id="cf-message" name="message" rows="6" required maxlength="5000"></textarea>
<p class="ca-field-error" data-error="message" hidden></p>
</div>
<!-- Honeypot: hidden from people, catnip for bots. Positioned off-screen
rather than display:none so headless bots that skip hidden fields
still fill it. Never type in this. -->
<div class="ca-honeypot" aria-hidden="true">
<label for="cf-company">Company</label>
<input id="cf-company" name="company" type="text" tabindex="-1" autocomplete="off" />
</div>
<div class="ca-form-foot">
<button type="submit" class="ca-submit" data-submit>Send message</button>
<p class="ca-form-status" data-status role="status" aria-live="polite"></p>
</div>
</form>
<!-- ---------- aside ---------- -->
<aside class="flex flex-col gap-8">
<div>
<h2 class="font-mono text-[0.7rem] tracking-[0.18em] text-[var(--ca-ink-4)] uppercase">
Direct
</h2>
<a href="mailto:hi@carlosarias.com" class="ca-quiet mt-3 inline-block text-lg">
hi@carlosarias.com
</a>
</div>
<div>
<h2 class="font-mono text-[0.7rem] tracking-[0.18em] text-[var(--ca-ink-4)] uppercase">
Elsewhere
</h2>
<div class="mt-3 flex flex-col gap-2">
{
(author.sameAs ?? []).map((href) => {
const host = new URL(href).hostname.replace(/^www\./, "");
const label = host.split(".")[0];
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
class="ca-quiet text-sm capitalize"
>
{label}
</a>
);
})
}
</div>
</div>
</aside>
</div>
</section>
</BaseLayout>
<script>
const form = document.querySelector("[data-contact]");
if (form) {
// Deep-link support: /contact?subject=fractional-cto preselects the
// dropdown. The footer's Services links use this, so a visitor arrives with
// the right enquiry already chosen. Unknown values are ignored (the select
// keeps its default), and the server coerces anything odd to "other".
const wanted = new URLSearchParams(location.search).get("subject");
if (wanted) {
const select = form.querySelector("#cf-subject");
const match = [...select.options].some((o) => o.value === wanted);
if (match) select.value = wanted;
}
const status = form.querySelector("[data-status]");
const submit = form.querySelector("[data-submit]");
const errorEls = [...form.querySelectorAll("[data-error]")];
const clearErrors = () => {
for (const el of errorEls) {
el.hidden = true;
el.textContent = "";
}
status.textContent = "";
status.dataset.state = "";
};
const showFieldErrors = (fields) => {
for (const el of errorEls) {
const msg = fields?.[el.dataset.error];
if (msg) {
el.textContent = msg;
el.hidden = false;
}
}
};
form.addEventListener("submit", async (e) => {
e.preventDefault();
clearErrors();
const data = Object.fromEntries(new FormData(form).entries());
submit.disabled = true;
submit.textContent = "Sending…";
try {
const res = await fetch("/api/contact/submit", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
const body = await res.json().catch(() => ({}));
if (res.ok && body.ok) {
form.reset();
status.textContent = body.data?.message || "Thanks — I'll be in touch shortly.";
status.dataset.state = "ok";
submit.textContent = "Sent";
return;
}
if (res.status === 422 && body.error?.fields) {
showFieldErrors(body.error.fields);
status.textContent = body.error.message || "Please check the highlighted fields.";
} else if (res.status === 429) {
status.textContent =
"Too many messages just now — please try again in a few minutes.";
} else {
status.textContent =
"Something went wrong sending that. Please email hi@carlosarias.com.";
}
status.dataset.state = "error";
} catch {
status.textContent =
"Couldn't reach the server. Please email hi@carlosarias.com instead.";
status.dataset.state = "error";
} finally {
if (submit.textContent === "Sending…") submit.textContent = "Send message";
submit.disabled = false;
}
});
}
</script>