@the-smithy/create-hearth-site 0.24.0 → 0.25.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 (53) hide show
  1. package/package.json +3 -3
  2. package/template/AGENTS.md +4 -0
  3. package/template/CLAUDE.md +72 -10
  4. package/template/README.md +18 -4
  5. package/template/package.json +3 -2
  6. package/template/scripts/hearth.mjs +56 -8
  7. package/template/src/app/donate/thank-you/page.tsx +1 -1
  8. package/template/src/app/globals.css +1 -0
  9. package/template/src/app/layout.tsx +1 -1
  10. package/template/src/app/not-found.tsx +1 -1
  11. package/template/src/app/registered/page.tsx +1 -1
  12. package/template/src/hearth/base.css +66 -0
  13. package/template/src/hearth/blocks/collections.tsx +6 -5
  14. package/template/src/hearth/blocks/content.tsx +64 -28
  15. package/template/src/hearth/blocks/custom.tsx +9 -1
  16. package/template/src/hearth/blocks/events.tsx +25 -17
  17. package/template/src/hearth/blocks/giving.tsx +24 -20
  18. package/template/src/hearth/blocks/index.tsx +29 -4
  19. package/template/src/hearth/blocks/info.tsx +15 -13
  20. package/template/src/hearth/blocks/media.tsx +10 -9
  21. package/template/src/hearth/components/EventView.tsx +61 -29
  22. package/template/src/hearth/components/FundraiserView.tsx +77 -0
  23. package/template/src/hearth/components/NotHere.tsx +9 -0
  24. package/template/src/hearth/components/PageContent.tsx +23 -0
  25. package/template/src/hearth/components/Registration.tsx +3 -2
  26. package/template/src/hearth/components/Share.tsx +11 -11
  27. package/template/src/hearth/format.ts +5 -3
  28. package/template/src/hearth/forms/ContactForm.tsx +5 -3
  29. package/template/src/hearth/forms/DonateForm.tsx +49 -32
  30. package/template/src/hearth/forms/DonationForm.tsx +13 -0
  31. package/template/src/hearth/forms/RegistrationForm.tsx +16 -14
  32. package/template/src/hearth/forms/VolunteerForm.tsx +10 -8
  33. package/template/src/hearth/forms/fields.tsx +17 -5
  34. package/template/src/hearth/hearth.css +3 -47
  35. package/template/src/hearth/layout/MobileNav.tsx +1 -1
  36. package/template/src/hearth/layout/NavLink.tsx +6 -2
  37. package/template/src/hearth/layout/RootLayout.tsx +8 -32
  38. package/template/src/hearth/layout/SiteFooter.tsx +13 -6
  39. package/template/src/hearth/layout/SiteFrame.tsx +48 -0
  40. package/template/src/hearth/layout/SiteHeader.tsx +9 -4
  41. package/template/src/hearth/overrides.ts +48 -0
  42. package/template/src/hearth/pages/donate.tsx +24 -15
  43. package/template/src/hearth/pages/event.tsx +3 -2
  44. package/template/src/hearth/pages/events.tsx +22 -10
  45. package/template/src/hearth/pages/fundraiser.tsx +10 -63
  46. package/template/src/hearth/pages/fundraisers.tsx +21 -11
  47. package/template/src/hearth/pages/not-found.tsx +28 -8
  48. package/template/src/hearth/pages/page.tsx +9 -15
  49. package/template/src/hearth/pages/registered.tsx +23 -10
  50. package/template/src/hearth/pages/thank-you.tsx +23 -10
  51. package/template/src/hearth/theme/defaults.ts +125 -2
  52. package/template/src/hearth/ui.tsx +20 -5
  53. package/template/src/theme/overrides.tsx +19 -5
@@ -8,18 +8,65 @@ import { hearthNext } from "../client";
8
8
  import { formatEventDate } from "../format";
9
9
  import { RegistrationForm } from "../forms/RegistrationForm";
10
10
  import { VolunteerForm } from "../forms/VolunteerForm";
