@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,75 +0,0 @@
1
- import Link from "next/link";
2
- import type { BlockOf, CollectionEntry } from "@the-smithy/hearth";
3
- import { Img, Section, SectionHeading } from "@/components/ui";
4
-
5
- const date = (iso: string | null) =>
6
- iso ? new Date(`${iso.slice(0, 10)}T12:00:00Z`).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" }) : null;
7
-
8
- function Card({ entry }: { entry: CollectionEntry }) {
9
- return (
10
- <li className="relative flex flex-col overflow-hidden rounded-2xl bg-surface ring-1 ring-line">
11
- {entry.image ? <Img image={entry.image} className="aspect-[16/10] w-full object-cover" /> : null}
12
- <div className="flex flex-1 flex-col gap-2 p-6">
13
- {entry.date ? <p className="text-sm font-semibold text-highlight">{date(entry.date)}</p> : null}
14
- <h3 className="text-xl font-semibold">
15
- <Link href={entry.path} className="after:absolute after:inset-0 hover:underline">
16
- {entry.title}
17
- </Link>
18
- </h3>
19
- {entry.summary ? <p className="leading-relaxed opacity-85">{entry.summary}</p> : null}
20
- {entry.tags.length ? (
21
- <ul className="mt-auto flex flex-wrap gap-2 pt-2">
22
- {entry.tags.map((t) => (
23
- <li key={t} className="rounded-full bg-muted px-3 py-1 text-xs font-semibold">
24
- {t}
25
- </li>
26
- ))}
27
- </ul>
28
- ) : null}
29
- </div>
30
- </li>
31
- );
32
- }
33
-
34
- /** Entries of a page collection (a blog, case studies…), as cards or a simple list. */
35
- export function CollectionList({ block }: { block: BlockOf<"collectionList"> }) {
36
- const { entries, collection, layout } = block.data;
37
- return (
38
- <Section>
39
- <SectionHeading eyebrow={block.data.eyebrow} heading={block.data.heading} intro={block.data.intro} />
40
- {!entries.length ? (
41
- <p className="text-lg opacity-75">Nothing here yet. Check back soon.</p>
42
- ) : layout === "list" ? (
43
- <ul className="max-w-3xl divide-y divide-line border-y border-line">
44
- {entries.map((e) => (
45
- <li key={e.path} className="py-5">
46
- <Link href={e.path} className="text-lg font-semibold hover:underline">
47
- {e.title}
48
- </Link>
49
- {e.date || e.summary ? (
50
- <p className="mt-1 opacity-80">
51
- {e.date ? <span className="font-semibold">{date(e.date)}</span> : null}
52
- {e.date && e.summary ? " · " : null}
53
- {e.summary}
54
- </p>
55
- ) : null}
56
- </li>
57
- ))}
58
- </ul>
59
- ) : (
60
- <ul className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
61
- {entries.map((e) => (
62
- <Card key={e.path} entry={e} />
63
- ))}
64
- </ul>
65
- )}
66
- {collection && entries.length >= block.data.limit ? (
67
- <p className="mt-8">
68
- <Link href={collection.path} className="font-semibold underline underline-offset-4">
69
- All {collection.name.toLowerCase()} →
70
- </Link>
71
- </p>
72
- ) : null}
73
- </Section>
74
- );
75
- }
@@ -1,257 +0,0 @@
1
- import type { BlockOf } from "@the-smithy/hearth";
2
- import { Button, Emphasis, Img, isDark, Markdown, Section, SectionHeading, SmartLink } from "@/components/ui";
3
-
4
- export function Hero({ block }: { block: BlockOf<"hero"> }) {
5
- const d = block.data;
6
- return (
7
- <section className="relative overflow-hidden bg-inverse text-inverse-ink">
8
- {d.image ? <Img image={d.image} priority className="absolute inset-0 size-full object-cover opacity-35" /> : null}
9
- <div className={`relative mx-auto max-w-site px-4 sm:px-6 ${d.size === "full" ? "py-28 sm:py-36" : "py-16 sm:py-20"}`}>
10
- {d.logo ? <Img image={d.logo} priority className="mb-6 h-20 w-auto sm:h-24" /> : null}
11
- {d.eyebrow ? <p className="mb-4 text-sm font-semibold uppercase tracking-[0.18em] opacity-80">{d.eyebrow}</p> : null}
12
- <h1 className="max-w-4xl text-[clamp(36px,5vw,64px)] font-bold leading-[1.05] tracking-tight">
13
- <Emphasis text={d.heading} className="underline decoration-2 underline-offset-[6px]" />
14
- </h1>
15
- {d.body ? <p className="mt-5 max-w-2xl text-lg leading-relaxed opacity-85 sm:text-xl">{d.body}</p> : null}
16
- {d.primaryCta || d.secondaryCta ? (
17
- <div className="mt-8 flex flex-wrap gap-3">
18
- <Button link={d.primaryCta} dark />
19
- <Button link={d.secondaryCta} variant="secondary" />
20
- </div>
21
- ) : null}
22
- </div>
23
- </section>
24
- );
25
- }
26
-
27
- export function RichText({ block }: { block: BlockOf<"richText"> }) {
28
- return (
29
- <Section>
30
- <Markdown className={block.data.width === "narrow" ? "max-w-3xl" : ""}>{block.data.markdown}</Markdown>
31
- </Section>
32
- );
33
- }
34
-
35
- export function MediaText({ block }: { block: BlockOf<"mediaText"> }) {
36
- const d = block.data;
37
- return (
38
- <Section>
39
- <div className={`grid items-center gap-10 md:grid-cols-2 ${d.imagePosition === "left" ? "md:[&>*:first-child]:order-2" : ""}`}>
40
- <div>
41
- <SectionHeading eyebrow={d.eyebrow} heading={d.heading} />
42
- <Markdown>{d.markdown}</Markdown>
43
- {d.cta ? (
44
- <div className="mt-6">
45
- <Button link={d.cta} />
46
- </div>
47
- ) : null}
48
- </div>
49
- {d.image ? <Img image={d.image} className={`w-full rounded-xl ${d.imageFit === "contain" ? "max-h-96 object-contain" : "aspect-[4/3] object-cover"}`} /> : null}
50
- </div>
51
- </Section>
52
- );
53
- }
54
-
55
- export function Stats({ block }: { block: BlockOf<"stats"> }) {
56
- return (
57
- <Section tone="inverse">
58
- <SectionHeading heading={block.data.heading} dark />
59
- <dl className="grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
60
- {block.data.items.map((s, i) => (
61
- <div key={i} className="flex flex-col-reverse">
62
- <dt className="mt-2 opacity-80">{s.label}</dt>
63
- <dd className="text-5xl font-bold">{s.value}</dd>
64
- </div>
65
- ))}
66
- </dl>
67
- </Section>
68
- );
69
- }
70
-
71
- export function CardGrid({ block }: { block: BlockOf<"cardGrid"> }) {
72
- const d = block.data;
73
- const dark = isDark(d.tone);
74
- return (
75
- <Section tone={d.tone}>
76
- <SectionHeading eyebrow={d.eyebrow} heading={d.heading} intro={d.intro} dark={dark} />
77
- <ul className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
78
- {d.items.map((c, i) => (
79
- <li key={i} className={`flex flex-col gap-3 rounded-2xl p-7 ${dark ? "bg-white/10" : "bg-surface ring-1 ring-line"}`}>
80
- {c.image ? <Img image={c.image} className="mb-2 aspect-[16/10] w-full rounded-lg object-cover" /> : null}
81
- <h3 className="text-xl font-semibold">{c.title}</h3>
82
- {c.body ? <p className="leading-relaxed opacity-80">{c.body}</p> : null}
83
- {c.link ? (
84
- <SmartLink href={c.link.href} className="mt-auto pt-2 font-semibold underline underline-offset-4">
85
- {c.link.label} →
86
- </SmartLink>
87
- ) : null}
88
- </li>
89
- ))}
90
- </ul>
91
- </Section>
92
- );
93
- }
94
-
95
- export function Projects({ block }: { block: BlockOf<"projects"> }) {
96
- const d = block.data;
97
- const dark = isDark(d.tone);
98
- const grid = d.layout === "grid";
99
- return (
100
- <Section tone={d.tone}>
101
- <SectionHeading eyebrow={d.eyebrow} heading={d.heading} intro={d.intro} dark={dark} />
102
- <ul className={grid ? "grid gap-5 sm:grid-cols-2 lg:grid-cols-3" : "grid gap-14"}>
103
- {d.items.map((p, i) => (
104
- <li
105
- key={i}
106
- className={
107
- grid
108
- ? `flex flex-col gap-3 rounded-2xl p-7 ${dark ? "bg-white/10" : "bg-surface ring-1 ring-line"}`
109
- : "grid items-center gap-8 md:grid-cols-2"
110
- }
111
- >
112
- {p.image ? (
113
- <Img
114
- image={p.image}
115
- className={`aspect-[16/10] w-full rounded-xl object-cover object-top ${grid ? "mb-2" : i % 2 ? "md:order-2" : ""}`}
116
- />
117
- ) : null}
118
- <div className="flex flex-col gap-3">
119
- <div className="flex flex-wrap items-center gap-3">
120
- <h3 className="text-xl font-semibold">{p.name}</h3>
121
- {p.status ? <span className="rounded-full bg-accent px-3 py-0.5 text-sm font-semibold text-accent-ink">{p.status}</span> : null}
122
- </div>
123
- {p.role ? <p className="text-sm font-semibold uppercase tracking-[0.12em] opacity-75">{p.role}</p> : null}
124
- {p.description ? <Markdown className="leading-relaxed opacity-80">{p.description}</Markdown> : null}
125
- {p.stack.length ? (
126
- <ul className="flex flex-wrap gap-2">
127
- {p.stack.map((s, j) => (
128
- <li key={j} className={`rounded-md px-2.5 py-1 text-sm ${dark ? "bg-white/10" : "bg-surface ring-1 ring-line"}`}>
129
- {s}
130
- </li>
131
- ))}
132
- </ul>
133
- ) : null}
134
- {p.url ? (
135
- <SmartLink href={p.url} className="mt-auto pt-2 font-semibold underline underline-offset-4">
136
- Visit {p.name} →
137
- </SmartLink>
138
- ) : null}
139
- </div>
140
- </li>
141
- ))}
142
- </ul>
143
- </Section>
144
- );
145
- }
146
-
147
- export function Pricing({ block }: { block: BlockOf<"pricing"> }) {
148
- const d = block.data;
149
- const dark = isDark(d.tone);
150
- return (
151
- <Section tone={d.tone}>
152
- <SectionHeading eyebrow={d.eyebrow} heading={d.heading} intro={d.intro} dark={dark} />
153
- <ul className={`grid gap-5 sm:grid-cols-2 ${d.plans.length >= 4 ? "lg:grid-cols-4" : "lg:grid-cols-3"}`}>
154
- {d.plans.map((p, i) => (
155
- <li
156
- key={i}
157
- className={`flex flex-col gap-4 rounded-2xl p-7 ${dark ? "bg-white/10" : "bg-surface"} ${p.highlight ? "ring-2 ring-accent" : dark ? "" : "ring-1 ring-line"}`}
158
- >
159
- {p.highlight ? <p className="self-start rounded-full bg-accent px-3 py-0.5 text-sm font-semibold text-accent-ink">{p.highlight}</p> : null}
160
- <h3 className="text-xl font-semibold">{p.name}</h3>
161
- <p>
162
- <span className="text-4xl font-bold tracking-tight">{p.price}</span>
163
- {p.period ? <span className="ml-1 opacity-75">{p.period}</span> : null}
164
- </p>
165
- {p.description ? <p className="leading-relaxed opacity-80">{p.description}</p> : null}
166
- {p.features.length ? (
167
- <ul className="grid gap-2 opacity-90">
168
- {p.features.map((f, j) => (
169
- <li key={j} className="flex gap-2.5">
170
- <span aria-hidden className={dark ? "" : "text-highlight"}>
171
- ✓
172
- </span>
173
- {f}
174
- </li>
175
- ))}
176
- </ul>
177
- ) : null}
178
- {p.cta ? (
179
- <div className="mt-auto pt-2">
180
- <Button link={p.cta} variant={p.highlight ? undefined : "secondary"} dark={dark} />
181
- </div>
182
- ) : null}
183
- </li>
184
- ))}
185
- </ul>
186
- {d.footnote ? <Markdown className="mt-8 max-w-3xl text-sm opacity-80">{d.footnote}</Markdown> : null}
187
- </Section>
188
- );
189
- }
190
-
191
- export function CallToAction({ block }: { block: BlockOf<"cta"> }) {
192
- const d = block.data;
193
- const content = (dark: boolean) => (
194
- <div className="flex flex-wrap items-center justify-between gap-8">
195
- <div className="max-w-2xl">
196
- <h2 className="text-[clamp(28px,3.4vw,40px)] font-bold leading-tight">
197
- <Emphasis text={d.heading} className={dark ? "underline decoration-2 underline-offset-4" : "text-highlight"} />
198
- </h2>
199
- {d.body ? <p className="mt-3 text-lg opacity-85">{d.body}</p> : null}
200
- </div>
201
- <div className="flex flex-wrap gap-3">
202
- <Button link={d.primary} dark={dark} />
203
- <Button link={d.secondary} variant="secondary" />
204
- </div>
205
- </div>
206
- );
207
- if (!d.image) return <Section tone={d.tone}>{content(isDark(d.tone))}</Section>;
208
- return (
209
- <section className="relative overflow-hidden text-white">
210
- <Img image={d.image} className="absolute inset-0 size-full object-cover" />
211
- <div className="relative bg-black/55">
212
- <div className="mx-auto max-w-site px-4 py-20 sm:px-6 sm:py-24">{content(true)}</div>
213
- </div>
214
- </section>
215
- );
216
- }
217
-
218
- export function Faq({ block }: { block: BlockOf<"faq"> }) {
219
- return (
220
- <Section>
221
- <SectionHeading heading={block.data.heading} />
222
- <div className="max-w-3xl divide-y divide-line border-y border-line">
223
- {block.data.items.map((q, i) => (
224
- <details key={i} className="group">
225
- <summary className="flex min-h-14 cursor-pointer list-none items-center justify-between gap-4 py-4 text-lg font-semibold [&::-webkit-details-marker]:hidden">
226
- {q.question}
227
- <span aria-hidden className="text-highlight transition group-open:rotate-45">
228
- +
229
- </span>
230
- </summary>
231
- <Markdown className="pb-5 opacity-85">{q.answer}</Markdown>
232
- </details>
233
- ))}
234
- </div>
235
- </Section>
236
- );
237
- }
238
-
239
- export function Quote({ block }: { block: BlockOf<"quote"> }) {
240
- const d = block.data;
241
- return (
242
- <Section tone={d.tone}>
243
- <figure className="flex max-w-3xl flex-col gap-6">
244
- <blockquote className="text-2xl font-semibold leading-snug sm:text-3xl">“{d.quote}”</blockquote>
245
- {d.name ? (
246
- <figcaption className="flex items-center gap-4">
247
- {d.image ? <Img image={d.image} className="size-14 rounded-full object-cover" /> : null}
248
- <span>
249
- <span className="block font-semibold">{d.name}</span>
250
- {d.role ? <span className="block opacity-75">{d.role}</span> : null}
251
- </span>
252
- </figcaption>
253
- ) : null}
254
- </figure>
255
- </Section>
256
- );
257
- }
@@ -1,20 +0,0 @@
1
- import type { BlockOf } from "@the-smithy/hearth";
2
- import type { ComponentType } from "react";
3
-
4
- /**
5
- * This site's own blocks (Settings → Custom blocks in Hearth), one component per block key. Hearth stores what
6
- * editors enter under `values`, keyed by each field's name; images come as { src, alt } with absolute URLs, links
7
- * as { label, href }, lists as arrays of items. GET /custom-blocks (`hearth.customBlocks.list()`) describes them.
8
- *
9
- * "team-member": ({ values }) => <TeamMember name={values.name as string} photo={values.photo as ImageRef} />,
10
- */
11
- const customBlocks: Record<string, ComponentType<{ values: Record<string, unknown> }>> = {};
12
-
13
- export function CustomBlock({ block }: { block: BlockOf<"custom"> }) {
14
- const Component = customBlocks[block.data.block];
15
- if (!Component) {
16
- if (process.env.NODE_ENV !== "production") console.warn(`[hearth] No component for custom block "${block.data.block}"; add one in components/blocks/custom.tsx.`);
17
- return null;
18
- }
19
- return <Component values={block.data.values} />;
20
- }
@@ -1,104 +0,0 @@
1
- import Link from "next/link";
2
- import type { BlockOf, HearthEvent } from "@the-smithy/hearth";
3
- import { Registration } from "@/components/Registration";
4
- import { Img, Section, SectionHeading } from "@/components/ui";
5
-
6
- /** "Sat, Oct 18 · 6:00 – 9:00 PM", in the event's own time zone. */
7
- export function formatEventDate(e: Pick<HearthEvent, "startsAt" | "endsAt" | "allDay" | "timezone">) {
8
- const tz = { timeZone: e.timezone };
9
- const day = new Intl.DateTimeFormat("en-US", { ...tz, weekday: "short", month: "short", day: "numeric", year: "numeric" });
10
- const time = new Intl.DateTimeFormat("en-US", { ...tz, hour: "numeric", minute: "2-digit" });
11
- const start = new Date(e.startsAt);
12
- const end = e.endsAt ? new Date(e.endsAt) : null;
13
- const sameDay = end && day.format(start) === day.format(end);
14
- if (e.allDay) return end && !sameDay ? `${day.format(start)} – ${day.format(end)}` : day.format(start);
15
- if (!end) return `${day.format(start)} · ${time.format(start)}`;
16
- return sameDay ? `${day.format(start)} · ${time.format(start)} – ${time.format(end)}` : `${day.format(start)} ${time.format(start)} – ${day.format(end)} ${time.format(end)}`;
17
- }
18
-
19
- export function EventCard({ event }: { event: HearthEvent }) {
20
- const cancelled = event.status === "cancelled";
21
- return (
22
- <li className="flex flex-col overflow-hidden rounded-2xl bg-surface ring-1 ring-line">
23
- {event.image ? <Img image={event.image} className="aspect-[16/9] w-full object-cover" /> : null}
24
- <div className="flex flex-1 flex-col gap-2 p-6">
25
- <p className="text-sm font-semibold text-highlight">
26
- {cancelled ? <span className="mr-2 rounded bg-danger px-1.5 py-0.5 text-white">Cancelled</span> : null}
27
- <time dateTime={event.startsAt}>{formatEventDate(event)}</time>
28
- </p>
29
- <h3 className="text-xl font-semibold">
30
- <Link href={event.path} className="after:absolute after:inset-0 hover:underline">
31
- {event.title}
32
- </Link>
33
- </h3>
34
- {event.location.name ? <p className="opacity-75">{event.location.name}</p> : null}
35
- {event.summary ? <p className="leading-relaxed opacity-85">{event.summary}</p> : null}
36
- <div className="mt-auto pt-2">
37
- <Registration event={event} size="small" />
38
- </div>
39
- </div>
40
- </li>
41
- );
42
- }
43
-
44
- export function EventList({ events }: { events: HearthEvent[] }) {
45
- if (!events.length) return <p className="text-lg opacity-75">No upcoming events right now. Check back soon.</p>;
46
- return (
47
- <ul className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 [&>li]:relative">
48
- {events.map((e) => (
49
- <EventCard key={e.slug} event={e} />
50
- ))}
51
- </ul>
52
- );
53
- }
54
-
55
- export function UpcomingEvents({ block }: { block: BlockOf<"upcomingEvents"> }) {
56
- const d = block.data;
57
- return (
58
- <Section>
59
- <SectionHeading eyebrow={d.eyebrow} heading={d.heading} intro={d.intro} />
60
- <EventList events={d.events} />
61
- {d.events.length ? (
62
- <p className="mt-8">
63
- <Link href="/events" className="font-semibold underline underline-offset-4">
64
- All events →
65
- </Link>
66
- </p>
67
- ) : null}
68
- </Section>
69
- );
70
- }
71
-
72
- /** Events that have ended, most recent first (a talk archive). They keep their pages, so each still links to it. */
73
- export function PastEvents({ block }: { block: BlockOf<"pastEvents"> }) {
74
- const d = block.data;
75
- if (!d.events.length) return null;
76
- return (
77
- <Section tone="muted">
78
- <SectionHeading eyebrow={d.eyebrow} heading={d.heading} intro={d.intro} />
79
- <EventList events={d.events} />
80
- </Section>
81
- );
82
- }
83
-
84
- export function EventSpotlight({ block }: { block: BlockOf<"eventSpotlight"> }) {
85
- const e = block.data.event;
86
- if (!e) return null;
87
- return (
88
- <Section tone="muted">
89
- <div className="grid items-center gap-10 md:grid-cols-2">
90
- <div>
91
- {block.data.heading ? <p className="mb-3 text-sm font-semibold uppercase tracking-[0.18em] opacity-75">{block.data.heading}</p> : null}
92
- <h2 className="text-[clamp(28px,3.4vw,40px)] font-bold leading-tight">{e.title}</h2>
93
- <p className="mt-3 text-lg font-semibold text-highlight">{formatEventDate(e)}</p>
94
- {e.location.name ? <p className="mt-1 opacity-80">{e.location.name}</p> : null}
95
- {e.summary ? <p className="mt-4 text-lg opacity-85">{e.summary}</p> : null}
96
- <Link href={e.path} className="mt-6 inline-block rounded-lg bg-accent px-6 py-3.5 font-semibold text-accent-ink hover:opacity-90">
97
- Event details
98
- </Link>
99
- </div>
100
- {e.image ? <Img image={e.image} className="aspect-[4/3] w-full rounded-xl object-cover" /> : null}
101
- </div>
102
- </Section>
103
- );
104
- }
@@ -1,120 +0,0 @@
1
- import Link from "next/link";
2
- import type { BlockOf, Fundraiser } from "@the-smithy/hearth";
3
- import { DonateForm } from "@/components/DonateForm";
4
- import { Img, Section, SectionHeading } from "@/components/ui";
5
-
6
- /** Cents → "$1,250". */
7
- export const money = (cents: number) => (cents / 100).toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: cents % 100 ? 2 : 0 });
8
-
9
- /** Raised so far, against the goal when there is one. */
10
- export function Progress({ fundraiser, large }: { fundraiser: Fundraiser; large?: boolean }) {
11
- const { raisedCents, goalCents, donorCount } = fundraiser;
12
- const pct = goalCents ? Math.min(100, Math.round((raisedCents / goalCents) * 100)) : null;
13
- return (
14
- <div className="flex flex-col gap-2">
15
- <p className={large ? "text-2xl" : "text-base"}>
16
- <span className="font-bold">{money(raisedCents)}</span>
17
- <span className="opacity-75">{goalCents ? ` raised of ${money(goalCents)}` : " raised"}</span>
18
- </p>
19
- {pct !== null ? (
20
- <div className="h-2.5 overflow-hidden rounded-full bg-line" role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100} aria-label="Progress toward the goal">
21
- <div className="h-full rounded-full bg-accent" style={{ width: `${pct}%` }} />
22
- </div>
23
- ) : null}
24
- {donorCount ? (
25
- <p className="text-sm opacity-75">
26
- {donorCount} online donation{donorCount === 1 ? "" : "s"}
27
- </p>
28
- ) : null}
29
- </div>
30
- );
31
- }
32
-
33
- export function FundraiserCard({ fundraiser }: { fundraiser: Fundraiser }) {
34
- return (
35
- <li className="relative flex flex-col overflow-hidden rounded-2xl bg-surface ring-1 ring-line">
36
- {fundraiser.image ? <Img image={fundraiser.image} className="aspect-[16/9] w-full object-cover" /> : null}
37
- <div className="flex flex-1 flex-col gap-3 p-6">
38
- {fundraiser.status === "completed" ? <p className="w-fit rounded bg-muted px-2 py-0.5 text-sm font-semibold">Completed</p> : null}
39
- <h3 className="text-xl font-semibold">
40
- <Link href={fundraiser.path} className="after:absolute after:inset-0 hover:underline">
41
- {fundraiser.title}
42
- </Link>
43
- </h3>
44
- {fundraiser.summary ? <p className="leading-relaxed opacity-85">{fundraiser.summary}</p> : null}
45
- <div className="mt-auto pt-2">
46
- <Progress fundraiser={fundraiser} />
47
- </div>
48
- </div>
49
- </li>
50
- );
51
- }
52
-
53
- export function FundraiserList({ fundraisers }: { fundraisers: Fundraiser[] }) {
54
- if (!fundraisers.length) return <p className="text-lg opacity-75">No fundraisers right now. Check back soon.</p>;
55
- return (
56
- <ul className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
57
- {fundraisers.map((f) => (
58
- <FundraiserCard key={f.slug} fundraiser={f} />
59
- ))}
60
- </ul>
61
- );
62
- }
63
-
64
- export function FundraiserGrid({ block }: { block: BlockOf<"fundraiserGrid"> }) {
65
- const d = block.data;
66
- return (
67
- <Section>
68
- <SectionHeading eyebrow={d.eyebrow} heading={d.heading} intro={d.intro} />
69
- <FundraiserList fundraisers={d.fundraisers} />
70
- {d.fundraisers.length ? (
71
- <p className="mt-8">
72
- <Link href="/fundraisers" className="font-semibold underline underline-offset-4">
73
- All fundraisers →
74
- </Link>
75
- </p>
76
- ) : null}
77
- </Section>
78
- );
79
- }
80
-
81
- export function FundraiserSpotlight({ block }: { block: BlockOf<"fundraiserSpotlight"> }) {
82
- const f = block.data.fundraiser;
83
- if (!f) return null;
84
- return (
85
- <Section tone="muted">
86
- <div className="grid items-center gap-10 md:grid-cols-2">
87
- <div className="flex flex-col gap-4">
88
- {block.data.heading ? <p className="text-sm font-semibold uppercase tracking-[0.18em] opacity-75">{block.data.heading}</p> : null}
89
- <h2 className="text-[clamp(28px,3.4vw,40px)] font-bold leading-tight">{f.title}</h2>
90
- {f.summary ? <p className="text-lg opacity-85">{f.summary}</p> : null}
91
- <Progress fundraiser={f} large />
92
- <p className="flex flex-wrap gap-3 pt-2">
93
- {f.status === "active" ? (
94
- <Link href={`${f.path}#donate`} className="rounded-lg bg-accent px-6 py-3.5 font-semibold text-accent-ink hover:opacity-90">
95
- Donate
96
- </Link>
97
- ) : null}
98
- <Link href={f.path} className="rounded-lg px-6 py-3.5 font-semibold ring-1 ring-line hover:bg-surface">
99
- Learn more
100
- </Link>
101
- </p>
102
- </div>
103
- {f.image ? <Img image={f.image} className="aspect-[4/3] w-full rounded-xl object-cover" /> : null}
104
- </div>
105
- </Section>
106
- );
107
- }
108
-
109
- export function Donate({ block }: { block: BlockOf<"donate"> }) {
110
- const d = block.data;
111
- return (
112
- <Section>
113
- <div className="max-w-2xl">
114
- <SectionHeading heading={d.heading} intro={d.intro} />
115
- {d.fundraiser ? <p className="mb-6 font-semibold">Supporting: {d.fundraiser.title}</p> : null}
116
- <DonateForm presetsCents={d.presetsCents} minCents={d.minCents} maxCents={d.maxCents} fundraiser={d.fundraiser?.slug} submitLabel={d.submitLabel} />
117
- </div>
118
- </Section>
119
- );
120
- }
@@ -1,52 +0,0 @@
1
- import type { BlockOf } from "@the-smithy/hearth";
2
- import { Section, SectionHeading } from "@/components/ui";
3
-
4
- export function Location({ block }: { block: BlockOf<"location"> }) {
5
- const d = block.data;
6
- const query = encodeURIComponent(`${d.name}, ${d.address}`);
7
- return (
8
- <Section>
9
- <SectionHeading eyebrow={d.eyebrow} heading={d.heading} />
10
- <div className="grid gap-10 md:grid-cols-2">
11
- <div className="flex flex-col gap-4 text-lg">
12
- <h3 className="text-xl font-semibold">{d.name}</h3>
13
- <address className="whitespace-pre-line not-italic">{d.address}</address>
14
- {d.hours ? <p className="whitespace-pre-line">{d.hours}</p> : null}
15
- {d.notes ? <p className="opacity-80">{d.notes}</p> : null}
16
- <a href={`https://www.google.com/maps/dir/?api=1&destination=${query}`} rel="noopener" className="font-semibold text-highlight underline underline-offset-4">
17
- Get directions
18
- </a>
19
- </div>
20
- {d.showMap ? (
21
- <iframe
22
- title={`Map of ${d.name}`}
23
- src={`https://www.google.com/maps?q=${query}&output=embed`}
24
- loading="lazy"
25
- referrerPolicy="no-referrer-when-downgrade"
26
- className="aspect-[4/3] w-full rounded-xl border-0 bg-muted"
27
- />
28
- ) : null}
29
- </div>
30
- </Section>
31
- );
32
- }
33
-
34
- export function Schedule({ block }: { block: BlockOf<"schedule"> }) {
35
- const d = block.data;
36
- return (
37
- <Section>
38
- <SectionHeading eyebrow={d.eyebrow} heading={d.heading} intro={d.intro} />
39
- <ol className="max-w-3xl divide-y divide-line border-y border-line">
40
- {d.items.map((item, i) => (
41
- <li key={i} className="grid gap-1 py-5 sm:grid-cols-[10rem_1fr] sm:gap-6">
42
- <p className="font-semibold text-highlight">{item.time}</p>
43
- <div>
44
- <p className="text-lg font-semibold">{item.title}</p>
45
- {item.details ? <p className="mt-1 opacity-80">{item.details}</p> : null}
46
- </div>
47
- </li>
48
- ))}
49
- </ol>
50
- </Section>
51
- );
52
- }