@the-smithy/create-hearth-site 0.20.0 → 0.22.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.
- package/bin/create-hearth-site.js +5 -0
- package/package.json +2 -2
- package/template/AGENTS.md +10 -4
- package/template/CLAUDE.md +77 -19
- package/template/README.md +55 -25
- package/template/SITE.md +17 -0
- package/template/package.json +2 -1
- package/template/scripts/hearth.mjs +157 -13
- package/template/src/app/[[...slug]]/page.tsx +2 -54
- package/template/src/app/api/preview/exit/route.ts +1 -1
- package/template/src/app/api/preview/route.ts +1 -1
- package/template/src/app/api/revalidate/route.ts +1 -1
- package/template/src/app/apple-icon.tsx +1 -1
- package/template/src/app/donate/page.tsx +2 -35
- package/template/src/app/donate/thank-you/page.tsx +2 -22
- package/template/src/app/events/[slug]/page.tsx +2 -26
- package/template/src/app/events/page.tsx +2 -29
- package/template/src/app/fundraisers/[slug]/page.tsx +2 -55
- package/template/src/app/fundraisers/page.tsx +2 -37
- package/template/src/app/globals.css +3 -77
- package/template/src/app/icon.tsx +1 -1
- package/template/src/app/layout.tsx +2 -36
- package/template/src/app/llms.txt/route.ts +7 -0
- package/template/src/app/not-found.tsx +2 -14
- package/template/src/app/registered/page.tsx +2 -22
- package/template/src/app/robots.ts +1 -1
- package/template/src/app/sitemap.ts +2 -2
- package/template/src/{app → hearth}/actions.ts +1 -1
- package/template/src/{components → hearth}/blocks/OpenMap.tsx +11 -9
- package/template/src/hearth/blocks/collections.tsx +74 -0
- package/template/src/hearth/blocks/content.tsx +252 -0
- package/template/src/hearth/blocks/custom.tsx +12 -0
- package/template/src/hearth/blocks/events.tsx +130 -0
- package/template/src/hearth/blocks/giving.tsx +123 -0
- package/template/src/{components → hearth}/blocks/index.tsx +14 -10
- package/template/src/hearth/blocks/info.tsx +84 -0
- package/template/src/{components → hearth}/blocks/media.tsx +30 -27
- package/template/src/hearth/components/EventView.tsx +120 -0
- package/template/src/{components → hearth/components}/Registration.tsx +14 -14
- package/template/src/hearth/components/Share.tsx +102 -0
- package/template/src/hearth/format.ts +31 -0
- package/template/src/hearth/forms/ContactForm.tsx +27 -0
- package/template/src/hearth/forms/DonateForm.tsx +69 -0
- package/template/src/{components/blocks → hearth/forms}/EmberFormBlock.tsx +5 -4
- package/template/src/hearth/forms/RegistrationForm.tsx +68 -0
- package/template/src/hearth/forms/VolunteerForm.tsx +58 -0
- package/template/src/hearth/forms/fields.tsx +137 -0
- package/template/src/hearth/hearth.css +52 -0
- package/template/src/hearth/layout/MobileNav.tsx +49 -0
- package/template/src/hearth/layout/NavLink.tsx +16 -0
- package/template/src/hearth/layout/RootLayout.tsx +40 -0
- package/template/src/{components → hearth/layout}/SiteFooter.tsx +19 -14
- package/template/src/hearth/layout/SiteHeader.tsx +53 -0
- package/template/src/hearth/manifest.json +37 -0
- package/template/src/hearth/pages/donate.tsx +36 -0
- package/template/src/hearth/pages/event.tsx +26 -0
- package/template/src/hearth/pages/events.tsx +30 -0
- package/template/src/hearth/pages/fundraiser.tsx +77 -0
- package/template/src/hearth/pages/fundraisers.tsx +38 -0
- package/template/src/hearth/pages/not-found.tsx +15 -0
- package/template/src/hearth/pages/page.tsx +54 -0
- package/template/src/hearth/pages/registered.tsx +19 -0
- package/template/src/hearth/pages/thank-you.tsx +19 -0
- package/template/src/hearth/theme/defaults.ts +510 -0
- package/template/src/hearth/theme/index.ts +31 -0
- package/template/src/hearth/ui.tsx +144 -0
- package/template/src/theme/custom-blocks.tsx +11 -0
- package/template/src/theme/fonts.ts +9 -0
- package/template/src/theme/index.ts +13 -0
- package/template/src/theme/overrides.tsx +16 -0
- package/template/src/theme/theme.css +29 -0
- package/template/src/components/DonateForm.tsx +0 -104
- package/template/src/components/EventView.tsx +0 -107
- package/template/src/components/RegistrationForm.tsx +0 -103
- package/template/src/components/SiteHeader.tsx +0 -37
- package/template/src/components/VolunteerForm.tsx +0 -143
- package/template/src/components/blocks/Form.tsx +0 -92
- package/template/src/components/blocks/collections.tsx +0 -75
- package/template/src/components/blocks/content.tsx +0 -258
- package/template/src/components/blocks/custom.tsx +0 -20
- package/template/src/components/blocks/events.tsx +0 -138
- package/template/src/components/blocks/giving.tsx +0 -120
- package/template/src/components/blocks/info.tsx +0 -83
- package/template/src/components/ui.tsx +0 -137
- /package/template/src/{lib/hearth.ts → hearth/client.ts} +0 -0
- /package/template/src/{lib → hearth}/site.ts +0 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useActionState } from "react";
|
|
4
|
+
import type { EventVolunteering } from "@the-smithy/hearth";
|
|
5
|
+
import { theme } from "@/theme";
|
|
6
|
+
import { volunteer, type VolunteerState } from "../actions";
|
|
7
|
+
import { formatShift } from "../format";
|
|
8
|
+
import { Alert, FieldError, Honeypot, OptionCard, Question, Submit, Success, TextField } from "./fields";
|
|
9
|
+
|
|
10
|
+
const t = theme.form;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Volunteer sign-up for an event: name and email, shifts to choose from (when the event lists them), then the
|
|
14
|
+
* event's own questions. Sign-ups land under the event's Volunteers in Hearth.
|
|
15
|
+
*/
|
|
16
|
+
export function VolunteerForm({ slug, timezone, volunteering }: { slug: string; timezone: string; volunteering: EventVolunteering }) {
|
|
17
|
+
const [state, action] = useActionState<VolunteerState | null, FormData>(volunteer.bind(null, slug), null);
|
|
18
|
+
const errors = state && !state.ok ? state.fieldErrors : {};
|
|
19
|
+
const values = (state && !state.ok ? state.values : undefined) ?? {};
|
|
20
|
+
const chosen = (state && !state.ok ? state.shifts : undefined) ?? [];
|
|
21
|
+
const many = volunteering.pickShifts === "many";
|
|
22
|
+
|
|
23
|
+
if (state?.ok) return <Success>{state.message}</Success>;
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<form action={action} className={t.root} noValidate>
|
|
27
|
+
{state && !state.ok ? <Alert>{state.message}</Alert> : null}
|
|
28
|
+
<Honeypot />
|
|
29
|
+
<div className={t.row}>
|
|
30
|
+
<TextField label="Name" name="name" required autoComplete="name" defaultValue={values.name} error={errors.name} />
|
|
31
|
+
<TextField label="Email" name="email" type="email" required autoComplete="email" defaultValue={values.email} error={errors.email} />
|
|
32
|
+
</div>
|
|
33
|
+
{volunteering.pickShifts !== "none" ? (
|
|
34
|
+
<fieldset>
|
|
35
|
+
<legend className={t.legend}>{many ? "Shifts you can do" : "Choose a shift"}</legend>
|
|
36
|
+
<div className={t.options}>
|
|
37
|
+
{volunteering.shifts.map((s) => (
|
|
38
|
+
<OptionCard
|
|
39
|
+
key={s.id}
|
|
40
|
+
input={<input type={many ? "checkbox" : "radio"} name="shift" value={s.id} disabled={s.full} defaultChecked={chosen.includes(s.id)} className={t.optionInput} />}
|
|
41
|
+
title={s.name}
|
|
42
|
+
meta={s.full ? "Full" : s.remaining !== null && s.remaining <= 5 ? `${s.remaining} spot${s.remaining === 1 ? "" : "s"} left` : null}
|
|
43
|
+
>
|
|
44
|
+
<span className={t.optionDetail}>{formatShift(s, timezone)}</span>
|
|
45
|
+
{s.description ? <span className={t.optionDetail}>{s.description}</span> : null}
|
|
46
|
+
</OptionCard>
|
|
47
|
+
))}
|
|
48
|
+
</div>
|
|
49
|
+
<FieldError>{errors.shifts}</FieldError>
|
|
50
|
+
</fieldset>
|
|
51
|
+
) : null}
|
|
52
|
+
{volunteering.fields.map((f) => (
|
|
53
|
+
<Question key={f.name} field={f} idPrefix="volunteer" error={errors[f.name]} value={values[f.name]} />
|
|
54
|
+
))}
|
|
55
|
+
<Submit label={volunteering.submitLabel} pending="Signing up…" />
|
|
56
|
+
</form>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { BlockOf } from "@the-smithy/hearth";
|
|
4
|
+
import type { InputHTMLAttributes, ReactNode } from "react";
|
|
5
|
+
import { useFormStatus } from "react-dom";
|
|
6
|
+
import { theme } from "@/theme";
|
|
7
|
+
|
|
8
|
+
/* The pieces every form is built from (contact form, volunteer sign-up, registration, donations), styled by `form`. */
|
|
9
|
+
|
|
10
|
+
const t = theme.form;
|
|
11
|
+
|
|
12
|
+
/** A question defined in Hearth (a form field, an event's volunteer question). */
|
|
13
|
+
type FormField = BlockOf<"form">["data"]["fields"] extends (infer F)[] | undefined ? F : never;
|
|
14
|
+
|
|
15
|
+
export function Alert({ children }: { children: ReactNode }) {
|
|
16
|
+
return (
|
|
17
|
+
<p role="alert" className={t.alert}>
|
|
18
|
+
{children}
|
|
19
|
+
</p>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function Success({ children }: { children: ReactNode }) {
|
|
24
|
+
return (
|
|
25
|
+
<p role="status" className={t.success}>
|
|
26
|
+
{children}
|
|
27
|
+
</p>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Hidden from people, filled in by bots: Hearth drops submissions that fill it. */
|
|
32
|
+
export function Honeypot() {
|
|
33
|
+
return (
|
|
34
|
+
<div aria-hidden className="absolute -left-[9999px]">
|
|
35
|
+
<label>
|
|
36
|
+
Leave this empty
|
|
37
|
+
<input name="_gotcha" tabIndex={-1} autoComplete="off" />
|
|
38
|
+
</label>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function Submit({ label, pending: pendingLabel }: { label: string; pending: string }) {
|
|
44
|
+
const { pending } = useFormStatus();
|
|
45
|
+
return (
|
|
46
|
+
<button type="submit" disabled={pending} className={t.submit}>
|
|
47
|
+
{pending ? pendingLabel : label}
|
|
48
|
+
</button>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function FieldError({ id, children }: { id?: string; children?: string }) {
|
|
53
|
+
return children ? (
|
|
54
|
+
<span id={id} className={t.error}>
|
|
55
|
+
{children}
|
|
56
|
+
</span>
|
|
57
|
+
) : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A labelled text input (name, email, phone…), with its error under it. */
|
|
61
|
+
export function TextField({ label, optional, error, textarea, rows = 3, ...input }: { label: string; optional?: boolean; error?: string; textarea?: boolean; rows?: number } & InputHTMLAttributes<HTMLInputElement> & { maxLength?: number }) {
|
|
62
|
+
const describedBy = error && input.name ? `${input.name}-error` : undefined;
|
|
63
|
+
const common = { ...input, "aria-invalid": error ? true : undefined, "aria-describedby": describedBy, className: t.input };
|
|
64
|
+
return (
|
|
65
|
+
<label className={t.field}>
|
|
66
|
+
<span className={t.label}>
|
|
67
|
+
{label}
|
|
68
|
+
{optional ? <span className={t.optional}>(optional)</span> : null}
|
|
69
|
+
</span>
|
|
70
|
+
{textarea ? <textarea {...(common as object)} rows={rows} /> : <input {...common} />}
|
|
71
|
+
<FieldError id={describedBy}>{error}</FieldError>
|
|
72
|
+
</label>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function Checkbox({ name, label, defaultChecked }: { name: string; label: ReactNode; defaultChecked?: boolean }) {
|
|
77
|
+
return (
|
|
78
|
+
<label className={t.check}>
|
|
79
|
+
<input type="checkbox" name={name} defaultChecked={defaultChecked} className={t.checkbox} />
|
|
80
|
+
{label}
|
|
81
|
+
</label>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One of the questions defined in Hearth (a form's fields, an event's volunteer questions). */
|
|
86
|
+
export function Question({ field: f, idPrefix, error, value }: { field: FormField; idPrefix: string; error?: string; value?: string }) {
|
|
87
|
+
const id = `${idPrefix}-${f.name}`;
|
|
88
|
+
const common = { id, name: f.name, required: f.required, "aria-invalid": error ? true : undefined, "aria-describedby": error ? `${id}-error` : undefined };
|
|
89
|
+
return (
|
|
90
|
+
<div>
|
|
91
|
+
{f.type === "checkbox" ? (
|
|
92
|
+
<label className={t.check}>
|
|
93
|
+
<input type="checkbox" {...common} defaultChecked={value === "on"} className={t.checkbox} />
|
|
94
|
+
{f.label}
|
|
95
|
+
</label>
|
|
96
|
+
) : (
|
|
97
|
+
<>
|
|
98
|
+
<label htmlFor={id} className={t.label}>
|
|
99
|
+
{f.label}
|
|
100
|
+
{f.required ? null : <span className={t.optional}>(optional)</span>}
|
|
101
|
+
</label>
|
|
102
|
+
{f.type === "textarea" ? (
|
|
103
|
+
<textarea {...common} rows={5} defaultValue={value} placeholder={f.placeholder} className={t.input} />
|
|
104
|
+
) : f.type === "select" ? (
|
|
105
|
+
<select {...common} defaultValue={value ?? ""} className={t.input}>
|
|
106
|
+
<option value="" disabled>
|
|
107
|
+
{f.placeholder || "Choose one…"}
|
|
108
|
+
</option>
|
|
109
|
+
{(f.options ?? []).map((o) => (
|
|
110
|
+
<option key={o}>{o}</option>
|
|
111
|
+
))}
|
|
112
|
+
</select>
|
|
113
|
+
) : (
|
|
114
|
+
<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={t.input} />
|
|
115
|
+
)}
|
|
116
|
+
</>
|
|
117
|
+
)}
|
|
118
|
+
<FieldError id={`${id}-error`}>{error}</FieldError>
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A choice drawn as a card (a shift, a ticket): its input, title, a note on the right and details under it. */
|
|
124
|
+
export function OptionCard({ input, title, meta, children }: { input: ReactNode; title: ReactNode; meta?: ReactNode; children?: ReactNode }) {
|
|
125
|
+
return (
|
|
126
|
+
<label className={t.option}>
|
|
127
|
+
{input}
|
|
128
|
+
<span className={t.optionText}>
|
|
129
|
+
<span className={t.optionTitle}>
|
|
130
|
+
<span>{title}</span>
|
|
131
|
+
{meta ? <span className={t.optionMeta}>{meta}</span> : null}
|
|
132
|
+
</span>
|
|
133
|
+
{children}
|
|
134
|
+
</span>
|
|
135
|
+
</label>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Hearth's base CSS, shared by every site: the on-dark variant, rich text and focus rings. Don't edit it in a site:
|
|
3
|
+
* tokens and the site's own CSS go in src/theme/theme.css, which is loaded after this file and can override anything here.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/* On an inverse or accent ground: set by sections with a dark tone, the hero, image calls to action and the footer. */
|
|
7
|
+
@custom-variant on-dark (&:where([data-ground="dark"], [data-ground="dark"] *));
|
|
8
|
+
|
|
9
|
+
/* Markdown from Hearth (rich text, FAQ answers, media + text). Unlayered, so its line height beats text-* sizes. */
|
|
10
|
+
.prose {
|
|
11
|
+
line-height: 1.7;
|
|
12
|
+
}
|
|
13
|
+
.prose > * + * {
|
|
14
|
+
margin-top: 1em;
|
|
15
|
+
}
|
|
16
|
+
.prose h2 {
|
|
17
|
+
font-size: 1.75rem;
|
|
18
|
+
font-weight: 700;
|
|
19
|
+
line-height: 1.25;
|
|
20
|
+
margin-top: 1.6em;
|
|
21
|
+
}
|
|
22
|
+
.prose h3 {
|
|
23
|
+
font-size: 1.35rem;
|
|
24
|
+
font-weight: 700;
|
|
25
|
+
margin-top: 1.4em;
|
|
26
|
+
}
|
|
27
|
+
.prose a {
|
|
28
|
+
text-decoration: underline;
|
|
29
|
+
text-underline-offset: 3px;
|
|
30
|
+
}
|
|
31
|
+
.prose ul {
|
|
32
|
+
list-style: disc;
|
|
33
|
+
padding-left: 1.4em;
|
|
34
|
+
}
|
|
35
|
+
.prose ol {
|
|
36
|
+
list-style: decimal;
|
|
37
|
+
padding-left: 1.4em;
|
|
38
|
+
}
|
|
39
|
+
.prose blockquote {
|
|
40
|
+
border-left: 3px solid var(--color-line);
|
|
41
|
+
padding-left: 1em;
|
|
42
|
+
font-style: italic;
|
|
43
|
+
}
|
|
44
|
+
.prose img {
|
|
45
|
+
border-radius: 0.75rem;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* Unlayered, so it beats utilities like focus:outline-none: keyboard focus always shows. */
|
|
49
|
+
:focus-visible {
|
|
50
|
+
outline: 2px solid var(--color-accent);
|
|
51
|
+
outline-offset: 2px;
|
|
52
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { usePathname } from "next/navigation";
|
|
4
|
+
import { useEffect, useRef } from "react";
|
|
5
|
+
import { theme } from "@/theme";
|
|
6
|
+
import { NavLink } from "./NavLink";
|
|
7
|
+
|
|
8
|
+
type Link = { label: string; href: string };
|
|
9
|
+
|
|
10
|
+
const t = theme.mobileNav;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The menu on phones: a button that opens the links full screen, in a dialog (so focus stays in it and Escape closes
|
|
14
|
+
* it). It closes when a link is followed.
|
|
15
|
+
*/
|
|
16
|
+
export function MobileNav({ siteName, links, cta }: { siteName: string; links: Link[]; cta?: Link }) {
|
|
17
|
+
const dialog = useRef<HTMLDialogElement>(null);
|
|
18
|
+
const pathname = usePathname();
|
|
19
|
+
useEffect(() => dialog.current?.close(), [pathname]);
|
|
20
|
+
const close = () => dialog.current?.close();
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div className={t.root}>
|
|
24
|
+
<button type="button" className={t.toggle} aria-haspopup="dialog" onClick={() => dialog.current?.showModal()}>
|
|
25
|
+
{theme.text.menu}
|
|
26
|
+
</button>
|
|
27
|
+
<dialog ref={dialog} aria-label="Menu" className={t.dialog}>
|
|
28
|
+
<div className={t.top}>
|
|
29
|
+
<p className={t.title}>{siteName}</p>
|
|
30
|
+
<button type="button" className={t.close} onClick={close}>
|
|
31
|
+
{theme.text.close}
|
|
32
|
+
</button>
|
|
33
|
+
</div>
|
|
34
|
+
<nav aria-label="Main" className={t.nav}>
|
|
35
|
+
{links.map((item) => (
|
|
36
|
+
<NavLink key={item.href + item.label} href={item.href} className={t.link} onClick={close}>
|
|
37
|
+
{item.label}
|
|
38
|
+
</NavLink>
|
|
39
|
+
))}
|
|
40
|
+
{cta ? (
|
|
41
|
+
<NavLink href={cta.href} className={t.cta} onClick={close}>
|
|
42
|
+
{cta.label}
|
|
43
|
+
</NavLink>
|
|
44
|
+
) : null}
|
|
45
|
+
</nav>
|
|
46
|
+
</dialog>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { usePathname } from "next/navigation";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
import { SmartLink } from "../ui";
|
|
6
|
+
|
|
7
|
+
/** A menu link that marks itself as the current page (aria-current), for the theme's aria-[current=page]: variant. */
|
|
8
|
+
export function NavLink({ href, className, onClick, children }: { href: string; className: string; onClick?: () => void; children: ReactNode }) {
|
|
9
|
+
const pathname = usePathname();
|
|
10
|
+
const current = href === "/" ? pathname === "/" : pathname === href || pathname.startsWith(`${href}/`);
|
|
11
|
+
return (
|
|
12
|
+
<SmartLink href={href} className={className} onClick={onClick} aria-current={current ? "page" : undefined}>
|
|
13
|
+
{children}
|
|
14
|
+
</SmartLink>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { HearthAnalytics } from "@the-smithy/hearth/analytics";
|
|
3
|
+
import { siteLang } from "@the-smithy/hearth/next";
|
|
4
|
+
import { HearthPreview } from "@the-smithy/hearth/preview";
|
|
5
|
+
import { theme } from "@/theme";
|
|
6
|
+
import { fontVariables } from "@/theme/fonts";
|
|
7
|
+
import { hearthNext } from "../client";
|
|
8
|
+
import { SiteFooter } from "./SiteFooter";
|
|
9
|
+
import { SiteHeader } from "./SiteHeader";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What every page starts from: full links, the title pattern, the default description and share image, the site's
|
|
13
|
+
* language, Google and Bing verification, and no indexing on preview deploys or while the site is hidden in Hearth.
|
|
14
|
+
* Pages from Hearth then set their own title, description, canonical address and share image.
|
|
15
|
+
*/
|
|
16
|
+
export const generateMetadata = () => hearthNext.siteMetadata();
|
|
17
|
+
|
|
18
|
+
export default async function RootLayout({ children }: { children: ReactNode }) {
|
|
19
|
+
const site = await hearthNext.getSite();
|
|
20
|
+
const preview = await hearthNext.isPreview();
|
|
21
|
+
const t = theme.page;
|
|
22
|
+
return (
|
|
23
|
+
<html lang={siteLang(site)} className={fontVariables || undefined}>
|
|
24
|
+
<body className={t.body}>
|
|
25
|
+
<a href="#main" className={t.skipLink}>
|
|
26
|
+
Skip to content
|
|
27
|
+
</a>
|
|
28
|
+
{/* Live draft preview from Hearth: shows while an editor previews, refreshes as they edit. */}
|
|
29
|
+
{preview ? <HearthPreview /> : null}
|
|
30
|
+
<SiteHeader site={site} />
|
|
31
|
+
<main id="main" className={t.main}>
|
|
32
|
+
{children}
|
|
33
|
+
</main>
|
|
34
|
+
<SiteFooter site={site} />
|
|
35
|
+
{/* Hearth's built-in visitor analytics (cookieless, no banner needed): off in draft preview and when the site turns it off. */}
|
|
36
|
+
{site.analytics && !preview ? <HearthAnalytics config={site.analytics} /> : null}
|
|
37
|
+
</body>
|
|
38
|
+
</html>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Site } from "@the-smithy/hearth";
|
|
2
|
-
import {
|
|
2
|
+
import { theme } from "@/theme";
|
|
3
|
+
import { cx } from "../theme";
|
|
4
|
+
import { ground, SmartLink, toTone } from "../ui";
|
|
3
5
|
|
|
4
6
|
const platformName: Record<string, string> = {
|
|
5
7
|
facebook: "Facebook",
|
|
@@ -12,28 +14,31 @@ const platformName: Record<string, string> = {
|
|
|
12
14
|
|
|
13
15
|
/** Contact details, footer links and social links from Hearth (Settings → Content). */
|
|
14
16
|
export function SiteFooter({ site }: { site: Site }) {
|
|
15
|
-
const { footerLinks, contact, social } = site.settings;
|
|
17
|
+
const { footerLinks, contact, social, tagline } = site.settings;
|
|
18
|
+
const t = theme.footer;
|
|
19
|
+
const tone = toTone(t.tone);
|
|
16
20
|
return (
|
|
17
|
-
<footer className=
|
|
18
|
-
<div className=
|
|
19
|
-
<div className=
|
|
20
|
-
<p className=
|
|
21
|
-
{
|
|
21
|
+
<footer {...ground(tone)} className={cx(theme.tones[tone], t.root)}>
|
|
22
|
+
<div className={t.inner}>
|
|
23
|
+
<div className={t.about}>
|
|
24
|
+
<p className={t.name}>{site.name}</p>
|
|
25
|
+
{tagline ? <p className={t.tagline}>{tagline}</p> : null}
|
|
26
|
+
{contact.address ? <address className={t.address}>{contact.address}</address> : null}
|
|
22
27
|
{contact.phone ? (
|
|
23
|
-
<a href={`tel:${contact.phone.replace(/[^\d+]/g, "")}`} className=
|
|
28
|
+
<a href={`tel:${contact.phone.replace(/[^\d+]/g, "")}`} className={t.contact}>
|
|
24
29
|
{contact.phone}
|
|
25
30
|
</a>
|
|
26
31
|
) : null}
|
|
27
32
|
{contact.email ? (
|
|
28
|
-
<a href={`mailto:${contact.email}`} className=
|
|
33
|
+
<a href={`mailto:${contact.email}`} className={t.contact}>
|
|
29
34
|
{contact.email}
|
|
30
35
|
</a>
|
|
31
36
|
) : null}
|
|
32
37
|
</div>
|
|
33
38
|
{footerLinks.length ? (
|
|
34
|
-
<nav aria-label="Footer" className=
|
|
39
|
+
<nav aria-label="Footer" className={t.nav}>
|
|
35
40
|
{footerLinks.map((item) => (
|
|
36
|
-
<SmartLink key={item.href + item.label} href={item.href} className=
|
|
41
|
+
<SmartLink key={item.href + item.label} href={item.href} className={t.link}>
|
|
37
42
|
{item.label}
|
|
38
43
|
</SmartLink>
|
|
39
44
|
))}
|
|
@@ -42,10 +47,10 @@ export function SiteFooter({ site }: { site: Site }) {
|
|
|
42
47
|
<div />
|
|
43
48
|
)}
|
|
44
49
|
{social.length ? (
|
|
45
|
-
<ul className=
|
|
50
|
+
<ul className={t.social}>
|
|
46
51
|
{social.map((s) => (
|
|
47
52
|
<li key={s.url}>
|
|
48
|
-
<a href={s.url} rel="me noopener" className=
|
|
53
|
+
<a href={s.url} rel="me noopener" className={t.socialLink}>
|
|
49
54
|
{s.label ?? platformName[s.platform] ?? new URL(s.url).hostname.replace(/^www\./, "")}
|
|
50
55
|
</a>
|
|
51
56
|
</li>
|
|
@@ -53,7 +58,7 @@ export function SiteFooter({ site }: { site: Site }) {
|
|
|
53
58
|
</ul>
|
|
54
59
|
) : null}
|
|
55
60
|
</div>
|
|
56
|
-
<p className=
|
|
61
|
+
<p className={t.copyright}>
|
|
57
62
|
© {new Date().getFullYear()} {site.name}
|
|
58
63
|
</p>
|
|
59
64
|
</footer>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import type { Site } from "@the-smithy/hearth";
|
|
3
|
+
import { theme } from "@/theme";
|
|
4
|
+
import { Logo } from "@/theme/overrides";
|
|
5
|
+
import { cx } from "../theme";
|
|
6
|
+
import { ground, Img, toTone } from "../ui";
|
|
7
|
+
import { MobileNav } from "./MobileNav";
|
|
8
|
+
import { NavLink } from "./NavLink";
|
|
9
|
+
|
|
10
|
+
/** Logo, menu, menu button and announcement banner, all edited in Hearth (Settings → General, and Navigation). */
|
|
11
|
+
export function SiteHeader({ site }: { site: Site }) {
|
|
12
|
+
const { announcement, seo, tagline, navigation, navigationCta } = site.settings;
|
|
13
|
+
const t = theme.header;
|
|
14
|
+
const tone = toTone(t.tone);
|
|
15
|
+
return (
|
|
16
|
+
<header {...ground(tone)} className={cx(theme.tones[tone], t.root)}>
|
|
17
|
+
{announcement ? <p className={t.announcement}>{announcement}</p> : null}
|
|
18
|
+
<div className={t.inner}>
|
|
19
|
+
<Link href="/" className={t.brand}>
|
|
20
|
+
{Logo ? (
|
|
21
|
+
<Logo site={site} />
|
|
22
|
+
) : (
|
|
23
|
+
<>
|
|
24
|
+
{seo.logo ? <Img image={{ src: seo.logo.src, alt: "" }} className={t.logo} /> : null}
|
|
25
|
+
<span className={t.names}>
|
|
26
|
+
<span>{site.name}</span>
|
|
27
|
+
{tagline ? <span className={t.tagline}>{tagline}</span> : null}
|
|
28
|
+
</span>
|
|
29
|
+
</>
|
|
30
|
+
)}
|
|
31
|
+
</Link>
|
|
32
|
+
{navigation.length || navigationCta ? (
|
|
33
|
+
<>
|
|
34
|
+
<nav aria-label="Main" className={t.nav}>
|
|
35
|
+
{navigation.map((item) => (
|
|
36
|
+
<NavLink key={item.href + item.label} href={item.href} className={t.link}>
|
|
37
|
+
{item.label}
|
|
38
|
+
</NavLink>
|
|
39
|
+
))}
|
|
40
|
+
{/* The menu button (Navigation → Menu button in Hearth): one action that stands out from the links. */}
|
|
41
|
+
{navigationCta ? (
|
|
42
|
+
<NavLink href={navigationCta.href} className={t.cta}>
|
|
43
|
+
{navigationCta.label}
|
|
44
|
+
</NavLink>
|
|
45
|
+
) : null}
|
|
46
|
+
</nav>
|
|
47
|
+
<MobileNav siteName={site.name} links={navigation} cta={navigationCta} />
|
|
48
|
+
</>
|
|
49
|
+
) : null}
|
|
50
|
+
</div>
|
|
51
|
+
</header>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "Files Hearth manages in this site. pnpm hearth:update replaces the managed ones with the latest release and adds seeded files that are new; it never touches anything else (src/theme, your own routes). Don't edit managed files: style through src/theme.",
|
|
3
|
+
"version": "dev",
|
|
4
|
+
"managed": [
|
|
5
|
+
"src/hearth/",
|
|
6
|
+
"src/app/layout.tsx",
|
|
7
|
+
"src/app/globals.css",
|
|
8
|
+
"src/app/not-found.tsx",
|
|
9
|
+
"src/app/[[...slug]]/page.tsx",
|
|
10
|
+
"src/app/events/page.tsx",
|
|
11
|
+
"src/app/events/[slug]/page.tsx",
|
|
12
|
+
"src/app/fundraisers/page.tsx",
|
|
13
|
+
"src/app/fundraisers/[slug]/page.tsx",
|
|
14
|
+
"src/app/donate/page.tsx",
|
|
15
|
+
"src/app/donate/thank-you/page.tsx",
|
|
16
|
+
"src/app/registered/page.tsx",
|
|
17
|
+
"src/app/robots.ts",
|
|
18
|
+
"src/app/sitemap.ts",
|
|
19
|
+
"src/app/llms.txt/route.ts",
|
|
20
|
+
"src/app/api/preview/route.ts",
|
|
21
|
+
"src/app/api/preview/exit/route.ts",
|
|
22
|
+
"src/app/api/revalidate/route.ts",
|
|
23
|
+
"scripts/hearth.mjs",
|
|
24
|
+
"CLAUDE.md",
|
|
25
|
+
"AGENTS.md"
|
|
26
|
+
],
|
|
27
|
+
"seeded": [
|
|
28
|
+
"src/theme/",
|
|
29
|
+
"src/app/icon.tsx",
|
|
30
|
+
"src/app/apple-icon.tsx",
|
|
31
|
+
"SITE.md",
|
|
32
|
+
"README.md",
|
|
33
|
+
"next.config.ts",
|
|
34
|
+
"netlify.toml",
|
|
35
|
+
".env.example"
|
|
36
|
+
]
|
|
37
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { notFound } from "next/navigation";
|
|
3
|
+
import { theme } from "@/theme";
|
|
4
|
+
import { hearthNext } from "../client";
|
|
5
|
+
import { DonateForm } from "../forms/DonateForm";
|
|
6
|
+
import { hasFeature } from "../site";
|
|
7
|
+
import { Section } from "../ui";
|
|
8
|
+
|
|
9
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
10
|
+
const site = await hearthNext.getSite();
|
|
11
|
+
return {
|
|
12
|
+
title: "Donate",
|
|
13
|
+
description: `Support ${site.name} with a donation.`,
|
|
14
|
+
alternates: { canonical: "/donate" },
|
|
15
|
+
openGraph: { title: "Donate", url: "/donate" },
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A general donation page (sites with Giving). Ask staff to reserve "donate" on the site's Plan card in Hearth so
|
|
21
|
+
* no page takes this address. For a page built in Hearth instead, use the Donation form block there.
|
|
22
|
+
*/
|
|
23
|
+
export default async function DonatePage() {
|
|
24
|
+
const site = await hearthNext.getSite();
|
|
25
|
+
if (!hasFeature(site, "giving")) notFound();
|
|
26
|
+
const giving = site.settings.giving;
|
|
27
|
+
return (
|
|
28
|
+
<Section block="donatePage">
|
|
29
|
+
<div className={theme.donate.wrap}>
|
|
30
|
+
<h1 className={theme.page.title}>Donate</h1>
|
|
31
|
+
<p className={theme.page.lead}>Your gift supports {giving.legalName || site.name}.</p>
|
|
32
|
+
<DonateForm presetsCents={giving.presetsCents} minCents={giving.minCents} maxCents={giving.maxCents} />
|
|
33
|
+
</div>
|
|
34
|
+
</Section>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { metadataFromMeta } from "@the-smithy/hearth/next";
|
|
2
|
+
import { permanentRedirect } from "next/navigation";
|
|
3
|
+
import { hearthNext } from "../client";
|
|
4
|
+
import { EventView } from "../components/EventView";
|
|
5
|
+
import { hasFeature } from "../site";
|
|
6
|
+
|
|
7
|
+
type Props = { params: Promise<{ slug: string }> };
|
|
8
|
+
|
|
9
|
+
async function load(slug: string) {
|
|
10
|
+
const site = await hearthNext.getSite();
|
|
11
|
+
return hasFeature(site, "events") ? hearthNext.getEvent(slug) : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function generateMetadata({ params }: Props) {
|
|
15
|
+
const event = await load((await params).slug);
|
|
16
|
+
return event ? metadataFromMeta(event.meta) : {};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Events without a type live here. Events in a collection (e.g. Talks) live at their collection's address. */
|
|
20
|
+
export default async function EventPage({ params }: Props) {
|
|
21
|
+
const slug = (await params).slug;
|
|
22
|
+
const event = await load(slug);
|
|
23
|
+
if (!event) return hearthNext.redirectOrNotFound(`/events/${slug}`);
|
|
24
|
+
if (event.path !== `/events/${event.slug}`) permanentRedirect(event.path);
|
|
25
|
+
return <EventView event={event} />;
|
|
26
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { notFound } from "next/navigation";
|
|
3
|
+
import { theme } from "@/theme";
|
|
4
|
+
import { EventList } from "../blocks/events";
|
|
5
|
+
import { hearthNext } from "../client";
|
|
6
|
+
import { hasFeature } from "../site";
|
|
7
|
+
import { Section } from "../ui";
|
|
8
|
+
|
|
9
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
10
|
+
const site = await hearthNext.getSite();
|
|
11
|
+
return {
|
|
12
|
+
title: "Events",
|
|
13
|
+
description: `Upcoming events from ${site.name}.`,
|
|
14
|
+
alternates: { canonical: "/events" },
|
|
15
|
+
openGraph: { title: "Events", url: "/events" },
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Upcoming events (only on sites with the Events feature in Hearth). */
|
|
20
|
+
export default async function EventsPage() {
|
|
21
|
+
const site = await hearthNext.getSite();
|
|
22
|
+
if (!hasFeature(site, "events")) notFound();
|
|
23
|
+
const events = await hearthNext.getEvents({ when: "upcoming" });
|
|
24
|
+
return (
|
|
25
|
+
<Section block="events">
|
|
26
|
+
<h1 className={theme.page.title}>Events</h1>
|
|
27
|
+
<EventList events={events} />
|
|
28
|
+
</Section>
|
|
29
|
+
);
|
|
30
|
+
}
|