Redesign: warmer, more editorial visual pass; remove stray em dashes
CI / test (push) Successful in 3m42s
Release / test (push) Successful in 3m44s
Release / build-and-deploy (push) Successful in 8m59s

Replaces em dashes with en dashes across all hardcoded copy, emails, and
seed content, then reworks the site's visual language so it reads as
deliberately designed rather than assembled from generic components:

- New ArrowLink component replaces ad hoc "Mehr erfahren ->" links and
  ghost buttons; one shared FormField (TextField/TextAreaField) replaces
  7 near-duplicate field styles across every form
- Navbar loses its glassmorphism blur for a solid warm background, gets a
  subtle active-link underline and a softer, warmer mega-menu
- PageHeader supports per-page blob tone/position and a compact mode
  instead of an identical band on every interior page
- Homepage: the core offer is featured large instead of four equal cards,
  the philosophy section gets a single calmer accent blob, blog teasers
  now show their cover image like the real /aktuelles page does
- Booking calendar and widget: 44px touch targets throughout (month nav,
  day cells, time slots, cancel action), a visible "today" ring, and
  responsive padding so the calendar isn't cramped on small screens
- Quieter, warmer empty states on /termine and /aktuelles

No functional changes: all interaction logic (nav menus, forms, booking,
CMS data fetching) is untouched, only markup and styling.
This commit is contained in:
2026-09-06 22:00:28 +02:00
parent 65b6eeac51
commit 526a908c80
41 changed files with 425 additions and 337 deletions
+4 -3
View File
@@ -55,9 +55,10 @@ export default async function AktuellesPage() {
<Section tone="plain">
{posts.length === 0 ? (
<p className="text-lg text-anouma-plum">
Hier erscheinen bald die ersten Neuigkeiten — schau gerne wieder vorbei.
</p>
<div className="rounded-3xl bg-anouma-cream-light p-8 text-center sm:p-10">
<p className="font-serif text-xl font-medium text-anouma-plum">Hier erscheinen bald die ersten Neuigkeiten</p>
<p className="mt-2 text-base leading-relaxed text-anouma-plum/90">Schau gerne wieder vorbei.</p>
</div>
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{posts.map((post, i) => (
+2 -2
View File
@@ -19,7 +19,7 @@ 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.";
"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()]);
@@ -170,7 +170,7 @@ export default async function AngebotePage() {
<CTA
title="Unsicher, welcher Raum passt?"
lead="Melde dich gerne für ein unverbindliches Kennenlerngespräch — gemeinsam schauen wir, was dein Anliegen gerade braucht."
lead="Melde dich gerne für ein unverbindliches Kennenlerngespräch – gemeinsam schauen wir, was dein Anliegen gerade braucht."
/>
</>
);
+1 -1
View File
@@ -53,7 +53,7 @@ export default async function SingkreisePage() {
<CTA
title="Deine Stimme erklingen lassen"
lead="Ob offen für alle, für Schwangere oder für Mamas mit Baby — melde dich, wenn du einen Kreis besuchen möchtest."
lead="Ob offen für alle, für Schwangere oder für Mamas mit Baby – melde dich, wenn du einen Kreis besuchen möchtest."
/>
</>
);
@@ -21,6 +21,7 @@ export default async function VerifyEmailPage({ params }: Args) {
eyebrow="Konto"
title={verified ? "E-Mail-Adresse bestätigt" : "Bestätigung fehlgeschlagen"}
crumbs={[{ title: "E-Mail bestätigen" }]}
compact
/>
<Section tone="plain">
<div className="mx-auto max-w-md text-center">
+4 -4
View File
@@ -21,7 +21,7 @@ export default async function DatenschutzPage() {
const contact = await getContactGlobal();
return (
<>
<PageHeader title="Datenschutzerklärung" crumbs={[{ title: "Datenschutz" }]} />
<PageHeader title="Datenschutzerklärung" crumbs={[{ title: "Datenschutz" }]} compact />
<Section tone="plain">
<div className="mx-auto max-w-2xl space-y-8">
<PlaceholderNote>
@@ -33,9 +33,9 @@ export default async function DatenschutzPage() {
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Verantwortliche Stelle</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
[Platzhalter — Vor- und Nachname]
[Platzhalter – Vor- und Nachname]
<br />
[Platzhalter — Anschrift]
[Platzhalter – Anschrift]
<br />
E-Mail: {contact.email}
</p>
@@ -48,7 +48,7 @@ export default async function DatenschutzPage() {
<p className="mt-4 leading-relaxed text-anouma-plum">
Beim Besuch dieser Website werden aus technischen Gründen automatisch Informationen
erfasst, die dein Browser übermittelt (z. B. IP-Adresse, Datum und Uhrzeit des
Zugriffs). [Platzhalter — Details zum verwendeten Hosting-Anbieter ergänzen.]
Zugriffs). [Platzhalter – Details zum verwendeten Hosting-Anbieter ergänzen.]
</p>
</div>
+3 -3
View File
@@ -10,7 +10,7 @@ import { JsonLd, webPageJsonLd } from "@/lib/seo/jsonld";
export const dynamic = "force-dynamic";
const 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" });
@@ -36,11 +36,11 @@ export default async function ImpressionenPage() {
<PageHeader
eyebrow="Impressionen"
title="Einblicke in gemeinsame Räume"
lead="Diese Galerie ist als Platzhalter-System angelegt — echte Fotografien lassen sich hier später direkt einsetzen, ohne das Layout zu verändern."
lead="Diese Galerie ist als Platzhalter-System angelegt – echte Fotografien lassen sich hier später direkt einsetzen, ohne das Layout zu verändern."
crumbs={[{ title: "Impressionen" }]}
/>
<Section tone="plain">
<div className="grid auto-rows-[14rem] grid-cols-2 gap-4 sm:grid-cols-4">
<div className="grid auto-rows-[10rem] grid-cols-2 gap-4 sm:auto-rows-[14rem] sm:grid-cols-4">
{gallery.map((item, i) => (
<Reveal key={item.label} delay={Math.min(i * 0.04, 0.3)} className={item.span}>
<ImagePlaceholder mood={item.mood} label={item.label} shape="soft" className="h-full w-full" />
+6 -6
View File
@@ -23,7 +23,7 @@ export default async function ImpressumPage() {
const contact = await getContactGlobal();
return (
<>
<PageHeader title="Impressum" crumbs={[{ title: "Impressum" }]} />
<PageHeader title="Impressum" crumbs={[{ title: "Impressum" }]} compact />
<Section tone="plain">
<div className="mx-auto max-w-2xl space-y-8">
<PlaceholderNote>
@@ -35,11 +35,11 @@ export default async function ImpressumPage() {
<div>
<h2 className="font-serif text-2xl font-medium text-anouma-plum">Angaben gemäß § 5 TMG</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
[Platzhalter — Vor- und Nachname]
[Platzhalter – Vor- und Nachname]
<br />
[Platzhalter — Straße und Hausnummer]
[Platzhalter – Straße und Hausnummer]
<br />
[Platzhalter — PLZ und Ort]
[Platzhalter – PLZ und Ort]
</p>
</div>
@@ -61,7 +61,7 @@ export default async function ImpressumPage() {
Umsatzsteuer-Identifikationsnummer
</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
[Platzhalter — sofern vorhanden, gemäß § 27 a Umsatzsteuergesetz]
[Platzhalter – sofern vorhanden, gemäß § 27 a Umsatzsteuergesetz]
</p>
</div>
@@ -70,7 +70,7 @@ export default async function ImpressumPage() {
Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV
</h2>
<p className="mt-4 leading-relaxed text-anouma-plum">
[Platzhalter — Vor- und Nachname, Anschrift wie oben]
[Platzhalter – Vor- und Nachname, Anschrift wie oben]
</p>
</div>
+3
View File
@@ -50,6 +50,9 @@ export default async function KontaktPage() {
title={contact.title || "Ich freue mich, von dir zu hören"}
lead={contact.lead}
crumbs={[{ title: "Kontakt" }]}
blobTone="sage"
blobPosition="top-left"
compact
/>
<Section tone="plain">
+2 -2
View File
@@ -44,12 +44,12 @@ export default async function KontoLayout({ children }: { children: ReactNode })
<Link
key={item.href}
href={item.href}
className="whitespace-nowrap rounded-full px-4 py-2.5 text-sm font-medium text-anouma-plum transition-colors hover:bg-white lg:rounded-2xl"
className="flex min-h-11 items-center whitespace-nowrap rounded-full px-4 text-sm font-medium text-anouma-plum transition-colors hover:bg-white lg:rounded-2xl"
>
{item.title}
</Link>
))}
<LogoutButton className="whitespace-nowrap rounded-full px-4 py-2.5 text-left text-sm font-medium text-anouma-plum/70 transition-colors hover:bg-white lg:rounded-2xl" />
<LogoutButton className="flex min-h-11 items-center whitespace-nowrap rounded-full px-4 text-left text-sm font-medium text-anouma-plum/70 transition-colors hover:bg-white lg:rounded-2xl" />
</nav>
<div className="min-w-0">{children}</div>
</div>
+2 -4
View File
@@ -1,8 +1,8 @@
import type { Metadata } from "next";
import Link from "next/link";
import { getCurrentCustomer } from "@/lib/auth/customer";
import { getCustomerBookings } from "@/lib/booking/queries";
import { BookingCard } from "@/components/booking/BookingCard";
import { ArrowLink } from "@/components/ArrowLink";
export const metadata: Metadata = { title: "Mein Konto" };
@@ -61,9 +61,7 @@ export default async function KontoOverviewPage() {
{past.length > 0 && (
<div>
<Link href="/konto/termine" className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4">
Alle Termine (inkl. vergangene) ansehen →
</Link>
<ArrowLink href="/konto/termine">Alle Termine (inkl. vergangene) ansehen</ArrowLink>
</div>
)}
</div>
+1 -1
View File
@@ -14,7 +14,7 @@ export default async function KontoProfilPage() {
<div className="mt-6 rounded-3xl bg-white p-6">
<ProfileForm id={customer.id} name={customer.name} phone={customer.phone ?? ""} />
<p className="mt-6 text-xs text-anouma-plum/60">
E-Mail: {customer.email} — für eine Änderung der E-Mail-Adresse melde dich bitte direkt bei Anna.
E-Mail: {customer.email} – für eine Änderung der E-Mail-Adresse melde dich bitte direkt bei Anna.
</p>
</div>
</div>
+1 -1
View File
@@ -20,7 +20,7 @@ export default async function LoginPage() {
return (
<>
<PageHeader eyebrow="Konto" title="Anmelden" crumbs={[{ title: "Anmelden" }]} />
<PageHeader eyebrow="Konto" title="Anmelden" crumbs={[{ title: "Anmelden" }]} compact />
<Section tone="plain">
<div className="mx-auto max-w-md">
<Suspense>
+80 -59
View File
@@ -2,9 +2,11 @@ import type { Metadata } from "next";
import Link from "next/link";
import { Hero } from "@/components/Hero";
import { Button } from "@/components/Button";
import { ArrowLink } from "@/components/ArrowLink";
import { Section, SectionHeading } from "@/components/Section";
import { AngebotCard } from "@/components/AngebotCard";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { OrganicBlob } from "@/components/OrganicBlob";
import { RichText } from "@/components/RichText";
import { CTA } from "@/components/CTA";
import { Reveal } from "@/components/Reveal";
@@ -29,7 +31,7 @@ export async function generateMetadata(): Promise<Metadata> {
fallbackDescription: () =>
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,
});
@@ -53,6 +55,8 @@ export default async function Home() {
getUpcomingEvents(3),
]);
const groups = groupOffersByCategory(offers);
const featuredGroup = groups.find((g) => g.category === "prozessbegleitung");
const remainingGroups = featuredGroup ? groups.filter((g) => g !== featuredGroup) : groups;
const siteUrl = await getSiteUrl();
return (
@@ -73,9 +77,9 @@ export default async function Home() {
actions={
<>
<Button href="/termin-buchen">Termin buchen</Button>
<Button href="/angebote" variant="secondary">
<ArrowLink href="/angebote" size="base" className="self-center">
Angebote entdecken
</Button>
</ArrowLink>
</>
}
>
@@ -89,24 +93,39 @@ export default async function Home() {
<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."
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 className="mt-14 space-y-6">
{/* Prozessbegleitung ist das Kernangebot – bekommt deshalb den größeren, ersten Platz statt eines gleichwertigen 4er-Rasters. */}
{featuredGroup && (
<Reveal>
<AngebotCard
href={featuredGroup.offers.length === 1 ? `/angebote/${featuredGroup.offers[0].slug}` : featuredGroup.href}
title={featuredGroup.label}
tagline={featuredGroup.offers.length === 1 ? featuredGroup.offers[0].shortDescription : `${featuredGroup.offers.length} Angebote`}
mood={moodForOffer(featuredGroup.offers[0])}
imageSrc={mediaUrl(featuredGroup.offers[0].image, "card")}
size="large"
/>
</Reveal>
)}
<div className="grid gap-6 sm:grid-cols-3">
{remainingGroups.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>
</div>
</Section>
@@ -141,9 +160,7 @@ export default async function Home() {
</div>
)}
<div className="mt-8">
<Button href="/ueber-mich" variant="ghost">
Mehr über mich lesen →
</Button>
<ArrowLink href="/ueber-mich">Mehr über mich lesen</ArrowLink>
</div>
</Reveal>
</div>
@@ -151,24 +168,27 @@ export default async function Home() {
)}
{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>
))}
<Section tone="mauve" className="relative overflow-hidden">
<OrganicBlob tone="sage" className="-left-24 top-1/2 h-96 w-96 -translate-y-1/2 opacity-20" />
<div className="relative">
<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>
</div>
</Section>
)}
@@ -177,9 +197,7 @@ export default async function Home() {
<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>
<ArrowLink href="/termine">Alle Termine</ArrowLink>
</div>
<div className="mt-12 grid gap-6 sm:grid-cols-3">
{events.map((event, i) => (
@@ -195,23 +213,31 @@ export default async function Home() {
<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>
<ArrowLink href="/aktuelles">Alle Neuigkeiten</ArrowLink>
</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">
<Link href={`/aktuelles/${post.slug}`} className="group block">
<div className="relative aspect-[4/3] w-full overflow-hidden rounded-3xl">
<ImagePlaceholder
mood="sand"
label={post.title}
src={mediaUrl(post.coverImage, "card")}
alt={mediaAlt(post.coverImage) ?? post.title}
shape="soft"
className="h-full w-full transition-transform duration-700 group-hover:scale-[1.03]"
/>
</div>
<p className="mt-4 text-xs font-medium uppercase tracking-[0.18em] text-anouma-plum/70">
{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>
<h3 className="mt-2 font-serif text-xl font-medium text-anouma-plum">{post.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-anouma-plum/90">{post.teaser}</p>
</Link>
</Reveal>
))}
@@ -228,13 +254,8 @@ export default async function Home() {
</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 className="mt-10 flex justify-center">
<ArrowLink href="/impressionen">Alle Impressionen ansehen</ArrowLink>
</div>
</Section>
@@ -242,7 +263,7 @@ export default async function Home() {
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."
"Ich freue mich, von dir zu hören – schreib mir oder buche direkt ein unverbindliches Kennenlerngespräch."
}
/>
</>
+1
View File
@@ -24,6 +24,7 @@ export default async function RegisterPage() {
title="Konto erstellen"
lead="Mit einem Konto kannst du Termine anfragen und behältst alle deine Buchungen im Blick."
crumbs={[{ title: "Konto erstellen" }]}
compact
/>
<Section tone="plain">
<div className="mx-auto max-w-md">
+1 -1
View File
@@ -98,7 +98,7 @@ export default async function TerminBuchenPage() {
title="Allgemeine Anfrage"
lead={
bookableOffers.length > 0
? "Für einen konkreten Termin wähle oben ein Angebot — hier kannst du auch einfach eine allgemeine Nachricht schreiben."
? "Für einen konkreten Termin wähle oben ein Angebot – hier kannst du auch einfach eine allgemeine Nachricht schreiben."
: undefined
}
align="left"
+2
View File
@@ -126,6 +126,8 @@ export default async function EventDetailPage({ params }: Args) {
eyebrow={categoryLabel}
title={event.title}
crumbs={[{ title: "Termine", href: "/termine" }, { title: event.title }]}
blobTone="mauve"
blobPosition="top-left"
/>
<Section tone="plain">
<div className="grid gap-14 lg:grid-cols-[1fr_0.8fr]">
+12 -4
View File
@@ -3,6 +3,7 @@ import { PageHeader } from "@/components/PageHeader";
import { Section } from "@/components/Section";
import { Reveal } from "@/components/Reveal";
import { EventTeaserCard } from "@/components/EventTeaserCard";
import { ArrowLink } from "@/components/ArrowLink";
import { getAllEvents } from "@/lib/payload/content";
import { buildMetadata } from "@/lib/seo/metadata";
import { getSiteUrl } from "@/lib/seo/config";
@@ -32,14 +33,21 @@ export default async function TerminePage() {
title="Aktuelle Termine"
lead="Ein Überblick über anstehende Kreise, Begleitungen und Veranstaltungen."
crumbs={[{ title: "Termine" }]}
blobTone="mauve"
blobPosition="top-left"
/>
<Section tone="plain">
{upcoming.length === 0 ? (
<p className="text-lg text-anouma-plum">
Aktuell sind keine Termine geplant — schau gerne bald wieder vorbei oder melde dich
direkt über die <a href="/kontakt" className="underline underline-offset-4">Kontaktseite</a>.
</p>
<div className="rounded-3xl bg-anouma-cream-light p-8 text-center sm:p-10">
<p className="font-serif text-xl font-medium text-anouma-plum">Aktuell sind keine Termine geplant</p>
<p className="mt-2 text-base leading-relaxed text-anouma-plum/90">
Schau gerne bald wieder vorbei, oder melde dich direkt – ich freue mich von dir zu hören.
</p>
<div className="mt-5 flex justify-center">
<ArrowLink href="/kontakt">Zur Kontaktseite</ArrowLink>
</div>
</div>
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{upcoming.map((event, i) => (
+2 -3
View File
@@ -1,5 +1,6 @@
import Link from "next/link";
import { ImagePlaceholder } from "@/components/ImagePlaceholder";
import { ArrowIcon } from "@/components/ArrowIcon";
import type { ImageMood } from "@/lib/angebote";
type AngebotCardProps = {
@@ -42,9 +43,7 @@ export function AngebotCard({
<p className="mt-2 text-sm leading-relaxed text-white/90">{tagline}</p>
<span className="mt-4 inline-flex items-center gap-1.5 text-sm font-medium text-white/95">
Mehr erfahren
<svg width="14" height="10" viewBox="0 0 14 10" fill="none" aria-hidden="true" className="transition-transform duration-300 group-hover:translate-x-1">
<path d="M1 5h11.5M8 1l4.5 4L8 9" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<ArrowIcon />
</span>
</div>
</div>
+15
View File
@@ -0,0 +1,15 @@
/** The small forward-nudging arrow shared by ArrowLink and the "Mehr erfahren" affordance inside AngebotCard/EventTeaserCard (which can't use ArrowLink directly since those are already whole-tile links, and nesting an anchor inside an anchor is invalid). Purely visual — animates on the parent's `group-hover`. */
export function ArrowIcon({ className = "" }: { className?: string }) {
return (
<svg
width="14"
height="10"
viewBox="0 0 14 10"
fill="none"
aria-hidden="true"
className={`shrink-0 transition-transform duration-300 group-hover:translate-x-1 ${className}`}
>
<path d="M1 5h11.5M8 1l4.5 4L8 9" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
+43
View File
@@ -0,0 +1,43 @@
import Link from "next/link";
import type { ReactNode } from "react";
import { ArrowIcon } from "@/components/ArrowIcon";
type Tone = "mauve" | "plum" | "white";
type Size = "sm" | "base";
const tones: Record<Tone, string> = {
mauve: "text-anouma-mauve-dark",
plum: "text-anouma-plum",
white: "text-white",
};
const sizes: Record<Size, string> = {
sm: "gap-1.5 text-sm",
base: "gap-2 text-base",
};
type ArrowLinkProps = {
href: string;
children: ReactNode;
tone?: Tone;
size?: Size;
className?: string;
};
/**
* The site's one secondary-action pattern: a text link with a small arrow
* that nudges forward on hover. Used instead of a pill button wherever the
* action isn't the single primary one on screen — keeps most CTAs feeling
* like an invitation to read on rather than another button to click.
*/
export function ArrowLink({ href, children, tone = "mauve", size = "sm", className = "" }: ArrowLinkProps) {
return (
<Link
href={href}
className={`group inline-flex items-center font-medium ${tones[tone]} ${sizes[size]} ${className}`}
>
{children}
<ArrowIcon />
</Link>
);
}
+9 -22
View File
@@ -1,9 +1,8 @@
"use client";
import { useState, type FormEvent } from "react";
const fieldClass =
"w-full rounded-2xl border border-anouma-taupe/30 bg-background px-5 py-3.5 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
import { track } from "@/lib/analytics/client";
import { TextField, TextAreaField } from "@/components/FormField";
type ContactFormProps = {
toEmail: string;
@@ -31,31 +30,19 @@ export function ContactForm({
const message = String(data.get("message") ?? "");
const subject = encodeURIComponent(`${subjectPrefix} von ${name}`);
const body = encodeURIComponent(`${message}\n\n— ${name} (${email})`);
const body = encodeURIComponent(`${message}\n\n– ${name} (${email})`);
window.location.href = `mailto:${toEmail}?subject=${subject}&body=${body}`;
// Event only — never the form's name/email/message content, see
// lib/analytics/events.ts and the data-minimization requirement.
track("contact_clicked");
setSent(true);
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="name" className="mb-2 block text-sm font-medium text-anouma-plum">
Name
</label>
<input id="name" name="name" type="text" required className={fieldClass} autoComplete="name" />
</div>
<div>
<label htmlFor="email" className="mb-2 block text-sm font-medium text-anouma-plum">
E-Mail
</label>
<input id="email" name="email" type="email" required className={fieldClass} autoComplete="email" />
</div>
<div>
<label htmlFor="message" className="mb-2 block text-sm font-medium text-anouma-plum">
Deine Nachricht
</label>
<textarea id="message" name="message" required rows={5} className={fieldClass} />
</div>
<TextField label="Name" id="name" name="name" type="text" required autoComplete="name" />
<TextField label="E-Mail" id="email" name="email" type="email" required autoComplete="email" />
<TextAreaField label="Deine Nachricht" id="message" name="message" required rows={5} />
<button
type="submit"
className="inline-flex items-center justify-center rounded-full bg-anouma-mauve-dark px-7 py-3.5 text-sm font-medium tracking-wide text-white transition-colors duration-300 hover:bg-anouma-plum"
+2 -16
View File
@@ -1,6 +1,7 @@
import Link from "next/link";
import type { Event } from "@/payload-types";
import { formatEventDateParts, formatTimeRange } from "@/lib/format";
import { ArrowIcon } from "@/components/ArrowIcon";
export function EventTeaserCard({ event }: { event: Event }) {
const { day, month } = formatEventDateParts(event.date);
@@ -21,22 +22,7 @@ export function EventTeaserCard({ event }: { event: Event }) {
{event.location && <p className="text-sm text-anouma-plum/80">{event.location}</p>}
<span className="mt-2 inline-flex items-center gap-1.5 text-sm font-medium text-anouma-mauve-dark">
Mehr erfahren
<svg
width="12"
height="9"
viewBox="0 0 14 10"
fill="none"
aria-hidden="true"
className="transition-transform duration-300 group-hover:translate-x-1"
>
<path
d="M1 5h11.5M8 1l4.5 4L8 9"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<ArrowIcon />
</span>
</div>
</Link>
+1 -1
View File
@@ -16,7 +16,7 @@ export async function Footer() {
{siteConfig.name.toUpperCase()}
</Link>
<p className="mt-4 max-w-xs text-sm leading-relaxed text-anouma-cream-light/90">
Räume für Verbindung — mit dir selbst, miteinander und mit der Natur.
Räume für Verbindung – mit dir selbst, miteinander und mit der Natur.
</p>
</div>
+62
View File
@@ -0,0 +1,62 @@
import type { InputHTMLAttributes, ReactNode, TextareaHTMLAttributes } from "react";
/**
* The one field style used by every form on the site (previously each form
* declared its own near-identical `fieldClass` constant with small drifts in
* padding/size/background — see ContactForm, auth/*, meeting/* history).
* Functionality is untouched: both components just spread the native
* input/textarea props through, so controlled and uncontrolled forms,
* validation attributes, autoComplete, etc. all keep working exactly as
* before.
*/
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";
function FieldLabel({ label, htmlFor, hidden = false }: { label: string; htmlFor: string; hidden?: boolean }) {
return (
<label htmlFor={htmlFor} className={hidden ? "sr-only" : "mb-2 block text-sm font-medium text-anouma-plum"}>
{label}
</label>
);
}
type TextFieldProps = InputHTMLAttributes<HTMLInputElement> & {
label: string;
id: string;
/** Keeps the label for screen readers only — for compact inline fields identified by a placeholder instead (e.g. ResendVerificationButton's email prompt). */
hideLabel?: boolean;
wrapperClassName?: string;
};
export function TextField({ label, id, hideLabel = false, className = "", wrapperClassName = "", ...inputProps }: TextFieldProps) {
return (
<div className={wrapperClassName}>
<FieldLabel label={label} htmlFor={id} hidden={hideLabel} />
<input id={id} className={`${fieldClass} ${className}`} {...inputProps} />
</div>
);
}
type TextAreaFieldProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
label: string;
id: string;
wrapperClassName?: string;
};
export function TextAreaField({ label, id, className = "", wrapperClassName = "", ...textareaProps }: TextAreaFieldProps) {
return (
<div className={wrapperClassName}>
<FieldLabel label={label} htmlFor={id} />
<textarea id={id} className={`${fieldClass} ${className}`} {...textareaProps} />
</div>
);
}
/** Shared error-message treatment (`role="alert"`) used below every form field/submit button. */
export function FormError({ children, className = "" }: { children: ReactNode; className?: string }) {
return (
<p role="alert" className={`text-sm text-red-700 ${className}`}>
{children}
</p>
);
}
+1 -1
View File
@@ -40,7 +40,7 @@ export function Hero({ eyebrow, title, children, actions, mood = "rose", imageSr
<div className="relative mx-auto aspect-[4/5] w-full max-w-md animate-fade-in [animation-delay:200ms] lg:max-w-none">
<ImagePlaceholder
mood={mood}
label="Anouma — warme, naturverbundene Begleitung"
label="Anouma – warme, naturverbundene Begleitung"
src={imageSrc}
alt={imageAlt}
priority
+57 -48
View File
@@ -6,6 +6,8 @@ import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { groupOffersByCategory } from "@/lib/angebote";
import { mainNav, ctaNav, siteConfig } from "@/lib/site";
import { track } from "@/lib/analytics/client";
import { OrganicBlob } from "@/components/OrganicBlob";
import type { Offer } from "@/payload-types";
export function Navbar({ offers, customerName }: { offers: Offer[]; customerName?: string | null }) {
@@ -63,7 +65,7 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
}
return (
<header className="sticky top-0 z-50 border-b border-anouma-taupe/15 bg-background/85 backdrop-blur-md">
<header className="sticky top-0 z-50 border-b border-anouma-taupe/15 bg-background">
<div className="mx-auto flex h-20 max-w-6xl items-center justify-between px-6 sm:px-8 lg:px-12">
<Link
href="/"
@@ -108,52 +110,55 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
transition={{ duration: 0.22, ease: "easeOut" }}
className="absolute left-1/2 top-full z-50 mt-3 w-[min(640px,92vw)] -translate-x-1/2 rounded-3xl border border-anouma-taupe/15 bg-background p-8 shadow-xl shadow-anouma-plum/10"
className="absolute left-1/2 top-full z-50 mt-3 w-[min(640px,92vw)] -translate-x-1/2 overflow-hidden rounded-3xl border border-anouma-taupe/15 bg-background p-8 shadow-lg shadow-anouma-plum/10"
>
<div className="grid grid-cols-2 gap-8 sm:grid-cols-4">
{groups.map((group) => {
const isSingle = group.offers.length === 1;
const groupHref = isSingle
? `/angebote/${group.offers[0].slug}`
: group.href;
return (
<div key={group.category}>
<Link
href={groupHref}
className="font-serif text-lg font-medium text-anouma-plum hover:text-anouma-mauve-dark"
>
{group.label}
</Link>
{!isSingle && (
<ul className="mt-3 space-y-2">
{group.offers.map((offer) => (
<li key={offer.slug}>
<Link
href={`/angebote/${offer.slug}`}
className="text-sm text-anouma-plum transition-colors hover:text-anouma-mauve-dark"
>
{offer.title}
</Link>
</li>
))}
</ul>
)}
{isSingle && (
<p className="mt-3 text-sm leading-relaxed text-anouma-plum">
{group.offers[0].shortDescription}
</p>
)}
</div>
);
})}
</div>
<div className="mt-7 border-t border-anouma-taupe/15 pt-5">
<Link
href="/angebote"
className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4"
>
Alle Angebote im Überblick
</Link>
<OrganicBlob tone="cream" className="-right-16 -top-16 h-52 w-52 opacity-30" />
<div className="relative">
<div className="grid grid-cols-2 gap-8 sm:grid-cols-4">
{groups.map((group) => {
const isSingle = group.offers.length === 1;
const groupHref = isSingle
? `/angebote/${group.offers[0].slug}`
: group.href;
return (
<div key={group.category}>
<Link
href={groupHref}
className="font-serif text-lg font-medium text-anouma-plum hover:text-anouma-mauve-dark"
>
{group.label}
</Link>
{!isSingle && (
<ul className="mt-3 space-y-2">
{group.offers.map((offer) => (
<li key={offer.slug}>
<Link
href={`/angebote/${offer.slug}`}
className="text-sm text-anouma-plum transition-colors hover:text-anouma-mauve-dark"
>
{offer.title}
</Link>
</li>
))}
</ul>
)}
{isSingle && (
<p className="mt-3 text-sm leading-relaxed text-anouma-plum">
{group.offers[0].shortDescription}
</p>
)}
</div>
);
})}
</div>
<div className="mt-7 border-t border-anouma-taupe/15 pt-5">
<Link
href="/angebote"
className="text-sm font-medium text-anouma-mauve-dark underline underline-offset-4"
>
Alle Angebote im Überblick
</Link>
</div>
</div>
</motion.div>
)}
@@ -163,8 +168,9 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
<Link
key={item.href}
href={item.href}
className={`text-[15px] font-medium transition-colors hover:text-anouma-mauve-dark ${
pathname === item.href ? "text-anouma-mauve-dark" : "text-anouma-plum/90"
onClick={() => track("navigation_clicked", { path: item.href })}
className={`relative pb-1 text-[15px] font-medium transition-colors after:absolute after:bottom-0 after:left-0 after:h-px after:bg-anouma-mauve-dark after:transition-all after:duration-300 after:content-[''] hover:text-anouma-mauve-dark hover:after:w-full ${
pathname === item.href ? "text-anouma-mauve-dark after:w-full" : "text-anouma-plum/90 after:w-0"
}`}
>
{item.title}
@@ -182,6 +188,7 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
</Link>
<Link
href={ctaNav.href}
onClick={() => track("navigation_clicked", { path: ctaNav.href })}
className="rounded-full bg-anouma-mauve-dark px-6 py-2.5 text-sm font-medium text-white transition-colors hover:bg-anouma-plum"
>
{ctaNav.title}
@@ -300,6 +307,7 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
<Link
key={item.href}
href={item.href}
onClick={() => track("navigation_clicked", { path: item.href })}
className="border-b border-anouma-taupe/10 py-4 text-lg font-medium text-anouma-plum"
>
{item.title}
@@ -314,6 +322,7 @@ export function Navbar({ offers, customerName }: { offers: Offer[]; customerName
</Link>
<Link
href={ctaNav.href}
onClick={() => track("navigation_clicked", { path: ctaNav.href })}
className="mt-6 rounded-full bg-anouma-mauve-dark px-6 py-4 text-center text-base font-medium text-white"
>
{ctaNav.title}
+22 -3
View File
@@ -9,10 +9,23 @@ type PageHeaderProps = {
title: string;
lead?: ReactNode;
crumbs?: Crumb[];
/** Varies the header's decorative blob so every interior page doesn't open with the exact same rose-top-right treatment. */
blobTone?: "rose" | "sage" | "cream" | "mauve";
blobPosition?: "top-right" | "top-left";
/** Shorter vertical padding for pages that don't need much ceremony (e.g. Kontakt). */
compact?: boolean;
};
/** 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) {
export async function PageHeader({
eyebrow,
title,
lead,
crumbs,
blobTone = "rose",
blobPosition = "top-right",
compact = false,
}: PageHeaderProps) {
const breadcrumbData = crumbs?.length
? await (async () => {
const siteUrl = await getSiteUrl();
@@ -24,9 +37,15 @@ export async function PageHeader({ eyebrow, title, lead, crumbs }: PageHeaderPro
})()
: null;
const blobPositionClass = blobPosition === "top-left" ? "-left-20 -top-20" : "-right-20 -top-20";
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">
<OrganicBlob tone="rose" className="-right-20 -top-20 h-72 w-72" />
<section
className={`relative overflow-hidden border-b border-anouma-taupe/10 bg-anouma-cream-light ${
compact ? "pb-10 pt-10 sm:pb-12 sm:pt-12" : "pb-16 pt-14 sm:pb-20 sm:pt-20"
}`}
>
<OrganicBlob tone={blobTone} className={`${blobPositionClass} h-72 w-72`} />
<div className="relative mx-auto max-w-6xl px-6 sm:px-8 lg:px-12">
{breadcrumbData && <JsonLd data={breadcrumbData} />}
{crumbs && <Breadcrumbs items={crumbs} />}
+1 -1
View File
@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
export function PlaceholderNote({ children }: { children: ReactNode }) {
return (
<div className="rounded-2xl border border-dashed border-anouma-dustyrose/50 bg-anouma-rose-pale/20 px-5 py-4 text-sm leading-relaxed text-anouma-plum">
<span className="font-medium">Platzhalter — noch zu ergänzen:</span> {children}
<span className="font-medium">Platzhalter – noch zu ergänzen:</span> {children}
</div>
);
}
+4 -20
View File
@@ -3,9 +3,7 @@
import { useState, type FormEvent } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { ResendVerificationButton } from "./ResendVerificationButton";
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";
import { TextField, FormError } from "@/components/FormField";
export function LoginForm() {
const router = useRouter();
@@ -44,23 +42,9 @@ export function LoginForm() {
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="email" className="mb-2 block text-sm font-medium text-anouma-plum">
E-Mail
</label>
<input id="email" name="email" type="email" required className={fieldClass} autoComplete="email" />
</div>
<div>
<label htmlFor="password" className="mb-2 block text-sm font-medium text-anouma-plum">
Passwort
</label>
<input id="password" name="password" type="password" required className={fieldClass} autoComplete="current-password" />
</div>
{error && (
<p role="alert" className="text-sm text-red-700">
{error}
</p>
)}
<TextField label="E-Mail" id="email" name="email" type="email" required autoComplete="email" />
<TextField label="Passwort" id="password" name="password" type="password" required autoComplete="current-password" />
{error && <FormError>{error}</FormError>}
<button
type="submit"
disabled={loading}
+3 -15
View File
@@ -2,9 +2,7 @@
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
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";
import { TextField } from "@/components/FormField";
export function ProfileForm({ id, name, phone }: { id: number; name: string; phone: string }) {
const router = useRouter();
@@ -35,18 +33,8 @@ export function ProfileForm({ id, name, phone }: { id: number; name: string; pho
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="name" className="mb-2 block text-sm font-medium text-anouma-plum">
Name
</label>
<input id="name" name="name" type="text" required defaultValue={name} className={fieldClass} />
</div>
<div>
<label htmlFor="phone" className="mb-2 block text-sm font-medium text-anouma-plum">
Telefonnummer (optional)
</label>
<input id="phone" name="phone" type="tel" defaultValue={phone} className={fieldClass} />
</div>
<TextField label="Name" id="name" name="name" type="text" required defaultValue={name} />
<TextField label="Telefonnummer (optional)" id="phone" name="phone" type="tel" defaultValue={phone} />
<button
type="submit"
disabled={status === "saving"}
+13 -34
View File
@@ -2,9 +2,7 @@
import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
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";
import { TextField, FormError } from "@/components/FormField";
export function RegisterForm() {
const router = useRouter();
@@ -54,37 +52,18 @@ export function RegisterForm() {
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="name" className="mb-2 block text-sm font-medium text-anouma-plum">
Name
</label>
<input id="name" name="name" type="text" required className={fieldClass} autoComplete="name" />
</div>
<div>
<label htmlFor="email" className="mb-2 block text-sm font-medium text-anouma-plum">
E-Mail
</label>
<input id="email" name="email" type="email" required className={fieldClass} autoComplete="email" />
</div>
<div>
<label htmlFor="password" className="mb-2 block text-sm font-medium text-anouma-plum">
Passwort
</label>
<input
id="password"
name="password"
type="password"
required
minLength={8}
className={fieldClass}
autoComplete="new-password"
/>
</div>
{error && (
<p role="alert" className="text-sm text-red-700">
{error}
</p>
)}
<TextField label="Name" id="name" name="name" type="text" required autoComplete="name" />
<TextField label="E-Mail" id="email" name="email" type="email" required autoComplete="email" />
<TextField
label="Passwort"
id="password"
name="password"
type="password"
required
minLength={8}
autoComplete="new-password"
/>
{error && <FormError>{error}</FormError>}
<button
type="submit"
disabled={loading}
+8 -6
View File
@@ -1,9 +1,7 @@
"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";
import { useId, useState, type FormEvent } from "react";
import { TextField } from "@/components/FormField";
async function callResend(email?: string): Promise<{ ok: boolean; error?: string }> {
try {
@@ -44,6 +42,7 @@ export function ResendVerificationButton({
const [open, setOpen] = useState(false);
const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle");
const [error, setError] = useState<string | null>(null);
const emailFieldId = useId();
async function resendForSession() {
setStatus("sending");
@@ -108,12 +107,15 @@ export function ResendVerificationButton({
<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
<TextField
label="Deine E-Mail-Adresse"
hideLabel
id={emailFieldId}
type="email"
name="email"
required
placeholder="Deine E-Mail-Adresse"
className={`${fieldClass} max-w-xs flex-1`}
wrapperClassName="max-w-xs flex-1"
/>
<button
type="submit"
+2 -2
View File
@@ -97,7 +97,7 @@ export function BookingCard({ booking }: { booking: BookingRequest }) {
</a>
)}
{booking.appointmentType === "onsite" && (
<p className="text-sm text-anouma-plum/80">Vor Ort — Details siehe Bestätigungs-E-Mail.</p>
<p className="text-sm text-anouma-plum/80">Vor Ort – Details siehe Bestätigungs-E-Mail.</p>
)}
</div>
)}
@@ -107,7 +107,7 @@ export function BookingCard({ booking }: { booking: BookingRequest }) {
type="button"
disabled={busy}
onClick={() => callAction(`/api/booking/${booking.id}/cancel`)}
className="mt-4 text-sm font-medium text-anouma-plum/60 underline underline-offset-4 hover:text-red-700"
className="-ml-2 mt-4 inline-flex min-h-11 items-center rounded-full px-2 text-sm font-medium text-anouma-plum/60 underline underline-offset-4 transition-colors hover:bg-anouma-cream-light hover:text-red-700"
>
Termin stornieren
</button>
+14 -18
View File
@@ -3,6 +3,8 @@
import { useEffect, useState } from "react";
import { MonthCalendar, type CalendarMarker } from "./MonthCalendar";
import { ResendVerificationButton } from "@/components/auth/ResendVerificationButton";
import { TextAreaField, FormError } from "@/components/FormField";
import { track } from "@/lib/analytics/client";
type SlotsResponse = { durationMinutes: number; slots: Record<string, { start: string; end: string }[]> };
@@ -116,6 +118,8 @@ export function BookingWidget({
return;
}
setResult({ ok: true });
// Event only — never the offer/date/message content.
track("booking_started");
} catch {
setResult({ ok: false, error: "Verbindung fehlgeschlagen." });
setSubmitting(false);
@@ -144,7 +148,7 @@ export function BookingWidget({
key={slot.start}
type="button"
onClick={() => setSelectedSlot(slot.start)}
className={`rounded-full border px-4 py-2 text-sm font-medium transition-colors ${
className={`inline-flex min-h-11 items-center justify-center rounded-full border px-4 text-sm font-medium transition-colors ${
selectedSlot === slot.start
? "border-anouma-mauve-dark bg-anouma-mauve-dark text-white"
: "border-anouma-taupe/40 text-anouma-plum hover:border-anouma-mauve-dark"
@@ -189,24 +193,16 @@ export function BookingWidget({
</div>
</fieldset>
<div className="mt-4">
<label htmlFor="booking-message" className="mb-1.5 block text-sm font-medium text-anouma-plum">
Nachricht (optional)
</label>
<textarea
id="booking-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={3}
className="w-full rounded-2xl border border-anouma-taupe/30 bg-white px-4 py-3 text-sm text-anouma-plum focus:border-anouma-mauve-dark focus:outline-none"
/>
</div>
<TextAreaField
label="Nachricht (optional)"
id="booking-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={3}
wrapperClassName="mt-4"
/>
{result?.error && (
<p role="alert" className="mt-3 text-sm text-red-700">
{result.error}
</p>
)}
{result?.error && <FormError className="mt-3">{result.error}</FormError>}
<button
type="button"
+5 -5
View File
@@ -66,13 +66,13 @@ export function MonthCalendar({
const today = dateKey(new Date());
return (
<div className="rounded-3xl bg-white p-6">
<div className="rounded-3xl bg-white p-4 sm:p-6">
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1))}
aria-label="Vorheriger Monat"
className="flex h-9 w-9 items-center justify-center rounded-full text-anouma-plum hover:bg-anouma-cream-light"
className="flex h-11 w-11 items-center justify-center rounded-full text-anouma-plum hover:bg-anouma-cream-light"
>
‹
</button>
@@ -81,7 +81,7 @@ export function MonthCalendar({
type="button"
onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1))}
aria-label="Nächster Monat"
className="flex h-9 w-9 items-center justify-center rounded-full text-anouma-plum hover:bg-anouma-cream-light"
className="flex h-11 w-11 items-center justify-center rounded-full text-anouma-plum hover:bg-anouma-cream-light"
>
›
</button>
@@ -106,9 +106,9 @@ export function MonthCalendar({
key={j}
type="button"
onClick={() => onSelectDay?.(day)}
className={`flex aspect-square flex-col items-center justify-center rounded-xl text-sm transition-colors ${
className={`flex aspect-square min-h-11 flex-col items-center justify-center rounded-xl text-sm transition-colors ${
isSelected ? "bg-anouma-mauve-dark text-white" : "text-anouma-plum hover:bg-anouma-cream-light"
} ${key === today && !isSelected ? "font-semibold" : ""}`}
} ${key === today && !isSelected ? "font-semibold ring-1 ring-inset ring-anouma-mauve-dark/50" : ""}`}
>
<span>{day.getDate()}</span>
{dayMarkers.length > 0 && (
+21 -37
View File
@@ -1,9 +1,7 @@
"use client";
import { useState, type FormEvent } from "react";
const fieldClass =
"w-full rounded-2xl border border-anouma-taupe/30 bg-white px-5 py-3 text-base text-anouma-plum placeholder:text-anouma-plum/50 focus:border-anouma-mauve-dark focus:outline-none";
import { TextField, FormError } from "@/components/FormField";
export function RegisterForm({ slug }: { slug: string }) {
const [name, setName] = useState("");
@@ -37,46 +35,32 @@ export function RegisterForm({ slug }: { slug: string }) {
if (status === "done") {
return (
<p className="rounded-2xl bg-anouma-cream-light p-5 text-sm leading-relaxed text-anouma-plum" role="status">
Danke für deine Anmeldung — wir freuen uns auf dich!
Danke für deine Anmeldung – wir freuen uns auf dich!
</p>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="reg-name" className="mb-1.5 block text-sm font-medium text-anouma-plum">
Name
</label>
<input
id="reg-name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
className={fieldClass}
autoComplete="name"
/>
</div>
<div>
<label htmlFor="reg-email" className="mb-1.5 block text-sm font-medium text-anouma-plum">
E-Mail
</label>
<input
id="reg-email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className={fieldClass}
autoComplete="email"
/>
</div>
{error && (
<p role="alert" className="text-sm text-red-700">
{error}
</p>
)}
<TextField
label="Name"
id="reg-name"
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="name"
/>
<TextField
label="E-Mail"
id="reg-email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
/>
{error && <FormError>{error}</FormError>}
<button
type="submit"
disabled={status === "loading"}
+1 -1
View File
@@ -98,7 +98,7 @@ export function bookingRejectedEmail(args: BaseArgs): { subject: string; html: s
{ label: "Datum", value: args.dateLabel },
{ label: "Uhrzeit", value: args.timeLabel },
]),
paragraph("Melde dich gerne für einen neuen Termin — schau einfach wieder in deinem Konto vorbei.", { small: true }),
paragraph("Melde dich gerne für einen neuen Termin – schau einfach wieder in deinem Konto vorbei.", { small: true }),
].join("\n"),
});
return { subject: "Deine Terminanfrage bei ANOUMA", html };
+1 -1
View File
@@ -41,7 +41,7 @@ export function renderFactBox(rows: { label: string; value: string }[]): string
/** Shared table-based, inline-styled email shell — same design language across all ANOUMA emails. */
export function renderEmailShell(args: { preheader?: string; bodyHtml: string; footerText?: string }): string {
const { bodyHtml, footerText = "Räume für Verbindung — mit dir selbst, miteinander und mit der Natur." } = args;
const { bodyHtml, footerText = "Räume für Verbindung – mit dir selbst, miteinander und mit der Natur." } = args;
return `<!doctype html>
<html lang="de">
+1 -1
View File
@@ -76,7 +76,7 @@ export function renderReminderEmailHtml(data: ReminderEmailData): string {
</tr>
<tr>
<td style="padding:20px 32px;background:${colors.cream};text-align:center;">
<span style="color:${colors.plum};font-size:12px;">Räume für Verbindung — mit dir selbst, miteinander und mit der Natur.</span>
<span style="color:${colors.plum};font-size:12px;">Räume für Verbindung – mit dir selbst, miteinander und mit der Natur.</span>
</td>
</tr>
</table>
+2 -2
View File
@@ -1,9 +1,9 @@
export const siteConfig = {
name: "Anouma",
domain: "https://anouma.org",
title: "Anouma — Prozessbegleitung, Doula & Gemeinschaft",
title: "Anouma – Prozessbegleitung, Doula & Gemeinschaft",
description:
"Anouma begleitet dich in Lebensphasen, Schwangerschaft und Geburt sowie in Kinder- und Singkreisen — mit Naturverbundenheit, Vertrauen und einem geschützten Raum für deinen eigenen Weg.",
"Anouma begleitet dich in Lebensphasen, Schwangerschaft und Geburt sowie in Kinder- und Singkreisen – mit Naturverbundenheit, Vertrauen und einem geschützten Raum für deinen eigenen Weg.",
locale: "de_DE",
} as const;
+9 -9
View File
@@ -95,7 +95,7 @@ async function seedOffers(payload: Awaited<ReturnType<typeof getPayload>>) {
bookable: true,
price: "auf Anfrage",
durationMinutes: 60,
shortDescription: "Persönliche Einzelbegleitung — vor Ort oder online.",
shortDescription: "Persönliche Einzelbegleitung – vor Ort oder online.",
description: lexicalFromParagraphs(prozessbegleitung.sections[0].paragraphs),
},
];
@@ -138,13 +138,13 @@ async function seedGlobals(payload: Awaited<ReturnType<typeof getPayload>>) {
},
{
title: "Verbindung",
quote: "Räume für Verbindung — mit uns selbst, miteinander und mit der Natur.",
quote: "Räume für Verbindung – mit uns selbst, miteinander und mit der Natur.",
},
{ title: "Natur", quote: "Wir sind nicht nur in der Natur — wir sind ein Teil von ihr." },
{ title: "Natur", quote: "Wir sind nicht nur in der Natur – wir sind ein Teil von ihr." },
],
ctaTitle: "Kennenlernen & Termine vereinbaren",
ctaLead:
"Ich freue mich, von dir zu hören — schreib mir oder buche direkt ein unverbindliches Kennenlerngespräch.",
"Ich freue mich, von dir zu hören – schreib mir oder buche direkt ein unverbindliches Kennenlerngespräch.",
},
});
console.log(" ↺ Global „Startseite“ gesetzt");
@@ -166,7 +166,7 @@ async function seedGlobals(payload: Awaited<ReturnType<typeof getPayload>>) {
slug: "angebote-intro",
data: {
title: "Angebote",
lead: "Jeder Raum ist anders — und darf so genau das sein, was gerade gebraucht wird. Ein Überblick über meine Begleitung für Erwachsene, Kinder und Gemeinschaft.",
lead: "Jeder Raum ist anders – und darf so genau das sein, was gerade gebraucht wird. Ein Überblick über meine Begleitung für Erwachsene, Kinder und Gemeinschaft.",
},
});
console.log(" ↺ Global „Angebote (Einleitung)“ gesetzt");
@@ -186,11 +186,11 @@ async function seedGlobals(payload: Awaited<ReturnType<typeof getPayload>>) {
data: {
eyebrow: "Kontakt",
title: "Ich freue mich, von dir zu hören",
lead: "Ob Frage, Anliegen oder der Wunsch nach einem Kennenlerngespräch — schreib mir gerne.",
lead: "Ob Frage, Anliegen oder der Wunsch nach einem Kennenlerngespräch – schreib mir gerne.",
// Placeholder contact details — replace with the real ones in the admin.
email: "hallo@anouma.org",
phone: "+49 000 00 00 00",
region: "Platzhalter — Ort / Region",
region: "Platzhalter – Ort / Region",
},
});
console.log(" ↺ Global „Kontakt“ gesetzt (mit Platzhalter-Kontaktdaten — bitte im Admin ersetzen)");
@@ -200,11 +200,11 @@ async function seedGlobals(payload: Awaited<ReturnType<typeof getPayload>>) {
data: {
eyebrow: "Termin buchen",
title: "Kennenlernen & Termine vereinbaren",
lead: "Der erste Schritt ist oft der wichtigste. Melde dich gerne — unverbindlich und in deinem Tempo.",
lead: "Der erste Schritt ist oft der wichtigste. Melde dich gerne – unverbindlich und in deinem Tempo.",
steps: [
{
title: "Nachricht schreiben",
text: "Schildere kurz dein Anliegen und welches Angebot dich interessiert — über das Formular unten oder direkt per E-Mail.",
text: "Schildere kurz dein Anliegen und welches Angebot dich interessiert – über das Formular unten oder direkt per E-Mail.",
},
{
title: "Kennenlernen",