@the-smithy/create-hearth-site 0.24.1 → 0.26.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/README.md +2 -1
- package/bin/create-hearth-site.js +1 -1
- package/package.json +4 -3
- package/template/AGENTS.md +191 -9
- package/template/CLAUDE.md +2 -124
- package/template/GEMINI.md +3 -0
- package/template/HOSTING.md +88 -0
- package/template/README.md +30 -29
- package/template/SITE.md +1 -1
- package/template/_gitignore +2 -1
- package/template/package.json +3 -2
- package/template/scripts/hearth.mjs +82 -31
- package/template/src/app/donate/thank-you/page.tsx +1 -1
- package/template/src/app/globals.css +1 -0
- package/template/src/app/layout.tsx +1 -1
- package/template/src/app/not-found.tsx +1 -1
- package/template/src/app/registered/page.tsx +1 -1
- package/template/src/hearth/base.css +66 -0
- package/template/src/hearth/blocks/collections.tsx +6 -5
- package/template/src/hearth/blocks/content.tsx +64 -28
- package/template/src/hearth/blocks/custom.tsx +9 -1
- package/template/src/hearth/blocks/events.tsx +25 -17
- package/template/src/hearth/blocks/giving.tsx +24 -20
- package/template/src/hearth/blocks/index.tsx +29 -4
- package/template/src/hearth/blocks/info.tsx +15 -13
- package/template/src/hearth/blocks/media.tsx +10 -9
- package/template/src/hearth/components/EventView.tsx +61 -29
- package/template/src/hearth/components/FundraiserView.tsx +77 -0
- package/template/src/hearth/components/NotHere.tsx +9 -0
- package/template/src/hearth/components/PageContent.tsx +23 -0
- package/template/src/hearth/components/Registration.tsx +3 -2
- package/template/src/hearth/components/Share.tsx +11 -11
- package/template/src/hearth/format.ts +5 -3
- package/template/src/hearth/forms/ContactForm.tsx +5 -3
- package/template/src/hearth/forms/DonateForm.tsx +49 -32
- package/template/src/hearth/forms/DonationForm.tsx +13 -0
- package/template/src/hearth/forms/RegistrationForm.tsx +16 -14
- package/template/src/hearth/forms/VolunteerForm.tsx +10 -8
- package/template/src/hearth/forms/fields.tsx +17 -5
- package/template/src/hearth/hearth.css +3 -47
- package/template/src/hearth/layout/MobileNav.tsx +1 -1
- package/template/src/hearth/layout/NavLink.tsx +6 -2
- package/template/src/hearth/layout/RootLayout.tsx +8 -32
- package/template/src/hearth/layout/SiteFooter.tsx +13 -6
- package/template/src/hearth/layout/SiteFrame.tsx +48 -0
- package/template/src/hearth/layout/SiteHeader.tsx +9 -4
- package/template/src/hearth/manifest.json +3 -1
- package/template/src/hearth/overrides.ts +48 -0
- package/template/src/hearth/pages/donate.tsx +24 -15
- package/template/src/hearth/pages/event.tsx +3 -2
- package/template/src/hearth/pages/events.tsx +22 -10
- package/template/src/hearth/pages/fundraiser.tsx +10 -63
- package/template/src/hearth/pages/fundraisers.tsx +21 -11
- package/template/src/hearth/pages/not-found.tsx +28 -8
- package/template/src/hearth/pages/page.tsx +9 -15
- package/template/src/hearth/pages/registered.tsx +23 -10
- package/template/src/hearth/pages/thank-you.tsx +23 -10
- package/template/src/hearth/theme/defaults.ts +125 -2
- package/template/src/hearth/ui.tsx +20 -5
- package/template/src/theme/overrides.tsx +19 -5
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { Site } from "@the-smithy/hearth";
|
|
2
2
|
import { theme } from "@/theme";
|
|
3
|
+
import { overrides } from "../overrides";
|
|
3
4
|
import { cx } from "../theme";
|
|
4
|
-
import { ground, SmartLink, toTone } from "../ui";
|
|
5
|
+
import { fill, ground, SmartLink, toTone } from "../ui";
|
|
5
6
|
|
|
6
7
|
const platformName: Record<string, string> = {
|
|
7
8
|
facebook: "Facebook",
|
|
@@ -12,15 +13,22 @@ const platformName: Record<string, string> = {
|
|
|
12
13
|
tiktok: "TikTok",
|
|
13
14
|
};
|
|
14
15
|
|
|
15
|
-
/** Contact details, footer links
|
|
16
|
+
/** Contact details, footer links, social links and small print from Hearth (Settings → Content, and Navigation). */
|
|
16
17
|
export function SiteFooter({ site }: { site: Site }) {
|
|
17
|
-
const { footerLinks, contact, social, tagline } = site.settings;
|
|
18
|
+
const { footerLinks, contact, social, tagline, copyright, footerNote } = site.settings;
|
|
18
19
|
const t = theme.footer;
|
|
19
20
|
const tone = toTone(t.tone);
|
|
21
|
+
const Logo = overrides.Logo;
|
|
22
|
+
const year = String(new Date().getFullYear());
|
|
20
23
|
return (
|
|
21
24
|
<footer {...ground(tone)} className={cx(theme.tones[tone], t.root)}>
|
|
22
25
|
<div className={t.inner}>
|
|
23
26
|
<div className={t.about}>
|
|
27
|
+
{Logo && t.logo ? (
|
|
28
|
+
<div className={t.logo}>
|
|
29
|
+
<Logo site={site} place="footer" />
|
|
30
|
+
</div>
|
|
31
|
+
) : null}
|
|
24
32
|
<p className={t.name}>{site.name}</p>
|
|
25
33
|
{tagline ? <p className={t.tagline}>{tagline}</p> : null}
|
|
26
34
|
{contact.address ? <address className={t.address}>{contact.address}</address> : null}
|
|
@@ -58,9 +66,8 @@ export function SiteFooter({ site }: { site: Site }) {
|
|
|
58
66
|
</ul>
|
|
59
67
|
) : null}
|
|
60
68
|
</div>
|
|
61
|
-
<p className={t.copyright}>
|
|
62
|
-
|
|
63
|
-
</p>
|
|
69
|
+
<p className={t.copyright}>{copyright ? fill(copyright, { year }) : `© ${year} ${site.name}`}</p>
|
|
70
|
+
{footerNote ? <p className={t.note}>{footerNote}</p> : null}
|
|
64
71
|
</footer>
|
|
65
72
|
);
|
|
66
73
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { HearthAnalytics } from "@the-smithy/hearth/analytics";
|
|
3
|
+
import { HearthPreview } from "@the-smithy/hearth/preview";
|
|
4
|
+
import { theme } from "@/theme";
|
|
5
|
+
import { Blocks } from "../blocks";
|
|
6
|
+
import { hearthNext } from "../client";
|
|
7
|
+
import { overrides } from "../overrides";
|
|
8
|
+
import { Popup } from "./Popup";
|
|
9
|
+
import { SiteFooter } from "./SiteFooter";
|
|
10
|
+
import { SiteHeader } from "./SiteHeader";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Everything around a page's content: preview, header, footer, pop-ups and analytics. RootLayout puts it in <body>;
|
|
14
|
+
* a site that lives inside an app, with its own root layout, uses this as the layout of its public pages, and can give
|
|
15
|
+
* `headerActions` to show beside the menu.
|
|
16
|
+
*/
|
|
17
|
+
export default async function SiteFrame({ children, headerActions }: { children: ReactNode; headerActions?: ReactNode }) {
|
|
18
|
+
const [site, preview, globals] = await Promise.all([hearthNext.getSite(), hearthNext.isPreview(), hearthNext.getGlobalBlocks()]);
|
|
19
|
+
const t = theme.page;
|
|
20
|
+
const Footer = overrides.Footer ?? SiteFooter;
|
|
21
|
+
// Global blocks (Global blocks in Hearth) shown outside pages: under menu items, and as pop-ups.
|
|
22
|
+
const dropdowns = Object.fromEntries(
|
|
23
|
+
globals.filter((g) => site.settings.navigation.some((n) => n.dropdown === g.key)).map((g) => [g.key, <Blocks key={g.key} blocks={[g.block]} />]),
|
|
24
|
+
);
|
|
25
|
+
return (
|
|
26
|
+
<>
|
|
27
|
+
<a href="#main" className={t.skipLink}>
|
|
28
|
+
{theme.text.skipLink}
|
|
29
|
+
</a>
|
|
30
|
+
{/* Live draft preview from Hearth: shows while an editor previews, refreshes as they edit. */}
|
|
31
|
+
{preview ? <HearthPreview /> : null}
|
|
32
|
+
<SiteHeader site={site} dropdowns={dropdowns} actions={headerActions} />
|
|
33
|
+
<main id="main" className={t.main}>
|
|
34
|
+
{children}
|
|
35
|
+
</main>
|
|
36
|
+
<Footer site={site} />
|
|
37
|
+
{globals.map((g) =>
|
|
38
|
+
g.popup ? (
|
|
39
|
+
<Popup key={g.key} popupKey={g.key} name={g.name} title={g.popup.title} button={g.popup.button}>
|
|
40
|
+
<Blocks blocks={[g.block]} />
|
|
41
|
+
</Popup>
|
|
42
|
+
) : null,
|
|
43
|
+
)}
|
|
44
|
+
{/* Hearth's built-in visitor analytics (cookieless, no banner needed): off in draft preview and when the site turns it off. */}
|
|
45
|
+
{site.analytics && !preview ? <HearthAnalytics config={site.analytics} /> : null}
|
|
46
|
+
</>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -2,7 +2,7 @@ import Link from "next/link";
|
|
|
2
2
|
import type { ReactNode } from "react";
|
|
3
3
|
import type { Site } from "@the-smithy/hearth";
|
|
4
4
|
import { theme } from "@/theme";
|
|
5
|
-
import {
|
|
5
|
+
import { overrides } from "../overrides";
|
|
6
6
|
import { cx } from "../theme";
|
|
7
7
|
import { ground, Img, toTone } from "../ui";
|
|
8
8
|
import { MobileNav } from "./MobileNav";
|
|
@@ -11,19 +11,21 @@ import { NavLink } from "./NavLink";
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Logo, menu, menu button and announcement banner, all edited in Hearth (Settings → General, and Navigation).
|
|
14
|
-
* `dropdowns` holds the global blocks menu items open in a dropdown, drawn, by key.
|
|
14
|
+
* `dropdowns` holds the global blocks menu items open in a dropdown, drawn, by key. `actions` is what a site inside an
|
|
15
|
+
* app shows beside the menu at every width (Sign in, say), given by its layout through SiteFrame.
|
|
15
16
|
*/
|
|
16
|
-
export function SiteHeader({ site, dropdowns = {} }: { site: Site; dropdowns?: Record<string, ReactNode
|
|
17
|
+
export function SiteHeader({ site, dropdowns = {}, actions }: { site: Site; dropdowns?: Record<string, ReactNode>; actions?: ReactNode }) {
|
|
17
18
|
const { announcement, seo, tagline, navigation, navigationCta } = site.settings;
|
|
18
19
|
const t = theme.header;
|
|
19
20
|
const tone = toTone(t.tone);
|
|
21
|
+
const Logo = overrides.Logo;
|
|
20
22
|
return (
|
|
21
23
|
<header {...ground(tone)} className={cx(theme.tones[tone], t.root)}>
|
|
22
24
|
{announcement ? <p className={t.announcement}>{announcement}</p> : null}
|
|
23
25
|
<div className={t.inner}>
|
|
24
26
|
<Link href="/" className={t.brand}>
|
|
25
27
|
{Logo ? (
|
|
26
|
-
<Logo site={site} />
|
|
28
|
+
<Logo site={site} place="header" />
|
|
27
29
|
) : (
|
|
28
30
|
<>
|
|
29
31
|
{seo.logo ? <Img image={{ src: seo.logo.src, alt: "" }} className={t.logo} /> : null}
|
|
@@ -60,8 +62,11 @@ export function SiteHeader({ site, dropdowns = {} }: { site: Site; dropdowns?: R
|
|
|
60
62
|
</NavLink>
|
|
61
63
|
) : null}
|
|
62
64
|
</nav>
|
|
65
|
+
{actions ? <div className={t.actions}>{actions}</div> : null}
|
|
63
66
|
<MobileNav siteName={site.name} links={navigation} cta={navigationCta} dropdowns={dropdowns} />
|
|
64
67
|
</>
|
|
68
|
+
) : actions ? (
|
|
69
|
+
<div className={t.actions}>{actions}</div>
|
|
65
70
|
) : null}
|
|
66
71
|
</div>
|
|
67
72
|
</header>
|
|
@@ -22,13 +22,15 @@
|
|
|
22
22
|
"src/app/api/revalidate/route.ts",
|
|
23
23
|
"scripts/hearth.mjs",
|
|
24
24
|
"CLAUDE.md",
|
|
25
|
-
"AGENTS.md"
|
|
25
|
+
"AGENTS.md",
|
|
26
|
+
"HOSTING.md"
|
|
26
27
|
],
|
|
27
28
|
"seeded": [
|
|
28
29
|
"src/theme/",
|
|
29
30
|
"src/app/icon.tsx",
|
|
30
31
|
"src/app/apple-icon.tsx",
|
|
31
32
|
"SITE.md",
|
|
33
|
+
"GEMINI.md",
|
|
32
34
|
"README.md",
|
|
33
35
|
"next.config.ts",
|
|
34
36
|
"netlify.toml",
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Fundraiser, FundraiserDetail, HearthEvent, HearthEventDetail, Site } from "@the-smithy/hearth";
|
|
2
|
+
import type { BlockComponents } from "@the-smithy/hearth/react";
|
|
3
|
+
import type { ComponentType } from "react";
|
|
4
|
+
import * as site from "@/theme/overrides";
|
|
5
|
+
|
|
6
|
+
/** The level of a card's title: the next one down from what's above the cards, so h3 under a block's heading and h2 without one. */
|
|
7
|
+
export type CardHeading = "h2" | "h3";
|
|
8
|
+
|
|
9
|
+
/** What a donation form is given, Hearth's or the site's. */
|
|
10
|
+
export type DonateFormProps = {
|
|
11
|
+
presetsCents: number[];
|
|
12
|
+
minCents: number;
|
|
13
|
+
maxCents: number;
|
|
14
|
+
/** Donations go to this fundraiser; no choice is offered. */
|
|
15
|
+
fundraiser?: string;
|
|
16
|
+
/** The active fundraisers to choose between, when `fundraiser` isn't set. */
|
|
17
|
+
fundraisers?: { slug: string; title: string }[];
|
|
18
|
+
submitLabel?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Everything src/theme/overrides.tsx can export. All of it is optional, so a site's file, which updates never replace,
|
|
23
|
+
* exports only what it uses. The types are here for that file to use: `export const Footer: Overrides["Footer"] = …`.
|
|
24
|
+
*/
|
|
25
|
+
export type Overrides = {
|
|
26
|
+
/** Replaces a whole block's component. */
|
|
27
|
+
blocks?: Partial<BlockComponents>;
|
|
28
|
+
/** The logo in the header, and in the footer if the `footer.logo` slot is set. */
|
|
29
|
+
Logo?: ComponentType<{ site: Site; place: "header" | "footer" }> | null;
|
|
30
|
+
/** Replaces the footer. */
|
|
31
|
+
Footer?: ComponentType<{ site: Site }> | null;
|
|
32
|
+
/** Replaces an event's page, at /events/… and in collections. Its structured data is drawn around it. */
|
|
33
|
+
EventPage?: ComponentType<{ event: HearthEventDetail }> | null;
|
|
34
|
+
/** Replaces a fundraiser's page. Its structured data is drawn around it. */
|
|
35
|
+
FundraiserPage?: ComponentType<{ fundraiser: FundraiserDetail }> | null;
|
|
36
|
+
/** Replaces an event's card, as an <li>. May be async. */
|
|
37
|
+
EventCard?: ComponentType<{ event: HearthEvent; heading: CardHeading }> | null;
|
|
38
|
+
/** Replaces a fundraiser's card, as an <li>. May be async. */
|
|
39
|
+
FundraiserCard?: ComponentType<{ fundraiser: Fundraiser; heading: CardHeading }> | null;
|
|
40
|
+
/** Replaces the donation form. */
|
|
41
|
+
DonateForm?: ComponentType<DonateFormProps> | null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The site's overrides, whichever it has. Read them when drawing, not as a file loads: an override can import Hearth's
|
|
46
|
+
* own components, so src/theme/overrides.tsx may still be loading at that point. Server components only.
|
|
47
|
+
*/
|
|
48
|
+
export const overrides = site as unknown as Overrides;
|
|
@@ -2,35 +2,44 @@ import type { Metadata } from "next";
|
|
|
2
2
|
import { notFound } from "next/navigation";
|
|
3
3
|
import { theme } from "@/theme";
|
|
4
4
|
import { hearthNext } from "../client";
|
|
5
|
-
import {
|
|
5
|
+
import { PageContent } from "../components/PageContent";
|
|
6
|
+
import { DonationForm } from "../forms/DonationForm";
|
|
6
7
|
import { hasFeature } from "../site";
|
|
7
|
-
import { Section } from "../ui";
|
|
8
|
+
import { fill, Section } from "../ui";
|
|
8
9
|
|
|
9
10
|
export async function generateMetadata(): Promise<Metadata> {
|
|
10
|
-
const site = await hearthNext.getSite();
|
|
11
|
+
const [site, own] = await Promise.all([hearthNext.getSite(), hearthNext.generatePageMetadata("donate")]);
|
|
12
|
+
if (Object.keys(own).length) return own;
|
|
11
13
|
return {
|
|
12
|
-
title:
|
|
13
|
-
description:
|
|
14
|
+
title: theme.text.donateTitle,
|
|
15
|
+
description: fill(theme.text.donateDescription, { site: site.name }),
|
|
14
16
|
alternates: { canonical: "/donate" },
|
|
15
|
-
openGraph: { title:
|
|
17
|
+
openGraph: { title: theme.text.donateTitle, url: "/donate" },
|
|
16
18
|
};
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
|
-
* A general donation page (sites with Giving).
|
|
21
|
-
*
|
|
22
|
+
* A general donation page (sites with Giving). A page in Hearth at `donate`, with a Donation form block, replaces this
|
|
23
|
+
* one's words, so don't reserve "donate" on the site's Plan card in Hearth: that would refuse the page.
|
|
22
24
|
*/
|
|
23
25
|
export default async function DonatePage() {
|
|
24
26
|
const site = await hearthNext.getSite();
|
|
25
27
|
if (!hasFeature(site, "giving")) notFound();
|
|
28
|
+
const page = await hearthNext.getPage("donate");
|
|
26
29
|
const giving = site.settings.giving;
|
|
27
30
|
return (
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
<
|
|
31
|
-
|
|
32
|
-
<
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
<div data-page="donate">
|
|
32
|
+
{page ? (
|
|
33
|
+
<PageContent page={page} />
|
|
34
|
+
) : (
|
|
35
|
+
<Section block="donatePage">
|
|
36
|
+
<div className={theme.donate.wrap}>
|
|
37
|
+
<h1 className={theme.page.title}>{theme.text.donateTitle}</h1>
|
|
38
|
+
<p className={theme.page.lead}>{fill(theme.text.donateLead, { site: giving.legalName || site.name })}</p>
|
|
39
|
+
<DonationForm presetsCents={giving.presetsCents} minCents={giving.minCents} maxCents={giving.maxCents} />
|
|
40
|
+
</div>
|
|
41
|
+
</Section>
|
|
42
|
+
)}
|
|
43
|
+
</div>
|
|
35
44
|
);
|
|
36
45
|
}
|
|
@@ -3,6 +3,7 @@ import { permanentRedirect } from "next/navigation";
|
|
|
3
3
|
import { hearthNext } from "../client";
|
|
4
4
|
import { EventView } from "../components/EventView";
|
|
5
5
|
import { hasFeature } from "../site";
|
|
6
|
+
import { generateMetadata as notFoundMetadata } from "./not-found";
|
|
6
7
|
|
|
7
8
|
type Props = { params: Promise<{ slug: string }> };
|
|
8
9
|
|
|
@@ -13,11 +14,11 @@ async function load(slug: string) {
|
|
|
13
14
|
|
|
14
15
|
export async function generateMetadata({ params }: Props) {
|
|
15
16
|
const event = await load((await params).slug);
|
|
16
|
-
return event ? metadataFromMeta(event.meta) :
|
|
17
|
+
return event ? metadataFromMeta(event.meta) : notFoundMetadata();
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
/** Events without a type live here. Events in a collection (e.g. Talks) live at their collection's address. */
|
|
20
|
-
export default async function
|
|
21
|
+
export default async function EventRoute({ params }: Props) {
|
|
21
22
|
const slug = (await params).slug;
|
|
22
23
|
const event = await load(slug);
|
|
23
24
|
if (!event) return hearthNext.redirectOrNotFound(`/events/${slug}`);
|
|
@@ -3,28 +3,40 @@ import { notFound } from "next/navigation";
|
|
|
3
3
|
import { theme } from "@/theme";
|
|
4
4
|
import { EventList } from "../blocks/events";
|
|
5
5
|
import { hearthNext } from "../client";
|
|
6
|
+
import { PageContent } from "../components/PageContent";
|
|
6
7
|
import { hasFeature } from "../site";
|
|
7
|
-
import { Section } from "../ui";
|
|
8
|
+
import { fill, Section } from "../ui";
|
|
8
9
|
|
|
9
10
|
export async function generateMetadata(): Promise<Metadata> {
|
|
10
|
-
const site = await hearthNext.getSite();
|
|
11
|
+
const [site, own] = await Promise.all([hearthNext.getSite(), hearthNext.generatePageMetadata("events")]);
|
|
12
|
+
if (Object.keys(own).length) return own;
|
|
11
13
|
return {
|
|
12
|
-
title:
|
|
13
|
-
description:
|
|
14
|
+
title: theme.text.eventsTitle,
|
|
15
|
+
description: fill(theme.text.eventsDescription, { site: site.name }),
|
|
14
16
|
alternates: { canonical: "/events" },
|
|
15
|
-
openGraph: { title:
|
|
17
|
+
openGraph: { title: theme.text.eventsTitle, url: "/events" },
|
|
16
18
|
};
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
/** Upcoming events (only on sites with the Events feature in Hearth). */
|
|
21
|
+
/** Upcoming events (only on sites with the Events feature in Hearth), or a page in Hearth at `events` instead. */
|
|
20
22
|
export default async function EventsPage() {
|
|
21
23
|
const site = await hearthNext.getSite();
|
|
22
24
|
if (!hasFeature(site, "events")) notFound();
|
|
25
|
+
const page = await hearthNext.getPage("events");
|
|
26
|
+
if (page) {
|
|
27
|
+
return (
|
|
28
|
+
<div data-page="events">
|
|
29
|
+
<PageContent page={page} />
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
23
33
|
const events = await hearthNext.getEvents({ when: "upcoming" });
|
|
24
34
|
return (
|
|
25
|
-
<
|
|
26
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
35
|
+
<div data-page="events">
|
|
36
|
+
<Section block="events">
|
|
37
|
+
<h1 className={theme.page.title}>{theme.text.eventsTitle}</h1>
|
|
38
|
+
<EventList events={events} heading="h2" />
|
|
39
|
+
</Section>
|
|
40
|
+
</div>
|
|
29
41
|
);
|
|
30
42
|
}
|
|
@@ -1,77 +1,24 @@
|
|
|
1
1
|
import { metadataFromMeta } from "@the-smithy/hearth/next";
|
|
2
|
-
import { JsonLd } from "@the-smithy/hearth/react";
|
|
3
|
-
import { theme } from "@/theme";
|
|
4
|
-
import { Blocks } from "../blocks";
|
|
5
|
-
import { Progress } from "../blocks/giving";
|
|
6
2
|
import { hearthNext } from "../client";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import { hasFeature, siteUrl } from "../site";
|
|
11
|
-
import { fill, Img, Section } from "../ui";
|
|
3
|
+
import { FundraiserView } from "../components/FundraiserView";
|
|
4
|
+
import { hasFeature } from "../site";
|
|
5
|
+
import { generateMetadata as notFoundMetadata } from "./not-found";
|
|
12
6
|
|
|
13
7
|
type Props = { params: Promise<{ slug: string }> };
|
|
14
8
|
|
|
15
9
|
async function load(slug: string) {
|
|
16
10
|
const site = await hearthNext.getSite();
|
|
17
|
-
return hasFeature(site, "giving") ?
|
|
11
|
+
return hasFeature(site, "giving") ? hearthNext.getFundraiser(slug) : null;
|
|
18
12
|
}
|
|
19
13
|
|
|
20
14
|
export async function generateMetadata({ params }: Props) {
|
|
21
|
-
const
|
|
22
|
-
return
|
|
15
|
+
const fundraiser = await load((await params).slug);
|
|
16
|
+
return fundraiser ? metadataFromMeta(fundraiser.meta) : notFoundMetadata();
|
|
23
17
|
}
|
|
24
18
|
|
|
25
|
-
|
|
26
|
-
export default async function FundraiserPage({ params }: Props) {
|
|
19
|
+
export default async function FundraiserRoute({ params }: Props) {
|
|
27
20
|
const slug = (await params).slug;
|
|
28
|
-
const
|
|
29
|
-
if (!
|
|
30
|
-
|
|
31
|
-
const giving = site.settings.giving;
|
|
32
|
-
const t = theme.fundraiser;
|
|
33
|
-
const base = siteUrl(site);
|
|
34
|
-
const url = base ? `${base}${f.path}` : null;
|
|
35
|
-
const active = f.status === "active";
|
|
36
|
-
|
|
37
|
-
return (
|
|
38
|
-
<>
|
|
39
|
-
<JsonLd data={f.meta.jsonLd} />
|
|
40
|
-
<section className={t.header}>
|
|
41
|
-
<div className={t.headerInner}>
|
|
42
|
-
<div className={t.details}>
|
|
43
|
-
<p className={t.label}>{active ? "Fundraiser" : "Completed fundraiser"}</p>
|
|
44
|
-
<h1 className={t.title}>{f.title}</h1>
|
|
45
|
-
{f.summary ? <p className={t.summary}>{f.summary}</p> : null}
|
|
46
|
-
<Progress fundraiser={f} large />
|
|
47
|
-
{active ? (
|
|
48
|
-
<div className={t.actions}>
|
|
49
|
-
<a href="#donate" className={theme.button.primary}>
|
|
50
|
-
{theme.text.donate}
|
|
51
|
-
</a>
|
|
52
|
-
{url ? (
|
|
53
|
-
<Share
|
|
54
|
-
title={f.title}
|
|
55
|
-
url={url}
|
|
56
|
-
message={`I'm supporting ${f.title}${f.goalCents ? `: ${money(f.raisedCents)} raised of ${money(f.goalCents)} so far` : ""}. Give here: ${url}`}
|
|
57
|
-
heading="Share this fundraiser"
|
|
58
|
-
/>
|
|
59
|
-
) : null}
|
|
60
|
-
</div>
|
|
61
|
-
) : null}
|
|
62
|
-
</div>
|
|
63
|
-
{f.image ? <Img image={f.image} crop={4 / 3} className={t.image} /> : null}
|
|
64
|
-
</div>
|
|
65
|
-
</section>
|
|
66
|
-
<Blocks blocks={f.blocks} />
|
|
67
|
-
{active ? (
|
|
68
|
-
<Section block="fundraiserDonate">
|
|
69
|
-
<div className={t.give}>
|
|
70
|
-
<h2 className={t.giveTitle}>{fill(theme.text.giveTo, { fundraiser: f.title })}</h2>
|
|
71
|
-
<DonateForm presetsCents={giving.presetsCents} minCents={giving.minCents} maxCents={giving.maxCents} fundraiser={f.slug} />
|
|
72
|
-
</div>
|
|
73
|
-
</Section>
|
|
74
|
-
) : null}
|
|
75
|
-
</>
|
|
76
|
-
);
|
|
21
|
+
const fundraiser = await load(slug);
|
|
22
|
+
if (!fundraiser) return hearthNext.redirectOrNotFound(`/fundraisers/${slug}`);
|
|
23
|
+
return <FundraiserView fundraiser={fundraiser} />;
|
|
77
24
|
}
|
|
@@ -3,36 +3,46 @@ import { notFound } from "next/navigation";
|
|
|
3
3
|
import { theme } from "@/theme";
|
|
4
4
|
import { FundraiserList } from "../blocks/giving";
|
|
5
5
|
import { hearthNext } from "../client";
|
|
6
|
+
import { PageContent } from "../components/PageContent";
|
|
6
7
|
import { hasFeature } from "../site";
|
|
7
|
-
import { Section } from "../ui";
|
|
8
|
+
import { fill, Section } from "../ui";
|
|
8
9
|
|
|
9
10
|
export async function generateMetadata(): Promise<Metadata> {
|
|
10
|
-
const site = await hearthNext.getSite();
|
|
11
|
+
const [site, own] = await Promise.all([hearthNext.getSite(), hearthNext.generatePageMetadata("fundraisers")]);
|
|
12
|
+
if (Object.keys(own).length) return own;
|
|
11
13
|
return {
|
|
12
|
-
title:
|
|
13
|
-
description:
|
|
14
|
+
title: theme.text.fundraisersTitle,
|
|
15
|
+
description: fill(theme.text.fundraisersDescription, { site: site.name }),
|
|
14
16
|
alternates: { canonical: "/fundraisers" },
|
|
15
|
-
openGraph: { title:
|
|
17
|
+
openGraph: { title: theme.text.fundraisersTitle, url: "/fundraisers" },
|
|
16
18
|
};
|
|
17
19
|
}
|
|
18
20
|
|
|
19
|
-
/** Active fundraisers, then completed ones (only on sites with the Giving feature in Hearth). */
|
|
21
|
+
/** Active fundraisers, then completed ones (only on sites with the Giving feature in Hearth), or a page in Hearth at `fundraisers` instead. */
|
|
20
22
|
export default async function FundraisersPage() {
|
|
21
23
|
const site = await hearthNext.getSite();
|
|
22
24
|
if (!hasFeature(site, "giving")) notFound();
|
|
25
|
+
const page = await hearthNext.getPage("fundraisers");
|
|
26
|
+
if (page) {
|
|
27
|
+
return (
|
|
28
|
+
<div data-page="fundraisers">
|
|
29
|
+
<PageContent page={page} />
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
23
33
|
const [active, completed] = await Promise.all([hearthNext.getFundraisers({ status: "active" }), hearthNext.getFundraisers({ status: "completed" })]);
|
|
24
34
|
return (
|
|
25
|
-
|
|
35
|
+
<div data-page="fundraisers">
|
|
26
36
|
<Section block="fundraisers">
|
|
27
|
-
<h1 className={theme.page.title}>
|
|
28
|
-
<FundraiserList fundraisers={active} />
|
|
37
|
+
<h1 className={theme.page.title}>{theme.text.fundraisersTitle}</h1>
|
|
38
|
+
<FundraiserList fundraisers={active} heading="h2" />
|
|
29
39
|
</Section>
|
|
30
40
|
{completed.length ? (
|
|
31
41
|
<Section block="fundraisers" tone="muted">
|
|
32
|
-
<h2 className={theme.fundraiser.completedTitle}>
|
|
42
|
+
<h2 className={theme.fundraiser.completedTitle}>{theme.text.completedFundraisers}</h2>
|
|
33
43
|
<FundraiserList fundraisers={completed} />
|
|
34
44
|
</Section>
|
|
35
45
|
) : null}
|
|
36
|
-
|
|
46
|
+
</div>
|
|
37
47
|
);
|
|
38
48
|
}
|
|
@@ -1,15 +1,35 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
1
2
|
import Link from "next/link";
|
|
2
3
|
import { theme } from "@/theme";
|
|
4
|
+
import { hearthNext } from "../client";
|
|
5
|
+
import { PageBlocks } from "../components/PageContent";
|
|
3
6
|
import { Section } from "../ui";
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
/**
|
|
9
|
+
* With a page in Hearth at `not-found`, its title and description (Next.js already keeps a 404 out of search results).
|
|
10
|
+
* Pages use it too when they find nothing at their address: this file's own generateMetadata only runs when no route matches.
|
|
11
|
+
*/
|
|
12
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
13
|
+
const own = await hearthNext.getPage("not-found");
|
|
14
|
+
return own ? { title: { absolute: own.meta.fullTitle }, description: own.meta.description ?? undefined, robots: { index: false, follow: false } } : {};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The 404, always with that status: its words, or the blocks of a page in Hearth at `not-found`. Redirects run before it (pages/page.tsx). */
|
|
18
|
+
export default async function NotFound() {
|
|
19
|
+
const page = await hearthNext.getPage("not-found");
|
|
6
20
|
return (
|
|
7
|
-
<
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
21
|
+
<div data-page="not-found">
|
|
22
|
+
{page ? (
|
|
23
|
+
<PageBlocks page={page} />
|
|
24
|
+
) : (
|
|
25
|
+
<Section block="notFound" innerClassName={theme.page.notFound}>
|
|
26
|
+
<h1 className={theme.page.title}>{theme.text.notFoundTitle}</h1>
|
|
27
|
+
<p className={theme.page.lead}>{theme.text.notFoundLead}</p>
|
|
28
|
+
<Link href="/" className={theme.button.primary}>
|
|
29
|
+
{theme.text.notFoundHome}
|
|
30
|
+
</Link>
|
|
31
|
+
</Section>
|
|
32
|
+
)}
|
|
33
|
+
</div>
|
|
14
34
|
);
|
|
15
35
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { normalizeSlug } from "@the-smithy/hearth";
|
|
2
2
|
import { metadataFromMeta } from "@the-smithy/hearth/next";
|
|
3
|
-
import { JsonLd } from "@the-smithy/hearth/react";
|
|
4
3
|
import type { Metadata } from "next";
|
|
5
|
-
import { Blocks } from "../blocks";
|
|
6
4
|
import { hearthNext } from "../client";
|
|
7
5
|
import { EventView } from "../components/EventView";
|
|
6
|
+
import { PageContent } from "../components/PageContent";
|
|
8
7
|
import { hasFeature } from "../site";
|
|
8
|
+
import { generateMetadata as notFoundMetadata } from "./not-found";
|
|
9
9
|
|
|
10
10
|
type Props = { params: Promise<{ slug?: string[] }> };
|
|
11
11
|
|
|
@@ -24,20 +24,14 @@ async function collectionEvent(slug: string[] | undefined) {
|
|
|
24
24
|
return event?.collection?.id === collection.id ? event : null;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** The page in Hearth at this address `not-found` is what the 404 shows (pages/not-found.tsx), never a page of its own. */
|
|
28
|
+
const isServed = (slug: string[] | undefined) => normalizeSlug(slug) !== "not-found";
|
|
29
|
+
|
|
27
30
|
/** Every page in Hearth at its path ("/" is "home", "/about" is "about", "/blog/post" is a blog entry), plus events in collections. */
|
|
28
31
|
export default async function Page({ params }: Props) {
|
|
29
32
|
const slug = (await params).slug;
|
|
30
|
-
const page = await hearthNext.getPage(slug);
|
|
31
|
-
if (page) {
|
|
32
|
-
return (
|
|
33
|
-
<>
|
|
34
|
-
<JsonLd data={page.meta.jsonLd} />
|
|
35
|
-
{/* Pages usually open with a Hero (the visible title); this keeps a heading for the rest. */}
|
|
36
|
-
{page.blocks[0]?.type === "hero" ? null : <h1 className="sr-only">{page.title}</h1>}
|
|
37
|
-
<Blocks blocks={page.blocks} />
|
|
38
|
-
</>
|
|
39
|
-
);
|
|
40
|
-
}
|
|
33
|
+
const page = isServed(slug) ? await hearthNext.getPage(slug) : null;
|
|
34
|
+
if (page) return <PageContent page={page} />;
|
|
41
35
|
|
|
42
36
|
const event = await collectionEvent(slug);
|
|
43
37
|
if (event) return <EventView event={event} />;
|
|
@@ -47,8 +41,8 @@ export default async function Page({ params }: Props) {
|
|
|
47
41
|
|
|
48
42
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
49
43
|
const slug = (await params).slug;
|
|
50
|
-
const metadata = await hearthNext.generatePageMetadata(slug);
|
|
44
|
+
const metadata = isServed(slug) ? await hearthNext.generatePageMetadata(slug) : {};
|
|
51
45
|
if (Object.keys(metadata).length) return metadata;
|
|
52
46
|
const event = await collectionEvent(slug);
|
|
53
|
-
return event ? metadataFromMeta(event.meta) :
|
|
47
|
+
return event ? metadataFromMeta(event.meta) : notFoundMetadata();
|
|
54
48
|
}
|
|
@@ -1,19 +1,32 @@
|
|
|
1
1
|
import type { Metadata } from "next";
|
|
2
2
|
import Link from "next/link";
|
|
3
3
|
import { theme } from "@/theme";
|
|
4
|
+
import { hearthNext } from "../client";
|
|
5
|
+
import { PageContent } from "../components/PageContent";
|
|
4
6
|
import { Section } from "../ui";
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
/** Never in search results, whatever the page in Hearth says. */
|
|
9
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
10
|
+
const own = await hearthNext.generatePageMetadata("registered");
|
|
11
|
+
return { ...(Object.keys(own).length ? own : { title: theme.text.registeredTitle }), robots: { index: false, follow: false } };
|
|
12
|
+
}
|
|
7
13
|
|
|
8
|
-
/** Where the payment page sends people after paying for a ticket. The confirmation is emailed once payment completes. */
|
|
9
|
-
export default function
|
|
14
|
+
/** Where the payment page sends people after paying for a ticket. The confirmation is emailed once payment completes. A page in Hearth at `registered` replaces these words. */
|
|
15
|
+
export default async function Page() {
|
|
16
|
+
const page = await hearthNext.getPage("registered");
|
|
10
17
|
return (
|
|
11
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
<div data-page="registered">
|
|
19
|
+
{page ? (
|
|
20
|
+
<PageContent page={page} />
|
|
21
|
+
) : (
|
|
22
|
+
<Section block="registered">
|
|
23
|
+
<h1 className={theme.page.title}>{theme.text.registeredTitle}</h1>
|
|
24
|
+
<p className={theme.page.lead}>{theme.text.registeredLead}</p>
|
|
25
|
+
<Link href="/events" className={theme.page.back}>
|
|
26
|
+
{theme.text.registeredBack}
|
|
27
|
+
</Link>
|
|
28
|
+
</Section>
|
|
29
|
+
)}
|
|
30
|
+
</div>
|
|
18
31
|
);
|
|
19
32
|
}
|