@the-smithy/create-hearth-site 0.0.0-stage → 0.13.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 (50) hide show
  1. package/LICENSE +17 -0
  2. package/README.md +36 -2
  3. package/bin/create-hearth-site.js +120 -0
  4. package/package.json +34 -3
  5. package/template/.env.example +8 -0
  6. package/template/CLAUDE.md +37 -0
  7. package/template/README.md +68 -0
  8. package/template/_gitignore +11 -0
  9. package/template/_pnpm-workspace.yaml +2 -0
  10. package/template/netlify.toml +6 -0
  11. package/template/next.config.ts +27 -0
  12. package/template/package.json +32 -0
  13. package/template/postcss.config.mjs +3 -0
  14. package/template/src/app/[[...slug]]/page.tsx +54 -0
  15. package/template/src/app/actions.ts +43 -0
  16. package/template/src/app/api/preview/exit/route.ts +4 -0
  17. package/template/src/app/api/preview/route.ts +8 -0
  18. package/template/src/app/api/revalidate/route.ts +8 -0
  19. package/template/src/app/donate/page.tsx +27 -0
  20. package/template/src/app/donate/thank-you/page.tsx +22 -0
  21. package/template/src/app/events/[slug]/page.tsx +25 -0
  22. package/template/src/app/events/page.tsx +21 -0
  23. package/template/src/app/fundraisers/[slug]/page.tsx +55 -0
  24. package/template/src/app/fundraisers/page.tsx +29 -0
  25. package/template/src/app/globals.css +75 -0
  26. package/template/src/app/layout.tsx +39 -0
  27. package/template/src/app/not-found.tsx +14 -0
  28. package/template/src/app/registered/page.tsx +22 -0
  29. package/template/src/app/robots.ts +10 -0
  30. package/template/src/app/sitemap.ts +4 -0
  31. package/template/src/components/DonateForm.tsx +97 -0
  32. package/template/src/components/EventView.tsx +48 -0
  33. package/template/src/components/Registration.tsx +42 -0
  34. package/template/src/components/RegistrationForm.tsx +103 -0
  35. package/template/src/components/SiteFooter.tsx +61 -0
  36. package/template/src/components/SiteHeader.tsx +31 -0
  37. package/template/src/components/blocks/EmberFormBlock.tsx +38 -0
  38. package/template/src/components/blocks/Form.tsx +92 -0
  39. package/template/src/components/blocks/collections.tsx +75 -0
  40. package/template/src/components/blocks/content.tsx +257 -0
  41. package/template/src/components/blocks/custom.tsx +20 -0
  42. package/template/src/components/blocks/events.tsx +92 -0
  43. package/template/src/components/blocks/giving.tsx +120 -0
  44. package/template/src/components/blocks/index.tsx +65 -0
  45. package/template/src/components/blocks/info.tsx +52 -0
  46. package/template/src/components/blocks/media.tsx +107 -0
  47. package/template/src/components/ui.tsx +105 -0
  48. package/template/src/lib/hearth.ts +12 -0
  49. package/template/src/lib/site.ts +9 -0
  50. package/template/tsconfig.json +21 -0
