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
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"

View File

@ -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}>

View File

@ -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 ? (

View File

@ -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];
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 ? (

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"
>
<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>

View File

@ -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));
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);
// In a real app, you would show a success message here
}
};
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);
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: "",
});
// In a real app, you would show a success message here
} 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,20 +171,13 @@ 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>
{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 */}
@ -146,16 +196,26 @@ export default function SettingsPage() {
<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">
<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"
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 className="space-y-2">
@ -167,11 +227,14 @@ export default function SettingsPage() {
<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'}`}
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 className="space-y-2">
@ -189,6 +252,26 @@ export default function SettingsPage() {
/>
</div>
</div>
<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>
@ -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" />
)}
Update Password
</>
)}
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
)}
</main>
</div>
);

View File

@ -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;
}

View File

@ -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>
<div className="relative" ref={wrapperRef}>
<Button
variant={'outline'}
size="sm"
type="button"
variant="outline"
onClick={() => setIsOpen(!isOpen)}
className={cn(
'justify-start text-left font-normal',
!date && 'text-muted-foreground'
"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>}
{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",
{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"
/>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</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}
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}

View File

@ -51,16 +51,125 @@ 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
// 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) {
const errorMessage = extractErrorMessage(data as unknown as ApiError);
@ -235,10 +344,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);
}

View File

@ -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}`,

View File

@ -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",

View File

@ -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: {}