Add React DatePicker and styles, update package dependencies, and enhance booking validation
This commit is contained in:
parent
4f58a69dd1
commit
cef17ea895
@ -89,7 +89,7 @@ export function Header() {
|
||||
<Link
|
||||
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 ${
|
||||
pathname === "/admin/booking"
|
||||
pathname === "/admin/booking" || pathname.startsWith("/admin/booking/")
|
||||
? "bg-linear-to-r from-rose-500 to-pink-600 text-white"
|
||||
: isDark
|
||||
? "text-gray-300 hover:bg-gray-800"
|
||||
|
||||
@ -202,7 +202,7 @@ export default function AppointmentDetailPage() {
|
||||
|
||||
if (loading) {
|
||||
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">
|
||||
<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>
|
||||
@ -213,7 +213,7 @@ export default function AppointmentDetailPage() {
|
||||
|
||||
if (!appointment) {
|
||||
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">
|
||||
<p className={`text-lg mb-4 ${isDark ? "text-gray-400" : "text-gray-600"}`}>Appointment not found</p>
|
||||
<Button
|
||||
@ -230,13 +230,13 @@ export default function AppointmentDetailPage() {
|
||||
|
||||
return (
|
||||
<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">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<main className="p-3 sm:p-4 md:p-6 lg:p-8">
|
||||
{/* Page Header */}
|
||||
<div className="mb-4 sm:mb-6 flex flex-col gap-3 sm:gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
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" />
|
||||
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>
|
||||
<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]}
|
||||
</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}
|
||||
</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
|
||||
</p>
|
||||
</div>
|
||||
@ -261,11 +261,11 @@ export default function AppointmentDetailPage() {
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<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 === "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)}
|
||||
</span>
|
||||
</div>
|
||||
@ -598,7 +598,7 @@ export default function AppointmentDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Google Meet Style Schedule Dialog */}
|
||||
<Dialog open={scheduleDialogOpen} onOpenChange={setScheduleDialogOpen}>
|
||||
|
||||
@ -299,8 +299,18 @@ export default function Booking() {
|
||||
};
|
||||
|
||||
const handleSchedule = async () => {
|
||||
if (!selectedAppointment || !scheduledDate) {
|
||||
toast.error("Please select a date and time");
|
||||
if (!selectedAppointment) {
|
||||
toast.error("No appointment selected");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!scheduledDate) {
|
||||
toast.error("Please select a date");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!scheduledTime) {
|
||||
toast.error("Please select a time");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -309,7 +319,7 @@ export default function Booking() {
|
||||
// Combine date and time into ISO datetime string
|
||||
const [hours, minutes] = scheduledTime.split(":");
|
||||
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();
|
||||
|
||||
await scheduleAppointment(selectedAppointment.id, {
|
||||
@ -319,6 +329,9 @@ export default function Booking() {
|
||||
|
||||
toast.success("Appointment scheduled successfully!");
|
||||
setScheduleDialogOpen(false);
|
||||
setScheduledDate(undefined);
|
||||
setScheduledTime("09:00");
|
||||
setScheduledDuration(60);
|
||||
|
||||
// Refresh appointments list
|
||||
const data = await listAppointments();
|
||||
@ -724,7 +737,7 @@ export default function Booking() {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSchedule}
|
||||
disabled={isScheduling || !scheduledDate}
|
||||
disabled={isScheduling || !scheduledDate || !scheduledTime}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
{isScheduling ? (
|
||||
|
||||
@ -228,30 +228,48 @@ export default function BookNowPage() {
|
||||
}
|
||||
|
||||
// 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();
|
||||
today.setHours(0, 0, 0, 0); // Reset to start of day
|
||||
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
const preferredDates: string[] = [];
|
||||
|
||||
formData.preferredDays.forEach((dayName) => {
|
||||
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;
|
||||
if (daysUntilTarget === 0) daysUntilTarget = 7; // Next week if today
|
||||
// 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);
|
||||
|
||||
const targetDate = new Date(today);
|
||||
targetDate.setDate(today.getDate() + daysUntilTarget);
|
||||
const dateString = targetDate.toISOString().split("T")[0];
|
||||
preferredDates.push(dateString);
|
||||
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.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 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" } = {
|
||||
morning: "morning",
|
||||
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
|
||||
@ -260,15 +278,18 @@ export default function BookNowPage() {
|
||||
|
||||
// Prepare request payload according to API spec
|
||||
const payload = {
|
||||
first_name: formData.firstName,
|
||||
last_name: formData.lastName,
|
||||
email: formData.email,
|
||||
first_name: formData.firstName.trim(),
|
||||
last_name: formData.lastName.trim(),
|
||||
email: formData.email.trim().toLowerCase(),
|
||||
preferred_dates: preferredDates,
|
||||
preferred_time_slots: preferredTimeSlots,
|
||||
...(formData.phone && { phone: formData.phone }),
|
||||
...(formData.message && { reason: formData.message }),
|
||||
...(formData.phone && formData.phone.trim() && { phone: formData.phone.trim() }),
|
||||
...(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
|
||||
const appointmentData = await create(payload);
|
||||
|
||||
@ -763,7 +784,7 @@ export default function BookNowPage() {
|
||||
<Button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
{isCreating ? (
|
||||
|
||||
@ -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"
|
||||
>
|
||||
<CalendarPlus className="w-4 h-4 mr-2" />
|
||||
Request Appointment
|
||||
Book Appointment
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
@ -283,15 +283,15 @@ 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'}`}>
|
||||
Request Appointment
|
||||
No Upcoming Appointments
|
||||
</p>
|
||||
<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>
|
||||
<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">
|
||||
<CalendarPlus className="w-4 h-4 mr-2" />
|
||||
Request Appointment
|
||||
Book Appointment
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
|
||||
@ -13,19 +13,26 @@ import {
|
||||
Lock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
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() {
|
||||
const { theme } = useAppTheme();
|
||||
const isDark = theme === "dark";
|
||||
const { user } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetching, setFetching] = useState(true);
|
||||
const [formData, setFormData] = useState({
|
||||
fullName: "John Doe",
|
||||
email: "john.doe@example.com",
|
||||
phone: "+1 (555) 123-4567",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
});
|
||||
const [passwordData, setPasswordData] = useState({
|
||||
currentPassword: "",
|
||||
@ -38,6 +45,30 @@ export default function SettingsPage() {
|
||||
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) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
@ -60,35 +91,61 @@ export default function SettingsPage() {
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.firstName || !formData.lastName) {
|
||||
toast.error("First name and last name are required");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
// Simulate API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
setLoading(false);
|
||||
// In a real app, you would show a success message here
|
||||
try {
|
||||
await updateProfile({
|
||||
first_name: formData.firstName,
|
||||
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 () => {
|
||||
if (!passwordData.currentPassword) {
|
||||
toast.error("Please enter your current password");
|
||||
return;
|
||||
}
|
||||
if (passwordData.newPassword !== passwordData.confirmPassword) {
|
||||
// In a real app, you would show an error message here
|
||||
alert("New passwords do not match");
|
||||
toast.error("New passwords do not match");
|
||||
return;
|
||||
}
|
||||
if (passwordData.newPassword.length < 8) {
|
||||
// In a real app, you would show an error message here
|
||||
alert("Password must be at least 8 characters long");
|
||||
toast.error("Password must be at least 8 characters long");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
// Simulate API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
setLoading(false);
|
||||
// Reset password fields
|
||||
setPasswordData({
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
// In a real app, you would show a success message here
|
||||
try {
|
||||
// Note: The API might not have a change password endpoint for authenticated users
|
||||
// This would need to be implemented on the backend
|
||||
// For now, we'll show a message that this feature is coming soon
|
||||
toast.error("Password change feature is not yet available. Please use the forgot password flow.");
|
||||
// Reset password fields
|
||||
setPasswordData({
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
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 (
|
||||
@ -114,83 +171,109 @@ export default function SettingsPage() {
|
||||
</p>
|
||||
</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 className="max-w-4xl mx-auto">
|
||||
<div className="space-y-6">
|
||||
{/* Profile Information */}
|
||||
<Card className={isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className={`w-5 h-5 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
||||
<CardTitle className={isDark ? 'text-white' : 'text-gray-900'}>Profile Information</CardTitle>
|
||||
</div>
|
||||
<CardDescription className={isDark ? 'text-gray-400' : 'text-gray-600'}>
|
||||
Update your personal information and contact details
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
Full Name
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className={`absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 ${isDark ? 'text-gray-500' : 'text-gray-400'}`} />
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.fullName}
|
||||
onChange={(e) => handleInputChange("fullName", 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'}`}
|
||||
placeholder="Enter your full name"
|
||||
/>
|
||||
{fetching ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className={`w-8 h-8 animate-spin ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="space-y-6">
|
||||
{/* Profile Information */}
|
||||
<Card className={isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className={`w-5 h-5 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
||||
<CardTitle className={isDark ? 'text-white' : 'text-gray-900'}>Profile Information</CardTitle>
|
||||
</div>
|
||||
<CardDescription className={isDark ? 'text-gray-400' : 'text-gray-600'}>
|
||||
Update your personal information and contact details
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
Full Name
|
||||
</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="relative">
|
||||
<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 className="space-y-2">
|
||||
<label className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
Email Address
|
||||
</label>
|
||||
<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'}`} />
|
||||
<Input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleInputChange("email", 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'}`}
|
||||
placeholder="Enter your email"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<label className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
Email Address
|
||||
</label>
|
||||
<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'}`} />
|
||||
<Input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
disabled
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
<p className={`text-xs ${isDark ? 'text-gray-500' : 'text-gray-500'}`}>
|
||||
Email address cannot be changed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
Phone Number
|
||||
</label>
|
||||
<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'}`} />
|
||||
<Input
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
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'}`}
|
||||
placeholder="Enter your phone number"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<label className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
Phone Number
|
||||
</label>
|
||||
<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'}`} />
|
||||
<Input
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
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'}`}
|
||||
placeholder="Enter your phone number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="pt-2">
|
||||
<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 */}
|
||||
<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"}
|
||||
value={passwordData.currentPassword}
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
@ -241,7 +324,7 @@ export default function SettingsPage() {
|
||||
type={showPasswords.new ? "text" : "password"}
|
||||
value={passwordData.newPassword}
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
@ -271,7 +354,7 @@ export default function SettingsPage() {
|
||||
type={showPasswords.confirm ? "text" : "password"}
|
||||
value={passwordData.confirmPassword}
|
||||
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"
|
||||
/>
|
||||
<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"
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -117,3 +117,96 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@ -1,22 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import { Calendar as CalendarIcon } from 'lucide-react';
|
||||
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import DatePickerLib from 'react-datepicker';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
|
||||
interface DatePickerProps {
|
||||
date: Date | undefined;
|
||||
@ -25,7 +15,25 @@ interface 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 (
|
||||
<div className="space-y-2">
|
||||
@ -34,65 +42,38 @@ export function DatePicker({ date, setDate, label }: DatePickerProps) {
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant={'outline'}
|
||||
size="sm"
|
||||
className={cn(
|
||||
'justify-start text-left font-normal',
|
||||
!date && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{date ? format(date, 'PPP') : <span>Pick a date</span>}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<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}>
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={(selectedDate) => {
|
||||
setDate(selectedDate);
|
||||
setOpen(false);
|
||||
}}
|
||||
initialFocus
|
||||
classNames={{
|
||||
months: "space-y-4",
|
||||
month: "space-y-4",
|
||||
caption: "flex justify-center pt-3 pb-5 relative items-center border-b border-gray-200 dark:border-gray-700 mb-4",
|
||||
caption_label: "text-lg font-bold text-gray-800 dark:text-gray-100",
|
||||
nav: "flex items-center justify-between absolute inset-0",
|
||||
nav_button: cn(
|
||||
"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"
|
||||
),
|
||||
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 className="relative" ref={wrapperRef}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal h-10",
|
||||
!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>}
|
||||
</Button>
|
||||
{isOpen && (
|
||||
<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">
|
||||
<DatePickerLib
|
||||
selected={date || null}
|
||||
onChange={(selectedDate: Date | null) => {
|
||||
setDate(selectedDate || undefined);
|
||||
if (selectedDate) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
minDate={new Date()}
|
||||
inline
|
||||
calendarClassName="!border-0"
|
||||
wrapperClassName="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -461,3 +461,4 @@ export function ForgotPasswordDialog({ open, onOpenChange, onSuccess }: ForgotPa
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -30,7 +30,7 @@ function PopoverContent({
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -51,16 +51,124 @@ export async function createAppointment(
|
||||
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, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
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
|
||||
const errorMatch = responseText.match(/<pre[^>]*>(.*?)<\/pre>/is) ||
|
||||
responseText.match(/<h1[^>]*>(.*?)<\/h1>/is) ||
|
||||
responseText.match(/<title[^>]*>(.*?)<\/title>/is);
|
||||
|
||||
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) {
|
||||
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
||||
@ -235,10 +343,67 @@ export async function scheduleAppointment(
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@ -413,7 +413,7 @@ export async function updateProfile(input: UpdateProfileInput): Promise<User> {
|
||||
}
|
||||
|
||||
const response = await fetch(API_ENDPOINTS.auth.updateProfile, {
|
||||
method: "PATCH",
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${tokens.access}`,
|
||||
|
||||
@ -18,6 +18,7 @@
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tanstack/react-query": "^5.90.10",
|
||||
"@types/react-datepicker": "^7.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
@ -27,6 +28,7 @@
|
||||
"next": "16.0.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.0",
|
||||
"react-datepicker": "^8.9.0",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dom": "19.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
||||
@ -26,6 +26,9 @@ importers:
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.90.10
|
||||
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:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@ -53,6 +56,9 @@ importers:
|
||||
react:
|
||||
specifier: 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:
|
||||
specifier: ^9.11.1
|
||||
version: 9.11.1(react@19.2.0)
|
||||
@ -241,6 +247,12 @@ packages:
|
||||
react: '>=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':
|
||||
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
|
||||
|
||||
@ -946,6 +958,10 @@ packages:
|
||||
'@types/node@20.19.24':
|
||||
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':
|
||||
resolution: {integrity: sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==}
|
||||
peerDependencies:
|
||||
@ -2334,6 +2350,12 @@ packages:
|
||||
queue-microtask@1.2.3:
|
||||
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:
|
||||
resolution: {integrity: sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw==}
|
||||
engines: {node: '>=18'}
|
||||
@ -2576,6 +2598,9 @@ packages:
|
||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
tabbable@6.3.0:
|
||||
resolution: {integrity: sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==}
|
||||
|
||||
tailwind-merge@3.3.1:
|
||||
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
|
||||
|
||||
@ -2937,6 +2962,14 @@ snapshots:
|
||||
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': {}
|
||||
|
||||
'@humanfs/core@0.19.1': {}
|
||||
@ -3562,6 +3595,13 @@ snapshots:
|
||||
dependencies:
|
||||
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)':
|
||||
dependencies:
|
||||
'@types/react': 19.2.2
|
||||
@ -5310,6 +5350,14 @@ snapshots:
|
||||
|
||||
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):
|
||||
dependencies:
|
||||
'@date-fns/tz': 1.4.1
|
||||
@ -5654,6 +5702,8 @@ snapshots:
|
||||
|
||||
supports-preserve-symlinks-flag@1.0.0: {}
|
||||
|
||||
tabbable@6.3.0: {}
|
||||
|
||||
tailwind-merge@3.3.1: {}
|
||||
|
||||
tailwindcss@4.1.16: {}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user