1653 lines
61 KiB
TypeScript
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>
|
|
);
|
|
}
|