@the-smithy/create-hearth-site 0.19.0 → 0.21.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 (84) hide show
  1. package/bin/create-hearth-site.js +5 -0
  2. package/package.json +2 -2
  3. package/template/AGENTS.md +11 -2
  4. package/template/CLAUDE.md +87 -20
  5. package/template/README.md +55 -24
  6. package/template/SITE.md +17 -0
  7. package/template/package.json +3 -1
  8. package/template/scripts/hearth.mjs +194 -14
  9. package/template/src/app/[[...slug]]/page.tsx +2 -54
  10. package/template/src/app/api/preview/exit/route.ts +1 -1
  11. package/template/src/app/api/preview/route.ts +1 -1
  12. package/template/src/app/api/revalidate/route.ts +1 -1
  13. package/template/src/app/apple-icon.tsx +1 -1
  14. package/template/src/app/donate/page.tsx +2 -35
  15. package/template/src/app/donate/thank-you/page.tsx +2 -22
  16. package/template/src/app/events/[slug]/page.tsx +2 -26
  17. package/template/src/app/events/page.tsx +2 -29
  18. package/template/src/app/fundraisers/[slug]/page.tsx +2 -55
  19. package/template/src/app/fundraisers/page.tsx +2 -37
  20. package/template/src/app/globals.css +3 -74
  21. package/template/src/app/icon.tsx +1 -1
  22. package/template/src/app/layout.tsx +2 -36
  23. package/template/src/app/not-found.tsx +2 -14
  24. package/template/src/app/registered/page.tsx +2 -22
  25. package/template/src/app/robots.ts +1 -1
  26. package/template/src/app/sitemap.ts +2 -2
  27. package/template/src/{app → hearth}/actions.ts +15 -1
  28. package/template/src/hearth/blocks/OpenMap.tsx +104 -0
  29. package/template/src/hearth/blocks/collections.tsx +74 -0
  30. package/template/src/hearth/blocks/content.tsx +252 -0
  31. package/template/src/hearth/blocks/custom.tsx +12 -0
  32. package/template/src/hearth/blocks/events.tsx +130 -0
  33. package/template/src/hearth/blocks/giving.tsx +123 -0
  34. package/template/src/{components → hearth}/blocks/index.tsx +17 -11
  35. package/template/src/hearth/blocks/info.tsx +84 -0
  36. package/template/src/{components → hearth}/blocks/media.tsx +30 -27
  37. package/template/src/hearth/components/EventView.tsx +120 -0
  38. package/template/src/{components → hearth/components}/Registration.tsx +14 -14
  39. package/template/src/hearth/components/Share.tsx +102 -0
  40. package/template/src/hearth/format.ts +31 -0
  41. package/template/src/hearth/forms/ContactForm.tsx +27 -0
  42. package/template/src/hearth/forms/DonateForm.tsx +69 -0
  43. package/template/src/{components/blocks → hearth/forms}/EmberFormBlock.tsx +6 -5
  44. package/template/src/hearth/forms/RegistrationForm.tsx +68 -0
  45. package/template/src/hearth/forms/VolunteerForm.tsx +58 -0
  46. package/template/src/hearth/forms/fields.tsx +137 -0
  47. package/template/src/hearth/hearth.css +52 -0
  48. package/template/src/hearth/layout/MobileNav.tsx +49 -0
  49. package/template/src/hearth/layout/NavLink.tsx +16 -0
  50. package/template/src/hearth/layout/RootLayout.tsx +40 -0
  51. package/template/src/{components → hearth/layout}/SiteFooter.tsx +19 -14
  52. package/template/src/hearth/layout/SiteHeader.tsx +53 -0
  53. package/template/src/hearth/manifest.json +36 -0
  54. package/template/src/hearth/pages/donate.tsx +36 -0
  55. package/template/src/hearth/pages/event.tsx +26 -0
  56. package/template/src/hearth/pages/events.tsx +30 -0
  57. package/template/src/hearth/pages/fundraiser.tsx +77 -0
  58. package/template/src/hearth/pages/fundraisers.tsx +38 -0
  59. package/template/src/hearth/pages/not-found.tsx +15 -0
  60. package/template/src/hearth/pages/page.tsx +54 -0
  61. package/template/src/hearth/pages/registered.tsx +19 -0
  62. package/template/src/hearth/pages/thank-you.tsx +19 -0
  63. package/template/src/hearth/theme/defaults.ts +510 -0
  64. package/template/src/hearth/theme/index.ts +31 -0
  65. package/template/src/hearth/ui.tsx +144 -0
  66. package/template/src/theme/custom-blocks.tsx +11 -0
  67. package/template/src/theme/fonts.ts +9 -0
  68. package/template/src/theme/index.ts +13 -0
  69. package/template/src/theme/overrides.tsx +16 -0
  70. package/template/src/theme/theme.css +29 -0
  71. package/template/src/components/DonateForm.tsx +0 -104
  72. package/template/src/components/EventView.tsx +0 -48
  73. package/template/src/components/RegistrationForm.tsx +0 -103
  74. package/template/src/components/SiteHeader.tsx +0 -37
  75. package/template/src/components/blocks/Form.tsx +0 -92
  76. package/template/src/components/blocks/collections.tsx +0 -75
  77. package/template/src/components/blocks/content.tsx +0 -257
  78. package/template/src/components/blocks/custom.tsx +0 -20
  79. package/template/src/components/blocks/events.tsx +0 -104
  80. package/template/src/components/blocks/giving.tsx +0 -120
  81. package/template/src/components/blocks/info.tsx +0 -52
  82. package/template/src/components/ui.tsx +0 -128
  83. /package/template/src/{lib/hearth.ts → hearth/client.ts} +0 -0
  84. /package/template/src/{lib → hearth}/site.ts +0 -0
