@the-smithy/create-hearth-site 0.24.1 → 0.26.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/README.md +2 -1
- package/bin/create-hearth-site.js +1 -1
- package/package.json +4 -3
- package/template/AGENTS.md +191 -9
- package/template/CLAUDE.md +2 -124
- package/template/GEMINI.md +3 -0
- package/template/HOSTING.md +88 -0
- package/template/README.md +30 -29
- package/template/SITE.md +1 -1
- package/template/_gitignore +2 -1
- package/template/package.json +3 -2
- package/template/scripts/hearth.mjs +82 -31
- package/template/src/app/donate/thank-you/page.tsx +1 -1
- package/template/src/app/globals.css +1 -0
- package/template/src/app/layout.tsx +1 -1
- package/template/src/app/not-found.tsx +1 -1
- package/template/src/app/registered/page.tsx +1 -1
- package/template/src/hearth/base.css +66 -0
- package/template/src/hearth/blocks/collections.tsx +6 -5
- package/template/src/hearth/blocks/content.tsx +64 -28
- package/template/src/hearth/blocks/custom.tsx +9 -1
- package/template/src/hearth/blocks/events.tsx +25 -17
- package/template/src/hearth/blocks/giving.tsx +24 -20
- package/template/src/hearth/blocks/index.tsx +29 -4
- package/template/src/hearth/blocks/info.tsx +15 -13
- package/template/src/hearth/blocks/media.tsx +10 -9
- package/template/src/hearth/components/EventView.tsx +61 -29
- package/template/src/hearth/components/FundraiserView.tsx +77 -0
- package/template/src/hearth/components/NotHere.tsx +9 -0
- package/template/src/hearth/components/PageContent.tsx +23 -0
- package/template/src/hearth/components/Registration.tsx +3 -2
- package/template/src/hearth/components/Share.tsx +11 -11
- package/template/src/hearth/format.ts +5 -3
- package/template/src/hearth/forms/ContactForm.tsx +5 -3
- package/template/src/hearth/forms/DonateForm.tsx +49 -32
- package/template/src/hearth/forms/DonationForm.tsx +13 -0
- package/template/src/hearth/forms/RegistrationForm.tsx +16 -14
- package/template/src/hearth/forms/VolunteerForm.tsx +10 -8
- package/template/src/hearth/forms/fields.tsx +17 -5
- package/template/src/hearth/hearth.css +3 -47
- package/template/src/hearth/layout/MobileNav.tsx +1 -1
- package/template/src/hearth/layout/NavLink.tsx +6 -2
- package/template/src/hearth/layout/RootLayout.tsx +8 -32
- package/template/src/hearth/layout/SiteFooter.tsx +13 -6
- package/template/src/hearth/layout/SiteFrame.tsx +48 -0
- package/template/src/hearth/layout/SiteHeader.tsx +9 -4
- package/template/src/hearth/manifest.json +3 -1
- package/template/src/hearth/overrides.ts +48 -0
- package/template/src/hearth/pages/donate.tsx +24 -15
- package/template/src/hearth/pages/event.tsx +3 -2
- package/template/src/hearth/pages/events.tsx +22 -10
- package/template/src/hearth/pages/fundraiser.tsx +10 -63
- package/template/src/hearth/pages/fundraisers.tsx +21 -11
- package/template/src/hearth/pages/not-found.tsx +28 -8
- package/template/src/hearth/pages/page.tsx +9 -15
- package/template/src/hearth/pages/registered.tsx +23 -10
- package/template/src/hearth/pages/thank-you.tsx +23 -10
- package/template/src/hearth/theme/defaults.ts +125 -2
- package/template/src/hearth/ui.tsx +20 -5
- package/template/src/theme/overrides.tsx +19 -5
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Page } from "@the-smithy/hearth";
|
|
2
|
+
import { JsonLd } from "@the-smithy/hearth/react";
|
|
3
|
+
import { Blocks } from "../blocks";
|
|
4
|
+
|
|
5
|
+
/** A page from Hearth: its structured data and its blocks. */
|
|
6
|
+
export function PageContent({ page }: { page: Page }) {
|
|
7
|
+
return (
|
|
8
|
+
<>
|
|
9
|
+
<JsonLd data={page.meta.jsonLd} />
|
|
10
|
+
<PageBlocks page={page} />
|
|
11
|
+
</>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function PageBlocks({ page }: { page: Page }) {
|
|
16
|
+
return (
|
|
17
|
+
<>
|
|
18
|
+
{/* Pages usually open with a Hero (the visible title); this keeps a heading for the rest. */}
|
|
19
|
+
{page.blocks[0]?.type === "hero" ? null : <h1 className="sr-only">{page.title}</h1>}
|
|
20
|
+
<Blocks blocks={page.blocks} />
|
|
21
|
+
</>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { HearthEvent } from "@the-smithy/hearth";
|
|
2
2
|
import { theme } from "@/theme";
|
|
3
3
|
import { hearthNext } from "../client";
|
|
4
|
+
import { ExternalLink } from "../ui";
|
|
4
5
|
|
|
5
6
|
/** Upcoming until it ends (or, without an end time, until it starts). */
|
|
6
7
|
const isUpcoming = (e: Pick<HearthEvent, "startsAt" | "endsAt">) => new Date(e.endsAt ?? e.startsAt).getTime() > Date.now();
|
|
@@ -31,9 +32,9 @@ export async function Registration({ event, size = "large" }: { event: HearthEve
|
|
|
31
32
|
}
|
|
32
33
|
if (event.registrationUrl) {
|
|
33
34
|
return (
|
|
34
|
-
<
|
|
35
|
+
<ExternalLink href={event.registrationUrl} rel="noopener" className={theme.button.primary} data-size={small}>
|
|
35
36
|
{theme.text.register}
|
|
36
|
-
</
|
|
37
|
+
</ExternalLink>
|
|
37
38
|
);
|
|
38
39
|
}
|
|
39
40
|
const { events } = (await hearthNext.getSite()).settings;
|
|
@@ -23,9 +23,9 @@ export function Share({ title, url, message, heading }: { title: string; url: st
|
|
|
23
23
|
async function copy(value: string, label: string) {
|
|
24
24
|
try {
|
|
25
25
|
await navigator.clipboard.writeText(value);
|
|
26
|
-
setStatus(
|
|
26
|
+
setStatus(theme.text.shareCopied.replace("{what}", label));
|
|
27
27
|
} catch {
|
|
28
|
-
setStatus(
|
|
28
|
+
setStatus(theme.text.shareCopyFailed);
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
@@ -33,8 +33,8 @@ export function Share({ title, url, message, heading }: { title: string; url: st
|
|
|
33
33
|
const targets = [
|
|
34
34
|
{ label: "Facebook", href: `https://www.facebook.com/sharer/sharer.php?u=${enc(url)}` },
|
|
35
35
|
{ label: "X", href: `https://x.com/intent/post?text=${enc(text)}` },
|
|
36
|
-
{ label:
|
|
37
|
-
{ label:
|
|
36
|
+
{ label: theme.text.shareEmail, href: `mailto:?subject=${enc(title)}&body=${enc(text)}` },
|
|
37
|
+
{ label: theme.text.shareTextMessage, href: `sms:?&body=${enc(text)}` },
|
|
38
38
|
];
|
|
39
39
|
|
|
40
40
|
return (
|
|
@@ -66,26 +66,26 @@ export function Share({ title, url, message, heading }: { title: string; url: st
|
|
|
66
66
|
</div>
|
|
67
67
|
<div className={t.body}>
|
|
68
68
|
<label className={theme.form.field}>
|
|
69
|
-
<span className={theme.form.label}>
|
|
69
|
+
<span className={theme.form.label}>{theme.text.shareMessageLabel}</span>
|
|
70
70
|
<textarea id={`${id}-message`} rows={5} value={text} onChange={(e) => setText(e.target.value)} className={t.message} />
|
|
71
71
|
</label>
|
|
72
72
|
<div className={t.copy}>
|
|
73
|
-
<button type="button" onClick={() => copy(text,
|
|
74
|
-
|
|
73
|
+
<button type="button" onClick={() => copy(text, theme.text.shareTheMessage)} className={t.copyButton}>
|
|
74
|
+
{theme.text.shareCopyMessage}
|
|
75
75
|
</button>
|
|
76
|
-
<button type="button" onClick={() => copy(url,
|
|
77
|
-
|
|
76
|
+
<button type="button" onClick={() => copy(url, theme.text.shareTheLink)} className={t.copyButton}>
|
|
77
|
+
{theme.text.shareCopyLink}
|
|
78
78
|
</button>
|
|
79
79
|
</div>
|
|
80
80
|
<p role="status" aria-live="polite" className={t.status}>
|
|
81
81
|
{status}
|
|
82
82
|
</p>
|
|
83
83
|
<div>
|
|
84
|
-
<p className={t.targetsLabel}>
|
|
84
|
+
<p className={t.targetsLabel}>{theme.text.shareTo}</p>
|
|
85
85
|
<div className={t.targets}>
|
|
86
86
|
{nativeShare ? (
|
|
87
87
|
<button type="button" className={t.target} onClick={() => navigator.share({ title, text }).catch(() => {})}>
|
|
88
|
-
|
|
88
|
+
{theme.text.shareMoreApps}
|
|
89
89
|
</button>
|
|
90
90
|
) : null}
|
|
91
91
|
{targets.map((x) => (
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { HearthEvent } from "@the-smithy/hearth";
|
|
2
|
+
import { theme } from "@/theme";
|
|
2
3
|
|
|
3
|
-
/** "Sat, Oct 18 · 6:00 – 9:00 PM", in the event's own time zone. */
|
|
4
|
+
/** "Sat, Oct 18 · 6:00 – 9:00 PM", in the event's own time zone; "Saturday, October 18 · …" with `page.dateStyle` set to "long". */
|
|
4
5
|
export function formatEventDate(e: Pick<HearthEvent, "startsAt" | "endsAt" | "allDay" | "timezone">) {
|
|
5
6
|
const tz = { timeZone: e.timezone };
|
|
6
|
-
const
|
|
7
|
+
const words = theme.page.dateStyle === "long" ? "long" : "short";
|
|
8
|
+
const day = new Intl.DateTimeFormat("en-US", { ...tz, weekday: words, month: words, day: "numeric", year: "numeric" });
|
|
7
9
|
const time = new Intl.DateTimeFormat("en-US", { ...tz, hour: "numeric", minute: "2-digit" });
|
|
8
10
|
const start = new Date(e.startsAt);
|
|
9
11
|
const end = e.endsAt ? new Date(e.endsAt) : null;
|
|
@@ -28,4 +30,4 @@ export const formatEntryDate = (iso: string) =>
|
|
|
28
30
|
export const money = (cents: number) => (cents / 100).toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: cents % 100 ? 2 : 0 });
|
|
29
31
|
|
|
30
32
|
/** A ticket price: "$25" or "Free". */
|
|
31
|
-
export const price = (cents: number) => (cents ? money(cents) :
|
|
33
|
+
export const price = (cents: number) => (cents ? money(cents) : theme.text.free);
|
|
@@ -4,7 +4,7 @@ import type { BlockOf } from "@the-smithy/hearth";
|
|
|
4
4
|
import { useActionState } from "react";
|
|
5
5
|
import { theme } from "@/theme";
|
|
6
6
|
import { submitHearthForm, type FormState } from "../actions";
|
|
7
|
-
import { Alert, Honeypot, Question, Submit, Success } from "./fields";
|
|
7
|
+
import { Alert, Honeypot, Question, Submit, Success, useFocusFirstInvalid } from "./fields";
|
|
8
8
|
|
|
9
9
|
type FormData_ = BlockOf<"form">["data"];
|
|
10
10
|
|
|
@@ -12,16 +12,18 @@ type FormData_ = BlockOf<"form">["data"];
|
|
|
12
12
|
export function HearthForm({ form }: { form: FormData_ }) {
|
|
13
13
|
const [state, action] = useActionState<FormState, FormData>(submitHearthForm.bind(null, form.formKey), {});
|
|
14
14
|
|
|
15
|
+
const ref = useFocusFirstInvalid(state);
|
|
16
|
+
|
|
15
17
|
if (state.ok) return <Success>{state.message || form.successMessage}</Success>;
|
|
16
18
|
|
|
17
19
|
return (
|
|
18
|
-
<form action={action} className={theme.form.root} noValidate>
|
|
20
|
+
<form ref={ref} action={action} className={theme.form.root} noValidate>
|
|
19
21
|
{state.message ? <Alert>{state.message}</Alert> : null}
|
|
20
22
|
<Honeypot />
|
|
21
23
|
{form.fields.map((f) => (
|
|
22
24
|
<Question key={f.name} field={f} idPrefix={form.formKey} error={state.fieldErrors?.[f.name]} value={state.values?.[f.name]} />
|
|
23
25
|
))}
|
|
24
|
-
<Submit label={form.submitLabel} pending=
|
|
26
|
+
<Submit label={form.submitLabel} pending={theme.text.sending} />
|
|
25
27
|
</form>
|
|
26
28
|
);
|
|
27
29
|
}
|
|
@@ -1,69 +1,86 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useActionState, useState } from "react";
|
|
3
|
+
import { useActionState, useEffect, useState } from "react";
|
|
4
4
|
import type { FormResult } from "@the-smithy/hearth";
|
|
5
5
|
import { theme } from "@/theme";
|
|
6
6
|
import { donate } from "../actions";
|
|
7
7
|
import { money } from "../format";
|
|
8
|
-
import {
|
|
8
|
+
import type { DonateFormProps } from "../overrides";
|
|
9
|
+
import { fill } from "../ui";
|
|
10
|
+
import { Alert, Checkbox, FieldError, Submit, TextField, useFocusFirstInvalid } from "./fields";
|
|
9
11
|
|
|
10
12
|
const t = theme.form;
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
|
-
* Suggested amounts (from Hearth's Giving settings), a custom amount and optional details.
|
|
14
|
-
* checkout through Hearth and sends the donor to the payment page; they come back to
|
|
15
|
+
* Suggested amounts (from Hearth's Giving settings), a custom amount, a choice of fundraiser and optional details.
|
|
16
|
+
* Submitting starts a checkout through Hearth and sends the donor to the payment page; they come back to
|
|
17
|
+
* /donate/thank-you. `fundraiser` gives to that one. Otherwise `fundraisers`, the active ones, are offered, with
|
|
18
|
+
* "Where it's needed most" first and the one named by ?fundraiser= in the address chosen; without any, there's no choice.
|
|
15
19
|
*/
|
|
16
|
-
export function DonateForm({
|
|
17
|
-
presetsCents,
|
|
18
|
-
minCents,
|
|
19
|
-
maxCents,
|
|
20
|
-
fundraiser,
|
|
21
|
-
submitLabel = theme.text.donate,
|
|
22
|
-
}: {
|
|
23
|
-
presetsCents: number[];
|
|
24
|
-
minCents: number;
|
|
25
|
-
maxCents: number;
|
|
26
|
-
fundraiser?: string;
|
|
27
|
-
submitLabel?: string;
|
|
28
|
-
}) {
|
|
20
|
+
export function DonateForm({ presetsCents, minCents, maxCents, fundraiser, fundraisers = [], submitLabel = theme.text.donate }: DonateFormProps) {
|
|
29
21
|
const [state, action] = useActionState<FormResult | null, FormData>(donate, null);
|
|
30
22
|
const [amount, setAmount] = useState(presetsCents[1] !== undefined ? String(presetsCents[1] / 100) : "custom");
|
|
23
|
+
const [chosen, setChosen] = useState("");
|
|
31
24
|
const errors = state && !state.ok ? state.fieldErrors : {};
|
|
25
|
+
const ref = useFocusFirstInvalid(state);
|
|
26
|
+
const choice = !fundraiser && fundraisers.length > 0;
|
|
27
|
+
|
|
28
|
+
// From the address after loading, so that the page can stay prerendered.
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
const wanted = new URLSearchParams(window.location.search).get("fundraiser");
|
|
31
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- only known in the browser
|
|
32
|
+
if (wanted && fundraisers.some((f) => f.slug === wanted)) setChosen(wanted);
|
|
33
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- once, as the page loads
|
|
34
|
+
}, []);
|
|
32
35
|
|
|
33
36
|
return (
|
|
34
|
-
<form action={action} className={t.root} id="donate">
|
|
37
|
+
<form ref={ref} action={action} className={t.root} id="donate">
|
|
35
38
|
{state && !state.ok ? <Alert>{state.message}</Alert> : null}
|
|
36
39
|
{fundraiser ? <input type="hidden" name="fundraiser" value={fundraiser} /> : null}
|
|
37
40
|
<fieldset>
|
|
38
|
-
<legend className={t.legend}>
|
|
41
|
+
<legend className={t.legend}>{theme.text.donateAmount}</legend>
|
|
39
42
|
<div className={t.amounts}>
|
|
40
|
-
{[...presetsCents.map((cents) => ({ value: String(cents / 100), label: money(cents) })), { value: "custom", label:
|
|
43
|
+
{[...presetsCents.map((cents) => ({ value: String(cents / 100), label: money(cents) })), { value: "custom", label: theme.text.donateOther }].map((a) => (
|
|
41
44
|
<label key={a.value} className={t.amount}>
|
|
42
|
-
<input type="radio" name="amount" value={a.value} checked={amount === a.value} onChange={(e) => setAmount(e.target.value)} className="sr-only" />
|
|
45
|
+
<input type="radio" name="amount" value={a.value} checked={amount === a.value} onChange={(e) => setAmount(e.target.value)} data-invalid={errors.amount && amount === a.value && a.value !== "custom" ? "" : undefined} className="sr-only" />
|
|
43
46
|
{a.label}
|
|
44
47
|
</label>
|
|
45
48
|
))}
|
|
46
49
|
</div>
|
|
47
50
|
{amount === "custom" ? (
|
|
48
51
|
<div className={t.customAmount}>
|
|
49
|
-
<TextField label=
|
|
52
|
+
<TextField label={theme.text.donateCustomAmount} name="customAmount" inputMode="decimal" required placeholder={String(minCents / 100)} data-invalid={errors.amount ? "" : undefined} />
|
|
50
53
|
</div>
|
|
51
54
|
) : null}
|
|
52
|
-
<p className={t.hint}>
|
|
53
|
-
{money(minCents)} to {money(maxCents)}.
|
|
54
|
-
</p>
|
|
55
|
+
<p className={t.hint}>{fill(theme.text.donateRange, { min: money(minCents), max: money(maxCents) })}</p>
|
|
55
56
|
<FieldError>{errors.amount}</FieldError>
|
|
56
57
|
</fieldset>
|
|
58
|
+
{choice ? (
|
|
59
|
+
<div>
|
|
60
|
+
<label htmlFor="donate-fundraiser" className={t.label}>
|
|
61
|
+
{theme.text.donateChoice}
|
|
62
|
+
</label>
|
|
63
|
+
<select id="donate-fundraiser" name="fundraiser" value={chosen} onChange={(e) => setChosen(e.target.value)} aria-invalid={errors.fundraiser ? true : undefined} aria-describedby={errors.fundraiser ? "donate-fundraiser-error" : undefined} className={t.input}>
|
|
64
|
+
<option value="">{theme.text.donateAnywhere}</option>
|
|
65
|
+
{fundraisers.map((f) => (
|
|
66
|
+
<option key={f.slug} value={f.slug}>
|
|
67
|
+
{f.title}
|
|
68
|
+
</option>
|
|
69
|
+
))}
|
|
70
|
+
</select>
|
|
71
|
+
<FieldError id="donate-fundraiser-error">{errors.fundraiser}</FieldError>
|
|
72
|
+
</div>
|
|
73
|
+
) : null}
|
|
57
74
|
<div className={t.row}>
|
|
58
|
-
<TextField label=
|
|
59
|
-
<TextField label=
|
|
75
|
+
<TextField label={theme.text.yourName} name="name" autoComplete="name" error={errors.name} />
|
|
76
|
+
<TextField label={theme.text.donateEmail} name="email" type="email" autoComplete="email" error={errors.email} />
|
|
60
77
|
</div>
|
|
61
|
-
<TextField label=
|
|
62
|
-
<Checkbox name="anonymous" label=
|
|
78
|
+
<TextField label={theme.text.donateMessage} optional name="message" textarea rows={3} maxLength={500} error={errors.message} />
|
|
79
|
+
<Checkbox name="anonymous" label={theme.text.donateAnonymous} />
|
|
63
80
|
{/* Opt-in only, unticked: the fundraiser's updates go to donors who ask (and give an email). */}
|
|
64
|
-
{fundraiser ? <Checkbox name="updates" label=
|
|
65
|
-
<Submit label={submitLabel} pending=
|
|
66
|
-
<p className={t.note}>
|
|
81
|
+
{fundraiser || chosen ? <Checkbox name="updates" label={theme.text.donateUpdates} /> : null}
|
|
82
|
+
<Submit label={submitLabel} pending={theme.text.openingCheckout} />
|
|
83
|
+
<p className={t.note}>{theme.text.donateNote}</p>
|
|
67
84
|
</form>
|
|
68
85
|
);
|
|
69
86
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { hearthNext } from "../client";
|
|
2
|
+
import { overrides, type DonateFormProps } from "../overrides";
|
|
3
|
+
import { DonateForm } from "./DonateForm";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A donation form wherever the template draws one, the site's own if it replaces it. Without a fundraiser of its own, it
|
|
7
|
+
* offers the active ones to choose from.
|
|
8
|
+
*/
|
|
9
|
+
export async function DonationForm(props: Omit<DonateFormProps, "fundraisers">) {
|
|
10
|
+
const Form = overrides.DonateForm ?? DonateForm;
|
|
11
|
+
const fundraisers = props.fundraiser ? [] : await hearthNext.getFundraisers({ status: "active" });
|
|
12
|
+
return <Form {...props} fundraisers={fundraisers.map((f) => ({ slug: f.slug, title: f.title }))} />;
|
|
13
|
+
}
|
|
@@ -5,7 +5,8 @@ import type { EventTicket, FormResult } from "@the-smithy/hearth";
|
|
|
5
5
|
import { theme } from "@/theme";
|
|
6
6
|
import { register } from "../actions";
|
|
7
7
|
import { price } from "../format";
|
|
8
|
-
import {
|
|
8
|
+
import { fill } from "../ui";
|
|
9
|
+
import { Alert, FieldError, OptionCard, Submit, Success, TextField, useFocusFirstInvalid } from "./fields";
|
|
9
10
|
|
|
10
11
|
const t = theme.form;
|
|
11
12
|
|
|
@@ -20,49 +21,50 @@ export function RegistrationForm({ slug, tickets }: { slug: string; tickets: Eve
|
|
|
20
21
|
const ticket = tickets.find((x) => x.id === ticketId);
|
|
21
22
|
const errors = state && !state.ok ? state.fieldErrors : {};
|
|
22
23
|
const paid = Boolean(ticket?.priceCents);
|
|
23
|
-
const attendee = ticket?.attendeeLabel ??
|
|
24
|
+
const attendee = ticket?.attendeeLabel ?? theme.text.attendee;
|
|
25
|
+
const ref = useFocusFirstInvalid(state);
|
|
24
26
|
|
|
25
27
|
if (state?.ok) return <Success>{state.message}</Success>;
|
|
26
28
|
|
|
27
29
|
return (
|
|
28
|
-
<form action={action} className={t.root}>
|
|
30
|
+
<form ref={ref} action={action} className={t.root}>
|
|
29
31
|
{state && !state.ok ? <Alert>{state.message}</Alert> : null}
|
|
30
32
|
<fieldset>
|
|
31
|
-
<legend className={t.legend}>
|
|
33
|
+
<legend className={t.legend}>{theme.text.ticket}</legend>
|
|
32
34
|
<div className={t.options}>
|
|
33
35
|
{tickets.map((x) => (
|
|
34
36
|
<OptionCard
|
|
35
37
|
key={x.id}
|
|
36
|
-
input={<input type="radio" name="ticket" value={x.id} checked={ticketId === x.id} disabled={x.soldOut} onChange={() => setTicketId(x.id)} className={t.optionInput} />}
|
|
38
|
+
input={<input type="radio" name="ticket" value={x.id} checked={ticketId === x.id} disabled={x.soldOut} onChange={() => setTicketId(x.id)} data-invalid={errors.ticket && !x.soldOut ? "" : undefined} className={t.optionInput} />}
|
|
37
39
|
title={x.name}
|
|
38
|
-
meta={x.soldOut ?
|
|
40
|
+
meta={x.soldOut ? theme.text.soldOut : price(x.priceCents)}
|
|
39
41
|
>
|
|
40
42
|
{x.description ? <span className={t.optionDetail}>{x.description}</span> : null}
|
|
41
|
-
{x.remaining !== null && !x.soldOut && x.remaining <= 10 ? <span className={t.optionDetail}>{x.remaining}
|
|
43
|
+
{x.remaining !== null && !x.soldOut && x.remaining <= 10 ? <span className={t.optionDetail}>{fill(theme.text.ticketsLeft, { count: String(x.remaining) })}</span> : null}
|
|
42
44
|
</OptionCard>
|
|
43
45
|
))}
|
|
44
46
|
</div>
|
|
45
47
|
<FieldError>{errors.ticket}</FieldError>
|
|
46
48
|
</fieldset>
|
|
47
49
|
<div className={t.row}>
|
|
48
|
-
<TextField label=
|
|
49
|
-
<TextField label=
|
|
50
|
-
<TextField label=
|
|
51
|
-
<TextField label=
|
|
50
|
+
<TextField label={theme.text.yourName} name="name" required autoComplete="name" error={errors.name} />
|
|
51
|
+
<TextField label={theme.text.yourEmail} name="email" type="email" required autoComplete="email" error={errors.email} />
|
|
52
|
+
<TextField label={theme.text.yourPhone} optional name="phone" type="tel" autoComplete="tel" error={errors.phone} />
|
|
53
|
+
<TextField label={theme.text.teamName} optional name="teamName" error={errors.teamName} />
|
|
52
54
|
</div>
|
|
53
55
|
{ticket && ticket.attendeesPerRegistration > 0 ? (
|
|
54
56
|
<fieldset>
|
|
55
57
|
<legend className={t.legend}>{ticket.attendeesPerRegistration === 1 ? attendee : `${attendee}s`}</legend>
|
|
56
58
|
<div className={t.row}>
|
|
57
59
|
{Array.from({ length: ticket.attendeesPerRegistration }, (_, i) => (
|
|
58
|
-
<input key={`${ticket.id}-${i}`} name="attendee" required aria-label={`${attendee} ${i + 1}`} placeholder={`${attendee} ${i + 1}`} className={t.input} />
|
|
60
|
+
<input key={`${ticket.id}-${i}`} name="attendee" required aria-invalid={errors.attendees ? true : undefined} aria-label={`${attendee} ${i + 1}`} placeholder={`${attendee} ${i + 1}`} className={t.input} />
|
|
59
61
|
))}
|
|
60
62
|
</div>
|
|
61
63
|
<FieldError>{errors.attendees}</FieldError>
|
|
62
64
|
</fieldset>
|
|
63
65
|
) : null}
|
|
64
|
-
<TextField label=
|
|
65
|
-
<Submit label={paid ?
|
|
66
|
+
<TextField label={theme.text.notes} optional name="notes" textarea rows={3} maxLength={1000} error={errors.notes} />
|
|
67
|
+
<Submit label={paid ? theme.text.continueToPayment : theme.text.register} pending={paid ? theme.text.openingCheckout : theme.text.registering} />
|
|
66
68
|
</form>
|
|
67
69
|
);
|
|
68
70
|
}
|
|
@@ -5,7 +5,8 @@ import type { EventVolunteering } from "@the-smithy/hearth";
|
|
|
5
5
|
import { theme } from "@/theme";
|
|
6
6
|
import { volunteer, type VolunteerState } from "../actions";
|
|
7
7
|
import { formatShift } from "../format";
|
|
8
|
-
import {
|
|
8
|
+
import { fill } from "../ui";
|
|
9
|
+
import { Alert, FieldError, Honeypot, OptionCard, Question, Submit, Success, TextField, useFocusFirstInvalid } from "./fields";
|
|
9
10
|
|
|
10
11
|
const t = theme.form;
|
|
11
12
|
|
|
@@ -19,27 +20,28 @@ export function VolunteerForm({ slug, timezone, volunteering }: { slug: string;
|
|
|
19
20
|
const values = (state && !state.ok ? state.values : undefined) ?? {};
|
|
20
21
|
const chosen = (state && !state.ok ? state.shifts : undefined) ?? [];
|
|
21
22
|
const many = volunteering.pickShifts === "many";
|
|
23
|
+
const ref = useFocusFirstInvalid(state);
|
|
22
24
|
|
|
23
25
|
if (state?.ok) return <Success>{state.message}</Success>;
|
|
24
26
|
|
|
25
27
|
return (
|
|
26
|
-
<form action={action} className={t.root} noValidate>
|
|
28
|
+
<form ref={ref} action={action} className={t.root} noValidate>
|
|
27
29
|
{state && !state.ok ? <Alert>{state.message}</Alert> : null}
|
|
28
30
|
<Honeypot />
|
|
29
31
|
<div className={t.row}>
|
|
30
|
-
<TextField label=
|
|
31
|
-
<TextField label=
|
|
32
|
+
<TextField label={theme.text.yourName} name="name" required autoComplete="name" defaultValue={values.name} error={errors.name} />
|
|
33
|
+
<TextField label={theme.text.yourEmail} name="email" type="email" required autoComplete="email" defaultValue={values.email} error={errors.email} />
|
|
32
34
|
</div>
|
|
33
35
|
{volunteering.pickShifts !== "none" ? (
|
|
34
36
|
<fieldset>
|
|
35
|
-
<legend className={t.legend}>{many ?
|
|
37
|
+
<legend className={t.legend}>{many ? theme.text.shiftsMany : theme.text.shiftsOne}</legend>
|
|
36
38
|
<div className={t.options}>
|
|
37
39
|
{volunteering.shifts.map((s) => (
|
|
38
40
|
<OptionCard
|
|
39
41
|
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} />}
|
|
42
|
+
input={<input type={many ? "checkbox" : "radio"} name="shift" value={s.id} disabled={s.full} defaultChecked={chosen.includes(s.id)} data-invalid={errors.shifts && !s.full ? "" : undefined} className={t.optionInput} />}
|
|
41
43
|
title={s.name}
|
|
42
|
-
meta={s.full ?
|
|
44
|
+
meta={s.full ? theme.text.shiftFull : s.remaining !== null && s.remaining <= 5 ? fill(s.remaining === 1 ? theme.text.oneSpotLeft : theme.text.spotsLeft, { count: String(s.remaining) }) : null}
|
|
43
45
|
>
|
|
44
46
|
<span className={t.optionDetail}>{formatShift(s, timezone)}</span>
|
|
45
47
|
{s.description ? <span className={t.optionDetail}>{s.description}</span> : null}
|
|
@@ -52,7 +54,7 @@ export function VolunteerForm({ slug, timezone, volunteering }: { slug: string;
|
|
|
52
54
|
{volunteering.fields.map((f) => (
|
|
53
55
|
<Question key={f.name} field={f} idPrefix="volunteer" error={errors[f.name]} value={values[f.name]} />
|
|
54
56
|
))}
|
|
55
|
-
<Submit label={volunteering.submitLabel} pending=
|
|
57
|
+
<Submit label={volunteering.submitLabel} pending={theme.text.signingUp} />
|
|
56
58
|
</form>
|
|
57
59
|
);
|
|
58
60
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import type { BlockOf } from "@the-smithy/hearth";
|
|
4
|
-
import type
|
|
4
|
+
import { useEffect, useRef, type InputHTMLAttributes, type ReactNode } from "react";
|
|
5
5
|
import { useFormStatus } from "react-dom";
|
|
6
6
|
import { theme } from "@/theme";
|
|
7
7
|
|
|
@@ -12,6 +12,18 @@ const t = theme.form;
|
|
|
12
12
|
/** A question defined in Hearth (a form field, an event's volunteer question). */
|
|
13
13
|
type FormField = BlockOf<"form">["data"]["fields"] extends (infer F)[] | undefined ? F : never;
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* For a form's `ref`: after a submit comes back with errors, moves focus to the first field with one. Fields mark
|
|
17
|
+
* themselves with aria-invalid; choices that can't take it (a group of radio buttons) with data-invalid.
|
|
18
|
+
*/
|
|
19
|
+
export function useFocusFirstInvalid(state: unknown) {
|
|
20
|
+
const form = useRef<HTMLFormElement>(null);
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (state) form.current?.querySelector<HTMLElement>('[aria-invalid="true"], [data-invalid]')?.focus();
|
|
23
|
+
}, [state]);
|
|
24
|
+
return form;
|
|
25
|
+
}
|
|
26
|
+
|
|
15
27
|
export function Alert({ children }: { children: ReactNode }) {
|
|
16
28
|
return (
|
|
17
29
|
<p role="alert" className={t.alert}>
|
|
@@ -33,7 +45,7 @@ export function Honeypot() {
|
|
|
33
45
|
return (
|
|
34
46
|
<div aria-hidden className="absolute -left-[9999px]">
|
|
35
47
|
<label>
|
|
36
|
-
|
|
48
|
+
{theme.text.honeypot}
|
|
37
49
|
<input name="_gotcha" tabIndex={-1} autoComplete="off" />
|
|
38
50
|
</label>
|
|
39
51
|
</div>
|
|
@@ -65,7 +77,7 @@ export function TextField({ label, optional, error, textarea, rows = 3, ...input
|
|
|
65
77
|
<label className={t.field}>
|
|
66
78
|
<span className={t.label}>
|
|
67
79
|
{label}
|
|
68
|
-
{optional ? <span className={t.optional}>
|
|
80
|
+
{optional ? <span className={t.optional}>{theme.text.optional}</span> : null}
|
|
69
81
|
</span>
|
|
70
82
|
{textarea ? <textarea {...(common as object)} rows={rows} /> : <input {...common} />}
|
|
71
83
|
<FieldError id={describedBy}>{error}</FieldError>
|
|
@@ -97,14 +109,14 @@ export function Question({ field: f, idPrefix, error, value }: { field: FormFiel
|
|
|
97
109
|
<>
|
|
98
110
|
<label htmlFor={id} className={t.label}>
|
|
99
111
|
{f.label}
|
|
100
|
-
{f.required ? null : <span className={t.optional}>
|
|
112
|
+
{f.required ? null : <span className={t.optional}>{theme.text.optional}</span>}
|
|
101
113
|
</label>
|
|
102
114
|
{f.type === "textarea" ? (
|
|
103
115
|
<textarea {...common} rows={5} defaultValue={value} placeholder={f.placeholder} className={t.input} />
|
|
104
116
|
) : f.type === "select" ? (
|
|
105
117
|
<select {...common} defaultValue={value ?? ""} className={t.input}>
|
|
106
118
|
<option value="" disabled>
|
|
107
|
-
{f.placeholder ||
|
|
119
|
+
{f.placeholder || theme.text.chooseOne}
|
|
108
120
|
</option>
|
|
109
121
|
{(f.options ?? []).map((o) => (
|
|
110
122
|
<option key={o}>{o}</option>
|
|
@@ -1,52 +1,8 @@
|
|
|
1
1
|
/*
|
|
2
|
-
* Hearth's
|
|
3
|
-
*
|
|
2
|
+
* What Hearth's slots need to work, shared by every site: the on-dark variant. Don't edit it in a site: tokens and the
|
|
3
|
+
* site's own CSS go in src/theme/theme.css. Rich text and focus rings are in base.css, so that a site inside an app, whose
|
|
4
|
+
* own CSS already sets those, can import this file alone.
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
/* On an inverse or accent ground: set by sections with a dark tone, the hero, image calls to action and the footer. */
|
|
7
8
|
@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
|
-
}
|
|
@@ -25,7 +25,7 @@ export function MobileNav({ siteName, links, cta, dropdowns = {} }: { siteName:
|
|
|
25
25
|
<button type="button" className={t.toggle} aria-haspopup="dialog" onClick={() => dialog.current?.showModal()}>
|
|
26
26
|
{theme.text.menu}
|
|
27
27
|
</button>
|
|
28
|
-
<dialog ref={dialog} aria-label=
|
|
28
|
+
<dialog ref={dialog} aria-label={theme.text.menu} className={t.dialog}>
|
|
29
29
|
<div className={t.top}>
|
|
30
30
|
<p className={t.title}>{siteName}</p>
|
|
31
31
|
<button type="button" className={t.close} onClick={close}>
|
|
@@ -2,12 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
import { usePathname } from "next/navigation";
|
|
4
4
|
import type { ReactNode } from "react";
|
|
5
|
+
import { theme } from "@/theme";
|
|
5
6
|
import { SmartLink } from "../ui";
|
|
6
7
|
|
|
7
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* A menu link that marks itself as the current page (aria-current), for the theme's aria-[current=page]: variant. It
|
|
10
|
+
* counts as current on its own address and the ones under it, or only its own with `page.navMatch` set to "exact".
|
|
11
|
+
*/
|
|
8
12
|
export function NavLink({ href, className, onClick, children }: { href: string; className: string; onClick?: () => void; children: ReactNode }) {
|
|
9
13
|
const pathname = usePathname();
|
|
10
|
-
const current = href === "/"
|
|
14
|
+
const current = href === "/" || theme.page.navMatch === "exact" ? pathname === href : pathname === href || pathname.startsWith(`${href}/`);
|
|
11
15
|
return (
|
|
12
16
|
<SmartLink href={href} className={className} onClick={onClick} aria-current={current ? "page" : undefined}>
|
|
13
17
|
{children}
|
|
@@ -1,14 +1,10 @@
|
|
|
1
|
+
import type { Viewport } from "next";
|
|
1
2
|
import type { ReactNode } from "react";
|
|
2
|
-
import { HearthAnalytics } from "@the-smithy/hearth/analytics";
|
|
3
3
|
import { siteLang } from "@the-smithy/hearth/next";
|
|
4
|
-
import { HearthPreview } from "@the-smithy/hearth/preview";
|
|
5
4
|
import { theme } from "@/theme";
|
|
6
|
-
import { Blocks } from "../blocks";
|
|
7
5
|
import { fontVariables } from "@/theme/fonts";
|
|
8
6
|
import { hearthNext } from "../client";
|
|
9
|
-
import
|
|
10
|
-
import { SiteFooter } from "./SiteFooter";
|
|
11
|
-
import { SiteHeader } from "./SiteHeader";
|
|
7
|
+
import SiteFrame from "./SiteFrame";
|
|
12
8
|
|
|
13
9
|
/**
|
|
14
10
|
* What every page starts from: full links, the title pattern, the default description and share image, the site's
|
|
@@ -17,35 +13,15 @@ import { SiteHeader } from "./SiteHeader";
|
|
|
17
13
|
*/
|
|
18
14
|
export const generateMetadata = () => hearthNext.siteMetadata();
|
|
19
15
|
|
|
16
|
+
/** The browser's color around the page on phones (`page.themeColor`), when the site sets one. */
|
|
17
|
+
export const viewport: Viewport = theme.page.themeColor ? { themeColor: theme.page.themeColor } : {};
|
|
18
|
+
|
|
20
19
|
export default async function RootLayout({ children }: { children: ReactNode }) {
|
|
21
|
-
const
|
|
22
|
-
const t = theme.page;
|
|
23
|
-
// Global blocks (Global blocks in Hearth) shown outside pages: under menu items, and as pop-ups.
|
|
24
|
-
const dropdowns = Object.fromEntries(
|
|
25
|
-
globals.filter((g) => site.settings.navigation.some((n) => n.dropdown === g.key)).map((g) => [g.key, <Blocks key={g.key} blocks={[g.block]} />]),
|
|
26
|
-
);
|
|
20
|
+
const site = await hearthNext.getSite();
|
|
27
21
|
return (
|
|
28
22
|
<html lang={siteLang(site)} className={fontVariables || undefined}>
|
|
29
|
-
<body className={
|
|
30
|
-
<
|
|
31
|
-
Skip to content
|
|
32
|
-
</a>
|
|
33
|
-
{/* Live draft preview from Hearth: shows while an editor previews, refreshes as they edit. */}
|
|
34
|
-
{preview ? <HearthPreview /> : null}
|
|
35
|
-
<SiteHeader site={site} dropdowns={dropdowns} />
|
|
36
|
-
<main id="main" className={t.main}>
|
|
37
|
-
{children}
|
|
38
|
-
</main>
|
|
39
|
-
<SiteFooter site={site} />
|
|
40
|
-
{globals.map((g) =>
|
|
41
|
-
g.popup ? (
|
|
42
|
-
<Popup key={g.key} popupKey={g.key} name={g.name} title={g.popup.title} button={g.popup.button}>
|
|
43
|
-
<Blocks blocks={[g.block]} />
|
|
44
|
-
</Popup>
|
|
45
|
-
) : null,
|
|
46
|
-
)}
|
|
47
|
-
{/* Hearth's built-in visitor analytics (cookieless, no banner needed): off in draft preview and when the site turns it off. */}
|
|
48
|
-
{site.analytics && !preview ? <HearthAnalytics config={site.analytics} /> : null}
|
|
23
|
+
<body className={theme.page.body}>
|
|
24
|
+
<SiteFrame>{children}</SiteFrame>
|
|
49
25
|
</body>
|
|
50
26
|
</html>
|
|
51
27
|
);
|