Files
Calbook/components/booking/public-booking-flow.tsx

1653 lines
61 KiB
TypeScript

"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
addMonths,
eachDayOfInterval,
endOfMonth,
endOfWeek,
format,
isSameDay,
isSameMonth,
startOfMonth,
startOfWeek,
subMonths
} from "date-fns";
import { de } from "date-fns/locale";
import { formatInTimeZone } from "date-fns-tz";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
ArrowRight,
CalendarDays,
CheckCircle2,
ChevronLeft,
ChevronRight,
Clock,
Globe2,
Loader2,
User
} from "lucide-react";
import { PublicFooter } from "@/components/layout/public-footer";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import type { PublicBookingInitialConfig } from "@/lib/public-booking-config";
import { ALL_IANA_TIMEZONES } from "@/lib/all-timezones";
type Staff = {
id: string;
name: string;
slug: string;
bio: string | null;
avatarUrl: string | null;
};
type SlotsResponse = {
slots: {
staffId: string;
date: string;
slots: string[];
total: number;
}[];
};
type MonthAvailabilityResponse = {
availability?: Record<string, number>;
message?: string;
details?: {
retryAfterSeconds?: number;
};
};
type StaffResponse = {
mitarbeiter: Staff[];
config?: {
companyName?: string;
bookingNoticeText?: string;
defaultDurationMinutes?: number;
headerText?: string;
headerLogoUrl?: string;
footerPrivacyLabel?: string;
footerPrivacyUrl?: string;
footerImprintLabel?: string;
footerImprintUrl?: string;
footerCopyrightText?: string;
defaultTimezone?: string;
};
};
type SlotOption = {
time: string;
staffIds: string[];
};
type RescheduleInfo = {
customerFirstName: string;
customerLastName: string;
customerEmail: string;
customerPhone?: string | null;
notes?: string | null;
date: string;
time: string;
timezone?: string;
staffNames: string[];
};
type BookingResult = {
id: string;
date: string;
time: string;
timezone?: string;
startAt: string;
endAt: string;
meetingUrl: string;
staffNames: string[];
staffCount: number;
rescheduled?: boolean;
};
const contactSchema = z.object({
customerName: z.string().min(1, "Bitte Namen eingeben"),
customerEmail: z.string().email("Bitte gültige E-Mail eingeben"),
customerPhone: z.string().optional(),
notes: z.string().optional()
});
type ContactFormValues = z.infer<typeof contactSchema>;
type Props = {
embedded?: boolean;
rescheduleToken?: string;
initialConfig?: PublicBookingInitialConfig;
preselectedStaffSlug?: string;
};
type TimezoneOption = {
value: string;
label: string;
offsetMinutes: number;
searchable: string;
};
function formatIsoDate(date: Date) {
return format(date, "yyyy-MM-dd");
}
function dayLabel(date: Date) {
return format(date, "dd.MM.yyyy", { locale: de });
}
function detectBrowserTimezone() {
try {
const candidate = Intl.DateTimeFormat().resolvedOptions().timeZone;
if (!candidate) return null;
Intl.DateTimeFormat("de-DE", { timeZone: candidate }).format(new Date());
return candidate;
} catch {
return null;
}
}
function isSupportedTimezone(timezone: string) {
try {
Intl.DateTimeFormat("de-DE", { timeZone: timezone }).format(new Date());
return true;
} catch {
return false;
}
}
function prettyTimezoneLabel(timezone: string) {
return timezone.replaceAll("_", " ");
}
function timezoneUtcOffsetLabel(timezone: string, at: Date = new Date()) {
try {
const rawOffset = formatInTimeZone(at, timezone, "XXX");
const normalized = rawOffset === "Z" ? "+00:00" : rawOffset;
return `UTC${normalized}`;
} catch {
return "UTC";
}
}
function timezoneOffsetMinutes(timezone: string, at: Date = new Date()) {
try {
const rawOffset = formatInTimeZone(at, timezone, "XXX");
const normalized = rawOffset === "Z" ? "+00:00" : rawOffset;
const match = /^([+-])(\d{2}):(\d{2})$/.exec(normalized);
if (!match) return 0;
const sign = match[1] === "-" ? -1 : 1;
const hours = Number(match[2]);
const minutes = Number(match[3]);
return sign * (hours * 60 + minutes);
} catch {
return 0;
}
}
function normalizeTimezoneSearch(value: string) {
return value
.toLowerCase()
.replaceAll("_", " ")
.replaceAll("/", " ")
.replace(/\s+/g, " ")
.trim();
}
function scoreTimezoneOption(option: TimezoneOption, query: string) {
if (!query) return 0;
const normalizedValue = normalizeTimezoneSearch(option.value);
const normalizedLabel = normalizeTimezoneSearch(option.label);
const tokens = query.split(" ").filter(Boolean);
let score = 0;
if (normalizedValue === query) score = Math.max(score, 300);
if (normalizedLabel === query) score = Math.max(score, 290);
if (normalizedValue.startsWith(query)) score = Math.max(score, 250);
if (normalizedLabel.startsWith(query)) score = Math.max(score, 240);
if (normalizedValue.includes(query)) score = Math.max(score, 210);
if (normalizedLabel.includes(query)) score = Math.max(score, 200);
if (tokens.length > 0 && tokens.every((token) => option.searchable.includes(token))) {
score = Math.max(score, 160 - Math.min(40, tokens.length * 4));
}
return score;
}
function splitCustomerName(name: string) {
const parts = name
.trim()
.split(/\s+/)
.filter(Boolean);
const first = parts.shift() ?? "";
const last = parts.join(" ") || "-";
return {
customerFirstName: first,
customerLastName: last
};
}
function buildScopeSlotOptions(input: {
data: SlotsResponse;
selectedScope: string;
allStaffIds: string[];
}) {
const { data, selectedScope, allStaffIds } = input;
if (selectedScope === "all") {
if (allStaffIds.length === 0) return [];
const entryByStaffId = new Map<string, Set<string>>();
for (const entry of data.slots ?? []) {
entryByStaffId.set(entry.staffId, new Set(entry.slots));
}
const sets = allStaffIds.map((id) => entryByStaffId.get(id) ?? new Set<string>());
const firstSet = sets[0];
if (!firstSet) return [];
const intersection = Array.from(firstSet)
.filter((time) => sets.every((set) => set.has(time)))
.sort((a, b) => a.localeCompare(b));
return intersection.map((time) => ({
time,
staffIds: allStaffIds
}));
}
const single = (data.slots ?? []).find((entry) => entry.staffId === selectedScope);
if (!single) return [];
return [...single.slots].sort((a, b) => a.localeCompare(b)).map((time) => ({
time,
staffIds: [selectedScope]
}));
}
export function PublicBookingFlow({
embedded = false,
rescheduleToken,
initialConfig,
preselectedStaffSlug
}: Props) {
const [loadingMeta, setLoadingMeta] = useState(true);
const [metaError, setMetaError] = useState("");
const [staffList, setStaffList] = useState<Staff[]>([]);
const [staffById, setStaffById] = useState<Record<string, string>>({});
const [selectedScope, setSelectedScope] = useState<string | null>(null);
const [loadingReschedule, setLoadingReschedule] = useState(false);
const [rescheduleInfo, setRescheduleInfo] = useState<RescheduleInfo | null>(null);
const [activeRescheduleToken, setActiveRescheduleToken] = useState(rescheduleToken ?? "");
const [currentMonth, setCurrentMonth] = useState(startOfMonth(new Date()));
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const [monthAvailability, setMonthAvailability] = useState<Record<string, number>>({});
const [loadingMonth, setLoadingMonth] = useState(false);
const [monthError, setMonthError] = useState("");
const [slotOptions, setSlotOptions] = useState<SlotOption[]>([]);
const [loadingSlots, setLoadingSlots] = useState(false);
const [slotError, setSlotError] = useState("");
const [selectedTime, setSelectedTime] = useState<string | null>(null);
const [bookingNoticeText, setBookingNoticeText] = useState(
initialConfig?.bookingNoticeText ||
"Erzähl uns kurz, worum es geht - damit wir uns optimal vorbereiten können."
);
const [defaultDurationMinutes, setDefaultDurationMinutes] = useState(
initialConfig?.defaultDurationMinutes ?? 60
);
const [companyName, setCompanyName] = useState(initialConfig?.companyName || "CalBook");
const [headerText, setHeaderText] = useState(initialConfig?.headerText || "Gespräch");
const [headerLogoUrl, setHeaderLogoUrl] = useState(initialConfig?.headerLogoUrl || "");
const [footerPrivacyLabel, setFooterPrivacyLabel] = useState(
initialConfig?.footerPrivacyLabel || "Datenschutz"
);
const [footerPrivacyUrl, setFooterPrivacyUrl] = useState(
initialConfig?.footerPrivacyUrl || "/datenschutz"
);
const [footerImprintLabel, setFooterImprintLabel] = useState(
initialConfig?.footerImprintLabel || "Impressum"
);
const [footerImprintUrl, setFooterImprintUrl] = useState(
initialConfig?.footerImprintUrl || "/impressum"
);
const [footerCopyrightText, setFooterCopyrightText] = useState(
initialConfig?.footerCopyrightText || "© {{year}} {{companyName}}"
);
const [detectedTimezone, setDetectedTimezone] = useState(
initialConfig?.defaultTimezone || "Europe/Berlin"
);
const [customerTimezone, setCustomerTimezone] = useState(
initialConfig?.defaultTimezone || "Europe/Berlin"
);
const [timezoneInput, setTimezoneInput] = useState(
initialConfig?.defaultTimezone || "Europe/Berlin"
);
const [isTimezonePopoverOpen, setIsTimezonePopoverOpen] = useState(false);
const [bookingResult, setBookingResult] = useState<BookingResult | null>(null);
const timeSectionRef = useRef<HTMLElement>(null);
const formSectionRef = useRef<HTMLDivElement>(null);
const calendarSectionRef = useRef<HTMLElement>(null);
const monthRequestSeqRef = useRef(0);
const slotsRequestSeqRef = useRef(0);
const timezoneInputRef = useRef<HTMLInputElement>(null);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
setValue,
reset
} = useForm<ContactFormValues>({
resolver: zodResolver(contactSchema),
defaultValues: {
customerName: "",
customerEmail: "",
customerPhone: "",
notes: ""
}
});
const selectedDateIso = selectedDate ? formatIsoDate(selectedDate) : null;
const todayIso = useMemo(
() => formatInTimeZone(new Date(), customerTimezone, "yyyy-MM-dd"),
[customerTimezone]
);
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(monthStart);
const calendarDays = useMemo(
() =>
eachDayOfInterval({
start: startOfWeek(monthStart, { weekStartsOn: 1 }),
end: endOfWeek(monthEnd, { weekStartsOn: 1 })
}),
[monthEnd, monthStart]
);
const selectedSlot = useMemo(
() => slotOptions.find((slot) => slot.time === selectedTime) ?? null,
[slotOptions, selectedTime]
);
const allStaffIds = useMemo(
() => staffList.map((staff) => staff.id).sort((a, b) => a.localeCompare(b)),
[staffList]
);
const selectedScopeLabel = useMemo(() => {
if (!selectedScope) return "Noch nicht gewählt";
if (selectedScope === "all") return "Alle Personen";
return staffById[selectedScope] ?? "Person";
}, [selectedScope, staffById]);
const hasSelectedScope = Boolean(selectedScope);
const selectedStaffNamesLabel = useMemo(() => {
if (!selectedSlot) return "-";
const names = selectedSlot.staffIds
.map((id) => staffById[id] ?? null)
.filter((name): name is string => Boolean(name));
return names.length > 0 ? names.join(", ") : `${selectedSlot.staffIds.length} Person(en)`;
}, [selectedSlot, staffById]);
const timezoneOptions = useMemo<TimezoneOption[]>(() => {
const now = new Date();
const values = Array.from(
new Set([...ALL_IANA_TIMEZONES, detectedTimezone, customerTimezone])
).filter((value) => isSupportedTimezone(value));
const options = values.map((value) => {
const offsetLabel = timezoneUtcOffsetLabel(value, now);
return {
value,
label: `${prettyTimezoneLabel(value)} (${offsetLabel})`,
offsetMinutes: timezoneOffsetMinutes(value, now),
searchable: normalizeTimezoneSearch(`${value} ${prettyTimezoneLabel(value)} ${offsetLabel}`)
};
});
options.sort(
(a, b) => a.offsetMinutes - b.offsetMinutes || a.value.localeCompare(b.value)
);
return options;
}, [customerTimezone, detectedTimezone]);
useEffect(() => {
setTimezoneInput(customerTimezone);
}, [customerTimezone]);
const timezoneSearchResults = useMemo(() => {
const query = normalizeTimezoneSearch(timezoneInput);
if (!query) {
const selected = timezoneOptions.find((option) => option.value === customerTimezone);
const rest = timezoneOptions.filter((option) => option.value !== customerTimezone);
return selected ? [selected, ...rest] : timezoneOptions;
}
return timezoneOptions
.map((option) => ({
option,
score: scoreTimezoneOption(option, query)
}))
.filter((item) => item.score > 0)
.sort(
(a, b) =>
b.score - a.score ||
a.option.offsetMinutes - b.option.offsetMinutes ||
a.option.value.localeCompare(b.option.value)
)
.map((item) => item.option);
}, [customerTimezone, timezoneInput, timezoneOptions]);
const loadMeta = useCallback(async () => {
setLoadingMeta(true);
setMetaError("");
try {
const res = await fetch("/api/public/mitarbeiter", { cache: "no-store" });
const data = (await res.json()) as StaffResponse;
if (!res.ok) {
const message = "Buchungsdaten konnten nicht geladen werden.";
setMetaError(message);
toast.error(data?.config ? message : "Daten konnten nicht geladen werden.");
return;
}
setStaffList(data.mitarbeiter ?? []);
const staffMap = Object.fromEntries(
(data.mitarbeiter ?? []).map((staff) => [staff.id, staff.name])
);
setStaffById(staffMap);
setBookingNoticeText(
data.config?.bookingNoticeText ||
"Erzähl uns kurz, worum es geht - damit wir uns optimal vorbereiten können."
);
setDefaultDurationMinutes(data.config?.defaultDurationMinutes ?? 60);
setCompanyName((data.config?.companyName || "CalBook").trim() || "CalBook");
setHeaderText((data.config?.headerText || "Gespräch").trim() || "Gespräch");
setHeaderLogoUrl((data.config?.headerLogoUrl || "").trim());
setFooterPrivacyLabel(
(data.config?.footerPrivacyLabel || "Datenschutz").trim() || "Datenschutz"
);
setFooterPrivacyUrl(
(data.config?.footerPrivacyUrl || "/datenschutz").trim() || "/datenschutz"
);
setFooterImprintLabel(
(data.config?.footerImprintLabel || "Impressum").trim() || "Impressum"
);
setFooterImprintUrl(
(data.config?.footerImprintUrl || "/impressum").trim() || "/impressum"
);
setFooterCopyrightText(
(data.config?.footerCopyrightText || "© {{year}} {{companyName}}").trim() ||
"© {{year}} {{companyName}}"
);
if (preselectedStaffSlug) {
const match = (data.mitarbeiter ?? []).find((s) => s.slug === preselectedStaffSlug);
if (match) setSelectedScope(match.id);
}
} catch {
setMetaError("Buchungsdaten konnten nicht geladen werden.");
toast.error("Daten konnten nicht geladen werden.");
} finally {
setLoadingMeta(false);
}
}, [preselectedStaffSlug]);
useEffect(() => {
void loadMeta();
}, [loadMeta]);
useEffect(() => {
if (typeof window === "undefined") return;
const storedTimezone = window.localStorage.getItem("calbook_customer_timezone");
const browserTimezone = detectBrowserTimezone();
if (browserTimezone) {
setDetectedTimezone(browserTimezone);
}
if (storedTimezone && isSupportedTimezone(storedTimezone)) {
setCustomerTimezone(storedTimezone);
return;
}
if (browserTimezone) {
setCustomerTimezone(browserTimezone);
}
}, []);
useEffect(() => {
if (typeof window === "undefined") return;
window.localStorage.setItem("calbook_customer_timezone", customerTimezone);
}, [customerTimezone]);
const selectTimezone = useCallback((timezone: string) => {
setCustomerTimezone(timezone);
setTimezoneInput(timezone);
}, []);
const handleTimezoneInputChange = useCallback(
(rawValue: string) => {
setTimezoneInput(rawValue);
},
[]
);
const commitTimezoneInput = useCallback(() => {
const rawValue = timezoneInput.trim();
if (!rawValue) {
setTimezoneInput(customerTimezone);
return customerTimezone;
}
if (isSupportedTimezone(rawValue)) {
selectTimezone(rawValue);
return rawValue;
}
const normalized = normalizeTimezoneSearch(rawValue);
const exact = timezoneSearchResults.find(
(option) =>
normalizeTimezoneSearch(option.value) === normalized ||
normalizeTimezoneSearch(option.label) === normalized
);
if (exact) {
selectTimezone(exact.value);
return exact.value;
}
setTimezoneInput(customerTimezone);
return null;
}, [customerTimezone, selectTimezone, timezoneInput, timezoneSearchResults]);
const closeTimezonePopover = useCallback(
(inputValue?: string) => {
setIsTimezonePopoverOpen(false);
if (typeof inputValue === "string" && inputValue.trim()) {
setTimezoneInput(inputValue);
return;
}
setTimezoneInput(customerTimezone);
},
[customerTimezone]
);
useEffect(() => {
if (!isTimezonePopoverOpen) return;
const timer = window.setTimeout(() => {
timezoneInputRef.current?.focus();
timezoneInputRef.current?.select();
}, 0);
return () => window.clearTimeout(timer);
}, [isTimezonePopoverOpen]);
useEffect(() => {
if (!isTimezonePopoverOpen) return;
function onKeyDown(event: KeyboardEvent) {
if (event.key !== "Escape") return;
event.preventDefault();
closeTimezonePopover();
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [closeTimezonePopover, isTimezonePopoverOpen]);
useEffect(() => {
if (!selectedScope || selectedScope === "all") return;
const stillExists = staffList.some((staff) => staff.id === selectedScope);
if (!stillExists) {
setSelectedScope(null);
}
}, [selectedScope, staffList]);
useEffect(() => {
setSelectedDate(null);
setSelectedTime(null);
setSlotOptions([]);
setMonthAvailability({});
setMonthError("");
setSlotError("");
}, [selectedScope, customerTimezone]);
useEffect(() => {
setCurrentMonth(startOfMonth(new Date()));
}, [customerTimezone]);
useEffect(() => {
if (!activeRescheduleToken) return;
async function loadRescheduleInfo() {
setLoadingReschedule(true);
try {
const res = await fetch(
`/api/public/umbuchen?token=${encodeURIComponent(activeRescheduleToken)}&timezone=${encodeURIComponent(customerTimezone)}`,
{ cache: "no-store" }
);
const data = await res.json();
if (!res.ok) {
toast.error(data?.message ?? "Umbuchungsdaten konnten nicht geladen werden.");
return;
}
const parsed = data as RescheduleInfo;
setRescheduleInfo(parsed);
setSelectedScope("all");
const date = new Date(`${parsed.date}T00:00:00`);
setSelectedDate(date);
setCurrentMonth(startOfMonth(date));
setSelectedTime(parsed.time);
setValue("customerName", `${parsed.customerFirstName} ${parsed.customerLastName}`.trim());
setValue("customerEmail", parsed.customerEmail ?? "");
setValue("customerPhone", parsed.customerPhone ?? "");
setValue("notes", parsed.notes ?? "");
} catch {
toast.error("Umbuchungsdaten konnten nicht geladen werden.");
} finally {
setLoadingReschedule(false);
}
}
void loadRescheduleInfo();
}, [activeRescheduleToken, customerTimezone, setValue]);
const loadMonthAvailability = useCallback(async () => {
if (staffList.length === 0) {
setMonthAvailability({});
return;
}
if (!selectedScope) {
setMonthAvailability({});
return;
}
setLoadingMonth(true);
setMonthError("");
const requestSeq = ++monthRequestSeqRef.current;
try {
const params = new URLSearchParams({
monat: format(currentMonth, "yyyy-MM"),
timezone: customerTimezone
});
if (selectedScope === "all") {
params.set("requireAll", "true");
} else {
params.set("mitarbeiterId", selectedScope);
}
const res = await fetch(`/api/public/slots-monat?${params.toString()}`, {
cache: "no-store"
});
const data = (await res.json()) as MonthAvailabilityResponse;
if (requestSeq !== monthRequestSeqRef.current) {
return;
}
if (!res.ok) {
const retry = data?.details?.retryAfterSeconds;
if (res.status === 429 && retry) {
const message = `Zu viele Anfragen. Bitte in ${retry}s erneut versuchen.`;
setMonthError(message);
toast.error(message);
} else {
const message = data?.message ?? "Verfügbarkeit konnte nicht geladen werden.";
setMonthError(message);
toast.error(message);
}
return;
}
setMonthAvailability(data.availability ?? {});
} catch {
if (requestSeq !== monthRequestSeqRef.current) {
return;
}
setMonthError("Verfügbarkeit konnte nicht geladen werden.");
toast.error("Verfügbarkeit konnte nicht geladen werden.");
} finally {
if (requestSeq === monthRequestSeqRef.current) {
setLoadingMonth(false);
}
}
}, [currentMonth, customerTimezone, selectedScope, staffList.length]);
useEffect(() => {
void loadMonthAvailability();
}, [loadMonthAvailability]);
const loadSlots = useCallback(async () => {
if (!selectedDateIso) {
setSlotOptions([]);
return;
}
if (staffList.length === 0) {
setSlotOptions([]);
return;
}
if (!selectedScope) {
setSlotOptions([]);
return;
}
setLoadingSlots(true);
setSlotError("");
setSelectedTime(null);
const requestSeq = ++slotsRequestSeqRef.current;
try {
const params = new URLSearchParams({
datum: selectedDateIso,
timezone: customerTimezone
});
if (selectedScope !== "all") {
params.set("mitarbeiterId", selectedScope);
}
const res = await fetch(`/api/public/slots?${params.toString()}`, {
cache: "no-store"
});
const data = (await res.json()) as SlotsResponse & {
message?: string;
details?: {
retryAfterSeconds?: number;
};
};
if (requestSeq !== slotsRequestSeqRef.current) {
return;
}
if (!res.ok) {
const retry = data?.details?.retryAfterSeconds;
if (res.status === 429 && retry) {
const message = `Zu viele Anfragen. Bitte in ${retry}s erneut versuchen.`;
setSlotError(message);
toast.error(message);
} else {
const message = data?.message ?? "Slots konnten nicht geladen werden.";
setSlotError(message);
toast.error(message);
}
setSlotOptions([]);
return;
}
setSlotOptions(
buildScopeSlotOptions({
data,
selectedScope,
allStaffIds
})
);
} catch {
if (requestSeq !== slotsRequestSeqRef.current) {
return;
}
setSlotError("Slots konnten nicht geladen werden.");
toast.error("Slots konnten nicht geladen werden.");
setSlotOptions([]);
} finally {
if (requestSeq === slotsRequestSeqRef.current) {
setLoadingSlots(false);
}
}
}, [allStaffIds, customerTimezone, selectedDateIso, selectedScope, staffList.length]);
useEffect(() => {
void loadSlots();
}, [loadSlots]);
useEffect(() => {
if (!hasSelectedScope || !calendarSectionRef.current) return;
const timer = setTimeout(() => {
calendarSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}, 120);
return () => clearTimeout(timer);
}, [hasSelectedScope]);
useEffect(() => {
if (!selectedDate || !timeSectionRef.current) return;
const timer = setTimeout(() => {
timeSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}, 120);
return () => clearTimeout(timer);
}, [selectedDate]);
useEffect(() => {
if (!selectedTime || !formSectionRef.current) return;
const timer = setTimeout(() => {
formSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
}, 120);
return () => clearTimeout(timer);
}, [selectedTime]);
function onSelectTime(time: string) {
setSelectedTime(time);
}
const onBook = handleSubmit(async (values) => {
if (!selectedScope) {
toast.error("Bitte zuerst eine Person auswählen.");
return;
}
if (!selectedDateIso || !selectedTime) {
toast.error("Bitte Datum und Uhrzeit auswählen.");
return;
}
const names = splitCustomerName(values.customerName);
const payload = {
date: selectedDateIso,
time: selectedTime,
timezone: customerTimezone,
customerFirstName: names.customerFirstName,
customerLastName: names.customerLastName,
customerEmail: values.customerEmail,
customerPhone: values.customerPhone,
notes: values.notes,
...(selectedScope === "all"
? { requireAll: true }
: { mitarbeiterId: selectedScope }),
...(activeRescheduleToken ? { rescheduleToken: activeRescheduleToken } : {})
};
const res = await fetch("/api/public/buchen", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) {
toast.error(data?.message ?? "Buchung fehlgeschlagen");
return;
}
setBookingResult({
id: data.id,
date: data.date,
time: data.time,
timezone: data.timezone,
startAt: data.startAt,
endAt: data.endAt,
meetingUrl: data.meetingUrl ?? "",
staffNames: data.staffNames ?? [],
staffCount: data.staffCount ?? 0,
rescheduled: Boolean(data.rescheduled)
});
if (activeRescheduleToken) {
setActiveRescheduleToken("");
setRescheduleInfo(null);
}
reset({
customerName: "",
customerEmail: "",
customerPhone: "",
notes: ""
});
});
function downloadIcs() {
if (!bookingResult) return;
const start = new Date(bookingResult.startAt)
.toISOString()
.replace(/[-:]/g, "")
.replace(/\.\d{3}/, "");
const end = new Date(bookingResult.endAt)
.toISOString()
.replace(/[-:]/g, "")
.replace(/\.\d{3}/, "");
const staffNames = bookingResult.staffNames.join(", ") || "Person";
const meetingUrl = bookingResult.meetingUrl;
const content = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//CalBook//DE",
"BEGIN:VEVENT",
`UID:${bookingResult.id}@calbook`,
`DTSTAMP:${new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "")}`,
`DTSTART:${start}`,
`DTEND:${end}`,
`SUMMARY:Gespräch mit ${staffNames}`,
...(meetingUrl
? [
`DESCRIPTION:Jitsi-Link\\n${meetingUrl}`,
"LOCATION:Online (Jitsi Meet)",
`URL:${meetingUrl}`
]
: []),
"END:VEVENT",
"END:VCALENDAR"
].join("\r\n");
const blob = new Blob([content], { type: "text/calendar" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "termin.ics";
a.click();
URL.revokeObjectURL(url);
}
function startNewBooking() {
setBookingResult(null);
setSelectedTime(null);
}
if (bookingResult) {
return (
<div className="min-h-screen bg-slate-50 flex flex-col font-sans">
<div className="flex-1 flex items-center justify-center p-4">
<div className="max-w-lg w-full">
<motion.div
initial={{ opacity: 0, scale: 0.92, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ duration: 0.4, ease: [0.22, 1, 0.36, 1] }}
className="rounded-[28px] border border-slate-200 bg-white shadow-xl p-6 sm:p-10 text-center"
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, duration: 0.4, ease: [0.34, 1.56, 0.64, 1] }}
className="mb-6 inline-flex h-20 w-20 items-center justify-center rounded-full bg-emerald-100 ring-8 ring-emerald-50"
>
<CheckCircle2 className="h-10 w-10 text-emerald-600" />
</motion.div>
<h2 className="text-2xl font-black text-slate-900 tracking-tight mb-2">
{bookingResult.rescheduled ? "Umbuchung bestätigt" : "Buchung bestätigt"}
</h2>
<p className="text-sm text-slate-500 mb-8 leading-relaxed">
{bookingResult.rescheduled
? "Dein Termin wurde erfolgreich umgebucht."
: "Dein Termin wurde erfolgreich gebucht."}{" "}
Die Bestätigung wurde per E-Mail versendet.
</p>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-5 mb-6 text-left">
<div className="grid gap-3 text-sm">
<div className="flex items-start gap-3">
<div className="mt-0.5 rounded-lg bg-indigo-50 p-1.5">
<CalendarDays className="h-4 w-4 text-indigo-600" />
</div>
<div>
<p className="font-bold text-slate-900">
{dayLabel(new Date(`${bookingResult.date}T00:00:00`))} um {bookingResult.time}
</p>
<p className="text-xs text-slate-500">{bookingResult.timezone || customerTimezone}</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="mt-0.5 rounded-lg bg-indigo-50 p-1.5">
<User className="h-4 w-4 text-indigo-600" />
</div>
<div>
<p className="font-bold text-slate-900">{bookingResult.staffNames.join(", ") || "-"}</p>
<p className="text-xs text-slate-500">{bookingResult.staffCount} {bookingResult.staffCount === 1 ? "Person" : "Personen"}</p>
</div>
</div>
</div>
</div>
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
<button
type="button"
onClick={downloadIcs}
className="w-full sm:w-auto inline-flex h-11 items-center justify-center gap-2 rounded-xl bg-slate-900 px-6 text-sm font-bold text-white hover:bg-slate-800 transition"
>
<CalendarDays className="h-4 w-4" /> Zum Kalender
</button>
<button
type="button"
onClick={startNewBooking}
className="w-full sm:w-auto h-11 px-6 rounded-xl border border-slate-200 bg-white text-sm font-bold text-slate-700 hover:bg-slate-50 transition"
>
Weiteren Termin
</button>
</div>
</motion.div>
</div>
</div>
{!embedded ? (
<PublicFooter
companyName={companyName}
privacyLabel={footerPrivacyLabel}
privacyHref={footerPrivacyUrl}
imprintLabel={footerImprintLabel}
imprintHref={footerImprintUrl}
copyrightTemplate={footerCopyrightText}
/>
) : null}
</div>
);
}
return (
<div className={cn("min-h-screen bg-slate-50 flex flex-col font-sans", embedded && "min-h-0")}>
<div className={cn("w-full max-w-6xl mx-auto p-4 lg:p-8 flex flex-col h-full flex-1", embedded && "p-0")}>
{!embedded ? (
<header className="mb-8 flex w-full items-center justify-between gap-3">
<div className="min-w-0 flex items-center gap-3">
{headerLogoUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={headerLogoUrl}
alt="Logo"
className="w-10 h-10 rounded-xl object-cover border border-slate-200 bg-white"
/>
) : (
<div className="w-10 h-10 rounded-xl flex items-center justify-center" style={{ backgroundColor: "var(--accent)" }}>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
)}
<h1 className="truncate text-2xl font-bold text-slate-900">
{headerText}
</h1>
</div>
<div className="relative z-40 shrink-0">
<AnimatePresence>
{isTimezonePopoverOpen ? (
<motion.button
type="button"
aria-label="Zeitzonen-Auswahl schließen"
className="fixed inset-0 z-30 bg-slate-950/45 backdrop-blur-[2px]"
onClick={() => closeTimezonePopover()}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
/>
) : null}
</AnimatePresence>
<button
type="button"
className="relative z-40 flex h-10 w-10 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-600 transition hover:border-slate-300 hover:text-slate-900"
aria-label="Zeitzone auswählen"
aria-expanded={isTimezonePopoverOpen}
aria-controls="timezone-popover"
title="Zeitzone auswählen"
onClick={() => {
if (isTimezonePopoverOpen) {
closeTimezonePopover();
return;
}
setTimezoneInput(customerTimezone);
setIsTimezonePopoverOpen(true);
}}
>
<Globe2 className="w-5 h-5" />
</button>
<AnimatePresence>
{isTimezonePopoverOpen ? (
<motion.div
id="timezone-popover"
role="dialog"
aria-label="Zeitzonen-Auswahl"
className="absolute right-0 mt-2 z-40 w-[min(92vw,320px)] max-w-[calc(100vw-2rem)] rounded-2xl border border-slate-200 bg-white p-4 shadow-xl sm:w-[320px]"
initial={{ opacity: 0, y: -8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -6, scale: 0.985 }}
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
>
<label
className="block text-xs font-semibold text-slate-500 mb-2"
htmlFor="customer-timezone-header"
>
Zeitzone suchen & auswählen
</label>
<input
ref={timezoneInputRef}
id="customer-timezone-header"
value={timezoneInput}
onChange={(event) => handleTimezoneInputChange(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
const firstMatch = timezoneSearchResults[0];
if (firstMatch) {
selectTimezone(firstMatch.value);
closeTimezonePopover(firstMatch.value);
return;
}
const committed = commitTimezoneInput();
closeTimezonePopover(committed ?? undefined);
}}
onBlur={() => {
window.setTimeout(() => {
commitTimezoneInput();
}, 120);
}}
placeholder="z. B. Europe/Berlin oder Asia/Shanghai"
className="w-full h-11 px-3 bg-white border border-slate-200 rounded-xl text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
/>
<p className="mt-2 mb-2 text-[11px] text-slate-500">
{timezoneOptions.length} Zeitzonen verfügbar
{timezoneInput.trim()
? `${timezoneSearchResults.length} Treffer`
: ""}
</p>
<div className="mb-2 max-h-48 overflow-y-auto rounded-xl border border-slate-200 bg-slate-50">
{timezoneSearchResults.slice(0, 10).map((option) => (
<button
key={option.value}
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
selectTimezone(option.value);
closeTimezonePopover(option.value);
}}
className={cn(
"w-full px-3 py-2 text-left text-sm transition border-b border-slate-200 last:border-b-0",
option.value === customerTimezone
? "bg-indigo-50 text-indigo-700 font-semibold"
: "bg-transparent text-slate-700 hover:bg-slate-100"
)}
>
{option.label}
</button>
))}
{timezoneSearchResults.length === 0 ? (
<p className="px-3 py-3 text-xs text-slate-500">
Keine Treffer. Beispiel: Berlin, Shanghai, UTC, Europe/London
</p>
) : null}
</div>
<p className="mt-2 text-xs font-semibold text-slate-700">
Aktuell ausgewählt: {prettyTimezoneLabel(customerTimezone)} (
{timezoneUtcOffsetLabel(customerTimezone)})
</p>
{detectedTimezone !== customerTimezone ? (
<p className="mt-1 text-xs text-slate-500">
Geräte-Zeitzone: {prettyTimezoneLabel(detectedTimezone)} (
{timezoneUtcOffsetLabel(detectedTimezone)})
</p>
) : null}
</motion.div>
) : null}
</AnimatePresence>
</div>
</header>
) : null}
<main className="flex-1 flex flex-col gap-6 lg:gap-8 max-w-2xl mx-auto w-full pb-12">
{activeRescheduleToken ? (
<div className="bento-card text-sm text-slate-600">
{loadingReschedule ? (
<p>Umbuchungsdaten werden geladen ...</p>
) : rescheduleInfo ? (
<p>
Umbuchung aktiv für <strong>{rescheduleInfo.customerFirstName} {rescheduleInfo.customerLastName}</strong>
{" "}({dayLabel(new Date(`${rescheduleInfo.date}T00:00:00`))} um {rescheduleInfo.time})
</p>
) : (
<p>Umbuchungslink konnte nicht verifiziert werden.</p>
)}
</div>
) : null}
<section className="bento-card">
<h2 className="text-lg font-bold text-slate-900 mb-4">
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full text-white text-xs font-black mr-2" style={{ backgroundColor: "var(--accent)" }}>1</span>
Person auswählen
</h2>
{loadingMeta ? (
<div className="space-y-3">
<Skeleton className="h-14 rounded-xl" />
<Skeleton className="h-14 rounded-xl" />
</div>
) : metaError ? (
<div className="rounded-2xl border border-red-200 bg-red-50 p-4 text-sm text-red-800">
<p className="font-semibold">{metaError}</p>
<button
type="button"
onClick={() => void loadMeta()}
className="mt-3 h-10 rounded-xl bg-red-700 px-4 text-sm font-bold text-white hover:bg-red-800"
>
Erneut laden
</button>
</div>
) : staffList.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-300 bg-slate-50 p-5 text-sm text-slate-500">
<p className="font-semibold text-slate-900">Aktuell keine buchbaren Personen verfügbar.</p>
<p>Bitte versuche es später erneut oder kontaktiere uns direkt.</p>
</div>
) : (
<motion.div
className="grid gap-3"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
>
<motion.button
type="button"
onClick={() => setSelectedScope("all")}
className={cn(
"w-full rounded-xl border px-4 py-3 text-left transition",
selectedScope === "all"
? "border-indigo-600 bg-indigo-50"
: "border-slate-200 bg-white hover:border-indigo-300"
)}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18, delay: 0.02 }}
>
<p className="text-sm font-semibold text-slate-900">Alle</p>
<p className="text-xs text-slate-500">
Es werden nur Zeiten angezeigt, an denen alle Personen verfügbar sind.
</p>
</motion.button>
<div className="grid gap-2 sm:grid-cols-2">
{staffList.map((staff, index) => (
<motion.button
key={staff.id}
type="button"
onClick={() => setSelectedScope(staff.id)}
className={cn(
"rounded-xl border px-4 py-3 text-left transition",
selectedScope === staff.id
? "border-indigo-600 bg-indigo-50"
: "border-slate-200 bg-white hover:border-indigo-300"
)}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, delay: 0.06 + index * 0.03 }}
>
<p className="text-sm font-semibold text-slate-900">{staff.name}</p>
{staff.bio?.trim() ? (
<p className="text-xs text-slate-500 line-clamp-2">{staff.bio}</p>
) : null}
</motion.button>
))}
</div>
</motion.div>
)}
</section>
<AnimatePresence initial={false}>
{hasSelectedScope ? (
<motion.section
ref={calendarSectionRef}
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2 }}
className="bento-card flex flex-col"
>
<div className="flex justify-between items-center mb-6">
<div>
<h2 className="text-lg font-bold text-slate-900">
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full text-white text-xs font-black mr-2" style={{ backgroundColor: "var(--accent)" }}>2</span>
Datum auswählen
</h2>
<p className="text-xs text-slate-500 mt-1">Auswahl: {selectedScopeLabel}</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors text-slate-600"
>
<ChevronLeft className="w-5 h-5" />
</button>
<span className="font-bold text-slate-900 min-w-[120px] text-center capitalize">
{format(currentMonth, "MMMM yyyy", { locale: de })}
</span>
<button
type="button"
onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors text-slate-600"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
</div>
{loadingMeta || loadingMonth ? (
<Skeleton className="h-[320px] rounded-xl" />
) : monthError ? (
<div className="rounded-2xl border border-red-200 bg-red-50 p-5 text-sm text-red-800">
<p className="font-semibold">{monthError}</p>
<p className="mt-1">Die Verfügbarkeit für diesen Monat konnte nicht angezeigt werden.</p>
<button
type="button"
onClick={() => void loadMonthAvailability()}
className="mt-3 h-10 rounded-xl bg-red-700 px-4 text-sm font-bold text-white hover:bg-red-800"
>
Verfügbarkeit neu laden
</button>
</div>
) : (
<>
<div className="grid grid-cols-7 gap-1 sm:gap-2 text-center text-sm mb-4 font-bold text-slate-400">
<span>Mo</span>
<span>Di</span>
<span>Mi</span>
<span>Do</span>
<span>Fr</span>
<span>Sa</span>
<span>So</span>
</div>
<div className="grid grid-cols-7 gap-1 sm:gap-2 flex-grow">
{calendarDays.map((day) => {
const iso = formatIsoDate(day);
const isSelected = selectedDate ? isSameDay(day, selectedDate) : false;
const isCurrentMonth = isSameMonth(day, currentMonth);
const isPast = isCurrentMonth && iso < todayIso;
const dayAvailability = monthAvailability[iso];
const isAvailabilityUnknown =
isCurrentMonth && dayAvailability === undefined;
const isBookedOut = isCurrentMonth && dayAvailability === 0;
const isDisabled =
isPast || !isCurrentMonth || isBookedOut || isAvailabilityUnknown;
return (
<button
key={day.toISOString()}
type="button"
onClick={() => {
if (isDisabled) return;
setSelectedDate(day);
}}
disabled={isDisabled}
className={cn(
"h-10 sm:h-12 flex items-center justify-center rounded-xl transition-all duration-200 select-none text-sm sm:text-base font-medium",
!isCurrentMonth
? "text-slate-300"
: isPast
? "text-slate-300 cursor-not-allowed"
: isBookedOut || isAvailabilityUnknown
? "bg-slate-100 text-slate-400 line-through decoration-slate-300 cursor-not-allowed"
: isSelected
? "bg-indigo-600 text-white font-bold shadow-md shadow-indigo-600/20"
: "text-slate-700 bg-slate-50 border border-slate-100 hover:border-indigo-300 hover:bg-indigo-50/50"
)}
>
{format(day, "d")}
</button>
);
})}
</div>
</>
)}
</motion.section>
) : null}
</AnimatePresence>
<AnimatePresence>
{hasSelectedScope && selectedDate ? (
<motion.section
ref={timeSectionRef}
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.2 }}
className="bento-card"
>
<h2 className="text-lg font-bold text-slate-900 mb-4">
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full text-white text-xs font-black mr-2" style={{ backgroundColor: "var(--accent)" }}>3</span>
Uhrzeit auswählen am {dayLabel(selectedDate)}
</h2>
{loadingSlots ? (
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4">
{Array.from({ length: 8 }).map((_, idx) => (
<Skeleton key={idx} className="h-10 rounded-xl" />
))}
</div>
) : slotError ? (
<div className="py-6 flex flex-col items-center justify-center rounded-2xl border border-red-200 bg-red-50 text-center">
<Clock className="w-6 h-6 text-red-300 mb-2" />
<p className="text-red-800 text-sm font-semibold">{slotError}</p>
<button
type="button"
onClick={() => void loadSlots()}
className="mt-3 h-10 rounded-xl bg-red-700 px-4 text-sm font-bold text-white hover:bg-red-800"
>
Uhrzeiten neu laden
</button>
</div>
) : slotOptions.length === 0 ? (
<div className="py-6 flex flex-col items-center justify-center text-center">
<Clock className="w-6 h-6 text-slate-300 mb-2" />
<p className="text-slate-500 text-sm">
Keine Termine verfügbar.<br />Bitte wähle ein anderes Datum.
</p>
<button
type="button"
onClick={() => void loadMonthAvailability()}
className="mt-3 h-10 rounded-xl border border-slate-200 bg-white px-4 text-sm font-bold text-slate-700 hover:bg-slate-100"
>
Kalender aktualisieren
</button>
</div>
) : (
<div className="flex flex-wrap gap-3">
{slotOptions.map((slot) => (
<button
key={slot.time}
type="button"
onClick={() => {
onSelectTime(slot.time);
}}
className={cn(
"px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 select-none border",
selectedTime === slot.time
? "bg-indigo-600 border-indigo-600 text-white shadow-md shadow-indigo-600/20"
: "bg-white border-slate-200 text-slate-700 hover:border-indigo-600"
)}
>
{slot.time}
</button>
))}
</div>
)}
<p className="mt-6 text-sm text-slate-500">
Dauer: {defaultDurationMinutes} Min. Auswahl: {selectedScopeLabel}.
</p>
<p className="mt-1 text-xs text-slate-400">
Alle Zeiten in Zeitzone: {customerTimezone}
</p>
</motion.section>
) : null}
</AnimatePresence>
<AnimatePresence>
{hasSelectedScope && selectedDate && selectedTime ? (
<motion.div
ref={formSectionRef}
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.2 }}
className="bento-card"
>
<h2 className="text-lg font-bold text-slate-900 mb-4">
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full text-white text-xs font-black mr-2" style={{ backgroundColor: "var(--accent)" }}>4</span>
Kontaktdaten
</h2>
<form onSubmit={onBook} className="space-y-4">
<div>
<label
htmlFor="booking-customer-name"
className="mb-2 block text-xs font-bold uppercase tracking-wide text-slate-500"
>
Name*
</label>
<input
id="booking-customer-name"
type="text"
required
autoComplete="name"
{...register("customerName")}
className="w-full h-11 px-4 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:outline-none focus:border-indigo-600 focus:ring-1 focus:ring-indigo-600 transition-all font-medium text-slate-900 placeholder:text-slate-400 disabled:opacity-50"
placeholder="Dein Name"
/>
{errors.customerName ? (
<p className="mt-1 text-xs text-red-600">{errors.customerName.message}</p>
) : null}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label
htmlFor="booking-customer-email"
className="mb-2 block text-xs font-bold uppercase tracking-wide text-slate-500"
>
E-Mail*
</label>
<input
id="booking-customer-email"
type="email"
required
autoComplete="email"
{...register("customerEmail")}
className="w-full h-11 px-4 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:outline-none focus:border-indigo-600 focus:ring-1 focus:ring-indigo-600 transition-all font-medium text-slate-900 placeholder:text-slate-400 disabled:opacity-50"
placeholder="Deine E-Mail"
/>
{errors.customerEmail ? (
<p className="mt-1 text-xs text-red-600">{errors.customerEmail.message}</p>
) : null}
</div>
<div>
<label
htmlFor="booking-customer-phone"
className="mb-2 block text-xs font-bold uppercase tracking-wide text-slate-500"
>
Telefon
</label>
<input
id="booking-customer-phone"
type="tel"
autoComplete="tel"
{...register("customerPhone")}
className="w-full h-11 px-4 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:outline-none focus:border-indigo-600 focus:ring-1 focus:ring-indigo-600 transition-all font-medium text-slate-900 placeholder:text-slate-400 disabled:opacity-50"
placeholder="Telefon (optional)"
/>
</div>
</div>
<div>
<label
htmlFor="booking-notes"
className="mb-2 block text-xs font-bold uppercase tracking-wide text-slate-500"
>
Thema
</label>
<textarea
id="booking-notes"
rows={3}
{...register("notes")}
className="w-full p-4 bg-slate-50 border border-slate-200 rounded-xl text-sm focus:outline-none focus:border-indigo-600 focus:ring-1 focus:ring-indigo-600 transition-all resize-none font-medium text-slate-900 placeholder:text-slate-400 disabled:opacity-50"
placeholder="Thema des Gesprächs (optional)"
/>
<p className="mt-2 text-xs text-slate-500">{bookingNoticeText}</p>
</div>
<div className="rounded-xl bg-slate-50 border border-slate-200 px-4 py-3 text-sm text-slate-600">
<p>
Termin: <strong className="text-slate-900">{dayLabel(selectedDate)} um {selectedTime}</strong>
</p>
<p>
Zeitzone: <strong className="text-slate-900">{customerTimezone}</strong>
</p>
<p>
Auswahl: <strong className="text-slate-900">{selectedScopeLabel}</strong>
</p>
<p>
Zugewiesene Personen: <strong className="text-slate-900">{selectedSlot?.staffIds.length ?? 0}</strong>
</p>
<p>
Person(en): <strong className="text-slate-900">{selectedStaffNamesLabel}</strong>
</p>
</div>
<p className="rounded-xl border border-slate-200 bg-white px-4 py-3 text-xs leading-relaxed text-slate-500">
Mit der Buchung verarbeiten wir deine Angaben zur Terminplanung und senden dir
eine Bestätigung per E-Mail. Details findest du in der{" "}
<a className="font-semibold text-slate-700 underline" href={footerPrivacyUrl}>
Datenschutzerklärung
</a>
.
</p>
<button
type="submit"
disabled={isSubmitting}
className="w-full h-11 bg-slate-900 text-white rounded-xl flex items-center justify-center font-bold hover:bg-slate-800 focus:ring-4 focus:ring-slate-900/20 disabled:opacity-70 transition-all gap-2 px-6 group"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Wird gebucht...
</>
) : (
<>
Termin verbindlich buchen
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</>
)}
</button>
</form>
</motion.div>
) : null}
</AnimatePresence>
</main>
</div>
{!embedded ? (
<PublicFooter
companyName={companyName}
privacyLabel={footerPrivacyLabel}
privacyHref={footerPrivacyUrl}
imprintLabel={footerImprintLabel}
imprintHref={footerImprintUrl}
copyrightTemplate={footerCopyrightText}
/>
) : null}
</div>
);
}