@the-smithy/create-hearth-site 0.20.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 (85) hide show
  1. package/bin/create-hearth-site.js +5 -0
  2. package/package.json +2 -2
  3. package/template/AGENTS.md +10 -4
  4. package/template/CLAUDE.md +77 -19
  5. package/template/README.md +51 -23
  6. package/template/SITE.md +17 -0
  7. package/template/package.json +2 -1
  8. package/template/scripts/hearth.mjs +157 -13
  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 -77
  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 +1 -1
  28. package/template/src/{components → hearth}/blocks/OpenMap.tsx +11 -9
  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 +14 -10
  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 +5 -4
  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 -107
  73. package/template/src/components/RegistrationForm.tsx +0 -103
  74. package/template/src/components/SiteHeader.tsx +0 -37
  75. package/template/src/components/VolunteerForm.tsx +0 -143
  76. package/template/src/components/blocks/Form.tsx +0 -92
  77. package/template/src/components/blocks/collections.tsx +0 -75
  78. package/template/src/components/blocks/content.tsx +0 -258
  79. package/template/src/components/blocks/custom.tsx +0 -20
  80. package/template/src/components/blocks/events.tsx +0 -138
  81. package/template/src/components/blocks/giving.tsx +0 -120
  82. package/template/src/components/blocks/info.tsx +0 -83
  83. package/template/src/components/ui.tsx +0 -137
  84. /package/template/src/{lib/hearth.ts → hearth/client.ts} +0 -0
  85. /package/template/src/{lib → hearth}/site.ts +0 -0
