@the-smithy/create-hearth-site 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/bin/create-hearth-site.js +5 -0
  2. package/package.json +2 -2
  3. package/template/AGENTS.md +10 -4
  4. package/template/CLAUDE.md +77 -19
  5. package/template/README.md +55 -25
  6. package/template/SITE.md +17 -0
  7. package/template/package.json +2 -1
  8. package/template/scripts/hearth.mjs +157 -13
  9. package/template/src/app/[[...slug]]/page.tsx +2 -54
  10. package/template/src/app/api/preview/exit/route.ts +1 -1
  11. package/template/src/app/api/preview/route.ts +1 -1
  12. package/template/src/app/api/revalidate/route.ts +1 -1
  13. package/template/src/app/apple-icon.tsx +1 -1
  14. package/template/src/app/donate/page.tsx +2 -35
  15. package/template/src/app/donate/thank-you/page.tsx +2 -22
  16. package/template/src/app/events/[slug]/page.tsx +2 -26
  17. package/template/src/app/events/page.tsx +2 -29
  18. package/template/src/app/fundraisers/[slug]/page.tsx +2 -55
  19. package/template/src/app/fundraisers/page.tsx +2 -37
  20. package/template/src/app/globals.css +3 -77
  21. package/template/src/app/icon.tsx +1 -1
  22. package/template/src/app/layout.tsx +2 -36
  23. package/template/src/app/llms.txt/route.ts +7 -0
  24. package/template/src/app/not-found.tsx +2 -14
  25. package/template/src/app/registered/page.tsx +2 -22
  26. package/template/src/app/robots.ts +1 -1
  27. package/template/src/app/sitemap.ts +2 -2
  28. package/template/src/{app → hearth}/actions.ts +1 -1
  29. package/template/src/{components → hearth}/blocks/OpenMap.tsx +11 -9
  30. package/template/src/hearth/blocks/collections.tsx +74 -0
  31. package/template/src/hearth/blocks/content.tsx +252 -0
  32. package/template/src/hearth/blocks/custom.tsx +12 -0
  33. package/template/src/hearth/blocks/events.tsx +130 -0
  34. package/template/src/hearth/blocks/giving.tsx +123 -0
  35. package/template/src/{components → hearth}/blocks/index.tsx +14 -10
  36. package/template/src/hearth/blocks/info.tsx +84 -0
  37. package/template/src/{components → hearth}/blocks/media.tsx +30 -27
  38. package/template/src/hearth/components/EventView.tsx +120 -0
  39. package/template/src/{components → hearth/components}/Registration.tsx +14 -14
  40. package/template/src/hearth/components/Share.tsx +102 -0
  41. package/template/src/hearth/format.ts +31 -0
  42. package/template/src/hearth/forms/ContactForm.tsx +27 -0
  43. package/template/src/hearth/forms/DonateForm.tsx +69 -0
  44. package/template/src/{components/blocks → hearth/forms}/EmberFormBlock.tsx +5 -4
  45. package/template/src/hearth/forms/RegistrationForm.tsx +68 -0
  46. package/template/src/hearth/forms/VolunteerForm.tsx +58 -0
  47. package/template/src/hearth/forms/fields.tsx +137 -0
  48. package/template/src/hearth/hearth.css +52 -0
  49. package/template/src/hearth/layout/MobileNav.tsx +49 -0
  50. package/template/src/hearth/layout/NavLink.tsx +16 -0
  51. package/template/src/hearth/layout/RootLayout.tsx +40 -0
  52. package/template/src/{components → hearth/layout}/SiteFooter.tsx +19 -14
  53. package/template/src/hearth/layout/SiteHeader.tsx +53 -0
  54. package/template/src/hearth/manifest.json +37 -0
  55. package/template/src/hearth/pages/donate.tsx +36 -0
  56. package/template/src/hearth/pages/event.tsx +26 -0
  57. package/template/src/hearth/pages/events.tsx +30 -0
  58. package/template/src/hearth/pages/fundraiser.tsx +77 -0
  59. package/template/src/hearth/pages/fundraisers.tsx +38 -0
  60. package/template/src/hearth/pages/not-found.tsx +15 -0
  61. package/template/src/hearth/pages/page.tsx +54 -0
  62. package/template/src/hearth/pages/registered.tsx +19 -0
  63. package/template/src/hearth/pages/thank-you.tsx +19 -0
  64. package/template/src/hearth/theme/defaults.ts +510 -0
  65. package/template/src/hearth/theme/index.ts +31 -0
  66. package/template/src/hearth/ui.tsx +144 -0
  67. package/template/src/theme/custom-blocks.tsx +11 -0
  68. package/template/src/theme/fonts.ts +9 -0
  69. package/template/src/theme/index.ts +13 -0
  70. package/template/src/theme/overrides.tsx +16 -0
  71. package/template/src/theme/theme.css +29 -0
  72. package/template/src/components/DonateForm.tsx +0 -104
  73. package/template/src/components/EventView.tsx +0 -107
  74. package/template/src/components/RegistrationForm.tsx +0 -103
  75. package/template/src/components/SiteHeader.tsx +0 -37
  76. package/template/src/components/VolunteerForm.tsx +0 -143
  77. package/template/src/components/blocks/Form.tsx +0 -92
  78. package/template/src/components/blocks/collections.tsx +0 -75
  79. package/template/src/components/blocks/content.tsx +0 -258
  80. package/template/src/components/blocks/custom.tsx +0 -20
  81. package/template/src/components/blocks/events.tsx +0 -138
  82. package/template/src/components/blocks/giving.tsx +0 -120
  83. package/template/src/components/blocks/info.tsx +0 -83
  84. package/template/src/components/ui.tsx +0 -137
  85. /package/template/src/{lib/hearth.ts → hearth/client.ts} +0 -0
  86. /package/template/src/{lib → hearth}/site.ts +0 -0