@@ -0,0 +1,38 @@
1
+ "use client";
2
+
3
+ import { EmberForm } from "@the-smithy/ember/react";
4
+ import type { ReactNode } from "react";
5
+
6
+ /**
7
+ * A Form block set to an Ember form (`emberFormId`): rendered by the Ember SDK, so submissions become leads in
8
+ * Ember, The Smithy's CRM. The visitor's browser submits it straight to Ember. It takes the site's colors through
9
+ * the CSS variables in globals.css (they reach into the form's Shadow DOM); restyle with `appearance`.
10
+ * `fallback` (the site's own form) shows until the Ember form loads, and stays if Ember can't be reached.
11
+ */
12
+ export function EmberFormBlock({ formId, fallback }: { formId: string; fallback: ReactNode }) {
13
+ return (
14
+ <EmberForm
15
+ formId={formId}
16
+ appearance={{
17
+ theme: {
18
+ accent: "var(--color-accent)",
19
+ button: "var(--color-accent)",
20
+ buttonText: "var(--color-accent-ink)",
21
+ text: "var(--color-ink)",
22
+ label: "var(--color-ink)",
23
+ muted: "var(--color-muted-ink)",
24
+ error: "var(--color-danger)",
25
+ inputBackground: "var(--color-surface)",
26
+ border: "var(--color-line)",
27
+ successBackground: "var(--color-muted)",
28
+ radius: "8px",
29
+ maxWidth: "none",
30
+ font: "var(--font-sans)",
31
+ fontSize: "16px",
32
+ gap: "20px",
33
+ },
34
+ }}
35
+ fallback={fallback}
36
+ />
37
+ );
38
+ }
@@ -0,0 +1,92 @@
1
+ "use client";
2
+
3
+ import type { BlockOf } from "@the-smithy/hearth";
4
+ import { useActionState } from "react";
5
+ import { useFormStatus } from "react-dom";
6
+ import { submitHearthForm, type FormState } from "@/app/actions";
7
+
8
+ type FormData_ = BlockOf<"form">["data"];
9
+
10
+ const input = "block w-full rounded-lg border border-line bg-surface px-4 py-3 text-base text-ink focus:border-accent focus:outline-none";
11
+
12
+ function Submit({ label }: { label: string }) {
13
+ const { pending } = useFormStatus();
14
+ return (
15
+ <button type="submit" disabled={pending} className="rounded-lg bg-accent px-6 py-3.5 font-semibold text-accent-ink hover:opacity-90 disabled:opacity-60">
16
+ {pending ? "Sending…" : label}
17
+ </button>
18
+ );
19
+ }
20
+
21
+ /** The fields of a Hearth `form` block, posted through a server action. */
22
+ export function HearthForm({ form }: { form: FormData_ }) {
23
+ const [state, action] = useActionState<FormState, FormData>(submitHearthForm.bind(null, form.formKey), {});
24
+
25
+ if (state.ok) {
26
+ return (
27
+ <p role="status" className="rounded-xl bg-accent p-6 text-lg font-semibold text-accent-ink">
28
+ {state.message || form.successMessage}
29
+ </p>
30
+ );
31
+ }
32
+
33
+ return (
34
+ <form action={action} className="space-y-5" noValidate>
35
+ {state.message ? (
36
+ <p role="alert" className="rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-danger">
37
+ {state.message}
38
+ </p>
39
+ ) : null}
40
+ {/* Honeypot: hidden from people, filled in by bots. */}
41
+ <div aria-hidden className="absolute -left-[9999px]">
42
+ <label>
43
+ Leave this empty
44
+ <input name="_gotcha" tabIndex={-1} autoComplete="off" />
45
+ </label>
46
+ </div>
47
+ {form.fields.map((f) => {
48
+ const id = `${form.formKey}-${f.name}`;
49
+ const error = state.fieldErrors?.[f.name];
50
+ const value = state.values?.[f.name];
51
+ const common = { id, name: f.name, required: f.required, "aria-invalid": error ? true : undefined, "aria-describedby": error ? `${id}-error` : undefined };
52
+ return (
53
+ <div key={f.name}>
54
+ {f.type === "checkbox" ? (
55
+ <label className="flex items-center gap-3">
56
+ <input type="checkbox" {...common} defaultChecked={value === "on"} className="size-5 accent-accent" />
57
+ {f.label}
58
+ </label>
59
+ ) : (
60
+ <>
61
+ <label htmlFor={id} className="mb-1.5 block font-semibold">
62
+ {f.label}
63
+ {f.required ? null : <span className="ml-1 font-normal opacity-70">(optional)</span>}
64
+ </label>
65
+ {f.type === "textarea" ? (
66
+ <textarea {...common} rows={5} defaultValue={value} placeholder={f.placeholder} className={input} />
67
+ ) : f.type === "select" ? (
68
+ <select {...common} defaultValue={value ?? ""} className={input}>
69
+ <option value="" disabled>
70
+ {f.placeholder || "Choose one…"}
71
+ </option>
72
+ {(f.options ?? []).map((o) => (
73
+ <option key={o}>{o}</option>
74
+ ))}
75
+ </select>
76
+ ) : (
77
+ <input {...common} type={f.type} defaultValue={value} placeholder={f.placeholder} autoComplete={f.type === "email" ? "email" : f.type === "tel" ? "tel" : f.name === "name" ? "name" : undefined} className={input} />
78
+ )}
79
+ </>
80
+ )}
81
+ {error ? (
82
+ <p id={`${id}-error`} className="mt-1.5 text-sm text-danger">
83
+ {error}
84
+ </p>
85
+ ) : null}
86
+ </div>
87
+ );
88
+ })}
89
+ <Submit label={form.submitLabel} />
90
+ </form>
91
+ );
92
+ }
@@ -0,0 +1,75 @@
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
+ }
@@ -0,0 +1,257 @@
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} 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} 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
+ }
@@ -0,0 +1,20 @@
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
+ }
@@ -0,0 +1,92 @@
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
+ export function EventSpotlight({ block }: { block: BlockOf<"eventSpotlight"> }) {
73
+ const e = block.data.event;
74
+ if (!e) return null;
75
+ return (
76
+ <Section tone="muted">
77
+ <div className="grid items-center gap-10 md:grid-cols-2">
78
+ <div>
79
+ {block.data.heading ? <p className="mb-3 text-sm font-semibold uppercase tracking-[0.18em] opacity-75">{block.data.heading}</p> : null}
80
+ <h2 className="text-[clamp(28px,3.4vw,40px)] font-bold leading-tight">{e.title}</h2>
81
+ <p className="mt-3 text-lg font-semibold text-highlight">{formatEventDate(e)}</p>
82
+ {e.location.name ? <p className="mt-1 opacity-80">{e.location.name}</p> : null}
83
+ {e.summary ? <p className="mt-4 text-lg opacity-85">{e.summary}</p> : null}
84
+ <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">
85
+ Event details
86
+ </Link>
87
+ </div>
88
+ {e.image ? <Img image={e.image} className="aspect-[4/3] w-full rounded-xl object-cover" /> : null}
89
+ </div>
90
+ </Section>
91
+ );
92
+ }