Merge pull request 'feat/booking-panel' (#29) from feat/booking-panel into master
Reviewed-on: http://35.207.46.142/ATTUNE-HEART-THERAPY/website/pulls/29
This commit is contained in:
commit
37560787fc
@ -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}>
|
||||
|
||||
@ -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
|
||||
|
||||
// Save time slots to localStorage
|
||||
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 });
|
||||
|
||||
// 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>
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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.");
|
||||
// Get current slots from formData
|
||||
const currentSlots = formData.selectedSlots || [];
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
if (formData.preferredTimes.length === 0) {
|
||||
setError("Please select at least one preferred time.");
|
||||
return;
|
||||
}
|
||||
// Prepare and validate slots - be very lenient
|
||||
const validSlots = currentSlots
|
||||
.map(slot => {
|
||||
if (!slot) return null;
|
||||
|
||||
// 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 day - handle any format
|
||||
let dayNum: number;
|
||||
if (typeof slot.day === 'number') {
|
||||
dayNum = slot.day;
|
||||
} else {
|
||||
dayNum = parseInt(String(slot.day || 0), 10);
|
||||
}
|
||||
|
||||
formData.preferredDays.forEach((dayName) => {
|
||||
const targetDayIndex = days.indexOf(dayName);
|
||||
if (targetDayIndex === -1) {
|
||||
console.warn(`Invalid day name: ${dayName}`);
|
||||
// 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);
|
||||
// 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 (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();
|
||||
|
||||
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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 {
|
||||
|
||||
177
components/ClockDurationPicker.tsx
Normal file
177
components/ClockDurationPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
278
components/ClockTimePicker.tsx
Normal file
278
components/ClockTimePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
72
components/DurationPicker.tsx
Normal file
72
components/DurationPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -462,3 +462,4 @@ export function ForgotPasswordDialog({ open, onOpenChange, onSuccess }: ForgotPa
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
170
components/ScheduleAppointmentDialog.tsx
Normal file
170
components/ScheduleAppointmentDialog.tsx
Normal 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
115
components/TimePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
},
|
||||
});
|
||||
// Use weekly availability endpoint which is public
|
||||
const weeklyAvailability = await getWeeklyAvailability();
|
||||
|
||||
if (!response.ok) {
|
||||
// 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)
|
||||
? input.available_days.map(day => Number(day))
|
||||
: 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 = {};
|
||||
|
||||
const response = await fetch(API_ENDPOINTS.meetings.adminAvailability, {
|
||||
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;
|
||||
} else {
|
||||
throw new Error("Either availability_schedule or available_days must be provided");
|
||||
}
|
||||
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user