Files
anouma/app/(frontend)/angebote/page.tsx
T
maroandClaude Sonnet 5 1cd15aff25 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>
2026-08-25 22:57:31 +02:00

178 lines
6.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Metadata } from "next";
import { PageHeader } from "@/components/PageHeader";
import { Section, SectionHeading } from "@/components/Section";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { AngebotCard } from "@/components/AngebotCard";
import { Button } from "@/components/Button";
import { CTA } from "@/components/CTA";
import { Reveal } from "@/components/Reveal";
import { getOffers } from "@/lib/payload/content";
import { getAngeboteIntroGlobal, getSEOSettingsGlobal } from "@/lib/payload/globals";
import { groupOffersByCategory, moodForOffer } from "@/lib/angebote";
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";
const FALLBACK_DESCRIPTION =
"Prozessbegleitung, Doula-Begleitung, Kindergruppen und Singkreise — die Angebote von Anouma im Überblick.";
export async function generateMetadata(): Promise<Metadata> {
const [intro, seoSettings] = await Promise.all([getAngeboteIntroGlobal(), getSEOSettingsGlobal()]);
const resolved = resolveSeo({
seo: intro.seo,
fallbackTitle: `Begleitung für dich und deine Familie ${siteConfig.name}`,
fallbackDescription: () => intro.lead || seoSettings.defaultDescription || FALLBACK_DESCRIPTION,
defaultOgImage: seoSettings.defaultOgImage,
});
return buildMetadata({
title: resolved.title,
description: resolved.description,
path: "/angebote",
ogImageUrl: resolved.ogImageUrl,
keywords: resolved.keywords,
});
}
export default async function AngebotePage() {
const [intro, offers] = await Promise.all([getAngeboteIntroGlobal(), getOffers()]);
const groups = groupOffersByCategory(offers);
const siteUrl = await getSiteUrl();
const prozessbegleitung = groups.find((g) => g.category === "prozessbegleitung")?.offers[0];
const doula = groups.find((g) => g.category === "doula-begleitung")?.offers[0];
const kindergruppen = groups.find((g) => g.category === "kindergruppen");
const singkreise = groups.find((g) => g.category === "singkreise");
return (
<>
<JsonLd
data={webPageJsonLd({
name: intro.title || "Angebote",
description: intro.lead || FALLBACK_DESCRIPTION,
url: `${siteUrl}/angebote`,
})}
/>
<PageHeader
title={intro.title || "Angebote"}
lead={intro.lead}
crumbs={[{ title: "Angebote" }]}
/>
{prozessbegleitung && (
<Section tone="plain">
<div className="grid items-center gap-14 lg:grid-cols-2">
<Reveal>
<div className="relative aspect-[4/5] w-full max-w-md lg:max-w-none">
<ImagePlaceholder
mood={moodForOffer(prozessbegleitung)}
label={prozessbegleitung.title}
src={mediaUrl(prozessbegleitung.image, "hero")}
alt={mediaAlt(prozessbegleitung.image)}
className="h-full w-full"
/>
</div>
</Reveal>
<Reveal delay={0.1}>
<p className="mb-3 text-xs font-medium uppercase tracking-[0.22em] text-anouma-plum">
Für Erwachsene
</p>
<h2 className="font-serif text-4xl font-medium leading-tight text-anouma-plum">
{prozessbegleitung.title}
</h2>
<p className="mt-5 text-lg leading-relaxed text-anouma-plum">
{prozessbegleitung.shortDescription}
</p>
<div className="mt-8">
<Button href={`/angebote/${prozessbegleitung.slug}`}>
{prozessbegleitung.title} entdecken
</Button>
</div>
</Reveal>
</div>
</Section>
)}
{doula && (
<Section tone="cream">
<div className="grid items-center gap-14 lg:grid-cols-2">
<Reveal className="order-2 lg:order-1">
<p className="mb-3 text-xs font-medium uppercase tracking-[0.22em] text-anouma-plum">
Kinderwunsch · Schwangerschaft · Geburt · Wochenbett
</p>
<h2 className="font-serif text-4xl font-medium leading-tight text-anouma-plum">
{doula.title}
</h2>
<p className="mt-5 text-lg leading-relaxed text-anouma-plum">
{doula.shortDescription}
</p>
<div className="mt-8">
<Button href={`/angebote/${doula.slug}`}>{doula.title} entdecken</Button>
</div>
</Reveal>
<Reveal delay={0.1} className="order-1 lg:order-2">
<div className="relative aspect-[4/5] w-full max-w-md lg:max-w-none lg:ml-auto">
<ImagePlaceholder
mood={moodForOffer(doula)}
label={doula.title}
src={mediaUrl(doula.image, "hero")}
alt={mediaAlt(doula.image)}
className="h-full w-full"
/>
</div>
</Reveal>
</div>
</Section>
)}
{kindergruppen && kindergruppen.offers.length > 0 && (
<Section id="kindergruppen" tone="plain">
<SectionHeading eyebrow="Für Kinder" title="Kindergruppen" lead={kindergruppen.label} />
<div className="mt-14 grid gap-6 sm:grid-cols-2">
{kindergruppen.offers.map((offer, i) => (
<Reveal key={offer.slug} delay={i * 0.08}>
<AngebotCard
href={`/angebote/${offer.slug}`}
title={offer.title}
tagline={offer.shortDescription}
mood={moodForOffer(offer)}
imageSrc={mediaUrl(offer.image, "card")}
size="large"
/>
</Reveal>
))}
</div>
</Section>
)}
{singkreise && singkreise.offers.length > 0 && (
<Section tone="warm">
<SectionHeading eyebrow="Für Herz und Seele" title="Singkreise" />
<div className="mt-14 grid gap-6 sm:grid-cols-3">
{singkreise.offers.map((offer, i) => (
<Reveal key={offer.slug} delay={i * 0.08}>
<AngebotCard
href={`/angebote/${offer.slug}`}
title={offer.title}
tagline={offer.shortDescription}
mood={moodForOffer(offer)}
imageSrc={mediaUrl(offer.image, "card")}
/>
</Reveal>
))}
</div>
</Section>
)}
<CTA
title="Unsicher, welcher Raum passt?"
lead="Melde dich gerne für ein unverbindliches Kennenlerngespräch — gemeinsam schauen wir, was dein Anliegen gerade braucht."
/>
</>
);
}