Files
com.gregkilmer.www/services/site/src/pages/inquiries/index.astro
T
Claude 4614cd25a0 Add a dark and light mode toggle to the header
A sun and moon button at the end of the nav (and the Store's nav) swaps
the mode and remembers the choice in the browser. Until it is pressed the
site follows the device setting. A head script sets html[data-theme]
before first paint, so light styles now key off data-theme instead of a
media query, and motifs render both images with CSS showing one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
2026-10-18 19:57:05 +00:00

472 lines
16 KiB
Plaintext

---
import Button from "@gk/ui/components/Button.astro";
import Field from "@gk/ui/components/Field.astro";
import Notice from "@gk/ui/components/Notice.astro";
import HeroHorizon from "@gk/ui/components/HeroHorizon.astro";
import PageShell from "@gk/ui/components/PageShell.astro";
import Seal from "@gk/ui/components/Seal.astro";
import Star from "@gk/ui/components/Star.astro";
import StatusPill from "@gk/ui/components/StatusPill.astro";
import ChoicePills from "../../components/work/ChoicePills.astro";
import { choices, errorMessages, HONEYPOT, parseDraft, parseErrorCode, timing } from "../../lib/forms.ts";
import { turnstileSiteKey } from "../../lib/forms-server.ts";
import { site } from "../../lib/page.ts";
import { inquiryTypes, parseInquiryType, type InquiryType } from "../../lib/work.ts";
const s = await site();
const settings = s.settings();
const text = s.pageText("/inquiries");
// A post that was turned down comes back with its reason and the answers given.
const draft = parseDraft(Astro.cookies.get("gk-inquiry-draft")?.value);
if (draft) {
Astro.cookies.delete("gk-inquiry-draft", { path: "/" });
Astro.response.headers.set("Cache-Control", "private, no-store");
}
const errorCode = draft?.code ?? parseErrorCode(Astro.url.searchParams.get("error"));
const v = draft?.values ?? {};
const err = draft?.errors ?? {};
const selected = parseInquiryType(v.type ?? Astro.url.searchParams.get("type"));
const lede = text?.data.lede ?? "Roles, freelance web work or a custom Patiun order. Pick one and the form asks the right questions.";
const quickFacts = text?.data.quickFacts?.map((f) => f.text) ?? [`Replies within ${s.settings()?.replyTime ?? "a few days"}`, "Read by me, not a bot"];
const socials = settings?.socials ?? [];
const placeholders: Record<InquiryType | "none", string> = {
none: "Tell me what you have in mind.",
job: "The role, the team, and anything I should know.",
freelance: "What you need built, who it is for, and any links.",
patiun: "Card list, deck colors, theme, or anything I should know.",
};
const turnstileKey = turnstileSiteKey();
---
<PageShell title="Inquiries" family="inquiries" current="inquiries" description="Ask about a role, freelance web work or a custom Patiun order.">
{turnstileKey && <script slot="head" src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer is:inline></script>}
<div class="split">
<div class="intro">
<Seal name="transmutation" size={140} class="seal" foil />
<span class="eyebrow">Inquiries</span>
<h1>Let's make something</h1>
<span class="underline" aria-hidden="true"></span>
<p class="lede">{lede}</p>
{settings?.availability && <StatusPill class="avail">{settings.availability}</StatusPill>}
<HeroHorizon />
<ul class="facts">
{
quickFacts.map((f) => (
<li>
<Star size={12} />
{f}
</li>
))
}
{
socials.length > 0 && (
<li>
<Star size={12} />
<span>
Also on{" "}
{socials.map((x, i) => (
<>
<a href={x.url} rel="me noopener">{x.label}</a>
{i < socials.length - 2 ? ", " : i === socials.length - 2 ? " and " : ""}
</>
))}
</span>
</li>
)
}
</ul>
</div>
<form class="form" method="post" action="/api/inquiries" enctype="multipart/form-data" aria-label="Inquiry" data-inquiry>
<div class="frame">
{errorCode && (
<Notice role="alert" title={errorMessages[errorCode]}>
{Object.keys(err).length > 0 ? "The marked answers are below." : ""}
</Notice>
)}
<fieldset class="types">
<legend>What's this about?{err.type && <span class="err"> {err.type}</span>}</legend>
<div class="tiles">
{
inquiryTypes.map((t) => (
<label class="tile">
<input type="radio" name="type" value={t.value} checked={t.value === selected} required class="sr" />
<span class="tick" aria-hidden="true"><Star size={12} /></span>
<span class="tile-name">{t.label}</span>
<span class="tile-blurb">{t.blurb}</span>
</label>
))
}
</div>
</fieldset>
<div class="pair">
<Field label="Your name" name="name" placeholder="Jane Doe" autocomplete="name" required maxlength={120} value={v.name} error={err.name} />
<Field label="Email" name="email" type="email" placeholder="you@example.com" autocomplete="email" required maxlength={200} value={v.email} error={err.email} />
</div>
<div class="q q-job" data-q="job">
<div class="pair">
<Field label="Company" name="job_company" placeholder="Where is the role?" autocomplete="organization" maxlength={160} value={v.job_company} error={err.job_company} />
<Field label="Role" name="job_role" placeholder="e.g. Senior full-stack developer" maxlength={160} value={v.job_role} error={err.job_role} />
</div>
<ChoicePills legend="Kind of role" name="job_kind" options={[...choices.job_kind]} checked={v.job_kind} />
<Field label="Link to the posting" name="job_link" type="url" placeholder="https://" hint="Optional." maxlength={500} value={v.job_link} error={err.job_link} />
</div>
<div class="q q-freelance" data-q="freelance">
<ChoicePills legend="What do you need?" name="freelance_need" options={[...choices.freelance_need]} checked={v.freelance_need} />
<div class="pair">
<Field label="Your current site" name="freelance_site" type="url" placeholder="https://" hint="Optional." maxlength={500} value={v.freelance_site} error={err.freelance_site} />
<Field label="Timeline" name="freelance_timing" as="select" options={timing} />
</div>
<Field label="Date" name="freelance_date" type="date" class="when-date" value={v.freelance_date} error={err.freelance_date} />
</div>
<div class="q q-patiun" data-q="patiun">
<ChoicePills legend="What would you like made?" name="patiun_item" options={[...choices.patiun_item]} checked={v.patiun_item} />
<div class="pair">
<Field label="How many?" name="patiun_quantity" placeholder="e.g. 60 cards" maxlength={120} value={v.patiun_quantity} error={err.patiun_quantity} />
<Field label="Need it by" name="patiun_timing" as="select" options={timing} />
</div>
<Field label="Date" name="patiun_date" type="date" class="when-date" value={v.patiun_date} error={err.patiun_date} />
</div>
<div class="gk-label">
<label class="gk-label-text" for="f-details">Details</label>
<textarea
id="f-details"
name="details"
class="gk-field"
rows={5}
required
minlength={10}
maxlength={5000}
placeholder={placeholders[selected ?? "none"]}
aria-describedby={err.details ? "f-details-error" : undefined}
aria-invalid={err.details ? "true" : undefined}
data-details>{v.details ?? ""}</textarea>
{err.details && <span class="err" id="f-details-error">{err.details}</span>}
</div>
<label class="upload">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16V4M7 9l5-5 5 5M5 20h14" /></svg>
<span>Add a reference image or decklist <span class="browse">Browse</span><span class="file-name" data-file-name></span></span>
<input type="file" name="attachment" accept="image/*,.pdf,.txt,.csv,.dek,.dec" class="sr" data-file />
</label>
{err.attachment && <span class="err">{err.attachment}</span>}
{draft && !err.attachment && <span class="note">If you attached a file, add it again.</span>}
<div class="hp" aria-hidden="true">
<label>Leave this empty <input type="text" name={HONEYPOT} tabindex="-1" autocomplete="off" /></label>
</div>
{turnstileKey && <div class="cf-turnstile" data-sitekey={turnstileKey} data-theme="dark" data-response-field-name="cf-turnstile-response"></div>}
{turnstileKey && <noscript><span class="note">The bot check needs JavaScript. Turn it on for this page to send the form.</span></noscript>}
<div class="submit">
<Button type="submit">Send inquiry</Button>
{turnstileKey && <span class="note">Protected by Cloudflare Turnstile.</span>}
</div>
</div>
</form>
</div>
</PageShell>
<script>
const form = document.querySelector<HTMLFormElement>("[data-inquiry]");
if (form) {
const details = form.querySelector<HTMLTextAreaElement>("[data-details]");
const hints: Record<string, string> = {
job: "The role, the team, and anything I should know.",
freelance: "What you need built, who it is for, and any links.",
patiun: "Card list, deck colors, theme, or anything I should know.",
};
// Only the chosen type's questions are sent.
const sync = () => {
const type = form.querySelector<HTMLInputElement>('input[name="type"]:checked')?.value;
form.querySelectorAll<HTMLElement>("[data-q]").forEach((q) => {
q.querySelectorAll<HTMLInputElement | HTMLSelectElement>("input, select").forEach((el) => (el.disabled = q.dataset.q !== type));
});
if (details && type && hints[type]) details.placeholder = hints[type];
};
form.querySelectorAll('input[name="type"]').forEach((r) => r.addEventListener("change", sync));
sync();
const file = form.querySelector<HTMLInputElement>("[data-file]");
const fileName = form.querySelector<HTMLElement>("[data-file-name]");
file?.addEventListener("change", () => {
if (fileName) fileName.textContent = file.files?.[0] ? ` · ${file.files[0].name}` : "";
});
}
</script>
<style>
.split {
padding: 24px 0 24px;
display: flex;
flex-wrap: wrap;
gap: 48px;
align-items: flex-start;
}
.intro {
flex: 1 1 340px;
min-width: 0;
display: flex;
flex-direction: column;
gap: 18px;
}
.intro :global(.seal) {
width: 140px;
height: 140px;
opacity: 0.9;
}
/* Glow is for the night sky; on Starlight the foil stands on its own. */
:global(:root[data-theme="light"]) .intro :global(.seal) {
opacity: 1;
filter: none;
}
.eyebrow {
font-size: 11px;
letter-spacing: 0.3em;
font-weight: 600;
color: var(--gk-gold-bright);
text-transform: uppercase;
}
h1 {
margin: 0;
font-size: clamp(34px, 6vw, 48px);
letter-spacing: 0.08em;
line-height: 1.05;
}
.underline {
display: block;
width: 180px;
height: 2px;
margin-top: -8px;
border-radius: 2px;
background:
linear-gradient(90deg, var(--gk-gold), rgba(var(--gk-gold-rgb), 0.15) 70%, transparent),
radial-gradient(circle at 18% 50%, var(--gk-teal-bright) 0 2px, transparent 3px);
box-shadow: 0 0 8px rgba(var(--gk-gold-rgb), 0.35);
}
.lede {
margin: 0;
padding-left: 14px;
border-left: 2px solid var(--gk-gold);
font-size: 17px;
line-height: 1.65;
color: var(--gk-soft);
max-width: 44ch;
}
.intro :global(.avail) {
align-self: flex-start;
}
.facts {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 10px;
font-size: 14px;
color: var(--gk-muted);
}
.facts li {
display: flex;
align-items: center;
gap: 10px;
}
.form {
position: relative;
flex: 1 1 540px;
min-width: 0;
background: var(--gk-midnight);
border: 1px solid rgba(var(--gk-gold-rgb), 0.55);
border-radius: var(--gk-radius);
padding: 8px;
}
.frame {
border: 1px solid rgba(138, 101, 22, 0.35);
border-radius: 6px;
padding: 30px;
display: flex;
flex-direction: column;
gap: 20px;
}
.form :global(.gk-label-text) {
font-size: 14px;
color: var(--gk-text);
}
.form :global(.gk-field) {
font-size: 15px;
}
.types {
margin: 0;
padding: 0;
border: 0;
min-width: 0;
}
.types legend {
padding: 0;
margin-bottom: 10px;
font-size: 14px;
font-weight: 600;
}
.tiles {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.tile {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
min-height: 72px;
padding: 14px 16px;
border-radius: var(--gk-radius);
border: 1px solid var(--gk-lines);
background: var(--gk-sky);
cursor: pointer;
transition: border-color var(--gk-ease), background var(--gk-ease);
}
.tile:hover {
border-color: var(--gk-gold-line);
}
.tile:has(:checked) {
border-color: var(--gk-gold);
background:
repeating-linear-gradient(0deg, rgba(var(--gk-star-rgb), 0.04) 0 1px, transparent 1px 14px),
repeating-linear-gradient(90deg, rgba(var(--gk-star-rgb), 0.04) 0 1px, transparent 1px 14px),
rgba(var(--gk-gold-rgb), 0.1);
}
.tile:has(:focus-visible) {
outline: 2px solid var(--gk-gold);
outline-offset: 3px;
}
.tick {
position: absolute;
right: 10px;
top: 10px;
display: none;
}
.tile:has(:checked) .tick {
display: inline-flex;
}
.tile-name {
font-size: 14px;
font-weight: 700;
color: var(--gk-text);
padding-right: 18px;
}
.tile:has(:checked) .tile-name {
color: var(--gk-starlight);
}
.tile-blurb {
font-size: 12px;
color: var(--gk-muted);
}
.sr {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
pointer-events: none;
}
.pair {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.pair > :global(*) {
flex: 1 1 240px;
min-width: 0;
}
/* Each type's questions show once that type is picked; no script needed. */
.q {
display: none;
flex-direction: column;
gap: 20px;
}
.form:has(input[name="type"][value="job"]:checked) .q-job,
.form:has(input[name="type"][value="freelance"]:checked) .q-freelance,
.form:has(input[name="type"][value="patiun"]:checked) .q-patiun {
display: flex;
}
.q :global(.when-date) {
display: none;
}
.q:has(option[value="date"]:checked) :global(.when-date) {
display: flex;
}
.upload {
position: relative;
display: flex;
align-items: center;
gap: 14px;
padding: 18px;
border: 1px dashed var(--gk-lines);
border-radius: var(--gk-radius);
background: var(--gk-sky);
font-size: 14px;
color: var(--gk-muted);
cursor: pointer;
transition: border-color var(--gk-ease);
}
.upload svg {
flex: none;
color: var(--gk-teal-bright);
}
.upload:hover {
border-color: var(--gk-lines-strong);
}
.upload:has(:focus-visible) {
outline: 2px solid var(--gk-gold);
outline-offset: 3px;
}
.browse {
color: var(--gk-teal-bright);
font-weight: 600;
}
.file-name {
color: var(--gk-text);
}
.submit {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.note {
font-size: 13px;
color: var(--gk-muted);
}
.err {
font-size: 12px;
font-weight: 400;
color: var(--gk-danger);
}
.hp {
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
}
@media (max-width: 640px) {
.tiles {
grid-template-columns: 1fr;
}
.frame {
padding: 20px 16px;
}
.intro :global(.seal) {
width: 96px;
height: 96px;
}
}
</style>