@@ -0,0 +1,68 @@
1
+ "use client";
2
+
3
+ import { useActionState, useState } from "react";
4
+ import type { EventTicket, FormResult } from "@the-smithy/hearth";
5
+ import { theme } from "@/theme";
6
+ import { register } from "../actions";
7
+ import { price } from "../format";
8
+ import { Alert, FieldError, OptionCard, Submit, Success, TextField } from "./fields";
9
+
10
+ const t = theme.form;
11
+
12
+ /**
13
+ * On-site registration (sites with Tickets): pick a ticket, give contact details and attendee names. Free tickets
14
+ * are confirmed here; paid ones go to the payment page and come back to /registered.
15
+ */
16
+ export function RegistrationForm({ slug, tickets }: { slug: string; tickets: EventTicket[] }) {
17
+ const [state, action] = useActionState<FormResult | null, FormData>(register.bind(null, slug), null);
18
+ const available = tickets.filter((ticket) => !ticket.soldOut);
19
+ const [ticketId, setTicketId] = useState(available[0]?.id ?? "");
20
+ const ticket = tickets.find((x) => x.id === ticketId);
21
+ const errors = state && !state.ok ? state.fieldErrors : {};
22
+ const paid = Boolean(ticket?.priceCents);
23
+ const attendee = ticket?.attendeeLabel ?? "Attendee";
24
+
25
+ if (state?.ok) return <Success>{state.message}</Success>;
26
+
27
+ return (
28
+ <form action={action} className={t.root}>
29
+ {state && !state.ok ? <Alert>{state.message}</Alert> : null}
30
+ <fieldset>
31
+ <legend className={t.legend}>Ticket</legend>
32
+ <div className={t.options}>
33
+ {tickets.map((x) => (
34
+ <OptionCard
35
+ 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} />}
37
+ title={x.name}
38
+ meta={x.soldOut ? "Sold out" : price(x.priceCents)}
39
+ >
40
+ {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}
42
+ </OptionCard>
43
+ ))}
44
+ </div>
45
+ <FieldError>{errors.ticket}</FieldError>
46
+ </fieldset>
47
+ <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} />
52
+ </div>
53
+ {ticket && ticket.attendeesPerRegistration > 0 ? (
54
+ <fieldset>
55
+ <legend className={t.legend}>{ticket.attendeesPerRegistration === 1 ? attendee : `${attendee}s`}</legend>
56
+ <div className={t.row}>
57
+ {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} />
59
+ ))}
60
+ </div>
61
+ <FieldError>{errors.attendees}</FieldError>
62
+ </fieldset>
63
+ ) : 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
+ </form>
67
+ );
68
+ }
@@ -0,0 +1,58 @@
1
+ "use client";
2
+
3
+ import { useActionState } from "react";
4
+ import type { EventVolunteering } from "@the-smithy/hearth";
5
+ import { theme } from "@/theme";
6
+ import { volunteer, type VolunteerState } from "../actions";
7
+ import { formatShift } from "../format";
8
+ import { Alert, FieldError, Honeypot, OptionCard, Question, Submit, Success, TextField } from "./fields";
9
+
10
+ const t = theme.form;
11
+
12
+ /**
13
+ * Volunteer sign-up for an event: name and email, shifts to choose from (when the event lists them), then the
14
+ * event's own questions. Sign-ups land under the event's Volunteers in Hearth.
15
+ */
16
+ export function VolunteerForm({ slug, timezone, volunteering }: { slug: string; timezone: string; volunteering: EventVolunteering }) {
17
+ const [state, action] = useActionState<VolunteerState | null, FormData>(volunteer.bind(null, slug), null);
18
+ const errors = state && !state.ok ? state.fieldErrors : {};
19
+ const values = (state && !state.ok ? state.values : undefined) ?? {};
20
+ const chosen = (state && !state.ok ? state.shifts : undefined) ?? [];
21
+ const many = volunteering.pickShifts === "many";
22
+
23
+ if (state?.ok) return <Success>{state.message}</Success>;
24
+
25
+ return (
26
+ <form action={action} className={t.root} noValidate>
27
+ {state && !state.ok ? <Alert>{state.message}</Alert> : null}
28
+ <Honeypot />
29
+ <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
+ </div>
33
+ {volunteering.pickShifts !== "none" ? (
34
+ <fieldset>
35
+ <legend className={t.legend}>{many ? "Shifts you can do" : "Choose a shift"}</legend>
36
+ <div className={t.options}>
37
+ {volunteering.shifts.map((s) => (
38
+ <OptionCard
39
+ 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} />}
41
+ title={s.name}
42
+ meta={s.full ? "Full" : s.remaining !== null && s.remaining <= 5 ? `${s.remaining} spot${s.remaining === 1 ? "" : "s"} left` : null}
43
+ >
44
+ <span className={t.optionDetail}>{formatShift(s, timezone)}</span>
45
+ {s.description ? <span className={t.optionDetail}>{s.description}</span> : null}
46
+ </OptionCard>
47
+ ))}
48
+ </div>
49
+ <FieldError>{errors.shifts}</FieldError>
50
+ </fieldset>
51
+ ) : null}
52
+ {volunteering.fields.map((f) => (
53
+ <Question key={f.name} field={f} idPrefix="volunteer" error={errors[f.name]} value={values[f.name]} />
54
+ ))}
55
+ <Submit label={volunteering.submitLabel} pending="Signing up…" />
56
+ </form>
57
+ );
58
+ }
@@ -0,0 +1,137 @@
1
+ "use client";
2
+
3
+ import type { BlockOf } from "@the-smithy/hearth";
4
+ import type { InputHTMLAttributes, ReactNode } from "react";
5
+ import { useFormStatus } from "react-dom";
6
+ import { theme } from "@/theme";
7
+
8
+ /* The pieces every form is built from (contact form, volunteer sign-up, registration, donations), styled by `form`. */
9
+
10
+ const t = theme.form;
11
+
12
+ /** A question defined in Hearth (a form field, an event's volunteer question). */
13
+ type FormField = BlockOf<"form">["data"]["fields"] extends (infer F)[] | undefined ? F : never;
14
+
15
+ export function Alert({ children }: { children: ReactNode }) {
16
+ return (
17
+ <p role="alert" className={t.alert}>
18
+ {children}
19
+ </p>
20
+ );
21
+ }
22
+
23
+ export function Success({ children }: { children: ReactNode }) {
24
+ return (
25
+ <p role="status" className={t.success}>
26
+ {children}
27
+ </p>
28
+ );
29
+ }
30
+
31
+ /** Hidden from people, filled in by bots: Hearth drops submissions that fill it. */
32
+ export function Honeypot() {
33
+ return (
34
+ <div aria-hidden className="absolute -left-[9999px]">
35
+ <label>
36
+ Leave this empty
37
+ <input name="_gotcha" tabIndex={-1} autoComplete="off" />
38
+ </label>
39
+ </div>
40
+ );
41
+ }
42
+
43
+ export function Submit({ label, pending: pendingLabel }: { label: string; pending: string }) {
44
+ const { pending } = useFormStatus();
45
+ return (
46
+ <button type="submit" disabled={pending} className={t.submit}>
47
+ {pending ? pendingLabel : label}
48
+ </button>
49
+ );
50
+ }
51
+
52
+ export function FieldError({ id, children }: { id?: string; children?: string }) {
53
+ return children ? (
54
+ <span id={id} className={t.error}>
55
+ {children}
56
+ </span>
57
+ ) : null;
58
+ }
59
+
60
+ /** A labelled text input (name, email, phone…), with its error under it. */
61
+ export function TextField({ label, optional, error, textarea, rows = 3, ...input }: { label: string; optional?: boolean; error?: string; textarea?: boolean; rows?: number } & InputHTMLAttributes<HTMLInputElement> & { maxLength?: number }) {
62
+ const describedBy = error && input.name ? `${input.name}-error` : undefined;
63
+ const common = { ...input, "aria-invalid": error ? true : undefined, "aria-describedby": describedBy, className: t.input };
64
+ return (
65
+ <label className={t.field}>
66
+ <span className={t.label}>
67
+ {label}
68
+ {optional ? <span className={t.optional}>(optional)</span> : null}
69
+ </span>
70
+ {textarea ? <textarea {...(common as object)} rows={rows} /> : <input {...common} />}
71
+ <FieldError id={describedBy}>{error}</FieldError>
72
+ </label>
73
+ );
74
+ }
75
+
76
+ export function Checkbox({ name, label, defaultChecked }: { name: string; label: ReactNode; defaultChecked?: boolean }) {
77
+ return (
78
+ <label className={t.check}>
79
+ <input type="checkbox" name={name} defaultChecked={defaultChecked} className={t.checkbox} />
80
+ {label}
81
+ </label>
82
+ );
83
+ }
84
+
85
+ /** One of the questions defined in Hearth (a form's fields, an event's volunteer questions). */
86
+ export function Question({ field: f, idPrefix, error, value }: { field: FormField; idPrefix: string; error?: string; value?: string }) {
87
+ const id = `${idPrefix}-${f.name}`;
88
+ const common = { id, name: f.name, required: f.required, "aria-invalid": error ? true : undefined, "aria-describedby": error ? `${id}-error` : undefined };
89
+ return (
90
+ <div>
91
+ {f.type === "checkbox" ? (
92
+ <label className={t.check}>
93
+ <input type="checkbox" {...common} defaultChecked={value === "on"} className={t.checkbox} />
94
+ {f.label}
95
+ </label>
96
+ ) : (
97
+ <>
98
+ <label htmlFor={id} className={t.label}>
99
+ {f.label}
100
+ {f.required ? null : <span className={t.optional}>(optional)</span>}
101
+ </label>
102
+ {f.type === "textarea" ? (
103
+ <textarea {...common} rows={5} defaultValue={value} placeholder={f.placeholder} className={t.input} />
104
+ ) : f.type === "select" ? (
105
+ <select {...common} defaultValue={value ?? ""} className={t.input}>
106
+ <option value="" disabled>
107
+ {f.placeholder || "Choose one…"}
108
+ </option>
109
+ {(f.options ?? []).map((o) => (
110
+ <option key={o}>{o}</option>
111
+ ))}
112
+ </select>
113
+ ) : (
114
+ <input {...common} type={f.type} defaultValue={value} placeholder={f.placeholder} autoComplete={f.type === "email" ? "email" : f.type === "tel" ? "tel" : f.name === "name" ? "name" : undefined} className={t.input} />
115
+ )}
116
+ </>
117
+ )}
118
+ <FieldError id={`${id}-error`}>{error}</FieldError>
119
+ </div>
120
+ );
121
+ }
122
+
123
+ /** A choice drawn as a card (a shift, a ticket): its input, title, a note on the right and details under it. */
124
+ export function OptionCard({ input, title, meta, children }: { input: ReactNode; title: ReactNode; meta?: ReactNode; children?: ReactNode }) {
125
+ return (
126
+ <label className={t.option}>
127
+ {input}
128
+ <span className={t.optionText}>
129
+ <span className={t.optionTitle}>
130
+ <span>{title}</span>
131
+ {meta ? <span className={t.optionMeta}>{meta}</span> : null}
132
+ </span>
133
+ {children}
134
+ </span>
135
+ </label>
136
+ );
137
+ }
@@ -0,0 +1,52 @@
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.
4
+ */
5
+
6
+ /* On an inverse or accent ground: set by sections with a dark tone, the hero, image calls to action and the footer. */
7
+ @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
+ }
@@ -0,0 +1,49 @@
1
+ "use client";
2
+
3
+ import { usePathname } from "next/navigation";
4
+ import { useEffect, useRef } from "react";
5
+ import { theme } from "@/theme";
6
+ import { NavLink } from "./NavLink";
7
+
8
+ type Link = { label: string; href: string };
9
+
10
+ const t = theme.mobileNav;
11
+
12
+ /**
13
+ * The menu on phones: a button that opens the links full screen, in a dialog (so focus stays in it and Escape closes
14
+ * it). It closes when a link is followed.
15
+ */
16
+ export function MobileNav({ siteName, links, cta }: { siteName: string; links: Link[]; cta?: Link }) {
17
+ const dialog = useRef<HTMLDialogElement>(null);
18
+ const pathname = usePathname();
19
+ useEffect(() => dialog.current?.close(), [pathname]);
20
+ const close = () => dialog.current?.close();
21
+
22
+ return (
23
+ <div className={t.root}>
24
+ <button type="button" className={t.toggle} aria-haspopup="dialog" onClick={() => dialog.current?.showModal()}>
25
+ {theme.text.menu}
26
+ </button>
27
+ <dialog ref={dialog} aria-label="Menu" className={t.dialog}>
28
+ <div className={t.top}>
29
+ <p className={t.title}>{siteName}</p>
30
+ <button type="button" className={t.close} onClick={close}>
31
+ {theme.text.close}
32
+ </button>
33
+ </div>
34
+ <nav aria-label="Main" className={t.nav}>
35
+ {links.map((item) => (
36
+ <NavLink key={item.href + item.label} href={item.href} className={t.link} onClick={close}>
37
+ {item.label}
38
+ </NavLink>
39
+ ))}
40
+ {cta ? (
41
+ <NavLink href={cta.href} className={t.cta} onClick={close}>
42
+ {cta.label}
43
+ </NavLink>
44
+ ) : null}
45
+ </nav>
46
+ </dialog>
47
+ </div>
48
+ );
49
+ }
@@ -0,0 +1,16 @@
1
+ "use client";
2
+
3
+ import { usePathname } from "next/navigation";
4
+ import type { ReactNode } from "react";
5
+ import { SmartLink } from "../ui";
6
+
7
+ /** A menu link that marks itself as the current page (aria-current), for the theme's aria-[current=page]: variant. */
8
+ export function NavLink({ href, className, onClick, children }: { href: string; className: string; onClick?: () => void; children: ReactNode }) {
9
+ const pathname = usePathname();
10
+ const current = href === "/" ? pathname === "/" : pathname === href || pathname.startsWith(`${href}/`);
11
+ return (
12
+ <SmartLink href={href} className={className} onClick={onClick} aria-current={current ? "page" : undefined}>
13
+ {children}
14
+ </SmartLink>
15
+ );
16
+ }
@@ -0,0 +1,40 @@
1
+ import type { ReactNode } from "react";
2
+ import { HearthAnalytics } from "@the-smithy/hearth/analytics";
3
+ import { siteLang } from "@the-smithy/hearth/next";
4
+ import { HearthPreview } from "@the-smithy/hearth/preview";
5
+ import { theme } from "@/theme";
6
+ import { fontVariables } from "@/theme/fonts";
7
+ import { hearthNext } from "../client";
8
+ import { SiteFooter } from "./SiteFooter";
9
+ import { SiteHeader } from "./SiteHeader";
10
+
11
+ /**
12
+ * What every page starts from: full links, the title pattern, the default description and share image, the site's
13
+ * language, Google and Bing verification, and no indexing on preview deploys or while the site is hidden in Hearth.
14
+ * Pages from Hearth then set their own title, description, canonical address and share image.
15
+ */
16
+ export const generateMetadata = () => hearthNext.siteMetadata();
17
+
18
+ export default async function RootLayout({ children }: { children: ReactNode }) {
19
+ const site = await hearthNext.getSite();
20
+ const preview = await hearthNext.isPreview();
21
+ const t = theme.page;
22
+ return (
23
+ <html lang={siteLang(site)} className={fontVariables || undefined}>
24
+ <body className={t.body}>
25
+ <a href="#main" className={t.skipLink}>
26
+ Skip to content
27
+ </a>
28
+ {/* Live draft preview from Hearth: shows while an editor previews, refreshes as they edit. */}
29
+ {preview ? <HearthPreview /> : null}
30
+ <SiteHeader site={site} />
31
+ <main id="main" className={t.main}>
32
+ {children}
33
+ </main>
34
+ <SiteFooter site={site} />
35
+ {/* Hearth's built-in visitor analytics (cookieless, no banner needed): off in draft preview and when the site turns it off. */}
36
+ {site.analytics && !preview ? <HearthAnalytics config={site.analytics} /> : null}
37
+ </body>
38
+ </html>
39
+ );
40
+ }
@@ -1,5 +1,7 @@
1
1
  import type { Site } from "@the-smithy/hearth";
