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:
Claude
2026-10-11 23:50:06 +00:00
parent 60dfeecd7f
commit e3b0cf60c5
76 changed files with 6050 additions and 86 deletions
@@ -11,7 +11,7 @@ import StarChartArt from "@gk/ui/components/StarChartArt.astro";
import StatusPill from "@gk/ui/components/StatusPill.astro";
import StatusChip from "../StatusChip.astro";
import { formatDate, mediaSrc, type SiteIndex } from "../../lib/site.ts";
import type { Game } from "../../lib/library.ts";
import { gamePlayers, gamePlayTime, type Game } from "../../lib/library.ts";
interface Props {
game: Game;
@@ -22,6 +22,8 @@ const { game, site, hidden } = Astro.props;
const d = game.data;
const plays = d.howItPlays ?? [];
const status = site.statusFor(game);
// Players and play time sync from BoardGameGeek; nothing shows until they have.
const facts = [gamePlayers(game), gamePlayTime(game)].filter(Boolean);
---
<li
id={game.slug}
@@ -43,6 +45,7 @@ const status = site.statusFor(game);
{d.favorite && <><Star size={12} /><span class="gk-visually-hidden">Favorite</span></>}
</h3>
<p class="blurb">{d.blurb}</p>
{facts.length > 0 && <p class="facts">{facts.join(" · ")}</p>}
{
(status || d.wantToPlay || !d.owned || d.lastPlayed) && (
<div class="states">
@@ -110,6 +113,11 @@ const status = site.statusFor(game);
line-height: 1.45;
color: var(--gk-muted);
}
.facts {
margin: 0;
font-size: 12px;
color: var(--gk-soft);
}
.states {
display: flex;
flex-wrap: wrap;
@@ -10,7 +10,7 @@ import Star from "@gk/ui/components/Star.astro";
import StarChartArt from "@gk/ui/components/StarChartArt.astro";
import StatusChip from "../StatusChip.astro";
import { mediaSrc, type SiteIndex } from "../../lib/site.ts";
import { mediaGroupOf, mediumLabel, sectionOf, type Item } from "../../lib/library.ts";
import { itemDetails, mediaGroupOf, mediumLabel, sectionOf, type Item } from "../../lib/library.ts";
interface Props {
item: Item;
@@ -22,7 +22,7 @@ const d = item.data;
const status = site.statusFor(item);
const post = site.backlinks(item, { type: "post" })[0];
const postHref = post && site.href(post);
const details = [d.creator, d.year, d.platform, d.progress, d.group].filter((x) => x !== undefined && x !== "");
const details = itemDetails(item);
---
<li
id={item.slug}
@@ -2,12 +2,15 @@
/**
* The order request form. Posts to /api/orders. Field names:
* name, email, line (a line slug), option.<line slug>.<group> (an option
* name), quantity, details, notes, terms ("accepted").
* name), quantity, details, notes, terms ("accepted"), plus the honeypot and,
* when a Turnstile site key is set, the widget's cf-turnstile-response.
* `values` and `errors` refill the form after a post was turned down.
* Without JS every line's options show, labelled with their line; a small
* script shows only the chosen line's options and terms.
*/
import Button from "@gk/ui/components/Button.astro";
import Field from "@gk/ui/components/Field.astro";
import { HONEYPOT } from "../../lib/forms.ts";
import Star from "@gk/ui/components/Star.astro";
import { optionGroups, type Line, type Term } from "../../lib/store.ts";
import Terms from "./Terms.astro";
@@ -16,20 +19,23 @@ interface Props {
lines: Line[];
selected?: string;
storeTerms: Term[];
values?: Record<string, string>;
errors?: Record<string, string>;
turnstileKey?: string;
}
const { lines, selected, storeTerms } = Astro.props;
const { lines, selected, storeTerms, values: v = {}, errors: err = {}, turnstileKey } = Astro.props;
const chosen = lines.find((l) => l.slug === selected)?.slug ?? (lines.length === 1 ? lines[0]!.slug : undefined);
const slugPart = (x: string) => x.toLowerCase().replace(/[^a-z0-9]+/g, "-");
---
<form method="post" action="/api/orders" class="order" aria-labelledby="order-title" data-order-form>
<h2 id="order-title" class="gk-visually-hidden">Order request form</h2>
<div class="row">
<Field label="Name" name="name" autocomplete="name" required class="half" />
<Field label="Email" name="email" type="email" autocomplete="email" required class="half" />
<Field label="Name" name="name" autocomplete="name" required maxlength={120} class="half" value={v.name} error={err.name} />
<Field label="Email" name="email" type="email" autocomplete="email" required maxlength={200} class="half" value={v.email} error={err.email} />
</div>
<fieldset>
<legend>What would you like?</legend>
<legend>What would you like?{err.line && <span class="err"> {err.line}</span>}</legend>
<div class="choices">
{
lines.map((l) => (
@@ -49,11 +55,12 @@ const slugPart = (x: string) => x.toLowerCase().replace(/[^a-z0-9]+/g, "-");
<fieldset class="opts" data-line={l.slug} hidden={chosen ? l.slug !== chosen : undefined} disabled={chosen ? l.slug !== chosen : undefined}>
<legend>
{group} {lines.length > 1 && <span class="for">({l.title} only)</span>}
{err[`option.${l.slug}.${group}`] && <span class="err"> {err[`option.${l.slug}.${group}`]}</span>}
</legend>
<div class="choices">
{opts.map((o, i) => (
<label class:list={["gk-filter", "swatch", { out: !o.inStock }]}>
<input type="radio" name={`option.${l.slug}.${group}`} value={o.name} disabled={!o.inStock} id={`opt-${l.slug}-${slugPart(group)}-${i}`} />
<input type="radio" name={`option.${l.slug}.${group}`} value={o.name} disabled={!o.inStock} checked={o.inStock && v[`option.${l.slug}.${group}`] === o.name} id={`opt-${l.slug}-${slugPart(group)}-${i}`} />
{o.hex && <span class="dot" style={`background:${o.hex}`} aria-hidden="true" />}
{o.name}
{!o.inStock && <span class="stock">(out of stock)</span>}
@@ -65,16 +72,18 @@ const slugPart = (x: string) => x.toLowerCase().replace(/[^a-z0-9]+/g, "-");
)
}
<Field label="Approximate quantity" name="quantity" placeholder="e.g. 75 cards, or 1 deck box" required />
<Field
label="Card list or deck box spec"
name="details"
as="textarea"
rows={6}
hint="For cards, paste your full card list, one per line."
class="big"
/>
<Field label="Notes (optional)" name="notes" as="textarea" rows={3} />
<Field label="Approximate quantity" name="quantity" placeholder="e.g. 75 cards, or 1 deck box" required maxlength={200} value={v.quantity} error={err.quantity} />
<div class="gk-label big">
<label class="gk-label-text" for="f-details">Card list or deck box spec</label>
<textarea id="f-details" name="details" class="gk-field" rows={6} maxlength={20000} aria-describedby="f-details-hint">{v.details ?? ""}</textarea>
<span class="gk-hint" id="f-details-hint">For cards, paste your full card list, one per line.</span>
{err.details && <span class="err">{err.details}</span>}
</div>
<div class="gk-label">
<label class="gk-label-text" for="f-notes">Notes (optional)</label>
<textarea id="f-notes" name="notes" class="gk-field" rows={3} maxlength={5000}>{v.notes ?? ""}</textarea>
{err.notes && <span class="err">{err.notes}</span>}
</div>
{
(lines.some((l) => l.data.terms?.length) || storeTerms.length > 0) && (
@@ -102,9 +111,18 @@ const slugPart = (x: string) => x.toLowerCase().replace(/[^a-z0-9]+/g, "-");
<input type="checkbox" name="terms" value="accepted" required />
<span>I have read and accept the order terms for this request.</span>
</label>
{err.terms && <span class="err">{err.terms}</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="tnote">The bot check needs JavaScript. Turn it on for this page to send the form.</span></noscript>}
<div class="submit">
<Button type="submit">Send order request</Button>
{turnstileKey && <span class="tnote">Protected by Cloudflare Turnstile.</span>}
</div>
</form>
@@ -280,6 +298,22 @@ const slugPart = (x: string) => x.toLowerCase().replace(/[^a-z0-9]+/g, "-");
align-items: center;
gap: 12px;
}
.err {
font-size: 12px;
font-weight: 400;
color: #f0857a;
}
.tnote {
font-size: 13px;
color: var(--gk-muted);
}
.hp {
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
}
@media (max-width: 640px) {
.order {
padding: 22px 16px;
+19 -1
View File
@@ -20,6 +20,24 @@ export interface Note<T extends NoteType = NoteType> {
data: NoteData<T>;
body: string;
acceptedAt: string;
/**
* What the content service's sync jobs fetched for this note (BoardGameGeek,
* Archidekt, cover art). Already merged under `data` by `withSynced`, so
* pages read `data`; kept here so a page can tell a synced value apart.
*/
synced?: Record<string, unknown>;
}
/**
* Lay the synced values under the note's own: a value set in the note always
* wins, and a note with nothing synced is returned as it came.
*/
export function withSynced<T extends Note>(note: T): T {
const synced = note.synced;
if (!synced || !Object.keys(synced).length) return note;
const data: Record<string, unknown> = { ...synced };
for (const [k, v] of Object.entries(note.data as Record<string, unknown>)) if (v !== undefined && v !== null) data[k] = v;
return { ...note, data: data as T["data"] };
}
export interface Redirect {
@@ -48,7 +66,7 @@ async function loadAll(): Promise<SiteIndex> {
for (let offset = 0; ; offset += 1000) {
const published = includeDrafts() ? "" : "&published=true";
const page = await api<(Note & { publish?: boolean })[]>(`/api/notes?full=true${published}&limit=1000&offset=${offset}`);
notes.push(...page);
notes.push(...page.map(withSynced));
if (page.length < 1000) break;
}
const redirects = await api<Redirect[]>("/api/redirects").catch(() => []);
+155
View File
@@ -0,0 +1,155 @@
/**
* What the form endpoints do outside the process: Turnstile, the content
* service (live store notes, saving the copy, recording delivery) and SMTP.
* Settings come from process.env through env(); see services/site/.env.example.
*/
import type { Note } from "./content.ts";
import { env } from "./env.ts";
import { DEFAULT_ATTACHMENT_MB, RateLimiter, type Row, type Upload } from "./forms.ts";
import { SiteIndex } from "./site.ts";
import { storeState, type StoreState } from "./store.ts";
const contentUrl = () => env("CONTENT_URL", "http://localhost:4310").replace(/\/$/, "");
export const maxAttachmentBytes = () => Math.round(Number(env("FORM_MAX_ATTACHMENT_MB", String(DEFAULT_ATTACHMENT_MB))) * 1024 * 1024);
export const inquiryLimiter = new RateLimiter(
() => Number(env("CONTACT_RATE_LIMIT", "5")),
() => Number(env("CONTACT_RATE_WINDOW_MIN", "15")) * 60_000,
);
export const orderLimiter = new RateLimiter(
() => Number(env("ORDER_RATE_LIMIT", env("CONTACT_RATE_LIMIT", "5"))),
() => Number(env("ORDER_RATE_WINDOW_MIN", env("CONTACT_RATE_WINDOW_MIN", "15"))) * 60_000,
);
/** The public site key, for the widget. */
export const turnstileSiteKey = () => env("PUBLIC_TURNSTILE_SITE_KEY") ?? env("TURNSTILE_SITE_KEY");
/**
* Cloudflare Turnstile. Skipped when TURNSTILE_SECRET_KEY is unset (local
* development); when it is set, a missing token, a bad token or an error
* talking to Cloudflare all turn the post down.
*/
export async function verifyTurnstile(token: string, ip: string, fetcher: typeof fetch = fetch): Promise<boolean> {
const secret = env("TURNSTILE_SECRET_KEY");
if (!secret) return true;
if (!token) return false;
try {
const body = new URLSearchParams({ secret, response: token });
if (ip && ip !== "unknown") body.set("remoteip", ip);
const res = await fetcher("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
body,
signal: AbortSignal.timeout(8000),
});
const data = (await res.json()) as { success?: boolean };
return data.success === true;
} catch (err) {
console.error("Turnstile verification failed; turning the post down.", err);
return false;
}
}
/**
* The store's notes as they are right now, straight from the content
* service rather than the site's cached copy, so an order is checked against
* what Greg last saved. Throws when the service cannot answer.
*/
export async function liveStore(): Promise<StoreState> {
const get = async (type: string) => {
const res = await fetch(`${contentUrl()}/api/notes?type=${type}&full=true&published=true&limit=1000`, {
headers: { authorization: `Bearer ${env("CONTENT_TOKEN", "")}` },
signal: AbortSignal.timeout(8000),
});
if (!res.ok) throw new Error(`Content service answered ${res.status} for ${type}`);
return (await res.json()) as Note[];
};
const [settings, lines] = await Promise.all([get("store-settings"), get("product-line")]);
return storeState(new SiteIndex([...settings, ...lines]));
}
export interface SubmissionInput {
kind: "inquiry" | "order";
type: string;
name: string;
email: string;
fields: Row[];
attachment?: Upload | null;
}
const intake = async (path: string, body: unknown) => {
const token = env("CONTENT_INTAKE_TOKEN");
if (!token) throw new Error("CONTENT_INTAKE_TOKEN is not set");
const res = await fetch(`${contentUrl()}${path}`, {
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(20_000),
});
const data = (await res.json().catch(() => null)) as Record<string, unknown> | null;
if (!res.ok) throw new Error(`Content service answered ${res.status}: ${String(data?.error ?? "")}`);
return data;
};
/** Save the copy with the intake token. Returns the id and the receipt for reporting delivery. */
export async function saveSubmission(s: SubmissionInput): Promise<{ id: number; receipt: string }> {
const attachment = s.attachment ? { name: s.attachment.name, data: Buffer.from(await s.attachment.arrayBuffer()).toString("base64") } : undefined;
const out = (await intake("/api/submissions", { ...s, attachment })) as { id: number; receipt: string };
return { id: out.id, receipt: out.receipt };
}
export async function reportDelivery(id: number, receipt: string, status: "sent" | "skipped" | "failed", error?: string) {
await intake(`/api/submissions/${id}/delivery`, { receipt, status, ...(error ? { error } : {}) });
}
export interface Mail {
to: string;
replyTo: { name: string; address: string };
subject: string;
text: string;
attachment?: Upload | null;
}
/**
* Send through SMTP (nodemailer). "skipped" when SMTP_HOST is unset, so a
* site without mail still saves copies. Throws when sending fails.
*/
export async function sendMail(m: Mail): Promise<"sent" | "skipped"> {
const host = env("SMTP_HOST");
if (!host) return "skipped";
const nodemailer = (await import("nodemailer")).default;
const port = Number(env("SMTP_PORT", "587"));
const user = env("SMTP_USER");
const transport = nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: user ? { user, pass: env("SMTP_PASS", "") } : undefined,
// Only for a relay on the same private network that presents a self-signed certificate.
...(env("SMTP_ALLOW_SELFSIGNED") === "true" ? { tls: { rejectUnauthorized: false } } : {}),
connectionTimeout: 10_000,
greetingTimeout: 10_000,
socketTimeout: 20_000,
});
await transport.sendMail({
from: env("SMTP_FROM", "GregKilmer.com <no-reply@gregkilmer.com>"),
to: m.to,
replyTo: m.replyTo,
subject: m.subject.replace(/[\r\n]+/g, " "),
text: m.text,
attachments: m.attachment ? [{ filename: m.attachment.name, content: Buffer.from(await m.attachment.arrayBuffer()) }] : undefined,
});
return "sent";
}
export const contactTo = () => env("CONTACT_TO", "contact@gregkilmer.com");
export const orderTo = () => env("ORDER_TO", "orders@gregkilmer.com");
/** Short-lived cookie settings for the confirmation and try-again cookies. */
export const flashCookie = (url: URL) => ({
path: "/",
httpOnly: true,
sameSite: "lax" as const,
secure: import.meta.env.PROD || url.protocol === "https:",
maxAge: 600,
});
+476
View File
@@ -0,0 +1,476 @@
/**
* The inquiry and order forms' rules, as plain functions: what a valid post
* looks like, what an order may be accepted for, what the confirmation and
* the "try again" pages are handed, and how saving and emailing combine.
* Nothing here touches the network, so tests run it without Astro.
*/
import type { InquiryType, SentSummary } from "./work.ts";
import { parseInquiryType } from "./work.ts";
import { channelOf, hasCapacity, optionGroups, type Line, type StoreState } from "./store.ts";
export interface Row {
label: string;
value: string;
}
/** What a form post gives: FormData, or anything shaped like it in tests. */
export interface FormLike {
get(name: string): FormDataEntryValue | null;
}
export type FieldErrors = Record<string, string>;
export type Invalid = { ok: false; code: ErrorCode; errors: FieldErrors };
export type ErrorCode = "invalid" | "bot" | "rate" | "closed" | "full" | "unavailable" | "failed";
/** What the visitor reads for each kind of failure. Nothing here leaks internals. */
export const errorMessages: Record<ErrorCode, string> = {
invalid: "Some answers need another look. Nothing was sent yet.",
bot: "The bot check did not go through. Please try again.",
rate: "Too many requests from here in a short time. Please try again a little later.",
closed: "Orders for that are closed right now, so nothing was sent.",
full: "That line has no open slots right now, so nothing was sent.",
unavailable: "The store could not confirm availability just now. Please try again in a few minutes.",
failed: "Your request could not be sent just now. Please try again a little later.",
};
export const parseErrorCode = (raw: string | null | undefined): ErrorCode | undefined =>
raw && raw in errorMessages ? (raw as ErrorCode) : undefined;
const text = (form: FormLike, key: string): string => {
const v = form.get(key);
return typeof v === "string" ? v.replace(/\r\n/g, "\n").trim() : "";
};
const EMAIL = /^[^\s@<>()[\]\\,;:"]+@[^\s@<>()[\]\\,;:"]+\.[^\s@<>()[\]\\,;:"]{2,}$/;
export const isEmail = (s: string) => s.length <= 200 && EMAIL.test(s);
export const isHttpUrl = (s: string) => {
try {
const u = new URL(s);
return (u.protocol === "http:" || u.protocol === "https:") && !!u.hostname;
} catch {
return false;
}
};
const isDate = (s: string) => /^\d{4}-\d{2}-\d{2}$/.test(s) && !Number.isNaN(Date.parse(`${s}T00:00:00Z`));
/** The hidden field bots fill and people never see. */
export const HONEYPOT = "leave_blank";
export const isBot = (form: FormLike) => text(form, HONEYPOT) !== "";
/** The Turnstile widget adds this field to the form it sits in. */
export const TURNSTILE_FIELD = "cf-turnstile-response";
// ---------------------------------------------------------------- attachments
/** Matches the content service's list (services/content/src/submissions.ts). */
export const ATTACHMENT_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "avif", "heic", "pdf", "txt", "csv", "dek", "dec"];
export const DEFAULT_ATTACHMENT_MB = 10;
export interface Upload {
name: string;
size: number;
type: string;
arrayBuffer(): Promise<ArrayBuffer>;
}
const isUpload = (v: unknown): v is Upload =>
typeof v === "object" && v !== null && typeof (v as Upload).arrayBuffer === "function" && typeof (v as Upload).size === "number";
/** The attached file, or null for none; an error message when it cannot be taken. */
export function checkAttachment(form: FormLike, key: string, maxBytes: number): { file: Upload | null; error?: string } {
const v = form.get(key);
if (!isUpload(v) || v.size === 0) return { file: null };
const ext = /\.([A-Za-z0-9]{1,8})$/.exec(v.name)?.[1]?.toLowerCase();
if (!ext || !ATTACHMENT_EXTENSIONS.includes(ext)) {
return { file: null, error: "Attach an image, a PDF, or a text, CSV or deck list file." };
}
if (v.size > maxBytes) return { file: null, error: `Attachments can be up to ${Math.floor(maxBytes / 1024 / 1024)} MB.` };
return { file: v };
}
// ---------------------------------------------------------------- inquiries
export const choices = {
job_kind: ["Full-time", "Contract", "Not sure yet"],
freelance_need: ["A new site", "An app", "An integration", "Something else"],
patiun_item: ["Proxy cards", "Deck box", "Something else"],
} as const;
export const timing = [
{ value: "no-rush", label: "No rush" },
{ value: "within-a-month", label: "Within a month" },
{ value: "date", label: "A specific date" },
];
type Question =
| { key: string; label: string; kind: "text"; max: number }
| { key: string; label: string; kind: "url" }
| { key: string; label: string; kind: "choice"; options: readonly string[] }
| { key: string; label: string; kind: "timing"; dateKey: string };
/** Each type's own questions, in form order. Fields of other types are ignored. */
export const questions: Record<InquiryType, Question[]> = {
job: [
{ key: "job_company", label: "Company", kind: "text", max: 160 },
{ key: "job_role", label: "Role", kind: "text", max: 160 },
{ key: "job_kind", label: "Kind of role", kind: "choice", options: choices.job_kind },
{ key: "job_link", label: "Posting", kind: "url" },
],
freelance: [
{ key: "freelance_need", label: "Needs", kind: "choice", options: choices.freelance_need },
{ key: "freelance_site", label: "Current site", kind: "url" },
{ key: "freelance_timing", label: "Timeline", kind: "timing", dateKey: "freelance_date" },
],
patiun: [
{ key: "patiun_item", label: "Item", kind: "choice", options: choices.patiun_item },
{ key: "patiun_quantity", label: "How many", kind: "text", max: 120 },
{ key: "patiun_timing", label: "Need it by", kind: "timing", dateKey: "patiun_date" },
],
};
export interface Inquiry {
type: InquiryType;
name: string;
email: string;
details: string;
/** The chosen type's answers, in form order, empty ones left out. */
rows: Row[];
attachment: Upload | null;
}
const formatDay = (d: string) => new Date(`${d}T00:00:00Z`).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
export function validateInquiry(form: FormLike, opts: { maxBytes: number }): { ok: true; value: Inquiry } | Invalid {
const errors: FieldErrors = {};
const type = parseInquiryType(text(form, "type"));
if (!type) errors.type = "Pick what this is about.";
const name = text(form, "name");
if (!name) errors.name = "Add your name.";
else if (name.length > 120) errors.name = "Keep your name under 120 characters.";
const email = text(form, "email");
if (!isEmail(email)) errors.email = "Add an email address I can reply to.";
const details = text(form, "details");
if (details.length < 10) errors.details = "Tell me a little more: a sentence or two is plenty.";
else if (details.length > 5000) errors.details = "Keep the details under 5,000 characters, or attach a file.";
const rows: Row[] = [];
for (const q of type ? questions[type] : []) {
const v = text(form, q.key);
if (!v) continue;
if (q.kind === "text") {
if (v.length > q.max) errors[q.key] = `Keep this under ${q.max} characters.`;
else rows.push({ label: q.label, value: v });
} else if (q.kind === "url") {
if (v.length > 500 || !isHttpUrl(v)) errors[q.key] = "Use a full link starting with https://.";
else rows.push({ label: q.label, value: v });
} else if (q.kind === "choice") {
if (!q.options.includes(v)) errors[q.key] = "Pick one of the options.";
else rows.push({ label: q.label, value: v });
} else {
const t = timing.find((x) => x.value === v);
if (!t) errors[q.key] = "Pick one of the options.";
else if (t.value === "date") {
const d = text(form, q.dateKey);
if (!isDate(d)) errors[q.dateKey] = "Pick the date.";
else rows.push({ label: q.label, value: formatDay(d) });
} else rows.push({ label: q.label, value: t.label });
}
}
const att = checkAttachment(form, "attachment", opts.maxBytes);
if (att.error) errors.attachment = att.error;
if (Object.keys(errors).length || !type) return { ok: false, code: "invalid", errors };
return { ok: true, value: { type, name, email, details, rows, attachment: att.file } };
}
const inquiryTypeLabels: Record<InquiryType, string> = { job: "A job or role", freelance: "Freelance web work", patiun: "Patiun custom order" };
/** Everything the saved copy and the email carry, in order. */
export const inquiryFields = (q: Inquiry): Row[] => [...q.rows, { label: "Details", value: q.details }];
export function inquiryEmail(q: Inquiry, savedId: number | null): { subject: string; text: string } {
return {
subject: `Inquiry: ${inquiryTypeLabels[q.type]} from ${q.name}`,
text: [
`New inquiry through gregkilmer.com`,
``,
`Type: ${inquiryTypeLabels[q.type]}`,
`Name: ${q.name}`,
`Email: ${q.email}`,
...q.rows.map((r) => `${r.label}: ${r.value}`),
``,
`Details:`,
q.details,
``,
q.attachment ? `Attached: ${q.attachment.name}` : "",
savedId ? `Saved in the admin inbox as #${savedId}.` : `Not saved in the admin inbox: the content service did not take it. This email is the only copy.`,
].join("\n"),
};
}
/** The `gk-inquiry` cookie the sent page reads: what the visitor sent, briefly. */
export function inquirySent(q: Inquiry): SentSummary {
const short = (v: string) => (v.length > 120 ? `${v.slice(0, 119)}…` : v);
return {
type: q.type,
name: q.name,
email: q.email,
rows: [...q.rows.map((r) => ({ label: r.label, value: short(r.value) })), ...(q.attachment ? [{ label: "Attachment", value: short(q.attachment.name) }] : [])].slice(0, 6),
};
}
// ---------------------------------------------------------------- orders
export interface OrderRequest {
name: string;
email: string;
line: string;
quantity: string;
details: string;
notes: string;
}
/** Check the fields that do not depend on the store. */
export function validateOrderFields(form: FormLike): { ok: true; value: OrderRequest } | Invalid {
const errors: FieldErrors = {};
const name = text(form, "name");
if (!name) errors.name = "Add your name.";
else if (name.length > 120) errors.name = "Keep your name under 120 characters.";
const email = text(form, "email");
if (!isEmail(email)) errors.email = "Add an email address I can reply to.";
const line = text(form, "line");
if (!/^[a-z0-9-]{1,120}$/.test(line)) errors.line = "Pick what you would like.";
const quantity = text(form, "quantity");
if (!quantity) errors.quantity = "Give a rough quantity.";
else if (quantity.length > 200) errors.quantity = "Keep this under 200 characters.";
const details = text(form, "details");
if (details.length > 20_000) errors.details = "Keep this under 20,000 characters.";
const notes = text(form, "notes");
if (notes.length > 5000) errors.notes = "Keep notes under 5,000 characters.";
if (text(form, "terms") !== "accepted") errors.terms = "Read and accept the order terms to send a request.";
if (Object.keys(errors).length) return { ok: false, code: "invalid", errors };
return { ok: true, value: { name, email, line, quantity, details, notes } };
}
export interface CheckedOrder extends OrderRequest {
lineNote: Line;
/** The chosen options, as { label: group, value: option }. */
options: Row[];
}
/**
* Whether the store takes this order now, against the live notes. An order
* is accepted only for a line sold here, in a category with ordersOpen true,
* with an open slot or stock, and only with options that exist and are in
* stock. Anything else is turned down, whatever the form said.
*/
export function checkOrder(req: OrderRequest, form: FormLike, st: StoreState): { ok: true; value: CheckedOrder } | Invalid {
const line = st.lines.find((l) => l.slug === req.line);
if (!line || channelOf(line) !== "here") return { ok: false, code: "invalid", errors: { line: "That is not something you can order here." } };
if (!st.category(line.data.category).ordersOpen) return { ok: false, code: "closed", errors: {} };
if (!hasCapacity(line)) return { ok: false, code: "full", errors: {} };
const options: Row[] = [];
const errors: FieldErrors = {};
for (const [group, opts] of optionGroups(line)) {
const key = `option.${line.slug}.${group}`;
const v = text(form, key);
if (!v) continue;
const o = opts.find((x) => x.name === v);
if (!o) errors[key] = `Pick one of the ${group.toLowerCase()} options.`;
else if (!o.inStock) errors[key] = `${o.name} is out of stock. Pick another.`;
else options.push({ label: group, value: o.name });
}
if (Object.keys(errors).length) return { ok: false, code: "invalid", errors };
return { ok: true, value: { ...req, lineNote: line, options } };
}
export const orderFields = (o: CheckedOrder): Row[] => [
{ label: "Line", value: o.lineNote.title },
...o.options,
{ label: "Quantity", value: o.quantity },
...(o.details ? [{ label: "Details", value: o.details }] : []),
...(o.notes ? [{ label: "Notes", value: o.notes }] : []),
{ label: "Terms", value: "Accepted" },
];
export function orderEmail(o: CheckedOrder, savedId: number | null): { subject: string; text: string } {
return {
subject: `Order request: ${o.lineNote.title} from ${o.name}`,
text: [
`New order request through gregkilmer.com. No payment was taken.`,
``,
`Name: ${o.name}`,
`Email: ${o.email}`,
...orderFields(o).flatMap((r) => (r.value.includes("\n") ? [``, `${r.label}:`, r.value, ``] : [`${r.label}: ${r.value}`])),
``,
savedId ? `Saved in the admin inbox as #${savedId}.` : `Not saved in the admin inbox: the content service did not take it. This email is the only copy.`,
].join("\n"),
};
}
export interface OrderSent {
name: string;
email: string;
line: string;
lineSlug: string;
rows: Row[];
}
/** The `gk-order` cookie the order confirmation reads. */
export function orderSent(o: CheckedOrder): OrderSent {
const short = (v: string) => (v.length > 120 ? `${v.slice(0, 119)}…` : v);
return {
name: o.name,
email: o.email,
line: o.lineNote.title,
lineSlug: o.lineNote.slug,
rows: [...o.options, { label: "Quantity", value: o.quantity }].map((r) => ({ label: r.label, value: short(r.value) })).slice(0, 6),
};
}
export function parseOrderSent(raw: string | undefined): OrderSent | undefined {
if (!raw) return undefined;
try {
const v = JSON.parse(raw.startsWith("%7B") || raw.startsWith("%7b") ? decodeURIComponent(raw) : raw) as Record<string, unknown>;
const str = (x: unknown) => (typeof x === "string" && x.trim() ? x.trim().slice(0, 300) : "");
const rows = Array.isArray(v.rows)
? v.rows
.map((r) => ({ label: str((r as Row)?.label), value: str((r as Row)?.value) }))
.filter((r) => r.label && r.value)
.slice(0, 8)
: [];
return { name: str(v.name), email: str(v.email), line: str(v.line), lineSlug: str(v.lineSlug), rows };
} catch {
return undefined;
}
}
// ---------------------------------------------------------------- try again
/**
* What goes back to the form when a post is turned down: the error, which
* fields to mark, and the visitor's answers. It travels as a short-lived
* cookie, so it has to fit: the longest answers are cut first.
*/
export interface Draft {
code: ErrorCode;
errors: FieldErrors;
values: Record<string, string>;
}
export const DRAFT_LIMIT = 3600;
/** Every text value a form posted, files and the bot fields left out. */
export function formValues(form: { entries(): IterableIterator<[string, FormDataEntryValue]> } | FormData): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of (form as FormData).entries()) {
if (typeof v !== "string" || k === HONEYPOT || k === TURNSTILE_FIELD || k === "terms") continue;
if (k.length > 160) continue;
out[k] = v.slice(0, 20_000);
}
return out;
}
export function encodeDraft(d: Draft, limit = DRAFT_LIMIT): string {
const values = { ...d.values };
const enc = () => encodeURIComponent(JSON.stringify({ ...d, values }));
let out = enc();
while (out.length > limit) {
const longest = Object.entries(values).sort((a, b) => b[1].length - a[1].length)[0];
if (!longest || longest[1].length === 0) break;
const [k, v] = longest;
values[k] = v.length > 200 ? v.slice(0, Math.floor(v.length / 2)) : "";
out = enc();
}
return JSON.stringify({ ...d, values });
}
export function parseDraft(raw: string | undefined): Draft | undefined {
if (!raw) return undefined;
try {
const v = JSON.parse(raw.startsWith("%7B") || raw.startsWith("%7b") ? decodeURIComponent(raw) : raw) as Record<string, unknown>;
const code = parseErrorCode(typeof v.code === "string" ? v.code : undefined);
if (!code) return undefined;
const strings = (x: unknown) =>
Object.fromEntries(Object.entries(typeof x === "object" && x ? x : {}).filter((e): e is [string, string] => typeof e[1] === "string"));
return { code, errors: strings(v.errors), values: strings(v.values) };
} catch {
return undefined;
}
}
// ---------------------------------------------------------------- send
export type EmailResult = "sent" | "skipped" | "failed";
/**
* Save the copy, then email, then record how the email went. The visitor
* hears it worked when either the saved copy or the email made it; when
* neither did, the form shows an error.
*/
export async function saveAndSend(deps: {
save(): Promise<{ id: number; receipt: string }>;
email(savedId: number | null): Promise<"sent" | "skipped">;
report(id: number, receipt: string, status: EmailResult, error?: string): Promise<void>;
log?(message: string, err: unknown): void;
}): Promise<{ ok: boolean; savedId: number | null; email: EmailResult }> {
const log = deps.log ?? ((m, e) => console.error(m, e));
let saved: { id: number; receipt: string } | null = null;
try {
saved = await deps.save();
} catch (err) {
log("Saving the copy failed; trying the email anyway.", err);
}
let email: EmailResult;
let error: string | undefined;
try {
email = await deps.email(saved?.id ?? null);
} catch (err) {
email = "failed";
error = err instanceof Error ? err.message.slice(0, 500) : String(err).slice(0, 500);
log("Sending the email failed.", err);
}
if (saved) {
await deps.report(saved.id, saved.receipt, email, error).catch((err: unknown) => log("Recording the email result failed.", err));
}
return { ok: !!saved || email === "sent", savedId: saved?.id ?? null, email };
}
// ---------------------------------------------------------------- rate limit
/** N posts per address per window, in memory. Enough for one site process. */
export class RateLimiter {
private readonly hits = new Map<string, { count: number; reset: number }>();
constructor(
private readonly limit: () => number,
private readonly windowMs: () => number,
private readonly now: () => number = Date.now,
) {}
/** Counts this post; true when it is over the limit. */
hit(key: string): boolean {
const t = this.now();
if (this.hits.size > 5000) for (const [k, e] of this.hits) if (t > e.reset) this.hits.delete(k);
const e = this.hits.get(key);
if (!e || t > e.reset) {
this.hits.set(key, { count: 1, reset: t + this.windowMs() });
return false;
}
e.count += 1;
return e.count > this.limit();
}
}
/** The visitor's address: the proxy's X-Forwarded-For first, else the socket's. */
export function clientIp(headers: Headers, fallback?: string): string {
const fwd = headers.get("x-forwarded-for");
if (fwd) return fwd.split(",")[0]!.trim();
return headers.get("x-real-ip") ?? fallback ?? "unknown";
}
+49
View File
@@ -5,6 +5,7 @@
*/
import type { Note } from "./content.ts";
import type { SiteIndex } from "./site.ts";
import { formatDate } from "./site.ts";
export type Item = Note<"library-item">;
export type Game = Note<"board-game">;
@@ -47,6 +48,29 @@ export function byStanding(a: Item, b: Item): number {
);
}
/** Progress PersonalReader syncs onto a linked item (see withSynced in content.ts). */
interface ReaderSynced {
chapterProgress?: { chapter?: number; readAt?: string };
bookProgress?: { percent?: number; readAt?: string };
}
/** "Chapter 42, read Oct 3, 2026" or "62% read", from PersonalReader; undefined when nothing is synced. */
export function readerProgress(item: Item): string | undefined {
const d = item.data as ReaderSynced;
const c = d.chapterProgress;
const b = d.bookProgress;
const what = c?.chapter ? `Chapter ${c.chapter}` : b && typeof b.percent === "number" && b.percent > 0 ? `${b.percent}% read` : undefined;
if (!what) return undefined;
const at = (c?.chapter ? c.readAt : b?.readAt)?.slice(0, 10);
return at ? `${what}, read ${formatDate(at)}` : what;
}
/** The row's details line. The note's own `progress` wins over the synced one. */
export const itemDetails = (item: Item): (string | number)[] => {
const d = item.data;
return [d.creator, d.year, d.platform, d.progress || readerProgress(item), d.group].filter((x): x is string | number => x !== undefined && x !== "");
};
export interface LibraryFilter {
type?: MediaKey;
q: string;
@@ -152,6 +176,31 @@ export function gameShows(g: Game, f: ShelfFilter, text: string): boolean {
return !f.q || text.includes(f.q);
}
/** Values BoardGameGeek syncs onto a game (see withSynced in content.ts). */
interface GameSynced {
minPlayers?: number;
maxPlayers?: number;
playTime?: number;
minPlayTime?: number;
maxPlayTime?: number;
}
const range = (lo?: number, hi?: number) => (lo && hi && lo !== hi ? `${lo}–${hi}` : String(lo || hi || ""));
/** "2–4 players", "1 player" or undefined when BoardGameGeek has not been synced. */
export function gamePlayers(g: Game): string | undefined {
const d = g.data as GameSynced;
const r = range(d.minPlayers, d.maxPlayers);
return r ? `${r} ${r === "1" ? "player" : "players"}` : undefined;
}
/** "60–90 min", "45 min" or undefined. */
export function gamePlayTime(g: Game): string | undefined {
const d = g.data as GameSynced;
const r = d.minPlayTime && d.maxPlayTime ? range(d.minPlayTime, d.maxPlayTime) : range(d.playTime);
return r ? `${r} min` : undefined;
}
/** Every "how it plays" value on the shelf, A to Z. */
export const playStyles = (games: Game[]) =>
[...new Set(games.flatMap((g) => g.data.howItPlays ?? []))].sort((a, b) => a.localeCompare(b));
+37 -4
View File
@@ -118,10 +118,43 @@ export function priceSummary(line: Line): { from: string; note?: string; more: s
return { from: low.price, note: low.note, more };
}
/** Option groups in note order, with their options. */
export function optionGroups(line: Line): [string, NonNullable<Line["data"]["options"]>][] {
const map = new Map<string, NonNullable<Line["data"]["options"]>>();
for (const o of line.data.options ?? []) map.set(o.group, [...(map.get(o.group) ?? []), o]);
export type Option = NonNullable<Line["data"]["options"]>[number] & { inStock: boolean };
/** Stock the content service syncs onto a line: Bambuddy filament, Homebox leather and paper. */
type StockGroup = { group: string; options: { name: string; hex?: string; inStock: boolean }[] };
interface StockSynced {
filamentStock?: StockGroup[];
materialStock?: StockGroup[];
}
/**
* Option groups in note order, with their options. Synced stock sits under
* the note's own values: a note option keeps every value it sets, takes the
* synced `inStock` and `hex` only where it leaves them out (out of stock
* when the source does not have it, in stock when nothing is synced), and
* in-stock colors the note does not list follow its own.
*/
export function optionGroups(line: Line): [string, Option[]][] {
const d = line.data as Line["data"] & StockSynced;
const synced = new Map<string, StockGroup["options"]>();
for (const g of [...(d.filamentStock ?? []), ...(d.materialStock ?? [])]) synced.set(g.group.toLowerCase(), g.options);
const map = new Map<string, Option[]>();
for (const o of line.data.options ?? []) {
const stock = synced.get(o.group.toLowerCase());
const match = stock?.find((x) => x.name.toLowerCase() === o.name.toLowerCase() || (!!o.hex && x.hex?.toLowerCase() === o.hex.toLowerCase()));
const hex = o.hex ?? match?.hex;
map.set(o.group, [...(map.get(o.group) ?? []), { ...o, ...(hex ? { hex } : {}), inStock: o.inStock ?? (stock ? !!match?.inStock : true) }]);
}
for (const [group, opts] of map) {
const stock = synced.get(group.toLowerCase()) ?? [];
const listed = new Set(opts.map((o) => o.name.toLowerCase()));
const listedHex = new Set(opts.map((o) => o.hex?.toLowerCase()).filter(Boolean));
for (const x of stock) {
if (!x.inStock || listed.has(x.name.toLowerCase()) || (x.hex && listedHex.has(x.hex.toLowerCase()))) continue;
opts.push({ group, name: x.name, ...(x.hex ? { hex: x.hex } : {}), inStock: true });
listed.add(x.name.toLowerCase());
}
}
return [...map.entries()];
}
+23
View File
@@ -91,6 +91,29 @@ export function statusesFor(s: SiteIndex, page: string) {
/** Archidekt deck address. */
export const archidektUrl = (id: number) => `https://archidekt.com/decks/${id}`;
/** Values Archidekt syncs onto a deck (see withSynced in content.ts). */
interface DeckSynced {
commanderArt?: string;
colors?: string[];
commanders?: string[];
}
/** The deck's own cover, else its synced commander art. */
export function deckArt(deck: Note<"deck">): string | undefined {
const d = deck.data as DeckSynced & { cover?: string };
return mediaSrc(d.cover ?? d.commanderArt);
}
const COLOR_NAMES: Record<string, string> = { W: "White", U: "Blue", B: "Black", R: "Red", G: "Green" };
/** The deck's color identity as names, WUBRG order; empty when not synced. */
export function deckColors(deck: Note<"deck">): string[] {
return ((deck.data as DeckSynced).colors ?? []).map((c) => COLOR_NAMES[c]).filter((c): c is string => !!c);
}
/** The deck's commanders, when synced. */
export const deckCommanders = (deck: Note<"deck">): string[] => (deck.data as DeckSynced).commanders ?? [];
/** Deck's list address: Archidekt when set, else its own list link. */
export function deckUrl(deck: Note<"deck">): string | undefined {
return deck.data.archidektId ? archidektUrl(deck.data.archidektId) : deck.data.listUrl;
+76
View File
@@ -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);
};
+80
View File
@@ -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);
};
+68 -23
View File
@@ -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;
+2 -1
View File
@@ -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";
+23 -2
View File
@@ -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>
+8 -2
View File
@@ -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;