Compare commits

..

No commits in common. "master" and "feat/authentication" have entirely different histories.

45 changed files with 1816 additions and 8237 deletions

View File

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

View File

@ -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() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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]);

View File

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

View File

@ -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.
![Admin Dashboard Access - Email Notification for New Appointment Request](/ss2.png)
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.
![Scheduling Appointment - Client Confirmation Email](/ss1.png)
### 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,7 +15,7 @@ export function ClientFocus() {
const ages = [
"Children (0 to 10)",
"Teens",
"Teen",
"Adults",
"Elders (65+)"
];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -220,8 +220,8 @@ export function Services() {
animate={isInView ? { opacity: 1 } : {}}
transition={{ duration: 0.8, delay: 0.9 }}
>
Therapy is tailored for children (age 010), 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 610), 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>

View File

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

View File

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

View File

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

View File

@ -30,7 +30,7 @@ function PopoverContent({
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[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}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)$).*)",
],
};

View File

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

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 718 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 627 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 614 KiB