2
- import { SmartLink } from "@/components/ui";
2
+ import { theme } from "@/theme";
3
+ import { cx } from "../theme";
4
+ import { ground, SmartLink, toTone } from "../ui";
3
5
 
4
6
  const platformName: Record<string, string> = {
5
7
  facebook: "Facebook",
@@ -12,28 +14,31 @@ const platformName: Record<string, string> = {
12
14
 
13
15
  /** Contact details, footer links and social links from Hearth (Settings → Content). */
14
16
  export function SiteFooter({ site }: { site: Site }) {
15
- const { footerLinks, contact, social } = site.settings;
17
+ const { footerLinks, contact, social, tagline } = site.settings;
18
+ const t = theme.footer;
19
+ const tone = toTone(t.tone);
16
20
  return (
17
- <footer className="bg-inverse text-inverse-ink">
18
- <div className="mx-auto grid max-w-site gap-10 px-4 py-12 sm:grid-cols-3 sm:px-6">
19
- <div className="flex flex-col gap-2">
20
- <p className="text-lg font-bold">{site.name}</p>
21
- {contact.address ? <address className="whitespace-pre-line not-italic opacity-80">{contact.address}</address> : null}
21
+ <footer {...ground(tone)} className={cx(theme.tones[tone], t.root)}>
22
+ <div className={t.inner}>
23
+ <div className={t.about}>
24
+ <p className={t.name}>{site.name}</p>
25
+ {tagline ? <p className={t.tagline}>{tagline}</p> : null}
26
+ {contact.address ? <address className={t.address}>{contact.address}</address> : null}
22
27
  {contact.phone ? (
23
- <a href={`tel:${contact.phone.replace(/[^\d+]/g, "")}`} className="opacity-80 hover:opacity-100">
28
+ <a href={`tel:${contact.phone.replace(/[^\d+]/g, "")}`} className={t.contact}>
24
29
  {contact.phone}
25
30
  </a>
26
31
  ) : null}
27
32
  {contact.email ? (
28
- <a href={`mailto:${contact.email}`} className="opacity-80 hover:opacity-100">
33
+ <a href={`mailto:${contact.email}`} className={t.contact}>
29
34
  {contact.email}
30
35
  </a>
31
36
  ) : null}
32
37
  </div>
33
38
  {footerLinks.length ? (
34
- <nav aria-label="Footer" className="flex flex-col gap-2">
39
+ <nav aria-label="Footer" className={t.nav}>
35
40
  {footerLinks.map((item) => (
36
- <SmartLink key={item.href + item.label} href={item.href} className="opacity-80 hover:opacity-100">
41
+ <SmartLink key={item.href + item.label} href={item.href} className={t.link}>
37
42
  {item.label}
38
43
  </SmartLink>
39
44
  ))}
@@ -42,10 +47,10 @@ export function SiteFooter({ site }: { site: Site }) {
42
47
  <div />
43
48
  )}
44
49
  {social.length ? (
45
- <ul className="flex flex-col gap-2">
50
+ <ul className={t.social}>
46
51
  {social.map((s) => (
47
52
  <li key={s.url}>
48
- <a href={s.url} rel="me noopener" className="opacity-80 hover:opacity-100">
53
+ <a href={s.url} rel="me noopener" className={t.socialLink}>
49
54
  {s.label ?? platformName[s.platform] ?? new URL(s.url).hostname.replace(/^www\./, "")}
50
55
  </a>
51
56
  </li>
@@ -53,7 +58,7 @@ export function SiteFooter({ site }: { site: Site }) {
53
58
  </ul>
54
59
  ) : null}
55
60
  </div>
56
- <p className="mx-auto max-w-site px-4 pb-8 text-sm opacity-60 sm:px-6">
61
+ <p className={t.copyright}>
57
62
  © {new Date().getFullYear()} {site.name}
58
63
  </p>
59
64
  </footer>
@@ -0,0 +1,53 @@
1
+ import Link from "next/link";
2
+ import type { Site } from "@the-smithy/hearth";
3
+ import { theme } from "@/theme";
4
+ import { Logo } from "@/theme/overrides";
5
+ import { cx } from "../theme";
6
+ import { ground, Img, toTone } from "../ui";
7
+ import { MobileNav } from "./MobileNav";
8
+ import { NavLink } from "./NavLink";
9
+
10
+ /** Logo, menu, menu button and announcement banner, all edited in Hearth (Settings → General, and Navigation). */
11
+ export function SiteHeader({ site }: { site: Site }) {
12
+ const { announcement, seo, tagline, navigation, navigationCta } = site.settings;
13
+ const t = theme.header;
14
+ const tone = toTone(t.tone);
15
+ return (
16
+ <header {...ground(tone)} className={cx(theme.tones[tone], t.root)}>
17
+ {announcement ? <p className={t.announcement}>{announcement}</p> : null}
18
+ <div className={t.inner}>
19
+ <Link href="/" className={t.brand}>
20
+ {Logo ? (
21
+ <Logo site={site} />
22
+ ) : (
23
+ <>
24
+ {seo.logo ? <Img image={{ src: seo.logo.src, alt: "" }} className={t.logo} /> : null}
25
+ <span className={t.names}>
26
+ <span>{site.name}</span>
27
+ {tagline ? <span className={t.tagline}>{tagline}</span> : null}
28
+ </span>
29
+ </>
30
+ )}
31
+ </Link>
32
+ {navigation.length || navigationCta ? (
33
+ <>
34
+ <nav aria-label="Main" className={t.nav}>
35
+ {navigation.map((item) => (
36
+ <NavLink key={item.href + item.label} href={item.href} className={t.link}>
37
+ {item.label}
38
+ </NavLink>
39
+ ))}
40
+ {/* The menu button (Navigation → Menu button in Hearth): one action that stands out from the links. */}
41
+ {navigationCta ? (
42
+ <NavLink href={navigationCta.href} className={t.cta}>
43
+ {navigationCta.label}
44
+ </NavLink>
45
+ ) : null}
46
+ </nav>
47
+ <MobileNav siteName={site.name} links={navigation} cta={navigationCta} />
48
+ </>
49
+ ) : null}
50
+ </div>
51
+ </header>
52
+ );
53
+ }
@@ -0,0 +1,36 @@
1
+ {
2
+ "$comment": "Files Hearth manages in this site. pnpm hearth:update replaces the managed ones with the latest release and adds seeded files that are new; it never touches anything else (src/theme, your own routes). Don't edit managed files: style through src/theme.",
3
+ "version": "dev",
4
+ "managed": [
5
+ "src/hearth/",
6
+ "src/app/layout.tsx",
7
+ "src/app/globals.css",
8
+ "src/app/not-found.tsx",
9
+ "src/app/[[...slug]]/page.tsx",
10
+ "src/app/events/page.tsx",
11
+ "src/app/events/[slug]/page.tsx",
12
+ "src/app/fundraisers/page.tsx",
13
+ "src/app/fundraisers/[slug]/page.tsx",
14
+ "src/app/donate/page.tsx",
15
+ "src/app/donate/thank-you/page.tsx",
16
+ "src/app/registered/page.tsx",
17
+ "src/app/robots.ts",
18
+ "src/app/sitemap.ts",
19
+ "src/app/api/preview/route.ts",
20
+ "src/app/api/preview/exit/route.ts",
21
+ "src/app/api/revalidate/route.ts",
22
+ "scripts/hearth.mjs",
23
+ "CLAUDE.md",
24
+ "AGENTS.md"
25
+ ],
26
+ "seeded": [
27
+ "src/theme/",
28
+ "src/app/icon.tsx",
29
+ "src/app/apple-icon.tsx",
30
+ "SITE.md",
31
+ "README.md",
32
+ "next.config.ts",
33
+ "netlify.toml",
34
+ ".env.example"
35
+ ]
36
+ }
@@ -0,0 +1,36 @@
1
+ import type { Metadata } from "next";
2
+ import { notFound } from "next/navigation";
3
+ import { theme } from "@/theme";
4
+ import { hearthNext } from "../client";
5
+ import { DonateForm } from "../forms/DonateForm";
6
+ import { hasFeature } from "../site";
7
+ import { Section } from "../ui";
8
+
9
+ export async function generateMetadata(): Promise<Metadata> {
10
+ const site = await hearthNext.getSite();
11
+ return {
12
+ title: "Donate",
13
+ description: `Support ${site.name} with a donation.`,
14
+ alternates: { canonical: "/donate" },
15
+ openGraph: { title: "Donate", url: "/donate" },
16
+ };
17
+ }
18
+
19
+ /**
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
+ */
23
+ export default async function DonatePage() {
24
+ const site = await hearthNext.getSite();
25
+ if (!hasFeature(site, "giving")) notFound();
26
+ const giving = site.settings.giving;
27
+ 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>
35
+ );
36
+ }
@@ -0,0 +1,26 @@
1
+ import { metadataFromMeta } from "@the-smithy/hearth/next";
2
+ import { permanentRedirect } from "next/navigation";
3
+ import { hearthNext } from "../client";
4
+ import { EventView } from "../components/EventView";
5
+ import { hasFeature } from "../site";
6
+
7
+ type Props = { params: Promise<{ slug: string }> };
8
+
9
+ async function load(slug: string) {
10
+ const site = await hearthNext.getSite();
11
+ return hasFeature(site, "events") ? hearthNext.getEvent(slug) : null;
12
+ }
13
+
14
+ export async function generateMetadata({ params }: Props) {
15
+ const event = await load((await params).slug);
16
+ return event ? metadataFromMeta(event.meta) : {};
17
+ }
18
+
19
+ /** 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
+ const slug = (await params).slug;
22
+ const event = await load(slug);
23
+ if (!event) return hearthNext.redirectOrNotFound(`/events/${slug}`);
24
+ if (event.path !== `/events/${event.slug}`) permanentRedirect(event.path);
25
+ return <EventView event={event} />;
26
+ }