@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
@@ -1,54 +1,2 @@
1
- import { normalizeSlug } from "@the-smithy/hearth";
2
- import { metadataFromMeta } from "@the-smithy/hearth/next";
3
- import { JsonLd } from "@the-smithy/hearth/react";
4
- import type { Metadata } from "next";
5
- import { Blocks } from "@/components/blocks";
6
- import { EventView } from "@/components/EventView";
7
- import { hearthNext } from "@/lib/hearth";
8
- import { hasFeature } from "@/lib/site";
9
-
10
- type Props = { params: Promise<{ slug?: string[] }> };
11
-
12
- /**
13
- * An event in an event collection, e.g. /talks/<event> for a "Talks" collection. Pages are checked first,
14
- * so a page at the same address wins.
15
- */
16
- async function collectionEvent(slug: string[] | undefined) {
17
- if (!slug || slug.length < 2) return null;
18
- const site = await hearthNext.getSite();
19
- if (!hasFeature(site, "events")) return null;
20
- const prefix = normalizeSlug(slug.slice(0, -1));
21
- const collection = (await hearthNext.getCollections()).find((c) => c.kind === "events" && c.slug === prefix);
22
- if (!collection) return null;
23
- const event = await hearthNext.getEvent(normalizeSlug(slug.at(-1)));
24
- return event?.collection?.id === collection.id ? event : null;
25
- }
26
-
27
- /** Every page in Hearth at its path ("/" is "home", "/about" is "about", "/blog/post" is a blog entry), plus events in collections. */
28
- export default async function Page({ params }: Props) {
29
- const slug = (await params).slug;
30
- const page = await hearthNext.getPage(slug);
31
- if (page) {
32
- return (
33
- <>
34
- <JsonLd data={page.meta.jsonLd} />
35
- {/* Pages usually open with a Hero (the visible title); this keeps a heading for the rest. */}
36
- {page.blocks[0]?.type === "hero" ? null : <h1 className="sr-only">{page.title}</h1>}
37
- <Blocks blocks={page.blocks} />
38
- </>
39
- );
40
- }
41
-
42
- const event = await collectionEvent(slug);
43
- if (event) return <EventView event={event} />;
44
- // An old address (a renamed page, or the site before it moved to Hearth) redirects; anything else is a 404.
45
- return hearthNext.redirectOrNotFound(slug);
46
- }
47
-
48
- export async function generateMetadata({ params }: Props): Promise<Metadata> {
49
- const slug = (await params).slug;
50
- const metadata = await hearthNext.generatePageMetadata(slug);
51
- if (Object.keys(metadata).length) return metadata;
52
- const event = await collectionEvent(slug);
53
- return event ? metadataFromMeta(event.meta) : {};
54
- }
1
+ // Managed by Hearth (pnpm hearth:update replaces it). Style it in src/theme; the page is src/hearth/pages/page.tsx.
2
+ export { default, generateMetadata } from "@/hearth/pages/page";
@@ -1,4 +1,4 @@
1
- import { hearthNext } from "@/lib/hearth";
1
+ import { hearthNext } from "@/hearth/client";
2
2
 
3
3
  /** Ends a draft preview and returns to the published page (?path=/about). */
4
4
  export const GET = hearthNext.exitPreviewHandler();
@@ -1,4 +1,4 @@
1
- import { hearthNext } from "@/lib/hearth";
1
+ import { hearthNext } from "@/hearth/client";
2
2
 
