@the-smithy/create-hearth-site 0.19.0 → 0.20.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 +2 -2
- package/template/AGENTS.md +3 -0
- package/template/CLAUDE.md +10 -1
- package/template/README.md +4 -1
- package/template/package.json +1 -0
- package/template/scripts/hearth.mjs +38 -2
- package/template/src/app/actions.ts +14 -0
- package/template/src/app/fundraisers/[slug]/page.tsx +1 -1
- package/template/src/app/globals.css +3 -0
- package/template/src/components/DonateForm.tsx +3 -3
- package/template/src/components/EventView.tsx +83 -24
- package/template/src/components/RegistrationForm.tsx +1 -1
- package/template/src/components/VolunteerForm.tsx +143 -0
- package/template/src/components/blocks/EmberFormBlock.tsx +1 -1
- package/template/src/components/blocks/Form.tsx +1 -1
- package/template/src/components/blocks/OpenMap.tsx +102 -0
- package/template/src/components/blocks/collections.tsx +1 -1
- package/template/src/components/blocks/content.tsx +6 -5
- package/template/src/components/blocks/events.tsx +36 -2
- package/template/src/components/blocks/giving.tsx +2 -2
- package/template/src/components/blocks/index.tsx +4 -2
- package/template/src/components/blocks/info.tsx +44 -13
- package/template/src/components/blocks/media.tsx +2 -2
- package/template/src/components/ui.tsx +12 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@the-smithy/create-hearth-site",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Start a new client website powered by Hearth: pnpm create @the-smithy/hearth-site",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
],
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"vitest": "^5.0.3",
|
|
32
|
-
"@the-smithy/hearth-schema": "^0.
|
|
32
|
+
"@the-smithy/hearth-schema": "^0.20.0"
|
|
33
33
|
},
|
|
34
34
|
"scripts": {
|
|
35
35
|
"test": "node --test \"test/**/*.test.js\" && vitest run test/blocks.test.ts"
|
package/template/AGENTS.md
CHANGED
|
@@ -2,3 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
Read `CLAUDE.md` in this folder: it holds this project's instructions for every AI coding assistant, including
|
|
4
4
|
the first steps to run in a new checkout (`pnpm hearth:check`, then `pnpm hearth:docs`).
|
|
5
|
+
|
|
6
|
+
`pnpm hearth:docs` also installs the skills Hearth offers this site in `.claude/skills/`. Read the `SKILL.md` of any
|
|
7
|
+
that fits your task and follow it (see "Skills" in `CLAUDE.md`).
|
package/template/CLAUDE.md
CHANGED
|
@@ -13,12 +13,21 @@ Do these before writing code, and tell the developer what you found:
|
|
|
13
13
|
fix (missing key, wrong `HEARTH_SITE`, revoked key). Ask the developer to fix `.env.local`; don't guess values.
|
|
14
14
|
2. **Get the full reference:** `pnpm hearth:docs` saves Hearth's API guide (every block and its fields, events,
|
|
15
15
|
forms, previews, SEO) to `hearth-api.md`. Read the parts you need from it, and from
|
|
16
|
-
`node_modules/@the-smithy/hearth/llms.md` for the SDK.
|
|
16
|
+
`node_modules/@the-smithy/hearth/llms.md` for the SDK. It also installs any skills Hearth offers this site in
|
|
17
|
+
`.claude/skills/` (see "Skills" below), so run it again when they're missing.
|
|
17
18
|
3. **"Nothing is published"** means every page is a 404 until editors publish in Hearth. That's not a code
|
|
18
19
|
problem: say so, and don't add placeholder content to work around it.
|
|
19
20
|
4. **Then build the design** in `src/app/globals.css` (tokens) and `src/components/blocks/` (one component per
|
|
20
21
|
block type), checking with `pnpm dev` against the published pages.
|
|
21
22
|
|
|
23
|
+
## Skills
|
|
24
|
+
|
|
25
|
+
`pnpm hearth:docs` installs the skills Hearth offers this site in `.claude/skills/` (which ones depends on the
|
|
26
|
+
site). Each `SKILL.md` says what it's for and when to use it. Claude Code loads them by itself; other assistants
|
|
27
|
+
should read the ones that fit the task and follow them. Use them for that work, design work in particular, rather
|
|
28
|
+
than starting from scratch. They aren't committed (`.claude/skills/.gitignore` lists them), so a fresh checkout gets
|
|
29
|
+
them by running `pnpm hearth:docs`; never edit them, because the next run replaces them.
|
|
30
|
+
|
|
22
31
|
## Rules
|
|
23
32
|
|
|
24
33
|
- **Content comes from Hearth.** Don't hard-code page text, menus, contact details or SEO tags. They're edited
|
package/template/README.md
CHANGED
|
@@ -42,7 +42,8 @@ The project comes with instructions for coding agents (`CLAUDE.md`, which Claude
|
|
|
42
42
|
1. **Check the connection yourself, or have the agent do it:** `pnpm hearth:check` confirms the key works and
|
|
43
43
|
lists the published pages and the block types they use. It never prints the key.
|
|
44
44
|
2. **Give the agent the full reference:** `pnpm hearth:docs` saves Hearth's API guide to `hearth-api.md` (not
|
|
45
|
-
committed)
|
|
45
|
+
committed), and installs any coding-assistant skills Hearth offers your site in `.claude/skills/`. The agent's
|
|
46
|
+
instructions already point it at both.
|
|
46
47
|
3. **Then tell it what to build.** Prompts that work well:
|
|
47
48
|
|
|
48
49
|
- *"Run the first steps in CLAUDE.md and tell me what's published and which blocks the site uses."*
|
|
@@ -73,6 +74,8 @@ Keep the API key out of chat: the agent never needs to see it, only `.env.local`
|
|
|
73
74
|
and sites hidden in Hearth are kept out of search engines. What's still yours:
|
|
74
75
|
- Pages your own code serves (like `/events`) need their own `generateMetadata` and a place in `src/app/sitemap.ts`.
|
|
75
76
|
- Use `<Img priority>` for the image at the top of a page, and keep one `<h1>` per page (the Hero block renders it).
|
|
77
|
+
- Give `<Img>` the `crop` of an image that fills a frame (`crop={4 / 3}` for `aspect-[4/3] object-cover`) so it keeps
|
|
78
|
+
the focal point set in Hearth's media library in view.
|
|
76
79
|
- Add both the bare domain and `www` to the site in Netlify, with the one Hearth uses as primary: Netlify then
|
|
77
80
|
redirects the other to it.
|
|
78
81
|
|
package/template/package.json
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Setup helpers for this site, for you or your coding agent. Both read .env.local and never print the API key.
|
|
2
2
|
// pnpm hearth:check checks .env.local and the connection to Hearth, then lists what's published
|
|
3
|
-
// pnpm hearth:docs saves Hearth's full API guide to hearth-api.md (not committed) for an AI assistant to read
|
|
4
|
-
|
|
3
|
+
// pnpm hearth:docs saves Hearth's full API guide to hearth-api.md (not committed) for an AI assistant to read,
|
|
4
|
+
// and installs any coding-assistant skills Hearth offers this site in .claude/skills/
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
5
7
|
import { createHearthClient, DEFAULT_BASE_URL, isHearthError } from "@the-smithy/hearth";
|
|
6
8
|
|
|
7
9
|
const command = process.argv[2];
|
|
@@ -87,6 +89,40 @@ async function docs() {
|
|
|
87
89
|
if (!res.ok) fail(`Hearth didn't return the API guide (${res.status}). Check the API key with pnpm hearth:check.`);
|
|
88
90
|
writeFileSync("hearth-api.md", await res.text());
|
|
89
91
|
console.log("✓ Saved Hearth's full API guide to hearth-api.md (not committed). Point your coding assistant at it.");
|
|
92
|
+
await skills(env);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Installs the coding-assistant skills Hearth offers this site into .claude/skills/, replacing the ones it
|
|
97
|
+
* installed before. Their names are listed in .claude/skills/.gitignore, so they're never committed and the
|
|
98
|
+
* site's own skills there are left alone.
|
|
99
|
+
*/
|
|
100
|
+
async function skills(env) {
|
|
101
|
+
const res = await fetch(`${env.baseUrl}/admin/docs/skills.json`, { headers: { Authorization: `Bearer ${env.apiKey}` } });
|
|
102
|
+
if (!res.ok) return console.log(` Hearth didn't return the coding-assistant skills (${res.status}); skipped them.`);
|
|
103
|
+
const { skills: offered = [] } = await res.json();
|
|
104
|
+
const dir = path.join(".claude", "skills");
|
|
105
|
+
const listFile = path.join(dir, ".gitignore");
|
|
106
|
+
const before = existsSync(listFile)
|
|
107
|
+
? readFileSync(listFile, "utf8").split(/\r?\n/).map((l) => l.replace(/^\/|\/$/g, "")).filter((l) => l && !l.startsWith("#") && l !== ".gitignore")
|
|
108
|
+
: [];
|
|
109
|
+
const safe = (p) => /^[a-z0-9][a-z0-9-]*$/.test(p);
|
|
110
|
+
const names = offered.map((s) => s.name).filter(safe);
|
|
111
|
+
for (const name of before) if (safe(name) && !names.includes(name)) rmSync(path.join(dir, name), { recursive: true, force: true });
|
|
112
|
+
for (const skill of offered.filter((s) => safe(s.name))) {
|
|
113
|
+
rmSync(path.join(dir, skill.name), { recursive: true, force: true });
|
|
114
|
+
for (const file of skill.files) {
|
|
115
|
+
if (!file.path.split("/").every((part) => /^[A-Za-z0-9._-]+$/.test(part) && part !== "..")) continue;
|
|
116
|
+
const target = path.join(dir, skill.name, ...file.path.split("/"));
|
|
117
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
118
|
+
writeFileSync(target, file.content);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (names.length || before.length) {
|
|
122
|
+
mkdirSync(dir, { recursive: true });
|
|
123
|
+
writeFileSync(listFile, ["# Installed by pnpm hearth:docs from Hearth; refreshed each run.", ".gitignore", ...names.map((n) => `/${n}/`)].join("\n") + "\n");
|
|
124
|
+
}
|
|
125
|
+
if (names.length) console.log(`✓ Installed ${names.length === 1 ? "a skill" : `${names.length} skills`} for your coding assistant in .claude/skills/: ${names.join(", ")}.`);
|
|
90
126
|
}
|
|
91
127
|
|
|
92
128
|
try {
|
|
@@ -41,3 +41,17 @@ export async function donate(prev: FormResult | null, formData: FormData): Promi
|
|
|
41
41
|
export async function register(slug: string, prev: FormResult | null, formData: FormData): Promise<FormResult> {
|
|
42
42
|
return hearthNext.registerAction(slug, { returnPath: "/registered" })(prev, formData);
|
|
43
43
|
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Signs up to volunteer at an event that asks for volunteers. Sign-ups land under the event's Volunteers in Hearth,
|
|
47
|
+
* which also emails the site's alert addresses.
|
|
48
|
+
*/
|
|
49
|
+
export async function volunteer(slug: string, prev: VolunteerState | null, formData: FormData): Promise<VolunteerState> {
|
|
50
|
+
const result = await hearthNext.volunteerAction(slug)(prev, formData);
|
|
51
|
+
if (result.ok) return result;
|
|
52
|
+
// Hand back what they typed and chose, so the form keeps it after an error.
|
|
53
|
+
const values = Object.fromEntries([...formData.entries()].filter(([k, v]) => typeof v === "string" && !k.startsWith("$") && k !== "_gotcha" && k !== "shift")) as Record<string, string>;
|
|
54
|
+
return { ...result, values, shifts: formData.getAll("shift").map(String) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type VolunteerState = FormResult & { values?: Record<string, string>; shifts?: string[] };
|
|
@@ -38,7 +38,7 @@ export default async function FundraiserPage({ params }: Props) {
|
|
|
38
38
|
{f.summary ? <p className="text-lg opacity-85">{f.summary}</p> : null}
|
|
39
39
|
<Progress fundraiser={f} large />
|
|
40
40
|
</div>
|
|
41
|
-
{f.image ? <Img image={f.image} className="aspect-[4/3] w-full rounded-xl object-cover" /> : null}
|
|
41
|
+
{f.image ? <Img image={f.image} crop={4 / 3} className="aspect-[4/3] w-full rounded-xl object-cover" /> : null}
|
|
42
42
|
</div>
|
|
43
43
|
</section>
|
|
44
44
|
<Blocks blocks={f.blocks} />
|
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
--color-bg: #fbfaf7;
|
|
9
9
|
--color-ink: #1c1c1a;
|
|
10
10
|
--color-muted-ink: #5b5b55;
|
|
11
|
+
/* Dividers and card edges: decorative, so they can be faint. */
|
|
11
12
|
--color-line: #e3e0d8;
|
|
13
|
+
/* Form field and option borders: the border is how people see the field, so it needs 3:1 on surface, bg and muted. */
|
|
14
|
+
--color-field: #8a867e;
|
|
12
15
|
--color-surface: #ffffff;
|
|
13
16
|
--color-muted: #f1eee7;
|
|
14
17
|
--color-inverse: #1c1c1a;
|
|
@@ -5,7 +5,7 @@ import { useFormStatus } from "react-dom";
|
|
|
5
5
|
import type { FormResult } from "@the-smithy/hearth";
|
|
6
6
|
import { donate } from "@/app/actions";
|
|
7
7
|
|
|
8
|
-
const input = "block w-full rounded-lg border border-
|
|
8
|
+
const input = "block w-full rounded-lg border border-field bg-surface px-4 py-3 text-base text-ink focus:border-accent focus:outline-none";
|
|
9
9
|
const money = (cents: number) => (cents / 100).toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: cents % 100 ? 2 : 0 });
|
|
10
10
|
|
|
11
11
|
function Submit({ label }: { label: string }) {
|
|
@@ -50,12 +50,12 @@ export function DonateForm({
|
|
|
50
50
|
<legend className="mb-3 font-semibold">Amount</legend>
|
|
51
51
|
<div className="flex flex-wrap gap-3">
|
|
52
52
|
{presetsCents.map((cents) => (
|
|
53
|
-
<label key={cents} className="cursor-pointer rounded-lg px-5 py-3 font-semibold ring-1 ring-
|
|
53
|
+
<label key={cents} className="cursor-pointer rounded-lg px-5 py-3 font-semibold ring-1 ring-field has-checked:bg-accent has-checked:text-accent-ink has-checked:ring-accent">
|
|
54
54
|
<input type="radio" name="amount" value={cents / 100} checked={amount === String(cents / 100)} onChange={(e) => setAmount(e.target.value)} className="sr-only" />
|
|
55
55
|
{money(cents)}
|
|
56
56
|
</label>
|
|
57
57
|
))}
|
|
58
|
-
<label className="cursor-pointer rounded-lg px-5 py-3 font-semibold ring-1 ring-
|
|
58
|
+
<label className="cursor-pointer rounded-lg px-5 py-3 font-semibold ring-1 ring-field has-checked:bg-accent has-checked:text-accent-ink has-checked:ring-accent">
|
|
59
59
|
<input type="radio" name="amount" value="custom" checked={amount === "custom"} onChange={(e) => setAmount(e.target.value)} className="sr-only" />
|
|
60
60
|
Other
|
|
61
61
|
</label>
|
|
@@ -1,39 +1,86 @@
|
|
|
1
|
-
import type { HearthEventDetail } from "@the-smithy/hearth";
|
|
1
|
+
import type { BlockOf, HearthEventDetail } from "@the-smithy/hearth";
|
|
2
2
|
import { JsonLd } from "@the-smithy/hearth/react";
|
|
3
3
|
import { Blocks } from "@/components/blocks";
|
|
4
|
-
import {
|
|
4
|
+
import { Hero } from "@/components/blocks/content";
|
|
5
|
+
import { formatEventDate, volunteerAnchor } from "@/components/blocks/events";
|
|
5
6
|
import { Registration } from "@/components/Registration";
|
|
6
7
|
import { RegistrationForm } from "@/components/RegistrationForm";
|
|
8
|
+
import { VolunteerForm } from "@/components/VolunteerForm";
|
|
7
9
|
import { Img } from "@/components/ui";
|
|
8
10
|
|
|
9
|
-
/**
|
|
11
|
+
/**
|
|
12
|
+
* An event's page: header with date, place and registration, then its blocks. Used at /events/… and /<collection>/….
|
|
13
|
+
* 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
|
|
14
|
+
* name its eyebrow, the event's image its picture if it has none) and the details sit in a band under it.
|
|
15
|
+
* Volunteer sign-up goes at the bottom, unless a Volunteer sign-up block for the event is placed on the page.
|
|
16
|
+
*/
|
|
10
17
|
export function EventView({ event }: { event: HearthEventDetail }) {
|
|
11
18
|
const { location } = event;
|
|
19
|
+
const [first, ...rest] = event.blocks;
|
|
20
|
+
const hero = first?.type === "hero" ? (first as BlockOf<"hero">) : null;
|
|
21
|
+
// A Volunteer sign-up block for this event puts the form where the editor placed it, instead of at the bottom.
|
|
22
|
+
const volunteerBlock = event.blocks.some((b) => b.type === "volunteerSignup" && (b as BlockOf<"volunteerSignup">).data.event.slug === event.slug);
|
|
23
|
+
|
|
24
|
+
const cancelled = event.status === "cancelled" ? <p className="w-fit rounded bg-danger px-2 py-1 text-sm font-semibold text-white">This event is cancelled</p> : null;
|
|
25
|
+
const when = (
|
|
26
|
+
<p className="text-lg font-semibold text-highlight">
|
|
27
|
+
<time dateTime={event.startsAt}>{formatEventDate(event)}</time>
|
|
28
|
+
</p>
|
|
29
|
+
);
|
|
30
|
+
const where =
|
|
31
|
+
location.name || location.address ? (
|
|
32
|
+
<p>
|
|
33
|
+
{location.name ? <span className="block font-semibold">{location.name}</span> : null}
|
|
34
|
+
{location.address ? <span className="block opacity-80">{location.address}</span> : null}
|
|
35
|
+
</p>
|
|
36
|
+
) : null;
|
|
37
|
+
const summary = event.summary ? <p className="text-lg opacity-85">{event.summary}</p> : null;
|
|
38
|
+
const actions = (
|
|
39
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
40
|
+
<Registration event={event} />
|
|
41
|
+
{event.volunteering?.open ? (
|
|
42
|
+
<a href={volunteerBlock ? `#${volunteerAnchor(event.slug)}` : "#volunteer"} className="w-fit rounded-lg px-6 py-3.5 font-semibold ring-2 ring-current hover:opacity-80">
|
|
43
|
+
Volunteer
|
|
44
|
+
</a>
|
|
45
|
+
) : null}
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
|
|
12
49
|
return (
|
|
13
50
|
<>
|
|
14
51
|
<JsonLd data={event.meta.jsonLd} />
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
<
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
52
|
+
{hero ? (
|
|
53
|
+
<>
|
|
54
|
+
<Hero block={{ ...hero, data: { ...hero.data, eyebrow: hero.data.eyebrow ?? event.title, image: hero.data.image ?? event.image ?? undefined } }} />
|
|
55
|
+
<section className="bg-muted">
|
|
56
|
+
<div className="mx-auto flex max-w-site flex-wrap items-center justify-between gap-6 px-4 py-8 sm:px-6">
|
|
57
|
+
<div className="flex flex-col gap-2">
|
|
58
|
+
{cancelled}
|
|
59
|
+
{when}
|
|
60
|
+
{where}
|
|
61
|
+
{hero.data.body ? null : summary}
|
|
62
|
+
</div>
|
|
63
|
+
{actions}
|
|
64
|
+
</div>
|
|
65
|
+
</section>
|
|
66
|
+
</>
|
|
67
|
+
) : (
|
|
68
|
+
<section className="bg-muted">
|
|
69
|
+
<div className="mx-auto grid max-w-site items-center gap-10 px-4 py-16 sm:px-6 md:grid-cols-2">
|
|
70
|
+
<div className="flex flex-col gap-4">
|
|
71
|
+
{event.collection ? <p className="text-sm font-semibold uppercase tracking-[0.18em] opacity-75">{event.collection.name}</p> : null}
|
|
72
|
+
{cancelled}
|
|
73
|
+
<h1 className="text-[clamp(32px,4.4vw,52px)] font-bold leading-tight tracking-tight">{event.title}</h1>
|
|
74
|
+
{when}
|
|
75
|
+
{where}
|
|
76
|
+
{summary}
|
|
77
|
+
{actions}
|
|
78
|
+
</div>
|
|
79
|
+
{event.image ? <Img image={event.image} crop={4 / 3} className="aspect-[4/3] w-full rounded-xl object-cover" /> : null}
|
|
32
80
|
</div>
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
<Blocks blocks={event.blocks} />
|
|
81
|
+
</section>
|
|
82
|
+
)}
|
|
83
|
+
<Blocks blocks={hero ? rest : event.blocks} />
|
|
37
84
|
{event.registration?.open ? (
|
|
38
85
|
<section id="register" className="mx-auto max-w-site scroll-mt-8 px-4 py-16 sm:px-6">
|
|
39
86
|
<div className="max-w-2xl">
|
|
@@ -43,6 +90,18 @@ export function EventView({ event }: { event: HearthEventDetail }) {
|
|
|
43
90
|
</div>
|
|
44
91
|
</section>
|
|
45
92
|
) : null}
|
|
93
|
+
{event.volunteering?.open && !volunteerBlock ? (
|
|
94
|
+
<section id="volunteer" className="mx-auto max-w-site scroll-mt-8 px-4 py-16 sm:px-6">
|
|
95
|
+
<div className="max-w-2xl">
|
|
96
|
+
<h2 className="mb-2 text-2xl font-bold">Volunteer</h2>
|
|
97
|
+
{event.volunteering.intro ? <p className="mb-2 whitespace-pre-line opacity-85">{event.volunteering.intro}</p> : null}
|
|
98
|
+
{event.volunteering.fundraiser ? <p className="mb-2 opacity-80">This event supports {event.volunteering.fundraiser.title}.</p> : null}
|
|
99
|
+
<div className="mt-6">
|
|
100
|
+
<VolunteerForm slug={event.slug} timezone={event.timezone} volunteering={event.volunteering} />
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</section>
|
|
104
|
+
) : null}
|
|
46
105
|
</>
|
|
47
106
|
);
|
|
48
107
|
}
|
|
@@ -5,7 +5,7 @@ import { useFormStatus } from "react-dom";
|
|
|
5
5
|
import type { EventTicket, FormResult } from "@the-smithy/hearth";
|
|
6
6
|
import { register } from "@/app/actions";
|
|
7
7
|
|
|
8
|
-
const input = "block w-full rounded-lg border border-
|
|
8
|
+
const input = "block w-full rounded-lg border border-field bg-surface px-4 py-3 text-base text-ink focus:border-accent focus:outline-none";
|
|
9
9
|
const price = (cents: number) => (cents ? (cents / 100).toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: cents % 100 ? 2 : 0 }) : "Free");
|
|
10
10
|
|
|
11
11
|
function Submit({ paid }: { paid: boolean }) {
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useActionState } from "react";
|
|
4
|
+
import { useFormStatus } from "react-dom";
|
|
5
|
+
import type { EventVolunteering } from "@the-smithy/hearth";
|
|
6
|
+
import { volunteer, type VolunteerState } from "@/app/actions";
|
|
7
|
+
|
|
8
|
+
const input = "block w-full rounded-lg border border-field bg-surface px-4 py-3 text-base text-ink focus:border-accent focus:outline-none";
|
|
9
|
+
|
|
10
|
+
/** A shift's day and time in the event's time zone: "Sat, Nov 14 · 8:00 AM – 11:00 AM". */
|
|
11
|
+
function shiftTime(s: { startsAt: string; endsAt: string }, timeZone: string) {
|
|
12
|
+
const day = (d: string) => new Date(d).toLocaleDateString("en-US", { timeZone, weekday: "short", month: "short", day: "numeric" });
|
|
13
|
+
const time = (d: string) => new Date(d).toLocaleTimeString("en-US", { timeZone, hour: "numeric", minute: "2-digit" });
|
|
14
|
+
return day(s.startsAt) === day(s.endsAt) ? `${day(s.startsAt)} · ${time(s.startsAt)} – ${time(s.endsAt)}` : `${day(s.startsAt)} ${time(s.startsAt)} – ${day(s.endsAt)} ${time(s.endsAt)}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function Submit({ label }: { label: string }) {
|
|
18
|
+
const { pending } = useFormStatus();
|
|
19
|
+
return (
|
|
20
|
+
<button type="submit" disabled={pending} className="rounded-lg bg-accent px-6 py-3.5 font-semibold text-accent-ink hover:opacity-90 disabled:opacity-60">
|
|
21
|
+
{pending ? "Signing up…" : label}
|
|
22
|
+
</button>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Volunteer sign-up for an event: name and email, shifts to choose from (when the event lists them), then the
|
|
28
|
+
* event's own questions. Sign-ups land under the event's Volunteers in Hearth.
|
|
29
|
+
*/
|
|
30
|
+
export function VolunteerForm({ slug, timezone, volunteering }: { slug: string; timezone: string; volunteering: EventVolunteering }) {
|
|
31
|
+
const [state, action] = useActionState<VolunteerState | null, FormData>(volunteer.bind(null, slug), null);
|
|
32
|
+
const errors = state && !state.ok ? state.fieldErrors : {};
|
|
33
|
+
const values = (state && !state.ok ? state.values : undefined) ?? {};
|
|
34
|
+
const chosen = (state && !state.ok ? state.shifts : undefined) ?? [];
|
|
35
|
+
const many = volunteering.pickShifts === "many";
|
|
36
|
+
|
|
37
|
+
if (state?.ok) {
|
|
38
|
+
return (
|
|
39
|
+
<p role="status" className="rounded-xl bg-accent p-6 text-lg font-semibold text-accent-ink">
|
|
40
|
+
{state.message}
|
|
41
|
+
</p>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<form action={action} className="space-y-6" noValidate>
|
|
47
|
+
{state && !state.ok ? (
|
|
48
|
+
<p role="alert" className="rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-danger">
|
|
49
|
+
{state.message}
|
|
50
|
+
</p>
|
|
51
|
+
) : null}
|
|
52
|
+
{/* Honeypot: hidden from people, filled in by bots. */}
|
|
53
|
+
<div aria-hidden className="absolute -left-[9999px]">
|
|
54
|
+
<label>
|
|
55
|
+
Leave this empty
|
|
56
|
+
<input name="_gotcha" tabIndex={-1} autoComplete="off" />
|
|
57
|
+
</label>
|
|
58
|
+
</div>
|
|
59
|
+
<div className="grid gap-5 sm:grid-cols-2">
|
|
60
|
+
<label className="block">
|
|
61
|
+
<span className="mb-1.5 block font-semibold">Name</span>
|
|
62
|
+
<input name="name" required autoComplete="name" defaultValue={values.name} aria-invalid={errors.name ? true : undefined} className={input} />
|
|
63
|
+
{errors.name ? <span className="mt-1 block text-danger">{errors.name}</span> : null}
|
|
64
|
+
</label>
|
|
65
|
+
<label className="block">
|
|
66
|
+
<span className="mb-1.5 block font-semibold">Email</span>
|
|
67
|
+
<input name="email" type="email" required autoComplete="email" defaultValue={values.email} aria-invalid={errors.email ? true : undefined} className={input} />
|
|
68
|
+
{errors.email ? <span className="mt-1 block text-danger">{errors.email}</span> : null}
|
|
69
|
+
</label>
|
|
70
|
+
</div>
|
|
71
|
+
{volunteering.pickShifts !== "none" ? (
|
|
72
|
+
<fieldset>
|
|
73
|
+
<legend className="mb-3 font-semibold">{many ? "Shifts you can do" : "Choose a shift"}</legend>
|
|
74
|
+
<div className="space-y-3">
|
|
75
|
+
{volunteering.shifts.map((s) => (
|
|
76
|
+
<label key={s.id} className={`flex cursor-pointer items-start gap-3 rounded-lg p-4 ring-1 ring-line has-checked:ring-2 has-checked:ring-accent ${s.full ? "cursor-not-allowed opacity-50" : ""}`}>
|
|
77
|
+
<input
|
|
78
|
+
type={many ? "checkbox" : "radio"}
|
|
79
|
+
name="shift"
|
|
80
|
+
value={s.id}
|
|
81
|
+
disabled={s.full}
|
|
82
|
+
defaultChecked={chosen.includes(s.id)}
|
|
83
|
+
className="mt-1 size-5 accent-accent"
|
|
84
|
+
/>
|
|
85
|
+
<span className="flex-1">
|
|
86
|
+
<span className="flex flex-wrap justify-between gap-2 font-semibold">
|
|
87
|
+
<span>{s.name}</span>
|
|
88
|
+
<span className="font-normal opacity-80">{s.full ? "Full" : s.remaining !== null && s.remaining <= 5 ? `${s.remaining} spot${s.remaining === 1 ? "" : "s"} left` : null}</span>
|
|
89
|
+
</span>
|
|
90
|
+
<span className="mt-0.5 block opacity-80">{shiftTime(s, timezone)}</span>
|
|
91
|
+
{s.description ? <span className="mt-1 block opacity-80">{s.description}</span> : null}
|
|
92
|
+
</span>
|
|
93
|
+
</label>
|
|
94
|
+
))}
|
|
95
|
+
</div>
|
|
96
|
+
{errors.shifts ? <p className="mt-2 text-danger">{errors.shifts}</p> : null}
|
|
97
|
+
</fieldset>
|
|
98
|
+
) : null}
|
|
99
|
+
{volunteering.fields.map((f) => {
|
|
100
|
+
const id = `volunteer-${f.name}`;
|
|
101
|
+
const error = errors[f.name];
|
|
102
|
+
const common = { id, name: f.name, required: f.required, "aria-invalid": error ? true : undefined, "aria-describedby": error ? `${id}-error` : undefined };
|
|
103
|
+
return (
|
|
104
|
+
<div key={f.name}>
|
|
105
|
+
{f.type === "checkbox" ? (
|
|
106
|
+
<label className="flex items-center gap-3">
|
|
107
|
+
<input type="checkbox" {...common} defaultChecked={values[f.name] === "on"} className="size-5 accent-accent" />
|
|
108
|
+
{f.label}
|
|
109
|
+
</label>
|
|
110
|
+
) : (
|
|
111
|
+
<>
|
|
112
|
+
<label htmlFor={id} className="mb-1.5 block font-semibold">
|
|
113
|
+
{f.label}
|
|
114
|
+
{f.required ? null : <span className="ml-1 font-normal opacity-70">(optional)</span>}
|
|
115
|
+
</label>
|
|
116
|
+
{f.type === "textarea" ? (
|
|
117
|
+
<textarea {...common} rows={4} defaultValue={values[f.name]} placeholder={f.placeholder} className={input} />
|
|
118
|
+
) : f.type === "select" ? (
|
|
119
|
+
<select {...common} defaultValue={values[f.name] ?? ""} className={input}>
|
|
120
|
+
<option value="" disabled>
|
|
121
|
+
{f.placeholder || "Choose one…"}
|
|
122
|
+
</option>
|
|
123
|
+
{(f.options ?? []).map((o) => (
|
|
124
|
+
<option key={o}>{o}</option>
|
|
125
|
+
))}
|
|
126
|
+
</select>
|
|
127
|
+
) : (
|
|
128
|
+
<input {...common} type={f.type} defaultValue={values[f.name]} placeholder={f.placeholder} autoComplete={f.type === "tel" ? "tel" : undefined} className={input} />
|
|
129
|
+
)}
|
|
130
|
+
</>
|
|
131
|
+
)}
|
|
132
|
+
{error ? (
|
|
133
|
+
<p id={`${id}-error`} className="mt-1.5 text-sm text-danger">
|
|
134
|
+
{error}
|
|
135
|
+
</p>
|
|
136
|
+
) : null}
|
|
137
|
+
</div>
|
|
138
|
+
);
|
|
139
|
+
})}
|
|
140
|
+
<Submit label={volunteering.submitLabel} />
|
|
141
|
+
</form>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -23,7 +23,7 @@ export function EmberFormBlock({ formId, fallback }: { formId: string; fallback:
|
|
|
23
23
|
muted: "var(--color-muted-ink)",
|
|
24
24
|
error: "var(--color-danger)",
|
|
25
25
|
inputBackground: "var(--color-surface)",
|
|
26
|
-
border: "var(--color-
|
|
26
|
+
border: "var(--color-field)",
|
|
27
27
|
successBackground: "var(--color-muted)",
|
|
28
28
|
radius: "8px",
|
|
29
29
|
maxWidth: "none",
|
|
@@ -7,7 +7,7 @@ import { submitHearthForm, type FormState } from "@/app/actions";
|
|
|
7
7
|
|
|
8
8
|
type FormData_ = BlockOf<"form">["data"];
|
|
9
9
|
|
|
10
|
-
const input = "block w-full rounded-lg border border-
|
|
10
|
+
const input = "block w-full rounded-lg border border-field bg-surface px-4 py-3 text-base text-ink focus:border-accent focus:outline-none";
|
|
11
11
|
|
|
12
12
|
function Submit({ label }: { label: string }) {
|
|
13
13
|
const { pending } = useFormStatus();
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { directionsUrl, type Position } from "@the-smithy/hearth";
|
|
4
|
+
import { useEffect, useRef, useState } from "react";
|
|
5
|
+
import "maplibre-gl/dist/maplibre-gl.css";
|
|
6
|
+
|
|
7
|
+
/** Free OpenStreetMap tiles from OpenFreeMap: no key, no account, no cookies. Swap for another MapLibre style to restyle. */
|
|
8
|
+
const MAP_STYLE = "https://tiles.openfreemap.org/styles/liberty";
|
|
9
|
+
|
|
10
|
+
export type MapPlace = { name: string; address: string; position: Position };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A map with a pin for each place; a pin opens the place's name, address and directions. MapLibre loads only when the
|
|
14
|
+
* map is about to scroll into view, and scrolling the page never zooms it (two fingers, or Ctrl and scroll, do).
|
|
15
|
+
*/
|
|
16
|
+
export function OpenMap({ places, label, className = "" }: { places: MapPlace[]; label: string; className?: string }) {
|
|
17
|
+
const container = useRef<HTMLDivElement>(null);
|
|
18
|
+
const [failed, setFailed] = useState(false);
|
|
19
|
+
const key = JSON.stringify(places);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const el = container.current;
|
|
23
|
+
if (!el) return;
|
|
24
|
+
let cancelled = false;
|
|
25
|
+
let remove = () => {};
|
|
26
|
+
const show = async () => {
|
|
27
|
+
const ml = await import("maplibre-gl");
|
|
28
|
+
// MapLibre looks for its worker beside its own script, which isn't where the bundler puts it.
|
|
29
|
+
ml.setWorkerUrl(new URL("maplibre-gl/dist/maplibre-gl-worker.mjs", import.meta.url).href);
|
|
30
|
+
if (cancelled) return;
|
|
31
|
+
const list: MapPlace[] = JSON.parse(key);
|
|
32
|
+
const map = new ml.Map({
|
|
33
|
+
container: el,
|
|
34
|
+
style: MAP_STYLE,
|
|
35
|
+
center: [list[0].position.lng, list[0].position.lat],
|
|
36
|
+
zoom: 15,
|
|
37
|
+
attributionControl: { compact: true },
|
|
38
|
+
cooperativeGestures: true,
|
|
39
|
+
// MapLibre makes the map a labelled region (the canvas): name it for this map.
|
|
40
|
+
locale: { "Map.Title": label },
|
|
41
|
+
});
|
|
42
|
+
remove = () => map.remove();
|
|
43
|
+
map.addControl(new ml.NavigationControl({ showCompass: false }));
|
|
44
|
+
let loaded = false;
|
|
45
|
+
map.once("load", () => (loaded = true));
|
|
46
|
+
map.on("error", () => !loaded && setFailed(true));
|
|
47
|
+
|
|
48
|
+
const bounds = new ml.LngLatBounds();
|
|
49
|
+
for (const place of list) {
|
|
50
|
+
const at: [number, number] = [place.position.lng, place.position.lat];
|
|
51
|
+
bounds.extend(at);
|
|
52
|
+
const pin = document.createElement("button");
|
|
53
|
+
pin.type = "button";
|
|
54
|
+
pin.className = "block size-6 rounded-full border-[3px] border-white bg-highlight shadow-md";
|
|
55
|
+
pin.setAttribute("aria-label", place.name);
|
|
56
|
+
new ml.Marker({ element: pin }).setLngLat(at).setPopup(new ml.Popup({ offset: 14, closeButton: true }).setDOMContent(popup(place))).addTo(map);
|
|
57
|
+
}
|
|
58
|
+
if (list.length > 1) map.fitBounds(bounds, { padding: { top: 48, right: 56, bottom: 72, left: 48 }, maxZoom: 15, duration: 0 });
|
|
59
|
+
};
|
|
60
|
+
// Wait until the map is near the screen: most visitors never scroll to it.
|
|
61
|
+
const observer = new IntersectionObserver(
|
|
62
|
+
([entry]) => {
|
|
63
|
+
if (!entry.isIntersecting) return;
|
|
64
|
+
observer.disconnect();
|
|
65
|
+
show().catch(() => setFailed(true));
|
|
66
|
+
},
|
|
67
|
+
{ rootMargin: "300px" },
|
|
68
|
+
);
|
|
69
|
+
observer.observe(el);
|
|
70
|
+
return () => {
|
|
71
|
+
cancelled = true;
|
|
72
|
+
observer.disconnect();
|
|
73
|
+
remove();
|
|
74
|
+
};
|
|
75
|
+
}, [key, label]);
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className={`relative overflow-hidden rounded-xl bg-muted [&_.maplibregl-map]:font-sans [&_.maplibregl-popup-close-button]:size-8 [&_.maplibregl-popup-close-button]:text-xl ${className}`}>
|
|
79
|
+
{/* Sized by the frame: MapLibre makes its container position: relative. */}
|
|
80
|
+
<div ref={container} className="size-full" />
|
|
81
|
+
{failed ? <p className="absolute inset-0 grid place-items-center p-6 text-center opacity-80">The map couldn't load.</p> : null}
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A pin's popup, built as elements (never HTML strings) so names and addresses stay plain text. */
|
|
87
|
+
function popup(place: MapPlace) {
|
|
88
|
+
const box = document.createElement("div");
|
|
89
|
+
box.className = "grid gap-1 pr-4 font-sans text-sm text-ink";
|
|
90
|
+
const name = document.createElement("strong");
|
|
91
|
+
name.textContent = place.name;
|
|
92
|
+
const address = document.createElement("span");
|
|
93
|
+
address.className = "whitespace-pre-line";
|
|
94
|
+
address.textContent = place.address;
|
|
95
|
+
const link = document.createElement("a");
|
|
96
|
+
link.href = directionsUrl(place);
|
|
97
|
+
link.rel = "noopener";
|
|
98
|
+
link.className = "font-semibold text-highlight underline underline-offset-4";
|
|
99
|
+
link.textContent = "Get directions";
|
|
100
|
+
box.append(name, address, link);
|
|
101
|
+
return box;
|
|
102
|
+
}
|
|
@@ -8,7 +8,7 @@ const date = (iso: string | null) =>
|
|
|
8
8
|
function Card({ entry }: { entry: CollectionEntry }) {
|
|
9
9
|
return (
|
|
10
10
|
<li className="relative flex flex-col overflow-hidden rounded-2xl bg-surface ring-1 ring-line">
|
|
11
|
-
{entry.image ? <Img image={entry.image} className="aspect-[16/10] w-full object-cover" /> : null}
|
|
11
|
+
{entry.image ? <Img image={entry.image} crop={16 / 10} className="aspect-[16/10] w-full object-cover" /> : null}
|
|
12
12
|
<div className="flex flex-1 flex-col gap-2 p-6">
|
|
13
13
|
{entry.date ? <p className="text-sm font-semibold text-highlight">{date(entry.date)}</p> : null}
|
|
14
14
|
<h3 className="text-xl font-semibold">
|
|
@@ -5,7 +5,7 @@ export function Hero({ block }: { block: BlockOf<"hero"> }) {
|
|
|
5
5
|
const d = block.data;
|
|
6
6
|
return (
|
|
7
7
|
<section className="relative overflow-hidden bg-inverse text-inverse-ink">
|
|
8
|
-
{d.image ? <Img image={d.image} priority className="absolute inset-0 size-full object-cover opacity-35" /> : null}
|
|
8
|
+
{d.image ? <Img image={d.image} crop priority className="absolute inset-0 size-full object-cover opacity-35" /> : null}
|
|
9
9
|
<div className={`relative mx-auto max-w-site px-4 sm:px-6 ${d.size === "full" ? "py-28 sm:py-36" : "py-16 sm:py-20"}`}>
|
|
10
10
|
{d.logo ? <Img image={d.logo} priority className="mb-6 h-20 w-auto sm:h-24" /> : null}
|
|
11
11
|
{d.eyebrow ? <p className="mb-4 text-sm font-semibold uppercase tracking-[0.18em] opacity-80">{d.eyebrow}</p> : null}
|
|
@@ -46,7 +46,7 @@ export function MediaText({ block }: { block: BlockOf<"mediaText"> }) {
|
|
|
46
46
|
</div>
|
|
47
47
|
) : null}
|
|
48
48
|
</div>
|
|
49
|
-
{d.image ? <Img image={d.image} className={`w-full rounded-xl ${d.imageFit === "contain" ? "max-h-96 object-contain" : "aspect-[4/3] object-cover"}`} /> : null}
|
|
49
|
+
{d.image ? <Img image={d.image} crop={d.imageFit === "contain" ? undefined : 4 / 3} className={`w-full rounded-xl ${d.imageFit === "contain" ? "max-h-96 object-contain" : "aspect-[4/3] object-cover"}`} /> : null}
|
|
50
50
|
</div>
|
|
51
51
|
</Section>
|
|
52
52
|
);
|
|
@@ -77,7 +77,7 @@ export function CardGrid({ block }: { block: BlockOf<"cardGrid"> }) {
|
|
|
77
77
|
<ul className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
|
78
78
|
{d.items.map((c, i) => (
|
|
79
79
|
<li key={i} className={`flex flex-col gap-3 rounded-2xl p-7 ${dark ? "bg-white/10" : "bg-surface ring-1 ring-line"}`}>
|
|
80
|
-
{c.image ? <Img image={c.image} className="mb-2 aspect-[16/10] w-full rounded-lg object-cover" /> : null}
|
|
80
|
+
{c.image ? <Img image={c.image} crop={16 / 10} className="mb-2 aspect-[16/10] w-full rounded-lg object-cover" /> : null}
|
|
81
81
|
<h3 className="text-xl font-semibold">{c.title}</h3>
|
|
82
82
|
{c.body ? <p className="leading-relaxed opacity-80">{c.body}</p> : null}
|
|
83
83
|
{c.link ? (
|
|
@@ -112,6 +112,7 @@ export function Projects({ block }: { block: BlockOf<"projects"> }) {
|
|
|
112
112
|
{p.image ? (
|
|
113
113
|
<Img
|
|
114
114
|
image={p.image}
|
|
115
|
+
crop={16 / 10}
|
|
115
116
|
className={`aspect-[16/10] w-full rounded-xl object-cover object-top ${grid ? "mb-2" : i % 2 ? "md:order-2" : ""}`}
|
|
116
117
|
/>
|
|
117
118
|
) : null}
|
|
@@ -207,7 +208,7 @@ export function CallToAction({ block }: { block: BlockOf<"cta"> }) {
|
|
|
207
208
|
if (!d.image) return <Section tone={d.tone}>{content(isDark(d.tone))}</Section>;
|
|
208
209
|
return (
|
|
209
210
|
<section className="relative overflow-hidden text-white">
|
|
210
|
-
<Img image={d.image} className="absolute inset-0 size-full object-cover" />
|
|
211
|
+
<Img image={d.image} crop className="absolute inset-0 size-full object-cover" />
|
|
211
212
|
<div className="relative bg-black/55">
|
|
212
213
|
<div className="mx-auto max-w-site px-4 py-20 sm:px-6 sm:py-24">{content(true)}</div>
|
|
213
214
|
</div>
|
|
@@ -244,7 +245,7 @@ export function Quote({ block }: { block: BlockOf<"quote"> }) {
|
|
|
244
245
|
<blockquote className="text-2xl font-semibold leading-snug sm:text-3xl">“{d.quote}”</blockquote>
|
|
245
246
|
{d.name ? (
|
|
246
247
|
<figcaption className="flex items-center gap-4">
|
|
247
|
-
{d.image ? <Img image={d.image} className="size-14 rounded-full object-cover" /> : null}
|
|
248
|
+
{d.image ? <Img image={d.image} crop={1} className="size-14 rounded-full object-cover" /> : null}
|
|
248
249
|
<span>
|
|
249
250
|
<span className="block font-semibold">{d.name}</span>
|
|
250
251
|
{d.role ? <span className="block opacity-75">{d.role}</span> : null}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import Link from "next/link";
|
|
2
2
|
import type { BlockOf, HearthEvent } from "@the-smithy/hearth";
|
|
3
3
|
import { Registration } from "@/components/Registration";
|
|
4
|
+
import { VolunteerForm } from "@/components/VolunteerForm";
|
|
4
5
|
import { Img, Section, SectionHeading } from "@/components/ui";
|
|
5
6
|
|
|
6
7
|
/** "Sat, Oct 18 · 6:00 – 9:00 PM", in the event's own time zone. */
|
|
@@ -20,7 +21,7 @@ export function EventCard({ event }: { event: HearthEvent }) {
|
|
|
20
21
|
const cancelled = event.status === "cancelled";
|
|
21
22
|
return (
|
|
22
23
|
<li className="flex flex-col overflow-hidden rounded-2xl bg-surface ring-1 ring-line">
|
|
23
|
-
{event.image ? <Img image={event.image} className="aspect-[16/9] w-full object-cover" /> : null}
|
|
24
|
+
{event.image ? <Img image={event.image} crop={16 / 9} className="aspect-[16/9] w-full object-cover" /> : null}
|
|
24
25
|
<div className="flex flex-1 flex-col gap-2 p-6">
|
|
25
26
|
<p className="text-sm font-semibold text-highlight">
|
|
26
27
|
{cancelled ? <span className="mr-2 rounded bg-danger px-1.5 py-0.5 text-white">Cancelled</span> : null}
|
|
@@ -97,8 +98,41 @@ export function EventSpotlight({ block }: { block: BlockOf<"eventSpotlight"> })
|
|
|
97
98
|
Event details
|
|
98
99
|
</Link>
|
|
99
100
|
</div>
|
|
100
|
-
{e.image ? <Img image={e.image} className="aspect-[4/3] w-full rounded-xl object-cover" /> : null}
|
|
101
|
+
{e.image ? <Img image={e.image} crop={4 / 3} className="aspect-[4/3] w-full rounded-xl object-cover" /> : null}
|
|
101
102
|
</div>
|
|
102
103
|
</Section>
|
|
103
104
|
);
|
|
104
105
|
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Volunteer sign-up for an event, wherever the block is placed. On another page it names the event and links to it;
|
|
109
|
+
* once sign-up closes it says so. The event's own page leaves out its built-in form when this block is on it.
|
|
110
|
+
*/
|
|
111
|
+
export function VolunteerSignup({ block }: { block: BlockOf<"volunteerSignup"> }) {
|
|
112
|
+
const e = block.data.event;
|
|
113
|
+
const v = e.volunteering;
|
|
114
|
+
const intro = block.data.intro || v.intro;
|
|
115
|
+
return (
|
|
116
|
+
<Section id={volunteerAnchor(e.slug)}>
|
|
117
|
+
<div className="max-w-2xl">
|
|
118
|
+
<h2 className="mb-2 text-2xl font-bold">{block.data.heading || "Volunteer"}</h2>
|
|
119
|
+
{block.data.eventSlug ? (
|
|
120
|
+
<p className="mb-2 font-semibold">
|
|
121
|
+
<Link href={e.path} className="underline underline-offset-4 hover:opacity-80">
|
|
122
|
+
{e.title}
|
|
123
|
+
</Link>
|
|
124
|
+
<span className="text-highlight"> · {formatEventDate(e)}</span>
|
|
125
|
+
</p>
|
|
126
|
+
) : null}
|
|
127
|
+
{intro ? <p className="mb-2 whitespace-pre-line opacity-85">{intro}</p> : null}
|
|
128
|
+
{v.fundraiser ? <p className="mb-2 opacity-80">This event supports {v.fundraiser.title}.</p> : null}
|
|
129
|
+
<div className="mt-6">
|
|
130
|
+
{v.open ? <VolunteerForm slug={e.slug} timezone={e.timezone} volunteering={v} /> : <p className="opacity-80">Volunteer sign-up for this event has closed.</p>}
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
</Section>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Where an event's Volunteer sign-up block sits, for the event header's Volunteer button. */
|
|
138
|
+
export const volunteerAnchor = (slug: string) => `volunteer-${slug}`;
|
|
@@ -33,7 +33,7 @@ export function Progress({ fundraiser, large }: { fundraiser: Fundraiser; large?
|
|
|
33
33
|
export function FundraiserCard({ fundraiser }: { fundraiser: Fundraiser }) {
|
|
34
34
|
return (
|
|
35
35
|
<li className="relative flex flex-col overflow-hidden rounded-2xl bg-surface ring-1 ring-line">
|
|
36
|
-
{fundraiser.image ? <Img image={fundraiser.image} className="aspect-[16/9] w-full object-cover" /> : null}
|
|
36
|
+
{fundraiser.image ? <Img image={fundraiser.image} crop={16 / 9} className="aspect-[16/9] w-full object-cover" /> : null}
|
|
37
37
|
<div className="flex flex-1 flex-col gap-3 p-6">
|
|
38
38
|
{fundraiser.status === "completed" ? <p className="w-fit rounded bg-muted px-2 py-0.5 text-sm font-semibold">Completed</p> : null}
|
|
39
39
|
<h3 className="text-xl font-semibold">
|
|
@@ -100,7 +100,7 @@ export function FundraiserSpotlight({ block }: { block: BlockOf<"fundraiserSpotl
|
|
|
100
100
|
</Link>
|
|
101
101
|
</p>
|
|
102
102
|
</div>
|
|
103
|
-
{f.image ? <Img image={f.image} className="aspect-[4/3] w-full rounded-xl object-cover" /> : null}
|
|
103
|
+
{f.image ? <Img image={f.image} crop={4 / 3} className="aspect-[4/3] w-full rounded-xl object-cover" /> : null}
|
|
104
104
|
</div>
|
|
105
105
|
</Section>
|
|
106
106
|
);
|
|
@@ -3,12 +3,12 @@ import { HearthBlocks, type BlockComponents } from "@the-smithy/hearth/react";
|
|
|
3
3
|
import { Section, SectionHeading } from "@/components/ui";
|
|
4
4
|
import { CollectionList } from "./collections";
|
|
5
5
|
import { CallToAction, CardGrid, Faq, Hero, MediaText, Pricing, Projects, Quote, RichText, Stats } from "./content";
|
|
6
|
-
import { EventSpotlight, PastEvents, UpcomingEvents } from "./events";
|
|
6
|
+
import { EventSpotlight, PastEvents, UpcomingEvents, VolunteerSignup } from "./events";
|
|
7
7
|
import { CustomBlock } from "./custom";
|
|
8
8
|
import { EmberFormBlock } from "./EmberFormBlock";
|
|
9
9
|
import { HearthForm } from "./Form";
|
|
10
10
|
import { Donate, FundraiserGrid, FundraiserSpotlight } from "./giving";
|
|
11
|
-
import { Location, Schedule } from "./info";
|
|
11
|
+
import { Location, Locations, Schedule } from "./info";
|
|
12
12
|
import { Embed, Gallery, Logos, People, Video } from "./media";
|
|
13
13
|
|
|
14
14
|
function FormBlock({ block }: { block: BlockOf<"form"> }) {
|
|
@@ -49,11 +49,13 @@ export const blocks: BlockComponents = {
|
|
|
49
49
|
people: People,
|
|
50
50
|
logos: Logos,
|
|
51
51
|
location: Location,
|
|
52
|
+
locations: Locations,
|
|
52
53
|
schedule: Schedule,
|
|
53
54
|
form: FormBlock,
|
|
54
55
|
upcomingEvents: UpcomingEvents,
|
|
55
56
|
pastEvents: PastEvents,
|
|
56
57
|
eventSpotlight: EventSpotlight,
|
|
58
|
+
volunteerSignup: VolunteerSignup,
|
|
57
59
|
collectionList: CollectionList,
|
|
58
60
|
fundraiserSpotlight: FundraiserSpotlight,
|
|
59
61
|
fundraiserGrid: FundraiserGrid,
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import { Section, SectionHeading } from "@/components/ui";
|
|
1
|
+
import { directionsUrl, pinned, type BlockOf } from "@the-smithy/hearth";
|
|
2
|
+
import { Markdown, Section, SectionHeading, SmartLink } from "@/components/ui";
|
|
3
|
+
import { OpenMap } from "./OpenMap";
|
|
4
|
+
|
|
5
|
+
const linkStyle = "font-semibold text-highlight underline underline-offset-4";
|
|
3
6
|
|
|
4
7
|
export function Location({ block }: { block: BlockOf<"location"> }) {
|
|
5
8
|
const d = block.data;
|
|
6
|
-
const query = encodeURIComponent(`${d.name}, ${d.address}`);
|
|
7
9
|
return (
|
|
8
10
|
<Section>
|
|
9
11
|
<SectionHeading eyebrow={d.eyebrow} heading={d.heading} />
|
|
@@ -12,20 +14,49 @@ export function Location({ block }: { block: BlockOf<"location"> }) {
|
|
|
12
14
|
<h3 className="text-xl font-semibold">{d.name}</h3>
|
|
13
15
|
<address className="whitespace-pre-line not-italic">{d.address}</address>
|
|
14
16
|
{d.hours ? <p className="whitespace-pre-line">{d.hours}</p> : null}
|
|
15
|
-
{d.notes ? <
|
|
16
|
-
<a href={
|
|
17
|
+
{d.notes ? <Markdown className="opacity-80">{d.notes}</Markdown> : null}
|
|
18
|
+
<a href={directionsUrl(d)} rel="noopener" className={linkStyle}>
|
|
17
19
|
Get directions
|
|
18
20
|
</a>
|
|
19
21
|
</div>
|
|
20
|
-
{
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
22
|
+
{/* Content from before map pins has none: it shows no map until the pin is set in Hearth. */}
|
|
23
|
+
{d.showMap && d.position ? <OpenMap places={[{ name: d.name, address: d.address, position: d.position }]} label={`Map of ${d.name}`} className="aspect-[4/3]" /> : null}
|
|
24
|
+
</div>
|
|
25
|
+
</Section>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Several places: one map with a pin for each (places without a pin are only listed), and the list beside it. */
|
|
30
|
+
export function Locations({ block }: { block: BlockOf<"locations"> }) {
|
|
31
|
+
const d = block.data;
|
|
32
|
+
const onMap = d.showMap ? pinned(d.items) : [];
|
|
33
|
+
return (
|
|
34
|
+
<Section>
|
|
35
|
+
<SectionHeading eyebrow={d.eyebrow} heading={d.heading} intro={d.intro} />
|
|
36
|
+
<div className={`grid gap-10 ${onMap.length ? "md:grid-cols-[2fr_3fr]" : ""}`}>
|
|
37
|
+
{onMap.length ? (
|
|
38
|
+
<OpenMap places={onMap} label={d.heading ? `Map: ${d.heading}` : "Map of our locations"} className="aspect-[4/3] md:sticky md:top-24 md:order-2 md:aspect-auto md:h-[min(36rem,80vh)]" />
|
|
28
39
|
) : null}
|
|
40
|
+
<ul className={`divide-y divide-line border-y border-line ${onMap.length ? "" : "grid gap-x-10 sm:grid-cols-2 sm:divide-y-0 sm:border-y-0"}`}>
|
|
41
|
+
{d.items.map((place, i) => (
|
|
42
|
+
<li key={i} className="flex flex-col gap-2 py-6">
|
|
43
|
+
<h3 className="text-xl font-semibold">{place.name}</h3>
|
|
44
|
+
<address className="whitespace-pre-line not-italic">{place.address}</address>
|
|
45
|
+
{place.hours ? <p className="whitespace-pre-line">{place.hours}</p> : null}
|
|
46
|
+
{place.notes ? <p className="whitespace-pre-line opacity-80">{place.notes}</p> : null}
|
|
47
|
+
<p className="mt-1 flex flex-wrap gap-x-6 gap-y-2">
|
|
48
|
+
<a href={directionsUrl(place)} rel="noopener" className={linkStyle}>
|
|
49
|
+
Get directions<span className="sr-only"> to {place.name}</span>
|
|
50
|
+
</a>
|
|
51
|
+
{place.link ? (
|
|
52
|
+
<SmartLink href={place.link.href} className={linkStyle}>
|
|
53
|
+
{place.link.label}
|
|
54
|
+
</SmartLink>
|
|
55
|
+
) : null}
|
|
56
|
+
</p>
|
|
57
|
+
</li>
|
|
58
|
+
))}
|
|
59
|
+
</ul>
|
|
29
60
|
</div>
|
|
30
61
|
</Section>
|
|
31
62
|
);
|
|
@@ -13,7 +13,7 @@ export function Gallery({ block }: { block: BlockOf<"gallery"> }) {
|
|
|
13
13
|
{d.images.map((item, i) => (
|
|
14
14
|
<li key={i}>
|
|
15
15
|
<figure>
|
|
16
|
-
<Img image={item.image} className="aspect-[4/3] w-full rounded-xl object-cover" />
|
|
16
|
+
<Img image={item.image} crop={4 / 3} className="aspect-[4/3] w-full rounded-xl object-cover" />
|
|
17
17
|
{item.caption ? <figcaption className="mt-2 text-sm opacity-75">{item.caption}</figcaption> : null}
|
|
18
18
|
</figure>
|
|
19
19
|
</li>
|
|
@@ -68,7 +68,7 @@ export function People({ block }: { block: BlockOf<"people"> }) {
|
|
|
68
68
|
<ul className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
|
69
69
|
{d.people.map((p, i) => (
|
|
70
70
|
<li key={i} className="flex flex-col gap-3">
|
|
71
|
-
{p.image ? <Img image={p.image} className="aspect-square w-full rounded-2xl object-cover" /> : null}
|
|
71
|
+
{p.image ? <Img image={p.image} crop={1} className="aspect-square w-full rounded-2xl object-cover" /> : null}
|
|
72
72
|
<div>
|
|
73
73
|
<h3 className="text-xl font-semibold">{p.name}</h3>
|
|
74
74
|
{p.role ? <p className="opacity-75">{p.role}</p> : null}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import Link from "next/link";
|
|
2
2
|
import { Fragment, type ReactNode } from "react";
|
|
3
3
|
import ReactMarkdown from "react-markdown";
|
|
4
|
+
import { focalPosition, type ImageRef } from "@the-smithy/hearth";
|
|
4
5
|
|
|
5
6
|
type LinkRef = { label: string; href: string };
|
|
6
7
|
export type Tone = "default" | "muted" | "inverse" | "accent";
|
|
@@ -15,9 +16,9 @@ const toneClass: Record<Tone, string> = {
|
|
|
15
16
|
export const isDark = (tone?: Tone) => tone === "inverse" || tone === "accent";
|
|
16
17
|
|
|
17
18
|
/** A full-width band with the site's content width inside. */
|
|
18
|
-
export function Section({ tone = "default", className = "", children }: { tone?: Tone; className?: string; children: ReactNode }) {
|
|
19
|
+
export function Section({ tone = "default", id, className = "", children }: { tone?: Tone; id?: string; className?: string; children: ReactNode }) {
|
|
19
20
|
return (
|
|
20
|
-
<section className={toneClass[tone]}>
|
|
21
|
+
<section id={id} className={`${toneClass[tone]} ${id ? "scroll-mt-8" : ""}`}>
|
|
21
22
|
<div className={`mx-auto max-w-site px-4 py-16 sm:px-6 sm:py-20 ${className}`}>{children}</div>
|
|
22
23
|
</section>
|
|
23
24
|
);
|
|
@@ -103,17 +104,24 @@ export function Button({ link, variant = "primary", dark }: { link?: LinkRef; va
|
|
|
103
104
|
* An image from Hearth. Its width and height (from Hearth's media library) reserve its space, so the page doesn't jump
|
|
104
105
|
* as it loads. `priority` loads it first: use it for the image at the top of the page (the hero), which is usually
|
|
105
106
|
* the largest thing on screen; everything else loads as it scrolls into view.
|
|
107
|
+
*
|
|
108
|
+
* `crop` is for images cropped to fill a frame (`object-cover`): they keep the focal point set in Hearth's media
|
|
109
|
+
* library in view. Give the frame's aspect ratio (`crop={4 / 3}` for `aspect-[4/3]`) to centre the focal point, or
|
|
110
|
+
* `crop` alone for frames whose shape changes with the screen (a full-bleed hero). Leave it off for uncropped images.
|
|
106
111
|
*/
|
|
107
112
|
/* eslint-disable @next/next/no-img-element -- Hearth serves media from its CDN at the uploaded size */
|
|
108
113
|
export function Img({
|
|
109
114
|
image,
|
|
110
115
|
className = "",
|
|
111
116
|
priority = false,
|
|
117
|
+
crop,
|
|
112
118
|
}: {
|
|
113
|
-
image:
|
|
119
|
+
image: ImageRef;
|
|
114
120
|
className?: string;
|
|
115
121
|
priority?: boolean;
|
|
122
|
+
crop?: number | true;
|
|
116
123
|
}) {
|
|
124
|
+
const objectPosition = crop ? focalPosition(image, crop === true ? undefined : crop) : undefined;
|
|
117
125
|
return (
|
|
118
126
|
<img
|
|
119
127
|
src={image.src}
|
|
@@ -123,6 +131,7 @@ export function Img({
|
|
|
123
131
|
fetchPriority={priority ? "high" : "auto"}
|
|
124
132
|
decoding={priority ? "sync" : "async"}
|
|
125
133
|
className={className}
|
|
134
|
+
style={objectPosition ? { objectPosition } : undefined}
|
|
126
135
|
/>
|
|
127
136
|
);
|
|
128
137
|
}
|