feat/booking-panel #27

Merged
Hammond merged 2 commits from feat/booking-panel into master 2025-11-26 11:45:48 +00:00
14 changed files with 631 additions and 215 deletions

View File

@ -89,7 +89,7 @@ export function Header() {
<Link <Link
href="/admin/booking" href="/admin/booking"
className={`flex items-center gap-1 sm:gap-2 px-2 sm:px-3 md:px-4 py-1.5 sm:py-2 rounded-lg text-xs sm:text-sm font-medium transition-colors ${ className={`flex items-center gap-1 sm:gap-2 px-2 sm:px-3 md:px-4 py-1.5 sm:py-2 rounded-lg text-xs sm:text-sm font-medium transition-colors ${
pathname === "/admin/booking" pathname === "/admin/booking" || pathname.startsWith("/admin/booking/")
? "bg-linear-to-r from-rose-500 to-pink-600 text-white" ? "bg-linear-to-r from-rose-500 to-pink-600 text-white"
: isDark : isDark
? "text-gray-300 hover:bg-gray-800" ? "text-gray-300 hover:bg-gray-800"

View File

@ -202,7 +202,7 @@ export default function AppointmentDetailPage() {
if (loading) { if (loading) {
return ( return (
<div className={`min-h-screen flex items-center justify-center ${isDark ? "bg-gray-900" : "bg-gray-50"}`}> <div className={`min-h-[calc(100vh-4rem)] flex items-center justify-center ${isDark ? "bg-gray-900" : "bg-gray-50"}`}>
<div className="text-center"> <div className="text-center">
<Loader2 className={`w-12 h-12 animate-spin mx-auto mb-4 ${isDark ? "text-rose-400" : "text-rose-600"}`} /> <Loader2 className={`w-12 h-12 animate-spin mx-auto mb-4 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
<p className={`text-sm ${isDark ? "text-gray-400" : "text-gray-600"}`}>Loading appointment details...</p> <p className={`text-sm ${isDark ? "text-gray-400" : "text-gray-600"}`}>Loading appointment details...</p>
@ -213,7 +213,7 @@ export default function AppointmentDetailPage() {
if (!appointment) { if (!appointment) {
return ( return (
<div className={`min-h-screen flex items-center justify-center ${isDark ? "bg-gray-900" : "bg-gray-50"}`}> <div className={`min-h-[calc(100vh-4rem)] flex items-center justify-center ${isDark ? "bg-gray-900" : "bg-gray-50"}`}>
<div className="text-center"> <div className="text-center">
<p className={`text-lg mb-4 ${isDark ? "text-gray-400" : "text-gray-600"}`}>Appointment not found</p> <p className={`text-lg mb-4 ${isDark ? "text-gray-400" : "text-gray-600"}`}>Appointment not found</p>
<Button <Button
@ -230,13 +230,13 @@ export default function AppointmentDetailPage() {
return ( return (
<div className={`min-h-screen ${isDark ? "bg-gray-900" : "bg-gray-50"}`}> <div className={`min-h-screen ${isDark ? "bg-gray-900" : "bg-gray-50"}`}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8"> <main className="p-3 sm:p-4 md:p-6 lg:p-8">
{/* Header */} {/* Page Header */}
<div className="mb-8"> <div className="mb-4 sm:mb-6 flex flex-col gap-3 sm:gap-4">
<Button <Button
variant="ghost" variant="ghost"
onClick={() => router.push("/admin/booking")} onClick={() => router.push("/admin/booking")}
className={`flex items-center gap-2 mb-6 ${isDark ? "text-gray-300 hover:bg-gray-800 hover:text-white" : "text-gray-600 hover:bg-gray-100"}`} className={`flex items-center gap-2 w-fit ${isDark ? "text-gray-300 hover:bg-gray-800 hover:text-white" : "text-gray-600 hover:bg-gray-100"}`}
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
Back to Bookings Back to Bookings
@ -245,14 +245,14 @@ export default function AppointmentDetailPage() {
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div> <div>
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className={`h-16 w-16 rounded-full flex items-center justify-center text-2xl font-bold ${isDark ? "bg-gradient-to-br from-rose-500 to-pink-600 text-white" : "bg-gradient-to-br from-rose-100 to-pink-100 text-rose-600"}`}> <div className={`h-12 w-12 sm:h-16 sm:w-16 rounded-full flex items-center justify-center text-xl sm:text-2xl font-bold ${isDark ? "bg-gradient-to-br from-rose-500 to-pink-600 text-white" : "bg-gradient-to-br from-rose-100 to-pink-100 text-rose-600"}`}>
{appointment.first_name[0]}{appointment.last_name[0]} {appointment.first_name[0]}{appointment.last_name[0]}
</div> </div>
<div> <div>
<h1 className={`text-3xl sm:text-4xl font-bold ${isDark ? "text-white" : "text-gray-900"}`}> <h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${isDark ? "text-white" : "text-gray-900"}`}>
{appointment.first_name} {appointment.last_name} {appointment.first_name} {appointment.last_name}
</h1> </h1>
<p className={`text-sm mt-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}> <p className={`text-xs sm:text-sm mt-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Appointment Request Appointment Request
</p> </p>
</div> </div>
@ -261,11 +261,11 @@ export default function AppointmentDetailPage() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span <span
className={`px-4 py-2 inline-flex items-center gap-2 text-sm font-semibold rounded-full border ${getStatusColor( className={`px-3 sm:px-4 py-2 inline-flex items-center gap-2 text-xs sm:text-sm font-semibold rounded-full border ${getStatusColor(
appointment.status appointment.status
)}`} )}`}
> >
{appointment.status === "scheduled" && <CheckCircle2 className="w-4 h-4" />} {appointment.status === "scheduled" && <CheckCircle2 className="w-3 h-3 sm:w-4 sm:h-4" />}
{formatStatus(appointment.status)} {formatStatus(appointment.status)}
</span> </span>
</div> </div>
@ -598,7 +598,7 @@ export default function AppointmentDetailPage() {
)} )}
</div> </div>
</div> </div>
</div> </main>
{/* Google Meet Style Schedule Dialog */} {/* Google Meet Style Schedule Dialog */}
<Dialog open={scheduleDialogOpen} onOpenChange={setScheduleDialogOpen}> <Dialog open={scheduleDialogOpen} onOpenChange={setScheduleDialogOpen}>

View File

@ -299,8 +299,18 @@ export default function Booking() {
}; };
const handleSchedule = async () => { const handleSchedule = async () => {
if (!selectedAppointment || !scheduledDate) { if (!selectedAppointment) {
toast.error("Please select a date and time"); toast.error("No appointment selected");
return;
}
if (!scheduledDate) {
toast.error("Please select a date");
return;
}
if (!scheduledTime) {
toast.error("Please select a time");
return; return;
} }
@ -309,7 +319,7 @@ export default function Booking() {
// Combine date and time into ISO datetime string // Combine date and time into ISO datetime string
const [hours, minutes] = scheduledTime.split(":"); const [hours, minutes] = scheduledTime.split(":");
const datetime = new Date(scheduledDate); const datetime = new Date(scheduledDate);
datetime.setHours(parseInt(hours), parseInt(minutes), 0, 0); datetime.setHours(parseInt(hours, 10), parseInt(minutes, 10), 0, 0);
const isoString = datetime.toISOString(); const isoString = datetime.toISOString();
await scheduleAppointment(selectedAppointment.id, { await scheduleAppointment(selectedAppointment.id, {
@ -319,6 +329,9 @@ export default function Booking() {
toast.success("Appointment scheduled successfully!"); toast.success("Appointment scheduled successfully!");
setScheduleDialogOpen(false); setScheduleDialogOpen(false);
setScheduledDate(undefined);
setScheduledTime("09:00");
setScheduledDuration(60);
// Refresh appointments list // Refresh appointments list
const data = await listAppointments(); const data = await listAppointments();
@ -724,7 +737,7 @@ export default function Booking() {
</Button> </Button>
<Button <Button
onClick={handleSchedule} onClick={handleSchedule}
disabled={isScheduling || !scheduledDate} disabled={isScheduling || !scheduledDate || !scheduledTime}
className="bg-blue-600 hover:bg-blue-700 text-white" className="bg-blue-600 hover:bg-blue-700 text-white"
> >
{isScheduling ? ( {isScheduling ? (

View File

@ -228,30 +228,48 @@ export default function BookNowPage() {
} }
// Convert day names to dates (YYYY-MM-DD format) // Convert day names to dates (YYYY-MM-DD format)
// Get next occurrence of each selected day // Get next occurrence of each selected day within the next 30 days
const today = new Date(); 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 days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const preferredDates: string[] = []; const preferredDates: string[] = [];
formData.preferredDays.forEach((dayName) => { formData.preferredDays.forEach((dayName) => {
const targetDayIndex = days.indexOf(dayName); const targetDayIndex = days.indexOf(dayName);
if (targetDayIndex === -1) return; if (targetDayIndex === -1) {
console.warn(`Invalid day name: ${dayName}`);
return;
}
let daysUntilTarget = (targetDayIndex - today.getDay() + 7) % 7; // Find the next occurrence of this day within the next 30 days
if (daysUntilTarget === 0) daysUntilTarget = 7; // Next week if today for (let i = 1; i <= 30; i++) {
const checkDate = new Date(today);
const targetDate = new Date(today); checkDate.setDate(today.getDate() + i);
targetDate.setDate(today.getDate() + daysUntilTarget);
const dateString = targetDate.toISOString().split("T")[0]; if (checkDate.getDay() === targetDayIndex) {
preferredDates.push(dateString); 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.");
return;
}
// Map time slots - API expects "morning", "afternoon", "evening" // Map time slots - API expects "morning", "afternoon", "evening"
// Form has "morning", "lunchtime", "afternoon" // Form has "morning", "lunchtime", "afternoon" (where "afternoon" label is "Evening")
const timeSlotMap: { [key: string]: "morning" | "afternoon" | "evening" } = { const timeSlotMap: { [key: string]: "morning" | "afternoon" | "evening" } = {
morning: "morning", morning: "morning",
lunchtime: "afternoon", // Map lunchtime to afternoon lunchtime: "afternoon", // Map lunchtime to afternoon
afternoon: "afternoon", afternoon: "evening", // Form's "afternoon" value (labeled "Evening") maps to API's "evening"
}; };
const preferredTimeSlots = formData.preferredTimes const preferredTimeSlots = formData.preferredTimes
@ -260,15 +278,18 @@ export default function BookNowPage() {
// Prepare request payload according to API spec // Prepare request payload according to API spec
const payload = { const payload = {
first_name: formData.firstName, first_name: formData.firstName.trim(),
last_name: formData.lastName, last_name: formData.lastName.trim(),
email: formData.email, email: formData.email.trim().toLowerCase(),
preferred_dates: preferredDates, preferred_dates: preferredDates,
preferred_time_slots: preferredTimeSlots, preferred_time_slots: preferredTimeSlots,
...(formData.phone && { phone: formData.phone }), ...(formData.phone && formData.phone.trim() && { phone: formData.phone.trim() }),
...(formData.message && { reason: formData.message }), ...(formData.message && formData.message.trim() && { reason: formData.message.trim() }),
}; };
// Validate payload before sending
console.log("Booking payload:", JSON.stringify(payload, null, 2));
// Call the actual API using the hook // Call the actual API using the hook
const appointmentData = await create(payload); const appointmentData = await create(payload);
@ -763,7 +784,7 @@ export default function BookNowPage() {
<Button <Button
type="submit" type="submit"
size="lg" size="lg"
disabled={isCreating} disabled={isCreating || availableDaysOfWeek.length === 0}
className="w-full bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all h-12 text-base font-semibold disabled:opacity-50 disabled:cursor-not-allowed" className="w-full bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all h-12 text-base font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
> >
{isCreating ? ( {isCreating ? (

View File

@ -152,7 +152,7 @@ export default function UserDashboard() {
className="w-full sm:w-auto bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white" className="w-full sm:w-auto bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white"
> >
<CalendarPlus className="w-4 h-4 mr-2" /> <CalendarPlus className="w-4 h-4 mr-2" />
Request Appointment Book Appointment
</Button> </Button>
</Link> </Link>
</div> </div>
@ -283,15 +283,15 @@ export default function UserDashboard() {
<div className="flex flex-col items-center justify-center py-8 text-center"> <div className="flex flex-col items-center justify-center py-8 text-center">
<CalendarCheck className={`w-12 h-12 mb-4 ${isDark ? 'text-gray-500' : 'text-gray-400'}`} /> <CalendarCheck className={`w-12 h-12 mb-4 ${isDark ? 'text-gray-500' : 'text-gray-400'}`} />
<p className={`text-lg font-medium mb-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}> <p className={`text-lg font-medium mb-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
Request Appointment No Upcoming Appointments
</p> </p>
<p className={`text-sm mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}> <p className={`text-sm mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
No upcoming appointments. Book an appointment to get started. You don't have any scheduled appointments yet. Book an appointment to get started.
</p> </p>
<Link href="/book-now"> <Link href="/book-now">
<Button className="bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white"> <Button className="bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white">
<CalendarPlus className="w-4 h-4 mr-2" /> <CalendarPlus className="w-4 h-4 mr-2" />
Request Appointment Book Appointment
</Button> </Button>
</Link> </Link>
</div> </div>

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
@ -13,19 +13,26 @@ import {
Lock, Lock,
Eye, Eye,
EyeOff, EyeOff,
Loader2,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Navbar } from "@/components/Navbar"; import { Navbar } from "@/components/Navbar";
import { useAppTheme } from "@/components/ThemeProvider"; import { useAppTheme } from "@/components/ThemeProvider";
import { getProfile, updateProfile } from "@/lib/actions/auth";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
export default function SettingsPage() { export default function SettingsPage() {
const { theme } = useAppTheme(); const { theme } = useAppTheme();
const isDark = theme === "dark"; const isDark = theme === "dark";
const { user } = useAuth();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(true);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
fullName: "John Doe", firstName: "",
email: "john.doe@example.com", lastName: "",
phone: "+1 (555) 123-4567", email: "",
phone: "",
}); });
const [passwordData, setPasswordData] = useState({ const [passwordData, setPasswordData] = useState({
currentPassword: "", currentPassword: "",
@ -38,6 +45,30 @@ export default function SettingsPage() {
confirm: false, confirm: false,
}); });
// Fetch profile data on mount
useEffect(() => {
const fetchProfile = async () => {
setFetching(true);
try {
const profile = await getProfile();
setFormData({
firstName: profile.first_name || "",
lastName: profile.last_name || "",
email: profile.email || "",
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 {
setFetching(false);
}
};
fetchProfile();
}, []);
const handleInputChange = (field: string, value: string) => { const handleInputChange = (field: string, value: string) => {
setFormData((prev) => ({ setFormData((prev) => ({
...prev, ...prev,
@ -60,35 +91,61 @@ export default function SettingsPage() {
}; };
const handleSave = async () => { const handleSave = async () => {
if (!formData.firstName || !formData.lastName) {
toast.error("First name and last name are required");
return;
}
setLoading(true); setLoading(true);
// Simulate API call try {
await new Promise((resolve) => setTimeout(resolve, 1000)); await updateProfile({
setLoading(false); first_name: formData.firstName,
// In a real app, you would show a success message here last_name: formData.lastName,
phone_number: formData.phone || undefined,
});
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 {
setLoading(false);
}
}; };
const handlePasswordSave = async () => { const handlePasswordSave = async () => {
if (!passwordData.currentPassword) {
toast.error("Please enter your current password");
return;
}
if (passwordData.newPassword !== passwordData.confirmPassword) { if (passwordData.newPassword !== passwordData.confirmPassword) {
// In a real app, you would show an error message here toast.error("New passwords do not match");
alert("New passwords do not match");
return; return;
} }
if (passwordData.newPassword.length < 8) { if (passwordData.newPassword.length < 8) {
// In a real app, you would show an error message here toast.error("Password must be at least 8 characters long");
alert("Password must be at least 8 characters long");
return; return;
} }
setLoading(true); setLoading(true);
// Simulate API call try {
await new Promise((resolve) => setTimeout(resolve, 1000)); // Note: The API might not have a change password endpoint for authenticated users
setLoading(false); // This would need to be implemented on the backend
// Reset password fields // For now, we'll show a message that this feature is coming soon
setPasswordData({ toast.error("Password change feature is not yet available. Please use the forgot password flow.");
currentPassword: "", // Reset password fields
newPassword: "", setPasswordData({
confirmPassword: "", currentPassword: "",
}); newPassword: "",
// In a real app, you would show a success message here 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 {
setLoading(false);
}
}; };
return ( return (
@ -114,83 +171,109 @@ export default function SettingsPage() {
</p> </p>
</div> </div>
</div> </div>
<Button
onClick={handleSave}
disabled={loading}
className="w-full sm:w-auto bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white"
>
{loading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
) : (
<Save className="w-4 h-4 mr-2" />
)}
Save Changes
</Button>
</div> </div>
<div className="max-w-4xl mx-auto"> {fetching ? (
<div className="space-y-6"> <div className="flex items-center justify-center py-12">
{/* Profile Information */} <Loader2 className={`w-8 h-8 animate-spin ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
<Card className={isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}> </div>
<CardHeader> ) : (
<div className="flex items-center gap-2"> <div className="max-w-4xl mx-auto">
<User className={`w-5 h-5 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} /> <div className="space-y-6">
<CardTitle className={isDark ? 'text-white' : 'text-gray-900'}>Profile Information</CardTitle> {/* Profile Information */}
</div> <Card className={isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}>
<CardDescription className={isDark ? 'text-gray-400' : 'text-gray-600'}> <CardHeader>
Update your personal information and contact details <div className="flex items-center gap-2">
</CardDescription> <User className={`w-5 h-5 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
</CardHeader> <CardTitle className={isDark ? 'text-white' : 'text-gray-900'}>Profile Information</CardTitle>
<CardContent className="space-y-4"> </div>
<div className="space-y-2"> <CardDescription className={isDark ? 'text-gray-400' : 'text-gray-600'}>
<label className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}> Update your personal information and contact details
Full Name </CardDescription>
</label> </CardHeader>
<div className="relative"> <CardContent className="space-y-4">
<User className={`absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 ${isDark ? 'text-gray-500' : 'text-gray-400'}`} /> <div className="space-y-2">
<Input <label className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
type="text" Full Name
value={formData.fullName} </label>
onChange={(e) => handleInputChange("fullName", e.target.value)} <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
className={`pl-10 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`} <div className="relative">
placeholder="Enter your full name" <Input
/> type="text"
value={formData.firstName}
onChange={(e) => handleInputChange("firstName", e.target.value)}
className={`${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
placeholder="First name"
/>
</div>
<div className="relative">
<Input
type="text"
value={formData.lastName}
onChange={(e) => handleInputChange("lastName", e.target.value)}
className={`${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
placeholder="Last name"
/>
</div>
</div>
</div> </div>
</div>
<div className="space-y-2"> <div className="space-y-2">
<label className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}> <label className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
Email Address Email Address
</label> </label>
<div className="relative"> <div className="relative">
<Mail className={`absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 ${isDark ? 'text-gray-500' : 'text-gray-400'}`} /> <Mail className={`absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 ${isDark ? 'text-gray-500' : 'text-gray-400'}`} />
<Input <Input
type="email" type="email"
value={formData.email} value={formData.email}
onChange={(e) => handleInputChange("email", e.target.value)} disabled
className={`pl-10 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`} className={`pl-10 ${isDark ? 'bg-gray-700/50 border-gray-600 text-gray-400 cursor-not-allowed' : 'bg-gray-50 border-gray-300 text-gray-500 cursor-not-allowed'}`}
placeholder="Enter your email" placeholder="Enter your email"
/> />
</div>
<p className={`text-xs ${isDark ? 'text-gray-500' : 'text-gray-500'}`}>
Email address cannot be changed
</p>
</div> </div>
</div>
<div className="space-y-2"> <div className="space-y-2">
<label className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}> <label className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
Phone Number Phone Number
</label> </label>
<div className="relative"> <div className="relative">
<Phone className={`absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 ${isDark ? 'text-gray-500' : 'text-gray-400'}`} /> <Phone className={`absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 ${isDark ? 'text-gray-500' : 'text-gray-400'}`} />
<Input <Input
type="tel" type="tel"
value={formData.phone} value={formData.phone}
onChange={(e) => handleInputChange("phone", e.target.value)} onChange={(e) => handleInputChange("phone", e.target.value)}
className={`pl-10 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`} className={`pl-10 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
placeholder="Enter your phone number" placeholder="Enter your phone number"
/> />
</div>
</div> </div>
</div>
</CardContent> <div className="pt-2">
</Card> <Button
onClick={handleSave}
disabled={loading}
className="bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Save Changes
</>
)}
</Button>
</div>
</CardContent>
</Card>
{/* Change Password */} {/* Change Password */}
<Card className={isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}> <Card className={isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}>
@ -214,7 +297,7 @@ export default function SettingsPage() {
type={showPasswords.current ? "text" : "password"} type={showPasswords.current ? "text" : "password"}
value={passwordData.currentPassword} value={passwordData.currentPassword}
onChange={(e) => handlePasswordChange("currentPassword", e.target.value)} onChange={(e) => handlePasswordChange("currentPassword", e.target.value)}
className={`pl-10 pr-10 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`} className={`pl-10 pr-10 h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
placeholder="Enter your current password" placeholder="Enter your current password"
/> />
<button <button
@ -241,7 +324,7 @@ export default function SettingsPage() {
type={showPasswords.new ? "text" : "password"} type={showPasswords.new ? "text" : "password"}
value={passwordData.newPassword} value={passwordData.newPassword}
onChange={(e) => handlePasswordChange("newPassword", e.target.value)} onChange={(e) => handlePasswordChange("newPassword", e.target.value)}
className={`pl-10 pr-10 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`} className={`pl-10 pr-10 h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
placeholder="Enter your new password" placeholder="Enter your new password"
/> />
<button <button
@ -271,7 +354,7 @@ export default function SettingsPage() {
type={showPasswords.confirm ? "text" : "password"} type={showPasswords.confirm ? "text" : "password"}
value={passwordData.confirmPassword} value={passwordData.confirmPassword}
onChange={(e) => handlePasswordChange("confirmPassword", e.target.value)} onChange={(e) => handlePasswordChange("confirmPassword", e.target.value)}
className={`pl-10 pr-10 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`} className={`pl-10 pr-10 h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
placeholder="Confirm your new password" placeholder="Confirm your new password"
/> />
<button <button
@ -295,17 +378,23 @@ export default function SettingsPage() {
className="bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white" className="bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white"
> >
{loading ? ( {loading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div> <>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Updating...
</>
) : ( ) : (
<Lock className="w-4 h-4 mr-2" /> <>
<Lock className="w-4 h-4 mr-2" />
Update Password
</>
)} )}
Update Password
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div>
</div> </div>
</div> )}
</main> </main>
</div> </div>
); );

View File

@ -117,3 +117,96 @@
scrollbar-color: rgb(244 63 94) rgb(255 228 230); scrollbar-color: rgb(244 63 94) rgb(255 228 230);
} }
} }
/* React DatePicker Styles */
.react-datepicker {
font-family: inherit;
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
background-color: hsl(var(--background));
color: hsl(var(--foreground));
}
.react-datepicker__header {
background-color: hsl(var(--background));
border-bottom: 1px solid hsl(var(--border));
border-top-left-radius: 0.5rem;
border-top-right-radius: 0.5rem;
padding-top: 0.75rem;
}
.react-datepicker__current-month {
color: hsl(var(--foreground));
font-weight: 600;
font-size: 0.875rem;
}
.react-datepicker__day-name {
color: hsl(var(--muted-foreground));
font-weight: 500;
font-size: 0.75rem;
}
.react-datepicker__day {
color: hsl(var(--foreground));
border-radius: 0.375rem;
}
.react-datepicker__day:hover {
background-color: hsl(var(--accent));
color: hsl(var(--accent-foreground));
border-radius: 0.375rem;
}
.react-datepicker__day--selected,
.react-datepicker__day--keyboard-selected {
background-color: rgb(225 29 72);
color: white;
border-radius: 0.375rem;
}
.react-datepicker__day--selected:hover,
.react-datepicker__day--keyboard-selected:hover {
background-color: rgb(190 24 93);
}
.react-datepicker__day--disabled {
color: hsl(var(--muted-foreground));
opacity: 0.5;
cursor: not-allowed;
}
.react-datepicker__navigation {
top: 0.75rem;
}
.react-datepicker__navigation-icon::before {
border-color: hsl(var(--foreground));
}
.react-datepicker__navigation:hover *::before {
border-color: rgb(225 29 72);
}
html.dark .react-datepicker {
background-color: #1f2937;
border-color: #374151;
}
html.dark .react-datepicker__header {
background-color: #1f2937;
border-color: #374151;
}
html.dark .react-datepicker__current-month {
color: #f9fafb;
}
html.dark .react-datepicker__day {
color: #f9fafb;
}
html.dark .react-datepicker__day:hover {
background-color: #374151;
color: #f9fafb;
}

View File

@ -1,22 +1,12 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { Calendar as CalendarIcon } from 'lucide-react'; import { Calendar as CalendarIcon } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import DatePickerLib from 'react-datepicker';
import { Calendar } from '@/components/ui/calendar'; import 'react-datepicker/dist/react-datepicker.css';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface DatePickerProps { interface DatePickerProps {
date: Date | undefined; date: Date | undefined;
@ -25,7 +15,25 @@ interface DatePickerProps {
} }
export function DatePicker({ date, setDate, label }: DatePickerProps) { export function DatePicker({ date, setDate, label }: DatePickerProps) {
const [open, setOpen] = React.useState(false); const [isOpen, setIsOpen] = React.useState(false);
const wrapperRef = React.useRef<HTMLDivElement>(null);
// Close calendar 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]);
return ( return (
<div className="space-y-2"> <div className="space-y-2">
@ -34,65 +42,38 @@ export function DatePicker({ date, setDate, label }: DatePickerProps) {
{label} {label}
</label> </label>
)} )}
<DropdownMenu open={open} onOpenChange={setOpen}> <div className="relative" ref={wrapperRef}>
<DropdownMenuTrigger asChild> <Button
<Button type="button"
variant={'outline'} variant="outline"
size="sm" onClick={() => setIsOpen(!isOpen)}
className={cn( className={cn(
'justify-start text-left font-normal', "w-full justify-start text-left font-normal h-10",
!date && 'text-muted-foreground' !date && "text-muted-foreground",
)} "bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-600"
> )}
<CalendarIcon className="mr-2 h-4 w-4" /> >
{date ? format(date, 'PPP') : <span>Pick a date</span>} <CalendarIcon className="mr-2 h-4 w-4" />
</Button> {date ? format(date, "PPP") : <span>Pick a date</span>}
</DropdownMenuTrigger> </Button>
<DropdownMenuContent className="w-auto p-5 bg-gradient-to-br from-white to-gray-50 dark:from-gray-800 dark:to-gray-900 shadow-2xl border-2 border-gray-100 dark:border-gray-700 rounded-2xl" align="end" sideOffset={5}> {isOpen && (
<Calendar <div className="absolute z-[9999] mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg">
mode="single" <DatePickerLib
selected={date} selected={date || null}
onSelect={(selectedDate) => { onChange={(selectedDate: Date | null) => {
setDate(selectedDate); setDate(selectedDate || undefined);
setOpen(false); if (selectedDate) {
}} setIsOpen(false);
initialFocus }
classNames={{ }}
months: "space-y-4", minDate={new Date()}
month: "space-y-4", inline
caption: "flex justify-center pt-3 pb-5 relative items-center border-b border-gray-200 dark:border-gray-700 mb-4", calendarClassName="!border-0"
caption_label: "text-lg font-bold text-gray-800 dark:text-gray-100", wrapperClassName="w-full"
nav: "flex items-center justify-between absolute inset-0", />
nav_button: cn( </div>
"h-9 w-9 rounded-full bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 hover:bg-rose-50 dark:hover:bg-gray-600 hover:border-rose-300 dark:hover:border-rose-500 p-0 transition-all shadow-sm" )}
), </div>
nav_button_previous: "absolute left-0",
nav_button_next: "absolute right-0",
table: "w-full border-collapse space-y-3",
head_row: "flex mb-3",
head_cell: "text-gray-600 dark:text-gray-400 rounded-md w-11 font-semibold text-xs",
row: "flex w-full mt-2",
cell: cn(
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20",
"[&>button]:h-11 [&>button]:w-11 [&>button]:p-0 [&>button]:font-semibold [&>button]:cursor-pointer [&>button]:rounded-full [&>button]:transition-all"
),
day: cn(
"h-11 w-11 p-0 font-semibold aria-selected:opacity-100 hover:bg-rose-500 hover:text-white rounded-full transition-all cursor-pointer",
"hover:scale-110 active:scale-95 hover:shadow-md"
),
day_selected:
"bg-rose-600 text-white hover:bg-rose-700 hover:text-white focus:bg-rose-600 focus:text-white font-bold shadow-xl scale-110 ring-4 ring-rose-200 dark:ring-rose-800",
day_today: "bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 font-bold border-2 border-blue-300 dark:border-blue-600",
day_outside: "text-gray-300 dark:text-gray-600 opacity-50",
day_disabled: "text-gray-200 dark:text-gray-700 opacity-30 cursor-not-allowed",
day_range_middle:
"aria-selected:bg-rose-100 dark:aria-selected:bg-rose-900/30 aria-selected:text-rose-700 dark:aria-selected:text-rose-300",
day_hidden: "invisible",
}}
/>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
); );
} }

View File

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

View File

@ -30,7 +30,7 @@ function PopoverContent({
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border border-gray-200 p-4 shadow-md outline-hidden", "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[100] w-72 origin-(--radix-popover-content-transform-origin) rounded-md border border-gray-200 p-4 shadow-md outline-hidden",
className className
)} )}
{...props} {...props}

View File

@ -51,16 +51,125 @@ export async function createAppointment(
throw new Error("Authentication required. Please log in to book an appointment."); throw new Error("Authentication required. Please log in to book an appointment.");
} }
// Validate required fields
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");
}
// 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 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(", ")}`);
}
}
// Prepare the payload exactly as the API expects
// Only include fields that the API accepts - no jitsi_room_id or other fields
const payload: {
first_name: string;
last_name: string;
email: string;
preferred_dates: string[];
preferred_time_slots: 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,
};
// Only add optional fields if they have values
if (input.phone && input.phone.trim()) {
payload.phone = input.phone.trim();
}
if (input.reason && input.reason.trim()) {
payload.reason = input.reason.trim();
}
// Log the payload for debugging
console.log("Creating appointment with payload:", JSON.stringify(payload, null, 2));
console.log("API endpoint:", API_ENDPOINTS.meetings.createAppointment);
const response = await fetch(API_ENDPOINTS.meetings.createAppointment, { const response = await fetch(API_ENDPOINTS.meetings.createAppointment, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`, Authorization: `Bearer ${tokens.access}`,
}, },
body: JSON.stringify(input), body: JSON.stringify(payload),
}); });
const data: AppointmentResponse = await response.json(); // Read response text first (can only be read once)
const responseText = await response.text();
// Check content type before parsing
const contentType = response.headers.get("content-type");
let data: any;
if (contentType && contentType.includes("application/json")) {
try {
if (!responseText) {
throw new Error(`Server returned empty response (${response.status})`);
}
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 {
// Response is not JSON (likely HTML error page)
// Try to extract error message from HTML if possible
let errorMessage = `Server error (${response.status}): ${response.statusText || 'Internal Server Error'}`;
// Try to find error details in HTML
// Use [\s\S] instead of . with s flag for better compatibility
const errorMatch = responseText.match(/<pre[^>]*>([\s\S]*?)<\/pre>/i) ||
responseText.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i) ||
responseText.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
if (errorMatch && errorMatch[1]) {
const htmlError = errorMatch[1].replace(/<[^>]*>/g, '').trim();
if (htmlError) {
errorMessage += `. ${htmlError}`;
}
}
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);
}
if (!response.ok) { if (!response.ok) {
const errorMessage = extractErrorMessage(data as unknown as ApiError); const errorMessage = extractErrorMessage(data as unknown as ApiError);
@ -235,10 +344,67 @@ export async function scheduleAppointment(
body: JSON.stringify(input), body: JSON.stringify(input),
}); });
const data: AppointmentResponse = await response.json(); let data: any;
const contentType = response.headers.get("content-type");
if (contentType && contentType.includes("application/json")) {
try {
const text = await response.text();
data = text ? JSON.parse(text) : {};
} catch (e) {
data = {};
}
} else {
const text = await response.text();
data = text || {};
}
if (!response.ok) { if (!response.ok) {
const errorMessage = extractErrorMessage(data as unknown as ApiError); // Try to extract detailed error information
let errorMessage = `Failed to schedule appointment (${response.status})`;
if (data && Object.keys(data).length > 0) {
// Check for common error formats
if (data.detail) {
errorMessage = Array.isArray(data.detail) ? data.detail.join(", ") : String(data.detail);
} else if (data.message) {
errorMessage = Array.isArray(data.message) ? data.message.join(", ") : String(data.message);
} else if (data.error) {
errorMessage = Array.isArray(data.error) ? data.error.join(", ") : String(data.error);
} else if (typeof data === "string") {
errorMessage = data;
} else {
// Check for field-specific errors
const fieldErrors: string[] = [];
Object.keys(data).forEach((key) => {
if (key !== "detail" && key !== "message" && key !== "error") {
const fieldError = data[key];
if (Array.isArray(fieldError)) {
fieldErrors.push(`${key}: ${fieldError.join(", ")}`);
} else if (typeof fieldError === "string") {
fieldErrors.push(`${key}: ${fieldError}`);
}
}
});
if (fieldErrors.length > 0) {
errorMessage = fieldErrors.join(". ");
} else {
// If we have data but can't parse it, show the status
errorMessage = `Server error: ${response.status} ${response.statusText}`;
}
}
} else {
// No data in response
errorMessage = `Server error: ${response.status} ${response.statusText || 'Unknown error'}`;
}
console.error("Schedule appointment error:", {
status: response.status,
statusText: response.statusText,
data,
errorMessage,
});
throw new Error(errorMessage); throw new Error(errorMessage);
} }

View File

@ -413,7 +413,7 @@ export async function updateProfile(input: UpdateProfileInput): Promise<User> {
} }
const response = await fetch(API_ENDPOINTS.auth.updateProfile, { const response = await fetch(API_ENDPOINTS.auth.updateProfile, {
method: "PATCH", method: "PUT",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`, Authorization: `Bearer ${tokens.access}`,

View File

@ -18,6 +18,7 @@
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@tanstack/react-query": "^5.90.10", "@tanstack/react-query": "^5.90.10",
"@types/react-datepicker": "^7.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
@ -27,6 +28,7 @@
"next": "16.0.1", "next": "16.0.1",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "19.2.0", "react": "19.2.0",
"react-datepicker": "^8.9.0",
"react-day-picker": "^9.11.1", "react-day-picker": "^9.11.1",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",

View File

@ -26,6 +26,9 @@ importers:
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^5.90.10 specifier: ^5.90.10
version: 5.90.10(react@19.2.0) version: 5.90.10(react@19.2.0)
'@types/react-datepicker':
specifier: ^7.0.0
version: 7.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
class-variance-authority: class-variance-authority:
specifier: ^0.7.1 specifier: ^0.7.1
version: 0.7.1 version: 0.7.1
@ -53,6 +56,9 @@ importers:
react: react:
specifier: 19.2.0 specifier: 19.2.0
version: 19.2.0 version: 19.2.0
react-datepicker:
specifier: ^8.9.0
version: 8.9.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react-day-picker: react-day-picker:
specifier: ^9.11.1 specifier: ^9.11.1
version: 9.11.1(react@19.2.0) version: 9.11.1(react@19.2.0)
@ -241,6 +247,12 @@ packages:
react: '>=16.8.0' react: '>=16.8.0'
react-dom: '>=16.8.0' react-dom: '>=16.8.0'
'@floating-ui/react@0.27.16':
resolution: {integrity: sha512-9O8N4SeG2z++TSM8QA/KTeKFBVCNEz/AGS7gWPJf6KFRzmRWixFRnCnkPHRDwSVZW6QPDO6uT0P2SpWNKCc9/g==}
peerDependencies:
react: '>=17.0.0'
react-dom: '>=17.0.0'
'@floating-ui/utils@0.2.10': '@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
@ -946,6 +958,10 @@ packages:
'@types/node@20.19.24': '@types/node@20.19.24':
resolution: {integrity: sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==} resolution: {integrity: sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==}
'@types/react-datepicker@7.0.0':
resolution: {integrity: sha512-4tWwOUq589tozyQPBVEqGNng5DaZkomx5IVNuur868yYdgjH6RaL373/HKiVt1IDoNNXYiTGspm1F7kjrarM8Q==}
deprecated: This is a stub types definition. react-datepicker provides its own type definitions, so you do not need this installed.
'@types/react-dom@19.2.2': '@types/react-dom@19.2.2':
resolution: {integrity: sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==} resolution: {integrity: sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==}
peerDependencies: peerDependencies:
@ -2334,6 +2350,12 @@ packages:
queue-microtask@1.2.3: queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
react-datepicker@8.9.0:
resolution: {integrity: sha512-yoRsGxjqVRjk8iUBssrW9jcinTeyP9mAfTpuzdKvlESOUjdrY0sfDTzIZWJAn38jvNcxW1dnDmW1CinjiFdxYQ==}
peerDependencies:
react: ^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-day-picker@9.11.1: react-day-picker@9.11.1:
resolution: {integrity: sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw==} resolution: {integrity: sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -2576,6 +2598,9 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
tabbable@6.3.0:
resolution: {integrity: sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==}
tailwind-merge@3.3.1: tailwind-merge@3.3.1:
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
@ -2937,6 +2962,14 @@ snapshots:
react: 19.2.0 react: 19.2.0
react-dom: 19.2.0(react@19.2.0) react-dom: 19.2.0(react@19.2.0)
'@floating-ui/react@0.27.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@floating-ui/react-dom': 2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@floating-ui/utils': 0.2.10
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
tabbable: 6.3.0
'@floating-ui/utils@0.2.10': {} '@floating-ui/utils@0.2.10': {}
'@humanfs/core@0.19.1': {} '@humanfs/core@0.19.1': {}
@ -3562,6 +3595,13 @@ snapshots:
dependencies: dependencies:
undici-types: 6.21.0 undici-types: 6.21.0
'@types/react-datepicker@7.0.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
react-datepicker: 8.9.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
transitivePeerDependencies:
- react
- react-dom
'@types/react-dom@19.2.2(@types/react@19.2.2)': '@types/react-dom@19.2.2(@types/react@19.2.2)':
dependencies: dependencies:
'@types/react': 19.2.2 '@types/react': 19.2.2
@ -5310,6 +5350,14 @@ snapshots:
queue-microtask@1.2.3: {} queue-microtask@1.2.3: {}
react-datepicker@8.9.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@floating-ui/react': 0.27.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
clsx: 2.1.1
date-fns: 4.1.0
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
react-day-picker@9.11.1(react@19.2.0): react-day-picker@9.11.1(react@19.2.0):
dependencies: dependencies:
'@date-fns/tz': 1.4.1 '@date-fns/tz': 1.4.1
@ -5654,6 +5702,8 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {} supports-preserve-symlinks-flag@1.0.0: {}
tabbable@6.3.0: {}
tailwind-merge@3.3.1: {} tailwind-merge@3.3.1: {}
tailwindcss@4.1.16: {} tailwindcss@4.1.16: {}