3
3
  /**
4
4
  * Hearth's "Preview on the website" button opens this with a short-lived token. It checks the token with Hearth,
@@ -1,4 +1,4 @@
1
- import { hearthNext } from "@/lib/hearth";
1
+ import { hearthNext } from "@/hearth/client";
2
2
 
3
3
  /**
4
4
  * Hearth calls this when anything on the site is published, so changes show up within seconds.
@@ -1,5 +1,5 @@
1
1
  import { ImageResponse } from "next/og";
2
- import { hearthNext } from "@/lib/hearth";
2
+ import { hearthNext } from "@/hearth/client";
3
3
 
4
4
  export const size = { width: 180, height: 180 };
5
5
  export const contentType = "image/png";
@@ -1,35 +1,2 @@
1
- import type { Metadata } from "next";
2
- import { notFound } from "next/navigation";
3
- import { DonateForm } from "@/components/DonateForm";
4
- import { Section } from "@/components/ui";
5
- import { hearthNext } from "@/lib/hearth";
6
- import { hasFeature } from "@/lib/site";
7
-
8
- export async function generateMetadata(): Promise<Metadata> {
9
- const site = await hearthNext.getSite();
10
- return {
11
- title: "Donate",
12
- description: `Support ${site.name} with a donation.`,
13
- alternates: { canonical: "/donate" },
14
- openGraph: { title: "Donate", url: "/donate" },
15
- };
16
- }
17
-
18
- /**
19
- * A general donation page (sites with Giving). Ask staff to reserve "donate" on the site's Plan card in Hearth so
20
- * no page takes this address. For a page built in Hearth instead, use the Donation form block there.
21
- */
22
- export default async function DonatePage() {
23
- const site = await hearthNext.getSite();
24
- if (!hasFeature(site, "giving")) notFound();
25
- const giving = site.settings.giving;
26
- return (
27
- <Section>
28
- <div className="max-w-2xl">
29
- <h1 className="mb-4 text-[clamp(32px,4vw,48px)] font-bold tracking-tight">Donate</h1>
30
- <p className="mb-10 text-lg opacity-85">Your gift supports {giving.legalName || site.name}.</p>
31
- <DonateForm presetsCents={giving.presetsCents} minCents={giving.minCents} maxCents={giving.maxCents} />
32
- </div>
33
- </Section>
34
- );
35
- }
1
+ // Managed by Hearth (pnpm hearth:update replaces it). Style it in src/theme; the page is src/hearth/pages/donate.tsx.
2
+ export { default, generateMetadata } from "@/hearth/pages/donate";
@@ -1,22 +1,2 @@
1
- import type { Metadata } from "next";
2
- import Link from "next/link";
3
- import { Section } from "@/components/ui";
4
-
5
- export const metadata: Metadata = { title: "Thank you", robots: { index: false, follow: false } };
6
-
7
- /** Where the payment page sends donors back. The receipt is emailed once the payment completes. */
8
- export default function ThankYouPage() {
9
- return (
10
- <Section>
11
- <div className="max-w-2xl">
12
- <h1 className="mb-4 text-[clamp(32px,4vw,48px)] font-bold tracking-tight">Thank you</h1>
13
- <p className="text-lg opacity-85">Your gift makes a difference. If you gave your email, a receipt is on its way.</p>
14
- <p className="mt-8">
15
- <Link href="/" className="font-semibold underline underline-offset-4">
16
- Back to the homepage
17
- </Link>
18
- </p>
19
- </div>
20
- </Section>
21
- );
22
- }
1
+ // Managed by Hearth (pnpm hearth:update replaces it). Style it in src/theme; the page is src/hearth/pages/thank-you.tsx.
2
+ export { default, metadata } from "@/hearth/pages/thank-you";
@@ -1,26 +1,2 @@
1
- import { metadataFromMeta } from "@the-smithy/hearth/next";
2
- import { permanentRedirect } from "next/navigation";
3
- import { EventView } from "@/components/EventView";
4
- import { hearthNext } from "@/lib/hearth";
5
- import { hasFeature } from "@/lib/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
- }
1
+ // Managed by Hearth (pnpm hearth:update replaces it). Style it in src/theme; the page is src/hearth/pages/event.tsx.
2
+ export { default, generateMetadata } from "@/hearth/pages/event";
@@ -1,29 +1,2 @@
1
- import type { Metadata } from "next";
2
- import { notFound } from "next/navigation";
3
- import { EventList } from "@/components/blocks/events";
4
- import { Section } from "@/components/ui";
5
- import { hearthNext } from "@/lib/hearth";
6
- import { hasFeature } from "@/lib/site";
7
-
8
- export async function generateMetadata(): Promise<Metadata> {
9
- const site = await hearthNext.getSite();
10
- return {
11
- title: "Events",
12
- description: `Upcoming events from ${site.name}.`,
13
- alternates: { canonical: "/events" },
14
- openGraph: { title: "Events", url: "/events" },
15
- };
16
- }
17
-
18
- /** Upcoming events (only on sites with the Events feature in Hearth). */
19
- export default async function EventsPage() {
20
- const site = await hearthNext.getSite();
21
- if (!hasFeature(site, "events")) notFound();
22
- const events = await hearthNext.getEvents({ when: "upcoming" });
23
- return (
24
- <Section>
25
- <h1 className="mb-10 text-[clamp(32px,4vw,48px)] font-bold tracking-tight">Events</h1>
26
- <EventList events={events} />
27
- </Section>
28
- );
29
- }
1
+ // Managed by Hearth (pnpm hearth:update replaces it). Style it in src/theme; the page is src/hearth/pages/events.tsx.
2
+ export { default, generateMetadata } from "@/hearth/pages/events";
@@ -1,55 +1,2 @@
1
- import { metadataFromMeta } from "@the-smithy/hearth/next";
2
- import { JsonLd } from "@the-smithy/hearth/react";
3
- import { Blocks } from "@/components/blocks";
4
- import { Progress } from "@/components/blocks/giving";
5
- import { DonateForm } from "@/components/DonateForm";
6
- import { Img, Section } from "@/components/ui";
7
- import { hearthNext } from "@/lib/hearth";
8
- import { hasFeature } from "@/lib/site";
9
-
10
- type Props = { params: Promise<{ slug: string }> };
11
-
12
- async function load(slug: string) {
13
- const site = await hearthNext.getSite();
14
- return hasFeature(site, "giving") ? { site, fundraiser: await hearthNext.getFundraiser(slug) } : null;
15
- }
16
-
17
- export async function generateMetadata({ params }: Props) {
18
- const loaded = await load((await params).slug);
19
- return loaded?.fundraiser ? metadataFromMeta(loaded.fundraiser.meta) : {};
20
- }
21
-
22
- /** A fundraiser's page: progress and a donation form, then its story blocks. */
23
- export default async function FundraiserPage({ params }: Props) {
24
- const slug = (await params).slug;
25
- const loaded = await load(slug);
26
- if (!loaded?.fundraiser) return hearthNext.redirectOrNotFound(`/fundraisers/${slug}`);
27
- const { site, fundraiser: f } = loaded;
28
- const giving = site.settings.giving;
29
-
30
- return (
31
- <>
32
- <JsonLd data={f.meta.jsonLd} />
33
- <section className="bg-muted">
34
- <div className="mx-auto grid max-w-site items-center gap-10 px-4 py-16 sm:px-6 md:grid-cols-2">
35
- <div className="flex flex-col gap-4">
36
- <p className="text-sm font-semibold uppercase tracking-[0.18em] opacity-75">{f.status === "completed" ? "Completed fundraiser" : "Fundraiser"}</p>
37
- <h1 className="text-[clamp(32px,4.4vw,52px)] font-bold leading-tight tracking-tight">{f.title}</h1>
38
- {f.summary ? <p className="text-lg opacity-85">{f.summary}</p> : null}
39
- <Progress fundraiser={f} large />
40
- </div>
41
- {f.image ? <Img image={f.image} className="aspect-[4/3] w-full rounded-xl object-cover" /> : null}
42
- </div>
43
- </section>
44
- <Blocks blocks={f.blocks} />
45
- {f.status === "active" ? (
46
- <Section>
47
- <div className="max-w-2xl">
48
- <h2 className="mb-6 text-2xl font-bold">Give to {f.title}</h2>
49
- <DonateForm presetsCents={giving.presetsCents} minCents={giving.minCents} maxCents={giving.maxCents} fundraiser={f.slug} />
50
- </div>
51
- </Section>
52
- ) : null}
53
- </>
54
- );
55
- }
1
+ // Managed by Hearth (pnpm hearth:update replaces it). Style it in src/theme; the page is src/hearth/pages/fundraiser.tsx.
2
+ export { default, generateMetadata } from "@/hearth/pages/fundraiser";
@@ -1,37 +1,2 @@
1
- import type { Metadata } from "next";
2
- import { notFound } from "next/navigation";
3
- import { FundraiserList } from "@/components/blocks/giving";
4
- import { Section } from "@/components/ui";
5
- import { hearthNext } from "@/lib/hearth";
6
- import { hasFeature } from "@/lib/site";
7
-
8
- export async function generateMetadata(): Promise<Metadata> {
9
- const site = await hearthNext.getSite();
10
- return {
11
- title: "Fundraisers",
12
- description: `Fundraisers and campaigns from ${site.name}: see how each is doing and give.`,
13
- alternates: { canonical: "/fundraisers" },
14
- openGraph: { title: "Fundraisers", url: "/fundraisers" },
15
- };
16
- }
17
-
18
- /** Active fundraisers, then completed ones (only on sites with the Giving feature in Hearth). */
19
- export default async function FundraisersPage() {
20
- const site = await hearthNext.getSite();
21
- if (!hasFeature(site, "giving")) notFound();
22
- const [active, completed] = await Promise.all([hearthNext.getFundraisers({ status: "active" }), hearthNext.getFundraisers({ status: "completed" })]);
23
- return (
24
- <>
25
- <Section>
26
- <h1 className="mb-10 text-[clamp(32px,4vw,48px)] font-bold tracking-tight">Fundraisers</h1>
27
- <FundraiserList fundraisers={active} />
28
- </Section>
29
- {completed.length ? (
30
- <Section tone="muted">
31
- <h2 className="mb-8 text-2xl font-bold">Completed</h2>
32
- <FundraiserList fundraisers={completed} />
33
- </Section>
34
- ) : null}
35
- </>
36
- );
37
- }
1
+ // Managed by Hearth (pnpm hearth:update replaces it). Style it in src/theme; the page is src/hearth/pages/fundraisers.tsx.
2
+ export { default, generateMetadata } from "@/hearth/pages/fundraisers";
@@ -1,75 +1,4 @@
1
+ /* Managed by Hearth (pnpm hearth:update replaces it). The site's tokens and CSS go in src/theme/theme.css. */
1
2
  @import "tailwindcss";