@@ -0,0 +1,144 @@
1
+ import Link from "next/link";
2
+ import { Fragment, type AnchorHTMLAttributes, type ReactNode } from "react";
3
+ import ReactMarkdown from "react-markdown";
4
+ import { focalPosition, type ImageRef } from "@the-smithy/hearth";
5
+ import { theme } from "@/theme";
6
+ import { cx } from "./theme";
7
+
8
+ /*
9
+ * The pieces every block is built from. Their classes come from the theme (src/theme/index.ts); build a site's custom
10
+ * blocks from them too, so they match.
11
+ */
12
+
13
+ type LinkRef = { label: string; href: string };
14
+ export type Tone = "default" | "muted" | "inverse" | "accent";
15
+
16
+ const TONES: readonly string[] = ["default", "muted", "inverse", "accent"];
17
+ /** A tone from Hearth or from a `tone` slot in the theme (anything else is the default). */
18
+ export const toTone = (value: string | undefined): Tone => (value && TONES.includes(value) ? (value as Tone) : "default");
19
+ export const isDark = (tone?: Tone) => tone === "inverse" || tone === "accent";
20
+ /** Marks an element's ground, for the on-dark: variant. */
21
+ export const ground = (tone?: Tone) => ({ "data-ground": isDark(tone) ? "dark" : "light", "data-tone": tone ?? "default" });
22
+
23
+ /** Fills {name} placeholders in a theme text: fill(theme.text.giveTo, { fundraiser: f.title }). */
24
+ export const fill = (text: string, values: Record<string, string>) => text.replace(/\{(\w+)\}/g, (m, k: string) => values[k] ?? m);
25
+
26
+ /** A full-width band with the site's content width inside. `block` names the block type, for CSS that targets it. */
27
+ export function Section({ tone = "default", id, block, className, innerClassName, children }: { tone?: Tone; id?: string; block?: string; className?: string; innerClassName?: string; children: ReactNode }) {
28
+ return (
29
+ <section id={id} data-block={block} {...ground(tone)} className={cx(theme.tones[tone], theme.section.root, id && "scroll-mt-8", className)}>
30
+ <div className={cx(theme.section.inner, innerClassName)}>{children}</div>
31
+ </section>
32
+ );
33
+ }
34
+
35
+ /**
36
+ * Headings may mark words with *asterisks* to highlight them:
37
+ * "Fresh bread, *baked daily*".
38
+ */
39
+ export function Emphasis({ text, className = theme.emphasis.mark }: { text: string; className?: string }) {
40
+ return (
41
+ <>
42
+ {text.split(/\*([^*]+)\*/g).map((part, i) =>
43
+ i % 2 ? (
44
+ <span key={i} className={className}>
45
+ {part}
46
+ </span>
47
+ ) : (
48
+ <Fragment key={i}>{part}</Fragment>
49
+ ),
50
+ )}
51
+ </>
52
+ );
53
+ }
54
+
55
+ /** The text without highlight markers, for alt text and titles. */
56
+ export const plain = (text: string) => text.replace(/\*([^*]+)\*/g, "$1");
57
+
58
+ export function SectionHeading({ eyebrow, heading, intro }: { eyebrow?: string; heading?: string; intro?: string }) {
59
+ if (!eyebrow && !heading && !intro) return null;
60
+ const t = theme.heading;
61
+ return (
62
+ <div className={t.root}>
63
+ {eyebrow ? <p className={t.eyebrow}>{eyebrow}</p> : null}
64
+ {heading ? (
65
+ <h2 className={t.title}>
66
+ <Emphasis text={heading} />
67
+ </h2>
68
+ ) : null}
69
+ {intro ? <p className={t.intro}>{intro}</p> : null}
70
+ </div>
71
+ );
72
+ }
73
+
74
+ /** Markdown from Hearth. Give it its slot's classes (they include `prose`). */
75
+ export function Markdown({ children, className = theme.markdown.root, ...data }: { children: string; className?: string } & Record<`data-${string}`, string | undefined>) {
76
+ return (
77
+ <div className={className} {...data}>
78
+ <ReactMarkdown>{children}</ReactMarkdown>
79
+ </div>
80
+ );
81
+ }
82
+
83
+ export const isExternal = (href: string) => /^(https?:|mailto:|tel:)/.test(href);
84
+
85
+ /** A link to a path on the site or to anywhere else. */
86
+ export function SmartLink({ href, className, children, ...rest }: { href: string; className?: string; children: ReactNode } & Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href">) {
87
+ return isExternal(href) ? (
88
+ <a href={href} className={className} {...rest}>
89
+ {children}
90
+ </a>
91
+ ) : (
92
+ <Link href={href} className={className} {...rest}>
93
+ {children}
94
+ </Link>
95
+ );
96
+ }
97
+
98
+ /** A link drawn as a button: the theme's primary or secondary button, at its normal or small size. */
99
+ export function Button({ link, variant = "primary", size }: { link?: LinkRef; variant?: "primary" | "secondary"; size?: "small" }) {
100
+ if (!link) return null;
101
+ return (
102
+ <SmartLink href={link.href} className={theme.button[variant]} data-size={size}>
103
+ {link.label}
104
+ </SmartLink>
105
+ );
106
+ }
107
+
108
+ /**
109
+ * An image from Hearth. Its width and height (from Hearth's media library) reserve its space, so the page doesn't jump
110
+ * as it loads. `priority` loads it first: use it for the image at the top of the page (the hero), which is usually
111
+ * the largest thing on screen; everything else loads as it scrolls into view.
112
+ *
113
+ * `crop` is for images cropped to fill a frame (`object-cover`): they keep the focal point set in Hearth's media
114
+ * library in view. Give the frame's aspect ratio (`crop={4 / 3}` for `aspect-[4/3]`) to centre the focal point, or
115
+ * `crop` alone for frames whose shape changes with the screen (a full-bleed hero). Leave it off for uncropped images.
116
+ */
117
+ /* eslint-disable @next/next/no-img-element -- Hearth serves media from its CDN at the uploaded size */
118
+ export function Img({
119
+ image,
120
+ className = "",
121
+ priority = false,
122
+ crop,
123
+ ...data
124
+ }: {
125
+ image: ImageRef;
126
+ className?: string;
127
+ priority?: boolean;
128
+ crop?: number | true;
129
+ } & Record<`data-${string}`, string | undefined>) {
130
+ const objectPosition = crop ? focalPosition(image, crop === true ? undefined : crop) : undefined;
131
+ return (
132
+ <img
133
+ src={image.src}
134
+ alt={image.alt}
135
+ {...(image.width && image.height ? { width: image.width, height: image.height } : {})}
136
+ loading={priority ? "eager" : "lazy"}
137
+ fetchPriority={priority ? "high" : "auto"}
138
+ decoding={priority ? "sync" : "async"}
139
+ className={className}
140
+ style={objectPosition ? { objectPosition } : undefined}
141
+ {...data}
142
+ />
143
+ );
144
+ }
@@ -0,0 +1,11 @@
1
+ import type { ComponentType } from "react";
2
+
3
+ /**
4
+ * This site's own blocks (Settings → Custom blocks in Hearth), one component per block key. Hearth stores what
5
+ * editors enter under `values`, keyed by each field's name; images come as { src, alt } with absolute URLs, links
6
+ * as { label, href }, lists as arrays of items. GET /custom-blocks (`hearth.customBlocks.list()`) describes them.
7
+ * Build them from the pieces in src/hearth/ui.tsx so they take the theme.
8
+ *
9
+ * "team-member": ({ values }) => <TeamMember name={values.name as string} photo={values.photo as ImageRef} />,
10
+ */
11
+ export const customBlocks: Record<string, ComponentType<{ values: Record<string, unknown> }>> = {};
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Web fonts for this site, loaded with next/font (self-hosted, no layout shift). Give each a CSS variable, list the
3
+ * variables below, and use them in theme.css, e.g. --font-sans: var(--font-inter), sans-serif.
4
+ *
5
+ * import { Inter } from "next/font/google";
6
+ * const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
7
+ * export const fontVariables = inter.variable;
8
+ */
9
+ export const fontVariables = "";
@@ -0,0 +1,13 @@
1
+ import { defineTheme } from "@/hearth/theme";
2
+
3
+ /**
4
+ * This site's look, slot by slot. Every slot and its default classes is in src/hearth/theme/defaults.ts; set only
5
+ * the ones this design changes. A string replaces the default; a function extends it. Colors and fonts are tokens in
6
+ * theme.css, so prefer changing a token over many slots.
7
+ *
8
+ * hero: { title: "font-display text-6xl uppercase", inner: (base) => `${base} text-center` },
9
+ * button: { primary: "inline-block rounded-none bg-accent px-6 py-3 font-mono uppercase text-accent-ink" },
10
+ * stats: { tone: "accent" },
11
+ * text: { directions: "Directions" },
12
+ */
13
+ export const theme = defineTheme({});
@@ -0,0 +1,16 @@
1
+ import type { BlockComponents } from "@the-smithy/hearth/react";
2
+ import type { ComponentType } from "react";
3
+ import type { Site } from "@the-smithy/hearth";
4
+
5
+ /**
6
+ * The last resort, when a design can't be reached with slots and tokens: replace a whole block's component, or draw
7
+ * the header's logo yourself. Replaced components are this site's own code: `pnpm hearth:update` won't update them,
8
+ * so check them against src/hearth/blocks after each update. Build them from src/hearth/ui.tsx and the theme.
9
+ *
10
+ * import { CountingStats } from "./CountingStats";
11
+ * export const blocks: Partial<BlockComponents> = { stats: CountingStats };
12
+ */
13
+ export const blocks: Partial<BlockComponents> = {};
14
+
15
+ /** The header's logo; leave null to show the logo uploaded in Hearth (Settings → Search & sharing) and the site name. */
16
+ export const Logo: ComponentType<{ site: Site }> | null = null;
@@ -0,0 +1,29 @@
1
+ /*
2
+ * The site's brand: colors, fonts and widths as Tailwind tokens. Hearth's slots (src/hearth/theme/defaults.ts) only
3
+ * use these names, so a new design starts here. Hearth's "tone" options map onto them: default (bg, ink), muted,
4
+ * inverse and accent. Check text contrast on every ground, and form field borders at 3:1.
5
+ *
6
+ * Add the site's own CSS below the tokens: keyframes, background patterns, @utility classes your slots use.
7
+ */
8
+ @theme {
9
+ --color-bg: #fbfaf7;
10
+ --color-ink: #1c1c1a;
11
+ --color-muted-ink: #5b5b55;
12
+ /* Dividers and card edges: decorative, so they can be faint. */
13
+ --color-line: #e3e0d8;
14
+ /* Form field and option borders: the border is how people see the field, so it needs 3:1 on surface, bg and muted. */
15
+ --color-field: #8a867e;
16
+ --color-surface: #ffffff;
17
+ --color-muted: #f1eee7;
18
+ --color-inverse: #1c1c1a;
19
+ --color-inverse-ink: #f6f4ee;
20
+ --color-accent: #2f5d50;
21
+ --color-accent-ink: #ffffff;
22
+ /* Highlighted words in headings (*like this*) and links. Check contrast on every ground. */
23
+ --color-highlight: #2f5d50;
24
+ --color-danger: #a12a1f;
25
+
26
+ /* Fonts loaded in src/theme/fonts.ts set variables these can use, e.g. --font-sans: var(--font-inter), sans-serif. */
27
+ --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
28
+ --container-site: 72rem;
29
+ }
@@ -1,104 +0,0 @@
1
- "use client";
2
-
3
- import { useActionState, useState } from "react";
4
- import { useFormStatus } from "react-dom";
5
- import type { FormResult } from "@the-smithy/hearth";
6
- import { donate } 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
- const money = (cents: number) => (cents / 100).toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: cents % 100 ? 2 : 0 });
10
-
11
- function Submit({ label }: { label: string }) {
12
- const { pending } = useFormStatus();
13
- return (
14
- <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">
15
- {pending ? "Opening secure checkout…" : label}
16
- </button>
17
- );
18
- }
19
-
20
- /**
21
- * Suggested amounts (from Hearth's Giving settings), a custom amount and optional details. Submitting starts a
22
- * checkout through Hearth and sends the donor to the payment page; they come back to /donate/thank-you.
23
- */
24
- export function DonateForm({
25
- presetsCents,
26
- minCents,
27
- maxCents,
28
- fundraiser,
29
- submitLabel = "Donate",
30
- }: {
31
- presetsCents: number[];
32
- minCents: number;
33
- maxCents: number;
34
- fundraiser?: string;
35
- submitLabel?: string;
36
- }) {
37
- const [state, action] = useActionState<FormResult | null, FormData>(donate, null);
38
- const [amount, setAmount] = useState(presetsCents[1] !== undefined ? String(presetsCents[1] / 100) : "custom");
39
- const errors = state && !state.ok ? state.fieldErrors : {};
40
-
41
- return (
42
- <form action={action} className="space-y-6" id="donate">
43
- {state && !state.ok ? (
44
- <p role="alert" className="rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-danger">
45
- {state.message}
46
- </p>
47
- ) : null}
48
- {fundraiser ? <input type="hidden" name="fundraiser" value={fundraiser} /> : null}
49
- <fieldset>
50
- <legend className="mb-3 font-semibold">Amount</legend>
51
- <div className="flex flex-wrap gap-3">
52
- {presetsCents.map((cents) => (
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
- <input type="radio" name="amount" value={cents / 100} checked={amount === String(cents / 100)} onChange={(e) => setAmount(e.target.value)} className="sr-only" />
55
- {money(cents)}
56
- </label>
57
- ))}
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
- <input type="radio" name="amount" value="custom" checked={amount === "custom"} onChange={(e) => setAmount(e.target.value)} className="sr-only" />
60
- Other
61
- </label>
62
- </div>
63
- {amount === "custom" ? (
64
- <label className="mt-4 block max-w-xs">
65
- <span className="mb-1.5 block text-sm font-semibold">Amount in dollars</span>
66
- <input name="customAmount" inputMode="decimal" required placeholder={String(minCents / 100)} className={input} />
67
- </label>
68
- ) : null}
69
- <p className="mt-2 text-sm opacity-75">
70
- {money(minCents)} to {money(maxCents)}.
71
- </p>
72
- {errors.amount ? <p className="mt-2 text-danger">{errors.amount}</p> : null}
73
- </fieldset>
74
- <div className="grid gap-5 sm:grid-cols-2">
75
- <label className="block">
76
- <span className="mb-1.5 block font-semibold">Name</span>
77
- <input name="name" autoComplete="name" className={input} />
78
- </label>
79
- <label className="block">
80
- <span className="mb-1.5 block font-semibold">Email for your receipt</span>
81
- <input name="email" type="email" autoComplete="email" className={input} />
82
- {errors.email ? <span className="mt-1 block text-danger">{errors.email}</span> : null}
83
- </label>
84
- </div>
85
- <label className="block">
86
- <span className="mb-1.5 block font-semibold">Message (optional)</span>
87
- <textarea name="message" rows={3} maxLength={500} className={input} />
88
- </label>
89
- <label className="flex items-center gap-3">
90
- <input type="checkbox" name="anonymous" className="size-5 accent-accent" />
91
- Don&apos;t show my name publicly
92
- </label>
93
- {/* Opt-in only, unticked: the fundraiser's updates go to donors who ask (and give an email). */}
94
- {fundraiser ? (
95
- <label className="flex items-center gap-3">
96
- <input type="checkbox" name="updates" className="size-5 accent-accent" />
97
- Send me updates about this fundraiser
98
- </label>
99
- ) : null}
100
- <Submit label={submitLabel} />
101
- <p className="text-sm opacity-75">You&apos;ll finish on a secure payment page.</p>
102
- </form>
103
- );
104
- }
@@ -1,107 +0,0 @@
1
- import type { BlockOf, HearthEventDetail } from "@the-smithy/hearth";
2
- import { JsonLd } from "@the-smithy/hearth/react";
3
- import { Blocks } from "@/components/blocks";
4
- import { Hero } from "@/components/blocks/content";
5
- import { formatEventDate, volunteerAnchor } from "@/components/blocks/events";
6
- import { Registration } from "@/components/Registration";
7
- import { RegistrationForm } from "@/components/RegistrationForm";
8
- import { VolunteerForm } from "@/components/VolunteerForm";
9
- import { Img } from "@/components/ui";
10
-
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
- */
17
- export function EventView({ event }: { event: HearthEventDetail }) {
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
-
49
- return (
50
- <>
51
- <JsonLd data={event.meta.jsonLd} />
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}
80
- </div>
81
- </section>
82
- )}
83
- <Blocks blocks={hero ? rest : event.blocks} />
84
- {event.registration?.open ? (
85
- <section id="register" className="mx-auto max-w-site scroll-mt-8 px-4 py-16 sm:px-6">
86
- <div className="max-w-2xl">
87
- <h2 className="mb-2 text-2xl font-bold">Register</h2>
88
- {event.registration.fundraiser ? <p className="mb-6 opacity-80">Ticket sales support {event.registration.fundraiser.title}.</p> : null}
89
- <RegistrationForm slug={event.slug} tickets={event.registration.tickets} />
90
- </div>
91
- </section>
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}
105
- </>
106
- );
107
- }
@@ -1,103 +0,0 @@
1
- "use client";
2
-
3
- import { useActionState, useState } from "react";
4
- import { useFormStatus } from "react-dom";
5
- import type { EventTicket, FormResult } from "@the-smithy/hearth";
6
- import { register } 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
- const price = (cents: number) => (cents ? (cents / 100).toLocaleString("en-US", { style: "currency", currency: "USD", maximumFractionDigits: cents % 100 ? 2 : 0 }) : "Free");
10
-
11
- function Submit({ paid }: { paid: boolean }) {
12
- const { pending } = useFormStatus();
13
- return (
14
- <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">
15
- {pending ? (paid ? "Opening secure checkout…" : "Registering…") : paid ? "Continue to payment" : "Register"}
16
- </button>
17
- );
18
- }
19
-
20
- /**
21
- * On-site registration (sites with Tickets): pick a ticket, give contact details and attendee names. Free tickets
22
- * are confirmed here; paid ones go to the payment page and come back to /registered.
23
- */
24
- export function RegistrationForm({ slug, tickets }: { slug: string; tickets: EventTicket[] }) {
25
- const [state, action] = useActionState<FormResult | null, FormData>(register.bind(null, slug), null);
26
- const available = tickets.filter((t) => !t.soldOut);
27
- const [ticketId, setTicketId] = useState(available[0]?.id ?? "");
28
- const ticket = tickets.find((t) => t.id === ticketId);
29
- const errors = state && !state.ok ? state.fieldErrors : {};
30
-
31
- if (state?.ok) {
32
- return (
33
- <p role="status" className="rounded-xl bg-accent p-6 text-lg font-semibold text-accent-ink">
34
- {state.message}
35
- </p>
36
- );
37
- }
38
-
39
- return (
40
- <form action={action} className="space-y-6">
41
- {state && !state.ok ? (
42
- <p role="alert" className="rounded-lg border border-danger/30 bg-danger/5 px-4 py-3 text-danger">
43
- {state.message}
44
- </p>
45
- ) : null}
46
- <fieldset>
47
- <legend className="mb-3 font-semibold">Ticket</legend>
48
- <div className="space-y-3">
49
- {tickets.map((t) => (
50
- <label key={t.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 ${t.soldOut ? "opacity-50" : ""}`}>
51
- <input type="radio" name="ticket" value={t.id} checked={ticketId === t.id} disabled={t.soldOut} onChange={() => setTicketId(t.id)} className="mt-1 size-5 accent-accent" />
52
- <span className="flex-1">
53
- <span className="flex flex-wrap justify-between gap-2 font-semibold">
54
- <span>{t.name}</span>
55
- <span>{t.soldOut ? "Sold out" : price(t.priceCents)}</span>
56
- </span>
57
- {t.description ? <span className="mt-1 block opacity-80">{t.description}</span> : null}
58
- {t.remaining !== null && !t.soldOut && t.remaining <= 10 ? <span className="mt-1 block text-sm opacity-75">{t.remaining} left</span> : null}
59
- </span>
60
- </label>
61
- ))}
62
- </div>
63
- {errors.ticket ? <p className="mt-2 text-danger">{errors.ticket}</p> : null}
64
- </fieldset>
65
- <div className="grid gap-5 sm:grid-cols-2">
66
- <label className="block">
67
- <span className="mb-1.5 block font-semibold">Name</span>
68
- <input name="name" required autoComplete="name" className={input} />
69
- {errors.name ? <span className="mt-1 block text-danger">{errors.name}</span> : null}
70
- </label>
71
- <label className="block">
72
- <span className="mb-1.5 block font-semibold">Email</span>
73
- <input name="email" type="email" required autoComplete="email" className={input} />
74
- {errors.email ? <span className="mt-1 block text-danger">{errors.email}</span> : null}
75
- </label>
76
- <label className="block">
77
- <span className="mb-1.5 block font-semibold">Phone (optional)</span>
78
- <input name="phone" type="tel" autoComplete="tel" className={input} />
79
- </label>
80
- <label className="block">
81
- <span className="mb-1.5 block font-semibold">Team or organization (optional)</span>
82
- <input name="teamName" className={input} />
83
- </label>
84
- </div>
85
- {ticket && ticket.attendeesPerRegistration > 0 ? (
86
- <fieldset>
87
- <legend className="mb-3 font-semibold">{ticket.attendeesPerRegistration === 1 ? (ticket.attendeeLabel ?? "Attendee") : `${ticket.attendeeLabel ?? "Attendee"}s`}</legend>
88
- <div className="grid gap-3 sm:grid-cols-2">
89
- {Array.from({ length: ticket.attendeesPerRegistration }, (_, i) => (
90
- <input key={`${ticket.id}-${i}`} name="attendee" required aria-label={`${ticket.attendeeLabel ?? "Attendee"} ${i + 1}`} placeholder={`${ticket.attendeeLabel ?? "Attendee"} ${i + 1}`} className={input} />
91
- ))}
92
- </div>
93
- {errors.attendees ? <p className="mt-2 text-danger">{errors.attendees}</p> : null}
94
- </fieldset>
95
- ) : null}
96
- <label className="block">
97
- <span className="mb-1.5 block font-semibold">Notes (optional)</span>
98
- <textarea name="notes" rows={3} maxLength={1000} className={input} />
99
- </label>
100
- <Submit paid={Boolean(ticket?.priceCents)} />
101
- </form>
102
- );
103
- }
@@ -1,37 +0,0 @@
1
- import Link from "next/link";
2
- import type { Site } from "@the-smithy/hearth";
3
- import { Img, SmartLink } from "@/components/ui";
4
-
5
- /** Logo, menu, menu button and announcement banner, all edited in Hearth (Settings → General, and Navigation). */
6
- export function SiteHeader({ site }: { site: Site }) {
7
- const { announcement, seo, tagline, navigation, navigationCta } = site.settings;
8
- return (
9
- <header className="border-b border-line bg-bg">
10
- {announcement ? <p className="bg-accent px-4 py-2 text-center text-sm font-semibold text-accent-ink">{announcement}</p> : null}
11
- <div className="mx-auto flex max-w-site flex-wrap items-center justify-between gap-x-8 gap-y-3 px-4 py-4 sm:px-6">
12
- <Link href="/" className="flex items-center gap-3 text-xl font-bold">
13
- {seo.logo ? <Img image={{ src: seo.logo.src, alt: "" }} className="h-10 w-auto" /> : null}
14
- <span className="flex flex-col">
15
- <span>{site.name}</span>
16
- {tagline ? <span className="text-sm font-normal text-muted-ink">{tagline}</span> : null}
17
- </span>
18
- </Link>
19
- {navigation.length || navigationCta ? (
20
- <nav aria-label="Main" className="flex flex-wrap items-center gap-x-6 gap-y-1 font-medium">
21
- {navigation.map((item) => (
22
- <SmartLink key={item.href + item.label} href={item.href} className="py-2 hover:text-highlight">
23
- {item.label}
24
- </SmartLink>
25
- ))}
26
- {/* The menu button (Navigation → Menu button in Hearth): one action that stands out from the links. */}
27
- {navigationCta ? (
28
- <SmartLink href={navigationCta.href} className="rounded-lg bg-accent px-4 py-2 font-semibold text-accent-ink hover:opacity-90">
29
- {navigationCta.label}
30
- </SmartLink>
31
- ) : null}
32
- </nav>
33
- ) : null}
34
- </div>
35
- </header>
36
- );
37
- }