Verbessere Booking-Form UX: Reset selectedTime bei Treatment-Wechsel, bessere Loading-States und lokale Datumsvalidierung

This commit is contained in:
2025-10-04 18:09:46 +02:00
parent 3a13c8dffb
commit 97c1d3493f
9 changed files with 1481 additions and 155 deletions

View File

@@ -2,7 +2,6 @@ import { call, os } from "@orpc/server";
import { z } from "zod";
import { randomUUID } from "crypto";
import { createKV } from "../lib/create-kv.js";
import { createKV as createAvailabilityKV } from "../lib/create-kv.js";
import { sendEmail, sendEmailWithAGB, sendEmailWithAGBAndCalendar, sendEmailWithInspirationPhoto } from "../lib/email.js";
import { renderBookingPendingHTML, renderBookingConfirmedHTML, renderBookingCancelledHTML, renderAdminBookingNotificationHTML } from "../lib/email-templates.js";
import { router as rootRouter } from "./index.js";
@@ -30,6 +29,105 @@ function generateUrl(path: string = ''): string {
return `${protocol}://${domain}${path}`;
}
// Helper function to parse time string to minutes since midnight
function parseTime(timeStr: string): number {
const [hours, minutes] = timeStr.split(':').map(Number);
return hours * 60 + minutes;
}
// Helper function to check if date is in time-off period
function isDateInTimeOffPeriod(date: string, periods: TimeOffPeriod[]): boolean {
return periods.some(period => date >= period.startDate && date <= period.endDate);
}
// Helper function to validate booking time against recurring rules
async function validateBookingAgainstRules(
date: string,
time: string,
treatmentDuration: number
): Promise<void> {
// Parse date to get day of week
const [year, month, day] = date.split('-').map(Number);
const localDate = new Date(year, month - 1, day);
const dayOfWeek = localDate.getDay();
// Check time-off periods
const timeOffPeriods = await timeOffPeriodsKV.getAllItems();
if (isDateInTimeOffPeriod(date, timeOffPeriods)) {
throw new Error("Dieser Tag ist nicht verfügbar (Urlaubszeit).");
}
// Find matching recurring rules
const allRules = await recurringRulesKV.getAllItems();
const matchingRules = allRules.filter(rule =>
rule.isActive === true && rule.dayOfWeek === dayOfWeek
);
if (matchingRules.length === 0) {
throw new Error("Für diesen Wochentag sind keine Termine verfügbar.");
}
// Check if booking time falls within any rule's time span
const bookingStartMinutes = parseTime(time);
const bookingEndMinutes = bookingStartMinutes + treatmentDuration;
const isWithinRules = matchingRules.some(rule => {
const ruleStartMinutes = parseTime(rule.startTime);
const ruleEndMinutes = parseTime(rule.endTime);
// Booking must start at or after rule start and end at or before rule end
return bookingStartMinutes >= ruleStartMinutes && bookingEndMinutes <= ruleEndMinutes;
});
if (!isWithinRules) {
throw new Error("Die gewählte Uhrzeit liegt außerhalb der verfügbaren Zeiten.");
}
}
// Helper function to check for booking conflicts
async function checkBookingConflicts(
date: string,
time: string,
treatmentDuration: number,
excludeBookingId?: string
): Promise<void> {
const allBookings = await kv.getAllItems();
const dateBookings = allBookings.filter(booking =>
booking.appointmentDate === date &&
['pending', 'confirmed', 'completed'].includes(booking.status) &&
booking.id !== excludeBookingId
);
const bookingStartMinutes = parseTime(time);
const bookingEndMinutes = bookingStartMinutes + treatmentDuration;
// Cache treatment durations by ID to avoid N+1 lookups
const uniqueTreatmentIds = [...new Set(dateBookings.map(booking => booking.treatmentId))];
const treatmentDurationMap = new Map<string, number>();
for (const treatmentId of uniqueTreatmentIds) {
const treatment = await treatmentsKV.getItem(treatmentId);
treatmentDurationMap.set(treatmentId, treatment?.duration || 60);
}
// Check for overlaps with existing bookings
for (const existingBooking of dateBookings) {
// Use cached duration or fallback to bookedDurationMinutes if available
let existingDuration = treatmentDurationMap.get(existingBooking.treatmentId) || 60;
if (existingBooking.bookedDurationMinutes) {
existingDuration = existingBooking.bookedDurationMinutes;
}
const existingStartMinutes = parseTime(existingBooking.appointmentTime);
const existingEndMinutes = existingStartMinutes + existingDuration;
// Check overlap: bookingStart < existingEnd && bookingEnd > existingStart
if (bookingStartMinutes < existingEndMinutes && bookingEndMinutes > existingStartMinutes) {
throw new Error("Dieser Zeitslot ist bereits gebucht. Bitte wähle eine andere Zeit.");
}
}
}
const BookingSchema = z.object({
id: z.string(),
treatmentId: z.string(),
@@ -41,26 +139,50 @@ const BookingSchema = z.object({
status: z.enum(["pending", "confirmed", "cancelled", "completed"]),
notes: z.string().optional(),
inspirationPhoto: z.string().optional(), // Base64 encoded image data
bookedDurationMinutes: z.number().optional(), // Snapshot of treatment duration at booking time
createdAt: z.string(),
// DEPRECATED: slotId is no longer used for validation, kept for backward compatibility
slotId: z.string().optional(),
});
type Booking = z.output<typeof BookingSchema>;
const kv = createKV<Booking>("bookings");
type Availability = {
// DEPRECATED: Availability slots are no longer used for booking validation
// type Availability = {
// id: string;
// date: string;
// time: string;
// durationMinutes: number;
// status: "free" | "reserved";
// reservedByBookingId?: string;
// createdAt: string;
// };
// const availabilityKV = createAvailabilityKV<Availability>("availability");
type RecurringRule = {
id: string;
date: string;
time: string;
durationMinutes: number;
status: "free" | "reserved";
reservedByBookingId?: string;
dayOfWeek: number;
startTime: string;
endTime: string;
isActive: boolean;
createdAt: string;
slotDurationMinutes?: number;
};
type TimeOffPeriod = {
id: string;
startDate: string;
endDate: string;
reason: string;
createdAt: string;
};
const availabilityKV = createAvailabilityKV<Availability>("availability");
const recurringRulesKV = createKV<RecurringRule>("recurringRules");
const timeOffPeriodsKV = createKV<TimeOffPeriod>("timeOffPeriods");
// Import treatments KV for admin notifications
import { createKV as createTreatmentsKV } from "../lib/create-kv.js";
type Treatment = {
id: string;
name: string;
@@ -70,7 +192,7 @@ type Treatment = {
category: string;
createdAt: string;
};
const treatmentsKV = createTreatmentsKV<Treatment>("treatments");
const treatmentsKV = createKV<Treatment>("treatments");
const create = os
.input(BookingSchema.omit({ id: true, createdAt: true, status: true }))
@@ -105,6 +227,12 @@ const create = os
throw new Error(emailValidation.reason || "Ungültige E-Mail-Adresse");
}
// Validate appointment time is on 15-minute grid
const appointmentMinutes = parseTime(input.appointmentTime);
if (appointmentMinutes % 15 !== 0) {
throw new Error("Termine müssen auf 15-Minuten-Raster ausgerichtet sein (z.B. 09:00, 09:15, 09:30, 09:45).");
}
// Validate that the booking is not in the past
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
if (input.appointmentDate < today) {
@@ -133,30 +261,38 @@ const create = os
throw new Error("Du hast bereits eine Buchung für dieses Datum. Bitte wähle einen anderen Tag oder storniere zuerst.");
}
}
// Get treatment duration for validation
const treatment = await treatmentsKV.getItem(input.treatmentId);
if (!treatment) {
throw new Error("Behandlung nicht gefunden.");
}
// Validate booking time against recurring rules
await validateBookingAgainstRules(
input.appointmentDate,
input.appointmentTime,
treatment.duration
);
// Check for booking conflicts
await checkBookingConflicts(
input.appointmentDate,
input.appointmentTime,
treatment.duration
);
const id = randomUUID();
const booking = {
id,
...input,
bookedDurationMinutes: treatment.duration, // Snapshot treatment duration
status: "pending" as const,
createdAt: new Date().toISOString()
};
// First save the booking
// Save the booking
await kv.setItem(id, booking);
// Then reserve the slot only after successful booking creation
if (booking.slotId) {
const slot = await availabilityKV.getItem(booking.slotId);
if (!slot) throw new Error("Availability slot not found");
if (slot.status !== "free") throw new Error("Slot not available");
const updatedSlot: Availability = {
...slot,
status: "reserved",
reservedByBookingId: id,
};
await availabilityKV.setItem(slot.id, updatedSlot);
}
// Notify customer: request received (pending)
void (async () => {
// Create booking access token for status viewing
@@ -264,43 +400,7 @@ const updateStatus = os
const updatedBooking = { ...booking, status: input.status };
await kv.setItem(input.id, updatedBooking);
// Manage availability slot state transitions
if (booking.slotId) {
const slot = await availabilityKV.getItem(booking.slotId);
if (slot) {
// console.log(`Updating slot ${slot.id} (${slot.date} ${slot.time}) from ${slot.status} to ${input.status}`);
if (input.status === "cancelled") {
// Free the slot again
await availabilityKV.setItem(slot.id, {
...slot,
status: "free",
reservedByBookingId: undefined,
});
// console.log(`Slot ${slot.id} freed due to cancellation`);
} else if (input.status === "pending") {
// keep reserved as pending
if (slot.status !== "reserved") {
await availabilityKV.setItem(slot.id, {
...slot,
status: "reserved",
reservedByBookingId: booking.id,
});
// console.log(`Slot ${slot.id} reserved for pending booking`);
}
} else if (input.status === "confirmed" || input.status === "completed") {
// keep reserved; optionally noop
if (slot.status !== "reserved" || slot.reservedByBookingId !== booking.id) {
await availabilityKV.setItem(slot.id, {
...slot,
status: "reserved",
reservedByBookingId: booking.id,
});
// console.log(`Slot ${slot.id} confirmed as reserved`);
}
}
}
}
// Note: Slot state management removed - bookings now validated against recurring rules
// Email notifications on status changes
try {
if (input.status === "confirmed") {
@@ -322,7 +422,8 @@ const updateStatus = os
const allTreatments = await treatmentsKV.getAllItems();
const treatment = allTreatments.find(t => t.id === booking.treatmentId);
const treatmentName = treatment?.name || "Behandlung";
const treatmentDuration = treatment?.duration || 60; // Default 60 minutes if not found
// Use bookedDurationMinutes if available, otherwise fallback to treatment duration
const treatmentDuration = booking.bookedDurationMinutes || treatment?.duration || 60;
await sendEmailWithAGBAndCalendar({
to: booking.customerEmail,