Compare commits

...

4 Commits

20 changed files with 2662 additions and 923 deletions

View File

@ -30,8 +30,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { DatePicker } from "@/components/DatePicker";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ScheduleAppointmentDialog } from "@/components/ScheduleAppointmentDialog";
import { toast } from "sonner";
import type { Appointment } from "@/lib/models/appointments";
@ -62,7 +61,6 @@ export default function AppointmentDetailPage() {
const data = await getAppointmentDetail(appointmentId);
setAppointment(data);
} catch (error) {
console.error("Failed to fetch appointment details:", error);
toast.error("Failed to load appointment details");
router.push("/admin/booking");
} finally {
@ -139,10 +137,6 @@ export default function AppointmentDetailPage() {
return status.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase());
};
const timeSlots = Array.from({ length: 24 }, (_, i) => {
const hour = i.toString().padStart(2, "0");
return `${hour}:00`;
});
const handleSchedule = async () => {
if (!appointment || !scheduledDate) return;
@ -165,7 +159,6 @@ export default function AppointmentDetailPage() {
const updated = await getAppointmentDetail(appointment.id);
setAppointment(updated);
} catch (error: any) {
console.error("Failed to schedule appointment:", error);
toast.error(error.message || "Failed to schedule appointment");
} finally {
setIsScheduling(false);
@ -188,7 +181,6 @@ export default function AppointmentDetailPage() {
const updated = await getAppointmentDetail(appointment.id);
setAppointment(updated);
} catch (error: any) {
console.error("Failed to reject appointment:", error);
toast.error(error.message || "Failed to reject appointment");
} finally {
setIsRejecting(false);
@ -376,7 +368,7 @@ export default function AppointmentDetailPage() {
)}
{/* Preferred Dates & Times */}
{(appointment.preferred_dates?.length > 0 || appointment.preferred_time_slots?.length > 0) && (
{((appointment.preferred_dates && appointment.preferred_dates.length > 0) || (appointment.preferred_time_slots && appointment.preferred_time_slots.length > 0)) && (
<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 ${isDark ? "text-white" : "text-gray-900"}`}>
@ -384,37 +376,53 @@ export default function AppointmentDetailPage() {
</h2>
</div>
<div className="p-6 space-y-6">
{appointment.preferred_dates && appointment.preferred_dates.length > 0 && (
{appointment.preferred_dates && (
<div>
<p className={`text-sm font-medium mb-3 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Preferred Dates
</p>
<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
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"}`}
>
{formatShortDate(date)}
</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>
)}
{appointment.preferred_time_slots && appointment.preferred_time_slots.length > 0 && (
{appointment.preferred_time_slots && (
<div>
<p className={`text-sm font-medium mb-3 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Preferred Time Slots
</p>
<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
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"}`}
>
{slot}
</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>
)}
@ -422,6 +430,64 @@ export default function AppointmentDetailPage() {
</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 */}
{appointment.reason && (
<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 && (
<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">
{appointment.can_join_meeting ? (
<a
href={appointment.jitsi_meet_url}
target="_blank"
@ -593,6 +660,15 @@ export default function AppointmentDetailPage() {
<Video className="w-5 h-5" />
Join Meeting
</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>
)}
@ -600,140 +676,21 @@ export default function AppointmentDetailPage() {
</div>
</main>
{/* Google Meet Style Schedule Dialog */}
<Dialog open={scheduleDialogOpen} onOpenChange={setScheduleDialogOpen}>
<DialogContent className={`max-w-3xl ${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"}`}>
Set date and time for {appointment.first_name} {appointment.last_name}'s 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">
<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>
{/* Schedule Appointment Dialog */}
<ScheduleAppointmentDialog
open={scheduleDialogOpen}
onOpenChange={setScheduleDialogOpen}
appointment={appointment}
scheduledDate={scheduledDate}
setScheduledDate={setScheduledDate}
scheduledTime={scheduledTime}
setScheduledTime={setScheduledTime}
scheduledDuration={scheduledDuration}
setScheduledDuration={setScheduledDuration}
onSchedule={handleSchedule}
isScheduling={isScheduling}
isDark={isDark}
/>
{/* Reject Appointment Dialog */}
<Dialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>

View File

@ -27,8 +27,7 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { DatePicker } from "@/components/DatePicker";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ScheduleAppointmentDialog } from "@/components/ScheduleAppointmentDialog";
import { toast } from "sonner";
import type { Appointment } from "@/lib/models/appointments";
@ -50,7 +49,13 @@ export default function Booking() {
const isDark = theme === "dark";
// Availability management
const { adminAvailability, isLoadingAdminAvailability, updateAvailability, isUpdatingAvailability, refetchAdminAvailability } = useAppointments();
const {
adminAvailability,
isLoadingAdminAvailability,
updateAvailability,
isUpdatingAvailability,
refetchAdminAvailability,
} = useAppointments();
const [selectedDays, setSelectedDays] = useState<number[]>([]);
const [availabilityDialogOpen, setAvailabilityDialogOpen] = useState(false);
const [dayTimeSlots, setDayTimeSlots] = useState<Record<number, string[]>>({});
@ -65,22 +70,26 @@ export default function Booking() {
{ value: 6, label: "Sunday" },
];
// Load time slots from localStorage on mount
useEffect(() => {
const savedTimeSlots = localStorage.getItem("adminAvailabilityTimeSlots");
if (savedTimeSlots) {
try {
const parsed = JSON.parse(savedTimeSlots);
setDayTimeSlots(parsed);
} catch (error) {
console.error("Failed to parse saved time slots:", error);
}
}
}, []);
// Time slots will be loaded from API in the useEffect below
// Initialize selected days and time slots when availability is loaded
useEffect(() => {
if (adminAvailability?.available_days) {
// Try new format first (availability_schedule)
if (adminAvailability?.availability_schedule) {
const schedule = adminAvailability.availability_schedule;
const days = Object.keys(schedule).map(Number);
setSelectedDays(days);
// Convert schedule format to dayTimeSlots format
const initialSlots: Record<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);
// Load saved time slots or use defaults
const savedTimeSlots = localStorage.getItem("adminAvailabilityTimeSlots");
@ -91,18 +100,28 @@ export default function Booking() {
const parsed = JSON.parse(savedTimeSlots);
// Only use saved slots for days that are currently available
adminAvailability.available_days.forEach((day) => {
initialSlots[day] = parsed[day] || ["morning", "lunchtime", "afternoon"];
// Map old time slot names to new ones
const oldSlots = parsed[day] || [];
initialSlots[day] = oldSlots.map((slot: string) => {
if (slot === "lunchtime") return "afternoon";
if (slot === "afternoon") return "evening";
return slot;
}).filter((slot: string) => ["morning", "afternoon", "evening"].includes(slot));
// If no valid slots after mapping, use defaults
if (initialSlots[day].length === 0) {
initialSlots[day] = ["morning", "afternoon"];
}
});
} catch (error) {
// If parsing fails, use defaults
adminAvailability.available_days.forEach((day) => {
initialSlots[day] = ["morning", "lunchtime", "afternoon"];
initialSlots[day] = ["morning", "afternoon"];
});
}
} else {
// No saved slots, use defaults
adminAvailability.available_days.forEach((day) => {
initialSlots[day] = ["morning", "lunchtime", "afternoon"];
initialSlots[day] = ["morning", "afternoon"];
});
}
@ -112,8 +131,8 @@ export default function Booking() {
const timeSlotOptions = [
{ value: "morning", label: "Morning" },
{ value: "lunchtime", label: "Lunchtime" },
{ value: "afternoon", label: "Evening" },
{ value: "afternoon", label: "Lunchtime" },
{ value: "evening", label: "Evening" },
];
const handleDayToggle = (day: number) => {
@ -126,7 +145,7 @@ export default function Booking() {
if (!prev.includes(day) && !dayTimeSlots[day]) {
setDayTimeSlots((prevSlots) => ({
...prevSlots,
[day]: ["morning", "lunchtime", "afternoon"],
[day]: ["morning", "afternoon"],
}));
}
@ -170,14 +189,44 @@ export default function Booking() {
toast.error(`Please select at least one time slot for ${daysOfWeek.find(d => d.value === day)?.label}`);
return;
}
// Validate time slots are valid
const validSlots = ["morning", "afternoon", "evening"];
const invalidSlots = timeSlots.filter(slot => !validSlots.includes(slot));
if (invalidSlots.length > 0) {
toast.error(`Invalid time slots: ${invalidSlots.join(", ")}. Only morning, afternoon, and evening are allowed.`);
return;
}
}
try {
// Ensure selectedDays is an array of numbers
const daysToSave = selectedDays.map(day => Number(day)).sort();
await updateAvailability({ available_days: daysToSave });
// Build availability_schedule format: {"0": ["morning", "evening"], "1": ["afternoon"]}
const availabilitySchedule: Record<string, ("morning" | "afternoon" | "evening")[]> = {};
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));
toast.success("Availability updated successfully!");
@ -187,21 +236,40 @@ export default function Booking() {
}
setAvailabilityDialogOpen(false);
} catch (error) {
console.error("Failed to update availability:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to update availability";
toast.error(errorMessage);
toast.error(`Failed to update availability: ${errorMessage}`, {
duration: 5000,
});
}
};
const handleOpenAvailabilityDialog = () => {
if (adminAvailability?.available_days) {
// Try new format first (availability_schedule)
if (adminAvailability?.availability_schedule) {
const schedule = adminAvailability.availability_schedule;
const days = Object.keys(schedule).map(Number);
setSelectedDays(days);
// Convert schedule format to dayTimeSlots format
const initialSlots: Record<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);
// Initialize time slots for each day
const initialSlots: Record<number, string[]> = {};
adminAvailability.available_days.forEach((day) => {
initialSlots[day] = dayTimeSlots[day] || ["morning", "lunchtime", "afternoon"];
initialSlots[day] = dayTimeSlots[day] || ["morning", "afternoon"];
});
setDayTimeSlots(initialSlots);
} else {
// No existing availability, start fresh
setSelectedDays([]);
setDayTimeSlots({});
}
setAvailabilityDialogOpen(true);
};
@ -213,7 +281,6 @@ export default function Booking() {
const data = await listAppointments();
setAppointments(data || []);
} catch (error) {
console.error("Failed to fetch appointments:", error);
toast.error("Failed to load appointments. Please try again.");
setAppointments([]);
} finally {
@ -337,7 +404,6 @@ export default function Booking() {
const data = await listAppointments();
setAppointments(data || []);
} catch (error) {
console.error("Failed to schedule appointment:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to schedule appointment";
toast.error(errorMessage);
} finally {
@ -363,7 +429,6 @@ export default function Booking() {
const data = await listAppointments();
setAppointments(data || []);
} catch (error) {
console.error("Failed to reject appointment:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to reject appointment";
toast.error(errorMessage);
} finally {
@ -371,14 +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(
(appointment) =>
@ -442,34 +499,70 @@ export default function Booking() {
<h3 className={`text-sm font-semibold mb-3 ${isDark ? "text-white" : "text-gray-900"}`}>
Weekly Availability
</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">
{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>
);
})}
{(() => {
// Try new format first
if (adminAvailability.availability_schedule) {
return Object.keys(adminAvailability.availability_schedule).map((dayKey) => {
const dayNum = parseInt(dayKey);
const dayName = daysOfWeek.find(d => d.value === dayNum)?.label || `Day ${dayNum}`;
const timeSlots = adminAvailability.availability_schedule![dayKey] || [];
const slotLabels = timeSlots.map((slot: string) => {
const option = timeSlotOptions.find(opt => opt.value === slot);
return option ? option.label : slot;
});
return (
<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>
);
});
}
// 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>
) : (
<p className={`text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}>
@ -586,13 +679,19 @@ export default function Booking() {
</span>
</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"}`}>
{appointment.preferred_dates && appointment.preferred_dates.length > 0 ? (
{appointment.preferred_dates ? (
<div className="flex flex-col gap-1">
{appointment.preferred_dates.slice(0, 2).map((date, idx) => (
<span key={idx}>{formatDate(date)}</span>
))}
{appointment.preferred_dates.length > 2 && (
<span className="text-xs">+{appointment.preferred_dates.length - 2} more</span>
{Array.isArray(appointment.preferred_dates) ? (
<>
{(appointment.preferred_dates as string[]).slice(0, 2).map((date, idx) => (
<span key={idx}>{formatDate(date)}</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>
) : (
@ -655,103 +754,27 @@ export default function Booking() {
</main>
{/* Schedule Appointment Dialog */}
<Dialog open={scheduleDialogOpen} onOpenChange={setScheduleDialogOpen}>
<DialogContent className={`${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<DialogHeader>
<DialogTitle className={isDark ? "text-white" : "text-gray-900"}>
Schedule Appointment
</DialogTitle>
<DialogDescription className={isDark ? "text-gray-400" : "text-gray-500"}>
{selectedAppointment && (
<>Schedule appointment for {selectedAppointment.first_name} {selectedAppointment.last_name}</>
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
Date *
</label>
<DatePicker
date={scheduledDate}
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>
<ScheduleAppointmentDialog
open={scheduleDialogOpen}
onOpenChange={(open) => {
setScheduleDialogOpen(open);
if (!open) {
setScheduledDate(undefined);
setScheduledTime("09:00");
setScheduledDuration(60);
}
}}
appointment={selectedAppointment}
scheduledDate={scheduledDate}
setScheduledDate={setScheduledDate}
scheduledTime={scheduledTime}
setScheduledTime={setScheduledTime}
scheduledDuration={scheduledDuration}
setScheduledDuration={setScheduledDuration}
onSchedule={handleSchedule}
isScheduling={isScheduling}
isDark={isDark}
/>
{/* Reject Appointment Dialog */}
<Dialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>
@ -836,7 +859,7 @@ export default function Booking() {
Available Days & Times *
</label>
<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>
</div>

View File

@ -132,7 +132,6 @@ export default function Dashboard() {
trends,
});
} catch (error) {
console.error("Failed to fetch dashboard stats:", error);
toast.error("Failed to load dashboard statistics");
// Set default values on error
setStats({
@ -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"}`}>
<Icon className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? "text-gray-200" : "text-gray-600"}`} />
</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)}`}>
{card.trendUp ? (
<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"}`}>
{card.value}
</p>
{card.showTrend !== false && (
<p className={`text-xs ${isDark ? "text-gray-400" : "text-gray-500"}`}>
vs last month
</p>
)}
<p className={`text-xs ${isDark ? "text-gray-400" : "text-gray-500"}`}>
vs last month
</p>
</div>
</div>
);

View File

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

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { useState, useEffect, useMemo, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { useAppTheme } from "@/components/ThemeProvider";
import { Input } from "@/components/ui/input";
@ -35,8 +35,6 @@ import { useAuth } from "@/hooks/useAuth";
import { useAppointments } from "@/hooks/useAppointments";
import { toast } from "sonner";
import type { Appointment } from "@/lib/models/appointments";
import { getPublicAvailability } from "@/lib/actions/appointments";
import type { AdminAvailability } from "@/lib/models/appointments";
interface User {
ID: number;
@ -83,14 +81,21 @@ export default function BookNowPage() {
const { theme } = useAppTheme();
const isDark = theme === "dark";
const { isAuthenticated, logout } = useAuth();
const { create, isCreating, availableDates, availableDatesResponse, isLoadingAvailableDates } = useAppointments();
const {
create,
isCreating,
weeklyAvailability,
isLoadingWeeklyAvailability,
availabilityOverview,
isLoadingAvailabilityOverview,
availabilityConfig,
} = useAppointments();
const [formData, setFormData] = useState({
firstName: "",
lastName: "",
email: "",
phone: "",
preferredDays: [] as string[],
preferredTimes: [] as string[],
selectedSlots: [] as Array<{ day: number; time_slot: string }>, // New format
message: "",
});
const [booking, setBooking] = useState<Booking | null>(null);
@ -98,68 +103,97 @@ export default function BookNowPage() {
const [showLoginDialog, setShowLoginDialog] = useState(false);
const [showSignupDialog, setShowSignupDialog] = useState(false);
const [loginPrefillEmail, setLoginPrefillEmail] = useState<string | undefined>(undefined);
const [publicAvailability, setPublicAvailability] = useState<AdminAvailability | null>(null);
const [availableTimeSlots, setAvailableTimeSlots] = useState<Record<number, string[]>>({});
// Fetch public availability to get time slots
useEffect(() => {
const fetchAvailability = async () => {
try {
const availability = await getPublicAvailability();
if (availability) {
setPublicAvailability(availability);
// Try to get time slots from localStorage (if admin has set them)
// Note: This won't work for public users, but we can try
const savedTimeSlots = localStorage.getItem("adminAvailabilityTimeSlots");
if (savedTimeSlots) {
try {
const parsed = JSON.parse(savedTimeSlots);
setAvailableTimeSlots(parsed);
} catch (e) {
console.error("Failed to parse time slots:", e);
}
// Helper function to convert day name to day number (0-6)
const getDayNumber = (dayName: string): number => {
const dayMap: Record<string, number> = {
'monday': 0,
'tuesday': 1,
'wednesday': 2,
'thursday': 3,
'friday': 4,
'saturday': 5,
'sunday': 6,
};
return dayMap[dayName.toLowerCase()] ?? -1;
};
// Get available days from availability overview (primary) or weekly availability (fallback)
const availableDaysOfWeek = useMemo(() => {
// Try availability overview first (preferred)
if (availabilityOverview && availabilityOverview.available && availabilityOverview.next_available_dates && availabilityOverview.next_available_dates.length > 0) {
// Group by day name and get unique days with their slots from next_available_dates
const dayMap = new Map<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);
}
};
fetchAvailability();
}, []);
// Use available_days_display from API if available, otherwise extract from dates
const availableDaysOfWeek = useMemo(() => {
// If API provides available_days_display, use it directly
if (availableDatesResponse?.available_days_display && availableDatesResponse.available_days_display.length > 0) {
return availableDatesResponse.available_days_display;
});
// Convert Map values to array and sort by day number
return Array.from(dayMap.values())
.map(day => ({
day: day.day,
dayName: day.dayName,
availableSlots: Array.from(day.availableSlots),
}))
.sort((a, b) => a.day - b.day);
}
// Otherwise, extract from dates
if (!availableDates || availableDates.length === 0) {
return [];
// Fallback to weekly availability
if (weeklyAvailability) {
// Handle both array format and object with 'week' property
const weekArray = Array.isArray(weeklyAvailability)
? weeklyAvailability
: (weeklyAvailability as any)?.week;
if (weekArray && Array.isArray(weekArray)) {
return weekArray
.filter(day => {
const dayNum = Number(day.day);
return day.is_available &&
day.available_slots &&
Array.isArray(day.available_slots) &&
day.available_slots.length > 0 &&
!isNaN(dayNum) &&
dayNum >= 0 &&
dayNum <= 6;
})
.map(day => ({
day: Number(day.day),
dayName: day.day_name || 'Unknown',
availableSlots: day.available_slots || [],
}))
.sort((a, b) => a.day - b.day);
}
}
const daysSet = new Set<string>();
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
availableDates.forEach((dateStr: string) => {
try {
// Parse date string (YYYY-MM-DD format)
const [year, month, day] = dateStr.split('-').map(Number);
const date = new Date(year, month - 1, day);
if (!isNaN(date.getTime())) {
const dayIndex = date.getDay();
daysSet.add(dayNames[dayIndex]);
}
} catch (e) {
console.error('Invalid date:', dateStr, e);
}
});
// Return in weekday order (Monday first)
const weekdayOrder = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
return weekdayOrder.filter(day => daysSet.has(day));
}, [availableDates, availableDatesResponse]);
return [];
}, [availabilityOverview, weeklyAvailability]);
const handleLogout = () => {
logout();
@ -217,79 +251,89 @@ export default function BookNowPage() {
setError(null);
try {
if (formData.preferredDays.length === 0) {
setError("Please select at least one available day.");
return;
}
if (formData.preferredTimes.length === 0) {
setError("Please select at least one preferred time.");
return;
}
// Convert day names to dates (YYYY-MM-DD format)
// Get next occurrence of each selected day within the next 30 days
const today = new Date();
today.setHours(0, 0, 0, 0); // Reset to start of day
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const preferredDates: string[] = [];
// Get current slots from formData
const currentSlots = formData.selectedSlots || [];
formData.preferredDays.forEach((dayName) => {
const targetDayIndex = days.indexOf(dayName);
if (targetDayIndex === -1) {
console.warn(`Invalid day name: ${dayName}`);
// Check if slots are selected
if (!currentSlots || currentSlots.length === 0) {
setError("Please select at least one day and time slot combination by clicking on the time slot buttons.");
return;
}
// Prepare and validate slots - be very lenient
const validSlots = currentSlots
.map(slot => {
if (!slot) return null;
// Get day - handle any format
let dayNum: number;
if (typeof slot.day === 'number') {
dayNum = slot.day;
} else {
dayNum = parseInt(String(slot.day || 0), 10);
}
// Validate day
if (isNaN(dayNum) || dayNum < 0 || dayNum > 6) {
return null;
}
// Get time_slot - normalize
const timeSlot = String(slot.time_slot || '').trim().toLowerCase();
// Validate time_slot - accept morning, afternoon, evening
if (!timeSlot || !['morning', 'afternoon', 'evening'].includes(timeSlot)) {
return null;
}
return {
day: dayNum,
time_slot: timeSlot as "morning" | "afternoon" | "evening",
};
})
.filter((slot): slot is { day: number; time_slot: "morning" | "afternoon" | "evening" } => slot !== null);
// Final validation check
if (!validSlots || validSlots.length === 0) {
setError("Please select at least one day and time slot combination by clicking on the time slot buttons.");
return;
}
// Find the next occurrence of this day within the next 30 days
for (let i = 1; i <= 30; i++) {
const checkDate = new Date(today);
checkDate.setDate(today.getDate() + i);
if (checkDate.getDay() === targetDayIndex) {
const dateString = checkDate.toISOString().split("T")[0];
if (!preferredDates.includes(dateString)) {
preferredDates.push(dateString);
}
break; // Only take the first occurrence
}
}
});
// Sort dates
preferredDates.sort();
// Validate and limit field lengths to prevent database errors
const firstName = formData.firstName.trim().substring(0, 100);
const lastName = formData.lastName.trim().substring(0, 100);
const email = formData.email.trim().toLowerCase().substring(0, 100);
const phone = formData.phone ? formData.phone.trim().substring(0, 100) : undefined;
const reason = formData.message ? formData.message.trim().substring(0, 100) : undefined;
if (preferredDates.length === 0) {
setError("Please select at least one available day.");
// Validate required fields
if (!firstName || firstName.length === 0) {
setError("First name is required.");
return;
}
if (!lastName || lastName.length === 0) {
setError("Last name is required.");
return;
}
if (!email || email.length === 0) {
setError("Email address is required.");
return;
}
if (!email.includes('@')) {
setError("Please enter a valid email address.");
return;
}
// Map time slots - API expects "morning", "afternoon", "evening"
// Form has "morning", "lunchtime", "afternoon" (where "afternoon" label is "Evening")
const timeSlotMap: { [key: string]: "morning" | "afternoon" | "evening" } = {
morning: "morning",
lunchtime: "afternoon", // Map lunchtime to afternoon
afternoon: "evening", // Form's "afternoon" value (labeled "Evening") maps to API's "evening"
};
const preferredTimeSlots = formData.preferredTimes
.map((time) => timeSlotMap[time] || "morning")
.filter((time, index, self) => self.indexOf(time) === index) as ("morning" | "afternoon" | "evening")[]; // Remove duplicates
// Prepare request payload according to API spec
// Prepare payload with validated and limited fields
const payload = {
first_name: formData.firstName.trim(),
last_name: formData.lastName.trim(),
email: formData.email.trim().toLowerCase(),
preferred_dates: preferredDates,
preferred_time_slots: preferredTimeSlots,
...(formData.phone && formData.phone.trim() && { phone: formData.phone.trim() }),
...(formData.message && formData.message.trim() && { reason: formData.message.trim() }),
first_name: firstName,
last_name: lastName,
email: email,
selected_slots: validSlots,
...(phone && phone.length > 0 && { phone: phone }),
...(reason && reason.length > 0 && { reason: reason }),
};
// Validate payload before sending
console.log("Booking payload:", JSON.stringify(payload, null, 2));
// Call the actual API using the hook
const appointmentData = await create(payload);
@ -328,15 +372,11 @@ export default function BookNowPage() {
setBooking(bookingData);
toast.success("Appointment request submitted successfully! We'll review and get back to you soon.");
// Redirect to user dashboard after 3 seconds
setTimeout(() => {
router.push("/user/dashboard");
}, 3000);
// Stay on the booking page to show the receipt - no redirect
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to submit booking. Please try again.";
setError(errorMessage);
toast.error(errorMessage);
console.error("Booking error:", err);
}
};
@ -344,22 +384,50 @@ export default function BookNowPage() {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleDayToggle = (day: string) => {
// Handle slot selection (day + time slot combination)
const handleSlotToggle = (day: number, timeSlot: string) => {
setFormData((prev) => {
const days = prev.preferredDays.includes(day)
? prev.preferredDays.filter((d) => d !== day)
: [...prev.preferredDays, day];
return { ...prev, preferredDays: days };
const normalizedDay = Number(day);
const normalizedTimeSlot = String(timeSlot).toLowerCase().trim();
const currentSlots = prev.selectedSlots || [];
// Helper to check if two slots match
const slotsMatch = (slot1: { day: number; time_slot: string }, slot2: { day: number; time_slot: string }) => {
return Number(slot1.day) === Number(slot2.day) &&
String(slot1.time_slot).toLowerCase().trim() === String(slot2.time_slot).toLowerCase().trim();
};
const targetSlot = { day: normalizedDay, time_slot: normalizedTimeSlot };
// Check if this exact slot exists
const slotExists = currentSlots.some(slot => slotsMatch(slot, targetSlot));
if (slotExists) {
// Remove the slot
const newSlots = currentSlots.filter(slot => !slotsMatch(slot, targetSlot));
return {
...prev,
selectedSlots: newSlots,
};
} else {
// Add the slot
return {
...prev,
selectedSlots: [...currentSlots, targetSlot],
};
}
});
};
const handleTimeToggle = (time: string) => {
setFormData((prev) => {
const times = prev.preferredTimes.includes(time)
? prev.preferredTimes.filter((t) => t !== time)
: [...prev.preferredTimes, time];
return { ...prev, preferredTimes: times };
});
// Check if a slot is selected
const isSlotSelected = (day: number, timeSlot: string): boolean => {
const normalizedDay = Number(day);
const normalizedTimeSlot = String(timeSlot).toLowerCase().trim();
return (formData.selectedSlots || []).some(
slot => Number(slot.day) === normalizedDay &&
String(slot.time_slot).toLowerCase().trim() === normalizedTimeSlot
);
};
const formatDateTime = (dateString: string) => {
@ -473,77 +541,51 @@ export default function BookNowPage() {
<div className="px-4 sm:px-6 lg:px-12 pb-6 sm:pb-8 lg:pb-12">
{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="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'}`}>
<CheckCircle className={`w-8 h-8 ${isDark ? 'text-green-400' : 'text-green-600'}`} />
</div>
<div>
<h2 className={`text-2xl font-semibold mb-2 ${isDark ? 'text-white' : 'text-gray-900'}`}>
Booking Confirmed!
Booking Request Submitted!
</h2>
<p className={isDark ? 'text-gray-300' : 'text-gray-600'}>
Your appointment has been successfully booked.
<p className={`text-base ${isDark ? 'text-gray-300' : 'text-gray-600'}`}>
Your appointment request has been received.
</p>
</div>
<div className={`rounded-lg p-6 space-y-4 text-left ${isDark ? 'bg-gray-700/50' : 'bg-gray-50'}`}>
<div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Booking ID</p>
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>#{booking.ID}</p>
</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'}`}>
<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.user.first_name} {booking.user.last_name}
</p>
</div>
<div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Scheduled Time</p>
<p className={`text-base ${isDark ? 'text-white' : 'text-gray-900'}`}>{formatDateTime(booking.scheduled_at)}</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'}`}>
{booking.user.email}
</p>
</div>
<div>
<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 && (
{booking.user.phone && (
<div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Notes</p>
<p className={`text-base ${isDark ? 'text-white' : 'text-gray-900'}`}>{booking.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.user.phone}
</p>
</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
onClick={() => {
setBooking(null);
setFormData({
firstName: "",
lastName: "",
email: "",
phone: "",
preferredDays: [],
preferredTimes: [],
message: "",
});
}}
variant="outline"
>
Book Another Appointment
</Button>
<Button
onClick={() => router.push("/")}
onClick={() => router.back()}
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>
</div>
</div>
@ -580,6 +622,7 @@ export default function BookNowPage() {
onChange={(e) =>
handleChange("firstName", e.target.value)
}
maxLength={100}
required
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900 placeholder:text-gray-500'}`}
/>
@ -600,6 +643,7 @@ export default function BookNowPage() {
onChange={(e) =>
handleChange("lastName", e.target.value)
}
maxLength={100}
required
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900 placeholder:text-gray-500'}`}
/>
@ -620,6 +664,7 @@ export default function BookNowPage() {
placeholder="john.doe@example.com"
value={formData.email}
onChange={(e) => handleChange("email", e.target.value)}
maxLength={100}
required
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900 placeholder:text-gray-500'}`}
/>
@ -639,6 +684,7 @@ export default function BookNowPage() {
placeholder="+1 (555) 123-4567"
value={formData.phone}
onChange={(e) => handleChange("phone", e.target.value)}
maxLength={100}
required
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900 placeholder:text-gray-500'}`}
/>
@ -658,12 +704,12 @@ export default function BookNowPage() {
className={`text-sm font-medium flex items-center gap-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}
>
<Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-500'}`} />
Available Days *
Available Days & Times *
</label>
{isLoadingAvailableDates ? (
{(isLoadingWeeklyAvailability || isLoadingAvailabilityOverview) ? (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Loader2 className="w-4 h-4 animate-spin" />
Loading available days...
Loading availability...
</div>
) : availableDaysOfWeek.length === 0 ? (
<p className={`text-sm ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
@ -671,92 +717,77 @@ export default function BookNowPage() {
</p>
) : (
<>
<div className="flex flex-wrap gap-3">
{availableDaysOfWeek.map((day: string) => (
<label
key={day}
className={`flex items-center gap-2 cursor-pointer px-4 py-2 rounded-lg border transition-all ${
formData.preferredDays.includes(day)
<p className={`text-xs ${isDark ? 'text-gray-400' : 'text-gray-500'} mb-3`}>
Select one or more day-time combinations that work for you
</p>
<div className="space-y-4">
{availableDaysOfWeek.map((dayInfo, dayIndex) => {
// 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
? 'bg-rose-600 border-rose-500 text-white'
: 'bg-rose-500 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 hover:bg-rose-600'
: 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.preferredDays.includes(day)}
onChange={() => handleDayToggle(day)}
className="sr-only"
/>
<span className="text-sm font-medium">{day}</span>
</label>
))}
? '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 hover:bg-rose-50'
}`}
>
<span className="text-sm font-medium">
{timeSlotLabels[normalizedTimeSlot] || timeSlot}
</span>
</button>
);
})}
</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>
@ -775,6 +806,7 @@ export default function BookNowPage() {
placeholder="Tell us about any specific concerns or preferences..."
value={formData.message}
onChange={(e) => handleChange("message", e.target.value)}
maxLength={100}
className={`w-full rounded-md border px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-rose-500 focus-visible:border-rose-500 disabled:cursor-not-allowed disabled:opacity-50 ${isDark ? 'border-gray-600 bg-gray-700 text-white placeholder:text-gray-400 focus-visible:ring-rose-400 focus-visible:border-rose-400' : 'border-gray-300 bg-white text-gray-900 placeholder:text-gray-500'}`}
/>
</div>
@ -784,7 +816,7 @@ export default function BookNowPage() {
<Button
type="submit"
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"
>
{isCreating ? (
@ -804,19 +836,6 @@ export default function BookNowPage() {
</form>
</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 */}
{isAuthenticated && (
<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">
<Button
className="bg-gray-100 hover:bg-gray-50 shadow-md text-black"
onClick={() => router.push("/")}
onClick={() => router.back()}
>
<ArrowLeft className="mr-2" />
</Button>

View File

@ -1,6 +1,6 @@
"use client";
import { useMemo } from "react";
import { useMemo, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Calendar,
@ -21,23 +21,69 @@ import {
import Link from "next/link";
import { Navbar } from "@/components/Navbar";
import { useAppTheme } from "@/components/ThemeProvider";
import { useAppointments } from "@/hooks/useAppointments";
import { useAuth } from "@/hooks/useAuth";
import type { Appointment } from "@/lib/models/appointments";
import { listAppointments, getUserAppointmentStats } from "@/lib/actions/appointments";
import type { Appointment, UserAppointmentStats } from "@/lib/models/appointments";
import { toast } from "sonner";
export default function UserDashboard() {
const { theme } = useAppTheme();
const isDark = theme === "dark";
const { user } = useAuth();
const {
userAppointments,
userAppointmentStats,
isLoadingUserAppointments,
isLoadingUserStats,
refetchUserAppointments,
refetchUserStats,
} = useAppointments();
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<UserAppointmentStats | null>(null);
const [loadingStats, setLoadingStats] = useState(true);
// Fetch appointments using the same endpoint as admin booking table
useEffect(() => {
const fetchAppointments = async () => {
setLoading(true);
try {
const data = await listAppointments();
setAppointments(data || []);
} catch (error) {
toast.error("Failed to load appointments. Please try again.");
setAppointments([]);
} finally {
setLoading(false);
}
};
fetchAppointments();
}, []);
// Fetch stats from API using user email
useEffect(() => {
const fetchStats = async () => {
if (!user?.email) {
setLoadingStats(false);
return;
}
setLoadingStats(true);
try {
const statsData = await getUserAppointmentStats(user.email);
setStats(statsData);
} catch (error) {
toast.error("Failed to load appointment statistics.");
// Set default stats on error
setStats({
total_requests: 0,
pending_review: 0,
scheduled: 0,
rejected: 0,
completed: 0,
completion_rate: 0,
email: user.email,
});
} finally {
setLoadingStats(false);
}
};
fetchStats();
}, [user?.email]);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
@ -68,58 +114,67 @@ export default function UserDashboard() {
// Filter appointments by status
const upcomingAppointments = useMemo(() => {
return userAppointments.filter(
return appointments.filter(
(appointment) => appointment.status === "scheduled"
);
}, [userAppointments]);
}, [appointments]);
const pendingAppointments = useMemo(() => {
return appointments.filter(
(appointment) => appointment.status === "pending_review"
);
}, [appointments]);
const completedAppointments = useMemo(() => {
return userAppointments.filter(
return appointments.filter(
(appointment) => appointment.status === "completed"
);
}, [userAppointments]);
}, [appointments]);
const stats = userAppointmentStats || {
total_requests: 0,
pending_review: 0,
scheduled: 0,
rejected: 0,
completed: 0,
completion_rate: 0,
};
const rejectedAppointments = useMemo(() => {
return appointments.filter(
(appointment) => appointment.status === "rejected"
);
}, [appointments]);
const statCards = [
{
title: "Upcoming Appointments",
value: stats.scheduled,
icon: CalendarCheck,
trend: stats.scheduled > 0 ? `+${stats.scheduled}` : "0",
trendUp: true,
},
{
title: "Completed Sessions",
value: stats.completed || 0,
icon: CheckCircle2,
trend: stats.completed > 0 ? `+${stats.completed}` : "0",
trendUp: true,
},
{
title: "Total Appointments",
value: stats.total_requests,
icon: Calendar,
trend: `${Math.round(stats.completion_rate || 0)}%`,
trendUp: true,
},
{
title: "Pending Review",
value: stats.pending_review,
icon: Calendar,
trend: stats.pending_review > 0 ? `${stats.pending_review}` : "0",
trendUp: false,
},
];
// Sort appointments by created_at (newest first)
const allAppointments = useMemo(() => {
return [...appointments].sort((a, b) => {
const dateA = new Date(a.created_at).getTime();
const dateB = new Date(b.created_at).getTime();
return dateB - dateA;
});
}, [appointments]);
const loading = isLoadingUserAppointments || isLoadingUserStats;
// Use stats from API, fallback to calculated stats if API stats not available
const displayStats = useMemo(() => {
if (stats) {
return {
scheduled: stats.scheduled || 0,
completed: stats.completed || 0,
pending_review: stats.pending_review || 0,
rejected: stats.rejected || 0,
total_requests: stats.total_requests || 0,
completion_rate: stats.completion_rate || 0,
};
}
// Fallback: calculate from appointments if stats not loaded yet
const scheduled = appointments.filter(a => a.status === "scheduled").length;
const completed = appointments.filter(a => a.status === "completed").length;
const pending_review = appointments.filter(a => a.status === "pending_review").length;
const rejected = appointments.filter(a => a.status === "rejected").length;
const total_requests = appointments.length;
const completion_rate = total_requests > 0 ? (scheduled / total_requests) * 100 : 0;
return {
scheduled,
completed,
pending_review,
rejected,
total_requests,
completion_rate,
};
}, [stats, appointments]);
return (
<div className={`min-h-screen ${isDark ? 'bg-gray-900' : 'bg-gray-50'}`}>
@ -166,116 +221,278 @@ export default function UserDashboard() {
<>
{/* 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">
{statCards.map((card, index) => {
const Icon = card.icon;
return (
<div
key={index}
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'}`}>
<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
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'}`}>
<CalendarCheck 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.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>
{/* Upcoming Appointments Section */}
{upcomingAppointments.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'}`}>
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-gray-900'}`}>
Upcoming Appointments
</h2>
<div className="space-y-3">
{upcomingAppointments.map((appointment) => (
<div
key={appointment.id}
className={`border rounded-lg p-4 hover:shadow-md transition-shadow ${isDark ? 'border-gray-700 bg-gray-700/50' : 'border-gray-200'}`}
>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex-1">
{appointment.scheduled_datetime && (
<>
<div className="flex items-center gap-2 mb-2">
<Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
<span className={`font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
{formatDate(appointment.scheduled_datetime)}
</span>
{/* All Appointments Section */}
{allAppointments.length > 0 ? (
<div className={`rounded-lg border overflow-hidden ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}>
<div className={`px-4 sm:px-5 md:px-6 py-4 border-b ${isDark ? 'border-gray-700' : 'border-gray-200'}`}>
<h2 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
All Appointments
</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className={`${isDark ? "bg-gray-800 border-b border-gray-700" : "bg-gray-50 border-b border-gray-200"}`}>
<tr>
<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"}`}>
Appointment
</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"}`}>
Date & Time
</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"}`}>
Duration
</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 className="flex items-center gap-2 mb-2">
<Clock className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
<span className={isDark ? 'text-gray-300' : 'text-gray-700'}>
{formatTime(appointment.scheduled_datetime)}
</span>
{appointment.scheduled_duration && (
<span className={`text-sm font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
({appointment.scheduled_duration} minutes)
</span>
</td>
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap hidden md:table-cell">
{appointment.scheduled_datetime ? (
<>
<div className={`text-xs sm:text-sm ${isDark ? "text-white" : "text-gray-900"}`}>
{formatDate(appointment.scheduled_datetime)}
</div>
<div className={`text-xs sm:text-sm flex items-center gap-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
<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>
</>
)}
{appointment.reason && (
<p className={`text-sm mt-2 font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
{appointment.reason}
</p>
)}
</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>
))}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
) : !loading && (
@ -283,10 +500,10 @@ export default function UserDashboard() {
<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'}`} />
<p className={`text-lg font-medium mb-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
No Upcoming Appointments
No Appointments
</p>
<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>
<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">

View File

@ -58,7 +58,6 @@ export default function SettingsPage() {
phone: profile.phone_number || "",
});
} catch (error) {
console.error("Failed to fetch profile:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to load profile";
toast.error(errorMessage);
} finally {
@ -105,7 +104,6 @@ export default function SettingsPage() {
});
toast.success("Profile updated successfully");
} catch (error) {
console.error("Failed to update profile:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to update profile";
toast.error(errorMessage);
} finally {
@ -140,7 +138,6 @@ export default function SettingsPage() {
confirmPassword: "",
});
} catch (error) {
console.error("Failed to update password:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to update password";
toast.error(errorMessage);
} 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,
getAppointmentStats,
getJitsiMeetingInfo,
getWeeklyAvailability,
getAvailabilityConfig,
checkDateAvailability,
getAvailabilityOverview,
} from "@/lib/actions/appointments";
import type {
CreateAppointmentInput,
@ -29,29 +33,85 @@ import type {
UserAppointmentStats,
AvailableDatesResponse,
JitsiMeetingInfo,
WeeklyAvailabilityResponse,
AvailabilityConfig,
CheckDateAvailabilityResponse,
AvailabilityOverview,
} from "@/lib/models/appointments";
export function useAppointments() {
export function useAppointments(options?: {
enableAvailableDates?: boolean;
enableStats?: boolean;
enableConfig?: boolean;
enableWeeklyAvailability?: boolean;
enableOverview?: boolean;
}) {
const queryClient = useQueryClient();
const enableAvailableDates = options?.enableAvailableDates ?? false;
const enableStats = options?.enableStats ?? true;
const enableConfig = options?.enableConfig ?? true;
const enableWeeklyAvailability = options?.enableWeeklyAvailability ?? true;
const enableOverview = options?.enableOverview ?? true;
// Get available dates query
// Get available dates query (optional, disabled by default - using weekly_availability as primary source)
// Can be enabled when explicitly needed (e.g., on admin booking page)
const availableDatesQuery = useQuery<AvailableDatesResponse>({
queryKey: ["appointments", "available-dates"],
queryFn: () => getAvailableDates(),
enabled: enableAvailableDates, // Can be enabled when needed
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 0, // Don't retry failed requests
});
// Get weekly availability query
const weeklyAvailabilityQuery = useQuery<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
});
// List appointments query
const appointmentsQuery = useQuery<Appointment[]>({
queryKey: ["appointments", "list"],
queryFn: () => listAppointments(),
enabled: false, // Only fetch when explicitly called
queryFn: async () => {
const data = await listAppointments();
return data || [];
},
enabled: true, // Enable by default to fetch user appointments
staleTime: 30 * 1000, // 30 seconds
retry: 1, // Retry once on failure
refetchOnMount: true, // Always refetch when component mounts
});
// Get user appointments query
// Get user appointments query (disabled - using listAppointments instead)
const userAppointmentsQuery = useQuery<Appointment[]>({
queryKey: ["appointments", "user"],
queryFn: () => getUserAppointments(),
enabled: false, // Disabled - using listAppointments endpoint instead
staleTime: 30 * 1000, // 30 seconds
});
@ -78,10 +138,16 @@ export function useAppointments() {
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>({
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
});
@ -172,6 +238,9 @@ export function useAppointments() {
// Queries
availableDates: availableDatesQuery.data?.dates || [],
availableDatesResponse: availableDatesQuery.data,
weeklyAvailability: weeklyAvailabilityQuery.data,
availabilityConfig: availabilityConfigQuery.data,
availabilityOverview: availabilityOverviewQuery.data,
appointments: appointmentsQuery.data || [],
userAppointments: userAppointmentsQuery.data || [],
adminAvailability: adminAvailabilityQuery.data,
@ -180,6 +249,9 @@ export function useAppointments() {
// Query states
isLoadingAvailableDates: availableDatesQuery.isLoading,
isLoadingWeeklyAvailability: weeklyAvailabilityQuery.isLoading,
isLoadingAvailabilityConfig: availabilityConfigQuery.isLoading,
isLoadingAvailabilityOverview: availabilityOverviewQuery.isLoading,
isLoadingAppointments: appointmentsQuery.isLoading,
isLoadingUserAppointments: userAppointmentsQuery.isLoading,
isLoadingAdminAvailability: adminAvailabilityQuery.isLoading,
@ -188,12 +260,20 @@ export function useAppointments() {
// Query refetch functions
refetchAvailableDates: availableDatesQuery.refetch,
refetchWeeklyAvailability: weeklyAvailabilityQuery.refetch,
refetchAvailabilityConfig: availabilityConfigQuery.refetch,
refetchAvailabilityOverview: availabilityOverviewQuery.refetch,
refetchAppointments: appointmentsQuery.refetch,
refetchUserAppointments: userAppointmentsQuery.refetch,
refetchAdminAvailability: adminAvailabilityQuery.refetch,
refetchStats: appointmentStatsQuery.refetch,
refetchUserStats: userAppointmentStatsQuery.refetch,
// Helper functions
checkDateAvailability: async (date: string) => {
return await checkDateAvailability(date);
},
// Hooks for specific queries
useAppointmentDetail,
useJitsiMeetingInfo,

View File

@ -16,6 +16,11 @@ import type {
UserAppointmentStats,
JitsiMeetingInfo,
ApiError,
WeeklyAvailabilityResponse,
AvailabilityConfig,
CheckDateAvailabilityResponse,
AvailabilityOverview,
SelectedSlot,
} from "@/lib/models/appointments";
// Helper function to extract error message from API response
@ -55,58 +60,89 @@ export async function createAppointment(
if (!input.first_name || !input.last_name || !input.email) {
throw new Error("First name, last name, and email are required");
}
if (!input.preferred_dates || input.preferred_dates.length === 0) {
throw new Error("At least one preferred date is required");
}
if (!input.preferred_time_slots || input.preferred_time_slots.length === 0) {
throw new Error("At least one preferred time slot is required");
// New API format: use selected_slots
if (!input.selected_slots || input.selected_slots.length === 0) {
throw new Error("At least one time slot must be selected");
}
// Validate date format (YYYY-MM-DD)
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
for (const date of input.preferred_dates) {
if (!dateRegex.test(date)) {
throw new Error(`Invalid date format: ${date}. Expected YYYY-MM-DD format.`);
}
// Validate and clean selected_slots to ensure all have day and time_slot
// This filters out any invalid slots and ensures proper format
const validSlots: SelectedSlot[] = input.selected_slots
.filter((slot, index) => {
// Check if slot exists and is an object
if (!slot || typeof slot !== 'object') {
return false;
}
// Check if both day and time_slot properties exist
if (typeof slot.day === 'undefined' || typeof slot.time_slot === 'undefined') {
return false;
}
// Validate day is a number between 0-6
const dayNum = Number(slot.day);
if (isNaN(dayNum) || dayNum < 0 || dayNum > 6) {
return false;
}
// Validate time_slot is a valid string (normalize to lowercase)
const timeSlot = String(slot.time_slot).toLowerCase().trim();
if (!['morning', 'afternoon', 'evening'].includes(timeSlot)) {
return false;
}
return true;
})
.map(slot => ({
day: Number(slot.day),
time_slot: String(slot.time_slot).toLowerCase().trim() as "morning" | "afternoon" | "evening",
}));
if (validSlots.length === 0) {
throw new Error("At least one valid time slot must be selected. Each slot must have both 'day' (0-6) and 'time_slot' (morning, afternoon, or evening).");
}
// Validate time slots
const validTimeSlots = ["morning", "afternoon", "evening"];
for (const slot of input.preferred_time_slots) {
if (!validTimeSlots.includes(slot)) {
throw new Error(`Invalid time slot: ${slot}. Must be one of: ${validTimeSlots.join(", ")}`);
}
}
// Limit field lengths to prevent database errors (100 char limit for all string fields)
// Truncate all string fields BEFORE trimming to handle edge cases
const firstName = input.first_name ? String(input.first_name).trim().substring(0, 100) : '';
const lastName = input.last_name ? String(input.last_name).trim().substring(0, 100) : '';
const email = input.email ? String(input.email).trim().toLowerCase().substring(0, 100) : '';
const phone = input.phone ? String(input.phone).trim().substring(0, 100) : undefined;
const reason = input.reason ? String(input.reason).trim().substring(0, 100) : undefined;
// Prepare the payload exactly as the API expects
// Only include fields that the API accepts - no jitsi_room_id or other fields
// Build payload with only the fields the API expects - no extra fields
const payload: {
first_name: string;
last_name: string;
email: string;
preferred_dates: string[];
preferred_time_slots: string[];
selected_slots: Array<{ day: number; time_slot: string }>;
phone?: string;
reason?: string;
} = {
first_name: input.first_name.trim(),
last_name: input.last_name.trim(),
email: input.email.trim().toLowerCase(),
preferred_dates: input.preferred_dates,
preferred_time_slots: input.preferred_time_slots,
first_name: firstName,
last_name: lastName,
email: email,
selected_slots: validSlots.map(slot => ({
day: Number(slot.day),
time_slot: String(slot.time_slot).toLowerCase().trim(),
})),
};
// Only add optional fields if they have values
if (input.phone && input.phone.trim()) {
payload.phone = input.phone.trim();
// Only add optional fields if they have values (and are within length limits)
if (phone && phone.length > 0 && phone.length <= 100) {
payload.phone = phone;
}
if (input.reason && input.reason.trim()) {
payload.reason = input.reason.trim();
if (reason && reason.length > 0 && reason.length <= 100) {
payload.reason = reason;
}
// Log the payload for debugging
console.log("Creating appointment with payload:", JSON.stringify(payload, null, 2));
console.log("API endpoint:", API_ENDPOINTS.meetings.createAppointment);
// Final validation: ensure all string fields in payload are exactly 100 chars or less
// This is a safety check to prevent any encoding or serialization issues
const finalPayload = {
first_name: payload.first_name.substring(0, 100),
last_name: payload.last_name.substring(0, 100),
email: payload.email.substring(0, 100),
selected_slots: payload.selected_slots,
...(payload.phone && { phone: payload.phone.substring(0, 100) }),
...(payload.reason && { reason: payload.reason.substring(0, 100) }),
};
const response = await fetch(API_ENDPOINTS.meetings.createAppointment, {
method: "POST",
@ -114,7 +150,7 @@ export async function createAppointment(
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
body: JSON.stringify(payload),
body: JSON.stringify(finalPayload),
});
// Read response text first (can only be read once)
@ -131,14 +167,6 @@ export async function createAppointment(
}
data = JSON.parse(responseText);
} catch (e) {
// If JSON parsing fails, log the actual response
console.error("Failed to parse JSON response:", {
status: response.status,
statusText: response.statusText,
contentType,
url: API_ENDPOINTS.meetings.createAppointment,
preview: responseText.substring(0, 500)
});
throw new Error(`Server error (${response.status}): ${response.statusText || 'Invalid response format'}`);
}
} else {
@ -159,15 +187,6 @@ export async function createAppointment(
}
}
console.error("Non-JSON response received:", {
status: response.status,
statusText: response.statusText,
contentType,
url: API_ENDPOINTS.meetings.createAppointment,
payload: input,
preview: responseText.substring(0, 1000)
});
throw new Error(errorMessage);
}
@ -176,7 +195,27 @@ export async function createAppointment(
throw new Error(errorMessage);
}
// Handle different response formats
// Handle API response format: { appointment_id, message }
// According to API docs, response includes appointment_id and message
if (data.appointment_id) {
// Construct a minimal Appointment object from the response
// We'll use the input data plus the appointment_id from response
const appointment: Appointment = {
id: data.appointment_id,
first_name: input.first_name.trim(),
last_name: input.last_name.trim(),
email: input.email.trim().toLowerCase(),
phone: input.phone?.trim(),
reason: input.reason?.trim(),
selected_slots: validSlots,
status: "pending_review",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
return appointment;
}
// Handle different response formats for backward compatibility
if (data.appointment) {
return data.appointment;
}
@ -188,8 +227,9 @@ export async function createAppointment(
return data as unknown as Appointment;
}
// Get available dates
// Get available dates (optional endpoint - may fail if admin hasn't set availability)
export async function getAvailableDates(): Promise<AvailableDatesResponse> {
try {
const response = await fetch(API_ENDPOINTS.meetings.availableDates, {
method: "GET",
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) {
const errorMessage = extractErrorMessage(data as unknown as ApiError);
throw new Error(errorMessage);
// Return empty response instead of throwing - this endpoint is optional
return {
dates: [],
};
}
// If API returns array directly, wrap it in response object
@ -212,6 +270,95 @@ export async function getAvailableDates(): Promise<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)
@ -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) {
const errorMessage = extractErrorMessage(data as unknown as ApiError);
let errorData: any;
try {
errorData = JSON.parse(responseText);
} catch {
throw new Error(`Server error (${response.status}): ${response.statusText || 'Internal Server Error'}`);
}
const errorMessage = extractErrorMessage(errorData as unknown as ApiError);
throw new Error(errorMessage);
}
// Parse JSON response
let data: any;
try {
if (!responseText || responseText.trim().length === 0) {
return [];
}
data = JSON.parse(responseText);
} catch (error) {
throw new Error(`Failed to parse response: Invalid JSON format`);
}
// Handle different response formats
// API might return array directly or wrapped in an object
// API returns array directly: [{ id, first_name, ... }, ...]
if (Array.isArray(data)) {
return data;
}
// Handle wrapped responses (if any)
if (data && typeof data === 'object') {
if (data.appointments && Array.isArray(data.appointments)) {
return data.appointments;
}
if (data.results && Array.isArray(data.results)) {
return data.results;
}
// If data is an object but not an array and doesn't have appointments/results, return empty
// This shouldn't happen but handle gracefully
if (data.id || data.first_name) {
// Single appointment object, wrap in array
return [data];
}
}
return [];
@ -398,13 +571,6 @@ export async function scheduleAppointment(
errorMessage = `Server error: ${response.status} ${response.statusText || 'Unknown error'}`;
}
console.error("Schedule appointment error:", {
status: response.status,
statusText: response.statusText,
data,
errorMessage,
});
throw new Error(errorMessage);
}
@ -449,37 +615,41 @@ export async function rejectAppointment(
return data as unknown as Appointment;
}
// Get admin availability (public version - tries without auth first)
// Get admin availability (public version - uses weekly availability endpoint instead)
export async function getPublicAvailability(): Promise<AdminAvailability | null> {
try {
const response = await fetch(API_ENDPOINTS.meetings.adminAvailability, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
// Use weekly availability endpoint which is public
const weeklyAvailability = await getWeeklyAvailability();
// Normalize to array format
const weekArray = Array.isArray(weeklyAvailability)
? weeklyAvailability
: (weeklyAvailability as any).week || [];
if (!weekArray || weekArray.length === 0) {
return null;
}
const data: any = await response.json();
// Convert weekly availability to AdminAvailability format
const availabilitySchedule: Record<string, string[]> = {};
const availableDays: number[] = [];
const availableDaysDisplay: string[] = [];
// Handle both string and array formats for available_days
let availableDays: number[] = [];
if (typeof data.available_days === 'string') {
try {
availableDays = JSON.parse(data.available_days);
} catch {
availableDays = data.available_days.split(',').map((d: string) => parseInt(d.trim())).filter((d: number) => !isNaN(d));
weekArray.forEach((day: any) => {
if (day.is_available && day.available_slots && day.available_slots.length > 0) {
availabilitySchedule[day.day.toString()] = day.available_slots;
availableDays.push(day.day);
availableDaysDisplay.push(day.day_name);
}
} else if (Array.isArray(data.available_days)) {
availableDays = data.available_days;
}
});
return {
available_days: availableDays,
available_days_display: data.available_days_display || [],
available_days_display: availableDaysDisplay,
availability_schedule: availabilitySchedule,
all_available_slots: weekArray
.filter((d: any) => d.is_available)
.flatMap((d: any) => d.available_slots.map((slot: string) => ({ day: d.day, time_slot: slot as "morning" | "afternoon" | "evening" }))),
} as AdminAvailability;
} catch (error) {
return null;
@ -509,7 +679,67 @@ export async function getAdminAvailability(): Promise<AdminAvailability> {
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[] = [];
if (typeof data.available_days === 'string') {
try {
@ -538,14 +768,69 @@ export async function updateAdminAvailability(
throw new Error("Authentication required.");
}
// Ensure available_days is an array of numbers
const payload = {
available_days: Array.isArray(input.available_days)
// Prepare payload using new format (availability_schedule)
// API expects availability_schedule as an object with string keys (day numbers) and string arrays (time slot names)
// Format: { "0": ["morning", "afternoon"], "1": ["evening"], ... }
const payload: any = {};
if (input.availability_schedule) {
// Validate and clean the schedule object
// API expects: { "0": ["morning", "evening"], "1": ["afternoon"], ... }
// Time slots are strings: "morning", "afternoon", "evening"
const cleanedSchedule: Record<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
};
: input.available_days;
} else {
throw new Error("Either availability_schedule or available_days must be provided");
}
const response = await fetch(API_ENDPOINTS.meetings.adminAvailability, {
// Try PUT first, fallback to PATCH if needed
// The payload object will be JSON stringified, including availability_schedule as an object
let response = await fetch(API_ENDPOINTS.meetings.adminAvailability, {
method: "PUT",
headers: {
"Content-Type": "application/json",
@ -554,26 +839,251 @@ export async function updateAdminAvailability(
body: JSON.stringify(payload),
});
// If PUT fails with 500, try PATCH (some APIs prefer PATCH for updates)
if (!response.ok && response.status === 500) {
response = await fetch(API_ENDPOINTS.meetings.adminAvailability, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
body: JSON.stringify(payload),
});
}
// Read response text first (can only be read once)
const responseText = await response.text();
let data: any;
try {
data = await response.json();
// Get content type
const contentType = response.headers.get("content-type") || "";
// Handle empty response
if (!responseText || responseText.trim().length === 0) {
// If successful status but empty response, refetch the availability
if (response.ok) {
return await getAdminAvailability();
}
throw new Error(`Server error (${response.status}): ${response.statusText || 'Empty response from server'}`);
}
// Try to parse as JSON
if (contentType.includes("application/json")) {
try {
data = JSON.parse(responseText);
} catch (parseError) {
// If response is not JSON, use status text
throw new Error(response.statusText || "Failed to update availability");
throw new Error(`Server error (${response.status}): Invalid JSON response format`);
}
} else {
// Response is not JSON - try to extract useful information
// Try to extract error message from HTML if it's an HTML error page
let errorMessage = `Server error (${response.status}): ${response.statusText || 'Internal Server Error'}`;
let actualError = '';
let errorType = '';
let fullTraceback = '';
if (responseText) {
// Extract Django error details from HTML
const titleMatch = responseText.match(/<title[^>]*>(.*?)<\/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) {
const errorMessage = extractErrorMessage(data as unknown as ApiError);
console.error("Availability update error:", {
status: response.status,
statusText: response.statusText,
data,
payload
});
throw new Error(errorMessage);
// Build detailed error message
let detailedError = `Server error (${response.status}): `;
if (data && typeof data === 'object') {
if (data.detail) {
detailedError += Array.isArray(data.detail) ? data.detail.join(", ") : String(data.detail);
} else if (data.error) {
detailedError += Array.isArray(data.error) ? data.error.join(", ") : String(data.error);
} else if (data.message) {
detailedError += Array.isArray(data.message) ? data.message.join(", ") : String(data.message);
} else {
detailedError += response.statusText || 'Failed to update availability';
}
} else if (responseText && responseText.length > 0) {
// Try to extract error from HTML response if it's not JSON
detailedError += responseText.substring(0, 200);
} else {
detailedError += response.statusText || 'Failed to update availability';
}
throw new Error(detailedError);
}
// Handle both string and array formats for available_days in response
// Handle new format with availability_schedule in response
// API returns availability_schedule, which may be a JSON string or object
// Time slots may be strings or numeric indices
if (data && data.availability_schedule) {
let availabilitySchedule: Record<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[] = [];
if (typeof data.available_days === 'string') {
try {
@ -619,28 +1129,49 @@ export async function getAppointmentStats(): Promise<AppointmentStats> {
}
// Get user appointment stats
export async function getUserAppointmentStats(): Promise<UserAppointmentStats> {
export async function getUserAppointmentStats(email: string): Promise<UserAppointmentStats> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
if (!email) {
throw new Error("Email is required to fetch user appointment stats.");
}
const response = await fetch(API_ENDPOINTS.meetings.userAppointmentStats, {
method: "GET",
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
body: JSON.stringify({ email }),
});
const data: UserAppointmentStats = await response.json();
const responseText = await response.text();
if (!response.ok) {
const errorMessage = extractErrorMessage(data as unknown as ApiError);
let errorData: any;
try {
errorData = JSON.parse(responseText);
} catch {
throw new Error(`Server error (${response.status}): ${response.statusText || 'Internal Server Error'}`);
}
const errorMessage = extractErrorMessage(errorData as unknown as ApiError);
throw new Error(errorMessage);
}
let data: UserAppointmentStats;
try {
if (!responseText || responseText.trim().length === 0) {
throw new Error("Empty response from server");
}
data = JSON.parse(responseText);
} catch (error) {
throw new Error(`Failed to parse response: Invalid JSON format`);
}
return data;
}

View File

@ -24,6 +24,10 @@ export const API_ENDPOINTS = {
userAppointments: `${API_BASE_URL}/meetings/user/appointments/`,
userAppointmentStats: `${API_BASE_URL}/meetings/user/appointments/stats/`,
adminAvailability: `${API_BASE_URL}/meetings/admin/availability/`,
weeklyAvailability: `${API_BASE_URL}/meetings/availability/weekly/`,
availabilityConfig: `${API_BASE_URL}/meetings/availability/config/`,
checkDateAvailability: `${API_BASE_URL}/meetings/availability/check/`,
availabilityOverview: `${API_BASE_URL}/meetings/availability/overview/`,
},
} as const;

View File

@ -7,9 +7,10 @@ export interface Appointment {
email: string;
phone?: string;
reason?: string;
preferred_dates: string[]; // YYYY-MM-DD format
preferred_time_slots: string[]; // "morning", "afternoon", "evening"
status: "pending_review" | "scheduled" | "rejected" | "completed";
preferred_dates?: string; // YYYY-MM-DD format (legacy) - API returns as string, not array
preferred_time_slots?: string; // "morning", "afternoon", "evening" (legacy) - API returns as string
selected_slots?: SelectedSlot[]; // New format: day-time combinations
status: "pending_review" | "scheduled" | "rejected" | "completed" | "cancelled";
created_at: string;
updated_at: string;
scheduled_datetime?: string;
@ -17,9 +18,28 @@ export interface Appointment {
rejection_reason?: string;
jitsi_meet_url?: string;
jitsi_room_id?: string;
has_jitsi_meeting?: boolean;
can_join_meeting?: boolean;
has_jitsi_meeting?: boolean | string;
can_join_meeting?: boolean | string;
meeting_status?: string;
matching_availability?: MatchingAvailability | Array<{
date: string;
day_name: string;
available_slots: string[];
date_obj?: string;
}>;
are_preferences_available?: boolean | string;
// Additional fields from API response
full_name?: string;
formatted_created_at?: string;
formatted_scheduled_datetime?: string;
preferred_dates_display?: string;
preferred_time_slots_display?: string;
meeting_duration_display?: string;
}
export interface SelectedSlot {
day: number; // 0-6 (Monday-Sunday)
time_slot: "morning" | "afternoon" | "evening";
}
export interface AppointmentResponse {
@ -36,14 +56,74 @@ export interface AppointmentsListResponse {
}
export interface AvailableDatesResponse {
dates: string[]; // YYYY-MM-DD format
dates?: string[]; // YYYY-MM-DD format (legacy)
available_days?: number[]; // 0-6 (Monday-Sunday)
available_days_display?: string[];
// New format - array of date objects with time slots
available_dates?: Array<{
date: string; // YYYY-MM-DD
day_name: string;
available_slots: string[];
available_slots_display?: string[];
is_available: boolean;
}>;
}
export interface WeeklyAvailabilityDay {
day: number; // 0-6 (Monday-Sunday)
day_name: string;
available_slots: string[]; // ["morning", "afternoon", "evening"]
available_slots_display?: string[];
is_available: boolean;
}
export type WeeklyAvailabilityResponse = WeeklyAvailabilityDay[] | {
week?: WeeklyAvailabilityDay[];
[key: string]: any; // Allow for different response formats
};
export interface AvailabilityConfig {
days_of_week: Record<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 {
available_days: number[]; // 0-6 (Monday-Sunday)
available_days_display: string[];
available_days?: number[]; // 0-6 (Monday-Sunday) (legacy)
available_days_display?: string[];
availability_schedule?: Record<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 {
@ -62,6 +142,7 @@ export interface UserAppointmentStats {
rejected: number;
completed: number;
completion_rate: number;
email?: string;
}
export interface JitsiMeetingInfo {

View File

@ -1,19 +1,37 @@
import { z } from "zod";
// Create Appointment Schema
// Selected Slot Schema (for new API format)
export const selectedSlotSchema = z.object({
day: z.number().int().min(0).max(6),
time_slot: z.enum(["morning", "afternoon", "evening"]),
});
export type SelectedSlotInput = z.infer<typeof selectedSlotSchema>;
// Create Appointment Schema (updated to use selected_slots)
export const createAppointmentSchema = z.object({
first_name: z.string().min(1, "First name is required"),
last_name: z.string().min(1, "Last name is required"),
email: z.string().email("Invalid email address"),
first_name: z.string().min(1, "First name is required").max(100, "First name must be 100 characters or less"),
last_name: z.string().min(1, "Last name is required").max(100, "Last name must be 100 characters or less"),
email: z.string().email("Invalid email address").max(100, "Email must be 100 characters or less"),
selected_slots: z
.array(selectedSlotSchema)
.min(1, "At least one time slot must be selected"),
phone: z.string().max(100, "Phone must be 100 characters or less").optional(),
reason: z.string().max(100, "Reason must be 100 characters or less").optional(),
// Legacy fields (optional, for backward compatibility - but should not be sent)
preferred_dates: z
.array(z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format"))
.min(1, "At least one preferred date is required"),
.optional(),
preferred_time_slots: z
.array(z.enum(["morning", "afternoon", "evening"]))
.min(1, "At least one preferred time slot is required"),
phone: z.string().optional(),
reason: z.string().optional(),
});
.optional(),
}).refine(
(data) => data.selected_slots && data.selected_slots.length > 0,
{
message: "At least one time slot must be selected",
path: ["selected_slots"],
}
);
export type CreateAppointmentInput = z.infer<typeof createAppointmentSchema>;
@ -32,11 +50,18 @@ export const rejectAppointmentSchema = z.object({
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({
availability_schedule: z
.record(z.string(), z.array(z.enum(["morning", "afternoon", "evening"])))
.refine(
(schedule) => Object.keys(schedule).length > 0,
{ message: "At least one day must have availability" }
),
// Legacy field (optional, for backward compatibility)
available_days: z
.array(z.number().int().min(0).max(6))
.min(1, "At least one day must be selected"),
.optional(),
});
export type UpdateAvailabilityInput = z.infer<typeof updateAvailabilitySchema>;

View File

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