Files
anouma/app/(frontend)/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

251 lines
9.6 KiB
TypeScript
Raw Permalink 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 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."
}
/>
</>
);
}