@the-smithy/create-hearth-site 0.0.0-stage → 0.13.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 (50) hide show
  1. package/LICENSE +17 -0
  2. package/README.md +36 -2
  3. package/bin/create-hearth-site.js +120 -0
  4. package/package.json +34 -3
  5. package/template/.env.example +8 -0
  6. package/template/CLAUDE.md +37 -0
  7. package/template/README.md +68 -0
  8. package/template/_gitignore +11 -0
  9. package/template/_pnpm-workspace.yaml +2 -0
  10. package/template/netlify.toml +6 -0
  11. package/template/next.config.ts +27 -0
  12. package/template/package.json +32 -0
  13. package/template/postcss.config.mjs +3 -0
  14. package/template/src/app/[[...slug]]/page.tsx +54 -0
  15. package/template/src/app/actions.ts +43 -0
  16. package/template/src/app/api/preview/exit/route.ts +4 -0
  17. package/template/src/app/api/preview/route.ts +8 -0
  18. package/template/src/app/api/revalidate/route.ts +8 -0
  19. package/template/src/app/donate/page.tsx +27 -0
  20. package/template/src/app/donate/thank-you/page.tsx +22 -0
  21. package/template/src/app/events/[slug]/page.tsx +25 -0
  22. package/template/src/app/events/page.tsx +21 -0
  23. package/template/src/app/fundraisers/[slug]/page.tsx +55 -0
  24. package/template/src/app/fundraisers/page.tsx +29 -0
  25. package/template/src/app/globals.css +75 -0
  26. package/template/src/app/layout.tsx +39 -0
  27. package/template/src/app/not-found.tsx +14 -0
  28. package/template/src/app/registered/page.tsx +22 -0
  29. package/template/src/app/robots.ts +10 -0
  30. package/template/src/app/sitemap.ts +4 -0
  31. package/template/src/components/DonateForm.tsx +97 -0
  32. package/template/src/components/EventView.tsx +48 -0
  33. package/template/src/components/Registration.tsx +42 -0
  34. package/template/src/components/RegistrationForm.tsx +103 -0
  35. package/template/src/components/SiteFooter.tsx +61 -0
  36. package/template/src/components/SiteHeader.tsx +31 -0
  37. package/template/src/components/blocks/EmberFormBlock.tsx +38 -0
  38. package/template/src/components/blocks/Form.tsx +92 -0
  39. package/template/src/components/blocks/collections.tsx +75 -0
  40. package/template/src/components/blocks/content.tsx +257 -0
  41. package/template/src/components/blocks/custom.tsx +20 -0
  42. package/template/src/components/blocks/events.tsx +92 -0
  43. package/template/src/components/blocks/giving.tsx +120 -0
  44. package/template/src/components/blocks/index.tsx +65 -0
  45. package/template/src/components/blocks/info.tsx +52 -0
  46. package/template/src/components/blocks/media.tsx +107 -0
  47. package/template/src/components/ui.tsx +105 -0
  48. package/template/src/lib/hearth.ts +12 -0
  49. package/template/src/lib/site.ts +9 -0
  50. package/template/tsconfig.json +21 -0