11
+ import { overrides } from "../overrides";
11
12
  import { siteUrl } from "../site";
12
13
  import { fill, Img } from "../ui";
13
14
  import { Registration } from "./Registration";
14
15
  import { Share } from "./Share";
15
16
 
17
+ /** An event's page: its structured data, and the site's EventPage override or Hearth's own. Used at /events/… and /<collection>/…. */
18
+ export function EventView({ event }: { event: HearthEventDetail }) {
19
+ const Page = overrides.EventPage ?? EventContent;
20
+ return (
21
+ <>
22
+ <JsonLd data={event.meta.jsonLd} />
23
+ <Page event={event} />
24
+ </>
25
+ );
26
+ }
27
+
28
+ /** Registration on the event's page (sites with Tickets), while it's open. For an EventPage override to reuse. */
29
+ export function EventRegisterSection({ event }: { event: HearthEventDetail }) {
30
+ const t = theme.event;
31
+ if (!event.registration?.open) return null;
32
+ return (
33
+ <section id="register" className={t.section}>
34
+ <div className={t.sectionInner}>
35
+ <h2 className={t.sectionTitle}>{theme.text.register}</h2>
36
+ {event.registration.fundraiser ? <p className={t.sectionNote}>{fill(theme.text.ticketsSupport, { fundraiser: event.registration.fundraiser.title })}</p> : null}
37
+ <div className={t.form}>
38
+ <RegistrationForm slug={event.slug} tickets={event.registration.tickets} />
39
+ </div>
40
+ </div>
41
+ </section>
42
+ );
43
+ }
44
+
45
+ /** Volunteer sign-up on the event's page, while it's open. For an EventPage override to reuse; leave it out if the event's blocks have a Volunteer sign-up. */
46
+ export function EventVolunteerSection({ event }: { event: HearthEventDetail }) {
47
+ const t = theme.event;
48
+ if (!event.volunteering?.open) return null;
49
+ return (
50
+ <section id="volunteer" className={t.section}>
51
+ <div className={t.sectionInner}>
52
+ <h2 className={t.sectionTitle}>{theme.text.volunteer}</h2>
53
+ {event.volunteering.intro ? <p className={t.sectionIntro}>{event.volunteering.intro}</p> : null}
54
+ {event.volunteering.fundraiser ? <p className={t.sectionNote}>{fill(theme.text.supports, { fundraiser: event.volunteering.fundraiser.title })}</p> : null}
55
+ <div className={t.form}>
56
+ <VolunteerForm slug={event.slug} timezone={event.timezone} volunteering={event.volunteering} />
57
+ </div>
58
+ </div>
59
+ </section>
60
+ );
61
+ }
62
+
16
63
  /**
17
- * An event's page: header with date, place and registration, then its blocks. Used at /events/… and /<collection>/….
64
+ * Hearth's own event page: header with date, place and registration, then its blocks.
18
65
  * When the event's blocks start with a hero, the hero is the header (its heading is the page's only h1, the event's
19
66
  * name its eyebrow, the event's image its picture if it has none) and the details sit in a band under it.
20
67
  * Volunteer sign-up goes at the bottom, unless a Volunteer sign-up block for the event is placed on the page.
21
68
  */
