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
+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"}