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,36 @@
|
||||
# Site settings. Set them in the container's environment (never commit real
|
||||
# values). Read from process.env at run time only. See docs/site.md.
|
||||
|
||||
# --- Content service ----------------------------------------------------------
|
||||
CONTENT_URL=http://localhost:4310
|
||||
# Reader token: published notes, and the live store check for orders.
|
||||
CONTENT_TOKEN=
|
||||
# Intake token: files saved copies of inquiries and orders, can read nothing.
|
||||
CONTENT_INTAKE_TOKEN=
|
||||
|
||||
# --- Forms: Cloudflare Turnstile ---------------------------------------------
|
||||
# The widget shows on both forms when the site key is set.
|
||||
PUBLIC_TURNSTILE_SITE_KEY=
|
||||
# When set, a post without a valid token is turned down (fails closed if
|
||||
# Cloudflare cannot be reached). Unset in local development.
|
||||
TURNSTILE_SECRET_KEY=
|
||||
|
||||
# --- Forms: email (nodemailer over SMTP) --------------------------------------
|
||||
# Without SMTP_HOST no email is sent; the saved copy in the admin Inbox stands.
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
SMTP_FROM=GregKilmer.com <no-reply@gregkilmer.com>
|
||||
# Only for a relay on the same private network with a self-signed certificate.
|
||||
SMTP_ALLOW_SELFSIGNED=false
|
||||
CONTACT_TO=contact@gregkilmer.com
|
||||
ORDER_TO=orders@gregkilmer.com
|
||||
|
||||
# --- Forms: limits --------------------------------------------------------------
|
||||
# Posts per visitor address per window.
|
||||
CONTACT_RATE_LIMIT=5
|
||||
CONTACT_RATE_WINDOW_MIN=15
|
||||
# ORDER_RATE_LIMIT and ORDER_RATE_WINDOW_MIN default to the CONTACT_ values.
|
||||
# Largest inquiry attachment in MB; keep it at or under the content service's SUBMISSION_MAX_MB.
|
||||
FORM_MAX_ATTACHMENT_MB=10
|
||||
@@ -16,6 +16,7 @@
|
||||
"@gk/ui": "*",
|
||||
"astro": "7.3.5",
|
||||
"marked": "18.0.14",
|
||||
"nodemailer": "10.0.10",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfkit": "^0.20.2"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(() => []);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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()];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Note } from "../src/lib/content.ts";
|
||||
import {
|
||||
checkOrder,
|
||||
clientIp,
|
||||
DRAFT_LIMIT,
|
||||
encodeDraft,
|
||||
formValues,
|
||||
inquiryFields,
|
||||
inquirySent,
|
||||
isBot,
|
||||
orderFields,
|
||||
orderSent,
|
||||
parseDraft,
|
||||
parseOrderSent,
|
||||
RateLimiter,
|
||||
saveAndSend,
|
||||
validateInquiry,
|
||||
validateOrderFields,
|
||||
} from "../src/lib/forms.ts";
|
||||
import { SiteIndex } from "../src/lib/site.ts";
|
||||
import { storeState } from "../src/lib/store.ts";
|
||||
import { parseSentSummary } from "../src/lib/work.ts";
|
||||
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
const form = (fields: Record<string, string | File>) => {
|
||||
const f = new FormData();
|
||||
for (const [k, v] of Object.entries(fields)) f.append(k, v);
|
||||
return f;
|
||||
};
|
||||
|
||||
const base = { name: "Jane Doe", email: "jane@example.com", details: "A senior role on the platform team." };
|
||||
|
||||
describe("inquiry validation", () => {
|
||||
it("takes a job inquiry and only the job questions", () => {
|
||||
const r = validateInquiry(
|
||||
form({ ...base, type: "job", job_company: "Acme", job_kind: "Contract", job_link: "https://acme.example/jobs/1", patiun_quantity: "60", freelance_need: "An app" }),
|
||||
{ maxBytes: MB },
|
||||
);
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.value.rows).toEqual([
|
||||
{ label: "Company", value: "Acme" },
|
||||
{ label: "Kind of role", value: "Contract" },
|
||||
{ label: "Posting", value: "https://acme.example/jobs/1" },
|
||||
]);
|
||||
expect(inquiryFields(r.value).at(-1)).toEqual({ label: "Details", value: base.details });
|
||||
});
|
||||
|
||||
it("marks what is wrong", () => {
|
||||
const r = validateInquiry(form({ type: "nope", name: "", email: "x@", details: "hi", job_link: "javascript:alert(1)" }), { maxBytes: MB });
|
||||
expect(r.ok).toBe(false);
|
||||
if (r.ok) return;
|
||||
expect(Object.keys(r.errors).sort()).toEqual(["details", "email", "name", "type"]);
|
||||
});
|
||||
|
||||
it("checks choices, links and dates for the chosen type", () => {
|
||||
const bad = validateInquiry(
|
||||
form({ ...base, type: "freelance", freelance_need: "World peace", freelance_site: "ftp://x", freelance_timing: "date", freelance_date: "soon" }),
|
||||
{ maxBytes: MB },
|
||||
);
|
||||
expect(bad.ok).toBe(false);
|
||||
if (!bad.ok) expect(Object.keys(bad.errors).sort()).toEqual(["freelance_date", "freelance_need", "freelance_site"]);
|
||||
const good = validateInquiry(form({ ...base, type: "patiun", patiun_item: "Deck box", patiun_timing: "date", patiun_date: "2026-12-01" }), { maxBytes: MB });
|
||||
expect(good.ok && good.value.rows).toEqual([
|
||||
{ label: "Item", value: "Deck box" },
|
||||
{ label: "Need it by", value: "Dec 1, 2026" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("limits attachments by type and size", () => {
|
||||
const ok = validateInquiry(form({ ...base, type: "patiun", attachment: new File(["1 Sol Ring"], "deck.dek") }), { maxBytes: MB });
|
||||
expect(ok.ok && ok.value.attachment?.name).toBe("deck.dek");
|
||||
const exe = validateInquiry(form({ ...base, type: "patiun", attachment: new File(["MZ"], "x.exe") }), { maxBytes: MB });
|
||||
expect(!exe.ok && exe.errors.attachment).toBeTruthy();
|
||||
const big = validateInquiry(form({ ...base, type: "patiun", attachment: new File([new Uint8Array(MB + 1)], "big.png") }), { maxBytes: MB });
|
||||
expect(!big.ok && big.errors.attachment).toContain("1 MB");
|
||||
const empty = validateInquiry(form({ ...base, type: "patiun", attachment: new File([], "") }), { maxBytes: MB });
|
||||
expect(empty.ok && empty.value.attachment).toBeNull();
|
||||
});
|
||||
|
||||
it("builds the sent cookie the sent page reads", () => {
|
||||
const r = validateInquiry(form({ ...base, type: "patiun", patiun_quantity: "60 cards" }), { maxBytes: MB });
|
||||
if (!r.ok) throw new Error("should pass");
|
||||
const parsed = parseSentSummary(encodeURIComponent(JSON.stringify(inquirySent(r.value))));
|
||||
expect(parsed).toEqual({ type: "patiun", name: "Jane Doe", email: "jane@example.com", rows: [{ label: "How many", value: "60 cards" }] });
|
||||
});
|
||||
|
||||
it("spots the honeypot", () => {
|
||||
expect(isBot(form({ leave_blank: "http://spam" }))).toBe(true);
|
||||
expect(isBot(form({ leave_blank: "" }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Sample store notes, written for these tests.
|
||||
const settings = (open: Record<string, boolean>) =>
|
||||
({
|
||||
path: "Store/_settings.md",
|
||||
type: "store-settings",
|
||||
name: "_settings",
|
||||
slug: "settings",
|
||||
title: "Store settings",
|
||||
body: "",
|
||||
acceptedAt: "2026-10-01",
|
||||
data: { type: "store-settings", title: "Store settings", categories: Object.entries(open).map(([name, ordersOpen]) => ({ name, ordersOpen })) },
|
||||
}) as unknown as Note;
|
||||
|
||||
const line = (slug: string, data: Record<string, unknown>) =>
|
||||
({
|
||||
path: `Store/${slug}.md`,
|
||||
type: "product-line",
|
||||
name: slug,
|
||||
slug,
|
||||
title: slug.replace(/-/g, " "),
|
||||
body: "",
|
||||
acceptedAt: "2026-10-01",
|
||||
data: { type: "product-line", title: slug, category: "Tabletop", channel: "here", mode: "made to order", summary: "", priceTiers: [], ...data },
|
||||
}) as unknown as Note;
|
||||
|
||||
const store = (open: Record<string, boolean>, lines: Note[]) => storeState(new SiteIndex([settings(open), ...lines]));
|
||||
|
||||
const cards = line("proxy-cards", {
|
||||
slots: { open: 2, total: 5 },
|
||||
options: [
|
||||
{ group: "Finish", name: "Matte", inStock: true },
|
||||
{ group: "Finish", name: "Foil", inStock: false },
|
||||
],
|
||||
});
|
||||
const orderForm = (extra: Record<string, string> = {}) =>
|
||||
form({ name: "Bo", email: "bo@example.com", line: "proxy-cards", quantity: "75 cards", terms: "accepted", ...extra });
|
||||
|
||||
const fieldsOf = (f: FormData) => {
|
||||
const r = validateOrderFields(f);
|
||||
if (!r.ok) throw new Error(JSON.stringify(r.errors));
|
||||
return r.value;
|
||||
};
|
||||
|
||||
describe("order validation", () => {
|
||||
it("needs the terms, a line, a quantity and contact details", () => {
|
||||
const r = validateOrderFields(form({ line: "../x" }));
|
||||
expect(!r.ok && Object.keys(r.errors).sort()).toEqual(["email", "line", "name", "quantity", "terms"]);
|
||||
});
|
||||
|
||||
it("takes an order for an open line with room and an in-stock option", () => {
|
||||
const f = orderForm({ "option.proxy-cards.Finish": "Matte", "option.other-line.Color": "Red" });
|
||||
const r = checkOrder(fieldsOf(f), f, store({ Tabletop: true }, [cards]));
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
expect(r.value.options).toEqual([{ label: "Finish", value: "Matte" }]);
|
||||
expect(orderFields(r.value).map((x) => x.label)).toEqual(["Line", "Finish", "Quantity", "Terms"]);
|
||||
expect(parseOrderSent(encodeURIComponent(JSON.stringify(orderSent(r.value))))?.rows).toEqual([
|
||||
{ label: "Finish", value: "Matte" },
|
||||
{ label: "Quantity", value: "75 cards" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("turns down a closed category, whatever the post says", () => {
|
||||
const f = orderForm();
|
||||
expect(checkOrder(fieldsOf(f), f, store({ Tabletop: false }, [cards]))).toMatchObject({ ok: false, code: "closed" });
|
||||
// No settings note at all counts as closed.
|
||||
expect(checkOrder(fieldsOf(f), f, storeState(new SiteIndex([cards])))).toMatchObject({ ok: false, code: "closed" });
|
||||
});
|
||||
|
||||
it("turns down a line with no open slot or no stock", () => {
|
||||
const f = orderForm();
|
||||
const full = line("proxy-cards", { slots: { open: 0, total: 5 } });
|
||||
expect(checkOrder(fieldsOf(f), f, store({ Tabletop: true }, [full]))).toMatchObject({ ok: false, code: "full" });
|
||||
const sold = line("proxy-cards", { mode: "in stock", quantity: 0 });
|
||||
expect(checkOrder(fieldsOf(f), f, store({ Tabletop: true }, [sold]))).toMatchObject({ ok: false, code: "full" });
|
||||
const stocked = line("proxy-cards", { mode: "in stock", quantity: 3 });
|
||||
expect(checkOrder(fieldsOf(f), f, store({ Tabletop: true }, [stocked])).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("turns down lines sold elsewhere or unknown, and out-of-stock options", () => {
|
||||
const f = orderForm();
|
||||
const etsy = line("proxy-cards", { channel: "Patiun.com", slots: { open: 2, total: 5 } });
|
||||
expect(checkOrder(fieldsOf(f), f, store({ Tabletop: true }, [etsy]))).toMatchObject({ ok: false, code: "invalid" });
|
||||
expect(checkOrder(fieldsOf(f), f, store({ Tabletop: true }, []))).toMatchObject({ ok: false, code: "invalid" });
|
||||
const foil = orderForm({ "option.proxy-cards.Finish": "Foil" });
|
||||
expect(checkOrder(fieldsOf(foil), foil, store({ Tabletop: true }, [cards]))).toMatchObject({ ok: false, code: "invalid" });
|
||||
const made = orderForm({ "option.proxy-cards.Finish": "Gold leaf" });
|
||||
expect(checkOrder(fieldsOf(made), made, store({ Tabletop: true }, [cards]))).toMatchObject({ ok: false, code: "invalid" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("try-again drafts", () => {
|
||||
it("round-trips and fits in a cookie", () => {
|
||||
const values = formValues(form({ name: "Jane", details: "x".repeat(20_000), leave_blank: "", "cf-turnstile-response": "tok", terms: "accepted" }));
|
||||
expect(Object.keys(values)).toEqual(["name", "details"]);
|
||||
const raw = encodeDraft({ code: "invalid", errors: { email: "Add an email" }, values });
|
||||
expect(encodeURIComponent(raw).length).toBeLessThanOrEqual(DRAFT_LIMIT);
|
||||
const d = parseDraft(encodeURIComponent(raw));
|
||||
expect(d?.code).toBe("invalid");
|
||||
expect(d?.values.name).toBe("Jane");
|
||||
expect(d?.errors.email).toBe("Add an email");
|
||||
expect(parseDraft("garbage")).toBeUndefined();
|
||||
expect(parseDraft(JSON.stringify({ code: "nope" }))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("saving and emailing", () => {
|
||||
const quiet = { log: () => {} };
|
||||
|
||||
it("records a failed email and still succeeds when the copy was saved", async () => {
|
||||
const report = vi.fn(async () => {});
|
||||
const r = await saveAndSend({
|
||||
...quiet,
|
||||
save: async () => ({ id: 7, receipt: "rcpt" }),
|
||||
email: async () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
},
|
||||
report,
|
||||
});
|
||||
expect(r).toEqual({ ok: true, savedId: 7, email: "failed" });
|
||||
expect(report).toHaveBeenCalledWith(7, "rcpt", "failed", "ECONNREFUSED");
|
||||
});
|
||||
|
||||
it("still emails when saving fails, and fails only when both do", async () => {
|
||||
const email = vi.fn(async (id: number | null) => (id === null ? ("sent" as const) : ("skipped" as const)));
|
||||
const report = vi.fn(async () => {});
|
||||
const failSave = async (): Promise<{ id: number; receipt: string }> => {
|
||||
throw new Error("down");
|
||||
};
|
||||
expect(await saveAndSend({ ...quiet, save: failSave, email, report })).toEqual({ ok: true, savedId: null, email: "sent" });
|
||||
expect(report).not.toHaveBeenCalled();
|
||||
const r = await saveAndSend({
|
||||
...quiet,
|
||||
save: failSave,
|
||||
email: async () => {
|
||||
throw new Error("smtp down");
|
||||
},
|
||||
report,
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
// Nothing configured for mail and nothing saved: not a success either.
|
||||
expect((await saveAndSend({ ...quiet, save: failSave, email: async () => "skipped", report })).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("counts a skipped email as fine when the copy was saved", async () => {
|
||||
const report = vi.fn(async () => {});
|
||||
const r = await saveAndSend({ ...quiet, save: async () => ({ id: 1, receipt: "r" }), email: async () => "skipped", report });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(report).toHaveBeenCalledWith(1, "r", "skipped", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rate limit and address", () => {
|
||||
it("allows N posts per window per address", () => {
|
||||
let t = 0;
|
||||
const rl = new RateLimiter(() => 2, () => 1000, () => t);
|
||||
expect([rl.hit("a"), rl.hit("a"), rl.hit("a"), rl.hit("b")]).toEqual([false, false, true, false]);
|
||||
t = 1001;
|
||||
expect(rl.hit("a")).toBe(false);
|
||||
});
|
||||
|
||||
it("reads the forwarded address first", () => {
|
||||
expect(clientIp(new Headers({ "x-forwarded-for": "203.0.113.9, 10.0.0.1" }), "10.0.0.2")).toBe("203.0.113.9");
|
||||
expect(clientIp(new Headers(), "10.0.0.2")).toBe("10.0.0.2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* PersonalReader progress on Library rows and synced stock on Store options.
|
||||
* Synced values are the shapes the content service's jobs store (see
|
||||
* services/content/src/sync/jobs/); a note's own values always win, and a
|
||||
* note with nothing synced renders as before.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withSynced, type Note } from "../src/lib/content.ts";
|
||||
import { itemDetails, readerProgress, type Item } from "../src/lib/library.ts";
|
||||
import { optionGroups, type Line } from "../src/lib/store.ts";
|
||||
|
||||
const note = <T extends Note>(type: string, data: Record<string, unknown>, synced?: Record<string, unknown>) =>
|
||||
withSynced({ path: `${type}/x.md`, type, name: "x", slug: "x", title: "X", body: "", acceptedAt: "", data: { type, ...data }, synced } as unknown as T);
|
||||
|
||||
describe("Library progress from PersonalReader", () => {
|
||||
const base = { medium: "manhwa", state: "now", creator: "Someone", year: 2020 };
|
||||
|
||||
it("adds the chapter line to the details", () => {
|
||||
const i = note<Item>("library-item", base, { chapterProgress: { title: "X", status: "now", chapter: 142, readAt: "2026-10-03T05:00:00.000Z" } });
|
||||
expect(readerProgress(i)).toBe("Chapter 142, read Oct 3, 2026");
|
||||
expect(itemDetails(i)).toEqual(["Someone", 2020, "Chapter 142, read Oct 3, 2026"]);
|
||||
const book = note<Item>("library-item", { ...base, medium: "light novel" }, { bookProgress: { title: "X", percent: 62 } });
|
||||
expect(readerProgress(book)).toBe("62% read");
|
||||
});
|
||||
|
||||
it("lets the note's own progress win, and shows the same line when nothing is synced", () => {
|
||||
const own = note<Item>("library-item", { ...base, progress: "16/18 volumes" }, { chapterProgress: { chapter: 3 } });
|
||||
expect(itemDetails(own)).toEqual(["Someone", 2020, "16/18 volumes"]);
|
||||
const bare = note<Item>("library-item", base);
|
||||
expect(itemDetails(bare)).toEqual(["Someone", 2020]);
|
||||
expect(readerProgress(note<Item>("library-item", base, { bookProgress: { percent: 0 } }))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Store options with synced stock", () => {
|
||||
const lineData = (options: unknown[]) => ({ category: "Tabletop", mode: "made to order", channel: "here", summary: "", priceTiers: [], options });
|
||||
const stock = {
|
||||
filamentStock: [
|
||||
{
|
||||
group: "Filament",
|
||||
options: [
|
||||
{ name: "Jade White", hex: "#f5f5dc", inStock: true },
|
||||
{ name: "Bambu Green", hex: "#00ae42", inStock: false },
|
||||
{ name: "Charcoal", hex: "#2b2b2b", inStock: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("is exactly the note's options when nothing is synced", () => {
|
||||
const opts = [
|
||||
{ group: "Filament", name: "Jade White", hex: "#ffffff", inStock: true },
|
||||
{ group: "Finish", name: "Matte", inStock: false },
|
||||
];
|
||||
expect(optionGroups(note<Line>("product-line", lineData(opts)))).toEqual([
|
||||
["Filament", [opts[0]]],
|
||||
["Finish", [opts[1]]],
|
||||
]);
|
||||
// Left out with nothing synced: in stock, as before the field was optional.
|
||||
expect(optionGroups(note<Line>("product-line", lineData([{ group: "Finish", name: "Gloss" }])))[0]![1][0]!.inStock).toBe(true);
|
||||
});
|
||||
|
||||
it("sits under the note: note values win, gaps take the synced value, unlisted in-stock colors follow", () => {
|
||||
const l = note<Line>(
|
||||
"product-line",
|
||||
lineData([
|
||||
{ group: "Filament", name: "Jade White" },
|
||||
{ group: "Filament", name: "Bambu Green", inStock: true },
|
||||
{ group: "Filament", name: "Galaxy Black" },
|
||||
{ group: "Finish", name: "Matte", inStock: true },
|
||||
]),
|
||||
stock,
|
||||
);
|
||||
const [[g, filament], [, finish]] = optionGroups(l) as [string, ReturnType<typeof optionGroups>[number][1]][];
|
||||
expect(g).toBe("Filament");
|
||||
expect(filament).toEqual([
|
||||
{ group: "Filament", name: "Jade White", hex: "#f5f5dc", inStock: true },
|
||||
// The note says in stock: the note wins over the source.
|
||||
{ group: "Filament", name: "Bambu Green", hex: "#00ae42", inStock: true },
|
||||
// Not at the source: out of stock.
|
||||
{ group: "Filament", name: "Galaxy Black", inStock: false },
|
||||
{ group: "Filament", name: "Charcoal", hex: "#2b2b2b", inStock: true },
|
||||
]);
|
||||
expect(finish).toEqual([{ group: "Finish", name: "Matte", inStock: true }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withSynced, type Note } from "../src/lib/content.ts";
|
||||
import { gamePlayers, gamePlayTime, type Game } from "../src/lib/library.ts";
|
||||
import { deckArt, deckColors } from "../src/lib/world.ts";
|
||||
|
||||
const note = <T extends Note>(type: string, data: Record<string, unknown>, synced?: Record<string, unknown>) =>
|
||||
({ path: `${type}/x.md`, type, name: "x", slug: "x", title: "X", body: "", acceptedAt: "", data: { type, ...data }, synced }) as unknown as T;
|
||||
|
||||
describe("synced values", () => {
|
||||
it("sit under the note's own values: the note always wins", () => {
|
||||
const n = withSynced(note("library-item", { cover: "[[mine.jpg]]", title: "X" }, { cover: "https://x/cover.jpg", year: 2006 }));
|
||||
expect(n.data).toMatchObject({ cover: "[[mine.jpg]]", year: 2006 });
|
||||
const bare = withSynced(note("library-item", { title: "X" }, { cover: "https://x/cover.jpg" }));
|
||||
expect((bare.data as { cover?: string }).cover).toBe("https://x/cover.jpg");
|
||||
});
|
||||
|
||||
it("leave a note with nothing synced exactly as it came", () => {
|
||||
const n = note("board-game", { bggId: 1, blurb: "b" }, {});
|
||||
expect(withSynced(n)).toBe(n);
|
||||
const none = note<Game>("board-game", { bggId: 1, blurb: "b" });
|
||||
expect(withSynced(none)).toBe(none);
|
||||
expect([gamePlayers(none), gamePlayTime(none)]).toEqual([undefined, undefined]);
|
||||
expect(deckArt(note("deck", { game: "[[Magic]]" }))).toBeUndefined();
|
||||
expect(deckColors(note("deck", { game: "[[Magic]]" }))).toEqual([]);
|
||||
});
|
||||
|
||||
it("give the Shelf players and play time, and the decks art and colors", () => {
|
||||
const g = withSynced(note<Game>("board-game", { bggId: 1, blurb: "b" }, { minPlayers: 1, maxPlayers: 5, playTime: 70, minPlayTime: 40, maxPlayTime: 70 }));
|
||||
expect([gamePlayers(g), gamePlayTime(g)]).toEqual(["1–5 players", "40–70 min"]);
|
||||
const solo = withSynced(note<Game>("board-game", { bggId: 1, blurb: "b" }, { minPlayers: 1, maxPlayers: 1, playTime: 30 }));
|
||||
expect([gamePlayers(solo), gamePlayTime(solo)]).toEqual(["1 player", "30 min"]);
|
||||
const d = withSynced(note<Note<"deck">>("deck", { game: "[[Magic]]" }, { commanderArt: "https://cards.scryfall.io/a.jpg", colors: ["W", "U", "G"] }));
|
||||
expect(deckArt(d)).toBe("https://cards.scryfall.io/a.jpg");
|
||||
expect(deckColors(d)).toEqual(["White", "Blue", "Green"]);
|
||||
const own = withSynced(note<Note<"deck">>("deck", { game: "[[Magic]]", cover: "[[deck.jpg]]" }, { commanderArt: "https://cards.scryfall.io/a.jpg" }));
|
||||
expect(deckArt(own)).toBe("/media/deck.jpg");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user