22
- export async function EventView({ event }: { event: HearthEventDetail }) {
69
+ export async function EventContent({ event }: { event: HearthEventDetail }) {
23
70
  const t = theme.event;
24
71
  const site = await hearthNext.getSite();
25
72
  const base = siteUrl(site);
@@ -27,7 +74,7 @@ export async function EventView({ event }: { event: HearthEventDetail }) {
27
74
  const [first, ...rest] = event.blocks;
28
75
  const hero = first?.type === "hero" ? (first as BlockOf<"hero">) : null;
29
76
  // A Volunteer sign-up block for this event puts the form where the editor placed it, instead of at the bottom.
30
- const volunteerBlock = event.blocks.some((b) => b.type === "volunteerSignup" && (b as BlockOf<"volunteerSignup">).data.event.slug === event.slug);
77
+ const volunteerBlock = event.blocks.find((b) => b.type === "volunteerSignup" && (b as BlockOf<"volunteerSignup">).data.event.slug === event.slug);
31
78
  const online = event.attendance === "online" ? theme.text.online : event.attendance === "mixed" ? theme.text.onlineAndInPerson : null;
32
79
 
33
80
  const cancelled = event.status === "cancelled" ? <p className={t.cancelled}>{theme.text.cancelledEvent}</p> : null;
@@ -49,17 +96,23 @@ export async function EventView({ event }: { event: HearthEventDetail }) {
49
96
  <div className={t.actions}>
50
97
  <Registration event={event} />
51
98
  {event.volunteering?.open ? (
52
- <a href={volunteerBlock ? `#${volunteerAnchor(event.slug)}` : "#volunteer"} className={t.volunteer}>
99
+ <a href={volunteerBlock ? `#${volunteerBlock.anchor ?? volunteerAnchor(event.slug)}` : "#volunteer"} className={t.volunteer}>
53
100
  {theme.text.volunteer}
54
101
  </a>
55
102
  ) : null}
56
- {base && event.status !== "cancelled" ? <Share title={event.title} url={`${base}${event.path}`} message={`${event.title}, ${formatEventDate(event)}: ${base}${event.path}`} heading={`Share ${event.title}`} /> : null}
103
+ {base && event.status !== "cancelled" ? (
104
+ <Share
105
+ title={event.title}
106
+ url={`${base}${event.path}`}
107
+ message={fill(theme.text.shareEventMessage, { title: event.title, date: formatEventDate(event), url: `${base}${event.path}` })}
108
+ heading={fill(theme.text.shareEventHeading, { title: event.title })}
109
+ />
110
+ ) : null}
57
111
  </div>
58
112
  );
59
113
 
60
114
  return (
61
115
  <>
62
- <JsonLd data={event.meta.jsonLd} />
63
116
  {hero ? (
64
117
  <>
65
118
  <Hero block={{ ...hero, data: { ...hero.data, eyebrow: hero.data.eyebrow ?? event.title, image: hero.data.image ?? event.image ?? undefined } }} />
@@ -92,29 +145,8 @@ export async function EventView({ event }: { event: HearthEventDetail }) {
92
145
  </section>
93
146
  )}
94
147
  <Blocks blocks={hero ? rest : event.blocks} />
95
- {event.registration?.open ? (
96
- <section id="register" className={t.section}>
97
- <div className={t.sectionInner}>
98
- <h2 className={t.sectionTitle}>{theme.text.register}</h2>
99
- {event.registration.fundraiser ? <p className={t.sectionNote}>{fill(theme.text.ticketsSupport, { fundraiser: event.registration.fundraiser.title })}</p> : null}
100
- <div className={t.form}>
101
- <RegistrationForm slug={event.slug} tickets={event.registration.tickets} />
102
- </div>
103
- </div>
104
- </section>
105
- ) : null}
106
- {event.volunteering?.open && !volunteerBlock ? (
107
- <section id="volunteer" className={t.section}>
108
- <div className={t.sectionInner}>
109
- <h2 className={t.sectionTitle}>{theme.text.volunteer}</h2>
110
- {event.volunteering.intro ? <p className={t.sectionIntro}>{event.volunteering.intro}</p> : null}
111
- {event.volunteering.fundraiser ? <p className={t.sectionNote}>{fill(theme.text.supports, { fundraiser: event.volunteering.fundraiser.title })}</p> : null}
112
- <div className={t.form}>
113
- <VolunteerForm slug={event.slug} timezone={event.timezone} volunteering={event.volunteering} />
114
- </div>
115
- </div>
116
- </section>
117
- ) : null}
148
+ <EventRegisterSection event={event} />
149
+ {volunteerBlock ? null : <EventVolunteerSection event={event} />}
118
150
  </>
119
151
  );
120
152
  }
@@ -0,0 +1,77 @@
1
+ import type { FundraiserDetail } from "@the-smithy/hearth";
2
+ import { JsonLd } from "@the-smithy/hearth/react";
3
+ import { theme } from "@/theme";
4
+ import { Blocks } from "../blocks";
5
+ import { Progress } from "../blocks/giving";
6
+ import { hearthNext } from "../client";
7
+ import { DonationForm } from "../forms/DonationForm";
8
+ import { money } from "../format";
9
+ import { overrides } from "../overrides";
10
+ import { siteUrl } from "../site";
11
+ import { fill, Img, Section } from "../ui";
12
+ import { Share } from "./Share";
13
+
14
+ /** A fundraiser's page: its structured data, and the site's FundraiserPage override or Hearth's own. */
15
+ export function FundraiserView({ fundraiser }: { fundraiser: FundraiserDetail }) {
16
+ const Page = overrides.FundraiserPage ?? FundraiserContent;
17
+ return (
18
+ <>
19
+ <JsonLd data={fundraiser.meta.jsonLd} />
20
+ <Page fundraiser={fundraiser} />
21
+ </>
22
+ );
23
+ }
24
+
25
+ /** Hearth's own fundraiser page: progress, Donate and Share, then its story blocks and the donation form. */
26
+ export async function FundraiserContent({ fundraiser: f }: { fundraiser: FundraiserDetail }) {
27
+ const site = await hearthNext.getSite();
28
+ const giving = site.settings.giving;
29
+ const t = theme.fundraiser;
30
+ const base = siteUrl(site);
31
+ const url = base ? `${base}${f.path}` : null;
32
+ const active = f.status === "active";
33
+
34
+ return (
35
+ <>
36
+ <section className={t.header}>
37
+ <div className={t.headerInner}>
38
+ <div className={t.details}>
39
+ <p className={t.label}>{active ? theme.text.fundraiserLabel : theme.text.completedFundraiserLabel}</p>
40
+ <h1 className={t.title}>{f.title}</h1>
41
+ {f.summary ? <p className={t.summary}>{f.summary}</p> : null}
42
+ <Progress fundraiser={f} large />
43
+ {active ? (
44
+ <div className={t.actions}>
45
+ <a href="#donate" className={theme.button.primary}>
46
+ {theme.text.donate}
47
+ </a>
48
+ {url ? (
49
+ <Share
50
+ title={f.title}
51
+ url={url}
52
+ message={
53
+ f.goalCents
54
+ ? fill(theme.text.shareFundraiserGoalMessage, { title: f.title, raised: money(f.raisedCents), goal: money(f.goalCents), url })
55
+ : fill(theme.text.shareFundraiserMessage, { title: f.title, url })
56
+ }
57
+ heading={theme.text.shareFundraiserHeading}
58
+ />
59
+ ) : null}
60
+ </div>
61
+ ) : null}
62
+ </div>
63
+ {f.image ? <Img image={f.image} crop={4 / 3} className={t.image} /> : null}
64
+ </div>
65
+ </section>
66
+ <Blocks blocks={f.blocks} />
67
+ {active ? (
68
+ <Section block="fundraiserDonate">
69
+ <div className={t.give}>
70
+ <h2 className={t.giveTitle}>{fill(theme.text.giveTo, { fundraiser: f.title })}</h2>
71
+ <DonationForm presetsCents={giving.presetsCents} minCents={giving.minCents} maxCents={giving.maxCents} fundraiser={f.slug} />
72
+ </div>
73
+ </Section>
74
+ ) : null}
75
+ </>
76
+ );
77
+ }
@@ -0,0 +1,9 @@
1
+ "use client";
2
+
3
+ import { usePathname } from "next/navigation";
4
+ import type { ReactNode } from "react";
5
+
6
+ /** Draws its children everywhere but at `href`: a link to all of something isn't needed on the page it points at. */
7
+ export function NotHere({ href, children }: { href: string; children: ReactNode }) {
8
+ return usePathname() === href ? null : children;
9
+ }
@@ -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
  }