Merge pull request 'feat/booking-panel' (#29) from feat/booking-panel into master

Reviewed-on: http://35.207.46.142/ATTUNE-HEART-THERAPY/website/pulls/29
This commit is contained in:
Hammond 2025-11-27 19:44:53 +00:00
commit 37560787fc
20 changed files with 2662 additions and 923 deletions

View File

@ -30,8 +30,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { DatePicker } from "@/components/DatePicker"; import { ScheduleAppointmentDialog } from "@/components/ScheduleAppointmentDialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "sonner"; import { toast } from "sonner";
import type { Appointment } from "@/lib/models/appointments"; import type { Appointment } from "@/lib/models/appointments";
@ -62,7 +61,6 @@ export default function AppointmentDetailPage() {
const data = await getAppointmentDetail(appointmentId); const data = await getAppointmentDetail(appointmentId);
setAppointment(data); setAppointment(data);
} catch (error) { } catch (error) {
console.error("Failed to fetch appointment details:", error);
toast.error("Failed to load appointment details"); toast.error("Failed to load appointment details");
router.push("/admin/booking"); router.push("/admin/booking");
} finally { } finally {
@ -139,10 +137,6 @@ export default function AppointmentDetailPage() {
return status.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase()); 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 () => { const handleSchedule = async () => {
if (!appointment || !scheduledDate) return; if (!appointment || !scheduledDate) return;
@ -165,7 +159,6 @@ export default function AppointmentDetailPage() {
const updated = await getAppointmentDetail(appointment.id); const updated = await getAppointmentDetail(appointment.id);
setAppointment(updated); setAppointment(updated);
} catch (error: any) { } catch (error: any) {
console.error("Failed to schedule appointment:", error);
toast.error(error.message || "Failed to schedule appointment"); toast.error(error.message || "Failed to schedule appointment");
} finally { } finally {
setIsScheduling(false); setIsScheduling(false);
@ -188,7 +181,6 @@ export default function AppointmentDetailPage() {
const updated = await getAppointmentDetail(appointment.id); const updated = await getAppointmentDetail(appointment.id);
setAppointment(updated); setAppointment(updated);
} catch (error: any) { } catch (error: any) {
console.error("Failed to reject appointment:", error);
toast.error(error.message || "Failed to reject appointment"); toast.error(error.message || "Failed to reject appointment");
} finally { } finally {
setIsRejecting(false); setIsRejecting(false);
@ -376,7 +368,7 @@ export default function AppointmentDetailPage() {
)} )}
{/* Preferred Dates & Times */} {/* 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)) && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}> <div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}> <div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
<h2 className={`text-lg font-semibold ${isDark ? "text-white" : "text-gray-900"}`}> <h2 className={`text-lg font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
@ -384,37 +376,53 @@ export default function AppointmentDetailPage() {
</h2> </h2>
</div> </div>
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
{appointment.preferred_dates && appointment.preferred_dates.length > 0 && ( {appointment.preferred_dates && (
<div> <div>
<p className={`text-sm font-medium mb-3 ${isDark ? "text-gray-400" : "text-gray-500"}`}> <p className={`text-sm font-medium mb-3 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Preferred Dates Preferred Dates
</p> </p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{appointment.preferred_dates.map((date, idx) => ( {Array.isArray(appointment.preferred_dates) ? (
(appointment.preferred_dates as string[]).map((date, idx) => (
<span <span
key={idx} key={idx}
className={`px-4 py-2 rounded-lg text-sm font-medium ${isDark ? "bg-gray-700 text-gray-200 border border-gray-600" : "bg-gray-100 text-gray-700 border border-gray-200"}`} className={`px-4 py-2 rounded-lg text-sm font-medium ${isDark ? "bg-gray-700 text-gray-200 border border-gray-600" : "bg-gray-100 text-gray-700 border border-gray-200"}`}
> >
{formatShortDate(date)} {formatShortDate(date)}
</span> </span>
))} ))
) : (
<span
className={`px-4 py-2 rounded-lg text-sm font-medium ${isDark ? "bg-gray-700 text-gray-200 border border-gray-600" : "bg-gray-100 text-gray-700 border border-gray-200"}`}
>
{appointment.preferred_dates_display || appointment.preferred_dates || 'N/A'}
</span>
)}
</div> </div>
</div> </div>
)} )}
{appointment.preferred_time_slots && appointment.preferred_time_slots.length > 0 && ( {appointment.preferred_time_slots && (
<div> <div>
<p className={`text-sm font-medium mb-3 ${isDark ? "text-gray-400" : "text-gray-500"}`}> <p className={`text-sm font-medium mb-3 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Preferred Time Slots Preferred Time Slots
</p> </p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{appointment.preferred_time_slots.map((slot, idx) => ( {Array.isArray(appointment.preferred_time_slots) ? (
(appointment.preferred_time_slots as string[]).map((slot, idx) => (
<span <span
key={idx} key={idx}
className={`px-4 py-2 rounded-lg text-sm font-medium capitalize ${isDark ? "bg-rose-500/20 text-rose-300 border border-rose-500/30" : "bg-rose-50 text-rose-700 border border-rose-200"}`} className={`px-4 py-2 rounded-lg text-sm font-medium capitalize ${isDark ? "bg-rose-500/20 text-rose-300 border border-rose-500/30" : "bg-rose-50 text-rose-700 border border-rose-200"}`}
> >
{slot} {slot}
</span> </span>
))} ))
) : (
<span
className={`px-4 py-2 rounded-lg text-sm font-medium capitalize ${isDark ? "bg-rose-500/20 text-rose-300 border border-rose-500/30" : "bg-rose-50 text-rose-700 border border-rose-200"}`}
>
{appointment.preferred_time_slots_display || appointment.preferred_time_slots || 'N/A'}
</span>
)}
</div> </div>
</div> </div>
)} )}
@ -422,6 +430,64 @@ export default function AppointmentDetailPage() {
</div> </div>
)} )}
{/* Matching Availability */}
{appointment.matching_availability && Array.isArray(appointment.matching_availability) && appointment.matching_availability.length > 0 && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
<CalendarCheck className={`w-5 h-5 ${isDark ? "text-green-400" : "text-green-600"}`} />
Matching Availability
{appointment.are_preferences_available !== undefined && (
<span className={`ml-auto px-3 py-1 text-xs font-medium rounded-full ${appointment.are_preferences_available ? (isDark ? "bg-green-500/20 text-green-300 border border-green-500/30" : "bg-green-50 text-green-700 border border-green-200") : (isDark ? "bg-yellow-500/20 text-yellow-300 border border-yellow-500/30" : "bg-yellow-50 text-yellow-700 border border-yellow-200")}`}>
{appointment.are_preferences_available ? "Available" : "Partially Available"}
</span>
)}
</h2>
</div>
<div className="p-6">
<div className="space-y-4">
{appointment.matching_availability.map((match: any, idx: number) => (
<div
key={idx}
className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}
>
<div className="flex items-start justify-between mb-3">
<div>
<p className={`text-base font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
{match.day_name || "Unknown Day"}
</p>
<p className={`text-sm mt-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
{formatShortDate(match.date || match.date_obj || "")}
</p>
</div>
</div>
{match.available_slots && Array.isArray(match.available_slots) && match.available_slots.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{match.available_slots.map((slot: string, slotIdx: number) => {
const timeSlotLabels: Record<string, string> = {
morning: "Morning",
afternoon: "Lunchtime",
evening: "Evening",
};
const normalizedSlot = String(slot).toLowerCase().trim();
return (
<span
key={slotIdx}
className={`px-3 py-1.5 rounded-lg text-sm font-medium ${isDark ? "bg-green-500/20 text-green-300 border border-green-500/30" : "bg-green-50 text-green-700 border border-green-200"}`}
>
{timeSlotLabels[normalizedSlot] || slot}
</span>
);
})}
</div>
)}
</div>
))}
</div>
</div>
</div>
)}
{/* Reason */} {/* Reason */}
{appointment.reason && ( {appointment.reason && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}> <div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
@ -584,6 +650,7 @@ export default function AppointmentDetailPage() {
{appointment.status === "scheduled" && appointment.jitsi_meet_url && ( {appointment.status === "scheduled" && appointment.jitsi_meet_url && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gradient-to-br from-blue-900/20 to-purple-900/20 border-blue-800/30" : "bg-gradient-to-br from-blue-50 to-purple-50 border-blue-200"}`}> <div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gradient-to-br from-blue-900/20 to-purple-900/20 border-blue-800/30" : "bg-gradient-to-br from-blue-50 to-purple-50 border-blue-200"}`}>
<div className="p-6"> <div className="p-6">
{appointment.can_join_meeting ? (
<a <a
href={appointment.jitsi_meet_url} href={appointment.jitsi_meet_url}
target="_blank" target="_blank"
@ -593,6 +660,15 @@ export default function AppointmentDetailPage() {
<Video className="w-5 h-5" /> <Video className="w-5 h-5" />
Join Meeting Join Meeting
</a> </a>
) : (
<button
disabled
className={`flex items-center justify-center gap-2 w-full cursor-not-allowed h-12 rounded-lg text-base font-medium transition-colors ${isDark ? "bg-gray-700 text-gray-500" : "bg-gray-300 text-gray-500"}`}
>
<Video className="w-5 h-5" />
Meeting Not Available Yet
</button>
)}
</div> </div>
</div> </div>
)} )}
@ -600,140 +676,21 @@ export default function AppointmentDetailPage() {
</div> </div>
</main> </main>
{/* Google Meet Style Schedule Dialog */} {/* Schedule Appointment Dialog */}
<Dialog open={scheduleDialogOpen} onOpenChange={setScheduleDialogOpen}> <ScheduleAppointmentDialog
<DialogContent className={`max-w-3xl ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}> open={scheduleDialogOpen}
<DialogHeader className="pb-4"> onOpenChange={setScheduleDialogOpen}
<DialogTitle className={`text-2xl font-semibold ${isDark ? "text-white" : "text-gray-900"}`}> appointment={appointment}
Schedule Appointment scheduledDate={scheduledDate}
</DialogTitle> setScheduledDate={setScheduledDate}
<DialogDescription className={`text-base ${isDark ? "text-gray-400" : "text-gray-500"}`}> scheduledTime={scheduledTime}
Set date and time for {appointment.first_name} {appointment.last_name}'s appointment setScheduledTime={setScheduledTime}
</DialogDescription> scheduledDuration={scheduledDuration}
</DialogHeader> setScheduledDuration={setScheduledDuration}
onSchedule={handleSchedule}
<div className="space-y-6 py-4"> isScheduling={isScheduling}
{/* Date Selection */} isDark={isDark}
<div className="space-y-3"> />
<label className={`text-sm font-semibold ${isDark ? "text-gray-300" : "text-gray-700"}`}>
Select Date *
</label>
<div className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}>
<DatePicker
date={scheduledDate}
setDate={setScheduledDate}
/>
</div>
</div>
{/* Time Selection */}
<div className="space-y-3">
<label className={`text-sm font-semibold ${isDark ? "text-gray-300" : "text-gray-700"}`}>
Select Time *
</label>
<div className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}>
<Select value={scheduledTime} onValueChange={setScheduledTime}>
<SelectTrigger className={`h-12 text-base ${isDark ? "bg-gray-800 border-gray-600 text-white" : "bg-white border-gray-300"}`}>
<SelectValue placeholder="Choose a time" />
</SelectTrigger>
<SelectContent className={isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}>
{timeSlots.map((time) => (
<SelectItem
key={time}
value={time}
className={`h-12 text-base ${isDark ? "focus:bg-gray-700" : ""}`}
>
{new Date(`2000-01-01T${time}`).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
})}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Duration Selection */}
<div className="space-y-3">
<label className={`text-sm font-semibold ${isDark ? "text-gray-300" : "text-gray-700"}`}>
Duration
</label>
<div className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}>
<div className="grid grid-cols-4 gap-2">
{[30, 60, 90, 120].map((duration) => (
<button
key={duration}
onClick={() => setScheduledDuration(duration)}
className={`px-4 py-3 rounded-lg text-sm font-medium transition-all ${
scheduledDuration === duration
? isDark
? "bg-blue-600 text-white"
: "bg-blue-600 text-white"
: isDark
? "bg-gray-800 text-gray-300 hover:bg-gray-700 border border-gray-600"
: "bg-white text-gray-700 hover:bg-gray-50 border border-gray-200"
}`}
>
{duration} min
</button>
))}
</div>
</div>
</div>
{/* Preview */}
{scheduledDate && (
<div className={`p-4 rounded-xl border ${isDark ? "bg-blue-500/10 border-blue-500/30" : "bg-blue-50 border-blue-200"}`}>
<p className={`text-sm font-medium mb-2 ${isDark ? "text-blue-300" : "text-blue-700"}`}>
Appointment Preview
</p>
<div className="space-y-1">
<p className={`text-base font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
{formatDate(scheduledDate.toISOString())}
</p>
<p className={`text-sm ${isDark ? "text-gray-300" : "text-gray-700"}`}>
{new Date(`2000-01-01T${scheduledTime}`).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
})} {scheduledDuration} minutes
</p>
</div>
</div>
)}
</div>
<DialogFooter className="gap-3 pt-4">
<Button
variant="outline"
onClick={() => setScheduleDialogOpen(false)}
disabled={isScheduling}
className={`h-12 px-6 ${isDark ? "border-gray-700 text-gray-300 hover:bg-gray-700" : ""}`}
>
Cancel
</Button>
<Button
onClick={handleSchedule}
disabled={isScheduling || !scheduledDate}
className="h-12 px-6 bg-blue-600 hover:bg-blue-700 text-white"
>
{isScheduling ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Scheduling...
</>
) : (
<>
<CalendarCheck className="w-5 h-5 mr-2" />
Schedule Appointment
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Reject Appointment Dialog */} {/* Reject Appointment Dialog */}
<Dialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}> <Dialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>

View File

@ -27,8 +27,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { DatePicker } from "@/components/DatePicker"; import { ScheduleAppointmentDialog } from "@/components/ScheduleAppointmentDialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "sonner"; import { toast } from "sonner";
import type { Appointment } from "@/lib/models/appointments"; import type { Appointment } from "@/lib/models/appointments";
@ -50,7 +49,13 @@ export default function Booking() {
const isDark = theme === "dark"; const isDark = theme === "dark";
// Availability management // Availability management
const { adminAvailability, isLoadingAdminAvailability, updateAvailability, isUpdatingAvailability, refetchAdminAvailability } = useAppointments(); const {
adminAvailability,
isLoadingAdminAvailability,
updateAvailability,
isUpdatingAvailability,
refetchAdminAvailability,
} = useAppointments();
const [selectedDays, setSelectedDays] = useState<number[]>([]); const [selectedDays, setSelectedDays] = useState<number[]>([]);
const [availabilityDialogOpen, setAvailabilityDialogOpen] = useState(false); const [availabilityDialogOpen, setAvailabilityDialogOpen] = useState(false);
const [dayTimeSlots, setDayTimeSlots] = useState<Record<number, string[]>>({}); const [dayTimeSlots, setDayTimeSlots] = useState<Record<number, string[]>>({});
@ -65,22 +70,26 @@ export default function Booking() {
{ value: 6, label: "Sunday" }, { value: 6, label: "Sunday" },
]; ];
// Load time slots from localStorage on mount // Time slots will be loaded from API in the useEffect below
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);
}
}
}, []);
// Initialize selected days and time slots when availability is loaded // Initialize selected days and time slots when availability is loaded
useEffect(() => { 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<number, string[]> = {};
days.forEach((day) => {
initialSlots[day] = schedule[day.toString()] || [];
});
setDayTimeSlots(initialSlots);
}
// Fallback to legacy format
else if (adminAvailability?.available_days) {
setSelectedDays(adminAvailability.available_days); setSelectedDays(adminAvailability.available_days);
// Load saved time slots or use defaults // Load saved time slots or use defaults
const savedTimeSlots = localStorage.getItem("adminAvailabilityTimeSlots"); const savedTimeSlots = localStorage.getItem("adminAvailabilityTimeSlots");
@ -91,18 +100,28 @@ export default function Booking() {
const parsed = JSON.parse(savedTimeSlots); const parsed = JSON.parse(savedTimeSlots);
// Only use saved slots for days that are currently available // Only use saved slots for days that are currently available
adminAvailability.available_days.forEach((day) => { 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) { } catch (error) {
// If parsing fails, use defaults // If parsing fails, use defaults
adminAvailability.available_days.forEach((day) => { adminAvailability.available_days.forEach((day) => {
initialSlots[day] = ["morning", "lunchtime", "afternoon"]; initialSlots[day] = ["morning", "afternoon"];
}); });
} }
} else { } else {
// No saved slots, use defaults // No saved slots, use defaults
adminAvailability.available_days.forEach((day) => { adminAvailability.available_days.forEach((day) => {
initialSlots[day] = ["morning", "lunchtime", "afternoon"]; initialSlots[day] = ["morning", "afternoon"];
}); });
} }
@ -112,8 +131,8 @@ export default function Booking() {
const timeSlotOptions = [ const timeSlotOptions = [
{ value: "morning", label: "Morning" }, { value: "morning", label: "Morning" },
{ value: "lunchtime", label: "Lunchtime" }, { value: "afternoon", label: "Lunchtime" },
{ value: "afternoon", label: "Evening" }, { value: "evening", label: "Evening" },
]; ];
const handleDayToggle = (day: number) => { const handleDayToggle = (day: number) => {
@ -126,7 +145,7 @@ export default function Booking() {
if (!prev.includes(day) && !dayTimeSlots[day]) { if (!prev.includes(day) && !dayTimeSlots[day]) {
setDayTimeSlots((prevSlots) => ({ setDayTimeSlots((prevSlots) => ({
...prevSlots, ...prevSlots,
[day]: ["morning", "lunchtime", "afternoon"], [day]: ["morning", "afternoon"],
})); }));
} }
@ -170,14 +189,44 @@ export default function Booking() {
toast.error(`Please select at least one time slot for ${daysOfWeek.find(d => d.value === day)?.label}`); toast.error(`Please select at least one time slot for ${daysOfWeek.find(d => d.value === day)?.label}`);
return; 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 { try {
// Ensure selectedDays is an array of numbers // Build availability_schedule format: {"0": ["morning", "evening"], "1": ["afternoon"]}
const daysToSave = selectedDays.map(day => Number(day)).sort(); const availabilitySchedule: Record<string, ("morning" | "afternoon" | "evening")[]> = {};
await updateAvailability({ available_days: daysToSave }); 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): slot is "morning" | "afternoon" | "evening" =>
["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)); localStorage.setItem("adminAvailabilityTimeSlots", JSON.stringify(dayTimeSlots));
toast.success("Availability updated successfully!"); toast.success("Availability updated successfully!");
@ -187,21 +236,40 @@ export default function Booking() {
} }
setAvailabilityDialogOpen(false); setAvailabilityDialogOpen(false);
} catch (error) { } catch (error) {
console.error("Failed to update availability:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to update availability"; 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 = () => { 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<number, string[]> = {};
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); setSelectedDays(adminAvailability.available_days);
// Initialize time slots for each day // Initialize time slots for each day
const initialSlots: Record<number, string[]> = {}; const initialSlots: Record<number, string[]> = {};
adminAvailability.available_days.forEach((day) => { adminAvailability.available_days.forEach((day) => {
initialSlots[day] = dayTimeSlots[day] || ["morning", "lunchtime", "afternoon"]; initialSlots[day] = dayTimeSlots[day] || ["morning", "afternoon"];
}); });
setDayTimeSlots(initialSlots); setDayTimeSlots(initialSlots);
} else {
// No existing availability, start fresh
setSelectedDays([]);
setDayTimeSlots({});
} }
setAvailabilityDialogOpen(true); setAvailabilityDialogOpen(true);
}; };
@ -213,7 +281,6 @@ export default function Booking() {
const data = await listAppointments(); const data = await listAppointments();
setAppointments(data || []); setAppointments(data || []);
} catch (error) { } catch (error) {
console.error("Failed to fetch appointments:", error);
toast.error("Failed to load appointments. Please try again."); toast.error("Failed to load appointments. Please try again.");
setAppointments([]); setAppointments([]);
} finally { } finally {
@ -337,7 +404,6 @@ export default function Booking() {
const data = await listAppointments(); const data = await listAppointments();
setAppointments(data || []); setAppointments(data || []);
} catch (error) { } catch (error) {
console.error("Failed to schedule appointment:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to schedule appointment"; const errorMessage = error instanceof Error ? error.message : "Failed to schedule appointment";
toast.error(errorMessage); toast.error(errorMessage);
} finally { } finally {
@ -363,7 +429,6 @@ export default function Booking() {
const data = await listAppointments(); const data = await listAppointments();
setAppointments(data || []); setAppointments(data || []);
} catch (error) { } catch (error) {
console.error("Failed to reject appointment:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to reject appointment"; const errorMessage = error instanceof Error ? error.message : "Failed to reject appointment";
toast.error(errorMessage); toast.error(errorMessage);
} finally { } finally {
@ -371,14 +436,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( const filteredAppointments = appointments.filter(
(appointment) => (appointment) =>
@ -442,34 +499,70 @@ export default function Booking() {
<h3 className={`text-sm font-semibold mb-3 ${isDark ? "text-white" : "text-gray-900"}`}> <h3 className={`text-sm font-semibold mb-3 ${isDark ? "text-white" : "text-gray-900"}`}>
Weekly Availability Weekly Availability
</h3> </h3>
{adminAvailability.available_days_display && adminAvailability.available_days_display.length > 0 ? ( {(adminAvailability.availability_schedule || (adminAvailability.available_days_display && adminAvailability.available_days_display.length > 0)) ? (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{adminAvailability.available_days.map((dayNum, index) => { {(() => {
const dayName = daysOfWeek.find(d => d.value === dayNum)?.label || adminAvailability.available_days_display[index]; // Try new format first
const timeSlots = dayTimeSlots[dayNum] || []; if (adminAvailability.availability_schedule) {
const slotLabels = timeSlots.map(slot => { return Object.keys(adminAvailability.availability_schedule).map((dayKey) => {
const option = timeSlotOptions.find(opt => opt.value === slot); const dayNum = parseInt(dayKey);
return option ? option.label : slot; const dayName = daysOfWeek.find(d => d.value === dayNum)?.label || `Day ${dayNum}`;
}); const timeSlots = adminAvailability.availability_schedule![dayKey] || [];
return ( const slotLabels = timeSlots.map((slot: string) => {
<div const option = timeSlotOptions.find(opt => opt.value === slot);
key={dayNum} return option ? option.label : slot;
className={`flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm whitespace-nowrap ${ });
isDark return (
? "bg-rose-500/10 text-rose-200 border border-rose-500/20" <div
: "bg-rose-50 text-rose-700 border border-rose-200" key={dayNum}
}`} className={`flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm whitespace-nowrap ${
> isDark
<Check className={`w-3.5 h-3.5 shrink-0 ${isDark ? "text-rose-400" : "text-rose-600"}`} /> ? "bg-rose-500/10 text-rose-200 border border-rose-500/20"
<span className="font-medium shrink-0">{dayName}</span> : "bg-rose-50 text-rose-700 border border-rose-200"
{slotLabels.length > 0 && ( }`}
<span className={`text-sm shrink-0 ${isDark ? "text-rose-300" : "text-rose-600"}`}> >
({slotLabels.join(", ")}) <Check className={`w-3.5 h-3.5 shrink-0 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
</span> <span className="font-medium shrink-0">{dayName}</span>
)} {slotLabels.length > 0 && (
</div> <span className={`text-sm shrink-0 ${isDark ? "text-rose-300" : "text-rose-600"}`}>
); ({slotLabels.join(", ")})
})} </span>
)}
</div>
);
});
}
// 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 (
<div
key={dayNum}
className={`flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm whitespace-nowrap ${
isDark
? "bg-rose-500/10 text-rose-200 border border-rose-500/20"
: "bg-rose-50 text-rose-700 border border-rose-200"
}`}
>
<Check className={`w-3.5 h-3.5 shrink-0 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
<span className="font-medium shrink-0">{dayName}</span>
{slotLabels.length > 0 && (
<span className={`text-sm shrink-0 ${isDark ? "text-rose-300" : "text-rose-600"}`}>
({slotLabels.join(", ")})
</span>
)}
</div>
);
});
}
return null;
})()}
</div> </div>
) : ( ) : (
<p className={`text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}> <p className={`text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}>
@ -586,13 +679,19 @@ export default function Booking() {
</span> </span>
</td> </td>
<td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm hidden lg:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}> <td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm hidden lg:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
{appointment.preferred_dates && appointment.preferred_dates.length > 0 ? ( {appointment.preferred_dates ? (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{appointment.preferred_dates.slice(0, 2).map((date, idx) => ( {Array.isArray(appointment.preferred_dates) ? (
<span key={idx}>{formatDate(date)}</span> <>
))} {(appointment.preferred_dates as string[]).slice(0, 2).map((date, idx) => (
{appointment.preferred_dates.length > 2 && ( <span key={idx}>{formatDate(date)}</span>
<span className="text-xs">+{appointment.preferred_dates.length - 2} more</span> ))}
{appointment.preferred_dates.length > 2 && (
<span className="text-xs">+{appointment.preferred_dates.length - 2} more</span>
)}
</>
) : (
<span>{appointment.preferred_dates_display || appointment.preferred_dates}</span>
)} )}
</div> </div>
) : ( ) : (
@ -655,103 +754,27 @@ export default function Booking() {
</main> </main>
{/* Schedule Appointment Dialog */} {/* Schedule Appointment Dialog */}
<Dialog open={scheduleDialogOpen} onOpenChange={setScheduleDialogOpen}> <ScheduleAppointmentDialog
<DialogContent className={`${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}> open={scheduleDialogOpen}
<DialogHeader> onOpenChange={(open) => {
<DialogTitle className={isDark ? "text-white" : "text-gray-900"}> setScheduleDialogOpen(open);
Schedule Appointment if (!open) {
</DialogTitle> setScheduledDate(undefined);
<DialogDescription className={isDark ? "text-gray-400" : "text-gray-500"}> setScheduledTime("09:00");
{selectedAppointment && ( setScheduledDuration(60);
<>Schedule appointment for {selectedAppointment.first_name} {selectedAppointment.last_name}</> }
)} }}
</DialogDescription> appointment={selectedAppointment}
</DialogHeader> scheduledDate={scheduledDate}
setScheduledDate={setScheduledDate}
<div className="space-y-4 py-4"> scheduledTime={scheduledTime}
<div className="space-y-2"> setScheduledTime={setScheduledTime}
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}> scheduledDuration={scheduledDuration}
Date * setScheduledDuration={setScheduledDuration}
</label> onSchedule={handleSchedule}
<DatePicker isScheduling={isScheduling}
date={scheduledDate} isDark={isDark}
setDate={setScheduledDate} />
/>
</div>
<div className="space-y-2">
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
Time *
</label>
<Select value={scheduledTime} onValueChange={setScheduledTime}>
<SelectTrigger className={isDark ? "bg-gray-700 border-gray-600 text-white" : "bg-white border-gray-300"}>
<SelectValue placeholder="Select time" />
</SelectTrigger>
<SelectContent className={isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}>
{timeSlots.map((time) => (
<SelectItem
key={time}
value={time}
className={isDark ? "focus:bg-gray-700" : ""}
>
{new Date(`2000-01-01T${time}`).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
})}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
Duration (minutes)
</label>
<Select
value={scheduledDuration.toString()}
onValueChange={(value) => setScheduledDuration(parseInt(value))}
>
<SelectTrigger className={isDark ? "bg-gray-700 border-gray-600 text-white" : "bg-white border-gray-300"}>
<SelectValue placeholder="Select duration" />
</SelectTrigger>
<SelectContent className={isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}>
<SelectItem value="30" className={isDark ? "focus:bg-gray-700" : ""}>30 minutes</SelectItem>
<SelectItem value="60" className={isDark ? "focus:bg-gray-700" : ""}>60 minutes</SelectItem>
<SelectItem value="90" className={isDark ? "focus:bg-gray-700" : ""}>90 minutes</SelectItem>
<SelectItem value="120" className={isDark ? "focus:bg-gray-700" : ""}>120 minutes</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setScheduleDialogOpen(false)}
disabled={isScheduling}
className={isDark ? "border-gray-700 text-gray-300 hover:bg-gray-700" : ""}
>
Cancel
</Button>
<Button
onClick={handleSchedule}
disabled={isScheduling || !scheduledDate || !scheduledTime}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
{isScheduling ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Scheduling...
</>
) : (
"Schedule Appointment"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Reject Appointment Dialog */} {/* Reject Appointment Dialog */}
<Dialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}> <Dialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>
@ -836,7 +859,7 @@ export default function Booking() {
Available Days & Times * Available Days & Times *
</label> </label>
<p className={`text-xs mb-3 ${isDark ? "text-gray-400" : "text-gray-500"}`}> <p className={`text-xs mb-3 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Select days and choose time slots (Morning, Lunchtime, Evening) for each day Select days and choose time slots (Morning, Afternoon, Evening) for each day
</p> </p>
</div> </div>

View File

@ -132,7 +132,6 @@ export default function Dashboard() {
trends, trends,
}); });
} catch (error) { } catch (error) {
console.error("Failed to fetch dashboard stats:", error);
toast.error("Failed to load dashboard statistics"); toast.error("Failed to load dashboard statistics");
// Set default values on error // Set default values on error
setStats({ setStats({
@ -273,7 +272,7 @@ export default function Dashboard() {
<div className={`p-2 sm:p-2.5 rounded-lg ${isDark ? "bg-gray-700" : "bg-gray-50"}`}> <div className={`p-2 sm:p-2.5 rounded-lg ${isDark ? "bg-gray-700" : "bg-gray-50"}`}>
<Icon className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? "text-gray-200" : "text-gray-600"}`} /> <Icon className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? "text-gray-200" : "text-gray-600"}`} />
</div> </div>
{card.showTrend !== false && card.trend && ( {card.trend && (
<div className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${getTrendClasses(card.trendUp)}`}> <div className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${getTrendClasses(card.trendUp)}`}>
{card.trendUp ? ( {card.trendUp ? (
<ArrowUpRight className="w-3 h-3" /> <ArrowUpRight className="w-3 h-3" />
@ -292,11 +291,9 @@ export default function Dashboard() {
<p className={`text-xl sm:text-2xl font-bold mb-1 ${isDark ? "text-white" : "text-gray-900"}`}> <p className={`text-xl sm:text-2xl font-bold mb-1 ${isDark ? "text-white" : "text-gray-900"}`}>
{card.value} {card.value}
</p> </p>
{card.showTrend !== false && ( <p className={`text-xs ${isDark ? "text-gray-400" : "text-gray-500"}`}>
<p className={`text-xs ${isDark ? "text-gray-400" : "text-gray-500"}`}> vs last month
vs last month </p>
</p>
)}
</div> </div>
</div> </div>
); );

View File

@ -55,7 +55,6 @@ export default function AdminSettingsPage() {
phone: profile.phone_number || "", phone: profile.phone_number || "",
}); });
} catch (error) { } catch (error) {
console.error("Failed to fetch profile:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to load profile"; const errorMessage = error instanceof Error ? error.message : "Failed to load profile";
toast.error(errorMessage); toast.error(errorMessage);
} finally { } finally {
@ -102,7 +101,6 @@ export default function AdminSettingsPage() {
}); });
toast.success("Profile updated successfully!"); toast.success("Profile updated successfully!");
} catch (error) { } catch (error) {
console.error("Failed to update profile:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to update profile"; const errorMessage = error instanceof Error ? error.message : "Failed to update profile";
toast.error(errorMessage); toast.error(errorMessage);
} finally { } finally {

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo, useCallback } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useAppTheme } from "@/components/ThemeProvider"; import { useAppTheme } from "@/components/ThemeProvider";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -35,8 +35,6 @@ import { useAuth } from "@/hooks/useAuth";
import { useAppointments } from "@/hooks/useAppointments"; import { useAppointments } from "@/hooks/useAppointments";
import { toast } from "sonner"; import { toast } from "sonner";
import type { Appointment } from "@/lib/models/appointments"; import type { Appointment } from "@/lib/models/appointments";
import { getPublicAvailability } from "@/lib/actions/appointments";
import type { AdminAvailability } from "@/lib/models/appointments";
interface User { interface User {
ID: number; ID: number;
@ -83,14 +81,21 @@ export default function BookNowPage() {
const { theme } = useAppTheme(); const { theme } = useAppTheme();
const isDark = theme === "dark"; const isDark = theme === "dark";
const { isAuthenticated, logout } = useAuth(); const { isAuthenticated, logout } = useAuth();
const { create, isCreating, availableDates, availableDatesResponse, isLoadingAvailableDates } = useAppointments(); const {
create,
isCreating,
weeklyAvailability,
isLoadingWeeklyAvailability,
availabilityOverview,
isLoadingAvailabilityOverview,
availabilityConfig,
} = useAppointments();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
firstName: "", firstName: "",
lastName: "", lastName: "",
email: "", email: "",
phone: "", phone: "",
preferredDays: [] as string[], selectedSlots: [] as Array<{ day: number; time_slot: string }>, // New format
preferredTimes: [] as string[],
message: "", message: "",
}); });
const [booking, setBooking] = useState<Booking | null>(null); const [booking, setBooking] = useState<Booking | null>(null);
@ -98,68 +103,97 @@ export default function BookNowPage() {
const [showLoginDialog, setShowLoginDialog] = useState(false); const [showLoginDialog, setShowLoginDialog] = useState(false);
const [showSignupDialog, setShowSignupDialog] = useState(false); const [showSignupDialog, setShowSignupDialog] = useState(false);
const [loginPrefillEmail, setLoginPrefillEmail] = useState<string | undefined>(undefined); const [loginPrefillEmail, setLoginPrefillEmail] = useState<string | undefined>(undefined);
const [publicAvailability, setPublicAvailability] = useState<AdminAvailability | null>(null);
const [availableTimeSlots, setAvailableTimeSlots] = useState<Record<number, string[]>>({});
// Fetch public availability to get time slots // Helper function to convert day name to day number (0-6)
useEffect(() => { const getDayNumber = (dayName: string): number => {
const fetchAvailability = async () => { const dayMap: Record<string, number> = {
try { 'monday': 0,
const availability = await getPublicAvailability(); 'tuesday': 1,
if (availability) { 'wednesday': 2,
setPublicAvailability(availability); 'thursday': 3,
// Try to get time slots from localStorage (if admin has set them) 'friday': 4,
// Note: This won't work for public users, but we can try 'saturday': 5,
const savedTimeSlots = localStorage.getItem("adminAvailabilityTimeSlots"); 'sunday': 6,
if (savedTimeSlots) { };
try { return dayMap[dayName.toLowerCase()] ?? -1;
const parsed = JSON.parse(savedTimeSlots); };
setAvailableTimeSlots(parsed);
} catch (e) { // Get available days from availability overview (primary) or weekly availability (fallback)
console.error("Failed to parse time slots:", e); 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<string, { day: number; dayName: string; availableSlots: Set<string> }>();
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<string>();
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);
} // Convert Map values to array and sort by day number
}; return Array.from(dayMap.values())
fetchAvailability(); .map(day => ({
}, []); day: day.day,
dayName: day.dayName,
// Use available_days_display from API if available, otherwise extract from dates availableSlots: Array.from(day.availableSlots),
const availableDaysOfWeek = useMemo(() => { }))
// If API provides available_days_display, use it directly .sort((a, b) => a.day - b.day);
if (availableDatesResponse?.available_days_display && availableDatesResponse.available_days_display.length > 0) {
return availableDatesResponse.available_days_display;
} }
// Otherwise, extract from dates // Fallback to weekly availability
if (!availableDates || availableDates.length === 0) { if (weeklyAvailability) {
return []; // 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<string>(); return [];
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; }, [availabilityOverview, weeklyAvailability]);
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]);
const handleLogout = () => { const handleLogout = () => {
logout(); logout();
@ -217,79 +251,89 @@ export default function BookNowPage() {
setError(null); setError(null);
try { try {
if (formData.preferredDays.length === 0) { // Get current slots from formData
setError("Please select at least one available day."); const currentSlots = formData.selectedSlots || [];
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[] = [];
formData.preferredDays.forEach((dayName) => { // Check if slots are selected
const targetDayIndex = days.indexOf(dayName); if (!currentSlots || currentSlots.length === 0) {
if (targetDayIndex === -1) { setError("Please select at least one day and time slot combination by clicking on the time slot buttons.");
console.warn(`Invalid day name: ${dayName}`); 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; return;
} }
// Find the next occurrence of this day within the next 30 days // Validate and limit field lengths to prevent database errors
for (let i = 1; i <= 30; i++) { const firstName = formData.firstName.trim().substring(0, 100);
const checkDate = new Date(today); const lastName = formData.lastName.trim().substring(0, 100);
checkDate.setDate(today.getDate() + i); const email = formData.email.trim().toLowerCase().substring(0, 100);
const phone = formData.phone ? formData.phone.trim().substring(0, 100) : undefined;
if (checkDate.getDay() === targetDayIndex) { const reason = formData.message ? formData.message.trim().substring(0, 100) : undefined;
const dateString = checkDate.toISOString().split("T")[0];
if (!preferredDates.includes(dateString)) {
preferredDates.push(dateString);
}
break; // Only take the first occurrence
}
}
});
// Sort dates
preferredDates.sort();
if (preferredDates.length === 0) { // Validate required fields
setError("Please select at least one available day."); 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; return;
} }
// Map time slots - API expects "morning", "afternoon", "evening" // Prepare payload with validated and limited fields
// 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
const payload = { const payload = {
first_name: formData.firstName.trim(), first_name: firstName,
last_name: formData.lastName.trim(), last_name: lastName,
email: formData.email.trim().toLowerCase(), email: email,
preferred_dates: preferredDates, selected_slots: validSlots,
preferred_time_slots: preferredTimeSlots, ...(phone && phone.length > 0 && { phone: phone }),
...(formData.phone && formData.phone.trim() && { phone: formData.phone.trim() }), ...(reason && reason.length > 0 && { reason: reason }),
...(formData.message && formData.message.trim() && { reason: formData.message.trim() }),
}; };
// Validate payload before sending
console.log("Booking payload:", JSON.stringify(payload, null, 2));
// Call the actual API using the hook // Call the actual API using the hook
const appointmentData = await create(payload); const appointmentData = await create(payload);
@ -328,15 +372,11 @@ export default function BookNowPage() {
setBooking(bookingData); setBooking(bookingData);
toast.success("Appointment request submitted successfully! We'll review and get back to you soon."); toast.success("Appointment request submitted successfully! We'll review and get back to you soon.");
// Redirect to user dashboard after 3 seconds // Stay on the booking page to show the receipt - no redirect
setTimeout(() => {
router.push("/user/dashboard");
}, 3000);
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to submit booking. Please try again."; const errorMessage = err instanceof Error ? err.message : "Failed to submit booking. Please try again.";
setError(errorMessage); setError(errorMessage);
toast.error(errorMessage); toast.error(errorMessage);
console.error("Booking error:", err);
} }
}; };
@ -344,22 +384,50 @@ export default function BookNowPage() {
setFormData((prev) => ({ ...prev, [field]: value })); setFormData((prev) => ({ ...prev, [field]: value }));
}; };
const handleDayToggle = (day: string) => { // Handle slot selection (day + time slot combination)
const handleSlotToggle = (day: number, timeSlot: string) => {
setFormData((prev) => { setFormData((prev) => {
const days = prev.preferredDays.includes(day) const normalizedDay = Number(day);
? prev.preferredDays.filter((d) => d !== day) const normalizedTimeSlot = String(timeSlot).toLowerCase().trim();
: [...prev.preferredDays, day]; const currentSlots = prev.selectedSlots || [];
return { ...prev, preferredDays: days };
// 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) => { // Check if a slot is selected
setFormData((prev) => { const isSlotSelected = (day: number, timeSlot: string): boolean => {
const times = prev.preferredTimes.includes(time) const normalizedDay = Number(day);
? prev.preferredTimes.filter((t) => t !== time) const normalizedTimeSlot = String(timeSlot).toLowerCase().trim();
: [...prev.preferredTimes, time];
return { ...prev, preferredTimes: times }; return (formData.selectedSlots || []).some(
}); slot => Number(slot.day) === normalizedDay &&
String(slot.time_slot).toLowerCase().trim() === normalizedTimeSlot
);
}; };
const formatDateTime = (dateString: string) => { const formatDateTime = (dateString: string) => {
@ -473,77 +541,51 @@ export default function BookNowPage() {
<div className="px-4 sm:px-6 lg:px-12 pb-6 sm:pb-8 lg:pb-12"> <div className="px-4 sm:px-6 lg:px-12 pb-6 sm:pb-8 lg:pb-12">
{booking ? ( {booking ? (
<div className={`rounded-xl sm:rounded-2xl shadow-lg p-4 sm:p-6 lg:p-8 border ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}> <div className={`rounded-xl sm:rounded-2xl shadow-lg p-4 sm:p-6 lg:p-8 border ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}>
<div className="text-center space-y-4"> <div className="text-center space-y-6">
<div className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center ${isDark ? 'bg-green-900/30' : 'bg-green-100'}`}> <div className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center ${isDark ? 'bg-green-900/30' : 'bg-green-100'}`}>
<CheckCircle className={`w-8 h-8 ${isDark ? 'text-green-400' : 'text-green-600'}`} /> <CheckCircle className={`w-8 h-8 ${isDark ? 'text-green-400' : 'text-green-600'}`} />
</div> </div>
<div> <div>
<h2 className={`text-2xl font-semibold mb-2 ${isDark ? 'text-white' : 'text-gray-900'}`}> <h2 className={`text-2xl font-semibold mb-2 ${isDark ? 'text-white' : 'text-gray-900'}`}>
Booking Confirmed! Booking Request Submitted!
</h2> </h2>
<p className={isDark ? 'text-gray-300' : 'text-gray-600'}> <p className={`text-base ${isDark ? 'text-gray-300' : 'text-gray-600'}`}>
Your appointment has been successfully booked. Your appointment request has been received.
</p> </p>
</div> </div>
<div className={`rounded-lg p-6 space-y-4 text-left ${isDark ? 'bg-gray-700/50' : 'bg-gray-50'}`}> <div className={`rounded-lg p-6 space-y-4 text-left ${isDark ? 'bg-gray-700/50' : 'bg-gray-50'}`}>
<div> <div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Booking ID</p> <p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Name</p>
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>#{booking.ID}</p> <p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
</div>
<div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Patient</p>
<p className={`text-base ${isDark ? 'text-white' : 'text-gray-900'}`}>
{booking.user.first_name} {booking.user.last_name} {booking.user.first_name} {booking.user.last_name}
</p> </p>
</div> </div>
<div> <div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Scheduled Time</p> <p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Email</p>
<p className={`text-base ${isDark ? 'text-white' : 'text-gray-900'}`}>{formatDateTime(booking.scheduled_at)}</p> <p className={`text-base ${isDark ? 'text-white' : 'text-gray-900'}`}>
{booking.user.email}
</p>
</div> </div>
<div> {booking.user.phone && (
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Duration</p>
<p className={`text-base ${isDark ? 'text-white' : 'text-gray-900'}`}>{booking.duration} minutes</p>
</div>
<div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Status</p>
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${isDark ? 'bg-blue-900/50 text-blue-200' : 'bg-blue-100 text-blue-800'}`}>
{booking.status}
</span>
</div>
<div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Amount</p>
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>${booking.amount}</p>
</div>
{booking.notes && (
<div> <div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Notes</p> <p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Phone</p>
<p className={`text-base ${isDark ? 'text-white' : 'text-gray-900'}`}>{booking.notes}</p> <p className={`text-base ${isDark ? 'text-white' : 'text-gray-900'}`}>
{booking.user.phone}
</p>
</div> </div>
)} )}
</div> </div>
<div className="pt-4 flex flex-col sm:flex-row gap-3 justify-center"> <div className={`rounded-lg p-4 ${isDark ? 'bg-blue-900/20 border border-blue-800/50' : 'bg-blue-50 border border-blue-200'}`}>
<p className={`text-sm ${isDark ? 'text-blue-300' : 'text-blue-800'}`}>
You will be contacted shortly to confirm your appointment.
</p>
</div>
<div className="pt-4 flex justify-center">
<Button <Button
onClick={() => { onClick={() => router.back()}
setBooking(null);
setFormData({
firstName: "",
lastName: "",
email: "",
phone: "",
preferredDays: [],
preferredTimes: [],
message: "",
});
}}
variant="outline"
>
Book Another Appointment
</Button>
<Button
onClick={() => router.push("/")}
className="bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white" className="bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white"
> >
Return to Home Go Back
</Button> </Button>
</div> </div>
</div> </div>
@ -580,6 +622,7 @@ export default function BookNowPage() {
onChange={(e) => onChange={(e) =>
handleChange("firstName", e.target.value) handleChange("firstName", e.target.value)
} }
maxLength={100}
required 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'}`} 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) => onChange={(e) =>
handleChange("lastName", e.target.value) handleChange("lastName", e.target.value)
} }
maxLength={100}
required 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'}`} 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" placeholder="john.doe@example.com"
value={formData.email} value={formData.email}
onChange={(e) => handleChange("email", e.target.value)} onChange={(e) => handleChange("email", e.target.value)}
maxLength={100}
required 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'}`} 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" placeholder="+1 (555) 123-4567"
value={formData.phone} value={formData.phone}
onChange={(e) => handleChange("phone", e.target.value)} onChange={(e) => handleChange("phone", e.target.value)}
maxLength={100}
required 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'}`} 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'}`} className={`text-sm font-medium flex items-center gap-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}
> >
<Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-500'}`} /> <Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-500'}`} />
Available Days * Available Days & Times *
</label> </label>
{isLoadingAvailableDates ? ( {(isLoadingWeeklyAvailability || isLoadingAvailabilityOverview) ? (
<div className="flex items-center gap-2 text-sm text-gray-500"> <div className="flex items-center gap-2 text-sm text-gray-500">
<Loader2 className="w-4 h-4 animate-spin" /> <Loader2 className="w-4 h-4 animate-spin" />
Loading available days... Loading availability...
</div> </div>
) : availableDaysOfWeek.length === 0 ? ( ) : availableDaysOfWeek.length === 0 ? (
<p className={`text-sm ${isDark ? 'text-gray-400' : 'text-gray-500'}`}> <p className={`text-sm ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
@ -671,92 +717,77 @@ export default function BookNowPage() {
</p> </p>
) : ( ) : (
<> <>
<div className="flex flex-wrap gap-3"> <p className={`text-xs ${isDark ? 'text-gray-400' : 'text-gray-500'} mb-3`}>
{availableDaysOfWeek.map((day: string) => ( Select one or more day-time combinations that work for you
<label </p>
key={day} <div className="space-y-4">
className={`flex items-center gap-2 cursor-pointer px-4 py-2 rounded-lg border transition-all ${ {availableDaysOfWeek.map((dayInfo, dayIndex) => {
formData.preferredDays.includes(day) // Ensure day is always a valid number (already validated in useMemo)
const currentDay = typeof dayInfo.day === 'number' && !isNaN(dayInfo.day)
? dayInfo.day
: dayIndex; // Fallback to index if invalid
// Skip if day is still invalid
if (isNaN(currentDay) || currentDay < 0 || currentDay > 6) {
return null;
}
return (
<div key={`day-wrapper-${currentDay}-${dayIndex}`} className="space-y-2">
<h4 className={`text-sm font-semibold ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
{dayInfo.dayName || `Day ${currentDay}`}
</h4>
<div className="flex flex-wrap gap-2">
{dayInfo.availableSlots.map((timeSlot: string, slotIndex: number) => {
if (!timeSlot) return null;
const timeSlotLabels: Record<string, string> = {
morning: "Morning",
afternoon: "Lunchtime",
evening: "Evening",
};
// Normalize time slot for consistent comparison
const normalizedTimeSlot = String(timeSlot).toLowerCase().trim();
// Create unique key combining day, time slot, and index to ensure uniqueness
const slotKey = `day-${currentDay}-slot-${normalizedTimeSlot}-${slotIndex}`;
// Check if THIS specific day-time combination is selected
const isSelected = isSlotSelected(currentDay, normalizedTimeSlot);
return (
<button
key={slotKey}
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
// Pass the specific day and time slot for this button
handleSlotToggle(currentDay, normalizedTimeSlot);
}}
aria-pressed={isSelected}
className={`flex items-center gap-2 cursor-pointer px-4 py-2 rounded-lg border-2 transition-all focus:outline-none focus:ring-2 focus:ring-rose-500 ${
isSelected
? isDark ? isDark
? 'bg-rose-600 border-rose-500 text-white' ? 'bg-rose-600 border-rose-500 text-white hover:bg-rose-700'
: 'bg-rose-500 border-rose-500 text-white' : 'bg-rose-500 border-rose-500 text-white hover:bg-rose-600'
: isDark : isDark
? 'bg-gray-700 border-gray-600 text-gray-300 hover:border-rose-500' ? 'bg-gray-700 border-gray-600 text-gray-300 hover:border-rose-500 hover:bg-gray-650'
: 'bg-white border-gray-300 text-gray-700 hover:border-rose-500' : 'bg-white border-gray-300 text-gray-700 hover:border-rose-500 hover:bg-rose-50'
}`} }`}
> >
<input <span className="text-sm font-medium">
type="checkbox" {timeSlotLabels[normalizedTimeSlot] || timeSlot}
checked={formData.preferredDays.includes(day)} </span>
onChange={() => handleDayToggle(day)} </button>
className="sr-only" );
/> })}
<span className="text-sm font-medium">{day}</span> </div>
</label> </div>
))} );
})}
</div> </div>
</> </>
)} )}
</div> </div>
<div className="space-y-2">
<label
className={`text-sm font-medium flex items-center gap-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}
>
<Clock className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-500'}`} />
Preferred Time *
</label>
<div className="flex flex-wrap gap-3">
{(() => {
// Get available time slots based on selected days
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const dayIndices = formData.preferredDays.map(day => dayNames.indexOf(day));
// Get all unique time slots from selected days
let allAvailableSlots = new Set<string>();
dayIndices.forEach(dayIndex => {
if (dayIndex !== -1 && availableTimeSlots[dayIndex]) {
availableTimeSlots[dayIndex].forEach(slot => allAvailableSlots.add(slot));
}
});
// If no time slots found in localStorage, show all (fallback)
const slotsToShow = allAvailableSlots.size > 0
? Array.from(allAvailableSlots)
: ['morning', 'lunchtime', 'afternoon'];
const timeSlotMap = [
{ value: 'morning', label: 'Morning' },
{ value: 'lunchtime', label: 'Lunchtime' },
{ value: 'afternoon', label: 'Evening' }
];
// Only show time slots that are available
return timeSlotMap.filter(ts => slotsToShow.includes(ts.value));
})().map((time) => (
<label
key={time.value}
className={`flex items-center gap-2 cursor-pointer px-4 py-2 rounded-lg border transition-all ${
formData.preferredTimes.includes(time.value)
? isDark
? 'bg-rose-600 border-rose-500 text-white'
: 'bg-rose-500 border-rose-500 text-white'
: isDark
? 'bg-gray-700 border-gray-600 text-gray-300 hover:border-rose-500'
: 'bg-white border-gray-300 text-gray-700 hover:border-rose-500'
}`}
>
<input
type="checkbox"
checked={formData.preferredTimes.includes(time.value)}
onChange={() => handleTimeToggle(time.value)}
className="sr-only"
/>
<span className="text-sm font-medium">{time.label}</span>
</label>
))}
</div>
</div>
</div> </div>
</div> </div>
@ -775,6 +806,7 @@ export default function BookNowPage() {
placeholder="Tell us about any specific concerns or preferences..." placeholder="Tell us about any specific concerns or preferences..."
value={formData.message} value={formData.message}
onChange={(e) => handleChange("message", e.target.value)} 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'}`} 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'}`}
/> />
</div> </div>
@ -784,7 +816,7 @@ export default function BookNowPage() {
<Button <Button
type="submit" type="submit"
size="lg" size="lg"
disabled={isCreating || availableDaysOfWeek.length === 0} disabled={isCreating || availableDaysOfWeek.length === 0 || formData.selectedSlots.length === 0}
className="w-full bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all h-12 text-base font-semibold disabled:opacity-50 disabled:cursor-not-allowed" className="w-full bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all h-12 text-base font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
> >
{isCreating ? ( {isCreating ? (
@ -804,19 +836,6 @@ export default function BookNowPage() {
</form> </form>
</div> </div>
{/* Contact Information */}
<div className="mt-6 text-center">
<p className={isDark ? 'text-gray-300' : 'text-gray-600'}>
Prefer to book by phone?{" "}
<a
href="tel:+17548162311"
className={`font-medium underline ${isDark ? 'text-rose-400 hover:text-rose-300' : 'text-rose-600 hover:text-rose-700'}`}
>
Call us at (754) 816-2311
</a>
</p>
</div>
{/* Logout Button - Only show when authenticated */} {/* Logout Button - Only show when authenticated */}
{isAuthenticated && ( {isAuthenticated && (
<div className="mt-6 flex justify-center"> <div className="mt-6 flex justify-center">

View File

@ -297,7 +297,7 @@ For technical assistance, questions, or issues:
<div className="bg-white p-8 rounded-lg shadow-md"> <div className="bg-white p-8 rounded-lg shadow-md">
<Button <Button
className="bg-gray-100 hover:bg-gray-50 shadow-md text-black" className="bg-gray-100 hover:bg-gray-50 shadow-md text-black"
onClick={() => router.push("/")} onClick={() => router.back()}
> >
<ArrowLeft className="mr-2" /> <ArrowLeft className="mr-2" />
</Button> </Button>

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useMemo } from "react"; import { useMemo, useEffect, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Calendar, Calendar,
@ -21,23 +21,69 @@ import {
import Link from "next/link"; import Link from "next/link";
import { Navbar } from "@/components/Navbar"; import { Navbar } from "@/components/Navbar";
import { useAppTheme } from "@/components/ThemeProvider"; import { useAppTheme } from "@/components/ThemeProvider";
import { useAppointments } from "@/hooks/useAppointments";
import { useAuth } from "@/hooks/useAuth"; 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"; import { toast } from "sonner";
export default function UserDashboard() { export default function UserDashboard() {
const { theme } = useAppTheme(); const { theme } = useAppTheme();
const isDark = theme === "dark"; const isDark = theme === "dark";
const { user } = useAuth(); const { user } = useAuth();
const { const [appointments, setAppointments] = useState<Appointment[]>([]);
userAppointments, const [loading, setLoading] = useState(true);
userAppointmentStats, const [stats, setStats] = useState<UserAppointmentStats | null>(null);
isLoadingUserAppointments, const [loadingStats, setLoadingStats] = useState(true);
isLoadingUserStats,
refetchUserAppointments, // Fetch appointments using the same endpoint as admin booking table
refetchUserStats, useEffect(() => {
} = useAppointments(); 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 formatDate = (dateString: string) => {
const date = new Date(dateString); const date = new Date(dateString);
@ -68,58 +114,67 @@ export default function UserDashboard() {
// Filter appointments by status // Filter appointments by status
const upcomingAppointments = useMemo(() => { const upcomingAppointments = useMemo(() => {
return userAppointments.filter( return appointments.filter(
(appointment) => appointment.status === "scheduled" (appointment) => appointment.status === "scheduled"
); );
}, [userAppointments]); }, [appointments]);
const pendingAppointments = useMemo(() => {
return appointments.filter(
(appointment) => appointment.status === "pending_review"
);
}, [appointments]);
const completedAppointments = useMemo(() => { const completedAppointments = useMemo(() => {
return userAppointments.filter( return appointments.filter(
(appointment) => appointment.status === "completed" (appointment) => appointment.status === "completed"
); );
}, [userAppointments]); }, [appointments]);
const stats = userAppointmentStats || { const rejectedAppointments = useMemo(() => {
total_requests: 0, return appointments.filter(
pending_review: 0, (appointment) => appointment.status === "rejected"
scheduled: 0, );
rejected: 0, }, [appointments]);
completed: 0,
completion_rate: 0,
};
const statCards = [ // Sort appointments by created_at (newest first)
{ const allAppointments = useMemo(() => {
title: "Upcoming Appointments", return [...appointments].sort((a, b) => {
value: stats.scheduled, const dateA = new Date(a.created_at).getTime();
icon: CalendarCheck, const dateB = new Date(b.created_at).getTime();
trend: stats.scheduled > 0 ? `+${stats.scheduled}` : "0", return dateB - dateA;
trendUp: true, });
}, }, [appointments]);
{
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,
},
];
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 ( return (
<div className={`min-h-screen ${isDark ? 'bg-gray-900' : 'bg-gray-50'}`}> <div className={`min-h-screen ${isDark ? 'bg-gray-900' : 'bg-gray-50'}`}>
@ -166,116 +221,278 @@ export default function UserDashboard() {
<> <>
{/* Stats Grid */} {/* Stats Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 sm:gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 sm:gap-4">
{statCards.map((card, index) => { <div
const Icon = card.icon; className={`rounded-lg border p-4 sm:p-5 md:p-6 hover:shadow-md transition-shadow ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}
return ( >
<div <div className="flex items-start justify-between mb-3 sm:mb-4">
key={index} <div className={`p-2 sm:p-2.5 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
className={`rounded-lg border p-4 sm:p-5 md:p-6 hover:shadow-md transition-shadow ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`} <CalendarCheck className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
>
<div className="flex items-start justify-between mb-3 sm:mb-4">
<div className={`p-2 sm:p-2.5 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
<Icon className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
</div>
<div
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${
card.trendUp
? isDark ? "bg-green-900/30 text-green-400" : "bg-green-50 text-green-700"
: isDark ? "bg-red-900/30 text-red-400" : "bg-red-50 text-red-700"
}`}
>
{card.trendUp ? (
<ArrowUpRight className="w-3 h-3" />
) : (
<ArrowUpRight className="w-3 h-3" />
)}
<span>{card.trend}</span>
</div>
</div>
<div>
<h3 className={`text-xs font-medium mb-1 sm:mb-2 uppercase tracking-wider ${isDark ? 'text-rose-400' : 'text-rose-600'}`}>
{card.title}
</h3>
<p className={`text-xl sm:text-2xl font-bold mb-1 ${isDark ? 'text-white' : 'text-gray-900'}`}>
{card.value}
</p>
<p className={`text-xs font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>vs last month</p>
</div>
</div> </div>
); <div
})} className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${isDark ? "bg-green-900/30 text-green-400" : "bg-green-50 text-green-700"}`}
>
<ArrowUpRight className="w-3 h-3" />
<span>{displayStats.scheduled > 0 ? `+${displayStats.scheduled}` : "0"}</span>
</div>
</div>
<div>
<h3 className={`text-xs font-medium mb-1 sm:mb-2 uppercase tracking-wider ${isDark ? 'text-rose-400' : 'text-rose-600'}`}>
Upcoming Appointments
</h3>
<p className={`text-xl sm:text-2xl font-bold mb-1 ${isDark ? 'text-white' : 'text-gray-900'}`}>
{displayStats.scheduled}
</p>
<p className={`text-xs font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>vs last month</p>
</div>
</div>
<div
className={`rounded-lg border p-4 sm:p-5 md:p-6 hover:shadow-md transition-shadow ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}
>
<div className="flex items-start justify-between mb-3 sm:mb-4">
<div className={`p-2 sm:p-2.5 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
<CheckCircle2 className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
</div>
<div
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${isDark ? "bg-green-900/30 text-green-400" : "bg-green-50 text-green-700"}`}
>
<ArrowUpRight className="w-3 h-3" />
<span>{displayStats.completed > 0 ? `+${displayStats.completed}` : "0"}</span>
</div>
</div>
<div>
<h3 className={`text-xs font-medium mb-1 sm:mb-2 uppercase tracking-wider ${isDark ? 'text-rose-400' : 'text-rose-600'}`}>
Completed Sessions
</h3>
<p className={`text-xl sm:text-2xl font-bold mb-1 ${isDark ? 'text-white' : 'text-gray-900'}`}>
{displayStats.completed}
</p>
<p className={`text-xs font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>vs last month</p>
</div>
</div>
<div
className={`rounded-lg border p-4 sm:p-5 md:p-6 hover:shadow-md transition-shadow ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}
>
<div className="flex items-start justify-between mb-3 sm:mb-4">
<div className={`p-2 sm:p-2.5 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
<Calendar className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
</div>
<div
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${isDark ? "bg-green-900/30 text-green-400" : "bg-green-50 text-green-700"}`}
>
<ArrowUpRight className="w-3 h-3" />
<span>{`${Math.round(displayStats.completion_rate || 0)}%`}</span>
</div>
</div>
<div>
<h3 className={`text-xs font-medium mb-1 sm:mb-2 uppercase tracking-wider ${isDark ? 'text-rose-400' : 'text-rose-600'}`}>
Total Appointments
</h3>
<p className={`text-xl sm:text-2xl font-bold mb-1 ${isDark ? 'text-white' : 'text-gray-900'}`}>
{displayStats.total_requests}
</p>
<p className={`text-xs font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>vs last month</p>
</div>
</div>
<div
className={`rounded-lg border p-4 sm:p-5 md:p-6 hover:shadow-md transition-shadow ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}
>
<div className="flex items-start justify-between mb-3 sm:mb-4">
<div className={`p-2 sm:p-2.5 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
<Calendar className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
</div>
<div
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${isDark ? "bg-red-900/30 text-red-400" : "bg-red-50 text-red-700"}`}
>
<ArrowUpRight className="w-3 h-3" />
<span>{displayStats.pending_review > 0 ? `${displayStats.pending_review}` : "0"}</span>
</div>
</div>
<div>
<h3 className={`text-xs font-medium mb-1 sm:mb-2 uppercase tracking-wider ${isDark ? 'text-rose-400' : 'text-rose-600'}`}>
Pending Review
</h3>
<p className={`text-xl sm:text-2xl font-bold mb-1 ${isDark ? 'text-white' : 'text-gray-900'}`}>
{displayStats.pending_review}
</p>
<p className={`text-xs font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>vs last month</p>
</div>
</div>
</div> </div>
{/* Upcoming Appointments Section */} {/* All Appointments Section */}
{upcomingAppointments.length > 0 ? ( {allAppointments.length > 0 ? (
<div className={`rounded-lg border p-4 sm:p-5 md:p-6 ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}> <div className={`rounded-lg border overflow-hidden ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}>
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-gray-900'}`}> <div className={`px-4 sm:px-5 md:px-6 py-4 border-b ${isDark ? 'border-gray-700' : 'border-gray-200'}`}>
Upcoming Appointments <h2 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
</h2> All Appointments
<div className="space-y-3"> </h2>
{upcomingAppointments.map((appointment) => ( </div>
<div <div className="overflow-x-auto">
key={appointment.id} <table className="w-full">
className={`border rounded-lg p-4 hover:shadow-md transition-shadow ${isDark ? 'border-gray-700 bg-gray-700/50' : 'border-gray-200'}`} <thead className={`${isDark ? "bg-gray-800 border-b border-gray-700" : "bg-gray-50 border-b border-gray-200"}`}>
> <tr>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
<div className="flex-1"> Appointment
{appointment.scheduled_datetime && ( </th>
<> <th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider hidden md:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
<div className="flex items-center gap-2 mb-2"> Date & Time
<Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} /> </th>
<span className={`font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}> <th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider hidden lg:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
{formatDate(appointment.scheduled_datetime)} Duration
</span> </th>
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Status
</th>
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider hidden lg:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Preferred Times
</th>
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider hidden xl:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Created
</th>
<th className={`px-3 sm:px-4 md:px-6 py-3 text-right text-xs font-medium uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Actions
</th>
</tr>
</thead>
<tbody className={`${isDark ? "bg-gray-800 divide-gray-700" : "bg-white divide-gray-200"}`}>
{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<string, string> = {
morning: 'Morning',
afternoon: 'Lunchtime',
evening: 'Evening',
};
return (
<tr
key={appointment.id}
className={`transition-colors ${isDark ? "hover:bg-gray-700" : "hover:bg-gray-50"}`}
>
<td className="px-3 sm:px-4 md:px-6 py-4">
<div className="flex items-center">
<div className={`shrink-0 h-8 w-8 sm:h-10 sm:w-10 rounded-full flex items-center justify-center ${isDark ? "bg-gray-700" : "bg-gray-100"}`}>
<User className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? "text-gray-200" : "text-gray-600"}`} />
</div>
<div className="ml-2 sm:ml-4 min-w-0">
<div className={`text-xs sm:text-sm font-medium truncate ${isDark ? "text-white" : "text-gray-900"}`}>
{appointment.first_name} {appointment.last_name}
</div>
{appointment.reason && (
<div className={`text-xs sm:text-sm truncate mt-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
{appointment.reason}
</div>
)}
{appointment.scheduled_datetime && (
<div className={`text-xs sm:hidden mt-0.5 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
{formatDate(appointment.scheduled_datetime)}
</div>
)}
</div>
</div> </div>
<div className="flex items-center gap-2 mb-2"> </td>
<Clock className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} /> <td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap hidden md:table-cell">
<span className={isDark ? 'text-gray-300' : 'text-gray-700'}> {appointment.scheduled_datetime ? (
{formatTime(appointment.scheduled_datetime)} <>
</span> <div className={`text-xs sm:text-sm ${isDark ? "text-white" : "text-gray-900"}`}>
{appointment.scheduled_duration && ( {formatDate(appointment.scheduled_datetime)}
<span className={`text-sm font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}> </div>
({appointment.scheduled_duration} minutes) <div className={`text-xs sm:text-sm flex items-center gap-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
</span> <Clock className="w-3 h-3" />
{formatTime(appointment.scheduled_datetime)}
</div>
</>
) : (
<div className={`text-xs sm:text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Not scheduled
</div>
)}
</td>
<td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm hidden lg:table-cell ${isDark ? "text-white" : "text-gray-900"}`}>
{appointment.scheduled_duration ? `${appointment.scheduled_duration} min` : "-"}
</td>
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(
appointment.status
)}`}
>
{formatStatus(appointment.status)}
</span>
</td>
<td className={`px-3 sm:px-4 md:px-6 py-4 hidden lg:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
{appointment.selected_slots && appointment.selected_slots.length > 0 ? (
<div className="flex flex-col gap-1">
{appointment.selected_slots.slice(0, 2).map((slot, idx) => (
<span key={idx} className="text-xs sm:text-sm">
{dayNames[slot.day]} - {timeSlotLabels[slot.time_slot] || slot.time_slot}
</span>
))}
{appointment.selected_slots.length > 2 && (
<span className="text-xs">+{appointment.selected_slots.length - 2} more</span>
)}
</div>
) : (
"-"
)}
</td>
<td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm hidden xl:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
{formatDate(appointment.created_at)}
</td>
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end gap-1 sm:gap-2">
{appointment.jitsi_meet_url && (
<a
href={appointment.jitsi_meet_url}
target="_blank"
rel="noopener noreferrer"
className={`p-1.5 sm:p-2 rounded-lg transition-colors ${
appointment.can_join_meeting
? isDark
? "bg-blue-600 hover:bg-blue-700 text-white"
: "bg-blue-600 hover:bg-blue-700 text-white"
: isDark
? "text-gray-400 hover:text-gray-300 hover:bg-gray-700"
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
}`}
title={appointment.can_join_meeting ? "Join Meeting" : "Meeting Not Available"}
onClick={(e) => {
if (!appointment.can_join_meeting) {
e.preventDefault();
}
}}
>
<Video className="w-4 h-4" />
</a>
)} )}
</div> </div>
</> </td>
)} </tr>
{appointment.reason && ( );
<p className={`text-sm mt-2 font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}> })}
{appointment.reason} </tbody>
</p> </table>
)}
</div>
<div className="flex flex-col sm:items-end gap-3">
<div className="flex items-center gap-2">
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
appointment.status === "scheduled"
? isDark ? 'bg-green-900/30 text-green-400' : 'bg-green-50 text-green-700'
: appointment.status === "pending_review"
? isDark ? 'bg-yellow-900/30 text-yellow-400' : 'bg-yellow-50 text-yellow-700'
: isDark ? 'bg-red-900/30 text-red-400' : 'bg-red-50 text-red-700'
}`}>
{appointment.status.charAt(0).toUpperCase() +
appointment.status.slice(1).replace('_', ' ')}
</span>
</div>
{appointment.jitsi_meet_url && appointment.can_join_meeting && (
<a
href={appointment.jitsi_meet_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors"
>
<Video className="w-4 h-4" />
Join Session
</a>
)}
</div>
</div>
</div>
))}
</div> </div>
</div> </div>
) : !loading && ( ) : !loading && (
@ -283,10 +500,10 @@ export default function UserDashboard() {
<div className="flex flex-col items-center justify-center py-8 text-center"> <div className="flex flex-col items-center justify-center py-8 text-center">
<CalendarCheck className={`w-12 h-12 mb-4 ${isDark ? 'text-gray-500' : 'text-gray-400'}`} /> <CalendarCheck className={`w-12 h-12 mb-4 ${isDark ? 'text-gray-500' : 'text-gray-400'}`} />
<p className={`text-lg font-medium mb-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}> <p className={`text-lg font-medium mb-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
No Upcoming Appointments No Appointments
</p> </p>
<p className={`text-sm mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}> <p className={`text-sm mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
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.
</p> </p>
<Link href="/book-now"> <Link href="/book-now">
<Button className="bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white"> <Button className="bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white">

View File

@ -58,7 +58,6 @@ export default function SettingsPage() {
phone: profile.phone_number || "", phone: profile.phone_number || "",
}); });
} catch (error) { } catch (error) {
console.error("Failed to fetch profile:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to load profile"; const errorMessage = error instanceof Error ? error.message : "Failed to load profile";
toast.error(errorMessage); toast.error(errorMessage);
} finally { } finally {
@ -105,7 +104,6 @@ export default function SettingsPage() {
}); });
toast.success("Profile updated successfully"); toast.success("Profile updated successfully");
} catch (error) { } catch (error) {
console.error("Failed to update profile:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to update profile"; const errorMessage = error instanceof Error ? error.message : "Failed to update profile";
toast.error(errorMessage); toast.error(errorMessage);
} finally { } finally {
@ -140,7 +138,6 @@ export default function SettingsPage() {
confirmPassword: "", confirmPassword: "",
}); });
} catch (error) { } catch (error) {
console.error("Failed to update password:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to update password"; const errorMessage = error instanceof Error ? error.message : "Failed to update password";
toast.error(errorMessage); toast.error(errorMessage);
} finally { } finally {

View File

@ -0,0 +1,177 @@
'use client';
import * as React from 'react';
import { Timer } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
interface ClockDurationPickerProps {
duration: number; // Duration in minutes
setDuration: (duration: number) => void;
label?: string;
isDark?: boolean;
options?: number[]; // Optional custom duration options
}
export function ClockDurationPicker({
duration,
setDuration,
label,
isDark = false,
options = [15, 30, 45, 60, 75, 90, 105, 120]
}: ClockDurationPickerProps) {
const [isOpen, setIsOpen] = React.useState(false);
const wrapperRef = React.useRef<HTMLDivElement>(null);
// 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]);
// Handle duration selection
const handleDurationClick = (selectedDuration: number) => {
setDuration(selectedDuration);
setIsOpen(false);
};
// Calculate position for clock numbers
const getClockPosition = (index: number, total: number, radius: number = 130) => {
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 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 (
<div className="space-y-2">
{label && (
<label className={cn(
"text-sm font-semibold",
isDark ? "text-gray-300" : "text-gray-700"
)}>
{label}
</label>
)}
<div className="relative w-full" ref={wrapperRef}>
<Button
type="button"
variant="outline"
onClick={() => setIsOpen(!isOpen)}
className={cn(
"w-full justify-start text-left font-normal h-12 text-base",
!duration && "text-muted-foreground",
isDark
? "bg-gray-800 border-gray-600 text-white hover:bg-gray-700"
: "bg-white border-gray-300 text-gray-900 hover:bg-gray-50"
)}
>
<Timer className="mr-2 h-5 w-5" />
{displayDuration}
</Button>
{isOpen && (
<div className={cn(
"absolute z-[9999] top-full left-0 right-0 mt-2 rounded-lg shadow-xl border p-6 w-[420px] mx-auto overflow-visible",
isDark
? "bg-gray-800 border-gray-700"
: "bg-white border-gray-200"
)}>
{/* Clock face */}
<div className="relative w-[360px] h-[360px] mx-auto my-6 overflow-visible">
{/* Clock circle */}
<div className={cn(
"absolute inset-0 rounded-full border-2",
isDark ? "border-gray-600" : "border-gray-300"
)} />
{/* Center dot */}
<div className={cn(
"absolute top-1/2 left-1/2 w-2 h-2 rounded-full -translate-x-1/2 -translate-y-1/2 z-10",
isDark ? "bg-gray-400" : "bg-gray-600"
)} />
{/* Duration options arranged in a circle */}
{options.map((option, index) => {
const { x, y } = getClockPosition(index, options.length, 130);
const isSelected = duration === option;
return (
<button
key={option}
type="button"
onClick={() => handleDurationClick(option)}
className={cn(
"absolute w-16 h-16 rounded-full flex items-center justify-center text-xs font-semibold transition-all z-20 whitespace-nowrap",
isSelected
? isDark
? "bg-blue-600 text-white scale-110 shadow-lg ring-2 ring-blue-400"
: "bg-blue-600 text-white scale-110 shadow-lg ring-2 ring-blue-400"
: isDark
? "bg-gray-700 text-gray-200 hover:bg-gray-600 hover:scale-105"
: "bg-gray-100 text-gray-700 hover:bg-gray-200 hover:scale-105"
)}
style={{
left: `calc(50% + ${x}px)`,
top: `calc(50% + ${y}px)`,
transform: 'translate(-50%, -50%)',
}}
title={`${option} minutes`}
>
{formatDuration(option)}
</button>
);
})}
</div>
{/* Quick select buttons for common durations */}
<div className="flex gap-2 mt-4 justify-center flex-wrap">
{[30, 60, 90, 120].map((quickDuration) => (
<button
key={quickDuration}
type="button"
onClick={() => handleDurationClick(quickDuration)}
className={cn(
"px-3 py-1.5 rounded text-sm font-medium transition-colors",
duration === quickDuration
? isDark
? "bg-blue-600 text-white"
: "bg-blue-600 text-white"
: isDark
? "bg-gray-700 text-gray-300 hover:bg-gray-600"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
)}
>
{formatDuration(quickDuration)}
</button>
))}
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -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<HTMLDivElement>(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 (
<div className="space-y-2">
{label && (
<label className={cn(
"text-sm font-semibold",
isDark ? "text-gray-300" : "text-gray-700"
)}>
{label}
</label>
)}
<div className="relative" ref={wrapperRef}>
<Button
type="button"
variant="outline"
onClick={() => setIsOpen(!isOpen)}
className={cn(
"w-full justify-start text-left font-normal h-12 text-base",
!time && "text-muted-foreground",
isDark
? "bg-gray-800 border-gray-600 text-white hover:bg-gray-700"
: "bg-white border-gray-300 text-gray-900 hover:bg-gray-50"
)}
>
<Clock className="mr-2 h-5 w-5" />
{displayTime}
</Button>
{isOpen && (
<div className={cn(
"absolute z-[9999] mt-1 rounded-lg shadow-lg border p-4 -translate-y-1",
isDark
? "bg-gray-800 border-gray-700"
: "bg-white border-gray-200"
)}>
{/* Mode selector */}
<div className="flex gap-2 mb-4">
<button
onClick={() => setMode('hour')}
className={cn(
"px-3 py-1.5 rounded text-sm font-medium transition-colors",
mode === 'hour'
? isDark
? "bg-blue-600 text-white"
: "bg-blue-600 text-white"
: isDark
? "bg-gray-700 text-gray-300 hover:bg-gray-600"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
)}
>
Hour
</button>
<button
onClick={() => setMode('minute')}
className={cn(
"px-3 py-1.5 rounded text-sm font-medium transition-colors",
mode === 'minute'
? isDark
? "bg-blue-600 text-white"
: "bg-blue-600 text-white"
: isDark
? "bg-gray-700 text-gray-300 hover:bg-gray-600"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
)}
>
Minute
</button>
</div>
{/* Clock face */}
<div className="relative w-64 h-64 mx-auto my-4">
{/* Clock circle */}
<div className={cn(
"absolute inset-0 rounded-full border-2",
isDark ? "border-gray-600" : "border-gray-300"
)} />
{/* Center dot */}
<div className={cn(
"absolute top-1/2 left-1/2 w-2 h-2 rounded-full -translate-x-1/2 -translate-y-1/2 z-10",
isDark ? "bg-gray-400" : "bg-gray-600"
)} />
{/* Hour numbers */}
{mode === 'hour' && hourNumbers.map((hour, index) => {
const { x, y } = getClockPosition(index, 12, 90);
const isSelected = displayHours === hour;
return (
<button
key={hour}
type="button"
onClick={() => handleHourClick(hour)}
className={cn(
"absolute w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold transition-all z-20",
isSelected
? isDark
? "bg-blue-600 text-white scale-110 shadow-lg"
: "bg-blue-600 text-white scale-110 shadow-lg"
: isDark
? "bg-gray-700 text-gray-200 hover:bg-gray-600 hover:scale-105"
: "bg-gray-100 text-gray-700 hover:bg-gray-200 hover:scale-105"
)}
style={{
left: `calc(50% + ${x}px)`,
top: `calc(50% + ${y}px)`,
transform: 'translate(-50%, -50%)',
}}
>
{hour}
</button>
);
})}
{/* Minute numbers */}
{mode === 'minute' && minuteNumbers.map((minute, index) => {
const { x, y } = getClockPosition(index, 12, 90);
const isSelected = minutes === minute;
return (
<button
key={minute}
type="button"
onClick={() => handleMinuteClick(minute)}
className={cn(
"absolute w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold transition-all z-20",
isSelected
? isDark
? "bg-blue-600 text-white scale-110 shadow-lg"
: "bg-blue-600 text-white scale-110 shadow-lg"
: isDark
? "bg-gray-700 text-gray-200 hover:bg-gray-600 hover:scale-105"
: "bg-gray-100 text-gray-700 hover:bg-gray-200 hover:scale-105"
)}
style={{
left: `calc(50% + ${x}px)`,
top: `calc(50% + ${y}px)`,
transform: 'translate(-50%, -50%)',
}}
>
{minute}
</button>
);
})}
</div>
{/* AM/PM toggle */}
<div className="flex gap-2 mt-4 justify-center">
<button
type="button"
onClick={() => {
const newHours = ampm === 'PM' ? hours - 12 : hours;
setTime(`${newHours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`);
}}
className={cn(
"px-4 py-2 rounded text-sm font-medium transition-colors",
ampm === 'AM'
? isDark
? "bg-blue-600 text-white"
: "bg-blue-600 text-white"
: isDark
? "bg-gray-700 text-gray-300 hover:bg-gray-600"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
)}
>
AM
</button>
<button
type="button"
onClick={() => {
const newHours = ampm === 'AM' ? hours + 12 : hours;
setTime(`${newHours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`);
}}
className={cn(
"px-4 py-2 rounded text-sm font-medium transition-colors",
ampm === 'PM'
? isDark
? "bg-blue-600 text-white"
: "bg-blue-600 text-white"
: isDark
? "bg-gray-700 text-gray-300 hover:bg-gray-600"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
)}
>
PM
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -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 (
<div className="space-y-3">
{label && (
<label className={cn(
"text-sm font-semibold",
isDark ? "text-gray-300" : "text-gray-700"
)}>
{label}
</label>
)}
<div className="flex flex-wrap gap-2">
{options.map((option) => {
const isSelected = duration === option;
return (
<button
key={option}
type="button"
onClick={() => setDuration(option)}
className={cn(
"px-4 py-3 rounded-lg text-sm font-medium transition-all min-w-[80px]",
isSelected
? isDark
? "bg-blue-600 text-white shadow-md"
: "bg-blue-600 text-white shadow-md"
: isDark
? "bg-gray-800 text-gray-300 hover:bg-gray-700 border border-gray-600"
: "bg-white text-gray-700 hover:bg-gray-50 border border-gray-200"
)}
>
{formatDuration(option)}
</button>
);
})}
</div>
</div>
);
}

View File

@ -462,3 +462,4 @@ export function ForgotPasswordDialog({ open, onOpenChange, onSuccess }: ForgotPa

View File

@ -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<void>;
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={`max-w-4xl max-h-[90vh] overflow-y-auto ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<DialogHeader className="pb-4">
<DialogTitle className={`text-2xl font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
Schedule Appointment
</DialogTitle>
<DialogDescription className={`text-base ${isDark ? "text-gray-400" : "text-gray-500"}`}>
{appointment
? `Set date and time for ${appointment.first_name} ${appointment.last_name}'s appointment`
: "Set date and time for this appointment"}
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Date Selection */}
<div className="space-y-3">
<label className={`text-sm font-semibold ${isDark ? "text-gray-300" : "text-gray-700"}`}>
Select Date *
</label>
<div className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}>
<DatePicker
date={scheduledDate}
setDate={setScheduledDate}
/>
</div>
</div>
{/* Time Selection */}
<div className="space-y-3 -mt-2">
<div className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}>
<ClockTimePicker
time={scheduledTime}
setTime={setScheduledTime}
label="Select Time *"
isDark={isDark}
/>
</div>
</div>
{/* Duration Selection */}
<div className="space-y-3">
<div className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}>
<DurationPicker
duration={scheduledDuration}
setDuration={setScheduledDuration}
label="Duration"
isDark={isDark}
/>
</div>
</div>
{/* Preview */}
{scheduledDate && scheduledTime && (
<div className={`p-4 rounded-xl border ${isDark ? "bg-blue-500/10 border-blue-500/30" : "bg-blue-50 border-blue-200"}`}>
<p className={`text-sm font-medium mb-2 ${isDark ? "text-blue-300" : "text-blue-700"}`}>
Appointment Preview
</p>
<div className="space-y-1">
<p className={`text-base font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
{formatDate(scheduledDate)}
</p>
<p className={`text-sm ${isDark ? "text-gray-300" : "text-gray-700"}`}>
{formatTime(scheduledTime)} {scheduledDuration} minutes
</p>
</div>
</div>
)}
</div>
<DialogFooter className="gap-3 pt-4">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isScheduling}
className={`h-12 px-6 ${isDark ? "border-gray-700 text-gray-300 hover:bg-gray-700" : ""}`}
>
Cancel
</Button>
<Button
onClick={onSchedule}
disabled={isScheduling || !scheduledDate || !scheduledTime}
className="h-12 px-6 bg-blue-600 hover:bg-blue-700 text-white"
>
{isScheduling ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Scheduling...
</>
) : (
<>
<CalendarCheck className="w-5 h-5 mr-2" />
Schedule Appointment
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

115
components/TimePicker.tsx Normal file
View File

@ -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<HTMLDivElement>(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 (
<div className="space-y-2">
{label && (
<label className={cn(
"text-sm font-medium",
isDark ? "text-gray-300" : "text-gray-700"
)}>
{label}
</label>
)}
<div className="relative" ref={wrapperRef}>
<Button
type="button"
variant="outline"
onClick={() => setIsOpen(!isOpen)}
className={cn(
"w-full justify-start text-left font-normal h-12 text-base",
!timeValue && "text-muted-foreground",
isDark
? "bg-gray-800 border-gray-600 text-white hover:bg-gray-700"
: "bg-white border-gray-300 text-gray-900 hover:bg-gray-50"
)}
>
<Clock className="mr-2 h-5 w-5" />
{displayTime}
</Button>
{isOpen && (
<div className={cn(
"absolute z-[9999] mt-2 rounded-lg shadow-lg border",
isDark
? "bg-gray-800 border-gray-700"
: "bg-white border-gray-200"
)}>
<DatePickerLib
selected={timeValue}
onChange={handleTimeChange}
showTimeSelect
showTimeSelectOnly
timeIntervals={15}
timeCaption="Time"
dateFormat="h:mm aa"
inline
className="time-picker"
wrapperClassName="time-picker-wrapper"
/>
</div>
)}
</div>
</div>
);
}

View File

@ -15,6 +15,10 @@ import {
updateAdminAvailability, updateAdminAvailability,
getAppointmentStats, getAppointmentStats,
getJitsiMeetingInfo, getJitsiMeetingInfo,
getWeeklyAvailability,
getAvailabilityConfig,
checkDateAvailability,
getAvailabilityOverview,
} from "@/lib/actions/appointments"; } from "@/lib/actions/appointments";
import type { import type {
CreateAppointmentInput, CreateAppointmentInput,
@ -29,29 +33,85 @@ import type {
UserAppointmentStats, UserAppointmentStats,
AvailableDatesResponse, AvailableDatesResponse,
JitsiMeetingInfo, JitsiMeetingInfo,
WeeklyAvailabilityResponse,
AvailabilityConfig,
CheckDateAvailabilityResponse,
AvailabilityOverview,
} from "@/lib/models/appointments"; } 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 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<AvailableDatesResponse>({ const availableDatesQuery = useQuery<AvailableDatesResponse>({
queryKey: ["appointments", "available-dates"], queryKey: ["appointments", "available-dates"],
queryFn: () => getAvailableDates(), 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<WeeklyAvailabilityResponse>({
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<AvailabilityConfig>({
queryKey: ["appointments", "availability-config"],
queryFn: () => getAvailabilityConfig(),
enabled: enableConfig,
staleTime: 60 * 60 * 1000, // 1 hour (config rarely changes)
});
// Get availability overview query
const availabilityOverviewQuery = useQuery<AvailabilityOverview>({
queryKey: ["appointments", "availability-overview"],
queryFn: () => getAvailabilityOverview(),
enabled: enableOverview,
staleTime: 5 * 60 * 1000, // 5 minutes staleTime: 5 * 60 * 1000, // 5 minutes
}); });
// List appointments query // List appointments query
const appointmentsQuery = useQuery<Appointment[]>({ const appointmentsQuery = useQuery<Appointment[]>({
queryKey: ["appointments", "list"], queryKey: ["appointments", "list"],
queryFn: () => listAppointments(), queryFn: async () => {
enabled: false, // Only fetch when explicitly called 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<Appointment[]>({ const userAppointmentsQuery = useQuery<Appointment[]>({
queryKey: ["appointments", "user"], queryKey: ["appointments", "user"],
queryFn: () => getUserAppointments(), queryFn: () => getUserAppointments(),
enabled: false, // Disabled - using listAppointments endpoint instead
staleTime: 30 * 1000, // 30 seconds staleTime: 30 * 1000, // 30 seconds
}); });
@ -78,10 +138,16 @@ export function useAppointments() {
staleTime: 1 * 60 * 1000, // 1 minute 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<UserAppointmentStats>({ const userAppointmentStatsQuery = useQuery<UserAppointmentStats>({
queryKey: ["appointments", "user", "stats"], queryKey: ["appointments", "user", "stats"],
queryFn: () => getUserAppointmentStats(), 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 staleTime: 1 * 60 * 1000, // 1 minute
}); });
@ -172,6 +238,9 @@ export function useAppointments() {
// Queries // Queries
availableDates: availableDatesQuery.data?.dates || [], availableDates: availableDatesQuery.data?.dates || [],
availableDatesResponse: availableDatesQuery.data, availableDatesResponse: availableDatesQuery.data,
weeklyAvailability: weeklyAvailabilityQuery.data,
availabilityConfig: availabilityConfigQuery.data,
availabilityOverview: availabilityOverviewQuery.data,
appointments: appointmentsQuery.data || [], appointments: appointmentsQuery.data || [],
userAppointments: userAppointmentsQuery.data || [], userAppointments: userAppointmentsQuery.data || [],
adminAvailability: adminAvailabilityQuery.data, adminAvailability: adminAvailabilityQuery.data,
@ -180,6 +249,9 @@ export function useAppointments() {
// Query states // Query states
isLoadingAvailableDates: availableDatesQuery.isLoading, isLoadingAvailableDates: availableDatesQuery.isLoading,
isLoadingWeeklyAvailability: weeklyAvailabilityQuery.isLoading,
isLoadingAvailabilityConfig: availabilityConfigQuery.isLoading,
isLoadingAvailabilityOverview: availabilityOverviewQuery.isLoading,
isLoadingAppointments: appointmentsQuery.isLoading, isLoadingAppointments: appointmentsQuery.isLoading,
isLoadingUserAppointments: userAppointmentsQuery.isLoading, isLoadingUserAppointments: userAppointmentsQuery.isLoading,
isLoadingAdminAvailability: adminAvailabilityQuery.isLoading, isLoadingAdminAvailability: adminAvailabilityQuery.isLoading,
@ -188,12 +260,20 @@ export function useAppointments() {
// Query refetch functions // Query refetch functions
refetchAvailableDates: availableDatesQuery.refetch, refetchAvailableDates: availableDatesQuery.refetch,
refetchWeeklyAvailability: weeklyAvailabilityQuery.refetch,
refetchAvailabilityConfig: availabilityConfigQuery.refetch,
refetchAvailabilityOverview: availabilityOverviewQuery.refetch,
refetchAppointments: appointmentsQuery.refetch, refetchAppointments: appointmentsQuery.refetch,
refetchUserAppointments: userAppointmentsQuery.refetch, refetchUserAppointments: userAppointmentsQuery.refetch,
refetchAdminAvailability: adminAvailabilityQuery.refetch, refetchAdminAvailability: adminAvailabilityQuery.refetch,
refetchStats: appointmentStatsQuery.refetch, refetchStats: appointmentStatsQuery.refetch,
refetchUserStats: userAppointmentStatsQuery.refetch, refetchUserStats: userAppointmentStatsQuery.refetch,
// Helper functions
checkDateAvailability: async (date: string) => {
return await checkDateAvailability(date);
},
// Hooks for specific queries // Hooks for specific queries
useAppointmentDetail, useAppointmentDetail,
useJitsiMeetingInfo, useJitsiMeetingInfo,

View File

@ -16,6 +16,11 @@ import type {
UserAppointmentStats, UserAppointmentStats,
JitsiMeetingInfo, JitsiMeetingInfo,
ApiError, ApiError,
WeeklyAvailabilityResponse,
AvailabilityConfig,
CheckDateAvailabilityResponse,
AvailabilityOverview,
SelectedSlot,
} from "@/lib/models/appointments"; } from "@/lib/models/appointments";
// Helper function to extract error message from API response // 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) { if (!input.first_name || !input.last_name || !input.email) {
throw new Error("First name, last name, and email are required"); 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"); // New API format: use selected_slots
} if (!input.selected_slots || input.selected_slots.length === 0) {
if (!input.preferred_time_slots || input.preferred_time_slots.length === 0) { throw new Error("At least one time slot must be selected");
throw new Error("At least one preferred time slot is required");
} }
// Validate date format (YYYY-MM-DD) // Validate and clean selected_slots to ensure all have day and time_slot
const dateRegex = /^\d{4}-\d{2}-\d{2}$/; // This filters out any invalid slots and ensures proper format
for (const date of input.preferred_dates) { const validSlots: SelectedSlot[] = input.selected_slots
if (!dateRegex.test(date)) { .filter((slot, index) => {
throw new Error(`Invalid date format: ${date}. Expected YYYY-MM-DD format.`); // 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 // Limit field lengths to prevent database errors (100 char limit for all string fields)
const validTimeSlots = ["morning", "afternoon", "evening"]; // Truncate all string fields BEFORE trimming to handle edge cases
for (const slot of input.preferred_time_slots) { const firstName = input.first_name ? String(input.first_name).trim().substring(0, 100) : '';
if (!validTimeSlots.includes(slot)) { const lastName = input.last_name ? String(input.last_name).trim().substring(0, 100) : '';
throw new Error(`Invalid time slot: ${slot}. Must be one of: ${validTimeSlots.join(", ")}`); 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 // Build payload with only the fields the API expects - no extra fields
// Only include fields that the API accepts - no jitsi_room_id or other fields
const payload: { const payload: {
first_name: string; first_name: string;
last_name: string; last_name: string;
email: string; email: string;
preferred_dates: string[]; selected_slots: Array<{ day: number; time_slot: string }>;
preferred_time_slots: string[];
phone?: string; phone?: string;
reason?: string; reason?: string;
} = { } = {
first_name: input.first_name.trim(), first_name: firstName,
last_name: input.last_name.trim(), last_name: lastName,
email: input.email.trim().toLowerCase(), email: email,
preferred_dates: input.preferred_dates, selected_slots: validSlots.map(slot => ({
preferred_time_slots: input.preferred_time_slots, day: Number(slot.day),
time_slot: String(slot.time_slot).toLowerCase().trim(),
})),
}; };
// Only add optional fields if they have values // Only add optional fields if they have values (and are within length limits)
if (input.phone && input.phone.trim()) { if (phone && phone.length > 0 && phone.length <= 100) {
payload.phone = input.phone.trim(); payload.phone = phone;
} }
if (input.reason && input.reason.trim()) { if (reason && reason.length > 0 && reason.length <= 100) {
payload.reason = input.reason.trim(); payload.reason = reason;
} }
// Log the payload for debugging // Final validation: ensure all string fields in payload are exactly 100 chars or less
console.log("Creating appointment with payload:", JSON.stringify(payload, null, 2)); // This is a safety check to prevent any encoding or serialization issues
console.log("API endpoint:", API_ENDPOINTS.meetings.createAppointment); 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, { const response = await fetch(API_ENDPOINTS.meetings.createAppointment, {
method: "POST", method: "POST",
@ -114,7 +150,7 @@ export async function createAppointment(
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`, Authorization: `Bearer ${tokens.access}`,
}, },
body: JSON.stringify(payload), body: JSON.stringify(finalPayload),
}); });
// Read response text first (can only be read once) // Read response text first (can only be read once)
@ -131,14 +167,6 @@ export async function createAppointment(
} }
data = JSON.parse(responseText); data = JSON.parse(responseText);
} catch (e) { } 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'}`); throw new Error(`Server error (${response.status}): ${response.statusText || 'Invalid response format'}`);
} }
} else { } 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); throw new Error(errorMessage);
} }
@ -176,7 +195,27 @@ export async function createAppointment(
throw new Error(errorMessage); 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) { if (data.appointment) {
return data.appointment; return data.appointment;
} }
@ -188,8 +227,9 @@ export async function createAppointment(
return data as unknown as Appointment; 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<AvailableDatesResponse> { export async function getAvailableDates(): Promise<AvailableDatesResponse> {
try {
const response = await fetch(API_ENDPOINTS.meetings.availableDates, { const response = await fetch(API_ENDPOINTS.meetings.availableDates, {
method: "GET", method: "GET",
headers: { headers: {
@ -197,11 +237,29 @@ export async function getAvailableDates(): Promise<AvailableDatesResponse> {
}, },
}); });
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) { if (!response.ok) {
const errorMessage = extractErrorMessage(data as unknown as ApiError); // Return empty response instead of throwing - this endpoint is optional
throw new Error(errorMessage); return {
dates: [],
};
} }
// If API returns array directly, wrap it in response object // If API returns array directly, wrap it in response object
@ -212,6 +270,95 @@ export async function getAvailableDates(): Promise<AvailableDatesResponse> {
} }
return data as AvailableDatesResponse; 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<WeeklyAvailabilityResponse> {
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<AvailabilityConfig> {
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<CheckDateAvailabilityResponse> {
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<AvailabilityOverview> {
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) // List appointments (Admin sees all, users see their own)
@ -234,23 +381,49 @@ export async function listAppointments(email?: string): Promise<Appointment[]> {
}, },
}); });
const data = await response.json(); const responseText = await response.text();
if (!response.ok) { 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); 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 // 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)) { if (Array.isArray(data)) {
return data; return data;
} }
// Handle wrapped responses (if any)
if (data && typeof data === 'object') {
if (data.appointments && Array.isArray(data.appointments)) { if (data.appointments && Array.isArray(data.appointments)) {
return data.appointments; return data.appointments;
} }
if (data.results && Array.isArray(data.results)) { if (data.results && Array.isArray(data.results)) {
return 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 []; return [];
@ -398,13 +571,6 @@ export async function scheduleAppointment(
errorMessage = `Server error: ${response.status} ${response.statusText || 'Unknown error'}`; 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); throw new Error(errorMessage);
} }
@ -449,37 +615,41 @@ export async function rejectAppointment(
return data as unknown as Appointment; 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<AdminAvailability | null> { export async function getPublicAvailability(): Promise<AdminAvailability | null> {
try { try {
const response = await fetch(API_ENDPOINTS.meetings.adminAvailability, { // Use weekly availability endpoint which is public
method: "GET", const weeklyAvailability = await getWeeklyAvailability();
headers: {
"Content-Type": "application/json", // Normalize to array format
}, const weekArray = Array.isArray(weeklyAvailability)
}); ? weeklyAvailability
: (weeklyAvailability as any).week || [];
if (!response.ok) {
if (!weekArray || weekArray.length === 0) {
return null; return null;
} }
const data: any = await response.json(); // Convert weekly availability to AdminAvailability format
const availabilitySchedule: Record<string, string[]> = {};
const availableDays: number[] = [];
const availableDaysDisplay: string[] = [];
// Handle both string and array formats for available_days weekArray.forEach((day: any) => {
let availableDays: number[] = []; if (day.is_available && day.available_slots && day.available_slots.length > 0) {
if (typeof data.available_days === 'string') { availabilitySchedule[day.day.toString()] = day.available_slots;
try { availableDays.push(day.day);
availableDays = JSON.parse(data.available_days); availableDaysDisplay.push(day.day_name);
} catch {
availableDays = data.available_days.split(',').map((d: string) => parseInt(d.trim())).filter((d: number) => !isNaN(d));
} }
} else if (Array.isArray(data.available_days)) { });
availableDays = data.available_days;
}
return { return {
available_days: availableDays, 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; } as AdminAvailability;
} catch (error) { } catch (error) {
return null; return null;
@ -509,7 +679,67 @@ export async function getAdminAvailability(): Promise<AdminAvailability> {
throw new Error(errorMessage); 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<string, string[]>;
// Map numeric indices to string names (in case API returns numeric indices)
const numberToTimeSlot: Record<number, string> = {
0: 'morning',
1: 'afternoon',
2: 'evening',
};
// Parse if it's a string, otherwise use as-is
let rawSchedule: Record<string, number[] | string[]>;
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[] = []; let availableDays: number[] = [];
if (typeof data.available_days === 'string') { if (typeof data.available_days === 'string') {
try { try {
@ -538,14 +768,69 @@ export async function updateAdminAvailability(
throw new Error("Authentication required."); throw new Error("Authentication required.");
} }
// Ensure available_days is an array of numbers // Prepare payload using new format (availability_schedule)
const payload = { // API expects availability_schedule as an object with string keys (day numbers) and string arrays (time slot names)
available_days: Array.isArray(input.available_days) // 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<string, string[]> = {};
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<string, string[]> = {};
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.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", method: "PUT",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -554,26 +839,251 @@ export async function updateAdminAvailability(
body: JSON.stringify(payload), 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; 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) { } catch (parseError) {
// If response is not JSON, use status text throw new Error(`Server error (${response.status}): Invalid JSON response format`);
throw new Error(response.statusText || "Failed to update availability"); }
} 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[^>]*>(.*?)<\/title>/i);
const h1Match = responseText.match(/<h1[^>]*>(.*?)<\/h1>/i);
// Try to find the actual error traceback in <pre> tags (Django debug pages)
const tracebackMatch = responseText.match(/<pre[^>]*class="[^"]*traceback[^"]*"[^>]*>([\s\S]*?)<\/pre>/i) ||
responseText.match(/<pre[^>]*>([\s\S]*?)<\/pre>/i);
// Extract the actual error type and message
const errorTypeMatch = responseText.match(/<h2[^>]*>(.*?)<\/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) { if (!response.ok) {
const errorMessage = extractErrorMessage(data as unknown as ApiError); const errorMessage = extractErrorMessage(data as unknown as ApiError);
console.error("Availability update error:", {
status: response.status, // Build detailed error message
statusText: response.statusText, let detailedError = `Server error (${response.status}): `;
data, if (data && typeof data === 'object') {
payload if (data.detail) {
}); detailedError += Array.isArray(data.detail) ? data.detail.join(", ") : String(data.detail);
throw new Error(errorMessage); } 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<string, string[]>;
// Map numeric indices to string names (in case API returns numeric indices)
const numberToTimeSlot: Record<number, string> = {
0: 'morning',
1: 'afternoon',
2: 'evening',
};
// Parse if it's a string, otherwise use as-is
let rawSchedule: Record<string, number[] | string[]>;
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[] = []; let availableDays: number[] = [];
if (typeof data.available_days === 'string') { if (typeof data.available_days === 'string') {
try { try {
@ -619,28 +1129,49 @@ export async function getAppointmentStats(): Promise<AppointmentStats> {
} }
// Get user appointment stats // Get user appointment stats
export async function getUserAppointmentStats(): Promise<UserAppointmentStats> { export async function getUserAppointmentStats(email: string): Promise<UserAppointmentStats> {
const tokens = getStoredTokens(); const tokens = getStoredTokens();
if (!tokens.access) { if (!tokens.access) {
throw new Error("Authentication required."); 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, { const response = await fetch(API_ENDPOINTS.meetings.userAppointmentStats, {
method: "GET", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`, Authorization: `Bearer ${tokens.access}`,
}, },
body: JSON.stringify({ email }),
}); });
const data: UserAppointmentStats = await response.json(); const responseText = await response.text();
if (!response.ok) { 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); 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; return data;
} }

View File

@ -24,6 +24,10 @@ export const API_ENDPOINTS = {
userAppointments: `${API_BASE_URL}/meetings/user/appointments/`, userAppointments: `${API_BASE_URL}/meetings/user/appointments/`,
userAppointmentStats: `${API_BASE_URL}/meetings/user/appointments/stats/`, userAppointmentStats: `${API_BASE_URL}/meetings/user/appointments/stats/`,
adminAvailability: `${API_BASE_URL}/meetings/admin/availability/`, 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; } as const;

View File

@ -7,9 +7,10 @@ export interface Appointment {
email: string; email: string;
phone?: string; phone?: string;
reason?: string; reason?: string;
preferred_dates: string[]; // YYYY-MM-DD format preferred_dates?: string; // YYYY-MM-DD format (legacy) - API returns as string, not array
preferred_time_slots: string[]; // "morning", "afternoon", "evening" preferred_time_slots?: string; // "morning", "afternoon", "evening" (legacy) - API returns as string
status: "pending_review" | "scheduled" | "rejected" | "completed"; selected_slots?: SelectedSlot[]; // New format: day-time combinations
status: "pending_review" | "scheduled" | "rejected" | "completed" | "cancelled";
created_at: string; created_at: string;
updated_at: string; updated_at: string;
scheduled_datetime?: string; scheduled_datetime?: string;
@ -17,9 +18,28 @@ export interface Appointment {
rejection_reason?: string; rejection_reason?: string;
jitsi_meet_url?: string; jitsi_meet_url?: string;
jitsi_room_id?: string; jitsi_room_id?: string;
has_jitsi_meeting?: boolean; has_jitsi_meeting?: boolean | string;
can_join_meeting?: boolean; can_join_meeting?: boolean | string;
meeting_status?: 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 { export interface AppointmentResponse {
@ -36,14 +56,74 @@ export interface AppointmentsListResponse {
} }
export interface AvailableDatesResponse { 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?: number[]; // 0-6 (Monday-Sunday)
available_days_display?: string[]; 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<string, string>; // {"0": "Monday", ...}
time_slots: Record<string, string>; // {"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 { export interface AdminAvailability {
available_days: number[]; // 0-6 (Monday-Sunday) available_days?: number[]; // 0-6 (Monday-Sunday) (legacy)
available_days_display: string[]; available_days_display?: string[];
availability_schedule?: Record<string, string[]>; // {"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 { export interface AppointmentStats {
@ -62,6 +142,7 @@ export interface UserAppointmentStats {
rejected: number; rejected: number;
completed: number; completed: number;
completion_rate: number; completion_rate: number;
email?: string;
} }
export interface JitsiMeetingInfo { export interface JitsiMeetingInfo {

View File

@ -1,19 +1,37 @@
import { z } from "zod"; 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<typeof selectedSlotSchema>;
// Create Appointment Schema (updated to use selected_slots)
export const createAppointmentSchema = z.object({ export const createAppointmentSchema = z.object({
first_name: z.string().min(1, "First name is required"), 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"), 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"), 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 preferred_dates: z
.array(z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format")) .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 preferred_time_slots: z
.array(z.enum(["morning", "afternoon", "evening"])) .array(z.enum(["morning", "afternoon", "evening"]))
.min(1, "At least one preferred time slot is required"), .optional(),
phone: z.string().optional(), }).refine(
reason: z.string().optional(), (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<typeof createAppointmentSchema>; export type CreateAppointmentInput = z.infer<typeof createAppointmentSchema>;
@ -32,11 +50,18 @@ export const rejectAppointmentSchema = z.object({
export type RejectAppointmentInput = z.infer<typeof rejectAppointmentSchema>; export type RejectAppointmentInput = z.infer<typeof rejectAppointmentSchema>;
// Update Admin Availability Schema // Update Admin Availability Schema (updated to use availability_schedule)
export const updateAvailabilitySchema = z.object({ 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 available_days: z
.array(z.number().int().min(0).max(6)) .array(z.number().int().min(0).max(6))
.min(1, "At least one day must be selected"), .optional(),
}); });
export type UpdateAvailabilityInput = z.infer<typeof updateAvailabilitySchema>; export type UpdateAvailabilityInput = z.infer<typeof updateAvailabilitySchema>;

View File

@ -119,7 +119,6 @@ export async function encryptValue(value: string): Promise<string> {
const binaryString = String.fromCharCode(...combined); const binaryString = String.fromCharCode(...combined);
return btoa(binaryString); return btoa(binaryString);
} catch (error) { } catch (error) {
console.error("Encryption error:", error);
// If encryption fails, return original value (graceful degradation) // If encryption fails, return original value (graceful degradation)
return value; return value;
} }
@ -152,7 +151,6 @@ export async function decryptValue(encryptedValue: string): Promise<string> {
const decoder = new TextDecoder(); const decoder = new TextDecoder();
return decoder.decode(decrypted); return decoder.decode(decrypted);
} catch (error) { } catch (error) {
console.error("Decryption error:", error);
// If decryption fails, try to return as-is (might be unencrypted legacy data) // If decryption fails, try to return as-is (might be unencrypted legacy data)
return encryptedValue; return encryptedValue;
} }
@ -191,7 +189,6 @@ export async function decryptUserData(user: any): Promise<any> {
decrypted[field] = await decryptValue(String(decrypted[field])); decrypted[field] = await decryptValue(String(decrypted[field]));
} catch (error) { } catch (error) {
// If decryption fails, keep original value (might be unencrypted) // 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<any> {
try { try {
decrypted[field] = await decryptValue(decrypted[field]); decrypted[field] = await decryptValue(decrypted[field]);
} catch (error) { } 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) // If not encrypted, keep as-is (backward compatibility)