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
205 lines
7.4 KiB
Text
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’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’re working on and I’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’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">↓</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>
|