2
-
3
- /*
4
- * The site's brand. Blocks only use these tokens, so restyling a new site starts here.
5
- * Hearth's "tone" options map to them: default, muted, inverse and accent.
6
- */
7
- @theme {
8
- --color-bg: #fbfaf7;
9
- --color-ink: #1c1c1a;
10
- --color-muted-ink: #5b5b55;
11
- --color-line: #e3e0d8;
12
- --color-surface: #ffffff;
13
- --color-muted: #f1eee7;
14
- --color-inverse: #1c1c1a;
15
- --color-inverse-ink: #f6f4ee;
16
- --color-accent: #2f5d50;
17
- --color-accent-ink: #ffffff;
18
- /* Highlighted words in headings (*like this*) and links. Check contrast on every ground. */
19
- --color-highlight: #2f5d50;
20
- --color-danger: #a12a1f;
21
-
22
- --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
23
- --container-site: 72rem;
24
- }
25
-
26
- body {
27
- background: var(--color-bg);
28
- color: var(--color-ink);
29
- font-family: var(--font-sans);
30
- -webkit-font-smoothing: antialiased;
31
- }
32
-
33
- /* Markdown from Hearth (rich text, FAQ answers, media + text). */
34
- .prose {
35
- line-height: 1.7;
36
- }
37
- .prose > * + * {
38
- margin-top: 1em;
39
- }
40
- .prose h2 {
41
- font-size: 1.75rem;
42
- font-weight: 700;
43
- line-height: 1.25;
44
- margin-top: 1.6em;
45
- }
46
- .prose h3 {
47
- font-size: 1.35rem;
48
- font-weight: 700;
49
- margin-top: 1.4em;
50
- }
51
- .prose a {
52
- text-decoration: underline;
53
- text-underline-offset: 3px;
54
- }
55
- .prose ul {
56
- list-style: disc;
57
- padding-left: 1.4em;
58
- }
59
- .prose ol {
60
- list-style: decimal;
61
- padding-left: 1.4em;
62
- }
63
- .prose blockquote {
64
- border-left: 3px solid var(--color-line);
65
- padding-left: 1em;
66
- font-style: italic;
67
- }
68
- .prose img {
69
- border-radius: 0.75rem;
70
- }
71
-
72
- :focus-visible {
73
- outline: 2px solid var(--color-accent);
74
- outline-offset: 2px;
75
- }
3
+ @import "../hearth/hearth.css";
4
+ @import "../theme/theme.css";
@@ -1,5 +1,5 @@
1
1
  import { ImageResponse } from "next/og";
