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.
220 lines
8.3 KiB
TypeScript
220 lines
8.3 KiB
TypeScript
"use client";
|
|
|
|
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 }[]> };
|
|
|
|
function dateKey(d: Date) {
|
|
return d.toISOString().slice(0, 10);
|
|
}
|
|
|
|
function fmtTime(iso: string) {
|
|
return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
|
|
}
|
|
function fmtDate(iso: string) {
|
|
return new Date(iso).toLocaleDateString("de-DE", { weekday: "long", day: "2-digit", month: "long" });
|
|
}
|
|
|
|
export function BookingWidget({
|
|
offerSlug,
|
|
isLoggedIn,
|
|
isVerified,
|
|
}: {
|
|
offerSlug: string;
|
|
isLoggedIn: boolean;
|
|
isVerified: boolean;
|
|
}) {
|
|
const [slotsByDay, setSlotsByDay] = useState<SlotsResponse["slots"]>({});
|
|
const [loading, setLoading] = useState(true);
|
|
const [selectedDay, setSelectedDay] = useState<Date | null>(null);
|
|
const [selectedSlot, setSelectedSlot] = useState<string | null>(null);
|
|
const [appointmentType, setAppointmentType] = useState<"onsite" | "online">("onsite");
|
|
const [message, setMessage] = useState("");
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [result, setResult] = useState<{ ok: boolean; error?: string } | null>(null);
|
|
|
|
useEffect(() => {
|
|
const from = new Date();
|
|
const to = new Date(from.getTime() + 28 * 24 * 60 * 60 * 1000);
|
|
fetch(`/api/booking/slots?offer=${encodeURIComponent(offerSlug)}&from=${dateKey(from)}&to=${dateKey(to)}`)
|
|
.then((res) => res.json())
|
|
.then((data: SlotsResponse) => setSlotsByDay(data.slots ?? {}))
|
|
.finally(() => setLoading(false));
|
|
}, [offerSlug]);
|
|
|
|
if (result?.ok) {
|
|
return (
|
|
<div className="rounded-3xl bg-anouma-cream-light p-8">
|
|
<p className="text-xs font-medium uppercase tracking-wide text-anouma-dustyrose">Deine Anfrage</p>
|
|
{selectedSlot && (
|
|
<p className="mt-2 font-serif text-2xl font-medium text-anouma-plum">
|
|
{fmtDate(selectedSlot)}, {fmtTime(selectedSlot)} Uhr
|
|
</p>
|
|
)}
|
|
<p className="mt-4 text-sm font-medium text-anouma-plum">Status: Termin vorgeschlagen</p>
|
|
<p className="mt-3 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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!isLoggedIn) {
|
|
return (
|
|
<div className="rounded-3xl bg-anouma-cream-light p-8 text-center">
|
|
<p className="text-base text-anouma-plum">
|
|
Melde dich an oder erstelle ein Konto, um einen Termin anzufragen.
|
|
</p>
|
|
<div className="mt-5 flex flex-wrap justify-center gap-3">
|
|
<a
|
|
href={`/login?next=/angebote/${offerSlug}`}
|
|
className="rounded-full bg-anouma-mauve-dark px-6 py-2.5 text-sm font-medium text-white hover:bg-anouma-plum"
|
|
>
|
|
Anmelden
|
|
</a>
|
|
<a
|
|
href="/registrieren"
|
|
className="rounded-full border border-anouma-mauve-dark/40 px-6 py-2.5 text-sm font-medium text-anouma-plum hover:bg-white"
|
|
>
|
|
Konto erstellen
|
|
</a>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!isVerified) {
|
|
return (
|
|
<div className="rounded-3xl bg-anouma-cream-light p-8 text-center">
|
|
<p className="text-base text-anouma-plum">Bitte bestätige zuerst deine E-Mail-Adresse.</p>
|
|
<p className="mt-2 text-sm text-anouma-plum/70">Erst danach kannst du einen Termin anfragen.</p>
|
|
<ResendVerificationButton variant="session" className="mt-5 flex justify-center" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const markers: CalendarMarker[] = Object.keys(slotsByDay).map((key) => ({ date: new Date(key), status: "public" }));
|
|
const daySlots = selectedDay ? (slotsByDay[dateKey(selectedDay)] ?? []) : [];
|
|
|
|
async function submit() {
|
|
if (!selectedSlot) return;
|
|
setSubmitting(true);
|
|
setResult(null);
|
|
try {
|
|
const res = await fetch("/api/booking/request", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify({ offerSlug, start: selectedSlot, appointmentType, message }),
|
|
});
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) {
|
|
setResult({ ok: false, error: data.error || "Anfrage fehlgeschlagen." });
|
|
setSubmitting(false);
|
|
return;
|
|
}
|
|
setResult({ ok: true });
|
|
// Event only — never the offer/date/message content.
|
|
track("booking_started");
|
|
} catch {
|
|
setResult({ ok: false, error: "Verbindung fehlgeschlagen." });
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
if (loading) {
|
|
return <p className="text-sm text-anouma-plum/70">Verfügbare Termine werden geladen …</p>;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,340px)_1fr]">
|
|
<MonthCalendar markers={markers} onSelectDay={setSelectedDay} selectedDay={selectedDay} />
|
|
<div>
|
|
{!selectedDay && <p className="text-sm text-anouma-plum/70">Wähle einen markierten Tag, um freie Zeiten zu sehen.</p>}
|
|
{selectedDay && daySlots.length === 0 && (
|
|
<p className="text-sm text-anouma-plum/70">An diesem Tag ist leider kein Termin frei.</p>
|
|
)}
|
|
{selectedDay && daySlots.length > 0 && (
|
|
<div>
|
|
<p className="text-sm font-medium text-anouma-plum">{fmtDate(daySlots[0].start)}</p>
|
|
<div className="mt-3 flex flex-wrap gap-2">
|
|
{daySlots.map((slot) => (
|
|
<button
|
|
key={slot.start}
|
|
type="button"
|
|
onClick={() => setSelectedSlot(slot.start)}
|
|
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"
|
|
}`}
|
|
>
|
|
{fmtTime(slot.start)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{selectedSlot && (
|
|
<div className="rounded-3xl bg-anouma-cream-light p-6">
|
|
<p className="text-sm font-medium text-anouma-plum">
|
|
Ausgewählt: {fmtDate(selectedSlot)}, {fmtTime(selectedSlot)} Uhr
|
|
</p>
|
|
|
|
<fieldset className="mt-4">
|
|
<legend className="text-sm font-medium text-anouma-plum">Terminart</legend>
|
|
<div className="mt-2 flex gap-4">
|
|
<label className="flex items-center gap-2 text-sm text-anouma-plum">
|
|
<input
|
|
type="radio"
|
|
name="appointmentType"
|
|
checked={appointmentType === "onsite"}
|
|
onChange={() => setAppointmentType("onsite")}
|
|
/>
|
|
Vor Ort
|
|
</label>
|
|
<label className="flex items-center gap-2 text-sm text-anouma-plum">
|
|
<input
|
|
type="radio"
|
|
name="appointmentType"
|
|
checked={appointmentType === "online"}
|
|
onChange={() => setAppointmentType("online")}
|
|
/>
|
|
Online
|
|
</label>
|
|
</div>
|
|
</fieldset>
|
|
|
|
<TextAreaField
|
|
label="Nachricht (optional)"
|
|
id="booking-message"
|
|
value={message}
|
|
onChange={(e) => setMessage(e.target.value)}
|
|
rows={3}
|
|
wrapperClassName="mt-4"
|
|
/>
|
|
|
|
{result?.error && <FormError className="mt-3">{result.error}</FormError>}
|
|
|
|
<button
|
|
type="button"
|
|
onClick={submit}
|
|
disabled={submitting}
|
|
className="mt-5 rounded-full bg-anouma-mauve-dark px-7 py-3 text-sm font-medium text-white hover:bg-anouma-plum disabled:opacity-60"
|
|
>
|
|
{submitting ? "Wird gesendet …" : "Terminanfrage senden"}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|