@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
@@ -1,12 +1,15 @@
1
1
  import type { BlockOf } from "@the-smithy/hearth";
2
2
  import { theme } from "@/theme";
3
- import { Button, Emphasis, fill, ground, Img, Markdown, Section, SectionHeading, SmartLink, toTone } from "../ui";
3
+ import { cx } from "../theme";
4
+ import { Button, Emphasis, fill, ground, Img, Markdown, plain, Section, SectionHeading, SmartLink, toTone } from "../ui";
4
5
 
5
6
  export function Hero({ block }: { block: BlockOf<"hero"> }) {
6
7
  const d = block.data;
7
8
  const t = theme.hero;
9
+ const paragraphs = d.body?.split(/\n\s*\n/).filter((p) => p.trim()) ?? [];
10
+ // Without a tone it's the design's own ground, dark, in `hero.root`. A tone sets the ground, which turns on-dark: on or off.
8
11
  return (
9
- <section data-block="hero" {...ground("inverse")} className={t.root}>
12
+ <section id={block.anchor} data-block="hero" data-size={d.size} {...ground(d.tone ?? "inverse")} className={cx(d.tone ? `${theme.tones[d.tone]} ${t.toned}` : t.root, block.anchor && theme.section.anchored)}>
10
13
  {d.image ? <Img image={d.image} crop priority className={t.image} /> : null}
11
14
  <div className={t.inner} data-size={d.size}>
12
15
  {d.logo ? <Img image={d.logo} priority className={t.logo} /> : null}
@@ -14,7 +17,15 @@ export function Hero({ block }: { block: BlockOf<"hero"> }) {
14
17
  <h1 className={t.title}>
15
18
  <Emphasis text={d.heading} className={t.emphasis} />
16
19
  </h1>
17
- {d.body ? <p className={t.body}>{d.body}</p> : null}
20
+ {paragraphs.length > 1 ? (
21
+ paragraphs.map((p, i) => (
22
+ <p key={i} className={t.body}>
23
+ {p}
24
+ </p>
25
+ ))
26
+ ) : d.body ? (
27
+ <p className={t.body}>{d.body}</p>
28
+ ) : null}
18
29
  {d.primaryCta || d.secondaryCta ? (
19
30
  <div className={t.actions}>
20
31
  <Button link={d.primaryCta} />
@@ -28,7 +39,7 @@ export function Hero({ block }: { block: BlockOf<"hero"> }) {
28
39
 
29
40
  export function RichText({ block }: { block: BlockOf<"richText"> }) {
30
41
  return (
31
- <Section block="richText">
42
+ <Section block="richText" id={block.anchor}>
32
43
  <Markdown className={theme.richText.body} data-width={block.data.width}>
33
44
  {block.data.markdown}
34
45
  </Markdown>
@@ -40,7 +51,7 @@ export function MediaText({ block }: { block: BlockOf<"mediaText"> }) {
40
51
  const d = block.data;
41
52
  const t = theme.mediaText;
42
53
  return (
43
- <Section block="mediaText">
54
+ <Section block="mediaText" id={block.anchor}>
44
55
  <div className={t.grid} data-image={d.imagePosition}>
45
56
  <div className={t.text}>
46
57
  <SectionHeading eyebrow={d.eyebrow} heading={d.heading} />
@@ -60,7 +71,7 @@ export function MediaText({ block }: { block: BlockOf<"mediaText"> }) {
60
71
  export function Stats({ block }: { block: BlockOf<"stats"> }) {
61
72
  const t = theme.stats;
62
73
  return (
63
- <Section block="stats" tone={toTone(t.tone)}>
74
+ <Section block="stats" id={block.anchor} tone={toTone(t.tone)}>
64
75
  <SectionHeading heading={block.data.heading} />
65
76
  <dl className={t.list}>
66
77
  {block.data.items.map((s, i) => (
@@ -74,17 +85,27 @@ export function Stats({ block }: { block: BlockOf<"stats"> }) {
74
85
  );
75
86
  }
76
87
 
88
+ /** A card's title as a key ("Our *history*" is "our-history"), for CSS that targets one card: [data-card="our-history"]. */
89
+ const cardKey = (title: string) =>
90
+ plain(title)
91
+ .toLowerCase()
92
+ .replace(/[^a-z0-9]+/g, "-")
93
+ .replace(/^-|-$/g, "") || undefined;
94
+
77
95
  export function CardGrid({ block }: { block: BlockOf<"cardGrid"> }) {
78
96
  const d = block.data;
79
97
  const t = theme.cardGrid;
98
+ const Title = d.heading ? "h3" : "h2";
80
99
  return (
81
- <Section block="cardGrid" tone={d.tone}>
100
+ <Section block="cardGrid" id={block.anchor} tone={d.tone}>
82
101
  <SectionHeading eyebrow={d.eyebrow} heading={d.heading} intro={d.intro} />
83
102
  <ul className={t.list}>
84
103
  {d.items.map((c, i) => (
85
- <li key={i} className={t.card}>
104
+ <li key={i} className={t.card} data-card={cardKey(c.title)} data-icon={c.icon}>
86
105
  {c.image ? <Img image={c.image} crop={16 / 10} className={t.image} /> : null}
87
- <h3 className={t.title}>{c.title}</h3>
106
+ <Title className={t.title}>
107
+ <Emphasis text={c.title} />
108
+ </Title>
88
109
  {c.body ? <p className={t.body}>{c.body}</p> : null}
89
110
  {c.link ? (
90
111
  <SmartLink href={c.link.href} className={t.link}>
@@ -102,8 +123,9 @@ export function Projects({ block }: { block: BlockOf<"projects"> }) {
102
123
  const d = block.data;
103
124
  const t = theme.projects;
104
125
  const grid = d.layout === "grid";
126
+ const Title = d.heading ? "h3" : "h2";
105
127
  return (
106
- <Section block="projects" tone={d.tone}>
128
+ <Section block="projects" id={block.anchor} tone={d.tone}>
107
129
  <SectionHeading eyebrow={d.eyebrow} heading={d.heading} intro={d.intro} />
108
130
  <ul className={grid ? t.gridList : t.showcaseList} data-layout={d.layout}>
109
131
  {d.items.map((p, i) => (
@@ -111,7 +133,7 @@ export function Projects({ block }: { block: BlockOf<"projects"> }) {
111
133
  {p.image ? <Img image={p.image} crop={16 / 10} className={t.image} /> : null}
112
134
  <div className={t.text}>
113
135
  <div className={t.top}>
114
- <h3 className={t.name}>{p.name}</h3>
136
+ <Title className={t.name}>{p.name}</Title>
115
137
  {p.status ? <span className={t.status}>{p.status}</span> : null}
116
138
  </div>
117
139
  {p.role ? <p className={t.role}>{p.role}</p> : null}
@@ -141,14 +163,15 @@ export function Projects({ block }: { block: BlockOf<"projects"> }) {
141
163
  export function Pricing({ block }: { block: BlockOf<"pricing"> }) {
142
164
  const d = block.data;
143
165
  const t = theme.pricing;
166
+ const Title = d.heading ? "h3" : "h2";
144
167
  return (
145
- <Section block="pricing" tone={d.tone}>
168
+ <Section block="pricing" id={block.anchor} tone={d.tone}>
146
169
  <SectionHeading eyebrow={d.eyebrow} heading={d.heading} intro={d.intro} />
147
170
  <ul className={t.list} data-count={d.plans.length >= 4 ? "many" : "few"}>
148
171
  {d.plans.map((p, i) => (
149
172
  <li key={i} className={t.plan} data-highlight={p.highlight ? "" : undefined}>
150
173
  {p.highlight ? <p className={t.highlight}>{p.highlight}</p> : null}
151
- <h3 className={t.name}>{p.name}</h3>
174
+ <Title className={t.name}>{p.name}</Title>
152
175
  <p className={t.price}>
153
176
  <span className={t.amount}>{p.price}</span>
154
177
  {p.period ? <span className={t.period}>{p.period}</span> : null}
@@ -196,12 +219,12 @@ export function CallToAction({ block }: { block: BlockOf<"cta"> }) {
196
219
  );
197
220
  if (!d.image)
198
221
  return (
199
- <Section block="cta" tone={d.tone}>
222
+ <Section block="cta" id={block.anchor} tone={d.tone}>
200
223
  {content}
201
224
  </Section>
202
225
  );
203
226
  return (
204
- <section data-block="cta" {...ground("inverse")} className={t.imageRoot}>
227
+ <section id={block.anchor} data-block="cta" {...ground("inverse")} className={cx(t.imageRoot, block.anchor && theme.section.anchored)}>
205
228
  <Img image={d.image} crop className={t.image} />
206
229
  <div className={t.overlay}>
207
230
  <div className={t.overlayInner}>{content}</div>
@@ -213,19 +236,32 @@ export function CallToAction({ block }: { block: BlockOf<"cta"> }) {
213
236
  export function Faq({ block }: { block: BlockOf<"faq"> }) {
214
237
  const t = theme.faq;
215
238
  return (
216
- <Section block="faq">
239
+ <Section block="faq" id={block.anchor}>
217
240
  <SectionHeading heading={block.data.heading} />
218
- <div className={t.list}>
219
- {block.data.items.map((q, i) => (
220
- <details key={i} className={t.item}>
221
- <summary className={t.question}>
222
- {q.question}
223
- <span aria-hidden className={t.icon} />
224
- </summary>
225
- <Markdown className={t.answer}>{q.answer}</Markdown>
226
- </details>
227
- ))}
228
- </div>
241
+ {block.data.display === "list" ? (
242
+ <dl className={t.terms}>
243
+ {block.data.items.map((q, i) => (
244
+ <div key={i} className={t.term}>
245
+ <dt className={t.termQuestion}>{q.question}</dt>
246
+ <dd>
247
+ <Markdown className={t.termAnswer}>{q.answer}</Markdown>
248
+ </dd>
249
+ </div>
250
+ ))}
251
+ </dl>
252
+ ) : (
253
+ <div className={t.list}>
254
+ {block.data.items.map((q, i) => (
255
+ <details key={i} className={t.item}>
256
+ <summary className={t.question}>
257
+ {q.question}
258
+ <span aria-hidden className={t.icon} />
259
+ </summary>
260
+ <Markdown className={t.answer}>{q.answer}</Markdown>
261
+ </details>
262
+ ))}
263
+ </div>
264
+ )}
229
265
  </Section>
230
266
  );
231
267
  }
@@ -234,7 +270,7 @@ export function Quote({ block }: { block: BlockOf<"quote"> }) {
234
270
  const d = block.data;
235
271
  const t = theme.quote;
236
272
  return (
237
- <Section block="quote" tone={d.tone}>
273
+ <Section block="quote" id={block.anchor} tone={d.tone}>
238
274
  <figure className={t.figure}>
239
275
  <blockquote className={t.text}>{d.quote}</blockquote>
240
276
  {d.name ? (
@@ -1,4 +1,5 @@
1
1
  import type { BlockOf } from "@the-smithy/hearth";
2
+ import { theme } from "@/theme";
2
3
  import { customBlocks } from "@/theme/custom-blocks";
3
4
 
4
5
  /** A block type of this site's own (Settings → Custom blocks in Hearth), drawn by its component in src/theme/custom-blocks.tsx. */
@@ -8,5 +9,12 @@ export function CustomBlock({ block }: { block: BlockOf<"custom"> }) {
8
9
  if (process.env.NODE_ENV !== "production") console.warn(`[hearth] No component for custom block "${block.data.block}"; add one in src/theme/custom-blocks.tsx.`);
9
10
  return null;
10
11
  }
11
- return <Component values={block.data.values} />;
12
+ // Custom blocks draw their own markup, so a link's target goes around them.
13
+ return block.anchor ? (
14
+ <div id={block.anchor} className={theme.section.anchored}>
15
+ <Component values={block.data.values} />
16
+ </div>
17
+ ) : (
18
+ <Component values={block.data.values} />
19
+ );
12
20
  }
@@ -1,14 +1,16 @@
1
1
  import Link from "next/link";
2
2
  import type { BlockOf, HearthEvent } from "@the-smithy/hearth";
3
3
  import { theme } from "@/theme";
4
+ import { NotHere } from "../components/NotHere";
4
5
  import { Registration } from "../components/Registration";
5
6
  import { VolunteerForm } from "../forms/VolunteerForm";
6
7
  import { formatEventDate } from "../format";
8
+ import { overrides, type CardHeading } from "../overrides";
7
9
  import { fill, Img, Section, SectionHeading, toTone } from "../ui";
8
10
 
9
11
  export { formatEventDate };
10
12
 
11
- export function EventCard({ event }: { event: HearthEvent }) {
13
+ export function EventCard({ event, heading: Heading = "h3" }: { event: HearthEvent; heading?: CardHeading }) {
12
14
  const t = theme.eventCard;
13
15
  const cancelled = event.status === "cancelled";
14
16
  return (
@@ -19,11 +21,11 @@ export function EventCard({ event }: { event: HearthEvent }) {
19
21
  {cancelled ? <span className={t.cancelled}>{theme.text.cancelled}</span> : null}
20
22
  <time dateTime={event.startsAt}>{formatEventDate(event)}</time>
21
23
  </p>
22
- <h3 className={t.title}>
24
+ <Heading className={t.title}>
23
25
  <Link href={event.path} className={t.link}>
24
26
  {event.title}
25
27
  </Link>
26
- </h3>
28
+ </Heading>
27
29
  {event.location.name ? <p className={t.place}>{event.location.name}</p> : null}
28
30
  {event.summary ? <p className={t.summary}>{event.summary}</p> : null}
29
31
  <div className={t.actions}>
@@ -34,12 +36,14 @@ export function EventCard({ event }: { event: HearthEvent }) {
34
36
  );
35
37
  }
36
38
 
37
- export function EventList({ events }: { events: HearthEvent[] }) {
38
- if (!events.length) return <p className={theme.eventCard.empty}>{theme.text.noEvents}</p>;
39
+ /** Event cards (the site's own, if it replaces them). `heading` is the level of their titles; `emptyText` is what to say when there are none. */
40
+ export function EventList({ events, heading = "h3", emptyText }: { events: HearthEvent[]; heading?: CardHeading; emptyText?: string }) {
41
+ if (!events.length) return <p className={theme.eventCard.empty}>{emptyText || theme.text.noEvents}</p>;
42
+ const Card = overrides.EventCard ?? EventCard;
39
43
  return (
40
44
  <ul className={theme.eventCard.list}>
41
45
  {events.map((e) => (
42
- <EventCard key={e.slug} event={e} />
46
+ <Card key={e.slug} event={e} heading={heading} />
43
47
  ))}
44
48
  </ul>
45
49
  );
@@ -47,14 +51,18 @@ export function EventList({ events }: { events: HearthEvent[] }) {
47
51
 
48
52
  export function UpcomingEvents({ block }: { block: BlockOf<"upcomingEvents"> }) {
49
53
  const d = block.data;
54
+ const href = d.collection?.path ?? "/events";
50
55
  return (
51
- <Section block="upcomingEvents">
56
+ <Section block="upcomingEvents" id={block.anchor}>
52
57
  <SectionHeading eyebrow={d.eyebrow} heading={d.heading} intro={d.intro} />
53
- <EventList events={d.events} />
54
- {d.events.length ? (
55
- <Link href="/events" className={theme.upcomingEvents.more}>
56
- {theme.text.allEvents}
57
- </Link>
58
+ <EventList events={d.events} heading={d.heading ? "h3" : "h2"} emptyText={d.emptyText} />
59
+ {/* A limit of 0 is every event already, and on the page it points at the link would go nowhere. */}
60
+ {d.events.length && d.limit !== 0 ? (
61
+ <NotHere href={href}>
62
+ <Link href={href} className={theme.upcomingEvents.more}>
63
+ {d.collection ? fill(theme.text.allCollectionEvents, { collection: d.collection.name.toLowerCase() }) : theme.text.allEvents}
64
+ </Link>
65
+ </NotHere>
58
66
  ) : null}
59
67
  </Section>
60
68
  );
@@ -63,11 +71,11 @@ export function UpcomingEvents({ block }: { block: BlockOf<"upcomingEvents"> })
63
71
  /** Events that have ended, most recent first (a talk archive). They keep their pages, so each still links to it. */
64
72
  export function PastEvents({ block }: { block: BlockOf<"pastEvents"> }) {
65
73
  const d = block.data;
66
- if (!d.events.length) return null;
74
+ if (!d.events.length && !d.emptyText) return null;
67
75
  return (
68
- <Section block="pastEvents" tone={toTone(theme.pastEvents.tone)}>
76
+ <Section block="pastEvents" id={block.anchor} tone={toTone(theme.pastEvents.tone)}>
69
77
  <SectionHeading eyebrow={d.eyebrow} heading={d.heading} intro={d.intro} />
70
- <EventList events={d.events} />
78
+ <EventList events={d.events} heading={d.heading ? "h3" : "h2"} emptyText={d.emptyText} />
71
79
  </Section>
72
80
  );
73
81
  }
@@ -77,7 +85,7 @@ export function EventSpotlight({ block }: { block: BlockOf<"eventSpotlight"> })
77
85
  const t = theme.eventSpotlight;
78
86
  if (!e) return null;
79
87
  return (
80
- <Section block="eventSpotlight" tone={toTone(t.tone)}>
88
+ <Section block="eventSpotlight" id={block.anchor} tone={toTone(t.tone)}>
81
89
  <div className={t.grid}>
82
90
  <div>
83
91
  {block.data.heading ? <p className={t.label}>{block.data.heading}</p> : null}
@@ -110,7 +118,7 @@ export function VolunteerSignup({ block }: { block: BlockOf<"volunteerSignup"> }
110
118
  const t = theme.volunteerSignup;
111
119
  const intro = block.data.intro || v.intro;
112
120
  return (
113
- <Section block="volunteerSignup" id={volunteerAnchor(e.slug)}>
121
+ <Section block="volunteerSignup" id={block.anchor ?? volunteerAnchor(e.slug)}>
114
122
  <div className={t.wrap}>
115
123
  <h2 className={t.title}>{block.data.heading || theme.text.volunteer}</h2>
116
124
  {block.data.eventSlug ? (
@@ -1,8 +1,10 @@
1
1
  import Link from "next/link";
2
2
  import type { BlockOf, Fundraiser } from "@the-smithy/hearth";
3
3
  import { theme } from "@/theme";
4
- import { DonateForm } from "../forms/DonateForm";
4
+ import { NotHere } from "../components/NotHere";
5
+ import { DonationForm } from "../forms/DonationForm";
5
6
  import { money } from "../format";
7
+ import { overrides, type CardHeading } from "../overrides";
6
8
  import { fill, Img, Section, SectionHeading, toTone } from "../ui";
7
9
 
8
10
  export { money };
@@ -16,34 +18,32 @@ export function Progress({ fundraiser, large }: { fundraiser: Fundraiser; large?
16
18
  <div className={t.root}>
17
19
  <p className={t.amounts} data-size={large ? "large" : undefined}>
18
20
  <span className={t.raised}>{money(raisedCents)}</span>
19
- <span className={t.goal}>{goalCents ? ` raised of ${money(goalCents)}` : " raised"}</span>
21
+ <span className={t.goal}> {goalCents ? fill(theme.text.raisedOfGoal, { goal: money(goalCents) }) : theme.text.raised}</span>
20
22
  </p>
21
23
  {pct !== null ? (
22
- <div className={t.bar} role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100} aria-label="Progress toward the goal">
24
+ <div className={t.bar} role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100} aria-label={theme.text.progressLabel}>
23
25
  <div className={t.fill} style={{ width: `${pct}%` }} />
24
26
  </div>
25
27
  ) : null}
26
28
  {donorCount ? (
27
- <p className={t.donors}>
28
- {donorCount} online donation{donorCount === 1 ? "" : "s"}
29
- </p>
29
+ <p className={t.donors}>{fill(donorCount === 1 ? theme.text.oneDonation : theme.text.donations, { count: String(donorCount) })}</p>
30
30
  ) : null}
31
31
  </div>
32
32
  );
33
33
  }
34
34
 
35
- export function FundraiserCard({ fundraiser }: { fundraiser: Fundraiser }) {
35
+ export function FundraiserCard({ fundraiser, heading: Heading = "h3" }: { fundraiser: Fundraiser; heading?: CardHeading }) {
36
36
  const t = theme.fundraiserCard;
37
37
  return (
38
38
  <li className={t.card} data-status={fundraiser.status}>
39
39
  {fundraiser.image ? <Img image={fundraiser.image} crop={16 / 9} className={t.image} /> : null}
40
40
  <div className={t.body}>
41
41
  {fundraiser.status === "completed" ? <p className={t.completed}>{theme.text.completed}</p> : null}
42
- <h3 className={t.title}>
42
+ <Heading className={t.title}>
43
43
  <Link href={fundraiser.path} className={t.link}>
44
44
  {fundraiser.title}
45
45
  </Link>
46
- </h3>
46
+ </Heading>
47
47
  {fundraiser.summary ? <p className={t.summary}>{fundraiser.summary}</p> : null}
48
48
  <div className={t.progress}>
49
49
  <Progress fundraiser={fundraiser} />
@@ -53,12 +53,14 @@ export function FundraiserCard({ fundraiser }: { fundraiser: Fundraiser }) {
53
53
  );
54
54
  }
55
55
 
56
- export function FundraiserList({ fundraisers }: { fundraisers: Fundraiser[] }) {
57
- if (!fundraisers.length) return <p className={theme.fundraiserCard.empty}>{theme.text.noFundraisers}</p>;
56
+ /** Fundraiser cards (the site's own, if it replaces them). `heading` is the level of their titles; `emptyText` is what to say when there are none. */
57
+ export function FundraiserList({ fundraisers, heading = "h3", emptyText }: { fundraisers: Fundraiser[]; heading?: CardHeading; emptyText?: string }) {
58
+ if (!fundraisers.length) return <p className={theme.fundraiserCard.empty}>{emptyText || theme.text.noFundraisers}</p>;
59
+ const Card = overrides.FundraiserCard ?? FundraiserCard;
58
60
  return (
59
61
  <ul className={theme.fundraiserCard.list}>
60
62
  {fundraisers.map((f) => (
61
- <FundraiserCard key={f.slug} fundraiser={f} />
63
+ <Card key={f.slug} fundraiser={f} heading={heading} />
62
64
  ))}
63
65
  </ul>
64
66
  );
@@ -67,13 +69,15 @@ export function FundraiserList({ fundraisers }: { fundraisers: Fundraiser[] }) {
67
69
  export function FundraiserGrid({ block }: { block: BlockOf<"fundraiserGrid"> }) {
68
70
  const d = block.data;
69
71
  return (
70
- <Section block="fundraiserGrid">
72
+ <Section block="fundraiserGrid" id={block.anchor}>
71
73
  <SectionHeading eyebrow={d.eyebrow} heading={d.heading} intro={d.intro} />
72
- <FundraiserList fundraisers={d.fundraisers} />
74
+ <FundraiserList fundraisers={d.fundraisers} heading={d.heading ? "h3" : "h2"} emptyText={d.emptyText} />
73
75
  {d.fundraisers.length ? (
74
- <Link href="/fundraisers" className={theme.fundraiserGrid.more}>
75
- {theme.text.allFundraisers}
76
- </Link>
76
+ <NotHere href="/fundraisers">
77
+ <Link href="/fundraisers" className={theme.fundraiserGrid.more}>
78
+ {theme.text.allFundraisers}
79
+ </Link>
80
+ </NotHere>
77
81
  ) : null}
78
82
  </Section>
79
83
  );
@@ -84,7 +88,7 @@ export function FundraiserSpotlight({ block }: { block: BlockOf<"fundraiserSpotl
84
88
  const t = theme.fundraiserSpotlight;
85
89
  if (!f) return null;
86
90
  return (
87
- <Section block="fundraiserSpotlight" tone={toTone(t.tone)}>
91
+ <Section block="fundraiserSpotlight" id={block.anchor} tone={toTone(t.tone)}>
88
92
  <div className={t.grid}>
89
93
  <div className={t.details}>
90
94
  {block.data.heading ? <p className={t.label}>{block.data.heading}</p> : null}
@@ -112,11 +116,11 @@ export function Donate({ block }: { block: BlockOf<"donate"> }) {
112
116
  const d = block.data;
113
117
  const t = theme.donate;
114
118
  return (
115
- <Section block="donate">
119
+ <Section block="donate" id={block.anchor}>
116
120
  <div className={t.wrap}>
117
121
  <SectionHeading heading={d.heading} intro={d.intro} />
118
122
  {d.fundraiser ? <p className={t.supporting}>{fill(theme.text.supporting, { fundraiser: d.fundraiser.title })}</p> : null}
119
- <DonateForm presetsCents={d.presetsCents} minCents={d.minCents} maxCents={d.maxCents} fundraiser={d.fundraiser?.slug} submitLabel={d.submitLabel} />
123
+ <DonationForm presetsCents={d.presetsCents} minCents={d.minCents} maxCents={d.maxCents} fundraiser={d.fundraiser?.slug} submitLabel={d.submitLabel} />
120
124
  </div>
121
125
  </Section>
122
126
  );
@@ -1,9 +1,10 @@
1
1
  import type { BlockOf, ResolvedBlock } from "@the-smithy/hearth";
2
2
  import { HearthBlocks, type BlockComponents } from "@the-smithy/hearth/react";
3
+ import type { ComponentType } from "react";
3
4
  import { theme } from "@/theme";
4
- import { blocks as overrides } from "@/theme/overrides";
5
5
  import { EmberFormBlock } from "../forms/EmberFormBlock";
6
6
  import { HearthForm } from "../forms/ContactForm";
7
+ import { overrides } from "../overrides";
7
8
  import { Section, SectionHeading } from "../ui";
8
9
  import { CollectionList } from "./collections";
9
10
  import { CallToAction, CardGrid, Faq, Hero, MediaText, Pricing, Projects, Quote, RichText, Stats } from "./content";
@@ -15,7 +16,7 @@ import { Embed, Gallery, Logos, People, Video } from "./media";
15
16
 
16
17
  function FormBlock({ block }: { block: BlockOf<"form"> }) {
17
18
  return (
18
- <Section block="form">
19
+ <Section block="form" id={block.anchor}>
19
20
  <div className={theme.formBlock.wrap}>
20
21
  <SectionHeading heading={block.data.heading} intro={block.data.intro} />
21
22
  {/* Set to an Ember form in Hearth: Ember renders it and keeps the leads; the site's form is the fallback. */}
@@ -65,8 +66,32 @@ export const blocks: BlockComponents = {
65
66
  custom: CustomBlock,
66
67
  };
67
68
 
68
- const components: BlockComponents = { ...blocks, ...overrides };
69
+ /** A site's replaced block keeps working with anchors from Hearth, whether or not its code knows about them. */
70
+ const anchored = (Component: ComponentType<{ block: ResolvedBlock }>) =>
71
+ function Anchored({ block }: { block: ResolvedBlock }) {
72
+ return block.anchor ? (
73
+ <div id={block.anchor} className={theme.section.anchored}>
74
+ <Component block={block} />
75
+ </div>
76
+ ) : (
77
+ <Component block={block} />
78
+ );
79
+ };
80
+
81
+ let components: BlockComponents | undefined;
82
+
83
+ /**
84
+ * Hearth's blocks with the site's in their place. Made on first use, not as this file loads: an override in
85
+ * src/theme/overrides.tsx may import Blocks from here, and then that file hasn't finished loading at this point.
86
+ */
87
+ function allBlocks() {
88
+ components ??= {
89
+ ...blocks,
90
+ ...Object.fromEntries(Object.entries(overrides.blocks ?? {}).map(([type, component]) => [type, anchored(component as ComponentType<{ block: ResolvedBlock }>)])),
91
+ };
92
+ return components;
93
+ }
69
94
 
70
95
  export function Blocks({ blocks: list }: { blocks: ResolvedBlock[] }) {
71
- return <HearthBlocks blocks={list} components={components} fallback={() => null} />;
96
+ return <HearthBlocks blocks={list} components={allBlocks()} fallback={() => null} />;
72
97
  }
@@ -1,26 +1,27 @@
1
1
  import { directionsUrl, pinned, type BlockOf } from "@the-smithy/hearth";
2
2
  import { theme } from "@/theme";
3
- import { Markdown, Section, SectionHeading, SmartLink } from "../ui";
3
+ import { ExternalLink, fill, Markdown, Section, SectionHeading, SmartLink } from "../ui";
4
4
  import { OpenMap } from "./OpenMap";
5
5
 
6
6
  export function Location({ block }: { block: BlockOf<"location"> }) {
7
7
  const d = block.data;
8
8
  const t = theme.location;
9
+ const Title = d.heading ? "h3" : "h2";
9
10
  return (
10
- <Section block="location">
11
+ <Section block="location" id={block.anchor}>
11
12
  <SectionHeading eyebrow={d.eyebrow} heading={d.heading} />
12
13
  <div className={t.grid}>
13
14
  <div className={t.details}>
14
- <h3 className={t.name}>{d.name}</h3>
15
+ <Title className={t.name}>{d.name}</Title>
15
16
  <address className={t.address}>{d.address}</address>
16
17
  {d.hours ? <p className={t.hours}>{d.hours}</p> : null}
17
18
  {d.notes ? <Markdown className={t.notes}>{d.notes}</Markdown> : null}
18
- <a href={directionsUrl(d)} rel="noopener" className={t.directions}>
19
+ <ExternalLink href={directionsUrl(d)} rel="noopener" className={t.directions}>
19
20
  {theme.text.directions}
20
- </a>
21
+ </ExternalLink>
21
22
  </div>
22
23
  {/* Content from before map pins has none: it shows no map until the pin is set in Hearth. */}
23
- {d.showMap && d.position ? <OpenMap places={[{ name: d.name, address: d.address, position: d.position }]} label={`Map of ${d.name}`} className={t.map} /> : null}
24
+ {d.showMap && d.position ? <OpenMap places={[{ name: d.name, address: d.address, position: d.position }]} label={fill(theme.text.mapOf, { name: d.name })} className={t.map} /> : null}
24
25
  </div>
25
26
  </Section>
26
27
  );
@@ -31,23 +32,24 @@ export function Locations({ block }: { block: BlockOf<"locations"> }) {
31
32
  const d = block.data;
32
33
  const t = theme.locations;
33
34
  const onMap = d.showMap ? pinned(d.items) : [];
35
+ const Title = d.heading ? "h3" : "h2";
34
36
  return (
35
- <Section block="locations">
37
+ <Section block="locations" id={block.anchor}>
36
38
  <SectionHeading eyebrow={d.eyebrow} heading={d.heading} intro={d.intro} />
37
39
  <div className={t.grid} data-map={onMap.length ? "" : undefined}>
38
- {onMap.length ? <OpenMap places={onMap} label={d.heading ? `Map: ${d.heading}` : "Map of our locations"} className={t.map} /> : null}
40
+ {onMap.length ? <OpenMap places={onMap} label={d.heading ? fill(theme.text.mapHeading, { heading: d.heading }) : theme.text.mapPlaces} className={t.map} /> : null}
39
41
  <ul className={onMap.length ? t.list : t.listNoMap}>
40
42
  {d.items.map((place, i) => (
41
43
  <li key={i} className={t.item}>
42
- <h3 className={t.name}>{place.name}</h3>
44
+ <Title className={t.name}>{place.name}</Title>
43
45
  <address className={t.address}>{place.address}</address>
44
46
  {place.hours ? <p className={t.hours}>{place.hours}</p> : null}
45
47
  {place.notes ? <p className={t.notes}>{place.notes}</p> : null}
46
48
  <p className={t.links}>
47
- <a href={directionsUrl(place)} rel="noopener" className={t.link}>
49
+ <ExternalLink href={directionsUrl(place)} rel="noopener" className={t.link}>
48
50
  {theme.text.directions}
49
- <span className="sr-only"> to {place.name}</span>
50
- </a>
51
+ <span className="sr-only"> {fill(theme.text.directionsTo, { name: place.name })}</span>
52
+ </ExternalLink>
51
53
  {place.link ? (
52
54
  <SmartLink href={place.link.href} className={t.link}>
53
55
  {place.link.label}
@@ -66,7 +68,7 @@ export function Schedule({ block }: { block: BlockOf<"schedule"> }) {
66
68
  const d = block.data;
67
69
  const t = theme.schedule;
68
70
  return (
69
- <Section block="schedule">
71
+ <Section block="schedule" id={block.anchor}>
70
72
  <SectionHeading eyebrow={d.eyebrow} heading={d.heading} intro={d.intro} />
71
73
  <ol className={t.list}>
72
74
  {d.items.map((item, i) => (
@@ -1,13 +1,13 @@
1
1
  import { videoEmbedUrl, type BlockOf } from "@the-smithy/hearth";
2
2
  import { HearthEmbed } from "@the-smithy/hearth/react";
3
3
  import { theme } from "@/theme";
4
- import { Img, Section, SectionHeading } from "../ui";
4
+ import { ExternalLink, Img, Section, SectionHeading } from "../ui";
5
5
 
6
6
  export function Gallery({ block }: { block: BlockOf<"gallery"> }) {
7
7
  const d = block.data;
8
8
  const t = theme.gallery;
9
9
  return (
10
- <Section block="gallery">
10
+ <Section block="gallery" id={block.anchor}>
11
11
  <SectionHeading heading={d.heading} intro={d.intro} />
12
12
  <ul className={t.list} data-columns={d.columns}>
13
13
  {d.images.map((item, i) => (
@@ -29,7 +29,7 @@ export function Video({ block }: { block: BlockOf<"video"> }) {
29
29
  const src = videoEmbedUrl(d.url);
30
30
  if (!src) return null;
31
31
  return (
32
- <Section block="video">
32
+ <Section block="video" id={block.anchor}>
33
33
  <SectionHeading heading={d.heading} />
34
34
  <figure className={t.figure}>
35
35
  <div className={t.frame}>
@@ -52,7 +52,7 @@ export function Video({ block }: { block: BlockOf<"video"> }) {
52
52
  export function Embed({ block }: { block: BlockOf<"embed"> }) {
53
53
  const t = theme.embed;
54
54
  return (
55
- <Section block="embed">
55
+ <Section block="embed" id={block.anchor}>
56
56
  <figure className={t.figure} data-width={block.data.width}>
57
57
  <HearthEmbed block={block} className={t.frame} />
58
58
  {block.data.caption ? <figcaption className={t.caption}>{block.data.caption}</figcaption> : null}
@@ -64,15 +64,16 @@ export function Embed({ block }: { block: BlockOf<"embed"> }) {
64
64
  export function People({ block }: { block: BlockOf<"people"> }) {
65
65
  const d = block.data;
66
66
  const t = theme.people;
67
+ const Title = d.heading ? "h3" : "h2";
67
68
  return (
68
- <Section block="people">
69
+ <Section block="people" id={block.anchor}>
69
70
  <SectionHeading eyebrow={d.eyebrow} heading={d.heading} intro={d.intro} />
70
71
  <ul className={t.list}>
71
72
  {d.people.map((p, i) => (
72
73
  <li key={i} className={t.person}>
73
74
  {p.image ? <Img image={p.image} crop={1} className={t.image} /> : null}
74
75
  <div>
75
- <h3 className={t.name}>{p.name}</h3>
76
+ <Title className={t.name}>{p.name}</Title>
76
77
  {p.role ? <p className={t.role}>{p.role}</p> : null}
77
78
  </div>
78
79
  {p.bio ? <p className={t.bio}>{p.bio}</p> : null}
@@ -87,7 +88,7 @@ export function Logos({ block }: { block: BlockOf<"logos"> }) {
87
88
  const d = block.data;
88
89
  const t = theme.logos;
89
90
  return (
90
- <Section block="logos">
91
+ <Section block="logos" id={block.anchor}>
91
92
  <SectionHeading heading={d.heading} intro={d.intro} />
92
93
  <ul className={t.list}>
93
94
  {d.logos.map((l, i) => {
@@ -95,9 +96,9 @@ export function Logos({ block }: { block: BlockOf<"logos"> }) {
95
96
  return (
96
97
  <li key={i}>
97
98
  {l.url ? (
98
- <a href={l.url} rel="noopener" className={t.link}>
99
+ <ExternalLink href={l.url} rel="noopener" className={t.link}>
99
100
  {mark}
100
- </a>
101
+ </ExternalLink>
101
102
  ) : (
102
103
  mark
103
104
  )}