Refactor Booking component to manage time slots instead of time ranges. Update localStorage handling for time slots, enhance UI for selecting time slots, and improve validation for selected slots. Integrate public availability fetching in BookNowPage to display available time slots based on admin settings.

This commit is contained in:
iamkiddy 2025-11-25 21:04:22 +00:00
parent 6bcb6c5414
commit f6bd813c07
4 changed files with 249 additions and 171 deletions

View File

@ -53,7 +53,7 @@ export default function Booking() {
const { adminAvailability, isLoadingAdminAvailability, updateAvailability, isUpdatingAvailability, refetchAdminAvailability } = useAppointments();
const [selectedDays, setSelectedDays] = useState<number[]>([]);
const [availabilityDialogOpen, setAvailabilityDialogOpen] = useState(false);
const [dayTimeRanges, setDayTimeRanges] = useState<Record<number, { startTime: string; endTime: string }>>({});
const [dayTimeSlots, setDayTimeSlots] = useState<Record<number, string[]>>({});
const daysOfWeek = [
{ value: 0, label: "Monday" },
@ -65,64 +65,56 @@ export default function Booking() {
{ value: 6, label: "Sunday" },
];
// Load time ranges from localStorage on mount
// Load time slots from localStorage on mount
useEffect(() => {
const savedTimeRanges = localStorage.getItem("adminAvailabilityTimeRanges");
if (savedTimeRanges) {
const savedTimeSlots = localStorage.getItem("adminAvailabilityTimeSlots");
if (savedTimeSlots) {
try {
const parsed = JSON.parse(savedTimeRanges);
setDayTimeRanges(parsed);
const parsed = JSON.parse(savedTimeSlots);
setDayTimeSlots(parsed);
} catch (error) {
console.error("Failed to parse saved time ranges:", error);
console.error("Failed to parse saved time slots:", error);
}
}
}, []);
// Initialize selected days and time ranges when availability is loaded
// Initialize selected days and time slots when availability is loaded
useEffect(() => {
if (adminAvailability?.available_days) {
setSelectedDays(adminAvailability.available_days);
// Load saved time ranges or use defaults
const savedTimeRanges = localStorage.getItem("adminAvailabilityTimeRanges");
let initialRanges: Record<number, { startTime: string; endTime: string }> = {};
// Load saved time slots or use defaults
const savedTimeSlots = localStorage.getItem("adminAvailabilityTimeSlots");
let initialSlots: Record<number, string[]> = {};
if (savedTimeRanges) {
if (savedTimeSlots) {
try {
const parsed = JSON.parse(savedTimeRanges);
// Only use saved ranges for days that are currently available
const parsed = JSON.parse(savedTimeSlots);
// Only use saved slots for days that are currently available
adminAvailability.available_days.forEach((day) => {
initialRanges[day] = parsed[day] || { startTime: "09:00", endTime: "17:00" };
initialSlots[day] = parsed[day] || ["morning", "lunchtime", "afternoon"];
});
} catch (error) {
// If parsing fails, use defaults
adminAvailability.available_days.forEach((day) => {
initialRanges[day] = { startTime: "09:00", endTime: "17:00" };
initialSlots[day] = ["morning", "lunchtime", "afternoon"];
});
}
} else {
// No saved ranges, use defaults
// No saved slots, use defaults
adminAvailability.available_days.forEach((day) => {
initialRanges[day] = { startTime: "09:00", endTime: "17:00" };
initialSlots[day] = ["morning", "lunchtime", "afternoon"];
});
}
setDayTimeRanges(initialRanges);
setDayTimeSlots(initialSlots);
}
}, [adminAvailability]);
// Generate time slots for time picker
const generateTimeSlots = () => {
const slots = [];
for (let hour = 0; hour < 24; hour++) {
for (let minute = 0; minute < 60; minute += 30) {
const timeString = `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}`;
slots.push(timeString);
}
}
return slots;
};
const timeSlotsForPicker = generateTimeSlots();
const timeSlotOptions = [
{ value: "morning", label: "Morning" },
{ value: "lunchtime", label: "Lunchtime" },
{ value: "afternoon", label: "Evening" },
];
const handleDayToggle = (day: number) => {
setSelectedDays((prev) => {
@ -130,20 +122,20 @@ export default function Booking() {
? prev.filter((d) => d !== day)
: [...prev, day].sort();
// Initialize time range for newly added day
if (!prev.includes(day) && !dayTimeRanges[day]) {
setDayTimeRanges((prevRanges) => ({
...prevRanges,
[day]: { startTime: "09:00", endTime: "17:00" },
// Initialize time slots for newly added day
if (!prev.includes(day) && !dayTimeSlots[day]) {
setDayTimeSlots((prevSlots) => ({
...prevSlots,
[day]: ["morning", "lunchtime", "afternoon"],
}));
}
// Remove time range for removed day
// Remove time slots for removed day
if (prev.includes(day)) {
setDayTimeRanges((prevRanges) => {
const newRanges = { ...prevRanges };
delete newRanges[day];
return newRanges;
setDayTimeSlots((prevSlots) => {
const newSlots = { ...prevSlots };
delete newSlots[day];
return newSlots;
});
}
@ -151,14 +143,18 @@ export default function Booking() {
});
};
const handleTimeRangeChange = (day: number, field: "startTime" | "endTime", value: string) => {
setDayTimeRanges((prev) => ({
const handleTimeSlotToggle = (day: number, slot: string) => {
setDayTimeSlots((prev) => {
const currentSlots = prev[day] || [];
const newSlots = currentSlots.includes(slot)
? currentSlots.filter((s) => s !== slot)
: [...currentSlots, slot];
return {
...prev,
[day]: {
...prev[day],
[field]: value,
},
}));
[day]: newSlots,
};
});
};
const handleSaveAvailability = async () => {
@ -167,15 +163,11 @@ export default function Booking() {
return;
}
// Validate all time ranges
// Validate all time slots
for (const day of selectedDays) {
const timeRange = dayTimeRanges[day];
if (!timeRange || !timeRange.startTime || !timeRange.endTime) {
toast.error(`Please set time range for ${daysOfWeek.find(d => d.value === day)?.label}`);
return;
}
if (timeRange.startTime >= timeRange.endTime) {
toast.error(`End time must be after start time for ${daysOfWeek.find(d => d.value === day)?.label}`);
const timeSlots = dayTimeSlots[day];
if (!timeSlots || timeSlots.length === 0) {
toast.error(`Please select at least one time slot for ${daysOfWeek.find(d => d.value === day)?.label}`);
return;
}
}
@ -185,8 +177,8 @@ export default function Booking() {
const daysToSave = selectedDays.map(day => Number(day)).sort();
await updateAvailability({ available_days: daysToSave });
// Save time ranges to localStorage
localStorage.setItem("adminAvailabilityTimeRanges", JSON.stringify(dayTimeRanges));
// Save time slots to localStorage
localStorage.setItem("adminAvailabilityTimeSlots", JSON.stringify(dayTimeSlots));
toast.success("Availability updated successfully!");
// Refresh availability data
@ -204,12 +196,12 @@ export default function Booking() {
const handleOpenAvailabilityDialog = () => {
if (adminAvailability?.available_days) {
setSelectedDays(adminAvailability.available_days);
// Initialize time ranges for each day
const initialRanges: Record<number, { startTime: string; endTime: string }> = {};
// Initialize time slots for each day
const initialSlots: Record<number, string[]> = {};
adminAvailability.available_days.forEach((day) => {
initialRanges[day] = dayTimeRanges[day] || { startTime: "09:00", endTime: "17:00" };
initialSlots[day] = dayTimeSlots[day] || ["morning", "lunchtime", "afternoon"];
});
setDayTimeRanges(initialRanges);
setDayTimeSlots(initialSlots);
}
setAvailabilityDialogOpen(true);
};
@ -441,7 +433,11 @@ export default function Booking() {
<div className="flex flex-wrap gap-2">
{adminAvailability.available_days.map((dayNum, index) => {
const dayName = daysOfWeek.find(d => d.value === dayNum)?.label || adminAvailability.available_days_display[index];
const timeRange = dayTimeRanges[dayNum] || { startTime: "09:00", endTime: "17:00" };
const timeSlots = dayTimeSlots[dayNum] || [];
const slotLabels = timeSlots.map(slot => {
const option = timeSlotOptions.find(opt => opt.value === slot);
return option ? option.label : slot;
});
return (
<div
key={dayNum}
@ -453,19 +449,11 @@ export default function Booking() {
>
<Check className={`w-3.5 h-3.5 shrink-0 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
<span className="font-medium shrink-0">{dayName}</span>
{slotLabels.length > 0 && (
<span className={`text-sm shrink-0 ${isDark ? "text-rose-300" : "text-rose-600"}`}>
({new Date(`2000-01-01T${timeRange.startTime}`).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
})}{" "}
-{" "}
{new Date(`2000-01-01T${timeRange.endTime}`).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
})})
({slotLabels.join(", ")})
</span>
)}
</div>
);
})}
@ -835,14 +823,13 @@ export default function Booking() {
Available Days & Times *
</label>
<p className={`text-xs mb-3 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Select days and set time ranges for each day
Select days and choose time slots (Morning, Lunchtime, Evening) for each day
</p>
</div>
<div className="space-y-3">
{daysOfWeek.map((day) => {
const isSelected = selectedDays.includes(day.value);
const timeRange = dayTimeRanges[day.value] || { startTime: "09:00", endTime: "17:00" };
return (
<div
@ -886,80 +873,30 @@ export default function Booking() {
{isSelected && (
<div className="mt-3 pt-3 border-t border-gray-300 dark:border-gray-600">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Start Time */}
<div className="space-y-1.5">
<label className={`text-xs font-medium ${isDark ? "text-gray-400" : "text-gray-600"}`}>
Start Time
<label className={`text-xs font-medium mb-2 block ${isDark ? "text-gray-400" : "text-gray-600"}`}>
Available Time Slots
</label>
<Select
value={timeRange.startTime}
onValueChange={(value) => handleTimeRangeChange(day.value, "startTime", value)}
<div className="flex flex-wrap gap-2">
{timeSlotOptions.map((slot) => {
const isSelectedSlot = dayTimeSlots[day.value]?.includes(slot.value) || false;
return (
<button
key={slot.value}
type="button"
onClick={() => handleTimeSlotToggle(day.value, slot.value)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-all ${
isSelectedSlot
? isDark
? "bg-rose-600 border-rose-500 text-white"
: "bg-rose-500 border-rose-500 text-white"
: isDark
? "bg-gray-700 border-gray-600 text-gray-300 hover:border-rose-500"
: "bg-white border-gray-300 text-gray-700 hover:border-rose-500"
}`}
>
<SelectTrigger className={`h-9 text-sm ${isDark ? "bg-gray-700 border-gray-600 text-white" : "bg-white border-gray-300"}`}>
<SelectValue />
</SelectTrigger>
<SelectContent className={isDark ? "bg-gray-800 border-gray-700" : "bg-white"}>
{timeSlotsForPicker.map((time) => (
<SelectItem
key={time}
value={time}
className={isDark ? "text-white hover:bg-gray-700" : ""}
>
{new Date(`2000-01-01T${time}`).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
})}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* End Time */}
<div className="space-y-1.5">
<label className={`text-xs font-medium ${isDark ? "text-gray-400" : "text-gray-600"}`}>
End Time
</label>
<Select
value={timeRange.endTime}
onValueChange={(value) => handleTimeRangeChange(day.value, "endTime", value)}
>
<SelectTrigger className={`h-9 text-sm ${isDark ? "bg-gray-700 border-gray-600 text-white" : "bg-white border-gray-300"}`}>
<SelectValue />
</SelectTrigger>
<SelectContent className={isDark ? "bg-gray-800 border-gray-700" : "bg-white"}>
{timeSlotsForPicker.map((time) => (
<SelectItem
key={time}
value={time}
className={isDark ? "text-white hover:bg-gray-700" : ""}
>
{new Date(`2000-01-01T${time}`).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
})}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Time Range Preview */}
<div className={`mt-2 p-2 rounded text-xs ${isDark ? "bg-gray-800/50 text-gray-300" : "bg-white text-gray-600"}`}>
{new Date(`2000-01-01T${timeRange.startTime}`).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
})}{" "}
-{" "}
{new Date(`2000-01-01T${timeRange.endTime}`).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
{slot.label}
</button>
);
})}
</div>
</div>

View File

@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { useAppTheme } from "@/components/ThemeProvider";
import { Input } from "@/components/ui/input";
@ -24,6 +24,7 @@ import {
CheckCircle,
Loader2,
LogOut,
CalendarCheck,
} from "lucide-react";
import Link from "next/link";
import Image from "next/image";
@ -34,6 +35,8 @@ import { useAuth } from "@/hooks/useAuth";
import { useAppointments } from "@/hooks/useAppointments";
import { toast } from "sonner";
import type { Appointment } from "@/lib/models/appointments";
import { getPublicAvailability } from "@/lib/actions/appointments";
import type { AdminAvailability } from "@/lib/models/appointments";
interface User {
ID: number;
@ -80,7 +83,7 @@ export default function BookNowPage() {
const { theme } = useAppTheme();
const isDark = theme === "dark";
const { isAuthenticated, logout } = useAuth();
const { create, isCreating } = useAppointments();
const { create, isCreating, availableDates, availableDatesResponse, isLoadingAvailableDates } = useAppointments();
const [formData, setFormData] = useState({
firstName: "",
lastName: "",
@ -95,6 +98,68 @@ export default function BookNowPage() {
const [showLoginDialog, setShowLoginDialog] = useState(false);
const [showSignupDialog, setShowSignupDialog] = useState(false);
const [loginPrefillEmail, setLoginPrefillEmail] = useState<string | undefined>(undefined);
const [publicAvailability, setPublicAvailability] = useState<AdminAvailability | null>(null);
const [availableTimeSlots, setAvailableTimeSlots] = useState<Record<number, string[]>>({});
// Fetch public availability to get time slots
useEffect(() => {
const fetchAvailability = async () => {
try {
const availability = await getPublicAvailability();
if (availability) {
setPublicAvailability(availability);
// Try to get time slots from localStorage (if admin has set them)
// Note: This won't work for public users, but we can try
const savedTimeSlots = localStorage.getItem("adminAvailabilityTimeSlots");
if (savedTimeSlots) {
try {
const parsed = JSON.parse(savedTimeSlots);
setAvailableTimeSlots(parsed);
} catch (e) {
console.error("Failed to parse time slots:", e);
}
}
}
} catch (error) {
console.error("Failed to fetch public availability:", error);
}
};
fetchAvailability();
}, []);
// Use available_days_display from API if available, otherwise extract from dates
const availableDaysOfWeek = useMemo(() => {
// If API provides available_days_display, use it directly
if (availableDatesResponse?.available_days_display && availableDatesResponse.available_days_display.length > 0) {
return availableDatesResponse.available_days_display;
}
// Otherwise, extract from dates
if (!availableDates || availableDates.length === 0) {
return [];
}
const daysSet = new Set<string>();
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
availableDates.forEach((dateStr) => {
try {
// Parse date string (YYYY-MM-DD format)
const [year, month, day] = dateStr.split('-').map(Number);
const date = new Date(year, month - 1, day);
if (!isNaN(date.getTime())) {
const dayIndex = date.getDay();
daysSet.add(dayNames[dayIndex]);
}
} catch (e) {
console.error('Invalid date:', dateStr, e);
}
});
// Return in weekday order (Monday first)
const weekdayOrder = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
return weekdayOrder.filter(day => daysSet.has(day));
}, [availableDates, availableDatesResponse]);
const handleLogout = () => {
logout();
@ -574,8 +639,19 @@ export default function BookNowPage() {
<Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-500'}`} />
Available Days *
</label>
{isLoadingAvailableDates ? (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Loader2 className="w-4 h-4 animate-spin" />
Loading available days...
</div>
) : availableDaysOfWeek.length === 0 ? (
<p className={`text-sm ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
No available days at the moment. Please check back later.
</p>
) : (
<>
<div className="flex flex-wrap gap-3">
{['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'].map((day) => (
{availableDaysOfWeek.map((day) => (
<label
key={day}
className={`flex items-center gap-2 cursor-pointer px-4 py-2 rounded-lg border transition-all ${
@ -598,6 +674,8 @@ export default function BookNowPage() {
</label>
))}
</div>
</>
)}
</div>
<div className="space-y-2">
@ -608,11 +686,33 @@ export default function BookNowPage() {
Preferred Time *
</label>
<div className="flex flex-wrap gap-3">
{[
{(() => {
// Get available time slots based on selected days
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const dayIndices = formData.preferredDays.map(day => dayNames.indexOf(day));
// Get all unique time slots from selected days
let allAvailableSlots = new Set<string>();
dayIndices.forEach(dayIndex => {
if (dayIndex !== -1 && availableTimeSlots[dayIndex]) {
availableTimeSlots[dayIndex].forEach(slot => allAvailableSlots.add(slot));
}
});
// If no time slots found in localStorage, show all (fallback)
const slotsToShow = allAvailableSlots.size > 0
? Array.from(allAvailableSlots)
: ['morning', 'lunchtime', 'afternoon'];
const timeSlotMap = [
{ value: 'morning', label: 'Morning' },
{ value: 'lunchtime', label: 'Lunchtime' },
{ value: 'afternoon', label: 'Afternoon' }
].map((time) => (
{ value: 'afternoon', label: 'Evening' }
];
// Only show time slots that are available
return timeSlotMap.filter(ts => slotsToShow.includes(ts.value));
})().map((time) => (
<label
key={time.value}
className={`flex items-center gap-2 cursor-pointer px-4 py-2 rounded-lg border transition-all ${

View File

@ -32,7 +32,7 @@ export function useAppointments() {
const queryClient = useQueryClient();
// Get available dates query
const availableDatesQuery = useQuery<string[]>({
const availableDatesQuery = useQuery<AvailableDatesResponse>({
queryKey: ["appointments", "available-dates"],
queryFn: () => getAvailableDates(),
staleTime: 5 * 60 * 1000, // 5 minutes
@ -160,7 +160,8 @@ export function useAppointments() {
return {
// Queries
availableDates: availableDatesQuery.data || [],
availableDates: availableDatesQuery.data?.dates || [],
availableDatesResponse: availableDatesQuery.data,
appointments: appointmentsQuery.data || [],
userAppointments: userAppointmentsQuery.data || [],
adminAvailability: adminAvailabilityQuery.data,

View File

@ -79,7 +79,7 @@ export async function createAppointment(
}
// Get available dates
export async function getAvailableDates(): Promise<string[]> {
export async function getAvailableDates(): Promise<AvailableDatesResponse> {
const response = await fetch(API_ENDPOINTS.meetings.availableDates, {
method: "GET",
headers: {
@ -94,11 +94,14 @@ export async function getAvailableDates(): Promise<string[]> {
throw new Error(errorMessage);
}
// API returns array of dates in YYYY-MM-DD format
// If API returns array directly, wrap it in response object
if (Array.isArray(data)) {
return data;
return {
dates: data,
};
}
return (data as AvailableDatesResponse).dates || [];
return data as AvailableDatesResponse;
}
// List appointments (Admin sees all, users see their own)
@ -279,6 +282,43 @@ export async function rejectAppointment(
return data as unknown as Appointment;
}
// Get admin availability (public version - tries without auth first)
export async function getPublicAvailability(): Promise<AdminAvailability | null> {
try {
const response = await fetch(API_ENDPOINTS.meetings.adminAvailability, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
return null;
}
const data: any = await response.json();
// Handle both string and array formats for available_days
let availableDays: number[] = [];
if (typeof data.available_days === 'string') {
try {
availableDays = JSON.parse(data.available_days);
} catch {
availableDays = data.available_days.split(',').map((d: string) => parseInt(d.trim())).filter((d: number) => !isNaN(d));
}
} else if (Array.isArray(data.available_days)) {
availableDays = data.available_days;
}
return {
available_days: availableDays,
available_days_display: data.available_days_display || [],
} as AdminAvailability;
} catch (error) {
return null;
}
}
// Get admin availability
export async function getAdminAvailability(): Promise<AdminAvailability> {
const tokens = getStoredTokens();