@@ -1,143 +0,0 @@
1
- "use client";
2
-
3
- import { useActionState } from "react";
4
- import { useFormStatus } from "react-dom";
5
- import type { EventVolunteering } from "@the-smithy/hearth";
6
- import { volunteer, type VolunteerState } from "@/app/actions";
7
-
8
- const input = "block w-full rounded-lg border border-field bg-surface px-4 py-3 text-base text-ink focus:border-accent focus:outline-none";
9
-
10
- /** A shift's day and time in the event's time zone: "Sat, Nov 14 · 8:00 AM – 11:00 AM". */
11
- function shiftTime(s: { startsAt: string; endsAt: string }, timeZone: string) {
12
- const day = (d: string) => new Date(d).toLocaleDateString("en-US", { timeZone, weekday: "short", month: "short", day: "numeric" });
13
- const time = (d: string) => new Date(d).toLocaleTimeString("en-US", { timeZone, hour: "numeric", minute: "2-digit" });
14
- return day(s.startsAt) === day(s.endsAt) ? `${day(s.startsAt)} · ${time(s.startsAt)} – ${time(s.endsAt)}` : `${day(s.startsAt)} ${time(s.startsAt)} – ${day(s.endsAt)} ${time(s.endsAt)}`;
15
- }
16
-
17
- function Submit({ label }: { label: string }) {
18
- const { pending } = useFormStatus();
19
- return (
20
- <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">
21
- {pending ? "Signing up…" : label}
22
- </button>
23
- );
24
- }
25
-
26
- /**
27
- * Volunteer sign-up for an event: name and email, shifts to choose from (when the event lists them), then the
28
- * event's own questions. Sign-ups land under the event's Volunteers in Hearth.
29
- */
30
- export function VolunteerForm({ slug, timezone, volunteering }: { slug: string; timezone: string; volunteering: EventVolunteering }) {
31
- const [state, action] = useActionState<VolunteerState | null, FormData>(volunteer.bind(null, slug), null);
32
- const errors = state && !state.ok ? state.fieldErrors : {};
33
- const values = (state && !state.ok ? state.values : undefined) ?? {};
34
- const chosen = (state && !state.ok ? state.shifts : undefined) ?? [];
35
- const many = volunteering.pickShifts === "many";
36
-
37
- if (state?.ok) {
38
- return (
39
- <p role="status" className="rounded-xl bg-accent p-6 text-lg font-semibold text-accent-ink">
40
- {state.message}
41
- </p>
42
- );
43
- }
44
-
45
- return (
46
- <form action={action} className="space-y-6" noValidate>
47
- {state && !state.ok ? (
48
- <p role="alert" className="rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-danger">
49
- {state.message}
50
- </p>
51
- ) : null}
52
- {/* Honeypot: hidden from people, filled in by bots. */}
53
- <div aria-hidden className="absolute -left-[9999px]">
54
- <label>
55
- Leave this empty
56
- <input name="_gotcha" tabIndex={-1} autoComplete="off" />
57
- </label>
58
- </div>
59
- <div className="grid gap-5 sm:grid-cols-2">
60
- <label className="block">
61
- <span className="mb-1.5 block font-semibold">Name</span>
62
- <input name="name" required autoComplete="name" defaultValue={values.name} aria-invalid={errors.name ? true : undefined} className={input} />
63
- {errors.name ? <span className="mt-1 block text-danger">{errors.name}</span> : null}
64
- </label>
65
- <label className="block">
66
- <span className="mb-1.5 block font-semibold">Email</span>
67
- <input name="email" type="email" required autoComplete="email" defaultValue={values.email} aria-invalid={errors.email ? true : undefined} className={input} />
68
- {errors.email ? <span className="mt-1 block text-danger">{errors.email}</span> : null}
69
- </label>
70
- </div>
71
- {volunteering.pickShifts !== "none" ? (
72
- <fieldset>
73
- <legend className="mb-3 font-semibold">{many ? "Shifts you can do" : "Choose a shift"}</legend>
74
- <div className="space-y-3">
75
- {volunteering.shifts.map((s) => (
76
- <label key={s.id} className={`flex cursor-pointer items-start gap-3 rounded-lg p-4 ring-1 ring-line has-checked:ring-2 has-checked:ring-accent ${s.full ? "cursor-not-allowed opacity-50" : ""}`}>
77
- <input
78
- type={many ? "checkbox" : "radio"}
79
- name="shift"
80
- value={s.id}
81
- disabled={s.full}
82
- defaultChecked={chosen.includes(s.id)}
83
- className="mt-1 size-5 accent-accent"
84
- />
85
- <span className="flex-1">
86
- <span className="flex flex-wrap justify-between gap-2 font-semibold">
87
- <span>{s.name}</span>
88
- <span className="font-normal opacity-80">{s.full ? "Full" : s.remaining !== null && s.remaining <= 5 ? `${s.remaining} spot${s.remaining === 1 ? "" : "s"} left` : null}</span>
89
- </span>
90
- <span className="mt-0.5 block opacity-80">{shiftTime(s, timezone)}</span>
91
- {s.description ? <span className="mt-1 block opacity-80">{s.description}</span> : null}
92
- </span>
93
- </label>
94
- ))}
95
- </div>
96
- {errors.shifts ? <p className="mt-2 text-danger">{errors.shifts}</p> : null}
97
- </fieldset>
98
- ) : null}
99
- {volunteering.fields.map((f) => {
100
- const id = `volunteer-${f.name}`;
101
- const error = errors[f.name];
102
- const common = { id, name: f.name, required: f.required, "aria-invalid": error ? true : undefined, "aria-describedby": error ? `${id}-error` : undefined };
103
- return (
104
- <div key={f.name}>
105
- {f.type === "checkbox" ? (
106
- <label className="flex items-center gap-3">
107
- <input type="checkbox" {...common} defaultChecked={values[f.name] === "on"} className="size-5 accent-accent" />
108
- {f.label}
109
- </label>
110
- ) : (
111
- <>
112
- <label htmlFor={id} className="mb-1.5 block font-semibold">
113
- {f.label}
114
- {f.required ? null : <span className="ml-1 font-normal opacity-70">(optional)</span>}
115
- </label>
116
- {f.type === "textarea" ? (
117
- <textarea {...common} rows={4} defaultValue={values[f.name]} placeholder={f.placeholder} className={input} />
118
- ) : f.type === "select" ? (
119
- <select {...common} defaultValue={values[f.name] ?? ""} className={input}>
120
- <option value="" disabled>
121
- {f.placeholder || "Choose one…"}
122
- </option>
123
- {(f.options ?? []).map((o) => (
124
- <option key={o}>{o}</option>
125
- ))}
126
- </select>
127
- ) : (
128
- <input {...common} type={f.type} defaultValue={values[f.name]} placeholder={f.placeholder} autoComplete={f.type === "tel" ? "tel" : undefined} className={input} />
129
- )}
130
- </>
131
- )}
132
- {error ? (
133
- <p id={`${id}-error`} className="mt-1.5 text-sm text-danger">
134
- {error}
135
- </p>
136
- ) : null}
137
- </div>
138
- );
139
- })}
140
- <Submit label={volunteering.submitLabel} />
141
- </form>
142
- );
143
- }
@@ -1,92 +0,0 @@
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-field 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
- }
@@ -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} crop={16 / 10} 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,258 +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} crop 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} crop={d.imageFit === "contain" ? undefined : 4 / 3} 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} crop={16 / 10} 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
- crop={16 / 10}
116
- className={`aspect-[16/10] w-full rounded-xl object-cover object-top ${grid ? "mb-2" : i % 2 ? "md:order-2" : ""}`}
117
- />
118
- ) : null}
119
- <div className="flex flex-col gap-3">
120
- <div className="flex flex-wrap items-center gap-3">
121
- <h3 className="text-xl font-semibold">{p.name}</h3>
122
- {p.status ? <span className="rounded-full bg-accent px-3 py-0.5 text-sm font-semibold text-accent-ink">{p.status}</span> : null}
123
- </div>
124
- {p.role ? <p className="text-sm font-semibold uppercase tracking-[0.12em] opacity-75">{p.role}</p> : null}
125
- {p.description ? <Markdown className="leading-relaxed opacity-80">{p.description}</Markdown> : null}
126
- {p.stack.length ? (
127
- <ul className="flex flex-wrap gap-2">
128
- {p.stack.map((s, j) => (
129
- <li key={j} className={`rounded-md px-2.5 py-1 text-sm ${dark ? "bg-white/10" : "bg-surface ring-1 ring-line"}`}>
130
- {s}
131
- </li>
132
- ))}
133
- </ul>
134
- ) : null}
135
- {p.url ? (
136
- <SmartLink href={p.url} className="mt-auto pt-2 font-semibold underline underline-offset-4">
137
- Visit {p.name} →
138
- </SmartLink>
139
- ) : null}
140
- </div>
141
- </li>
142
- ))}
143
- </ul>
144
- </Section>
145
- );
146
- }
147
-
148
- export function Pricing({ block }: { block: BlockOf<"pricing"> }) {
149
- const d = block.data;
150
- const dark = isDark(d.tone);
151
- return (
152
- <Section tone={d.tone}>
153
- <SectionHeading eyebrow={d.eyebrow} heading={d.heading} intro={d.intro} dark={dark} />
154
- <ul className={`grid gap-5 sm:grid-cols-2 ${d.plans.length >= 4 ? "lg:grid-cols-4" : "lg:grid-cols-3"}`}>
155
- {d.plans.map((p, i) => (
156
- <li
157
- key={i}
158
- 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"}`}
159
- >
160
- {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}
161
- <h3 className="text-xl font-semibold">{p.name}</h3>
162
- <p>
163
- <span className="text-4xl font-bold tracking-tight">{p.price}</span>
164
- {p.period ? <span className="ml-1 opacity-75">{p.period}</span> : null}
165
- </p>
166
- {p.description ? <p className="leading-relaxed opacity-80">{p.description}</p> : null}
167
- {p.features.length ? (
168
- <ul className="grid gap-2 opacity-90">
169
- {p.features.map((f, j) => (
170
- <li key={j} className="flex gap-2.5">
171
- <span aria-hidden className={dark ? "" : "text-highlight"}>
172
- ✓
173
- </span>
174
- {f}
175
- </li>
176
- ))}
177
- </ul>
178
- ) : null}
179
- {p.cta ? (
180
- <div className="mt-auto pt-2">
181
- <Button link={p.cta} variant={p.highlight ? undefined : "secondary"} dark={dark} />
182
- </div>
183
- ) : null}
184
- </li>
185
- ))}
186
- </ul>
187
- {d.footnote ? <Markdown className="mt-8 max-w-3xl text-sm opacity-80">{d.footnote}</Markdown> : null}
188
- </Section>
189
- );
190
- }
191
-
192
- export function CallToAction({ block }: { block: BlockOf<"cta"> }) {
193
- const d = block.data;
194
- const content = (dark: boolean) => (
195
- <div className="flex flex-wrap items-center justify-between gap-8">
196
- <div className="max-w-2xl">
197
- <h2 className="text-[clamp(28px,3.4vw,40px)] font-bold leading-tight">
198
- <Emphasis text={d.heading} className={dark ? "underline decoration-2 underline-offset-4" : "text-highlight"} />
199
- </h2>
200
- {d.body ? <p className="mt-3 text-lg opacity-85">{d.body}</p> : null}
201
- </div>
202
- <div className="flex flex-wrap gap-3">
203
- <Button link={d.primary} dark={dark} />
204
- <Button link={d.secondary} variant="secondary" />
205
- </div>
206
- </div>
207
- );
208
- if (!d.image) return <Section tone={d.tone}>{content(isDark(d.tone))}</Section>;
209
- return (
210
- <section className="relative overflow-hidden text-white">
211
- <Img image={d.image} crop className="absolute inset-0 size-full object-cover" />
212
- <div className="relative bg-black/55">
213
- <div className="mx-auto max-w-site px-4 py-20 sm:px-6 sm:py-24">{content(true)}</div>
214
- </div>
215
- </section>
216
- );
217
- }
218
-
219
- export function Faq({ block }: { block: BlockOf<"faq"> }) {
220
- return (
221
- <Section>
222
- <SectionHeading heading={block.data.heading} />
223
- <div className="max-w-3xl divide-y divide-line border-y border-line">
224
- {block.data.items.map((q, i) => (
225
- <details key={i} className="group">
226
- <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">
227
- {q.question}
228
- <span aria-hidden className="text-highlight transition group-open:rotate-45">
229
- +
230
- </span>
231
- </summary>
232
- <Markdown className="pb-5 opacity-85">{q.answer}</Markdown>
233
- </details>
234
- ))}
235
- </div>
236
- </Section>
237
- );
238
- }
239
-
240
- export function Quote({ block }: { block: BlockOf<"quote"> }) {
241
- const d = block.data;
242
- return (
243
- <Section tone={d.tone}>
244
- <figure className="flex max-w-3xl flex-col gap-6">
245
- <blockquote className="text-2xl font-semibold leading-snug sm:text-3xl">“{d.quote}”</blockquote>
246
- {d.name ? (
247
- <figcaption className="flex items-center gap-4">
248
- {d.image ? <Img image={d.image} crop={1} className="size-14 rounded-full object-cover" /> : null}
249
- <span>
250
- <span className="block font-semibold">{d.name}</span>
251
- {d.role ? <span className="block opacity-75">{d.role}</span> : null}
252
- </span>
253
- </figcaption>
254
- ) : null}
255
- </figure>
256
- </Section>
257
- );
258
- }
@@ -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
- }