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.
124 lines
4.9 KiB
TypeScript
124 lines
4.9 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { BookingStatusBadge, resolveDisplayStatus } from "./BookingStatusBadge";
|
||
import type { BookingRequest, Event, Offer } from "@/payload-types";
|
||
|
||
function fmtDate(iso: string) {
|
||
return new Date(iso).toLocaleDateString("de-DE", { weekday: "long", day: "2-digit", month: "long", year: "numeric" });
|
||
}
|
||
function fmtTime(iso: string) {
|
||
return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
||
}
|
||
|
||
export function BookingCard({ booking }: { booking: BookingRequest }) {
|
||
const router = useRouter();
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const offer = typeof booking.offer === "object" ? (booking.offer as Offer) : null;
|
||
const event = typeof booking.linkedEvent === "object" ? (booking.linkedEvent as Event) : null;
|
||
const displayStatus = resolveDisplayStatus(booking.status, booking.date);
|
||
const hasAlternative = booking.status === "pending" && Boolean(booking.proposedAlternative?.date);
|
||
|
||
async function callAction(path: string) {
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
const res = await fetch(path, { method: "POST", credentials: "include" });
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
setError(data.error || "Aktion fehlgeschlagen.");
|
||
setBusy(false);
|
||
return;
|
||
}
|
||
router.refresh();
|
||
} catch {
|
||
setError("Verbindung fehlgeschlagen.");
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||
<div>
|
||
<h3 className="font-serif text-xl font-medium text-anouma-plum">{offer?.title ?? "Termin"}</h3>
|
||
<p className="mt-1 text-sm text-anouma-plum/80">
|
||
{fmtDate(booking.date)} · {fmtTime(booking.startTime)} – {fmtTime(booking.endTime)}
|
||
</p>
|
||
</div>
|
||
<BookingStatusBadge status={displayStatus} />
|
||
</div>
|
||
|
||
{booking.status === "pending" && !hasAlternative && (
|
||
<p className="mt-4 rounded-2xl bg-anouma-cream-light p-4 text-sm leading-relaxed text-anouma-plum">
|
||
Der Termin ist noch nicht verbindlich. Du erhältst eine E-Mail, sobald Anna den Termin bestätigt.
|
||
</p>
|
||
)}
|
||
|
||
{hasAlternative && booking.proposedAlternative && (
|
||
<div className="mt-4 rounded-2xl bg-anouma-cream-light p-4">
|
||
<p className="text-xs font-medium uppercase tracking-wide text-anouma-plum/70">Alternativer Termin</p>
|
||
<p className="mt-1 text-base text-anouma-plum">
|
||
{fmtDate(booking.proposedAlternative.date!)} · {fmtTime(booking.proposedAlternative.startTime!)} –{" "}
|
||
{fmtTime(booking.proposedAlternative.endTime!)}
|
||
</p>
|
||
<div className="mt-3 flex flex-wrap gap-3">
|
||
<button
|
||
type="button"
|
||
disabled={busy}
|
||
onClick={() => callAction(`/api/booking/${booking.id}/accept-alternative`)}
|
||
className="rounded-full bg-anouma-mauve-dark px-5 py-2.5 text-sm font-medium text-white hover:bg-anouma-plum disabled:opacity-60"
|
||
>
|
||
Termin annehmen
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={busy}
|
||
onClick={() => callAction(`/api/booking/${booking.id}/cancel`)}
|
||
className="rounded-full border border-anouma-mauve-dark/40 px-5 py-2.5 text-sm font-medium text-anouma-plum hover:bg-anouma-cream-light disabled:opacity-60"
|
||
>
|
||
Anderen Termin anfragen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{booking.status === "confirmed" && (
|
||
<div className="mt-4 space-y-3">
|
||
{booking.appointmentType === "online" && event && (
|
||
<a
|
||
href={`/termine/${event.slug}/beitreten`}
|
||
className="inline-flex rounded-full bg-anouma-mauve-dark px-5 py-2.5 text-sm font-medium text-white hover:bg-anouma-plum"
|
||
>
|
||
Video-Call betreten
|
||
</a>
|
||
)}
|
||
{booking.appointmentType === "onsite" && (
|
||
<p className="text-sm text-anouma-plum/80">Vor Ort – Details siehe Bestätigungs-E-Mail.</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{(booking.status === "pending" || booking.status === "confirmed") && displayStatus !== "past" && !hasAlternative && (
|
||
<button
|
||
type="button"
|
||
disabled={busy}
|
||
onClick={() => callAction(`/api/booking/${booking.id}/cancel`)}
|
||
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>
|
||
)}
|
||
|
||
{error && (
|
||
<p role="alert" className="mt-3 text-sm text-red-700">
|
||
{error}
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|