pwf-website-new/client/src/components/classes/booking-calendar.tsx

202 lines
7.1 KiB
TypeScript

import { useState } from "react";
import { Calendar } from "@/components/ui/calendar";
import { Class, insertBookingSchema } from "@/lib/schema";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useMutation } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button";
import { format, addDays, isAfter, isBefore, addMinutes, startOfDay } from "date-fns";
import { Loader2 } from "lucide-react";
interface BookingCalendarProps {
selectedClass: Class;
}
export function BookingCalendar({ selectedClass }: BookingCalendarProps) {
const { toast } = useToast();
const [selectedDate, setSelectedDate] = useState<Date | undefined>(addDays(new Date(), 1));
const [selectedTime, setSelectedTime] = useState<string | null>(null);
// Generate time slots for the selected date
const timeSlots = generateTimeSlots(selectedDate);
const bookMutation = useMutation({
mutationFn: async (bookingData: { classId: number, date: Date }) => {
const res = await apiRequest("POST", "/api/bookings", bookingData);
return await res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/bookings"] });
toast({
title: "Booking successful",
description: "Your class has been booked successfully!",
});
// Reset selected time
setSelectedTime(null);
},
onError: (error: Error) => {
toast({
title: "Booking failed",
description: error.message,
variant: "destructive",
});
},
});
const handleConfirmBooking = () => {
if (!selectedDate || !selectedTime) {
toast({
title: "Booking error",
description: "Please select both a date and time",
variant: "destructive",
});
return;
}
// Combine date and time
const [hours, minutes] = selectedTime.split(":").map(Number);
const bookingDateTime = new Date(selectedDate);
bookingDateTime.setHours(hours, minutes, 0, 0);
bookMutation.mutate({
classId: selectedClass.id,
date: bookingDateTime
});
};
const formatPrice = (price: number) => {
return `$${(price / 100).toFixed(2)}`;
};
return (
<div className="mt-20 bg-white rounded-xl shadow-xl p-6 md:p-8 lg:p-10">
<h3 className="text-2xl font-playfair font-bold text-center mb-8">Book Your Next Class</h3>
<div className="flex flex-col lg:flex-row">
<div className="lg:w-2/3 mb-8 lg:mb-0 lg:pr-8">
<div className="flex justify-center mb-6">
<Calendar
mode="single"
selected={selectedDate}
onSelect={setSelectedDate}
className="rounded-md border"
disabled={(date) => isBefore(date, startOfDay(new Date())) || isAfter(date, addDays(new Date(), 60))}
/>
</div>
{selectedDate && (
<div className="mt-6">
<h5 className="font-playfair font-semibold mb-3">
Available Times for {format(selectedDate, "MMMM d, yyyy")}
</h5>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{timeSlots.map((time, index) => (
<div
key={index}
className={`text-center py-2 rounded cursor-pointer transition duration-300 ${
selectedTime === time
? "bg-teal text-white"
: "bg-teal-light text-teal hover:bg-teal hover:text-white"
}`}
onClick={() => setSelectedTime(time)}
>
{formatTimeDisplay(time)}
</div>
))}
</div>
</div>
)}
</div>
<div className="lg:w-1/3 bg-gray-50 p-5 rounded-lg">
<h5 className="font-playfair font-semibold text-lg mb-4">Booking Summary</h5>
<div className="space-y-3 mb-6">
<div className="flex justify-between">
<span className="text-gray-600">Class:</span>
<span className="font-medium">{selectedClass.name}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Date:</span>
<span className="font-medium">
{selectedDate ? format(selectedDate, "MMMM d, yyyy") : "Select a date"}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Time:</span>
<span className="font-medium">
{selectedTime ? formatTimeDisplay(selectedTime) : "Select a time"}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Duration:</span>
<span className="font-medium">{selectedClass.duration} minutes</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Price:</span>
<span className="font-medium">{formatPrice(selectedClass.price)}</span>
</div>
</div>
<div className="border-t border-gray-200 pt-4 mb-6">
<div className="flex justify-between text-lg font-semibold">
<span>Total:</span>
<span>{formatPrice(selectedClass.price)}</span>
</div>
</div>
<Button
onClick={handleConfirmBooking}
disabled={!selectedDate || !selectedTime || bookMutation.isPending}
className="w-full bg-teal text-white font-medium py-3 rounded-md hover:bg-opacity-90 transition duration-300 mb-4"
>
{bookMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Processing
</>
) : (
"Confirm Booking"
)}
</Button>
<p className="text-sm text-gray-500 mt-4 text-center">
You'll receive a confirmation email with details and calendar invite.
</p>
</div>
</div>
</div>
);
}
// Helper functions
function generateTimeSlots(date?: Date): string[] {
if (!date) return [];
const slots = [];
const startTime = 9; // 9 AM
const endTime = 20; // 8 PM
for (let hour = startTime; hour < endTime; hour++) {
slots.push(`${hour}:00`);
slots.push(`${hour}:30`);
}
// Add a random selection of unavailable slots
const availableSlots = [...slots];
const numberOfUnavailableSlots = Math.floor(Math.random() * 4) + 2; // 2-5 unavailable slots
for (let i = 0; i < numberOfUnavailableSlots; i++) {
const randomIndex = Math.floor(Math.random() * availableSlots.length);
availableSlots.splice(randomIndex, 1);
}
return availableSlots;
}
function formatTimeDisplay(time: string): string {
const [hours, minutes] = time.split(":").map(Number);
const period = hours < 12 ? "AM" : "PM";
const displayHours = hours % 12 || 12;
return `${displayHours}:${minutes.toString().padStart(2, "0")} ${period}`;
}