Verbessere Booking-Form UX: Reset selectedTime bei Treatment-Wechsel, bessere Loading-States und lokale Datumsvalidierung
This commit is contained in:
@@ -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,
|
||||
|
Reference in New Issue
Block a user