@the-smithy/create-hearth-site 0.24.1 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +2 -1
  2. package/bin/create-hearth-site.js +1 -1
  3. package/package.json +4 -3
  4. package/template/AGENTS.md +191 -9
  5. package/template/CLAUDE.md +2 -124
  6. package/template/GEMINI.md +3 -0
  7. package/template/HOSTING.md +88 -0
  8. package/template/README.md +30 -29
  9. package/template/SITE.md +1 -1
  10. package/template/_gitignore +2 -1
  11. package/template/package.json +3 -2
  12. package/template/scripts/hearth.mjs +82 -31
  13. package/template/src/app/donate/thank-you/page.tsx +1 -1
  14. package/template/src/app/globals.css +1 -0
  15. package/template/src/app/layout.tsx +1 -1
  16. package/template/src/app/not-found.tsx +1 -1
  17. package/template/src/app/registered/page.tsx +1 -1
  18. package/template/src/hearth/base.css +66 -0
  19. package/template/src/hearth/blocks/collections.tsx +6 -5
  20. package/template/src/hearth/blocks/content.tsx +64 -28
  21. package/template/src/hearth/blocks/custom.tsx +9 -1
  22. package/template/src/hearth/blocks/events.tsx +25 -17
  23. package/template/src/hearth/blocks/giving.tsx +24 -20
  24. package/template/src/hearth/blocks/index.tsx +29 -4
  25. package/template/src/hearth/blocks/info.tsx +15 -13
  26. package/template/src/hearth/blocks/media.tsx +10 -9
  27. package/template/src/hearth/components/EventView.tsx +61 -29
  28. package/template/src/hearth/components/FundraiserView.tsx +77 -0
  29. package/template/src/hearth/components/NotHere.tsx +9 -0
  30. package/template/src/hearth/components/PageContent.tsx +23 -0
  31. package/template/src/hearth/components/Registration.tsx +3 -2
  32. package/template/src/hearth/components/Share.tsx +11 -11
  33. package/template/src/hearth/format.ts +5 -3
  34. package/template/src/hearth/forms/ContactForm.tsx +5 -3
  35. package/template/src/hearth/forms/DonateForm.tsx +49 -32
  36. package/template/src/hearth/forms/DonationForm.tsx +13 -0
  37. package/template/src/hearth/forms/RegistrationForm.tsx +16 -14
  38. package/template/src/hearth/forms/VolunteerForm.tsx +10 -8
  39. package/template/src/hearth/forms/fields.tsx +17 -5
  40. package/template/src/hearth/hearth.css +3 -47
  41. package/template/src/hearth/layout/MobileNav.tsx +1 -1
  42. package/template/src/hearth/layout/NavLink.tsx +6 -2
  43. package/template/src/hearth/layout/RootLayout.tsx +8 -32
  44. package/template/src/hearth/layout/SiteFooter.tsx +13 -6
  45. package/template/src/hearth/layout/SiteFrame.tsx +48 -0
  46. package/template/src/hearth/layout/SiteHeader.tsx +9 -4
  47. package/template/src/hearth/manifest.json +3 -1
  48. package/template/src/hearth/overrides.ts +48 -0
  49. package/template/src/hearth/pages/donate.tsx +24 -15
  50. package/template/src/hearth/pages/event.tsx +3 -2
  51. package/template/src/hearth/pages/events.tsx +22 -10
  52. package/template/src/hearth/pages/fundraiser.tsx +10 -63
  53. package/template/src/hearth/pages/fundraisers.tsx +21 -11
  54. package/template/src/hearth/pages/not-found.tsx +28 -8
  55. package/template/src/hearth/pages/page.tsx +9 -15
  56. package/template/src/hearth/pages/registered.tsx +23 -10
  57. package/template/src/hearth/pages/thank-you.tsx +23 -10
  58. package/template/src/hearth/theme/defaults.ts +125 -2
  59. package/template/src/hearth/ui.tsx +20 -5
  60. package/template/src/theme/overrides.tsx +19 -5
@@ -0,0 +1,23 @@
1
+ import type { Page } from "@the-smithy/hearth";
2
+ import { JsonLd } from "@the-smithy/hearth/react";
3
+ import { Blocks } from "../blocks";
4
+
5
+ /** A page from Hearth: its structured data and its blocks. */
6
+ export function PageContent({ page }: { page: Page }) {
7
+ return (
8
+ <>
9
+ <JsonLd data={page.meta.jsonLd} />
10
+ <PageBlocks page={page} />
11
+ </>
12
+ );
13
+ }
14
+
15
+ export function PageBlocks({ page }: { page: Page }) {
16
+ return (
17
+ <>
18
+ {/* Pages usually open with a Hero (the visible title); this keeps a heading for the rest. */}
19
+ {page.blocks[0]?.type === "hero" ? null : <h1 className="sr-only">{page.title}</h1>}
20
+ <Blocks blocks={page.blocks} />
21
+ </>
22
+ );
23
+ }
@@ -1,6 +1,7 @@
1
1
  import type { HearthEvent } from "@the-smithy/hearth";
2
2
  import { theme } from "@/theme";
