@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
@@ -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
  );
@@ -1,7 +1,8 @@
1
1
  import type { Site } from "@the-smithy/hearth";
2
2
  import { theme } from "@/theme";
3
+ import { overrides } from "../overrides";
3
4
  import { cx } from "../theme";
4
- import { ground, SmartLink, toTone } from "../ui";
5
+ import { fill, ground, SmartLink, toTone } from "../ui";
5
6
 
6
7
  const platformName: Record<string, string> = {
7
8
  facebook: "Facebook",
@@ -12,15 +13,22 @@ const platformName: Record<string, string> = {
12
13
  tiktok: "TikTok",
13
14
  };
14
15
 
15
- /** Contact details, footer links and social links from Hearth (Settings → Content). */
16
+ /** Contact details, footer links, social links and small print from Hearth (Settings → Content, and Navigation). */
16
17
  export function SiteFooter({ site }: { site: Site }) {
17
- const { footerLinks, contact, social, tagline } = site.settings;
18
+ const { footerLinks, contact, social, tagline, copyright, footerNote } = site.settings;
18
19
  const t = theme.footer;
19
20
  const tone = toTone(t.tone);
21
+ const Logo = overrides.Logo;
22
+ const year = String(new Date().getFullYear());
20
23
  return (
21
24
  <footer {...ground(tone)} className={cx(theme.tones[tone], t.root)}>
22
25
  <div className={t.inner}>
23
26
  <div className={t.about}>
27
+ {Logo && t.logo ? (
28
+ <div className={t.logo}>
29
+ <Logo site={site} place="footer" />
30
+ </div>
31
+ ) : null}
24
32
  <p className={t.name}>{site.name}</p>
25
33
  {tagline ? <p className={t.tagline}>{tagline}</p> : null}
26
34
  {contact.address ? <address className={t.address}>{contact.address}</address> : null}
@@ -58,9 +66,8 @@ export function SiteFooter({ site }: { site: Site }) {
58
66
  </ul>
59
67
  ) : null}
60
68
  </div>
61
- <p className={t.copyright}>
62
- © {new Date().getFullYear()} {site.name}
63
- </p>
69
+ <p className={t.copyright}>{copyright ? fill(copyright, { year }) : `© ${year} ${site.name}`}</p>
70
+ {footerNote ? <p className={t.note}>{footerNote}</p> : null}
64
71
  </footer>
65
72
  );
66
73
  }
@@ -0,0 +1,48 @@
1
+ import type { ReactNode } from "react";
2
+ import { HearthAnalytics } from "@the-smithy/hearth/analytics";
3
+ import { HearthPreview } from "@the-smithy/hearth/preview";
4
+ import { theme } from "@/theme";
5
+ import { Blocks } from "../blocks";
6
+ import { hearthNext } from "../client";
7
+ import { overrides } from "../overrides";
8
+ import { Popup } from "./Popup";
9
+ import { SiteFooter } from "./SiteFooter";
10
+ import { SiteHeader } from "./SiteHeader";
11
+
12
+ /**
13
+ * Everything around a page's content: preview, header, footer, pop-ups and analytics. RootLayout puts it in <body>;
14
+ * a site that lives inside an app, with its own root layout, uses this as the layout of its public pages, and can give
15
+ * `headerActions` to show beside the menu.
16
+ */
17
+ export default async function SiteFrame({ children, headerActions }: { children: ReactNode; headerActions?: ReactNode }) {
18
+ const [site, preview, globals] = await Promise.all([hearthNext.getSite(), hearthNext.isPreview(), hearthNext.getGlobalBlocks()]);
19
+ const t = theme.page;
20
+ const Footer = overrides.Footer ?? SiteFooter;
21
+ // Global blocks (Global blocks in Hearth) shown outside pages: under menu items, and as pop-ups.
22
+ const dropdowns = Object.fromEntries(
23
+ globals.filter((g) => site.settings.navigation.some((n) => n.dropdown === g.key)).map((g) => [g.key, <Blocks key={g.key} blocks={[g.block]} />]),
24
+ );
25
+ return (
26
+ <>
27
+ <a href="#main" className={t.skipLink}>
28
+ {theme.text.skipLink}
29
+ </a>
30
+ {/* Live draft preview from Hearth: shows while an editor previews, refreshes as they edit. */}
31
+ {preview ? <HearthPreview /> : null}
32
+ <SiteHeader site={site} dropdowns={dropdowns} actions={headerActions} />
33
+ <main id="main" className={t.main}>
34
+ {children}
35
+ </main>
36
+ <Footer site={site} />
37
+ {globals.map((g) =>
38
+ g.popup ? (
39
+ <Popup key={g.key} popupKey={g.key} name={g.name} title={g.popup.title} button={g.popup.button}>
40
+ <Blocks blocks={[g.block]} />
41
+ </Popup>
42
+ ) : null,
43
+ )}
44
+ {/* Hearth's built-in visitor analytics (cookieless, no banner needed): off in draft preview and when the site turns it off. */}
45
+ {site.analytics && !preview ? <HearthAnalytics config={site.analytics} /> : null}
46
+ </>
47
+ );
48
+ }
@@ -2,7 +2,7 @@ import Link from "next/link";
2
2
  import type { ReactNode } from "react";