2
- import { hearthNext } from "@/lib/hearth";
2
+ import { hearthNext } from "@/hearth/client";
3
3
 
4
4
  export const size = { width: 64, height: 64 };
5
5
  export const contentType = "image/png";
@@ -1,38 +1,4 @@
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 { SiteFooter } from "@/components/SiteFooter";
6
- import { SiteHeader } from "@/components/SiteHeader";
7
- import { hearthNext } from "@/lib/hearth";
1
+ // Managed by Hearth (pnpm hearth:update replaces it): the layout is src/hearth/layout/RootLayout.tsx, styled in src/theme.
8
2
  import "./globals.css";
9
3
 
10
- /**
11
- * What every page starts from: full links, the title pattern, the default description and share image, the site's
12
- * language, Google and Bing verification, and no indexing on preview deploys or while the site is hidden in Hearth.
13
- * Pages from Hearth then set their own title, description, canonical address and share image.
14
- */
15
- export const generateMetadata = () => hearthNext.siteMetadata();
16
-
17
- export default async function RootLayout({ children }: { children: ReactNode }) {
18
- const site = await hearthNext.getSite();
19
- const preview = await hearthNext.isPreview();
20
- return (
21
- <html lang={siteLang(site)}>
22
- <body className="flex min-h-dvh flex-col">
23
- <a href="#main" className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded focus:bg-surface focus:px-4 focus:py-2">
24
- Skip to content
25
- </a>
26
- {/* Live draft preview from Hearth: shows while an editor previews, refreshes as they edit. */}
27
- {preview ? <HearthPreview /> : null}
28
- <SiteHeader site={site} />
29
- <main id="main" className="flex-1">
30
- {children}
31
- </main>
32
- <SiteFooter site={site} />
33
- {/* Hearth's built-in visitor analytics (cookieless, no banner needed): off in draft preview and when the site turns it off. */}
34
- {site.analytics && !preview ? <HearthAnalytics config={site.analytics} /> : null}
35
- </body>
36
- </html>
37
- );
38
- }
4
+ export { default, generateMetadata } from "@/hearth/layout/RootLayout";
@@ -1,14 +1,2 @@
1
- import Link from "next/link";
2
- import { Section } from "@/components/ui";
3
-
4
- export default function NotFound() {
5
- return (
6
- <Section className="py-28">
7
- <h1 className="text-4xl font-bold">Page not found</h1>
8
- <p className="mt-4 text-lg opacity-80">The page you&apos;re looking for has moved or no longer exists.</p>
9
- <Link href="/" className="mt-8 inline-block rounded-lg bg-accent px-6 py-3.5 font-semibold text-accent-ink hover:opacity-90">
10
- Go to the homepage
11
- </Link>
12
- </Section>
13
- );
14
- }
1
+ // Managed by Hearth (pnpm hearth:update replaces it). Style it in src/theme; the page is src/hearth/pages/not-found.tsx.
2
+ export { default } from "@/hearth/pages/not-found";
@@ -1,22 +1,2 @@
1
- import type { Metadata } from "next";
2
- import Link from "next/link";
3
- import { Section } from "@/components/ui";
4
-
5
- export const metadata: Metadata = { title: "You're registered", robots: { index: false, follow: false } };
6
-
7
- /** Where the payment page sends people after paying for a ticket. The confirmation is emailed once payment completes. */
8
- export default function RegisteredPage() {
9
- return (
10
- <Section>
11
- <div className="max-w-2xl">
12
- <h1 className="mb-4 text-[clamp(32px,4vw,48px)] font-bold tracking-tight">You&apos;re registered</h1>
13
- <p className="text-lg opacity-85">Thanks for signing up. A confirmation is on its way to your email.</p>
14
- <p className="mt-8">
15
- <Link href="/events" className="font-semibold underline underline-offset-4">
16
- See all events
17
- </Link>
18
- </p>
19
- </div>
20
- </Section>
21
- );
22
- }
1
+ // Managed by Hearth (pnpm hearth:update replaces it). Style it in src/theme; the page is src/hearth/pages/registered.tsx.
2
+ export { default, metadata } from "@/hearth/pages/registered";
@@ -1,4 +1,4 @@
1
- import { hearthNext } from "@/lib/hearth";
1
+ import { hearthNext } from "@/hearth/client";
2
2
 