@@ -0,0 +1,55 @@
1
+ import { metadataFromMeta } from "@the-smithy/hearth/next";
2
+ import { JsonLd } from "@the-smithy/hearth/react";
3
+ import { notFound } from "next/navigation";
4
+ import { Blocks } from "@/components/blocks";
5
+ import { Progress } from "@/components/blocks/giving";
6
+ import { DonateForm } from "@/components/DonateForm";
7
+ import { Img, Section } from "@/components/ui";
8
+ import { hearthNext } from "@/lib/hearth";
9
+ import { hasFeature } from "@/lib/site";
10
+
11
+ type Props = { params: Promise<{ slug: string }> };
12
+
13
+ async function load(slug: string) {
14
+ const site = await hearthNext.getSite();
15
+ return hasFeature(site, "giving") ? { site, fundraiser: await hearthNext.getFundraiser(slug) } : null;
16
+ }
17
+
18
+ export async function generateMetadata({ params }: Props) {
19
+ const loaded = await load((await params).slug);
20
+ return loaded?.fundraiser ? metadataFromMeta(loaded.fundraiser.meta) : {};
21
+ }
22
+
23
+ /** A fundraiser's page: progress and a donation form, then its story blocks. */
24
+ export default async function FundraiserPage({ params }: Props) {
25
+ const loaded = await load((await params).slug);
26
+ if (!loaded?.fundraiser) notFound();
27
+ const { site, fundraiser: f } = loaded;
28
+ const giving = site.settings.giving;
29
+
30
+ return (
31
+ <>
32
+ <JsonLd data={f.meta.jsonLd} />
33
+ <section className="bg-muted">
34
+ <div className="mx-auto grid max-w-site items-center gap-10 px-4 py-16 sm:px-6 md:grid-cols-2">
35
+ <div className="flex flex-col gap-4">
36
+ <p className="text-sm font-semibold uppercase tracking-[0.18em] opacity-75">{f.status === "completed" ? "Completed fundraiser" : "Fundraiser"}</p>
37
+ <h1 className="text-[clamp(32px,4.4vw,52px)] font-bold leading-tight tracking-tight">{f.title}</h1>
38
+ {f.summary ? <p className="text-lg opacity-85">{f.summary}</p> : null}
39
+ <Progress fundraiser={f} large />
40
+ </div>
41
+ {f.image ? <Img image={f.image} className="aspect-[4/3] w-full rounded-xl object-cover" /> : null}
42
+ </div>
43
+ </section>
44
+ <Blocks blocks={f.blocks} />
45
+ {f.status === "active" ? (
46
+ <Section>
47
+ <div className="max-w-2xl">
48
+ <h2 className="mb-6 text-2xl font-bold">Give to {f.title}</h2>
49
+ <DonateForm presetsCents={giving.presetsCents} minCents={giving.minCents} maxCents={giving.maxCents} fundraiser={f.slug} />
50
+ </div>
51
+ </Section>
52
+ ) : null}
53
+ </>
54
+ );
55
+ }
@@ -0,0 +1,29 @@
1
+ import type { Metadata } from "next";
2
+ import { notFound } from "next/navigation";
3
+ import { FundraiserList } from "@/components/blocks/giving";
4
+ import { Section } from "@/components/ui";
5
+ import { hearthNext } from "@/lib/hearth";
6
+ import { hasFeature } from "@/lib/site";
7
+
8
+ export const metadata: Metadata = { title: "Fundraisers", alternates: { canonical: "/fundraisers" } };
9
+
10
+ /** Active fundraisers, then completed ones (only on sites with the Giving feature in Hearth). */
11
+ export default async function FundraisersPage() {
12
+ const site = await hearthNext.getSite();
13
+ if (!hasFeature(site, "giving")) notFound();
14
+ const [active, completed] = await Promise.all([hearthNext.getFundraisers({ status: "active" }), hearthNext.getFundraisers({ status: "completed" })]);
15
+ return (
16
+ <>
17
+ <Section>
18
+ <h1 className="mb-10 text-[clamp(32px,4vw,48px)] font-bold tracking-tight">Fundraisers</h1>
19
+ <FundraiserList fundraisers={active} />
20
+ </Section>
21
+ {completed.length ? (
22
+ <Section tone="muted">
23
+ <h2 className="mb-8 text-2xl font-bold">Completed</h2>
24
+ <FundraiserList fundraisers={completed} />
25
+ </Section>
26
+ ) : null}
27
+ </>
28
+ );
29
+ }
@@ -0,0 +1,75 @@
1
+ @import "tailwindcss";
2
+
3
+ /*
4
+ * The site's brand. Blocks only use these tokens, so restyling a new site starts here.
5
+ * Hearth's "tone" options map to them: default, muted, inverse and accent.
6
+ */
7
+ @theme {
8
+ --color-bg: #fbfaf7;
9
+ --color-ink: #1c1c1a;
10
+ --color-muted-ink: #5b5b55;
11
+ --color-line: #e3e0d8;
12
+ --color-surface: #ffffff;
13
+ --color-muted: #f1eee7;
14
+ --color-inverse: #1c1c1a;
15
+ --color-inverse-ink: #f6f4ee;
16
+ --color-accent: #2f5d50;
17
+ --color-accent-ink: #ffffff;
18
+ /* Highlighted words in headings (*like this*) and links. Check contrast on every ground. */
19
+ --color-highlight: #2f5d50;
20
+ --color-danger: #a12a1f;
21
+
22
+ --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
23
+ --container-site: 72rem;
24
+ }
25
+
26
+ body {
27
+ background: var(--color-bg);
28
+ color: var(--color-ink);
29
+ font-family: var(--font-sans);
30
+ -webkit-font-smoothing: antialiased;
31
+ }
32
+
33
+ /* Markdown from Hearth (rich text, FAQ answers, media + text). */
34
+ .prose {
35
+ line-height: 1.7;
36
+ }
37
+ .prose > * + * {
38
+ margin-top: 1em;
39
+ }
40
+ .prose h2 {
41
+ font-size: 1.75rem;
42
+ font-weight: 700;
43
+ line-height: 1.25;
44
+ margin-top: 1.6em;
45
+ }
46
+ .prose h3 {
47
+ font-size: 1.35rem;
48
+ font-weight: 700;
49
+ margin-top: 1.4em;
50
+ }
51
+ .prose a {
52
+ text-decoration: underline;
53
+ text-underline-offset: 3px;
54
+ }
55
+ .prose ul {
56
+ list-style: disc;
57
+ padding-left: 1.4em;
58
+ }
59
+ .prose ol {
60
+ list-style: decimal;
61
+ padding-left: 1.4em;
62
+ }
63
+ .prose blockquote {
64
+ border-left: 3px solid var(--color-line);
65
+ padding-left: 1em;
66
+ font-style: italic;
67
+ }
68
+ .prose img {
69
+ border-radius: 0.75rem;
70
+ }
71
+
72
+ :focus-visible {
73
+ outline: 2px solid var(--color-accent);
74
+ outline-offset: 2px;
75
+ }
@@ -0,0 +1,39 @@
1
+ import type { Metadata } from "next";
2
+ import type { ReactNode } from "react";
3
+ import { HearthPreview } from "@the-smithy/hearth/preview";
4
+ import { SiteFooter } from "@/components/SiteFooter";
5
+ import { SiteHeader } from "@/components/SiteHeader";
6
+ import { hearthNext } from "@/lib/hearth";
7
+ import { siteUrl } from "@/lib/site";
8
+ import "./globals.css";
9
+
10
+ export async function generateMetadata(): Promise<Metadata> {
11
+ const site = await hearthNext.getSite();
12
+ const base = siteUrl(site);
13
+ return {
14
+ // Pages set their own full title, description, canonical and share image from Hearth.
15
+ title: { default: site.name, template: site.settings.seo.titleTemplate ?? `%s | ${site.name}` },
16
+ description: site.settings.seo.description,
17
+ ...(base ? { metadataBase: new URL(base) } : {}),
18
+ };
19
+ }
20
+
21
+ export default async function RootLayout({ children }: { children: ReactNode }) {
22
+ const site = await hearthNext.getSite();
23
+ return (
24
+ <html lang="en">
25
+ <body className="flex min-h-dvh flex-col">
26
+ <a href="#main" className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded focus:bg-surface focus:px-4 focus:py-2">
27
+ Skip to content
28
+ </a>
29
+ {/* Live draft preview from Hearth: shows while an editor previews, refreshes as they edit. */}
30
+ {(await hearthNext.isPreview()) ? <HearthPreview /> : null}
31
+ <SiteHeader site={site} />
32
+ <main id="main" className="flex-1">
33
+ {children}
34
+ </main>
35
+ <SiteFooter site={site} />
36
+ </body>
37
+ </html>
38
+ );
39
+ }
@@ -0,0 +1,14 @@
1
+ import Link from "next/link";
2
+ import { Section } from "@/components/ui";
3
+
4
+ export default function NotFound() {
5
+ return (
6
+ <Section className="py-28">
7
+ <h1 className="text-4xl font-bold">Page not found</h1>
8
+ <p className="mt-4 text-lg opacity-80">The page you&apos;re looking for has moved or no longer exists.</p>
9
+ <Link href="/" className="mt-8 inline-block rounded-lg bg-accent px-6 py-3.5 font-semibold text-accent-ink hover:opacity-90">
10
+ Go to the homepage
11
+ </Link>
12
+ </Section>
13
+ );
14
+ }
@@ -0,0 +1,22 @@
1
+ import type { Metadata } from "next";
2
+ import Link from "next/link";
3
+ import { Section } from "@/components/ui";
4
+
5
+ export const metadata: Metadata = { title: "You're registered", robots: { index: false, follow: false } };
6
+
7
+ /** Where the payment page sends people after paying for a ticket. The confirmation is emailed once payment completes. */
8
+ export default function RegisteredPage() {
9
+ return (
10
+ <Section>
11
+ <div className="max-w-2xl">
12
+ <h1 className="mb-4 text-[clamp(32px,4vw,48px)] font-bold tracking-tight">You&apos;re registered</h1>
13
+ <p className="text-lg opacity-85">Thanks for signing up. A confirmation is on its way to your email.</p>
14
+ <p className="mt-8">
15
+ <Link href="/events" className="font-semibold underline underline-offset-4">
16
+ See all events
17
+ </Link>
18
+ </p>
19
+ </div>
20
+ </Section>
21
+ );
22
+ }
@@ -0,0 +1,10 @@
1
+ import type { MetadataRoute } from "next";
2
+ import { hearthNext } from "@/lib/hearth";
3
+ import { siteUrl } from "@/lib/site";
4
+
5
+ export default async function robots(): Promise<MetadataRoute.Robots> {
6
+ const base = siteUrl(await hearthNext.getSite());
7
+ // Until the site's domain is set in Hearth (e.g. on a preview URL), keep search engines out.
8
+ if (!base) return { rules: { userAgent: "*", disallow: "/" } };
9
+ return { rules: { userAgent: "*", allow: "/", disallow: "/api/" }, sitemap: `${base}/sitemap.xml` };
10
+ }
@@ -0,0 +1,4 @@
1
+ import { hearthNext } from "@/lib/hearth";
2
+
3
+ /** Every indexable page and event, as listed by Hearth. */
4
+ export default hearthNext.sitemap;
@@ -0,0 +1,97 @@
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-line 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-line 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-line 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
+ <Submit label={submitLabel} />
94
+ <p className="text-sm opacity-75">You&apos;ll finish on a secure payment page.</p>
95
+ </form>
96
+ );
97
+ }
@@ -0,0 +1,48 @@
1
+ import type { HearthEventDetail } from "@the-smithy/hearth";
2
+ import { JsonLd } from "@the-smithy/hearth/react";
3
+ import { Blocks } from "@/components/blocks";
4
+ import { formatEventDate } from "@/components/blocks/events";
5
+ import { Registration } from "@/components/Registration";
6
+ import { RegistrationForm } from "@/components/RegistrationForm";
7
+ import { Img } from "@/components/ui";
8
+
9
+ /** An event's page: header with date, place and registration, then its blocks. Used at /events/… and /<collection>/…. */
10
+ export function EventView({ event }: { event: HearthEventDetail }) {
11
+ const { location } = event;
12
+ return (
13
+ <>
14
+ <JsonLd data={event.meta.jsonLd} />
15
+ <section className="bg-muted">
16
+ <div className="mx-auto grid max-w-site items-center gap-10 px-4 py-16 sm:px-6 md:grid-cols-2">
17
+ <div className="flex flex-col gap-4">
18
+ {event.collection ? <p className="text-sm font-semibold uppercase tracking-[0.18em] opacity-75">{event.collection.name}</p> : null}
19
+ {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}
20
+ <h1 className="text-[clamp(32px,4.4vw,52px)] font-bold leading-tight tracking-tight">{event.title}</h1>
21
+ <p className="text-lg font-semibold text-highlight">
22
+ <time dateTime={event.startsAt}>{formatEventDate(event)}</time>
23
+ </p>
24
+ {location.name || location.address ? (
25
+ <p>
26
+ {location.name ? <span className="block font-semibold">{location.name}</span> : null}
27
+ {location.address ? <span className="block opacity-80">{location.address}</span> : null}
28
+ </p>
29
+ ) : null}
30
+ {event.summary ? <p className="text-lg opacity-85">{event.summary}</p> : null}
31
+ <Registration event={event} />
32
+ </div>
33
+ {event.image ? <Img image={event.image} className="aspect-[4/3] w-full rounded-xl object-cover" /> : null}
34
+ </div>
35
+ </section>
36
+ <Blocks blocks={event.blocks} />
37
+ {event.registration?.open ? (
38
+ <section id="register" className="mx-auto max-w-site scroll-mt-8 px-4 py-16 sm:px-6">
39
+ <div className="max-w-2xl">
40
+ <h2 className="mb-2 text-2xl font-bold">Register</h2>
41
+ {event.registration.fundraiser ? <p className="mb-6 opacity-80">Ticket sales support {event.registration.fundraiser.title}.</p> : null}
42
+ <RegistrationForm slug={event.slug} tickets={event.registration.tickets} />
43
+ </div>
44
+ </section>
45
+ ) : null}
46
+ </>
47
+ );
48
+ }
@@ -0,0 +1,42 @@
1
+ import type { HearthEvent } from "@the-smithy/hearth";
2
+ import { hearthNext } from "@/lib/hearth";
3
+
4
+ /** Upcoming until it ends (or, without an end time, until it starts). */
5
+ const isUpcoming = (e: Pick<HearthEvent, "startsAt" | "endsAt">) => new Date(e.endsAt ?? e.startsAt).getTime() > Date.now();
6
+
7
+ /**
8
+ * The event's registration: a Register button to the on-site form (sites with Tickets) or to its link (any
9
+ * platform), or, if the site turns it on in Hearth (Settings → Events), a "Registration coming soon" note while it's
10
+ * upcoming. Nothing for past or cancelled events.
11
+ */
12
+ export async function Registration({ event, size = "large" }: { event: HearthEvent; size?: "large" | "small" }) {
13
+ if (event.status === "cancelled" || !isUpcoming(event)) return null;
14
+ if (event.registration) {
15
+ if (!event.registration.open) {
16
+ const soldOut = event.registration.tickets.length > 0 && event.registration.tickets.every((t) => t.soldOut);
17
+ return <p className={`font-semibold opacity-75 ${size === "large" ? "" : "text-sm"}`}>{soldOut ? "Sold out" : "Registration closed"}</p>;
18
+ }
19
+ return (
20
+ <a
21
+ href={`${event.path}#register`}
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
25
+ </a>
26
+ );
27
+ }
28
+ if (event.registrationUrl) {
29
+ return (
30
+ <a
31
+ href={event.registrationUrl}
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
36
+ </a>
37
+ );
38
+ }
39
+ const { events } = (await hearthNext.getSite()).settings;
40
+ if (!events.registrationComingSoon) return null;
41
+ return <p className={`font-semibold opacity-75 ${size === "large" ? "" : "text-sm"}`}>{events.comingSoonText || "Registration coming soon"}</p>;
42
+ }
@@ -0,0 +1,103 @@
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-line 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
+ }
@@ -0,0 +1,61 @@
1
+ import type { Site } from "@the-smithy/hearth";
2
+ import { SmartLink } from "@/components/ui";
3
+
4
+ const platformName: Record<string, string> = {
5
+ facebook: "Facebook",
6
+ instagram: "Instagram",
7
+ linkedin: "LinkedIn",
8
+ x: "X",
9
+ youtube: "YouTube",
10
+ tiktok: "TikTok",
11
+ };
12
+
13
+ /** Contact details, footer links and social links from Hearth (Settings → Content). */
14
+ export function SiteFooter({ site }: { site: Site }) {
15
+ const { footerLinks, contact, social } = site.settings;
16
+ return (
17
+ <footer className="bg-inverse text-inverse-ink">
18
+ <div className="mx-auto grid max-w-site gap-10 px-4 py-12 sm:grid-cols-3 sm:px-6">
19
+ <div className="flex flex-col gap-2">
20
+ <p className="text-lg font-bold">{site.name}</p>
21
+ {contact.address ? <address className="whitespace-pre-line not-italic opacity-80">{contact.address}</address> : null}
22
+ {contact.phone ? (
23
+ <a href={`tel:${contact.phone.replace(/[^\d+]/g, "")}`} className="opacity-80 hover:opacity-100">
24
+ {contact.phone}
25
+ </a>
26
+ ) : null}
27
+ {contact.email ? (
28
+ <a href={`mailto:${contact.email}`} className="opacity-80 hover:opacity-100">
29
+ {contact.email}
30
+ </a>
31
+ ) : null}
32
+ </div>
33
+ {footerLinks.length ? (
34
+ <nav aria-label="Footer" className="flex flex-col gap-2">
35
+ {footerLinks.map((item) => (
36
+ <SmartLink key={item.href + item.label} href={item.href} className="opacity-80 hover:opacity-100">
37
+ {item.label}
38
+ </SmartLink>
39
+ ))}
40
+ </nav>
41
+ ) : (
42
+ <div />
43
+ )}
44
+ {social.length ? (
45
+ <ul className="flex flex-col gap-2">
46
+ {social.map((s) => (
47
+ <li key={s.url}>
48
+ <a href={s.url} rel="me noopener" className="opacity-80 hover:opacity-100">
49
+ {s.label ?? platformName[s.platform] ?? new URL(s.url).hostname.replace(/^www\./, "")}
50
+ </a>
51
+ </li>
52
+ ))}
53
+ </ul>
54
+ ) : null}
55
+ </div>
56
+ <p className="mx-auto max-w-site px-4 pb-8 text-sm opacity-60 sm:px-6">
57
+ © {new Date().getFullYear()} {site.name}
58
+ </p>
59
+ </footer>
60
+ );
61
+ }
@@ -0,0 +1,31 @@
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 and announcement banner, all edited in Hearth (Settings → General and Navigation). */
6
+ export function SiteHeader({ site }: { site: Site }) {
7
+ const { announcement, seo, tagline, navigation } = 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 ? (
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
+ </nav>
27
+ ) : null}
28
+ </div>
29
+ </header>
30
+ );
31
+ }