3
3
  import { hearthNext } from "../client";
4
+ import { ExternalLink } from "../ui";
4
5
 
5
6
  /** Upcoming until it ends (or, without an end time, until it starts). */
6
7
  const isUpcoming = (e: Pick<HearthEvent, "startsAt" | "endsAt">) => new Date(e.endsAt ?? e.startsAt).getTime() > Date.now();
@@ -31,9 +32,9 @@ export async function Registration({ event, size = "large" }: { event: HearthEve
31
32
  }
32
33
  if (event.registrationUrl) {
33
34
  return (
34
- <a href={event.registrationUrl} rel="noopener" className={theme.button.primary} data-size={small}>
35
+ <ExternalLink href={event.registrationUrl} rel="noopener" className={theme.button.primary} data-size={small}>
35
36
  {theme.text.register}
36
- </a>
37
+ </ExternalLink>
37
38
  );
38
39
  }
39
40
  const { events } = (await hearthNext.getSite()).settings;
@@ -23,9 +23,9 @@ export function Share({ title, url, message, heading }: { title: string; url: st
23
23
  async function copy(value: string, label: string) {
24
24
  try {
25
25
  await navigator.clipboard.writeText(value);
26
- setStatus(`${label} copied.`);
26
+ setStatus(theme.text.shareCopied.replace("{what}", label));
27
27
  } catch {
28
- setStatus("Couldn't copy automatically. Select the text and copy it.");
28
+ setStatus(theme.text.shareCopyFailed);
29
29
  }
30
30
  }
31
31
 
@@ -33,8 +33,8 @@ export function Share({ title, url, message, heading }: { title: string; url: st
33
33
  const targets = [
34
34
  { label: "Facebook", href: `https://www.facebook.com/sharer/sharer.php?u=${enc(url)}` },
35
35
  { label: "X", href: `https://x.com/intent/post?text=${enc(text)}` },
36
- { label: "Email", href: `mailto:?subject=${enc(title)}&body=${enc(text)}` },
37
- { label: "Text message", href: `sms:?&body=${enc(text)}` },
36
+ { label: theme.text.shareEmail, href: `mailto:?subject=${enc(title)}&body=${enc(text)}` },
37
+ { label: theme.text.shareTextMessage, href: `sms:?&body=${enc(text)}` },
38
38
  ];
39
39
 
40
40
  return (
@@ -66,26 +66,26 @@ export function Share({ title, url, message, heading }: { title: string; url: st
66
66
  </div>
67
67
  <div className={t.body}>
68
68
  <label className={theme.form.field}>
69
- <span className={theme.form.label}>Your message</span>
69
+ <span className={theme.form.label}>{theme.text.shareMessageLabel}</span>
70
70
  <textarea id={`${id}-message`} rows={5} value={text} onChange={(e) => setText(e.target.value)} className={t.message} />
71
71
  </label>
72
72
  <div className={t.copy}>
73
- <button type="button" onClick={() => copy(text, "Message")} className={t.copyButton}>
74
- Copy message
73
+ <button type="button" onClick={() => copy(text, theme.text.shareTheMessage)} className={t.copyButton}>
74
+ {theme.text.shareCopyMessage}
75
75
  </button>
76
- <button type="button" onClick={() => copy(url, "Link")} className={t.copyButton}>
77
- Copy link
76
+ <button type="button" onClick={() => copy(url, theme.text.shareTheLink)} className={t.copyButton}>
77
+ {theme.text.shareCopyLink}
78
78
  </button>
79
79
  </div>
80
80
  <p role="status" aria-live="polite" className={t.status}>
81
81
  {status}
82
82
  </p>
83
83
  <div>
84
- <p className={t.targetsLabel}>Or share to</p>
84
+ <p className={t.targetsLabel}>{theme.text.shareTo}</p>
85
85
  <div className={t.targets}>
86
86
  {nativeShare ? (
87
87
  <button type="button" className={t.target} onClick={() => navigator.share({ title, text }).catch(() => {})}>
88
- More apps…
88
+ {theme.text.shareMoreApps}
89
89
  </button>
90
90
  ) : null}
91
91
  {targets.map((x) => (
@@ -1,9 +1,11 @@
1
1
  import type { HearthEvent } from "@the-smithy/hearth";
2
+ import { theme } from "@/theme";
2
3
 
3
- /** "Sat, Oct 18 · 6:00 – 9:00 PM", in the event's own time zone. */
4
+ /** "Sat, Oct 18 · 6:00 – 9:00 PM", in the event's own time zone; "Saturday, October 18 · …" with `page.dateStyle` set to "long". */
4
5
  export function formatEventDate(e: Pick<HearthEvent, "startsAt" | "endsAt" | "allDay" | "timezone">) {
5
6
  const tz = { timeZone: e.timezone };
6
- const day = new Intl.DateTimeFormat("en-US", { ...tz, weekday: "short", month: "short", day: "numeric", year: "numeric" });
7
+ const words = theme.page.dateStyle === "long" ? "long" : "short";
8
+ const day = new Intl.DateTimeFormat("en-US", { ...tz, weekday: words, month: words, day: "numeric", year: "numeric" });
7
9
  const time = new Intl.DateTimeFormat("en-US", { ...tz, hour: "numeric", minute: "2-digit" });
8
10
  const start = new Date(e.startsAt);
9
11
  const end = e.endsAt ? new Date(e.endsAt) : null;
@@ -28,4 +30,4 @@ export const formatEntryDate = (iso: string) =>
28
30
  export const money = (cents: number) => (cents / 100).toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: cents % 100 ? 2 : 0 });
29
31
 
30
32
  /** A ticket price: "$25" or "Free". */
31
- export const price = (cents: number) => (cents ? money(cents) : "Free");
33
+ export const price = (cents: number) => (cents ? money(cents) : theme.text.free);
@@ -4,7 +4,7 @@ import type { BlockOf } from "@the-smithy/hearth";
4
4
  import { useActionState } from "react";
5
5
  import { theme } from "@/theme";
6
6
  import { submitHearthForm, type FormState } from "../actions";
7
- import { Alert, Honeypot, Question, Submit, Success } from "./fields";
7
+ import { Alert, Honeypot, Question, Submit, Success, useFocusFirstInvalid } from "./fields";
8
8
 
9
9
  type FormData_ = BlockOf<"form">["data"];
10
10
 
@@ -12,16 +12,18 @@ type FormData_ = BlockOf<"form">["data"];
12
12
  export function HearthForm({ form }: { form: FormData_ }) {
13
13
  const [state, action] = useActionState<FormState, FormData>(submitHearthForm.bind(null, form.formKey), {});
14
14
 
15
+ const ref = useFocusFirstInvalid(state);
16
+
15
17
  if (state.ok) return <Success>{state.message || form.successMessage}</Success>;
16
18
 
17
19
  return (
18
- <form action={action} className={theme.form.root} noValidate>
20
+ <form ref={ref} action={action} className={theme.form.root} noValidate>
19
21
  {state.message ? <Alert>{state.message}</Alert> : null}
20
22
  <Honeypot />
21
23
  {form.fields.map((f) => (
22
24
  <Question key={f.name} field={f} idPrefix={form.formKey} error={state.fieldErrors?.[f.name]} value={state.values?.[f.name]} />
23
25
  ))}
24
- <Submit label={form.submitLabel} pending="Sending…" />
26
+ <Submit label={form.submitLabel} pending={theme.text.sending} />
25
27
  </form>
26
28
  );
27
29
  }
@@ -1,69 +1,86 @@
1
1
  "use client";
2
2
 
3
- import { useActionState, useState } from "react";
3
+ import { useActionState, useEffect, useState } from "react";
4
4
  import type { FormResult } from "@the-smithy/hearth";
5
5
  import { theme } from "@/theme";
6
6
  import { donate } from "../actions";
7
7
  import { money } from "../format";
8
- import { Alert, Checkbox, FieldError, Submit, TextField } from "./fields";
8
+ import type { DonateFormProps } from "../overrides";
9
+ import { fill } from "../ui";
10
+ import { Alert, Checkbox, FieldError, Submit, TextField, useFocusFirstInvalid } from "./fields";
9
11
 
10
12
  const t = theme.form;
11
13
 
12
14
  /**
13
- * Suggested amounts (from Hearth's Giving settings), a custom amount and optional details. Submitting starts a
14
- * checkout through Hearth and sends the donor to the payment page; they come back to /donate/thank-you.
15
+ * Suggested amounts (from Hearth's Giving settings), a custom amount, a choice of fundraiser and optional details.
16
+ * Submitting starts a checkout through Hearth and sends the donor to the payment page; they come back to
17
+ * /donate/thank-you. `fundraiser` gives to that one. Otherwise `fundraisers`, the active ones, are offered, with
18
+ * "Where it's needed most" first and the one named by ?fundraiser= in the address chosen; without any, there's no choice.
15
19
  */
16
- export function DonateForm({
17
- presetsCents,
18
- minCents,
19
- maxCents,
20
- fundraiser,
21
- submitLabel = theme.text.donate,
22
- }: {
23
- presetsCents: number[];
24
- minCents: number;
25
- maxCents: number;
26
- fundraiser?: string;
27
- submitLabel?: string;
28
- }) {
20
+ export function DonateForm({ presetsCents, minCents, maxCents, fundraiser, fundraisers = [], submitLabel = theme.text.donate }: DonateFormProps) {
29
21
  const [state, action] = useActionState<FormResult | null, FormData>(donate, null);
30
22
  const [amount, setAmount] = useState(presetsCents[1] !== undefined ? String(presetsCents[1] / 100) : "custom");
23
+ const [chosen, setChosen] = useState("");
31
24
  const errors = state && !state.ok ? state.fieldErrors : {};
25
+ const ref = useFocusFirstInvalid(state);
26
+ const choice = !fundraiser && fundraisers.length > 0;
27
+
28
+ // From the address after loading, so that the page can stay prerendered.
29
+ useEffect(() => {
30
+ const wanted = new URLSearchParams(window.location.search).get("fundraiser");
31
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- only known in the browser
32
+ if (wanted && fundraisers.some((f) => f.slug === wanted)) setChosen(wanted);
33
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- once, as the page loads
34
+ }, []);
32
35
 
33
36
  return (
34
- <form action={action} className={t.root} id="donate">
37
+ <form ref={ref} action={action} className={t.root} id="donate">
35
38
  {state && !state.ok ? <Alert>{state.message}</Alert> : null}
36
39
  {fundraiser ? <input type="hidden" name="fundraiser" value={fundraiser} /> : null}
37
40
  <fieldset>
38
- <legend className={t.legend}>Amount</legend>
41
+ <legend className={t.legend}>{theme.text.donateAmount}</legend>
39
42
  <div className={t.amounts}>
40
- {[...presetsCents.map((cents) => ({ value: String(cents / 100), label: money(cents) })), { value: "custom", label: "Other" }].map((a) => (
43
+ {[...presetsCents.map((cents) => ({ value: String(cents / 100), label: money(cents) })), { value: "custom", label: theme.text.donateOther }].map((a) => (
41
44
  <label key={a.value} className={t.amount}>
42
- <input type="radio" name="amount" value={a.value} checked={amount === a.value} onChange={(e) => setAmount(e.target.value)} className="sr-only" />
45
+ <input type="radio" name="amount" value={a.value} checked={amount === a.value} onChange={(e) => setAmount(e.target.value)} data-invalid={errors.amount && amount === a.value && a.value !== "custom" ? "" : undefined} className="sr-only" />
43
46
  {a.label}
44
47
  </label>
45
48
  ))}
46
49
  </div>
47
50
  {amount === "custom" ? (
48
51
  <div className={t.customAmount}>
49
- <TextField label="Amount in dollars" name="customAmount" inputMode="decimal" required placeholder={String(minCents / 100)} />
52
+ <TextField label={theme.text.donateCustomAmount} name="customAmount" inputMode="decimal" required placeholder={String(minCents / 100)} data-invalid={errors.amount ? "" : undefined} />
50
53
  </div>
51
54
  ) : null}
52
- <p className={t.hint}>
53
- {money(minCents)} to {money(maxCents)}.
54
- </p>
55
+ <p className={t.hint}>{fill(theme.text.donateRange, { min: money(minCents), max: money(maxCents) })}</p>
55
56
  <FieldError>{errors.amount}</FieldError>
56
57
  </fieldset>
58
+ {choice ? (
59
+ <div>
60
+ <label htmlFor="donate-fundraiser" className={t.label}>
61
+ {theme.text.donateChoice}
62
+ </label>
63
+ <select id="donate-fundraiser" name="fundraiser" value={chosen} onChange={(e) => setChosen(e.target.value)} aria-invalid={errors.fundraiser ? true : undefined} aria-describedby={errors.fundraiser ? "donate-fundraiser-error" : undefined} className={t.input}>
64
+ <option value="">{theme.text.donateAnywhere}</option>
65
+ {fundraisers.map((f) => (
66
+ <option key={f.slug} value={f.slug}>
67
+ {f.title}
68
+ </option>
69
+ ))}
70
+ </select>
71
+ <FieldError id="donate-fundraiser-error">{errors.fundraiser}</FieldError>
72
+ </div>
73
+ ) : null}
57
74
  <div className={t.row}>
58
- <TextField label="Name" name="name" autoComplete="name" error={errors.name} />
59
- <TextField label="Email for your receipt" name="email" type="email" autoComplete="email" error={errors.email} />
75
+ <TextField label={theme.text.yourName} name="name" autoComplete="name" error={errors.name} />
76
+ <TextField label={theme.text.donateEmail} name="email" type="email" autoComplete="email" error={errors.email} />
60
77
  </div>
61
- <TextField label="Message" optional name="message" textarea rows={3} maxLength={500} error={errors.message} />
62
- <Checkbox name="anonymous" label="Don't show my name publicly" />
78
+ <TextField label={theme.text.donateMessage} optional name="message" textarea rows={3} maxLength={500} error={errors.message} />
79
+ <Checkbox name="anonymous" label={theme.text.donateAnonymous} />
63
80
  {/* Opt-in only, unticked: the fundraiser's updates go to donors who ask (and give an email). */}
64
- {fundraiser ? <Checkbox name="updates" label="Send me updates about this fundraiser" /> : null}
65
- <Submit label={submitLabel} pending="Opening secure checkout…" />
66
- <p className={t.note}>You&apos;ll finish on a secure payment page.</p>
81
+ {fundraiser || chosen ? <Checkbox name="updates" label={theme.text.donateUpdates} /> : null}
82
+ <Submit label={submitLabel} pending={theme.text.openingCheckout} />
83
+ <p className={t.note}>{theme.text.donateNote}</p>
67
84
  </form>
68
85
  );
69
86
  }
@@ -0,0 +1,13 @@
1
+ import { hearthNext } from "../client";
2
+ import { overrides, type DonateFormProps } from "../overrides";
3
+ import { DonateForm } from "./DonateForm";
4
+
5
+ /**
6
+ * A donation form wherever the template draws one, the site's own if it replaces it. Without a fundraiser of its own, it
7
+ * offers the active ones to choose from.
8
+ */
9
+ export async function DonationForm(props: Omit<DonateFormProps, "fundraisers">) {
10
+ const Form = overrides.DonateForm ?? DonateForm;
11
+ const fundraisers = props.fundraiser ? [] : await hearthNext.getFundraisers({ status: "active" });
12
+ return <Form {...props} fundraisers={fundraisers.map((f) => ({ slug: f.slug, title: f.title }))} />;
13
+ }
@@ -5,7 +5,8 @@ import type { EventTicket, FormResult } from "@the-smithy/hearth";
5
5
  import { theme } from "@/theme";
6
6
  import { register } from "../actions";
7
7
  import { price } from "../format";
8
- import { Alert, FieldError, OptionCard, Submit, Success, TextField } from "./fields";
8
+ import { fill } from "../ui";
9
+ import { Alert, FieldError, OptionCard, Submit, Success, TextField, useFocusFirstInvalid } from "./fields";
9
10
 
10
11
  const t = theme.form;
11
12
 
@@ -20,49 +21,50 @@ export function RegistrationForm({ slug, tickets }: { slug: string; tickets: Eve
20
21
  const ticket = tickets.find((x) => x.id === ticketId);
21
22
  const errors = state && !state.ok ? state.fieldErrors : {};
22
23
  const paid = Boolean(ticket?.priceCents);
23
- const attendee = ticket?.attendeeLabel ?? "Attendee";
24
+ const attendee = ticket?.attendeeLabel ?? theme.text.attendee;
25
+ const ref = useFocusFirstInvalid(state);
24
26
 
25
27
  if (state?.ok) return <Success>{state.message}</Success>;
26
28
 
27
29
  return (
28
- <form action={action} className={t.root}>
30
+ <form ref={ref} action={action} className={t.root}>
29
31
  {state && !state.ok ? <Alert>{state.message}</Alert> : null}
30
32
  <fieldset>
31
- <legend className={t.legend}>Ticket</legend>
33
+ <legend className={t.legend}>{theme.text.ticket}</legend>
32
34
  <div className={t.options}>
33
35
  {tickets.map((x) => (
34
36
  <OptionCard
35
37
  key={x.id}
36
- input={<input type="radio" name="ticket" value={x.id} checked={ticketId === x.id} disabled={x.soldOut} onChange={() => setTicketId(x.id)} className={t.optionInput} />}
38
+ input={<input type="radio" name="ticket" value={x.id} checked={ticketId === x.id} disabled={x.soldOut} onChange={() => setTicketId(x.id)} data-invalid={errors.ticket && !x.soldOut ? "" : undefined} className={t.optionInput} />}
37
39
  title={x.name}
38
- meta={x.soldOut ? "Sold out" : price(x.priceCents)}
40
+ meta={x.soldOut ? theme.text.soldOut : price(x.priceCents)}
39
41
  >
40
42
  {x.description ? <span className={t.optionDetail}>{x.description}</span> : null}
41
- {x.remaining !== null && !x.soldOut && x.remaining <= 10 ? <span className={t.optionDetail}>{x.remaining} left</span> : null}
43
+ {x.remaining !== null && !x.soldOut && x.remaining <= 10 ? <span className={t.optionDetail}>{fill(theme.text.ticketsLeft, { count: String(x.remaining) })}</span> : null}
42
44
  </OptionCard>
43
45
  ))}
44
46
  </div>
45
47
  <FieldError>{errors.ticket}</FieldError>
46
48
  </fieldset>
47
49
  <div className={t.row}>
48
- <TextField label="Name" name="name" required autoComplete="name" error={errors.name} />
49
- <TextField label="Email" name="email" type="email" required autoComplete="email" error={errors.email} />
50
- <TextField label="Phone" optional name="phone" type="tel" autoComplete="tel" error={errors.phone} />
51
- <TextField label="Team or organization" optional name="teamName" error={errors.teamName} />
50
+ <TextField label={theme.text.yourName} name="name" required autoComplete="name" error={errors.name} />
51
+ <TextField label={theme.text.yourEmail} name="email" type="email" required autoComplete="email" error={errors.email} />
52
+ <TextField label={theme.text.yourPhone} optional name="phone" type="tel" autoComplete="tel" error={errors.phone} />
53
+ <TextField label={theme.text.teamName} optional name="teamName" error={errors.teamName} />
52
54
  </div>
53
55
  {ticket && ticket.attendeesPerRegistration > 0 ? (
54
56
  <fieldset>
55
57
  <legend className={t.legend}>{ticket.attendeesPerRegistration === 1 ? attendee : `${attendee}s`}</legend>
56
58
  <div className={t.row}>
57
59
  {Array.from({ length: ticket.attendeesPerRegistration }, (_, i) => (
58
- <input key={`${ticket.id}-${i}`} name="attendee" required aria-label={`${attendee} ${i + 1}`} placeholder={`${attendee} ${i + 1}`} className={t.input} />
60
+ <input key={`${ticket.id}-${i}`} name="attendee" required aria-invalid={errors.attendees ? true : undefined} aria-label={`${attendee} ${i + 1}`} placeholder={`${attendee} ${i + 1}`} className={t.input} />
59
61
  ))}
60
62
  </div>
61
63
  <FieldError>{errors.attendees}</FieldError>
62
64
  </fieldset>
63
65
  ) : null}
64
- <TextField label="Notes" optional name="notes" textarea rows={3} maxLength={1000} error={errors.notes} />
65
- <Submit label={paid ? "Continue to payment" : theme.text.register} pending={paid ? "Opening secure checkout…" : "Registering…"} />
66
+ <TextField label={theme.text.notes} optional name="notes" textarea rows={3} maxLength={1000} error={errors.notes} />
67
+ <Submit label={paid ? theme.text.continueToPayment : theme.text.register} pending={paid ? theme.text.openingCheckout : theme.text.registering} />
66
68
  </form>
67
69
  );
68
70
  }
@@ -5,7 +5,8 @@ import type { EventVolunteering } from "@the-smithy/hearth";
5
5
  import { theme } from "@/theme";
6
6
  import { volunteer, type VolunteerState } from "../actions";
7
7
  import { formatShift } from "../format";
8
- import { Alert, FieldError, Honeypot, OptionCard, Question, Submit, Success, TextField } from "./fields";
8
+ import { fill } from "../ui";
9
+ import { Alert, FieldError, Honeypot, OptionCard, Question, Submit, Success, TextField, useFocusFirstInvalid } from "./fields";
9
10
 
10
11
  const t = theme.form;
11
12
 
@@ -19,27 +20,28 @@ export function VolunteerForm({ slug, timezone, volunteering }: { slug: string;
19
20
  const values = (state && !state.ok ? state.values : undefined) ?? {};
20
21
  const chosen = (state && !state.ok ? state.shifts : undefined) ?? [];
21
22
  const many = volunteering.pickShifts === "many";
23
+ const ref = useFocusFirstInvalid(state);
22
24
 
23
25
  if (state?.ok) return <Success>{state.message}</Success>;
24
26
 
25
27
  return (
26
- <form action={action} className={t.root} noValidate>
28
+ <form ref={ref} action={action} className={t.root} noValidate>
27
29
  {state && !state.ok ? <Alert>{state.message}</Alert> : null}
28
30
  <Honeypot />
29
31
  <div className={t.row}>
30
- <TextField label="Name" name="name" required autoComplete="name" defaultValue={values.name} error={errors.name} />
31
- <TextField label="Email" name="email" type="email" required autoComplete="email" defaultValue={values.email} error={errors.email} />
32
+ <TextField label={theme.text.yourName} name="name" required autoComplete="name" defaultValue={values.name} error={errors.name} />
33
+ <TextField label={theme.text.yourEmail} name="email" type="email" required autoComplete="email" defaultValue={values.email} error={errors.email} />
32
34
  </div>
33
35
  {volunteering.pickShifts !== "none" ? (
34
36
  <fieldset>
35
- <legend className={t.legend}>{many ? "Shifts you can do" : "Choose a shift"}</legend>
37
+ <legend className={t.legend}>{many ? theme.text.shiftsMany : theme.text.shiftsOne}</legend>
36
38
  <div className={t.options}>
37
39
  {volunteering.shifts.map((s) => (
38
40
  <OptionCard
39
41
  key={s.id}
40
- input={<input type={many ? "checkbox" : "radio"} name="shift" value={s.id} disabled={s.full} defaultChecked={chosen.includes(s.id)} className={t.optionInput} />}
42
+ input={<input type={many ? "checkbox" : "radio"} name="shift" value={s.id} disabled={s.full} defaultChecked={chosen.includes(s.id)} data-invalid={errors.shifts && !s.full ? "" : undefined} className={t.optionInput} />}
41
43
  title={s.name}
42
- meta={s.full ? "Full" : s.remaining !== null && s.remaining <= 5 ? `${s.remaining} spot${s.remaining === 1 ? "" : "s"} left` : null}
44
+ meta={s.full ? theme.text.shiftFull : s.remaining !== null && s.remaining <= 5 ? fill(s.remaining === 1 ? theme.text.oneSpotLeft : theme.text.spotsLeft, { count: String(s.remaining) }) : null}
43
45
  >
44
46
  <span className={t.optionDetail}>{formatShift(s, timezone)}</span>
45
47
  {s.description ? <span className={t.optionDetail}>{s.description}</span> : null}
@@ -52,7 +54,7 @@ export function VolunteerForm({ slug, timezone, volunteering }: { slug: string;
52
54
  {volunteering.fields.map((f) => (
53
55
  <Question key={f.name} field={f} idPrefix="volunteer" error={errors[f.name]} value={values[f.name]} />
54
56
  ))}
55
- <Submit label={volunteering.submitLabel} pending="Signing up…" />
57
+ <Submit label={volunteering.submitLabel} pending={theme.text.signingUp} />
56
58
  </form>
57
59
  );
58
60
  }
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import type { BlockOf } from "@the-smithy/hearth";
4
- import type { InputHTMLAttributes, ReactNode } from "react";
4
+ import { useEffect, useRef, type InputHTMLAttributes, type ReactNode } from "react";
5
5
  import { useFormStatus } from "react-dom";
6
6
  import { theme } from "@/theme";
7
7
 
@@ -12,6 +12,18 @@ const t = theme.form;
12
12
  /** A question defined in Hearth (a form field, an event's volunteer question). */
13
13
  type FormField = BlockOf<"form">["data"]["fields"] extends (infer F)[] | undefined ? F : never;
14
14
 
15
+ /**
16
+ * For a form's `ref`: after a submit comes back with errors, moves focus to the first field with one. Fields mark
17
+ * themselves with aria-invalid; choices that can't take it (a group of radio buttons) with data-invalid.
18
+ */
19
+ export function useFocusFirstInvalid(state: unknown) {
20
+ const form = useRef<HTMLFormElement>(null);
21
+ useEffect(() => {
22
+ if (state) form.current?.querySelector<HTMLElement>('[aria-invalid="true"], [data-invalid]')?.focus();
23
+ }, [state]);
24
+ return form;
25
+ }
26
+
15
27
  export function Alert({ children }: { children: ReactNode }) {
16
28
  return (
17
29
  <p role="alert" className={t.alert}>
@@ -33,7 +45,7 @@ export function Honeypot() {
33
45
  return (
34
46
  <div aria-hidden className="absolute -left-[9999px]">
35
47
  <label>
36
- Leave this empty
48
+ {theme.text.honeypot}
37
49
  <input name="_gotcha" tabIndex={-1} autoComplete="off" />
38
50
  </label>
39
51
  </div>
@@ -65,7 +77,7 @@ export function TextField({ label, optional, error, textarea, rows = 3, ...input
65
77
  <label className={t.field}>
66
78
  <span className={t.label}>
67
79
  {label}
68
- {optional ? <span className={t.optional}>(optional)</span> : null}
80
+ {optional ? <span className={t.optional}>{theme.text.optional}</span> : null}
69
81
  </span>
70
82
  {textarea ? <textarea {...(common as object)} rows={rows} /> : <input {...common} />}
71
83
  <FieldError id={describedBy}>{error}</FieldError>
@@ -97,14 +109,14 @@ export function Question({ field: f, idPrefix, error, value }: { field: FormFiel
97
109
  <>
98
110
  <label htmlFor={id} className={t.label}>
99
111
  {f.label}
100
- {f.required ? null : <span className={t.optional}>(optional)</span>}
112
+ {f.required ? null : <span className={t.optional}>{theme.text.optional}</span>}
101
113
  </label>
102
114
  {f.type === "textarea" ? (
103
115
  <textarea {...common} rows={5} defaultValue={value} placeholder={f.placeholder} className={t.input} />
104
116
  ) : f.type === "select" ? (
105
117
  <select {...common} defaultValue={value ?? ""} className={t.input}>
106
118
  <option value="" disabled>
107
- {f.placeholder || "Choose one…"}
119
+ {f.placeholder || theme.text.chooseOne}
108
120
  </option>
109
121
  {(f.options ?? []).map((o) => (
110
122
  <option key={o}>{o}</option>
@@ -1,52 +1,8 @@
1
1
  /*
2
- * Hearth's base CSS, shared by every site: the on-dark variant, rich text and focus rings. Don't edit it in a site:
3
- * tokens and the site's own CSS go in src/theme/theme.css, which is loaded after this file and can override anything here.
2
+ * What Hearth's slots need to work, shared by every site: the on-dark variant. Don't edit it in a site: tokens and the
3
+ * site's own CSS go in src/theme/theme.css. Rich text and focus rings are in base.css, so that a site inside an app, whose
4
+ * own CSS already sets those, can import this file alone.
4
5
  */
5
6
 
6
7
  /* On an inverse or accent ground: set by sections with a dark tone, the hero, image calls to action and the footer. */
7
8
  @custom-variant on-dark (&:where([data-ground="dark"], [data-ground="dark"] *));
8
-
9
- /* Markdown from Hearth (rich text, FAQ answers, media + text). Unlayered, so its line height beats text-* sizes. */
10
- .prose {
11
- line-height: 1.7;
12
- }
13
- .prose > * + * {
14
- margin-top: 1em;
15
- }
16
- .prose h2 {
17
- font-size: 1.75rem;
18
- font-weight: 700;
19
- line-height: 1.25;
20
- margin-top: 1.6em;
21
- }
22
- .prose h3 {
23
- font-size: 1.35rem;
24
- font-weight: 700;
25
- margin-top: 1.4em;
26
- }
27
- .prose a {
28
- text-decoration: underline;
29
- text-underline-offset: 3px;
30
- }
31
- .prose ul {
32
- list-style: disc;
33
- padding-left: 1.4em;
34
- }
35
- .prose ol {
36
- list-style: decimal;
37
- padding-left: 1.4em;
38
- }
39
- .prose blockquote {
40
- border-left: 3px solid var(--color-line);
41
- padding-left: 1em;
42
- font-style: italic;
43
- }
44
- .prose img {
45
- border-radius: 0.75rem;
46
- }
47
-
48
- /* Unlayered, so it beats utilities like focus:outline-none: keyboard focus always shows. */
49
- :focus-visible {
50
- outline: 2px solid var(--color-accent);
51
- outline-offset: 2px;
52
- }
@@ -25,7 +25,7 @@ export function MobileNav({ siteName, links, cta, dropdowns = {} }: { siteName:
25
25
  <button type="button" className={t.toggle} aria-haspopup="dialog" onClick={() => dialog.current?.showModal()}>
26
26
  {theme.text.menu}
27
27
  </button>
28
- <dialog ref={dialog} aria-label="Menu" className={t.dialog}>
28
+ <dialog ref={dialog} aria-label={theme.text.menu} className={t.dialog}>
29
29
  <div className={t.top}>
30
30
  <p className={t.title}>{siteName}</p>
31
31
  <button type="button" className={t.close} onClick={close}>
@@ -2,12 +2,16 @@
2
2
 
3
3
  import { usePathname } from "next/navigation";
4
4
  import type { ReactNode } from "react";
5
+ import { theme } from "@/theme";
5
6
  import { SmartLink } from "../ui";
6
7
 
7
- /** A menu link that marks itself as the current page (aria-current), for the theme's aria-[current=page]: variant. */
8
+ /**
9
+ * A menu link that marks itself as the current page (aria-current), for the theme's aria-[current=page]: variant. It
10
+ * counts as current on its own address and the ones under it, or only its own with `page.navMatch` set to "exact".
11
+ */
8
12
  export function NavLink({ href, className, onClick, children }: { href: string; className: string; onClick?: () => void; children: ReactNode }) {
9
13
  const pathname = usePathname();
10
- const current = href === "/" ? pathname === "/" : pathname === href || pathname.startsWith(`${href}/`);
14
+ const current = href === "/" || theme.page.navMatch === "exact" ? pathname === href : pathname === href || pathname.startsWith(`${href}/`);
11
15
  return (
12
16
  <SmartLink href={href} className={className} onClick={onClick} aria-current={current ? "page" : undefined}>
13
17
  {children}
@@ -1,14 +1,10 @@
1
+ import type { Viewport } from "next";
1
2
  import type { ReactNode } from "react";
2
- import { HearthAnalytics } from "@the-smithy/hearth/analytics";
3
3
  import { siteLang } from "@the-smithy/hearth/next";
4
- import { HearthPreview } from "@the-smithy/hearth/preview";
5
4
  import { theme } from "@/theme";
6
- import { Blocks } from "../blocks";
7
5
  import { fontVariables } from "@/theme/fonts";
8
6
  import { hearthNext } from "../client";
9
- import { Popup } from "./Popup";
10
- import { SiteFooter } from "./SiteFooter";
11
- import { SiteHeader } from "./SiteHeader";
7
+ import SiteFrame from "./SiteFrame";
12
8
 
13
9
  /**
14
10
  * What every page starts from: full links, the title pattern, the default description and share image, the site's
@@ -17,35 +13,15 @@ import { SiteHeader } from "./SiteHeader";
17
13
  */
18
14
  export const generateMetadata = () => hearthNext.siteMetadata();
19
15
 
16
+ /** The browser's color around the page on phones (`page.themeColor`), when the site sets one. */
17
+ export const viewport: Viewport = theme.page.themeColor ? { themeColor: theme.page.themeColor } : {};
18
+
20
19
  export default async function RootLayout({ children }: { children: ReactNode }) {
21
- const [site, preview, globals] = await Promise.all([hearthNext.getSite(), hearthNext.isPreview(), hearthNext.getGlobalBlocks()]);
22
- const t = theme.page;
23
- // Global blocks (Global blocks in Hearth) shown outside pages: under menu items, and as pop-ups.
24
- const dropdowns = Object.fromEntries(
25
- globals.filter((g) => site.settings.navigation.some((n) => n.dropdown === g.key)).map((g) => [g.key, <Blocks key={g.key} blocks={[g.block]} />]),
26
- );
20
+ const site = await hearthNext.getSite();
27
21
  return (
28
22
  <html lang={siteLang(site)} className={fontVariables || undefined}>
29
- <body className={t.body}>
30
- <a href="#main" className={t.skipLink}>
31
- Skip to content
32
- </a>
33
- {/* Live draft preview from Hearth: shows while an editor previews, refreshes as they edit. */}
34
- {preview ? <HearthPreview /> : null}
35
- <SiteHeader site={site} dropdowns={dropdowns} />
36
- <main id="main" className={t.main}>
37
- {children}
38
- </main>
39
- <SiteFooter site={site} />
40
- {globals.map((g) =>
41
- g.popup ? (
42
- <Popup key={g.key} popupKey={g.key} name={g.name} title={g.popup.title} button={g.popup.button}>
43
- <Blocks blocks={[g.block]} />
44
- </Popup>
45
- ) : null,
46
- )}
47
- {/* Hearth's built-in visitor analytics (cookieless, no banner needed): off in draft preview and when the site turns it off. */}
48
- {site.analytics && !preview ? <HearthAnalytics config={site.analytics} /> : null}
23
+ <body className={theme.page.body}>
24
+ <SiteFrame>{children}</SiteFrame>
49
25
  </body>
50
26
  </html>
51
27
  );