mirror of
https://github.com/Patiun/com.gregkilmer.www.git
synced 2026-11-08 04:08:24 +00:00
Phase 6: sync jobs, inquiry and order forms
- Sync scheduler in the content service with a per-job cache (sync_cache), sync_runs reporting and Healthchecks pings. Jobs: Archidekt, BoardGameGeek, cover art (Open Library, TMDB, AniList), Suwayomi and Kavita reading progress, Bambuddy filament and Homebox material stock. A note's own value always wins over a synced one. - PersonalReader: updates only Library notes with a matching anilistId, changes to Greg's notes are held for review; Reading series with no note show as suggestions on the admin Overview. - /api/inquiries and /api/orders: Turnstile, rate limits, a saved copy in the content service (intake token) plus email over SMTP. Orders are re-checked against the live notes: accepted only when the category's ordersOpen is true and the line has capacity. - Admin Inbox for saved submissions. Options' inStock is optional so synced stock can fill it. All settings from process.env; .env.example per service. - Sources could not be reached from the build environment; parsers are tested against fixtures labelled as not live. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012aSbo2D1qP8cMFQCykf7kx
This commit is contained in:
@@ -1,20 +1,31 @@
|
||||
---
|
||||
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 { env } from "../../lib/env.ts";
|
||||
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");
|
||||
const selected = parseInquiryType(Astro.url.searchParams.get("type"));
|
||||
// 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 ?? [];
|
||||
@@ -25,12 +36,7 @@ const placeholders: Record<InquiryType | "none", string> = {
|
||||
freelance: "What you need built, who it is for, and any links.",
|
||||
patiun: "Card list, deck colors, theme, or anything I should know.",
|
||||
};
|
||||
const timing = [
|
||||
{ value: "no-rush", label: "No rush" },
|
||||
{ value: "within-a-month", label: "Within a month" },
|
||||
{ value: "date", label: "A specific date" },
|
||||
];
|
||||
const turnstileKey = env("PUBLIC_TURNSTILE_SITE_KEY") ?? env("TURNSTILE_SITE_KEY");
|
||||
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>}
|
||||
@@ -74,8 +80,13 @@ const turnstileKey = env("PUBLIC_TURNSTILE_SITE_KEY") ?? env("TURNSTILE_SITE_KEY
|
||||
|
||||
<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?</legend>
|
||||
<legend>What's this about?{err.type && <span class="err"> {err.type}</span>}</legend>
|
||||
<div class="tiles">
|
||||
{
|
||||
inquiryTypes.map((t) => (
|
||||
@@ -91,46 +102,68 @@ const turnstileKey = env("PUBLIC_TURNSTILE_SITE_KEY") ?? env("TURNSTILE_SITE_KEY
|
||||
</fieldset>
|
||||
|
||||
<div class="pair">
|
||||
<Field label="Your name" name="name" placeholder="Jane Doe" autocomplete="name" required maxlength={120} />
|
||||
<Field label="Email" name="email" type="email" placeholder="you@example.com" autocomplete="email" required maxlength={200} />
|
||||
<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} />
|
||||
<Field label="Role" name="job_role" placeholder="e.g. Senior full-stack developer" maxlength={160} />
|
||||
<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={["Full-time", "Contract", "Not sure yet"]} />
|
||||
<Field label="Link to the posting" name="job_link" type="url" placeholder="https://" hint="Optional." maxlength={500} />
|
||||
<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={["A new site", "An app", "An integration", "Something else"]} />
|
||||
<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} />
|
||||
<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" />
|
||||
<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={["Proxy cards", "Deck box", "Something else"]} />
|
||||
<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} />
|
||||
<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" />
|
||||
<Field label="Date" name="patiun_date" type="date" class="when-date" value={v.patiun_date} error={err.patiun_date} />
|
||||
</div>
|
||||
|
||||
<Field label="Details" name="details" as="textarea" rows={5} required maxlength={5000} placeholder={placeholders[selected ?? "none"]} data-details />
|
||||
<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>}
|
||||
|
||||
{turnstileKey && <div class="cf-turnstile" data-sitekey={turnstileKey} data-theme="dark"></div>}
|
||||
<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>
|
||||
@@ -406,6 +439,18 @@ const turnstileKey = env("PUBLIC_TURNSTILE_SITE_KEY") ?? env("TURNSTILE_SITE_KEY
|
||||
font-size: 13px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
.err {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #f0857a;
|
||||
}
|
||||
.hp {
|
||||
position: absolute;
|
||||
left: -10000px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.tiles {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
Reference in New Issue
Block a user