@the-smithy/create-hearth-site 0.24.0 → 0.25.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/package.json +3 -3
- package/template/AGENTS.md +4 -0
- package/template/CLAUDE.md +72 -10
- package/template/README.md +18 -4
- package/template/package.json +3 -2
- package/template/scripts/hearth.mjs +56 -8
- 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/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
|
@@ -8,18 +8,65 @@ import { hearthNext } from "../client";
|
|
|
8
8
|
import { formatEventDate } from "../format";
|
|
9
9
|
import { RegistrationForm } from "../forms/RegistrationForm";
|
|
10
10
|
import { VolunteerForm } from "../forms/VolunteerForm";
|
|
11
|
+
import { overrides } from "../overrides";
|
|
11
12
|
import { siteUrl } from "../site";
|
|
12
13
|
import { fill, Img } from "../ui";
|
|
13
14
|
import { Registration } from "./Registration";
|
|
14
15
|
import { Share } from "./Share";
|
|
15
16
|
|
|
17
|
+
/** An event's page: its structured data, and the site's EventPage override or Hearth's own. Used at /events/… and /<collection>/…. */
|
|
18
|
+
export function EventView({ event }: { event: HearthEventDetail }) {
|
|
19
|
+
const Page = overrides.EventPage ?? EventContent;
|
|
20
|
+
return (
|
|
21
|
+
<>
|
|
22
|
+
<JsonLd data={event.meta.jsonLd} />
|
|
23
|
+
<Page event={event} />
|
|
24
|
+
</>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Registration on the event's page (sites with Tickets), while it's open. For an EventPage override to reuse. */
|
|
29
|
+
export function EventRegisterSection({ event }: { event: HearthEventDetail }) {
|
|
30
|
+
const t = theme.event;
|
|
31
|
+
if (!event.registration?.open) return null;
|
|
32
|
+
return (
|
|
33
|
+
<section id="register" className={t.section}>
|
|
34
|
+
<div className={t.sectionInner}>
|
|
35
|
+
<h2 className={t.sectionTitle}>{theme.text.register}</h2>
|
|
36
|
+
{event.registration.fundraiser ? <p className={t.sectionNote}>{fill(theme.text.ticketsSupport, { fundraiser: event.registration.fundraiser.title })}</p> : null}
|
|
37
|
+
<div className={t.form}>
|
|
38
|
+
<RegistrationForm slug={event.slug} tickets={event.registration.tickets} />
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</section>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Volunteer sign-up on the event's page, while it's open. For an EventPage override to reuse; leave it out if the event's blocks have a Volunteer sign-up. */
|
|
46
|
+
export function EventVolunteerSection({ event }: { event: HearthEventDetail }) {
|
|
47
|
+
const t = theme.event;
|
|
48
|
+
if (!event.volunteering?.open) return null;
|
|
49
|
+
return (
|
|
50
|
+
<section id="volunteer" className={t.section}>
|
|
51
|
+
<div className={t.sectionInner}>
|
|
52
|
+
<h2 className={t.sectionTitle}>{theme.text.volunteer}</h2>
|
|
53
|
+
{event.volunteering.intro ? <p className={t.sectionIntro}>{event.volunteering.intro}</p> : null}
|
|
54
|
+
{event.volunteering.fundraiser ? <p className={t.sectionNote}>{fill(theme.text.supports, { fundraiser: event.volunteering.fundraiser.title })}</p> : null}
|
|
55
|
+
<div className={t.form}>
|
|
56
|
+
<VolunteerForm slug={event.slug} timezone={event.timezone} volunteering={event.volunteering} />
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</section>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
16
63
|
/**
|
|
17
|
-
*
|
|
64
|
+
* Hearth's own event page: header with date, place and registration, then its blocks.
|
|
18
65
|
* When the event's blocks start with a hero, the hero is the header (its heading is the page's only h1, the event's
|
|
19
66
|
* name its eyebrow, the event's image its picture if it has none) and the details sit in a band under it.
|
|
20
67
|
* Volunteer sign-up goes at the bottom, unless a Volunteer sign-up block for the event is placed on the page.
|
|
21
68
|
*/
|
|
22
|
-
export async function
|
|
69
|
+
export async function EventContent({ event }: { event: HearthEventDetail }) {
|
|
23
70
|
const t = theme.event;
|
|
24
71
|
const site = await hearthNext.getSite();
|
|
25
72
|
const base = siteUrl(site);
|
|
@@ -27,7 +74,7 @@ export async function EventView({ event }: { event: HearthEventDetail }) {
|
|
|
27
74
|
const [first, ...rest] = event.blocks;
|
|
28
75
|
const hero = first?.type === "hero" ? (first as BlockOf<"hero">) : null;
|
|
29
76
|
// A Volunteer sign-up block for this event puts the form where the editor placed it, instead of at the bottom.
|
|
30
|
-
const volunteerBlock = event.blocks.
|
|
77
|
+
const volunteerBlock = event.blocks.find((b) => b.type === "volunteerSignup" && (b as BlockOf<"volunteerSignup">).data.event.slug === event.slug);
|
|
31
78
|
const online = event.attendance === "online" ? theme.text.online : event.attendance === "mixed" ? theme.text.onlineAndInPerson : null;
|
|
32
79
|
|
|
33
80
|
const cancelled = event.status === "cancelled" ? <p className={t.cancelled}>{theme.text.cancelledEvent}</p> : null;
|
|
@@ -49,17 +96,23 @@ export async function EventView({ event }: { event: HearthEventDetail }) {
|
|
|
49
96
|
<div className={t.actions}>
|
|
50
97
|
<Registration event={event} />
|
|
51
98
|
{event.volunteering?.open ? (
|
|
52
|
-
<a href={volunteerBlock ? `#${volunteerAnchor(event.slug)}` : "#volunteer"} className={t.volunteer}>
|
|
99
|
+
<a href={volunteerBlock ? `#${volunteerBlock.anchor ?? volunteerAnchor(event.slug)}` : "#volunteer"} className={t.volunteer}>
|
|
53
100
|
{theme.text.volunteer}
|
|
54
101
|
</a>
|
|
55
102
|
) : null}
|
|
56
|
-
{base && event.status !== "cancelled" ?
|
|
103
|
+
{base && event.status !== "cancelled" ? (
|
|
104
|
+
<Share
|
|
105
|
+
title={event.title}
|
|
106
|
+
url={`${base}${event.path}`}
|
|
107
|
+
message={fill(theme.text.shareEventMessage, { title: event.title, date: formatEventDate(event), url: `${base}${event.path}` })}
|
|
108
|
+
heading={fill(theme.text.shareEventHeading, { title: event.title })}
|
|
109
|
+
/>
|
|
110
|
+
) : null}
|
|
57
111
|
</div>
|
|
58
112
|
);
|
|
59
113
|
|
|
60
114
|
return (
|
|
61
115
|
<>
|
|
62
|
-
<JsonLd data={event.meta.jsonLd} />
|
|
63
116
|
{hero ? (
|
|
64
117
|
<>
|
|
65
118
|
<Hero block={{ ...hero, data: { ...hero.data, eyebrow: hero.data.eyebrow ?? event.title, image: hero.data.image ?? event.image ?? undefined } }} />
|
|
@@ -92,29 +145,8 @@ export async function EventView({ event }: { event: HearthEventDetail }) {
|
|
|
92
145
|
</section>
|
|
93
146
|
)}
|
|
94
147
|
<Blocks blocks={hero ? rest : event.blocks} />
|
|
95
|
-
{event
|
|
96
|
-
|
|
97
|
-
<div className={t.sectionInner}>
|
|
98
|
-
<h2 className={t.sectionTitle}>{theme.text.register}</h2>
|
|
99
|
-
{event.registration.fundraiser ? <p className={t.sectionNote}>{fill(theme.text.ticketsSupport, { fundraiser: event.registration.fundraiser.title })}</p> : null}
|
|
100
|
-
<div className={t.form}>
|
|
101
|
-
<RegistrationForm slug={event.slug} tickets={event.registration.tickets} />
|
|
102
|
-
</div>
|
|
103
|
-
</div>
|
|
104
|
-
</section>
|
|
105
|
-
) : null}
|
|
106
|
-
{event.volunteering?.open && !volunteerBlock ? (
|
|
107
|
-
<section id="volunteer" className={t.section}>
|
|
108
|
-
<div className={t.sectionInner}>
|
|
109
|
-
<h2 className={t.sectionTitle}>{theme.text.volunteer}</h2>
|
|
110
|
-
{event.volunteering.intro ? <p className={t.sectionIntro}>{event.volunteering.intro}</p> : null}
|
|
111
|
-
{event.volunteering.fundraiser ? <p className={t.sectionNote}>{fill(theme.text.supports, { fundraiser: event.volunteering.fundraiser.title })}</p> : null}
|
|
112
|
-
<div className={t.form}>
|
|
113
|
-
<VolunteerForm slug={event.slug} timezone={event.timezone} volunteering={event.volunteering} />
|
|
114
|
-
</div>
|
|
115
|
-
</div>
|
|
116
|
-
</section>
|
|
117
|
-
) : null}
|
|
148
|
+
<EventRegisterSection event={event} />
|
|
149
|
+
{volunteerBlock ? null : <EventVolunteerSection event={event} />}
|
|
118
150
|
</>
|
|
119
151
|
);
|
|
120
152
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { FundraiserDetail } from "@the-smithy/hearth";
|
|
2
|
+
import { JsonLd } from "@the-smithy/hearth/react";
|
|
3
|
+
import { theme } from "@/theme";
|
|
4
|
+
import { Blocks } from "../blocks";
|
|
5
|
+
import { Progress } from "../blocks/giving";
|
|
6
|
+
import { hearthNext } from "../client";
|
|
7
|
+
import { DonationForm } from "../forms/DonationForm";
|
|
8
|
+
import { money } from "../format";
|
|
9
|
+
import { overrides } from "../overrides";
|
|
10
|
+
import { siteUrl } from "../site";
|
|
11
|
+
import { fill, Img, Section } from "../ui";
|
|
12
|
+
import { Share } from "./Share";
|
|
13
|
+
|
|
14
|
+
/** A fundraiser's page: its structured data, and the site's FundraiserPage override or Hearth's own. */
|
|
15
|
+
export function FundraiserView({ fundraiser }: { fundraiser: FundraiserDetail }) {
|
|
16
|
+
const Page = overrides.FundraiserPage ?? FundraiserContent;
|
|
17
|
+
return (
|
|
18
|
+
<>
|
|
19
|
+
<JsonLd data={fundraiser.meta.jsonLd} />
|
|
20
|
+
<Page fundraiser={fundraiser} />
|
|
21
|
+
</>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Hearth's own fundraiser page: progress, Donate and Share, then its story blocks and the donation form. */
|
|
26
|
+
export async function FundraiserContent({ fundraiser: f }: { fundraiser: FundraiserDetail }) {
|
|
27
|
+
const site = await hearthNext.getSite();
|
|
28
|
+
const giving = site.settings.giving;
|
|
29
|
+
const t = theme.fundraiser;
|
|
30
|
+
const base = siteUrl(site);
|
|
31
|
+
const url = base ? `${base}${f.path}` : null;
|
|
32
|
+
const active = f.status === "active";
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<>
|
|
36
|
+
<section className={t.header}>
|
|
37
|
+
<div className={t.headerInner}>
|
|
38
|
+
<div className={t.details}>
|
|
39
|
+
<p className={t.label}>{active ? theme.text.fundraiserLabel : theme.text.completedFundraiserLabel}</p>
|
|
40
|
+
<h1 className={t.title}>{f.title}</h1>
|
|
41
|
+
{f.summary ? <p className={t.summary}>{f.summary}</p> : null}
|
|
42
|
+
<Progress fundraiser={f} large />
|
|
43
|
+
{active ? (
|
|
44
|
+
<div className={t.actions}>
|
|
45
|
+
<a href="#donate" className={theme.button.primary}>
|
|
46
|
+
{theme.text.donate}
|
|
47
|
+
</a>
|
|
48
|
+
{url ? (
|
|
49
|
+
<Share
|
|
50
|
+
title={f.title}
|
|
51
|
+
url={url}
|
|
52
|
+
message={
|
|
53
|
+
f.goalCents
|
|
54
|
+
? fill(theme.text.shareFundraiserGoalMessage, { title: f.title, raised: money(f.raisedCents), goal: money(f.goalCents), url })
|
|
55
|
+
: fill(theme.text.shareFundraiserMessage, { title: f.title, url })
|
|
56
|
+
}
|
|
57
|
+
heading={theme.text.shareFundraiserHeading}
|
|
58
|
+
/>
|
|
59
|
+
) : null}
|
|
60
|
+
</div>
|
|
61
|
+
) : null}
|
|
62
|
+
</div>
|
|
63
|
+
{f.image ? <Img image={f.image} crop={4 / 3} className={t.image} /> : null}
|
|
64
|
+
</div>
|
|
65
|
+
</section>
|
|
66
|
+
<Blocks blocks={f.blocks} />
|
|
67
|
+
{active ? (
|
|
68
|
+
<Section block="fundraiserDonate">
|
|
69
|
+
<div className={t.give}>
|
|
70
|
+
<h2 className={t.giveTitle}>{fill(theme.text.giveTo, { fundraiser: f.title })}</h2>
|
|
71
|
+
<DonationForm presetsCents={giving.presetsCents} minCents={giving.minCents} maxCents={giving.maxCents} fundraiser={f.slug} />
|
|
72
|
+
</div>
|
|
73
|
+
</Section>
|
|
74
|
+
) : null}
|
|
75
|
+
</>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { usePathname } from "next/navigation";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
|
|
6
|
+
/** Draws its children everywhere but at `href`: a link to all of something isn't needed on the page it points at. */
|
|
7
|
+
export function NotHere({ href, children }: { href: string; children: ReactNode }) {
|
|
8
|
+
return usePathname() === href ? null : children;
|
|
9
|
+
}
|
|
@@ -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
|
}
|