From 6b83b092e3ae13111877699e63b8c0952dfd49d2 Mon Sep 17 00:00:00 2001 From: iamkiddy Date: Thu, 27 Nov 2025 19:18:59 +0000 Subject: [PATCH 1/3] Refactor Booking and AppointmentDetail components to integrate new appointment scheduling format. Update time slot management to use selected_slots, enhance availability loading logic, and improve error handling. Replace legacy date and time handling with a more robust API-driven approach for better user experience and data consistency. --- app/(admin)/admin/booking/[id]/page.tsx | 245 +++----- app/(admin)/admin/booking/page.tsx | 347 ++++++----- app/(admin)/admin/dashboard/page.tsx | 1 - app/(admin)/admin/settings/page.tsx | 2 - app/(pages)/book-now/page.tsx | 599 +++++++++--------- app/(pages)/deliverables/page.tsx | 2 +- app/(user)/user/dashboard/page.tsx | 537 +++++++++++----- app/(user)/user/settings/page.tsx | 3 - components/ClockDurationPicker.tsx | 177 ++++++ components/ClockTimePicker.tsx | 278 +++++++++ components/DurationPicker.tsx | 72 +++ components/ForgotPasswordDialog.tsx | 1 + components/ScheduleAppointmentDialog.tsx | 170 +++++ components/TimePicker.tsx | 115 ++++ hooks/useAppointments.ts | 85 ++- lib/actions/appointments.ts | 755 +++++++++++++++++++---- lib/api_urls.ts | 4 + lib/models/appointments.ts | 97 ++- lib/schema/appointments.ts | 47 +- lib/utils/encryption.ts | 5 +- 20 files changed, 2631 insertions(+), 911 deletions(-) create mode 100644 components/ClockDurationPicker.tsx create mode 100644 components/ClockTimePicker.tsx create mode 100644 components/DurationPicker.tsx create mode 100644 components/ScheduleAppointmentDialog.tsx create mode 100644 components/TimePicker.tsx diff --git a/app/(admin)/admin/booking/[id]/page.tsx b/app/(admin)/admin/booking/[id]/page.tsx index 793408d..47b134e 100644 --- a/app/(admin)/admin/booking/[id]/page.tsx +++ b/app/(admin)/admin/booking/[id]/page.tsx @@ -30,8 +30,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { DatePicker } from "@/components/DatePicker"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { ScheduleAppointmentDialog } from "@/components/ScheduleAppointmentDialog"; import { toast } from "sonner"; import type { Appointment } from "@/lib/models/appointments"; @@ -62,7 +61,6 @@ export default function AppointmentDetailPage() { const data = await getAppointmentDetail(appointmentId); setAppointment(data); } catch (error) { - console.error("Failed to fetch appointment details:", error); toast.error("Failed to load appointment details"); router.push("/admin/booking"); } finally { @@ -139,10 +137,6 @@ export default function AppointmentDetailPage() { return status.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase()); }; - const timeSlots = Array.from({ length: 24 }, (_, i) => { - const hour = i.toString().padStart(2, "0"); - return `${hour}:00`; - }); const handleSchedule = async () => { if (!appointment || !scheduledDate) return; @@ -165,7 +159,6 @@ export default function AppointmentDetailPage() { const updated = await getAppointmentDetail(appointment.id); setAppointment(updated); } catch (error: any) { - console.error("Failed to schedule appointment:", error); toast.error(error.message || "Failed to schedule appointment"); } finally { setIsScheduling(false); @@ -188,7 +181,6 @@ export default function AppointmentDetailPage() { const updated = await getAppointmentDetail(appointment.id); setAppointment(updated); } catch (error: any) { - console.error("Failed to reject appointment:", error); toast.error(error.message || "Failed to reject appointment"); } finally { setIsRejecting(false); @@ -422,6 +414,64 @@ export default function AppointmentDetailPage() { )} + {/* Matching Availability */} + {appointment.matching_availability && Array.isArray(appointment.matching_availability) && appointment.matching_availability.length > 0 && ( +
+
+

+ + Matching Availability + {appointment.are_preferences_available !== undefined && ( + + {appointment.are_preferences_available ? "Available" : "Partially Available"} + + )} +

+
+
+
+ {appointment.matching_availability.map((match: any, idx: number) => ( +
+
+
+

+ {match.day_name || "Unknown Day"} +

+

+ {formatShortDate(match.date || match.date_obj || "")} +

+
+
+ {match.available_slots && Array.isArray(match.available_slots) && match.available_slots.length > 0 && ( +
+ {match.available_slots.map((slot: string, slotIdx: number) => { + const timeSlotLabels: Record = { + morning: "Morning", + afternoon: "Lunchtime", + evening: "Evening", + }; + const normalizedSlot = String(slot).toLowerCase().trim(); + return ( + + {timeSlotLabels[normalizedSlot] || slot} + + ); + })} +
+ )} +
+ ))} +
+
+
+ )} + {/* Reason */} {appointment.reason && (
@@ -584,15 +634,25 @@ export default function AppointmentDetailPage() { {appointment.status === "scheduled" && appointment.jitsi_meet_url && (
- - + {appointment.can_join_meeting ? ( + + + ) : ( + + )}
)} @@ -600,140 +660,21 @@ export default function AppointmentDetailPage() {
- {/* Google Meet Style Schedule Dialog */} - - - - - Schedule Appointment - - - Set date and time for {appointment.first_name} {appointment.last_name}'s appointment - - - -
- {/* Date Selection */} -
- -
- -
-
- - {/* Time Selection */} -
- -
- -
-
- - {/* Duration Selection */} -
- -
-
- {[30, 60, 90, 120].map((duration) => ( - - ))} -
-
-
- - {/* Preview */} - {scheduledDate && ( -
-

- Appointment Preview -

-
-

- {formatDate(scheduledDate.toISOString())} -

-

- {new Date(`2000-01-01T${scheduledTime}`).toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - hour12: true, - })} • {scheduledDuration} minutes -

-
-
- )} -
- - - - - -
-
+ {/* Schedule Appointment Dialog */} + {/* Reject Appointment Dialog */} diff --git a/app/(admin)/admin/booking/page.tsx b/app/(admin)/admin/booking/page.tsx index 163d95e..c1850c9 100644 --- a/app/(admin)/admin/booking/page.tsx +++ b/app/(admin)/admin/booking/page.tsx @@ -27,8 +27,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { DatePicker } from "@/components/DatePicker"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { ScheduleAppointmentDialog } from "@/components/ScheduleAppointmentDialog"; import { toast } from "sonner"; import type { Appointment } from "@/lib/models/appointments"; @@ -50,7 +49,13 @@ export default function Booking() { const isDark = theme === "dark"; // Availability management - const { adminAvailability, isLoadingAdminAvailability, updateAvailability, isUpdatingAvailability, refetchAdminAvailability } = useAppointments(); + const { + adminAvailability, + isLoadingAdminAvailability, + updateAvailability, + isUpdatingAvailability, + refetchAdminAvailability, + } = useAppointments(); const [selectedDays, setSelectedDays] = useState([]); const [availabilityDialogOpen, setAvailabilityDialogOpen] = useState(false); const [dayTimeSlots, setDayTimeSlots] = useState>({}); @@ -65,22 +70,26 @@ export default function Booking() { { value: 6, label: "Sunday" }, ]; - // Load time slots from localStorage on mount - useEffect(() => { - const savedTimeSlots = localStorage.getItem("adminAvailabilityTimeSlots"); - if (savedTimeSlots) { - try { - const parsed = JSON.parse(savedTimeSlots); - setDayTimeSlots(parsed); - } catch (error) { - console.error("Failed to parse saved time slots:", error); - } - } - }, []); + // Time slots will be loaded from API in the useEffect below // Initialize selected days and time slots when availability is loaded useEffect(() => { - if (adminAvailability?.available_days) { + // Try new format first (availability_schedule) + if (adminAvailability?.availability_schedule) { + const schedule = adminAvailability.availability_schedule; + const days = Object.keys(schedule).map(Number); + setSelectedDays(days); + + // Convert schedule format to dayTimeSlots format + const initialSlots: Record = {}; + days.forEach((day) => { + initialSlots[day] = schedule[day.toString()] || []; + }); + + setDayTimeSlots(initialSlots); + } + // Fallback to legacy format + else if (adminAvailability?.available_days) { setSelectedDays(adminAvailability.available_days); // Load saved time slots or use defaults const savedTimeSlots = localStorage.getItem("adminAvailabilityTimeSlots"); @@ -91,18 +100,28 @@ export default function Booking() { const parsed = JSON.parse(savedTimeSlots); // Only use saved slots for days that are currently available adminAvailability.available_days.forEach((day) => { - initialSlots[day] = parsed[day] || ["morning", "lunchtime", "afternoon"]; + // Map old time slot names to new ones + const oldSlots = parsed[day] || []; + initialSlots[day] = oldSlots.map((slot: string) => { + if (slot === "lunchtime") return "afternoon"; + if (slot === "afternoon") return "evening"; + return slot; + }).filter((slot: string) => ["morning", "afternoon", "evening"].includes(slot)); + // If no valid slots after mapping, use defaults + if (initialSlots[day].length === 0) { + initialSlots[day] = ["morning", "afternoon"]; + } }); } catch (error) { // If parsing fails, use defaults adminAvailability.available_days.forEach((day) => { - initialSlots[day] = ["morning", "lunchtime", "afternoon"]; + initialSlots[day] = ["morning", "afternoon"]; }); } } else { // No saved slots, use defaults adminAvailability.available_days.forEach((day) => { - initialSlots[day] = ["morning", "lunchtime", "afternoon"]; + initialSlots[day] = ["morning", "afternoon"]; }); } @@ -112,8 +131,8 @@ export default function Booking() { const timeSlotOptions = [ { value: "morning", label: "Morning" }, - { value: "lunchtime", label: "Lunchtime" }, - { value: "afternoon", label: "Evening" }, + { value: "afternoon", label: "Lunchtime" }, + { value: "evening", label: "Evening" }, ]; const handleDayToggle = (day: number) => { @@ -126,7 +145,7 @@ export default function Booking() { if (!prev.includes(day) && !dayTimeSlots[day]) { setDayTimeSlots((prevSlots) => ({ ...prevSlots, - [day]: ["morning", "lunchtime", "afternoon"], + [day]: ["morning", "afternoon"], })); } @@ -170,14 +189,42 @@ export default function Booking() { toast.error(`Please select at least one time slot for ${daysOfWeek.find(d => d.value === day)?.label}`); return; } + // Validate time slots are valid + const validSlots = ["morning", "afternoon", "evening"]; + const invalidSlots = timeSlots.filter(slot => !validSlots.includes(slot)); + if (invalidSlots.length > 0) { + toast.error(`Invalid time slots: ${invalidSlots.join(", ")}. Only morning, afternoon, and evening are allowed.`); + return; + } } try { - // Ensure selectedDays is an array of numbers - const daysToSave = selectedDays.map(day => Number(day)).sort(); - await updateAvailability({ available_days: daysToSave }); + // Build availability_schedule format: {"0": ["morning", "evening"], "1": ["afternoon"]} + const availabilitySchedule: Record = {}; + selectedDays.forEach(day => { + const timeSlots = dayTimeSlots[day]; + if (timeSlots && timeSlots.length > 0) { + // Ensure only valid time slots and remove duplicates + const validSlots = timeSlots + .filter(slot => ["morning", "afternoon", "evening"].includes(slot)) + .filter((slot, index, self) => self.indexOf(slot) === index); // Remove duplicates + + if (validSlots.length > 0) { + availabilitySchedule[day.toString()] = validSlots; + } + } + }); + + // Validate we have at least one day with slots + if (Object.keys(availabilitySchedule).length === 0) { + toast.error("Please select at least one day with valid time slots"); + return; + } + + // Send in new format + await updateAvailability({ availability_schedule: availabilitySchedule }); - // Save time slots to localStorage + // Also save to localStorage for backwards compatibility localStorage.setItem("adminAvailabilityTimeSlots", JSON.stringify(dayTimeSlots)); toast.success("Availability updated successfully!"); @@ -187,21 +234,40 @@ export default function Booking() { } setAvailabilityDialogOpen(false); } catch (error) { - console.error("Failed to update availability:", error); const errorMessage = error instanceof Error ? error.message : "Failed to update availability"; - toast.error(errorMessage); + toast.error(`Failed to update availability: ${errorMessage}`, { + duration: 5000, + }); } }; const handleOpenAvailabilityDialog = () => { - if (adminAvailability?.available_days) { + // Try new format first (availability_schedule) + if (adminAvailability?.availability_schedule) { + const schedule = adminAvailability.availability_schedule; + const days = Object.keys(schedule).map(Number); + setSelectedDays(days); + + // Convert schedule format to dayTimeSlots format + const initialSlots: Record = {}; + days.forEach((day) => { + initialSlots[day] = schedule[day.toString()] || ["morning", "afternoon"]; + }); + setDayTimeSlots(initialSlots); + } + // Fallback to legacy format + else if (adminAvailability?.available_days) { setSelectedDays(adminAvailability.available_days); // Initialize time slots for each day const initialSlots: Record = {}; adminAvailability.available_days.forEach((day) => { - initialSlots[day] = dayTimeSlots[day] || ["morning", "lunchtime", "afternoon"]; + initialSlots[day] = dayTimeSlots[day] || ["morning", "afternoon"]; }); setDayTimeSlots(initialSlots); + } else { + // No existing availability, start fresh + setSelectedDays([]); + setDayTimeSlots({}); } setAvailabilityDialogOpen(true); }; @@ -213,7 +279,6 @@ export default function Booking() { const data = await listAppointments(); setAppointments(data || []); } catch (error) { - console.error("Failed to fetch appointments:", error); toast.error("Failed to load appointments. Please try again."); setAppointments([]); } finally { @@ -337,7 +402,6 @@ export default function Booking() { const data = await listAppointments(); setAppointments(data || []); } catch (error) { - console.error("Failed to schedule appointment:", error); const errorMessage = error instanceof Error ? error.message : "Failed to schedule appointment"; toast.error(errorMessage); } finally { @@ -363,7 +427,6 @@ export default function Booking() { const data = await listAppointments(); setAppointments(data || []); } catch (error) { - console.error("Failed to reject appointment:", error); const errorMessage = error instanceof Error ? error.message : "Failed to reject appointment"; toast.error(errorMessage); } finally { @@ -371,14 +434,6 @@ export default function Booking() { } }; - // Generate time slots - const timeSlots = []; - for (let hour = 8; hour <= 18; hour++) { - for (let minute = 0; minute < 60; minute += 30) { - const timeString = `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}`; - timeSlots.push(timeString); - } - } const filteredAppointments = appointments.filter( (appointment) => @@ -442,34 +497,70 @@ export default function Booking() {

Weekly Availability

- {adminAvailability.available_days_display && adminAvailability.available_days_display.length > 0 ? ( + {(adminAvailability.availability_schedule || (adminAvailability.available_days_display && adminAvailability.available_days_display.length > 0)) ? (
- {adminAvailability.available_days.map((dayNum, index) => { - const dayName = daysOfWeek.find(d => d.value === dayNum)?.label || adminAvailability.available_days_display[index]; - const timeSlots = dayTimeSlots[dayNum] || []; - const slotLabels = timeSlots.map(slot => { - const option = timeSlotOptions.find(opt => opt.value === slot); - return option ? option.label : slot; - }); - return ( -
- - {dayName} - {slotLabels.length > 0 && ( - - ({slotLabels.join(", ")}) - - )} -
- ); - })} + {(() => { + // Try new format first + if (adminAvailability.availability_schedule) { + return Object.keys(adminAvailability.availability_schedule).map((dayKey) => { + const dayNum = parseInt(dayKey); + const dayName = daysOfWeek.find(d => d.value === dayNum)?.label || `Day ${dayNum}`; + const timeSlots = adminAvailability.availability_schedule![dayKey] || []; + const slotLabels = timeSlots.map((slot: string) => { + const option = timeSlotOptions.find(opt => opt.value === slot); + return option ? option.label : slot; + }); + return ( +
+ + {dayName} + {slotLabels.length > 0 && ( + + ({slotLabels.join(", ")}) + + )} +
+ ); + }); + } + // Fallback to legacy format + else if (adminAvailability.available_days && adminAvailability.available_days.length > 0) { + return adminAvailability.available_days.map((dayNum, index) => { + const dayName = daysOfWeek.find(d => d.value === dayNum)?.label || adminAvailability.available_days_display?.[index]; + const timeSlots = dayTimeSlots[dayNum] || []; + const slotLabels = timeSlots.map(slot => { + const option = timeSlotOptions.find(opt => opt.value === slot); + return option ? option.label : slot; + }); + return ( +
+ + {dayName} + {slotLabels.length > 0 && ( + + ({slotLabels.join(", ")}) + + )} +
+ ); + }); + } + return null; + })()}
) : (

@@ -655,103 +746,27 @@ export default function Booking() { {/* Schedule Appointment Dialog */} -

- - - - Schedule Appointment - - - {selectedAppointment && ( - <>Schedule appointment for {selectedAppointment.first_name} {selectedAppointment.last_name} - )} - - - -
-
- - -
- -
- - -
- -
- - -
-
- - - - - -
-
+ { + setScheduleDialogOpen(open); + if (!open) { + setScheduledDate(undefined); + setScheduledTime("09:00"); + setScheduledDuration(60); + } + }} + appointment={selectedAppointment} + scheduledDate={scheduledDate} + setScheduledDate={setScheduledDate} + scheduledTime={scheduledTime} + setScheduledTime={setScheduledTime} + scheduledDuration={scheduledDuration} + setScheduledDuration={setScheduledDuration} + onSchedule={handleSchedule} + isScheduling={isScheduling} + isDark={isDark} + /> {/* Reject Appointment Dialog */} @@ -836,7 +851,7 @@ export default function Booking() { Available Days & Times *

- Select days and choose time slots (Morning, Lunchtime, Evening) for each day + Select days and choose time slots (Morning, Afternoon, Evening) for each day

diff --git a/app/(admin)/admin/dashboard/page.tsx b/app/(admin)/admin/dashboard/page.tsx index 6b9e7ca..2c3e3c8 100644 --- a/app/(admin)/admin/dashboard/page.tsx +++ b/app/(admin)/admin/dashboard/page.tsx @@ -132,7 +132,6 @@ export default function Dashboard() { trends, }); } catch (error) { - console.error("Failed to fetch dashboard stats:", error); toast.error("Failed to load dashboard statistics"); // Set default values on error setStats({ diff --git a/app/(admin)/admin/settings/page.tsx b/app/(admin)/admin/settings/page.tsx index 90a17c5..80eb948 100644 --- a/app/(admin)/admin/settings/page.tsx +++ b/app/(admin)/admin/settings/page.tsx @@ -55,7 +55,6 @@ export default function AdminSettingsPage() { phone: profile.phone_number || "", }); } catch (error) { - console.error("Failed to fetch profile:", error); const errorMessage = error instanceof Error ? error.message : "Failed to load profile"; toast.error(errorMessage); } finally { @@ -102,7 +101,6 @@ export default function AdminSettingsPage() { }); toast.success("Profile updated successfully!"); } catch (error) { - console.error("Failed to update profile:", error); const errorMessage = error instanceof Error ? error.message : "Failed to update profile"; toast.error(errorMessage); } finally { diff --git a/app/(pages)/book-now/page.tsx b/app/(pages)/book-now/page.tsx index 28faf5d..b00fe7c 100644 --- a/app/(pages)/book-now/page.tsx +++ b/app/(pages)/book-now/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect, useMemo, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { useAppTheme } from "@/components/ThemeProvider"; import { Input } from "@/components/ui/input"; @@ -35,8 +35,6 @@ 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; @@ -83,14 +81,21 @@ export default function BookNowPage() { const { theme } = useAppTheme(); const isDark = theme === "dark"; const { isAuthenticated, logout } = useAuth(); - const { create, isCreating, availableDates, availableDatesResponse, isLoadingAvailableDates } = useAppointments(); + const { + create, + isCreating, + weeklyAvailability, + isLoadingWeeklyAvailability, + availabilityOverview, + isLoadingAvailabilityOverview, + availabilityConfig, + } = useAppointments(); const [formData, setFormData] = useState({ firstName: "", lastName: "", email: "", phone: "", - preferredDays: [] as string[], - preferredTimes: [] as string[], + selectedSlots: [] as Array<{ day: number; time_slot: string }>, // New format message: "", }); const [booking, setBooking] = useState(null); @@ -98,68 +103,97 @@ export default function BookNowPage() { const [showLoginDialog, setShowLoginDialog] = useState(false); const [showSignupDialog, setShowSignupDialog] = useState(false); const [loginPrefillEmail, setLoginPrefillEmail] = useState(undefined); - const [publicAvailability, setPublicAvailability] = useState(null); - const [availableTimeSlots, setAvailableTimeSlots] = useState>({}); - // 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); - } + // Helper function to convert day name to day number (0-6) + const getDayNumber = (dayName: string): number => { + const dayMap: Record = { + 'monday': 0, + 'tuesday': 1, + 'wednesday': 2, + 'thursday': 3, + 'friday': 4, + 'saturday': 5, + 'sunday': 6, + }; + return dayMap[dayName.toLowerCase()] ?? -1; + }; + + // Get available days from availability overview (primary) or weekly availability (fallback) + const availableDaysOfWeek = useMemo(() => { + // Try availability overview first (preferred) + if (availabilityOverview && availabilityOverview.available && availabilityOverview.next_available_dates && availabilityOverview.next_available_dates.length > 0) { + // Group by day name and get unique days with their slots from next_available_dates + const dayMap = new Map }>(); + + availabilityOverview.next_available_dates.forEach((dateInfo: any) => { + if (!dateInfo || !dateInfo.day_name) return; + + const dayName = String(dateInfo.day_name).trim(); + const dayNum = getDayNumber(dayName); + + if (dayNum >= 0 && dayNum <= 6 && dateInfo.available_slots && Array.isArray(dateInfo.available_slots) && dateInfo.available_slots.length > 0) { + const existingDay = dayMap.get(dayName); + + if (existingDay) { + // Merge slots if day already exists + dateInfo.available_slots.forEach((slot: string) => { + existingDay.availableSlots.add(String(slot).toLowerCase().trim()); + }); + } else { + // Create new day entry + const slotsSet = new Set(); + dateInfo.available_slots.forEach((slot: string) => { + slotsSet.add(String(slot).toLowerCase().trim()); + }); + dayMap.set(dayName, { + day: dayNum, + dayName: dayName, + availableSlots: slotsSet, + }); } } - } 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; + }); + + // Convert Map values to array and sort by day number + return Array.from(dayMap.values()) + .map(day => ({ + day: day.day, + dayName: day.dayName, + availableSlots: Array.from(day.availableSlots), + })) + .sort((a, b) => a.day - b.day); } - // Otherwise, extract from dates - if (!availableDates || availableDates.length === 0) { - return []; + // Fallback to weekly availability + if (weeklyAvailability) { + // Handle both array format and object with 'week' property + const weekArray = Array.isArray(weeklyAvailability) + ? weeklyAvailability + : (weeklyAvailability as any)?.week; + + if (weekArray && Array.isArray(weekArray)) { + return weekArray + .filter(day => { + const dayNum = Number(day.day); + return day.is_available && + day.available_slots && + Array.isArray(day.available_slots) && + day.available_slots.length > 0 && + !isNaN(dayNum) && + dayNum >= 0 && + dayNum <= 6; + }) + .map(day => ({ + day: Number(day.day), + dayName: day.day_name || 'Unknown', + availableSlots: day.available_slots || [], + })) + .sort((a, b) => a.day - b.day); + } } - const daysSet = new Set(); - const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; - - availableDates.forEach((dateStr: string) => { - 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]); + return []; + }, [availabilityOverview, weeklyAvailability]); const handleLogout = () => { logout(); @@ -217,79 +251,89 @@ export default function BookNowPage() { setError(null); try { - if (formData.preferredDays.length === 0) { - setError("Please select at least one available day."); - return; - } - - if (formData.preferredTimes.length === 0) { - setError("Please select at least one preferred time."); - return; - } - - // Convert day names to dates (YYYY-MM-DD format) - // Get next occurrence of each selected day within the next 30 days - const today = new Date(); - today.setHours(0, 0, 0, 0); // Reset to start of day - const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; - const preferredDates: string[] = []; + // Get current slots from formData + const currentSlots = formData.selectedSlots || []; - formData.preferredDays.forEach((dayName) => { - const targetDayIndex = days.indexOf(dayName); - if (targetDayIndex === -1) { - console.warn(`Invalid day name: ${dayName}`); + // Check if slots are selected + if (!currentSlots || currentSlots.length === 0) { + setError("Please select at least one day and time slot combination by clicking on the time slot buttons."); + return; + } + + // Prepare and validate slots - be very lenient + const validSlots = currentSlots + .map(slot => { + if (!slot) return null; + + // Get day - handle any format + let dayNum: number; + if (typeof slot.day === 'number') { + dayNum = slot.day; + } else { + dayNum = parseInt(String(slot.day || 0), 10); + } + + // Validate day + if (isNaN(dayNum) || dayNum < 0 || dayNum > 6) { + return null; + } + + // Get time_slot - normalize + const timeSlot = String(slot.time_slot || '').trim().toLowerCase(); + + // Validate time_slot - accept morning, afternoon, evening + if (!timeSlot || !['morning', 'afternoon', 'evening'].includes(timeSlot)) { + return null; + } + + return { + day: dayNum, + time_slot: timeSlot as "morning" | "afternoon" | "evening", + }; + }) + .filter((slot): slot is { day: number; time_slot: "morning" | "afternoon" | "evening" } => slot !== null); + + // Final validation check + if (!validSlots || validSlots.length === 0) { + setError("Please select at least one day and time slot combination by clicking on the time slot buttons."); return; } - // Find the next occurrence of this day within the next 30 days - for (let i = 1; i <= 30; i++) { - const checkDate = new Date(today); - checkDate.setDate(today.getDate() + i); - - if (checkDate.getDay() === targetDayIndex) { - const dateString = checkDate.toISOString().split("T")[0]; - if (!preferredDates.includes(dateString)) { - preferredDates.push(dateString); - } - break; // Only take the first occurrence - } - } - }); - - // Sort dates - preferredDates.sort(); + // Validate and limit field lengths to prevent database errors + const firstName = formData.firstName.trim().substring(0, 100); + const lastName = formData.lastName.trim().substring(0, 100); + const email = formData.email.trim().toLowerCase().substring(0, 100); + const phone = formData.phone ? formData.phone.trim().substring(0, 100) : undefined; + const reason = formData.message ? formData.message.trim().substring(0, 100) : undefined; - if (preferredDates.length === 0) { - setError("Please select at least one available day."); + // Validate required fields + if (!firstName || firstName.length === 0) { + setError("First name is required."); + return; + } + if (!lastName || lastName.length === 0) { + setError("Last name is required."); + return; + } + if (!email || email.length === 0) { + setError("Email address is required."); + return; + } + if (!email.includes('@')) { + setError("Please enter a valid email address."); return; } - // Map time slots - API expects "morning", "afternoon", "evening" - // Form has "morning", "lunchtime", "afternoon" (where "afternoon" label is "Evening") - const timeSlotMap: { [key: string]: "morning" | "afternoon" | "evening" } = { - morning: "morning", - lunchtime: "afternoon", // Map lunchtime to afternoon - afternoon: "evening", // Form's "afternoon" value (labeled "Evening") maps to API's "evening" - }; - - const preferredTimeSlots = formData.preferredTimes - .map((time) => timeSlotMap[time] || "morning") - .filter((time, index, self) => self.indexOf(time) === index) as ("morning" | "afternoon" | "evening")[]; // Remove duplicates - - // Prepare request payload according to API spec + // Prepare payload with validated and limited fields const payload = { - first_name: formData.firstName.trim(), - last_name: formData.lastName.trim(), - email: formData.email.trim().toLowerCase(), - preferred_dates: preferredDates, - preferred_time_slots: preferredTimeSlots, - ...(formData.phone && formData.phone.trim() && { phone: formData.phone.trim() }), - ...(formData.message && formData.message.trim() && { reason: formData.message.trim() }), + first_name: firstName, + last_name: lastName, + email: email, + selected_slots: validSlots, + ...(phone && phone.length > 0 && { phone: phone }), + ...(reason && reason.length > 0 && { reason: reason }), }; - // Validate payload before sending - console.log("Booking payload:", JSON.stringify(payload, null, 2)); - // Call the actual API using the hook const appointmentData = await create(payload); @@ -328,15 +372,11 @@ export default function BookNowPage() { setBooking(bookingData); toast.success("Appointment request submitted successfully! We'll review and get back to you soon."); - // Redirect to user dashboard after 3 seconds - setTimeout(() => { - router.push("/user/dashboard"); - }, 3000); + // Stay on the booking page to show the receipt - no redirect } catch (err) { const errorMessage = err instanceof Error ? err.message : "Failed to submit booking. Please try again."; setError(errorMessage); toast.error(errorMessage); - console.error("Booking error:", err); } }; @@ -344,22 +384,50 @@ export default function BookNowPage() { setFormData((prev) => ({ ...prev, [field]: value })); }; - const handleDayToggle = (day: string) => { + // Handle slot selection (day + time slot combination) + const handleSlotToggle = (day: number, timeSlot: string) => { setFormData((prev) => { - const days = prev.preferredDays.includes(day) - ? prev.preferredDays.filter((d) => d !== day) - : [...prev.preferredDays, day]; - return { ...prev, preferredDays: days }; + const normalizedDay = Number(day); + const normalizedTimeSlot = String(timeSlot).toLowerCase().trim(); + const currentSlots = prev.selectedSlots || []; + + // Helper to check if two slots match + const slotsMatch = (slot1: { day: number; time_slot: string }, slot2: { day: number; time_slot: string }) => { + return Number(slot1.day) === Number(slot2.day) && + String(slot1.time_slot).toLowerCase().trim() === String(slot2.time_slot).toLowerCase().trim(); + }; + + const targetSlot = { day: normalizedDay, time_slot: normalizedTimeSlot }; + + // Check if this exact slot exists + const slotExists = currentSlots.some(slot => slotsMatch(slot, targetSlot)); + + if (slotExists) { + // Remove the slot + const newSlots = currentSlots.filter(slot => !slotsMatch(slot, targetSlot)); + return { + ...prev, + selectedSlots: newSlots, + }; + } else { + // Add the slot + return { + ...prev, + selectedSlots: [...currentSlots, targetSlot], + }; + } }); }; - const handleTimeToggle = (time: string) => { - setFormData((prev) => { - const times = prev.preferredTimes.includes(time) - ? prev.preferredTimes.filter((t) => t !== time) - : [...prev.preferredTimes, time]; - return { ...prev, preferredTimes: times }; - }); + // Check if a slot is selected + const isSlotSelected = (day: number, timeSlot: string): boolean => { + const normalizedDay = Number(day); + const normalizedTimeSlot = String(timeSlot).toLowerCase().trim(); + + return (formData.selectedSlots || []).some( + slot => Number(slot.day) === normalizedDay && + String(slot.time_slot).toLowerCase().trim() === normalizedTimeSlot + ); }; const formatDateTime = (dateString: string) => { @@ -473,77 +541,51 @@ export default function BookNowPage() {
{booking ? (
-
+

- Booking Confirmed! + Booking Request Submitted!

-

- Your appointment has been successfully booked. +

+ Your appointment request has been received.

-

Booking ID

-

#{booking.ID}

-
-
-

Patient

-

+

Name

+

{booking.user.first_name} {booking.user.last_name}

-

Scheduled Time

-

{formatDateTime(booking.scheduled_at)}

+

Email

+

+ {booking.user.email} +

-
-

Duration

-

{booking.duration} minutes

-
-
-

Status

- - {booking.status} - -
-
-

Amount

-

${booking.amount}

-
- {booking.notes && ( + {booking.user.phone && (
-

Notes

-

{booking.notes}

+

Phone

+

+ {booking.user.phone} +

)}
-
+
+

+ You will be contacted shortly to confirm your appointment. +

+
+
-
@@ -580,6 +622,7 @@ export default function BookNowPage() { onChange={(e) => handleChange("firstName", e.target.value) } + maxLength={100} required className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900 placeholder:text-gray-500'}`} /> @@ -600,6 +643,7 @@ export default function BookNowPage() { onChange={(e) => handleChange("lastName", e.target.value) } + maxLength={100} required className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900 placeholder:text-gray-500'}`} /> @@ -620,6 +664,7 @@ export default function BookNowPage() { placeholder="john.doe@example.com" value={formData.email} onChange={(e) => handleChange("email", e.target.value)} + maxLength={100} required className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900 placeholder:text-gray-500'}`} /> @@ -639,6 +684,7 @@ export default function BookNowPage() { placeholder="+1 (555) 123-4567" value={formData.phone} onChange={(e) => handleChange("phone", e.target.value)} + maxLength={100} required className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900 placeholder:text-gray-500'}`} /> @@ -658,12 +704,12 @@ export default function BookNowPage() { className={`text-sm font-medium flex items-center gap-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`} > - Available Days * + Available Days & Times * - {isLoadingAvailableDates ? ( + {(isLoadingWeeklyAvailability || isLoadingAvailabilityOverview) ? (
- Loading available days... + Loading availability...
) : availableDaysOfWeek.length === 0 ? (

@@ -671,92 +717,85 @@ export default function BookNowPage() {

) : ( <> -
- {availableDaysOfWeek.map((day: string) => ( -
- -
- -
- {(() => { - // 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(); - 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: 'Evening' } - ]; - - // Only show time slots that are available - return timeSlotMap.filter(ts => slotsToShow.includes(ts.value)); - })().map((time) => ( - - ))} -
-
@@ -775,6 +814,7 @@ export default function BookNowPage() { placeholder="Tell us about any specific concerns or preferences..." value={formData.message} onChange={(e) => handleChange("message", e.target.value)} + maxLength={100} className={`w-full rounded-md border px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-rose-500 focus-visible:border-rose-500 disabled:cursor-not-allowed disabled:opacity-50 ${isDark ? 'border-gray-600 bg-gray-700 text-white placeholder:text-gray-400 focus-visible:ring-rose-400 focus-visible:border-rose-400' : 'border-gray-300 bg-white text-gray-900 placeholder:text-gray-500'}`} />
@@ -784,7 +824,7 @@ export default function BookNowPage() {
- {/* Contact Information */} -
-

- Prefer to book by phone?{" "} - - Call us at (754) 816-2311 - -

-
- {/* Logout Button - Only show when authenticated */} {isAuthenticated && (
diff --git a/app/(pages)/deliverables/page.tsx b/app/(pages)/deliverables/page.tsx index b64a9c2..a45647e 100644 --- a/app/(pages)/deliverables/page.tsx +++ b/app/(pages)/deliverables/page.tsx @@ -297,7 +297,7 @@ For technical assistance, questions, or issues:
diff --git a/app/(user)/user/dashboard/page.tsx b/app/(user)/user/dashboard/page.tsx index e4277a6..b51c275 100644 --- a/app/(user)/user/dashboard/page.tsx +++ b/app/(user)/user/dashboard/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo } from "react"; +import { useMemo, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { Calendar, @@ -21,23 +21,69 @@ import { import Link from "next/link"; import { Navbar } from "@/components/Navbar"; import { useAppTheme } from "@/components/ThemeProvider"; -import { useAppointments } from "@/hooks/useAppointments"; import { useAuth } from "@/hooks/useAuth"; -import type { Appointment } from "@/lib/models/appointments"; +import { listAppointments, getUserAppointmentStats } from "@/lib/actions/appointments"; +import type { Appointment, UserAppointmentStats } from "@/lib/models/appointments"; import { toast } from "sonner"; export default function UserDashboard() { const { theme } = useAppTheme(); const isDark = theme === "dark"; const { user } = useAuth(); - const { - userAppointments, - userAppointmentStats, - isLoadingUserAppointments, - isLoadingUserStats, - refetchUserAppointments, - refetchUserStats, - } = useAppointments(); + const [appointments, setAppointments] = useState([]); + const [loading, setLoading] = useState(true); + const [stats, setStats] = useState(null); + const [loadingStats, setLoadingStats] = useState(true); + + // Fetch appointments using the same endpoint as admin booking table + useEffect(() => { + const fetchAppointments = async () => { + setLoading(true); + try { + const data = await listAppointments(); + setAppointments(data || []); + } catch (error) { + toast.error("Failed to load appointments. Please try again."); + setAppointments([]); + } finally { + setLoading(false); + } + }; + + fetchAppointments(); + }, []); + + // Fetch stats from API using user email + useEffect(() => { + const fetchStats = async () => { + if (!user?.email) { + setLoadingStats(false); + return; + } + + setLoadingStats(true); + try { + const statsData = await getUserAppointmentStats(user.email); + setStats(statsData); + } catch (error) { + toast.error("Failed to load appointment statistics."); + // Set default stats on error + setStats({ + total_requests: 0, + pending_review: 0, + scheduled: 0, + rejected: 0, + completed: 0, + completion_rate: 0, + email: user.email, + }); + } finally { + setLoadingStats(false); + } + }; + + fetchStats(); + }, [user?.email]); const formatDate = (dateString: string) => { const date = new Date(dateString); @@ -68,58 +114,67 @@ export default function UserDashboard() { // Filter appointments by status const upcomingAppointments = useMemo(() => { - return userAppointments.filter( + return appointments.filter( (appointment) => appointment.status === "scheduled" ); - }, [userAppointments]); + }, [appointments]); + + const pendingAppointments = useMemo(() => { + return appointments.filter( + (appointment) => appointment.status === "pending_review" + ); + }, [appointments]); const completedAppointments = useMemo(() => { - return userAppointments.filter( + return appointments.filter( (appointment) => appointment.status === "completed" ); - }, [userAppointments]); + }, [appointments]); - const stats = userAppointmentStats || { - total_requests: 0, - pending_review: 0, - scheduled: 0, - rejected: 0, - completed: 0, - completion_rate: 0, - }; + const rejectedAppointments = useMemo(() => { + return appointments.filter( + (appointment) => appointment.status === "rejected" + ); + }, [appointments]); - const statCards = [ - { - title: "Upcoming Appointments", - value: stats.scheduled, - icon: CalendarCheck, - trend: stats.scheduled > 0 ? `+${stats.scheduled}` : "0", - trendUp: true, - }, - { - title: "Completed Sessions", - value: stats.completed || 0, - icon: CheckCircle2, - trend: stats.completed > 0 ? `+${stats.completed}` : "0", - trendUp: true, - }, - { - title: "Total Appointments", - value: stats.total_requests, - icon: Calendar, - trend: `${Math.round(stats.completion_rate || 0)}%`, - trendUp: true, - }, - { - title: "Pending Review", - value: stats.pending_review, - icon: Calendar, - trend: stats.pending_review > 0 ? `${stats.pending_review}` : "0", - trendUp: false, - }, - ]; + // Sort appointments by created_at (newest first) + const allAppointments = useMemo(() => { + return [...appointments].sort((a, b) => { + const dateA = new Date(a.created_at).getTime(); + const dateB = new Date(b.created_at).getTime(); + return dateB - dateA; + }); + }, [appointments]); - const loading = isLoadingUserAppointments || isLoadingUserStats; + // Use stats from API, fallback to calculated stats if API stats not available + const displayStats = useMemo(() => { + if (stats) { + return { + scheduled: stats.scheduled || 0, + completed: stats.completed || 0, + pending_review: stats.pending_review || 0, + rejected: stats.rejected || 0, + total_requests: stats.total_requests || 0, + completion_rate: stats.completion_rate || 0, + }; + } + // Fallback: calculate from appointments if stats not loaded yet + const scheduled = appointments.filter(a => a.status === "scheduled").length; + const completed = appointments.filter(a => a.status === "completed").length; + const pending_review = appointments.filter(a => a.status === "pending_review").length; + const rejected = appointments.filter(a => a.status === "rejected").length; + const total_requests = appointments.length; + const completion_rate = total_requests > 0 ? (scheduled / total_requests) * 100 : 0; + + return { + scheduled, + completed, + pending_review, + rejected, + total_requests, + completion_rate, + }; + }, [stats, appointments]); return (
@@ -166,116 +221,278 @@ export default function UserDashboard() { <> {/* Stats Grid */}
- {statCards.map((card, index) => { - const Icon = card.icon; - return ( -
-
-
- -
-
- {card.trendUp ? ( - - ) : ( - - )} - {card.trend} -
-
- -
-

- {card.title} -

-

- {card.value} -

-

vs last month

-
+
+
+
+
- ); - })} +
+ + {displayStats.scheduled > 0 ? `+${displayStats.scheduled}` : "0"} +
+
+
+

+ Upcoming Appointments +

+

+ {displayStats.scheduled} +

+

vs last month

+
+
+ +
+
+
+ +
+
+ + {displayStats.completed > 0 ? `+${displayStats.completed}` : "0"} +
+
+
+

+ Completed Sessions +

+

+ {displayStats.completed} +

+

vs last month

+
+
+ +
+
+
+ +
+
+ + {`${Math.round(displayStats.completion_rate || 0)}%`} +
+
+
+

+ Total Appointments +

+

+ {displayStats.total_requests} +

+

vs last month

+
+
+ +
+
+
+ +
+
+ + {displayStats.pending_review > 0 ? `${displayStats.pending_review}` : "0"} +
+
+
+

+ Pending Review +

+

+ {displayStats.pending_review} +

+

vs last month

+
+
- {/* Upcoming Appointments Section */} - {upcomingAppointments.length > 0 ? ( -
-

- Upcoming Appointments -

-
- {upcomingAppointments.map((appointment) => ( -
-
-
- {appointment.scheduled_datetime && ( - <> -
- - - {formatDate(appointment.scheduled_datetime)} - + {/* All Appointments Section */} + {allAppointments.length > 0 ? ( +
+
+

+ All Appointments +

+
+
+ + + + + + + + + + + + + + {allAppointments.map((appointment) => { + const getStatusColor = (status: string) => { + switch (status) { + case "scheduled": + return isDark ? 'bg-green-900/30 text-green-400' : 'bg-green-50 text-green-700'; + case "pending_review": + return isDark ? 'bg-yellow-900/30 text-yellow-400' : 'bg-yellow-50 text-yellow-700'; + case "completed": + return isDark ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-50 text-blue-700'; + case "rejected": + return isDark ? 'bg-red-900/30 text-red-400' : 'bg-red-50 text-red-700'; + default: + return isDark ? 'bg-gray-700 text-gray-300' : 'bg-gray-100 text-gray-700'; + } + }; + + const formatStatus = (status: string) => { + return status.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase()); + }; + + const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; + const timeSlotLabels: Record = { + morning: 'Morning', + afternoon: 'Lunchtime', + evening: 'Evening', + }; + + return ( + + + + + + + + + + ); + })} + +
+ Appointment + + Status + + Actions +
+
+
+ +
+
+
+ {appointment.first_name} {appointment.last_name} +
+ {appointment.reason && ( +
+ {appointment.reason} +
+ )} + {appointment.scheduled_datetime && ( +
+ {formatDate(appointment.scheduled_datetime)} +
+ )} +
-
- - - {formatTime(appointment.scheduled_datetime)} - - {appointment.scheduled_duration && ( - - ({appointment.scheduled_duration} minutes) - +
+ {appointment.scheduled_datetime ? ( + <> +
+ {formatDate(appointment.scheduled_datetime)} +
+
+ + {formatTime(appointment.scheduled_datetime)} +
+ + ) : ( +
+ Not scheduled +
+ )} +
+ + {formatStatus(appointment.status)} + + + - - )} - {appointment.reason && ( -

- {appointment.reason} -

- )} - -
-
- - {appointment.status.charAt(0).toUpperCase() + - appointment.status.slice(1).replace('_', ' ')} - -
- {appointment.jitsi_meet_url && appointment.can_join_meeting && ( - - - )} -
- - - ))} +
) : !loading && ( @@ -283,10 +500,10 @@ export default function UserDashboard() {

- No Upcoming Appointments + No Appointments

- You don't have any scheduled appointments yet. Book an appointment to get started. + You don't have any appointments yet. Book an appointment to get started.

+ {isOpen && ( +
+ {/* Clock face */} +
+ {/* Clock circle */} +
+ + {/* Center dot */} +
+ + {/* Duration options arranged in a circle */} + {options.map((option, index) => { + const { x, y } = getClockPosition(index, options.length, 130); + const isSelected = duration === option; + return ( + + ); + })} +
+ + {/* Quick select buttons for common durations */} +
+ {[30, 60, 90, 120].map((quickDuration) => ( + + ))} +
+
+ )} +
+
+ ); +} + diff --git a/components/ClockTimePicker.tsx b/components/ClockTimePicker.tsx new file mode 100644 index 0000000..0cef4b9 --- /dev/null +++ b/components/ClockTimePicker.tsx @@ -0,0 +1,278 @@ +'use client'; + +import * as React from 'react'; +import { Clock } from 'lucide-react'; +import { format } from 'date-fns'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; + +interface ClockTimePickerProps { + time: string; // HH:mm format (e.g., "09:00") + setTime: (time: string) => void; + label?: string; + isDark?: boolean; +} + +export function ClockTimePicker({ time, setTime, label, isDark = false }: ClockTimePickerProps) { + const [isOpen, setIsOpen] = React.useState(false); + const [mode, setMode] = React.useState<'hour' | 'minute'>('hour'); + const wrapperRef = React.useRef(null); + + // Parse time string to hours and minutes + const [hours, minutes] = React.useMemo(() => { + if (!time) return [9, 0]; + const parts = time.split(':').map(Number); + return [parts[0] || 9, parts[1] || 0]; + }, [time]); + + // Convert to 12-hour format for display + const displayHours = hours % 12 || 12; + const ampm = hours >= 12 ? 'PM' : 'AM'; + + // Close picker when clicking outside + React.useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) { + setIsOpen(false); + setMode('hour'); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + // Handle hour selection + const handleHourClick = (selectedHour: number) => { + const newHours = ampm === 'PM' && selectedHour !== 12 + ? selectedHour + 12 + : ampm === 'AM' && selectedHour === 12 + ? 0 + : selectedHour; + + setTime(`${newHours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`); + setMode('minute'); + }; + + // Handle minute selection + const handleMinuteClick = (selectedMinute: number) => { + setTime(`${hours.toString().padStart(2, '0')}:${selectedMinute.toString().padStart(2, '0')}`); + setIsOpen(false); + setMode('hour'); + }; + + // Generate hour numbers (1-12) + const hourNumbers = Array.from({ length: 12 }, (_, i) => i + 1); + + // Generate minute numbers (0, 15, 30, 45 or 0-59) + const minuteNumbers = Array.from({ length: 12 }, (_, i) => i * 5); // 0, 5, 10, 15, ..., 55 + + // Calculate position for clock numbers + const getClockPosition = (index: number, total: number, radius: number = 90) => { + const angle = (index * 360) / total - 90; // Start from top (-90 degrees) + const radian = (angle * Math.PI) / 180; + const x = Math.cos(radian) * radius; + const y = Math.sin(radian) * radius; + return { x, y }; + }; + + // Format display time + const displayTime = time + ? `${displayHours}:${minutes.toString().padStart(2, '0')} ${ampm}` + : 'Select time'; + + return ( +
+ {label && ( + + )} +
+ + {isOpen && ( +
+ {/* Mode selector */} +
+ + +
+ + {/* Clock face */} +
+ {/* Clock circle */} +
+ + {/* Center dot */} +
+ + {/* Hour numbers */} + {mode === 'hour' && hourNumbers.map((hour, index) => { + const { x, y } = getClockPosition(index, 12, 90); + const isSelected = displayHours === hour; + return ( + + ); + })} + + {/* Minute numbers */} + {mode === 'minute' && minuteNumbers.map((minute, index) => { + const { x, y } = getClockPosition(index, 12, 90); + const isSelected = minutes === minute; + return ( + + ); + })} +
+ + {/* AM/PM toggle */} +
+ + +
+
+ )} +
+
+ ); +} + diff --git a/components/DurationPicker.tsx b/components/DurationPicker.tsx new file mode 100644 index 0000000..2a5ffc3 --- /dev/null +++ b/components/DurationPicker.tsx @@ -0,0 +1,72 @@ +'use client'; + +import * as React from 'react'; +import { Timer } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; + +interface DurationPickerProps { + duration: number; // Duration in minutes + setDuration: (duration: number) => void; + label?: string; + isDark?: boolean; + options?: number[]; // Optional custom duration options +} + +export function DurationPicker({ + duration, + setDuration, + label, + isDark = false, + options = [15, 30, 45, 60, 120] +}: DurationPickerProps) { + // Format duration display + const formatDuration = (minutes: number) => { + if (minutes < 60) { + return `${minutes}m`; + } + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; + }; + + const displayDuration = duration ? formatDuration(duration) : 'Select duration'; + + return ( +
+ {label && ( + + )} +
+ {options.map((option) => { + const isSelected = duration === option; + return ( + + ); + })} +
+
+ ); +} + diff --git a/components/ForgotPasswordDialog.tsx b/components/ForgotPasswordDialog.tsx index 75047a8..b749554 100644 --- a/components/ForgotPasswordDialog.tsx +++ b/components/ForgotPasswordDialog.tsx @@ -462,3 +462,4 @@ export function ForgotPasswordDialog({ open, onOpenChange, onSuccess }: ForgotPa + diff --git a/components/ScheduleAppointmentDialog.tsx b/components/ScheduleAppointmentDialog.tsx new file mode 100644 index 0000000..201e229 --- /dev/null +++ b/components/ScheduleAppointmentDialog.tsx @@ -0,0 +1,170 @@ +'use client'; + +import * as React from 'react'; +import { CalendarCheck, Loader2, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { DatePicker } from '@/components/DatePicker'; +import { ClockTimePicker } from '@/components/ClockTimePicker'; +import { DurationPicker } from '@/components/DurationPicker'; +import type { Appointment } from '@/lib/models/appointments'; + +interface ScheduleAppointmentDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + appointment: Appointment | null; + scheduledDate: Date | undefined; + setScheduledDate: (date: Date | undefined) => void; + scheduledTime: string; + setScheduledTime: (time: string) => void; + scheduledDuration: number; + setScheduledDuration: (duration: number) => void; + onSchedule: () => Promise; + isScheduling: boolean; + isDark?: boolean; +} + +export function ScheduleAppointmentDialog({ + open, + onOpenChange, + appointment, + scheduledDate, + setScheduledDate, + scheduledTime, + setScheduledTime, + scheduledDuration, + setScheduledDuration, + onSchedule, + isScheduling, + isDark = false, +}: ScheduleAppointmentDialogProps) { + const formatDate = (date: Date) => { + return date.toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + year: "numeric", + }); + }; + + const formatTime = (timeString: string) => { + const [hours, minutes] = timeString.split(":").map(Number); + const date = new Date(); + date.setHours(hours); + date.setMinutes(minutes); + return date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + }; + + return ( + + + + + Schedule Appointment + + + {appointment + ? `Set date and time for ${appointment.first_name} ${appointment.last_name}'s appointment` + : "Set date and time for this appointment"} + + + +
+ {/* Date Selection */} +
+ +
+ +
+
+ + {/* Time Selection */} +
+
+ +
+
+ + {/* Duration Selection */} +
+
+ +
+
+ + {/* Preview */} + {scheduledDate && scheduledTime && ( +
+

+ Appointment Preview +

+
+

+ {formatDate(scheduledDate)} +

+

+ {formatTime(scheduledTime)} • {scheduledDuration} minutes +

+
+
+ )} +
+ + + + + +
+
+ ); +} + diff --git a/components/TimePicker.tsx b/components/TimePicker.tsx new file mode 100644 index 0000000..95e2349 --- /dev/null +++ b/components/TimePicker.tsx @@ -0,0 +1,115 @@ +'use client'; + +import * as React from 'react'; +import { Clock } from 'lucide-react'; +import { format } from 'date-fns'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import DatePickerLib from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; + +interface TimePickerProps { + time: string; // HH:mm format (e.g., "09:00") + setTime: (time: string) => void; + label?: string; + isDark?: boolean; +} + +export function TimePicker({ time, setTime, label, isDark = false }: TimePickerProps) { + const [isOpen, setIsOpen] = React.useState(false); + const wrapperRef = React.useRef(null); + + // Convert HH:mm string to Date object for the time picker + const timeValue = React.useMemo(() => { + if (!time) return null; + const [hours, minutes] = time.split(':').map(Number); + const date = new Date(); + date.setHours(hours || 9); + date.setMinutes(minutes || 0); + date.setSeconds(0); + return date; + }, [time]); + + // Handle time change from the picker + const handleTimeChange = (date: Date | null) => { + if (date) { + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + setTime(`${hours}:${minutes}`); + } + }; + + // Close picker when clicking outside + React.useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + // Format display time + const displayTime = timeValue + ? format(timeValue, 'h:mm a') // e.g., "9:00 AM" + : 'Select time'; + + return ( +
+ {label && ( + + )} +
+ + {isOpen && ( +
+ +
+ )} +
+
+ ); +} + diff --git a/hooks/useAppointments.ts b/hooks/useAppointments.ts index 2bb7d02..da49cba 100644 --- a/hooks/useAppointments.ts +++ b/hooks/useAppointments.ts @@ -15,6 +15,10 @@ import { updateAdminAvailability, getAppointmentStats, getJitsiMeetingInfo, + getWeeklyAvailability, + getAvailabilityConfig, + checkDateAvailability, + getAvailabilityOverview, } from "@/lib/actions/appointments"; import type { CreateAppointmentInput, @@ -29,29 +33,85 @@ import type { UserAppointmentStats, AvailableDatesResponse, JitsiMeetingInfo, + WeeklyAvailabilityResponse, + AvailabilityConfig, + CheckDateAvailabilityResponse, + AvailabilityOverview, } from "@/lib/models/appointments"; -export function useAppointments() { +export function useAppointments(options?: { + enableAvailableDates?: boolean; + enableStats?: boolean; + enableConfig?: boolean; + enableWeeklyAvailability?: boolean; + enableOverview?: boolean; +}) { const queryClient = useQueryClient(); + const enableAvailableDates = options?.enableAvailableDates ?? false; + const enableStats = options?.enableStats ?? true; + const enableConfig = options?.enableConfig ?? true; + const enableWeeklyAvailability = options?.enableWeeklyAvailability ?? true; + const enableOverview = options?.enableOverview ?? true; - // Get available dates query + // Get available dates query (optional, disabled by default - using weekly_availability as primary source) + // Can be enabled when explicitly needed (e.g., on admin booking page) const availableDatesQuery = useQuery({ queryKey: ["appointments", "available-dates"], queryFn: () => getAvailableDates(), + enabled: enableAvailableDates, // Can be enabled when needed + staleTime: 5 * 60 * 1000, // 5 minutes + retry: 0, // Don't retry failed requests + }); + + // Get weekly availability query + const weeklyAvailabilityQuery = useQuery({ + queryKey: ["appointments", "weekly-availability"], + queryFn: async () => { + const data = await getWeeklyAvailability(); + // Normalize response format - ensure it's always an object with week array + if (Array.isArray(data)) { + return { week: data }; + } + return data; + }, + enabled: enableWeeklyAvailability, + staleTime: 5 * 60 * 1000, // 5 minutes + }); + + // Get availability config query + const availabilityConfigQuery = useQuery({ + queryKey: ["appointments", "availability-config"], + queryFn: () => getAvailabilityConfig(), + enabled: enableConfig, + staleTime: 60 * 60 * 1000, // 1 hour (config rarely changes) + }); + + // Get availability overview query + const availabilityOverviewQuery = useQuery({ + queryKey: ["appointments", "availability-overview"], + queryFn: () => getAvailabilityOverview(), + enabled: enableOverview, staleTime: 5 * 60 * 1000, // 5 minutes }); // List appointments query const appointmentsQuery = useQuery({ queryKey: ["appointments", "list"], - queryFn: () => listAppointments(), - enabled: false, // Only fetch when explicitly called + queryFn: async () => { + const data = await listAppointments(); + return data || []; + }, + enabled: true, // Enable by default to fetch user appointments + staleTime: 30 * 1000, // 30 seconds + retry: 1, // Retry once on failure + refetchOnMount: true, // Always refetch when component mounts }); - // Get user appointments query + // Get user appointments query (disabled - using listAppointments instead) const userAppointmentsQuery = useQuery({ queryKey: ["appointments", "user"], queryFn: () => getUserAppointments(), + enabled: false, // Disabled - using listAppointments endpoint instead staleTime: 30 * 1000, // 30 seconds }); @@ -82,6 +142,7 @@ export function useAppointments() { const userAppointmentStatsQuery = useQuery({ queryKey: ["appointments", "user", "stats"], queryFn: () => getUserAppointmentStats(), + enabled: enableStats, staleTime: 1 * 60 * 1000, // 1 minute }); @@ -172,6 +233,9 @@ export function useAppointments() { // Queries availableDates: availableDatesQuery.data?.dates || [], availableDatesResponse: availableDatesQuery.data, + weeklyAvailability: weeklyAvailabilityQuery.data, + availabilityConfig: availabilityConfigQuery.data, + availabilityOverview: availabilityOverviewQuery.data, appointments: appointmentsQuery.data || [], userAppointments: userAppointmentsQuery.data || [], adminAvailability: adminAvailabilityQuery.data, @@ -180,6 +244,9 @@ export function useAppointments() { // Query states isLoadingAvailableDates: availableDatesQuery.isLoading, + isLoadingWeeklyAvailability: weeklyAvailabilityQuery.isLoading, + isLoadingAvailabilityConfig: availabilityConfigQuery.isLoading, + isLoadingAvailabilityOverview: availabilityOverviewQuery.isLoading, isLoadingAppointments: appointmentsQuery.isLoading, isLoadingUserAppointments: userAppointmentsQuery.isLoading, isLoadingAdminAvailability: adminAvailabilityQuery.isLoading, @@ -188,12 +255,20 @@ export function useAppointments() { // Query refetch functions refetchAvailableDates: availableDatesQuery.refetch, + refetchWeeklyAvailability: weeklyAvailabilityQuery.refetch, + refetchAvailabilityConfig: availabilityConfigQuery.refetch, + refetchAvailabilityOverview: availabilityOverviewQuery.refetch, refetchAppointments: appointmentsQuery.refetch, refetchUserAppointments: userAppointmentsQuery.refetch, refetchAdminAvailability: adminAvailabilityQuery.refetch, refetchStats: appointmentStatsQuery.refetch, refetchUserStats: userAppointmentStatsQuery.refetch, + // Helper functions + checkDateAvailability: async (date: string) => { + return await checkDateAvailability(date); + }, + // Hooks for specific queries useAppointmentDetail, useJitsiMeetingInfo, diff --git a/lib/actions/appointments.ts b/lib/actions/appointments.ts index 11badc6..64ea7b6 100644 --- a/lib/actions/appointments.ts +++ b/lib/actions/appointments.ts @@ -16,6 +16,11 @@ import type { UserAppointmentStats, JitsiMeetingInfo, ApiError, + WeeklyAvailabilityResponse, + AvailabilityConfig, + CheckDateAvailabilityResponse, + AvailabilityOverview, + SelectedSlot, } from "@/lib/models/appointments"; // Helper function to extract error message from API response @@ -55,58 +60,89 @@ export async function createAppointment( if (!input.first_name || !input.last_name || !input.email) { throw new Error("First name, last name, and email are required"); } - if (!input.preferred_dates || input.preferred_dates.length === 0) { - throw new Error("At least one preferred date is required"); - } - if (!input.preferred_time_slots || input.preferred_time_slots.length === 0) { - throw new Error("At least one preferred time slot is required"); + + // New API format: use selected_slots + if (!input.selected_slots || input.selected_slots.length === 0) { + throw new Error("At least one time slot must be selected"); } - // Validate date format (YYYY-MM-DD) - const dateRegex = /^\d{4}-\d{2}-\d{2}$/; - for (const date of input.preferred_dates) { - if (!dateRegex.test(date)) { - throw new Error(`Invalid date format: ${date}. Expected YYYY-MM-DD format.`); - } + // Validate and clean selected_slots to ensure all have day and time_slot + // This filters out any invalid slots and ensures proper format + const validSlots: SelectedSlot[] = input.selected_slots + .filter((slot, index) => { + // Check if slot exists and is an object + if (!slot || typeof slot !== 'object') { + return false; + } + // Check if both day and time_slot properties exist + if (typeof slot.day === 'undefined' || typeof slot.time_slot === 'undefined') { + return false; + } + // Validate day is a number between 0-6 + const dayNum = Number(slot.day); + if (isNaN(dayNum) || dayNum < 0 || dayNum > 6) { + return false; + } + // Validate time_slot is a valid string (normalize to lowercase) + const timeSlot = String(slot.time_slot).toLowerCase().trim(); + if (!['morning', 'afternoon', 'evening'].includes(timeSlot)) { + return false; + } + return true; + }) + .map(slot => ({ + day: Number(slot.day), + time_slot: String(slot.time_slot).toLowerCase().trim() as "morning" | "afternoon" | "evening", + })); + + if (validSlots.length === 0) { + throw new Error("At least one valid time slot must be selected. Each slot must have both 'day' (0-6) and 'time_slot' (morning, afternoon, or evening)."); } - // Validate time slots - const validTimeSlots = ["morning", "afternoon", "evening"]; - for (const slot of input.preferred_time_slots) { - if (!validTimeSlots.includes(slot)) { - throw new Error(`Invalid time slot: ${slot}. Must be one of: ${validTimeSlots.join(", ")}`); - } - } + // Limit field lengths to prevent database errors (100 char limit for all string fields) + // Truncate all string fields BEFORE trimming to handle edge cases + const firstName = input.first_name ? String(input.first_name).trim().substring(0, 100) : ''; + const lastName = input.last_name ? String(input.last_name).trim().substring(0, 100) : ''; + const email = input.email ? String(input.email).trim().toLowerCase().substring(0, 100) : ''; + const phone = input.phone ? String(input.phone).trim().substring(0, 100) : undefined; + const reason = input.reason ? String(input.reason).trim().substring(0, 100) : undefined; - // Prepare the payload exactly as the API expects - // Only include fields that the API accepts - no jitsi_room_id or other fields + // Build payload with only the fields the API expects - no extra fields const payload: { first_name: string; last_name: string; email: string; - preferred_dates: string[]; - preferred_time_slots: string[]; + selected_slots: Array<{ day: number; time_slot: string }>; phone?: string; reason?: string; } = { - first_name: input.first_name.trim(), - last_name: input.last_name.trim(), - email: input.email.trim().toLowerCase(), - preferred_dates: input.preferred_dates, - preferred_time_slots: input.preferred_time_slots, + first_name: firstName, + last_name: lastName, + email: email, + selected_slots: validSlots.map(slot => ({ + day: Number(slot.day), + time_slot: String(slot.time_slot).toLowerCase().trim(), + })), }; - // Only add optional fields if they have values - if (input.phone && input.phone.trim()) { - payload.phone = input.phone.trim(); + // Only add optional fields if they have values (and are within length limits) + if (phone && phone.length > 0 && phone.length <= 100) { + payload.phone = phone; } - if (input.reason && input.reason.trim()) { - payload.reason = input.reason.trim(); + if (reason && reason.length > 0 && reason.length <= 100) { + payload.reason = reason; } - // Log the payload for debugging - console.log("Creating appointment with payload:", JSON.stringify(payload, null, 2)); - console.log("API endpoint:", API_ENDPOINTS.meetings.createAppointment); + // Final validation: ensure all string fields in payload are exactly 100 chars or less + // This is a safety check to prevent any encoding or serialization issues + const finalPayload = { + first_name: payload.first_name.substring(0, 100), + last_name: payload.last_name.substring(0, 100), + email: payload.email.substring(0, 100), + selected_slots: payload.selected_slots, + ...(payload.phone && { phone: payload.phone.substring(0, 100) }), + ...(payload.reason && { reason: payload.reason.substring(0, 100) }), + }; const response = await fetch(API_ENDPOINTS.meetings.createAppointment, { method: "POST", @@ -114,7 +150,7 @@ export async function createAppointment( "Content-Type": "application/json", Authorization: `Bearer ${tokens.access}`, }, - body: JSON.stringify(payload), + body: JSON.stringify(finalPayload), }); // Read response text first (can only be read once) @@ -131,14 +167,6 @@ export async function createAppointment( } data = JSON.parse(responseText); } catch (e) { - // If JSON parsing fails, log the actual response - console.error("Failed to parse JSON response:", { - status: response.status, - statusText: response.statusText, - contentType, - url: API_ENDPOINTS.meetings.createAppointment, - preview: responseText.substring(0, 500) - }); throw new Error(`Server error (${response.status}): ${response.statusText || 'Invalid response format'}`); } } else { @@ -159,15 +187,6 @@ export async function createAppointment( } } - console.error("Non-JSON response received:", { - status: response.status, - statusText: response.statusText, - contentType, - url: API_ENDPOINTS.meetings.createAppointment, - payload: input, - preview: responseText.substring(0, 1000) - }); - throw new Error(errorMessage); } @@ -176,7 +195,27 @@ export async function createAppointment( throw new Error(errorMessage); } - // Handle different response formats + // Handle API response format: { appointment_id, message } + // According to API docs, response includes appointment_id and message + if (data.appointment_id) { + // Construct a minimal Appointment object from the response + // We'll use the input data plus the appointment_id from response + const appointment: Appointment = { + id: data.appointment_id, + first_name: input.first_name.trim(), + last_name: input.last_name.trim(), + email: input.email.trim().toLowerCase(), + phone: input.phone?.trim(), + reason: input.reason?.trim(), + selected_slots: validSlots, + status: "pending_review", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + return appointment; + } + + // Handle different response formats for backward compatibility if (data.appointment) { return data.appointment; } @@ -188,8 +227,9 @@ export async function createAppointment( return data as unknown as Appointment; } -// Get available dates +// Get available dates (optional endpoint - may fail if admin hasn't set availability) export async function getAvailableDates(): Promise { + try { const response = await fetch(API_ENDPOINTS.meetings.availableDates, { method: "GET", headers: { @@ -197,11 +237,29 @@ export async function getAvailableDates(): Promise { }, }); - const data: AvailableDatesResponse | string[] = await response.json(); + // Handle different response formats + const contentType = response.headers.get("content-type"); + let data: any; + + if (contentType && contentType.includes("application/json")) { + const responseText = await response.text(); + if (!responseText) { + throw new Error(`Server returned empty response (${response.status})`); + } + try { + data = JSON.parse(responseText); + } catch (parseError) { + throw new Error(`Invalid response format (${response.status})`); + } + } else { + throw new Error(`Server error (${response.status}): ${response.statusText || 'Invalid response'}`); + } if (!response.ok) { - const errorMessage = extractErrorMessage(data as unknown as ApiError); - throw new Error(errorMessage); + // Return empty response instead of throwing - this endpoint is optional + return { + dates: [], + }; } // If API returns array directly, wrap it in response object @@ -212,6 +270,95 @@ export async function getAvailableDates(): Promise { } return data as AvailableDatesResponse; + } catch (error) { + // Return empty response - don't break the app + return { + dates: [], + }; + } +} + +// Get weekly availability (Public) +export async function getWeeklyAvailability(): Promise { + const response = await fetch(API_ENDPOINTS.meetings.weeklyAvailability, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + const data: any = await response.json(); + + if (!response.ok) { + const errorMessage = extractErrorMessage(data as unknown as ApiError); + throw new Error(errorMessage); + } + + // Handle different response formats - API might return array directly or wrapped + if (Array.isArray(data)) { + return data; + } + + // If wrapped in an object, return as is (our interface supports it) + return data; +} + +// Get availability configuration (Public) +export async function getAvailabilityConfig(): Promise { + const response = await fetch(API_ENDPOINTS.meetings.availabilityConfig, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + const data: AvailabilityConfig = await response.json(); + + if (!response.ok) { + const errorMessage = extractErrorMessage(data as unknown as ApiError); + throw new Error(errorMessage); + } + + return data; +} + +// Check date availability (Public) +export async function checkDateAvailability(date: string): Promise { + const response = await fetch(API_ENDPOINTS.meetings.checkDateAvailability, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ date }), + }); + + const data: CheckDateAvailabilityResponse = await response.json(); + + if (!response.ok) { + const errorMessage = extractErrorMessage(data as unknown as ApiError); + throw new Error(errorMessage); + } + + return data; +} + +// Get availability overview (Public) +export async function getAvailabilityOverview(): Promise { + const response = await fetch(API_ENDPOINTS.meetings.availabilityOverview, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + const data: AvailabilityOverview = await response.json(); + + if (!response.ok) { + const errorMessage = extractErrorMessage(data as unknown as ApiError); + throw new Error(errorMessage); + } + + return data; } // List appointments (Admin sees all, users see their own) @@ -234,23 +381,49 @@ export async function listAppointments(email?: string): Promise { }, }); - const data = await response.json(); + const responseText = await response.text(); if (!response.ok) { - const errorMessage = extractErrorMessage(data as unknown as ApiError); + let errorData: any; + try { + errorData = JSON.parse(responseText); + } catch { + throw new Error(`Server error (${response.status}): ${response.statusText || 'Internal Server Error'}`); + } + const errorMessage = extractErrorMessage(errorData as unknown as ApiError); throw new Error(errorMessage); } + // Parse JSON response + let data: any; + try { + if (!responseText || responseText.trim().length === 0) { + return []; + } + data = JSON.parse(responseText); + } catch (error) { + throw new Error(`Failed to parse response: Invalid JSON format`); + } + // Handle different response formats - // API might return array directly or wrapped in an object + // API returns array directly: [{ id, first_name, ... }, ...] if (Array.isArray(data)) { return data; } + // Handle wrapped responses (if any) + if (data && typeof data === 'object') { if (data.appointments && Array.isArray(data.appointments)) { return data.appointments; } if (data.results && Array.isArray(data.results)) { return data.results; + } + // If data is an object but not an array and doesn't have appointments/results, return empty + // This shouldn't happen but handle gracefully + if (data.id || data.first_name) { + // Single appointment object, wrap in array + return [data]; + } } return []; @@ -398,13 +571,6 @@ export async function scheduleAppointment( errorMessage = `Server error: ${response.status} ${response.statusText || 'Unknown error'}`; } - console.error("Schedule appointment error:", { - status: response.status, - statusText: response.statusText, - data, - errorMessage, - }); - throw new Error(errorMessage); } @@ -449,37 +615,41 @@ export async function rejectAppointment( return data as unknown as Appointment; } -// Get admin availability (public version - tries without auth first) +// Get admin availability (public version - uses weekly availability endpoint instead) export async function getPublicAvailability(): Promise { try { - const response = await fetch(API_ENDPOINTS.meetings.adminAvailability, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }); - - if (!response.ok) { + // Use weekly availability endpoint which is public + const weeklyAvailability = await getWeeklyAvailability(); + + // Normalize to array format + const weekArray = Array.isArray(weeklyAvailability) + ? weeklyAvailability + : (weeklyAvailability as any).week || []; + + if (!weekArray || weekArray.length === 0) { return null; } - const data: any = await response.json(); + // Convert weekly availability to AdminAvailability format + const availabilitySchedule: Record = {}; + const availableDays: number[] = []; + const availableDaysDisplay: string[] = []; - // 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)); + weekArray.forEach((day: any) => { + if (day.is_available && day.available_slots && day.available_slots.length > 0) { + availabilitySchedule[day.day.toString()] = day.available_slots; + availableDays.push(day.day); + availableDaysDisplay.push(day.day_name); } - } else if (Array.isArray(data.available_days)) { - availableDays = data.available_days; - } + }); return { available_days: availableDays, - available_days_display: data.available_days_display || [], + available_days_display: availableDaysDisplay, + availability_schedule: availabilitySchedule, + all_available_slots: weekArray + .filter((d: any) => d.is_available) + .flatMap((d: any) => d.available_slots.map((slot: string) => ({ day: d.day, time_slot: slot as "morning" | "afternoon" | "evening" }))), } as AdminAvailability; } catch (error) { return null; @@ -509,7 +679,67 @@ export async function getAdminAvailability(): Promise { throw new Error(errorMessage); } - // Handle both string and array formats for available_days + // Handle new format with availability_schedule + // API returns availability_schedule, which may be a JSON string or object + // Time slots are strings: "morning", "afternoon", "evening" + if (data.availability_schedule) { + let availabilitySchedule: Record; + + // Map numeric indices to string names (in case API returns numeric indices) + const numberToTimeSlot: Record = { + 0: 'morning', + 1: 'afternoon', + 2: 'evening', + }; + + // Parse if it's a string, otherwise use as-is + let rawSchedule: Record; + if (typeof data.availability_schedule === 'string') { + try { + rawSchedule = JSON.parse(data.availability_schedule); + } catch (parseError) { + rawSchedule = {}; + } + } else { + rawSchedule = data.availability_schedule; + } + + // Convert to string format, handling both numeric indices and string values + availabilitySchedule = {}; + Object.keys(rawSchedule).forEach(day => { + const slots = rawSchedule[day]; + if (Array.isArray(slots) && slots.length > 0) { + // Check if slots are numbers (indices) or already strings + if (typeof slots[0] === 'number') { + // Convert numeric indices to string names + availabilitySchedule[day] = (slots as number[]) + .map((num: number) => numberToTimeSlot[num]) + .filter((slot: string | undefined) => slot !== undefined) as string[]; + } else { + // Already strings, validate and use as-is + availabilitySchedule[day] = (slots as string[]).filter(slot => + ['morning', 'afternoon', 'evening'].includes(slot) + ); + } + } + }); + + const availableDays = Object.keys(availabilitySchedule).map(Number); + + // Generate available_days_display if not provided + const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; + const availableDaysDisplay = availableDays.map(day => dayNames[day] || `Day ${day}`); + + return { + available_days: availableDays, + available_days_display: data.availability_schedule_display ? [data.availability_schedule_display] : availableDaysDisplay, + availability_schedule: availabilitySchedule, + availability_schedule_display: data.availability_schedule_display, + all_available_slots: data.all_available_slots || [], + } as AdminAvailability; + } + + // Handle legacy format let availableDays: number[] = []; if (typeof data.available_days === 'string') { try { @@ -538,14 +768,69 @@ export async function updateAdminAvailability( throw new Error("Authentication required."); } - // Ensure available_days is an array of numbers - const payload = { - available_days: Array.isArray(input.available_days) + // Prepare payload using new format (availability_schedule) + // API expects availability_schedule as an object with string keys (day numbers) and string arrays (time slot names) + // Format: { "0": ["morning", "afternoon"], "1": ["evening"], ... } + const payload: any = {}; + + if (input.availability_schedule) { + // Validate and clean the schedule object + // API expects: { "0": ["morning", "evening"], "1": ["afternoon"], ... } + // Time slots are strings: "morning", "afternoon", "evening" + const cleanedSchedule: Record = {}; + Object.keys(input.availability_schedule).forEach(key => { + // Ensure key is a valid day (0-6) + const dayNum = parseInt(key); + if (isNaN(dayNum) || dayNum < 0 || dayNum > 6) { + return; + } + + const slots = input.availability_schedule[key]; + if (Array.isArray(slots) && slots.length > 0) { + // Filter to only valid time slot strings and remove duplicates + const validSlots = slots + .filter((slot: string) => + typeof slot === 'string' && ['morning', 'afternoon', 'evening'].includes(slot) + ) + .filter((slot: string, index: number, self: string[]) => + self.indexOf(slot) === index + ); // Remove duplicates + + if (validSlots.length > 0) { + // Ensure day key is a string (as per API spec) + cleanedSchedule[key.toString()] = validSlots; + } + } + }); + + if (Object.keys(cleanedSchedule).length === 0) { + throw new Error("At least one day with valid time slots must be provided"); + } + + // Sort the schedule keys for consistency + const sortedSchedule: Record = {}; + Object.keys(cleanedSchedule) + .sort((a, b) => parseInt(a) - parseInt(b)) + .forEach(key => { + sortedSchedule[key] = cleanedSchedule[key]; + }); + + // IMPORTANT: API expects availability_schedule as an object (not stringified) + // Format: { "0": ["morning", "afternoon"], "1": ["evening"], ... } + payload.availability_schedule = sortedSchedule; + } else if (input.available_days) { + // Legacy format: available_days + payload.available_days = Array.isArray(input.available_days) ? input.available_days.map(day => Number(day)) - : input.available_days - }; + : input.available_days; + } else { + throw new Error("Either availability_schedule or available_days must be provided"); + } - const response = await fetch(API_ENDPOINTS.meetings.adminAvailability, { + + // Try PUT first, fallback to PATCH if needed + // The payload object will be JSON stringified, including availability_schedule as an object + let response = await fetch(API_ENDPOINTS.meetings.adminAvailability, { method: "PUT", headers: { "Content-Type": "application/json", @@ -554,26 +839,251 @@ export async function updateAdminAvailability( body: JSON.stringify(payload), }); + // If PUT fails with 500, try PATCH (some APIs prefer PATCH for updates) + if (!response.ok && response.status === 500) { + response = await fetch(API_ENDPOINTS.meetings.adminAvailability, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokens.access}`, + }, + body: JSON.stringify(payload), + }); + } + + // Read response text first (can only be read once) + const responseText = await response.text(); let data: any; - try { - data = await response.json(); + + // Get content type + const contentType = response.headers.get("content-type") || ""; + + // Handle empty response + if (!responseText || responseText.trim().length === 0) { + // If successful status but empty response, refetch the availability + if (response.ok) { + return await getAdminAvailability(); + } + + throw new Error(`Server error (${response.status}): ${response.statusText || 'Empty response from server'}`); + } + + // Try to parse as JSON + if (contentType.includes("application/json")) { + try { + data = JSON.parse(responseText); } catch (parseError) { - // If response is not JSON, use status text - throw new Error(response.statusText || "Failed to update availability"); + throw new Error(`Server error (${response.status}): Invalid JSON response format`); + } + } else { + // Response is not JSON - try to extract useful information + // Try to extract error message from HTML if it's an HTML error page + let errorMessage = `Server error (${response.status}): ${response.statusText || 'Internal Server Error'}`; + let actualError = ''; + let errorType = ''; + let fullTraceback = ''; + + if (responseText) { + // Extract Django error details from HTML + const titleMatch = responseText.match(/]*>(.*?)<\/title>/i); + const h1Match = responseText.match(/]*>(.*?)<\/h1>/i); + + // Try to find the actual error traceback in
 tags (Django debug pages)
+      const tracebackMatch = responseText.match(/]*class="[^"]*traceback[^"]*"[^>]*>([\s\S]*?)<\/pre>/i) ||
+                            responseText.match(/]*>([\s\S]*?)<\/pre>/i);
+      
+      // Extract the actual error type and message
+      const errorTypeMatch = responseText.match(/]*>(.*?)<\/h2>/i);
+      
+      if (tracebackMatch && tracebackMatch[1]) {
+        // Extract the full traceback and find the actual error
+        const tracebackText = tracebackMatch[1].replace(/<[^>]*>/g, ''); // Remove HTML tags
+        fullTraceback = tracebackText;
+        const tracebackLines = tracebackText.split('\n').filter(line => line.trim());
+        
+        // Look for the actual error message - Django errors usually appear at the end
+        // First, try to find error patterns in the entire traceback
+        const errorPatterns = [
+          // Database column errors
+          /column\s+[\w.]+\.(\w+)\s+(does not exist|already exists|is missing)/i,
+          // Programming errors
+          /(ProgrammingError|OperationalError|IntegrityError|DatabaseError|ValueError|TypeError|AttributeError|KeyError):\s*(.+?)(?:\n|$)/i,
+          // Generic error patterns
+          /^(\w+Error):\s*(.+)$/i,
+          // Error messages without type
+          /^(.+Error[:\s]+.+)$/i,
+        ];
+        
+        // Search from the end backwards (errors are usually at the end)
+        for (let i = tracebackLines.length - 1; i >= 0; i--) {
+          const line = tracebackLines[i];
+          
+          // Check each pattern
+          for (const pattern of errorPatterns) {
+            const match = line.match(pattern);
+            if (match) {
+              // For database column errors, capture the full message
+              if (pattern.source.includes('column')) {
+                actualError = match[0];
+                errorType = 'DatabaseError';
+              } else if (match[1] && match[2]) {
+                errorType = match[1];
+                actualError = match[2].trim();
+              } else {
+                actualError = match[0];
+              }
+              break;
+            }
+          }
+          if (actualError) break;
+        }
+        
+        // If no pattern match, look for lines containing "Error" or common error keywords
+        if (!actualError) {
+          for (let i = tracebackLines.length - 1; i >= Math.max(0, tracebackLines.length - 10); i--) {
+            const line = tracebackLines[i];
+            if (line.match(/(Error|Exception|Failed|Invalid|Missing|does not exist|already exists)/i)) {
+              actualError = line;
+              break;
+            }
+          }
+        }
+        
+        // Last resort: get the last line
+        if (!actualError && tracebackLines.length > 0) {
+          actualError = tracebackLines[tracebackLines.length - 1];
+        }
+        
+        // Clean up the error message
+        if (actualError) {
+          actualError = actualError.trim();
+          // Remove common prefixes
+          actualError = actualError.replace(/^(Traceback|File|Error|Exception):\s*/i, '');
+        }
+      } else if (errorTypeMatch && errorTypeMatch[1]) {
+        errorType = errorTypeMatch[1].replace(/<[^>]*>/g, '').trim();
+        actualError = errorType;
+        if (errorType && errorType.length < 200) {
+          errorMessage += `. ${errorType}`;
+        }
+      } else if (h1Match && h1Match[1]) {
+        actualError = h1Match[1].replace(/<[^>]*>/g, '').trim();
+        if (actualError && actualError.length < 200) {
+          errorMessage += `. ${actualError}`;
+        }
+      } else if (titleMatch && titleMatch[1]) {
+        actualError = titleMatch[1].replace(/<[^>]*>/g, '').trim();
+        if (actualError && actualError.length < 200) {
+          errorMessage += `. ${actualError}`;
+        }
+      }
+    }
+    
+    // Update error message with the extracted error
+    if (actualError) {
+      errorMessage = `Server error (${response.status}): ${actualError}`;
+    }
+    
+    throw new Error(errorMessage);
   }
 
   if (!response.ok) {
     const errorMessage = extractErrorMessage(data as unknown as ApiError);
-    console.error("Availability update error:", {
-      status: response.status,
-      statusText: response.statusText,
-      data,
-      payload
-    });
-    throw new Error(errorMessage);
+    
+    // Build detailed error message
+    let detailedError = `Server error (${response.status}): `;
+    if (data && typeof data === 'object') {
+      if (data.detail) {
+        detailedError += Array.isArray(data.detail) ? data.detail.join(", ") : String(data.detail);
+      } else if (data.error) {
+        detailedError += Array.isArray(data.error) ? data.error.join(", ") : String(data.error);
+      } else if (data.message) {
+        detailedError += Array.isArray(data.message) ? data.message.join(", ") : String(data.message);
+      } else {
+        detailedError += response.statusText || 'Failed to update availability';
+      }
+    } else if (responseText && responseText.length > 0) {
+      // Try to extract error from HTML response if it's not JSON
+      detailedError += responseText.substring(0, 200);
+    } else {
+      detailedError += response.statusText || 'Failed to update availability';
+    }
+    
+    throw new Error(detailedError);
   }
 
-  // Handle both string and array formats for available_days in response
+  // Handle new format with availability_schedule in response
+  // API returns availability_schedule, which may be a JSON string or object
+  // Time slots may be strings or numeric indices
+  if (data && data.availability_schedule) {
+    let availabilitySchedule: Record;
+    
+    // Map numeric indices to string names (in case API returns numeric indices)
+    const numberToTimeSlot: Record = {
+      0: 'morning',
+      1: 'afternoon',
+      2: 'evening',
+    };
+    
+    // Parse if it's a string, otherwise use as-is
+    let rawSchedule: Record;
+    if (typeof data.availability_schedule === 'string') {
+      try {
+        rawSchedule = JSON.parse(data.availability_schedule);
+      } catch (parseError) {
+        rawSchedule = {};
+      }
+    } else if (typeof data.availability_schedule === 'object') {
+      rawSchedule = data.availability_schedule;
+    } else {
+      rawSchedule = {};
+    }
+    
+    // Convert to string format, handling both numeric indices and string values
+    availabilitySchedule = {};
+    Object.keys(rawSchedule).forEach(day => {
+      const slots = rawSchedule[day];
+      if (Array.isArray(slots) && slots.length > 0) {
+        // Check if slots are numbers (indices) or already strings
+        if (typeof slots[0] === 'number') {
+          // Convert numeric indices to string names
+          availabilitySchedule[day] = (slots as number[])
+            .map((num: number) => numberToTimeSlot[num])
+            .filter((slot: string | undefined) => slot !== undefined) as string[];
+        } else {
+          // Already strings, validate and use as-is
+          availabilitySchedule[day] = (slots as string[]).filter(slot => 
+            ['morning', 'afternoon', 'evening'].includes(slot)
+          );
+        }
+      }
+    });
+    
+    const availableDays = Object.keys(availabilitySchedule).map(Number);
+    const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
+    const availableDaysDisplay = availableDays.map(day => dayNames[day] || `Day ${day}`);
+
+    return {
+      available_days: availableDays,
+      available_days_display: data.availability_schedule_display ? 
+        (Array.isArray(data.availability_schedule_display) ? 
+          data.availability_schedule_display : 
+          [data.availability_schedule_display]) : 
+        availableDaysDisplay,
+      availability_schedule: availabilitySchedule,
+      availability_schedule_display: data.availability_schedule_display,
+      all_available_slots: data.all_available_slots || [],
+    } as AdminAvailability;
+  }
+  
+  // If response is empty but successful (200), return empty availability
+  // This might happen if the server doesn't return data on success
+  if (response.ok && (!data || Object.keys(data).length === 0)) {
+    // Refetch the availability to get the updated data
+    return getAdminAvailability();
+  }
+
+  // Handle legacy format
   let availableDays: number[] = [];
   if (typeof data.available_days === 'string') {
     try {
@@ -619,28 +1129,49 @@ export async function getAppointmentStats(): Promise {
 }
 
 // Get user appointment stats
-export async function getUserAppointmentStats(): Promise {
+export async function getUserAppointmentStats(email: string): Promise {
   const tokens = getStoredTokens();
   
   if (!tokens.access) {
     throw new Error("Authentication required.");
   }
 
+  if (!email) {
+    throw new Error("Email is required to fetch user appointment stats.");
+  }
+
   const response = await fetch(API_ENDPOINTS.meetings.userAppointmentStats, {
-    method: "GET",
+    method: "POST",
     headers: {
       "Content-Type": "application/json",
       Authorization: `Bearer ${tokens.access}`,
     },
+    body: JSON.stringify({ email }),
   });
 
-  const data: UserAppointmentStats = await response.json();
+  const responseText = await response.text();
 
   if (!response.ok) {
-    const errorMessage = extractErrorMessage(data as unknown as ApiError);
+    let errorData: any;
+    try {
+      errorData = JSON.parse(responseText);
+    } catch {
+      throw new Error(`Server error (${response.status}): ${response.statusText || 'Internal Server Error'}`);
+    }
+    const errorMessage = extractErrorMessage(errorData as unknown as ApiError);
     throw new Error(errorMessage);
   }
 
+  let data: UserAppointmentStats;
+  try {
+    if (!responseText || responseText.trim().length === 0) {
+      throw new Error("Empty response from server");
+    }
+    data = JSON.parse(responseText);
+  } catch (error) {
+    throw new Error(`Failed to parse response: Invalid JSON format`);
+  }
+
   return data;
 }
 
diff --git a/lib/api_urls.ts b/lib/api_urls.ts
index a4ffa32..e7322b1 100644
--- a/lib/api_urls.ts
+++ b/lib/api_urls.ts
@@ -24,6 +24,10 @@ export const API_ENDPOINTS = {
     userAppointments: `${API_BASE_URL}/meetings/user/appointments/`,
     userAppointmentStats: `${API_BASE_URL}/meetings/user/appointments/stats/`,
     adminAvailability: `${API_BASE_URL}/meetings/admin/availability/`,
+    weeklyAvailability: `${API_BASE_URL}/meetings/availability/weekly/`,
+    availabilityConfig: `${API_BASE_URL}/meetings/availability/config/`,
+    checkDateAvailability: `${API_BASE_URL}/meetings/availability/check/`,
+    availabilityOverview: `${API_BASE_URL}/meetings/availability/overview/`,
   },
 } as const;
 
diff --git a/lib/models/appointments.ts b/lib/models/appointments.ts
index 73d8e6d..8b14b4b 100644
--- a/lib/models/appointments.ts
+++ b/lib/models/appointments.ts
@@ -7,9 +7,10 @@ export interface Appointment {
   email: string;
   phone?: string;
   reason?: string;
-  preferred_dates: string[]; // YYYY-MM-DD format
-  preferred_time_slots: string[]; // "morning", "afternoon", "evening"
-  status: "pending_review" | "scheduled" | "rejected" | "completed";
+  preferred_dates?: string; // YYYY-MM-DD format (legacy) - API returns as string, not array
+  preferred_time_slots?: string; // "morning", "afternoon", "evening" (legacy) - API returns as string
+  selected_slots?: SelectedSlot[]; // New format: day-time combinations
+  status: "pending_review" | "scheduled" | "rejected" | "completed" | "cancelled";
   created_at: string;
   updated_at: string;
   scheduled_datetime?: string;
@@ -17,9 +18,28 @@ export interface Appointment {
   rejection_reason?: string;
   jitsi_meet_url?: string;
   jitsi_room_id?: string;
-  has_jitsi_meeting?: boolean;
-  can_join_meeting?: boolean;
+  has_jitsi_meeting?: boolean | string;
+  can_join_meeting?: boolean | string;
   meeting_status?: string;
+  matching_availability?: MatchingAvailability | Array<{
+    date: string;
+    day_name: string;
+    available_slots: string[];
+    date_obj?: string;
+  }>;
+  are_preferences_available?: boolean | string;
+  // Additional fields from API response
+  full_name?: string;
+  formatted_created_at?: string;
+  formatted_scheduled_datetime?: string;
+  preferred_dates_display?: string;
+  preferred_time_slots_display?: string;
+  meeting_duration_display?: string;
+}
+
+export interface SelectedSlot {
+  day: number; // 0-6 (Monday-Sunday)
+  time_slot: "morning" | "afternoon" | "evening";
 }
 
 export interface AppointmentResponse {
@@ -36,14 +56,74 @@ export interface AppointmentsListResponse {
 }
 
 export interface AvailableDatesResponse {
-  dates: string[]; // YYYY-MM-DD format
+  dates?: string[]; // YYYY-MM-DD format (legacy)
   available_days?: number[]; // 0-6 (Monday-Sunday)
   available_days_display?: string[];
+  // New format - array of date objects with time slots
+  available_dates?: Array<{
+    date: string; // YYYY-MM-DD
+    day_name: string;
+    available_slots: string[];
+    available_slots_display?: string[];
+    is_available: boolean;
+  }>;
+}
+
+export interface WeeklyAvailabilityDay {
+  day: number; // 0-6 (Monday-Sunday)
+  day_name: string;
+  available_slots: string[]; // ["morning", "afternoon", "evening"]
+  available_slots_display?: string[];
+  is_available: boolean;
+}
+
+export type WeeklyAvailabilityResponse = WeeklyAvailabilityDay[] | {
+  week?: WeeklyAvailabilityDay[];
+  [key: string]: any; // Allow for different response formats
+};
+
+export interface AvailabilityConfig {
+  days_of_week: Record; // {"0": "Monday", ...}
+  time_slots: Record; // {"morning": "Morning (9AM - 12PM)", ...}
+}
+
+export interface CheckDateAvailabilityResponse {
+  date: string;
+  day_name: string;
+  available_slots: string[];
+  available_slots_display?: string[];
+  is_available: boolean;
+}
+
+export interface AvailabilityOverview {
+  available: boolean;
+  total_available_slots: number;
+  available_days: string[];
+  next_available_dates: Array<{
+    date: string;
+    day_name: string;
+    available_slots: string[];
+    is_available: boolean;
+  }>;
 }
 
 export interface AdminAvailability {
-  available_days: number[]; // 0-6 (Monday-Sunday)
-  available_days_display: string[];
+  available_days?: number[]; // 0-6 (Monday-Sunday) (legacy)
+  available_days_display?: string[];
+  availability_schedule?: Record; // {"0": ["morning", "evening"], "1": ["afternoon"]}
+  availability_schedule_display?: string;
+  all_available_slots?: SelectedSlot[];
+}
+
+export interface MatchingAvailability {
+  appointment_id: string;
+  preferences_match_availability: boolean;
+  matching_slots: Array<{
+    date: string; // YYYY-MM-DD
+    time_slot: string;
+    day: number;
+  }>;
+  total_matching_slots: number;
 }
 
 export interface AppointmentStats {
@@ -62,6 +142,7 @@ export interface UserAppointmentStats {
   rejected: number;
   completed: number;
   completion_rate: number;
+  email?: string;
 }
 
 export interface JitsiMeetingInfo {
diff --git a/lib/schema/appointments.ts b/lib/schema/appointments.ts
index 714d9d4..69b7e9d 100644
--- a/lib/schema/appointments.ts
+++ b/lib/schema/appointments.ts
@@ -1,19 +1,37 @@
 import { z } from "zod";
 
-// Create Appointment Schema
+// Selected Slot Schema (for new API format)
+export const selectedSlotSchema = z.object({
+  day: z.number().int().min(0).max(6),
+  time_slot: z.enum(["morning", "afternoon", "evening"]),
+});
+
+export type SelectedSlotInput = z.infer;
+
+// Create Appointment Schema (updated to use selected_slots)
 export const createAppointmentSchema = z.object({
-  first_name: z.string().min(1, "First name is required"),
-  last_name: z.string().min(1, "Last name is required"),
-  email: z.string().email("Invalid email address"),
+  first_name: z.string().min(1, "First name is required").max(100, "First name must be 100 characters or less"),
+  last_name: z.string().min(1, "Last name is required").max(100, "Last name must be 100 characters or less"),
+  email: z.string().email("Invalid email address").max(100, "Email must be 100 characters or less"),
+  selected_slots: z
+    .array(selectedSlotSchema)
+    .min(1, "At least one time slot must be selected"),
+  phone: z.string().max(100, "Phone must be 100 characters or less").optional(),
+  reason: z.string().max(100, "Reason must be 100 characters or less").optional(),
+  // Legacy fields (optional, for backward compatibility - but should not be sent)
   preferred_dates: z
     .array(z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format"))
-    .min(1, "At least one preferred date is required"),
+    .optional(),
   preferred_time_slots: z
     .array(z.enum(["morning", "afternoon", "evening"]))
-    .min(1, "At least one preferred time slot is required"),
-  phone: z.string().optional(),
-  reason: z.string().optional(),
-});
+    .optional(),
+}).refine(
+  (data) => data.selected_slots && data.selected_slots.length > 0,
+  {
+    message: "At least one time slot must be selected",
+    path: ["selected_slots"],
+  }
+);
 
 export type CreateAppointmentInput = z.infer;
 
@@ -32,11 +50,18 @@ export const rejectAppointmentSchema = z.object({
 
 export type RejectAppointmentInput = z.infer;
 
-// Update Admin Availability Schema
+// Update Admin Availability Schema (updated to use availability_schedule)
 export const updateAvailabilitySchema = z.object({
+  availability_schedule: z
+    .record(z.string(), z.array(z.enum(["morning", "afternoon", "evening"])))
+    .refine(
+      (schedule) => Object.keys(schedule).length > 0,
+      { message: "At least one day must have availability" }
+    ),
+  // Legacy field (optional, for backward compatibility)
   available_days: z
     .array(z.number().int().min(0).max(6))
-    .min(1, "At least one day must be selected"),
+    .optional(),
 });
 
 export type UpdateAvailabilityInput = z.infer;
diff --git a/lib/utils/encryption.ts b/lib/utils/encryption.ts
index 5dd9986..44fb2a8 100644
--- a/lib/utils/encryption.ts
+++ b/lib/utils/encryption.ts
@@ -119,7 +119,6 @@ export async function encryptValue(value: string): Promise {
     const binaryString = String.fromCharCode(...combined);
     return btoa(binaryString);
   } catch (error) {
-    console.error("Encryption error:", error);
     // If encryption fails, return original value (graceful degradation)
     return value;
   }
@@ -152,7 +151,6 @@ export async function decryptValue(encryptedValue: string): Promise {
     const decoder = new TextDecoder();
     return decoder.decode(decrypted);
   } catch (error) {
-    console.error("Decryption error:", error);
     // If decryption fails, try to return as-is (might be unencrypted legacy data)
     return encryptedValue;
   }
@@ -191,7 +189,6 @@ export async function decryptUserData(user: any): Promise {
         decrypted[field] = await decryptValue(String(decrypted[field]));
       } catch (error) {
         // If decryption fails, keep original value (might be unencrypted)
-        console.warn(`Failed to decrypt field ${field}:`, error);
       }
     }
   }
@@ -228,7 +225,7 @@ export async function smartDecryptUserData(user: any): Promise {
         try {
           decrypted[field] = await decryptValue(decrypted[field]);
         } catch (error) {
-          console.warn(`Failed to decrypt field ${field}:`, error);
+          // Failed to decrypt field, keep original value
         }
       }
       // If not encrypted, keep as-is (backward compatibility)
-- 
2.39.5


From 5556e88fbf189b0407266d5a37c0c384c1349603 Mon Sep 17 00:00:00 2001
From: iamkiddy 
Date: Thu, 27 Nov 2025 19:33:54 +0000
Subject: [PATCH 2/3] Refactor Booking and AppointmentDetail components to
 improve handling of preferred dates and time slots. Enhance type safety by
 ensuring preferred_dates and preferred_time_slots are validated as arrays.
 Update rendering logic to handle different data formats for better user
 experience and consistency.

---
 app/(admin)/admin/booking/[id]/page.tsx | 48 ++++++++++++++++---------
 app/(admin)/admin/booking/page.tsx      | 24 ++++++++-----
 app/(admin)/admin/dashboard/page.tsx    | 10 +++---
 hooks/useAppointments.ts                | 11 ++++--
 4 files changed, 60 insertions(+), 33 deletions(-)

diff --git a/app/(admin)/admin/booking/[id]/page.tsx b/app/(admin)/admin/booking/[id]/page.tsx
index 47b134e..eda1ac4 100644
--- a/app/(admin)/admin/booking/[id]/page.tsx
+++ b/app/(admin)/admin/booking/[id]/page.tsx
@@ -368,7 +368,7 @@ export default function AppointmentDetailPage() {
             )}
 
             {/* Preferred Dates & Times */}
-            {(appointment.preferred_dates?.length > 0 || appointment.preferred_time_slots?.length > 0) && (
+            {((appointment.preferred_dates && appointment.preferred_dates.length > 0) || (appointment.preferred_time_slots && appointment.preferred_time_slots.length > 0)) && (
               

@@ -376,37 +376,53 @@ export default function AppointmentDetailPage() {

- {appointment.preferred_dates && appointment.preferred_dates.length > 0 && ( + {appointment.preferred_dates && (

Preferred Dates

- {appointment.preferred_dates.map((date, idx) => ( + {Array.isArray(appointment.preferred_dates) ? ( + (appointment.preferred_dates as string[]).map((date, idx) => ( {formatShortDate(date)} - ))} + )) + ) : ( + + {appointment.preferred_dates_display || appointment.preferred_dates || 'N/A'} + + )}
)} - {appointment.preferred_time_slots && appointment.preferred_time_slots.length > 0 && ( + {appointment.preferred_time_slots && (

Preferred Time Slots

- {appointment.preferred_time_slots.map((slot, idx) => ( + {Array.isArray(appointment.preferred_time_slots) ? ( + (appointment.preferred_time_slots as string[]).map((slot, idx) => ( {slot} - ))} + )) + ) : ( + + {appointment.preferred_time_slots_display || appointment.preferred_time_slots || 'N/A'} + + )}
)} @@ -635,15 +651,15 @@ export default function AppointmentDetailPage() {
{appointment.can_join_meeting ? ( - - + + ) : (
); diff --git a/hooks/useAppointments.ts b/hooks/useAppointments.ts index da49cba..0ab8800 100644 --- a/hooks/useAppointments.ts +++ b/hooks/useAppointments.ts @@ -138,11 +138,16 @@ export function useAppointments(options?: { staleTime: 1 * 60 * 1000, // 1 minute }); - // Get user appointment stats query + // Get user appointment stats query - disabled because it requires email parameter + // Use getUserAppointmentStats(email) directly where email is available const userAppointmentStatsQuery = useQuery({ queryKey: ["appointments", "user", "stats"], - queryFn: () => getUserAppointmentStats(), - enabled: enableStats, + queryFn: async () => { + // This query is disabled - getUserAppointmentStats requires email parameter + // Use getUserAppointmentStats(email) directly in components where email is available + return {} as UserAppointmentStats; + }, + enabled: false, // Disabled - requires email parameter which hook doesn't have access to staleTime: 1 * 60 * 1000, // 1 minute }); -- 2.39.5 From c8c84e16443921c829938abe9d6a86d8760f470e Mon Sep 17 00:00:00 2001 From: iamkiddy Date: Thu, 27 Nov 2025 19:37:54 +0000 Subject: [PATCH 3/3] Remove selected slots display from BookNowPage to streamline user interface and improve clarity in slot selection process. --- app/(pages)/book-now/page.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/app/(pages)/book-now/page.tsx b/app/(pages)/book-now/page.tsx index b00fe7c..46d4073 100644 --- a/app/(pages)/book-now/page.tsx +++ b/app/(pages)/book-now/page.tsx @@ -719,11 +719,6 @@ export default function BookNowPage() { <>

Select one or more day-time combinations that work for you - {formData.selectedSlots.length > 0 && ( - - ({formData.selectedSlots.length} {formData.selectedSlots.length === 1 ? 'slot' : 'slots'} selected) - - )}

{availableDaysOfWeek.map((dayInfo, dayIndex) => { @@ -779,9 +774,6 @@ export default function BookNowPage() { : 'bg-white border-gray-300 text-gray-700 hover:border-rose-500 hover:bg-rose-50' }`} > - {isSelected && ( - - )} {timeSlotLabels[normalizedTimeSlot] || timeSlot} -- 2.39.5