Compare commits
No commits in common. "master" and "feat/authentication" have entirely different histories.
master
...
feat/authe
@ -6,13 +6,14 @@ import { usePathname, useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Inbox,
|
||||
Calendar,
|
||||
LayoutGrid,
|
||||
Heart,
|
||||
UserCog,
|
||||
Bell,
|
||||
Settings,
|
||||
LogOut,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { useAppTheme } from "@/components/ThemeProvider";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
@ -22,8 +23,7 @@ import { toast } from "sonner";
|
||||
export function Header() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
// Notification state - commented out
|
||||
// const [notificationsOpen, setNotificationsOpen] = useState(false);
|
||||
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
const { theme } = useAppTheme();
|
||||
const isDark = theme === "dark";
|
||||
@ -36,8 +36,7 @@ export function Header() {
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
// Mock notifications data - commented out
|
||||
/*
|
||||
// Mock notifications data
|
||||
const notifications = [
|
||||
{
|
||||
id: 1,
|
||||
@ -58,7 +57,6 @@ export function Header() {
|
||||
];
|
||||
|
||||
const unreadCount = notifications.filter((n) => !n.read).length;
|
||||
*/
|
||||
|
||||
return (
|
||||
<header className={`fixed top-0 left-0 right-0 z-50 ${isDark ? "bg-gray-900 border-gray-800" : "bg-white border-gray-200"} border-b`}>
|
||||
@ -90,7 +88,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.startsWith("/admin/booking/")
|
||||
pathname === "/admin/booking"
|
||||
? "bg-linear-to-r from-rose-500 to-pink-600 text-white"
|
||||
: isDark
|
||||
? "text-gray-300 hover:bg-gray-800"
|
||||
@ -100,13 +98,91 @@ export function Header() {
|
||||
<Calendar className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
<span className="hidden sm:inline">Book Appointment</span>
|
||||
</Link>
|
||||
|
||||
</nav>
|
||||
|
||||
{/* Right Side Actions */}
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 md:gap-3">
|
||||
<ThemeToggle />
|
||||
{/* Notification Popover - Commented out */}
|
||||
<Popover open={notificationsOpen} onOpenChange={setNotificationsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="relative w-8 h-8 sm:w-9 sm:h-9 md:w-10 md:h-10 cursor-pointer">
|
||||
<Inbox className={`w-5 h-5 sm:w-6 sm:h-6 md:w-8 md:h-8 ${isDark ? "text-gray-300" : "text-gray-600"}`} />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute top-0.5 right-0.5 sm:top-1 sm:right-1 w-1.5 h-1.5 sm:w-2 sm:h-2 bg-green-500 rounded-full"></span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className={`w-[calc(100vw-2rem)] sm:w-80 md:w-96 p-0 shadow-xl border ${isDark ? "bg-gray-900 border-gray-800" : "bg-white border-gray-200"}`} align="end">
|
||||
{/* Thumbtack Design at Top Right */}
|
||||
<div className="relative">
|
||||
<div className={`absolute -top-2 right-8 w-4 h-4 rotate-45 ${isDark ? "bg-gray-900 border-l border-t border-gray-800" : "bg-white border-l border-t border-gray-200"}`}></div>
|
||||
<div className={`absolute -top-1 right-8 w-2 h-2 translate-x-1/2 ${isDark ? "bg-gray-900" : "bg-white"}`}></div>
|
||||
</div>
|
||||
<div className={`flex items-center justify-between p-4 border-b ${isDark ? "border-gray-800" : ""}`}>
|
||||
<h3 className={`font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>Notifications</h3>
|
||||
{unreadCount > 0 && (
|
||||
<span className="px-2 py-1 text-xs font-medium bg-rose-100 text-rose-700 rounded-full">
|
||||
{unreadCount} new
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{notifications.length === 0 ? (
|
||||
<div className={`p-8 text-center ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
<Bell className={`w-12 h-12 mx-auto mb-2 ${isDark ? "text-gray-600" : "text-gray-300"}`} />
|
||||
<p className="text-sm">No notifications</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`divide-y ${isDark ? "divide-gray-800" : ""}`}>
|
||||
{notifications.map((notification) => {
|
||||
return (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`p-4 transition-colors cursor-pointer ${
|
||||
!notification.read
|
||||
? isDark
|
||||
? "bg-rose-500/10"
|
||||
: "bg-rose-50/50"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p
|
||||
className={`text-sm font-medium ${isDark ? "text-white" : "text-gray-900"} ${
|
||||
!notification.read ? "font-semibold" : ""
|
||||
}`}
|
||||
>
|
||||
{notification.title}
|
||||
</p>
|
||||
{!notification.read && (
|
||||
<span className="shrink-0 w-2 h-2 bg-green-500 rounded-full mt-1"></span>
|
||||
)}
|
||||
</div>
|
||||
<p className={`text-sm mt-1 ${isDark ? "text-gray-400" : "text-gray-600"}`}>
|
||||
{notification.message}
|
||||
</p>
|
||||
<p className={`text-xs mt-1 ${isDark ? "text-gray-500" : "text-gray-400"}`}>
|
||||
{notification.time}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`p-3 border-t ${isDark ? "border-gray-800 bg-gray-900/80" : "bg-gray-50"}`}>
|
||||
<Link
|
||||
href="/admin/notifications"
|
||||
onClick={() => setNotificationsOpen(false)}
|
||||
className={`block w-full text-center text-sm font-medium hover:underline transition-colors ${isDark ? "text-rose-300 hover:text-rose-200" : "text-rose-600 hover:text-rose-700"}`}
|
||||
>
|
||||
View all notifications
|
||||
</Link>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Popover open={userMenuOpen} onOpenChange={setUserMenuOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
|
||||
@ -12,7 +12,6 @@ import {
|
||||
Menu,
|
||||
X,
|
||||
Heart,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { useAppTheme } from "@/components/ThemeProvider";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
@ -21,7 +20,6 @@ import { toast } from "sonner";
|
||||
const navItems = [
|
||||
{ label: "Dashboard", icon: LayoutGrid, href: "/admin/dashboard" },
|
||||
{ label: "Book Appointment", icon: Calendar, href: "/admin/booking" },
|
||||
{ label: "Deliverables", icon: FileText, href: "/deliverables" },
|
||||
];
|
||||
|
||||
export default function SideNav() {
|
||||
|
||||
@ -18,10 +18,9 @@ import {
|
||||
ExternalLink,
|
||||
Copy,
|
||||
MapPin,
|
||||
Pencil,
|
||||
} from "lucide-react";
|
||||
import { useAppTheme } from "@/components/ThemeProvider";
|
||||
import { getAppointmentDetail, scheduleAppointment, rejectAppointment, listAppointments, startMeeting, endMeeting, rescheduleAppointment, cancelAppointment } from "@/lib/actions/appointments";
|
||||
import { getAppointmentDetail, scheduleAppointment, rejectAppointment } from "@/lib/actions/appointments";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@ -31,7 +30,8 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScheduleAppointmentDialog } from "@/components/ScheduleAppointmentDialog";
|
||||
import { DatePicker } from "@/components/DatePicker";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import type { Appointment } from "@/lib/models/appointments";
|
||||
|
||||
@ -44,21 +44,12 @@ export default function AppointmentDetailPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false);
|
||||
const [rejectDialogOpen, setRejectDialogOpen] = useState(false);
|
||||
const [rescheduleDialogOpen, setRescheduleDialogOpen] = useState(false);
|
||||
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
|
||||
const [scheduledTime, setScheduledTime] = useState<string>("09:00");
|
||||
const [scheduledDuration, setScheduledDuration] = useState<number>(60);
|
||||
const [rescheduleDate, setRescheduleDate] = useState<Date | undefined>(undefined);
|
||||
const [rescheduleTime, setRescheduleTime] = useState<string>("09:00");
|
||||
const [rescheduleDuration, setRescheduleDuration] = useState<number>(60);
|
||||
const [rejectionReason, setRejectionReason] = useState<string>("");
|
||||
const [isScheduling, setIsScheduling] = useState(false);
|
||||
const [isRejecting, setIsRejecting] = useState(false);
|
||||
const [isRescheduling, setIsRescheduling] = useState(false);
|
||||
const [isStartingMeeting, setIsStartingMeeting] = useState(false);
|
||||
const [isEndingMeeting, setIsEndingMeeting] = useState(false);
|
||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||
const [isCancelling, setIsCancelling] = useState(false);
|
||||
const { theme } = useAppTheme();
|
||||
const isDark = theme === "dark";
|
||||
|
||||
@ -68,24 +59,10 @@ export default function AppointmentDetailPage() {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch both detail and list to get selected_slots from list endpoint
|
||||
const [detailData, listData] = await Promise.all([
|
||||
getAppointmentDetail(appointmentId),
|
||||
listAppointments().catch(() => []) // Fallback to empty array if list fails
|
||||
]);
|
||||
|
||||
// Find matching appointment in list to get selected_slots
|
||||
const listAppointment = Array.isArray(listData)
|
||||
? listData.find((apt: Appointment) => apt.id === appointmentId)
|
||||
: null;
|
||||
|
||||
// Merge selected_slots from list into detail data
|
||||
if (listAppointment && listAppointment.selected_slots && Array.isArray(listAppointment.selected_slots) && listAppointment.selected_slots.length > 0) {
|
||||
detailData.selected_slots = listAppointment.selected_slots;
|
||||
}
|
||||
|
||||
setAppointment(detailData);
|
||||
const data = await getAppointmentDetail(appointmentId);
|
||||
setAppointment(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch appointment details:", error);
|
||||
toast.error("Failed to load appointment details");
|
||||
router.push("/admin/booking");
|
||||
} finally {
|
||||
@ -162,6 +139,10 @@ export default function AppointmentDetailPage() {
|
||||
return status.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase());
|
||||
};
|
||||
|
||||
const timeSlots = Array.from({ length: 24 }, (_, i) => {
|
||||
const hour = i.toString().padStart(2, "0");
|
||||
return `${hour}:00`;
|
||||
});
|
||||
|
||||
const handleSchedule = async () => {
|
||||
if (!appointment || !scheduledDate) return;
|
||||
@ -184,6 +165,7 @@ export default function AppointmentDetailPage() {
|
||||
const updated = await getAppointmentDetail(appointment.id);
|
||||
setAppointment(updated);
|
||||
} catch (error: any) {
|
||||
console.error("Failed to schedule appointment:", error);
|
||||
toast.error(error.message || "Failed to schedule appointment");
|
||||
} finally {
|
||||
setIsScheduling(false);
|
||||
@ -206,100 +188,21 @@ export default function AppointmentDetailPage() {
|
||||
const updated = await getAppointmentDetail(appointment.id);
|
||||
setAppointment(updated);
|
||||
} catch (error: any) {
|
||||
console.error("Failed to reject appointment:", error);
|
||||
toast.error(error.message || "Failed to reject appointment");
|
||||
} finally {
|
||||
setIsRejecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReschedule = async () => {
|
||||
if (!appointment || !rescheduleDate) return;
|
||||
|
||||
setIsRescheduling(true);
|
||||
try {
|
||||
const dateTime = new Date(rescheduleDate);
|
||||
const [hours, minutes] = rescheduleTime.split(":").map(Number);
|
||||
dateTime.setHours(hours, minutes, 0, 0);
|
||||
|
||||
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
await rescheduleAppointment(appointment.id, {
|
||||
new_scheduled_datetime: dateTime.toISOString(),
|
||||
new_scheduled_duration: rescheduleDuration,
|
||||
timezone: userTimezone,
|
||||
});
|
||||
|
||||
toast.success("Appointment rescheduled successfully");
|
||||
setRescheduleDialogOpen(false);
|
||||
|
||||
// Refresh appointment data
|
||||
const updated = await getAppointmentDetail(appointment.id);
|
||||
setAppointment(updated);
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || "Failed to reschedule appointment");
|
||||
} finally {
|
||||
setIsRescheduling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelAppointment = async () => {
|
||||
if (!appointment) return;
|
||||
|
||||
setIsCancelling(true);
|
||||
try {
|
||||
await cancelAppointment(appointment.id);
|
||||
toast.success("Appointment cancelled successfully");
|
||||
setCancelDialogOpen(false);
|
||||
// Refetch appointment to get updated status
|
||||
const updatedAppointment = await getAppointmentDetail(appointment.id);
|
||||
setAppointment(updatedAppointment);
|
||||
router.push("/admin/booking");
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to cancel appointment";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsCancelling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string, label: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success(`${label} copied to clipboard`);
|
||||
};
|
||||
|
||||
const handleStartMeeting = async () => {
|
||||
if (!appointment) return;
|
||||
|
||||
setIsStartingMeeting(true);
|
||||
try {
|
||||
const updated = await startMeeting(appointment.id);
|
||||
setAppointment(updated);
|
||||
toast.success("Meeting started successfully");
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || "Failed to start meeting");
|
||||
} finally {
|
||||
setIsStartingMeeting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndMeeting = async () => {
|
||||
if (!appointment) return;
|
||||
|
||||
setIsEndingMeeting(true);
|
||||
try {
|
||||
const updated = await endMeeting(appointment.id);
|
||||
setAppointment(updated);
|
||||
toast.success("Meeting ended successfully");
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || "Failed to end meeting");
|
||||
} finally {
|
||||
setIsEndingMeeting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={`min-h-[calc(100vh-4rem)] flex items-center justify-center ${isDark ? "bg-gray-900" : "bg-gray-50"}`}>
|
||||
<div className={`min-h-screen 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>
|
||||
@ -310,7 +213,7 @@ export default function AppointmentDetailPage() {
|
||||
|
||||
if (!appointment) {
|
||||
return (
|
||||
<div className={`min-h-[calc(100vh-4rem)] flex items-center justify-center ${isDark ? "bg-gray-900" : "bg-gray-50"}`}>
|
||||
<div className={`min-h-screen 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
|
||||
@ -327,13 +230,13 @@ export default function AppointmentDetailPage() {
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen ${isDark ? "bg-gray-900" : "bg-gray-50"}`}>
|
||||
<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">
|
||||
<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">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => router.push("/admin/booking")}
|
||||
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"}`}
|
||||
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"}`}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Bookings
|
||||
@ -342,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-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"}`}>
|
||||
<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"}`}>
|
||||
{appointment.first_name[0]}{appointment.last_name[0]}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
<h1 className={`text-3xl sm:text-4xl font-bold ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
{appointment.first_name} {appointment.last_name}
|
||||
</h1>
|
||||
<p className={`text-xs sm:text-sm mt-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
<p className={`text-sm mt-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
Appointment Request
|
||||
</p>
|
||||
</div>
|
||||
@ -358,11 +261,11 @@ export default function AppointmentDetailPage() {
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
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(
|
||||
className={`px-4 py-2 inline-flex items-center gap-2 text-sm font-semibold rounded-full border ${getStatusColor(
|
||||
appointment.status
|
||||
)}`}
|
||||
>
|
||||
{appointment.status === "scheduled" && <CheckCircle2 className="w-3 h-3 sm:w-4 sm:h-4" />}
|
||||
{appointment.status === "scheduled" && <CheckCircle2 className="w-4 h-4" />}
|
||||
{formatStatus(appointment.status)}
|
||||
</span>
|
||||
</div>
|
||||
@ -436,35 +339,10 @@ export default function AppointmentDetailPage() {
|
||||
{appointment.scheduled_datetime && (
|
||||
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
||||
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
<Calendar className={`w-5 h-5 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
|
||||
Scheduled Appointment
|
||||
</h2>
|
||||
{appointment.status === "scheduled" && (
|
||||
<button
|
||||
onClick={() => {
|
||||
// Initialize reschedule fields with current appointment values
|
||||
if (appointment.scheduled_datetime) {
|
||||
const scheduledDate = new Date(appointment.scheduled_datetime);
|
||||
setRescheduleDate(scheduledDate);
|
||||
const hours = scheduledDate.getHours().toString().padStart(2, "0");
|
||||
const minutes = scheduledDate.getMinutes().toString().padStart(2, "0");
|
||||
setRescheduleTime(`${hours}:${minutes}`);
|
||||
}
|
||||
if (appointment.scheduled_duration) {
|
||||
setRescheduleDuration(appointment.scheduled_duration);
|
||||
}
|
||||
setRescheduleDialogOpen(true);
|
||||
}}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors bg-blue-600 hover:bg-blue-700 text-white`}
|
||||
title="Reschedule Appointment"
|
||||
>
|
||||
<Pencil className="w-4 h-4 shrink-0 sm:hidden" />
|
||||
<span className="hidden sm:inline text-sm font-medium">Reschedule</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
<Calendar className={`w-5 h-5 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
|
||||
Scheduled Appointment
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
@ -497,103 +375,49 @@ export default function AppointmentDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected Slots */}
|
||||
{appointment.selected_slots && Array.isArray(appointment.selected_slots) && appointment.selected_slots.length > 0 && (
|
||||
{/* Preferred Dates & Times */}
|
||||
{(appointment.preferred_dates?.length > 0 || appointment.preferred_time_slots?.length > 0) && (
|
||||
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
||||
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
|
||||
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
<CalendarCheck className={`w-5 h-5 ${isDark ? "text-green-400" : "text-green-600"}`} />
|
||||
Selected Time Slots
|
||||
{appointment.are_preferences_available !== undefined && (
|
||||
<span className={`ml-auto px-3 py-1 text-xs font-medium rounded-full ${appointment.are_preferences_available ? (isDark ? "bg-green-500/20 text-green-300 border border-green-500/30" : "bg-green-50 text-green-700 border border-green-200") : (isDark ? "bg-yellow-500/20 text-yellow-300 border border-yellow-500/30" : "bg-yellow-50 text-yellow-700 border border-yellow-200")}`}>
|
||||
{appointment.are_preferences_available ? "All Available" : "Partially Available"}
|
||||
</span>
|
||||
)}
|
||||
<h2 className={`text-lg font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
Preferred Availability
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
{(() => {
|
||||
const dayNames: Record<number, string> = {
|
||||
0: "Monday",
|
||||
1: "Tuesday",
|
||||
2: "Wednesday",
|
||||
3: "Thursday",
|
||||
4: "Friday",
|
||||
5: "Saturday",
|
||||
6: "Sunday",
|
||||
};
|
||||
const timeSlotLabels: Record<string, string> = {
|
||||
morning: "Morning",
|
||||
afternoon: "Lunchtime",
|
||||
evening: "Evening",
|
||||
};
|
||||
|
||||
// Time slot order: morning, afternoon (lunchtime), evening
|
||||
const timeSlotOrder: Record<string, number> = {
|
||||
morning: 0,
|
||||
afternoon: 1,
|
||||
evening: 2,
|
||||
};
|
||||
|
||||
// Group slots by date
|
||||
const slotsByDate: Record<string, typeof appointment.selected_slots> = {};
|
||||
appointment.selected_slots.forEach((slot: any) => {
|
||||
const date = slot.date || "";
|
||||
if (!slotsByDate[date]) {
|
||||
slotsByDate[date] = [];
|
||||
}
|
||||
slotsByDate[date].push(slot);
|
||||
});
|
||||
|
||||
// Sort dates and slots within each date
|
||||
const sortedDates = Object.keys(slotsByDate).sort((a, b) => {
|
||||
return new Date(a).getTime() - new Date(b).getTime();
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{sortedDates.map((date) => {
|
||||
// Sort slots within this date by time slot order
|
||||
const slots = slotsByDate[date].sort((a: any, b: any) => {
|
||||
const aSlot = String(a.time_slot).toLowerCase().trim();
|
||||
const bSlot = String(b.time_slot).toLowerCase().trim();
|
||||
const aOrder = timeSlotOrder[aSlot] ?? 999;
|
||||
const bOrder = timeSlotOrder[bSlot] ?? 999;
|
||||
return aOrder - bOrder;
|
||||
});
|
||||
|
||||
return (
|
||||
<div key={date} className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}>
|
||||
<div className="mb-3">
|
||||
<p className={`text-base font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
{formatShortDate(date)}
|
||||
</p>
|
||||
{slots.length > 0 && slots[0]?.day !== undefined && (
|
||||
<p className={`text-sm mt-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
{dayNames[slots[0].day] || `Day ${slots[0].day}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{slots.map((slot: any, idx: number) => {
|
||||
const timeSlot = String(slot.time_slot).toLowerCase().trim();
|
||||
const timeLabel = timeSlotLabels[timeSlot] || slot.time_slot;
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium ${isDark ? "bg-green-500/20 text-green-300 border border-green-500/30" : "bg-green-50 text-green-700 border border-green-200"}`}
|
||||
>
|
||||
{timeLabel}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="p-6 space-y-6">
|
||||
{appointment.preferred_dates && appointment.preferred_dates.length > 0 && (
|
||||
<div>
|
||||
<p className={`text-sm font-medium mb-3 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
Preferred Dates
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{appointment.preferred_dates.map((date, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium ${isDark ? "bg-gray-700 text-gray-200 border border-gray-600" : "bg-gray-100 text-gray-700 border border-gray-200"}`}
|
||||
>
|
||||
{formatShortDate(date)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
{appointment.preferred_time_slots && appointment.preferred_time_slots.length > 0 && (
|
||||
<div>
|
||||
<p className={`text-sm font-medium mb-3 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
Preferred Time Slots
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{appointment.preferred_time_slots.map((slot, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium capitalize ${isDark ? "bg-rose-500/20 text-rose-300 border border-rose-500/30" : "bg-rose-50 text-rose-700 border border-rose-200"}`}
|
||||
>
|
||||
{slot}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -632,7 +456,7 @@ export default function AppointmentDetailPage() {
|
||||
)}
|
||||
|
||||
{/* Meeting Information */}
|
||||
{appointment.moderator_join_url && (
|
||||
{appointment.jitsi_meet_url && (
|
||||
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gradient-to-br from-blue-900/20 to-purple-900/20 border-blue-800/30" : "bg-gradient-to-br from-blue-50 to-purple-50 border-blue-200"}`}>
|
||||
<div className={`px-6 py-4 border-b ${isDark ? "border-blue-800/30" : "border-blue-200"}`}>
|
||||
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
@ -651,10 +475,9 @@ export default function AppointmentDetailPage() {
|
||||
{appointment.jitsi_room_id}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => appointment.can_join_as_moderator && copyToClipboard(appointment.jitsi_room_id!, "Room ID")}
|
||||
disabled={!appointment.can_join_as_moderator}
|
||||
className={`p-2 rounded-lg transition-colors ${appointment.can_join_as_moderator ? (isDark ? "hover:bg-gray-700" : "hover:bg-gray-100") : (isDark ? "opacity-50 cursor-not-allowed" : "opacity-50 cursor-not-allowed")}`}
|
||||
title={appointment.can_join_as_moderator ? "Copy room ID" : "Meeting not available"}
|
||||
onClick={() => copyToClipboard(appointment.jitsi_room_id!, "Room ID")}
|
||||
className={`p-2 rounded-lg hover:bg-opacity-80 transition-colors ${isDark ? "hover:bg-gray-700" : "hover:bg-gray-100"}`}
|
||||
title="Copy room ID"
|
||||
>
|
||||
<Copy className={`w-4 h-4 ${isDark ? "text-gray-400" : "text-gray-500"}`} />
|
||||
</button>
|
||||
@ -663,27 +486,32 @@ export default function AppointmentDetailPage() {
|
||||
)}
|
||||
<div>
|
||||
<p className={`text-xs font-medium mb-2 uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
Moderator Meeting Link
|
||||
Meeting Link
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`flex-1 text-sm px-3 py-2 rounded-lg truncate ${isDark ? "bg-gray-800/50 text-gray-500 border border-gray-700" : "bg-gray-100 text-gray-400 border border-gray-300"}`}>
|
||||
{appointment.moderator_join_url}
|
||||
</div>
|
||||
<button
|
||||
disabled
|
||||
className={`px-4 py-2 rounded-lg font-medium cursor-not-allowed ${isDark ? "bg-gray-700 text-gray-500" : "bg-gray-300 text-gray-500"}`}
|
||||
<a
|
||||
href={appointment.jitsi_meet_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`flex-1 text-sm px-3 py-2 rounded-lg truncate ${isDark ? "bg-gray-800 text-blue-400 hover:bg-gray-700" : "bg-white text-blue-600 hover:bg-gray-50 border border-gray-200"}`}
|
||||
>
|
||||
{appointment.jitsi_meet_url}
|
||||
</a>
|
||||
<a
|
||||
href={appointment.jitsi_meet_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${isDark ? "bg-blue-600 hover:bg-blue-700 text-white" : "bg-blue-600 hover:bg-blue-700 text-white"}`}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{appointment.can_join_as_moderator !== undefined && (
|
||||
<div className={`flex items-center gap-2 px-4 py-3 rounded-lg ${appointment.can_join_as_moderator ? (isDark ? "bg-green-500/20 border border-green-500/30" : "bg-green-50 border border-green-200") : (isDark ? "bg-gray-800 border border-gray-700" : "bg-gray-50 border border-gray-200")}`}>
|
||||
<div className={`h-2 w-2 rounded-full ${appointment.can_join_as_moderator ? (isDark ? "bg-green-400" : "bg-green-600") : (isDark ? "bg-gray-500" : "bg-gray-400")}`} />
|
||||
<p className={`text-sm font-medium ${appointment.can_join_as_moderator ? (isDark ? "text-green-300" : "text-green-700") : (isDark ? "text-gray-400" : "text-gray-500")}`}>
|
||||
{appointment.can_join_as_moderator
|
||||
? "Meeting is active - You can join as moderator"
|
||||
: "Click here to join"}
|
||||
{appointment.can_join_meeting !== undefined && (
|
||||
<div className={`flex items-center gap-2 px-4 py-3 rounded-lg ${appointment.can_join_meeting ? (isDark ? "bg-green-500/20 border border-green-500/30" : "bg-green-50 border border-green-200") : (isDark ? "bg-gray-800 border border-gray-700" : "bg-gray-50 border border-gray-200")}`}>
|
||||
<div className={`h-2 w-2 rounded-full ${appointment.can_join_meeting ? (isDark ? "bg-green-400" : "bg-green-600") : (isDark ? "bg-gray-500" : "bg-gray-400")}`} />
|
||||
<p className={`text-sm font-medium ${appointment.can_join_meeting ? (isDark ? "text-green-300" : "text-green-700") : (isDark ? "text-gray-400" : "text-gray-500")}`}>
|
||||
{appointment.can_join_meeting ? "Meeting is active - You can join now" : "Meeting is not available yet"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@ -726,44 +554,6 @@ export default function AppointmentDetailPage() {
|
||||
{formatStatus(appointment.status)}
|
||||
</span>
|
||||
</div>
|
||||
{appointment.scheduled_datetime && (
|
||||
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className={`text-xs font-medium mb-2 uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
Meeting Information
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className={`text-xs font-medium mb-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
Meeting Start Time:
|
||||
</p>
|
||||
<p className={`text-sm font-medium ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
{formatDate(appointment.scheduled_datetime)} at {formatTime(appointment.scheduled_datetime)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className={`text-xs font-medium mb-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
How to Access:
|
||||
</p>
|
||||
<p className={`text-sm ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||
Up to 10 minutes before the meeting is scheduled to begin, click the "Start Meeting" button below to begin the session. Once started, participants can join using the meeting link. You can also use the{" "}
|
||||
{appointment.moderator_join_url ? (
|
||||
<a
|
||||
href={appointment.moderator_join_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`font-medium underline hover:opacity-80 ${isDark ? "text-blue-400" : "text-blue-600"}`}
|
||||
>
|
||||
moderator link
|
||||
</a>
|
||||
) : (
|
||||
"moderator link"
|
||||
)}{" "}
|
||||
to join directly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -790,159 +580,160 @@ export default function AppointmentDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cancel Appointment Button */}
|
||||
{appointment.status === "scheduled" && (
|
||||
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
||||
<div className="p-6">
|
||||
<Button
|
||||
onClick={() => setCancelDialogOpen(true)}
|
||||
variant="outline"
|
||||
className={`w-full h-12 text-sm sm:text-base font-medium border-red-600 text-red-600 hover:bg-red-50 ${isDark ? "hover:bg-red-900/20 border-red-500" : ""}`}
|
||||
>
|
||||
<X className="w-4 h-4 sm:w-5 sm:h-5 mr-2 shrink-0" />
|
||||
<span className="text-center">Cancel Appointment</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Meeting Button (if scheduled) */}
|
||||
{appointment.status === "scheduled" && appointment.moderator_join_url && (
|
||||
{/* Join Meeting Button (if scheduled) */}
|
||||
{appointment.status === "scheduled" && appointment.jitsi_meet_url && (
|
||||
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gradient-to-br from-blue-900/20 to-purple-900/20 border-blue-800/30" : "bg-gradient-to-br from-blue-50 to-purple-50 border-blue-200"}`}>
|
||||
<div className="p-6 space-y-3">
|
||||
{(() => {
|
||||
// Check if meeting has ended
|
||||
const endedAt = appointment.meeting_ended_at;
|
||||
const hasEnded = endedAt != null && endedAt !== "";
|
||||
|
||||
// If meeting has ended, show "Meeting has ended"
|
||||
if (hasEnded) {
|
||||
return (
|
||||
<button
|
||||
disabled
|
||||
className={`flex items-center justify-center gap-2 w-full cursor-not-allowed h-12 rounded-lg text-sm sm:text-base font-medium transition-colors ${isDark ? "bg-gray-700 text-gray-500" : "bg-gray-300 text-gray-500"}`}
|
||||
>
|
||||
<Video className="w-4 h-4 sm:w-5 sm:h-5 shrink-0" />
|
||||
<span className="text-center">Meeting has ended</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if can join as moderator (handle both boolean and string values)
|
||||
const canJoinAsModerator = appointment.can_join_as_moderator === true || appointment.can_join_as_moderator === "true";
|
||||
// Check if meeting has started (handle both field names)
|
||||
const startedAt = appointment.started_at || appointment.meeting_started_at;
|
||||
const hasStarted = startedAt != null && startedAt !== "";
|
||||
|
||||
// If can_join_as_moderator != true, display "Meeting would be available shortly"
|
||||
if (!canJoinAsModerator) {
|
||||
return (
|
||||
<button
|
||||
disabled
|
||||
className={`flex items-center justify-center gap-2 w-full cursor-not-allowed h-12 rounded-lg text-sm sm:text-base font-medium transition-colors ${isDark ? "bg-gray-700 text-gray-500" : "bg-gray-300 text-gray-500"}`}
|
||||
>
|
||||
<Video className="w-4 h-4 sm:w-5 sm:h-5 shrink-0" />
|
||||
<span className="text-center">Click here to join</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// If can_join_as_moderator == true && started_at != null, show "Join Now" button
|
||||
if (hasStarted) {
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
href={appointment.moderator_join_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`flex items-center justify-center gap-2 w-full bg-blue-600 hover:bg-blue-700 text-white h-12 rounded-lg text-sm sm:text-base font-medium transition-colors`}
|
||||
>
|
||||
<Video className="w-4 h-4 sm:w-5 sm:h-5 shrink-0" />
|
||||
<span className="text-center">Join Now</span>
|
||||
</a>
|
||||
<Button
|
||||
onClick={handleEndMeeting}
|
||||
disabled={isEndingMeeting}
|
||||
variant="outline"
|
||||
className={`w-full h-12 text-sm sm:text-base font-medium border-red-600 text-red-600 hover:bg-red-50 ${isDark ? "hover:bg-red-900/20" : ""}`}
|
||||
>
|
||||
{isEndingMeeting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 sm:w-5 sm:h-5 mr-2 animate-spin shrink-0" />
|
||||
<span className="text-center">Ending...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<X className="w-4 h-4 sm:w-5 sm:h-5 mr-2 shrink-0" />
|
||||
<span className="text-center">End Meeting</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// If can_join_as_moderator == true && started_at == null, show "Start Meeting" button
|
||||
return (
|
||||
<Button
|
||||
onClick={handleStartMeeting}
|
||||
disabled={isStartingMeeting}
|
||||
className="w-full bg-green-600 hover:bg-green-700 text-white h-12 text-sm sm:text-base font-medium"
|
||||
>
|
||||
{isStartingMeeting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 sm:w-5 sm:h-5 mr-2 animate-spin shrink-0" />
|
||||
<span className="text-center">Starting...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Video className="w-4 h-4 sm:w-5 sm:h-5 mr-2 shrink-0" />
|
||||
<span className="text-center">Start Meeting</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})()}
|
||||
<div className="p-6">
|
||||
<a
|
||||
href={appointment.jitsi_meet_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`flex items-center justify-center gap-2 w-full bg-blue-600 hover:bg-blue-700 text-white h-12 rounded-lg text-base font-medium transition-colors`}
|
||||
>
|
||||
<Video className="w-5 h-5" />
|
||||
Join Meeting
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Schedule Appointment Dialog */}
|
||||
<ScheduleAppointmentDialog
|
||||
open={scheduleDialogOpen}
|
||||
onOpenChange={setScheduleDialogOpen}
|
||||
appointment={appointment}
|
||||
scheduledDate={scheduledDate}
|
||||
setScheduledDate={setScheduledDate}
|
||||
scheduledTime={scheduledTime}
|
||||
setScheduledTime={setScheduledTime}
|
||||
scheduledDuration={scheduledDuration}
|
||||
setScheduledDuration={setScheduledDuration}
|
||||
onSchedule={handleSchedule}
|
||||
isScheduling={isScheduling}
|
||||
isDark={isDark}
|
||||
/>
|
||||
{/* Google Meet Style Schedule Dialog */}
|
||||
<Dialog open={scheduleDialogOpen} onOpenChange={setScheduleDialogOpen}>
|
||||
<DialogContent className={`max-w-3xl ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
||||
<DialogHeader className="pb-4">
|
||||
<DialogTitle className={`text-2xl font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
Schedule Appointment
|
||||
</DialogTitle>
|
||||
<DialogDescription className={`text-base ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
Set date and time for {appointment.first_name} {appointment.last_name}'s appointment
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Date Selection */}
|
||||
<div className="space-y-3">
|
||||
<label className={`text-sm font-semibold ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||
Select Date *
|
||||
</label>
|
||||
<div className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}>
|
||||
<DatePicker
|
||||
date={scheduledDate}
|
||||
setDate={setScheduledDate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reschedule Appointment Dialog */}
|
||||
<ScheduleAppointmentDialog
|
||||
open={rescheduleDialogOpen}
|
||||
onOpenChange={setRescheduleDialogOpen}
|
||||
appointment={appointment}
|
||||
scheduledDate={rescheduleDate}
|
||||
setScheduledDate={setRescheduleDate}
|
||||
scheduledTime={rescheduleTime}
|
||||
setScheduledTime={setRescheduleTime}
|
||||
scheduledDuration={rescheduleDuration}
|
||||
setScheduledDuration={setRescheduleDuration}
|
||||
onSchedule={handleReschedule}
|
||||
isScheduling={isRescheduling}
|
||||
isDark={isDark}
|
||||
title="Reschedule Appointment"
|
||||
description={appointment ? `Change the date and time for ${appointment.first_name} ${appointment.last_name}'s appointment` : "Change the date and time for this appointment"}
|
||||
/>
|
||||
{/* Time Selection */}
|
||||
<div className="space-y-3">
|
||||
<label className={`text-sm font-semibold ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||
Select Time *
|
||||
</label>
|
||||
<div className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}>
|
||||
<Select value={scheduledTime} onValueChange={setScheduledTime}>
|
||||
<SelectTrigger className={`h-12 text-base ${isDark ? "bg-gray-800 border-gray-600 text-white" : "bg-white border-gray-300"}`}>
|
||||
<SelectValue placeholder="Choose a time" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className={isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}>
|
||||
{timeSlots.map((time) => (
|
||||
<SelectItem
|
||||
key={time}
|
||||
value={time}
|
||||
className={`h-12 text-base ${isDark ? "focus:bg-gray-700" : ""}`}
|
||||
>
|
||||
{new Date(`2000-01-01T${time}`).toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
})}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration Selection */}
|
||||
<div className="space-y-3">
|
||||
<label className={`text-sm font-semibold ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||
Duration
|
||||
</label>
|
||||
<div className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{[30, 60, 90, 120].map((duration) => (
|
||||
<button
|
||||
key={duration}
|
||||
onClick={() => setScheduledDuration(duration)}
|
||||
className={`px-4 py-3 rounded-lg text-sm font-medium transition-all ${
|
||||
scheduledDuration === duration
|
||||
? isDark
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-blue-600 text-white"
|
||||
: isDark
|
||||
? "bg-gray-800 text-gray-300 hover:bg-gray-700 border border-gray-600"
|
||||
: "bg-white text-gray-700 hover:bg-gray-50 border border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{duration} min
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{scheduledDate && (
|
||||
<div className={`p-4 rounded-xl border ${isDark ? "bg-blue-500/10 border-blue-500/30" : "bg-blue-50 border-blue-200"}`}>
|
||||
<p className={`text-sm font-medium mb-2 ${isDark ? "text-blue-300" : "text-blue-700"}`}>
|
||||
Appointment Preview
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<p className={`text-base font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
{formatDate(scheduledDate.toISOString())}
|
||||
</p>
|
||||
<p className={`text-sm ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||
{new Date(`2000-01-01T${scheduledTime}`).toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
})} • {scheduledDuration} minutes
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-3 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setScheduleDialogOpen(false)}
|
||||
disabled={isScheduling}
|
||||
className={`h-12 px-6 ${isDark ? "border-gray-700 text-gray-300 hover:bg-gray-700" : ""}`}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSchedule}
|
||||
disabled={isScheduling || !scheduledDate}
|
||||
className="h-12 px-6 bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
{isScheduling ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
||||
Scheduling...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CalendarCheck className="w-5 h-5 mr-2" />
|
||||
Schedule Appointment
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Reject Appointment Dialog */}
|
||||
<Dialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>
|
||||
@ -1004,47 +795,6 @@ export default function AppointmentDetailPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Cancel Appointment Confirmation Dialog */}
|
||||
<Dialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}>
|
||||
<DialogContent className={isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className={isDark ? "text-white" : "text-gray-900"}>
|
||||
Cancel Appointment
|
||||
</DialogTitle>
|
||||
<DialogDescription className={isDark ? "text-gray-400" : "text-gray-500"}>
|
||||
Are you sure you want to cancel this appointment? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCancelDialogOpen(false)}
|
||||
disabled={isCancelling}
|
||||
className={isDark ? "border-gray-600 text-gray-300 hover:bg-gray-700" : ""}
|
||||
>
|
||||
No, Keep Appointment
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCancelAppointment}
|
||||
disabled={isCancelling}
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
>
|
||||
{isCancelling ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Cancelling...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
Yes, Cancel Appointment
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -11,12 +11,9 @@ import {
|
||||
X,
|
||||
Loader2,
|
||||
User,
|
||||
Settings,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { useAppTheme } from "@/components/ThemeProvider";
|
||||
import { listAppointments, scheduleAppointment, rejectAppointment } from "@/lib/actions/appointments";
|
||||
import { useAppointments } from "@/hooks/useAppointments";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@ -27,7 +24,8 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScheduleAppointmentDialog } from "@/components/ScheduleAppointmentDialog";
|
||||
import { DatePicker } from "@/components/DatePicker";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import type { Appointment } from "@/lib/models/appointments";
|
||||
|
||||
@ -47,232 +45,6 @@ export default function Booking() {
|
||||
const [isRejecting, setIsRejecting] = useState(false);
|
||||
const { theme } = useAppTheme();
|
||||
const isDark = theme === "dark";
|
||||
|
||||
// Availability management
|
||||
const {
|
||||
adminAvailability,
|
||||
isLoadingAdminAvailability,
|
||||
updateAvailability,
|
||||
isUpdatingAvailability,
|
||||
refetchAdminAvailability,
|
||||
} = useAppointments();
|
||||
const [selectedDays, setSelectedDays] = useState<number[]>([]);
|
||||
const [availabilityDialogOpen, setAvailabilityDialogOpen] = useState(false);
|
||||
const [dayTimeSlots, setDayTimeSlots] = useState<Record<number, string[]>>({});
|
||||
|
||||
const daysOfWeek = [
|
||||
{ value: 0, label: "Monday" },
|
||||
{ value: 1, label: "Tuesday" },
|
||||
{ value: 2, label: "Wednesday" },
|
||||
{ value: 3, label: "Thursday" },
|
||||
{ value: 4, label: "Friday" },
|
||||
{ value: 5, label: "Saturday" },
|
||||
{ value: 6, label: "Sunday" },
|
||||
];
|
||||
|
||||
// Time slots will be loaded from API in the useEffect below
|
||||
|
||||
// Initialize selected days and time slots when availability is loaded
|
||||
useEffect(() => {
|
||||
// Try new format first (availability_schedule)
|
||||
if (adminAvailability?.availability_schedule) {
|
||||
const schedule = adminAvailability.availability_schedule;
|
||||
const days = Object.keys(schedule).map(Number);
|
||||
setSelectedDays(days);
|
||||
|
||||
// Convert schedule format to dayTimeSlots format
|
||||
const initialSlots: Record<number, string[]> = {};
|
||||
days.forEach((day) => {
|
||||
initialSlots[day] = schedule[day.toString()] || [];
|
||||
});
|
||||
|
||||
setDayTimeSlots(initialSlots);
|
||||
}
|
||||
// Fallback to legacy format
|
||||
else if (adminAvailability?.available_days) {
|
||||
setSelectedDays(adminAvailability.available_days);
|
||||
// Load saved time slots or use defaults
|
||||
const savedTimeSlots = localStorage.getItem("adminAvailabilityTimeSlots");
|
||||
let initialSlots: Record<number, string[]> = {};
|
||||
|
||||
if (savedTimeSlots) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedTimeSlots);
|
||||
// Only use saved slots for days that are currently available
|
||||
adminAvailability.available_days.forEach((day) => {
|
||||
// Map old time slot names to new ones
|
||||
const oldSlots = parsed[day] || [];
|
||||
initialSlots[day] = oldSlots.map((slot: string) => {
|
||||
if (slot === "lunchtime") return "afternoon";
|
||||
if (slot === "afternoon") return "evening";
|
||||
return slot;
|
||||
}).filter((slot: string) => ["morning", "afternoon", "evening"].includes(slot));
|
||||
// If no valid slots after mapping, use defaults
|
||||
if (initialSlots[day].length === 0) {
|
||||
initialSlots[day] = ["morning", "afternoon"];
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
// If parsing fails, use defaults
|
||||
adminAvailability.available_days.forEach((day) => {
|
||||
initialSlots[day] = ["morning", "afternoon"];
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No saved slots, use defaults
|
||||
adminAvailability.available_days.forEach((day) => {
|
||||
initialSlots[day] = ["morning", "afternoon"];
|
||||
});
|
||||
}
|
||||
|
||||
setDayTimeSlots(initialSlots);
|
||||
}
|
||||
}, [adminAvailability]);
|
||||
|
||||
const timeSlotOptions = [
|
||||
{ value: "morning", label: "Morning" },
|
||||
{ value: "afternoon", label: "Lunchtime" },
|
||||
{ value: "evening", label: "Evening" },
|
||||
];
|
||||
|
||||
const handleDayToggle = (day: number) => {
|
||||
setSelectedDays((prev) => {
|
||||
const newDays = prev.includes(day)
|
||||
? prev.filter((d) => d !== day)
|
||||
: [...prev, day].sort();
|
||||
|
||||
// Initialize time slots for newly added day
|
||||
if (!prev.includes(day) && !dayTimeSlots[day]) {
|
||||
setDayTimeSlots((prevSlots) => ({
|
||||
...prevSlots,
|
||||
[day]: ["morning", "afternoon"],
|
||||
}));
|
||||
}
|
||||
|
||||
// Remove time slots for removed day
|
||||
if (prev.includes(day)) {
|
||||
setDayTimeSlots((prevSlots) => {
|
||||
const newSlots = { ...prevSlots };
|
||||
delete newSlots[day];
|
||||
return newSlots;
|
||||
});
|
||||
}
|
||||
|
||||
return newDays;
|
||||
});
|
||||
};
|
||||
|
||||
const handleTimeSlotToggle = (day: number, slot: string) => {
|
||||
setDayTimeSlots((prev) => {
|
||||
const currentSlots = prev[day] || [];
|
||||
const newSlots = currentSlots.includes(slot)
|
||||
? currentSlots.filter((s) => s !== slot)
|
||||
: [...currentSlots, slot];
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[day]: newSlots,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveAvailability = async () => {
|
||||
if (selectedDays.length === 0) {
|
||||
toast.error("Please select at least one available day");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate all time slots
|
||||
for (const day of selectedDays) {
|
||||
const timeSlots = dayTimeSlots[day];
|
||||
if (!timeSlots || timeSlots.length === 0) {
|
||||
toast.error(`Please select at least one time slot for ${daysOfWeek.find(d => d.value === day)?.label}`);
|
||||
return;
|
||||
}
|
||||
// Validate time slots are valid
|
||||
const validSlots = ["morning", "afternoon", "evening"];
|
||||
const invalidSlots = timeSlots.filter(slot => !validSlots.includes(slot));
|
||||
if (invalidSlots.length > 0) {
|
||||
toast.error(`Invalid time slots: ${invalidSlots.join(", ")}. Only morning, afternoon, and evening are allowed.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Build availability_schedule format: {"0": ["morning", "evening"], "1": ["afternoon"]}
|
||||
const availabilitySchedule: Record<string, ("morning" | "afternoon" | "evening")[]> = {};
|
||||
selectedDays.forEach(day => {
|
||||
const timeSlots = dayTimeSlots[day];
|
||||
if (timeSlots && timeSlots.length > 0) {
|
||||
// Ensure only valid time slots and remove duplicates
|
||||
const validSlots = timeSlots
|
||||
.filter((slot): slot is "morning" | "afternoon" | "evening" =>
|
||||
["morning", "afternoon", "evening"].includes(slot)
|
||||
)
|
||||
.filter((slot, index, self) => self.indexOf(slot) === index); // Remove duplicates
|
||||
|
||||
if (validSlots.length > 0) {
|
||||
availabilitySchedule[day.toString()] = validSlots;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Validate we have at least one day with slots
|
||||
if (Object.keys(availabilitySchedule).length === 0) {
|
||||
toast.error("Please select at least one day with valid time slots");
|
||||
return;
|
||||
}
|
||||
|
||||
// Send in new format
|
||||
await updateAvailability({ availability_schedule: availabilitySchedule });
|
||||
|
||||
// Also save to localStorage for backwards compatibility
|
||||
localStorage.setItem("adminAvailabilityTimeSlots", JSON.stringify(dayTimeSlots));
|
||||
|
||||
toast.success("Availability updated successfully!");
|
||||
// Refresh availability data
|
||||
if (refetchAdminAvailability) {
|
||||
await refetchAdminAvailability();
|
||||
}
|
||||
setAvailabilityDialogOpen(false);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to update availability";
|
||||
toast.error(`Failed to update availability: ${errorMessage}`, {
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenAvailabilityDialog = () => {
|
||||
// Try new format first (availability_schedule)
|
||||
if (adminAvailability?.availability_schedule) {
|
||||
const schedule = adminAvailability.availability_schedule;
|
||||
const days = Object.keys(schedule).map(Number);
|
||||
setSelectedDays(days);
|
||||
|
||||
// Convert schedule format to dayTimeSlots format
|
||||
const initialSlots: Record<number, string[]> = {};
|
||||
days.forEach((day) => {
|
||||
initialSlots[day] = schedule[day.toString()] || ["morning", "afternoon"];
|
||||
});
|
||||
setDayTimeSlots(initialSlots);
|
||||
}
|
||||
// Fallback to legacy format
|
||||
else if (adminAvailability?.available_days) {
|
||||
setSelectedDays(adminAvailability.available_days);
|
||||
// Initialize time slots for each day
|
||||
const initialSlots: Record<number, string[]> = {};
|
||||
adminAvailability.available_days.forEach((day) => {
|
||||
initialSlots[day] = dayTimeSlots[day] || ["morning", "afternoon"];
|
||||
});
|
||||
setDayTimeSlots(initialSlots);
|
||||
} else {
|
||||
// No existing availability, start fresh
|
||||
setSelectedDays([]);
|
||||
setDayTimeSlots({});
|
||||
}
|
||||
setAvailabilityDialogOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchBookings = async () => {
|
||||
@ -281,6 +53,7 @@ export default function Booking() {
|
||||
const data = await listAppointments();
|
||||
setAppointments(data || []);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch appointments:", error);
|
||||
toast.error("Failed to load appointments. Please try again.");
|
||||
setAppointments([]);
|
||||
} finally {
|
||||
@ -366,18 +139,8 @@ export default function Booking() {
|
||||
};
|
||||
|
||||
const handleSchedule = async () => {
|
||||
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");
|
||||
if (!selectedAppointment || !scheduledDate) {
|
||||
toast.error("Please select a date and time");
|
||||
return;
|
||||
}
|
||||
|
||||
@ -386,7 +149,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, 10), parseInt(minutes, 10), 0, 0);
|
||||
datetime.setHours(parseInt(hours), parseInt(minutes), 0, 0);
|
||||
const isoString = datetime.toISOString();
|
||||
|
||||
await scheduleAppointment(selectedAppointment.id, {
|
||||
@ -396,14 +159,12 @@ 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();
|
||||
setAppointments(data || []);
|
||||
} catch (error) {
|
||||
console.error("Failed to schedule appointment:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to schedule appointment";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
@ -429,6 +190,7 @@ export default function Booking() {
|
||||
const data = await listAppointments();
|
||||
setAppointments(data || []);
|
||||
} catch (error) {
|
||||
console.error("Failed to reject appointment:", error);
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to reject appointment";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
@ -436,6 +198,14 @@ export default function Booking() {
|
||||
}
|
||||
};
|
||||
|
||||
// Generate time slots
|
||||
const timeSlots = [];
|
||||
for (let hour = 8; hour <= 18; hour++) {
|
||||
for (let minute = 0; minute < 60; minute += 30) {
|
||||
const timeString = `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}`;
|
||||
timeSlots.push(timeString);
|
||||
}
|
||||
}
|
||||
|
||||
const filteredAppointments = appointments.filter(
|
||||
(appointment) =>
|
||||
@ -465,16 +235,7 @@ export default function Booking() {
|
||||
Manage and view all appointment bookings
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleOpenAvailabilityDialog}
|
||||
variant="outline"
|
||||
className={`flex items-center gap-2 ${isDark ? "border-gray-700 hover:bg-gray-800" : "border-gray-300 hover:bg-gray-50"}`}
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
Manage Availability
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="relative">
|
||||
<Search className={`absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 ${isDark ? "text-gray-400" : "text-gray-500"}`} />
|
||||
@ -488,92 +249,6 @@ export default function Booking() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Available Days Display Card */}
|
||||
{adminAvailability && (
|
||||
<div className={`mb-4 sm:mb-6 rounded-lg border p-4 sm:p-5 ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`p-2 rounded-lg ${isDark ? "bg-rose-500/20" : "bg-rose-50"}`}>
|
||||
<CalendarCheck className={`w-5 h-5 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className={`text-sm font-semibold mb-3 ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
Weekly Availability
|
||||
</h3>
|
||||
{(adminAvailability.availability_schedule || (adminAvailability.available_days_display && adminAvailability.available_days_display.length > 0)) ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(() => {
|
||||
// Try new format first
|
||||
if (adminAvailability.availability_schedule) {
|
||||
return Object.keys(adminAvailability.availability_schedule).map((dayKey) => {
|
||||
const dayNum = parseInt(dayKey);
|
||||
const dayName = daysOfWeek.find(d => d.value === dayNum)?.label || `Day ${dayNum}`;
|
||||
const timeSlots = adminAvailability.availability_schedule![dayKey] || [];
|
||||
const slotLabels = timeSlots.map((slot: string) => {
|
||||
const option = timeSlotOptions.find(opt => opt.value === slot);
|
||||
return option ? option.label : slot;
|
||||
});
|
||||
return (
|
||||
<div
|
||||
key={dayNum}
|
||||
className={`flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm whitespace-nowrap ${
|
||||
isDark
|
||||
? "bg-rose-500/10 text-rose-200 border border-rose-500/20"
|
||||
: "bg-rose-50 text-rose-700 border border-rose-200"
|
||||
}`}
|
||||
>
|
||||
<Check className={`w-3.5 h-3.5 shrink-0 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
|
||||
<span className="font-medium shrink-0">{dayName}</span>
|
||||
{slotLabels.length > 0 && (
|
||||
<span className={`text-sm shrink-0 ${isDark ? "text-rose-300" : "text-rose-600"}`}>
|
||||
({slotLabels.join(", ")})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
// Fallback to legacy format
|
||||
else if (adminAvailability.available_days && adminAvailability.available_days.length > 0) {
|
||||
return adminAvailability.available_days.map((dayNum, index) => {
|
||||
const dayName = daysOfWeek.find(d => d.value === dayNum)?.label || adminAvailability.available_days_display?.[index];
|
||||
const timeSlots = dayTimeSlots[dayNum] || [];
|
||||
const slotLabels = timeSlots.map(slot => {
|
||||
const option = timeSlotOptions.find(opt => opt.value === slot);
|
||||
return option ? option.label : slot;
|
||||
});
|
||||
return (
|
||||
<div
|
||||
key={dayNum}
|
||||
className={`flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm whitespace-nowrap ${
|
||||
isDark
|
||||
? "bg-rose-500/10 text-rose-200 border border-rose-500/20"
|
||||
: "bg-rose-50 text-rose-700 border border-rose-200"
|
||||
}`}
|
||||
>
|
||||
<Check className={`w-3.5 h-3.5 shrink-0 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
|
||||
<span className="font-medium shrink-0">{dayName}</span>
|
||||
{slotLabels.length > 0 && (
|
||||
<span className={`text-sm shrink-0 ${isDark ? "text-rose-300" : "text-rose-600"}`}>
|
||||
({slotLabels.join(", ")})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<p className={`text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
No availability set. Click "Manage Availability" to set your available days.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className={`animate-spin rounded-full h-8 w-8 border-b-2 ${isDark ? "border-gray-600" : "border-gray-400"}`}></div>
|
||||
@ -607,7 +282,7 @@ export default function Booking() {
|
||||
Status
|
||||
</th>
|
||||
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider hidden lg:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
Preferred Availability
|
||||
Preferred Dates
|
||||
</th>
|
||||
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider hidden xl:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
Created
|
||||
@ -678,96 +353,19 @@ export default function Booking() {
|
||||
{formatStatus(appointment.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className={`px-3 sm:px-4 md:px-6 py-4 text-xs sm:text-sm hidden lg:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
{(() => {
|
||||
const dayNames: Record<number, string> = {
|
||||
0: "Monday",
|
||||
1: "Tuesday",
|
||||
2: "Wednesday",
|
||||
3: "Thursday",
|
||||
4: "Friday",
|
||||
5: "Saturday",
|
||||
6: "Sunday",
|
||||
};
|
||||
const timeSlotLabels: Record<string, string> = {
|
||||
morning: "Morning",
|
||||
afternoon: "Lunchtime",
|
||||
evening: "Evening",
|
||||
};
|
||||
|
||||
// Show selected_slots if available
|
||||
if (appointment.selected_slots && Array.isArray(appointment.selected_slots) && appointment.selected_slots.length > 0) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{appointment.selected_slots.slice(0, 2).map((slot, idx) => (
|
||||
<span key={idx} className="text-xs sm:text-sm">
|
||||
{dayNames[slot.day] || `Day ${slot.day}`} - {timeSlotLabels[slot.time_slot] || slot.time_slot}
|
||||
</span>
|
||||
))}
|
||||
{appointment.selected_slots.length > 2 && (
|
||||
<span className="text-xs opacity-75">
|
||||
+{appointment.selected_slots.length - 2} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback to preferred_dates and preferred_time_slots if selected_slots not available
|
||||
const dates = Array.isArray(appointment.preferred_dates)
|
||||
? appointment.preferred_dates
|
||||
: appointment.preferred_dates
|
||||
? [appointment.preferred_dates]
|
||||
: [];
|
||||
|
||||
const timeSlots = Array.isArray(appointment.preferred_time_slots)
|
||||
? appointment.preferred_time_slots
|
||||
: appointment.preferred_time_slots
|
||||
? [appointment.preferred_time_slots]
|
||||
: [];
|
||||
|
||||
if (dates.length === 0 && timeSlots.length === 0) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{dates.length > 0 && (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{dates.slice(0, 2).map((date, idx) => (
|
||||
<span key={idx} className="font-medium">
|
||||
{formatDate(date)}
|
||||
</span>
|
||||
))}
|
||||
{dates.length > 2 && (
|
||||
<span className="text-xs opacity-75">
|
||||
+{dates.length - 2} more date{dates.length - 2 > 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{timeSlots.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{timeSlots.map((slot, idx) => {
|
||||
const normalizedSlot = String(slot).toLowerCase().trim();
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium ${
|
||||
isDark
|
||||
? "bg-blue-500/20 text-blue-300 border border-blue-500/30"
|
||||
: "bg-blue-50 text-blue-700 border border-blue-200"
|
||||
}`}
|
||||
>
|
||||
{timeSlotLabels[normalizedSlot] || slot}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm hidden lg:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
{appointment.preferred_dates && appointment.preferred_dates.length > 0 ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{appointment.preferred_dates.slice(0, 2).map((date, idx) => (
|
||||
<span key={idx}>{formatDate(date)}</span>
|
||||
))}
|
||||
{appointment.preferred_dates.length > 2 && (
|
||||
<span className="text-xs">+{appointment.preferred_dates.length - 2} more</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</td>
|
||||
<td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm hidden xl:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
{formatDate(appointment.created_at)}
|
||||
@ -802,13 +400,13 @@ export default function Booking() {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{appointment.moderator_join_url && (
|
||||
{appointment.jitsi_meet_url && (
|
||||
<a
|
||||
href={appointment.moderator_join_url}
|
||||
href={appointment.jitsi_meet_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`p-1.5 sm:p-2 rounded-lg transition-colors ${isDark ? "text-gray-300 hover:text-white hover:bg-gray-700" : "text-gray-600 hover:text-gray-900 hover:bg-gray-100"}`}
|
||||
title="Join Meeting as Moderator"
|
||||
title="Join Meeting"
|
||||
>
|
||||
<Video className="w-4 h-4" />
|
||||
</a>
|
||||
@ -825,27 +423,103 @@ export default function Booking() {
|
||||
</main>
|
||||
|
||||
{/* Schedule Appointment Dialog */}
|
||||
<ScheduleAppointmentDialog
|
||||
open={scheduleDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setScheduleDialogOpen(open);
|
||||
if (!open) {
|
||||
setScheduledDate(undefined);
|
||||
setScheduledTime("09:00");
|
||||
setScheduledDuration(60);
|
||||
}
|
||||
}}
|
||||
appointment={selectedAppointment}
|
||||
scheduledDate={scheduledDate}
|
||||
setScheduledDate={setScheduledDate}
|
||||
scheduledTime={scheduledTime}
|
||||
setScheduledTime={setScheduledTime}
|
||||
scheduledDuration={scheduledDuration}
|
||||
setScheduledDuration={setScheduledDuration}
|
||||
onSchedule={handleSchedule}
|
||||
isScheduling={isScheduling}
|
||||
isDark={isDark}
|
||||
/>
|
||||
<Dialog open={scheduleDialogOpen} onOpenChange={setScheduleDialogOpen}>
|
||||
<DialogContent className={`${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className={isDark ? "text-white" : "text-gray-900"}>
|
||||
Schedule Appointment
|
||||
</DialogTitle>
|
||||
<DialogDescription className={isDark ? "text-gray-400" : "text-gray-500"}>
|
||||
{selectedAppointment && (
|
||||
<>Schedule appointment for {selectedAppointment.first_name} {selectedAppointment.last_name}</>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||
Date *
|
||||
</label>
|
||||
<DatePicker
|
||||
date={scheduledDate}
|
||||
setDate={setScheduledDate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||
Time *
|
||||
</label>
|
||||
<Select value={scheduledTime} onValueChange={setScheduledTime}>
|
||||
<SelectTrigger className={isDark ? "bg-gray-700 border-gray-600 text-white" : "bg-white border-gray-300"}>
|
||||
<SelectValue placeholder="Select time" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className={isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}>
|
||||
{timeSlots.map((time) => (
|
||||
<SelectItem
|
||||
key={time}
|
||||
value={time}
|
||||
className={isDark ? "focus:bg-gray-700" : ""}
|
||||
>
|
||||
{new Date(`2000-01-01T${time}`).toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
})}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||
Duration (minutes)
|
||||
</label>
|
||||
<Select
|
||||
value={scheduledDuration.toString()}
|
||||
onValueChange={(value) => setScheduledDuration(parseInt(value))}
|
||||
>
|
||||
<SelectTrigger className={isDark ? "bg-gray-700 border-gray-600 text-white" : "bg-white border-gray-300"}>
|
||||
<SelectValue placeholder="Select duration" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className={isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}>
|
||||
<SelectItem value="30" className={isDark ? "focus:bg-gray-700" : ""}>30 minutes</SelectItem>
|
||||
<SelectItem value="60" className={isDark ? "focus:bg-gray-700" : ""}>60 minutes</SelectItem>
|
||||
<SelectItem value="90" className={isDark ? "focus:bg-gray-700" : ""}>90 minutes</SelectItem>
|
||||
<SelectItem value="120" className={isDark ? "focus:bg-gray-700" : ""}>120 minutes</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setScheduleDialogOpen(false)}
|
||||
disabled={isScheduling}
|
||||
className={isDark ? "border-gray-700 text-gray-300 hover:bg-gray-700" : ""}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSchedule}
|
||||
disabled={isScheduling || !scheduledDate}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
{isScheduling ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Scheduling...
|
||||
</>
|
||||
) : (
|
||||
"Schedule Appointment"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Reject Appointment Dialog */}
|
||||
<Dialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>
|
||||
@ -907,151 +581,6 @@ export default function Booking() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Availability Management Dialog */}
|
||||
<Dialog open={availabilityDialogOpen} onOpenChange={setAvailabilityDialogOpen}>
|
||||
<DialogContent className={`max-w-2xl max-h-[90vh] overflow-y-auto ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className={`text-xl sm:text-2xl font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
Manage Weekly Availability
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{isLoadingAdminAvailability ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-rose-600" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Days Selection with Time Ranges */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className={`text-sm font-medium mb-3 block ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||
Available Days & Times *
|
||||
</label>
|
||||
<p className={`text-xs mb-3 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
Select days and choose time slots (Morning, Afternoon, Evening) for each day
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{daysOfWeek.map((day) => {
|
||||
const isSelected = selectedDays.includes(day.value);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.value}
|
||||
className={`rounded-lg border p-4 transition-all ${
|
||||
isSelected
|
||||
? isDark
|
||||
? "bg-rose-500/10 border-rose-500/30"
|
||||
: "bg-rose-50 border-rose-200"
|
||||
: isDark
|
||||
? "bg-gray-700/50 border-gray-600"
|
||||
: "bg-gray-50 border-gray-200"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDayToggle(day.value)}
|
||||
className={`flex items-center gap-2 flex-1 ${
|
||||
isSelected ? "cursor-pointer" : "cursor-pointer"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-5 h-5 rounded border-2 flex items-center justify-center flex-shrink-0 ${
|
||||
isSelected
|
||||
? isDark
|
||||
? "bg-rose-600 border-rose-500"
|
||||
: "bg-rose-500 border-rose-500"
|
||||
: isDark
|
||||
? "border-gray-500"
|
||||
: "border-gray-300"
|
||||
}`}
|
||||
>
|
||||
{isSelected && <Check className="w-3 h-3 text-white" />}
|
||||
</div>
|
||||
<span className={`text-sm font-medium ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
{day.label}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isSelected && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-300 dark:border-gray-600">
|
||||
<label className={`text-xs font-medium mb-2 block ${isDark ? "text-gray-400" : "text-gray-600"}`}>
|
||||
Available Time Slots
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{timeSlotOptions.map((slot) => {
|
||||
const isSelectedSlot = dayTimeSlots[day.value]?.includes(slot.value) || false;
|
||||
return (
|
||||
<button
|
||||
key={slot.value}
|
||||
type="button"
|
||||
onClick={() => handleTimeSlotToggle(day.value, slot.value)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-all ${
|
||||
isSelectedSlot
|
||||
? isDark
|
||||
? "bg-rose-600 border-rose-500 text-white"
|
||||
: "bg-rose-500 border-rose-500 text-white"
|
||||
: isDark
|
||||
? "bg-gray-700 border-gray-600 text-gray-300 hover:border-rose-500"
|
||||
: "bg-white border-gray-300 text-gray-700 hover:border-rose-500"
|
||||
}`}
|
||||
>
|
||||
{slot.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setAvailabilityDialogOpen(false);
|
||||
if (adminAvailability?.available_days) {
|
||||
setSelectedDays(adminAvailability.available_days);
|
||||
}
|
||||
}}
|
||||
disabled={isUpdatingAvailability}
|
||||
className={isDark ? "border-gray-700 text-gray-300 hover:bg-gray-700" : ""}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveAvailability}
|
||||
disabled={isUpdatingAvailability || selectedDays.length === 0}
|
||||
className="bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white"
|
||||
>
|
||||
{isUpdatingAvailability ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
Save Availability
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -9,7 +8,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Users,
|
||||
UserCheck,
|
||||
@ -20,12 +18,10 @@ import {
|
||||
TrendingUp,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { useAppTheme } from "@/components/ThemeProvider";
|
||||
import { getAllUsers } from "@/lib/actions/auth";
|
||||
import { getAppointmentStats, listAppointments } from "@/lib/actions/appointments";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
import type { User } from "@/lib/models/auth";
|
||||
import type { Appointment } from "@/lib/models/appointments";
|
||||
@ -37,15 +33,8 @@ interface DashboardStats {
|
||||
upcoming_bookings: number;
|
||||
completed_bookings: number;
|
||||
cancelled_bookings: number;
|
||||
active_upcoming_meetings: number;
|
||||
total_revenue: number;
|
||||
monthly_revenue: number;
|
||||
// Percentage fields from API
|
||||
users_pct?: number;
|
||||
scheduled_pct?: number;
|
||||
completed_pct?: number;
|
||||
pending_review_pct?: number;
|
||||
rejected_pct?: number;
|
||||
trends: {
|
||||
total_users: string;
|
||||
active_users: string;
|
||||
@ -63,7 +52,6 @@ export default function Dashboard() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [timePeriod, setTimePeriod] = useState<string>("last_month");
|
||||
const { theme } = useAppTheme();
|
||||
const { user } = useAuth();
|
||||
const isDark = theme === "dark";
|
||||
|
||||
useEffect(() => {
|
||||
@ -87,12 +75,10 @@ export default function Dashboard() {
|
||||
const totalBookings = appointmentStats?.total_requests || appointments.length;
|
||||
const upcomingBookings = appointmentStats?.scheduled ||
|
||||
appointments.filter((apt) => apt.status === "scheduled").length;
|
||||
// Completed bookings from API stats
|
||||
const completedBookings = appointmentStats?.completed ||
|
||||
appointments.filter((apt) => apt.status === "completed").length;
|
||||
// Completed bookings - not in API status types, so set to 0
|
||||
const completedBookings = 0;
|
||||
const cancelledBookings = appointmentStats?.rejected ||
|
||||
appointments.filter((apt) => apt.status === "rejected").length;
|
||||
const activeUpcomingMeetings = appointmentStats?.active_upcoming_meetings || 0;
|
||||
|
||||
// Calculate revenue (assuming appointments have amount field, defaulting to 0)
|
||||
const now = new Date();
|
||||
@ -119,17 +105,16 @@ export default function Dashboard() {
|
||||
return sum + amount;
|
||||
}, 0);
|
||||
|
||||
// Trends object kept for compatibility but not used for percentage badges
|
||||
// All percentage badges now use API-provided _pct values
|
||||
// For now, use static trends (in a real app, you'd calculate these from historical data)
|
||||
const trends = {
|
||||
total_users: "0%",
|
||||
active_users: "0%",
|
||||
total_bookings: "0%",
|
||||
upcoming_bookings: "0",
|
||||
total_users: "+12%",
|
||||
active_users: "+8%",
|
||||
total_bookings: "+24%",
|
||||
upcoming_bookings: "+6",
|
||||
completed_bookings: "0%",
|
||||
cancelled_bookings: "0%",
|
||||
total_revenue: "0%",
|
||||
monthly_revenue: "0%",
|
||||
total_revenue: "+18%",
|
||||
monthly_revenue: "+32%",
|
||||
};
|
||||
|
||||
setStats({
|
||||
@ -139,18 +124,12 @@ export default function Dashboard() {
|
||||
upcoming_bookings: upcomingBookings,
|
||||
completed_bookings: completedBookings,
|
||||
cancelled_bookings: cancelledBookings,
|
||||
active_upcoming_meetings: activeUpcomingMeetings,
|
||||
total_revenue: totalRevenue,
|
||||
monthly_revenue: monthlyRevenue,
|
||||
// Include percentage fields from API
|
||||
users_pct: appointmentStats?.users_pct,
|
||||
scheduled_pct: appointmentStats?.scheduled_pct,
|
||||
completed_pct: appointmentStats?.completed_pct,
|
||||
pending_review_pct: appointmentStats?.pending_review_pct,
|
||||
rejected_pct: appointmentStats?.rejected_pct,
|
||||
trends,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch dashboard stats:", error);
|
||||
toast.error("Failed to load dashboard statistics");
|
||||
// Set default values on error
|
||||
setStats({
|
||||
@ -158,16 +137,10 @@ export default function Dashboard() {
|
||||
active_users: 0,
|
||||
total_bookings: 0,
|
||||
upcoming_bookings: 0,
|
||||
completed_bookings: 0,
|
||||
cancelled_bookings: 0,
|
||||
active_upcoming_meetings: 0,
|
||||
total_revenue: 0,
|
||||
monthly_revenue: 0,
|
||||
users_pct: undefined,
|
||||
scheduled_pct: undefined,
|
||||
completed_pct: undefined,
|
||||
pending_review_pct: undefined,
|
||||
rejected_pct: undefined,
|
||||
completed_bookings: 0,
|
||||
cancelled_bookings: 0,
|
||||
total_revenue: 0,
|
||||
monthly_revenue: 0,
|
||||
trends: {
|
||||
total_users: "0%",
|
||||
active_users: "0%",
|
||||
@ -192,49 +165,56 @@ export default function Dashboard() {
|
||||
title: "Total Users",
|
||||
value: stats?.total_users ?? 0,
|
||||
icon: Users,
|
||||
trend: stats?.users_pct !== undefined ? `${Math.round(stats.users_pct)}%` : undefined,
|
||||
trend: stats?.trends.total_users ?? "0%",
|
||||
trendUp: true,
|
||||
},
|
||||
{
|
||||
title: "Active Users",
|
||||
value: stats?.active_users ?? 0,
|
||||
icon: UserCheck,
|
||||
trend: undefined, // No _pct field from API for active users
|
||||
trend: stats?.trends.active_users ?? "0%",
|
||||
trendUp: true,
|
||||
},
|
||||
{
|
||||
title: "Total Bookings",
|
||||
value: stats?.total_bookings ?? 0,
|
||||
icon: Calendar,
|
||||
trend: undefined, // No _pct field from API for total bookings
|
||||
trend: stats?.trends.total_bookings ?? "0%",
|
||||
trendUp: true,
|
||||
},
|
||||
{
|
||||
title: "Upcoming Bookings",
|
||||
value: stats?.upcoming_bookings ?? 0,
|
||||
icon: CalendarCheck,
|
||||
trend: stats?.scheduled_pct !== undefined ? `${Math.round(stats.scheduled_pct)}%` : undefined,
|
||||
trend: stats?.trends.upcoming_bookings ?? "0",
|
||||
trendUp: true,
|
||||
},
|
||||
{
|
||||
title: "Completed Bookings",
|
||||
value: stats?.completed_bookings ?? 0,
|
||||
icon: CalendarCheck,
|
||||
trend: stats?.completed_pct !== undefined ? `${Math.round(stats.completed_pct)}%` : undefined,
|
||||
trend: stats?.trends.completed_bookings ?? "0%",
|
||||
trendUp: true,
|
||||
},
|
||||
{
|
||||
title: "Cancelled Bookings",
|
||||
value: stats?.cancelled_bookings ?? 0,
|
||||
icon: CalendarX,
|
||||
trend: stats?.rejected_pct !== undefined ? `${Math.round(stats.rejected_pct)}%` : undefined,
|
||||
trend: stats?.trends.cancelled_bookings ?? "0%",
|
||||
trendUp: false,
|
||||
},
|
||||
{
|
||||
title: "Active Upcoming Meetings",
|
||||
value: stats?.active_upcoming_meetings ?? 0,
|
||||
icon: CalendarCheck,
|
||||
trend: undefined,
|
||||
title: "Total Revenue",
|
||||
value: `$${stats?.total_revenue.toLocaleString() ?? 0}`,
|
||||
icon: DollarSign,
|
||||
trend: stats?.trends.total_revenue ?? "0%",
|
||||
trendUp: true,
|
||||
},
|
||||
{
|
||||
title: "Monthly Revenue",
|
||||
value: `$${stats?.monthly_revenue.toLocaleString() ?? 0}`,
|
||||
icon: TrendingUp,
|
||||
trend: stats?.trends.monthly_revenue ?? "0%",
|
||||
trendUp: true,
|
||||
},
|
||||
];
|
||||
@ -256,23 +236,13 @@ export default function Dashboard() {
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className={`text-2xl font-semibold mb-1 ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
Welcome Back! {user?.first_name || ""}
|
||||
Welcome Back! Hammond
|
||||
</h1>
|
||||
<p className={`text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
Here's an overview of your practice today
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/deliverables">
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`flex items-center gap-2 ${isDark ? "bg-gray-800 border-gray-700 text-gray-100 hover:bg-gray-700" : "bg-white border-gray-200 text-gray-900 hover:bg-gray-50"}`}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Deliverables</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<Select value={timePeriod} onValueChange={setTimePeriod}>
|
||||
<Select value={timePeriod} onValueChange={setTimePeriod}>
|
||||
<SelectTrigger className={`w-full sm:w-[200px] cursor-pointer ${isDark ? "bg-gray-800 border-gray-700 text-gray-100" : "bg-white border-gray-200 text-gray-900"}`}>
|
||||
<SelectValue placeholder="Select period" />
|
||||
</SelectTrigger>
|
||||
@ -282,7 +252,6 @@ export default function Dashboard() {
|
||||
<SelectItem className={isDark ? "focus:bg-gray-700" : ""} value="last_year">Last Year</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@ -292,7 +261,7 @@ export default function Dashboard() {
|
||||
) : (
|
||||
<>
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 sm:gap-4">
|
||||
{statCards.map((card, index) => {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
@ -304,29 +273,26 @@ export default function Dashboard() {
|
||||
<div className={`p-2 sm:p-2.5 rounded-lg ${isDark ? "bg-gray-700" : "bg-gray-50"}`}>
|
||||
<Icon className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? "text-gray-200" : "text-gray-600"}`} />
|
||||
</div>
|
||||
{card.trend && (
|
||||
<div className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${getTrendClasses(card.trendUp)}`}>
|
||||
{card.trendUp ? (
|
||||
<ArrowUpRight className="w-3 h-3" />
|
||||
) : (
|
||||
<ArrowDownRight className="w-3 h-3" />
|
||||
)}
|
||||
<span className="hidden sm:inline">{card.trend}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${getTrendClasses(card.trendUp)}`}>
|
||||
{card.trendUp ? (
|
||||
<ArrowUpRight className="w-3 h-3" />
|
||||
) : (
|
||||
<ArrowDownRight className="w-3 h-3" />
|
||||
)}
|
||||
<span className="hidden sm:inline">{card.trend}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className={`text-xs font-medium mb-1 sm:mb-2 uppercase tracking-wider ${isDark ? "text-rose-300" : "text-rose-600"}`}>
|
||||
{card.title}
|
||||
</h3>
|
||||
<p className={`text-xl sm:text-2xl font-bold ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
<p className={`text-xl sm:text-2xl font-bold mb-1 ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
{card.value}
|
||||
</p>
|
||||
{/* "vs last month" text commented out */}
|
||||
{/* <p className={`text-xs ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
vs last month
|
||||
</p> */}
|
||||
<p className={`text-xs ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
vs last month
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } 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,21 +13,16 @@ import {
|
||||
Lock,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useAppTheme } from "@/components/ThemeProvider";
|
||||
import { getProfile, updateProfile } from "@/lib/actions/auth";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetching, setFetching] = useState(true);
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
fullName: "Hammond",
|
||||
email: "admin@attuneheart.com",
|
||||
phone: "+1 (555) 123-4567",
|
||||
});
|
||||
const [passwordData, setPasswordData] = useState({
|
||||
currentPassword: "",
|
||||
@ -42,29 +37,6 @@ export default function AdminSettingsPage() {
|
||||
const { theme } = useAppTheme();
|
||||
const isDark = theme === "dark";
|
||||
|
||||
// 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) {
|
||||
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,
|
||||
@ -87,25 +59,11 @@ export default function AdminSettingsPage() {
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.firstName || !formData.lastName) {
|
||||
toast.error("First name and last name are required");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateProfile({
|
||||
first_name: formData.firstName,
|
||||
last_name: formData.lastName,
|
||||
phone_number: formData.phone || undefined,
|
||||
});
|
||||
toast.success("Profile updated successfully!");
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to update profile";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// Simulate API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
setLoading(false);
|
||||
// In a real app, you would show a success message here
|
||||
};
|
||||
|
||||
const handlePasswordSave = async () => {
|
||||
@ -155,20 +113,15 @@ export default function AdminSettingsPage() {
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={loading || fetching}
|
||||
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"
|
||||
disabled={loading}
|
||||
className="w-full sm:w-auto bg-linear-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...
|
||||
</>
|
||||
<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
|
||||
</>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -186,49 +139,21 @@ export default function AdminSettingsPage() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{fetching ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-rose-600" />
|
||||
<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" : ""}`}
|
||||
placeholder="Enter your full name"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||
First 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.firstName}
|
||||
onChange={(e) => handleInputChange("firstName", e.target.value)}
|
||||
className={`pl-10 ${isDark ? "bg-gray-700 border-gray-600 text-white placeholder:text-gray-400" : ""}`}
|
||||
placeholder="Enter your first name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||
Last 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.lastName}
|
||||
onChange={(e) => handleInputChange("lastName", e.target.value)}
|
||||
className={`pl-10 ${isDark ? "bg-gray-700 border-gray-600 text-white placeholder:text-gray-400" : ""}`}
|
||||
placeholder="Enter your last name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||
@ -239,14 +164,11 @@ export default function AdminSettingsPage() {
|
||||
<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="Email address"
|
||||
onChange={(e) => handleInputChange("email", e.target.value)}
|
||||
className={`pl-10 ${isDark ? "bg-gray-700 border-gray-600 text-white placeholder:text-gray-400" : ""}`}
|
||||
placeholder="Enter your email"
|
||||
/>
|
||||
</div>
|
||||
<p className={`text-xs ${isDark ? "text-gray-500" : "text-gray-400"}`}>
|
||||
Email address cannot be changed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
||||
@ -8,7 +8,7 @@ import {
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "@/components/ui/input-otp";
|
||||
import { Heart, Eye, EyeOff, X, Loader2, CheckCircle2, Mail } from "lucide-react";
|
||||
import { Heart, Eye, EyeOff, X, Loader2, CheckCircle2 } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
@ -34,7 +34,6 @@ function LoginContent() {
|
||||
const [showPassword2, setShowPassword2] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [registeredEmail, setRegisteredEmail] = useState("");
|
||||
const [showResendOtp, setShowResendOtp] = useState(false);
|
||||
|
||||
// Login form data
|
||||
const [loginData, setLoginData] = useState<LoginInput>({
|
||||
@ -103,8 +102,8 @@ function LoginContent() {
|
||||
const timer = setTimeout(() => {
|
||||
// Always redirect based on user role, ignore redirect parameter if user is admin
|
||||
const redirectParam = searchParams.get("redirect");
|
||||
const defaultRedirect = isAdmin ? "/admin/booking" : "/user/dashboard";
|
||||
const finalRedirect = isAdmin ? "/admin/booking" : (redirectParam || defaultRedirect);
|
||||
const defaultRedirect = isAdmin ? "/admin/dashboard" : "/user/dashboard";
|
||||
const finalRedirect = isAdmin ? "/admin/dashboard" : (redirectParam || defaultRedirect);
|
||||
|
||||
// Use window.location.href to ensure full page reload and cookie reading
|
||||
window.location.href = finalRedirect;
|
||||
@ -139,28 +138,23 @@ function LoginContent() {
|
||||
// Wait a moment for cookies to be set, then redirect
|
||||
// Check if user is admin/staff/superuser - check all possible field names
|
||||
const user = result.user as any;
|
||||
const isTruthy = (value: any): boolean => {
|
||||
if (value === true || value === "true" || value === 1 || value === "1") return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const userIsAdmin =
|
||||
isTruthy(user.is_admin) ||
|
||||
isTruthy(user.isAdmin) ||
|
||||
isTruthy(user.is_staff) ||
|
||||
isTruthy(user.isStaff) ||
|
||||
isTruthy(user.is_superuser) ||
|
||||
isTruthy(user.isSuperuser);
|
||||
user.is_admin === true ||
|
||||
user.isAdmin === true ||
|
||||
user.is_staff === true ||
|
||||
user.isStaff === true ||
|
||||
user.is_superuser === true ||
|
||||
user.isSuperuser === true;
|
||||
|
||||
// Wait longer for cookies to be set and middleware to process
|
||||
setTimeout(() => {
|
||||
// Always redirect based on user role, ignore redirect parameter if user is admin
|
||||
// This ensures admins always go to admin booking page
|
||||
const defaultRedirect = userIsAdmin ? "/admin/booking" : "/user/dashboard";
|
||||
// This ensures admins always go to admin dashboard
|
||||
const defaultRedirect = userIsAdmin ? "/admin/dashboard" : "/user/dashboard";
|
||||
|
||||
// Only use redirect parameter if user is NOT admin
|
||||
const redirectParam = searchParams.get("redirect");
|
||||
const finalRedirect = userIsAdmin ? "/admin/booking" : (redirectParam || defaultRedirect);
|
||||
const finalRedirect = userIsAdmin ? "/admin/dashboard" : (redirectParam || defaultRedirect);
|
||||
|
||||
// Use window.location.href instead of router.push to ensure full page reload
|
||||
// This ensures cookies are read correctly by middleware
|
||||
@ -170,15 +164,6 @@ function LoginContent() {
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Login failed. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
|
||||
// Check if error is about email verification
|
||||
if (errorMessage.toLowerCase().includes("verify your email") ||
|
||||
errorMessage.toLowerCase().includes("email address before logging")) {
|
||||
setShowResendOtp(true);
|
||||
} else {
|
||||
setShowResendOtp(false);
|
||||
}
|
||||
|
||||
setErrors({});
|
||||
}
|
||||
};
|
||||
@ -390,14 +375,33 @@ function LoginContent() {
|
||||
{/* Heading */}
|
||||
<h1 className="text-3xl font-bold bg-linear-to-r from-rose-600 via-pink-600 to-rose-600 bg-clip-text text-transparent mb-2">
|
||||
{step === "login" && "Welcome back"}
|
||||
{step === "signup" && "Create an account"}
|
||||
{step === "verify" && "Verify your email"}
|
||||
</h1>
|
||||
{/* Subtitle */}
|
||||
{step === "login" && (
|
||||
<p className={`mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||
Sign in to access your admin dashboard
|
||||
New to Attune Heart Therapy?{" "}
|
||||
<Link
|
||||
href="/signup"
|
||||
className={`underline font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
{step === "signup" && (
|
||||
<p className={`mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||
Already have an account?{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep("login")}
|
||||
className={`underline font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{step === "verify" && registeredEmail && (
|
||||
<p className={`mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||
We've sent a verification code to <strong>{registeredEmail}</strong>
|
||||
@ -494,56 +498,6 @@ function LoginContent() {
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Resend OTP - Show when email verification error occurs */}
|
||||
{showResendOtp && (
|
||||
<div className={`p-4 rounded-lg border ${isDark ? 'bg-yellow-900/20 border-yellow-800' : 'bg-yellow-50 border-yellow-200'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<Mail className={`w-5 h-5 mt-0.5 flex-shrink-0 ${isDark ? 'text-yellow-400' : 'text-yellow-600'}`} />
|
||||
<div className="flex-1">
|
||||
<p className={`text-sm font-medium ${isDark ? 'text-yellow-200' : 'text-yellow-900'}`}>
|
||||
Email verification required
|
||||
</p>
|
||||
<p className={`text-xs sm:text-sm mt-1 ${isDark ? 'text-yellow-300' : 'text-yellow-700'}`}>
|
||||
Please verify your email address before logging in. We can resend the verification code to {loginData.email}.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
onClick={async () => {
|
||||
if (!loginData.email) {
|
||||
toast.error("Email address is required to resend OTP.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await resendOtpMutation.mutateAsync({
|
||||
email: loginData.email,
|
||||
context: "registration"
|
||||
});
|
||||
toast.success("OTP resent successfully! Please check your email.");
|
||||
setShowResendOtp(false);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to resend OTP. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
}}
|
||||
disabled={resendOtpMutation.isPending}
|
||||
className={`h-auto p-0 mt-2 text-xs sm:text-sm font-medium ${isDark ? 'text-yellow-400 hover:text-yellow-300' : 'text-yellow-700 hover:text-yellow-800'}`}
|
||||
>
|
||||
{resendOtpMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-3 h-3 mr-1 animate-spin inline" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
"Resend verification code"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remember Me & Forgot Password */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
@ -565,6 +519,168 @@ function LoginContent() {
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Signup Form */}
|
||||
{step === "signup" && (
|
||||
<form className="space-y-4" onSubmit={handleSignup}>
|
||||
{/* First Name Field */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="firstName" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
First Name *
|
||||
</label>
|
||||
<Input
|
||||
id="firstName"
|
||||
type="text"
|
||||
placeholder="John"
|
||||
value={signupData.first_name}
|
||||
onChange={(e) => handleSignupChange("first_name", e.target.value)}
|
||||
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'} ${errors.first_name ? 'border-red-500' : ''}`}
|
||||
required
|
||||
/>
|
||||
{errors.first_name && (
|
||||
<p className="text-sm text-red-500">{errors.first_name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Last Name Field */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="lastName" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Last Name *
|
||||
</label>
|
||||
<Input
|
||||
id="lastName"
|
||||
type="text"
|
||||
placeholder="Doe"
|
||||
value={signupData.last_name}
|
||||
onChange={(e) => handleSignupChange("last_name", e.target.value)}
|
||||
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'} ${errors.last_name ? 'border-red-500' : ''}`}
|
||||
required
|
||||
/>
|
||||
{errors.last_name && (
|
||||
<p className="text-sm text-red-500">{errors.last_name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email Field */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="signup-email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Email address *
|
||||
</label>
|
||||
<Input
|
||||
id="signup-email"
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
value={signupData.email}
|
||||
onChange={(e) => handleSignupChange("email", e.target.value)}
|
||||
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'} ${errors.email ? 'border-red-500' : ''}`}
|
||||
required
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-red-500">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phone Field */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="phone" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Phone Number (Optional)
|
||||
</label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
placeholder="+1 (555) 123-4567"
|
||||
value={signupData.phone_number || ""}
|
||||
onChange={(e) => handleSignupChange("phone_number", e.target.value)}
|
||||
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="signup-password" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Password *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="signup-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Password (min 8 characters)"
|
||||
value={signupData.password}
|
||||
onChange={(e) => handleSignupChange("password", e.target.value)}
|
||||
className={`h-11 pr-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'} ${errors.password ? 'border-red-500' : ''}`}
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className={`absolute right-4 top-1/2 -translate-y-1/2 h-auto w-auto p-0 ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-700'}`}
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-5 h-5" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-red-500">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password Field */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="signup-password2" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Confirm Password *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="signup-password2"
|
||||
type={showPassword2 ? "text" : "password"}
|
||||
placeholder="Confirm password"
|
||||
value={signupData.password2}
|
||||
onChange={(e) => handleSignupChange("password2", e.target.value)}
|
||||
className={`h-11 pr-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'} ${errors.password2 ? 'border-red-500' : ''}`}
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowPassword2(!showPassword2)}
|
||||
className={`absolute right-4 top-1/2 -translate-y-1/2 h-auto w-auto p-0 ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-700'}`}
|
||||
aria-label={showPassword2 ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword2 ? (
|
||||
<EyeOff className="w-5 h-5" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{errors.password2 && (
|
||||
<p className="text-sm text-red-500">{errors.password2}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={registerMutation.isPending}
|
||||
className="w-full h-12 text-base font-semibold bg-linear-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 disabled:opacity-50 disabled:cursor-not-allowed mt-6"
|
||||
>
|
||||
{registerMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating account...
|
||||
</>
|
||||
) : (
|
||||
"Sign up"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* OTP Verification Form */}
|
||||
{step === "verify" && (
|
||||
@ -659,17 +775,17 @@ function LoginContent() {
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Back to login */}
|
||||
{/* Back to signup */}
|
||||
<div className="text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep("login");
|
||||
setStep("signup");
|
||||
setOtpData({ email: "", otp: "" });
|
||||
}}
|
||||
className={`text-sm font-medium ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-600 hover:text-gray-700'}`}
|
||||
>
|
||||
← Back to login
|
||||
← Back to signup
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -44,7 +44,7 @@ function SignupContent() {
|
||||
// Redirect if already authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
const redirect = searchParams.get("redirect") || "/admin/booking";
|
||||
const redirect = searchParams.get("redirect") || "/admin/dashboard";
|
||||
router.push(redirect);
|
||||
}
|
||||
}, [isAuthenticated, router, searchParams]);
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAppTheme } from "@/components/ThemeProvider";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@ -24,13 +24,11 @@ import {
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
LogOut,
|
||||
CalendarCheck,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LoginDialog } from "@/components/LoginDialog";
|
||||
import { SignupDialog } from "@/components/SignupDialog";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useAppointments } from "@/hooks/useAppointments";
|
||||
import { toast } from "sonner";
|
||||
@ -81,141 +79,19 @@ export default function BookNowPage() {
|
||||
const { theme } = useAppTheme();
|
||||
const isDark = theme === "dark";
|
||||
const { isAuthenticated, logout } = useAuth();
|
||||
const {
|
||||
create,
|
||||
isCreating,
|
||||
weeklyAvailability,
|
||||
isLoadingWeeklyAvailability,
|
||||
availabilityOverview,
|
||||
isLoadingAvailabilityOverview,
|
||||
availabilityConfig,
|
||||
} = useAppointments();
|
||||
const { create, isCreating } = useAppointments();
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
selectedSlots: [] as Array<{ day: number; time_slot: string }>, // New format
|
||||
preferredDays: [] as string[],
|
||||
preferredTimes: [] as string[],
|
||||
message: "",
|
||||
});
|
||||
const [booking, setBooking] = useState<Booking | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showLoginDialog, setShowLoginDialog] = useState(false);
|
||||
const [showSignupDialog, setShowSignupDialog] = useState(false);
|
||||
const [loginPrefillEmail, setLoginPrefillEmail] = useState<string | undefined>(undefined);
|
||||
|
||||
// Helper function to convert day name to day number (0-6)
|
||||
const getDayNumber = (dayName: string): number => {
|
||||
const dayMap: Record<string, number> = {
|
||||
'monday': 0,
|
||||
'tuesday': 1,
|
||||
'wednesday': 2,
|
||||
'thursday': 3,
|
||||
'friday': 4,
|
||||
'saturday': 5,
|
||||
'sunday': 6,
|
||||
};
|
||||
return dayMap[dayName.toLowerCase()] ?? -1;
|
||||
};
|
||||
|
||||
// Get available days from availability overview (primary) or weekly availability (fallback)
|
||||
const availableDaysOfWeek = useMemo(() => {
|
||||
// Try availability overview first (preferred)
|
||||
if (availabilityOverview && availabilityOverview.available && availabilityOverview.next_available_dates && availabilityOverview.next_available_dates.length > 0) {
|
||||
// Group by day name and get unique days with their slots from next_available_dates
|
||||
const dayMap = new Map<string, { day: number; dayName: string; availableSlots: Set<string> }>();
|
||||
|
||||
availabilityOverview.next_available_dates.forEach((dateInfo: any) => {
|
||||
if (!dateInfo || !dateInfo.day_name) return;
|
||||
|
||||
const dayName = String(dateInfo.day_name).trim();
|
||||
const dayNum = getDayNumber(dayName);
|
||||
|
||||
if (dayNum >= 0 && dayNum <= 6 && dateInfo.available_slots && Array.isArray(dateInfo.available_slots) && dateInfo.available_slots.length > 0) {
|
||||
const existingDay = dayMap.get(dayName);
|
||||
|
||||
if (existingDay) {
|
||||
// Merge slots if day already exists
|
||||
dateInfo.available_slots.forEach((slot: string) => {
|
||||
existingDay.availableSlots.add(String(slot).toLowerCase().trim());
|
||||
});
|
||||
} else {
|
||||
// Create new day entry
|
||||
const slotsSet = new Set<string>();
|
||||
dateInfo.available_slots.forEach((slot: string) => {
|
||||
slotsSet.add(String(slot).toLowerCase().trim());
|
||||
});
|
||||
dayMap.set(dayName, {
|
||||
day: dayNum,
|
||||
dayName: dayName,
|
||||
availableSlots: slotsSet,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Time slot order: morning, afternoon (lunchtime), evening
|
||||
const timeSlotOrder: Record<string, number> = {
|
||||
morning: 0,
|
||||
afternoon: 1,
|
||||
evening: 2,
|
||||
};
|
||||
|
||||
// Convert Map values to array, sort slots, and sort by day number
|
||||
return Array.from(dayMap.values())
|
||||
.map(day => ({
|
||||
day: day.day,
|
||||
dayName: day.dayName,
|
||||
availableSlots: Array.from(day.availableSlots).sort((a, b) => {
|
||||
const aOrder = timeSlotOrder[a.toLowerCase().trim()] ?? 999;
|
||||
const bOrder = timeSlotOrder[b.toLowerCase().trim()] ?? 999;
|
||||
return aOrder - bOrder;
|
||||
}),
|
||||
}))
|
||||
.sort((a, b) => a.day - b.day);
|
||||
}
|
||||
|
||||
// Fallback to weekly availability
|
||||
if (weeklyAvailability) {
|
||||
// Handle both array format and object with 'week' property
|
||||
const weekArray = Array.isArray(weeklyAvailability)
|
||||
? weeklyAvailability
|
||||
: (weeklyAvailability as any)?.week;
|
||||
|
||||
if (weekArray && Array.isArray(weekArray)) {
|
||||
// Time slot order: morning, afternoon (lunchtime), evening
|
||||
const timeSlotOrder: Record<string, number> = {
|
||||
morning: 0,
|
||||
afternoon: 1,
|
||||
evening: 2,
|
||||
};
|
||||
|
||||
return weekArray
|
||||
.filter(day => {
|
||||
const dayNum = Number(day.day);
|
||||
return day.is_available &&
|
||||
day.available_slots &&
|
||||
Array.isArray(day.available_slots) &&
|
||||
day.available_slots.length > 0 &&
|
||||
!isNaN(dayNum) &&
|
||||
dayNum >= 0 &&
|
||||
dayNum <= 6;
|
||||
})
|
||||
.map(day => ({
|
||||
day: Number(day.day),
|
||||
dayName: day.day_name || 'Unknown',
|
||||
availableSlots: (day.available_slots || []).sort((a: string, b: string) => {
|
||||
const aOrder = timeSlotOrder[String(a).toLowerCase().trim()] ?? 999;
|
||||
const bOrder = timeSlotOrder[String(b).toLowerCase().trim()] ?? 999;
|
||||
return aOrder - bOrder;
|
||||
}),
|
||||
}))
|
||||
.sort((a, b) => a.day - b.day);
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [availabilityOverview, weeklyAvailability]);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
@ -229,8 +105,7 @@ export default function BookNowPage() {
|
||||
|
||||
// Check if user is authenticated
|
||||
if (!isAuthenticated) {
|
||||
// Show alert and open login dialog if not authenticated
|
||||
toast.error("Please log in or sign up to book an appointment");
|
||||
// Open login dialog if not authenticated
|
||||
setShowLoginDialog(true);
|
||||
return;
|
||||
}
|
||||
@ -242,144 +117,64 @@ export default function BookNowPage() {
|
||||
const handleLoginSuccess = async () => {
|
||||
// Close login dialog
|
||||
setShowLoginDialog(false);
|
||||
// If there's a pending booking submission, proceed with it
|
||||
// Otherwise, just close the dialog and allow user to fill the form
|
||||
if (formData.selectedSlots.length > 0 && formData.firstName && formData.lastName && formData.email) {
|
||||
await submitBooking();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignupSuccess = async () => {
|
||||
// Close signup dialog
|
||||
setShowSignupDialog(false);
|
||||
// If there's a pending booking submission, proceed with it
|
||||
// Otherwise, just close the dialog and allow user to fill the form
|
||||
if (formData.selectedSlots.length > 0 && formData.firstName && formData.lastName && formData.email) {
|
||||
await submitBooking();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitchToSignup = () => {
|
||||
// Close login dialog and open signup dialog
|
||||
setShowLoginDialog(false);
|
||||
setTimeout(() => {
|
||||
setShowSignupDialog(true);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleSwitchToLogin = (email?: string) => {
|
||||
// Close signup dialog and open login dialog with email prefilled
|
||||
setShowSignupDialog(false);
|
||||
setLoginPrefillEmail(email);
|
||||
setTimeout(() => {
|
||||
setShowLoginDialog(true);
|
||||
}, 100);
|
||||
// After successful login, proceed with booking submission
|
||||
await submitBooking();
|
||||
};
|
||||
|
||||
const submitBooking = async () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Get current slots from formData
|
||||
const currentSlots = formData.selectedSlots || [];
|
||||
|
||||
// Check if slots are selected
|
||||
if (!currentSlots || currentSlots.length === 0) {
|
||||
setError("Please select at least one day and time slot combination by clicking on the time slot buttons.");
|
||||
if (formData.preferredDays.length === 0) {
|
||||
setError("Please select at least one available day.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare and validate slots - only send exactly what user selected
|
||||
// Filter out duplicates and ensure we only send the specific selected slots
|
||||
const uniqueSlots = new Map<string, { day: number; time_slot: string }>();
|
||||
|
||||
currentSlots.forEach(slot => {
|
||||
if (!slot) return;
|
||||
|
||||
// Get day - handle any format
|
||||
let dayNum: number;
|
||||
if (typeof slot.day === 'number') {
|
||||
dayNum = slot.day;
|
||||
} else {
|
||||
dayNum = parseInt(String(slot.day || 0), 10);
|
||||
}
|
||||
|
||||
// Validate day
|
||||
if (isNaN(dayNum) || dayNum < 0 || dayNum > 6) {
|
||||
if (formData.preferredTimes.length === 0) {
|
||||
setError("Please select at least one preferred time.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get time_slot - normalize
|
||||
const timeSlot = String(slot.time_slot || '').trim().toLowerCase();
|
||||
// Convert day names to dates (YYYY-MM-DD format)
|
||||
// Get next occurrence of each selected day
|
||||
const today = new Date();
|
||||
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;
|
||||
|
||||
// Validate time_slot - accept morning, afternoon, evening
|
||||
if (!timeSlot || !['morning', 'afternoon', 'evening'].includes(timeSlot)) {
|
||||
return;
|
||||
}
|
||||
let daysUntilTarget = (targetDayIndex - today.getDay() + 7) % 7;
|
||||
if (daysUntilTarget === 0) daysUntilTarget = 7; // Next week if today
|
||||
|
||||
// Create unique key to prevent duplicates
|
||||
const uniqueKey = `${dayNum}-${timeSlot}`;
|
||||
uniqueSlots.set(uniqueKey, {
|
||||
day: dayNum,
|
||||
time_slot: timeSlot as "morning" | "afternoon" | "evening",
|
||||
});
|
||||
const targetDate = new Date(today);
|
||||
targetDate.setDate(today.getDate() + daysUntilTarget);
|
||||
const dateString = targetDate.toISOString().split("T")[0];
|
||||
preferredDates.push(dateString);
|
||||
});
|
||||
|
||||
// Map time slots - API expects "morning", "afternoon", "evening"
|
||||
// Form has "morning", "lunchtime", "afternoon"
|
||||
const timeSlotMap: { [key: string]: "morning" | "afternoon" | "evening" } = {
|
||||
morning: "morning",
|
||||
lunchtime: "afternoon", // Map lunchtime to afternoon
|
||||
afternoon: "afternoon",
|
||||
};
|
||||
|
||||
// Convert map to array
|
||||
const validSlots = Array.from(uniqueSlots.values()).map(slot => ({
|
||||
day: slot.day,
|
||||
time_slot: slot.time_slot as "morning" | "afternoon" | "evening",
|
||||
}));
|
||||
const preferredTimeSlots = formData.preferredTimes
|
||||
.map((time) => timeSlotMap[time] || "morning")
|
||||
.filter((time, index, self) => self.indexOf(time) === index) as ("morning" | "afternoon" | "evening")[]; // Remove duplicates
|
||||
|
||||
// Final validation check
|
||||
if (!validSlots || validSlots.length === 0) {
|
||||
setError("Please select at least one day and time slot combination by clicking on the time slot buttons.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate and limit field lengths to prevent database errors
|
||||
const firstName = formData.firstName.trim().substring(0, 100);
|
||||
const lastName = formData.lastName.trim().substring(0, 100);
|
||||
const email = formData.email.trim().toLowerCase().substring(0, 100);
|
||||
const phone = formData.phone ? formData.phone.trim().substring(0, 100) : undefined;
|
||||
const reason = formData.message ? formData.message.trim().substring(0, 100) : undefined;
|
||||
|
||||
// Validate required fields
|
||||
if (!firstName || firstName.length === 0) {
|
||||
setError("First name is required.");
|
||||
return;
|
||||
}
|
||||
if (!lastName || lastName.length === 0) {
|
||||
setError("Last name is required.");
|
||||
return;
|
||||
}
|
||||
if (!email || email.length === 0) {
|
||||
setError("Email address is required.");
|
||||
return;
|
||||
}
|
||||
if (!email.includes('@')) {
|
||||
setError("Please enter a valid email address.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare payload with validated and limited fields
|
||||
// CRITICAL: Only send exactly what the user selected, nothing more
|
||||
const selectedSlotsPayload = validSlots.map(slot => ({
|
||||
day: Number(slot.day), // Ensure it's a number (0-6)
|
||||
time_slot: String(slot.time_slot).toLowerCase().trim() as "morning" | "afternoon" | "evening", // Ensure lowercase and correct type
|
||||
}));
|
||||
|
||||
// Build payload with ONLY the fields the API requires/accepts
|
||||
// API required: first_name, last_name, email, selected_slots
|
||||
// API optional: phone, reason
|
||||
// DO NOT include: preferred_dates, preferred_time_slots (not needed)
|
||||
// Prepare request payload according to API spec
|
||||
const payload = {
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
email: email,
|
||||
selected_slots: selectedSlotsPayload, // Only send what user explicitly selected (day + time_slot format)
|
||||
...(phone && phone.length > 0 && { phone: phone }),
|
||||
...(reason && reason.length > 0 && { reason: reason }),
|
||||
first_name: formData.firstName,
|
||||
last_name: formData.lastName,
|
||||
email: formData.email,
|
||||
preferred_dates: preferredDates,
|
||||
preferred_time_slots: preferredTimeSlots,
|
||||
...(formData.phone && { phone: formData.phone }),
|
||||
...(formData.message && { reason: formData.message }),
|
||||
};
|
||||
|
||||
// Call the actual API using the hook
|
||||
@ -420,11 +215,15 @@ export default function BookNowPage() {
|
||||
setBooking(bookingData);
|
||||
toast.success("Appointment request submitted successfully! We'll review and get back to you soon.");
|
||||
|
||||
// Stay on the booking page to show the receipt - no redirect
|
||||
// Redirect to user dashboard after 3 seconds
|
||||
setTimeout(() => {
|
||||
router.push("/user/dashboard");
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to submit booking. Please try again.";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
console.error("Booking error:", err);
|
||||
}
|
||||
};
|
||||
|
||||
@ -432,63 +231,22 @@ export default function BookNowPage() {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
// Handle slot selection (day + time slot combination)
|
||||
// CRITICAL: Only toggle the specific slot that was clicked, nothing else
|
||||
const handleSlotToggle = useCallback((day: number, timeSlot: string) => {
|
||||
const normalizedDay = Number(day);
|
||||
const normalizedTimeSlot = String(timeSlot).toLowerCase().trim();
|
||||
|
||||
// Validate inputs
|
||||
if (isNaN(normalizedDay) || normalizedDay < 0 || normalizedDay > 6) {
|
||||
return; // Invalid day, don't change anything
|
||||
}
|
||||
|
||||
if (!['morning', 'afternoon', 'evening'].includes(normalizedTimeSlot)) {
|
||||
return; // Invalid time slot, don't change anything
|
||||
}
|
||||
|
||||
setFormData((prev) => {
|
||||
|
||||
const currentSlots = prev.selectedSlots || [];
|
||||
|
||||
// Helper to check if two slots match EXACTLY (both day AND time_slot)
|
||||
const slotsMatch = (slot1: { day: number; time_slot: string }, slot2: { day: number; time_slot: string }) => {
|
||||
return Number(slot1.day) === Number(slot2.day) &&
|
||||
String(slot1.time_slot).toLowerCase().trim() === String(slot2.time_slot).toLowerCase().trim();
|
||||
};
|
||||
|
||||
const targetSlot = { day: normalizedDay, time_slot: normalizedTimeSlot };
|
||||
|
||||
// Check if this EXACT slot exists (check for duplicates too)
|
||||
const existingIndex = currentSlots.findIndex(slot => slotsMatch(slot, targetSlot));
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Remove ONLY this specific slot (also removes duplicates)
|
||||
const newSlots = currentSlots.filter(slot => !slotsMatch(slot, targetSlot));
|
||||
return {
|
||||
...prev,
|
||||
selectedSlots: newSlots,
|
||||
};
|
||||
} else {
|
||||
// Add ONLY this specific slot if it doesn't exist (prevent duplicates)
|
||||
const newSlots = [...currentSlots, targetSlot];
|
||||
return {
|
||||
...prev,
|
||||
selectedSlots: newSlots,
|
||||
};
|
||||
}
|
||||
const handleDayToggle = (day: string) => {
|
||||
setFormData((prev) => {
|
||||
const days = prev.preferredDays.includes(day)
|
||||
? prev.preferredDays.filter((d) => d !== day)
|
||||
: [...prev.preferredDays, day];
|
||||
return { ...prev, preferredDays: days };
|
||||
});
|
||||
}, []);
|
||||
};
|
||||
|
||||
// Check if a slot is selected
|
||||
const isSlotSelected = (day: number, timeSlot: string): boolean => {
|
||||
const normalizedDay = Number(day);
|
||||
const normalizedTimeSlot = String(timeSlot).toLowerCase().trim();
|
||||
|
||||
return (formData.selectedSlots || []).some(
|
||||
slot => Number(slot.day) === normalizedDay &&
|
||||
String(slot.time_slot).toLowerCase().trim() === normalizedTimeSlot
|
||||
);
|
||||
const handleTimeToggle = (time: string) => {
|
||||
setFormData((prev) => {
|
||||
const times = prev.preferredTimes.includes(time)
|
||||
? prev.preferredTimes.filter((t) => t !== time)
|
||||
: [...prev.preferredTimes, time];
|
||||
return { ...prev, preferredTimes: times };
|
||||
});
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString: string) => {
|
||||
@ -602,51 +360,77 @@ export default function BookNowPage() {
|
||||
<div className="px-4 sm:px-6 lg:px-12 pb-6 sm:pb-8 lg:pb-12">
|
||||
{booking ? (
|
||||
<div className={`rounded-xl sm:rounded-2xl shadow-lg p-4 sm:p-6 lg:p-8 border ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}>
|
||||
<div className="text-center space-y-6">
|
||||
<div className="text-center space-y-4">
|
||||
<div className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center ${isDark ? 'bg-green-900/30' : 'bg-green-100'}`}>
|
||||
<CheckCircle className={`w-8 h-8 ${isDark ? 'text-green-400' : 'text-green-600'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className={`text-2xl font-semibold mb-2 ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
Booking Request Submitted!
|
||||
Booking Confirmed!
|
||||
</h2>
|
||||
<p className={`text-base ${isDark ? 'text-gray-300' : 'text-gray-600'}`}>
|
||||
Your appointment request has been received.
|
||||
<p className={isDark ? 'text-gray-300' : 'text-gray-600'}>
|
||||
Your appointment has been successfully booked.
|
||||
</p>
|
||||
</div>
|
||||
<div className={`rounded-lg p-6 space-y-4 text-left ${isDark ? 'bg-gray-700/50' : 'bg-gray-50'}`}>
|
||||
<div>
|
||||
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Name</p>
|
||||
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Booking ID</p>
|
||||
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>#{booking.ID}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Patient</p>
|
||||
<p className={`text-base ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{booking.user.first_name} {booking.user.last_name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Email</p>
|
||||
<p className={`text-base ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{booking.user.email}
|
||||
</p>
|
||||
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Scheduled Time</p>
|
||||
<p className={`text-base ${isDark ? 'text-white' : 'text-gray-900'}`}>{formatDateTime(booking.scheduled_at)}</p>
|
||||
</div>
|
||||
{booking.user.phone && (
|
||||
<div>
|
||||
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Phone</p>
|
||||
<p className={`text-base ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{booking.user.phone}
|
||||
</p>
|
||||
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Duration</p>
|
||||
<p className={`text-base ${isDark ? 'text-white' : 'text-gray-900'}`}>{booking.duration} minutes</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Status</p>
|
||||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${isDark ? 'bg-blue-900/50 text-blue-200' : 'bg-blue-100 text-blue-800'}`}>
|
||||
{booking.status}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Amount</p>
|
||||
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>${booking.amount}</p>
|
||||
</div>
|
||||
{booking.notes && (
|
||||
<div>
|
||||
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Notes</p>
|
||||
<p className={`text-base ${isDark ? 'text-white' : 'text-gray-900'}`}>{booking.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`rounded-lg p-4 ${isDark ? 'bg-blue-900/20 border border-blue-800/50' : 'bg-blue-50 border border-blue-200'}`}>
|
||||
<p className={`text-sm ${isDark ? 'text-blue-300' : 'text-blue-800'}`}>
|
||||
You will be contacted shortly to confirm your appointment.
|
||||
</p>
|
||||
</div>
|
||||
<div className="pt-4 flex justify-center">
|
||||
<div className="pt-4 flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Button
|
||||
onClick={() => router.back()}
|
||||
onClick={() => {
|
||||
setBooking(null);
|
||||
setFormData({
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
preferredDays: [],
|
||||
preferredTimes: [],
|
||||
message: "",
|
||||
});
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
Book Another Appointment
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => router.push("/")}
|
||||
className="bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white"
|
||||
>
|
||||
Go Back
|
||||
Return to Home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -683,7 +467,6 @@ export default function BookNowPage() {
|
||||
onChange={(e) =>
|
||||
handleChange("firstName", e.target.value)
|
||||
}
|
||||
maxLength={100}
|
||||
required
|
||||
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900 placeholder:text-gray-500'}`}
|
||||
/>
|
||||
@ -704,7 +487,6 @@ export default function BookNowPage() {
|
||||
onChange={(e) =>
|
||||
handleChange("lastName", e.target.value)
|
||||
}
|
||||
maxLength={100}
|
||||
required
|
||||
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900 placeholder:text-gray-500'}`}
|
||||
/>
|
||||
@ -725,7 +507,6 @@ export default function BookNowPage() {
|
||||
placeholder="john.doe@example.com"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange("email", e.target.value)}
|
||||
maxLength={100}
|
||||
required
|
||||
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900 placeholder:text-gray-500'}`}
|
||||
/>
|
||||
@ -745,7 +526,6 @@ export default function BookNowPage() {
|
||||
placeholder="+1 (555) 123-4567"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleChange("phone", e.target.value)}
|
||||
maxLength={100}
|
||||
required
|
||||
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900 placeholder:text-gray-500'}`}
|
||||
/>
|
||||
@ -759,95 +539,75 @@ export default function BookNowPage() {
|
||||
Appointment Details
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
className={`text-sm font-medium flex items-center gap-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}
|
||||
>
|
||||
<Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-500'}`} />
|
||||
Available Days & Times *
|
||||
Available Days *
|
||||
</label>
|
||||
{(isLoadingWeeklyAvailability || isLoadingAvailabilityOverview) ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Loading availability...
|
||||
</div>
|
||||
) : availableDaysOfWeek.length === 0 ? (
|
||||
<p className={`text-sm ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
No available days at the moment. Please check back later.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className={`text-xs ${isDark ? 'text-gray-400' : 'text-gray-500'} mb-3`}>
|
||||
Select one or more day-time combinations that work for you. You can select multiple time slots for the same day (e.g., Monday Morning and Monday Evening).
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
{availableDaysOfWeek.map((dayInfo, dayIndex) => {
|
||||
// Ensure day is always a valid number (already validated in useMemo)
|
||||
const currentDay = typeof dayInfo.day === 'number' && !isNaN(dayInfo.day)
|
||||
? dayInfo.day
|
||||
: dayIndex; // Fallback to index if invalid
|
||||
|
||||
// Skip if day is still invalid
|
||||
if (isNaN(currentDay) || currentDay < 0 || currentDay > 6) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`day-wrapper-${currentDay}-${dayIndex}`} className="space-y-2">
|
||||
<h4 className={`text-sm font-semibold ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
{dayInfo.dayName || `Day ${currentDay}`}
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{dayInfo.availableSlots.map((timeSlot: string, slotIndex: number) => {
|
||||
if (!timeSlot) return null;
|
||||
|
||||
const timeSlotLabels: Record<string, string> = {
|
||||
morning: "Morning",
|
||||
afternoon: "Lunchtime",
|
||||
evening: "Evening",
|
||||
};
|
||||
// Normalize time slot for consistent comparison
|
||||
const normalizedTimeSlot = String(timeSlot).toLowerCase().trim();
|
||||
// Create unique key combining day, time slot, and index to ensure uniqueness
|
||||
const slotKey = `day-${currentDay}-slot-${normalizedTimeSlot}-${slotIndex}`;
|
||||
// Check if THIS specific day-time combination is selected
|
||||
const isSelected = isSlotSelected(currentDay, normalizedTimeSlot);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={slotKey}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Pass the specific day and time slot for this button
|
||||
handleSlotToggle(currentDay, normalizedTimeSlot);
|
||||
}}
|
||||
aria-pressed={isSelected}
|
||||
className={`flex items-center gap-2 cursor-pointer px-4 py-2 rounded-lg border-2 transition-all focus:outline-none focus:ring-2 focus:ring-rose-500 ${
|
||||
isSelected
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'].map((day) => (
|
||||
<label
|
||||
key={day}
|
||||
className={`flex items-center gap-2 cursor-pointer px-4 py-2 rounded-lg border transition-all ${
|
||||
formData.preferredDays.includes(day)
|
||||
? isDark
|
||||
? 'bg-rose-600 border-rose-500 text-white hover:bg-rose-700'
|
||||
: 'bg-rose-500 border-rose-500 text-white hover:bg-rose-600'
|
||||
? 'bg-rose-600 border-rose-500 text-white'
|
||||
: 'bg-rose-500 border-rose-500 text-white'
|
||||
: isDark
|
||||
? 'bg-gray-700 border-gray-600 text-gray-300 hover:border-rose-500 hover:bg-gray-650'
|
||||
: 'bg-white border-gray-300 text-gray-700 hover:border-rose-500 hover:bg-rose-50'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-medium">
|
||||
{timeSlotLabels[normalizedTimeSlot] || timeSlot}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
? 'bg-gray-700 border-gray-600 text-gray-300 hover:border-rose-500'
|
||||
: 'bg-white border-gray-300 text-gray-700 hover:border-rose-500'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.preferredDays.includes(day)}
|
||||
onChange={() => handleDayToggle(day)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="text-sm font-medium">{day}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
className={`text-sm font-medium flex items-center gap-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}
|
||||
>
|
||||
<Clock className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-500'}`} />
|
||||
Preferred Time *
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{[
|
||||
{ value: 'morning', label: 'Morning' },
|
||||
{ value: 'lunchtime', label: 'Lunchtime' },
|
||||
{ value: 'afternoon', label: 'Afternoon' }
|
||||
].map((time) => (
|
||||
<label
|
||||
key={time.value}
|
||||
className={`flex items-center gap-2 cursor-pointer px-4 py-2 rounded-lg border transition-all ${
|
||||
formData.preferredTimes.includes(time.value)
|
||||
? isDark
|
||||
? 'bg-rose-600 border-rose-500 text-white'
|
||||
: 'bg-rose-500 border-rose-500 text-white'
|
||||
: isDark
|
||||
? 'bg-gray-700 border-gray-600 text-gray-300 hover:border-rose-500'
|
||||
: 'bg-white border-gray-300 text-gray-700 hover:border-rose-500'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.preferredTimes.includes(time.value)}
|
||||
onChange={() => handleTimeToggle(time.value)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="text-sm font-medium">{time.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -867,7 +627,6 @@ export default function BookNowPage() {
|
||||
placeholder="Tell us about any specific concerns or preferences..."
|
||||
value={formData.message}
|
||||
onChange={(e) => handleChange("message", e.target.value)}
|
||||
maxLength={100}
|
||||
className={`w-full rounded-md border px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-rose-500 focus-visible:border-rose-500 disabled:cursor-not-allowed disabled:opacity-50 ${isDark ? 'border-gray-600 bg-gray-700 text-white placeholder:text-gray-400 focus-visible:ring-rose-400 focus-visible:border-rose-400' : 'border-gray-300 bg-white text-gray-900 placeholder:text-gray-500'}`}
|
||||
/>
|
||||
</div>
|
||||
@ -877,7 +636,7 @@ export default function BookNowPage() {
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
disabled={isCreating || availableDaysOfWeek.length === 0 || formData.selectedSlots.length === 0}
|
||||
disabled={isCreating}
|
||||
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 ? (
|
||||
@ -897,6 +656,19 @@ export default function BookNowPage() {
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Contact Information */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className={isDark ? 'text-gray-300' : 'text-gray-600'}>
|
||||
Prefer to book by phone?{" "}
|
||||
<a
|
||||
href="tel:+17548162311"
|
||||
className={`font-medium underline ${isDark ? 'text-rose-400 hover:text-rose-300' : 'text-rose-600 hover:text-rose-700'}`}
|
||||
>
|
||||
Call us at (754) 816-2311
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Logout Button - Only show when authenticated */}
|
||||
{isAuthenticated && (
|
||||
<div className="mt-6 flex justify-center">
|
||||
@ -921,24 +693,8 @@ export default function BookNowPage() {
|
||||
{/* Login Dialog */}
|
||||
<LoginDialog
|
||||
open={showLoginDialog}
|
||||
onOpenChange={(open) => {
|
||||
setShowLoginDialog(open);
|
||||
if (!open) {
|
||||
setLoginPrefillEmail(undefined);
|
||||
}
|
||||
}}
|
||||
onOpenChange={setShowLoginDialog}
|
||||
onLoginSuccess={handleLoginSuccess}
|
||||
onSwitchToSignup={handleSwitchToSignup}
|
||||
prefillEmail={loginPrefillEmail}
|
||||
skipRedirect={true}
|
||||
/>
|
||||
|
||||
{/* Signup Dialog */}
|
||||
<SignupDialog
|
||||
open={showSignupDialog}
|
||||
onOpenChange={setShowSignupDialog}
|
||||
onSignupSuccess={handleSignupSuccess}
|
||||
onSwitchToLogin={handleSwitchToLogin}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,369 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft, Heart } from "lucide-react";
|
||||
import React from "react";
|
||||
import ReactMarkdown, { Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const ReadmePage = () => {
|
||||
const router = useRouter();
|
||||
const readmeContent = `
|
||||
## Attune Heart Therapy
|
||||
|
||||
Welcome to your Attune Heart Therapy platform! This documentation provides everything you need to understand and navigate the complete system, including the landing page, booking system, user/client dashboard, and admin dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 📂 What's Included
|
||||
|
||||
Your Attune Heart Therapy platform includes a comprehensive system for managing therapy appointments and client interactions:
|
||||
|
||||
| Section | Description |
|
||||
| --------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
| Landing Page | Public-facing homepage with navigation, services overview, and booking access |
|
||||
| Booking System | User-friendly appointment booking flow where clients can request therapy sessions |
|
||||
| User Dashboard | Client portal to view appointments, manage profile, and track booking status |
|
||||
| Admin Dashboard | Administrative interface to manage appointments, view statistics, and schedule sessions |
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Admin Dashboard Access
|
||||
|
||||
### Step 1: Navigate to Login
|
||||
|
||||
1. Go to your website's homepage
|
||||
2. Click on the **"Admin Panel"** link in the footer (under Quick Links)
|
||||
3. Or navigate directly to: \`https://attunehearttherapy.com/login\`
|
||||
|
||||
### Step 2: Login Credentials
|
||||
|
||||
**Email Address:** \`admin@attunehearttherapy.com\`
|
||||
|
||||
|
||||
### Step 3: Access Dashboard
|
||||
|
||||
1. Enter your admin email address
|
||||
2. Enter your password
|
||||
3. Click **"Sign In"**
|
||||
4. You will be automatically redirected to the Admin Dashboard
|
||||
|
||||
---
|
||||
|
||||
## 🎥 Telehealth Sessions Guide
|
||||
|
||||
This section provides step-by-step guidance on how to access and manage telehealth therapy sessions through the Admin Dashboard of the Attune Heart Therapy platform.
|
||||
|
||||
### Accessing the Admin Dashboard
|
||||
|
||||
**Email Notification:** When a user requests an appointment, you (the admin) will receive an email notification. The image below shows an example of this email notification.
|
||||
|
||||

|
||||
|
||||
1. Navigate to: \`https://attunehearttherapy.com/login\`
|
||||
2. Enter your admin email address: \`admin@attunehearttherapy.com\`
|
||||
3. Enter your password
|
||||
4. Click **"Sign In"**
|
||||
5. You will be automatically redirected to the **Admin Dashboard**
|
||||
6. Click on **"Bookings"** in the navigation menu to view all appointments
|
||||
|
||||
### Viewing Appointment Details
|
||||
|
||||
1. From the Bookings page, click on any appointment
|
||||
2. View complete client information, preferred dates, and time slots
|
||||
3. Check appointment status and review all details
|
||||
|
||||
### Scheduling an Appointment
|
||||
|
||||
1. From the appointment details page, click **"Schedule Appointment"**
|
||||
2. Select the date and time for the session
|
||||
3. Choose the duration (15, 30, 45, 60, or 120 minutes)
|
||||
4. Click **"Schedule"** to confirm
|
||||
5. The system will automatically create a Jitsi video meeting room and send a confirmation email to the client
|
||||
|
||||
**Confirmation Email:** After you schedule an appointment, the client receives a confirmation email with their appointment details. The image below shows an example of the confirmation email that clients receive.
|
||||
|
||||

|
||||
|
||||
### Joining a Video Meeting
|
||||
|
||||
1. Meetings become available **10 minutes before** the scheduled start time
|
||||
2. Navigate to any scheduled appointment's details page
|
||||
3. In the sidebar, find the **"Join Meeting"** button
|
||||
4. Click **"Join Meeting"** when it becomes active (10 minutes before scheduled time)
|
||||
|
||||
### 🔗 Quick Access Links
|
||||
|
||||
[Visit Attune Heart Therapy](https://attunehearttherapy.com/) - Official website
|
||||
|
||||
[Access Admin Dashboard](https://attunehearttherapy.com/login) - Login to manage your practice
|
||||
|
||||
[Book an Appointment](https://attunehearttherapy.com/book-now) - Client booking page
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support & Contact
|
||||
|
||||
For technical assistance, questions, or issues:
|
||||
|
||||
**Email:** [info@BlackBusinessLabs.com](mailto:info@BlackBusinessLabs.com)
|
||||
|
||||
**Phone:** [(646) 895-4856](tel:+16468954856) - *CEO Tray Bailey's direct mobile*
|
||||
|
||||
---
|
||||
|
||||
*For questions or additional support, please contact Black Business Labs at the information provided above.*`;
|
||||
|
||||
const components: Components = {
|
||||
h1: ({ node, ...props }) => (
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "2.2em",
|
||||
fontWeight: "600",
|
||||
marginTop: "1.2em",
|
||||
marginBottom: "0.6em",
|
||||
borderBottom: "1px solid #eaeaea",
|
||||
paddingBottom: "0.3em",
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
h2: ({ node, children, ...props }) => {
|
||||
// Extract text content from children
|
||||
const extractText = (child: any): string => {
|
||||
if (typeof child === 'string') return child;
|
||||
if (typeof child === 'number') return String(child);
|
||||
if (React.isValidElement(child)) {
|
||||
const childProps = child.props as any;
|
||||
if (childProps?.children) {
|
||||
return React.Children.toArray(childProps.children).map(extractText).join('');
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const textContent = React.Children.toArray(children).map(extractText).join('');
|
||||
|
||||
// Check if this is the title heading
|
||||
if (textContent.includes('Attune Heart Therapy - System Overview')) {
|
||||
return (
|
||||
<h2
|
||||
style={{
|
||||
fontSize: "1.8em",
|
||||
fontWeight: "600",
|
||||
marginTop: "1.2em",
|
||||
marginBottom: "0.6em",
|
||||
borderBottom: "1px solid #eaeaea",
|
||||
paddingBottom: "0.3em",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5em",
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<span>{children}</span>
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<h2
|
||||
style={{
|
||||
fontSize: "1.8em",
|
||||
fontWeight: "600",
|
||||
marginTop: "1.2em",
|
||||
marginBottom: "0.6em",
|
||||
borderBottom: "1px solid #eaeaea",
|
||||
paddingBottom: "0.3em",
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
},
|
||||
h3: ({ node, ...props }) => (
|
||||
<h3
|
||||
style={{
|
||||
fontSize: "1.5em",
|
||||
fontWeight: "600",
|
||||
marginTop: "1.2em",
|
||||
marginBottom: "0.6em",
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
p: ({ node, ...props }) => (
|
||||
<p style={{ marginBottom: "1.2em", lineHeight: "1.8" }} {...props} />
|
||||
),
|
||||
a: ({ node, ...props }) => (
|
||||
<a
|
||||
style={{ color: "#0366d6", textDecoration: "none", fontWeight: "500" }}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ul: ({ node, ...props }) => (
|
||||
<ul
|
||||
style={{
|
||||
paddingLeft: "1.5em",
|
||||
marginBottom: "1.2em",
|
||||
listStyleType: "disc",
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ol: ({ node, ...props }) => (
|
||||
<ol
|
||||
style={{
|
||||
paddingLeft: "1.5em",
|
||||
marginBottom: "1.2em",
|
||||
listStyleType: "decimal",
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
li: ({ node, ...props }) => (
|
||||
<li style={{ marginBottom: "0.4em" }} {...props} />
|
||||
),
|
||||
table: ({ node, ...props }) => (
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
marginBottom: "1.2em",
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.08)",
|
||||
border: "1px solid #dfe2e5",
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
th: ({ node, ...props }) => (
|
||||
<th
|
||||
style={{
|
||||
border: "1px solid #dfe2e5",
|
||||
padding: "0.6em 0.8em",
|
||||
textAlign: "left",
|
||||
backgroundColor: "#f6f8fa",
|
||||
fontWeight: "600",
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
td: ({ node, ...props }) => (
|
||||
<td
|
||||
style={{
|
||||
border: "1px solid #dfe2e5",
|
||||
padding: "0.6em 0.8em",
|
||||
textAlign: "left",
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
pre: ({ node, children, ...props }) => (
|
||||
<pre
|
||||
style={{
|
||||
backgroundColor: "#f6f8fa",
|
||||
padding: "1em",
|
||||
borderRadius: "6px",
|
||||
overflowX: "auto",
|
||||
fontSize: "0.9em",
|
||||
lineHeight: "1.5",
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
code: (props) => {
|
||||
// Using `props: any` and casting to bypass TypeScript error with `inline` prop.
|
||||
const {
|
||||
node,
|
||||
inline: isInline,
|
||||
className,
|
||||
children,
|
||||
// Destructure known non-HTML props from react-markdown to prevent them from being spread onto the <code> tag
|
||||
index,
|
||||
siblingCount,
|
||||
ordered,
|
||||
checked,
|
||||
style: _style, // if style is passed in props, avoid conflict with style object below
|
||||
...htmlProps // Spread remaining props, assuming they are valid HTML attributes for <code>
|
||||
} = props as any;
|
||||
|
||||
const codeStyleBase = {
|
||||
fontFamily:
|
||||
'SFMono-Regular, Consolas, "Liberation Mono", Menlo, Courier, monospace',
|
||||
};
|
||||
|
||||
if (isInline) {
|
||||
return (
|
||||
<code
|
||||
className={className}
|
||||
style={{
|
||||
...codeStyleBase,
|
||||
backgroundColor: "rgba(27,31,35,0.07)", // Slightly adjusted for better visibility
|
||||
padding: "0.2em 0.4em",
|
||||
margin: "0 0.1em",
|
||||
fontSize: "85%",
|
||||
borderRadius: "3px",
|
||||
}}
|
||||
{...htmlProps}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
// For block code (inside <pre>)
|
||||
return (
|
||||
<code
|
||||
className={className} // className might contain "language-js" etc.
|
||||
style={{
|
||||
...codeStyleBase,
|
||||
// Most styling for block code is handled by the <pre> wrapper
|
||||
// However, ensure no extra padding/margin if pre handles it
|
||||
padding: 0,
|
||||
backgroundColor: "transparent", // Pre has the background
|
||||
}}
|
||||
{...htmlProps}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
img: ({ node, ...props }: any) => (
|
||||
<img
|
||||
{...props}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
height: "auto",
|
||||
borderRadius: "8px",
|
||||
marginTop: "1em",
|
||||
marginBottom: "1em",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
|
||||
}}
|
||||
alt={props.alt || "Guide screenshot"}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<div className="bg-white dark:bg-gray-900 p-8 rounded-lg shadow-md">
|
||||
<Button
|
||||
className="bg-gray-100 hover:bg-gray-50 dark:bg-gray-800 dark:hover:bg-gray-700 shadow-md text-black dark:text-white mb-6"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<ArrowLeft className="mr-2" />
|
||||
</Button>
|
||||
|
||||
<ReactMarkdown components={components} remarkPlugins={[remarkGfm]}>
|
||||
{readmeContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReadmePage;
|
||||
@ -1,72 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
|
||||
export default function SwotAnalysisPage() {
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
// This ensures the PDF viewer only renders on the client side
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-6">
|
||||
<Link href="/docs">
|
||||
<Button variant="outline" className="mb-4">
|
||||
← Back to Documentation
|
||||
</Button>
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold mb-2">WODEY SWOT Analysis</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
An in-depth analysis of Strengths, Weaknesses, Opportunities, and
|
||||
Threats for the WODEY platform.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mb-4">
|
||||
<a
|
||||
href="/docs/SWOT-Analysis.pdf"
|
||||
download="WODEY-SWOT-Analysis.pdf"
|
||||
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded shadow-md"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
Download PDF
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{isClient ? (
|
||||
<div className="w-full h-[calc(100vh-200px)] rounded-lg overflow-hidden">
|
||||
<Image
|
||||
src="/WODEY-SWOT-Analysis.jpg"
|
||||
alt="SWOT Analysis"
|
||||
width={600}
|
||||
height={600}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-full h-[calc(100vh-200px)] bg-gray-100 rounded-lg">
|
||||
Loading viewer...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,620 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
User,
|
||||
Video,
|
||||
CalendarCheck,
|
||||
Loader2,
|
||||
ArrowLeft,
|
||||
Mail,
|
||||
Phone as PhoneIcon,
|
||||
MessageSquare,
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
} from "lucide-react";
|
||||
import { useAppTheme } from "@/components/ThemeProvider";
|
||||
import { getAppointmentDetail, listAppointments } from "@/lib/actions/appointments";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { toast } from "sonner";
|
||||
import type { Appointment } from "@/lib/models/appointments";
|
||||
|
||||
export default function UserAppointmentDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const appointmentId = params.id as string;
|
||||
|
||||
const [appointment, setAppointment] = useState<Appointment | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { theme } = useAppTheme();
|
||||
const isDark = theme === "dark";
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAppointment = async () => {
|
||||
if (!appointmentId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch both detail and list to get selected_slots from list endpoint
|
||||
const [detailData, listData] = await Promise.all([
|
||||
getAppointmentDetail(appointmentId),
|
||||
listAppointments().catch(() => []) // Fallback to empty array if list fails
|
||||
]);
|
||||
|
||||
// Find matching appointment in list to get selected_slots
|
||||
const listAppointment = Array.isArray(listData)
|
||||
? listData.find((apt: Appointment) => apt.id === appointmentId)
|
||||
: null;
|
||||
|
||||
// Merge selected_slots from list into detail data
|
||||
if (listAppointment && listAppointment.selected_slots && Array.isArray(listAppointment.selected_slots) && listAppointment.selected_slots.length > 0) {
|
||||
detailData.selected_slots = listAppointment.selected_slots;
|
||||
}
|
||||
|
||||
setAppointment(detailData);
|
||||
} catch (error) {
|
||||
toast.error("Failed to load appointment details");
|
||||
router.push("/user/dashboard");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAppointment();
|
||||
}, [appointmentId, router]);
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
});
|
||||
};
|
||||
|
||||
const formatShortDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const normalized = status.toLowerCase();
|
||||
if (isDark) {
|
||||
switch (normalized) {
|
||||
case "scheduled":
|
||||
return "bg-blue-500/20 text-blue-300 border-blue-500/30";
|
||||
case "completed":
|
||||
return "bg-green-500/20 text-green-300 border-green-500/30";
|
||||
case "rejected":
|
||||
case "cancelled":
|
||||
return "bg-red-500/20 text-red-300 border-red-500/30";
|
||||
case "pending_review":
|
||||
case "pending":
|
||||
return "bg-yellow-500/20 text-yellow-300 border-yellow-500/30";
|
||||
default:
|
||||
return "bg-gray-700 text-gray-200 border-gray-600";
|
||||
}
|
||||
}
|
||||
switch (normalized) {
|
||||
case "scheduled":
|
||||
return "bg-blue-50 text-blue-700 border-blue-200";
|
||||
case "completed":
|
||||
return "bg-green-50 text-green-700 border-green-200";
|
||||
case "rejected":
|
||||
case "cancelled":
|
||||
return "bg-red-50 text-red-700 border-red-200";
|
||||
case "pending_review":
|
||||
case "pending":
|
||||
return "bg-yellow-50 text-yellow-700 border-yellow-200";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-700 border-gray-300";
|
||||
}
|
||||
};
|
||||
|
||||
const formatStatus = (status: string) => {
|
||||
return status.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase());
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string, label: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success(`${label} copied to clipboard`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={`min-h-screen ${isDark ? "bg-gray-900" : "bg-gray-50"}`}>
|
||||
<Navbar />
|
||||
<div className="min-h-[calc(100vh-4rem)] flex items-center justify-center">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!appointment) {
|
||||
return (
|
||||
<div className={`min-h-screen ${isDark ? "bg-gray-900" : "bg-gray-50"}`}>
|
||||
<Navbar />
|
||||
<div className="min-h-[calc(100vh-4rem)] flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className={`text-lg mb-4 ${isDark ? "text-gray-400" : "text-gray-600"}`}>Appointment not found</p>
|
||||
<Button
|
||||
onClick={() => router.push("/user/dashboard")}
|
||||
className="bg-rose-600 hover:bg-rose-700 text-white"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen ${isDark ? "bg-gray-900" : "bg-gray-50"}`}>
|
||||
<Navbar />
|
||||
<main className="container mx-auto px-4 sm:px-6 lg:px-8 space-y-6 pt-20 sm:pt-24 pb-8">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col gap-3 sm:gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => router.push("/user/dashboard")}
|
||||
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 Dashboard
|
||||
</Button>
|
||||
|
||||
<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-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"}`}>
|
||||
<CalendarCheck className="w-6 h-6 sm:w-8 sm:h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
Appointment Details
|
||||
</h1>
|
||||
<p className={`text-xs sm:text-sm mt-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
Request #{appointment.id.slice(0, 8)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
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-3 h-3 sm:w-4 sm:h-4" />}
|
||||
{formatStatus(appointment.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Main Content - Left Column (2/3) */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Appointment Information Card */}
|
||||
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
||||
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
|
||||
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
<User className={`w-5 h-5 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
|
||||
Appointment Information
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div className="space-y-1">
|
||||
<p className={`text-xs font-medium uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
Full Name
|
||||
</p>
|
||||
<p className={`text-base font-medium ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
{appointment.first_name} {appointment.last_name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className={`text-xs font-medium uppercase tracking-wider flex items-center gap-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
<Mail className="w-3 h-3" />
|
||||
Email Address
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className={`text-base font-medium ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
{appointment.email}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => copyToClipboard(appointment.email, "Email")}
|
||||
className={`p-1.5 rounded-lg hover:bg-opacity-80 transition-colors ${isDark ? "hover:bg-gray-700" : "hover:bg-gray-100"}`}
|
||||
title="Copy email"
|
||||
>
|
||||
<Copy className={`w-4 h-4 ${isDark ? "text-gray-400" : "text-gray-500"}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{appointment.phone && (
|
||||
<div className="space-y-1">
|
||||
<p className={`text-xs font-medium uppercase tracking-wider flex items-center gap-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
<PhoneIcon className="w-3 h-3" />
|
||||
Phone Number
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className={`text-base font-medium ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
{appointment.phone}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => copyToClipboard(appointment.phone!, "Phone")}
|
||||
className={`p-1.5 rounded-lg hover:bg-opacity-80 transition-colors ${isDark ? "hover:bg-gray-700" : "hover:bg-gray-100"}`}
|
||||
title="Copy phone"
|
||||
>
|
||||
<Copy className={`w-4 h-4 ${isDark ? "text-gray-400" : "text-gray-500"}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scheduled Appointment Details */}
|
||||
{appointment.scheduled_datetime && (
|
||||
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
||||
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
|
||||
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
<Calendar className={`w-5 h-5 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
|
||||
Scheduled Appointment
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`p-4 rounded-xl ${isDark ? "bg-blue-500/10 border border-blue-500/20" : "bg-blue-50 border border-blue-100"}`}>
|
||||
<Calendar className={`w-6 h-6 ${isDark ? "text-blue-400" : "text-blue-600"}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className={`text-2xl font-bold mb-1 ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
{formatDate(appointment.scheduled_datetime)}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className={`w-4 h-4 ${isDark ? "text-gray-400" : "text-gray-500"}`} />
|
||||
<p className={`text-base ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||
{formatTime(appointment.scheduled_datetime)}
|
||||
</p>
|
||||
</div>
|
||||
{appointment.scheduled_duration && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-base ${isDark ? "text-gray-400" : "text-gray-500"}`}>•</span>
|
||||
<p className={`text-base ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||
{appointment.meeting_duration_display || `${appointment.scheduled_duration} minutes`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected Slots */}
|
||||
{appointment.selected_slots && Array.isArray(appointment.selected_slots) && appointment.selected_slots.length > 0 && (
|
||||
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
||||
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
|
||||
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
<CalendarCheck className={`w-5 h-5 ${isDark ? "text-green-400" : "text-green-600"}`} />
|
||||
Selected Time Slots
|
||||
{appointment.are_preferences_available !== undefined && (
|
||||
<span className={`ml-auto px-3 py-1 text-xs font-medium rounded-full ${appointment.are_preferences_available ? (isDark ? "bg-green-500/20 text-green-300 border border-green-500/30" : "bg-green-50 text-green-700 border border-green-200") : (isDark ? "bg-yellow-500/20 text-yellow-300 border border-yellow-500/30" : "bg-yellow-50 text-yellow-700 border border-yellow-200")}`}>
|
||||
{appointment.are_preferences_available ? "All Available" : "Partially Available"}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
{(() => {
|
||||
const dayNames: Record<number, string> = {
|
||||
0: "Monday",
|
||||
1: "Tuesday",
|
||||
2: "Wednesday",
|
||||
3: "Thursday",
|
||||
4: "Friday",
|
||||
5: "Saturday",
|
||||
6: "Sunday",
|
||||
};
|
||||
const timeSlotLabels: Record<string, string> = {
|
||||
morning: "Morning",
|
||||
afternoon: "Lunchtime",
|
||||
evening: "Evening",
|
||||
};
|
||||
|
||||
// Time slot order: morning, afternoon (lunchtime), evening
|
||||
const timeSlotOrder: Record<string, number> = {
|
||||
morning: 0,
|
||||
afternoon: 1,
|
||||
evening: 2,
|
||||
};
|
||||
|
||||
// Group slots by date
|
||||
const slotsByDate: Record<string, typeof appointment.selected_slots> = {};
|
||||
appointment.selected_slots.forEach((slot: any) => {
|
||||
const date = slot.date || "";
|
||||
if (!slotsByDate[date]) {
|
||||
slotsByDate[date] = [];
|
||||
}
|
||||
slotsByDate[date].push(slot);
|
||||
});
|
||||
|
||||
// Sort dates and slots within each date
|
||||
const sortedDates = Object.keys(slotsByDate).sort((a, b) => {
|
||||
return new Date(a).getTime() - new Date(b).getTime();
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{sortedDates.map((date) => {
|
||||
// Sort slots within this date by time slot order
|
||||
const slots = slotsByDate[date].sort((a: any, b: any) => {
|
||||
const aSlot = String(a.time_slot).toLowerCase().trim();
|
||||
const bSlot = String(b.time_slot).toLowerCase().trim();
|
||||
const aOrder = timeSlotOrder[aSlot] ?? 999;
|
||||
const bOrder = timeSlotOrder[bSlot] ?? 999;
|
||||
return aOrder - bOrder;
|
||||
});
|
||||
|
||||
return (
|
||||
<div key={date} className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}>
|
||||
<div className="mb-3">
|
||||
<p className={`text-base font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
{formatShortDate(date)}
|
||||
</p>
|
||||
{slots.length > 0 && slots[0]?.day !== undefined && (
|
||||
<p className={`text-sm mt-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
{dayNames[slots[0].day] || `Day ${slots[0].day}`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{slots.map((slot: any, idx: number) => {
|
||||
const timeSlot = String(slot.time_slot).toLowerCase().trim();
|
||||
const timeLabel = timeSlotLabels[timeSlot] || slot.time_slot;
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium ${isDark ? "bg-green-500/20 text-green-300 border border-green-500/30" : "bg-green-50 text-green-700 border border-green-200"}`}
|
||||
>
|
||||
{timeLabel}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reason */}
|
||||
{appointment.reason && (
|
||||
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
||||
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
|
||||
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
<MessageSquare className={`w-5 h-5 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
|
||||
Reason for Appointment
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<p className={`text-base leading-relaxed ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||
{appointment.reason}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rejection Reason */}
|
||||
{appointment.rejection_reason && (
|
||||
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-red-900/20 border-red-800/50" : "bg-red-50 border-red-200"}`}>
|
||||
<div className={`px-6 py-4 border-b ${isDark ? "border-red-800/50" : "border-red-200"}`}>
|
||||
<h2 className={`text-lg font-semibold ${isDark ? "text-red-300" : "text-red-900"}`}>
|
||||
Rejection Reason
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<p className={`text-base leading-relaxed ${isDark ? "text-red-200" : "text-red-800"}`}>
|
||||
{appointment.rejection_reason}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Meeting Information */}
|
||||
{/* Video Meeting card hidden - users can join via the Join Now button in sidebar */}
|
||||
{/* {appointment.participant_join_url && (
|
||||
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gradient-to-br from-blue-900/20 to-purple-900/20 border-blue-800/30" : "bg-gradient-to-br from-blue-50 to-purple-50 border-blue-200"}`}>
|
||||
<div className={`px-6 py-4 border-b ${isDark ? "border-blue-800/30" : "border-blue-200"}`}>
|
||||
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
<Video className={`w-5 h-5 ${isDark ? "text-blue-400" : "text-blue-600"}`} />
|
||||
Video Meeting
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{appointment.can_join_as_participant !== undefined && (
|
||||
<div className={`flex items-center gap-2 px-4 py-3 rounded-lg ${appointment.can_join_as_participant ? (isDark ? "bg-green-500/20 border border-green-500/30" : "bg-green-50 border border-green-200") : (isDark ? "bg-gray-800 border border-gray-700" : "bg-gray-50 border border-gray-200")}`}>
|
||||
<div className={`h-2 w-2 rounded-full ${appointment.can_join_as_participant ? (isDark ? "bg-green-400" : "bg-green-600") : (isDark ? "bg-gray-500" : "bg-gray-400")}`} />
|
||||
<p className={`text-sm font-medium ${appointment.can_join_as_participant ? (isDark ? "text-green-300" : "text-green-700") : (isDark ? "text-gray-400" : "text-gray-500")}`}>
|
||||
{appointment.can_join_as_participant ? "Meeting is active - You can join now" : "Click here to join"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
|
||||
{/* Sidebar - Right Column (1/3) */}
|
||||
<div className="space-y-6">
|
||||
{/* Quick Info Card */}
|
||||
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
||||
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
|
||||
<h2 className={`text-lg font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
Quick Info
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<p className={`text-xs font-medium mb-1 uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
Created
|
||||
</p>
|
||||
<p className={`text-sm font-medium ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
{formatShortDate(appointment.created_at)}
|
||||
</p>
|
||||
<p className={`text-xs mt-0.5 ${isDark ? "text-gray-500" : "text-gray-500"}`}>
|
||||
{formatTime(appointment.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className={`text-xs font-medium mb-1 uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
Status
|
||||
</p>
|
||||
<span
|
||||
className={`inline-flex items-center gap-2 px-3 py-1.5 text-sm font-semibold rounded-lg border ${getStatusColor(
|
||||
appointment.status
|
||||
)}`}
|
||||
>
|
||||
{appointment.status === "scheduled" && <CheckCircle2 className="w-4 h-4" />}
|
||||
{formatStatus(appointment.status)}
|
||||
</span>
|
||||
</div>
|
||||
{appointment.scheduled_datetime && (
|
||||
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className={`text-xs font-medium mb-2 uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
Meeting Information
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className={`text-xs font-medium mb-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
Meeting Start Time:
|
||||
</p>
|
||||
<p className={`text-sm font-medium ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
{formatDate(appointment.scheduled_datetime)} at {formatTime(appointment.scheduled_datetime)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className={`text-xs font-medium mb-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
How to Access:
|
||||
</p>
|
||||
<p className={`text-sm ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||
{`Up to 10 minutes before the meeting is scheduled to begin, click the "Join Now" button below when the meeting becomes available. The meeting will be accessible starting at ${formatTime(appointment.scheduled_datetime)}.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Join Meeting Button */}
|
||||
{appointment.status === "scheduled" && appointment.participant_join_url && (
|
||||
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gradient-to-br from-blue-900/20 to-purple-900/20 border-blue-800/30" : "bg-gradient-to-br from-blue-50 to-purple-50 border-blue-200"}`}>
|
||||
<div className="p-6">
|
||||
{(() => {
|
||||
// Check if meeting has ended
|
||||
const endedAt = appointment.meeting_ended_at;
|
||||
const hasEnded = endedAt != null && endedAt !== "";
|
||||
|
||||
// If meeting has ended, show "Meeting has ended"
|
||||
if (hasEnded) {
|
||||
return (
|
||||
<button
|
||||
disabled
|
||||
className={`flex items-center justify-center gap-2 w-full cursor-not-allowed h-12 rounded-lg text-base font-medium transition-colors ${isDark ? "bg-gray-700 text-gray-500" : "bg-gray-300 text-gray-500"}`}
|
||||
>
|
||||
<Video className="w-5 h-5" />
|
||||
Meeting has ended
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if can join as participant (handle both boolean and string values)
|
||||
const canJoinAsParticipant = appointment.can_join_as_participant === true || appointment.can_join_as_participant === "true";
|
||||
// Check if meeting has started (handle both field names)
|
||||
const startedAt = appointment.started_at || appointment.meeting_started_at;
|
||||
const hasStarted = startedAt != null && startedAt !== "";
|
||||
|
||||
// If can_join_as_participant != true, display "Click here to join"
|
||||
if (!canJoinAsParticipant) {
|
||||
return (
|
||||
<button
|
||||
disabled
|
||||
className={`flex items-center justify-center gap-2 w-full cursor-not-allowed h-12 rounded-lg text-sm sm:text-base font-medium transition-colors ${isDark ? "bg-gray-700 text-gray-500" : "bg-gray-300 text-gray-500"}`}
|
||||
>
|
||||
<Video className="w-4 h-4 sm:w-5 sm:h-5 shrink-0" />
|
||||
<span className="text-center">Click here to join</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// If can_join_as_participant == true && started_at != null, show "Join Now" button
|
||||
if (hasStarted) {
|
||||
return (
|
||||
<a
|
||||
href={appointment.participant_join_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`flex items-center justify-center gap-2 w-full bg-blue-600 hover:bg-blue-700 text-white h-12 rounded-lg text-base font-medium transition-colors`}
|
||||
>
|
||||
<Video className="w-5 h-5" />
|
||||
Join Now
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
// If can_join_as_participant == true && started_at == null, show "Click here to join"
|
||||
return (
|
||||
<button
|
||||
disabled
|
||||
className={`flex items-center justify-center gap-2 w-full cursor-not-allowed h-12 rounded-lg text-sm sm:text-base font-medium transition-colors ${isDark ? "bg-gray-700 text-gray-500" : "bg-gray-300 text-gray-500"}`}
|
||||
>
|
||||
<Video className="w-4 h-4 sm:w-5 sm:h-5 shrink-0" />
|
||||
<span className="text-center">Click here to join</span>
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Calendar,
|
||||
@ -17,67 +16,56 @@ import {
|
||||
CalendarCheck,
|
||||
ArrowUpRight,
|
||||
Settings,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Navbar } from "@/components/Navbar";
|
||||
import { useAppTheme } from "@/components/ThemeProvider";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { getUserAppointments, getUserAppointmentStats } from "@/lib/actions/appointments";
|
||||
import type { Appointment, UserAppointmentStats } from "@/lib/models/appointments";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Booking {
|
||||
ID: number;
|
||||
scheduled_at: string;
|
||||
duration: number;
|
||||
status: string;
|
||||
amount: number;
|
||||
notes: string;
|
||||
jitsi_room_url?: string;
|
||||
}
|
||||
|
||||
export default function UserDashboard() {
|
||||
const router = useRouter();
|
||||
const { theme } = useAppTheme();
|
||||
const isDark = theme === "dark";
|
||||
const { user } = useAuth();
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [stats, setStats] = useState<UserAppointmentStats | null>(null);
|
||||
const [loadingStats, setLoadingStats] = useState(true);
|
||||
|
||||
// Fetch user appointments from user-specific endpoint
|
||||
useEffect(() => {
|
||||
const fetchAppointments = async () => {
|
||||
// Simulate API call to fetch user bookings
|
||||
const fetchBookings = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getUserAppointments();
|
||||
setAppointments(data || []);
|
||||
// Simulate network delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// Mock data - in real app, this would fetch from API
|
||||
const mockBookings: Booking[] = [
|
||||
{
|
||||
ID: 1,
|
||||
scheduled_at: "2025-01-15T10:00:00Z",
|
||||
duration: 60,
|
||||
status: "scheduled",
|
||||
amount: 150,
|
||||
notes: "Initial consultation",
|
||||
jitsi_room_url: "https://meet.jit.si/sample-room",
|
||||
},
|
||||
];
|
||||
setBookings(mockBookings);
|
||||
} catch (error) {
|
||||
toast.error("Failed to load appointments. Please try again.");
|
||||
setAppointments([]);
|
||||
console.error("Failed to fetch bookings:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAppointments();
|
||||
}, []);
|
||||
|
||||
// Fetch stats from API for authenticated user
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
setLoadingStats(true);
|
||||
try {
|
||||
const statsData = await getUserAppointmentStats();
|
||||
setStats(statsData);
|
||||
} catch (error) {
|
||||
toast.error("Failed to load appointment statistics.");
|
||||
setStats({
|
||||
total_requests: 0,
|
||||
pending_review: 0,
|
||||
scheduled: 0,
|
||||
rejected: 0,
|
||||
completed: 0,
|
||||
completion_rate: 0,
|
||||
});
|
||||
} finally {
|
||||
setLoadingStats(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
fetchBookings();
|
||||
}, []);
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
@ -98,87 +86,46 @@ export default function UserDashboard() {
|
||||
});
|
||||
};
|
||||
|
||||
const formatMemberSince = (dateString?: string) => {
|
||||
if (!dateString) return "N/A";
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
const upcomingBookings = bookings.filter(
|
||||
(booking) => booking.status === "scheduled"
|
||||
);
|
||||
const completedBookings = bookings.filter(
|
||||
(booking) => booking.status === "completed"
|
||||
);
|
||||
const cancelledBookings = bookings.filter(
|
||||
(booking) => booking.status === "cancelled"
|
||||
);
|
||||
|
||||
// Filter appointments by status
|
||||
const upcomingAppointments = useMemo(() => {
|
||||
return appointments.filter(
|
||||
(appointment) => appointment.status === "scheduled"
|
||||
);
|
||||
}, [appointments]);
|
||||
|
||||
const pendingAppointments = useMemo(() => {
|
||||
return appointments.filter(
|
||||
(appointment) => appointment.status === "pending_review"
|
||||
);
|
||||
}, [appointments]);
|
||||
|
||||
const completedAppointments = useMemo(() => {
|
||||
return appointments.filter(
|
||||
(appointment) => appointment.status === "completed"
|
||||
);
|
||||
}, [appointments]);
|
||||
|
||||
const rejectedAppointments = useMemo(() => {
|
||||
return appointments.filter(
|
||||
(appointment) => appointment.status === "rejected"
|
||||
);
|
||||
}, [appointments]);
|
||||
|
||||
// Sort appointments by created_at (newest first)
|
||||
const allAppointments = useMemo(() => {
|
||||
return [...appointments].sort((a, b) => {
|
||||
const dateA = new Date(a.created_at).getTime();
|
||||
const dateB = new Date(b.created_at).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
}, [appointments]);
|
||||
|
||||
// Use stats from API, fallback to calculated stats if API stats not available
|
||||
const displayStats = useMemo(() => {
|
||||
if (stats) {
|
||||
return {
|
||||
scheduled: stats.scheduled || 0,
|
||||
scheduled_pct: stats.scheduled_pct,
|
||||
completed: stats.completed || 0,
|
||||
completed_pct: stats.completed_pct,
|
||||
pending_review: stats.pending_review || 0,
|
||||
pending_review_pct: stats.pending_review_pct,
|
||||
rejected: stats.rejected || 0,
|
||||
rejected_pct: stats.rejected_pct,
|
||||
total_requests: stats.total_requests || 0,
|
||||
completion_rate: stats.completion_rate || 0,
|
||||
};
|
||||
}
|
||||
// Fallback: calculate from appointments if stats not loaded yet
|
||||
// Note: Percentage values (_pct) are only available from API, not calculated
|
||||
const scheduled = appointments.filter(a => a.status === "scheduled").length;
|
||||
const completed = appointments.filter(a => a.status === "completed").length;
|
||||
const pending_review = appointments.filter(a => a.status === "pending_review").length;
|
||||
const rejected = appointments.filter(a => a.status === "rejected").length;
|
||||
const total_requests = appointments.length;
|
||||
const completion_rate = total_requests > 0 ? (scheduled / total_requests) * 100 : 0;
|
||||
|
||||
return {
|
||||
scheduled,
|
||||
scheduled_pct: undefined, // Only from API
|
||||
completed,
|
||||
completed_pct: undefined, // Only from API
|
||||
pending_review,
|
||||
pending_review_pct: undefined, // Only from API
|
||||
rejected,
|
||||
rejected_pct: undefined, // Only from API
|
||||
total_requests,
|
||||
completion_rate,
|
||||
};
|
||||
}, [stats, appointments]);
|
||||
const statCards = [
|
||||
{
|
||||
title: "Upcoming Appointments",
|
||||
value: upcomingBookings.length,
|
||||
icon: CalendarCheck,
|
||||
trend: "+2",
|
||||
trendUp: true,
|
||||
},
|
||||
{
|
||||
title: "Completed Sessions",
|
||||
value: completedBookings.length,
|
||||
icon: CheckCircle2,
|
||||
trend: "+5",
|
||||
trendUp: true,
|
||||
},
|
||||
{
|
||||
title: "Total Appointments",
|
||||
value: bookings.length,
|
||||
icon: Calendar,
|
||||
trend: "+12%",
|
||||
trendUp: true,
|
||||
},
|
||||
{
|
||||
title: "Total Spent",
|
||||
value: `$${bookings.reduce((sum, b) => sum + b.amount, 0)}`,
|
||||
icon: Heart,
|
||||
trend: "+18%",
|
||||
trendUp: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen ${isDark ? 'bg-gray-900' : 'bg-gray-50'}`}>
|
||||
@ -219,373 +166,177 @@ export default function UserDashboard() {
|
||||
|
||||
{loading ? (
|
||||
<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 className={`animate-spin rounded-full h-8 w-8 border-b-2 ${isDark ? 'border-gray-600' : 'border-gray-400'}`}></div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 sm:gap-4">
|
||||
{statCards.map((card, index) => {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`rounded-lg border p-4 sm:p-5 md:p-6 hover:shadow-md transition-shadow ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3 sm:mb-4">
|
||||
<div className={`p-2 sm:p-2.5 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
|
||||
<CalendarCheck className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
||||
<Icon className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
||||
</div>
|
||||
{displayStats.scheduled_pct !== undefined && (
|
||||
<div
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${isDark ? "bg-green-900/30 text-green-400" : "bg-green-50 text-green-700"}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${
|
||||
card.trendUp
|
||||
? isDark ? "bg-green-900/30 text-green-400" : "bg-green-50 text-green-700"
|
||||
: isDark ? "bg-red-900/30 text-red-400" : "bg-red-50 text-red-700"
|
||||
}`}
|
||||
>
|
||||
{card.trendUp ? (
|
||||
<ArrowUpRight className="w-3 h-3" />
|
||||
<span>{`${Math.round(displayStats.scheduled_pct)}%`}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={`text-xs font-medium mb-1 sm:mb-2 uppercase tracking-wider ${isDark ? 'text-rose-400' : 'text-rose-600'}`}>
|
||||
Upcoming Appointments
|
||||
</h3>
|
||||
<p className={`text-xl sm:text-2xl font-bold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{displayStats.scheduled}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`rounded-lg border p-4 sm:p-5 md:p-6 hover:shadow-md transition-shadow ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3 sm:mb-4">
|
||||
<div className={`p-2 sm:p-2.5 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
|
||||
<CheckCircle2 className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
||||
</div>
|
||||
{displayStats.completed_pct !== undefined && (
|
||||
<div
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${isDark ? "bg-green-900/30 text-green-400" : "bg-green-50 text-green-700"}`}
|
||||
>
|
||||
<ArrowUpRight className="w-3 h-3" />
|
||||
<span>{`${Math.round(displayStats.completed_pct)}%`}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={`text-xs font-medium mb-1 sm:mb-2 uppercase tracking-wider ${isDark ? 'text-rose-400' : 'text-rose-600'}`}>
|
||||
Completed Sessions
|
||||
</h3>
|
||||
<p className={`text-xl sm:text-2xl font-bold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{displayStats.completed}
|
||||
</p>
|
||||
) : (
|
||||
<ArrowUpRight className="w-3 h-3" />
|
||||
)}
|
||||
<span>{card.trend}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`rounded-lg border p-4 sm:p-5 md:p-6 hover:shadow-md transition-shadow ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3 sm:mb-4">
|
||||
<div className={`p-2 sm:p-2.5 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
|
||||
<Calendar className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
||||
</div>
|
||||
{/* No percentage badge for total appointments */}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={`text-xs font-medium mb-1 sm:mb-2 uppercase tracking-wider ${isDark ? 'text-rose-400' : 'text-rose-600'}`}>
|
||||
Total Appointments
|
||||
{card.title}
|
||||
</h3>
|
||||
<p className={`text-xl sm:text-2xl font-bold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{displayStats.total_requests}
|
||||
<p className={`text-xl sm:text-2xl font-bold mb-1 ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{card.value}
|
||||
</p>
|
||||
<p className={`text-xs font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>vs last month</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`rounded-lg border p-4 sm:p-5 md:p-6 hover:shadow-md transition-shadow ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3 sm:mb-4">
|
||||
<div className={`p-2 sm:p-2.5 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
|
||||
<Calendar className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
||||
</div>
|
||||
{displayStats.pending_review_pct !== undefined && (
|
||||
<div
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${isDark ? "bg-yellow-900/30 text-yellow-400" : "bg-yellow-50 text-yellow-700"}`}
|
||||
>
|
||||
<ArrowUpRight className="w-3 h-3" />
|
||||
<span>{`${Math.round(displayStats.pending_review_pct)}%`}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={`text-xs font-medium mb-1 sm:mb-2 uppercase tracking-wider ${isDark ? 'text-rose-400' : 'text-rose-600'}`}>
|
||||
Pending Review
|
||||
</h3>
|
||||
<p className={`text-xl sm:text-2xl font-bold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{displayStats.pending_review}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* All Appointments Section */}
|
||||
{allAppointments.length > 0 ? (
|
||||
<div className={`rounded-lg border overflow-hidden ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}>
|
||||
<div className={`px-4 sm:px-5 md:px-6 py-4 border-b ${isDark ? 'border-gray-700' : 'border-gray-200'}`}>
|
||||
<h2 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
All Appointments
|
||||
{/* Upcoming Appointments Section */}
|
||||
{upcomingBookings.length > 0 && (
|
||||
<div className={`rounded-lg border p-4 sm:p-5 md:p-6 ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}>
|
||||
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
Upcoming Appointments
|
||||
</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className={`${isDark ? "bg-gray-800 border-b border-gray-700" : "bg-gray-50 border-b border-gray-200"}`}>
|
||||
<tr>
|
||||
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
Appointment
|
||||
</th>
|
||||
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider hidden md:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
Date & Time
|
||||
</th>
|
||||
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider hidden lg:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
Duration
|
||||
</th>
|
||||
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
Status
|
||||
</th>
|
||||
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider hidden lg:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
Preferred Times
|
||||
</th>
|
||||
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider hidden xl:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
Created
|
||||
</th>
|
||||
<th className={`px-3 sm:px-4 md:px-6 py-3 text-right text-xs font-medium uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={`${isDark ? "bg-gray-800 divide-gray-700" : "bg-white divide-gray-200"}`}>
|
||||
{allAppointments.map((appointment) => {
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "scheduled":
|
||||
return isDark ? 'bg-green-900/30 text-green-400' : 'bg-green-50 text-green-700';
|
||||
case "pending_review":
|
||||
return isDark ? 'bg-yellow-900/30 text-yellow-400' : 'bg-yellow-50 text-yellow-700';
|
||||
case "completed":
|
||||
return isDark ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-50 text-blue-700';
|
||||
case "rejected":
|
||||
case "cancelled":
|
||||
case "canceled":
|
||||
return isDark ? 'bg-red-900/30 text-red-400' : 'bg-red-50 text-red-700';
|
||||
default:
|
||||
return isDark ? 'bg-gray-700 text-gray-300' : 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
};
|
||||
|
||||
const formatStatus = (status: string) => {
|
||||
return status.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase());
|
||||
};
|
||||
|
||||
const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
const timeSlotLabels: Record<string, string> = {
|
||||
morning: 'Morning',
|
||||
afternoon: 'Lunchtime',
|
||||
evening: 'Evening',
|
||||
};
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={appointment.id}
|
||||
className={`transition-colors cursor-pointer ${isDark ? "hover:bg-gray-700" : "hover:bg-gray-50"}`}
|
||||
onClick={() => router.push(`/user/appointments/${appointment.id}`)}
|
||||
>
|
||||
<td className="px-3 sm:px-4 md:px-6 py-4">
|
||||
<div className="flex items-center">
|
||||
<div className={`shrink-0 h-8 w-8 sm:h-10 sm:w-10 rounded-full flex items-center justify-center ${isDark ? "bg-gray-700" : "bg-gray-100"}`}>
|
||||
<User className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? "text-gray-200" : "text-gray-600"}`} />
|
||||
</div>
|
||||
<div className="ml-2 sm:ml-4 min-w-0">
|
||||
<div className={`text-xs sm:text-sm font-medium truncate ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
{appointment.first_name} {appointment.last_name}
|
||||
</div>
|
||||
{appointment.reason && (
|
||||
<div className={`text-xs sm:text-sm truncate mt-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
{appointment.reason}
|
||||
</div>
|
||||
)}
|
||||
{appointment.scheduled_datetime && (
|
||||
<div className={`text-xs sm:hidden mt-0.5 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
{formatDate(appointment.scheduled_datetime)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap hidden md:table-cell">
|
||||
{appointment.scheduled_datetime ? (
|
||||
<>
|
||||
<div className={`text-xs sm:text-sm ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
{formatDate(appointment.scheduled_datetime)}
|
||||
</div>
|
||||
<div className={`text-xs sm:text-sm flex items-center gap-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatTime(appointment.scheduled_datetime)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={`text-xs sm:text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
Not scheduled
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm hidden lg:table-cell ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
{appointment.scheduled_duration ? `${appointment.scheduled_duration} min` : "-"}
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(
|
||||
appointment.status
|
||||
)}`}
|
||||
>
|
||||
{formatStatus(appointment.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className={`px-3 sm:px-4 md:px-6 py-4 hidden lg:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
{appointment.selected_slots && appointment.selected_slots.length > 0 ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{appointment.selected_slots.slice(0, 2).map((slot, idx) => (
|
||||
<span key={idx} className="text-xs sm:text-sm">
|
||||
{dayNames[slot.day]} - {timeSlotLabels[slot.time_slot] || slot.time_slot}
|
||||
</span>
|
||||
))}
|
||||
{appointment.selected_slots.length > 2 && (
|
||||
<span className="text-xs">+{appointment.selected_slots.length - 2} more</span>
|
||||
<div className="space-y-3">
|
||||
{upcomingBookings.map((booking) => (
|
||||
<div
|
||||
key={booking.ID}
|
||||
className={`border rounded-lg p-4 hover:shadow-md transition-shadow ${isDark ? 'border-gray-700 bg-gray-700/50' : 'border-gray-200'}`}
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
||||
<span className={`font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{formatDate(booking.scheduled_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
||||
<span className={isDark ? 'text-gray-300' : 'text-gray-700'}>
|
||||
{formatTime(booking.scheduled_at)}
|
||||
</span>
|
||||
<span className={`text-sm font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||
({booking.duration} minutes)
|
||||
</span>
|
||||
</div>
|
||||
{booking.notes && (
|
||||
<p className={`text-sm mt-2 font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
{booking.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</td>
|
||||
<td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm hidden xl:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
{formatDate(appointment.created_at)}
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex items-center justify-end gap-1 sm:gap-2">
|
||||
{appointment.participant_join_url && (
|
||||
<div className="flex flex-col sm:items-end gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${isDark ? 'bg-green-900/30 text-green-400' : 'bg-green-50 text-green-700'}`}>
|
||||
{booking.status.charAt(0).toUpperCase() +
|
||||
booking.status.slice(1)}
|
||||
</span>
|
||||
<span className={`text-lg font-bold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
${booking.amount}
|
||||
</span>
|
||||
</div>
|
||||
{booking.jitsi_room_url && (
|
||||
<a
|
||||
href={appointment.participant_join_url}
|
||||
href={booking.jitsi_room_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`p-1.5 sm:p-2 rounded-lg transition-colors ${
|
||||
appointment.can_join_as_participant
|
||||
? isDark
|
||||
? "bg-blue-600 hover:bg-blue-700 text-white"
|
||||
: "bg-blue-600 hover:bg-blue-700 text-white"
|
||||
: isDark
|
||||
? "text-gray-400 hover:text-gray-300 hover:bg-gray-700"
|
||||
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
title={appointment.can_join_as_participant ? "Join Meeting" : "Meeting Not Available"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!appointment.can_join_as_participant) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Video className="w-4 h-4" />
|
||||
</a>
|
||||
)}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
<Video className="w-4 h-4" />
|
||||
Join Session
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : !loading && (
|
||||
<div className={`rounded-lg border p-4 sm:p-5 md:p-6 ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}>
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<CalendarCheck className={`w-12 h-12 mb-4 ${isDark ? 'text-gray-500' : 'text-gray-400'}`} />
|
||||
<p className={`text-lg font-medium mb-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||
No Appointments
|
||||
</p>
|
||||
<p className={`text-sm mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||
You don't have any appointments yet. Book an appointment to get started.
|
||||
</p>
|
||||
<Link href="/book-now">
|
||||
<Button className="bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white">
|
||||
<CalendarPlus className="w-4 h-4 mr-2" />
|
||||
Book Appointment
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account Information */}
|
||||
{user && (
|
||||
<div className={`rounded-lg border p-4 sm:p-5 md:p-6 ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}>
|
||||
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
Account Information
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
|
||||
<User className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||
Full Name
|
||||
</p>
|
||||
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{user.first_name} {user.last_name}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`rounded-lg border p-4 sm:p-5 md:p-6 ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}>
|
||||
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
Account Information
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
|
||||
<User className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
|
||||
<Mail className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||
Email
|
||||
</p>
|
||||
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||
Full Name
|
||||
</p>
|
||||
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
John Doe
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
|
||||
<Mail className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||
Email
|
||||
</p>
|
||||
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
john.doe@example.com
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
|
||||
<Phone className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||
Phone
|
||||
</p>
|
||||
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
+1 (555) 123-4567
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
|
||||
<Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||
Member Since
|
||||
</p>
|
||||
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
January 2025
|
||||
</p>
|
||||
</div>
|
||||
{user.phone_number && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
|
||||
<Phone className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||
Phone
|
||||
</p>
|
||||
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{user.phone_number}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{user.date_joined && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
|
||||
<Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||
Member Since
|
||||
</p>
|
||||
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||
{formatMemberSince(user.date_joined)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } 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,26 +13,19 @@ 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({
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
fullName: "John Doe",
|
||||
email: "john.doe@example.com",
|
||||
phone: "+1 (555) 123-4567",
|
||||
});
|
||||
const [passwordData, setPasswordData] = useState({
|
||||
currentPassword: "",
|
||||
@ -45,29 +38,6 @@ 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) {
|
||||
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,
|
||||
@ -90,59 +60,35 @@ export default function SettingsPage() {
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.firstName || !formData.lastName) {
|
||||
toast.error("First name and last name are required");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await updateProfile({
|
||||
first_name: formData.firstName,
|
||||
last_name: formData.lastName,
|
||||
phone_number: formData.phone || undefined,
|
||||
});
|
||||
toast.success("Profile updated successfully");
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to update profile";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// Simulate API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
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) {
|
||||
toast.error("New passwords do not match");
|
||||
// In a real app, you would show an error message here
|
||||
alert("New passwords do not match");
|
||||
return;
|
||||
}
|
||||
if (passwordData.newPassword.length < 8) {
|
||||
toast.error("Password must be at least 8 characters long");
|
||||
// In a real app, you would show an error message here
|
||||
alert("Password must be at least 8 characters long");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
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) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to update password";
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// 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
|
||||
};
|
||||
|
||||
return (
|
||||
@ -168,109 +114,83 @@ 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 */}
|
||||
<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 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"
|
||||
/>
|
||||
</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}
|
||||
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 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>
|
||||
</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>
|
||||
<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 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>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Change Password */}
|
||||
<Card className={isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}>
|
||||
@ -294,7 +214,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 h-11 ${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 ${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
|
||||
@ -321,7 +241,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 h-11 ${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 ${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
|
||||
@ -351,7 +271,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 h-11 ${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 ${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
|
||||
@ -375,23 +295,17 @@ 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 ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Updating...
|
||||
</>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="w-4 h-4 mr-2" />
|
||||
Update Password
|
||||
</>
|
||||
<Lock className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Update Password
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -117,96 +117,3 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ export function ClientFocus() {
|
||||
|
||||
const ages = [
|
||||
"Children (0 to 10)",
|
||||
"Teens",
|
||||
"Teen",
|
||||
"Adults",
|
||||
"Elders (65+)"
|
||||
];
|
||||
|
||||
@ -1,177 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Timer } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface ClockDurationPickerProps {
|
||||
duration: number; // Duration in minutes
|
||||
setDuration: (duration: number) => void;
|
||||
label?: string;
|
||||
isDark?: boolean;
|
||||
options?: number[]; // Optional custom duration options
|
||||
}
|
||||
|
||||
export function ClockDurationPicker({
|
||||
duration,
|
||||
setDuration,
|
||||
label,
|
||||
isDark = false,
|
||||
options = [15, 30, 45, 60, 75, 90, 105, 120]
|
||||
}: ClockDurationPickerProps) {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const wrapperRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close picker when clicking outside
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle duration selection
|
||||
const handleDurationClick = (selectedDuration: number) => {
|
||||
setDuration(selectedDuration);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
// Calculate position for clock numbers
|
||||
const getClockPosition = (index: number, total: number, radius: number = 130) => {
|
||||
const angle = (index * 360) / total - 90; // Start from top (-90 degrees)
|
||||
const radian = (angle * Math.PI) / 180;
|
||||
const x = Math.cos(radian) * radius;
|
||||
const y = Math.sin(radian) * radius;
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
// Format duration display
|
||||
const formatDuration = (minutes: number) => {
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||
};
|
||||
|
||||
const displayDuration = duration ? formatDuration(duration) : 'Select duration';
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && (
|
||||
<label className={cn(
|
||||
"text-sm font-semibold",
|
||||
isDark ? "text-gray-300" : "text-gray-700"
|
||||
)}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative w-full" ref={wrapperRef}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal h-12 text-base",
|
||||
!duration && "text-muted-foreground",
|
||||
isDark
|
||||
? "bg-gray-800 border-gray-600 text-white hover:bg-gray-700"
|
||||
: "bg-white border-gray-300 text-gray-900 hover:bg-gray-50"
|
||||
)}
|
||||
>
|
||||
<Timer className="mr-2 h-5 w-5" />
|
||||
{displayDuration}
|
||||
</Button>
|
||||
{isOpen && (
|
||||
<div className={cn(
|
||||
"absolute z-[9999] top-full left-0 right-0 mt-2 rounded-lg shadow-xl border p-6 w-[420px] mx-auto overflow-visible",
|
||||
isDark
|
||||
? "bg-gray-800 border-gray-700"
|
||||
: "bg-white border-gray-200"
|
||||
)}>
|
||||
{/* Clock face */}
|
||||
<div className="relative w-[360px] h-[360px] mx-auto my-6 overflow-visible">
|
||||
{/* Clock circle */}
|
||||
<div className={cn(
|
||||
"absolute inset-0 rounded-full border-2",
|
||||
isDark ? "border-gray-600" : "border-gray-300"
|
||||
)} />
|
||||
|
||||
{/* Center dot */}
|
||||
<div className={cn(
|
||||
"absolute top-1/2 left-1/2 w-2 h-2 rounded-full -translate-x-1/2 -translate-y-1/2 z-10",
|
||||
isDark ? "bg-gray-400" : "bg-gray-600"
|
||||
)} />
|
||||
|
||||
{/* Duration options arranged in a circle */}
|
||||
{options.map((option, index) => {
|
||||
const { x, y } = getClockPosition(index, options.length, 130);
|
||||
const isSelected = duration === option;
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => handleDurationClick(option)}
|
||||
className={cn(
|
||||
"absolute w-16 h-16 rounded-full flex items-center justify-center text-xs font-semibold transition-all z-20 whitespace-nowrap",
|
||||
isSelected
|
||||
? isDark
|
||||
? "bg-blue-600 text-white scale-110 shadow-lg ring-2 ring-blue-400"
|
||||
: "bg-blue-600 text-white scale-110 shadow-lg ring-2 ring-blue-400"
|
||||
: isDark
|
||||
? "bg-gray-700 text-gray-200 hover:bg-gray-600 hover:scale-105"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200 hover:scale-105"
|
||||
)}
|
||||
style={{
|
||||
left: `calc(50% + ${x}px)`,
|
||||
top: `calc(50% + ${y}px)`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
title={`${option} minutes`}
|
||||
>
|
||||
{formatDuration(option)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Quick select buttons for common durations */}
|
||||
<div className="flex gap-2 mt-4 justify-center flex-wrap">
|
||||
{[30, 60, 90, 120].map((quickDuration) => (
|
||||
<button
|
||||
key={quickDuration}
|
||||
type="button"
|
||||
onClick={() => handleDurationClick(quickDuration)}
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded text-sm font-medium transition-colors",
|
||||
duration === quickDuration
|
||||
? isDark
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-blue-600 text-white"
|
||||
: isDark
|
||||
? "bg-gray-700 text-gray-300 hover:bg-gray-600"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
{formatDuration(quickDuration)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,278 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Clock } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface ClockTimePickerProps {
|
||||
time: string; // HH:mm format (e.g., "09:00")
|
||||
setTime: (time: string) => void;
|
||||
label?: string;
|
||||
isDark?: boolean;
|
||||
}
|
||||
|
||||
export function ClockTimePicker({ time, setTime, label, isDark = false }: ClockTimePickerProps) {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const [mode, setMode] = React.useState<'hour' | 'minute'>('hour');
|
||||
const wrapperRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Parse time string to hours and minutes
|
||||
const [hours, minutes] = React.useMemo(() => {
|
||||
if (!time) return [9, 0];
|
||||
const parts = time.split(':').map(Number);
|
||||
return [parts[0] || 9, parts[1] || 0];
|
||||
}, [time]);
|
||||
|
||||
// Convert to 12-hour format for display
|
||||
const displayHours = hours % 12 || 12;
|
||||
const ampm = hours >= 12 ? 'PM' : 'AM';
|
||||
|
||||
// Close picker when clicking outside
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
setMode('hour');
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle hour selection
|
||||
const handleHourClick = (selectedHour: number) => {
|
||||
const newHours = ampm === 'PM' && selectedHour !== 12
|
||||
? selectedHour + 12
|
||||
: ampm === 'AM' && selectedHour === 12
|
||||
? 0
|
||||
: selectedHour;
|
||||
|
||||
setTime(`${newHours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`);
|
||||
setMode('minute');
|
||||
};
|
||||
|
||||
// Handle minute selection
|
||||
const handleMinuteClick = (selectedMinute: number) => {
|
||||
setTime(`${hours.toString().padStart(2, '0')}:${selectedMinute.toString().padStart(2, '0')}`);
|
||||
setIsOpen(false);
|
||||
setMode('hour');
|
||||
};
|
||||
|
||||
// Generate hour numbers (1-12)
|
||||
const hourNumbers = Array.from({ length: 12 }, (_, i) => i + 1);
|
||||
|
||||
// Generate minute numbers (0, 15, 30, 45 or 0-59)
|
||||
const minuteNumbers = Array.from({ length: 12 }, (_, i) => i * 5); // 0, 5, 10, 15, ..., 55
|
||||
|
||||
// Calculate position for clock numbers
|
||||
const getClockPosition = (index: number, total: number, radius: number = 90) => {
|
||||
const angle = (index * 360) / total - 90; // Start from top (-90 degrees)
|
||||
const radian = (angle * Math.PI) / 180;
|
||||
const x = Math.cos(radian) * radius;
|
||||
const y = Math.sin(radian) * radius;
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
// Format display time
|
||||
const displayTime = time
|
||||
? `${displayHours}:${minutes.toString().padStart(2, '0')} ${ampm}`
|
||||
: 'Select time';
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && (
|
||||
<label className={cn(
|
||||
"text-sm font-semibold",
|
||||
isDark ? "text-gray-300" : "text-gray-700"
|
||||
)}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative" ref={wrapperRef}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal h-12 text-base",
|
||||
!time && "text-muted-foreground",
|
||||
isDark
|
||||
? "bg-gray-800 border-gray-600 text-white hover:bg-gray-700"
|
||||
: "bg-white border-gray-300 text-gray-900 hover:bg-gray-50"
|
||||
)}
|
||||
>
|
||||
<Clock className="mr-2 h-5 w-5" />
|
||||
{displayTime}
|
||||
</Button>
|
||||
{isOpen && (
|
||||
<div className={cn(
|
||||
"absolute z-[9999] mt-1 rounded-lg shadow-lg border p-4 -translate-y-1",
|
||||
isDark
|
||||
? "bg-gray-800 border-gray-700"
|
||||
: "bg-white border-gray-200"
|
||||
)}>
|
||||
{/* Mode selector */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setMode('hour')}
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded text-sm font-medium transition-colors",
|
||||
mode === 'hour'
|
||||
? isDark
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-blue-600 text-white"
|
||||
: isDark
|
||||
? "bg-gray-700 text-gray-300 hover:bg-gray-600"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
Hour
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('minute')}
|
||||
className={cn(
|
||||
"px-3 py-1.5 rounded text-sm font-medium transition-colors",
|
||||
mode === 'minute'
|
||||
? isDark
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-blue-600 text-white"
|
||||
: isDark
|
||||
? "bg-gray-700 text-gray-300 hover:bg-gray-600"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
Minute
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Clock face */}
|
||||
<div className="relative w-64 h-64 mx-auto my-4">
|
||||
{/* Clock circle */}
|
||||
<div className={cn(
|
||||
"absolute inset-0 rounded-full border-2",
|
||||
isDark ? "border-gray-600" : "border-gray-300"
|
||||
)} />
|
||||
|
||||
{/* Center dot */}
|
||||
<div className={cn(
|
||||
"absolute top-1/2 left-1/2 w-2 h-2 rounded-full -translate-x-1/2 -translate-y-1/2 z-10",
|
||||
isDark ? "bg-gray-400" : "bg-gray-600"
|
||||
)} />
|
||||
|
||||
{/* Hour numbers */}
|
||||
{mode === 'hour' && hourNumbers.map((hour, index) => {
|
||||
const { x, y } = getClockPosition(index, 12, 90);
|
||||
const isSelected = displayHours === hour;
|
||||
return (
|
||||
<button
|
||||
key={hour}
|
||||
type="button"
|
||||
onClick={() => handleHourClick(hour)}
|
||||
className={cn(
|
||||
"absolute w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold transition-all z-20",
|
||||
isSelected
|
||||
? isDark
|
||||
? "bg-blue-600 text-white scale-110 shadow-lg"
|
||||
: "bg-blue-600 text-white scale-110 shadow-lg"
|
||||
: isDark
|
||||
? "bg-gray-700 text-gray-200 hover:bg-gray-600 hover:scale-105"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200 hover:scale-105"
|
||||
)}
|
||||
style={{
|
||||
left: `calc(50% + ${x}px)`,
|
||||
top: `calc(50% + ${y}px)`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
>
|
||||
{hour}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Minute numbers */}
|
||||
{mode === 'minute' && minuteNumbers.map((minute, index) => {
|
||||
const { x, y } = getClockPosition(index, 12, 90);
|
||||
const isSelected = minutes === minute;
|
||||
return (
|
||||
<button
|
||||
key={minute}
|
||||
type="button"
|
||||
onClick={() => handleMinuteClick(minute)}
|
||||
className={cn(
|
||||
"absolute w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold transition-all z-20",
|
||||
isSelected
|
||||
? isDark
|
||||
? "bg-blue-600 text-white scale-110 shadow-lg"
|
||||
: "bg-blue-600 text-white scale-110 shadow-lg"
|
||||
: isDark
|
||||
? "bg-gray-700 text-gray-200 hover:bg-gray-600 hover:scale-105"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200 hover:scale-105"
|
||||
)}
|
||||
style={{
|
||||
left: `calc(50% + ${x}px)`,
|
||||
top: `calc(50% + ${y}px)`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
>
|
||||
{minute}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* AM/PM toggle */}
|
||||
<div className="flex gap-2 mt-4 justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newHours = ampm === 'PM' ? hours - 12 : hours;
|
||||
setTime(`${newHours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`);
|
||||
}}
|
||||
className={cn(
|
||||
"px-4 py-2 rounded text-sm font-medium transition-colors",
|
||||
ampm === 'AM'
|
||||
? isDark
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-blue-600 text-white"
|
||||
: isDark
|
||||
? "bg-gray-700 text-gray-300 hover:bg-gray-600"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
AM
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newHours = ampm === 'AM' ? hours + 12 : hours;
|
||||
setTime(`${newHours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`);
|
||||
}}
|
||||
className={cn(
|
||||
"px-4 py-2 rounded text-sm font-medium transition-colors",
|
||||
ampm === 'PM'
|
||||
? isDark
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-blue-600 text-white"
|
||||
: isDark
|
||||
? "bg-gray-700 text-gray-300 hover:bg-gray-600"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
)}
|
||||
>
|
||||
PM
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -9,7 +9,6 @@ import { Textarea } from "@/components/ui/textarea";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { toast } from "sonner";
|
||||
import { useAppTheme } from "@/components/ThemeProvider";
|
||||
import { submitContactForm } from "@/lib/actions/auth";
|
||||
|
||||
export function ContactSection() {
|
||||
const ref = useRef(null);
|
||||
@ -22,26 +21,13 @@ export function ContactSection() {
|
||||
phone: "",
|
||||
message: "",
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await submitContactForm(formData);
|
||||
toast.success("Message Sent Successfully", {
|
||||
description: "Thank you for reaching out. We'll get back to you soon!",
|
||||
});
|
||||
setFormData({ name: "", email: "", phone: "", message: "" });
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Failed to send message. Please try again.";
|
||||
toast.error("Error Sending Message", {
|
||||
description: errorMessage,
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
toast("Message Received", {
|
||||
description: "Thank you for reaching out. We'll get back to you soon!",
|
||||
});
|
||||
setFormData({ name: "", email: "", phone: "", message: "" });
|
||||
};
|
||||
|
||||
return (
|
||||
@ -218,12 +204,11 @@ export function ContactSection() {
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full cursor-pointer bg-gradient-to-r from-rose-500 to-pink-600 text-white transition-all hover:from-rose-600 hover:to-pink-700 hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="w-full cursor-pointer bg-gradient-to-r from-rose-500 to-pink-600 text-white transition-all hover:from-rose-600 hover:to-pink-700 hover:scale-[1.02]"
|
||||
size="lg"
|
||||
>
|
||||
<Send className="mr-2 h-5 w-5" />
|
||||
{isSubmitting ? "Sending..." : "Send Message"}
|
||||
Send Message
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
@ -1,12 +1,22 @@
|
||||
'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 DatePickerLib from 'react-datepicker';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
interface DatePickerProps {
|
||||
date: Date | undefined;
|
||||
@ -15,25 +25,7 @@ interface DatePickerProps {
|
||||
}
|
||||
|
||||
export function DatePicker({ date, setDate, label }: DatePickerProps) {
|
||||
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]);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@ -42,38 +34,65 @@ export function DatePicker({ date, setDate, label }: DatePickerProps) {
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative" ref={wrapperRef}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal h-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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,72 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Timer } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface DurationPickerProps {
|
||||
duration: number; // Duration in minutes
|
||||
setDuration: (duration: number) => void;
|
||||
label?: string;
|
||||
isDark?: boolean;
|
||||
options?: number[]; // Optional custom duration options
|
||||
}
|
||||
|
||||
export function DurationPicker({
|
||||
duration,
|
||||
setDuration,
|
||||
label,
|
||||
isDark = false,
|
||||
options = [15, 30, 45, 60, 120]
|
||||
}: DurationPickerProps) {
|
||||
// Format duration display
|
||||
const formatDuration = (minutes: number) => {
|
||||
if (minutes < 60) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
||||
};
|
||||
|
||||
const displayDuration = duration ? formatDuration(duration) : 'Select duration';
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{label && (
|
||||
<label className={cn(
|
||||
"text-sm font-semibold",
|
||||
isDark ? "text-gray-300" : "text-gray-700"
|
||||
)}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map((option) => {
|
||||
const isSelected = duration === option;
|
||||
return (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setDuration(option)}
|
||||
className={cn(
|
||||
"px-4 py-3 rounded-lg text-sm font-medium transition-all min-w-[80px]",
|
||||
isSelected
|
||||
? isDark
|
||||
? "bg-blue-600 text-white shadow-md"
|
||||
: "bg-blue-600 text-white shadow-md"
|
||||
: isDark
|
||||
? "bg-gray-800 text-gray-300 hover:bg-gray-700 border border-gray-600"
|
||||
: "bg-white text-gray-700 hover:bg-gray-50 border border-gray-200"
|
||||
)}
|
||||
>
|
||||
{formatDuration(option)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -127,7 +127,7 @@ export function Footer() {
|
||||
<li className="flex items-start gap-3">
|
||||
<MapPin className="h-4 w-4 mt-1 text-rose-600 dark:text-rose-400 flex-shrink-0" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Hollywood, Florida
|
||||
Miami, Florida
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@ -1,465 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAppTheme } from "@/components/ThemeProvider";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "@/components/ui/input-otp";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Eye, EyeOff, Loader2, X, CheckCircle2 } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import {
|
||||
forgotPasswordSchema,
|
||||
verifyPasswordResetOtpSchema,
|
||||
resetPasswordSchema,
|
||||
type ForgotPasswordInput,
|
||||
type VerifyPasswordResetOtpInput,
|
||||
type ResetPasswordInput
|
||||
} from "@/lib/schema/auth";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ForgotPasswordDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
type Step = "request" | "verify" | "reset";
|
||||
|
||||
export function ForgotPasswordDialog({ open, onOpenChange, onSuccess }: ForgotPasswordDialogProps) {
|
||||
const { theme } = useAppTheme();
|
||||
const isDark = theme === "dark";
|
||||
const {
|
||||
forgotPasswordMutation,
|
||||
verifyPasswordResetOtpMutation,
|
||||
resetPasswordMutation,
|
||||
resendOtpMutation
|
||||
} = useAuth();
|
||||
const [step, setStep] = useState<Step>("request");
|
||||
const [email, setEmail] = useState("");
|
||||
const [otpData, setOtpData] = useState<VerifyPasswordResetOtpInput>({
|
||||
email: "",
|
||||
otp: "",
|
||||
});
|
||||
const [resetData, setResetData] = useState<ResetPasswordInput>({
|
||||
email: "",
|
||||
otp: "",
|
||||
new_password: "",
|
||||
confirm_password: "",
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showPassword2, setShowPassword2] = useState(false);
|
||||
|
||||
const handleRequestOtp = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const validation = forgotPasswordSchema.safeParse({ email });
|
||||
if (!validation.success) {
|
||||
const firstError = validation.error.issues[0];
|
||||
toast.error(firstError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await forgotPasswordMutation.mutateAsync({ email });
|
||||
setOtpData({ email, otp: "" });
|
||||
setResetData({ email, otp: "", new_password: "", confirm_password: "" });
|
||||
setStep("verify");
|
||||
toast.success("Password reset OTP sent! Please check your email.");
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to send OTP. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyOtp = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const emailToVerify = email || otpData.email;
|
||||
if (!emailToVerify) {
|
||||
toast.error("Email is required");
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = verifyPasswordResetOtpSchema.safeParse({
|
||||
email: emailToVerify,
|
||||
otp: otpData.otp,
|
||||
});
|
||||
|
||||
if (!validation.success) {
|
||||
const firstError = validation.error.issues[0];
|
||||
toast.error(firstError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await verifyPasswordResetOtpMutation.mutateAsync({
|
||||
email: emailToVerify,
|
||||
otp: otpData.otp,
|
||||
});
|
||||
setResetData({
|
||||
email: emailToVerify,
|
||||
otp: otpData.otp,
|
||||
new_password: "",
|
||||
confirm_password: ""
|
||||
});
|
||||
setStep("reset");
|
||||
toast.success("OTP verified! Please set your new password.");
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "OTP verification failed. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const validation = resetPasswordSchema.safeParse(resetData);
|
||||
if (!validation.success) {
|
||||
const firstError = validation.error.issues[0];
|
||||
toast.error(firstError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await resetPasswordMutation.mutateAsync(resetData);
|
||||
toast.success("Password reset successful! Please log in with your new password.");
|
||||
handleDialogChange(false);
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Password reset failed. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendOtp = async () => {
|
||||
const emailToResend = email || otpData.email;
|
||||
if (!emailToResend) {
|
||||
toast.error("Email is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await resendOtpMutation.mutateAsync({
|
||||
email: emailToResend,
|
||||
context: "password_reset"
|
||||
});
|
||||
toast.success("OTP resent successfully! Please check your email.");
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to resend OTP";
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOtpChange = (field: keyof VerifyPasswordResetOtpInput, value: string) => {
|
||||
setOtpData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
// Reset step when dialog closes
|
||||
const handleDialogChange = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
setStep("request");
|
||||
setEmail("");
|
||||
setOtpData({ email: "", otp: "" });
|
||||
setResetData({ email: "", otp: "", new_password: "", confirm_password: "" });
|
||||
}
|
||||
onOpenChange(isOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleDialogChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={`max-w-md max-h-[90vh] overflow-hidden flex flex-col p-0 ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}
|
||||
>
|
||||
{/* Header with Close Button - Fixed */}
|
||||
<div className="flex items-start justify-between p-6 pb-4 flex-shrink-0 border-b border-gray-200 dark:border-gray-700">
|
||||
<DialogHeader className="flex-1 pr-2">
|
||||
<DialogTitle className="text-2xl sm:text-3xl font-bold bg-gradient-to-r from-rose-600 via-pink-600 to-rose-600 bg-clip-text text-transparent">
|
||||
{step === "request" && "Reset Password"}
|
||||
{step === "verify" && "Verify OTP"}
|
||||
{step === "reset" && "Set New Password"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className={`text-sm sm:text-base mt-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||
{step === "request" && "Enter your email to receive a password reset code"}
|
||||
{step === "verify" && "Enter the verification code sent to your email"}
|
||||
{step === "reset" && "Enter your new password"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* Close Button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDialogChange(false)}
|
||||
className={`flex-shrink-0 w-8 h-8 rounded-full ${isDark ? 'text-gray-400 hover:text-gray-300 hover:bg-gray-700' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'}`}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="overflow-y-auto flex-1 px-6">
|
||||
{/* Request OTP Form */}
|
||||
{step === "request" && (
|
||||
<form className="space-y-4 sm:space-y-5 py-4 sm:py-6" onSubmit={handleRequestOtp}>
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<label htmlFor="forgot-email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Email address *
|
||||
</label>
|
||||
<Input
|
||||
id="forgot-email"
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className={`h-11 sm:h-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={forgotPasswordMutation.isPending}
|
||||
className="w-full h-11 sm:h-12 text-sm sm:text-base font-semibold 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 disabled:opacity-50 disabled:cursor-not-allowed mt-4 sm:mt-6"
|
||||
>
|
||||
{forgotPasswordMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
"Send Reset Code"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Verify OTP Form */}
|
||||
{step === "verify" && (
|
||||
<form className="space-y-4 sm:space-y-5 py-4 sm:py-6" onSubmit={handleVerifyOtp}>
|
||||
<div className={`p-3 sm:p-4 rounded-lg border ${isDark ? 'bg-blue-900/20 border-blue-800' : 'bg-blue-50 border-blue-200'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className={`w-5 h-5 mt-0.5 flex-shrink-0 ${isDark ? 'text-blue-400' : 'text-blue-600'}`} />
|
||||
<div>
|
||||
<p className={`text-sm font-medium ${isDark ? 'text-blue-200' : 'text-blue-900'}`}>
|
||||
Check your email
|
||||
</p>
|
||||
<p className={`text-xs sm:text-sm mt-1 ${isDark ? 'text-blue-300' : 'text-blue-700'}`}>
|
||||
We've sent a 6-digit verification code to {email || otpData.email || "your email address"}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email Field (if not set) */}
|
||||
{!email && (
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<label htmlFor="verify-email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Email address *
|
||||
</label>
|
||||
<Input
|
||||
id="verify-email"
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
value={otpData.email}
|
||||
onChange={(e) => handleOtpChange("email", e.target.value)}
|
||||
className={`h-11 sm:h-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OTP Field */}
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<label className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Verification Code *
|
||||
</label>
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={otpData.otp}
|
||||
onChange={(value) => handleOtpChange("otp", value)}
|
||||
>
|
||||
<InputOTPGroup className="gap-2 sm:gap-3">
|
||||
<InputOTPSlot index={0} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
|
||||
<InputOTPSlot index={1} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
|
||||
<InputOTPSlot index={2} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
|
||||
<InputOTPSlot index={3} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
|
||||
<InputOTPSlot index={4} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
|
||||
<InputOTPSlot index={5} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resend OTP */}
|
||||
<div className="text-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
onClick={handleResendOtp}
|
||||
disabled={resendOtpMutation?.isPending}
|
||||
className={`h-auto p-0 text-xs sm:text-sm font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
|
||||
>
|
||||
{resendOtpMutation?.isPending ? "Sending..." : "Didn't receive the code? Resend"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={verifyPasswordResetOtpMutation.isPending}
|
||||
className="w-full h-11 sm:h-12 text-sm sm:text-base font-semibold 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 disabled:opacity-50 disabled:cursor-not-allowed mt-4 sm:mt-6"
|
||||
>
|
||||
{verifyPasswordResetOtpMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Verifying...
|
||||
</>
|
||||
) : (
|
||||
"Verify Code"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Back to request */}
|
||||
<div className="text-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
onClick={() => {
|
||||
setStep("request");
|
||||
setOtpData({ email: "", otp: "" });
|
||||
}}
|
||||
className={`h-auto p-0 text-xs sm:text-sm font-medium ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-600 hover:text-gray-700'}`}
|
||||
>
|
||||
← Back
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Reset Password Form */}
|
||||
{step === "reset" && (
|
||||
<form className="space-y-4 sm:space-y-5 py-4 sm:py-6" onSubmit={handleResetPassword}>
|
||||
{/* New Password Field */}
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<label htmlFor="reset-password" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
New Password *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="reset-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="New password (min 8 characters)"
|
||||
value={resetData.new_password}
|
||||
onChange={(e) => setResetData({ ...resetData, new_password: e.target.value })}
|
||||
className={`h-11 sm:h-12 pr-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className={`absolute right-3 sm:right-4 top-1/2 -translate-y-1/2 h-auto w-auto p-0 ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-700'}`}
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm Password Field */}
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<label htmlFor="reset-password2" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Confirm New Password *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="reset-password2"
|
||||
type={showPassword2 ? "text" : "password"}
|
||||
placeholder="Confirm new password"
|
||||
value={resetData.confirm_password}
|
||||
onChange={(e) => setResetData({ ...resetData, confirm_password: e.target.value })}
|
||||
className={`h-11 sm:h-12 pr-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowPassword2(!showPassword2)}
|
||||
className={`absolute right-3 sm:right-4 top-1/2 -translate-y-1/2 h-auto w-auto p-0 ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-700'}`}
|
||||
aria-label={showPassword2 ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword2 ? (
|
||||
<EyeOff className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={resetPasswordMutation.isPending}
|
||||
className="w-full h-11 sm:h-12 text-sm sm:text-base font-semibold 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 disabled:opacity-50 disabled:cursor-not-allowed mt-4 sm:mt-6"
|
||||
>
|
||||
{resetPasswordMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Resetting password...
|
||||
</>
|
||||
) : (
|
||||
"Reset Password"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Back to verify */}
|
||||
<div className="text-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
onClick={() => {
|
||||
setStep("verify");
|
||||
setResetData({ ...resetData, new_password: "", confirm_password: "" });
|
||||
}}
|
||||
className={`h-auto p-0 text-xs sm:text-sm font-medium ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-600 hover:text-gray-700'}`}
|
||||
>
|
||||
← Back
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAppTheme } from "@/components/ThemeProvider";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@ -11,54 +11,51 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Eye, EyeOff, Loader2, X, Mail } from "lucide-react";
|
||||
import { Eye, EyeOff, Loader2, X } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { loginSchema, type LoginInput } from "@/lib/schema/auth";
|
||||
import { loginSchema, registerSchema, type LoginInput, type RegisterInput } from "@/lib/schema/auth";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { ForgotPasswordDialog } from "./ForgotPasswordDialog";
|
||||
import { VerifyOtpDialog } from "./VerifyOtpDialog";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
interface LoginDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onLoginSuccess: () => void;
|
||||
prefillEmail?: string;
|
||||
onSwitchToSignup?: () => void;
|
||||
skipRedirect?: boolean; // Option to skip automatic redirect
|
||||
}
|
||||
|
||||
// Login Dialog component
|
||||
export function LoginDialog({ open, onOpenChange, onLoginSuccess, prefillEmail, onSwitchToSignup, skipRedirect = false }: LoginDialogProps) {
|
||||
export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogProps) {
|
||||
const { theme } = useAppTheme();
|
||||
const isDark = theme === "dark";
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { login, loginMutation } = useAuth();
|
||||
const { login, register, loginMutation, registerMutation } = useAuth();
|
||||
const [isSignup, setIsSignup] = useState(false);
|
||||
const [loginData, setLoginData] = useState<LoginInput>({
|
||||
email: "",
|
||||
password: "",
|
||||
});
|
||||
const [signupData, setSignupData] = useState<RegisterInput>({
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
email: "",
|
||||
phone_number: "",
|
||||
password: "",
|
||||
password2: "",
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [forgotPasswordDialogOpen, setForgotPasswordDialogOpen] = useState(false);
|
||||
const [showResendOtp, setShowResendOtp] = useState(false);
|
||||
const [verifyOtpDialogOpen, setVerifyOtpDialogOpen] = useState(false);
|
||||
|
||||
// Pre-fill email if provided
|
||||
useEffect(() => {
|
||||
if (prefillEmail && open) {
|
||||
setLoginData(prev => ({ ...prev, email: prefillEmail }));
|
||||
}
|
||||
}, [prefillEmail, open]);
|
||||
const [showPassword2, setShowPassword2] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
// Validate form
|
||||
const validation = loginSchema.safeParse(loginData);
|
||||
if (!validation.success) {
|
||||
const firstError = validation.error.issues[0];
|
||||
toast.error(firstError.message);
|
||||
setError(firstError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -68,89 +65,73 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess, prefillEmail,
|
||||
if (result.tokens && result.user) {
|
||||
toast.success("Login successful!");
|
||||
setShowPassword(false);
|
||||
setShowResendOtp(false);
|
||||
onOpenChange(false);
|
||||
// Reset form
|
||||
setLoginData({ email: "", password: "" });
|
||||
|
||||
// Check if user is admin/staff/superuser
|
||||
const user = result.user as any;
|
||||
const isTruthy = (value: any): boolean => {
|
||||
if (value === true || value === "true" || value === 1 || value === "1") return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const userIsAdmin =
|
||||
isTruthy(user.is_admin) ||
|
||||
isTruthy(user.isAdmin) ||
|
||||
isTruthy(user.is_staff) ||
|
||||
isTruthy(user.isStaff) ||
|
||||
isTruthy(user.is_superuser) ||
|
||||
isTruthy(user.isSuperuser);
|
||||
|
||||
// Call onLoginSuccess callback first
|
||||
onLoginSuccess();
|
||||
|
||||
// Only redirect if skipRedirect is false and we're not on the booking page
|
||||
if (!skipRedirect && pathname !== "/book-now") {
|
||||
// Redirect based on user role
|
||||
const redirectPath = userIsAdmin ? "/admin/booking" : "/user/dashboard";
|
||||
setTimeout(() => {
|
||||
window.location.href = redirectPath;
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Login failed. Please try again.";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
|
||||
// Check if error is about email verification
|
||||
if (errorMessage.toLowerCase().includes("verify your email") ||
|
||||
errorMessage.toLowerCase().includes("email address before logging")) {
|
||||
setShowResendOtp(true);
|
||||
} else {
|
||||
setShowResendOtp(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle resend OTP - just open the verification dialog (it will auto-send OTP)
|
||||
const handleResendOtp = () => {
|
||||
if (!loginData.email) {
|
||||
toast.error("Email address is required to resend OTP.");
|
||||
const handleSignup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
// Validate form
|
||||
const validation = registerSchema.safeParse(signupData);
|
||||
if (!validation.success) {
|
||||
const firstError = validation.error.issues[0];
|
||||
setError(firstError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Close login dialog and open OTP verification dialog
|
||||
// The VerifyOtpDialog will automatically send the OTP when it opens
|
||||
setShowResendOtp(false);
|
||||
onOpenChange(false);
|
||||
setTimeout(() => {
|
||||
setVerifyOtpDialogOpen(true);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Handle OTP verification success
|
||||
const handleOtpVerificationSuccess = () => {
|
||||
// After successful verification, user can try logging in again
|
||||
setVerifyOtpDialogOpen(false);
|
||||
// Optionally reopen login dialog
|
||||
setTimeout(() => {
|
||||
onOpenChange(true);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Reset form when dialog closes
|
||||
const handleDialogChange = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
setLoginData({ email: "", password: "" });
|
||||
setShowResendOtp(false);
|
||||
try {
|
||||
const result = await register(signupData);
|
||||
|
||||
if (result.message) {
|
||||
toast.success("Registration successful! Please check your email for OTP verification.");
|
||||
// Switch to login after successful registration
|
||||
setIsSignup(false);
|
||||
setLoginData({ email: signupData.email, password: "" });
|
||||
setSignupData({
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
email: "",
|
||||
phone_number: "",
|
||||
password: "",
|
||||
password2: "",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Signup failed. Please try again.";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
onOpenChange(isOpen);
|
||||
};
|
||||
|
||||
const handleSwitchToSignup = () => {
|
||||
setIsSignup(true);
|
||||
setError(null);
|
||||
setLoginData({ email: "", password: "" });
|
||||
};
|
||||
|
||||
const handleSwitchToLogin = () => {
|
||||
setIsSignup(false);
|
||||
setError(null);
|
||||
setSignupData({
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
email: "",
|
||||
phone_number: "",
|
||||
password: "",
|
||||
password2: "",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleDialogChange}>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={`max-w-md max-h-[90vh] overflow-hidden flex flex-col p-0 ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}
|
||||
@ -159,57 +140,110 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess, prefillEmail,
|
||||
<div className="flex items-start justify-between p-6 pb-4 flex-shrink-0 border-b border-gray-200 dark:border-gray-700">
|
||||
<DialogHeader className="flex-1 pr-2">
|
||||
<DialogTitle className="text-2xl sm:text-3xl font-bold bg-gradient-to-r from-rose-600 via-pink-600 to-rose-600 bg-clip-text text-transparent">
|
||||
Welcome back
|
||||
{isSignup ? "Create an account" : "Welcome back"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className={`text-sm sm:text-base mt-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||
Please log in to complete your booking
|
||||
{isSignup
|
||||
? "Sign up to complete your booking"
|
||||
: "Please log in to complete your booking"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* Close Button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDialogChange(false)}
|
||||
className={`flex-shrink-0 w-8 h-8 rounded-full ${isDark ? 'text-gray-400 hover:text-gray-300 hover:bg-gray-700' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'}`}
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center transition-colors ${isDark ? 'text-gray-400 hover:text-gray-300 hover:bg-gray-700' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'}`}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="overflow-y-auto flex-1 px-6">
|
||||
{/* Login Form */}
|
||||
<form className="space-y-4 sm:space-y-5 py-4 sm:py-6" onSubmit={handleLogin}>
|
||||
{/* Email Field */}
|
||||
{/* Signup Form */}
|
||||
{isSignup ? (
|
||||
<form className="space-y-4 sm:space-y-5 py-4 sm:py-6" onSubmit={handleSignup}>
|
||||
{error && (
|
||||
<div className={`p-3 rounded-lg border ${isDark ? 'bg-red-900/20 border-red-800' : 'bg-red-50 border-red-200'}`}>
|
||||
<p className={`text-sm ${isDark ? 'text-red-200' : 'text-red-800'}`}>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* First Name Field */}
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<label htmlFor="login-email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Email address
|
||||
<label htmlFor="signup-firstName" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
First Name *
|
||||
</label>
|
||||
<Input
|
||||
id="login-email"
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
value={loginData.email}
|
||||
onChange={(e) => setLoginData({ ...loginData, email: e.target.value })}
|
||||
id="signup-firstName"
|
||||
type="text"
|
||||
placeholder="John"
|
||||
value={signupData.first_name}
|
||||
onChange={(e) => setSignupData({ ...signupData, first_name: e.target.value })}
|
||||
className={`h-11 sm:h-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Last Name Field */}
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<label htmlFor="signup-lastName" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Last Name *
|
||||
</label>
|
||||
<Input
|
||||
id="signup-lastName"
|
||||
type="text"
|
||||
placeholder="Doe"
|
||||
value={signupData.last_name}
|
||||
onChange={(e) => setSignupData({ ...signupData, last_name: e.target.value })}
|
||||
className={`h-11 sm:h-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email Field */}
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<label htmlFor="signup-email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Email address *
|
||||
</label>
|
||||
<Input
|
||||
id="signup-email"
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
value={signupData.email}
|
||||
onChange={(e) => setSignupData({ ...signupData, email: e.target.value })}
|
||||
className={`h-11 sm:h-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Phone Field */}
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<label htmlFor="signup-phone" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Phone Number (Optional)
|
||||
</label>
|
||||
<Input
|
||||
id="signup-phone"
|
||||
type="tel"
|
||||
placeholder="+1 (555) 123-4567"
|
||||
value={signupData.phone_number || ""}
|
||||
onChange={(e) => setSignupData({ ...signupData, phone_number: e.target.value })}
|
||||
className={`h-11 sm:h-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<label htmlFor="login-password" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Your password
|
||||
<label htmlFor="signup-password" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Password *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="login-password"
|
||||
id="signup-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Your password"
|
||||
value={loginData.password}
|
||||
onChange={(e) => setLoginData({ ...loginData, password: e.target.value })}
|
||||
placeholder="Password (min 8 characters)"
|
||||
value={signupData.password}
|
||||
onChange={(e) => setSignupData({ ...signupData, password: e.target.value })}
|
||||
className={`h-11 sm:h-12 pr-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||
required
|
||||
/>
|
||||
@ -230,99 +264,175 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess, prefillEmail,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm Password Field */}
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<label htmlFor="signup-password2" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Confirm Password *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="signup-password2"
|
||||
type={showPassword2 ? "text" : "password"}
|
||||
placeholder="Confirm password"
|
||||
value={signupData.password2}
|
||||
onChange={(e) => setSignupData({ ...signupData, password2: e.target.value })}
|
||||
className={`h-11 sm:h-12 pr-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowPassword2(!showPassword2)}
|
||||
className={`absolute right-3 sm:right-4 top-1/2 -translate-y-1/2 h-auto w-auto p-0 ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-700'}`}
|
||||
aria-label={showPassword2 ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword2 ? (
|
||||
<EyeOff className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loginMutation.isPending}
|
||||
disabled={registerMutation.isPending}
|
||||
className="w-full h-11 sm:h-12 text-sm sm:text-base font-semibold 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 disabled:opacity-50 disabled:cursor-not-allowed mt-4 sm:mt-6"
|
||||
>
|
||||
{loginMutation.isPending ? (
|
||||
{registerMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Logging in...
|
||||
Creating account...
|
||||
</>
|
||||
) : (
|
||||
"Log in"
|
||||
"Sign up"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Resend OTP - Show when email verification error occurs */}
|
||||
{showResendOtp && (
|
||||
<div className={`p-3 sm:p-4 rounded-lg border ${isDark ? 'bg-yellow-900/20 border-yellow-800' : 'bg-yellow-50 border-yellow-200'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<Mail className={`w-5 h-5 mt-0.5 flex-shrink-0 ${isDark ? 'text-yellow-400' : 'text-yellow-600'}`} />
|
||||
<div className="flex-1">
|
||||
<p className={`text-sm font-medium ${isDark ? 'text-yellow-200' : 'text-yellow-900'}`}>
|
||||
Email verification required
|
||||
</p>
|
||||
<p className={`text-xs sm:text-sm mt-1 ${isDark ? 'text-yellow-300' : 'text-yellow-700'}`}>
|
||||
Please verify your email address before logging in. We can resend the verification code to {loginData.email}.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
onClick={handleResendOtp}
|
||||
className={`h-auto p-0 mt-2 text-xs sm:text-sm font-medium ${isDark ? 'text-yellow-400 hover:text-yellow-300' : 'text-yellow-700 hover:text-yellow-800'}`}
|
||||
>
|
||||
Resend verification code
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Forgot Password */}
|
||||
<div className="flex items-center justify-end text-xs sm:text-sm">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className={`font-medium text-xs sm:text-sm h-auto p-0 ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
|
||||
onClick={() => {
|
||||
handleDialogChange(false);
|
||||
setTimeout(() => {
|
||||
setForgotPasswordDialogOpen(true);
|
||||
}, 100);
|
||||
}}
|
||||
>
|
||||
Forgot password?
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Sign Up Prompt */}
|
||||
{/* Switch to Login */}
|
||||
<p className={`text-xs sm:text-sm text-center pt-2 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||
New to Attune Heart Therapy?{" "}
|
||||
<Button
|
||||
Already have an account?{" "}
|
||||
<button
|
||||
type="button"
|
||||
variant="link"
|
||||
onClick={() => {
|
||||
handleDialogChange(false);
|
||||
if (onSwitchToSignup) {
|
||||
onSwitchToSignup();
|
||||
}
|
||||
}}
|
||||
className={`h-auto p-0 font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
|
||||
onClick={handleSwitchToLogin}
|
||||
className={`underline font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
|
||||
>
|
||||
Sign up
|
||||
</Button>
|
||||
Log in
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
) : (
|
||||
/* Login Form */
|
||||
<form className="space-y-4 sm:space-y-5 py-4 sm:py-6" onSubmit={handleLogin}>
|
||||
{error && (
|
||||
<div className={`p-3 rounded-lg border ${isDark ? 'bg-red-900/20 border-red-800' : 'bg-red-50 border-red-200'}`}>
|
||||
<p className={`text-sm ${isDark ? 'text-red-200' : 'text-red-800'}`}>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Field */}
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<label htmlFor="login-email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Email address
|
||||
</label>
|
||||
<Input
|
||||
id="login-email"
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
value={loginData.email}
|
||||
onChange={(e) => setLoginData({ ...loginData, email: e.target.value })}
|
||||
className={`h-11 sm:h-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<label htmlFor="login-password" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Your password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="login-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Your password"
|
||||
value={loginData.password}
|
||||
onChange={(e) => setLoginData({ ...loginData, password: e.target.value })}
|
||||
className={`h-11 sm:h-12 pr-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className={`absolute right-3 sm:right-4 top-1/2 -translate-y-1/2 h-auto w-auto p-0 ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-700'}`}
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loginMutation.isPending}
|
||||
className="w-full h-11 sm:h-12 text-sm sm:text-base font-semibold 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 disabled:opacity-50 disabled:cursor-not-allowed mt-4 sm:mt-6"
|
||||
>
|
||||
{loginMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Logging in...
|
||||
</>
|
||||
) : (
|
||||
"Log in"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Remember Me & Forgot Password */}
|
||||
<div className="flex items-center justify-between text-xs sm:text-sm">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberMe}
|
||||
onChange={(e) => setRememberMe(e.target.checked)}
|
||||
className={`w-4 h-4 rounded text-rose-600 focus:ring-2 focus:ring-rose-500 cursor-pointer ${isDark ? 'border-gray-600 bg-gray-700' : 'border-gray-300'}`}
|
||||
/>
|
||||
<span className={isDark ? 'text-gray-300' : 'text-black'}>Remember me</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className={`font-medium text-xs sm:text-sm ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sign Up Prompt */}
|
||||
<p className={`text-xs sm:text-sm text-center pt-2 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||
New to Attune Heart Therapy?{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSwitchToSignup}
|
||||
className={`underline font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
|
||||
>
|
||||
Sign up
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
{/* Forgot Password Dialog */}
|
||||
<ForgotPasswordDialog
|
||||
open={forgotPasswordDialogOpen}
|
||||
onOpenChange={setForgotPasswordDialogOpen}
|
||||
/>
|
||||
|
||||
{/* Verify OTP Dialog */}
|
||||
<VerifyOtpDialog
|
||||
open={verifyOtpDialogOpen}
|
||||
onOpenChange={setVerifyOtpDialogOpen}
|
||||
email={loginData.email}
|
||||
context="registration"
|
||||
onVerificationSuccess={handleOtpVerificationSuccess}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -2,11 +2,10 @@
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Heart, Menu, X, LogOut, LayoutGrid } from "lucide-react";
|
||||
import { Heart, Menu, X, LogOut } from "lucide-react";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LoginDialog } from "@/components/LoginDialog";
|
||||
import { SignupDialog } from "@/components/SignupDialog";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useAppTheme } from "@/components/ThemeProvider";
|
||||
@ -17,18 +16,13 @@ export function Navbar() {
|
||||
const { theme } = useAppTheme();
|
||||
const isDark = theme === "dark";
|
||||
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
|
||||
const [signupDialogOpen, setSignupDialogOpen] = useState(false);
|
||||
const [prefillEmail, setPrefillEmail] = useState<string | undefined>(undefined);
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const isUserDashboard = pathname?.startsWith("/user/dashboard");
|
||||
const isAdminDashboard = pathname?.startsWith("/admin/dashboard");
|
||||
const isUserSettings = pathname?.startsWith("/user/settings");
|
||||
const isAdminSettings = pathname?.startsWith("/admin/settings");
|
||||
const isUserAppointmentDetails = pathname?.startsWith("/user/appointments/");
|
||||
const isUserRoute = pathname?.startsWith("/user/");
|
||||
const { isAuthenticated, logout, user, isAdmin } = useAuth();
|
||||
const { isAuthenticated, logout } = useAuth();
|
||||
|
||||
const scrollToSection = (id: string) => {
|
||||
const element = document.getElementById(id);
|
||||
@ -39,11 +33,8 @@ export function Navbar() {
|
||||
};
|
||||
|
||||
const handleLoginSuccess = () => {
|
||||
// Check if user is admin/staff/superuser and redirect accordingly
|
||||
// Note: user might not be immediately available, so we check isAdmin from hook
|
||||
// which is computed from the user data
|
||||
const redirectPath = isAdmin ? "/admin/booking" : "/user/dashboard";
|
||||
router.push(redirectPath);
|
||||
// Redirect to admin dashboard after successful login
|
||||
router.push("/admin/dashboard");
|
||||
setMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
@ -83,10 +74,7 @@ export function Navbar() {
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-1.5 sm:gap-2"
|
||||
>
|
||||
<Link href="/" className="flex items-center gap-1.5 sm:gap-2">
|
||||
<div className="bg-gradient-to-r from-rose-500 to-pink-600 p-1.5 sm:p-2 rounded-lg sm:rounded-xl">
|
||||
<Heart className="h-4 w-4 sm:h-5 sm:w-5 text-white fill-white" />
|
||||
</div>
|
||||
@ -133,7 +121,7 @@ export function Navbar() {
|
||||
</Button>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
{!isAdmin && (
|
||||
{!isUserDashboard && (
|
||||
<Link
|
||||
href="/book-now"
|
||||
className={`text-sm font-medium transition-colors cursor-pointer px-3 py-2 rounded-lg hover:opacity-90 ${isDark ? 'text-gray-300 hover:text-white' : 'text-gray-700 hover:text-rose-600'}`}
|
||||
@ -142,26 +130,15 @@ export function Navbar() {
|
||||
</Link>
|
||||
)}
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
{!(isUserDashboard || isAdminDashboard || isUserSettings || isAdminSettings || isUserAppointmentDetails) && (
|
||||
<Link
|
||||
href={isAdmin ? "/admin/dashboard" : "/user/dashboard"}
|
||||
className={`text-sm font-medium transition-colors cursor-pointer px-3 py-2 rounded-lg hover:opacity-90 ${isDark ? 'text-gray-300 hover:text-white' : 'text-gray-700 hover:text-rose-600'}`}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4 inline mr-1.5" />
|
||||
Dashboard
|
||||
</Link>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="bg-red-600 hover:bg-red-700 text-white border-red-600 hover:border-red-700 hover:opacity-90 hover:scale-105 transition-all text-xs sm:text-sm"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
Logout
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="bg-red-600 hover:bg-red-700 text-white border-red-600 hover:border-red-700 hover:opacity-90 hover:scale-105 transition-all text-xs sm:text-sm"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
Logout
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -248,7 +225,7 @@ export function Navbar() {
|
||||
Sign In
|
||||
</Button>
|
||||
)}
|
||||
{!isAdmin && (
|
||||
{!isUserDashboard && (
|
||||
<Link
|
||||
href="/book-now"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
@ -258,28 +235,16 @@ export function Navbar() {
|
||||
</Link>
|
||||
)}
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
{!(isUserDashboard || isAdminDashboard || isUserSettings || isAdminSettings || isUserAppointmentDetails) && (
|
||||
<Link
|
||||
href={isAdmin ? "/admin/dashboard" : "/user/dashboard"}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className={`text-left text-sm sm:text-base font-medium py-2.5 sm:py-3 px-3 sm:px-4 rounded-lg transition-colors flex items-center gap-2 ${isDark ? 'text-gray-300 hover:bg-gray-800' : 'text-gray-700 hover:bg-gray-100'}`}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
Dashboard
|
||||
</Link>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start text-sm sm:text-base bg-red-600 hover:bg-red-700 text-white border-red-600 hover:border-red-700"
|
||||
onClick={() => {
|
||||
handleLogout();
|
||||
}}
|
||||
>
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
Logout
|
||||
</Button>
|
||||
</>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start text-sm sm:text-base bg-red-600 hover:bg-red-700 text-white border-red-600 hover:border-red-700"
|
||||
onClick={() => {
|
||||
handleLogout();
|
||||
}}
|
||||
>
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
Logout
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -293,28 +258,6 @@ export function Navbar() {
|
||||
open={loginDialogOpen}
|
||||
onOpenChange={setLoginDialogOpen}
|
||||
onLoginSuccess={handleLoginSuccess}
|
||||
prefillEmail={prefillEmail}
|
||||
onSwitchToSignup={() => {
|
||||
setLoginDialogOpen(false);
|
||||
// Small delay to ensure dialog closes before opening signup
|
||||
setTimeout(() => {
|
||||
setSignupDialogOpen(true);
|
||||
}, 100);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Signup Dialog */}
|
||||
<SignupDialog
|
||||
open={signupDialogOpen}
|
||||
onOpenChange={setSignupDialogOpen}
|
||||
onSwitchToLogin={(email?: string) => {
|
||||
setSignupDialogOpen(false);
|
||||
setPrefillEmail(email);
|
||||
// Small delay to ensure dialog closes before opening login
|
||||
setTimeout(() => {
|
||||
setLoginDialogOpen(true);
|
||||
}, 100);
|
||||
}}
|
||||
/>
|
||||
</motion.nav>
|
||||
);
|
||||
|
||||
@ -1,174 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { CalendarCheck, Loader2, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { DatePicker } from '@/components/DatePicker';
|
||||
import { ClockTimePicker } from '@/components/ClockTimePicker';
|
||||
import { DurationPicker } from '@/components/DurationPicker';
|
||||
import type { Appointment } from '@/lib/models/appointments';
|
||||
|
||||
interface ScheduleAppointmentDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
appointment: Appointment | null;
|
||||
scheduledDate: Date | undefined;
|
||||
setScheduledDate: (date: Date | undefined) => void;
|
||||
scheduledTime: string;
|
||||
setScheduledTime: (time: string) => void;
|
||||
scheduledDuration: number;
|
||||
setScheduledDuration: (duration: number) => void;
|
||||
onSchedule: () => Promise<void>;
|
||||
isScheduling: boolean;
|
||||
isDark?: boolean;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function ScheduleAppointmentDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
appointment,
|
||||
scheduledDate,
|
||||
setScheduledDate,
|
||||
scheduledTime,
|
||||
setScheduledTime,
|
||||
scheduledDuration,
|
||||
setScheduledDuration,
|
||||
onSchedule,
|
||||
isScheduling,
|
||||
isDark = false,
|
||||
title,
|
||||
description,
|
||||
}: ScheduleAppointmentDialogProps) {
|
||||
const formatDate = (date: Date) => {
|
||||
return date.toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (timeString: string) => {
|
||||
const [hours, minutes] = timeString.split(":").map(Number);
|
||||
const date = new Date();
|
||||
date.setHours(hours);
|
||||
date.setMinutes(minutes);
|
||||
return date.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className={`max-w-4xl max-h-[90vh] overflow-y-auto ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
||||
<DialogHeader className="pb-4">
|
||||
<DialogTitle className={`text-2xl font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
{title || "Schedule Appointment"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className={`text-base ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
{description || (appointment
|
||||
? `Set date and time for ${appointment.first_name} ${appointment.last_name}'s appointment`
|
||||
: "Set date and time for this appointment")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Date Selection */}
|
||||
<div className="space-y-3">
|
||||
<label className={`text-sm font-semibold ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||
Select Date *
|
||||
</label>
|
||||
<div className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}>
|
||||
<DatePicker
|
||||
date={scheduledDate}
|
||||
setDate={setScheduledDate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Selection */}
|
||||
<div className="space-y-3 -mt-2">
|
||||
<div className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}>
|
||||
<ClockTimePicker
|
||||
time={scheduledTime}
|
||||
setTime={setScheduledTime}
|
||||
label="Select Time *"
|
||||
isDark={isDark}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration Selection */}
|
||||
<div className="space-y-3">
|
||||
<div className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}>
|
||||
<DurationPicker
|
||||
duration={scheduledDuration}
|
||||
setDuration={setScheduledDuration}
|
||||
label="Duration"
|
||||
isDark={isDark}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{scheduledDate && scheduledTime && (
|
||||
<div className={`p-4 rounded-xl border ${isDark ? "bg-blue-500/10 border-blue-500/30" : "bg-blue-50 border-blue-200"}`}>
|
||||
<p className={`text-sm font-medium mb-2 ${isDark ? "text-blue-300" : "text-blue-700"}`}>
|
||||
Appointment Preview
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<p className={`text-base font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
{formatDate(scheduledDate)}
|
||||
</p>
|
||||
<p className={`text-sm ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||
{formatTime(scheduledTime)} • {scheduledDuration} minutes
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-3 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isScheduling}
|
||||
className={`h-12 px-6 ${isDark ? "border-gray-700 text-gray-300 hover:bg-gray-700" : ""}`}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSchedule}
|
||||
disabled={isScheduling || !scheduledDate || !scheduledTime}
|
||||
className="h-12 px-6 bg-blue-600 hover:bg-blue-700 text-white"
|
||||
>
|
||||
{isScheduling ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
||||
Scheduling...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CalendarCheck className="w-5 h-5 mr-2" />
|
||||
Schedule Appointment
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@ -220,8 +220,8 @@ export function Services() {
|
||||
animate={isInView ? { opacity: 1 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.9 }}
|
||||
>
|
||||
Therapy is tailored for children (age 0–10), teens, adults, and older adults, with services offered to individuals and a
|
||||
special focus on supporting Black and African American families and all of South Florida's Diverse Communities.
|
||||
Therapy is tailored for children (age 6–10), teens, adults, and older adults, with services offered to individuals and a
|
||||
special focus on supporting Black and African American families in Miami and Hollywood, Florida.
|
||||
</motion.p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@ -1,479 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAppTheme } from "@/components/ThemeProvider";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "@/components/ui/input-otp";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Eye, EyeOff, Loader2, X, CheckCircle2 } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { registerSchema, verifyOtpSchema, type RegisterInput, type VerifyOtpInput } from "@/lib/schema/auth";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface SignupDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSignupSuccess?: () => void;
|
||||
onSwitchToLogin?: (email?: string) => void;
|
||||
}
|
||||
|
||||
type Step = "signup" | "verify";
|
||||
|
||||
export function SignupDialog({ open, onOpenChange, onSignupSuccess, onSwitchToLogin }: SignupDialogProps) {
|
||||
const { theme } = useAppTheme();
|
||||
const isDark = theme === "dark";
|
||||
const { register, verifyOtp, registerMutation, verifyOtpMutation, resendOtpMutation } = useAuth();
|
||||
const [step, setStep] = useState<Step>("signup");
|
||||
const [registeredEmail, setRegisteredEmail] = useState("");
|
||||
const [signupData, setSignupData] = useState<RegisterInput>({
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
email: "",
|
||||
phone_number: "",
|
||||
password: "",
|
||||
password2: "",
|
||||
});
|
||||
const [otpData, setOtpData] = useState<VerifyOtpInput>({
|
||||
email: "",
|
||||
otp: "",
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showPassword2, setShowPassword2] = useState(false);
|
||||
|
||||
const handleSignup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate form
|
||||
const validation = registerSchema.safeParse(signupData);
|
||||
if (!validation.success) {
|
||||
const firstError = validation.error.issues[0];
|
||||
toast.error(firstError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await register(signupData);
|
||||
|
||||
// Always switch to OTP verification step after successful registration
|
||||
const email = signupData.email;
|
||||
setRegisteredEmail(email);
|
||||
setOtpData({ email: email, otp: "" });
|
||||
|
||||
// Clear signup form
|
||||
setSignupData({
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
email: "",
|
||||
phone_number: "",
|
||||
password: "",
|
||||
password2: "",
|
||||
});
|
||||
|
||||
// Switch to verify step
|
||||
setStep("verify");
|
||||
toast.success("Registration successful! Please check your email for OTP verification.");
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Signup failed. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyOtp = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const emailToVerify = registeredEmail || otpData.email;
|
||||
if (!emailToVerify) {
|
||||
toast.error("Email is required");
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = verifyOtpSchema.safeParse({
|
||||
email: emailToVerify,
|
||||
otp: otpData.otp,
|
||||
});
|
||||
|
||||
if (!validation.success) {
|
||||
const firstError = validation.error.issues[0];
|
||||
toast.error(firstError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await verifyOtp({
|
||||
email: emailToVerify,
|
||||
otp: otpData.otp,
|
||||
});
|
||||
|
||||
if (result.message) {
|
||||
toast.success("Email verified successfully! Please log in.");
|
||||
// Close signup dialog and open login dialog with email
|
||||
const emailToPass = emailToVerify;
|
||||
onOpenChange(false);
|
||||
// Call onSwitchToLogin with email to open login dialog with pre-filled email
|
||||
if (onSwitchToLogin) {
|
||||
onSwitchToLogin(emailToPass);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "OTP verification failed. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendOtp = async () => {
|
||||
const emailToResend = registeredEmail || otpData.email;
|
||||
if (!emailToResend) {
|
||||
toast.error("Email is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await resendOtpMutation.mutateAsync({ email: emailToResend, context: "registration" });
|
||||
toast.success("OTP resent successfully! Please check your email.");
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to resend OTP";
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOtpChange = (field: keyof VerifyOtpInput, value: string) => {
|
||||
setOtpData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
// Reset step when dialog closes
|
||||
const handleDialogChange = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
setStep("signup");
|
||||
setRegisteredEmail("");
|
||||
setOtpData({ email: "", otp: "" });
|
||||
setSignupData({
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
email: "",
|
||||
phone_number: "",
|
||||
password: "",
|
||||
password2: "",
|
||||
});
|
||||
}
|
||||
onOpenChange(isOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleDialogChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={`max-w-md max-h-[90vh] overflow-hidden flex flex-col p-0 ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}
|
||||
>
|
||||
{/* Header with Close Button - Fixed */}
|
||||
<div className="flex items-start justify-between p-6 pb-4 flex-shrink-0 border-b border-gray-200 dark:border-gray-700">
|
||||
<DialogHeader className="flex-1 pr-2">
|
||||
<DialogTitle className="text-2xl sm:text-3xl font-bold bg-gradient-to-r from-rose-600 via-pink-600 to-rose-600 bg-clip-text text-transparent">
|
||||
{step === "signup" && "Create an account"}
|
||||
{step === "verify" && "Verify your email"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className={`text-sm sm:text-base mt-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||
{step === "signup" && "Sign up to complete your booking"}
|
||||
{step === "verify" && "Enter the verification code sent to your email"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* Close Button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDialogChange(false)}
|
||||
className={`flex-shrink-0 w-8 h-8 rounded-full ${isDark ? 'text-gray-400 hover:text-gray-300 hover:bg-gray-700' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'}`}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="overflow-y-auto flex-1 px-6">
|
||||
{/* Signup Form */}
|
||||
{step === "signup" && (
|
||||
<form className="space-y-4 sm:space-y-5 py-4 sm:py-6" onSubmit={handleSignup}>
|
||||
{/* First Name Field */}
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<label htmlFor="signup-firstName" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
First Name *
|
||||
</label>
|
||||
<Input
|
||||
id="signup-firstName"
|
||||
type="text"
|
||||
placeholder="John"
|
||||
value={signupData.first_name}
|
||||
onChange={(e) => setSignupData({ ...signupData, first_name: e.target.value })}
|
||||
className={`h-11 sm:h-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Last Name Field */}
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<label htmlFor="signup-lastName" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Last Name *
|
||||
</label>
|
||||
<Input
|
||||
id="signup-lastName"
|
||||
type="text"
|
||||
placeholder="Doe"
|
||||
value={signupData.last_name}
|
||||
onChange={(e) => setSignupData({ ...signupData, last_name: e.target.value })}
|
||||
className={`h-11 sm:h-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email Field */}
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<label htmlFor="signup-email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Email address *
|
||||
</label>
|
||||
<Input
|
||||
id="signup-email"
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
value={signupData.email}
|
||||
onChange={(e) => setSignupData({ ...signupData, email: e.target.value })}
|
||||
className={`h-11 sm:h-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Phone Field */}
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<label htmlFor="signup-phone" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Phone Number (Optional)
|
||||
</label>
|
||||
<Input
|
||||
id="signup-phone"
|
||||
type="tel"
|
||||
placeholder="+1 (555) 123-4567"
|
||||
value={signupData.phone_number || ""}
|
||||
onChange={(e) => setSignupData({ ...signupData, phone_number: e.target.value })}
|
||||
className={`h-11 sm:h-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<label htmlFor="signup-password" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Password *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="signup-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Password (min 8 characters)"
|
||||
value={signupData.password}
|
||||
onChange={(e) => setSignupData({ ...signupData, password: e.target.value })}
|
||||
className={`h-11 sm:h-12 pr-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className={`absolute right-3 sm:right-4 top-1/2 -translate-y-1/2 h-auto w-auto p-0 ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-700'}`}
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm Password Field */}
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<label htmlFor="signup-password2" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Confirm Password *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="signup-password2"
|
||||
type={showPassword2 ? "text" : "password"}
|
||||
placeholder="Confirm password"
|
||||
value={signupData.password2}
|
||||
onChange={(e) => setSignupData({ ...signupData, password2: e.target.value })}
|
||||
className={`h-11 sm:h-12 pr-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowPassword2(!showPassword2)}
|
||||
className={`absolute right-3 sm:right-4 top-1/2 -translate-y-1/2 h-auto w-auto p-0 ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-700'}`}
|
||||
aria-label={showPassword2 ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword2 ? (
|
||||
<EyeOff className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={registerMutation.isPending}
|
||||
className="w-full h-11 sm:h-12 text-sm sm:text-base font-semibold 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 disabled:opacity-50 disabled:cursor-not-allowed mt-4 sm:mt-6"
|
||||
>
|
||||
{registerMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating account...
|
||||
</>
|
||||
) : (
|
||||
"Sign up"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Switch to Login */}
|
||||
<p className={`text-xs sm:text-sm text-center pt-2 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||
Already have an account?{" "}
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
onClick={() => {
|
||||
handleDialogChange(false);
|
||||
if (onSwitchToLogin) {
|
||||
onSwitchToLogin();
|
||||
}
|
||||
}}
|
||||
className={`h-auto p-0 font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
|
||||
>
|
||||
Log in
|
||||
</Button>
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* OTP Verification Form */}
|
||||
{step === "verify" && (
|
||||
<form className="space-y-4 sm:space-y-5 py-4 sm:py-6" onSubmit={handleVerifyOtp}>
|
||||
<div className={`p-3 sm:p-4 rounded-lg border ${isDark ? 'bg-blue-900/20 border-blue-800' : 'bg-blue-50 border-blue-200'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className={`w-5 h-5 mt-0.5 flex-shrink-0 ${isDark ? 'text-blue-400' : 'text-blue-600'}`} />
|
||||
<div>
|
||||
<p className={`text-sm font-medium ${isDark ? 'text-blue-200' : 'text-blue-900'}`}>
|
||||
Check your email
|
||||
</p>
|
||||
<p className={`text-xs sm:text-sm mt-1 ${isDark ? 'text-blue-300' : 'text-blue-700'}`}>
|
||||
We've sent a 6-digit verification code to {registeredEmail || otpData.email || "your email address"}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Email Field (if not set) */}
|
||||
{!registeredEmail && (
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<label htmlFor="verify-email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Email address *
|
||||
</label>
|
||||
<Input
|
||||
id="verify-email"
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
value={otpData.email}
|
||||
onChange={(e) => handleOtpChange("email", e.target.value)}
|
||||
className={`h-11 sm:h-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OTP Field */}
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<label className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Verification Code *
|
||||
</label>
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={otpData.otp}
|
||||
onChange={(value) => handleOtpChange("otp", value)}
|
||||
>
|
||||
<InputOTPGroup className="gap-2 sm:gap-3">
|
||||
<InputOTPSlot index={0} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
|
||||
<InputOTPSlot index={1} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
|
||||
<InputOTPSlot index={2} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
|
||||
<InputOTPSlot index={3} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
|
||||
<InputOTPSlot index={4} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
|
||||
<InputOTPSlot index={5} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resend OTP */}
|
||||
<div className="text-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
onClick={handleResendOtp}
|
||||
disabled={resendOtpMutation?.isPending}
|
||||
className={`h-auto p-0 text-xs sm:text-sm font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
|
||||
>
|
||||
{resendOtpMutation?.isPending ? "Sending..." : "Didn't receive the code? Resend"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={verifyOtpMutation.isPending}
|
||||
className="w-full h-11 sm:h-12 text-sm sm:text-base font-semibold 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 disabled:opacity-50 disabled:cursor-not-allowed mt-4 sm:mt-6"
|
||||
>
|
||||
{verifyOtpMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Verifying...
|
||||
</>
|
||||
) : (
|
||||
"Verify Email"
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Back to signup */}
|
||||
<div className="text-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
onClick={() => {
|
||||
setStep("signup");
|
||||
setOtpData({ email: "", otp: "" });
|
||||
}}
|
||||
className={`h-auto p-0 text-xs sm:text-sm font-medium ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-600 hover:text-gray-700'}`}
|
||||
>
|
||||
← Back to signup
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,115 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Clock } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import DatePickerLib from 'react-datepicker';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
|
||||
interface TimePickerProps {
|
||||
time: string; // HH:mm format (e.g., "09:00")
|
||||
setTime: (time: string) => void;
|
||||
label?: string;
|
||||
isDark?: boolean;
|
||||
}
|
||||
|
||||
export function TimePicker({ time, setTime, label, isDark = false }: TimePickerProps) {
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
const wrapperRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Convert HH:mm string to Date object for the time picker
|
||||
const timeValue = React.useMemo(() => {
|
||||
if (!time) return null;
|
||||
const [hours, minutes] = time.split(':').map(Number);
|
||||
const date = new Date();
|
||||
date.setHours(hours || 9);
|
||||
date.setMinutes(minutes || 0);
|
||||
date.setSeconds(0);
|
||||
return date;
|
||||
}, [time]);
|
||||
|
||||
// Handle time change from the picker
|
||||
const handleTimeChange = (date: Date | null) => {
|
||||
if (date) {
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
setTime(`${hours}:${minutes}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Close picker when clicking outside
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Format display time
|
||||
const displayTime = timeValue
|
||||
? format(timeValue, 'h:mm a') // e.g., "9:00 AM"
|
||||
: 'Select time';
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && (
|
||||
<label className={cn(
|
||||
"text-sm font-medium",
|
||||
isDark ? "text-gray-300" : "text-gray-700"
|
||||
)}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative" ref={wrapperRef}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={cn(
|
||||
"w-full justify-start text-left font-normal h-12 text-base",
|
||||
!timeValue && "text-muted-foreground",
|
||||
isDark
|
||||
? "bg-gray-800 border-gray-600 text-white hover:bg-gray-700"
|
||||
: "bg-white border-gray-300 text-gray-900 hover:bg-gray-50"
|
||||
)}
|
||||
>
|
||||
<Clock className="mr-2 h-5 w-5" />
|
||||
{displayTime}
|
||||
</Button>
|
||||
{isOpen && (
|
||||
<div className={cn(
|
||||
"absolute z-[9999] mt-2 rounded-lg shadow-lg border",
|
||||
isDark
|
||||
? "bg-gray-800 border-gray-700"
|
||||
: "bg-white border-gray-200"
|
||||
)}>
|
||||
<DatePickerLib
|
||||
selected={timeValue}
|
||||
onChange={handleTimeChange}
|
||||
showTimeSelect
|
||||
showTimeSelectOnly
|
||||
timeIntervals={15}
|
||||
timeCaption="Time"
|
||||
dateFormat="h:mm aa"
|
||||
inline
|
||||
className="time-picker"
|
||||
wrapperClassName="time-picker-wrapper"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,329 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAppTheme } from "@/components/ThemeProvider";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "@/components/ui/input-otp";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Loader2, X, CheckCircle2 } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { verifyOtpSchema, type VerifyOtpInput } from "@/lib/schema/auth";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface VerifyOtpDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
email: string;
|
||||
context?: "registration" | "password_reset";
|
||||
onVerificationSuccess?: () => void;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function VerifyOtpDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
email: initialEmail,
|
||||
context = "registration",
|
||||
onVerificationSuccess,
|
||||
title = "Verify your email",
|
||||
description = "Enter the verification code sent to your email"
|
||||
}: VerifyOtpDialogProps) {
|
||||
const { theme } = useAppTheme();
|
||||
const isDark = theme === "dark";
|
||||
const { verifyOtp, verifyOtpMutation, resendOtpMutation } = useAuth();
|
||||
const [otpData, setOtpData] = useState<VerifyOtpInput>({
|
||||
email: initialEmail,
|
||||
otp: "",
|
||||
});
|
||||
const [email, setEmail] = useState(initialEmail);
|
||||
const [otpSent, setOtpSent] = useState(false);
|
||||
const [isSendingOtp, setIsSendingOtp] = useState(false);
|
||||
|
||||
// Update email when prop changes
|
||||
useEffect(() => {
|
||||
if (initialEmail) {
|
||||
setEmail(initialEmail);
|
||||
setOtpData(prev => ({ ...prev, email: initialEmail }));
|
||||
}
|
||||
}, [initialEmail]);
|
||||
|
||||
// Automatically send OTP when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && !otpSent) {
|
||||
const emailToSend = initialEmail || email;
|
||||
if (emailToSend) {
|
||||
setIsSendingOtp(true);
|
||||
resendOtpMutation.mutateAsync({
|
||||
email: emailToSend,
|
||||
context
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Verification code sent! Please check your email.");
|
||||
setOtpSent(true);
|
||||
setIsSendingOtp(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to send verification code";
|
||||
toast.error(errorMessage);
|
||||
setIsSendingOtp(false);
|
||||
// Still allow user to manually resend
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Reset when dialog closes
|
||||
if (!open) {
|
||||
setOtpSent(false);
|
||||
setIsSendingOtp(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, initialEmail, email, context, otpSent]);
|
||||
|
||||
const handleVerifyOtp = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const emailToVerify = email || otpData.email;
|
||||
if (!emailToVerify) {
|
||||
toast.error("Email address is required");
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = verifyOtpSchema.safeParse({
|
||||
email: emailToVerify,
|
||||
otp: otpData.otp,
|
||||
});
|
||||
|
||||
if (!validation.success) {
|
||||
const firstError = validation.error.issues[0];
|
||||
toast.error(firstError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await verifyOtp({
|
||||
email: emailToVerify,
|
||||
otp: otpData.otp,
|
||||
});
|
||||
|
||||
if (result.message || result.tokens) {
|
||||
toast.success("Email verified successfully!");
|
||||
// Reset form
|
||||
setOtpData({ email: emailToVerify, otp: "" });
|
||||
onOpenChange(false);
|
||||
// Call success callback if provided
|
||||
if (onVerificationSuccess) {
|
||||
onVerificationSuccess();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "OTP verification failed. Please try again.";
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendOtp = async () => {
|
||||
const emailToResend = email || otpData.email;
|
||||
if (!emailToResend) {
|
||||
toast.error("Email address is required");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSendingOtp(true);
|
||||
await resendOtpMutation.mutateAsync({
|
||||
email: emailToResend,
|
||||
context
|
||||
});
|
||||
toast.success("OTP resent successfully! Please check your email.");
|
||||
setOtpSent(true);
|
||||
setIsSendingOtp(false);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to resend OTP";
|
||||
toast.error(errorMessage);
|
||||
setIsSendingOtp(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOtpChange = (field: keyof VerifyOtpInput, value: string) => {
|
||||
setOtpData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
// Reset form when dialog closes
|
||||
const handleDialogChange = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
setOtpData({ email: initialEmail || "", otp: "" });
|
||||
setOtpSent(false);
|
||||
setIsSendingOtp(false);
|
||||
}
|
||||
onOpenChange(isOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleDialogChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={`max-w-md max-h-[90vh] overflow-hidden flex flex-col p-0 ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}
|
||||
>
|
||||
{/* Header with Close Button - Fixed */}
|
||||
<div className="flex items-start justify-between p-6 pb-4 flex-shrink-0 border-b border-gray-200 dark:border-gray-700">
|
||||
<DialogHeader className="flex-1 pr-2">
|
||||
<DialogTitle className="text-2xl sm:text-3xl font-bold bg-gradient-to-r from-rose-600 via-pink-600 to-rose-600 bg-clip-text text-transparent">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className={`text-sm sm:text-base mt-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{/* Close Button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDialogChange(false)}
|
||||
className={`flex-shrink-0 w-8 h-8 rounded-full ${isDark ? 'text-gray-400 hover:text-gray-300 hover:bg-gray-700' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'}`}
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="overflow-y-auto flex-1 px-6">
|
||||
<form className="space-y-4 sm:space-y-5 py-4 sm:py-6" onSubmit={handleVerifyOtp}>
|
||||
{/* Loading indicator while sending */}
|
||||
{isSendingOtp && !otpSent && (
|
||||
<div className={`p-3 sm:p-4 rounded-lg border ${isDark ? 'bg-yellow-900/20 border-yellow-800' : 'bg-yellow-50 border-yellow-200'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-yellow-600" />
|
||||
<p className={`text-sm font-medium ${isDark ? 'text-yellow-200' : 'text-yellow-900'}`}>
|
||||
Sending verification code...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success message after sending */}
|
||||
{otpSent && (
|
||||
<div className={`p-3 sm:p-4 rounded-lg border ${isDark ? 'bg-blue-900/20 border-blue-800' : 'bg-blue-50 border-blue-200'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className={`w-5 h-5 mt-0.5 flex-shrink-0 ${isDark ? 'text-blue-400' : 'text-blue-600'}`} />
|
||||
<div>
|
||||
<p className={`text-sm font-medium ${isDark ? 'text-blue-200' : 'text-blue-900'}`}>
|
||||
Check your email
|
||||
</p>
|
||||
<p className={`text-xs sm:text-sm mt-1 ${isDark ? 'text-blue-300' : 'text-blue-700'}`}>
|
||||
We've sent a 6-digit verification code to {email || otpData.email || "your email address"}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show info message if OTP hasn't been sent yet but dialog is open */}
|
||||
{!isSendingOtp && !otpSent && (
|
||||
<div className={`p-3 sm:p-4 rounded-lg border ${isDark ? 'bg-blue-900/20 border-blue-800' : 'bg-blue-50 border-blue-200'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className={`w-5 h-5 mt-0.5 flex-shrink-0 ${isDark ? 'text-blue-400' : 'text-blue-600'}`} />
|
||||
<div>
|
||||
<p className={`text-sm font-medium ${isDark ? 'text-blue-200' : 'text-blue-900'}`}>
|
||||
Enter verification code
|
||||
</p>
|
||||
<p className={`text-xs sm:text-sm mt-1 ${isDark ? 'text-blue-300' : 'text-blue-700'}`}>
|
||||
Enter the 6-digit verification code sent to {email || otpData.email || "your email address"}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Field (if not provided or editable) */}
|
||||
{!initialEmail && (
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<label htmlFor="verify-email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Email address *
|
||||
</label>
|
||||
<Input
|
||||
id="verify-email"
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
handleOtpChange("email", e.target.value);
|
||||
}}
|
||||
className={`h-11 sm:h-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OTP Field */}
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<label className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Verification Code *
|
||||
</label>
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={otpData.otp}
|
||||
onChange={(value) => handleOtpChange("otp", value)}
|
||||
>
|
||||
<InputOTPGroup className="gap-2 sm:gap-3">
|
||||
<InputOTPSlot index={0} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
|
||||
<InputOTPSlot index={1} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
|
||||
<InputOTPSlot index={2} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
|
||||
<InputOTPSlot index={3} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
|
||||
<InputOTPSlot index={4} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
|
||||
<InputOTPSlot index={5} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resend OTP */}
|
||||
<div className="text-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
onClick={handleResendOtp}
|
||||
disabled={resendOtpMutation.isPending}
|
||||
className={`h-auto p-0 text-xs sm:text-sm font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
|
||||
>
|
||||
{resendOtpMutation.isPending ? "Sending..." : "Didn't receive the code? Resend"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={verifyOtpMutation.isPending}
|
||||
className="w-full h-11 sm:h-12 text-sm sm:text-base font-semibold 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 disabled:opacity-50 disabled:cursor-not-allowed mt-4 sm:mt-6"
|
||||
>
|
||||
{verifyOtpMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Verifying...
|
||||
</>
|
||||
) : (
|
||||
"Verify Email"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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-[100] 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-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border border-gray-200 p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -7,7 +7,6 @@ import {
|
||||
getAvailableDates,
|
||||
listAppointments,
|
||||
getUserAppointments,
|
||||
getUserAppointmentStats,
|
||||
getAppointmentDetail,
|
||||
scheduleAppointment,
|
||||
rejectAppointment,
|
||||
@ -15,10 +14,6 @@ import {
|
||||
updateAdminAvailability,
|
||||
getAppointmentStats,
|
||||
getJitsiMeetingInfo,
|
||||
getWeeklyAvailability,
|
||||
getAvailabilityConfig,
|
||||
checkDateAvailability,
|
||||
getAvailabilityOverview,
|
||||
} from "@/lib/actions/appointments";
|
||||
import type {
|
||||
CreateAppointmentInput,
|
||||
@ -30,88 +25,30 @@ import type {
|
||||
Appointment,
|
||||
AdminAvailability,
|
||||
AppointmentStats,
|
||||
UserAppointmentStats,
|
||||
AvailableDatesResponse,
|
||||
JitsiMeetingInfo,
|
||||
WeeklyAvailabilityResponse,
|
||||
AvailabilityConfig,
|
||||
CheckDateAvailabilityResponse,
|
||||
AvailabilityOverview,
|
||||
} from "@/lib/models/appointments";
|
||||
|
||||
export function useAppointments(options?: {
|
||||
enableAvailableDates?: boolean;
|
||||
enableStats?: boolean;
|
||||
enableConfig?: boolean;
|
||||
enableWeeklyAvailability?: boolean;
|
||||
enableOverview?: boolean;
|
||||
}) {
|
||||
export function useAppointments() {
|
||||
const queryClient = useQueryClient();
|
||||
const enableAvailableDates = options?.enableAvailableDates ?? false;
|
||||
const enableStats = options?.enableStats ?? true;
|
||||
const enableConfig = options?.enableConfig ?? true;
|
||||
const enableWeeklyAvailability = options?.enableWeeklyAvailability ?? true;
|
||||
const enableOverview = options?.enableOverview ?? true;
|
||||
|
||||
// Get available dates query (optional, disabled by default - using weekly_availability as primary source)
|
||||
// Can be enabled when explicitly needed (e.g., on admin booking page)
|
||||
const availableDatesQuery = useQuery<AvailableDatesResponse>({
|
||||
// Get available dates query
|
||||
const availableDatesQuery = useQuery<string[]>({
|
||||
queryKey: ["appointments", "available-dates"],
|
||||
queryFn: () => getAvailableDates(),
|
||||
enabled: enableAvailableDates, // Can be enabled when needed
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
retry: 0, // Don't retry failed requests
|
||||
});
|
||||
|
||||
// Get weekly availability query
|
||||
const weeklyAvailabilityQuery = useQuery<WeeklyAvailabilityResponse>({
|
||||
queryKey: ["appointments", "weekly-availability"],
|
||||
queryFn: async () => {
|
||||
const data = await getWeeklyAvailability();
|
||||
// Normalize response format - ensure it's always an object with week array
|
||||
if (Array.isArray(data)) {
|
||||
return { week: data };
|
||||
}
|
||||
return data;
|
||||
},
|
||||
enabled: enableWeeklyAvailability,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
// Get availability config query
|
||||
const availabilityConfigQuery = useQuery<AvailabilityConfig>({
|
||||
queryKey: ["appointments", "availability-config"],
|
||||
queryFn: () => getAvailabilityConfig(),
|
||||
enabled: enableConfig,
|
||||
staleTime: 60 * 60 * 1000, // 1 hour (config rarely changes)
|
||||
});
|
||||
|
||||
// Get availability overview query
|
||||
const availabilityOverviewQuery = useQuery<AvailabilityOverview>({
|
||||
queryKey: ["appointments", "availability-overview"],
|
||||
queryFn: () => getAvailabilityOverview(),
|
||||
enabled: enableOverview,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
// List appointments query
|
||||
const appointmentsQuery = useQuery<Appointment[]>({
|
||||
queryKey: ["appointments", "list"],
|
||||
queryFn: async () => {
|
||||
const data = await listAppointments();
|
||||
return data || [];
|
||||
},
|
||||
enabled: true, // Enable by default to fetch user appointments
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
retry: 1, // Retry once on failure
|
||||
refetchOnMount: true, // Always refetch when component mounts
|
||||
queryFn: () => listAppointments(),
|
||||
enabled: false, // Only fetch when explicitly called
|
||||
});
|
||||
|
||||
// Get user appointments query (disabled - using listAppointments instead)
|
||||
// Get user appointments query
|
||||
const userAppointmentsQuery = useQuery<Appointment[]>({
|
||||
queryKey: ["appointments", "user"],
|
||||
queryFn: () => getUserAppointments(),
|
||||
enabled: false, // Disabled - using listAppointments endpoint instead
|
||||
staleTime: 30 * 1000, // 30 seconds
|
||||
});
|
||||
|
||||
@ -138,13 +75,6 @@ export function useAppointments(options?: {
|
||||
staleTime: 1 * 60 * 1000, // 1 minute
|
||||
});
|
||||
|
||||
const userAppointmentStatsQuery = useQuery<UserAppointmentStats>({
|
||||
queryKey: ["appointments", "user", "stats"],
|
||||
queryFn: () => getUserAppointmentStats(),
|
||||
enabled: enableStats,
|
||||
staleTime: 1 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Get Jitsi meeting info query
|
||||
const useJitsiMeetingInfo = (id: string | null) => {
|
||||
return useQuery<JitsiMeetingInfo>({
|
||||
@ -230,43 +160,25 @@ export function useAppointments(options?: {
|
||||
|
||||
return {
|
||||
// Queries
|
||||
availableDates: availableDatesQuery.data?.dates || [],
|
||||
availableDatesResponse: availableDatesQuery.data,
|
||||
weeklyAvailability: weeklyAvailabilityQuery.data,
|
||||
availabilityConfig: availabilityConfigQuery.data,
|
||||
availabilityOverview: availabilityOverviewQuery.data,
|
||||
availableDates: availableDatesQuery.data || [],
|
||||
appointments: appointmentsQuery.data || [],
|
||||
userAppointments: userAppointmentsQuery.data || [],
|
||||
adminAvailability: adminAvailabilityQuery.data,
|
||||
appointmentStats: appointmentStatsQuery.data,
|
||||
userAppointmentStats: userAppointmentStatsQuery.data,
|
||||
|
||||
// Query states
|
||||
isLoadingAvailableDates: availableDatesQuery.isLoading,
|
||||
isLoadingWeeklyAvailability: weeklyAvailabilityQuery.isLoading,
|
||||
isLoadingAvailabilityConfig: availabilityConfigQuery.isLoading,
|
||||
isLoadingAvailabilityOverview: availabilityOverviewQuery.isLoading,
|
||||
isLoadingAppointments: appointmentsQuery.isLoading,
|
||||
isLoadingUserAppointments: userAppointmentsQuery.isLoading,
|
||||
isLoadingAdminAvailability: adminAvailabilityQuery.isLoading,
|
||||
isLoadingStats: appointmentStatsQuery.isLoading,
|
||||
isLoadingUserStats: userAppointmentStatsQuery.isLoading,
|
||||
|
||||
// Query refetch functions
|
||||
refetchAvailableDates: availableDatesQuery.refetch,
|
||||
refetchWeeklyAvailability: weeklyAvailabilityQuery.refetch,
|
||||
refetchAvailabilityConfig: availabilityConfigQuery.refetch,
|
||||
refetchAvailabilityOverview: availabilityOverviewQuery.refetch,
|
||||
refetchAppointments: appointmentsQuery.refetch,
|
||||
refetchUserAppointments: userAppointmentsQuery.refetch,
|
||||
refetchAdminAvailability: adminAvailabilityQuery.refetch,
|
||||
refetchStats: appointmentStatsQuery.refetch,
|
||||
refetchUserStats: userAppointmentStatsQuery.refetch,
|
||||
|
||||
// Helper functions
|
||||
checkDateAvailability: async (date: string) => {
|
||||
return await checkDateAvailability(date);
|
||||
},
|
||||
|
||||
// Hooks for specific queries
|
||||
useAppointmentDetail,
|
||||
|
||||
@ -9,308 +9,102 @@ import type {
|
||||
import type {
|
||||
Appointment,
|
||||
AppointmentResponse,
|
||||
AppointmentsListResponse,
|
||||
AvailableDatesResponse,
|
||||
AdminAvailability,
|
||||
AppointmentStats,
|
||||
UserAppointmentStats,
|
||||
JitsiMeetingInfo,
|
||||
ApiError,
|
||||
WeeklyAvailabilityResponse,
|
||||
AvailabilityConfig,
|
||||
CheckDateAvailabilityResponse,
|
||||
AvailabilityOverview,
|
||||
SelectedSlot,
|
||||
} from "@/lib/models/appointments";
|
||||
|
||||
// Helper function to extract error message from API response
|
||||
function extractErrorMessage(error: ApiError): string {
|
||||
if (error.detail) {
|
||||
return Array.isArray(error.detail) ? error.detail.join(", ") : String(error.detail);
|
||||
if (Array.isArray(error.detail)) {
|
||||
return error.detail.join(", ");
|
||||
}
|
||||
return String(error.detail);
|
||||
}
|
||||
|
||||
if (error.message) {
|
||||
return Array.isArray(error.message) ? error.message.join(", ") : String(error.message);
|
||||
if (Array.isArray(error.message)) {
|
||||
return error.message.join(", ");
|
||||
}
|
||||
return String(error.message);
|
||||
}
|
||||
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
return "An error occurred";
|
||||
}
|
||||
|
||||
async function parseResponse(response: Response): Promise<any> {
|
||||
const responseText = await response.text();
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
|
||||
if (!responseText || responseText.trim().length === 0) {
|
||||
if (response.ok) {
|
||||
return null;
|
||||
}
|
||||
throw new Error(`Server error (${response.status}): ${response.statusText || 'Empty response'}`);
|
||||
}
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
try {
|
||||
return JSON.parse(responseText);
|
||||
} catch {
|
||||
throw new Error(`Server error (${response.status}): Invalid JSON format`);
|
||||
}
|
||||
}
|
||||
|
||||
const errorMatch = responseText.match(/<pre[^>]*>([\s\S]*?)<\/pre>/i) ||
|
||||
responseText.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
|
||||
const errorText = errorMatch?.[1]?.replace(/<[^>]*>/g, '').trim() || '';
|
||||
throw new Error(`Server error (${response.status}): ${errorText || response.statusText || 'Internal Server Error'}`);
|
||||
}
|
||||
|
||||
function extractHtmlError(responseText: string): string {
|
||||
const errorMatch = responseText.match(/<pre[^>]*>([\s\S]*?)<\/pre>/i);
|
||||
if (!errorMatch) return '';
|
||||
|
||||
const traceback = errorMatch[1].replace(/<[^>]*>/g, '');
|
||||
const lines = traceback.split('\n').filter(line => line.trim());
|
||||
|
||||
for (let i = lines.length - 1; i >= Math.max(0, lines.length - 5); i--) {
|
||||
const line = lines[i];
|
||||
if (line.match(/(Error|Exception|Failed)/i)) {
|
||||
return line.trim().replace(/^(Traceback|File|Error|Exception):\s*/i, '');
|
||||
}
|
||||
}
|
||||
|
||||
return lines[lines.length - 1]?.trim() || '';
|
||||
return "An error occurred while creating the appointment";
|
||||
}
|
||||
|
||||
function validateAndCleanSlots(slots: any[]): SelectedSlot[] {
|
||||
return slots
|
||||
.filter(slot => {
|
||||
if (!slot || typeof slot !== 'object') return false;
|
||||
const dayNum = Number(slot.day);
|
||||
const timeSlot = String(slot.time_slot || '').toLowerCase().trim();
|
||||
return !isNaN(dayNum) && dayNum >= 0 && dayNum <= 6 &&
|
||||
['morning', 'afternoon', 'evening'].includes(timeSlot);
|
||||
})
|
||||
.map(slot => ({
|
||||
day: Number(slot.day),
|
||||
time_slot: String(slot.time_slot).toLowerCase().trim() as "morning" | "afternoon" | "evening",
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeAvailabilitySchedule(schedule: any): Record<string, string[]> {
|
||||
if (typeof schedule === 'string') {
|
||||
try {
|
||||
schedule = JSON.parse(schedule);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const numberToTimeSlot: Record<number, string> = {
|
||||
0: 'morning',
|
||||
1: 'afternoon',
|
||||
2: 'evening',
|
||||
};
|
||||
|
||||
const result: Record<string, string[]> = {};
|
||||
Object.keys(schedule || {}).forEach(day => {
|
||||
const slots = schedule[day];
|
||||
if (Array.isArray(slots) && slots.length > 0) {
|
||||
result[day] = typeof slots[0] === 'number'
|
||||
? slots.map((num: number) => numberToTimeSlot[num]).filter(Boolean) as string[]
|
||||
: slots.filter((s: string) => ['morning', 'afternoon', 'evening'].includes(s));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function createAppointment(input: CreateAppointmentInput): Promise<Appointment> {
|
||||
// Create appointment
|
||||
export async function createAppointment(
|
||||
input: CreateAppointmentInput
|
||||
): Promise<Appointment> {
|
||||
const tokens = getStoredTokens();
|
||||
|
||||
if (!tokens.access) {
|
||||
throw new Error("Authentication required. Please log in to book an appointment.");
|
||||
}
|
||||
|
||||
if (!input.first_name || !input.last_name || !input.email) {
|
||||
throw new Error("First name, last name, and email are required");
|
||||
}
|
||||
|
||||
if (!input.selected_slots || input.selected_slots.length === 0) {
|
||||
throw new Error("At least one time slot must be selected");
|
||||
}
|
||||
|
||||
const validSlots = validateAndCleanSlots(input.selected_slots);
|
||||
if (validSlots.length === 0) {
|
||||
throw new Error("At least one valid time slot must be selected. Each slot must have both 'day' (0-6) and 'time_slot' (morning, afternoon, or evening).");
|
||||
}
|
||||
|
||||
// Explicitly exclude legacy fields - API doesn't need preferred_dates or preferred_time_slots
|
||||
// We only use selected_slots format
|
||||
|
||||
const truncate = (str: string, max: number) => String(str || '').trim().substring(0, max);
|
||||
|
||||
const selectedSlotsForPayload = validSlots.map(slot => ({
|
||||
day: slot.day,
|
||||
time_slot: slot.time_slot,
|
||||
}));
|
||||
|
||||
// Build payload with ONLY the fields the API requires/accepts
|
||||
// DO NOT include preferred_dates or preferred_time_slots - the API doesn't need them
|
||||
const payload: {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
selected_slots: Array<{ day: number; time_slot: string }>;
|
||||
phone?: string;
|
||||
reason?: string;
|
||||
} = {
|
||||
first_name: truncate(input.first_name, 100),
|
||||
last_name: truncate(input.last_name, 100),
|
||||
email: truncate(input.email, 100).toLowerCase(),
|
||||
selected_slots: selectedSlotsForPayload,
|
||||
};
|
||||
|
||||
// Only add optional fields if they exist
|
||||
if (input.phone && input.phone.length > 0) {
|
||||
payload.phone = truncate(input.phone, 100);
|
||||
}
|
||||
if (input.reason && input.reason.length > 0) {
|
||||
payload.reason = truncate(input.reason, 100);
|
||||
}
|
||||
|
||||
const requestBody = JSON.stringify(payload);
|
||||
|
||||
const response = await fetch(API_ENDPOINTS.meetings.createAppointment, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${tokens.access}`,
|
||||
},
|
||||
body: requestBody,
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
const data = await parseResponse(response);
|
||||
|
||||
const data: AppointmentResponse = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(extractErrorMessage(data as unknown as ApiError));
|
||||
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Build clean response - explicitly exclude preferred_dates and preferred_time_slots
|
||||
// Backend may return these legacy fields, but we only use selected_slots format (per API spec)
|
||||
const rawResponse: any = data.appointment || data.data || data;
|
||||
// Handle different response formats
|
||||
if (data.appointment) {
|
||||
return data.appointment;
|
||||
}
|
||||
if ((data as any).data) {
|
||||
return (data as any).data;
|
||||
}
|
||||
|
||||
// Build appointment object from scratch with ONLY the fields we want
|
||||
// Explicitly DO NOT include preferred_dates, preferred_time_slots, or their display variants
|
||||
const appointmentResponse: any = {
|
||||
id: data.appointment_id || rawResponse.id || '',
|
||||
first_name: rawResponse.first_name || input.first_name.trim(),
|
||||
last_name: rawResponse.last_name || input.last_name.trim(),
|
||||
email: rawResponse.email || input.email.trim().toLowerCase(),
|
||||
phone: rawResponse.phone || input.phone?.trim(),
|
||||
reason: rawResponse.reason || input.reason?.trim(),
|
||||
// Use selected_slots from our original input (preserve the format we sent - per API spec)
|
||||
selected_slots: validSlots,
|
||||
status: rawResponse.status || "pending_review",
|
||||
created_at: rawResponse.created_at || new Date().toISOString(),
|
||||
updated_at: rawResponse.updated_at || new Date().toISOString(),
|
||||
// Include other useful fields from response
|
||||
...(rawResponse.jitsi_meet_url && { jitsi_meet_url: rawResponse.jitsi_meet_url }),
|
||||
...(rawResponse.jitsi_room_id && { jitsi_room_id: rawResponse.jitsi_room_id }),
|
||||
...(rawResponse.matching_availability && { matching_availability: rawResponse.matching_availability }),
|
||||
...(rawResponse.are_preferences_available !== undefined && { are_preferences_available: rawResponse.are_preferences_available }),
|
||||
...(rawResponse.available_slots_info && { available_slots_info: rawResponse.available_slots_info }),
|
||||
// Explicitly EXCLUDED: preferred_dates, preferred_time_slots, preferred_dates_display, preferred_time_slots_display
|
||||
};
|
||||
|
||||
// Explicitly delete preferred_dates and preferred_time_slots from response object
|
||||
// These are backend legacy fields - we only use selected_slots format
|
||||
if ('preferred_dates' in appointmentResponse) {
|
||||
delete appointmentResponse.preferred_dates;
|
||||
}
|
||||
if ('preferred_time_slots' in appointmentResponse) {
|
||||
delete appointmentResponse.preferred_time_slots;
|
||||
}
|
||||
if ('preferred_dates_display' in appointmentResponse) {
|
||||
delete appointmentResponse.preferred_dates_display;
|
||||
}
|
||||
if ('preferred_time_slots_display' in appointmentResponse) {
|
||||
delete appointmentResponse.preferred_time_slots_display;
|
||||
}
|
||||
|
||||
return appointmentResponse;
|
||||
// If appointment is returned directly
|
||||
return data as unknown as Appointment;
|
||||
}
|
||||
|
||||
export async function getAvailableDates(): Promise<AvailableDatesResponse> {
|
||||
try {
|
||||
// Get available dates
|
||||
export async function getAvailableDates(): Promise<string[]> {
|
||||
const response = await fetch(API_ENDPOINTS.meetings.availableDates, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return { dates: [] };
|
||||
}
|
||||
|
||||
const data = await parseResponse(response);
|
||||
return Array.isArray(data) ? { dates: data } : data;
|
||||
} catch {
|
||||
return { dates: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getWeeklyAvailability(): Promise<WeeklyAvailabilityResponse> {
|
||||
const response = await fetch(API_ENDPOINTS.meetings.weeklyAvailability, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const data = await parseResponse(response);
|
||||
const data: AvailableDatesResponse | string[] = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(extractErrorMessage(data as unknown as ApiError));
|
||||
}
|
||||
|
||||
return Array.isArray(data) ? data : data;
|
||||
}
|
||||
|
||||
export async function getAvailabilityConfig(): Promise<AvailabilityConfig> {
|
||||
const response = await fetch(API_ENDPOINTS.meetings.availabilityConfig, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
const data = await parseResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(extractErrorMessage(data as unknown as ApiError));
|
||||
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function checkDateAvailability(date: string): Promise<CheckDateAvailabilityResponse> {
|
||||
const response = await fetch(API_ENDPOINTS.meetings.checkDateAvailability, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ date }),
|
||||
});
|
||||
|
||||
const data = await parseResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(extractErrorMessage(data as unknown as ApiError));
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getAvailabilityOverview(): Promise<AvailabilityOverview> {
|
||||
const response = await fetch(API_ENDPOINTS.meetings.availabilityOverview, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
const data = await parseResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(extractErrorMessage(data as unknown as ApiError));
|
||||
// API returns array of dates in YYYY-MM-DD format
|
||||
if (Array.isArray(data)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return data;
|
||||
return (data as AvailableDatesResponse).dates || [];
|
||||
}
|
||||
|
||||
// List appointments (Admin sees all, users see their own)
|
||||
export async function listAppointments(email?: string): Promise<Appointment[]> {
|
||||
const tokens = getStoredTokens();
|
||||
|
||||
if (!tokens.access) {
|
||||
throw new Error("Authentication required.");
|
||||
}
|
||||
@ -327,20 +121,32 @@ export async function listAppointments(email?: string): Promise<Appointment[]> {
|
||||
},
|
||||
});
|
||||
|
||||
const data = await parseResponse(response);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(extractErrorMessage(data as unknown as ApiError));
|
||||
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) return data;
|
||||
if (data?.appointments && Array.isArray(data.appointments)) return data.appointments;
|
||||
if (data?.results && Array.isArray(data.results)) return data.results;
|
||||
if (data?.id || data?.first_name) return [data];
|
||||
// Handle different response formats
|
||||
// API might return array directly or wrapped in an object
|
||||
if (Array.isArray(data)) {
|
||||
return data;
|
||||
}
|
||||
if (data.appointments && Array.isArray(data.appointments)) {
|
||||
return data.appointments;
|
||||
}
|
||||
if (data.results && Array.isArray(data.results)) {
|
||||
return data.results;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get user appointments
|
||||
export async function getUserAppointments(): Promise<Appointment[]> {
|
||||
const tokens = getStoredTokens();
|
||||
|
||||
if (!tokens.access) {
|
||||
throw new Error("Authentication required.");
|
||||
}
|
||||
@ -353,19 +159,32 @@ export async function getUserAppointments(): Promise<Appointment[]> {
|
||||
},
|
||||
});
|
||||
|
||||
const data = await parseResponse(response);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(extractErrorMessage(data as unknown as ApiError));
|
||||
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) return data;
|
||||
if (data?.appointments && Array.isArray(data.appointments)) return data.appointments;
|
||||
if (data?.results && Array.isArray(data.results)) return data.results;
|
||||
// Handle different response formats
|
||||
// API might return array directly or wrapped in an object
|
||||
if (Array.isArray(data)) {
|
||||
return data;
|
||||
}
|
||||
if (data.appointments && Array.isArray(data.appointments)) {
|
||||
return data.appointments;
|
||||
}
|
||||
if (data.results && Array.isArray(data.results)) {
|
||||
return data.results;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get appointment detail
|
||||
export async function getAppointmentDetail(id: string): Promise<Appointment> {
|
||||
const tokens = getStoredTokens();
|
||||
|
||||
if (!tokens.access) {
|
||||
throw new Error("Authentication required.");
|
||||
}
|
||||
@ -378,133 +197,97 @@ export async function getAppointmentDetail(id: string): Promise<Appointment> {
|
||||
},
|
||||
});
|
||||
|
||||
const data = await parseResponse(response);
|
||||
const data: AppointmentResponse = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(extractErrorMessage(data as unknown as ApiError));
|
||||
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return (data as AppointmentResponse).appointment || data;
|
||||
if (data.appointment) {
|
||||
return data.appointment;
|
||||
}
|
||||
|
||||
return data as unknown as Appointment;
|
||||
}
|
||||
|
||||
export async function scheduleAppointment(id: string, input: ScheduleAppointmentInput): Promise<Appointment> {
|
||||
// Schedule appointment (Admin only)
|
||||
export async function scheduleAppointment(
|
||||
id: string,
|
||||
input: ScheduleAppointmentInput
|
||||
): Promise<Appointment> {
|
||||
const tokens = getStoredTokens();
|
||||
|
||||
if (!tokens.access) {
|
||||
throw new Error("Authentication required.");
|
||||
}
|
||||
|
||||
// Get user's timezone
|
||||
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
// Build payload with defaults
|
||||
const payload: any = {
|
||||
...input,
|
||||
// Always include timezone when scheduling with datetime
|
||||
...(input.scheduled_datetime && { timezone: input.timezone || userTimezone }),
|
||||
// Default create_jitsi_meeting to true if not specified
|
||||
create_jitsi_meeting: input.create_jitsi_meeting !== undefined ? input.create_jitsi_meeting : true,
|
||||
};
|
||||
|
||||
// Remove undefined fields
|
||||
Object.keys(payload).forEach(key => {
|
||||
if (payload[key] === undefined) {
|
||||
delete payload[key];
|
||||
}
|
||||
});
|
||||
|
||||
const response = await fetch(`${API_ENDPOINTS.meetings.listAppointments}${id}/schedule/`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${tokens.access}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
const data = await parseResponse(response);
|
||||
const data: AppointmentResponse = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(extractErrorMessage(data as unknown as ApiError));
|
||||
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return data.appointment || data;
|
||||
if (data.appointment) {
|
||||
return data.appointment;
|
||||
}
|
||||
|
||||
return data as unknown as Appointment;
|
||||
}
|
||||
|
||||
export async function rejectAppointment(id: string, input: RejectAppointmentInput): Promise<Appointment> {
|
||||
// Reject appointment (Admin only)
|
||||
export async function rejectAppointment(
|
||||
id: string,
|
||||
input: RejectAppointmentInput
|
||||
): Promise<Appointment> {
|
||||
const tokens = getStoredTokens();
|
||||
|
||||
if (!tokens.access) {
|
||||
throw new Error("Authentication required.");
|
||||
}
|
||||
|
||||
// Build payload - only include rejection_reason if provided
|
||||
const payload: any = {};
|
||||
if (input.rejection_reason) {
|
||||
payload.rejection_reason = input.rejection_reason;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_ENDPOINTS.meetings.listAppointments}${id}/reject/`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${tokens.access}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
const data = await parseResponse(response);
|
||||
const data: AppointmentResponse = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(extractErrorMessage(data as unknown as ApiError));
|
||||
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return (data as AppointmentResponse).appointment || data;
|
||||
}
|
||||
|
||||
export async function getPublicAvailability(): Promise<AdminAvailability | null> {
|
||||
try {
|
||||
const weeklyAvailability = await getWeeklyAvailability();
|
||||
const weekArray = Array.isArray(weeklyAvailability)
|
||||
? weeklyAvailability
|
||||
: (weeklyAvailability as any).week || [];
|
||||
|
||||
if (!weekArray || weekArray.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const availabilitySchedule: Record<string, string[]> = {};
|
||||
const availableDays: number[] = [];
|
||||
const availableDaysDisplay: string[] = [];
|
||||
|
||||
weekArray.forEach((day: any) => {
|
||||
if (day.is_available && day.available_slots?.length > 0) {
|
||||
availabilitySchedule[day.day.toString()] = day.available_slots;
|
||||
availableDays.push(day.day);
|
||||
availableDaysDisplay.push(day.day_name);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
available_days: availableDays,
|
||||
available_days_display: availableDaysDisplay,
|
||||
availability_schedule: availabilitySchedule,
|
||||
all_available_slots: weekArray
|
||||
.filter((d: any) => d.is_available)
|
||||
.flatMap((d: any) =>
|
||||
d.available_slots.map((slot: string) => ({
|
||||
day: d.day,
|
||||
time_slot: slot as "morning" | "afternoon" | "evening"
|
||||
}))
|
||||
),
|
||||
} as AdminAvailability;
|
||||
} catch {
|
||||
return null;
|
||||
if (data.appointment) {
|
||||
return data.appointment;
|
||||
}
|
||||
|
||||
return data as unknown as Appointment;
|
||||
}
|
||||
|
||||
// Get admin availability
|
||||
export async function getAdminAvailability(): Promise<AdminAvailability> {
|
||||
const tokens = getStoredTokens();
|
||||
|
||||
if (!tokens.access) {
|
||||
throw new Error("Authentication required.");
|
||||
}
|
||||
|
||||
const response = await fetch(API_ENDPOINTS.meetings.adminAvailability, {
|
||||
const response = await fetch(`${API_ENDPOINTS.meetings.base}admin/availability/`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
@ -512,173 +295,49 @@ export async function getAdminAvailability(): Promise<AdminAvailability> {
|
||||
},
|
||||
});
|
||||
|
||||
const data = await parseResponse(response);
|
||||
const data: AdminAvailability = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(extractErrorMessage(data as unknown as ApiError));
|
||||
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
if (data.availability_schedule) {
|
||||
const availabilitySchedule = normalizeAvailabilitySchedule(data.availability_schedule);
|
||||
const availableDays = Object.keys(availabilitySchedule).map(Number);
|
||||
const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
const availableDaysDisplay = availableDays.map(day => dayNames[day] || `Day ${day}`);
|
||||
|
||||
return {
|
||||
available_days: availableDays,
|
||||
available_days_display: Array.isArray(data.availability_schedule_display)
|
||||
? data.availability_schedule_display
|
||||
: data.availability_schedule_display
|
||||
? [data.availability_schedule_display]
|
||||
: availableDaysDisplay,
|
||||
availability_schedule: availabilitySchedule,
|
||||
availability_schedule_display: data.availability_schedule_display,
|
||||
all_available_slots: data.all_available_slots || [],
|
||||
} as AdminAvailability;
|
||||
}
|
||||
|
||||
let availableDays: number[] = [];
|
||||
if (typeof data.available_days === 'string') {
|
||||
try {
|
||||
availableDays = JSON.parse(data.available_days);
|
||||
} catch {
|
||||
availableDays = data.available_days.split(',').map((d: string) => parseInt(d.trim())).filter((d: number) => !isNaN(d));
|
||||
}
|
||||
} else if (Array.isArray(data.available_days)) {
|
||||
availableDays = data.available_days;
|
||||
}
|
||||
|
||||
return {
|
||||
available_days: availableDays,
|
||||
available_days_display: data.available_days_display || [],
|
||||
} as AdminAvailability;
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateAdminAvailability(input: UpdateAvailabilityInput): Promise<AdminAvailability> {
|
||||
// Update admin availability
|
||||
export async function updateAdminAvailability(
|
||||
input: UpdateAvailabilityInput
|
||||
): Promise<AdminAvailability> {
|
||||
const tokens = getStoredTokens();
|
||||
|
||||
if (!tokens.access) {
|
||||
throw new Error("Authentication required.");
|
||||
}
|
||||
|
||||
if (!input.availability_schedule) {
|
||||
throw new Error("availability_schedule is required");
|
||||
}
|
||||
|
||||
const cleanedSchedule: Record<string, string[]> = {};
|
||||
Object.keys(input.availability_schedule).forEach(key => {
|
||||
const dayNum = parseInt(key);
|
||||
if (isNaN(dayNum) || dayNum < 0 || dayNum > 6) return;
|
||||
|
||||
const slots = input.availability_schedule[key];
|
||||
if (Array.isArray(slots) && slots.length > 0) {
|
||||
const validSlots = slots
|
||||
.filter((slot: string) => typeof slot === 'string' && ['morning', 'afternoon', 'evening'].includes(slot))
|
||||
.filter((slot: string, index: number, self: string[]) => self.indexOf(slot) === index);
|
||||
|
||||
if (validSlots.length > 0) {
|
||||
cleanedSchedule[key.toString()] = validSlots;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(cleanedSchedule).length === 0) {
|
||||
throw new Error("At least one day with valid time slots must be provided");
|
||||
}
|
||||
|
||||
const sortedSchedule: Record<string, string[]> = {};
|
||||
Object.keys(cleanedSchedule)
|
||||
.sort((a, b) => parseInt(a) - parseInt(b))
|
||||
.forEach(key => {
|
||||
sortedSchedule[key] = cleanedSchedule[key];
|
||||
});
|
||||
|
||||
let response = await fetch(API_ENDPOINTS.meetings.adminAvailability, {
|
||||
const response = await fetch(`${API_ENDPOINTS.meetings.base}admin/availability/`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${tokens.access}`,
|
||||
},
|
||||
body: JSON.stringify({ availability_schedule: sortedSchedule }),
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
if (!response.ok && response.status === 500) {
|
||||
response = await fetch(API_ENDPOINTS.meetings.adminAvailability, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${tokens.access}`,
|
||||
},
|
||||
body: JSON.stringify({ availability_schedule: sortedSchedule }),
|
||||
});
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
|
||||
if (!responseText || responseText.trim().length === 0) {
|
||||
if (response.ok) {
|
||||
return await getAdminAvailability();
|
||||
}
|
||||
throw new Error(`Server error (${response.status}): ${response.statusText || 'Empty response'}`);
|
||||
}
|
||||
|
||||
let data: any;
|
||||
if (contentType.includes("application/json")) {
|
||||
try {
|
||||
data = JSON.parse(responseText);
|
||||
} catch {
|
||||
throw new Error(`Server error (${response.status}): Invalid JSON format`);
|
||||
}
|
||||
} else {
|
||||
const htmlError = extractHtmlError(responseText);
|
||||
throw new Error(`Server error (${response.status}): ${htmlError || response.statusText || 'Internal Server Error'}`);
|
||||
}
|
||||
const data: AdminAvailability = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(extractErrorMessage(data as unknown as ApiError));
|
||||
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
if (data?.availability_schedule) {
|
||||
const availabilitySchedule = normalizeAvailabilitySchedule(data.availability_schedule);
|
||||
const availableDays = Object.keys(availabilitySchedule).map(Number);
|
||||
const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
const availableDaysDisplay = availableDays.map(day => dayNames[day] || `Day ${day}`);
|
||||
|
||||
return {
|
||||
available_days: availableDays,
|
||||
available_days_display: Array.isArray(data.availability_schedule_display)
|
||||
? data.availability_schedule_display
|
||||
: data.availability_schedule_display
|
||||
? [data.availability_schedule_display]
|
||||
: availableDaysDisplay,
|
||||
availability_schedule: availabilitySchedule,
|
||||
availability_schedule_display: data.availability_schedule_display,
|
||||
all_available_slots: data.all_available_slots || [],
|
||||
} as AdminAvailability;
|
||||
}
|
||||
|
||||
if (response.ok && (!data || Object.keys(data).length === 0)) {
|
||||
return await getAdminAvailability();
|
||||
}
|
||||
|
||||
let availableDays: number[] = [];
|
||||
if (typeof data.available_days === 'string') {
|
||||
try {
|
||||
availableDays = JSON.parse(data.available_days);
|
||||
} catch {
|
||||
availableDays = data.available_days.split(',').map((d: string) => parseInt(d.trim())).filter((d: number) => !isNaN(d));
|
||||
}
|
||||
} else if (Array.isArray(data.available_days)) {
|
||||
availableDays = data.available_days;
|
||||
}
|
||||
|
||||
return {
|
||||
available_days: availableDays,
|
||||
available_days_display: data.available_days_display || [],
|
||||
} as AdminAvailability;
|
||||
return data;
|
||||
}
|
||||
|
||||
// Get appointment stats (Admin only)
|
||||
export async function getAppointmentStats(): Promise<AppointmentStats> {
|
||||
const tokens = getStoredTokens();
|
||||
|
||||
if (!tokens.access) {
|
||||
throw new Error("Authentication required.");
|
||||
}
|
||||
@ -691,38 +350,20 @@ export async function getAppointmentStats(): Promise<AppointmentStats> {
|
||||
},
|
||||
});
|
||||
|
||||
const data = await parseResponse(response);
|
||||
const data: AppointmentStats = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(extractErrorMessage(data as unknown as ApiError));
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function getUserAppointmentStats(): Promise<UserAppointmentStats> {
|
||||
const tokens = getStoredTokens();
|
||||
if (!tokens.access) {
|
||||
throw new Error("Authentication required.");
|
||||
}
|
||||
|
||||
const response = await fetch(API_ENDPOINTS.meetings.userAppointmentStats, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${tokens.access}`,
|
||||
},
|
||||
});
|
||||
|
||||
const data = await parseResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(extractErrorMessage(data as unknown as ApiError));
|
||||
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// Get Jitsi meeting info
|
||||
export async function getJitsiMeetingInfo(id: string): Promise<JitsiMeetingInfo> {
|
||||
const tokens = getStoredTokens();
|
||||
|
||||
if (!tokens.access) {
|
||||
throw new Error("Authentication required.");
|
||||
}
|
||||
@ -735,142 +376,13 @@ export async function getJitsiMeetingInfo(id: string): Promise<JitsiMeetingInfo>
|
||||
},
|
||||
});
|
||||
|
||||
const data = await parseResponse(response);
|
||||
const data: JitsiMeetingInfo = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(extractErrorMessage(data as unknown as ApiError));
|
||||
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function startMeeting(
|
||||
id: string,
|
||||
options?: {
|
||||
metadata?: string;
|
||||
recording_url?: string;
|
||||
}
|
||||
): Promise<Appointment> {
|
||||
const tokens = getStoredTokens();
|
||||
if (!tokens.access) {
|
||||
throw new Error("Authentication required.");
|
||||
}
|
||||
|
||||
const response = await fetch(API_ENDPOINTS.meetings.startMeeting(id), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${tokens.access}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: "start",
|
||||
metadata: options?.metadata,
|
||||
recording_url: options?.recording_url,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await parseResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(extractErrorMessage(data as unknown as ApiError));
|
||||
}
|
||||
|
||||
// The API returns { action, metadata, recording_url }, not an Appointment
|
||||
// So we need to refetch the appointment to get the updated state
|
||||
return await getAppointmentDetail(id);
|
||||
}
|
||||
|
||||
export async function endMeeting(id: string): Promise<Appointment> {
|
||||
const tokens = getStoredTokens();
|
||||
if (!tokens.access) {
|
||||
throw new Error("Authentication required.");
|
||||
}
|
||||
|
||||
const response = await fetch(API_ENDPOINTS.meetings.endMeeting(id), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${tokens.access}`,
|
||||
},
|
||||
});
|
||||
|
||||
const data = await parseResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(extractErrorMessage(data as unknown as ApiError));
|
||||
}
|
||||
|
||||
// The API returns a response object, not an Appointment
|
||||
// So we need to refetch the appointment to get the updated state
|
||||
return await getAppointmentDetail(id);
|
||||
}
|
||||
|
||||
export interface RescheduleAppointmentInput {
|
||||
new_scheduled_datetime: string; // ISO datetime string
|
||||
new_scheduled_duration: number; // in minutes
|
||||
timezone: string;
|
||||
}
|
||||
|
||||
export async function rescheduleAppointment(id: string, input: RescheduleAppointmentInput): Promise<Appointment> {
|
||||
const tokens = getStoredTokens();
|
||||
if (!tokens.access) {
|
||||
throw new Error("Authentication required.");
|
||||
}
|
||||
|
||||
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; // Get user's timezone
|
||||
|
||||
const payload: any = {
|
||||
new_scheduled_datetime: input.new_scheduled_datetime,
|
||||
new_scheduled_duration: input.new_scheduled_duration,
|
||||
timezone: input.timezone !== undefined ? input.timezone : userTimezone,
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_ENDPOINTS.meetings.listAppointments}${id}/reschedule/`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${tokens.access}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await parseResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(extractErrorMessage(data as unknown as ApiError));
|
||||
}
|
||||
|
||||
// Refetch the appointment to get the updated state
|
||||
return await getAppointmentDetail(id);
|
||||
}
|
||||
|
||||
export interface CancelAppointmentInput {
|
||||
action?: string;
|
||||
metadata?: string;
|
||||
recording_url?: string;
|
||||
}
|
||||
|
||||
export async function cancelAppointment(id: string, input?: CancelAppointmentInput): Promise<any> {
|
||||
const tokens = getStoredTokens();
|
||||
if (!tokens.access) {
|
||||
throw new Error("Authentication required.");
|
||||
}
|
||||
|
||||
const payload: any = {};
|
||||
if (input?.action) payload.action = input.action;
|
||||
if (input?.metadata) payload.metadata = input.metadata;
|
||||
if (input?.recording_url) payload.recording_url = input.recording_url;
|
||||
|
||||
const response = await fetch(`${API_ENDPOINTS.meetings.base}meetings/${id}/cancel/`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${tokens.access}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await parseResponse(response);
|
||||
if (!response.ok) {
|
||||
throw new Error(extractErrorMessage(data as unknown as ApiError));
|
||||
}
|
||||
|
||||
// Refetch the appointment to get the updated state
|
||||
return await getAppointmentDetail(id);
|
||||
}
|
||||
|
||||
@ -8,8 +8,6 @@ import type {
|
||||
VerifyPasswordResetOtpInput,
|
||||
ResetPasswordInput,
|
||||
TokenRefreshInput,
|
||||
UpdateProfileInput,
|
||||
ContactInput,
|
||||
} from "@/lib/schema/auth";
|
||||
import type { AuthResponse, ApiError, AuthTokens, User } from "@/lib/models/auth";
|
||||
|
||||
@ -371,87 +369,3 @@ export async function getAllUsers(): Promise<User[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get user profile
|
||||
export async function getProfile(): Promise<User> {
|
||||
const tokens = getStoredTokens();
|
||||
|
||||
if (!tokens.access) {
|
||||
throw new Error("Authentication required.");
|
||||
}
|
||||
|
||||
const response = await fetch(API_ENDPOINTS.auth.getProfile, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${tokens.access}`,
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = extractErrorMessage(data);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Handle different response formats
|
||||
if (data.user) {
|
||||
return data.user;
|
||||
}
|
||||
if (data.id) {
|
||||
return data;
|
||||
}
|
||||
|
||||
throw new Error("Invalid profile response format");
|
||||
}
|
||||
|
||||
// Update user profile
|
||||
export async function updateProfile(input: UpdateProfileInput): Promise<User> {
|
||||
const tokens = getStoredTokens();
|
||||
|
||||
if (!tokens.access) {
|
||||
throw new Error("Authentication required.");
|
||||
}
|
||||
|
||||
const response = await fetch(API_ENDPOINTS.auth.updateProfile, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${tokens.access}`,
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
const errorMessage = extractErrorMessage(data);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
// Handle different response formats
|
||||
if (data.user) {
|
||||
return data.user;
|
||||
}
|
||||
if (data.id) {
|
||||
return data;
|
||||
}
|
||||
|
||||
throw new Error("Invalid profile response format");
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit contact form
|
||||
*/
|
||||
export async function submitContactForm(input: ContactInput): Promise<{ message: string }> {
|
||||
const response = await fetch(API_ENDPOINTS.auth.contact, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
return handleResponse<{ message: string }>(response);
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
// Get API base URL from environment variable
|
||||
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
const getApiBaseUrl = () => {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_API_URL || "";
|
||||
// Remove trailing slash if present
|
||||
const cleanUrl = baseUrl.replace(/\/$/, "");
|
||||
// Add /api if not already present
|
||||
return cleanUrl ? `${cleanUrl}/api` : "";
|
||||
};
|
||||
|
||||
export const API_BASE_URL = getApiBaseUrl();
|
||||
|
||||
export const API_ENDPOINTS = {
|
||||
auth: {
|
||||
@ -13,9 +21,6 @@ export const API_ENDPOINTS = {
|
||||
resetPassword: `${API_BASE_URL}/auth/reset-password/`,
|
||||
tokenRefresh: `${API_BASE_URL}/auth/token/refresh/`,
|
||||
allUsers: `${API_BASE_URL}/auth/all-users/`,
|
||||
getProfile: `${API_BASE_URL}/auth/profile/`,
|
||||
updateProfile: `${API_BASE_URL}/auth/profile/update/`,
|
||||
contact: `${API_BASE_URL}/auth/contact/`,
|
||||
},
|
||||
meetings: {
|
||||
base: `${API_BASE_URL}/meetings/`,
|
||||
@ -23,14 +28,6 @@ export const API_ENDPOINTS = {
|
||||
createAppointment: `${API_BASE_URL}/meetings/appointments/create/`,
|
||||
listAppointments: `${API_BASE_URL}/meetings/appointments/`,
|
||||
userAppointments: `${API_BASE_URL}/meetings/user/appointments/`,
|
||||
userAppointmentStats: `${API_BASE_URL}/meetings/user/appointments/stats/`,
|
||||
adminAvailability: `${API_BASE_URL}/meetings/admin/availability/`,
|
||||
weeklyAvailability: `${API_BASE_URL}/meetings/availability/weekly/`,
|
||||
availabilityConfig: `${API_BASE_URL}/meetings/availability/config/`,
|
||||
checkDateAvailability: `${API_BASE_URL}/meetings/availability/check/`,
|
||||
availabilityOverview: `${API_BASE_URL}/meetings/availability/overview/`,
|
||||
startMeeting: (id: string) => `${API_BASE_URL}/meetings/meetings/${id}/start/`,
|
||||
endMeeting: (id: string) => `${API_BASE_URL}/meetings/meetings/${id}/end/`,
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
@ -7,10 +7,9 @@ export interface Appointment {
|
||||
email: string;
|
||||
phone?: string;
|
||||
reason?: string;
|
||||
preferred_dates?: string | string[]; // YYYY-MM-DD format - API can return as string or array
|
||||
preferred_time_slots?: string | string[]; // "morning", "afternoon", "evening" - API can return as string or array
|
||||
selected_slots?: SelectedSlot[]; // New format: day-time combinations
|
||||
status: "pending_review" | "scheduled" | "rejected" | "completed" | "cancelled";
|
||||
preferred_dates: string[]; // YYYY-MM-DD format
|
||||
preferred_time_slots: string[]; // "morning", "afternoon", "evening"
|
||||
status: "pending_review" | "scheduled" | "rejected";
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
scheduled_datetime?: string;
|
||||
@ -18,39 +17,9 @@ export interface Appointment {
|
||||
rejection_reason?: string;
|
||||
jitsi_meet_url?: string;
|
||||
jitsi_room_id?: string;
|
||||
jitsi_meeting_created?: boolean;
|
||||
meeting_started_at?: string;
|
||||
started_at?: string; // Alternative field name from API
|
||||
meeting_ended_at?: string;
|
||||
meeting_duration_actual?: number;
|
||||
meeting_info?: any;
|
||||
has_jitsi_meeting?: boolean | string;
|
||||
can_join_meeting?: boolean | string;
|
||||
can_join_as_moderator?: boolean | string;
|
||||
can_join_as_participant?: boolean | string;
|
||||
moderator_join_url?: string;
|
||||
participant_join_url?: string;
|
||||
has_jitsi_meeting?: boolean;
|
||||
can_join_meeting?: boolean;
|
||||
meeting_status?: string;
|
||||
matching_availability?: MatchingAvailability | Array<{
|
||||
date: string;
|
||||
day_name: string;
|
||||
available_slots: string[];
|
||||
date_obj?: string;
|
||||
}>;
|
||||
are_preferences_available?: boolean | string;
|
||||
// Additional fields from API response
|
||||
full_name?: string;
|
||||
formatted_created_at?: string;
|
||||
formatted_scheduled_datetime?: string;
|
||||
preferred_dates_display?: string;
|
||||
preferred_time_slots_display?: string;
|
||||
meeting_duration_display?: string;
|
||||
}
|
||||
|
||||
export interface SelectedSlot {
|
||||
day: number; // 0-6 (Monday-Sunday)
|
||||
date?: string; // YYYY-MM-DD format
|
||||
time_slot: "morning" | "afternoon" | "evening";
|
||||
}
|
||||
|
||||
export interface AppointmentResponse {
|
||||
@ -67,111 +36,23 @@ export interface AppointmentsListResponse {
|
||||
}
|
||||
|
||||
export interface AvailableDatesResponse {
|
||||
dates?: string[]; // YYYY-MM-DD format (legacy)
|
||||
dates: string[]; // YYYY-MM-DD format
|
||||
available_days?: number[]; // 0-6 (Monday-Sunday)
|
||||
available_days_display?: string[];
|
||||
// New format - array of date objects with time slots
|
||||
available_dates?: Array<{
|
||||
date: string; // YYYY-MM-DD
|
||||
day_name: string;
|
||||
available_slots: string[];
|
||||
available_slots_display?: string[];
|
||||
is_available: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface WeeklyAvailabilityDay {
|
||||
day: number; // 0-6 (Monday-Sunday)
|
||||
day_name: string;
|
||||
available_slots: string[]; // ["morning", "afternoon", "evening"]
|
||||
available_slots_display?: string[];
|
||||
is_available: boolean;
|
||||
}
|
||||
|
||||
export type WeeklyAvailabilityResponse = WeeklyAvailabilityDay[] | {
|
||||
week?: WeeklyAvailabilityDay[];
|
||||
[key: string]: any; // Allow for different response formats
|
||||
};
|
||||
|
||||
export interface AvailabilityConfig {
|
||||
days_of_week: Record<string, string>; // {"0": "Monday", ...}
|
||||
time_slots: Record<string, string>; // {"morning": "Morning (9AM - 12PM)", ...}
|
||||
}
|
||||
|
||||
export interface CheckDateAvailabilityResponse {
|
||||
date: string;
|
||||
day_name: string;
|
||||
available_slots: string[];
|
||||
available_slots_display?: string[];
|
||||
is_available: boolean;
|
||||
}
|
||||
|
||||
export interface AvailabilityOverview {
|
||||
available: boolean;
|
||||
total_available_slots: number;
|
||||
available_days: string[];
|
||||
next_available_dates: Array<{
|
||||
date: string;
|
||||
day_name: string;
|
||||
available_slots: string[];
|
||||
is_available: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface AdminAvailability {
|
||||
available_days?: number[]; // 0-6 (Monday-Sunday) (legacy)
|
||||
available_days_display?: string[];
|
||||
availability_schedule?: Record<string, string[]>; // {"0": ["morning", "evening"], "1": ["afternoon"]}
|
||||
availability_schedule_display?: string;
|
||||
all_available_slots?: SelectedSlot[];
|
||||
}
|
||||
|
||||
export interface MatchingAvailability {
|
||||
appointment_id: string;
|
||||
preferences_match_availability: boolean;
|
||||
matching_slots: Array<{
|
||||
date: string; // YYYY-MM-DD
|
||||
time_slot: string;
|
||||
day: number;
|
||||
}>;
|
||||
total_matching_slots: number;
|
||||
available_days: number[]; // 0-6 (Monday-Sunday)
|
||||
available_days_display: string[];
|
||||
}
|
||||
|
||||
export interface AppointmentStats {
|
||||
total_requests: number;
|
||||
pending_review: number;
|
||||
pending_review_pct?: number;
|
||||
scheduled: number;
|
||||
scheduled_pct?: number;
|
||||
rejected: number;
|
||||
rejected_pct?: number;
|
||||
completed: number;
|
||||
completed_pct?: number;
|
||||
completion_rate: number;
|
||||
users?: number; // Total users count from API
|
||||
users_pct?: number;
|
||||
active_upcoming_meetings?: number;
|
||||
availability_coverage?: number;
|
||||
availability_coverage_pct?: number;
|
||||
available_days_count?: number;
|
||||
jitsi_meetings_created?: number;
|
||||
meetings_with_video?: number;
|
||||
meetings_with_video_pct?: number;
|
||||
video_meetings?: number;
|
||||
}
|
||||
|
||||
export interface UserAppointmentStats {
|
||||
total_requests: number;
|
||||
pending_review: number;
|
||||
pending_review_pct?: number;
|
||||
scheduled: number;
|
||||
scheduled_pct?: number;
|
||||
rejected: number;
|
||||
rejected_pct?: number;
|
||||
completed: number;
|
||||
completed_pct?: number;
|
||||
completion_rate: number;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface JitsiMeetingInfo {
|
||||
|
||||
@ -1,62 +1,27 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// Selected Slot Schema (for new API format)
|
||||
export const selectedSlotSchema = z.object({
|
||||
day: z.number().int().min(0).max(6),
|
||||
time_slot: z.enum(["morning", "afternoon", "evening"]),
|
||||
});
|
||||
|
||||
export type SelectedSlotInput = z.infer<typeof selectedSlotSchema>;
|
||||
|
||||
// Create Appointment Schema (updated to use selected_slots)
|
||||
// Create Appointment Schema
|
||||
export const createAppointmentSchema = z.object({
|
||||
first_name: z.string().min(1, "First name is required").max(100, "First name must be 100 characters or less"),
|
||||
last_name: z.string().min(1, "Last name is required").max(100, "Last name must be 100 characters or less"),
|
||||
email: z.string().email("Invalid email address").max(100, "Email must be 100 characters or less"),
|
||||
selected_slots: z
|
||||
.array(selectedSlotSchema)
|
||||
.min(1, "At least one time slot must be selected"),
|
||||
phone: z.string().max(100, "Phone must be 100 characters or less").optional(),
|
||||
reason: z.string().max(100, "Reason must be 100 characters or less").optional(),
|
||||
// Legacy fields (optional, for backward compatibility - but should not be sent)
|
||||
first_name: z.string().min(1, "First name is required"),
|
||||
last_name: z.string().min(1, "Last name is required"),
|
||||
email: z.string().email("Invalid email address"),
|
||||
preferred_dates: z
|
||||
.array(z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format"))
|
||||
.optional(),
|
||||
.min(1, "At least one preferred date is required"),
|
||||
preferred_time_slots: z
|
||||
.array(z.enum(["morning", "afternoon", "evening"]))
|
||||
.optional(),
|
||||
}).refine(
|
||||
(data) => data.selected_slots && data.selected_slots.length > 0,
|
||||
{
|
||||
message: "At least one time slot must be selected",
|
||||
path: ["selected_slots"],
|
||||
}
|
||||
);
|
||||
.min(1, "At least one preferred time slot is required"),
|
||||
phone: z.string().optional(),
|
||||
reason: z.string().optional(),
|
||||
});
|
||||
|
||||
export type CreateAppointmentInput = z.infer<typeof createAppointmentSchema>;
|
||||
|
||||
// Schedule Appointment Schema (Admin only)
|
||||
// Supports two scheduling methods:
|
||||
// 1. Direct datetime: scheduled_datetime + scheduled_duration + timezone
|
||||
// 2. Date and slot: date_str + time_slot + scheduled_duration
|
||||
export const scheduleAppointmentSchema = z.object({
|
||||
scheduled_datetime: z.string().datetime("Invalid datetime format").optional(),
|
||||
scheduled_datetime: z.string().datetime("Invalid datetime format"),
|
||||
scheduled_duration: z.number().int().positive().optional(),
|
||||
timezone: z.string().optional(),
|
||||
date_str: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format").optional(),
|
||||
time_slot: z.enum(["morning", "afternoon", "evening"]).optional(),
|
||||
create_jitsi_meeting: z.boolean().optional(),
|
||||
jitsi_custom_config: z.string().optional(),
|
||||
}).refine(
|
||||
(data) => {
|
||||
// Either scheduled_datetime OR (date_str + time_slot) must be provided
|
||||
return data.scheduled_datetime || (data.date_str && data.time_slot);
|
||||
},
|
||||
{
|
||||
message: "Either scheduled_datetime or both date_str and time_slot must be provided",
|
||||
path: ["scheduled_datetime"],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
export type ScheduleAppointmentInput = z.infer<typeof scheduleAppointmentSchema>;
|
||||
|
||||
@ -67,18 +32,11 @@ export const rejectAppointmentSchema = z.object({
|
||||
|
||||
export type RejectAppointmentInput = z.infer<typeof rejectAppointmentSchema>;
|
||||
|
||||
// Update Admin Availability Schema (updated to use availability_schedule)
|
||||
// Update Admin Availability Schema
|
||||
export const updateAvailabilitySchema = z.object({
|
||||
availability_schedule: z
|
||||
.record(z.string(), z.array(z.enum(["morning", "afternoon", "evening"])))
|
||||
.refine(
|
||||
(schedule) => Object.keys(schedule).length > 0,
|
||||
{ message: "At least one day must have availability" }
|
||||
),
|
||||
// Legacy field (optional, for backward compatibility)
|
||||
available_days: z
|
||||
.array(z.number().int().min(0).max(6))
|
||||
.optional(),
|
||||
.min(1, "At least one day must be selected"),
|
||||
});
|
||||
|
||||
export type UpdateAvailabilityInput = z.infer<typeof updateAvailabilitySchema>;
|
||||
|
||||
@ -78,22 +78,3 @@ export const tokenRefreshSchema = z.object({
|
||||
|
||||
export type TokenRefreshInput = z.infer<typeof tokenRefreshSchema>;
|
||||
|
||||
// Update Profile Schema
|
||||
export const updateProfileSchema = z.object({
|
||||
first_name: z.string().min(1, "First name is required"),
|
||||
last_name: z.string().min(1, "Last name is required"),
|
||||
phone_number: z.string().optional(),
|
||||
});
|
||||
|
||||
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
|
||||
|
||||
// Contact Form Schema
|
||||
export const contactSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
email: z.string().email("Invalid email address"),
|
||||
phone: z.string().min(1, "Phone number is required"),
|
||||
message: z.string().min(1, "Message is required"),
|
||||
});
|
||||
|
||||
export type ContactInput = z.infer<typeof contactSchema>;
|
||||
|
||||
|
||||
@ -1,237 +0,0 @@
|
||||
/**
|
||||
* Encryption utilities for securing sensitive user data
|
||||
* Uses Web Crypto API with AES-GCM for authenticated encryption
|
||||
*/
|
||||
|
||||
// Generate a key from a password using PBKDF2
|
||||
async function deriveKey(password: string, salt: BufferSource): Promise<CryptoKey> {
|
||||
const encoder = new TextEncoder();
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(password),
|
||||
"PBKDF2",
|
||||
false,
|
||||
["deriveBits", "deriveKey"]
|
||||
);
|
||||
|
||||
return crypto.subtle.deriveKey(
|
||||
{
|
||||
name: "PBKDF2",
|
||||
salt: salt,
|
||||
iterations: 100000,
|
||||
hash: "SHA-256",
|
||||
},
|
||||
keyMaterial,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"]
|
||||
);
|
||||
}
|
||||
|
||||
// Get or create encryption key from localStorage
|
||||
async function getEncryptionKey(): Promise<CryptoKey> {
|
||||
const STORAGE_KEY = "encryption_salt";
|
||||
const PASSWORD_KEY = "encryption_password";
|
||||
|
||||
// Generate a unique password based on user's browser fingerprint
|
||||
// This creates a consistent key per browser/device
|
||||
const getBrowserFingerprint = (): string => {
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx) {
|
||||
ctx.textBaseline = "top";
|
||||
ctx.font = "14px 'Arial'";
|
||||
ctx.textBaseline = "alphabetic";
|
||||
ctx.fillStyle = "#f60";
|
||||
ctx.fillRect(125, 1, 62, 20);
|
||||
ctx.fillStyle = "#069";
|
||||
ctx.fillText("Browser fingerprint", 2, 15);
|
||||
ctx.fillStyle = "rgba(102, 204, 0, 0.7)";
|
||||
ctx.fillText("Browser fingerprint", 4, 17);
|
||||
}
|
||||
const fingerprint = (canvas.toDataURL() || "") +
|
||||
(navigator.userAgent || "") +
|
||||
(navigator.language || "") +
|
||||
(screen.width || 0) +
|
||||
(screen.height || 0) +
|
||||
(new Date().getTimezoneOffset() || 0);
|
||||
return fingerprint;
|
||||
} catch (error) {
|
||||
// Fallback if canvas fingerprinting fails
|
||||
return (navigator.userAgent || "") +
|
||||
(navigator.language || "") +
|
||||
(screen.width || 0) +
|
||||
(screen.height || 0);
|
||||
}
|
||||
};
|
||||
|
||||
let salt = localStorage.getItem(STORAGE_KEY);
|
||||
let password = localStorage.getItem(PASSWORD_KEY);
|
||||
|
||||
if (!salt || !password) {
|
||||
// Generate new salt and password
|
||||
const saltBytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
salt = Array.from(saltBytes)
|
||||
.map(b => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
|
||||
password = getBrowserFingerprint();
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, salt);
|
||||
localStorage.setItem(PASSWORD_KEY, password);
|
||||
}
|
||||
|
||||
// Convert hex string back to Uint8Array
|
||||
const saltBytes = salt.match(/.{1,2}/g)?.map(byte => parseInt(byte, 16)) || [];
|
||||
const saltArray = new Uint8Array(saltBytes);
|
||||
|
||||
return deriveKey(password, saltArray);
|
||||
}
|
||||
|
||||
// Encrypt a string value
|
||||
export async function encryptValue(value: string): Promise<string> {
|
||||
if (!value || typeof window === "undefined") return value;
|
||||
|
||||
try {
|
||||
const key = await getEncryptionKey();
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(value);
|
||||
|
||||
// Generate a random IV for each encryption
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv: iv,
|
||||
},
|
||||
key,
|
||||
data
|
||||
);
|
||||
|
||||
// Combine IV and encrypted data
|
||||
const combined = new Uint8Array(iv.length + encrypted.byteLength);
|
||||
combined.set(iv);
|
||||
combined.set(new Uint8Array(encrypted), iv.length);
|
||||
|
||||
// Convert to base64 for storage
|
||||
const binaryString = String.fromCharCode(...combined);
|
||||
return btoa(binaryString);
|
||||
} catch (error) {
|
||||
// If encryption fails, return original value (graceful degradation)
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt a string value
|
||||
export async function decryptValue(encryptedValue: string): Promise<string> {
|
||||
if (!encryptedValue || typeof window === "undefined") return encryptedValue;
|
||||
|
||||
try {
|
||||
const key = await getEncryptionKey();
|
||||
|
||||
// Decode from base64
|
||||
const binaryString = atob(encryptedValue);
|
||||
const combined = Uint8Array.from(binaryString, c => c.charCodeAt(0));
|
||||
|
||||
// Extract IV and encrypted data
|
||||
const iv = combined.slice(0, 12);
|
||||
const encrypted = combined.slice(12);
|
||||
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv: iv,
|
||||
},
|
||||
key,
|
||||
encrypted
|
||||
);
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(decrypted);
|
||||
} catch (error) {
|
||||
// If decryption fails, try to return as-is (might be unencrypted legacy data)
|
||||
return encryptedValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypt sensitive fields in a user object
|
||||
export async function encryptUserData(user: any): Promise<any> {
|
||||
if (!user || typeof window === "undefined") return user;
|
||||
|
||||
const encrypted = { ...user };
|
||||
|
||||
// Encrypt sensitive fields
|
||||
const sensitiveFields = ["first_name", "last_name", "phone_number", "email"];
|
||||
|
||||
for (const field of sensitiveFields) {
|
||||
if (encrypted[field]) {
|
||||
encrypted[field] = await encryptValue(String(encrypted[field]));
|
||||
}
|
||||
}
|
||||
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
// Decrypt sensitive fields in a user object
|
||||
export async function decryptUserData(user: any): Promise<any> {
|
||||
if (!user || typeof window === "undefined") return user;
|
||||
|
||||
const decrypted = { ...user };
|
||||
|
||||
// Decrypt sensitive fields
|
||||
const sensitiveFields = ["first_name", "last_name", "phone_number", "email"];
|
||||
|
||||
for (const field of sensitiveFields) {
|
||||
if (decrypted[field]) {
|
||||
try {
|
||||
decrypted[field] = await decryptValue(String(decrypted[field]));
|
||||
} catch (error) {
|
||||
// If decryption fails, keep original value (might be unencrypted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
// Check if a value is encrypted (heuristic check)
|
||||
function isEncrypted(value: string): boolean {
|
||||
// Encrypted values are base64 encoded and have a specific structure
|
||||
// This is a simple heuristic - encrypted values will be longer and base64-like
|
||||
if (!value || value.length < 20) return false;
|
||||
|
||||
try {
|
||||
// Try to decode as base64
|
||||
atob(value);
|
||||
// If it decodes successfully and is long enough, it's likely encrypted
|
||||
return value.length > 30;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Smart encrypt/decrypt that handles both encrypted and unencrypted data
|
||||
export async function smartDecryptUserData(user: any): Promise<any> {
|
||||
if (!user || typeof window === "undefined") return user;
|
||||
|
||||
const decrypted = { ...user };
|
||||
const sensitiveFields = ["first_name", "last_name", "phone_number", "email"];
|
||||
|
||||
for (const field of sensitiveFields) {
|
||||
if (decrypted[field] && typeof decrypted[field] === "string") {
|
||||
if (isEncrypted(decrypted[field])) {
|
||||
try {
|
||||
decrypted[field] = await decryptValue(decrypted[field]);
|
||||
} catch (error) {
|
||||
// Failed to decrypt field, keep original value
|
||||
}
|
||||
}
|
||||
// If not encrypted, keep as-is (backward compatibility)
|
||||
}
|
||||
}
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
@ -50,13 +50,13 @@ export function middleware(request: NextRequest) {
|
||||
// Redirect authenticated users away from auth routes
|
||||
if (isAuthRoute && isAuthenticated) {
|
||||
// Redirect based on user role
|
||||
const redirectPath = isAdmin ? "/admin/booking" : "/user/dashboard";
|
||||
const redirectPath = isAdmin ? "/admin/dashboard" : "/user/dashboard";
|
||||
return NextResponse.redirect(new URL(redirectPath, request.url));
|
||||
}
|
||||
|
||||
// Redirect admin users away from user routes
|
||||
if (isUserRoute && isAuthenticated && isAdmin) {
|
||||
return NextResponse.redirect(new URL("/admin/booking", request.url));
|
||||
return NextResponse.redirect(new URL("/admin/dashboard", request.url));
|
||||
}
|
||||
|
||||
// Redirect non-admin users away from admin routes
|
||||
@ -72,3 +72,4 @@ export const config = {
|
||||
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@ -18,7 +18,6 @@
|
||||
"@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",
|
||||
@ -28,18 +27,14 @@
|
||||
"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",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
|
||||
927
pnpm-lock.yaml
927
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
BIN
public/ss1.png
BIN
public/ss1.png
Binary file not shown.
|
Before Width: | Height: | Size: 718 KiB |
BIN
public/ss2.png
BIN
public/ss2.png
Binary file not shown.
|
Before Width: | Height: | Size: 627 KiB |
BIN
public/ss3.png
BIN
public/ss3.png
Binary file not shown.
|
Before Width: | Height: | Size: 614 KiB |
Loading…
Reference in New Issue
Block a user