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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user