3
3
  import type { Site } from "@the-smithy/hearth";
4
4
  import { theme } from "@/theme";
5
- import { Logo } from "@/theme/overrides";
5
+ import { overrides } from "../overrides";
6
6
  import { cx } from "../theme";
7
7
  import { ground, Img, toTone } from "../ui";
8
8
  import { MobileNav } from "./MobileNav";
@@ -11,19 +11,21 @@ import { NavLink } from "./NavLink";
11
11
 
12
12
  /**
13
13
  * Logo, menu, menu button and announcement banner, all edited in Hearth (Settings → General, and Navigation).
14
- * `dropdowns` holds the global blocks menu items open in a dropdown, drawn, by key.
14
+ * `dropdowns` holds the global blocks menu items open in a dropdown, drawn, by key. `actions` is what a site inside an
15
+ * app shows beside the menu at every width (Sign in, say), given by its layout through SiteFrame.
15
16
  */
16
- export function SiteHeader({ site, dropdowns = {} }: { site: Site; dropdowns?: Record<string, ReactNode> }) {
17
+ export function SiteHeader({ site, dropdowns = {}, actions }: { site: Site; dropdowns?: Record<string, ReactNode>; actions?: ReactNode }) {
17
18
  const { announcement, seo, tagline, navigation, navigationCta } = site.settings;
18
19
  const t = theme.header;
19
20
  const tone = toTone(t.tone);
21
+ const Logo = overrides.Logo;
20
22
  return (
21
23
  <header {...ground(tone)} className={cx(theme.tones[tone], t.root)}>
22
24
  {announcement ? <p className={t.announcement}>{announcement}</p> : null}
23
25
  <div className={t.inner}>
24
26
  <Link href="/" className={t.brand}>
25
27
  {Logo ? (
26
- <Logo site={site} />
28
+ <Logo site={site} place="header" />
27
29
  ) : (
28
30
  <>
29
31
  {seo.logo ? <Img image={{ src: seo.logo.src, alt: "" }} className={t.logo} /> : null}
@@ -60,8 +62,11 @@ export function SiteHeader({ site, dropdowns = {} }: { site: Site; dropdowns?: R
60
62
  </NavLink>
61
63
  ) : null}
62
64
  </nav>
65
+ {actions ? <div className={t.actions}>{actions}</div> : null}
63
66
  <MobileNav siteName={site.name} links={navigation} cta={navigationCta} dropdowns={dropdowns} />
64
67
  </>
68
+ ) : actions ? (
69
+ <div className={t.actions}>{actions}</div>
65
70
  ) : null}
66
71
  </div>
67
72
  </header>
@@ -0,0 +1,48 @@
1
+ import type { Fundraiser, FundraiserDetail, HearthEvent, HearthEventDetail, Site } from "@the-smithy/hearth";
2
+ import type { BlockComponents } from "@the-smithy/hearth/react";
3
+ import type { ComponentType } from "react";
4
+ import * as site from "@/theme/overrides";
5
+
6
+ /** The level of a card's title: the next one down from what's above the cards, so h3 under a block's heading and h2 without one. */
7
+ export type CardHeading = "h2" | "h3";
8
+
9
+ /** What a donation form is given, Hearth's or the site's. */
10
+ export type DonateFormProps = {
11
+ presetsCents: number[];
12
+ minCents: number;
13
+ maxCents: number;
14
+ /** Donations go to this fundraiser; no choice is offered. */
15
+ fundraiser?: string;
16
+ /** The active fundraisers to choose between, when `fundraiser` isn't set. */
17
+ fundraisers?: { slug: string; title: string }[];
18
+ submitLabel?: string;
19
+ };
20
+
21
+ /**
22
+ * Everything src/theme/overrides.tsx can export. All of it is optional, so a site's file, which updates never replace,
23
+ * exports only what it uses. The types are here for that file to use: `export const Footer: Overrides["Footer"] = …`.
24
+ */
25
+ export type Overrides = {
26
+ /** Replaces a whole block's component. */
27
+ blocks?: Partial<BlockComponents>;
28
+ /** The logo in the header, and in the footer if the `footer.logo` slot is set. */
29
+ Logo?: ComponentType<{ site: Site; place: "header" | "footer" }> | null;
30
+ /** Replaces the footer. */
31
+ Footer?: ComponentType<{ site: Site }> | null;
32
+ /** Replaces an event's page, at /events/… and in collections. Its structured data is drawn around it. */
33
+ EventPage?: ComponentType<{ event: HearthEventDetail }> | null;
34
+ /** Replaces a fundraiser's page. Its structured data is drawn around it. */
35
+ FundraiserPage?: ComponentType<{ fundraiser: FundraiserDetail }> | null;
36
+ /** Replaces an event's card, as an <li>. May be async. */
37
+ EventCard?: ComponentType<{ event: HearthEvent; heading: CardHeading }> | null;
38
+ /** Replaces a fundraiser's card, as an <li>. May be async. */
39
+ FundraiserCard?: ComponentType<{ fundraiser: Fundraiser; heading: CardHeading }> | null;
40
+ /** Replaces the donation form. */
41
+ DonateForm?: ComponentType<DonateFormProps> | null;
42
+ };
43
+
44
+ /**
45
+ * The site's overrides, whichever it has. Read them when drawing, not as a file loads: an override can import Hearth's
46
+ * own components, so src/theme/overrides.tsx may still be loading at that point. Server components only.
47
+ */
48
+ export const overrides = site as unknown as Overrides;
@@ -2,35 +2,44 @@ import type { Metadata } from "next";
2
2
  import { notFound } from "next/navigation";
3
3
  import { theme } from "@/theme";
4
4
  import { hearthNext } from "../client";
5
- import { DonateForm } from "../forms/DonateForm";
5
+ import { PageContent } from "../components/PageContent";
6
+ import { DonationForm } from "../forms/DonationForm";
6
7
  import { hasFeature } from "../site";
7
- import { Section } from "../ui";
8
+ import { fill, Section } from "../ui";
8
9
 
9
10
  export async function generateMetadata(): Promise<Metadata> {
10
- const site = await hearthNext.getSite();
11
+ const [site, own] = await Promise.all([hearthNext.getSite(), hearthNext.generatePageMetadata("donate")]);
12
+ if (Object.keys(own).length) return own;
11
13
  return {
12
- title: "Donate",
13
- description: `Support ${site.name} with a donation.`,
14
+ title: theme.text.donateTitle,
15
+ description: fill(theme.text.donateDescription, { site: site.name }),
14
16
  alternates: { canonical: "/donate" },
15
- openGraph: { title: "Donate", url: "/donate" },
17
+ openGraph: { title: theme.text.donateTitle, url: "/donate" },
16
18
  };
17
19
  }
18
20
 
19
21
  /**
20
- * A general donation page (sites with Giving). Ask staff to reserve "donate" on the site's Plan card in Hearth so
21
- * no page takes this address. For a page built in Hearth instead, use the Donation form block there.
22
+ * A general donation page (sites with Giving). A page in Hearth at `donate`, with a Donation form block, replaces this
23
+ * one's words, so don't reserve "donate" on the site's Plan card in Hearth: that would refuse the page.
22
24
  */
23
25
  export default async function DonatePage() {
24
26
  const site = await hearthNext.getSite();
25
27
  if (!hasFeature(site, "giving")) notFound();
28
+ const page = await hearthNext.getPage("donate");
26
29
  const giving = site.settings.giving;
27
30
  return (
28
- <Section block="donatePage">
29
- <div className={theme.donate.wrap}>
30
- <h1 className={theme.page.title}>Donate</h1>
31
- <p className={theme.page.lead}>Your gift supports {giving.legalName || site.name}.</p>
32
- <DonateForm presetsCents={giving.presetsCents} minCents={giving.minCents} maxCents={giving.maxCents} />
33
- </div>
34
- </Section>
31
+ <div data-page="donate">
32
+ {page ? (
33
+ <PageContent page={page} />
34
+ ) : (
35
+ <Section block="donatePage">
36
+ <div className={theme.donate.wrap}>
37
+ <h1 className={theme.page.title}>{theme.text.donateTitle}</h1>
38
+ <p className={theme.page.lead}>{fill(theme.text.donateLead, { site: giving.legalName || site.name })}</p>
39
+ <DonationForm presetsCents={giving.presetsCents} minCents={giving.minCents} maxCents={giving.maxCents} />
40
+ </div>
41
+ </Section>
42
+ )}
43
+ </div>
35
44
  );
36
45
  }
@@ -3,6 +3,7 @@ import { permanentRedirect } from "next/navigation";
3
3
  import { hearthNext } from "../client";
4
4
  import { EventView } from "../components/EventView";
5
5
  import { hasFeature } from "../site";
6
+ import { generateMetadata as notFoundMetadata } from "./not-found";
6
7
 
7
8
  type Props = { params: Promise<{ slug: string }> };
8
9
 
@@ -13,11 +14,11 @@ async function load(slug: string) {
13
14
 
14
15
  export async function generateMetadata({ params }: Props) {
15
16
  const event = await load((await params).slug);
16
- return event ? metadataFromMeta(event.meta) : {};
17
+ return event ? metadataFromMeta(event.meta) : notFoundMetadata();
17
18
  }
18
19
 
19
20
  /** Events without a type live here. Events in a collection (e.g. Talks) live at their collection's address. */
20
- export default async function EventPage({ params }: Props) {
21
+ export default async function EventRoute({ params }: Props) {
21
22
  const slug = (await params).slug;
22
23
  const event = await load(slug);
23
24
  if (!event) return hearthNext.redirectOrNotFound(`/events/${slug}`);
@@ -3,28 +3,40 @@ import { notFound } from "next/navigation";
3
3
  import { theme } from "@/theme";
4
4
  import { EventList } from "../blocks/events";
5
5
  import { hearthNext } from "../client";
6
+ import { PageContent } from "../components/PageContent";
6
7
  import { hasFeature } from "../site";
7
- import { Section } from "../ui";
8
+ import { fill, Section } from "../ui";
8
9
 
9
10
  export async function generateMetadata(): Promise<Metadata> {
10
- const site = await hearthNext.getSite();
11
+ const [site, own] = await Promise.all([hearthNext.getSite(), hearthNext.generatePageMetadata("events")]);
12
+ if (Object.keys(own).length) return own;
11
13
  return {
12
- title: "Events",
13
- description: `Upcoming events from ${site.name}.`,
14
+ title: theme.text.eventsTitle,
15
+ description: fill(theme.text.eventsDescription, { site: site.name }),
14
16
  alternates: { canonical: "/events" },
15
- openGraph: { title: "Events", url: "/events" },
17
+ openGraph: { title: theme.text.eventsTitle, url: "/events" },
16
18
  };
17
19
  }
18
20
 
19
- /** Upcoming events (only on sites with the Events feature in Hearth). */
21
+ /** Upcoming events (only on sites with the Events feature in Hearth), or a page in Hearth at `events` instead. */
20
22
  export default async function EventsPage() {
21
23
  const site = await hearthNext.getSite();
22
24
  if (!hasFeature(site, "events")) notFound();
25
+ const page = await hearthNext.getPage("events");
26
+ if (page) {
27
+ return (
28
+ <div data-page="events">
29
+ <PageContent page={page} />
30
+ </div>
31
+ );
32
+ }
23
33
  const events = await hearthNext.getEvents({ when: "upcoming" });
24
34
  return (
25
- <Section block="events">
26
- <h1 className={theme.page.title}>Events</h1>
27
- <EventList events={events} />
28
- </Section>
35
+ <div data-page="events">
36
+ <Section block="events">
37
+ <h1 className={theme.page.title}>{theme.text.eventsTitle}</h1>
38
+ <EventList events={events} heading="h2" />
39
+ </Section>
40
+ </div>
29
41
  );
30
42
  }