@the-smithy/create-hearth-site 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +51 -23
- 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/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 +36 -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
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
import { videoEmbedUrl, type BlockOf } from "@the-smithy/hearth";
|
|
2
2
|
import { HearthEmbed } from "@the-smithy/hearth/react";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
const columns = { "2": "sm:grid-cols-2", "3": "sm:grid-cols-2 lg:grid-cols-3", "4": "sm:grid-cols-2 lg:grid-cols-4" } as const;
|
|
3
|
+
import { theme } from "@/theme";
|
|
4
|
+
import { Img, Section, SectionHeading } from "../ui";
|
|
6
5
|
|
|
7
6
|
export function Gallery({ block }: { block: BlockOf<"gallery"> }) {
|
|
8
7
|
const d = block.data;
|
|
8
|
+
const t = theme.gallery;
|
|
9
9
|
return (
|
|
10
|
-
<Section>
|
|
10
|
+
<Section block="gallery">
|
|
11
11
|
<SectionHeading heading={d.heading} intro={d.intro} />
|
|
12
|
-
<ul className={
|
|
12
|
+
<ul className={t.list} data-columns={d.columns}>
|
|
13
13
|
{d.images.map((item, i) => (
|
|
14
14
|
<li key={i}>
|
|
15
15
|
<figure>
|
|
16
|
-
<Img image={item.image} crop={4 / 3} className=
|
|
17
|
-
{item.caption ? <figcaption className=
|
|
16
|
+
<Img image={item.image} crop={4 / 3} className={t.image} />
|
|
17
|
+
{item.caption ? <figcaption className={t.caption}>{item.caption}</figcaption> : null}
|
|
18
18
|
</figure>
|
|
19
19
|
</li>
|
|
20
20
|
))}
|
|
@@ -25,13 +25,14 @@ export function Gallery({ block }: { block: BlockOf<"gallery"> }) {
|
|
|
25
25
|
|
|
26
26
|
export function Video({ block }: { block: BlockOf<"video"> }) {
|
|
27
27
|
const d = block.data;
|
|
28
|
+
const t = theme.video;
|
|
28
29
|
const src = videoEmbedUrl(d.url);
|
|
29
30
|
if (!src) return null;
|
|
30
31
|
return (
|
|
31
|
-
<Section>
|
|
32
|
+
<Section block="video">
|
|
32
33
|
<SectionHeading heading={d.heading} />
|
|
33
|
-
<figure className=
|
|
34
|
-
<div className=
|
|
34
|
+
<figure className={t.figure}>
|
|
35
|
+
<div className={t.frame}>
|
|
35
36
|
<iframe
|
|
36
37
|
src={src}
|
|
37
38
|
title={d.title}
|
|
@@ -41,7 +42,7 @@ export function Video({ block }: { block: BlockOf<"video"> }) {
|
|
|
41
42
|
referrerPolicy="strict-origin-when-cross-origin"
|
|
42
43
|
/>
|
|
43
44
|
</div>
|
|
44
|
-
{d.caption ? <figcaption className=
|
|
45
|
+
{d.caption ? <figcaption className={t.caption}>{d.caption}</figcaption> : null}
|
|
45
46
|
</figure>
|
|
46
47
|
</Section>
|
|
47
48
|
);
|
|
@@ -49,12 +50,12 @@ export function Video({ block }: { block: BlockOf<"video"> }) {
|
|
|
49
50
|
|
|
50
51
|
/** Embed code from another service, in the SDK's sandboxed frame (it can't reach the rest of the page). */
|
|
51
52
|
export function Embed({ block }: { block: BlockOf<"embed"> }) {
|
|
52
|
-
const
|
|
53
|
+
const t = theme.embed;
|
|
53
54
|
return (
|
|
54
|
-
<Section>
|
|
55
|
-
<figure className={width}>
|
|
56
|
-
<HearthEmbed block={block} className=
|
|
57
|
-
{block.data.caption ? <figcaption className=
|
|
55
|
+
<Section block="embed">
|
|
56
|
+
<figure className={t.figure} data-width={block.data.width}>
|
|
57
|
+
<HearthEmbed block={block} className={t.frame} />
|
|
58
|
+
{block.data.caption ? <figcaption className={t.caption}>{block.data.caption}</figcaption> : null}
|
|
58
59
|
</figure>
|
|
59
60
|
</Section>
|
|
60
61
|
);
|
|
@@ -62,18 +63,19 @@ export function Embed({ block }: { block: BlockOf<"embed"> }) {
|
|
|
62
63
|
|
|
63
64
|
export function People({ block }: { block: BlockOf<"people"> }) {
|
|
64
65
|
const d = block.data;
|
|
66
|
+
const t = theme.people;
|
|
65
67
|
return (
|
|
66
|
-
<Section>
|
|
68
|
+
<Section block="people">
|
|
67
69
|
<SectionHeading eyebrow={d.eyebrow} heading={d.heading} intro={d.intro} />
|
|
68
|
-
<ul className=
|
|
70
|
+
<ul className={t.list}>
|
|
69
71
|
{d.people.map((p, i) => (
|
|
70
|
-
<li key={i} className=
|
|
71
|
-
{p.image ? <Img image={p.image} crop={1} className=
|
|
72
|
+
<li key={i} className={t.person}>
|
|
73
|
+
{p.image ? <Img image={p.image} crop={1} className={t.image} /> : null}
|
|
72
74
|
<div>
|
|
73
|
-
<h3 className=
|
|
74
|
-
{p.role ? <p className=
|
|
75
|
+
<h3 className={t.name}>{p.name}</h3>
|
|
76
|
+
{p.role ? <p className={t.role}>{p.role}</p> : null}
|
|
75
77
|
</div>
|
|
76
|
-
{p.bio ? <p className=
|
|
78
|
+
{p.bio ? <p className={t.bio}>{p.bio}</p> : null}
|
|
77
79
|
</li>
|
|
78
80
|
))}
|
|
79
81
|
</ul>
|
|
@@ -83,16 +85,17 @@ export function People({ block }: { block: BlockOf<"people"> }) {
|
|
|
83
85
|
|
|
84
86
|
export function Logos({ block }: { block: BlockOf<"logos"> }) {
|
|
85
87
|
const d = block.data;
|
|
88
|
+
const t = theme.logos;
|
|
86
89
|
return (
|
|
87
|
-
<Section>
|
|
90
|
+
<Section block="logos">
|
|
88
91
|
<SectionHeading heading={d.heading} intro={d.intro} />
|
|
89
|
-
<ul className=
|
|
92
|
+
<ul className={t.list}>
|
|
90
93
|
{d.logos.map((l, i) => {
|
|
91
|
-
const mark = l.logo ? <Img image={{ src: l.logo.src, alt: l.logo.alt || l.name }} className=
|
|
94
|
+
const mark = l.logo ? <Img image={{ src: l.logo.src, alt: l.logo.alt || l.name }} className={t.image} /> : <span className={t.name}>{l.name}</span>;
|
|
92
95
|
return (
|
|
93
96
|
<li key={i}>
|
|
94
97
|
{l.url ? (
|
|
95
|
-
<a href={l.url} rel="noopener" className=
|
|
98
|
+
<a href={l.url} rel="noopener" className={t.link}>
|
|
96
99
|
{mark}
|
|
97
100
|
</a>
|
|
98
101
|
) : (
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { BlockOf, HearthEventDetail } from "@the-smithy/hearth";
|
|
2
|
+
import { JsonLd } from "@the-smithy/hearth/react";
|
|
3
|
+
import { theme } from "@/theme";
|
|
4
|
+
import { Blocks } from "../blocks";
|
|
5
|
+
import { Hero } from "../blocks/content";
|
|
6
|
+
import { volunteerAnchor } from "../blocks/events";
|
|
7
|
+
import { hearthNext } from "../client";
|
|
8
|
+
import { formatEventDate } from "../format";
|
|
9
|
+
import { RegistrationForm } from "../forms/RegistrationForm";
|
|
10
|
+
import { VolunteerForm } from "../forms/VolunteerForm";
|
|
11
|
+
import { siteUrl } from "../site";
|
|
12
|
+
import { fill, Img } from "../ui";
|
|
13
|
+
import { Registration } from "./Registration";
|
|
14
|
+
import { Share } from "./Share";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* An event's page: header with date, place and registration, then its blocks. Used at /events/… and /<collection>/….
|
|
18
|
+
* 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
|
+
* name its eyebrow, the event's image its picture if it has none) and the details sit in a band under it.
|
|
20
|
+
* Volunteer sign-up goes at the bottom, unless a Volunteer sign-up block for the event is placed on the page.
|
|
21
|
+
*/
|
|
22
|
+
export async function EventView({ event }: { event: HearthEventDetail }) {
|
|
23
|
+
const t = theme.event;
|
|
24
|
+
const site = await hearthNext.getSite();
|
|
25
|
+
const base = siteUrl(site);
|
|
26
|
+
const { location } = event;
|
|
27
|
+
const [first, ...rest] = event.blocks;
|
|
28
|
+
const hero = first?.type === "hero" ? (first as BlockOf<"hero">) : null;
|
|
29
|
+
// 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.some((b) => b.type === "volunteerSignup" && (b as BlockOf<"volunteerSignup">).data.event.slug === event.slug);
|
|
31
|
+
const online = event.attendance === "online" ? theme.text.online : event.attendance === "mixed" ? theme.text.onlineAndInPerson : null;
|
|
32
|
+
|
|
33
|
+
const cancelled = event.status === "cancelled" ? <p className={t.cancelled}>{theme.text.cancelledEvent}</p> : null;
|
|
34
|
+
const when = (
|
|
35
|
+
<p className={t.when}>
|
|
36
|
+
<time dateTime={event.startsAt}>{formatEventDate(event)}</time>
|
|
37
|
+
</p>
|
|
38
|
+
);
|
|
39
|
+
const where =
|
|
40
|
+
location.name || location.address || online ? (
|
|
41
|
+
<p className={t.where}>
|
|
42
|
+
{location.name ? <span className={t.placeName}>{location.name}</span> : null}
|
|
43
|
+
{location.address ? <span className={t.address}>{location.address}</span> : null}
|
|
44
|
+
{online ? <span className={t.online}>{online}</span> : null}
|
|
45
|
+
</p>
|
|
46
|
+
) : null;
|
|
47
|
+
const summary = event.summary ? <p className={t.summary}>{event.summary}</p> : null;
|
|
48
|
+
const actions = (
|
|
49
|
+
<div className={t.actions}>
|
|
50
|
+
<Registration event={event} />
|
|
51
|
+
{event.volunteering?.open ? (
|
|
52
|
+
<a href={volunteerBlock ? `#${volunteerAnchor(event.slug)}` : "#volunteer"} className={t.volunteer}>
|
|
53
|
+
{theme.text.volunteer}
|
|
54
|
+
</a>
|
|
55
|
+
) : null}
|
|
56
|
+
{base && event.status !== "cancelled" ? <Share title={event.title} url={`${base}${event.path}`} message={`${event.title}, ${formatEventDate(event)}: ${base}${event.path}`} heading={`Share ${event.title}`} /> : null}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<>
|
|
62
|
+
<JsonLd data={event.meta.jsonLd} />
|
|
63
|
+
{hero ? (
|
|
64
|
+
<>
|
|
65
|
+
<Hero block={{ ...hero, data: { ...hero.data, eyebrow: hero.data.eyebrow ?? event.title, image: hero.data.image ?? event.image ?? undefined } }} />
|
|
66
|
+
<section className={t.band}>
|
|
67
|
+
<div className={t.bandInner}>
|
|
68
|
+
<div className={t.bandDetails}>
|
|
69
|
+
{cancelled}
|
|
70
|
+
{when}
|
|
71
|
+
{where}
|
|
72
|
+
{hero.data.body ? null : summary}
|
|
73
|
+
</div>
|
|
74
|
+
{actions}
|
|
75
|
+
</div>
|
|
76
|
+
</section>
|
|
77
|
+
</>
|
|
78
|
+
) : (
|
|
79
|
+
<section className={t.header}>
|
|
80
|
+
<div className={t.headerInner}>
|
|
81
|
+
<div className={t.details}>
|
|
82
|
+
{event.collection ? <p className={t.collection}>{event.collection.name}</p> : null}
|
|
83
|
+
{cancelled}
|
|
84
|
+
<h1 className={t.title}>{event.title}</h1>
|
|
85
|
+
{when}
|
|
86
|
+
{where}
|
|
87
|
+
{summary}
|
|
88
|
+
{actions}
|
|
89
|
+
</div>
|
|
90
|
+
{event.image ? <Img image={event.image} crop={4 / 3} className={t.image} /> : null}
|
|
91
|
+
</div>
|
|
92
|
+
</section>
|
|
93
|
+
)}
|
|
94
|
+
<Blocks blocks={hero ? rest : event.blocks} />
|
|
95
|
+
{event.registration?.open ? (
|
|
96
|
+
<section id="register" className={t.section}>
|
|
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}
|
|
118
|
+
</>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { HearthEvent } from "@the-smithy/hearth";
|
|
2
|
-
import {
|
|
2
|
+
import { theme } from "@/theme";
|
|
3
|
+
import { hearthNext } from "../client";
|
|
3
4
|
|
|
4
5
|
/** Upcoming until it ends (or, without an end time, until it starts). */
|
|
5
6
|
const isUpcoming = (e: Pick<HearthEvent, "startsAt" | "endsAt">) => new Date(e.endsAt ?? e.startsAt).getTime() > Date.now();
|
|
@@ -11,32 +12,31 @@ const isUpcoming = (e: Pick<HearthEvent, "startsAt" | "endsAt">) => new Date(e.e
|
|
|
11
12
|
*/
|
|
12
13
|
export async function Registration({ event, size = "large" }: { event: HearthEvent; size?: "large" | "small" }) {
|
|
13
14
|
if (event.status === "cancelled" || !isUpcoming(event)) return null;
|
|
15
|
+
const small = size === "small" ? "small" : undefined;
|
|
16
|
+
const note = (text: string) => (
|
|
17
|
+
<p className={theme.registration.note} data-size={small}>
|
|
18
|
+
{text}
|
|
19
|
+
</p>
|
|
20
|
+
);
|
|
14
21
|
if (event.registration) {
|
|
15
22
|
if (!event.registration.open) {
|
|
16
23
|
const soldOut = event.registration.tickets.length > 0 && event.registration.tickets.every((t) => t.soldOut);
|
|
17
|
-
return
|
|
24
|
+
return note(soldOut ? theme.text.soldOut : theme.text.registrationClosed);
|
|
18
25
|
}
|
|
19
26
|
return (
|
|
20
|
-
<a
|
|
21
|
-
|
|
22
|
-
className={`relative z-10 w-fit rounded-lg bg-accent font-semibold text-accent-ink hover:opacity-90 ${size === "large" ? "px-6 py-3.5" : "px-4 py-2 text-sm"}`}
|
|
23
|
-
>
|
|
24
|
-
Register
|
|
27
|
+
<a href={`${event.path}#register`} className={theme.button.primary} data-size={small}>
|
|
28
|
+
{theme.text.register}
|
|
25
29
|
</a>
|
|
26
30
|
);
|
|
27
31
|
}
|
|
28
32
|
if (event.registrationUrl) {
|
|
29
33
|
return (
|
|
30
|
-
<a
|
|
31
|
-
|
|
32
|
-
rel="noopener"
|
|
33
|
-
className={`relative z-10 w-fit rounded-lg bg-accent font-semibold text-accent-ink hover:opacity-90 ${size === "large" ? "px-6 py-3.5" : "px-4 py-2 text-sm"}`}
|
|
34
|
-
>
|
|
35
|
-
Register
|
|
34
|
+
<a href={event.registrationUrl} rel="noopener" className={theme.button.primary} data-size={small}>
|
|
35
|
+
{theme.text.register}
|
|
36
36
|
</a>
|
|
37
37
|
);
|
|
38
38
|
}
|
|
39
39
|
const { events } = (await hearthNext.getSite()).settings;
|
|
40
40
|
if (!events.registrationComingSoon) return null;
|
|
41
|
-
return
|
|
41
|
+
return note(events.comingSoonText || theme.text.comingSoon);
|
|
42
42
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
4
|
+
import { theme } from "@/theme";
|
|
5
|
+
|
|
6
|
+
const t = theme.share;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A Share button (event and fundraiser pages): a dialog with a message people can edit, copy buttons, and links to
|
|
10
|
+
* share it by Facebook, X, email or text, or the phone's own share sheet.
|
|
11
|
+
*/
|
|
12
|
+
export function Share({ title, url, message, heading }: { title: string; url: string; message: string; heading: string }) {
|
|
13
|
+
const dialog = useRef<HTMLDialogElement>(null);
|
|
14
|
+
const id = useId();
|
|
15
|
+
const [text, setText] = useState(message);
|
|
16
|
+
const [status, setStatus] = useState("");
|
|
17
|
+
const [nativeShare, setNativeShare] = useState(false);
|
|
18
|
+
|
|
19
|
+
// navigator.share only exists in the browser, and mostly on phones.
|
|
20
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- feature detection after mount
|
|
21
|
+
useEffect(() => setNativeShare(typeof navigator.share === "function"), []);
|
|
22
|
+
|
|
23
|
+
async function copy(value: string, label: string) {
|
|
24
|
+
try {
|
|
25
|
+
await navigator.clipboard.writeText(value);
|
|
26
|
+
setStatus(`${label} copied.`);
|
|
27
|
+
} catch {
|
|
28
|
+
setStatus("Couldn't copy automatically. Select the text and copy it.");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const enc = encodeURIComponent;
|
|
33
|
+
const targets = [
|
|
34
|
+
{ label: "Facebook", href: `https://www.facebook.com/sharer/sharer.php?u=${enc(url)}` },
|
|
35
|
+
{ label: "X", href: `https://x.com/intent/post?text=${enc(text)}` },
|
|
36
|
+
{ label: "Email", href: `mailto:?subject=${enc(title)}&body=${enc(text)}` },
|
|
37
|
+
{ label: "Text message", href: `sms:?&body=${enc(text)}` },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<>
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
className={t.button}
|
|
45
|
+
onClick={() => {
|
|
46
|
+
setText(message);
|
|
47
|
+
setStatus("");
|
|
48
|
+
dialog.current?.showModal();
|
|
49
|
+
}}
|
|
50
|
+
>
|
|
51
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
52
|
+
<path d="M4 12v7a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7M16 6l-4-4-4 4M12 2v14" />
|
|
53
|
+
</svg>
|
|
54
|
+
{theme.text.share}
|
|
55
|
+
</button>
|
|
56
|
+
<dialog ref={dialog} aria-labelledby={`${id}-title`} className={t.dialog} onClick={(e) => e.target === e.currentTarget && e.currentTarget.close()}>
|
|
57
|
+
<div className={t.top}>
|
|
58
|
+
<h2 id={`${id}-title`} className={t.title}>
|
|
59
|
+
{heading}
|
|
60
|
+
</h2>
|
|
61
|
+
<button type="button" onClick={() => dialog.current?.close()} className={t.close} aria-label={theme.text.close}>
|
|
62
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
|
|
63
|
+
<path d="M6 6l12 12M18 6L6 18" />
|
|
64
|
+
</svg>
|
|
65
|
+
</button>
|
|
66
|
+
</div>
|
|
67
|
+
<div className={t.body}>
|
|
68
|
+
<label className={theme.form.field}>
|
|
69
|
+
<span className={theme.form.label}>Your message</span>
|
|
70
|
+
<textarea id={`${id}-message`} rows={5} value={text} onChange={(e) => setText(e.target.value)} className={t.message} />
|
|
71
|
+
</label>
|
|
72
|
+
<div className={t.copy}>
|
|
73
|
+
<button type="button" onClick={() => copy(text, "Message")} className={t.copyButton}>
|
|
74
|
+
Copy message
|
|
75
|
+
</button>
|
|
76
|
+
<button type="button" onClick={() => copy(url, "Link")} className={t.copyButton}>
|
|
77
|
+
Copy link
|
|
78
|
+
</button>
|
|
79
|
+
</div>
|
|
80
|
+
<p role="status" aria-live="polite" className={t.status}>
|
|
81
|
+
{status}
|
|
82
|
+
</p>
|
|
83
|
+
<div>
|
|
84
|
+
<p className={t.targetsLabel}>Or share to</p>
|
|
85
|
+
<div className={t.targets}>
|
|
86
|
+
{nativeShare ? (
|
|
87
|
+
<button type="button" className={t.target} onClick={() => navigator.share({ title, text }).catch(() => {})}>
|
|
88
|
+
More apps…
|
|
89
|
+
</button>
|
|
90
|
+
) : null}
|
|
91
|
+
{targets.map((x) => (
|
|
92
|
+
<a key={x.label} href={x.href} target={x.href.startsWith("http") ? "_blank" : undefined} rel="noopener noreferrer" className={t.target}>
|
|
93
|
+
{x.label}
|
|
94
|
+
</a>
|
|
95
|
+
))}
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
</dialog>
|
|
100
|
+
</>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { HearthEvent } from "@the-smithy/hearth";
|
|
2
|
+
|
|
3
|
+
/** "Sat, Oct 18 · 6:00 – 9:00 PM", in the event's own time zone. */
|
|
4
|
+
export function formatEventDate(e: Pick<HearthEvent, "startsAt" | "endsAt" | "allDay" | "timezone">) {
|
|
5
|
+
const tz = { timeZone: e.timezone };
|
|
6
|
+
const day = new Intl.DateTimeFormat("en-US", { ...tz, weekday: "short", month: "short", day: "numeric", year: "numeric" });
|
|
7
|
+
const time = new Intl.DateTimeFormat("en-US", { ...tz, hour: "numeric", minute: "2-digit" });
|
|
8
|
+
const start = new Date(e.startsAt);
|
|
9
|
+
const end = e.endsAt ? new Date(e.endsAt) : null;
|
|
10
|
+
const sameDay = end && day.format(start) === day.format(end);
|
|
11
|
+
if (e.allDay) return end && !sameDay ? `${day.format(start)} – ${day.format(end)}` : day.format(start);
|
|
12
|
+
if (!end) return `${day.format(start)} · ${time.format(start)}`;
|
|
13
|
+
return sameDay ? `${day.format(start)} · ${time.format(start)} – ${time.format(end)}` : `${day.format(start)} ${time.format(start)} – ${day.format(end)} ${time.format(end)}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A shift's day and time in the event's time zone: "Sat, Nov 14 · 8:00 AM – 11:00 AM". */
|
|
17
|
+
export function formatShift(s: { startsAt: string; endsAt: string }, timeZone: string) {
|
|
18
|
+
const day = (d: string) => new Date(d).toLocaleDateString("en-US", { timeZone, weekday: "short", month: "short", day: "numeric" });
|
|
19
|
+
const time = (d: string) => new Date(d).toLocaleTimeString("en-US", { timeZone, hour: "numeric", minute: "2-digit" });
|
|
20
|
+
return day(s.startsAt) === day(s.endsAt) ? `${day(s.startsAt)} · ${time(s.startsAt)} – ${time(s.endsAt)}` : `${day(s.startsAt)} ${time(s.startsAt)} – ${day(s.endsAt)} ${time(s.endsAt)}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A collection entry's date ("2026-10-03") as "October 3, 2026". */
|
|
24
|
+
export const formatEntryDate = (iso: string) =>
|
|
25
|
+
new Date(`${iso.slice(0, 10)}T12:00:00Z`).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" });
|
|
26
|
+
|
|
27
|
+
/** Cents → "$1,250". */
|
|
28
|
+
export const money = (cents: number) => (cents / 100).toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: cents % 100 ? 2 : 0 });
|
|
29
|
+
|
|
30
|
+
/** A ticket price: "$25" or "Free". */
|
|
31
|
+
export const price = (cents: number) => (cents ? money(cents) : "Free");
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { BlockOf } from "@the-smithy/hearth";
|
|
4
|
+
import { useActionState } from "react";
|
|
5
|
+
import { theme } from "@/theme";
|
|
6
|
+
import { submitHearthForm, type FormState } from "../actions";
|
|
7
|
+
import { Alert, Honeypot, Question, Submit, Success } from "./fields";
|
|
8
|
+
|
|
9
|
+
type FormData_ = BlockOf<"form">["data"];
|
|
10
|
+
|
|
11
|
+
/** The fields of a Hearth `form` block, posted through a server action. */
|
|
12
|
+
export function HearthForm({ form }: { form: FormData_ }) {
|
|
13
|
+
const [state, action] = useActionState<FormState, FormData>(submitHearthForm.bind(null, form.formKey), {});
|
|
14
|
+
|
|
15
|
+
if (state.ok) return <Success>{state.message || form.successMessage}</Success>;
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<form action={action} className={theme.form.root} noValidate>
|
|
19
|
+
{state.message ? <Alert>{state.message}</Alert> : null}
|
|
20
|
+
<Honeypot />
|
|
21
|
+
{form.fields.map((f) => (
|
|
22
|
+
<Question key={f.name} field={f} idPrefix={form.formKey} error={state.fieldErrors?.[f.name]} value={state.values?.[f.name]} />
|
|
23
|
+
))}
|
|
24
|
+
<Submit label={form.submitLabel} pending="Sending…" />
|
|
25
|
+
</form>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useActionState, useState } from "react";
|
|
4
|
+
import type { FormResult } from "@the-smithy/hearth";
|
|
5
|
+
import { theme } from "@/theme";
|
|
6
|
+
import { donate } from "../actions";
|
|
7
|
+
import { money } from "../format";
|
|
8
|
+
import { Alert, Checkbox, FieldError, Submit, TextField } from "./fields";
|
|
9
|
+
|
|
10
|
+
const t = theme.form;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Suggested amounts (from Hearth's Giving settings), a custom amount and optional details. Submitting starts a
|
|
14
|
+
* checkout through Hearth and sends the donor to the payment page; they come back to /donate/thank-you.
|
|
15
|
+
*/
|
|
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
|
+
}) {
|
|
29
|
+
const [state, action] = useActionState<FormResult | null, FormData>(donate, null);
|
|
30
|
+
const [amount, setAmount] = useState(presetsCents[1] !== undefined ? String(presetsCents[1] / 100) : "custom");
|
|
31
|
+
const errors = state && !state.ok ? state.fieldErrors : {};
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<form action={action} className={t.root} id="donate">
|
|
35
|
+
{state && !state.ok ? <Alert>{state.message}</Alert> : null}
|
|
36
|
+
{fundraiser ? <input type="hidden" name="fundraiser" value={fundraiser} /> : null}
|
|
37
|
+
<fieldset>
|
|
38
|
+
<legend className={t.legend}>Amount</legend>
|
|
39
|
+
<div className={t.amounts}>
|
|
40
|
+
{[...presetsCents.map((cents) => ({ value: String(cents / 100), label: money(cents) })), { value: "custom", label: "Other" }].map((a) => (
|
|
41
|
+
<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" />
|
|
43
|
+
{a.label}
|
|
44
|
+
</label>
|
|
45
|
+
))}
|
|
46
|
+
</div>
|
|
47
|
+
{amount === "custom" ? (
|
|
48
|
+
<div className={t.customAmount}>
|
|
49
|
+
<TextField label="Amount in dollars" name="customAmount" inputMode="decimal" required placeholder={String(minCents / 100)} />
|
|
50
|
+
</div>
|
|
51
|
+
) : null}
|
|
52
|
+
<p className={t.hint}>
|
|
53
|
+
{money(minCents)} to {money(maxCents)}.
|
|
54
|
+
</p>
|
|
55
|
+
<FieldError>{errors.amount}</FieldError>
|
|
56
|
+
</fieldset>
|
|
57
|
+
<div className={t.row}>
|
|
58
|
+
<TextField label="Name" name="name" autoComplete="name" error={errors.name} />
|
|
59
|
+
<TextField label="Email for your receipt" name="email" type="email" autoComplete="email" error={errors.email} />
|
|
60
|
+
</div>
|
|
61
|
+
<TextField label="Message" optional name="message" textarea rows={3} maxLength={500} error={errors.message} />
|
|
62
|
+
<Checkbox name="anonymous" label="Don't show my name publicly" />
|
|
63
|
+
{/* Opt-in only, unticked: the fundraiser's updates go to donors who ask (and give an email). */}
|
|
64
|
+
{fundraiser ? <Checkbox name="updates" label="Send me updates about this fundraiser" /> : null}
|
|
65
|
+
<Submit label={submitLabel} pending="Opening secure checkout…" />
|
|
66
|
+
<p className={t.note}>You'll finish on a secure payment page.</p>
|
|
67
|
+
</form>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import { EmberForm } from "@the-smithy/ember/react";
|
|
4
4
|
import type { ReactNode } from "react";
|
|
5
|
+
import { theme } from "@/theme";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* A Form block set to an Ember form (`emberFormId`): rendered by the Ember SDK, so submissions become leads in
|
|
8
9
|
* Ember, The Smithy's CRM. The visitor's browser submits it straight to Ember. It takes the site's colors through
|
|
9
|
-
* the
|
|
10
|
+
* the color and font tokens in src/theme/theme.css (they reach into the form's Shadow DOM); its shape comes from the theme's `emberForm` slots.
|
|
10
11
|
* `fallback` (the site's own form) shows until the Ember form loads, and stays if Ember can't be reached.
|
|
11
12
|
*/
|
|
12
13
|
export function EmberFormBlock({ formId, fallback }: { formId: string; fallback: ReactNode }) {
|
|
@@ -25,11 +26,11 @@ export function EmberFormBlock({ formId, fallback }: { formId: string; fallback:
|
|
|
25
26
|
inputBackground: "var(--color-surface)",
|
|
26
27
|
border: "var(--color-field)",
|
|
27
28
|
successBackground: "var(--color-muted)",
|
|
28
|
-
radius:
|
|
29
|
+
radius: theme.emberForm.radius,
|
|
29
30
|
maxWidth: "none",
|
|
30
31
|
font: "var(--font-sans)",
|
|
31
|
-
fontSize:
|
|
32
|
-
gap:
|
|
32
|
+
fontSize: theme.emberForm.fontSize,
|
|
33
|
+
gap: theme.emberForm.gap,
|
|
33
34
|
},
|
|
34
35
|
}}
|
|
35
36
|
fallback={fallback}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useActionState, useState } from "react";
|
|
4
|
+
import type { EventTicket, FormResult } from "@the-smithy/hearth";
|
|
5
|
+
import { theme } from "@/theme";
|
|
6
|
+
import { register } from "../actions";
|
|
7
|
+
import { price } from "../format";
|
|
8
|
+
import { Alert, FieldError, OptionCard, Submit, Success, TextField } from "./fields";
|
|
9
|
+
|
|
10
|
+
const t = theme.form;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* On-site registration (sites with Tickets): pick a ticket, give contact details and attendee names. Free tickets
|
|
14
|
+
* are confirmed here; paid ones go to the payment page and come back to /registered.
|
|
15
|
+
*/
|
|
16
|
+
export function RegistrationForm({ slug, tickets }: { slug: string; tickets: EventTicket[] }) {
|
|
17
|
+
const [state, action] = useActionState<FormResult | null, FormData>(register.bind(null, slug), null);
|
|
18
|
+
const available = tickets.filter((ticket) => !ticket.soldOut);
|
|
19
|
+
const [ticketId, setTicketId] = useState(available[0]?.id ?? "");
|
|
20
|
+
const ticket = tickets.find((x) => x.id === ticketId);
|
|
21
|
+
const errors = state && !state.ok ? state.fieldErrors : {};
|
|
22
|
+
const paid = Boolean(ticket?.priceCents);
|
|
23
|
+
const attendee = ticket?.attendeeLabel ?? "Attendee";
|
|
24
|
+
|
|
25
|
+
if (state?.ok) return <Success>{state.message}</Success>;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<form action={action} className={t.root}>
|
|
29
|
+
{state && !state.ok ? <Alert>{state.message}</Alert> : null}
|
|
30
|
+
<fieldset>
|
|
31
|
+
<legend className={t.legend}>Ticket</legend>
|
|
32
|
+
<div className={t.options}>
|
|
33
|
+
{tickets.map((x) => (
|
|
34
|
+
<OptionCard
|
|
35
|
+
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} />}
|
|
37
|
+
title={x.name}
|
|
38
|
+
meta={x.soldOut ? "Sold out" : price(x.priceCents)}
|
|
39
|
+
>
|
|
40
|
+
{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} left</span> : null}
|
|
42
|
+
</OptionCard>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
<FieldError>{errors.ticket}</FieldError>
|
|
46
|
+
</fieldset>
|
|
47
|
+
<div className={t.row}>
|
|
48
|
+
<TextField label="Name" name="name" required autoComplete="name" error={errors.name} />
|
|
49
|
+
<TextField label="Email" name="email" type="email" required autoComplete="email" error={errors.email} />
|
|
50
|
+
<TextField label="Phone" optional name="phone" type="tel" autoComplete="tel" error={errors.phone} />
|
|
51
|
+
<TextField label="Team or organization" optional name="teamName" error={errors.teamName} />
|
|
52
|
+
</div>
|
|
53
|
+
{ticket && ticket.attendeesPerRegistration > 0 ? (
|
|
54
|
+
<fieldset>
|
|
55
|
+
<legend className={t.legend}>{ticket.attendeesPerRegistration === 1 ? attendee : `${attendee}s`}</legend>
|
|
56
|
+
<div className={t.row}>
|
|
57
|
+
{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} />
|
|
59
|
+
))}
|
|
60
|
+
</div>
|
|
61
|
+
<FieldError>{errors.attendees}</FieldError>
|
|
62
|
+
</fieldset>
|
|
63
|
+
) : null}
|
|
64
|
+
<TextField label="Notes" optional name="notes" textarea rows={3} maxLength={1000} error={errors.notes} />
|
|
65
|
+
<Submit label={paid ? "Continue to payment" : theme.text.register} pending={paid ? "Opening secure checkout…" : "Registering…"} />
|
|
66
|
+
</form>
|
|
67
|
+
);
|
|
68
|
+
}
|