3
3
  /**
4
4
  * Keeps crawlers out until the site has a domain in Hearth, while it's hidden from search engines there, and on
@@ -1,5 +1,5 @@
1
- import { hearthNext } from "@/lib/hearth";
2
- import { hasFeature } from "@/lib/site";
1
+ import { hearthNext } from "@/hearth/client";
2
+ import { hasFeature } from "@/hearth/site";
3
3
 
4
4
  /** Every indexable page, event and fundraiser in Hearth, plus the pages this site's own code serves. */
5
5
  export default async function sitemap() {
@@ -1,7 +1,7 @@
1
1
  "use server";
2
2
 
3
3
  import type { FormResult } from "@the-smithy/hearth";
4
- import { hearthNext } from "@/lib/hearth";
4
+ import { hearthNext } from "./client";
5
5
 
6
6
  export type FormState = {
7
7
  ok?: boolean;
@@ -41,3 +41,17 @@ export async function donate(prev: FormResult | null, formData: FormData): Promi
41
41
  export async function register(slug: string, prev: FormResult | null, formData: FormData): Promise<FormResult> {
42
42
  return hearthNext.registerAction(slug, { returnPath: "/registered" })(prev, formData);
43
43
  }
44
+
45
+ /**
46
+ * Signs up to volunteer at an event that asks for volunteers. Sign-ups land under the event's Volunteers in Hearth,
47
+ * which also emails the site's alert addresses.
48
+ */
49
+ export async function volunteer(slug: string, prev: VolunteerState | null, formData: FormData): Promise<VolunteerState> {
50
+ const result = await hearthNext.volunteerAction(slug)(prev, formData);
51
+ if (result.ok) return result;
52
+ // Hand back what they typed and chose, so the form keeps it after an error.
53
+ const values = Object.fromEntries([...formData.entries()].filter(([k, v]) => typeof v === "string" && !k.startsWith("$") && k !== "_gotcha" && k !== "shift")) as Record<string, string>;
54
+ return { ...result, values, shifts: formData.getAll("shift").map(String) };
55
+ }
56
+
57
+ export type VolunteerState = FormResult & { values?: Record<string, string>; shifts?: string[] };
@@ -0,0 +1,104 @@
1
+ "use client";
2
+
3
+ import { directionsUrl, type Position } from "@the-smithy/hearth";
4
+ import { useEffect, useRef, useState } from "react";
5
+ import "maplibre-gl/dist/maplibre-gl.css";
6
+ import { theme } from "@/theme";
7
+ import { cx } from "../theme";
8
+
9
+ const t = theme.map;
10
+
11
+ export type MapPlace = { name: string; address: string; position: Position };
12
+
13
+ /**
14
+ * A map with a pin for each place; a pin opens the place's name, address and directions. MapLibre loads only when the
15
+ * map is about to scroll into view, and scrolling the page never zooms it (two fingers, or Ctrl and scroll, do).
16
+ */
17
+ export function OpenMap({ places, label, className = "" }: { places: MapPlace[]; label: string; className?: string }) {
18
+ const container = useRef<HTMLDivElement>(null);
19
+ const [failed, setFailed] = useState(false);
20
+ const key = JSON.stringify(places);
21
+
22
+ useEffect(() => {
23
+ const el = container.current;
24
+ if (!el) return;
25
+ let cancelled = false;
26
+ let remove = () => {};
27
+ const show = async () => {
28
+ const ml = await import("maplibre-gl");
29
+ // MapLibre looks for its worker beside its own script, which isn't where the bundler puts it.
30
+ ml.setWorkerUrl(new URL("maplibre-gl/dist/maplibre-gl-worker.mjs", import.meta.url).href);
31
+ if (cancelled) return;
32
+ const list: MapPlace[] = JSON.parse(key);
33
+ const map = new ml.Map({
34
+ container: el,
35
+ // Free OpenStreetMap tiles from OpenFreeMap by default (no key, no cookies); the theme's map.style swaps them.
36
+ style: t.style,
37
+ center: [list[0].position.lng, list[0].position.lat],
38
+ zoom: 15,
39
+ attributionControl: { compact: true },
40
+ cooperativeGestures: true,
41
+ // MapLibre makes the map a labelled region (the canvas): name it for this map.
42
+ locale: { "Map.Title": label },
43
+ });
44
+ remove = () => map.remove();
45
+ map.addControl(new ml.NavigationControl({ showCompass: false }));
46
+ let loaded = false;
47
+ map.once("load", () => (loaded = true));
48
+ map.on("error", () => !loaded && setFailed(true));
49
+
50
+ const bounds = new ml.LngLatBounds();
51
+ for (const place of list) {
52
+ const at: [number, number] = [place.position.lng, place.position.lat];
53
+ bounds.extend(at);
54
+ const pin = document.createElement("button");
55
+ pin.type = "button";
56
+ pin.className = t.pin;
57
+ pin.setAttribute("aria-label", place.name);
58
+ new ml.Marker({ element: pin }).setLngLat(at).setPopup(new ml.Popup({ offset: 14, closeButton: true }).setDOMContent(popup(place))).addTo(map);
59
+ }
60
+ if (list.length > 1) map.fitBounds(bounds, { padding: { top: 48, right: 56, bottom: 72, left: 48 }, maxZoom: 15, duration: 0 });
61
+ };
62
+ // Wait until the map is near the screen: most visitors never scroll to it.
63
+ const observer = new IntersectionObserver(
64
+ ([entry]) => {
65
+ if (!entry.isIntersecting) return;
66
+ observer.disconnect();
67
+ show().catch(() => setFailed(true));
68
+ },
69
+ { rootMargin: "300px" },
70
+ );
71
+ observer.observe(el);
72
+ return () => {
73
+ cancelled = true;
74
+ observer.disconnect();
75
+ remove();
76
+ };
77
+ }, [key, label]);
78
+
79
+ return (
80
+ <div className={cx(t.frame, className)}>
81
+ {/* Sized by the frame: MapLibre makes its container position: relative. */}
82
+ <div ref={container} className="size-full" />
83
+ {failed ? <p className={t.failed}>{theme.text.mapFailed}</p> : null}
84
+ </div>
85
+ );
86
+ }
87
+
88
+ /** A pin's popup, built as elements (never HTML strings) so names and addresses stay plain text. */
89
+ function popup(place: MapPlace) {
90
+ const box = document.createElement("div");
91
+ box.className = t.popup;
92
+ const name = document.createElement("strong");
93
+ name.textContent = place.name;
94
+ const address = document.createElement("span");
95
+ address.className = "whitespace-pre-line";
96
+ address.textContent = place.address;
97
+ const link = document.createElement("a");
98
+ link.href = directionsUrl(place);
99
+ link.rel = "noopener";
100
+ link.className = t.popupLink;
101
+ link.textContent = theme.text.directions;
102
+ box.append(name, address, link);
103
+ return box;
104
+ }