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:
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* POST /api/inquiries: the Inquiries form (multipart, works without script).
|
||||
* Checks the post, the rate limit and Turnstile, saves a copy through the
|
||||
* content service, emails Greg, and redirects (303) to /inquiries/sent with
|
||||
* the gk-inquiry cookie. A post that is turned down goes back to /inquiries
|
||||
* with the reason and the visitor's answers.
|
||||
*/
|
||||
import type { APIRoute } from "astro";
|
||||
import {
|
||||
clientIp,
|
||||
encodeDraft,
|
||||
formValues,
|
||||
inquiryEmail,
|
||||
inquiryFields,
|
||||
inquirySent,
|
||||
isBot,
|
||||
saveAndSend,
|
||||
TURNSTILE_FIELD,
|
||||
validateInquiry,
|
||||
type ErrorCode,
|
||||
type FieldErrors,
|
||||
} from "../../lib/forms.ts";
|
||||
import {
|
||||
contactTo,
|
||||
flashCookie,
|
||||
inquiryLimiter,
|
||||
maxAttachmentBytes,
|
||||
reportDelivery,
|
||||
saveSubmission,
|
||||
sendMail,
|
||||
verifyTurnstile,
|
||||
} from "../../lib/forms-server.ts";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const POST: APIRoute = async ({ request, cookies, redirect, url, clientAddress }) => {
|
||||
let form: FormData;
|
||||
try {
|
||||
form = await request.formData();
|
||||
} catch {
|
||||
return redirect("/inquiries?error=invalid", 303);
|
||||
}
|
||||
const type = String(form.get("type") ?? "");
|
||||
const back = (code: ErrorCode, errors: FieldErrors = {}) => {
|
||||
cookies.set("gk-inquiry-draft", encodeDraft({ code, errors, values: formValues(form) }), flashCookie(url));
|
||||
const q = new URLSearchParams({ error: code });
|
||||
if (/^[a-z]{1,20}$/.test(type)) q.set("type", type);
|
||||
return redirect(`/inquiries?${q}`, 303);
|
||||
};
|
||||
|
||||
// A bot filled the hidden field: answer as if it worked, keep nothing.
|
||||
if (isBot(form)) return redirect("/inquiries/sent", 303);
|
||||
|
||||
const checked = validateInquiry(form, { maxBytes: maxAttachmentBytes() });
|
||||
if (!checked.ok) return back(checked.code, checked.errors);
|
||||
const q = checked.value;
|
||||
|
||||
let ip = "unknown";
|
||||
try {
|
||||
ip = clientIp(request.headers, clientAddress);
|
||||
} catch {
|
||||
ip = clientIp(request.headers);
|
||||
}
|
||||
if (inquiryLimiter.hit(ip)) return back("rate");
|
||||
if (!(await verifyTurnstile(String(form.get(TURNSTILE_FIELD) ?? ""), ip))) return back("bot");
|
||||
|
||||
const result = await saveAndSend({
|
||||
save: () => saveSubmission({ kind: "inquiry", type: q.type, name: q.name, email: q.email, fields: inquiryFields(q), attachment: q.attachment }),
|
||||
email: (savedId) => sendMail({ to: contactTo(), replyTo: { name: q.name, address: q.email }, ...inquiryEmail(q, savedId), attachment: q.attachment }),
|
||||
report: reportDelivery,
|
||||
});
|
||||
if (!result.ok) return back("failed");
|
||||
|
||||
cookies.set("gk-inquiry", JSON.stringify(inquirySent(q)), flashCookie(url));
|
||||
return redirect("/inquiries/sent", 303);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* POST /api/orders: the Store's order request form (works without script).
|
||||
* Checks the post, the rate limit and Turnstile, then re-checks the line
|
||||
* against the live store notes: an order is taken only when its category's
|
||||
* ordersOpen is true and the line has an open slot or stock, whatever the
|
||||
* post says. Then it saves a copy, emails Greg, and redirects (303) to
|
||||
* /store/order/sent with the gk-order cookie. No payment is taken.
|
||||
*/
|
||||
import type { APIRoute } from "astro";
|
||||
import {
|
||||
checkOrder,
|
||||
clientIp,
|
||||
encodeDraft,
|
||||
formValues,
|
||||
isBot,
|
||||
orderEmail,
|
||||
orderFields,
|
||||
orderSent,
|
||||
saveAndSend,
|
||||
TURNSTILE_FIELD,
|
||||
validateOrderFields,
|
||||
type ErrorCode,
|
||||
type FieldErrors,
|
||||
} from "../../lib/forms.ts";
|
||||
import { flashCookie, liveStore, orderLimiter, orderTo, reportDelivery, saveSubmission, sendMail, verifyTurnstile } from "../../lib/forms-server.ts";
|
||||
import type { StoreState } from "../../lib/store.ts";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export const POST: APIRoute = async ({ request, cookies, redirect, url, clientAddress }) => {
|
||||
let form: FormData;
|
||||
try {
|
||||
form = await request.formData();
|
||||
} catch {
|
||||
return redirect("/store/order?error=invalid", 303);
|
||||
}
|
||||
const line = String(form.get("line") ?? "");
|
||||
const back = (code: ErrorCode, errors: FieldErrors = {}) => {
|
||||
cookies.set("gk-order-draft", encodeDraft({ code, errors, values: formValues(form) }), flashCookie(url));
|
||||
const q = new URLSearchParams({ error: code });
|
||||
if (/^[a-z0-9-]{1,120}$/.test(line)) q.set("line", line);
|
||||
return redirect(`/store/order?${q}`, 303);
|
||||
};
|
||||
|
||||
if (isBot(form)) return redirect("/store/order/sent", 303);
|
||||
|
||||
const fields = validateOrderFields(form);
|
||||
if (!fields.ok) return back(fields.code, fields.errors);
|
||||
|
||||
let ip = "unknown";
|
||||
try {
|
||||
ip = clientIp(request.headers, clientAddress);
|
||||
} catch {
|
||||
ip = clientIp(request.headers);
|
||||
}
|
||||
if (orderLimiter.hit(ip)) return back("rate");
|
||||
if (!(await verifyTurnstile(String(form.get(TURNSTILE_FIELD) ?? ""), ip))) return back("bot");
|
||||
|
||||
// The gate: always the live notes, never the cached copy or the form.
|
||||
let st: StoreState;
|
||||
try {
|
||||
st = await liveStore();
|
||||
} catch (err) {
|
||||
console.error("Could not read the live store notes; turning the order down.", err);
|
||||
return back("unavailable");
|
||||
}
|
||||
const checked = checkOrder(fields.value, form, st);
|
||||
if (!checked.ok) return back(checked.code, checked.errors);
|
||||
const o = checked.value;
|
||||
|
||||
const result = await saveAndSend({
|
||||
save: () => saveSubmission({ kind: "order", type: o.lineNote.slug, name: o.name, email: o.email, fields: orderFields(o) }),
|
||||
email: (savedId) => sendMail({ to: orderTo(), replyTo: { name: o.name, address: o.email }, ...orderEmail(o, savedId) }),
|
||||
report: reportDelivery,
|
||||
});
|
||||
if (!result.ok) return back("failed");
|
||||
|
||||
cookies.set("gk-order", JSON.stringify(orderSent(o)), flashCookie(url));
|
||||
return redirect("/store/order/sent", 303);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* The Shelf: every board game. What is on the table (from the status log),
|
||||
* then the collection, filtered by how it plays, owned, want to play and
|
||||
* favorites, and sorted by last played, A to Z or recently added. Player
|
||||
* counts and play times come from BoardGameGeek later; no filter for them yet.
|
||||
* counts, play times and box art sync from BoardGameGeek (a cover set in the
|
||||
* note wins); no filter for them yet.
|
||||
*/
|
||||
import Divider from "@gk/ui/components/Divider.astro";
|
||||
import FilterGroup from "@gk/ui/components/FilterGroup.astro";
|
||||
|
||||
@@ -15,10 +15,20 @@ import OrderForm from "../../components/store/OrderForm.astro";
|
||||
import { site } from "../../lib/page.ts";
|
||||
import { formatDate } from "../../lib/site.ts";
|
||||
import { canOrder, channelOf, storeState } from "../../lib/store.ts";
|
||||
import { errorMessages, parseDraft, parseErrorCode } from "../../lib/forms.ts";
|
||||
import { turnstileSiteKey } from "../../lib/forms-server.ts";
|
||||
|
||||
const s = await site();
|
||||
const st = storeState(s);
|
||||
const selected = Astro.url.searchParams.get("line") ?? undefined;
|
||||
// A post that was turned down comes back with its reason and the answers given.
|
||||
const draft = parseDraft(Astro.cookies.get("gk-order-draft")?.value);
|
||||
if (draft) {
|
||||
Astro.cookies.delete("gk-order-draft", { path: "/" });
|
||||
Astro.response.headers.set("Cache-Control", "private, no-store");
|
||||
}
|
||||
const errorCode = draft?.code ?? parseErrorCode(Astro.url.searchParams.get("error"));
|
||||
const turnstileKey = turnstileSiteKey();
|
||||
const selected = draft?.values.line ?? Astro.url.searchParams.get("line") ?? undefined;
|
||||
const orderable = st.lines.filter((l) => canOrder(l, st));
|
||||
const selling = st.lines.filter((l) => channelOf(l) === "here");
|
||||
const shown = orderable.length ? orderable : selling;
|
||||
@@ -26,6 +36,7 @@ const turnarounds = [...new Set(st.groups.map(([c]) => st.category(c).turnaround
|
||||
const reopens = st.closed.filter((c) => c.reopens).map((c) => `${c.name} reopens ${formatDate(c.reopens)}.`);
|
||||
---
|
||||
<PageShell title="Order request" family="store" current="store" description="Send an order request for something from the Store.">
|
||||
{turnstileKey && <script slot="head" src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer is:inline></script>}
|
||||
<section class="intro" aria-labelledby="order-h1">
|
||||
<Breadcrumb here="Order request" />
|
||||
<h1 id="order-h1">Order request</h1>
|
||||
@@ -34,9 +45,16 @@ const reopens = st.closed.filter((c) => c.reopens).map((c) => `${c.name} reopens
|
||||
|
||||
<div class="layout">
|
||||
<div class="main">
|
||||
{errorCode && (
|
||||
<div class="problem">
|
||||
<Notice role="alert" title={errorMessages[errorCode]}>
|
||||
{draft && Object.keys(draft.errors).length > 0 ? "The marked answers are below." : ""}
|
||||
</Notice>
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
orderable.length > 0 ? (
|
||||
<OrderForm lines={orderable} selected={selected} storeTerms={st.terms} />
|
||||
<OrderForm lines={orderable} selected={selected} storeTerms={st.terms} values={draft?.values} errors={draft?.errors} turnstileKey={turnstileKey} />
|
||||
) : (
|
||||
<div class="closed">
|
||||
{st.anyOpen ? (
|
||||
@@ -104,6 +122,9 @@ const reopens = st.closed.filter((c) => c.reopens).map((c) => `${c.name} reopens
|
||||
flex: 3 1 600px;
|
||||
min-width: 0;
|
||||
}
|
||||
.problem {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.closed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
---
|
||||
/**
|
||||
* The confirmation after an order request. /api/orders sets the short-lived
|
||||
* `gk-order` cookie (URL-encoded JSON: name, email, line, rows) before it
|
||||
* redirects here; the page shows it once and clears it. Without the cookie
|
||||
* the page still reads correctly.
|
||||
*/
|
||||
import Button from "@gk/ui/components/Button.astro";
|
||||
import PageShell from "@gk/ui/components/PageShell.astro";
|
||||
import Star from "@gk/ui/components/Star.astro";
|
||||
import TickRing from "@gk/ui/components/TickRing.astro";
|
||||
import TickScale from "@gk/ui/components/TickScale.astro";
|
||||
import Breadcrumb from "../../../components/store/Breadcrumb.astro";
|
||||
import { parseOrderSent } from "../../../lib/forms.ts";
|
||||
import { site } from "../../../lib/page.ts";
|
||||
import { storeState } from "../../../lib/store.ts";
|
||||
|
||||
const sent = parseOrderSent(Astro.cookies.get("gk-order")?.value);
|
||||
if (sent) Astro.cookies.delete("gk-order", { path: "/" });
|
||||
Astro.response.headers.set("Cache-Control", "private, no-store");
|
||||
|
||||
const s = await site().catch(() => null);
|
||||
const replyTime = s?.settings()?.replyTime ?? "a few days";
|
||||
const line = sent?.lineSlug && s ? storeState(s).lines.find((l) => l.slug === sent.lineSlug) : undefined;
|
||||
const turnaround = line && s ? storeState(s).category(line.data.category).turnaround : undefined;
|
||||
const rows = [
|
||||
...(sent?.line ? [{ label: "Line", value: sent.line }] : []),
|
||||
...(sent?.rows ?? []),
|
||||
...(sent?.email ? [{ label: "Reply to", value: sent.email }] : []),
|
||||
];
|
||||
const firstName = sent?.name?.split(/\s+/)[0];
|
||||
const steps = [
|
||||
{ title: "I check the request", text: "The details, the options and my queue." },
|
||||
{ title: "I confirm with you", text: "The total and the timing, by email. No payment is taken through the site." },
|
||||
{ title: "I start once you say yes", text: turnaround ?? "Nothing is made until we agree." },
|
||||
];
|
||||
---
|
||||
<PageShell title="Order request sent" family="store" current="store" description="Your order request is on its way.">
|
||||
<meta slot="head" name="robots" content="noindex" />
|
||||
<section class="intro" aria-labelledby="sent-h1">
|
||||
<Breadcrumb here="Request sent" />
|
||||
<h1 id="sent-h1">Your request is in</h1>
|
||||
<p class="lede">Thanks{firstName ? `, ${firstName}` : ""}. I read every order request myself and reply within {replyTime}.</p>
|
||||
</section>
|
||||
|
||||
<div class="layout">
|
||||
<div class="main">
|
||||
<ol class="steps" aria-label="What happens next">
|
||||
{
|
||||
steps.map((st, i) => (
|
||||
<li>
|
||||
<TickRing size={36}>{i + 1}</TickRing>
|
||||
<span class="step-text">
|
||||
<span class="step-title">{st.title}</span>
|
||||
<span class="step-detail">{st.text}</span>
|
||||
</span>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ol>
|
||||
<div class="actions">
|
||||
<Button href="/store">Back to the Store</Button>
|
||||
<Button variant="secondary" href="/inquiries">Something custom? Start an inquiry</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{
|
||||
rows.length > 0 && (
|
||||
<aside class="sent" aria-labelledby="sent-heading">
|
||||
<h2 id="sent-heading" class="kicker">
|
||||
<Star size={11} />
|
||||
What you sent
|
||||
</h2>
|
||||
<TickScale width={150} height={10} count={30} class="ticks" />
|
||||
<dl>
|
||||
{rows.map((r) => (
|
||||
<div class="row">
|
||||
<dt>{r.label}</dt>
|
||||
<dd>{r.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PageShell>
|
||||
|
||||
<style>
|
||||
.intro {
|
||||
padding: 36px 0 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(32px, 5vw, 44px);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.lede {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--gk-soft);
|
||||
max-width: 64ch;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.layout {
|
||||
padding: 16px 0 64px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 32px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.main {
|
||||
flex: 3 1 480px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
.steps {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
border-bottom: 1px solid var(--gk-lines);
|
||||
}
|
||||
.steps li {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
padding: 14px 0;
|
||||
border-top: 1px solid var(--gk-lines);
|
||||
font-size: 13px;
|
||||
}
|
||||
.step-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.step-title {
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
}
|
||||
.step-detail {
|
||||
font-size: 14px;
|
||||
color: var(--gk-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.sent {
|
||||
flex: 1 1 300px;
|
||||
max-width: 420px;
|
||||
min-width: 0;
|
||||
padding: 24px;
|
||||
border-radius: var(--gk-radius);
|
||||
background: var(--gk-midnight);
|
||||
border: 1px solid rgba(201, 162, 74, 0.55);
|
||||
}
|
||||
.kicker {
|
||||
margin: 0 0 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: var(--gk-font-body);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 600;
|
||||
color: var(--gk-gold-bright);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.sent :global(.ticks) {
|
||||
margin: -4px 0 2px;
|
||||
}
|
||||
dl {
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 10px 0;
|
||||
border-top: 1px solid var(--gk-lines);
|
||||
font-size: 14px;
|
||||
}
|
||||
dt {
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
dd {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.sent {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -11,7 +11,7 @@ import WorldSection from "../components/world/WorldSection.astro";
|
||||
import type { Note } from "../lib/content.ts";
|
||||
import { plural, site } from "../lib/page.ts";
|
||||
import { slugify } from "@gk/schema";
|
||||
import { byOrder, deckUrl, makePhotos, sentence, stancePill, systemPill, systemStates } from "../lib/world.ts";
|
||||
import { byOrder, deckArt, deckColors, deckCommanders, deckUrl, makePhotos, sentence, stancePill, systemPill, systemStates } from "../lib/world.ts";
|
||||
|
||||
const s = await site();
|
||||
const text = s.pageText("/tabletop");
|
||||
@@ -151,7 +151,7 @@ const workshopImg = s.all("make").flatMap((m) => makePhotos(m))[0];
|
||||
const game = gameOf(d);
|
||||
return (
|
||||
<li id={d.slug} class="deck" data-value={deckGroup(d)}>
|
||||
<StarChartArt seal="astrolabe" ratio="auto" class="d-art" />
|
||||
<StarChartArt seal="astrolabe" ratio="auto" class="d-art" src={deckArt(d)} alt={deckCommanders(d).join(" and ")} />
|
||||
<div class="d-txt">
|
||||
<span class="d-kick">{[shortGame(game.title), d.data.format].filter(Boolean).join(" · ")}</span>
|
||||
<div class="e-top">
|
||||
@@ -167,6 +167,7 @@ const workshopImg = s.all("make").flatMap((m) => makePhotos(m))[0];
|
||||
</h3>
|
||||
{deckOfMoment?.path === d.path && <StatusPill>Playing now</StatusPill>}
|
||||
</div>
|
||||
{deckColors(d).length > 0 && <p class="d-colors">{deckColors(d).join(" · ")}</p>}
|
||||
{d.data.take && <p class="e-blurb">{d.data.take}</p>}
|
||||
{url && (
|
||||
<a href={url} class="gk-text d-link" rel="noopener" aria-label={`${d.title} deck list`}>
|
||||
@@ -401,6 +402,11 @@ const workshopImg = s.all("make").flatMap((m) => makePhotos(m))[0];
|
||||
.d-name a:hover {
|
||||
color: var(--gk-gold-bright);
|
||||
}
|
||||
.d-colors {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--gk-muted);
|
||||
}
|
||||
.d-link {
|
||||
align-self: flex-start;
|
||||
font-size: 13px;
|
||||
|
||||
Reference in New Issue
Block a user