- 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>
251 lines
9.6 KiB
TypeScript
251 lines
9.6 KiB
TypeScript
import type { Metadata } from "next";
|
||
import Link from "next/link";
|
||
import { Hero } from "@/components/Hero";
|
||
import { Button } from "@/components/Button";
|
||
import { Section, SectionHeading } from "@/components/Section";
|
||
import { AngebotCard } from "@/components/AngebotCard";
|
||
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
|
||
import { RichText } from "@/components/RichText";
|
||
import { CTA } from "@/components/CTA";
|
||
import { Reveal } from "@/components/Reveal";
|
||
import { EventTeaserCard } from "@/components/EventTeaserCard";
|
||
import { getOffers, getPosts, getUpcomingEvents } from "@/lib/payload/content";
|
||
import { getHomeGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals";
|
||
import { groupOffersByCategory, moodForOffer } from "@/lib/angebote";
|
||
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 async function generateMetadata(): Promise<Metadata> {
|
||
const [home, seoSettings] = await Promise.all([getHomeGlobal(), getSEOSettingsGlobal()]);
|
||
const resolved = resolveSeo({
|
||
seo: home.seo,
|
||
fallbackTitle: `${siteConfig.name} – Begleitung für dich und deine Familie`,
|
||
fallbackDescription: () =>
|
||
home.heroSupporting ||
|
||
seoSettings.defaultDescription ||
|
||
"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() {
|
||
const [home, offers, posts, events] = await Promise.all([
|
||
getHomeGlobal(),
|
||
getOffers(),
|
||
getPosts(3),
|
||
getUpcomingEvents(3),
|
||
]);
|
||
const groups = groupOffersByCategory(offers);
|
||
const siteUrl = await getSiteUrl();
|
||
|
||
return (
|
||
<>
|
||
<JsonLd
|
||
data={webPageJsonLd({
|
||
name: home.heroTitle || "Willkommen bei Anouma",
|
||
description: home.heroSupporting || siteConfig.description,
|
||
url: siteUrl,
|
||
})}
|
||
/>
|
||
<Hero
|
||
eyebrow={home.heroEyebrow ?? undefined}
|
||
title={home.heroTitle || "Willkommen bei Anouma"}
|
||
mood="rose"
|
||
imageSrc={mediaUrl(home.heroImage, "hero")}
|
||
imageAlt={mediaAlt(home.heroImage)}
|
||
actions={
|
||
<>
|
||
<Button href="/termin-buchen">Termin buchen</Button>
|
||
<Button href="/angebote" variant="secondary">
|
||
Angebote entdecken
|
||
</Button>
|
||
</>
|
||
}
|
||
>
|
||
{home.heroHighlight && <p>{home.heroHighlight}</p>}
|
||
{home.heroSupporting && (
|
||
<p className="mt-3 text-base text-anouma-plum/90">{home.heroSupporting}</p>
|
||
)}
|
||
</Hero>
|
||
|
||
<Section tone="cream">
|
||
<SectionHeading
|
||
eyebrow="Angebote"
|
||
title="Räume, die zu deinem Weg passen"
|
||
lead="Von persönlicher Prozessbegleitung über Doula-Begleitung bis zu Kinder- und Singkreisen — jedes Angebot ist ein eigener, geschützter Raum."
|
||
/>
|
||
<div className="mt-14 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||
{groups.map((group, i) => {
|
||
const isSingle = group.offers.length === 1;
|
||
const first = group.offers[0];
|
||
return (
|
||
<Reveal key={group.category} delay={i * 0.08}>
|
||
<AngebotCard
|
||
href={isSingle ? `/angebote/${first.slug}` : group.href}
|
||
title={group.label}
|
||
tagline={isSingle ? first.shortDescription : `${group.offers.length} Angebote`}
|
||
mood={moodForOffer(first)}
|
||
imageSrc={mediaUrl(first.image, "card")}
|
||
/>
|
||
</Reveal>
|
||
);
|
||
})}
|
||
</div>
|
||
</Section>
|
||
|
||
{(home.aboutTitle || home.aboutText) && (
|
||
<Section tone="plain">
|
||
<div className="grid gap-14 lg:grid-cols-2 lg:items-center">
|
||
<Reveal>
|
||
<div className="relative mx-auto aspect-[4/5] w-full max-w-md lg:max-w-none">
|
||
<ImagePlaceholder
|
||
mood="mauve"
|
||
label="Anouma"
|
||
src={mediaUrl(home.aboutImage, "hero")}
|
||
alt={mediaAlt(home.aboutImage)}
|
||
className="h-full w-full"
|
||
/>
|
||
</div>
|
||
</Reveal>
|
||
<Reveal delay={0.1}>
|
||
{home.aboutEyebrow && (
|
||
<p className="mb-3 text-xs font-medium uppercase tracking-[0.22em] text-anouma-plum">
|
||
{home.aboutEyebrow}
|
||
</p>
|
||
)}
|
||
{home.aboutTitle && (
|
||
<h2 className="text-balance font-serif text-4xl font-medium leading-tight text-anouma-plum sm:text-5xl">
|
||
{home.aboutTitle}
|
||
</h2>
|
||
)}
|
||
{home.aboutText && (
|
||
<div className="mt-6">
|
||
<RichText data={home.aboutText} />
|
||
</div>
|
||
)}
|
||
<div className="mt-8">
|
||
<Button href="/ueber-mich" variant="ghost">
|
||
Mehr über mich lesen →
|
||
</Button>
|
||
</div>
|
||
</Reveal>
|
||
</div>
|
||
</Section>
|
||
)}
|
||
|
||
{home.values && home.values.length > 0 && (
|
||
<Section tone="mauve">
|
||
<SectionHeading
|
||
eyebrow={home.valuesEyebrow ?? undefined}
|
||
title={home.valuesTitle || "Philosophie"}
|
||
align="center"
|
||
className="mx-auto text-anouma-cream-light [&_h2]:text-anouma-cream-light [&_p]:text-anouma-cream-light/90"
|
||
/>
|
||
<div className="mt-16 grid gap-10 sm:grid-cols-3">
|
||
{home.values.map((wert, i) => (
|
||
<Reveal key={wert.id ?? wert.title} delay={i * 0.1} className="text-center">
|
||
<p className="font-serif text-sm font-medium uppercase tracking-[0.2em] text-white/85">
|
||
{wert.title}
|
||
</p>
|
||
<p className="mt-4 text-balance font-serif text-2xl leading-snug italic">
|
||
„{wert.quote}“
|
||
</p>
|
||
</Reveal>
|
||
))}
|
||
</div>
|
||
</Section>
|
||
)}
|
||
|
||
{events.length > 0 && (
|
||
<Section tone="warm">
|
||
<div className="flex flex-wrap items-end justify-between gap-6">
|
||
<SectionHeading eyebrow="Termine" title="Aktuelle Termine" className="mb-0" />
|
||
<Button href="/termine" variant="ghost">
|
||
Alle Termine →
|
||
</Button>
|
||
</div>
|
||
<div className="mt-12 grid gap-6 sm:grid-cols-3">
|
||
{events.map((event, i) => (
|
||
<Reveal key={event.slug} delay={i * 0.08}>
|
||
<EventTeaserCard event={event} />
|
||
</Reveal>
|
||
))}
|
||
</div>
|
||
</Section>
|
||
)}
|
||
|
||
{posts.length > 0 && (
|
||
<Section tone="cream">
|
||
<div className="flex flex-wrap items-end justify-between gap-6">
|
||
<SectionHeading eyebrow="Aktuelles" title="Neuigkeiten und Inspiration" className="mb-0" />
|
||
<Button href="/aktuelles" variant="ghost">
|
||
Alle Neuigkeiten →
|
||
</Button>
|
||
</div>
|
||
<div className="mt-12 grid gap-6 sm:grid-cols-3">
|
||
{posts.map((post, i) => (
|
||
<Reveal key={post.slug} delay={i * 0.08}>
|
||
<Link href={`/aktuelles/${post.slug}`} className="block rounded-3xl bg-background p-7">
|
||
<p className="text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum">
|
||
{new Date(post.publishDate ?? post.createdAt).toLocaleDateString("de-DE", {
|
||
day: "2-digit",
|
||
month: "long",
|
||
year: "numeric",
|
||
})}
|
||
</p>
|
||
<h3 className="mt-3 font-serif text-xl font-medium text-anouma-plum">{post.title}</h3>
|
||
<p className="mt-3 text-sm leading-relaxed text-anouma-plum">{post.teaser}</p>
|
||
</Link>
|
||
</Reveal>
|
||
))}
|
||
</div>
|
||
</Section>
|
||
)}
|
||
|
||
<Section tone="plain">
|
||
<SectionHeading eyebrow="Impressionen" title="Einblicke in gemeinsame Räume" align="center" className="mx-auto" />
|
||
<div className="mt-14 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||
{(["moss", "rose", "peach", "sand"] as const).map((mood, i) => (
|
||
<Reveal key={mood} delay={i * 0.06}>
|
||
<ImagePlaceholder mood={mood} label="Impression" shape="soft" className="aspect-square" />
|
||
</Reveal>
|
||
))}
|
||
</div>
|
||
<div className="mt-10 text-center">
|
||
<Link
|
||
href="/impressionen"
|
||
className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4"
|
||
>
|
||
Alle Impressionen ansehen
|
||
</Link>
|
||
</div>
|
||
</Section>
|
||
|
||
<CTA
|
||
title={home.ctaTitle || "Kennenlernen & Termine vereinbaren"}
|
||
lead={
|
||
home.ctaLead ||
|
||
"Ich freue mich, von dir zu hören — schreib mir oder buche direkt ein unverbindliches Kennenlerngespräch."
|
||
}
|
||
/>
|
||
</>
|
||
);
|
||
}
|