Add email verification, personal calendar feed, and full SEO implementation

- Customer accounts now require email verification (hashed, single-use,
  time-limited tokens) before they can request/confirm bookings, with
  resend flows on login/account/booking widget and rate limiting.
- Admins get a private, rotatable iCalendar (ICS) subscription feed of
  their confirmed bookings and public events, timezone-correct for
  Europe/Berlin including DST, never exposing meeting passwords.
- Adds a full SEO layer: per-page canonical/OG/Twitter metadata with
  CMS-editable overrides and content-derived fallbacks, a dynamic
  sitemap.xml and robots.txt driven by real published content, JSON-LD
  (Organization/LocalBusiness, WebSite, WebPage, BreadcrumbList, Service,
  Event, BlogPosting) that never fabricates data, and a CMS-managed
  redirect table for changed slugs.
- Global ANOUMA-naming audit: the brand name is never used to label
  personal account/calendar areas anywhere in the app, CMS, or emails.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 22:57:31 +02:00
co-authored by Claude Sonnet 5
parent 50c39a70e0
commit 1cd15aff25
72 changed files with 2835 additions and 257 deletions
+49 -4
View File
@@ -1,26 +1,61 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound, permanentRedirect, redirect } from "next/navigation";
import { PageHeader } from "@/components/PageHeader"; import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section"; import { Section } from "@/components/Section";
import { ImagePlaceholder } from "@/components/ImagePlaceholder"; import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { RichText } from "@/components/RichText"; import { RichText } from "@/components/RichText";
import { getPostBySlug } from "@/lib/payload/content"; import { getPostBySlug } from "@/lib/payload/content";
import { mediaAlt, mediaUrl } from "@/lib/payload/media"; import { mediaAlt, mediaUrl } from "@/lib/payload/media";
import { getSEOSettingsGlobal } from "@/lib/payload/globals";
import { resolveSeo } from "@/lib/seo/resolve";
import { buildMetadata } from "@/lib/seo/metadata";
import { getSiteUrl } from "@/lib/seo/config";
import { excerptFromRichText } from "@/lib/seo/textExcerpt";
import { JsonLd, articleJsonLd } from "@/lib/seo/jsonld";
import { resolveRedirect } from "@/lib/seo/redirects";
import { siteConfig } from "@/lib/site";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
type Args = { params: Promise<{ slug: string }> }; type Args = { params: Promise<{ slug: string }> };
async function resolvePostOrRedirect(slug: string) {
const post = await getPostBySlug(slug);
if (post) return post;
const match = await resolveRedirect(`/aktuelles/${slug}`);
if (match) {
if (match.permanent) permanentRedirect(match.to);
redirect(match.to);
}
return null;
}
export async function generateMetadata({ params }: Args): Promise<Metadata> { export async function generateMetadata({ params }: Args): Promise<Metadata> {
const { slug } = await params; const { slug } = await params;
const post = await getPostBySlug(slug); const [post, seoSettings] = await Promise.all([getPostBySlug(slug), getSEOSettingsGlobal()]);
if (!post) return {}; if (!post) return {};
return { title: post.title, description: post.teaser };
const resolved = resolveSeo({
seo: post.seo,
fallbackTitle: `${post.title} ${siteConfig.name}`,
fallbackDescription: () => post.teaser,
fallbackImage: post.coverImage,
defaultOgImage: seoSettings.defaultOgImage,
});
return buildMetadata({
title: resolved.title,
description: resolved.description,
path: `/aktuelles/${post.slug}`,
ogImageUrl: resolved.ogImageUrl,
keywords: resolved.keywords,
type: "article",
});
} }
export default async function PostDetailPage({ params }: Args) { export default async function PostDetailPage({ params }: Args) {
const { slug } = await params; const { slug } = await params;
const post = await getPostBySlug(slug); const post = await resolvePostOrRedirect(slug);
if (!post) notFound(); if (!post) notFound();
const publishDate = new Date(post.publishDate ?? post.createdAt).toLocaleDateString("de-DE", { const publishDate = new Date(post.publishDate ?? post.createdAt).toLocaleDateString("de-DE", {
@@ -28,9 +63,19 @@ export default async function PostDetailPage({ params }: Args) {
month: "long", month: "long",
year: "numeric", year: "numeric",
}); });
const siteUrl = await getSiteUrl();
return ( return (
<> <>
<JsonLd
data={articleJsonLd({
post,
description: post.seo?.description || post.teaser || excerptFromRichText(post.content),
imageUrl: mediaUrl(post.coverImage, "hero"),
siteUrl,
providerName: siteConfig.name,
})}
/>
<PageHeader <PageHeader
eyebrow={publishDate} eyebrow={publishDate}
title={post.title} title={post.title}
+29 -6
View File
@@ -5,24 +5,47 @@ import { Section } from "@/components/Section";
import { ImagePlaceholder } from "@/components/ImagePlaceholder"; import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { Reveal } from "@/components/Reveal"; import { Reveal } from "@/components/Reveal";
import { getPosts } from "@/lib/payload/content"; import { getPosts } from "@/lib/payload/content";
import { getAktuellesIntroGlobal } from "@/lib/payload/globals"; import { getAktuellesIntroGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals";
import { mediaAlt, mediaUrl } from "@/lib/payload/media"; import { mediaAlt, mediaUrl } from "@/lib/payload/media";
import { resolveSeo } from "@/lib/seo/resolve";
import { buildMetadata } from "@/lib/seo/metadata";
import { getSiteUrl } from "@/lib/seo/config";
import { JsonLd, webPageJsonLd } from "@/lib/seo/jsonld";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const FALLBACK_DESCRIPTION = "Neuigkeiten, Termine und Inspiration von Anouma.";
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const intro = await getAktuellesIntroGlobal(); const [intro, seoSettings] = await Promise.all([getAktuellesIntroGlobal(), getSEOSettingsGlobal()]);
return { const resolved = resolveSeo({
title: intro.title || "Aktuelles", seo: intro.seo,
description: intro.lead || "Neuigkeiten, Termine und Inspiration von Anouma.", fallbackTitle: "Aktuelles",
}; fallbackDescription: () => intro.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION,
defaultOgImage: seoSettings.defaultOgImage,
});
return buildMetadata({
title: resolved.title,
description: resolved.description,
path: "/aktuelles",
ogImageUrl: resolved.ogImageUrl,
keywords: resolved.keywords,
});
} }
export default async function AktuellesPage() { export default async function AktuellesPage() {
const [intro, posts] = await Promise.all([getAktuellesIntroGlobal(), getPosts()]); const [intro, posts] = await Promise.all([getAktuellesIntroGlobal(), getPosts()]);
const siteUrl = await getSiteUrl();
return ( return (
<> <>
<JsonLd
data={webPageJsonLd({
name: intro.title || "Aktuelles",
description: intro.lead || FALLBACK_DESCRIPTION,
url: `${siteUrl}/aktuelles`,
})}
/>
<PageHeader <PageHeader
eyebrow={intro.eyebrow || "Aktuelles"} eyebrow={intro.eyebrow || "Aktuelles"}
title={intro.title || "Neuigkeiten, Termine und Inspiration"} title={intro.title || "Neuigkeiten, Termine und Inspiration"}
+54 -8
View File
@@ -1,5 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound, permanentRedirect, redirect } from "next/navigation";
import { PageHeader } from "@/components/PageHeader"; import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section"; import { Section } from "@/components/Section";
import { ImagePlaceholder } from "@/components/ImagePlaceholder"; import { ImagePlaceholder } from "@/components/ImagePlaceholder";
@@ -11,31 +11,77 @@ import { moodForOffer } from "@/lib/angebote";
import { mediaAlt, mediaUrl } from "@/lib/payload/media"; import { mediaAlt, mediaUrl } from "@/lib/payload/media";
import { OFFER_CATEGORIES } from "@/collections/Offers"; import { OFFER_CATEGORIES } from "@/collections/Offers";
import { getCurrentCustomer } from "@/lib/auth/customer"; import { getCurrentCustomer } from "@/lib/auth/customer";
import { getSEOSettingsGlobal } from "@/lib/payload/globals";
import { resolveSeo } from "@/lib/seo/resolve";
import { buildMetadata } from "@/lib/seo/metadata";
import { getSiteUrl } from "@/lib/seo/config";
import { excerptFromRichText } from "@/lib/seo/textExcerpt";
import { JsonLd, serviceJsonLd } from "@/lib/seo/jsonld";
import { resolveRedirect } from "@/lib/seo/redirects";
import { siteConfig } from "@/lib/site";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
type Args = { params: Promise<{ slug: string }> }; type Args = { params: Promise<{ slug: string }> };
async function resolveOfferOrRedirect(slug: string) {
const offer = await getOfferBySlug(slug);
if (offer) return offer;
const match = await resolveRedirect(`/angebote/${slug}`);
if (match) {
if (match.permanent) permanentRedirect(match.to);
redirect(match.to);
}
return null;
}
export async function generateMetadata({ params }: Args): Promise<Metadata> { export async function generateMetadata({ params }: Args): Promise<Metadata> {
const { slug } = await params; const { slug } = await params;
const offer = await getOfferBySlug(slug); const [offer, seoSettings] = await Promise.all([getOfferBySlug(slug), getSEOSettingsGlobal()]);
if (!offer) return {}; if (!offer) return {};
return {
title: offer.title, const resolved = resolveSeo({
description: offer.shortDescription, seo: offer.seo,
}; fallbackTitle: `${offer.title} ${siteConfig.name}`,
fallbackDescription: () => offer.shortDescription,
fallbackImage: offer.image,
defaultOgImage: seoSettings.defaultOgImage,
});
return buildMetadata({
title: resolved.title,
description: resolved.description,
path: `/angebote/${offer.slug}`,
ogImageUrl: resolved.ogImageUrl,
keywords: resolved.keywords,
// Private single-session offers (e.g. "Einzelbegleitung") stay reachable
// via their direct link but must never be indexed or listed.
noindex: offer.visibility === "private",
});
} }
export default async function OfferDetailPage({ params }: Args) { export default async function OfferDetailPage({ params }: Args) {
const { slug } = await params; const { slug } = await params;
const offer = await getOfferBySlug(slug); const offer = await resolveOfferOrRedirect(slug);
if (!offer) notFound(); if (!offer) notFound();
const categoryLabel = OFFER_CATEGORIES.find((c) => c.value === offer.category)?.label; const categoryLabel = OFFER_CATEGORIES.find((c) => c.value === offer.category)?.label;
const customer = offer.bookable ? await getCurrentCustomer() : null; const customer = offer.bookable ? await getCurrentCustomer() : null;
const siteUrl = await getSiteUrl();
return ( return (
<> <>
{offer.visibility !== "private" && (
<JsonLd
data={serviceJsonLd({
offer,
description: offer.seo?.description || excerptFromRichText(offer.description) || offer.shortDescription,
imageUrl: mediaUrl(offer.image, "hero"),
siteUrl,
providerName: siteConfig.name,
})}
/>
)}
<PageHeader <PageHeader
eyebrow={categoryLabel} eyebrow={categoryLabel}
title={offer.title} title={offer.title}
@@ -81,7 +127,7 @@ export default async function OfferDetailPage({ params }: Args) {
<Section tone="cream"> <Section tone="cream">
<div className="mx-auto max-w-3xl"> <div className="mx-auto max-w-3xl">
<h2 className="mb-6 text-center font-serif text-3xl font-medium text-anouma-plum">Termin auswählen</h2> <h2 className="mb-6 text-center font-serif text-3xl font-medium text-anouma-plum">Termin auswählen</h2>
<BookingWidget offerSlug={offer.slug!} isLoggedIn={Boolean(customer)} /> <BookingWidget offerSlug={offer.slug!} isLoggedIn={Boolean(customer)} isVerified={Boolean(customer?.emailVerified)} />
</div> </div>
</Section> </Section>
) : ( ) : (
+31 -8
View File
@@ -7,25 +7,41 @@ import { Button } from "@/components/Button";
import { CTA } from "@/components/CTA"; import { CTA } from "@/components/CTA";
import { Reveal } from "@/components/Reveal"; import { Reveal } from "@/components/Reveal";
import { getOffers } from "@/lib/payload/content"; import { getOffers } from "@/lib/payload/content";
import { getAngeboteIntroGlobal } from "@/lib/payload/globals"; import { getAngeboteIntroGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals";
import { groupOffersByCategory, moodForOffer } from "@/lib/angebote"; import { groupOffersByCategory, moodForOffer } from "@/lib/angebote";
import { mediaAlt, mediaUrl } from "@/lib/payload/media"; import { mediaAlt, mediaUrl } from "@/lib/payload/media";
import { resolveSeo } from "@/lib/seo/resolve";
import { buildMetadata } from "@/lib/seo/metadata";
import { getSiteUrl } from "@/lib/seo/config";
import { JsonLd, webPageJsonLd } from "@/lib/seo/jsonld";
import { siteConfig } from "@/lib/site";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const FALLBACK_DESCRIPTION =
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise — die Angebote von Anouma im Überblick.";
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const intro = await getAngeboteIntroGlobal(); const [intro, seoSettings] = await Promise.all([getAngeboteIntroGlobal(), getSEOSettingsGlobal()]);
return { const resolved = resolveSeo({
title: intro.title || "Angebote", seo: intro.seo,
description: fallbackTitle: `Begleitung für dich und deine Familie ${siteConfig.name}`,
intro.lead || fallbackDescription: () => intro.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION,
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise — die Angebote von Anouma im Überblick.", defaultOgImage: seoSettings.defaultOgImage,
}; });
return buildMetadata({
title: resolved.title,
description: resolved.description,
path: "/angebote",
ogImageUrl: resolved.ogImageUrl,
keywords: resolved.keywords,
});
} }
export default async function AngebotePage() { export default async function AngebotePage() {
const [intro, offers] = await Promise.all([getAngeboteIntroGlobal(), getOffers()]); const [intro, offers] = await Promise.all([getAngeboteIntroGlobal(), getOffers()]);
const groups = groupOffersByCategory(offers); const groups = groupOffersByCategory(offers);
const siteUrl = await getSiteUrl();
const prozessbegleitung = groups.find((g) => g.category === "prozessbegleitung")?.offers[0]; const prozessbegleitung = groups.find((g) => g.category === "prozessbegleitung")?.offers[0];
const doula = groups.find((g) => g.category === "doula-begleitung")?.offers[0]; const doula = groups.find((g) => g.category === "doula-begleitung")?.offers[0];
@@ -34,6 +50,13 @@ export default async function AngebotePage() {
return ( return (
<> <>
<JsonLd
data={webPageJsonLd({
name: intro.title || "Angebote",
description: intro.lead || FALLBACK_DESCRIPTION,
url: `${siteUrl}/angebote`,
})}
/>
<PageHeader <PageHeader
title={intro.title || "Angebote"} title={intro.title || "Angebote"}
lead={intro.lead} lead={intro.lead}
+9 -4
View File
@@ -7,13 +7,18 @@ import { Reveal } from "@/components/Reveal";
import { getOffers } from "@/lib/payload/content"; import { getOffers } from "@/lib/payload/content";
import { moodForOffer } from "@/lib/angebote"; import { moodForOffer } from "@/lib/angebote";
import { mediaUrl } from "@/lib/payload/media"; import { mediaUrl } from "@/lib/payload/media";
import { buildMetadata } from "@/lib/seo/metadata";
import { siteConfig } from "@/lib/site";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const metadata: Metadata = { export async function generateMetadata(): Promise<Metadata> {
title: "Singkreise", return buildMetadata({
description: "Gemeinsam singen. Verbinden. Heilen. Für Herz und Seele.", title: `Singkreise ${siteConfig.name}`,
}; description: "Gemeinsam singen. Verbinden. Heilen. Für Herz und Seele.",
path: "/angebote/singkreise",
});
}
export default async function SingkreisePage() { export default async function SingkreisePage() {
const offers = await getOffers(); const offers = await getOffers();
@@ -0,0 +1,54 @@
import type { Metadata } from "next";
import Link from "next/link";
import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { ResendVerificationButton } from "@/components/auth/ResendVerificationButton";
import { verifyEmailToken } from "@/lib/auth/verifyEmailToken";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "E-Mail bestätigen", robots: { index: false, follow: false } };
type Args = { params: Promise<{ token: string }> };
export default async function VerifyEmailPage({ params }: Args) {
const { token } = await params;
const verified = await verifyEmailToken(token);
return (
<>
<PageHeader
eyebrow="Konto"
title={verified ? "E-Mail-Adresse bestätigt" : "Bestätigung fehlgeschlagen"}
crumbs={[{ title: "E-Mail bestätigen" }]}
/>
<Section tone="plain">
<div className="mx-auto max-w-md text-center">
{verified ? (
<>
<p className="text-base leading-relaxed text-anouma-plum">Dein Konto ist jetzt aktiviert.</p>
<Link
href="/konto"
className="mt-6 inline-flex rounded-full bg-anouma-mauve-dark px-7 py-3.5 text-sm font-medium text-white transition-colors hover:bg-anouma-plum"
>
Zum Konto
</Link>
</>
) : (
<>
<p className="text-base leading-relaxed text-anouma-plum">
Dieser Verifizierungslink ist ungültig oder abgelaufen.
</p>
<div className="mt-6 flex flex-col items-center gap-4">
<ResendVerificationButton variant="email-prompt" triggerLabel="Neuen Link anfordern" />
<Link href="/login" className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4">
Zur Anmeldung
</Link>
</div>
</>
)}
</div>
</Section>
</>
);
}
@@ -0,0 +1,59 @@
import { NextResponse } from "next/server";
import { getCMS } from "@/lib/payload/getPayload";
import { hashCalendarFeedToken } from "@/lib/calendar/feedToken";
import { buildICSCalendar } from "@/lib/calendar/ics";
import { getAdminFeedICSEvents } from "@/lib/calendar/adminFeed";
export const dynamic = "force-dynamic";
const TOKEN_SHAPE = /^[0-9a-f]{64}$/i;
type Args = { params: Promise<{ tokenFile: string }> };
/**
* GET /calendar/<token>.ics — a private iCalendar feed, subscribable from
* Apple Calendar, Google Calendar, Outlook, Thunderbird etc. The token
* itself is the only credential (there's no login here), so it's treated
* like a secret: looked up by hash only, never logged, and the response is
* marked non-cacheable-by-proxies and non-indexable.
*/
export async function GET(_request: Request, { params }: Args) {
const { tokenFile } = await params;
if (!tokenFile.endsWith(".ics")) {
return new NextResponse("Not found", { status: 404 });
}
const token = tokenFile.slice(0, -".ics".length);
if (!TOKEN_SHAPE.test(token)) {
return new NextResponse("Not found", { status: 404 });
}
const payload = await getCMS();
const hash = hashCalendarFeedToken(token);
const { docs } = await payload.find({
collection: "users",
where: { calendarFeedTokenHash: { equals: hash } },
limit: 1,
});
// Section 15 / later extension: once normal user accounts also get a
// personal feed, a matching lookup against "customers" would go here,
// returning that customer's own bookings instead of the admin feed below.
const owner = docs[0];
if (!owner) {
return new NextResponse("Not found", { status: 404, headers: { "X-Robots-Tag": "noindex, nofollow" } });
}
const events = await getAdminFeedICSEvents(payload);
const ics = buildICSCalendar({ calendarName: "Persönlicher Kalender", events });
return new NextResponse(ics, {
status: 200,
headers: {
"Content-Type": "text/calendar; charset=utf-8",
"Content-Disposition": 'inline; filename="kalender.ics"',
"X-Robots-Tag": "noindex, nofollow",
"Cache-Control": "private, no-store",
},
});
}
+10 -5
View File
@@ -3,14 +3,19 @@ import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section"; import { Section } from "@/components/Section";
import { PlaceholderNote } from "@/components/PlaceholderNote"; import { PlaceholderNote } from "@/components/PlaceholderNote";
import { getContactGlobal } from "@/lib/payload/globals"; import { getContactGlobal } from "@/lib/payload/globals";
import { getSiteUrl } from "@/lib/seo/config";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const metadata: Metadata = { export async function generateMetadata(): Promise<Metadata> {
title: "Datenschutz", const siteUrl = await getSiteUrl();
description: "Datenschutzerklärung von Anouma.", return {
robots: { index: false, follow: true }, title: "Datenschutz",
}; description: "Datenschutzerklärung von Anouma.",
robots: { index: false, follow: true },
alternates: { canonical: `${siteUrl}/datenschutz` },
};
}
export default async function DatenschutzPage() { export default async function DatenschutzPage() {
const contact = await getContactGlobal(); const contact = await getContactGlobal();
+14 -5
View File
@@ -4,11 +4,17 @@ import { Section } from "@/components/Section";
import { ImagePlaceholder } from "@/components/ImagePlaceholder"; import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { Reveal } from "@/components/Reveal"; import { Reveal } from "@/components/Reveal";
import type { ImageMood } from "@/lib/angebote"; import type { ImageMood } from "@/lib/angebote";
import { buildMetadata } from "@/lib/seo/metadata";
import { getSiteUrl } from "@/lib/seo/config";
import { JsonLd, webPageJsonLd } from "@/lib/seo/jsonld";
export const metadata: Metadata = { export const dynamic = "force-dynamic";
title: "Impressionen",
description: "Bildeindrücke aus der Arbeit von Anouma — Natur, Gemeinschaft und gemeinsame Räume.", const DESCRIPTION = "Bildeindrücke aus der Arbeit von Anouma — Natur, Gemeinschaft und gemeinsame Räume.";
};
export async function generateMetadata(): Promise<Metadata> {
return buildMetadata({ title: "Impressionen", description: DESCRIPTION, path: "/impressionen" });
}
const gallery: { mood: ImageMood; label: string; span?: string }[] = [ const gallery: { mood: ImageMood; label: string; span?: string }[] = [
{ mood: "moss", label: "Wald und Naturverbundenheit", span: "sm:row-span-2" }, { mood: "moss", label: "Wald und Naturverbundenheit", span: "sm:row-span-2" },
@@ -21,9 +27,12 @@ const gallery: { mood: ImageMood; label: string; span?: string }[] = [
{ mood: "mauve", label: "Räume der Verbindung" }, { mood: "mauve", label: "Räume der Verbindung" },
]; ];
export default function ImpressionenPage() { export default async function ImpressionenPage() {
const siteUrl = await getSiteUrl();
return ( return (
<> <>
<JsonLd data={webPageJsonLd({ name: "Impressionen", description: DESCRIPTION, url: `${siteUrl}/impressionen` })} />
<PageHeader <PageHeader
eyebrow="Impressionen" eyebrow="Impressionen"
title="Einblicke in gemeinsame Räume" title="Einblicke in gemeinsame Räume"
+12 -5
View File
@@ -3,14 +3,21 @@ import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section"; import { Section } from "@/components/Section";
import { PlaceholderNote } from "@/components/PlaceholderNote"; import { PlaceholderNote } from "@/components/PlaceholderNote";
import { getContactGlobal } from "@/lib/payload/globals"; import { getContactGlobal } from "@/lib/payload/globals";
import { getSiteUrl } from "@/lib/seo/config";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const metadata: Metadata = { export async function generateMetadata(): Promise<Metadata> {
title: "Impressum", const siteUrl = await getSiteUrl();
description: "Impressum von Anouma gemäß § 5 TMG.", return {
robots: { index: false, follow: true }, title: "Impressum",
}; description: "Impressum von Anouma gemäß § 5 TMG.",
// noindex, but still crawlable — legal boilerplate deliberately kept
// out of search results without cutting off the internal links on it.
robots: { index: false, follow: true },
alternates: { canonical: `${siteUrl}/impressum` },
};
}
export default async function ImpressumPage() { export default async function ImpressumPage() {
const contact = await getContactGlobal(); const contact = await getContactGlobal();
+30 -6
View File
@@ -4,23 +4,47 @@ import { Section } from "@/components/Section";
import { ContactForm } from "@/components/ContactForm"; import { ContactForm } from "@/components/ContactForm";
import { ImagePlaceholder } from "@/components/ImagePlaceholder"; import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { Reveal } from "@/components/Reveal"; import { Reveal } from "@/components/Reveal";
import { getContactGlobal } from "@/lib/payload/globals"; import { getContactGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals";
import { resolveSeo } from "@/lib/seo/resolve";
import { buildMetadata } from "@/lib/seo/metadata";
import { getSiteUrl } from "@/lib/seo/config";
import { JsonLd, webPageJsonLd } from "@/lib/seo/jsonld";
import { siteConfig } from "@/lib/site";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const FALLBACK_DESCRIPTION = "Ich freue mich, von dir zu hören.";
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const contact = await getContactGlobal(); const [contact, seoSettings] = await Promise.all([getContactGlobal(), getSEOSettingsGlobal()]);
return { const resolved = resolveSeo({
title: contact.title || "Kontakt", seo: contact.seo,
description: contact.lead || "Ich freue mich, von dir zu hören.", fallbackTitle: `Kontakt ${siteConfig.name}`,
}; fallbackDescription: () => contact.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION,
defaultOgImage: seoSettings.defaultOgImage,
});
return buildMetadata({
title: resolved.title,
description: resolved.description,
path: "/kontakt",
ogImageUrl: resolved.ogImageUrl,
keywords: resolved.keywords,
});
} }
export default async function KontaktPage() { export default async function KontaktPage() {
const contact = await getContactGlobal(); const contact = await getContactGlobal();
const siteUrl = await getSiteUrl();
return ( return (
<> <>
<JsonLd
data={webPageJsonLd({
name: contact.title || "Kontakt",
description: contact.lead || FALLBACK_DESCRIPTION,
url: `${siteUrl}/kontakt`,
})}
/>
<PageHeader <PageHeader
eyebrow={contact.eyebrow || "Kontakt"} eyebrow={contact.eyebrow || "Kontakt"}
title={contact.title || "Ich freue mich, von dir zu hören"} title={contact.title || "Ich freue mich, von dir zu hören"}
+13
View File
@@ -1,11 +1,15 @@
import type { Metadata } from "next";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { Container } from "@/components/Section"; import { Container } from "@/components/Section";
import { LogoutButton } from "@/components/auth/LogoutButton"; import { LogoutButton } from "@/components/auth/LogoutButton";
import { ResendVerificationButton } from "@/components/auth/ResendVerificationButton";
import { getCurrentCustomer } from "@/lib/auth/customer"; import { getCurrentCustomer } from "@/lib/auth/customer";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
// Applies to every /konto/* page at once — private account area, never indexable.
export const metadata: Metadata = { robots: { index: false, follow: false } };
const navItems = [ const navItems = [
{ title: "Übersicht", href: "/konto" }, { title: "Übersicht", href: "/konto" },
@@ -25,6 +29,15 @@ export default async function KontoLayout({ children }: { children: ReactNode })
<p className="text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum/70">Mein Konto</p> <p className="text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum/70">Mein Konto</p>
<h1 className="mt-2 font-serif text-3xl font-medium text-anouma-plum">Hallo, {customer.name}</h1> <h1 className="mt-2 font-serif text-3xl font-medium text-anouma-plum">Hallo, {customer.name}</h1>
</div> </div>
{!customer.emailVerified && (
<div className="mb-8 rounded-2xl bg-anouma-cream-beige p-5">
<p className="text-sm font-medium text-anouma-plum">Bitte bestätige zuerst deine E-Mail-Adresse.</p>
<p className="mt-1 text-sm text-anouma-plum/80">
Erst danach kannst du Termine anfragen. Prüfe dein Postfach oder fordere unten eine neue E-Mail an.
</p>
<ResendVerificationButton variant="session" className="mt-3" />
</div>
)}
<div className="grid gap-10 lg:grid-cols-[220px_1fr]"> <div className="grid gap-10 lg:grid-cols-[220px_1fr]">
<nav aria-label="Konto-Navigation" className="flex gap-2 overflow-x-auto lg:flex-col lg:gap-1"> <nav aria-label="Konto-Navigation" className="flex gap-2 overflow-x-auto lg:flex-col lg:gap-1">
{navItems.map((item) => ( {navItems.map((item) => (
+19 -1
View File
@@ -5,6 +5,9 @@ import { Footer } from "@/components/Footer";
import { siteConfig } from "@/lib/site"; import { siteConfig } from "@/lib/site";
import { getOffers } from "@/lib/payload/content"; import { getOffers } from "@/lib/payload/content";
import { getCurrentCustomer } from "@/lib/auth/customer"; import { getCurrentCustomer } from "@/lib/auth/customer";
import { getBookingSettingsGlobal, getContactGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals";
import { getSiteUrl } from "@/lib/seo/config";
import { JsonLd, organizationOrLocalBusinessJsonLd, websiteJsonLd } from "@/lib/seo/jsonld";
import "./globals.css"; import "./globals.css";
// Every page under this layout can read live content from Payload, so the // Every page under this layout can read live content from Payload, so the
@@ -43,10 +46,19 @@ export const metadata: Metadata = {
title: siteConfig.title, title: siteConfig.title,
description: siteConfig.description, description: siteConfig.description,
}, },
// Configurable via env only — never a hardcoded verification ID (see .env.example).
...(process.env.GOOGLE_SITE_VERIFICATION ? { verification: { google: process.env.GOOGLE_SITE_VERIFICATION } } : {}),
}; };
export default async function RootLayout({ children }: LayoutProps<"/">) { export default async function RootLayout({ children }: LayoutProps<"/">) {
const [offers, customer] = await Promise.all([getOffers(), getCurrentCustomer()]); const [offers, customer, seoSettings, bookingSettings, contact, siteUrl] = await Promise.all([
getOffers(),
getCurrentCustomer(),
getSEOSettingsGlobal(),
getBookingSettingsGlobal(),
getContactGlobal(),
getSiteUrl(),
]);
return ( return (
<html <html
@@ -55,6 +67,12 @@ export default async function RootLayout({ children }: LayoutProps<"/">) {
className={`${cormorant.variable} ${inter.variable} h-full antialiased`} className={`${cormorant.variable} ${inter.variable} h-full antialiased`}
> >
<body className="flex min-h-full flex-col bg-background text-foreground"> <body className="flex min-h-full flex-col bg-background text-foreground">
<JsonLd
data={[
websiteJsonLd(siteUrl),
organizationOrLocalBusinessJsonLd({ settings: seoSettings, booking: bookingSettings, contact, siteUrl }),
]}
/>
<a <a
href="#main-content" href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[100] focus:rounded-full focus:bg-anouma-mauve-dark focus:px-5 focus:py-3 focus:text-anouma-cream-light" className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[100] focus:rounded-full focus:bg-anouma-mauve-dark focus:px-5 focus:py-3 focus:text-anouma-cream-light"
+1
View File
@@ -11,6 +11,7 @@ export const dynamic = "force-dynamic";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Anmelden", title: "Anmelden",
robots: { index: false, follow: false },
}; };
export default async function LoginPage() { export default async function LoginPage() {
+33 -6
View File
@@ -10,20 +10,39 @@ import { CTA } from "@/components/CTA";
import { Reveal } from "@/components/Reveal"; import { Reveal } from "@/components/Reveal";
import { EventTeaserCard } from "@/components/EventTeaserCard"; import { EventTeaserCard } from "@/components/EventTeaserCard";
import { getOffers, getPosts, getUpcomingEvents } from "@/lib/payload/content"; import { getOffers, getPosts, getUpcomingEvents } from "@/lib/payload/content";
import { getHomeGlobal } from "@/lib/payload/globals"; import { getHomeGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals";
import { groupOffersByCategory, moodForOffer } from "@/lib/angebote"; import { groupOffersByCategory, moodForOffer } from "@/lib/angebote";
import { mediaAlt, mediaUrl } from "@/lib/payload/media"; import { mediaAlt, mediaUrl } from "@/lib/payload/media";
import { siteConfig } from "@/lib/site";
import { resolveSeo } from "@/lib/seo/resolve";
import { buildMetadata } from "@/lib/seo/metadata";
import { getSiteUrl } from "@/lib/seo/config";
import { JsonLd, webPageJsonLd } from "@/lib/seo/jsonld";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const home = await getHomeGlobal(); const [home, seoSettings] = await Promise.all([getHomeGlobal(), getSEOSettingsGlobal()]);
return { const resolved = resolveSeo({
title: { absolute: home.heroTitle || "Willkommen bei Anouma" }, seo: home.seo,
description: fallbackTitle: `${siteConfig.name} Begleitung für dich und deine Familie`,
fallbackDescription: () =>
home.heroSupporting || home.heroSupporting ||
seoSettings.defaultDescription ||
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise — Räume für Verbindung mit dir selbst, miteinander und mit der Natur.", "Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise — Räume für Verbindung mit dir selbst, miteinander und mit der Natur.",
}; fallbackImage: home.heroImage,
defaultOgImage: seoSettings.defaultOgImage,
});
const meta = await buildMetadata({
title: resolved.title,
description: resolved.description,
path: "",
ogImageUrl: resolved.ogImageUrl,
keywords: resolved.keywords,
});
// Homepage bypasses the "%s — Anouma" title template — the brand is
// already part of the fallback/CMS title itself, avoiding "Anouma — Anouma".
return { ...meta, title: { absolute: resolved.title } };
} }
export default async function Home() { export default async function Home() {
@@ -34,9 +53,17 @@ export default async function Home() {
getUpcomingEvents(3), getUpcomingEvents(3),
]); ]);
const groups = groupOffersByCategory(offers); const groups = groupOffersByCategory(offers);
const siteUrl = await getSiteUrl();
return ( return (
<> <>
<JsonLd
data={webPageJsonLd({
name: home.heroTitle || "Willkommen bei Anouma",
description: home.heroSupporting || siteConfig.description,
url: siteUrl,
})}
/>
<Hero <Hero
eyebrow={home.heroEyebrow ?? undefined} eyebrow={home.heroEyebrow ?? undefined}
title={home.heroTitle || "Willkommen bei Anouma"} title={home.heroTitle || "Willkommen bei Anouma"}
+1
View File
@@ -10,6 +10,7 @@ export const dynamic = "force-dynamic";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Konto erstellen", title: "Konto erstellen",
robots: { index: false, follow: false },
}; };
export default async function RegisterPage() { export default async function RegisterPage() {
+30 -6
View File
@@ -4,27 +4,51 @@ import { Section, SectionHeading } from "@/components/Section";
import { ContactForm } from "@/components/ContactForm"; import { ContactForm } from "@/components/ContactForm";
import { AngebotCard } from "@/components/AngebotCard"; import { AngebotCard } from "@/components/AngebotCard";
import { Reveal } from "@/components/Reveal"; import { Reveal } from "@/components/Reveal";
import { getBookingGlobal, getContactGlobal } from "@/lib/payload/globals"; import { getBookingGlobal, getContactGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals";
import { getOffers } from "@/lib/payload/content"; import { getOffers } from "@/lib/payload/content";
import { moodForOffer } from "@/lib/angebote"; import { moodForOffer } from "@/lib/angebote";
import { mediaUrl } from "@/lib/payload/media"; import { mediaUrl } from "@/lib/payload/media";
import { resolveSeo } from "@/lib/seo/resolve";
import { buildMetadata } from "@/lib/seo/metadata";
import { getSiteUrl } from "@/lib/seo/config";
import { JsonLd, webPageJsonLd } from "@/lib/seo/jsonld";
import { siteConfig } from "@/lib/site";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const FALLBACK_DESCRIPTION = "Kennenlernen & Termine vereinbaren.";
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const booking = await getBookingGlobal(); const [booking, seoSettings] = await Promise.all([getBookingGlobal(), getSEOSettingsGlobal()]);
return { const resolved = resolveSeo({
title: booking.title || "Termin buchen", seo: booking.seo,
description: booking.lead || "Kennenlernen & Termine vereinbaren.", fallbackTitle: `Termin buchen ${siteConfig.name}`,
}; fallbackDescription: () => booking.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION,
defaultOgImage: seoSettings.defaultOgImage,
});
return buildMetadata({
title: resolved.title,
description: resolved.description,
path: "/termin-buchen",
ogImageUrl: resolved.ogImageUrl,
keywords: resolved.keywords,
});
} }
export default async function TerminBuchenPage() { export default async function TerminBuchenPage() {
const [booking, contact, offers] = await Promise.all([getBookingGlobal(), getContactGlobal(), getOffers()]); const [booking, contact, offers] = await Promise.all([getBookingGlobal(), getContactGlobal(), getOffers()]);
const bookableOffers = offers.filter((offer) => offer.bookable); const bookableOffers = offers.filter((offer) => offer.bookable);
const siteUrl = await getSiteUrl();
return ( return (
<> <>
<JsonLd
data={webPageJsonLd({
name: booking.title || "Termin buchen",
description: booking.lead || FALLBACK_DESCRIPTION,
url: `${siteUrl}/termin-buchen`,
})}
/>
<PageHeader <PageHeader
eyebrow={booking.eyebrow || "Termin buchen"} eyebrow={booking.eyebrow || "Termin buchen"}
title={booking.title || "Kennenlernen & Termine vereinbaren"} title={booking.title || "Kennenlernen & Termine vereinbaren"}
@@ -10,6 +10,7 @@ export const dynamic = "force-dynamic";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Meeting beitreten", title: "Meeting beitreten",
robots: { index: false, follow: false },
}; };
type Args = { params: Promise<{ slug: string }> }; type Args = { params: Promise<{ slug: string }> };
+50 -4
View File
@@ -1,5 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { notFound } from "next/navigation"; import { notFound, permanentRedirect, redirect } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { getPayload } from "payload"; import { getPayload } from "payload";
@@ -11,29 +11,64 @@ import { RichText } from "@/components/RichText";
import { CTA } from "@/components/CTA"; import { CTA } from "@/components/CTA";
import { RegisterForm } from "@/components/meeting/RegisterForm"; import { RegisterForm } from "@/components/meeting/RegisterForm";
import { getEventBySlug } from "@/lib/payload/content"; import { getEventBySlug } from "@/lib/payload/content";
import { getBookingSettingsGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals";
import { mediaAlt, mediaUrl } from "@/lib/payload/media"; import { mediaAlt, mediaUrl } from "@/lib/payload/media";
import { formatFullDate, formatTimeRange } from "@/lib/format"; import { formatFullDate, formatTimeRange } from "@/lib/format";
import { EVENT_CATEGORIES } from "@/collections/Events"; import { EVENT_CATEGORIES } from "@/collections/Events";
import { canJoinMeeting, combineDateAndTime, getMeetingStatus, statusLabel } from "@/lib/meeting/status"; import { canJoinMeeting, combineDateAndTime, getMeetingStatus, statusLabel } from "@/lib/meeting/status";
import { resolveSeo } from "@/lib/seo/resolve";
import { buildMetadata } from "@/lib/seo/metadata";
import { getSiteUrl } from "@/lib/seo/config";
import { excerptFromRichText } from "@/lib/seo/textExcerpt";
import { JsonLd, eventJsonLd } from "@/lib/seo/jsonld";
import { resolveRedirect } from "@/lib/seo/redirects";
import { siteConfig } from "@/lib/site";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
type Args = { params: Promise<{ slug: string }> }; type Args = { params: Promise<{ slug: string }> };
async function resolveEventOrRedirect(slug: string) {
const event = await getEventBySlug(slug);
if (event) return event;
const match = await resolveRedirect(`/termine/${slug}`);
if (match) {
if (match.permanent) permanentRedirect(match.to);
redirect(match.to);
}
return null;
}
export async function generateMetadata({ params }: Args): Promise<Metadata> { export async function generateMetadata({ params }: Args): Promise<Metadata> {
const { slug } = await params; const { slug } = await params;
const event = await getEventBySlug(slug); const [event, seoSettings] = await Promise.all([getEventBySlug(slug), getSEOSettingsGlobal()]);
if (!event) return {}; if (!event) return {};
return { title: event.title };
const resolved = resolveSeo({
seo: event.seo,
fallbackTitle: `${event.title} ${siteConfig.name}`,
fallbackDescription: () => excerptFromRichText(event.description) || `${event.title} bei ${siteConfig.name}.`,
fallbackImage: event.image,
defaultOgImage: seoSettings.defaultOgImage,
});
return buildMetadata({
title: resolved.title,
description: resolved.description,
path: `/termine/${event.slug}`,
ogImageUrl: resolved.ogImageUrl,
keywords: resolved.keywords,
});
} }
export default async function EventDetailPage({ params }: Args) { export default async function EventDetailPage({ params }: Args) {
const { slug } = await params; const { slug } = await params;
const event = await getEventBySlug(slug); const event = await resolveEventOrRedirect(slug);
if (!event) notFound(); if (!event) notFound();
const categoryLabel = EVENT_CATEGORIES.find((c) => c.value === event.category)?.label; const categoryLabel = EVENT_CATEGORIES.find((c) => c.value === event.category)?.label;
const time = formatTimeRange(event.startTime, event.endTime); const time = formatTimeRange(event.startTime, event.endTime);
const [siteUrl, bookingSettings] = await Promise.all([getSiteUrl(), getBookingSettingsGlobal()]);
let meetingCard = null; let meetingCard = null;
if (event.isOnline) { if (event.isOnline) {
@@ -76,6 +111,17 @@ export default async function EventDetailPage({ params }: Args) {
return ( return (
<> <>
<JsonLd
data={eventJsonLd({
event,
description: event.seo?.description || excerptFromRichText(event.description) || `${event.title} bei ${siteConfig.name}.`,
imageUrl: mediaUrl(event.image, "hero"),
joinUrl: event.isOnline ? `${siteUrl}/termine/${slug}/beitreten` : undefined,
siteUrl,
providerName: siteConfig.name,
booking: bookingSettings,
})}
/>
<PageHeader <PageHeader
eyebrow={categoryLabel} eyebrow={categoryLabel}
title={event.title} title={event.title}
+10 -4
View File
@@ -4,13 +4,17 @@ import { Section } from "@/components/Section";
import { Reveal } from "@/components/Reveal"; import { Reveal } from "@/components/Reveal";
import { EventTeaserCard } from "@/components/EventTeaserCard"; import { EventTeaserCard } from "@/components/EventTeaserCard";
import { getAllEvents } from "@/lib/payload/content"; import { getAllEvents } from "@/lib/payload/content";
import { buildMetadata } from "@/lib/seo/metadata";
import { getSiteUrl } from "@/lib/seo/config";
import { JsonLd, webPageJsonLd } from "@/lib/seo/jsonld";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const metadata: Metadata = { const DESCRIPTION = "Aktuelle Termine und Veranstaltungen von Anouma.";
title: "Termine",
description: "Aktuelle Termine und Veranstaltungen von Anouma.", export async function generateMetadata(): Promise<Metadata> {
}; return buildMetadata({ title: "Termine", description: DESCRIPTION, path: "/termine" });
}
export default async function TerminePage() { export default async function TerminePage() {
const events = await getAllEvents(); const events = await getAllEvents();
@@ -18,9 +22,11 @@ export default async function TerminePage() {
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
const upcoming = events.filter((e) => new Date(e.date) >= today).reverse(); const upcoming = events.filter((e) => new Date(e.date) >= today).reverse();
const past = events.filter((e) => new Date(e.date) < today); const past = events.filter((e) => new Date(e.date) < today);
const siteUrl = await getSiteUrl();
return ( return (
<> <>
<JsonLd data={webPageJsonLd({ name: "Termine", description: DESCRIPTION, url: `${siteUrl}/termine` })} />
<PageHeader <PageHeader
eyebrow="Termine" eyebrow="Termine"
title="Aktuelle Termine" title="Aktuelle Termine"
+30 -6
View File
@@ -6,25 +6,49 @@ import { RichText } from "@/components/RichText";
import { CTA } from "@/components/CTA"; import { CTA } from "@/components/CTA";
import { Reveal } from "@/components/Reveal"; import { Reveal } from "@/components/Reveal";
import { OrganicBlob } from "@/components/OrganicBlob"; import { OrganicBlob } from "@/components/OrganicBlob";
import { getAboutGlobal } from "@/lib/payload/globals"; import { getAboutGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals";
import { mediaAlt, mediaUrl } from "@/lib/payload/media"; import { mediaAlt, mediaUrl } from "@/lib/payload/media";
import { resolveSeo } from "@/lib/seo/resolve";
import { buildMetadata } from "@/lib/seo/metadata";
import { getSiteUrl } from "@/lib/seo/config";
import { excerptFromRichText } from "@/lib/seo/textExcerpt";
import { JsonLd, webPageJsonLd } from "@/lib/seo/jsonld";
import { siteConfig } from "@/lib/site";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const about = await getAboutGlobal(); const [about, seoSettings] = await Promise.all([getAboutGlobal(), getSEOSettingsGlobal()]);
return { const resolved = resolveSeo({
title: about.title || "Über mich", seo: about.seo,
description: "Mein Weg, meine Werte und was mich bewegt.", fallbackTitle: `Über mich ${siteConfig.name}`,
}; fallbackDescription: () => excerptFromRichText(about.body) || seoSettings.defaultDescription || "Mein Weg, meine Werte und was mich bewegt.",
fallbackImage: about.portrait,
defaultOgImage: seoSettings.defaultOgImage,
});
return buildMetadata({
title: resolved.title,
description: resolved.description,
path: "/ueber-mich",
ogImageUrl: resolved.ogImageUrl,
keywords: resolved.keywords,
});
} }
export default async function UeberMichPage() { export default async function UeberMichPage() {
const about = await getAboutGlobal(); const about = await getAboutGlobal();
const hasClosing = about.closingHighlight || about.closingParagraph; const hasClosing = about.closingHighlight || about.closingParagraph;
const siteUrl = await getSiteUrl();
return ( return (
<> <>
<JsonLd
data={webPageJsonLd({
name: about.title || "Über mich",
description: excerptFromRichText(about.body) || "Mein Weg, meine Werte und was mich bewegt.",
url: `${siteUrl}/ueber-mich`,
})}
/>
<PageHeader <PageHeader
eyebrow={about.eyebrow || "Über mich"} eyebrow={about.eyebrow || "Über mich"}
title={about.title || "Mein Weg, meine Werte und was mich bewegt"} title={about.title || "Mein Weg, meine Werte und was mich bewegt"}
@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { calendarFeedUrl, generateCalendarFeedToken, hashCalendarFeedToken } from "@/lib/calendar/feedToken";
export const dynamic = "force-dynamic";
/**
* POST /api/admin/calendar-feed/regenerate — (re)generates Anna's personal
* ICS feed link. Only the new token's hash is stored; the plaintext URL is
* returned exactly once in this response and can never be retrieved again
* — generating a new one immediately invalidates whichever link was issued
* before (see collections/Users.ts).
*/
export async function POST(request: Request) {
try {
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers: request.headers });
if (!user || user.collection !== "users") {
return NextResponse.json({ error: "Nicht autorisiert." }, { status: 401 });
}
const token = generateCalendarFeedToken();
await payload.update({
collection: "users",
id: user.id,
data: { calendarFeedTokenHash: hashCalendarFeedToken(token) },
});
return NextResponse.json({ url: calendarFeedUrl(token) });
} catch (error) {
console.error("calendar feed regenerate failed", error);
return NextResponse.json({ error: "Der Kalender-Link konnte nicht erzeugt werden." }, { status: 500 });
}
}
@@ -0,0 +1,91 @@
import { NextResponse } from "next/server";
import { getPayload } from "payload";
import config from "@payload-config";
import { checkRateLimit, getClientIp } from "@/lib/auth/rateLimit";
import { EMAIL_VERIFICATION_TTL_MS, generateVerificationToken, hashVerificationToken, verificationExpiryISO } from "@/lib/auth/verification";
import { verificationEmail } from "@/lib/email/authTemplates";
import { sendEmail } from "@/lib/email/sendBookingEmails";
import type { Customer } from "@/payload-types";
export const dynamic = "force-dynamic";
// Deliberately identical whether or not an account exists / is already
// verified — this endpoint must never let a caller find out either of
// those things about an email address that isn't their own session.
const GENERIC_RESPONSE = {
ok: true,
message: "Falls ein Konto mit dieser E-Mail-Adresse existiert und noch nicht bestätigt ist, wurde eine neue Bestätigungs-E-Mail gesendet.",
};
async function issueAndSend(payload: Awaited<ReturnType<typeof getPayload>>, customer: Customer) {
const token = generateVerificationToken();
await payload.update({
collection: "customers",
id: customer.id,
data: {
emailVerificationTokenHash: hashVerificationToken(token),
emailVerificationExpires: verificationExpiryISO(),
},
});
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3000";
await sendEmail(
customer.email,
verificationEmail({
name: customer.name,
verifyUrl: `${serverUrl}/auth/verify-email/${token}`,
expiresHours: Math.round(EMAIL_VERIFICATION_TTL_MS / (60 * 60 * 1000)),
}),
);
}
export async function POST(request: Request) {
const ip = getClientIp(request);
if (!checkRateLimit(`resend-verification:ip:${ip}`, { max: 10, windowMs: 60 * 60 * 1000 })) {
return NextResponse.json({ error: "Zu viele Anfragen. Bitte versuche es später erneut." }, { status: 429 });
}
try {
const payload = await getPayload({ config });
const { user } = await payload.auth({ headers: request.headers });
let customer: Customer | null = null;
if (user && user.collection === "customers") {
customer = user as Customer;
} else {
let body: { email?: unknown } = {};
try {
body = await request.json();
} catch {
// no body — handled below as a missing email
}
const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : "";
if (!email) {
return NextResponse.json({ error: "E-Mail-Adresse erforderlich." }, { status: 400 });
}
if (!checkRateLimit(`resend-verification:email:${email}`, { max: 3, windowMs: 60 * 60 * 1000 })) {
return NextResponse.json({ error: "Zu viele Anfragen. Bitte versuche es später erneut." }, { status: 429 });
}
const { docs } = await payload.find({
collection: "customers",
where: { email: { equals: email } },
limit: 1,
});
customer = (docs[0] as Customer) ?? null;
}
if (customer && !customer.emailVerified) {
if (!checkRateLimit(`resend-verification:customer:${customer.id}`, { max: 3, windowMs: 60 * 60 * 1000 })) {
return NextResponse.json({ error: "Zu viele Anfragen. Bitte versuche es später erneut." }, { status: 429 });
}
await issueAndSend(payload, customer);
}
return NextResponse.json(GENERIC_RESPONSE);
} catch (error) {
console.error("resend verification failed", error);
return NextResponse.json({ error: "Die Bestätigungs-E-Mail konnte nicht gesendet werden." }, { status: 500 });
}
}
@@ -15,6 +15,9 @@ export async function POST(request: Request, { params }: Args) {
if (!user || user.collection !== "customers") { if (!user || user.collection !== "customers") {
return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 }); return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
} }
if (!user.emailVerified) {
return NextResponse.json({ error: "Bitte bestätige zuerst deine E-Mail-Adresse.", code: "EMAIL_NOT_VERIFIED" }, { status: 403 });
}
const booking = await payload.findByID({ collection: "booking-requests", id, depth: 0 }).catch(() => null); const booking = await payload.findByID({ collection: "booking-requests", id, depth: 0 }).catch(() => null);
if (!booking || Number(booking.user) !== Number(user.id)) { if (!booking || Number(booking.user) !== Number(user.id)) {
@@ -15,6 +15,8 @@ export async function POST(request: Request, { params }: Args) {
if (!user || user.collection !== "customers") { if (!user || user.collection !== "customers") {
return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 }); return NextResponse.json({ error: "Bitte melde dich an." }, { status: 401 });
} }
// Cancelling is deliberately allowed regardless of verification status —
// only creating/confirming a booking is a gated "protected" action.
const booking = await payload.findByID({ collection: "booking-requests", id, depth: 0 }).catch(() => null); const booking = await payload.findByID({ collection: "booking-requests", id, depth: 0 }).catch(() => null);
if (!booking || Number(booking.user) !== Number(user.id)) { if (!booking || Number(booking.user) !== Number(user.id)) {
@@ -35,6 +35,9 @@ export async function POST(request: Request) {
if (!user || user.collection !== "customers") { if (!user || user.collection !== "customers") {
return NextResponse.json({ error: "Bitte melde dich an, um einen Termin anzufragen." }, { status: 401 }); return NextResponse.json({ error: "Bitte melde dich an, um einen Termin anzufragen." }, { status: 401 });
} }
if (!user.emailVerified) {
return NextResponse.json({ error: "Bitte bestätige zuerst deine E-Mail-Adresse.", code: "EMAIL_NOT_VERIFIED" }, { status: 403 });
}
const { docs } = await payload.find({ const { docs } = await payload.find({
collection: "offers", collection: "offers",
+6 -2
View File
@@ -25,6 +25,7 @@ const inter = Inter({
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Seite nicht gefunden", title: "Seite nicht gefunden",
robots: { index: false, follow: false },
}; };
export default function GlobalNotFound() { export default function GlobalNotFound() {
@@ -47,14 +48,17 @@ export default function GlobalNotFound() {
Diesen Weg gibt es hier nicht Diesen Weg gibt es hier nicht
</h1> </h1>
<p className="mt-5 max-w-md text-lg leading-relaxed text-anouma-plum"> <p className="mt-5 max-w-md text-lg leading-relaxed text-anouma-plum">
Die gesuchte Seite konnte nicht gefunden werden. Vielleicht findest du deinen Weg Diese Seite wurde nicht gefunden. Vielleicht findest du deinen Weg über die
über die Startseite oder die Angebote weiter. Startseite, die Angebote oder den Kontakt weiter.
</p> </p>
<div className="mt-8 flex flex-wrap gap-4"> <div className="mt-8 flex flex-wrap gap-4">
<Button href="/">Zur Startseite</Button> <Button href="/">Zur Startseite</Button>
<Button href="/angebote" variant="secondary"> <Button href="/angebote" variant="secondary">
Angebote ansehen Angebote ansehen
</Button> </Button>
<Button href="/kontakt" variant="secondary">
Kontakt
</Button>
</div> </div>
</div> </div>
<div className="relative mx-auto aspect-square w-full max-w-sm"> <div className="relative mx-auto aspect-square w-full max-w-sm">
+32 -4
View File
@@ -1,15 +1,43 @@
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
import { siteConfig } from "@/lib/site"; import { getSiteUrl, isSiteIndexable } from "@/lib/seo/config";
// Reads the CMS's SEO settings (site URL override, robots kill switch) on
// every request — must not be statically prerendered at build time.
export const dynamic = "force-dynamic";
export default async function robots(): Promise<MetadataRoute.Robots> {
const [siteUrl, indexable] = await Promise.all([getSiteUrl(), isSiteIndexable()]);
if (!indexable) {
// Global kill switch (SEO-Einstellungen → "Website für Suchmaschinen
// sichtbar") — block everything rather than list exceptions.
return { rules: [{ userAgent: "*", disallow: "/" }] };
}
export default function robots(): MetadataRoute.Robots {
return { return {
rules: [ rules: [
{ {
userAgent: "*", userAgent: "*",
allow: "/", allow: "/",
disallow: ["/impressum", "/datenschutz"], disallow: [
// Admin panel and its API.
"/admin",
"/admin/*",
"/api/*",
// Customer accounts — never public.
"/konto",
"/konto/*",
"/login",
"/registrieren",
"/auth/*",
// Private calendar-subscription feed (secret-token URLs).
"/calendar/*",
// Meeting join/call flow — session-gated, never a page worth indexing.
"/termine/*/beitreten",
"/termine/*/call",
],
}, },
], ],
sitemap: `${siteConfig.domain}/sitemap.xml`, sitemap: `${siteUrl}/sitemap.xml`,
}; };
} }
+73 -23
View File
@@ -1,29 +1,79 @@
import type { MetadataRoute } from "next"; import type { MetadataRoute } from "next";
import { siteConfig } from "@/lib/site"; import { getAllEvents, getOffers, getPosts } from "@/lib/payload/content";
import { getSiteUrl, isSiteIndexable } from "@/lib/seo/config";
const routes = [ // Reads live CMS content on every request — must not be statically
"", // prerendered at build time (no DB is available during `next build`; see
"/ueber-mich", // AGENTS.md / the same convention used by every other CMS-backed route).
"/angebote", export const dynamic = "force-dynamic";
"/angebote/prozessbegleitung",
"/angebote/doula-begleitung", // Static, always-public routes that aren't backed by a dynamic [slug]
"/angebote/erdenkinder", // collection. Deliberately excludes: /login, /registrieren, /konto*,
"/angebote/maedchenkreis", // /admin*, /calendar/*, /auth/*, /termine/*/beitreten, /termine/*/call,
"/angebote/singkreise", // /api/* — none of those are public content (see app/robots.ts, which
"/angebote/singkreise/singen-im-kreis", // mirrors this same exclusion list).
"/angebote/singkreise/singen-fuer-schwangere", const staticRoutes: { path: string; changeFrequency: MetadataRoute.Sitemap[number]["changeFrequency"]; priority: number }[] = [
"/angebote/singkreise/mama-baby-singkreis", { path: "", changeFrequency: "weekly", priority: 1 },
"/aktuelles", { path: "/ueber-mich", changeFrequency: "monthly", priority: 0.7 },
"/termin-buchen", { path: "/angebote", changeFrequency: "monthly", priority: 0.9 },
"/kontakt", { path: "/angebote/singkreise", changeFrequency: "monthly", priority: 0.6 },
"/impressionen", { path: "/aktuelles", changeFrequency: "weekly", priority: 0.7 },
{ path: "/termine", changeFrequency: "weekly", priority: 0.7 },
{ path: "/termin-buchen", changeFrequency: "monthly", priority: 0.8 },
{ path: "/kontakt", changeFrequency: "yearly", priority: 0.5 },
{ path: "/impressionen", changeFrequency: "monthly", priority: 0.3 },
{ path: "/impressum", changeFrequency: "yearly", priority: 0.1 },
{ path: "/datenschutz", changeFrequency: "yearly", priority: 0.1 },
]; ];
export default function sitemap(): MetadataRoute.Sitemap { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
return routes.map((route) => ({ if (!(await isSiteIndexable())) return [];
url: `${siteConfig.domain}${route}`,
lastModified: new Date(), const siteUrl = await getSiteUrl();
changeFrequency: route === "" ? "weekly" : "monthly", const now = new Date();
priority: route === "" ? 1 : 0.7,
const entries: MetadataRoute.Sitemap = staticRoutes.map((route) => ({
url: `${siteUrl}${route.path}`,
lastModified: now,
changeFrequency: route.changeFrequency,
priority: route.priority,
})); }));
// getOffers/getAllEvents/getPosts already only return published,
// publicly-readable documents (overrideAccess: false — see
// lib/payload/content.ts's header comment).
const [offers, events, posts] = await Promise.all([getOffers(), getAllEvents(), getPosts()]);
for (const offer of offers) {
if (!offer.slug || offer.visibility === "private") continue;
entries.push({
url: `${siteUrl}/angebote/${offer.slug}`,
lastModified: new Date(offer.updatedAt),
changeFrequency: "monthly",
priority: 0.8,
});
}
for (const event of events) {
// Private single-session bookings must never appear in the sitemap.
if (!event.slug || event.isPrivateBooking) continue;
entries.push({
url: `${siteUrl}/termine/${event.slug}`,
lastModified: new Date(event.updatedAt),
changeFrequency: "weekly",
priority: 0.6,
});
}
for (const post of posts) {
if (!post.slug) continue;
entries.push({
url: `${siteUrl}/aktuelles/${post.slug}`,
lastModified: new Date(post.updatedAt),
changeFrequency: "monthly",
priority: 0.6,
});
}
return entries;
} }
+73 -1
View File
@@ -1,4 +1,8 @@
import type { CollectionConfig } from "payload"; import type { CollectionAfterChangeHook, CollectionBeforeChangeHook, CollectionConfig } from "payload";
import { isAdminFieldLevel } from "@/access";
import { EMAIL_VERIFICATION_TTL_MS, generateVerificationToken, hashVerificationToken, verificationExpiryISO } from "@/lib/auth/verification";
import { verificationEmail } from "@/lib/email/authTemplates";
import { sendEmail } from "@/lib/email/sendBookingEmails";
const isSelfOrAdmin = ({ req }: { req: { user?: { collection?: string; id?: unknown } | null } }) => { const isSelfOrAdmin = ({ req }: { req: { user?: { collection?: string; id?: unknown } | null } }) => {
const user = req.user; const user = req.user;
@@ -8,6 +12,41 @@ const isSelfOrAdmin = ({ req }: { req: { user?: { collection?: string; id?: unkn
return false; return false;
}; };
// Runs on every new customer account: issues a random, single-use,
// time-limited email-verification token. Only its hash is ever persisted
// (see the emailVerificationTokenHash field below) — the plaintext exists
// only in req.context for the afterChange hook below to email out, and is
// never written to the database or logged.
const issueVerificationToken: CollectionBeforeChangeHook = ({ data, operation, req }) => {
if (operation !== "create") return data;
const token = generateVerificationToken();
data.emailVerified = false;
data.emailVerificationTokenHash = hashVerificationToken(token);
data.emailVerificationExpires = verificationExpiryISO();
req.context.pendingVerificationToken = token;
return data;
};
const sendVerificationEmail: CollectionAfterChangeHook = async ({ doc, operation, req }) => {
if (operation !== "create") return doc;
const token = req.context.pendingVerificationToken;
if (typeof token !== "string") return doc;
try {
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3000";
await sendEmail(
doc.email,
verificationEmail({
name: doc.name,
verifyUrl: `${serverUrl}/auth/verify-email/${token}`,
expiresHours: Math.round(EMAIL_VERIFICATION_TTL_MS / (60 * 60 * 1000)),
}),
);
} catch (err) {
req.payload.logger.error({ err, msg: "customers sendVerificationEmail failed" });
}
return doc;
};
export const Customers: CollectionConfig = { export const Customers: CollectionConfig = {
slug: "customers", slug: "customers",
labels: { labels: {
@@ -32,6 +71,10 @@ export const Customers: CollectionConfig = {
update: isSelfOrAdmin, update: isSelfOrAdmin,
delete: ({ req }) => req.user?.collection === "users", delete: ({ req }) => req.user?.collection === "users",
}, },
hooks: {
beforeChange: [issueVerificationToken],
afterChange: [sendVerificationEmail],
},
fields: [ fields: [
{ {
name: "name", name: "name",
@@ -48,6 +91,35 @@ export const Customers: CollectionConfig = {
description: "Optional — für eine spätere Nutzung vorbereitet, aktuell nicht verpflichtend.", description: "Optional — für eine spätere Nutzung vorbereitet, aktuell nicht verpflichtend.",
}, },
}, },
{
name: "emailVerified",
type: "checkbox",
label: "E-Mail bestätigt",
defaultValue: false,
// "create" AND "update" must both be locked down — otherwise a
// customer could simply include emailVerified: true in their own
// registration or profile request and skip verification entirely.
access: { create: isAdminFieldLevel, update: isAdminFieldLevel },
admin: {
position: "sidebar",
description: "Wird automatisch gesetzt, sobald der Bestätigungslink aus der E-Mail angeklickt wird. Kann hier bei Bedarf manuell gesetzt werden.",
},
},
{
name: "emailVerificationTokenHash",
type: "text",
// Never exposed or settable via any API — only the two hooks above
// (running through the trusted Local API) ever touch this field.
access: { create: () => false, read: () => false, update: () => false },
admin: { hidden: true },
index: true,
},
{
name: "emailVerificationExpires",
type: "date",
access: { create: () => false, read: () => false, update: () => false },
admin: { hidden: true },
},
// "email" and "password" are added automatically by the auth config. // "email" and "password" are added automatically by the auth config.
], ],
}; };
+2
View File
@@ -1,6 +1,7 @@
import type { CollectionBeforeChangeHook, CollectionConfig } from "payload"; import type { CollectionBeforeChangeHook, CollectionConfig } from "payload";
import { isAdmin, publishedOrAdmin } from "@/access"; import { isAdmin, publishedOrAdmin } from "@/access";
import { slugField } from "@/fields/slug"; import { slugField } from "@/fields/slug";
import { seoFields } from "@/fields/seo";
import { generateMeetingPassword } from "@/lib/meeting/password"; import { generateMeetingPassword } from "@/lib/meeting/password";
export const EVENT_CATEGORIES = [ export const EVENT_CATEGORIES = [
@@ -243,5 +244,6 @@ export const Events: CollectionConfig = {
label: "Zugehörige Buchungsanfrage", label: "Zugehörige Buchungsanfrage",
admin: { position: "sidebar", readOnly: true }, admin: { position: "sidebar", readOnly: true },
}, },
seoFields(),
], ],
}; };
+2
View File
@@ -1,6 +1,7 @@
import type { CollectionConfig } from "payload"; import type { CollectionConfig } from "payload";
import { isAdmin, publishedOrAdmin } from "@/access"; import { isAdmin, publishedOrAdmin } from "@/access";
import { slugField } from "@/fields/slug"; import { slugField } from "@/fields/slug";
import { seoFields } from "@/fields/seo";
// Groups the 7 individual offers for the mega menu and the /angebote // Groups the 7 individual offers for the mega menu and the /angebote
// overview page (Kindergruppen bundles Erdenkinder + Mädchenkreis, Singkreise // overview page (Kindergruppen bundles Erdenkinder + Mädchenkreis, Singkreise
@@ -123,5 +124,6 @@ export const Offers: CollectionConfig = {
}, },
], ],
}, },
seoFields(),
], ],
}; };
+9
View File
@@ -1,6 +1,7 @@
import type { CollectionConfig } from "payload"; import type { CollectionConfig } from "payload";
import { isAdmin, publishedOrAdmin } from "@/access"; import { isAdmin, publishedOrAdmin } from "@/access";
import { slugField } from "@/fields/slug"; import { slugField } from "@/fields/slug";
import { seoFields } from "@/fields/seo";
export const Posts: CollectionConfig = { export const Posts: CollectionConfig = {
slug: "posts", slug: "posts",
@@ -64,5 +65,13 @@ export const Posts: CollectionConfig = {
date: { pickerAppearance: "dayOnly", displayFormat: "dd.MM.yyyy" }, date: { pickerAppearance: "dayOnly", displayFormat: "dd.MM.yyyy" },
}, },
}, },
{
name: "author",
type: "text",
label: "Autor:in",
defaultValue: "Anouma",
admin: { position: "sidebar" },
},
seoFields(),
], ],
}; };
+76
View File
@@ -0,0 +1,76 @@
import type { CollectionConfig } from "payload";
import { isAdmin } from "@/access";
function normalizePath(value: string): string {
const trimmed = value.trim();
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
return withSlash.length > 1 ? withSlash.replace(/\/+$/, "") : withSlash;
}
/**
* Simple slug-change redirect table (see lib/seo/redirects.ts). Deliberately
* scoped to the three dynamic [slug] detail routes (Angebote/Termine/
* Aktuelles) — that's the only place slugs actually live and can change, so
* looking this up only there avoids adding a database lookup to every
* single request the way a catch-all middleware would.
*/
export const Redirects: CollectionConfig = {
slug: "redirects",
labels: {
singular: "Weiterleitung",
plural: "Weiterleitungen",
},
admin: {
useAsTitle: "fromPath",
defaultColumns: ["fromPath", "toPath", "type", "enabled"],
group: "SEO",
description: "Leitet eine alte URL (z. B. nach einer Slug-Änderung bei Angeboten, Terminen oder Aktuelles) dauerhaft auf eine neue weiter.",
},
access: {
read: () => true,
create: isAdmin,
update: isAdmin,
delete: isAdmin,
},
fields: [
{
name: "fromPath",
type: "text",
label: "Alte URL (Pfad)",
required: true,
unique: true,
index: true,
admin: { description: "Nur der Pfad, z. B. /angebote/doula (ohne Domain)." },
hooks: {
beforeValidate: [({ value }) => (typeof value === "string" && value ? normalizePath(value) : value)],
},
},
{
name: "toPath",
type: "text",
label: "Neue URL (Pfad)",
required: true,
admin: { description: "Ziel-Pfad, z. B. /angebote/doula-begleitung." },
hooks: {
beforeValidate: [({ value }) => (typeof value === "string" && value ? normalizePath(value) : value)],
},
},
{
name: "type",
type: "select",
label: "Art der Weiterleitung",
required: true,
defaultValue: "permanent",
options: [
{ label: "301 Dauerhaft", value: "permanent" },
{ label: "302 Vorübergehend", value: "temporary" },
],
},
{
name: "enabled",
type: "checkbox",
label: "Aktiv",
defaultValue: true,
},
],
};
+11
View File
@@ -45,6 +45,17 @@ export const Users: CollectionConfig = {
update: isAdminFieldLevel, update: isAdminFieldLevel,
}, },
}, },
{
name: "calendarFeedTokenHash",
type: "text",
// Only ever set by /api/admin/calendar-feed/regenerate (see
// lib/calendar/feedToken.ts) — never exposed or directly settable via
// any API, so the plaintext token is never retrievable again after
// it's shown once at generation time.
access: { create: () => false, read: () => false, update: () => false },
admin: { hidden: true },
index: true,
},
// "email" and "password" are added automatically by `auth: true`-style config. // "email" and "password" are added automatically by `auth: true`-style config.
], ],
}; };
+16 -1
View File
@@ -1,6 +1,8 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Breadcrumbs, type Crumb } from "@/components/Breadcrumbs"; import { Breadcrumbs, type Crumb } from "@/components/Breadcrumbs";
import { OrganicBlob } from "@/components/OrganicBlob"; import { OrganicBlob } from "@/components/OrganicBlob";
import { JsonLd, breadcrumbJsonLd } from "@/lib/seo/jsonld";
import { getSiteUrl } from "@/lib/seo/config";
type PageHeaderProps = { type PageHeaderProps = {
eyebrow?: string; eyebrow?: string;
@@ -9,11 +11,24 @@ type PageHeaderProps = {
crumbs?: Crumb[]; crumbs?: Crumb[];
}; };
export function PageHeader({ eyebrow, title, lead, crumbs }: PageHeaderProps) { /** Renders the visual breadcrumb trail AND its matching BreadcrumbList JSON-LD from the same `crumbs` — every page that already passes `crumbs` gets structured breadcrumb data for free. */
export async function PageHeader({ eyebrow, title, lead, crumbs }: PageHeaderProps) {
const breadcrumbData = crumbs?.length
? await (async () => {
const siteUrl = await getSiteUrl();
const items = [
{ name: "Startseite", url: siteUrl },
...crumbs.map((crumb) => ({ name: crumb.title, url: crumb.href ? `${siteUrl}${crumb.href}` : undefined })),
];
return breadcrumbJsonLd(items);
})()
: null;
return ( return (
<section className="relative overflow-hidden border-b border-anouma-taupe/10 bg-anouma-cream-light pb-16 pt-14 sm:pb-20 sm:pt-20"> <section className="relative overflow-hidden border-b border-anouma-taupe/10 bg-anouma-cream-light pb-16 pt-14 sm:pb-20 sm:pt-20">
<OrganicBlob tone="rose" className="-right-20 -top-20 h-72 w-72" /> <OrganicBlob tone="rose" className="-right-20 -top-20 h-72 w-72" />
<div className="relative mx-auto max-w-6xl px-6 sm:px-8 lg:px-12"> <div className="relative mx-auto max-w-6xl px-6 sm:px-8 lg:px-12">
{breadcrumbData && <JsonLd data={breadcrumbData} />}
{crumbs && <Breadcrumbs items={crumbs} />} {crumbs && <Breadcrumbs items={crumbs} />}
{eyebrow && ( {eyebrow && (
<p className="mb-4 text-xs font-medium uppercase tracking-[0.24em] text-anouma-plum"> <p className="mb-4 text-xs font-medium uppercase tracking-[0.24em] text-anouma-plum">
+9
View File
@@ -1,5 +1,6 @@
import type { AdminViewServerProps } from "payload"; import type { AdminViewServerProps } from "payload";
import { getAdminCalendarItems, type AdminCalendarItem } from "@/lib/booking/adminCalendar"; import { getAdminCalendarItems, type AdminCalendarItem } from "@/lib/booking/adminCalendar";
import { CalendarFeedPanel } from "./CalendarFeedPanel";
import styles from "./AdminCalendarView.module.css"; import styles from "./AdminCalendarView.module.css";
const WEEKDAY_LABELS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; const WEEKDAY_LABELS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
@@ -58,6 +59,12 @@ export async function AdminCalendarView({ initPageResult }: AdminViewServerProps
rangeEnd.setDate(rangeEnd.getDate() + 41); // 6 full weeks rangeEnd.setDate(rangeEnd.getDate() + 41); // 6 full weeks
const items = await getAdminCalendarItems(req.payload, { from: rangeStart, to: rangeEnd }); const items = await getAdminCalendarItems(req.payload, { from: rangeStart, to: rangeEnd });
// Local API (overrideAccess: true by default) so this can see
// calendarFeedTokenHash despite its field-level access being locked to
// "nobody" for every real API path — see collections/Users.ts.
const currentUser = req.user ? await req.payload.findByID({ collection: "users", id: req.user.id, req }) : null;
const calendarFeedConfigured = Boolean((currentUser as { calendarFeedTokenHash?: string | null } | null)?.calendarFeedTokenHash);
const itemsByDay = new Map<string, AdminCalendarItem[]>(); const itemsByDay = new Map<string, AdminCalendarItem[]>();
for (const item of items) { for (const item of items) {
const key = dateKey(new Date(item.date)); const key = dateKey(new Date(item.date));
@@ -134,6 +141,8 @@ export async function AdminCalendarView({ initPageResult }: AdminViewServerProps
</span> </span>
))} ))}
</div> </div>
<CalendarFeedPanel initiallyConfigured={calendarFeedConfigured} />
</div> </div>
<div className={styles.schedule}> <div className={styles.schedule}>
@@ -0,0 +1,122 @@
.panel {
margin-top: 28px;
border: 1px solid #e6e6e6;
border-radius: 12px;
padding: 20px 22px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.heading {
font-size: 16px;
font-weight: 600;
margin: 0 0 4px;
color: #1f1f1f;
}
.hint {
font-size: 13px;
color: #666;
margin: 0 0 14px;
line-height: 1.5;
}
.status {
font-size: 13px;
color: #555;
margin: 0 0 14px;
}
.button {
display: inline-flex;
align-items: center;
border: none;
border-radius: 999px;
background: #8b616d;
color: #fff;
font-size: 13px;
font-weight: 600;
padding: 9px 18px;
cursor: pointer;
}
.button:hover {
background: #66505f;
}
.button:disabled {
opacity: 0.6;
cursor: default;
}
.secondaryButton {
display: inline-flex;
align-items: center;
border-radius: 999px;
font-size: 13px;
font-weight: 600;
padding: 9px 18px;
cursor: pointer;
background: transparent;
color: #8b616d;
border: 1px solid #8b616d;
}
.secondaryButton:hover {
background: #f7f0f2;
}
.secondaryButton:disabled {
opacity: 0.6;
cursor: default;
}
.result {
margin-top: 16px;
border-radius: 10px;
background: #f7f4f1;
padding: 14px 16px;
}
.resultWarning {
font-size: 12px;
font-weight: 600;
color: #7a2020;
margin: 0 0 8px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.urlRow {
display: flex;
gap: 8px;
align-items: center;
}
.urlInput {
flex: 1;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 12px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid #dcdcdc;
background: #fff;
color: #333;
}
.copied {
font-size: 12px;
color: #3a4f30;
margin: 8px 0 0;
}
.error {
font-size: 13px;
color: #7a2020;
margin-top: 10px;
}
.instructions {
margin-top: 18px;
font-size: 12px;
color: #777;
line-height: 1.6;
}
.instructions strong {
color: #444;
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import styles from "./CalendarFeedPanel.module.css";
export function CalendarFeedPanel({ initiallyConfigured }: { initiallyConfigured: boolean }) {
const [configured, setConfigured] = useState(initiallyConfigured);
const [loading, setLoading] = useState(false);
const [url, setUrl] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [error, setError] = useState<string | null>(null);
async function generate() {
setLoading(true);
setError(null);
setCopied(false);
try {
const res = await fetch("/api/admin/calendar-feed/regenerate", { method: "POST", credentials: "include" });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data.error || "Der Kalender-Link konnte nicht erzeugt werden.");
return;
}
setUrl(data.url);
setConfigured(true);
} catch {
setError("Verbindung fehlgeschlagen.");
} finally {
setLoading(false);
}
}
async function copy() {
if (!url) return;
try {
await navigator.clipboard.writeText(url);
setCopied(true);
} catch {
// Clipboard API can be unavailable (e.g. insecure context) — the
// input is still selectable/copyable by hand in that case.
}
}
return (
<div className={styles.panel}>
<h2 className={styles.heading}>Kalender synchronisieren</h2>
<p className={styles.hint}>
Abonniere deinen persönlichen Kalender in Apple Kalender, Google Kalender, Outlook, Thunderbird oder einer
anderen iCalendar-kompatiblen App. Der Link enthält ein geheimes Zugriffstoken teile ihn nicht.
</p>
{!url && (
<p className={styles.status}>
{configured ? "Ein Kalender-Link ist eingerichtet." : "Es ist noch kein Kalender-Link eingerichtet."}
</p>
)}
{!url && (
<button type="button" className={styles.button} onClick={generate} disabled={loading}>
{loading ? "Wird erzeugt …" : configured ? "Kalender-Link neu generieren" : "Kalender-Link generieren"}
</button>
)}
{error && <p className={styles.error}>{error}</p>}
{url && (
<div className={styles.result}>
<p className={styles.resultWarning}>Wird nur jetzt einmal angezeigt bitte gleich kopieren.</p>
<div className={styles.urlRow}>
<input className={styles.urlInput} type="text" readOnly value={url} onFocus={(e) => e.currentTarget.select()} />
<button type="button" className={styles.secondaryButton} onClick={copy}>
Kopieren
</button>
</div>
{copied && <p className={styles.copied}>In die Zwischenablage kopiert.</p>}
<button type="button" className={styles.secondaryButton} style={{ marginTop: 12 }} onClick={generate} disabled={loading}>
{loading ? "Wird erzeugt …" : "Kalender-Link neu generieren"}
</button>
</div>
)}
<p className={styles.instructions}>
<strong>Apple Kalender:</strong> Ablage Neues Kalenderabonnement Link einfügen.
<br />
<strong>Google Kalender:</strong> Weitere Kalender + Per URL Link einfügen.
<br />
<strong>Outlook:</strong> Kalender hinzufügen Aus dem Internet abonnieren Link einfügen.
<br />
<strong>Thunderbird:</strong> Kalender Neuer Kalender Im Netzwerk Link einfügen.
</p>
</div>
);
}
+2
View File
@@ -2,6 +2,7 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { ResendVerificationButton } from "./ResendVerificationButton";
const fieldClass = const fieldClass =
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none"; "w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
@@ -67,6 +68,7 @@ export function LoginForm() {
> >
{loading ? "Wird geprüft …" : "Anmelden"} {loading ? "Wird geprüft …" : "Anmelden"}
</button> </button>
<ResendVerificationButton variant="email-prompt" className="pt-1" />
</form> </form>
); );
} }
@@ -0,0 +1,134 @@
"use client";
import { useState, type FormEvent } from "react";
const fieldClass =
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-4 py-2.5 text-sm text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
async function callResend(email?: string): Promise<{ ok: boolean; error?: string }> {
try {
const res = await fetch("/api/auth/resend-verification", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(email ? { email } : {}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) return { ok: false, error: data.error || "Die Bestätigungs-E-Mail konnte nicht gesendet werden." };
return { ok: true };
} catch {
return { ok: false, error: "Verbindung fehlgeschlagen." };
}
}
const GENERIC_SENT_MESSAGE =
"Falls das Konto noch nicht bestätigt ist, wurde eine neue Bestätigungs-E-Mail gesendet.";
/**
* variant "session": a single button that resends for the currently logged-in
* customer (used in /konto and the booking widget, where a session already
* exists).
*
* variant "email-prompt": a toggle link that reveals an email field first
* (used on /login, where the visitor may not be authenticated yet).
*/
export function ResendVerificationButton({
variant,
triggerLabel = "E-Mail noch nicht bestätigt?",
className,
}: {
variant: "session" | "email-prompt";
triggerLabel?: string;
className?: string;
}) {
const [open, setOpen] = useState(false);
const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle");
const [error, setError] = useState<string | null>(null);
async function resendForSession() {
setStatus("sending");
const result = await callResend();
if (result.ok) {
setStatus("sent");
} else {
setStatus("error");
setError(result.error ?? null);
}
}
async function handleEmailSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus("sending");
setError(null);
const email = String(new FormData(e.currentTarget).get("email") ?? "");
const result = await callResend(email);
if (result.ok) {
setStatus("sent");
} else {
setStatus("error");
setError(result.error ?? null);
}
}
if (variant === "session") {
return (
<div className={className}>
{status === "sent" ? (
<p className="text-sm text-anouma-olive">{GENERIC_SENT_MESSAGE}</p>
) : (
<button
type="button"
onClick={resendForSession}
disabled={status === "sending"}
className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4 disabled:opacity-60"
>
{status === "sending" ? "Wird gesendet …" : "Bestätigungs-E-Mail erneut senden"}
</button>
)}
{status === "error" && error && (
<p role="alert" className="mt-1 text-sm text-red-700">
{error}
</p>
)}
</div>
);
}
return (
<div className={className}>
{!open ? (
<button
type="button"
onClick={() => setOpen(true)}
className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4"
>
{triggerLabel}
</button>
) : status === "sent" ? (
<p className="text-sm text-anouma-olive">{GENERIC_SENT_MESSAGE}</p>
) : (
<form onSubmit={handleEmailSubmit} className="mt-2 flex flex-wrap items-start gap-2">
<input
type="email"
name="email"
required
placeholder="Deine E-Mail-Adresse"
className={`${fieldClass} max-w-xs flex-1`}
/>
<button
type="submit"
disabled={status === "sending"}
className="rounded-full bg-anouma-mauve-dark px-5 py-2.5 text-sm font-medium text-white hover:bg-anouma-plum disabled:opacity-60"
>
{status === "sending" ? "Wird gesendet …" : "Bestätigungs-E-Mail erneut senden"}
</button>
</form>
)}
{status === "error" && error && (
<p role="alert" className="mt-1 text-sm text-red-700">
{error}
</p>
)}
</div>
);
}
+20 -1
View File
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { MonthCalendar, type CalendarMarker } from "./MonthCalendar"; import { MonthCalendar, type CalendarMarker } from "./MonthCalendar";
import { ResendVerificationButton } from "@/components/auth/ResendVerificationButton";
type SlotsResponse = { durationMinutes: number; slots: Record<string, { start: string; end: string }[]> }; type SlotsResponse = { durationMinutes: number; slots: Record<string, { start: string; end: string }[]> };
@@ -16,7 +17,15 @@ function fmtDate(iso: string) {
return new Date(iso).toLocaleDateString("de-DE", { weekday: "long", day: "2-digit", month: "long" }); return new Date(iso).toLocaleDateString("de-DE", { weekday: "long", day: "2-digit", month: "long" });
} }
export function BookingWidget({ offerSlug, isLoggedIn }: { offerSlug: string; isLoggedIn: boolean }) { export function BookingWidget({
offerSlug,
isLoggedIn,
isVerified,
}: {
offerSlug: string;
isLoggedIn: boolean;
isVerified: boolean;
}) {
const [slotsByDay, setSlotsByDay] = useState<SlotsResponse["slots"]>({}); const [slotsByDay, setSlotsByDay] = useState<SlotsResponse["slots"]>({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedDay, setSelectedDay] = useState<Date | null>(null); const [selectedDay, setSelectedDay] = useState<Date | null>(null);
@@ -76,6 +85,16 @@ export function BookingWidget({ offerSlug, isLoggedIn }: { offerSlug: string; is
); );
} }
if (!isVerified) {
return (
<div className="rounded-3xl bg-anouma-cream-light p-8 text-center">
<p className="text-base text-anouma-plum">Bitte bestätige zuerst deine E-Mail-Adresse.</p>
<p className="mt-2 text-sm text-anouma-plum/70">Erst danach kannst du einen Termin anfragen.</p>
<ResendVerificationButton variant="session" className="mt-5 flex justify-center" />
</div>
);
}
const markers: CalendarMarker[] = Object.keys(slotsByDay).map((key) => ({ date: new Date(key), status: "public" })); const markers: CalendarMarker[] = Object.keys(slotsByDay).map((key) => ({ date: new Date(key), status: "public" }));
const daySlots = selectedDay ? (slotsByDay[dateKey(selectedDay)] ?? []) : []; const daySlots = selectedDay ? (slotsByDay[dateKey(selectedDay)] ?? []) : [];
+53
View File
@@ -0,0 +1,53 @@
import type { Field } from "payload";
/**
* Reusable, always-optional SEO field group — added to every public-facing
* collection/global. Never required: every consumer falls back to a
* sensible, automatically derived value when a field is left empty (see
* lib/seo/resolve.ts), per the "SEO fields are optional with fallbacks"
* requirement.
*/
export function seoFields(): Field {
return {
type: "collapsible",
label: "SEO",
admin: {
description: "Optional — wird automatisch aus dem Inhalt abgeleitet, wenn hier nichts eingetragen ist.",
},
fields: [
{
name: "seo",
type: "group",
label: "",
fields: [
{
name: "title",
type: "text",
label: "SEO Title",
maxLength: 70,
admin: { description: "Falls leer: automatisch aus dem Seitentitel erzeugt." },
},
{
name: "description",
type: "textarea",
label: "Meta Description",
maxLength: 300,
admin: { description: "Falls leer: automatisch aus dem Inhalt erzeugt (erste ~160 Zeichen)." },
},
{
name: "keywords",
type: "text",
label: "SEO Keywords (optional, kommagetrennt)",
},
{
name: "ogImage",
type: "upload",
relationTo: "media",
label: "Open Graph Bild",
admin: { description: "Falls leer: Hauptbild dieser Seite bzw. das Standard-Social-Bild aus den SEO-Einstellungen." },
},
],
},
],
};
}
+2
View File
@@ -1,5 +1,6 @@
import type { GlobalConfig } from "payload"; import type { GlobalConfig } from "payload";
import { isAdmin } from "@/access"; import { isAdmin } from "@/access";
import { seoFields } from "@/fields/seo";
export const About: GlobalConfig = { export const About: GlobalConfig = {
slug: "about", slug: "about",
@@ -26,5 +27,6 @@ export const About: GlobalConfig = {
{ name: "closingParagraph", type: "textarea", label: "Abschlusstext" }, { name: "closingParagraph", type: "textarea", label: "Abschlusstext" },
], ],
}, },
seoFields(),
], ],
}; };
+2
View File
@@ -1,5 +1,6 @@
import type { GlobalConfig } from "payload"; import type { GlobalConfig } from "payload";
import { isAdmin } from "@/access"; import { isAdmin } from "@/access";
import { seoFields } from "@/fields/seo";
export const AktuellesIntro: GlobalConfig = { export const AktuellesIntro: GlobalConfig = {
slug: "aktuelles-intro", slug: "aktuelles-intro",
@@ -16,5 +17,6 @@ export const AktuellesIntro: GlobalConfig = {
{ name: "eyebrow", type: "text", label: "Kicker" }, { name: "eyebrow", type: "text", label: "Kicker" },
{ name: "title", type: "text", label: "Überschrift", required: true }, { name: "title", type: "text", label: "Überschrift", required: true },
{ name: "lead", type: "textarea", label: "Einleitungstext" }, { name: "lead", type: "textarea", label: "Einleitungstext" },
seoFields(),
], ],
}; };
+2
View File
@@ -1,5 +1,6 @@
import type { GlobalConfig } from "payload"; import type { GlobalConfig } from "payload";
import { isAdmin } from "@/access"; import { isAdmin } from "@/access";
import { seoFields } from "@/fields/seo";
export const AngeboteIntro: GlobalConfig = { export const AngeboteIntro: GlobalConfig = {
slug: "angebote-intro", slug: "angebote-intro",
@@ -16,5 +17,6 @@ export const AngeboteIntro: GlobalConfig = {
{ name: "eyebrow", type: "text", label: "Kicker" }, { name: "eyebrow", type: "text", label: "Kicker" },
{ name: "title", type: "text", label: "Überschrift", required: true }, { name: "title", type: "text", label: "Überschrift", required: true },
{ name: "lead", type: "textarea", label: "Einleitungstext" }, { name: "lead", type: "textarea", label: "Einleitungstext" },
seoFields(),
], ],
}; };
+2
View File
@@ -1,5 +1,6 @@
import type { GlobalConfig } from "payload"; import type { GlobalConfig } from "payload";
import { isAdmin } from "@/access"; import { isAdmin } from "@/access";
import { seoFields } from "@/fields/seo";
export const Booking: GlobalConfig = { export const Booking: GlobalConfig = {
slug: "booking", slug: "booking",
@@ -28,5 +29,6 @@ export const Booking: GlobalConfig = {
], ],
}, },
{ name: "formNote", type: "textarea", label: "Hinweis unter dem Formular" }, { name: "formNote", type: "textarea", label: "Hinweis unter dem Formular" },
seoFields(),
], ],
}; };
+9 -2
View File
@@ -15,7 +15,14 @@ export const BookingSettings: GlobalConfig = {
fields: [ fields: [
{ name: "locationName", type: "text", label: "Name des Ortes", defaultValue: "Anouma" }, { name: "locationName", type: "text", label: "Name des Ortes", defaultValue: "Anouma" },
{ name: "street", type: "text", label: "Straße und Hausnummer" }, { name: "street", type: "text", label: "Straße und Hausnummer" },
{ name: "postalCode", type: "text", label: "PLZ" }, {
{ name: "city", type: "text", label: "Ort" }, type: "row",
fields: [
{ name: "postalCode", type: "text", label: "PLZ", admin: { width: "34%" } },
{ name: "city", type: "text", label: "Ort", admin: { width: "33%" } },
{ name: "region", type: "text", label: "Region/Bundesland (optional)", admin: { width: "33%" } },
],
},
{ name: "country", type: "text", label: "Land (optional)", admin: { description: "Für strukturierte Daten, z. B. „Deutschland“." } },
], ],
}; };
+2
View File
@@ -1,5 +1,6 @@
import type { GlobalConfig } from "payload"; import type { GlobalConfig } from "payload";
import { isAdmin } from "@/access"; import { isAdmin } from "@/access";
import { seoFields } from "@/fields/seo";
export const Contact: GlobalConfig = { export const Contact: GlobalConfig = {
slug: "contact", slug: "contact",
@@ -24,5 +25,6 @@ export const Contact: GlobalConfig = {
{ name: "region", type: "text", label: "Region / Ort", admin: { width: "33%" } }, { name: "region", type: "text", label: "Region / Ort", admin: { width: "33%" } },
], ],
}, },
seoFields(),
], ],
}; };
+2
View File
@@ -1,5 +1,6 @@
import type { GlobalConfig } from "payload"; import type { GlobalConfig } from "payload";
import { isAdmin } from "@/access"; import { isAdmin } from "@/access";
import { seoFields } from "@/fields/seo";
export const Home: GlobalConfig = { export const Home: GlobalConfig = {
slug: "home", slug: "home",
@@ -66,5 +67,6 @@ export const Home: GlobalConfig = {
}, },
], ],
}, },
seoFields(),
], ],
}; };
+146
View File
@@ -0,0 +1,146 @@
import type { GlobalConfig } from "payload";
import { isAdmin } from "@/access";
const WEEKDAYS = [
{ label: "Montag", value: "Monday" },
{ label: "Dienstag", value: "Tuesday" },
{ label: "Mittwoch", value: "Wednesday" },
{ label: "Donnerstag", value: "Thursday" },
{ label: "Freitag", value: "Friday" },
{ label: "Samstag", value: "Saturday" },
{ label: "Sonntag", value: "Sunday" },
] as const;
/**
* Central SEO settings, edited once by Anna, applied automatically across
* the whole website (see lib/seo/*). Everything here is optional — nothing
* is invented if a field is left blank (see lib/seo/jsonld.ts, which only
* emits LocalBusiness structured data once real address data exists).
*/
export const SEOSettings: GlobalConfig = {
slug: "seo-settings",
label: "SEO-Einstellungen",
admin: {
description: "Globale SEO-Vorgaben für die gesamte Website — greift automatisch überall dort, wo eine Seite keine eigenen SEO-Angaben hat.",
group: "SEO",
},
access: {
read: () => true,
update: isAdmin,
},
fields: [
{
type: "tabs",
tabs: [
{
label: "Allgemein",
fields: [
{
name: "siteUrl",
type: "text",
label: "Website-URL",
admin: {
description: "Nur bei Bedarf setzen (z. B. https://anouma.org) — überschreibt die Server-Konfiguration. Leer lassen, um die technische Standardeinstellung zu verwenden.",
},
validate: (value: string | null | undefined) => {
if (!value) return true;
return /^https?:\/\/.+/.test(value) || "Bitte eine vollständige URL angeben (z. B. https://anouma.org).";
},
},
{
name: "defaultDescription",
type: "textarea",
label: "Standard-Beschreibung",
maxLength: 300,
admin: { description: "Wird verwendet, wenn eine Seite keine eigene Meta-Beschreibung hat." },
},
{
name: "defaultOgImage",
type: "upload",
relationTo: "media",
label: "Standard Social-Media-Bild",
admin: { description: "Wird verwendet, wenn eine Seite kein eigenes Open-Graph-Bild hat." },
},
{
name: "robotsIndexable",
type: "checkbox",
label: "Website für Suchmaschinen sichtbar",
defaultValue: true,
admin: {
description: "Deaktivieren, um die gesamte öffentliche Website vorübergehend für Suchmaschinen zu sperren (z. B. während des Aufbaus). Betrifft nicht Admin/Konto — die sind ohnehin immer gesperrt.",
},
},
],
},
{
label: "Lokale Informationen",
admin: { description: "Werden nur verwendet, wenn tatsächlich ausgefüllt — es werden keine Angaben erfunden." },
fields: [
{
name: "businessName",
type: "text",
label: "Geschäfts-/Markenname",
defaultValue: "Anouma",
},
{
name: "businessDescription",
type: "textarea",
label: "Kurzbeschreibung",
admin: { description: "Kurzer, sachlicher Beschreibungstext für strukturierte Daten (nicht der Website-Text)." },
},
{
type: "row",
fields: [
{ name: "latitude", type: "number", label: "Breitengrad (optional)", admin: { width: "50%" } },
{ name: "longitude", type: "number", label: "Längengrad (optional)", admin: { width: "50%" } },
],
},
{
name: "openingHours",
type: "array",
label: "Öffnungs-/Erreichbarkeitszeiten",
admin: { description: "Optional — nur für tatsächlich feste Zeiten (z. B. Sprechzeiten)." },
fields: [
{
type: "row",
fields: [
{
name: "days",
type: "select",
label: "Tage",
hasMany: true,
required: true,
options: [...WEEKDAYS],
admin: { width: "50%" },
},
{ name: "opens", type: "text", label: "Von (z. B. 09:00)", required: true, admin: { width: "25%" } },
{ name: "closes", type: "text", label: "Bis (z. B. 17:00)", required: true, admin: { width: "25%" } },
],
},
],
},
],
},
{
label: "Social Media",
fields: [
{
name: "socialLinks",
type: "array",
label: "Profile",
fields: [
{
type: "row",
fields: [
{ name: "platform", type: "text", label: "Plattform (z. B. Instagram)", required: true, admin: { width: "35%" } },
{ name: "url", type: "text", label: "Link", required: true, admin: { width: "65%" } },
],
},
],
},
],
},
],
},
],
};
+38
View File
@@ -0,0 +1,38 @@
/**
* Minimal in-memory fixed-window rate limiter for a single Node process
* (this app always runs as one process — see server.ts). Good enough to
* blunt abuse of the email-verification resend endpoint without adding an
* external store; resets on deploy/restart, which is an acceptable
* trade-off for this use case.
*/
const buckets = new Map<string, { count: number; resetAt: number }>();
// Opportunistic cleanup so long-running processes don't accumulate an
// unbounded number of stale keys (one per distinct IP/email ever seen).
const MAX_TRACKED_KEYS = 5000;
function sweepExpired(now: number) {
for (const [key, bucket] of buckets) {
if (bucket.resetAt < now) buckets.delete(key);
}
}
export function checkRateLimit(key: string, opts: { max: number; windowMs: number }): boolean {
const now = Date.now();
if (buckets.size > MAX_TRACKED_KEYS) sweepExpired(now);
const bucket = buckets.get(key);
if (!bucket || bucket.resetAt < now) {
buckets.set(key, { count: 1, resetAt: now + opts.windowMs });
return true;
}
if (bucket.count >= opts.max) return false;
bucket.count += 1;
return true;
}
export function getClientIp(request: Request): string {
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) return forwarded.split(",")[0]!.trim();
return "unknown";
}
+22
View File
@@ -0,0 +1,22 @@
import { createHash, randomBytes } from "node:crypto";
/** How long a freshly issued email-verification link stays valid. */
export const EMAIL_VERIFICATION_TTL_MS = 24 * 60 * 60 * 1000;
/** 256 bits of randomness, hex-encoded — not guessable, never derived from user data. */
export function generateVerificationToken(): string {
return randomBytes(32).toString("hex");
}
/**
* Only this hash is ever persisted (see collections/Customers.ts) — the
* plaintext token exists only in the URL sent by email and briefly in
* memory while that email is being sent.
*/
export function hashVerificationToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export function verificationExpiryISO(): string {
return new Date(Date.now() + EMAIL_VERIFICATION_TTL_MS).toISOString();
}
+38
View File
@@ -0,0 +1,38 @@
import { getCMS } from "@/lib/payload/getPayload";
import { hashVerificationToken } from "@/lib/auth/verification";
const TOKEN_SHAPE = /^[0-9a-f]{64}$/i;
/**
* Verifies a token from a /auth/verify-email/[token] link: looks it up by
* hash (the plaintext is never stored), checks it hasn't expired, then
* marks the account verified and immediately clears the hash + expiry so
* the same link can never be used a second time.
*/
export async function verifyEmailToken(token: string): Promise<boolean> {
if (!TOKEN_SHAPE.test(token)) return false;
const payload = await getCMS();
const hash = hashVerificationToken(token);
const { docs } = await payload.find({
collection: "customers",
where: { emailVerificationTokenHash: { equals: hash } },
limit: 1,
});
const customer = docs[0];
if (!customer || !customer.emailVerificationExpires) return false;
if (new Date(customer.emailVerificationExpires).getTime() < Date.now()) return false;
await payload.update({
collection: "customers",
id: customer.id,
data: {
emailVerified: true,
emailVerificationTokenHash: null,
emailVerificationExpires: null,
},
});
return true;
}
+109
View File
@@ -0,0 +1,109 @@
import type { Payload } from "payload";
import { EVENT_CATEGORIES } from "@/collections/Events";
import type { ICSEvent } from "./ics";
import type { BookingRequest, Customer, Event, Offer } from "@/payload-types";
// A rolling window rather than "everything ever": calendar apps refetch a
// subscribed feed periodically on their own, so this just needs to cover a
// sensible range each time, not paginate an entire history.
const WINDOW_PAST_MS = 30 * 24 * 60 * 60 * 1000;
const WINDOW_FUTURE_MS = 400 * 24 * 60 * 60 * 1000;
/**
* Builds the calendar entries for Anna's personal ICS feed: confirmed
* bookings (never pending ones) plus standalone public events — onsite and
* online alike. Deliberately never includes the meeting password anywhere
* in the output, only a link to the protected join page.
*
* Local API is used with the default overrideAccess: true because this is
* only ever reached after the caller already verified the feed's secret
* token (see app/(frontend)/calendar/[tokenFile]/route.ts) — equivalent
* trust to the existing admin calendar view.
*/
export async function getAdminFeedICSEvents(payload: Payload): Promise<ICSEvent[]> {
const from = new Date(Date.now() - WINDOW_PAST_MS);
const to = new Date(Date.now() + WINDOW_FUTURE_MS);
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3000";
const domainHost = new URL(serverUrl).host;
const [{ docs: bookings }, { docs: events }, settings] = await Promise.all([
payload.find({
collection: "booking-requests",
where: {
and: [
{ status: { equals: "confirmed" } },
{ date: { greater_than_equal: from.toISOString() } },
{ date: { less_than_equal: to.toISOString() } },
],
},
depth: 2,
limit: 1000,
}),
payload.find({
collection: "events",
where: {
and: [
{ date: { greater_than_equal: from.toISOString() } },
{ date: { less_than_equal: to.toISOString() } },
{ isPrivateBooking: { not_equals: true } },
],
},
depth: 0,
limit: 1000,
}),
payload.findGlobal({ slug: "booking-settings" }),
]);
const addressParts = [settings.locationName, settings.street, [settings.postalCode, settings.city].filter(Boolean).join(" ")].filter(Boolean);
const fullAddress = addressParts.join(", ");
const bookingEvents: ICSEvent[] = (bookings as BookingRequest[]).map((b) => {
const offer = typeof b.offer === "object" ? (b.offer as Offer) : null;
const customer = typeof b.user === "object" ? (b.user as Customer) : null;
const isOnline = b.appointmentType === "online";
const description = [
customer?.name ? `Mit: ${customer.name}` : null,
isOnline ? "Online-Termin" : "Vor-Ort-Termin",
b.userMessage ? `Nachricht: ${b.userMessage}` : null,
]
.filter(Boolean)
.join("\n");
let url: string | undefined;
if (isOnline && b.linkedEvent && typeof b.linkedEvent === "object") {
url = `${serverUrl}/termine/${(b.linkedEvent as Event).slug}/beitreten`;
} else if (!isOnline && fullAddress) {
url = `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(fullAddress)}`;
}
return {
uid: `booking-${b.id}@${domainHost}`,
start: new Date(b.startTime),
end: new Date(b.endTime),
summary: offer?.title ?? "Termin",
description: description || undefined,
// Vor Ort: vollständige Adresse. Online: literal "Online" — never the
// meeting password, which is intentionally not part of this feed.
location: isOnline ? "Online" : fullAddress || undefined,
url,
};
});
const eventEvents: ICSEvent[] = (events as Event[]).map((e) => {
const isOnline = Boolean(e.isOnline);
const start = new Date(e.startTime || e.date);
const end = new Date(e.endTime || e.startTime || e.date);
return {
uid: `event-${e.id}@${domainHost}`,
start,
end,
summary: e.title,
description: EVENT_CATEGORIES.find((c) => c.value === e.category)?.label,
location: isOnline ? "Online" : e.location || fullAddress || undefined,
url: e.slug ? `${serverUrl}/termine/${e.slug}` : undefined,
};
});
return [...bookingEvents, ...eventEvents];
}
+21
View File
@@ -0,0 +1,21 @@
import { createHash, randomBytes } from "node:crypto";
/**
* Long-lived, random, rotatable secret for a personal calendar-subscription
* URL (/calendar/<token>.ics) — treated like an API key, not a
* short-lived link: only its hash is stored, so a database leak alone
* can't be used to subscribe to anyone's calendar, and rotating it
* (overwriting the stored hash) immediately invalidates the old URL.
*/
export function generateCalendarFeedToken(): string {
return randomBytes(32).toString("hex");
}
export function hashCalendarFeedToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export function calendarFeedUrl(token: string): string {
const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3000";
return `${serverUrl}/calendar/${token}.ics`;
}
+72
View File
@@ -0,0 +1,72 @@
import { BERLIN_VTIMEZONE_LINES, toBerlinICSDateTime, toUTCICSStamp } from "./timezone";
export type ICSEvent = {
uid: string;
start: Date;
end: Date;
summary: string;
description?: string;
location?: string;
url?: string;
};
function escapeICSText(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,")
.replace(/\n/g, "\\n");
}
/** RFC 5545 line folding: lines must not exceed 75 octets; continuations start with a single space. */
function foldLine(line: string): string {
const encoder = new TextEncoder();
if (encoder.encode(line).length <= 75) return line;
const chunks: string[] = [];
let current = "";
for (const char of line) {
const candidate = current + char;
if (encoder.encode(candidate).length > 75) {
chunks.push(current);
current = char;
} else {
current = candidate;
}
}
if (current) chunks.push(current);
return chunks.join("\r\n ");
}
/** Builds a complete RFC 5545 VCALENDAR document, Europe/Berlin timezone-aware throughout. */
export function buildICSCalendar(args: { calendarName: string; events: ICSEvent[] }): string {
const lines: string[] = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//ANOUMA//Calendar Feed//DE",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
`X-WR-CALNAME:${escapeICSText(args.calendarName)}`,
"X-WR-TIMEZONE:Europe/Berlin",
...BERLIN_VTIMEZONE_LINES,
];
const stamp = toUTCICSStamp(new Date());
for (const ev of args.events) {
lines.push(
"BEGIN:VEVENT",
`UID:${ev.uid}`,
`DTSTAMP:${stamp}`,
`DTSTART;TZID=Europe/Berlin:${toBerlinICSDateTime(ev.start)}`,
`DTEND;TZID=Europe/Berlin:${toBerlinICSDateTime(ev.end)}`,
`SUMMARY:${escapeICSText(ev.summary)}`,
);
if (ev.description) lines.push(`DESCRIPTION:${escapeICSText(ev.description)}`);
if (ev.location) lines.push(`LOCATION:${escapeICSText(ev.location)}`);
if (ev.url) lines.push(`URL:${ev.url}`);
lines.push("STATUS:CONFIRMED", "END:VEVENT");
}
lines.push("END:VCALENDAR");
return lines.map(foldLine).join("\r\n") + "\r\n";
}
+57
View File
@@ -0,0 +1,57 @@
const BERLIN_TZ = "Europe/Berlin";
/**
* Converts a JS Date (a UTC instant) into its Europe/Berlin wall-clock
* representation for iCalendar's local `TZID` date-time form
* (`DTSTART;TZID=Europe/Berlin:YYYYMMDDTHHMMSS`). Uses the ICU timezone
* database via Intl rather than manual UTC+1/+2 arithmetic, so daylight
* saving transitions are always handled correctly — no naive UTC offset
* math that would drift by an hour half the year.
*/
export function toBerlinICSDateTime(date: Date): string {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: BERLIN_TZ,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
}).formatToParts(date);
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "00";
return `${get("year")}${get("month")}${get("day")}T${get("hour")}${get("minute")}${get("second")}`;
}
/** UTC timestamp form (`YYYYMMDDTHHMMSSZ`) — used only for DTSTAMP, which marks generation time, not a local event time. */
export function toUTCICSStamp(date: Date): string {
return date.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
}
/**
* Standard IANA Europe/Berlin VTIMEZONE block (the same one commonly
* embedded by calendar exporters): CEST from the last Sunday in March,
* CET from the last Sunday in October — the actual EU DST rule, not an
* approximation.
*/
export const BERLIN_VTIMEZONE_LINES = [
"BEGIN:VTIMEZONE",
"TZID:Europe/Berlin",
"X-LIC-LOCATION:Europe/Berlin",
"BEGIN:DAYLIGHT",
"TZOFFSETFROM:+0100",
"TZOFFSETTO:+0200",
"TZNAME:CEST",
"DTSTART:19700329T020000",
"RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU",
"END:DAYLIGHT",
"BEGIN:STANDARD",
"TZOFFSETFROM:+0200",
"TZOFFSETTO:+0100",
"TZNAME:CET",
"DTSTART:19701025T030000",
"RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU",
"END:STANDARD",
"END:VTIMEZONE",
];
+14
View File
@@ -0,0 +1,14 @@
import { paragraph, renderButton, renderEmailShell } from "./layout";
export function verificationEmail(args: { name: string; verifyUrl: string; expiresHours: number }): { subject: string; html: string } {
const html = renderEmailShell({
bodyHtml: [
paragraph(`Hallo ${args.name},`),
paragraph("dein Konto wurde erstellt.", { italic: true }),
paragraph("Bitte bestätige deine E-Mail-Adresse, um dein Konto zu aktivieren."),
renderButton("E-Mail-Adresse bestätigen", args.verifyUrl),
paragraph(`Dieser Link ist ${args.expiresHours} Stunden gültig. Falls du kein Konto erstellt hast, kannst du diese E-Mail ignorieren.`, { small: true }),
].join("\n"),
});
return { subject: "Bitte bestätige deine E-Mail-Adresse", html };
}
+1 -1
View File
@@ -98,7 +98,7 @@ export function bookingRejectedEmail(args: BaseArgs): { subject: string; html: s
{ label: "Datum", value: args.dateLabel }, { label: "Datum", value: args.dateLabel },
{ label: "Uhrzeit", value: args.timeLabel }, { label: "Uhrzeit", value: args.timeLabel },
]), ]),
paragraph("Melde dich gerne für einen neuen Termin — schau einfach wieder in deinem ANOUMA-Konto vorbei.", { small: true }), paragraph("Melde dich gerne für einen neuen Termin — schau einfach wieder in deinem Konto vorbei.", { small: true }),
].join("\n"), ].join("\n"),
}); });
return { subject: "Deine Terminanfrage bei ANOUMA", html }; return { subject: "Deine Terminanfrage bei ANOUMA", html };
+11 -1
View File
@@ -1,6 +1,6 @@
import { cache } from "react"; import { cache } from "react";
import { getCMS } from "@/lib/payload/getPayload"; import { getCMS } from "@/lib/payload/getPayload";
import type { About, AktuellesIntro, AngeboteIntro, Booking, Contact, Home } from "@/payload-types"; import type { About, AktuellesIntro, AngeboteIntro, Booking, BookingSetting, Contact, Home, SeoSetting } from "@/payload-types";
export const getHomeGlobal = cache(async (): Promise<Home> => { export const getHomeGlobal = cache(async (): Promise<Home> => {
const payload = await getCMS(); const payload = await getCMS();
@@ -31,3 +31,13 @@ export const getBookingGlobal = cache(async (): Promise<Booking> => {
const payload = await getCMS(); const payload = await getCMS();
return payload.findGlobal({ slug: "booking" }); return payload.findGlobal({ slug: "booking" });
}); });
export const getBookingSettingsGlobal = cache(async (): Promise<BookingSetting> => {
const payload = await getCMS();
return payload.findGlobal({ slug: "booking-settings" });
});
export const getSEOSettingsGlobal = cache(async (): Promise<SeoSetting> => {
const payload = await getCMS();
return payload.findGlobal({ slug: "seo-settings" });
});
+36
View File
@@ -0,0 +1,36 @@
import { getSEOSettingsGlobal } from "@/lib/payload/globals";
import { siteConfig } from "@/lib/site";
function stripTrailingSlash(url: string): string {
return url.replace(/\/+$/, "");
}
/**
* The canonical base URL for the whole site. Prefers the CMS override
* (SEO-Einstellungen → Website-URL) when it's a valid absolute URL, then
* NEXT_PUBLIC_SERVER_URL (the single source of truth used everywhere else
* in this app — emails, meeting links, Payload's own serverURL), then the
* hardcoded siteConfig fallback. Never throws — SEO metadata must never
* break a page render.
*/
export async function getSiteUrl(): Promise<string> {
try {
const settings = await getSEOSettingsGlobal();
if (settings.siteUrl && /^https?:\/\/.+/.test(settings.siteUrl)) {
return stripTrailingSlash(settings.siteUrl);
}
} catch {
// CMS/DB unreachable — fall through to the env-based default.
}
return stripTrailingSlash(process.env.NEXT_PUBLIC_SERVER_URL || siteConfig.domain);
}
/** Whether the site should be indexable at all (global kill switch in the CMS). */
export async function isSiteIndexable(): Promise<boolean> {
try {
const settings = await getSEOSettingsGlobal();
return settings.robotsIndexable !== false;
} catch {
return true;
}
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Formats a JS Date as an ISO 8601 string carrying its actual Europe/Berlin
* UTC offset (e.g. "2026-06-15T09:00:00+02:00" in summer,
* "...+01:00" in winter) — what schema.org's Event startDate/endDate
* expect. Uses Intl's timezone database rather than hardcoded +1/+2 math,
* so it's correct across the DST transition automatically.
*/
export function toBerlinOffsetISOString(date: Date): string {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
timeZoneName: "shortOffset",
}).formatToParts(date);
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "00";
const tzName = parts.find((p) => p.type === "timeZoneName")?.value ?? "GMT+0";
const match = /GMT([+-])(\d+)(?::(\d+))?/.exec(tzName);
const sign = match?.[1] ?? "+";
const offsetHours = (match?.[2] ?? "0").padStart(2, "0");
const offsetMinutes = (match?.[3] ?? "0").padStart(2, "0");
return `${get("year")}-${get("month")}-${get("day")}T${get("hour")}:${get("minute")}:${get("second")}${sign}${offsetHours}:${offsetMinutes}`;
}
+231
View File
@@ -0,0 +1,231 @@
import { siteConfig } from "@/lib/site";
import { toBerlinOffsetISOString } from "./dates";
import type { BookingSetting, Contact, Event, Offer, Post, SeoSetting } from "@/payload-types";
// Server-only builders — every function here returns `null` instead of
// guessing when the real data needed for a valid/honest result isn't
// present. Nothing here ever invents a review, price, address or phone
// number (see section 27/35 of the SEO brief).
/** JSON.stringify with `<` escaped so this can never be broken out of by any (currently non-existent) embedded "</script>" sequence. */
function toSafeJsonLdString(data: unknown): string {
return JSON.stringify(data).replace(/</g, "\\u003c");
}
type JsonLdValue = Record<string, unknown>;
export function organizationName(settings: SeoSetting | null | undefined): string {
return settings?.businessName?.trim() || siteConfig.name;
}
function hasFullAddress(booking: BookingSetting | null | undefined): booking is BookingSetting & { street: string; city: string; postalCode: string } {
return Boolean(booking?.street?.trim() && booking?.city?.trim() && booking?.postalCode?.trim());
}
function postalAddress(booking: BookingSetting) {
return {
"@type": "PostalAddress",
streetAddress: booking.street!.trim(),
postalCode: booking.postalCode!.trim(),
addressLocality: booking.city!.trim(),
...(booking.region?.trim() ? { addressRegion: booking.region.trim() } : {}),
...(booking.country?.trim() ? { addressCountry: booking.country.trim() } : {}),
};
}
/**
* Organization (always safe — just a name and a URL) upgraded to
* ProfessionalService/LocalBusiness once a real, complete street address
* has actually been entered in the CMS. Phone/opening hours/geo are added
* only when present; nothing is fabricated to "complete" the schema.
*/
export function organizationOrLocalBusinessJsonLd(args: {
settings: SeoSetting | null | undefined;
booking: BookingSetting | null | undefined;
contact: Contact | null | undefined;
siteUrl: string;
}): JsonLdValue {
const name = organizationName(args.settings);
const base: JsonLdValue = {
"@context": "https://schema.org",
name,
url: args.siteUrl,
};
if (args.settings?.businessDescription?.trim()) base.description = args.settings.businessDescription.trim();
if (args.contact?.email) base.email = args.contact.email;
if (!hasFullAddress(args.booking)) {
return { ...base, "@type": "Organization" };
}
const result: JsonLdValue = {
...base,
"@type": "ProfessionalService",
address: postalAddress(args.booking),
};
if (args.contact?.phone?.trim()) result.telephone = args.contact.phone.trim();
if (typeof args.settings?.latitude === "number" && typeof args.settings?.longitude === "number") {
result.geo = { "@type": "GeoCoordinates", latitude: args.settings.latitude, longitude: args.settings.longitude };
}
if (args.settings?.openingHours?.length) {
result.openingHoursSpecification = args.settings.openingHours.map((entry) => ({
"@type": "OpeningHoursSpecification",
dayOfWeek: entry.days.map((day) => `https://schema.org/${day}`),
opens: entry.opens,
closes: entry.closes,
}));
}
if (args.settings?.socialLinks?.length) {
result.sameAs = args.settings.socialLinks.map((link) => link.url).filter(Boolean);
}
return result;
}
export function websiteJsonLd(siteUrl: string): JsonLdValue {
return {
"@context": "https://schema.org",
"@type": "WebSite",
name: siteConfig.name,
url: siteUrl,
};
}
export function webPageJsonLd(args: { name: string; description: string; url: string }): JsonLdValue {
return {
"@context": "https://schema.org",
"@type": "WebPage",
name: args.name,
description: args.description,
url: args.url,
};
}
export function breadcrumbJsonLd(items: { name: string; url?: string }[]): JsonLdValue | null {
if (items.length === 0) return null;
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
// The last item's "item" URL is deliberately omitted when absent —
// that's the current page, and per Google's guidelines a BreadcrumbList
// entry doesn't need a URL for the page the visitor is already on.
itemListElement: items.map((item, index) => ({
"@type": "ListItem",
position: index + 1,
name: item.name,
...(item.url ? { item: item.url } : {}),
})),
};
}
export function serviceJsonLd(args: {
offer: Offer;
description: string;
imageUrl?: string;
siteUrl: string;
providerName: string;
areaServed?: string;
}): JsonLdValue {
const result: JsonLdValue = {
"@context": "https://schema.org",
"@type": "Service",
name: args.offer.title,
description: args.description,
url: `${args.siteUrl}/angebote/${args.offer.slug}`,
provider: { "@type": "Organization", name: args.providerName, url: args.siteUrl },
};
if (args.imageUrl) result.image = args.imageUrl;
if (args.areaServed) result.areaServed = args.areaServed;
// Deliberately no "offers"/price — the CMS "price" field is free text
// (e.g. "auf Anfrage") and can't be turned into a valid structured price
// without risking incorrect data.
return result;
}
export function eventJsonLd(args: {
event: Event;
description: string;
imageUrl?: string;
joinUrl?: string;
siteUrl: string;
providerName: string;
booking: BookingSetting | null | undefined;
}): JsonLdValue | null {
const { event } = args;
// Private single-session bookings must never surface as a public Event
// (see section 18) — defense in depth alongside the page-level checks
// that already keep them out of public listings entirely.
if (event.isPrivateBooking) return null;
if (!event.date) return null;
const start = new Date(event.startTime || event.date);
const isOnline = Boolean(event.isOnline);
const result: JsonLdValue = {
"@context": "https://schema.org",
"@type": "Event",
name: event.title,
description: args.description,
startDate: toBerlinOffsetISOString(start),
eventStatus: "https://schema.org/EventScheduled",
eventAttendanceMode: isOnline ? "https://schema.org/OnlineEventAttendanceMode" : "https://schema.org/OfflineEventAttendanceMode",
url: `${args.siteUrl}/termine/${event.slug}`,
organizer: { "@type": "Organization", name: args.providerName, url: args.siteUrl },
};
if (event.endTime) result.endDate = toBerlinOffsetISOString(new Date(event.endTime));
if (args.imageUrl) result.image = args.imageUrl;
if (isOnline) {
result.location = { "@type": "VirtualLocation", url: args.joinUrl ?? result.url };
} else if (hasFullAddress(args.booking)) {
result.location = {
"@type": "Place",
name: event.location || args.booking.locationName || args.providerName,
address: postalAddress(args.booking),
};
} else if (event.location) {
// No structured address on file, but the event itself names a place —
// still valid schema.org (address is optional on Place).
result.location = { "@type": "Place", name: event.location };
}
return result;
}
export function articleJsonLd(args: {
post: Post;
description: string;
imageUrl?: string;
siteUrl: string;
providerName: string;
}): JsonLdValue {
const url = `${args.siteUrl}/aktuelles/${args.post.slug}`;
return {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: args.post.title,
description: args.description,
url,
mainEntityOfPage: url,
datePublished: new Date(args.post.publishDate ?? args.post.createdAt).toISOString(),
dateModified: new Date(args.post.updatedAt).toISOString(),
author: { "@type": "Person", name: args.post.author?.trim() || args.providerName },
publisher: { "@type": "Organization", name: args.providerName, url: args.siteUrl },
...(args.imageUrl ? { image: args.imageUrl } : {}),
};
}
/** Renders one or more JSON-LD objects as server-side <script> tags. Filters out any null entries so callers can pass conditional builders directly. */
export function JsonLd({ data }: { data: JsonLdValue | JsonLdValue[] | null | (JsonLdValue | null)[] }) {
const items = (Array.isArray(data) ? data : [data]).filter((item): item is JsonLdValue => Boolean(item));
if (items.length === 0) return null;
return (
<>
{items.map((item, index) => (
<script key={index} type="application/ld+json" dangerouslySetInnerHTML={{ __html: toSafeJsonLdString(item) }} />
))}
</>
);
}
+52
View File
@@ -0,0 +1,52 @@
import type { Metadata } from "next";
import { siteConfig } from "@/lib/site";
import { getSiteUrl, isSiteIndexable } from "./config";
/**
* Single shared Metadata builder used by every public page's
* generateMetadata — guarantees every page gets its own canonical, robots,
* Open Graph and Twitter/X card data instead of copy-pasted boilerplate
* (and the accidental duplicate-description bugs that come with that).
*
* `path` is the page's path relative to the site root (e.g.
* "/angebote/doula-begleitung", or "" for the homepage).
*/
export async function buildMetadata(args: {
title: string;
description: string;
path: string;
ogImageUrl?: string;
keywords?: string;
noindex?: boolean;
type?: "website" | "article";
}): Promise<Metadata> {
const [siteUrl, sitewideIndexable] = await Promise.all([getSiteUrl(), isSiteIndexable()]);
const canonical = `${siteUrl}${args.path}`;
const indexable = !args.noindex && sitewideIndexable;
const images = args.ogImageUrl ? [{ url: args.ogImageUrl }] : undefined;
return {
title: args.title,
description: args.description,
keywords: args.keywords,
alternates: { canonical },
robots: indexable
? { index: true, follow: true }
: { index: false, follow: false },
openGraph: {
title: args.title,
description: args.description,
url: canonical,
siteName: siteConfig.name,
type: args.type ?? "website",
images,
locale: siteConfig.locale,
},
twitter: {
card: images ? "summary_large_image" : "summary",
title: args.title,
description: args.description,
images: images?.map((image) => image.url),
},
};
}
+23
View File
@@ -0,0 +1,23 @@
import { cache } from "react";
import { getCMS } from "@/lib/payload/getPayload";
/**
* Looks up a CMS-configured redirect for a changed slug (see
* collections/Redirects.ts). Only called from the three dynamic [slug]
* detail pages right before they'd otherwise 404 — not a catch-all
* middleware, so this never adds a database lookup to the other ~30 routes
* that never need it.
*/
export const resolveRedirect = cache(async (fromPath: string): Promise<{ to: string; permanent: boolean } | null> => {
const payload = await getCMS();
const { docs } = await payload.find({
collection: "redirects",
where: { and: [{ fromPath: { equals: fromPath } }, { enabled: { equals: true } }] },
limit: 1,
depth: 0,
overrideAccess: false,
});
const redirect = docs[0];
if (!redirect) return null;
return { to: redirect.toPath, permanent: redirect.type !== "temporary" };
});
+50
View File
@@ -0,0 +1,50 @@
import type { Media } from "@/payload-types";
import { mediaUrl } from "@/lib/payload/media";
type MediaRef = Media | number | null | undefined;
type SeoGroup =
| {
title?: string | null;
description?: string | null;
keywords?: string | null;
ogImage?: MediaRef;
}
| null
| undefined;
export type ResolvedSeo = {
title: string;
description: string;
keywords?: string;
ogImageUrl?: string;
};
/**
* Priority chain used everywhere on the site (see section 32 of the SEO
* brief): manual CMS field → content-derived fallback → nothing invented.
* `fallbackDescription` may be a thunk so callers only pay for building an
* excerpt (walking richText) when the manual field is actually empty.
*/
export function resolveSeo(args: {
seo?: SeoGroup;
fallbackTitle: string;
fallbackDescription: string | (() => string);
fallbackImage?: MediaRef;
defaultOgImage?: MediaRef;
}): ResolvedSeo {
const title = args.seo?.title?.trim() || args.fallbackTitle;
const description =
args.seo?.description?.trim() ||
(typeof args.fallbackDescription === "function" ? args.fallbackDescription() : args.fallbackDescription);
const ogImageUrl =
mediaUrl(args.seo?.ogImage, "hero") ?? mediaUrl(args.fallbackImage, "hero") ?? mediaUrl(args.defaultOgImage, "hero");
return {
title,
description,
keywords: args.seo?.keywords?.trim() || undefined,
ogImageUrl,
};
}
+32
View File
@@ -0,0 +1,32 @@
type LexicalNode = { text?: string; children?: LexicalNode[] };
function collectText(node: LexicalNode, out: string[]): void {
if (typeof node.text === "string" && node.text) out.push(node.text);
if (Array.isArray(node.children)) {
for (const child of node.children) collectText(child, out);
}
}
/** Walks a Lexical richText field's JSON (any node shape) and extracts plain text, regardless of formatting/links/lists used. */
export function plainTextFromRichText(data: unknown): string {
if (!data || typeof data !== "object") return "";
const root = (data as { root?: LexicalNode }).root;
if (!root) return "";
const parts: string[] = [];
collectText(root, parts);
return parts.join(" ").replace(/\s+/g, " ").trim();
}
/** Truncates at a word boundary rather than mid-word, for a natural-reading meta description fallback. */
export function excerpt(text: string, maxLength = 160): string {
const clean = text.replace(/\s+/g, " ").trim();
if (clean.length <= maxLength) return clean;
const truncated = clean.slice(0, maxLength);
const lastSpace = truncated.lastIndexOf(" ");
const safe = lastSpace > maxLength * 0.6 ? truncated.slice(0, lastSpace) : truncated;
return `${safe.trim()}`;
}
export function excerptFromRichText(data: unknown, maxLength = 160): string {
return excerpt(plainTextFromRichText(data), maxLength);
}
+352
View File
@@ -78,6 +78,7 @@ export interface Config {
availability: Availability; availability: Availability;
'availability-overrides': AvailabilityOverride; 'availability-overrides': AvailabilityOverride;
'booking-requests': BookingRequest; 'booking-requests': BookingRequest;
redirects: Redirect;
'payload-kv': PayloadKv; 'payload-kv': PayloadKv;
'payload-locked-documents': PayloadLockedDocument; 'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference; 'payload-preferences': PayloadPreference;
@@ -95,6 +96,7 @@ export interface Config {
availability: AvailabilitySelect<false> | AvailabilitySelect<true>; availability: AvailabilitySelect<false> | AvailabilitySelect<true>;
'availability-overrides': AvailabilityOverridesSelect<false> | AvailabilityOverridesSelect<true>; 'availability-overrides': AvailabilityOverridesSelect<false> | AvailabilityOverridesSelect<true>;
'booking-requests': BookingRequestsSelect<false> | BookingRequestsSelect<true>; 'booking-requests': BookingRequestsSelect<false> | BookingRequestsSelect<true>;
redirects: RedirectsSelect<false> | RedirectsSelect<true>;
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>; 'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>; 'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>; 'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
@@ -113,6 +115,7 @@ export interface Config {
booking: Booking; booking: Booking;
'meeting-settings': MeetingSetting; 'meeting-settings': MeetingSetting;
'booking-settings': BookingSetting; 'booking-settings': BookingSetting;
'seo-settings': SeoSetting;
}; };
globalsSelect: { globalsSelect: {
home: HomeSelect<false> | HomeSelect<true>; home: HomeSelect<false> | HomeSelect<true>;
@@ -123,6 +126,7 @@ export interface Config {
booking: BookingSelect<false> | BookingSelect<true>; booking: BookingSelect<false> | BookingSelect<true>;
'meeting-settings': MeetingSettingsSelect<false> | MeetingSettingsSelect<true>; 'meeting-settings': MeetingSettingsSelect<false> | MeetingSettingsSelect<true>;
'booking-settings': BookingSettingsSelect<false> | BookingSettingsSelect<true>; 'booking-settings': BookingSettingsSelect<false> | BookingSettingsSelect<true>;
'seo-settings': SeoSettingsSelect<false> | SeoSettingsSelect<true>;
}; };
locale: null; locale: null;
widgets: { widgets: {
@@ -180,6 +184,7 @@ export interface User {
id: number; id: number;
name: string; name: string;
role: 'admin'; role: 'admin';
calendarFeedTokenHash?: string | null;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
email: string; email: string;
@@ -212,6 +217,12 @@ export interface Customer {
* Optional — für eine spätere Nutzung vorbereitet, aktuell nicht verpflichtend. * Optional — für eine spätere Nutzung vorbereitet, aktuell nicht verpflichtend.
*/ */
phone?: string | null; phone?: string | null;
/**
* Wird automatisch gesetzt, sobald der Bestätigungslink aus der E-Mail angeklickt wird. Kann hier bei Bedarf manuell gesetzt werden.
*/
emailVerified?: boolean | null;
emailVerificationTokenHash?: string | null;
emailVerificationExpires?: string | null;
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
email: string; email: string;
@@ -352,6 +363,21 @@ export interface Event {
*/ */
isPrivateBooking?: boolean | null; isPrivateBooking?: boolean | null;
bookingRequest?: (number | null) | BookingRequest; bookingRequest?: (number | null) | BookingRequest;
seo?: {
/**
* Falls leer: automatisch aus dem Seitentitel erzeugt.
*/
title?: string | null;
/**
* Falls leer: automatisch aus dem Inhalt erzeugt (erste ~160 Zeichen).
*/
description?: string | null;
keywords?: string | null;
/**
* Falls leer: Hauptbild dieser Seite bzw. das Standard-Social-Bild aus den SEO-Einstellungen.
*/
ogImage?: (number | null) | Media;
};
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
_status?: ('draft' | 'published') | null; _status?: ('draft' | 'published') | null;
@@ -439,6 +465,21 @@ export interface Offer {
*/ */
price?: string | null; price?: string | null;
durationMinutes?: number | null; durationMinutes?: number | null;
seo?: {
/**
* Falls leer: automatisch aus dem Seitentitel erzeugt.
*/
title?: string | null;
/**
* Falls leer: automatisch aus dem Inhalt erzeugt (erste ~160 Zeichen).
*/
description?: string | null;
keywords?: string | null;
/**
* Falls leer: Hauptbild dieser Seite bzw. das Standard-Social-Bild aus den SEO-Einstellungen.
*/
ogImage?: (number | null) | Media;
};
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
_status?: ('draft' | 'published') | null; _status?: ('draft' | 'published') | null;
@@ -477,6 +518,22 @@ export interface Post {
}; };
coverImage?: (number | null) | Media; coverImage?: (number | null) | Media;
publishDate?: string | null; publishDate?: string | null;
author?: string | null;
seo?: {
/**
* Falls leer: automatisch aus dem Seitentitel erzeugt.
*/
title?: string | null;
/**
* Falls leer: automatisch aus dem Inhalt erzeugt (erste ~160 Zeichen).
*/
description?: string | null;
keywords?: string | null;
/**
* Falls leer: Hauptbild dieser Seite bzw. das Standard-Social-Bild aus den SEO-Einstellungen.
*/
ogImage?: (number | null) | Media;
};
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
_status?: ('draft' | 'published') | null; _status?: ('draft' | 'published') | null;
@@ -533,6 +590,27 @@ export interface AvailabilityOverride {
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
} }
/**
* Leitet eine alte URL (z. B. nach einer Slug-Änderung bei Angeboten, Terminen oder Aktuelles) dauerhaft auf eine neue weiter.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "redirects".
*/
export interface Redirect {
id: number;
/**
* Nur der Pfad, z. B. /angebote/doula (ohne Domain).
*/
fromPath: string;
/**
* Ziel-Pfad, z. B. /angebote/doula-begleitung.
*/
toPath: string;
type: 'permanent' | 'temporary';
enabled?: boolean | null;
updatedAt: string;
createdAt: string;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv". * via the `definition` "payload-kv".
@@ -596,6 +674,10 @@ export interface PayloadLockedDocument {
| ({ | ({
relationTo: 'booking-requests'; relationTo: 'booking-requests';
value: number | BookingRequest; value: number | BookingRequest;
} | null)
| ({
relationTo: 'redirects';
value: number | Redirect;
} | null); } | null);
globalSlug?: string | null; globalSlug?: string | null;
user: user:
@@ -656,6 +738,7 @@ export interface PayloadMigration {
export interface UsersSelect<T extends boolean = true> { export interface UsersSelect<T extends boolean = true> {
name?: T; name?: T;
role?: T; role?: T;
calendarFeedTokenHash?: T;
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
email?: T; email?: T;
@@ -680,6 +763,9 @@ export interface UsersSelect<T extends boolean = true> {
export interface CustomersSelect<T extends boolean = true> { export interface CustomersSelect<T extends boolean = true> {
name?: T; name?: T;
phone?: T; phone?: T;
emailVerified?: T;
emailVerificationTokenHash?: T;
emailVerificationExpires?: T;
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
email?: T; email?: T;
@@ -777,6 +863,14 @@ export interface EventsSelect<T extends boolean = true> {
hostReminder30Sent?: T; hostReminder30Sent?: T;
isPrivateBooking?: T; isPrivateBooking?: T;
bookingRequest?: T; bookingRequest?: T;
seo?:
| T
| {
title?: T;
description?: T;
keywords?: T;
ogImage?: T;
};
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
_status?: T; _status?: T;
@@ -792,6 +886,15 @@ export interface PostsSelect<T extends boolean = true> {
content?: T; content?: T;
coverImage?: T; coverImage?: T;
publishDate?: T; publishDate?: T;
author?: T;
seo?:
| T
| {
title?: T;
description?: T;
keywords?: T;
ogImage?: T;
};
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
_status?: T; _status?: T;
@@ -812,6 +915,14 @@ export interface OffersSelect<T extends boolean = true> {
bookable?: T; bookable?: T;
price?: T; price?: T;
durationMinutes?: T; durationMinutes?: T;
seo?:
| T
| {
title?: T;
description?: T;
keywords?: T;
ogImage?: T;
};
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
_status?: T; _status?: T;
@@ -881,6 +992,18 @@ export interface BookingRequestsSelect<T extends boolean = true> {
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
} }
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "redirects_select".
*/
export interface RedirectsSelect<T extends boolean = true> {
fromPath?: T;
toPath?: T;
type?: T;
enabled?: T;
updatedAt?: T;
createdAt?: T;
}
/** /**
* This interface was referenced by `Config`'s JSON-Schema * This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv_select". * via the `definition` "payload-kv_select".
@@ -963,6 +1086,21 @@ export interface Home {
| null; | null;
ctaTitle?: string | null; ctaTitle?: string | null;
ctaLead?: string | null; ctaLead?: string | null;
seo?: {
/**
* Falls leer: automatisch aus dem Seitentitel erzeugt.
*/
title?: string | null;
/**
* Falls leer: automatisch aus dem Inhalt erzeugt (erste ~160 Zeichen).
*/
description?: string | null;
keywords?: string | null;
/**
* Falls leer: Hauptbild dieser Seite bzw. das Standard-Social-Bild aus den SEO-Einstellungen.
*/
ogImage?: (number | null) | Media;
};
updatedAt?: string | null; updatedAt?: string | null;
createdAt?: string | null; createdAt?: string | null;
} }
@@ -995,6 +1133,21 @@ export interface About {
closingLead?: string | null; closingLead?: string | null;
closingHighlight?: string | null; closingHighlight?: string | null;
closingParagraph?: string | null; closingParagraph?: string | null;
seo?: {
/**
* Falls leer: automatisch aus dem Seitentitel erzeugt.
*/
title?: string | null;
/**
* Falls leer: automatisch aus dem Inhalt erzeugt (erste ~160 Zeichen).
*/
description?: string | null;
keywords?: string | null;
/**
* Falls leer: Hauptbild dieser Seite bzw. das Standard-Social-Bild aus den SEO-Einstellungen.
*/
ogImage?: (number | null) | Media;
};
updatedAt?: string | null; updatedAt?: string | null;
createdAt?: string | null; createdAt?: string | null;
} }
@@ -1009,6 +1162,21 @@ export interface AngeboteIntro {
eyebrow?: string | null; eyebrow?: string | null;
title: string; title: string;
lead?: string | null; lead?: string | null;
seo?: {
/**
* Falls leer: automatisch aus dem Seitentitel erzeugt.
*/
title?: string | null;
/**
* Falls leer: automatisch aus dem Inhalt erzeugt (erste ~160 Zeichen).
*/
description?: string | null;
keywords?: string | null;
/**
* Falls leer: Hauptbild dieser Seite bzw. das Standard-Social-Bild aus den SEO-Einstellungen.
*/
ogImage?: (number | null) | Media;
};
updatedAt?: string | null; updatedAt?: string | null;
createdAt?: string | null; createdAt?: string | null;
} }
@@ -1023,6 +1191,21 @@ export interface AktuellesIntro {
eyebrow?: string | null; eyebrow?: string | null;
title: string; title: string;
lead?: string | null; lead?: string | null;
seo?: {
/**
* Falls leer: automatisch aus dem Seitentitel erzeugt.
*/
title?: string | null;
/**
* Falls leer: automatisch aus dem Inhalt erzeugt (erste ~160 Zeichen).
*/
description?: string | null;
keywords?: string | null;
/**
* Falls leer: Hauptbild dieser Seite bzw. das Standard-Social-Bild aus den SEO-Einstellungen.
*/
ogImage?: (number | null) | Media;
};
updatedAt?: string | null; updatedAt?: string | null;
createdAt?: string | null; createdAt?: string | null;
} }
@@ -1040,6 +1223,21 @@ export interface Contact {
email: string; email: string;
phone?: string | null; phone?: string | null;
region?: string | null; region?: string | null;
seo?: {
/**
* Falls leer: automatisch aus dem Seitentitel erzeugt.
*/
title?: string | null;
/**
* Falls leer: automatisch aus dem Inhalt erzeugt (erste ~160 Zeichen).
*/
description?: string | null;
keywords?: string | null;
/**
* Falls leer: Hauptbild dieser Seite bzw. das Standard-Social-Bild aus den SEO-Einstellungen.
*/
ogImage?: (number | null) | Media;
};
updatedAt?: string | null; updatedAt?: string | null;
createdAt?: string | null; createdAt?: string | null;
} }
@@ -1062,6 +1260,21 @@ export interface Booking {
}[] }[]
| null; | null;
formNote?: string | null; formNote?: string | null;
seo?: {
/**
* Falls leer: automatisch aus dem Seitentitel erzeugt.
*/
title?: string | null;
/**
* Falls leer: automatisch aus dem Inhalt erzeugt (erste ~160 Zeichen).
*/
description?: string | null;
keywords?: string | null;
/**
* Falls leer: Hauptbild dieser Seite bzw. das Standard-Social-Bild aus den SEO-Einstellungen.
*/
ogImage?: (number | null) | Media;
};
updatedAt?: string | null; updatedAt?: string | null;
createdAt?: string | null; createdAt?: string | null;
} }
@@ -1091,6 +1304,63 @@ export interface BookingSetting {
street?: string | null; street?: string | null;
postalCode?: string | null; postalCode?: string | null;
city?: string | null; city?: string | null;
region?: string | null;
/**
* Für strukturierte Daten, z. B. „Deutschland“.
*/
country?: string | null;
updatedAt?: string | null;
createdAt?: string | null;
}
/**
* Globale SEO-Vorgaben für die gesamte Website — greift automatisch überall dort, wo eine Seite keine eigenen SEO-Angaben hat.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "seo-settings".
*/
export interface SeoSetting {
id: number;
/**
* Nur bei Bedarf setzen (z. B. https://anouma.org) — überschreibt die Server-Konfiguration. Leer lassen, um die technische Standardeinstellung zu verwenden.
*/
siteUrl?: string | null;
/**
* Wird verwendet, wenn eine Seite keine eigene Meta-Beschreibung hat.
*/
defaultDescription?: string | null;
/**
* Wird verwendet, wenn eine Seite kein eigenes Open-Graph-Bild hat.
*/
defaultOgImage?: (number | null) | Media;
/**
* Deaktivieren, um die gesamte öffentliche Website vorübergehend für Suchmaschinen zu sperren (z. B. während des Aufbaus). Betrifft nicht Admin/Konto — die sind ohnehin immer gesperrt.
*/
robotsIndexable?: boolean | null;
businessName?: string | null;
/**
* Kurzer, sachlicher Beschreibungstext für strukturierte Daten (nicht der Website-Text).
*/
businessDescription?: string | null;
latitude?: number | null;
longitude?: number | null;
/**
* Optional — nur für tatsächlich feste Zeiten (z. B. Sprechzeiten).
*/
openingHours?:
| {
days: ('Monday' | 'Tuesday' | 'Wednesday' | 'Thursday' | 'Friday' | 'Saturday' | 'Sunday')[];
opens: string;
closes: string;
id?: string | null;
}[]
| null;
socialLinks?:
| {
platform: string;
url: string;
id?: string | null;
}[]
| null;
updatedAt?: string | null; updatedAt?: string | null;
createdAt?: string | null; createdAt?: string | null;
} }
@@ -1119,6 +1389,14 @@ export interface HomeSelect<T extends boolean = true> {
}; };
ctaTitle?: T; ctaTitle?: T;
ctaLead?: T; ctaLead?: T;
seo?:
| T
| {
title?: T;
description?: T;
keywords?: T;
ogImage?: T;
};
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
globalType?: T; globalType?: T;
@@ -1135,6 +1413,14 @@ export interface AboutSelect<T extends boolean = true> {
closingLead?: T; closingLead?: T;
closingHighlight?: T; closingHighlight?: T;
closingParagraph?: T; closingParagraph?: T;
seo?:
| T
| {
title?: T;
description?: T;
keywords?: T;
ogImage?: T;
};
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
globalType?: T; globalType?: T;
@@ -1147,6 +1433,14 @@ export interface AngeboteIntroSelect<T extends boolean = true> {
eyebrow?: T; eyebrow?: T;
title?: T; title?: T;
lead?: T; lead?: T;
seo?:
| T
| {
title?: T;
description?: T;
keywords?: T;
ogImage?: T;
};
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
globalType?: T; globalType?: T;
@@ -1159,6 +1453,14 @@ export interface AktuellesIntroSelect<T extends boolean = true> {
eyebrow?: T; eyebrow?: T;
title?: T; title?: T;
lead?: T; lead?: T;
seo?:
| T
| {
title?: T;
description?: T;
keywords?: T;
ogImage?: T;
};
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
globalType?: T; globalType?: T;
@@ -1174,6 +1476,14 @@ export interface ContactSelect<T extends boolean = true> {
email?: T; email?: T;
phone?: T; phone?: T;
region?: T; region?: T;
seo?:
| T
| {
title?: T;
description?: T;
keywords?: T;
ogImage?: T;
};
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
globalType?: T; globalType?: T;
@@ -1194,6 +1504,14 @@ export interface BookingSelect<T extends boolean = true> {
id?: T; id?: T;
}; };
formNote?: T; formNote?: T;
seo?:
| T
| {
title?: T;
description?: T;
keywords?: T;
ogImage?: T;
};
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
globalType?: T; globalType?: T;
@@ -1219,6 +1537,40 @@ export interface BookingSettingsSelect<T extends boolean = true> {
street?: T; street?: T;
postalCode?: T; postalCode?: T;
city?: T; city?: T;
region?: T;
country?: T;
updatedAt?: T;
createdAt?: T;
globalType?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "seo-settings_select".
*/
export interface SeoSettingsSelect<T extends boolean = true> {
siteUrl?: T;
defaultDescription?: T;
defaultOgImage?: T;
robotsIndexable?: T;
businessName?: T;
businessDescription?: T;
latitude?: T;
longitude?: T;
openingHours?:
| T
| {
days?: T;
opens?: T;
closes?: T;
id?: T;
};
socialLinks?:
| T
| {
platform?: T;
url?: T;
id?: T;
};
updatedAt?: T; updatedAt?: T;
createdAt?: T; createdAt?: T;
globalType?: T; globalType?: T;
+4 -1
View File
@@ -15,6 +15,7 @@ import { Customers } from "@/collections/Customers";
import { Availability } from "@/collections/Availability"; import { Availability } from "@/collections/Availability";
import { AvailabilityOverrides } from "@/collections/AvailabilityOverrides"; import { AvailabilityOverrides } from "@/collections/AvailabilityOverrides";
import { BookingRequests } from "@/collections/BookingRequests"; import { BookingRequests } from "@/collections/BookingRequests";
import { Redirects } from "@/collections/Redirects";
import { Home } from "@/globals/Home"; import { Home } from "@/globals/Home";
import { About } from "@/globals/About"; import { About } from "@/globals/About";
@@ -24,6 +25,7 @@ import { Contact } from "@/globals/Contact";
import { Booking } from "@/globals/Booking"; import { Booking } from "@/globals/Booking";
import { MeetingSettings } from "@/globals/MeetingSettings"; import { MeetingSettings } from "@/globals/MeetingSettings";
import { BookingSettings } from "@/globals/BookingSettings"; import { BookingSettings } from "@/globals/BookingSettings";
import { SEOSettings } from "@/globals/SEOSettings";
const filename = fileURLToPath(import.meta.url); const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename); const dirname = path.dirname(filename);
@@ -61,8 +63,9 @@ export default buildConfig({
Availability, Availability,
AvailabilityOverrides, AvailabilityOverrides,
BookingRequests, BookingRequests,
Redirects,
], ],
globals: [Home, About, AngeboteIntro, AktuellesIntro, Contact, Booking, MeetingSettings, BookingSettings], globals: [Home, About, AngeboteIntro, AktuellesIntro, Contact, Booking, MeetingSettings, BookingSettings, SEOSettings],
editor: lexicalEditor(), editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || "", secret: process.env.PAYLOAD_SECRET || "",
typescript: { typescript: {
+70
View File
@@ -0,0 +1,70 @@
/**
* Optional, dev-only SEO sanity check NOT part of the build or deploy
* pipeline, never blocks production. Run manually:
*
* npm run seo:check
*
* Only ever warns; exits 0 regardless of findings (a content gap here isn't
* a broken deploy).
*/
import { getPayload } from "payload";
import config from "../payload.config";
let warnings = 0;
function warn(message: string) {
warnings++;
console.warn(`${message}`);
}
async function checkCollection(
payload: Awaited<ReturnType<typeof getPayload>>,
collection: "offers" | "events" | "posts",
label: string,
) {
console.log(`${label}:`);
const { docs } = await payload.find({ collection, limit: 500, depth: 0 });
const slugCounts = new Map<string, number>();
for (const doc of docs as { slug?: string | null; title?: string }[]) {
if (doc.slug) slugCounts.set(doc.slug, (slugCounts.get(doc.slug) ?? 0) + 1);
}
for (const [slug, count] of slugCounts) {
if (count > 1) warn(`Slug „${slug}“ kommt ${count}× vor.`);
}
for (const doc of docs as { slug?: string | null; title?: string; seo?: { description?: string | null } | null }[]) {
if (!doc.title?.trim()) warn(`Dokument ohne Titel (slug: ${doc.slug ?? "—"}).`);
if (!doc.slug?.trim()) warn(`Dokument ohne Slug: „${doc.title ?? "—"}“.`);
}
if (docs.length === 0) console.log(" (keine Einträge)");
}
async function checkMedia(payload: Awaited<ReturnType<typeof getPayload>>) {
console.log("Medien (Alt-Texte):");
const { docs } = await payload.find({ collection: "media", limit: 1000, depth: 0 });
const missingAlt = (docs as { alt?: string; filename?: string }[]).filter((doc) => !doc.alt?.trim());
if (missingAlt.length === 0) {
console.log(" Alle Bilder haben einen Alt-Text.");
} else {
for (const doc of missingAlt) warn(`Bild ohne Alt-Text: ${doc.filename ?? "unbekannt"}`);
}
}
async function run() {
const payload = await getPayload({ config });
await checkCollection(payload, "offers", "Angebote");
await checkCollection(payload, "events", "Termine");
await checkCollection(payload, "posts", "Aktuelles");
await checkMedia(payload);
console.log(`\n${warnings === 0 ? "Keine Auffälligkeiten gefunden." : `${warnings} Hinweis(e) gefunden — siehe oben.`}`);
process.exit(0);
}
run().catch((err) => {
console.error(err);
process.exit(0);
});
-142
View File
@@ -1,142 +0,0 @@
#!/usr/bin/env bash
# ANOUMA — safe, release-based update.
#
# ./update.sh interactive update to the latest release tag
# ./update.sh --auto used by the AUTO_UPDATE cron job — silently
# does nothing if already on the latest release
# ./update.sh vX.Y.Z update to a specific tag
#
# Never deletes volumes. Never force-deploys an untagged commit from main —
# only real release tags (vX.Y.Z) are deployed. Backs up the database before
# touching anything, and rolls the *code* back (not the database schema —
# migrations should be forward-compatible, see DEPLOYMENT.md) if the
# post-update healthcheck fails.
set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
COLOR_INFO="\033[36m"; COLOR_WARN="\033[33m"; COLOR_ERROR="\033[31m"; COLOR_OK="\033[32m"; COLOR_RESET="\033[0m"
log_info() { printf "${COLOR_INFO}[INFO]${COLOR_RESET} %s\n" "$1"; }
log_warn() { printf "${COLOR_WARN}[WARN]${COLOR_RESET} %s\n" "$1"; }
log_error() { printf "${COLOR_ERROR}[ERROR]${COLOR_RESET} %s\n" "$1" >&2; }
log_success() { printf "${COLOR_OK}[SUCCESS]${COLOR_RESET} %s\n" "$1"; }
fail() { log_error "$1"; exit 1; }
command -v git >/dev/null 2>&1 || fail "Git wird für release-basierte Updates benötigt."
command -v docker >/dev/null 2>&1 || fail "Docker wurde nicht gefunden."
COMPOSE="docker compose"; docker compose version >/dev/null 2>&1 || COMPOSE="docker-compose"
AUTO_MODE=false
TARGET_VERSION=""
for arg in "$@"; do
case "$arg" in
--auto) AUTO_MODE=true ;;
v*) TARGET_VERSION="$arg" ;;
esac
done
# ---------------------------------------------------------------------------
# Update lock — prevents two updates (e.g. a manual one and the cron job)
# from running at the same time.
# ---------------------------------------------------------------------------
LOCK_FILE="$(pwd)/.update.lock"
if [ -f "$LOCK_FILE" ] && kill -0 "$(cat "$LOCK_FILE")" 2>/dev/null; then
log_warn "Update already running."
exit 0
fi
echo $$ > "$LOCK_FILE"
trap 'rm -f "$LOCK_FILE"' EXIT
[ -f .env ] || fail ".env nicht gefunden — bitte zuerst ./setup.sh ausführen."
[ -d .git ] || fail "Kein Git-Repository — Updates funktionieren nur in einem Checkout von https://git.maro.run/maro/anouma.git."
if [ -n "$(git status --porcelain)" ]; then
fail "Das Arbeitsverzeichnis hat uncommittete Änderungen — Update abgebrochen, um nichts zu überschreiben."
fi
CURRENT_REF="$(git describe --tags --exact-match 2>/dev/null || git rev-parse --short HEAD)"
log_info "Aktuelle Version: $CURRENT_REF"
log_info "Prüfe auf neue Releases …"
git fetch --tags --quiet origin
if [ -n "$TARGET_VERSION" ]; then
NEW_VERSION="$TARGET_VERSION"
else
# Highest vX.Y.Z tag, sorted as real version numbers (not alphabetically).
NEW_VERSION="$(git tag -l 'v*' | sort -t. -k1.2,1n -k2,2n -k3,3n | tail -n1)"
fi
if [ -z "$NEW_VERSION" ]; then
log_info "Keine Release-Tags im Repository gefunden — nichts zu deployen (main wird bewusst nicht automatisch deployt)."
exit 0
fi
if [ "$NEW_VERSION" = "$CURRENT_REF" ]; then
log_info "Bereits auf dem neuesten Release ($CURRENT_REF)."
exit 0
fi
if [ "$AUTO_MODE" = true ]; then
log_info "Neues Release gefunden: $NEW_VERSION (aktuell: $CURRENT_REF) — automatisches Update wird gestartet."
fi
log_info "Update: $CURRENT_REF$NEW_VERSION"
# ---------------------------------------------------------------------------
# Backup
# ---------------------------------------------------------------------------
mkdir -p backups
BACKUP_FILE="backups/database-$(date +%Y-%m-%d-%H%M).sql.gz"
log_info "Erstelle Datenbank-Backup: $BACKUP_FILE"
set -a; source .env; set +a
if $COMPOSE ps -q postgres >/dev/null 2>&1 && [ -n "$($COMPOSE ps -q postgres)" ]; then
$COMPOSE exec -T postgres pg_dump -U "${POSTGRES_USER:-postgres}" "${POSTGRES_DB:-anouma}" | gzip > "$BACKUP_FILE"
log_success "Backup erstellt ($(du -h "$BACKUP_FILE" | cut -f1))."
else
log_warn "Datenbank-Container läuft nicht — Backup übersprungen."
fi
# Retention: delete backups older than BACKUP_RETENTION_DAYS, but never the
# one we just created.
RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-14}"
find backups -name 'database-*.sql.gz' -mtime "+${RETENTION_DAYS}" -not -name "$(basename "$BACKUP_FILE")" -delete 2>/dev/null || true
# ---------------------------------------------------------------------------
# Deploy
# ---------------------------------------------------------------------------
wait_healthy() {
local service="$1" timeout_iterations="$2" id status
for _ in $(seq 1 "$timeout_iterations"); do
id="$($COMPOSE ps -q "$service" 2>/dev/null || true)"
if [ -n "$id" ]; then
status="$(docker inspect --format '{{.State.Health.Status}}' "$id" 2>/dev/null || true)"
[ "$status" = "healthy" ] && return 0
fi
sleep 2
done
return 1
}
deploy_ref() {
local ref="$1"
git checkout --quiet "$ref"
$COMPOSE build
$COMPOSE run --rm app npm run migrate
$COMPOSE up -d
}
log_info "Checke $NEW_VERSION aus und baue neu …"
if deploy_ref "$NEW_VERSION" && wait_healthy app 60; then
log_success "Update auf $NEW_VERSION erfolgreich."
echo "$NEW_VERSION" > .installed-version
exit 0
fi
log_error "Healthcheck nach Update auf $NEW_VERSION fehlgeschlagen — rolle Code auf $CURRENT_REF zurück."
log_warn "Datenbank-Migrationen werden dabei NICHT rückgängig gemacht (siehe DEPLOYMENT.md) — falls $NEW_VERSION eine nicht abwärtskompatible Migration enthielt, stelle das Backup manuell wieder her: $BACKUP_FILE"
if deploy_ref "$CURRENT_REF" && wait_healthy app 60; then
log_warn "Rollback auf $CURRENT_REF erfolgreich — die Anwendung läuft wieder auf der vorherigen Version."
exit 1
fi
fail "Rollback ebenfalls fehlgeschlagen — bitte manuell prüfen: $COMPOSE logs app"