feat/authentication #21
@ -17,6 +17,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useAppTheme } from "@/components/ThemeProvider";
|
import { useAppTheme } from "@/components/ThemeProvider";
|
||||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@ -25,6 +27,14 @@ export function Header() {
|
|||||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||||
const { theme } = useAppTheme();
|
const { theme } = useAppTheme();
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
|
const { logout } = useAuth();
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
setUserMenuOpen(false);
|
||||||
|
logout();
|
||||||
|
toast.success("Logged out successfully");
|
||||||
|
router.push("/");
|
||||||
|
};
|
||||||
|
|
||||||
// Mock notifications data
|
// Mock notifications data
|
||||||
const notifications = [
|
const notifications = [
|
||||||
@ -209,10 +219,7 @@ export function Header() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={handleLogout}
|
||||||
setUserMenuOpen(false);
|
|
||||||
router.push("/");
|
|
||||||
}}
|
|
||||||
className={`w-full flex items-center gap-3 px-4 py-3 justify-start transition-colors cursor-pointer ${
|
className={`w-full flex items-center gap-3 px-4 py-3 justify-start transition-colors cursor-pointer ${
|
||||||
isDark ? "hover:bg-gray-800" : "hover:bg-gray-50"
|
isDark ? "hover:bg-gray-800" : "hover:bg-gray-50"
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@ -14,6 +14,8 @@ import {
|
|||||||
Heart,
|
Heart,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useAppTheme } from "@/components/ThemeProvider";
|
import { useAppTheme } from "@/components/ThemeProvider";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ label: "Dashboard", icon: LayoutGrid, href: "/admin/dashboard" },
|
{ label: "Dashboard", icon: LayoutGrid, href: "/admin/dashboard" },
|
||||||
@ -26,6 +28,14 @@ export default function SideNav() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { theme } = useAppTheme();
|
const { theme } = useAppTheme();
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
|
const { logout } = useAuth();
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
setOpen(false);
|
||||||
|
logout();
|
||||||
|
toast.success("Logged out successfully");
|
||||||
|
router.push("/");
|
||||||
|
};
|
||||||
|
|
||||||
const getActiveIndex = () => {
|
const getActiveIndex = () => {
|
||||||
return navItems.findIndex((item) => pathname?.includes(item.href)) ?? -1;
|
return navItems.findIndex((item) => pathname?.includes(item.href)) ?? -1;
|
||||||
@ -176,10 +186,7 @@ export default function SideNav() {
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={handleLogout}
|
||||||
setOpen(false);
|
|
||||||
router.push("/");
|
|
||||||
}}
|
|
||||||
className={`group flex items-center gap-2 py-2.5 pl-3 md:pl-3 pr-3 md:pr-3 transition-colors duration-200 w-[90%] md:w-[90%] ml-1 md:ml-2 cursor-pointer justify-start rounded-lg ${
|
className={`group flex items-center gap-2 py-2.5 pl-3 md:pl-3 pr-3 md:pr-3 transition-colors duration-200 w-[90%] md:w-[90%] ml-1 md:ml-2 cursor-pointer justify-start rounded-lg ${
|
||||||
isDark
|
isDark
|
||||||
? "text-gray-300 hover:bg-gray-800 hover:text-rose-300"
|
? "text-gray-300 hover:bg-gray-800 hover:text-rose-300"
|
||||||
|
|||||||
800
app/(admin)/admin/booking/[id]/page.tsx
Normal file
800
app/(admin)/admin/booking/[id]/page.tsx
Normal file
@ -0,0 +1,800 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
User,
|
||||||
|
Video,
|
||||||
|
CalendarCheck,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
ArrowLeft,
|
||||||
|
Mail,
|
||||||
|
Phone as PhoneIcon,
|
||||||
|
MessageSquare,
|
||||||
|
CheckCircle2,
|
||||||
|
ExternalLink,
|
||||||
|
Copy,
|
||||||
|
MapPin,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useAppTheme } from "@/components/ThemeProvider";
|
||||||
|
import { getAppointmentDetail, scheduleAppointment, rejectAppointment } from "@/lib/actions/appointments";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
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";
|
||||||
|
|
||||||
|
export default function AppointmentDetailPage() {
|
||||||
|
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 [scheduleDialogOpen, setScheduleDialogOpen] = useState(false);
|
||||||
|
const [rejectDialogOpen, setRejectDialogOpen] = useState(false);
|
||||||
|
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
|
||||||
|
const [scheduledTime, setScheduledTime] = useState<string>("09:00");
|
||||||
|
const [scheduledDuration, setScheduledDuration] = useState<number>(60);
|
||||||
|
const [rejectionReason, setRejectionReason] = useState<string>("");
|
||||||
|
const [isScheduling, setIsScheduling] = useState(false);
|
||||||
|
const [isRejecting, setIsRejecting] = useState(false);
|
||||||
|
const { theme } = useAppTheme();
|
||||||
|
const isDark = theme === "dark";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAppointment = async () => {
|
||||||
|
if (!appointmentId) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
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 {
|
||||||
|
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 timeSlots = Array.from({ length: 24 }, (_, i) => {
|
||||||
|
const hour = i.toString().padStart(2, "0");
|
||||||
|
return `${hour}:00`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSchedule = async () => {
|
||||||
|
if (!appointment || !scheduledDate) return;
|
||||||
|
|
||||||
|
setIsScheduling(true);
|
||||||
|
try {
|
||||||
|
const dateTime = new Date(scheduledDate);
|
||||||
|
const [hours, minutes] = scheduledTime.split(":").map(Number);
|
||||||
|
dateTime.setHours(hours, minutes, 0, 0);
|
||||||
|
|
||||||
|
await scheduleAppointment(appointment.id, {
|
||||||
|
scheduled_datetime: dateTime.toISOString(),
|
||||||
|
scheduled_duration: scheduledDuration,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Appointment scheduled successfully");
|
||||||
|
setScheduleDialogOpen(false);
|
||||||
|
|
||||||
|
// Refresh appointment data
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReject = async () => {
|
||||||
|
if (!appointment) return;
|
||||||
|
|
||||||
|
setIsRejecting(true);
|
||||||
|
try {
|
||||||
|
await rejectAppointment(appointment.id, {
|
||||||
|
rejection_reason: rejectionReason || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Appointment rejected successfully");
|
||||||
|
setRejectDialogOpen(false);
|
||||||
|
|
||||||
|
// Refresh appointment data
|
||||||
|
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 copyToClipboard = (text: string, label: string) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
toast.success(`${label} copied to clipboard`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!appointment) {
|
||||||
|
return (
|
||||||
|
<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
|
||||||
|
onClick={() => router.push("/admin/booking")}
|
||||||
|
className="bg-rose-600 hover:bg-rose-700 text-white"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Back to Bookings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`min-h-screen ${isDark ? "bg-gray-900" : "bg-gray-50"}`}>
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => router.push("/admin/booking")}
|
||||||
|
className={`flex items-center gap-2 mb-6 ${isDark ? "text-gray-300 hover:bg-gray-800 hover:text-white" : "text-gray-600 hover:bg-gray-100"}`}
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Back to Bookings
|
||||||
|
</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-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-3xl sm:text-4xl font-bold ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||||
|
{appointment.first_name} {appointment.last_name}
|
||||||
|
</h1>
|
||||||
|
<p className={`text-sm mt-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
|
Appointment Request
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={`px-4 py-2 inline-flex items-center gap-2 text-sm font-semibold rounded-full border ${getStatusColor(
|
||||||
|
appointment.status
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{appointment.status === "scheduled" && <CheckCircle2 className="w-4 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">
|
||||||
|
{/* Patient 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"}`} />
|
||||||
|
Patient 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>
|
||||||
|
|
||||||
|
{/* Appointment Details Card */}
|
||||||
|
{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.scheduled_duration} minutes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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 ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||||
|
Preferred Availability
|
||||||
|
</h2>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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 */}
|
||||||
|
{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"}`}>
|
||||||
|
<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.jitsi_room_id && (
|
||||||
|
<div>
|
||||||
|
<p className={`text-xs font-medium mb-2 uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
|
Meeting Room ID
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className={`text-sm font-mono px-3 py-2 rounded-lg ${isDark ? "bg-gray-800 text-gray-200" : "bg-white text-gray-900 border border-gray-200"}`}>
|
||||||
|
{appointment.jitsi_room_id}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className={`text-xs font-medium mb-2 uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
|
Meeting Link
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<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" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
{appointment.status === "pending_review" && (
|
||||||
|
<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 space-y-3">
|
||||||
|
<Button
|
||||||
|
onClick={() => setScheduleDialogOpen(true)}
|
||||||
|
className="w-full bg-blue-600 hover:bg-blue-700 text-white h-12 text-base font-medium"
|
||||||
|
>
|
||||||
|
<CalendarCheck className="w-5 h-5 mr-2" />
|
||||||
|
Schedule Appointment
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setRejectDialogOpen(true)}
|
||||||
|
variant="outline"
|
||||||
|
className={`w-full h-12 text-base font-medium border-red-600 text-red-600 hover:bg-red-50 ${isDark ? "hover:bg-red-900/20" : ""}`}
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 mr-2" />
|
||||||
|
Reject Request
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
{/* 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}>
|
||||||
|
<DialogContent className={`max-w-2xl ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className={`text-2xl font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||||
|
Reject Appointment Request
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className={`text-base ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
|
Reject appointment request from {appointment.first_name} {appointment.last_name}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className={`text-sm font-semibold ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||||
|
Rejection Reason (Optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={rejectionReason}
|
||||||
|
onChange={(e) => setRejectionReason(e.target.value)}
|
||||||
|
placeholder="Enter reason for rejection..."
|
||||||
|
rows={5}
|
||||||
|
className={`w-full rounded-xl border px-4 py-3 text-base ${
|
||||||
|
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"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="gap-3 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setRejectDialogOpen(false)}
|
||||||
|
disabled={isRejecting}
|
||||||
|
className={`h-12 px-6 ${isDark ? "border-gray-700 text-gray-300 hover:bg-gray-700" : ""}`}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleReject}
|
||||||
|
disabled={isRejecting}
|
||||||
|
className="h-12 px-6 bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
>
|
||||||
|
{isRejecting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
|
||||||
|
Rejecting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<X className="w-5 h-5 mr-2" />
|
||||||
|
Reject Appointment
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,111 +1,64 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
Clock,
|
Clock,
|
||||||
User,
|
|
||||||
Video,
|
Video,
|
||||||
FileText,
|
Search,
|
||||||
MoreVertical,
|
CalendarCheck,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useAppTheme } from "@/components/ThemeProvider";
|
import { useAppTheme } from "@/components/ThemeProvider";
|
||||||
|
import { listAppointments, scheduleAppointment, rejectAppointment } from "@/lib/actions/appointments";
|
||||||
interface User {
|
import { Input } from "@/components/ui/input";
|
||||||
ID: number;
|
import { Button } from "@/components/ui/button";
|
||||||
CreatedAt?: string;
|
import {
|
||||||
UpdatedAt?: string;
|
Dialog,
|
||||||
DeletedAt?: string | null;
|
DialogContent,
|
||||||
first_name: string;
|
DialogDescription,
|
||||||
last_name: string;
|
DialogFooter,
|
||||||
email: string;
|
DialogHeader,
|
||||||
phone: string;
|
DialogTitle,
|
||||||
location: string;
|
} from "@/components/ui/dialog";
|
||||||
date_of_birth?: string;
|
import { DatePicker } from "@/components/DatePicker";
|
||||||
is_admin?: boolean;
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
bookings?: null;
|
import { toast } from "sonner";
|
||||||
}
|
import type { Appointment } from "@/lib/models/appointments";
|
||||||
|
|
||||||
interface Booking {
|
|
||||||
ID: number;
|
|
||||||
CreatedAt: string;
|
|
||||||
UpdatedAt: string;
|
|
||||||
DeletedAt: string | null;
|
|
||||||
user_id: number;
|
|
||||||
user: User;
|
|
||||||
scheduled_at: string;
|
|
||||||
duration: number;
|
|
||||||
status: string;
|
|
||||||
jitsi_room_id: string;
|
|
||||||
jitsi_room_url: string;
|
|
||||||
payment_id: string;
|
|
||||||
payment_status: string;
|
|
||||||
amount: number;
|
|
||||||
notes: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BookingsResponse {
|
|
||||||
bookings: Booking[];
|
|
||||||
limit: number;
|
|
||||||
offset: number;
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Booking() {
|
export default function Booking() {
|
||||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
const router = useRouter();
|
||||||
|
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false);
|
||||||
|
const [rejectDialogOpen, setRejectDialogOpen] = useState(false);
|
||||||
|
const [selectedAppointment, setSelectedAppointment] = useState<Appointment | null>(null);
|
||||||
|
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
|
||||||
|
const [scheduledTime, setScheduledTime] = useState<string>("09:00");
|
||||||
|
const [scheduledDuration, setScheduledDuration] = useState<number>(60);
|
||||||
|
const [rejectionReason, setRejectionReason] = useState<string>("");
|
||||||
|
const [isScheduling, setIsScheduling] = useState(false);
|
||||||
|
const [isRejecting, setIsRejecting] = useState(false);
|
||||||
const { theme } = useAppTheme();
|
const { theme } = useAppTheme();
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Simulate API call
|
|
||||||
const fetchBookings = async () => {
|
const fetchBookings = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
try {
|
||||||
|
const data = await listAppointments();
|
||||||
// Mock API response
|
setAppointments(data || []);
|
||||||
const mockData: BookingsResponse = {
|
} catch (error) {
|
||||||
bookings: [
|
console.error("Failed to fetch appointments:", error);
|
||||||
{
|
toast.error("Failed to load appointments. Please try again.");
|
||||||
ID: 1,
|
setAppointments([]);
|
||||||
CreatedAt: "2025-11-06T11:33:45.704633Z",
|
} finally {
|
||||||
UpdatedAt: "2025-11-06T11:33:45.707543Z",
|
|
||||||
DeletedAt: null,
|
|
||||||
user_id: 3,
|
|
||||||
user: {
|
|
||||||
ID: 3,
|
|
||||||
CreatedAt: "2025-11-06T10:43:01.299311Z",
|
|
||||||
UpdatedAt: "2025-11-06T10:43:48.427284Z",
|
|
||||||
DeletedAt: null,
|
|
||||||
first_name: "John",
|
|
||||||
last_name: "Smith",
|
|
||||||
email: "john.doe@example.com",
|
|
||||||
phone: "+1234567891",
|
|
||||||
location: "Los Angeles, CA",
|
|
||||||
date_of_birth: "0001-01-01T00:00:00Z",
|
|
||||||
is_admin: true,
|
|
||||||
bookings: null,
|
|
||||||
},
|
|
||||||
scheduled_at: "2025-11-07T10:00:00Z",
|
|
||||||
duration: 60,
|
|
||||||
status: "scheduled",
|
|
||||||
jitsi_room_id: "booking-1-1762428825-22c92ced2870c17c",
|
|
||||||
jitsi_room_url:
|
|
||||||
"https://meet.jit.si/booking-1-1762428825-22c92ced2870c17c",
|
|
||||||
payment_id: "",
|
|
||||||
payment_status: "pending",
|
|
||||||
amount: 52,
|
|
||||||
notes: "Initial consultation session",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
limit: 50,
|
|
||||||
offset: 0,
|
|
||||||
total: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
setBookings(mockData.bookings);
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchBookings();
|
fetchBookings();
|
||||||
@ -137,8 +90,10 @@ export default function Booking() {
|
|||||||
return "bg-blue-500/20 text-blue-200";
|
return "bg-blue-500/20 text-blue-200";
|
||||||
case "completed":
|
case "completed":
|
||||||
return "bg-green-500/20 text-green-200";
|
return "bg-green-500/20 text-green-200";
|
||||||
|
case "rejected":
|
||||||
case "cancelled":
|
case "cancelled":
|
||||||
return "bg-red-500/20 text-red-200";
|
return "bg-red-500/20 text-red-200";
|
||||||
|
case "pending_review":
|
||||||
case "pending":
|
case "pending":
|
||||||
return "bg-yellow-500/20 text-yellow-200";
|
return "bg-yellow-500/20 text-yellow-200";
|
||||||
default:
|
default:
|
||||||
@ -150,8 +105,10 @@ export default function Booking() {
|
|||||||
return "bg-blue-100 text-blue-700";
|
return "bg-blue-100 text-blue-700";
|
||||||
case "completed":
|
case "completed":
|
||||||
return "bg-green-100 text-green-700";
|
return "bg-green-100 text-green-700";
|
||||||
|
case "rejected":
|
||||||
case "cancelled":
|
case "cancelled":
|
||||||
return "bg-red-100 text-red-700";
|
return "bg-red-100 text-red-700";
|
||||||
|
case "pending_review":
|
||||||
case "pending":
|
case "pending":
|
||||||
return "bg-yellow-100 text-yellow-700";
|
return "bg-yellow-100 text-yellow-700";
|
||||||
default:
|
default:
|
||||||
@ -159,41 +116,107 @@ export default function Booking() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPaymentStatusColor = (status: string) => {
|
const formatStatus = (status: string) => {
|
||||||
const normalized = status.toLowerCase();
|
return status.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase());
|
||||||
if (isDark) {
|
};
|
||||||
switch (normalized) {
|
|
||||||
case "paid":
|
const handleViewDetails = (appointment: Appointment) => {
|
||||||
return "bg-green-500/20 text-green-200";
|
router.push(`/admin/booking/${appointment.id}`);
|
||||||
case "pending":
|
};
|
||||||
return "bg-yellow-500/20 text-yellow-200";
|
|
||||||
case "failed":
|
const handleScheduleClick = (appointment: Appointment) => {
|
||||||
return "bg-red-500/20 text-red-200";
|
setSelectedAppointment(appointment);
|
||||||
default:
|
setScheduledDate(undefined);
|
||||||
return "bg-gray-700 text-gray-200";
|
setScheduledTime("09:00");
|
||||||
|
setScheduledDuration(60);
|
||||||
|
setScheduleDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRejectClick = (appointment: Appointment) => {
|
||||||
|
setSelectedAppointment(appointment);
|
||||||
|
setRejectionReason("");
|
||||||
|
setRejectDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSchedule = async () => {
|
||||||
|
if (!selectedAppointment || !scheduledDate) {
|
||||||
|
toast.error("Please select a date and time");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
switch (normalized) {
|
setIsScheduling(true);
|
||||||
case "paid":
|
try {
|
||||||
return "bg-green-100 text-green-700";
|
// Combine date and time into ISO datetime string
|
||||||
case "pending":
|
const [hours, minutes] = scheduledTime.split(":");
|
||||||
return "bg-yellow-100 text-yellow-700";
|
const datetime = new Date(scheduledDate);
|
||||||
case "failed":
|
datetime.setHours(parseInt(hours), parseInt(minutes), 0, 0);
|
||||||
return "bg-red-100 text-red-700";
|
const isoString = datetime.toISOString();
|
||||||
default:
|
|
||||||
return "bg-gray-100 text-gray-700";
|
await scheduleAppointment(selectedAppointment.id, {
|
||||||
|
scheduled_datetime: isoString,
|
||||||
|
scheduled_duration: scheduledDuration,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Appointment scheduled successfully!");
|
||||||
|
setScheduleDialogOpen(false);
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
setIsScheduling(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredBookings = bookings.filter(
|
const handleReject = async () => {
|
||||||
(booking) =>
|
if (!selectedAppointment) {
|
||||||
booking.user.first_name
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRejecting(true);
|
||||||
|
try {
|
||||||
|
await rejectAppointment(selectedAppointment.id, {
|
||||||
|
rejection_reason: rejectionReason || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Appointment rejected successfully");
|
||||||
|
setRejectDialogOpen(false);
|
||||||
|
|
||||||
|
// Refresh appointments list
|
||||||
|
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 {
|
||||||
|
setIsRejecting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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) =>
|
||||||
|
appointment.first_name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(searchTerm.toLowerCase()) ||
|
.includes(searchTerm.toLowerCase()) ||
|
||||||
booking.user.last_name
|
appointment.last_name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(searchTerm.toLowerCase()) ||
|
.includes(searchTerm.toLowerCase()) ||
|
||||||
booking.user.email.toLowerCase().includes(searchTerm.toLowerCase())
|
appointment.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
(appointment.phone && appointment.phone.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -202,7 +225,8 @@ export default function Booking() {
|
|||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="p-3 sm:p-4 md:p-6 lg:p-8">
|
<main className="p-3 sm:p-4 md:p-6 lg:p-8">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
<div className="mb-4 sm:mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
<div className="mb-4 sm:mb-6 flex flex-col gap-3 sm:gap-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className={`text-xl sm:text-2xl font-semibold mb-1 ${isDark ? "text-white" : "text-gray-900"}`}>
|
<h1 className={`text-xl sm:text-2xl font-semibold mb-1 ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||||
Bookings
|
Bookings
|
||||||
@ -211,25 +235,32 @@ export default function Booking() {
|
|||||||
Manage and view all appointment bookings
|
Manage and view all appointment bookings
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button className={`w-full sm:w-auto px-3 sm:px-4 py-2 rounded-lg text-xs sm:text-sm font-medium transition-colors ${
|
</div>
|
||||||
isDark ? "bg-rose-500 text-white hover:bg-rose-600" : "bg-gray-900 text-white hover:bg-gray-800"
|
{/* Search Bar */}
|
||||||
}`}>
|
<div className="relative">
|
||||||
+ New Booking
|
<Search className={`absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 ${isDark ? "text-gray-400" : "text-gray-500"}`} />
|
||||||
</button>
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by name, email, or phone..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className={`pl-10 ${isDark ? "bg-gray-800 border-gray-700 text-white placeholder:text-gray-400" : "bg-white border-gray-200 text-gray-900 placeholder:text-gray-500"}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<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>
|
<div className={`animate-spin rounded-full h-8 w-8 border-b-2 ${isDark ? "border-gray-600" : "border-gray-400"}`}></div>
|
||||||
</div>
|
</div>
|
||||||
) : filteredBookings.length === 0 ? (
|
) : filteredAppointments.length === 0 ? (
|
||||||
<div className={`rounded-lg border p-12 text-center ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
<div className={`rounded-lg border p-12 text-center ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
||||||
<Calendar className={`w-12 h-12 mx-auto mb-4 ${isDark ? "text-gray-500" : "text-gray-400"}`} />
|
<Calendar className={`w-12 h-12 mx-auto mb-4 ${isDark ? "text-gray-500" : "text-gray-400"}`} />
|
||||||
<p className={`font-medium mb-1 ${isDark ? "text-gray-200" : "text-gray-600"}`}>No bookings found</p>
|
<p className={`font-medium mb-1 ${isDark ? "text-gray-200" : "text-gray-600"}`}>No bookings found</p>
|
||||||
<p className={`text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
<p className={`text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
{searchTerm
|
{searchTerm
|
||||||
? "Try adjusting your search terms"
|
? "Try adjusting your search terms"
|
||||||
: "Create a new booking to get started"}
|
: "No appointments have been created yet"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -251,10 +282,10 @@ export default function Booking() {
|
|||||||
Status
|
Status
|
||||||
</th>
|
</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"}`}>
|
<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"}`}>
|
||||||
Payment
|
Preferred Dates
|
||||||
</th>
|
</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"}`}>
|
<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"}`}>
|
||||||
Amount
|
Created
|
||||||
</th>
|
</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"}`}>
|
<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
|
Actions
|
||||||
@ -262,10 +293,11 @@ export default function Booking() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className={`${isDark ? "bg-gray-800 divide-gray-700" : "bg-white divide-gray-200"}`}>
|
<tbody className={`${isDark ? "bg-gray-800 divide-gray-700" : "bg-white divide-gray-200"}`}>
|
||||||
{filteredBookings.map((booking) => (
|
{filteredAppointments.map((appointment) => (
|
||||||
<tr
|
<tr
|
||||||
key={booking.ID}
|
key={appointment.id}
|
||||||
className={`transition-colors ${isDark ? "hover:bg-gray-700" : "hover:bg-gray-50"}`}
|
className={`transition-colors cursor-pointer ${isDark ? "hover:bg-gray-700" : "hover:bg-gray-50"}`}
|
||||||
|
onClick={() => handleViewDetails(appointment)}
|
||||||
>
|
>
|
||||||
<td className="px-3 sm:px-4 md:px-6 py-4">
|
<td className="px-3 sm:px-4 md:px-6 py-4">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@ -274,55 +306,103 @@ export default function Booking() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="ml-2 sm:ml-4 min-w-0">
|
<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"}`}>
|
<div className={`text-xs sm:text-sm font-medium truncate ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||||
{booking.user.first_name} {booking.user.last_name}
|
{appointment.first_name} {appointment.last_name}
|
||||||
</div>
|
</div>
|
||||||
<div className={`text-xs sm:text-sm truncate hidden sm:block ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
<div className={`text-xs sm:text-sm truncate hidden sm:block ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
{booking.user.email}
|
{appointment.email}
|
||||||
</div>
|
</div>
|
||||||
|
{appointment.phone && (
|
||||||
|
<div className={`text-xs truncate hidden sm:block ${isDark ? "text-gray-500" : "text-gray-400"}`}>
|
||||||
|
{appointment.phone}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{appointment.scheduled_datetime && (
|
||||||
<div className={`text-xs sm:hidden mt-0.5 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
<div className={`text-xs sm:hidden mt-0.5 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
{formatDate(booking.scheduled_at)}
|
{formatDate(appointment.scheduled_datetime)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap hidden md:table-cell">
|
<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"}`}>
|
<div className={`text-xs sm:text-sm ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||||
{formatDate(booking.scheduled_at)}
|
{formatDate(appointment.scheduled_datetime)}
|
||||||
</div>
|
</div>
|
||||||
<div className={`text-xs sm:text-sm flex items-center gap-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
<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" />
|
<Clock className="w-3 h-3" />
|
||||||
{formatTime(booking.scheduled_at)}
|
{formatTime(appointment.scheduled_datetime)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className={`text-xs sm:text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
|
Not scheduled
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</td>
|
</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"}`}>
|
<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"}`}>
|
||||||
{booking.duration} min
|
{appointment.scheduled_duration ? `${appointment.scheduled_duration} min` : "-"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap">
|
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap">
|
||||||
<span
|
<span
|
||||||
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(
|
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(
|
||||||
booking.status
|
appointment.status
|
||||||
)}`}
|
)}`}
|
||||||
>
|
>
|
||||||
{booking.status}
|
{formatStatus(appointment.status)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap hidden lg:table-cell">
|
<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"}`}>
|
||||||
<span
|
{appointment.preferred_dates && appointment.preferred_dates.length > 0 ? (
|
||||||
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getPaymentStatusColor(
|
<div className="flex flex-col gap-1">
|
||||||
booking.payment_status
|
{appointment.preferred_dates.slice(0, 2).map((date, idx) => (
|
||||||
)}`}
|
<span key={idx}>{formatDate(date)}</span>
|
||||||
>
|
))}
|
||||||
{booking.payment_status}
|
{appointment.preferred_dates.length > 2 && (
|
||||||
</span>
|
<span className="text-xs">+{appointment.preferred_dates.length - 2} more</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm font-medium hidden xl:table-cell ${isDark ? "text-white" : "text-gray-900"}`}>
|
<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"}`}>
|
||||||
${booking.amount}
|
{formatDate(appointment.created_at)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<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">
|
<div className="flex items-center justify-end gap-1 sm:gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
{booking.jitsi_room_url && (
|
{appointment.status === "pending_review" && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleScheduleClick(appointment)}
|
||||||
|
className={`px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-lg transition-colors ${
|
||||||
|
isDark
|
||||||
|
? "bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
|
: "bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
|
}`}
|
||||||
|
title="Schedule Appointment"
|
||||||
|
>
|
||||||
|
<span className="hidden sm:inline">Schedule</span>
|
||||||
|
<CalendarCheck className="w-4 h-4 sm:hidden" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRejectClick(appointment)}
|
||||||
|
className={`px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-lg transition-colors ${
|
||||||
|
isDark
|
||||||
|
? "bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
: "bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
}`}
|
||||||
|
title="Reject Appointment"
|
||||||
|
>
|
||||||
|
<span className="hidden sm:inline">Reject</span>
|
||||||
|
<X className="w-4 h-4 sm:hidden" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{appointment.jitsi_meet_url && (
|
||||||
<a
|
<a
|
||||||
href={booking.jitsi_room_url}
|
href={appointment.jitsi_meet_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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"}`}
|
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"}`}
|
||||||
@ -331,17 +411,6 @@ export default function Booking() {
|
|||||||
<Video className="w-4 h-4" />
|
<Video className="w-4 h-4" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{booking.notes && (
|
|
||||||
<button
|
|
||||||
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="View Notes"
|
|
||||||
>
|
|
||||||
<FileText className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button 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"}`}>
|
|
||||||
<MoreVertical className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -352,6 +421,166 @@ export default function Booking() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Schedule Appointment Dialog */}
|
||||||
|
<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}>
|
||||||
|
<DialogContent className={`${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className={isDark ? "text-white" : "text-gray-900"}>
|
||||||
|
Reject Appointment
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className={isDark ? "text-gray-400" : "text-gray-500"}>
|
||||||
|
{selectedAppointment && (
|
||||||
|
<>Reject appointment request from {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"}`}>
|
||||||
|
Rejection Reason (Optional)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={rejectionReason}
|
||||||
|
onChange={(e) => setRejectionReason(e.target.value)}
|
||||||
|
placeholder="Enter reason for rejection..."
|
||||||
|
rows={4}
|
||||||
|
className={`w-full rounded-md border px-3 py-2 text-sm ${
|
||||||
|
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"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setRejectDialogOpen(false)}
|
||||||
|
disabled={isRejecting}
|
||||||
|
className={isDark ? "border-gray-700 text-gray-300 hover:bg-gray-700" : ""}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleReject}
|
||||||
|
disabled={isRejecting}
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
>
|
||||||
|
{isRejecting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Rejecting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Reject Appointment"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,11 @@ import {
|
|||||||
ArrowDownRight,
|
ArrowDownRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useAppTheme } from "@/components/ThemeProvider";
|
import { useAppTheme } from "@/components/ThemeProvider";
|
||||||
|
import { getAllUsers } from "@/lib/actions/auth";
|
||||||
|
import { getAppointmentStats, listAppointments } from "@/lib/actions/appointments";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type { User } from "@/lib/models/auth";
|
||||||
|
import type { Appointment } from "@/lib/models/appointments";
|
||||||
|
|
||||||
interface DashboardStats {
|
interface DashboardStats {
|
||||||
total_users: number;
|
total_users: number;
|
||||||
@ -30,6 +35,16 @@ interface DashboardStats {
|
|||||||
cancelled_bookings: number;
|
cancelled_bookings: number;
|
||||||
total_revenue: number;
|
total_revenue: number;
|
||||||
monthly_revenue: number;
|
monthly_revenue: number;
|
||||||
|
trends: {
|
||||||
|
total_users: string;
|
||||||
|
active_users: string;
|
||||||
|
total_bookings: string;
|
||||||
|
upcoming_bookings: string;
|
||||||
|
completed_bookings: string;
|
||||||
|
cancelled_bookings: string;
|
||||||
|
total_revenue: string;
|
||||||
|
monthly_revenue: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
@ -40,86 +55,166 @@ export default function Dashboard() {
|
|||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Simulate API call
|
|
||||||
const fetchStats = async () => {
|
const fetchStats = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
// Simulate network delay
|
try {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
// Fetch all data in parallel
|
||||||
|
const [users, appointmentStats, appointments] = await Promise.all([
|
||||||
|
getAllUsers().catch(() => [] as User[]),
|
||||||
|
getAppointmentStats().catch(() => null),
|
||||||
|
listAppointments().catch(() => [] as Appointment[]),
|
||||||
|
]);
|
||||||
|
|
||||||
// Mock API response
|
// Calculate statistics
|
||||||
const mockData: DashboardStats = {
|
// Use users count from appointment stats if available, otherwise use getAllUsers result
|
||||||
total_users: 3,
|
const totalUsers = appointmentStats?.users ?? users.length;
|
||||||
active_users: 3,
|
const activeUsers = users.filter(
|
||||||
total_bookings: 6,
|
(user) => user.is_active === true || user.isActive === true
|
||||||
upcoming_bookings: 6,
|
).length;
|
||||||
|
|
||||||
|
const totalBookings = appointmentStats?.total_requests || appointments.length;
|
||||||
|
const upcomingBookings = appointmentStats?.scheduled ||
|
||||||
|
appointments.filter((apt) => apt.status === "scheduled").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;
|
||||||
|
|
||||||
|
// Calculate revenue (assuming appointments have amount field, defaulting to 0)
|
||||||
|
const now = new Date();
|
||||||
|
const currentMonth = now.getMonth();
|
||||||
|
const currentYear = now.getFullYear();
|
||||||
|
|
||||||
|
const totalRevenue = appointments.reduce((sum, apt) => {
|
||||||
|
// If appointment has amount field, use it, otherwise default to 0
|
||||||
|
const amount = (apt as any).amount || 0;
|
||||||
|
return sum + amount;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const monthlyRevenue = appointments
|
||||||
|
.filter((apt) => {
|
||||||
|
if (!apt.scheduled_datetime) return false;
|
||||||
|
const aptDate = new Date(apt.scheduled_datetime);
|
||||||
|
return (
|
||||||
|
aptDate.getMonth() === currentMonth &&
|
||||||
|
aptDate.getFullYear() === currentYear
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.reduce((sum, apt) => {
|
||||||
|
const amount = (apt as any).amount || 0;
|
||||||
|
return sum + amount;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// For now, use static trends (in a real app, you'd calculate these from historical data)
|
||||||
|
const trends = {
|
||||||
|
total_users: "+12%",
|
||||||
|
active_users: "+8%",
|
||||||
|
total_bookings: "+24%",
|
||||||
|
upcoming_bookings: "+6",
|
||||||
|
completed_bookings: "0%",
|
||||||
|
cancelled_bookings: "0%",
|
||||||
|
total_revenue: "+18%",
|
||||||
|
monthly_revenue: "+32%",
|
||||||
|
};
|
||||||
|
|
||||||
|
setStats({
|
||||||
|
total_users: totalUsers,
|
||||||
|
active_users: activeUsers,
|
||||||
|
total_bookings: totalBookings,
|
||||||
|
upcoming_bookings: upcomingBookings,
|
||||||
|
completed_bookings: completedBookings,
|
||||||
|
cancelled_bookings: cancelledBookings,
|
||||||
|
total_revenue: totalRevenue,
|
||||||
|
monthly_revenue: monthlyRevenue,
|
||||||
|
trends,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch dashboard stats:", error);
|
||||||
|
toast.error("Failed to load dashboard statistics");
|
||||||
|
// Set default values on error
|
||||||
|
setStats({
|
||||||
|
total_users: 0,
|
||||||
|
active_users: 0,
|
||||||
|
total_bookings: 0,
|
||||||
|
upcoming_bookings: 0,
|
||||||
completed_bookings: 0,
|
completed_bookings: 0,
|
||||||
cancelled_bookings: 0,
|
cancelled_bookings: 0,
|
||||||
total_revenue: 0,
|
total_revenue: 0,
|
||||||
monthly_revenue: 0,
|
monthly_revenue: 0,
|
||||||
};
|
trends: {
|
||||||
|
total_users: "0%",
|
||||||
setStats(mockData);
|
active_users: "0%",
|
||||||
|
total_bookings: "0%",
|
||||||
|
upcoming_bookings: "0",
|
||||||
|
completed_bookings: "0%",
|
||||||
|
cancelled_bookings: "0%",
|
||||||
|
total_revenue: "0%",
|
||||||
|
monthly_revenue: "0%",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fetchStats();
|
fetchStats();
|
||||||
}, []);
|
}, [timePeriod]);
|
||||||
|
|
||||||
const statCards = [
|
const statCards = [
|
||||||
{
|
{
|
||||||
title: "Total Users",
|
title: "Total Users",
|
||||||
value: stats?.total_users ?? 0,
|
value: stats?.total_users ?? 0,
|
||||||
icon: Users,
|
icon: Users,
|
||||||
trend: "+12%",
|
trend: stats?.trends.total_users ?? "0%",
|
||||||
trendUp: true,
|
trendUp: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Active Users",
|
title: "Active Users",
|
||||||
value: stats?.active_users ?? 0,
|
value: stats?.active_users ?? 0,
|
||||||
icon: UserCheck,
|
icon: UserCheck,
|
||||||
trend: "+8%",
|
trend: stats?.trends.active_users ?? "0%",
|
||||||
trendUp: true,
|
trendUp: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Total Bookings",
|
title: "Total Bookings",
|
||||||
value: stats?.total_bookings ?? 0,
|
value: stats?.total_bookings ?? 0,
|
||||||
icon: Calendar,
|
icon: Calendar,
|
||||||
trend: "+24%",
|
trend: stats?.trends.total_bookings ?? "0%",
|
||||||
trendUp: true,
|
trendUp: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Upcoming Bookings",
|
title: "Upcoming Bookings",
|
||||||
value: stats?.upcoming_bookings ?? 0,
|
value: stats?.upcoming_bookings ?? 0,
|
||||||
icon: CalendarCheck,
|
icon: CalendarCheck,
|
||||||
trend: "+6",
|
trend: stats?.trends.upcoming_bookings ?? "0",
|
||||||
trendUp: true,
|
trendUp: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Completed Bookings",
|
title: "Completed Bookings",
|
||||||
value: stats?.completed_bookings ?? 0,
|
value: stats?.completed_bookings ?? 0,
|
||||||
icon: CalendarCheck,
|
icon: CalendarCheck,
|
||||||
trend: "0%",
|
trend: stats?.trends.completed_bookings ?? "0%",
|
||||||
trendUp: true,
|
trendUp: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Cancelled Bookings",
|
title: "Cancelled Bookings",
|
||||||
value: stats?.cancelled_bookings ?? 0,
|
value: stats?.cancelled_bookings ?? 0,
|
||||||
icon: CalendarX,
|
icon: CalendarX,
|
||||||
trend: "0%",
|
trend: stats?.trends.cancelled_bookings ?? "0%",
|
||||||
trendUp: false,
|
trendUp: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Total Revenue",
|
title: "Total Revenue",
|
||||||
value: `$${stats?.total_revenue.toLocaleString() ?? 0}`,
|
value: `$${stats?.total_revenue.toLocaleString() ?? 0}`,
|
||||||
icon: DollarSign,
|
icon: DollarSign,
|
||||||
trend: "+18%",
|
trend: stats?.trends.total_revenue ?? "0%",
|
||||||
trendUp: true,
|
trendUp: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Monthly Revenue",
|
title: "Monthly Revenue",
|
||||||
value: `$${stats?.monthly_revenue.toLocaleString() ?? 0}`,
|
value: `$${stats?.monthly_revenue.toLocaleString() ?? 0}`,
|
||||||
icon: TrendingUp,
|
icon: TrendingUp,
|
||||||
trend: "+32%",
|
trend: stats?.trends.monthly_revenue ?? "0%",
|
||||||
trendUp: true,
|
trendUp: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -1,20 +1,349 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useEffect, Suspense } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Heart, Eye, EyeOff, X } from "lucide-react";
|
import {
|
||||||
import Link from "next/link";
|
InputOTP,
|
||||||
|
InputOTPGroup,
|
||||||
|
InputOTPSlot,
|
||||||
|
} from "@/components/ui/input-otp";
|
||||||
|
import { Heart, Eye, EyeOff, X, Loader2, CheckCircle2 } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/navigation";
|
import Link from "next/link";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useAppTheme } from "@/components/ThemeProvider";
|
import { useAppTheme } from "@/components/ThemeProvider";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import {
|
||||||
|
loginSchema,
|
||||||
|
registerSchema,
|
||||||
|
verifyOtpSchema,
|
||||||
|
type LoginInput,
|
||||||
|
type RegisterInput,
|
||||||
|
type VerifyOtpInput
|
||||||
|
} from "@/lib/schema/auth";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export default function Login() {
|
type Step = "login" | "signup" | "verify";
|
||||||
|
|
||||||
|
function LoginContent() {
|
||||||
const { theme } = useAppTheme();
|
const { theme } = useAppTheme();
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
|
const [step, setStep] = useState<Step>("login");
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showPassword2, setShowPassword2] = useState(false);
|
||||||
const [rememberMe, setRememberMe] = useState(false);
|
const [rememberMe, setRememberMe] = useState(false);
|
||||||
|
const [registeredEmail, setRegisteredEmail] = useState("");
|
||||||
|
|
||||||
|
// Login form data
|
||||||
|
const [loginData, setLoginData] = useState<LoginInput>({
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Signup form data
|
||||||
|
const [signupData, setSignupData] = useState<RegisterInput>({
|
||||||
|
first_name: "",
|
||||||
|
last_name: "",
|
||||||
|
email: "",
|
||||||
|
phone_number: "",
|
||||||
|
password: "",
|
||||||
|
password2: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// OTP verification data
|
||||||
|
const [otpData, setOtpData] = useState<VerifyOtpInput>({
|
||||||
|
email: "",
|
||||||
|
otp: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState<Partial<Record<string, string>>>({});
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const {
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
verifyOtp,
|
||||||
|
isAuthenticated,
|
||||||
|
isAdmin,
|
||||||
|
loginMutation,
|
||||||
|
registerMutation,
|
||||||
|
verifyOtpMutation,
|
||||||
|
resendOtpMutation
|
||||||
|
} = useAuth();
|
||||||
|
|
||||||
|
// Check for verify step or email from query parameters
|
||||||
|
useEffect(() => {
|
||||||
|
const verifyEmail = searchParams.get("verify");
|
||||||
|
const emailParam = searchParams.get("email");
|
||||||
|
const errorParam = searchParams.get("error");
|
||||||
|
|
||||||
|
// Don't show verify step if there's an error indicating OTP sending failed
|
||||||
|
if (errorParam && errorParam.toLowerCase().includes("failed to send")) {
|
||||||
|
setStep("login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verifyEmail === "true" && emailParam) {
|
||||||
|
// Show verify step if verify=true
|
||||||
|
setStep("verify");
|
||||||
|
setRegisteredEmail(emailParam);
|
||||||
|
setOtpData({ email: emailParam, otp: "" });
|
||||||
|
} else if (emailParam && step === "login") {
|
||||||
|
// Pre-fill email in login form if email parameter is present
|
||||||
|
setLoginData(prev => ({ ...prev, email: emailParam }));
|
||||||
|
}
|
||||||
|
}, [searchParams, step]);
|
||||||
|
|
||||||
|
// Redirect if already authenticated
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
// Use a small delay to ensure cookies are set and middleware has processed
|
||||||
|
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/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;
|
||||||
|
}, 200);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, isAdmin, searchParams]);
|
||||||
|
|
||||||
|
// Handle login
|
||||||
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setErrors({});
|
||||||
|
|
||||||
|
// Validate form
|
||||||
|
const validation = loginSchema.safeParse(loginData);
|
||||||
|
if (!validation.success) {
|
||||||
|
const fieldErrors: Partial<Record<string, string>> = {};
|
||||||
|
validation.error.issues.forEach((err) => {
|
||||||
|
if (err.path[0]) {
|
||||||
|
fieldErrors[err.path[0] as string] = err.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setErrors(fieldErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await login(loginData);
|
||||||
|
|
||||||
|
if (result.tokens && result.user) {
|
||||||
|
toast.success("Login successful!");
|
||||||
|
// 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 userIsAdmin =
|
||||||
|
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 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/dashboard" : (redirectParam || defaultRedirect);
|
||||||
|
|
||||||
|
// Use window.location.href instead of router.push to ensure full page reload
|
||||||
|
// This ensures cookies are read correctly by middleware
|
||||||
|
window.location.href = finalRedirect;
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Login failed. Please try again.";
|
||||||
|
toast.error(errorMessage);
|
||||||
|
setErrors({});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle signup
|
||||||
|
const handleSignup = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setErrors({});
|
||||||
|
|
||||||
|
// Validate form
|
||||||
|
const validation = registerSchema.safeParse(signupData);
|
||||||
|
if (!validation.success) {
|
||||||
|
const fieldErrors: Partial<Record<string, string>> = {};
|
||||||
|
validation.error.issues.forEach((err) => {
|
||||||
|
if (err.path[0]) {
|
||||||
|
fieldErrors[err.path[0] as string] = err.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setErrors(fieldErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await register(signupData);
|
||||||
|
|
||||||
|
// Check if registration was successful (user created)
|
||||||
|
// Even if OTP sending failed, we should allow user to proceed to verification
|
||||||
|
// and use resend OTP feature
|
||||||
|
if (result && result.message) {
|
||||||
|
// Registration successful - proceed to OTP verification
|
||||||
|
toast.success("Registration successful! Please check your email for OTP verification.");
|
||||||
|
setRegisteredEmail(signupData.email);
|
||||||
|
setOtpData({ email: signupData.email, otp: "" });
|
||||||
|
setStep("verify");
|
||||||
|
} else {
|
||||||
|
// If no message but no error, still proceed (some APIs might not return message)
|
||||||
|
toast.success("Registration successful! Please check your email for OTP verification.");
|
||||||
|
setRegisteredEmail(signupData.email);
|
||||||
|
setOtpData({ email: signupData.email, otp: "" });
|
||||||
|
setStep("verify");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Handle different types of errors
|
||||||
|
let errorMessage = "Registration failed. Please try again.";
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
errorMessage = error.message;
|
||||||
|
|
||||||
|
// If OTP sending failed, don't show OTP verification - just show error
|
||||||
|
if (errorMessage.toLowerCase().includes("failed to send") ||
|
||||||
|
errorMessage.toLowerCase().includes("failed to send otp")) {
|
||||||
|
toast.error("Registration failed: OTP could not be sent. Please try again later or contact support.");
|
||||||
|
setErrors({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's an OTP sending error but registration might have succeeded
|
||||||
|
if (errorMessage.toLowerCase().includes("otp") ||
|
||||||
|
errorMessage.toLowerCase().includes("email") ||
|
||||||
|
errorMessage.toLowerCase().includes("send")) {
|
||||||
|
// If OTP sending failed but user might be created, allow proceeding to verification
|
||||||
|
// User can use resend OTP
|
||||||
|
toast.warning("Registration completed, but OTP email could not be sent. You can request a new OTP on the next screen.");
|
||||||
|
setRegisteredEmail(signupData.email);
|
||||||
|
setOtpData({ email: signupData.email, otp: "" });
|
||||||
|
setStep("verify");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error(errorMessage);
|
||||||
|
setErrors({});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle OTP verification
|
||||||
|
const handleVerifyOtp = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setErrors({});
|
||||||
|
|
||||||
|
// Use registeredEmail if available, otherwise use otpData.email
|
||||||
|
const emailToVerify = registeredEmail || otpData.email;
|
||||||
|
if (!emailToVerify) {
|
||||||
|
setErrors({ email: "Email address is required" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare OTP data with email
|
||||||
|
const otpToVerify = {
|
||||||
|
email: emailToVerify,
|
||||||
|
otp: otpData.otp,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate OTP
|
||||||
|
const validation = verifyOtpSchema.safeParse(otpToVerify);
|
||||||
|
if (!validation.success) {
|
||||||
|
const fieldErrors: Partial<Record<string, string>> = {};
|
||||||
|
validation.error.issues.forEach((err) => {
|
||||||
|
if (err.path[0]) {
|
||||||
|
fieldErrors[err.path[0] as string] = err.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setErrors(fieldErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await verifyOtp(otpToVerify);
|
||||||
|
|
||||||
|
// If verification is successful, switch to login step
|
||||||
|
toast.success("Email verified successfully! You can now login.");
|
||||||
|
// Switch to login step and pre-fill email
|
||||||
|
setStep("login");
|
||||||
|
setLoginData(prev => ({ ...prev, email: emailToVerify }));
|
||||||
|
setOtpData({ email: "", otp: "" });
|
||||||
|
setRegisteredEmail("");
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "OTP verification failed. Please try again.";
|
||||||
|
toast.error(errorMessage);
|
||||||
|
setErrors({});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle resend OTP
|
||||||
|
const handleResendOtp = async () => {
|
||||||
|
const emailToUse = registeredEmail || otpData.email;
|
||||||
|
|
||||||
|
if (!emailToUse) {
|
||||||
|
toast.error("Email address is required to resend OTP.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await resendOtpMutation.mutateAsync({ email: emailToUse, context: "registration" });
|
||||||
|
toast.success("OTP resent successfully! Please check your email.");
|
||||||
|
// Update registeredEmail if it wasn't set
|
||||||
|
if (!registeredEmail) {
|
||||||
|
setRegisteredEmail(emailToUse);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
let errorMessage = "Failed to resend OTP. Please try again.";
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
errorMessage = error.message;
|
||||||
|
|
||||||
|
// Provide more helpful error messages
|
||||||
|
if (errorMessage.toLowerCase().includes("ssl") ||
|
||||||
|
errorMessage.toLowerCase().includes("certificate")) {
|
||||||
|
errorMessage = "Email service is currently unavailable. Please contact support or try again later.";
|
||||||
|
} else if (errorMessage.toLowerCase().includes("not found") ||
|
||||||
|
errorMessage.toLowerCase().includes("does not exist")) {
|
||||||
|
errorMessage = "Email address not found. Please check your email or register again.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle form field changes
|
||||||
|
const handleLoginChange = (field: keyof LoginInput, value: string) => {
|
||||||
|
setLoginData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
if (errors[field]) {
|
||||||
|
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSignupChange = (field: keyof RegisterInput, value: string) => {
|
||||||
|
setSignupData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
if (errors[field]) {
|
||||||
|
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOtpChange = (field: keyof VerifyOtpInput, value: string) => {
|
||||||
|
setOtpData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
if (errors[field]) {
|
||||||
|
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen relative flex items-center justify-center px-4 py-12">
|
<div className="min-h-screen relative flex items-center justify-center px-4 py-12">
|
||||||
@ -38,31 +367,58 @@ export default function Login() {
|
|||||||
<span className="text-white text-xl font-semibold">Attune Heart Therapy</span>
|
<span className="text-white text-xl font-semibold">Attune Heart Therapy</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Centered White Card */}
|
||||||
|
|
||||||
{/* Centered White Card - Login Form */}
|
|
||||||
<div className={`relative z-20 w-full max-w-md rounded-2xl shadow-2xl p-8 ${isDark ? 'bg-gray-800 border border-gray-700' : 'bg-white'}`}>
|
<div className={`relative z-20 w-full max-w-md rounded-2xl shadow-2xl p-8 ${isDark ? 'bg-gray-800 border border-gray-700' : 'bg-white'}`}>
|
||||||
{/* Header with Close Button */}
|
{/* Header with Close Button */}
|
||||||
<div className="flex items-start justify-between mb-2">
|
<div className="flex items-start justify-between mb-2">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
{/* Heading */}
|
{/* Heading */}
|
||||||
<h1 className="text-3xl font-bold bg-gradient-to-r from-rose-600 via-pink-600 to-rose-600 bg-clip-text text-transparent mb-2">
|
<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">
|
||||||
Welcome back
|
{step === "login" && "Welcome back"}
|
||||||
|
{step === "signup" && "Create an account"}
|
||||||
|
{step === "verify" && "Verify your email"}
|
||||||
</h1>
|
</h1>
|
||||||
{/* Sign Up Prompt */}
|
{/* Subtitle */}
|
||||||
|
{step === "login" && (
|
||||||
<p className={`mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
<p className={`mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||||
New to Attune Heart Therapy?{" "}
|
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'}`}>
|
<Link
|
||||||
|
href="/signup"
|
||||||
|
className={`underline font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
|
||||||
|
>
|
||||||
Sign up
|
Sign up
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</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>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{step === "verify" && !registeredEmail && (
|
||||||
|
<p className={`mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||||
|
Enter the verification code sent to your email
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Close Button */}
|
{/* Close Button */}
|
||||||
<Button
|
<Button
|
||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
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'}`}
|
className={`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"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
@ -70,10 +426,8 @@ export default function Login() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Login Form */}
|
{/* Login Form */}
|
||||||
<form className="space-y-6" onSubmit={(e) => {
|
{step === "login" && (
|
||||||
e.preventDefault();
|
<form className="space-y-6" onSubmit={handleLogin}>
|
||||||
router.push("/");
|
|
||||||
}}>
|
|
||||||
{/* Email Field */}
|
{/* Email Field */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
<label htmlFor="email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||||
@ -83,9 +437,14 @@ export default function Login() {
|
|||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="Email address"
|
placeholder="Email address"
|
||||||
className={`h-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
value={loginData.email}
|
||||||
|
onChange={(e) => handleLoginChange("email", e.target.value)}
|
||||||
|
className={`h-12 ${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
|
required
|
||||||
/>
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="text-sm text-red-500">{errors.email}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Password Field */}
|
{/* Password Field */}
|
||||||
@ -98,7 +457,9 @@ export default function Login() {
|
|||||||
id="password"
|
id="password"
|
||||||
type={showPassword ? "text" : "password"}
|
type={showPassword ? "text" : "password"}
|
||||||
placeholder="Your password"
|
placeholder="Your password"
|
||||||
className={`h-12 pr-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
value={loginData.password}
|
||||||
|
onChange={(e) => handleLoginChange("password", e.target.value)}
|
||||||
|
className={`h-12 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
|
required
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
@ -116,14 +477,25 @@ export default function Login() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="text-sm text-red-500">{errors.password}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full h-12 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={loginMutation.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"
|
||||||
>
|
>
|
||||||
Log in
|
{loginMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Logging in...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Log in"
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Remember Me & Forgot Password */}
|
{/* Remember Me & Forgot Password */}
|
||||||
@ -137,16 +509,300 @@ export default function Login() {
|
|||||||
/>
|
/>
|
||||||
<span className={isDark ? 'text-gray-300' : 'text-black'}>Remember me</span>
|
<span className={isDark ? 'text-gray-300' : 'text-black'}>Remember me</span>
|
||||||
</label>
|
</label>
|
||||||
<Link
|
<button
|
||||||
href="/forgot-password"
|
type="button"
|
||||||
className={`font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
|
className={`font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
|
||||||
>
|
>
|
||||||
Forgot password?
|
Forgot password?
|
||||||
</Link>
|
</button>
|
||||||
|
</div>
|
||||||
|
</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>
|
</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>
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* OTP Verification Form */}
|
||||||
|
{step === "verify" && (
|
||||||
|
<form className="space-y-6" onSubmit={handleVerifyOtp}>
|
||||||
|
<div className={`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 ${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-sm mt-1 ${isDark ? 'text-blue-300' : 'text-blue-700'}`}>
|
||||||
|
We've sent a 6-digit verification code to your email address.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email Field (if not set) */}
|
||||||
|
{!registeredEmail && (
|
||||||
|
<div className="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-12 ${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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* OTP Field */}
|
||||||
|
<div className="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)}
|
||||||
|
aria-invalid={!!errors.otp}
|
||||||
|
>
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot index={0} />
|
||||||
|
<InputOTPSlot index={1} />
|
||||||
|
<InputOTPSlot index={2} />
|
||||||
|
<InputOTPSlot index={3} />
|
||||||
|
<InputOTPSlot index={4} />
|
||||||
|
<InputOTPSlot index={5} />
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
</div>
|
||||||
|
{errors.otp && (
|
||||||
|
<p className="text-sm text-red-500 text-center">{errors.otp}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resend OTP */}
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResendOtp}
|
||||||
|
disabled={resendOtpMutation.isPending}
|
||||||
|
className={`text-sm font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||||
|
>
|
||||||
|
{resendOtpMutation.isPending ? "Sending..." : "Didn't receive the code? Resend"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={verifyOtpMutation.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"
|
||||||
|
>
|
||||||
|
{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"
|
||||||
|
onClick={() => {
|
||||||
|
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 signup
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-rose-600" />
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<LoginContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
470
app/(auth)/signup/page.tsx
Normal file
470
app/(auth)/signup/page.tsx
Normal file
@ -0,0 +1,470 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, Suspense } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
InputOTP,
|
||||||
|
InputOTPGroup,
|
||||||
|
InputOTPSlot,
|
||||||
|
} from "@/components/ui/input-otp";
|
||||||
|
import { Heart, Eye, EyeOff, X, Loader2, CheckCircle2 } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useAppTheme } from "@/components/ThemeProvider";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { registerSchema, verifyOtpSchema, type RegisterInput, type VerifyOtpInput } from "@/lib/schema/auth";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
function SignupContent() {
|
||||||
|
const { theme } = useAppTheme();
|
||||||
|
const isDark = theme === "dark";
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showPassword2, setShowPassword2] = useState(false);
|
||||||
|
const [step, setStep] = useState<"register" | "verify">("register");
|
||||||
|
const [registeredEmail, setRegisteredEmail] = useState("");
|
||||||
|
const [formData, setFormData] = useState<RegisterInput>({
|
||||||
|
first_name: "",
|
||||||
|
last_name: "",
|
||||||
|
email: "",
|
||||||
|
phone_number: "",
|
||||||
|
password: "",
|
||||||
|
password2: "",
|
||||||
|
});
|
||||||
|
const [otpData, setOtpData] = useState<VerifyOtpInput>({
|
||||||
|
email: "",
|
||||||
|
otp: "",
|
||||||
|
});
|
||||||
|
const [errors, setErrors] = useState<Partial<Record<keyof RegisterInput | keyof VerifyOtpInput, string>>>({});
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { register, verifyOtp, isAuthenticated, registerMutation, verifyOtpMutation, resendOtpMutation } = useAuth();
|
||||||
|
|
||||||
|
// Redirect if already authenticated
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
const redirect = searchParams.get("redirect") || "/admin/dashboard";
|
||||||
|
router.push(redirect);
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, router, searchParams]);
|
||||||
|
|
||||||
|
const handleRegister = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setErrors({});
|
||||||
|
|
||||||
|
// Validate form
|
||||||
|
const validation = registerSchema.safeParse(formData);
|
||||||
|
if (!validation.success) {
|
||||||
|
const fieldErrors: Partial<Record<keyof RegisterInput, string>> = {};
|
||||||
|
validation.error.issues.forEach((err) => {
|
||||||
|
if (err.path[0]) {
|
||||||
|
fieldErrors[err.path[0] as keyof RegisterInput] = err.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setErrors(fieldErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await register(formData);
|
||||||
|
|
||||||
|
// If registration is successful, redirect to login page with verify parameter
|
||||||
|
toast.success("Registration successful! Please check your email for OTP verification.");
|
||||||
|
// Redirect to login page with verify step
|
||||||
|
router.push(`/login?verify=true&email=${encodeURIComponent(formData.email)}`);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Registration failed. Please try again.";
|
||||||
|
|
||||||
|
// If OTP sending failed, don't show OTP verification - just show error
|
||||||
|
if (errorMessage.toLowerCase().includes("failed to send") ||
|
||||||
|
errorMessage.toLowerCase().includes("failed to send otp")) {
|
||||||
|
toast.error("Registration failed: OTP could not be sent. Please try again later or contact support.");
|
||||||
|
setErrors({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error(errorMessage);
|
||||||
|
// Don't set field errors for server errors, only show toast
|
||||||
|
setErrors({});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerifyOtp = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setErrors({});
|
||||||
|
|
||||||
|
// Validate OTP
|
||||||
|
const validation = verifyOtpSchema.safeParse(otpData);
|
||||||
|
if (!validation.success) {
|
||||||
|
const fieldErrors: Partial<Record<keyof VerifyOtpInput, string>> = {};
|
||||||
|
validation.error.issues.forEach((err) => {
|
||||||
|
if (err.path[0]) {
|
||||||
|
fieldErrors[err.path[0] as keyof VerifyOtpInput] = err.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setErrors(fieldErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await verifyOtp(otpData);
|
||||||
|
|
||||||
|
// If verification is successful (no error thrown), show success and redirect
|
||||||
|
toast.success("Email verified successfully! Redirecting to login...");
|
||||||
|
|
||||||
|
// Redirect to login page after OTP verification with email pre-filled
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push(`/login?email=${encodeURIComponent(otpData.email)}`);
|
||||||
|
}, 1500);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "OTP verification failed. Please try again.";
|
||||||
|
toast.error(errorMessage);
|
||||||
|
// Don't set field errors for server errors, only show toast
|
||||||
|
setErrors({});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResendOtp = async () => {
|
||||||
|
try {
|
||||||
|
await resendOtpMutation.mutateAsync({ email: registeredEmail, context: "registration" });
|
||||||
|
toast.success("OTP resent successfully! Please check your email.");
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "Failed to resend OTP. Please try again.";
|
||||||
|
toast.error(errorMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (field: keyof RegisterInput, value: string) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
// Clear error when user starts typing
|
||||||
|
if (errors[field]) {
|
||||||
|
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOtpChange = (field: keyof VerifyOtpInput, value: string) => {
|
||||||
|
setOtpData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
// Clear error when user starts typing
|
||||||
|
if (errors[field]) {
|
||||||
|
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen relative flex items-center justify-center px-4 py-12">
|
||||||
|
{/* Background Image */}
|
||||||
|
<div className="absolute inset-0 z-0">
|
||||||
|
<Image
|
||||||
|
src="/woman.jpg"
|
||||||
|
alt="Therapy and counseling session with African American clients"
|
||||||
|
fill
|
||||||
|
className="object-cover object-center"
|
||||||
|
priority
|
||||||
|
sizes="100vw"
|
||||||
|
/>
|
||||||
|
{/* Overlay for better readability */}
|
||||||
|
<div className="absolute inset-0 bg-black/20"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Branding - Top Left */}
|
||||||
|
<div className="absolute top-8 left-8 flex items-center gap-3 z-30">
|
||||||
|
<Heart className="w-6 h-6 text-white" fill="white" />
|
||||||
|
<span className="text-white text-xl font-semibold">Attune Heart Therapy</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Centered White Card - Signup Form */}
|
||||||
|
<div className={`relative z-20 w-full max-w-md rounded-2xl shadow-2xl p-8 ${isDark ? 'bg-gray-800 border border-gray-700' : 'bg-white'}`}>
|
||||||
|
{/* Header with Close Button */}
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
{/* Heading */}
|
||||||
|
<h1 className="text-3xl font-bold bg-gradient-to-r from-rose-600 via-pink-600 to-rose-600 bg-clip-text text-transparent mb-2">
|
||||||
|
{step === "register" ? "Create an account" : "Verify your email"}
|
||||||
|
</h1>
|
||||||
|
{/* Login Prompt */}
|
||||||
|
{step === "register" && (
|
||||||
|
<p className={`mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Link href="/login" className={`underline font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}>
|
||||||
|
Log in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{step === "verify" && (
|
||||||
|
<p className={`mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||||
|
We've sent a verification code to <strong>{registeredEmail}</strong>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Close Button */}
|
||||||
|
<Button
|
||||||
|
onClick={() => router.back()}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
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>
|
||||||
|
|
||||||
|
{step === "register" ? (
|
||||||
|
/* Registration Form */
|
||||||
|
<form className="space-y-4" onSubmit={handleRegister}>
|
||||||
|
{/* 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={formData.first_name}
|
||||||
|
onChange={(e) => handleChange("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={formData.last_name}
|
||||||
|
onChange={(e) => handleChange("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="email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||||
|
Email address *
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="Email address"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => handleChange("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
|
||||||
|
/>
|
||||||
|
</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={formData.phone_number || ""}
|
||||||
|
onChange={(e) => handleChange("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="password" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||||
|
Password *
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? "text" : "password"}
|
||||||
|
placeholder="Password (min 8 characters)"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => handleChange("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="password2" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||||
|
Confirm Password *
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password2"
|
||||||
|
type={showPassword2 ? "text" : "password"}
|
||||||
|
placeholder="Confirm password"
|
||||||
|
value={formData.password2}
|
||||||
|
onChange={(e) => handleChange("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-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-6"
|
||||||
|
>
|
||||||
|
{registerMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Creating account...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Sign up"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
/* OTP Verification Form */
|
||||||
|
<form className="space-y-6" onSubmit={handleVerifyOtp}>
|
||||||
|
<div className={`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 ${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-sm mt-1 ${isDark ? 'text-blue-300' : 'text-blue-700'}`}>
|
||||||
|
We've sent a 6-digit verification code to your email address.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* OTP Field */}
|
||||||
|
<div className="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)}
|
||||||
|
aria-invalid={!!errors.otp}
|
||||||
|
>
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot index={0} />
|
||||||
|
<InputOTPSlot index={1} />
|
||||||
|
<InputOTPSlot index={2} />
|
||||||
|
<InputOTPSlot index={3} />
|
||||||
|
<InputOTPSlot index={4} />
|
||||||
|
<InputOTPSlot index={5} />
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Resend OTP */}
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResendOtp}
|
||||||
|
disabled={resendOtpMutation.isPending}
|
||||||
|
className={`text-sm font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||||
|
>
|
||||||
|
{resendOtpMutation.isPending ? "Sending..." : "Didn't receive the code? Resend"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={verifyOtpMutation.isPending}
|
||||||
|
className="w-full h-12 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"
|
||||||
|
>
|
||||||
|
{verifyOtpMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Verifying...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Verify Email"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Back to registration */}
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setStep("register");
|
||||||
|
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 registration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Signup() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-rose-600" />
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<SignupContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -23,11 +23,16 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
LogOut,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { LoginDialog } from "@/components/LoginDialog";
|
import { LoginDialog } from "@/components/LoginDialog";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { useAppointments } from "@/hooks/useAppointments";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import type { Appointment } from "@/lib/models/appointments";
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
ID: number;
|
ID: number;
|
||||||
@ -73,6 +78,8 @@ export default function BookNowPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { theme } = useAppTheme();
|
const { theme } = useAppTheme();
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
|
const { isAuthenticated, logout } = useAuth();
|
||||||
|
const { create, isCreating } = useAppointments();
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
firstName: "",
|
firstName: "",
|
||||||
lastName: "",
|
lastName: "",
|
||||||
@ -82,139 +89,140 @@ export default function BookNowPage() {
|
|||||||
preferredTimes: [] as string[],
|
preferredTimes: [] as string[],
|
||||||
message: "",
|
message: "",
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [booking, setBooking] = useState<Booking | null>(null);
|
const [booking, setBooking] = useState<Booking | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showLoginDialog, setShowLoginDialog] = useState(false);
|
const [showLoginDialog, setShowLoginDialog] = useState(false);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
toast.success("Logged out successfully");
|
||||||
|
router.push("/");
|
||||||
|
};
|
||||||
|
|
||||||
// Handle submit button click
|
// Handle submit button click
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Open login dialog instead of submitting directly
|
|
||||||
|
// Check if user is authenticated
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
// Open login dialog if not authenticated
|
||||||
setShowLoginDialog(true);
|
setShowLoginDialog(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If authenticated, proceed with booking
|
||||||
|
await submitBooking();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoginSuccess = async () => {
|
const handleLoginSuccess = async () => {
|
||||||
|
// Close login dialog
|
||||||
|
setShowLoginDialog(false);
|
||||||
// After successful login, proceed with booking submission
|
// After successful login, proceed with booking submission
|
||||||
await submitBooking();
|
await submitBooking();
|
||||||
};
|
};
|
||||||
|
|
||||||
const submitBooking = async () => {
|
const submitBooking = async () => {
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (formData.preferredDays.length === 0) {
|
if (formData.preferredDays.length === 0) {
|
||||||
setError("Please select at least one available day.");
|
setError("Please select at least one available day.");
|
||||||
setLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.preferredTimes.length === 0) {
|
if (formData.preferredTimes.length === 0) {
|
||||||
setError("Please select at least one preferred time.");
|
setError("Please select at least one preferred time.");
|
||||||
setLoading(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For now, we'll use the first selected day and first selected time
|
// Convert day names to dates (YYYY-MM-DD format)
|
||||||
// This can be adjusted based on your backend requirements
|
// Get next occurrence of each selected day
|
||||||
const firstDay = formData.preferredDays[0];
|
|
||||||
const firstTime = formData.preferredTimes[0];
|
|
||||||
const timeMap: { [key: string]: string } = {
|
|
||||||
morning: "09:00",
|
|
||||||
lunchtime: "12:00",
|
|
||||||
afternoon: "14:00",
|
|
||||||
};
|
|
||||||
const time24 = timeMap[firstTime] || "09:00";
|
|
||||||
|
|
||||||
// Get next occurrence of the first selected day
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||||
const targetDayIndex = days.indexOf(firstDay);
|
const preferredDates: string[] = [];
|
||||||
|
|
||||||
|
formData.preferredDays.forEach((dayName) => {
|
||||||
|
const targetDayIndex = days.indexOf(dayName);
|
||||||
|
if (targetDayIndex === -1) return;
|
||||||
|
|
||||||
let daysUntilTarget = (targetDayIndex - today.getDay() + 7) % 7;
|
let daysUntilTarget = (targetDayIndex - today.getDay() + 7) % 7;
|
||||||
if (daysUntilTarget === 0) daysUntilTarget = 7; // Next week if today
|
if (daysUntilTarget === 0) daysUntilTarget = 7; // Next week if today
|
||||||
|
|
||||||
const targetDate = new Date(today);
|
const targetDate = new Date(today);
|
||||||
targetDate.setDate(today.getDate() + daysUntilTarget);
|
targetDate.setDate(today.getDate() + daysUntilTarget);
|
||||||
const dateString = targetDate.toISOString().split("T")[0];
|
const dateString = targetDate.toISOString().split("T")[0];
|
||||||
|
preferredDates.push(dateString);
|
||||||
|
});
|
||||||
|
|
||||||
// Combine date and time into scheduled_at (ISO format)
|
// Map time slots - API expects "morning", "afternoon", "evening"
|
||||||
const dateTimeString = `${dateString}T${time24}:00Z`;
|
// Form has "morning", "lunchtime", "afternoon"
|
||||||
|
const timeSlotMap: { [key: string]: "morning" | "afternoon" | "evening" } = {
|
||||||
|
morning: "morning",
|
||||||
|
lunchtime: "afternoon", // Map lunchtime to afternoon
|
||||||
|
afternoon: "afternoon",
|
||||||
|
};
|
||||||
|
|
||||||
// Prepare request payload
|
const preferredTimeSlots = formData.preferredTimes
|
||||||
|
.map((time) => timeSlotMap[time] || "morning")
|
||||||
|
.filter((time, index, self) => self.indexOf(time) === index) as ("morning" | "afternoon" | "evening")[]; // Remove duplicates
|
||||||
|
|
||||||
|
// Prepare request payload according to API spec
|
||||||
const payload = {
|
const payload = {
|
||||||
first_name: formData.firstName,
|
first_name: formData.firstName,
|
||||||
last_name: formData.lastName,
|
last_name: formData.lastName,
|
||||||
email: formData.email,
|
email: formData.email,
|
||||||
phone: formData.phone,
|
preferred_dates: preferredDates,
|
||||||
scheduled_at: dateTimeString,
|
preferred_time_slots: preferredTimeSlots,
|
||||||
duration: 60, // Default to 60 minutes
|
...(formData.phone && { phone: formData.phone }),
|
||||||
preferred_days: formData.preferredDays,
|
...(formData.message && { reason: formData.message }),
|
||||||
preferred_times: formData.preferredTimes,
|
|
||||||
notes: formData.message || "",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Simulate API call - Replace with actual API endpoint
|
// Call the actual API using the hook
|
||||||
const response = await fetch("/api/bookings", {
|
const appointmentData = await create(payload);
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
}).catch(() => {
|
|
||||||
// Fallback to mock data if API is not available
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
let bookingData: Booking;
|
// Convert API response to Booking format for display
|
||||||
|
// Use a stable ID - if appointmentData.id exists, use it, otherwise use 0
|
||||||
|
const appointmentId = appointmentData.id ? parseInt(appointmentData.id, 10) : 0;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
if (response && response.ok) {
|
const bookingData: Booking = {
|
||||||
const data: BookingsResponse = await response.json();
|
ID: appointmentId || 0,
|
||||||
bookingData = data.bookings[0];
|
CreatedAt: appointmentData.created_at || now,
|
||||||
} else {
|
UpdatedAt: appointmentData.updated_at || now,
|
||||||
// Mock response for development - matches the API structure provided
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
||||||
bookingData = {
|
|
||||||
ID: Math.floor(Math.random() * 1000),
|
|
||||||
CreatedAt: new Date().toISOString(),
|
|
||||||
UpdatedAt: new Date().toISOString(),
|
|
||||||
DeletedAt: null,
|
DeletedAt: null,
|
||||||
user_id: 1,
|
user_id: 0, // API doesn't return user_id in this response
|
||||||
user: {
|
user: {
|
||||||
ID: 1,
|
ID: 0,
|
||||||
CreatedAt: new Date().toISOString(),
|
first_name: appointmentData.first_name,
|
||||||
UpdatedAt: new Date().toISOString(),
|
last_name: appointmentData.last_name,
|
||||||
DeletedAt: null,
|
email: appointmentData.email,
|
||||||
first_name: formData.firstName,
|
phone: appointmentData.phone || "",
|
||||||
last_name: formData.lastName,
|
|
||||||
email: formData.email,
|
|
||||||
phone: formData.phone,
|
|
||||||
location: "",
|
location: "",
|
||||||
date_of_birth: "0001-01-01T00:00:00Z",
|
|
||||||
is_admin: false,
|
is_admin: false,
|
||||||
bookings: null,
|
bookings: null,
|
||||||
},
|
},
|
||||||
scheduled_at: dateTimeString,
|
scheduled_at: appointmentData.scheduled_datetime || "",
|
||||||
duration: 60,
|
duration: appointmentData.scheduled_duration || 60,
|
||||||
status: "scheduled",
|
status: appointmentData.status || "pending_review",
|
||||||
jitsi_room_id: `booking-${Math.floor(Math.random() * 1000)}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
jitsi_room_id: appointmentData.jitsi_room_id || "",
|
||||||
jitsi_room_url: `https://meet.jit.si/booking-${Math.floor(Math.random() * 1000)}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
jitsi_room_url: appointmentData.jitsi_meet_url || "",
|
||||||
payment_id: "",
|
payment_id: "",
|
||||||
payment_status: "pending",
|
payment_status: "pending",
|
||||||
amount: 52,
|
amount: 0,
|
||||||
notes: formData.message || "Initial consultation session",
|
notes: appointmentData.reason || "",
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
setBooking(bookingData);
|
setBooking(bookingData);
|
||||||
setLoading(false);
|
toast.success("Appointment request submitted successfully! We'll review and get back to you soon.");
|
||||||
|
|
||||||
// Redirect to home after 2 seconds
|
// Redirect to user dashboard after 3 seconds
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
router.push("/");
|
router.push("/user/dashboard");
|
||||||
}, 2000);
|
}, 3000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("Failed to submit booking. Please try again.");
|
const errorMessage = err instanceof Error ? err.message : "Failed to submit booking. Please try again.";
|
||||||
setLoading(false);
|
setError(errorMessage);
|
||||||
|
toast.error(errorMessage);
|
||||||
console.error("Booking error:", err);
|
console.error("Booking error:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -628,10 +636,10 @@ export default function BookNowPage() {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
size="lg"
|
size="lg"
|
||||||
disabled={loading}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{isCreating ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
Submitting...
|
Submitting...
|
||||||
@ -660,6 +668,20 @@ export default function BookNowPage() {
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Logout Button - Only show when authenticated */}
|
||||||
|
{isAuthenticated && (
|
||||||
|
<div className="mt-6 flex justify-center">
|
||||||
|
<Button
|
||||||
|
onClick={handleLogout}
|
||||||
|
variant="outline"
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white border-red-600 hover:border-red-700"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4 mr-2" />
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -21,7 +21,7 @@ export default function RootLayout({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className={inter.className}>
|
<body className={inter.className} suppressHydrationWarning>
|
||||||
<Providers>
|
<Providers>
|
||||||
{children}
|
{children}
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|||||||
@ -2,12 +2,26 @@
|
|||||||
|
|
||||||
import { ThemeProvider } from "../components/ThemeProvider";
|
import { ThemeProvider } from "../components/ThemeProvider";
|
||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
export function Providers({ children }: { children: ReactNode }) {
|
export function Providers({ children }: { children: ReactNode }) {
|
||||||
|
const [queryClient] = useState(
|
||||||
|
() =>
|
||||||
|
new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 60 * 1000, // 1 minute
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<QueryClientProvider client={queryClient}>
|
||||||
{children}
|
<ThemeProvider>{children}</ThemeProvider>
|
||||||
</ThemeProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import { Heart, Mail, Phone, MapPin } from "lucide-react";
|
import { Heart, Mail, Phone, MapPin } from "lucide-react";
|
||||||
import { useAppTheme } from "@/components/ThemeProvider";
|
import { useAppTheme } from "@/components/ThemeProvider";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
const { theme } = useAppTheme();
|
const { theme } = useAppTheme();
|
||||||
@ -16,10 +17,11 @@ export function Footer() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const quickLinks = [
|
const quickLinks = [
|
||||||
{ name: 'Home', href: '#home' },
|
{ name: 'Home', href: '#home', isScroll: true },
|
||||||
{ name: 'About', href: '#about' },
|
{ name: 'About', href: '#about', isScroll: true },
|
||||||
{ name: 'Services', href: '#services' },
|
{ name: 'Services', href: '#services', isScroll: true },
|
||||||
{ name: 'Contact', href: '#contact' },
|
{ name: 'Contact', href: '#contact', isScroll: true },
|
||||||
|
{ name: 'Admin Panel', href: '/login', isScroll: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -74,12 +76,21 @@ export function Footer() {
|
|||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{quickLinks.map((link) => (
|
{quickLinks.map((link) => (
|
||||||
<li key={link.name}>
|
<li key={link.name}>
|
||||||
|
{link.isScroll ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => scrollToSection(link.href.replace('#', ''))}
|
onClick={() => scrollToSection(link.href.replace('#', ''))}
|
||||||
className="text-sm text-muted-foreground hover:text-rose-600 dark:hover:text-rose-400 transition-colors cursor-pointer hover:translate-x-1 inline-block transition-transform"
|
className="text-sm text-muted-foreground hover:text-rose-600 dark:hover:text-rose-400 transition-colors cursor-pointer hover:translate-x-1 inline-block transition-transform"
|
||||||
>
|
>
|
||||||
{link.name}
|
{link.name}
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href={link.href}
|
||||||
|
className="text-sm text-muted-foreground hover:text-rose-600 dark:hover:text-rose-400 transition-colors cursor-pointer hover:translate-x-1 inline-block transition-transform"
|
||||||
|
>
|
||||||
|
{link.name}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@ -12,6 +12,10 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Eye, EyeOff, Loader2, X } from "lucide-react";
|
import { Eye, EyeOff, Loader2, X } from "lucide-react";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { loginSchema, registerSchema, type LoginInput, type RegisterInput } from "@/lib/schema/auth";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
interface LoginDialogProps {
|
interface LoginDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -23,58 +27,87 @@ interface LoginDialogProps {
|
|||||||
export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogProps) {
|
export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogProps) {
|
||||||
const { theme } = useAppTheme();
|
const { theme } = useAppTheme();
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
|
const router = useRouter();
|
||||||
|
const { login, register, loginMutation, registerMutation } = useAuth();
|
||||||
const [isSignup, setIsSignup] = useState(false);
|
const [isSignup, setIsSignup] = useState(false);
|
||||||
const [loginData, setLoginData] = useState({
|
const [loginData, setLoginData] = useState<LoginInput>({
|
||||||
email: "",
|
email: "",
|
||||||
password: "",
|
password: "",
|
||||||
});
|
});
|
||||||
const [signupData, setSignupData] = useState({
|
const [signupData, setSignupData] = useState<RegisterInput>({
|
||||||
fullName: "",
|
first_name: "",
|
||||||
|
last_name: "",
|
||||||
email: "",
|
email: "",
|
||||||
phone: "",
|
phone_number: "",
|
||||||
|
password: "",
|
||||||
|
password2: "",
|
||||||
});
|
});
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showPassword2, setShowPassword2] = useState(false);
|
||||||
const [rememberMe, setRememberMe] = useState(false);
|
const [rememberMe, setRememberMe] = useState(false);
|
||||||
const [loginLoading, setLoginLoading] = useState(false);
|
|
||||||
const [signupLoading, setSignupLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleLogin = async (e: React.FormEvent) => {
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoginLoading(true);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
// Validate form
|
||||||
// Simulate login API call
|
const validation = loginSchema.safeParse(loginData);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
if (!validation.success) {
|
||||||
|
const firstError = validation.error.issues[0];
|
||||||
|
setError(firstError.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// After successful login, close dialog and call success callback
|
try {
|
||||||
|
const result = await login(loginData);
|
||||||
|
|
||||||
|
if (result.tokens && result.user) {
|
||||||
|
toast.success("Login successful!");
|
||||||
setShowPassword(false);
|
setShowPassword(false);
|
||||||
setLoginLoading(false);
|
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
onLoginSuccess();
|
onLoginSuccess();
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("Login failed. Please try again.");
|
const errorMessage = err instanceof Error ? err.message : "Login failed. Please try again.";
|
||||||
setLoginLoading(false);
|
setError(errorMessage);
|
||||||
|
toast.error(errorMessage);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSignup = async (e: React.FormEvent) => {
|
const handleSignup = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSignupLoading(true);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
// Validate form
|
||||||
// Simulate signup API call
|
const validation = registerSchema.safeParse(signupData);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
if (!validation.success) {
|
||||||
|
const firstError = validation.error.issues[0];
|
||||||
|
setError(firstError.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// After successful signup, automatically log in and proceed
|
try {
|
||||||
setSignupLoading(false);
|
const result = await register(signupData);
|
||||||
onOpenChange(false);
|
|
||||||
onLoginSuccess();
|
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) {
|
} catch (err) {
|
||||||
setError("Signup failed. Please try again.");
|
const errorMessage = err instanceof Error ? err.message : "Signup failed. Please try again.";
|
||||||
setSignupLoading(false);
|
setError(errorMessage);
|
||||||
|
toast.error(errorMessage);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -87,22 +120,29 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
|
|||||||
const handleSwitchToLogin = () => {
|
const handleSwitchToLogin = () => {
|
||||||
setIsSignup(false);
|
setIsSignup(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
setSignupData({ fullName: "", email: "", phone: "" });
|
setSignupData({
|
||||||
|
first_name: "",
|
||||||
|
last_name: "",
|
||||||
|
email: "",
|
||||||
|
phone_number: "",
|
||||||
|
password: "",
|
||||||
|
password2: "",
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
showCloseButton={false}
|
showCloseButton={false}
|
||||||
className={`sm:max-w-md ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}
|
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 */}
|
{/* Header with Close Button - Fixed */}
|
||||||
<div className="flex items-start justify-between mb-2">
|
<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">
|
<DialogHeader className="flex-1 pr-2">
|
||||||
<DialogTitle className="text-3xl font-bold bg-gradient-to-r from-rose-600 via-pink-600 to-rose-600 bg-clip-text text-transparent">
|
<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">
|
||||||
{isSignup ? "Create an account" : "Welcome back"}
|
{isSignup ? "Create an account" : "Welcome back"}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className={isDark ? 'text-gray-400' : 'text-gray-600'}>
|
<DialogDescription className={`text-sm sm:text-base mt-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||||
{isSignup
|
{isSignup
|
||||||
? "Sign up to complete your booking"
|
? "Sign up to complete your booking"
|
||||||
: "Please log in to complete your booking"}
|
: "Please log in to complete your booking"}
|
||||||
@ -118,33 +158,51 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable Content */}
|
||||||
|
<div className="overflow-y-auto flex-1 px-6">
|
||||||
{/* Signup Form */}
|
{/* Signup Form */}
|
||||||
{isSignup ? (
|
{isSignup ? (
|
||||||
<form className="space-y-6 mt-4" onSubmit={handleSignup}>
|
<form className="space-y-4 sm:space-y-5 py-4 sm:py-6" onSubmit={handleSignup}>
|
||||||
{error && (
|
{error && (
|
||||||
<div className={`p-3 rounded-lg border ${isDark ? 'bg-red-900/20 border-red-800' : 'bg-red-50 border-red-200'}`}>
|
<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>
|
<p className={`text-sm ${isDark ? 'text-red-200' : 'text-red-800'}`}>{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Full Name Field */}
|
{/* First Name Field */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-1.5 sm:space-y-2">
|
||||||
<label htmlFor="signup-fullName" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
<label htmlFor="signup-firstName" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||||
Full Name *
|
First Name *
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="signup-fullName"
|
id="signup-firstName"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="John Doe"
|
placeholder="John"
|
||||||
value={signupData.fullName}
|
value={signupData.first_name}
|
||||||
onChange={(e) => setSignupData({ ...signupData, fullName: e.target.value })}
|
onChange={(e) => setSignupData({ ...signupData, first_name: e.target.value })}
|
||||||
className={`h-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Email Field */}
|
{/* Email Field */}
|
||||||
<div className="space-y-2">
|
<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'}`}>
|
<label htmlFor="signup-email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||||
Email address *
|
Email address *
|
||||||
</label>
|
</label>
|
||||||
@ -154,34 +212,97 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
|
|||||||
placeholder="Email address"
|
placeholder="Email address"
|
||||||
value={signupData.email}
|
value={signupData.email}
|
||||||
onChange={(e) => setSignupData({ ...signupData, email: e.target.value })}
|
onChange={(e) => setSignupData({ ...signupData, email: e.target.value })}
|
||||||
className={`h-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Phone Field */}
|
{/* Phone Field */}
|
||||||
<div className="space-y-2">
|
<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'}`}>
|
<label htmlFor="signup-phone" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||||
Phone Number *
|
Phone Number (Optional)
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="signup-phone"
|
id="signup-phone"
|
||||||
type="tel"
|
type="tel"
|
||||||
placeholder="+1 (555) 123-4567"
|
placeholder="+1 (555) 123-4567"
|
||||||
value={signupData.phone}
|
value={signupData.phone_number || ""}
|
||||||
onChange={(e) => setSignupData({ ...signupData, phone: e.target.value })}
|
onChange={(e) => setSignupData({ ...signupData, phone_number: e.target.value })}
|
||||||
className={`h-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
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
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={signupLoading}
|
disabled={registerMutation.isPending}
|
||||||
className="w-full h-12 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"
|
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"
|
||||||
>
|
>
|
||||||
{signupLoading ? (
|
{registerMutation.isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
Creating account...
|
Creating account...
|
||||||
@ -192,7 +313,7 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Switch to Login */}
|
{/* Switch to Login */}
|
||||||
<p className={`text-sm text-center ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
<p className={`text-xs sm:text-sm text-center pt-2 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||||
Already have an account?{" "}
|
Already have an account?{" "}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -205,7 +326,7 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
|
|||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
/* Login Form */
|
/* Login Form */
|
||||||
<form className="space-y-6 mt-4" onSubmit={handleLogin}>
|
<form className="space-y-4 sm:space-y-5 py-4 sm:py-6" onSubmit={handleLogin}>
|
||||||
{error && (
|
{error && (
|
||||||
<div className={`p-3 rounded-lg border ${isDark ? 'bg-red-900/20 border-red-800' : 'bg-red-50 border-red-200'}`}>
|
<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>
|
<p className={`text-sm ${isDark ? 'text-red-200' : 'text-red-800'}`}>{error}</p>
|
||||||
@ -213,7 +334,7 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Email Field */}
|
{/* Email Field */}
|
||||||
<div className="space-y-2">
|
<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'}`}>
|
<label htmlFor="login-email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||||
Email address
|
Email address
|
||||||
</label>
|
</label>
|
||||||
@ -223,13 +344,13 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
|
|||||||
placeholder="Email address"
|
placeholder="Email address"
|
||||||
value={loginData.email}
|
value={loginData.email}
|
||||||
onChange={(e) => setLoginData({ ...loginData, email: e.target.value })}
|
onChange={(e) => setLoginData({ ...loginData, email: e.target.value })}
|
||||||
className={`h-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
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
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Password Field */}
|
{/* Password Field */}
|
||||||
<div className="space-y-2">
|
<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'}`}>
|
<label htmlFor="login-password" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||||
Your password
|
Your password
|
||||||
</label>
|
</label>
|
||||||
@ -240,7 +361,7 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
|
|||||||
placeholder="Your password"
|
placeholder="Your password"
|
||||||
value={loginData.password}
|
value={loginData.password}
|
||||||
onChange={(e) => setLoginData({ ...loginData, password: e.target.value })}
|
onChange={(e) => setLoginData({ ...loginData, password: e.target.value })}
|
||||||
className={`h-12 pr-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
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
|
required
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
@ -248,13 +369,13 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
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'}`}
|
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"}
|
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||||
>
|
>
|
||||||
{showPassword ? (
|
{showPassword ? (
|
||||||
<EyeOff className="w-5 h-5" />
|
<EyeOff className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||||
) : (
|
) : (
|
||||||
<Eye className="w-5 h-5" />
|
<Eye className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -263,10 +384,10 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
|
|||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loginLoading}
|
disabled={loginMutation.isPending}
|
||||||
className="w-full h-12 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"
|
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"
|
||||||
>
|
>
|
||||||
{loginLoading ? (
|
{loginMutation.isPending ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
Logging in...
|
Logging in...
|
||||||
@ -277,7 +398,7 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Remember Me & Forgot Password */}
|
{/* Remember Me & Forgot Password */}
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-xs sm:text-sm">
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@ -289,7 +410,7 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
|
|||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
|
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)}
|
onClick={() => onOpenChange(false)}
|
||||||
>
|
>
|
||||||
Forgot password?
|
Forgot password?
|
||||||
@ -297,7 +418,7 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sign Up Prompt */}
|
{/* Sign Up Prompt */}
|
||||||
<p className={`text-sm text-center ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
<p className={`text-xs sm:text-sm text-center pt-2 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||||
New to Attune Heart Therapy?{" "}
|
New to Attune Heart Therapy?{" "}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -309,6 +430,7 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
|
|||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,13 +2,15 @@
|
|||||||
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Heart, Menu, X } from "lucide-react";
|
import { Heart, Menu, X, LogOut } from "lucide-react";
|
||||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { LoginDialog } from "@/components/LoginDialog";
|
import { LoginDialog } from "@/components/LoginDialog";
|
||||||
import { useRouter, usePathname } from "next/navigation";
|
import { useRouter, usePathname } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useAppTheme } from "@/components/ThemeProvider";
|
import { useAppTheme } from "@/components/ThemeProvider";
|
||||||
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
const { theme } = useAppTheme();
|
const { theme } = useAppTheme();
|
||||||
@ -18,6 +20,9 @@ export function Navbar() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isUserDashboard = pathname?.startsWith("/user/dashboard");
|
const isUserDashboard = pathname?.startsWith("/user/dashboard");
|
||||||
|
const isUserSettings = pathname?.startsWith("/user/settings");
|
||||||
|
const isUserRoute = pathname?.startsWith("/user/");
|
||||||
|
const { isAuthenticated, logout } = useAuth();
|
||||||
|
|
||||||
const scrollToSection = (id: string) => {
|
const scrollToSection = (id: string) => {
|
||||||
const element = document.getElementById(id);
|
const element = document.getElementById(id);
|
||||||
@ -28,11 +33,18 @@ export function Navbar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleLoginSuccess = () => {
|
const handleLoginSuccess = () => {
|
||||||
// Redirect to user dashboard after successful login
|
// Redirect to admin dashboard after successful login
|
||||||
router.push("/user/dashboard");
|
router.push("/admin/dashboard");
|
||||||
setMobileMenuOpen(false);
|
setMobileMenuOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
toast.success("Logged out successfully");
|
||||||
|
setMobileMenuOpen(false);
|
||||||
|
router.push("/");
|
||||||
|
};
|
||||||
|
|
||||||
// Close mobile menu when clicking outside
|
// Close mobile menu when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mobileMenuOpen) {
|
if (mobileMenuOpen) {
|
||||||
@ -73,7 +85,7 @@ export function Navbar() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Desktop Navigation */}
|
{/* Desktop Navigation */}
|
||||||
{!isUserDashboard && (
|
{!isUserRoute && (
|
||||||
<div className="hidden lg:flex items-center gap-4 xl:gap-6">
|
<div className="hidden lg:flex items-center gap-4 xl:gap-6">
|
||||||
<button
|
<button
|
||||||
onClick={() => scrollToSection("about")}
|
onClick={() => scrollToSection("about")}
|
||||||
@ -98,7 +110,7 @@ export function Navbar() {
|
|||||||
|
|
||||||
{/* Desktop Actions */}
|
{/* Desktop Actions */}
|
||||||
<div className="hidden lg:flex items-center gap-2">
|
<div className="hidden lg:flex items-center gap-2">
|
||||||
{!isUserDashboard && (
|
{!isAuthenticated && !isUserDashboard && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -109,9 +121,25 @@ export function Navbar() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<Button size="sm" className="hover:opacity-90 hover:scale-105 transition-all text-xs sm:text-sm" asChild>
|
{!isUserDashboard && (
|
||||||
<a href="/book-now">Book Now</a>
|
<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'}`}
|
||||||
|
>
|
||||||
|
Book-Now
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{isAuthenticated && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Actions */}
|
{/* Mobile Actions */}
|
||||||
@ -161,7 +189,7 @@ export function Navbar() {
|
|||||||
>
|
>
|
||||||
<div className="flex flex-col p-4 sm:p-6 space-y-3 sm:space-y-4">
|
<div className="flex flex-col p-4 sm:p-6 space-y-3 sm:space-y-4">
|
||||||
{/* Mobile Navigation Links */}
|
{/* Mobile Navigation Links */}
|
||||||
{!isUserDashboard && (
|
{!isUserRoute && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => scrollToSection("about")}
|
onClick={() => scrollToSection("about")}
|
||||||
@ -185,7 +213,7 @@ export function Navbar() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`border-t pt-3 sm:pt-4 mt-3 sm:mt-4 space-y-2 sm:space-y-3 ${isDark ? 'border-gray-700' : 'border-gray-200'}`}>
|
<div className={`border-t pt-3 sm:pt-4 mt-3 sm:mt-4 space-y-2 sm:space-y-3 ${isDark ? 'border-gray-700' : 'border-gray-200'}`}>
|
||||||
{!isUserDashboard && (
|
{!isAuthenticated && !isUserDashboard && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`w-full justify-start text-sm sm:text-base ${isDark ? 'border-gray-700 text-gray-300 hover:bg-gray-800' : ''}`}
|
className={`w-full justify-start text-sm sm:text-base ${isDark ? 'border-gray-700 text-gray-300 hover:bg-gray-800' : ''}`}
|
||||||
@ -197,14 +225,27 @@ export function Navbar() {
|
|||||||
Sign In
|
Sign In
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
{!isUserDashboard && (
|
||||||
className="w-full justify-start bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white text-sm sm:text-base"
|
<Link
|
||||||
asChild
|
href="/book-now"
|
||||||
|
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 ${isDark ? 'text-gray-300 hover:bg-gray-800' : 'text-gray-700 hover:bg-gray-100'}`}
|
||||||
>
|
>
|
||||||
<Link href="/book-now" onClick={() => setMobileMenuOpen(false)}>
|
Book-Now
|
||||||
Book Now
|
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
|
{isAuthenticated && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
77
components/ui/input-otp.tsx
Normal file
77
components/ui/input-otp.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { OTPInput, OTPInputContext } from "input-otp"
|
||||||
|
import { MinusIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function InputOTP({
|
||||||
|
className,
|
||||||
|
containerClassName,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof OTPInput> & {
|
||||||
|
containerClassName?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<OTPInput
|
||||||
|
data-slot="input-otp"
|
||||||
|
containerClassName={cn(
|
||||||
|
"flex items-center gap-2 has-disabled:opacity-50",
|
||||||
|
containerClassName
|
||||||
|
)}
|
||||||
|
className={cn("disabled:cursor-not-allowed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-otp-group"
|
||||||
|
className={cn("flex items-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPSlot({
|
||||||
|
index,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
index: number
|
||||||
|
}) {
|
||||||
|
const inputOTPContext = React.useContext(OTPInputContext)
|
||||||
|
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-otp-slot"
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
{hasFakeCaret && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||||
|
<MinusIcon />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
||||||
@ -1,8 +1,29 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// Simple toaster component - can be enhanced later with toast notifications
|
import { Toaster as Sonner } from "sonner";
|
||||||
|
import { useAppTheme } from "@/components/ThemeProvider";
|
||||||
|
|
||||||
export function Toaster() {
|
export function Toaster() {
|
||||||
return null;
|
const { theme } = useAppTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme === "dark" ? "dark" : "light"}
|
||||||
|
position="top-center"
|
||||||
|
richColors
|
||||||
|
closeButton
|
||||||
|
duration={4000}
|
||||||
|
expand={true}
|
||||||
|
toastOptions={{
|
||||||
|
classNames: {
|
||||||
|
toast: "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||||
|
description: "group-[.toast]:text-muted-foreground",
|
||||||
|
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||||
|
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
207
hooks/useAppointments.ts
Normal file
207
hooks/useAppointments.ts
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import {
|
||||||
|
createAppointment,
|
||||||
|
getAvailableDates,
|
||||||
|
listAppointments,
|
||||||
|
getUserAppointments,
|
||||||
|
getAppointmentDetail,
|
||||||
|
scheduleAppointment,
|
||||||
|
rejectAppointment,
|
||||||
|
getAdminAvailability,
|
||||||
|
updateAdminAvailability,
|
||||||
|
getAppointmentStats,
|
||||||
|
getJitsiMeetingInfo,
|
||||||
|
} from "@/lib/actions/appointments";
|
||||||
|
import type {
|
||||||
|
CreateAppointmentInput,
|
||||||
|
ScheduleAppointmentInput,
|
||||||
|
RejectAppointmentInput,
|
||||||
|
UpdateAvailabilityInput,
|
||||||
|
} from "@/lib/schema/appointments";
|
||||||
|
import type {
|
||||||
|
Appointment,
|
||||||
|
AdminAvailability,
|
||||||
|
AppointmentStats,
|
||||||
|
JitsiMeetingInfo,
|
||||||
|
} from "@/lib/models/appointments";
|
||||||
|
|
||||||
|
export function useAppointments() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Get available dates query
|
||||||
|
const availableDatesQuery = useQuery<string[]>({
|
||||||
|
queryKey: ["appointments", "available-dates"],
|
||||||
|
queryFn: () => getAvailableDates(),
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
// List appointments query
|
||||||
|
const appointmentsQuery = useQuery<Appointment[]>({
|
||||||
|
queryKey: ["appointments", "list"],
|
||||||
|
queryFn: () => listAppointments(),
|
||||||
|
enabled: false, // Only fetch when explicitly called
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get user appointments query
|
||||||
|
const userAppointmentsQuery = useQuery<Appointment[]>({
|
||||||
|
queryKey: ["appointments", "user"],
|
||||||
|
queryFn: () => getUserAppointments(),
|
||||||
|
staleTime: 30 * 1000, // 30 seconds
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get appointment detail query
|
||||||
|
const useAppointmentDetail = (id: string | null) => {
|
||||||
|
return useQuery<Appointment>({
|
||||||
|
queryKey: ["appointments", "detail", id],
|
||||||
|
queryFn: () => getAppointmentDetail(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get admin availability query
|
||||||
|
const adminAvailabilityQuery = useQuery<AdminAvailability>({
|
||||||
|
queryKey: ["appointments", "admin", "availability"],
|
||||||
|
queryFn: () => getAdminAvailability(),
|
||||||
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get appointment stats query
|
||||||
|
const appointmentStatsQuery = useQuery<AppointmentStats>({
|
||||||
|
queryKey: ["appointments", "stats"],
|
||||||
|
queryFn: () => getAppointmentStats(),
|
||||||
|
staleTime: 1 * 60 * 1000, // 1 minute
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get Jitsi meeting info query
|
||||||
|
const useJitsiMeetingInfo = (id: string | null) => {
|
||||||
|
return useQuery<JitsiMeetingInfo>({
|
||||||
|
queryKey: ["appointments", "jitsi", id],
|
||||||
|
queryFn: () => getJitsiMeetingInfo(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
staleTime: 30 * 1000, // 30 seconds
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create appointment mutation
|
||||||
|
const createAppointmentMutation = useMutation({
|
||||||
|
mutationFn: (input: CreateAppointmentInput) => createAppointment(input),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["appointments"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Schedule appointment mutation
|
||||||
|
const scheduleAppointmentMutation = useMutation({
|
||||||
|
mutationFn: ({ id, input }: { id: string; input: ScheduleAppointmentInput }) =>
|
||||||
|
scheduleAppointment(id, input),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["appointments"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reject appointment mutation
|
||||||
|
const rejectAppointmentMutation = useMutation({
|
||||||
|
mutationFn: ({ id, input }: { id: string; input: RejectAppointmentInput }) =>
|
||||||
|
rejectAppointment(id, input),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["appointments"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update admin availability mutation
|
||||||
|
const updateAdminAvailabilityMutation = useMutation({
|
||||||
|
mutationFn: (input: UpdateAvailabilityInput) => updateAdminAvailability(input),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["appointments", "admin", "availability"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["appointments", "available-dates"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convenience functions
|
||||||
|
const create = useCallback(
|
||||||
|
async (input: CreateAppointmentInput) => {
|
||||||
|
return await createAppointmentMutation.mutateAsync(input);
|
||||||
|
},
|
||||||
|
[createAppointmentMutation]
|
||||||
|
);
|
||||||
|
|
||||||
|
const schedule = useCallback(
|
||||||
|
async (id: string, input: ScheduleAppointmentInput) => {
|
||||||
|
return await scheduleAppointmentMutation.mutateAsync({ id, input });
|
||||||
|
},
|
||||||
|
[scheduleAppointmentMutation]
|
||||||
|
);
|
||||||
|
|
||||||
|
const reject = useCallback(
|
||||||
|
async (id: string, input: RejectAppointmentInput) => {
|
||||||
|
return await rejectAppointmentMutation.mutateAsync({ id, input });
|
||||||
|
},
|
||||||
|
[rejectAppointmentMutation]
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateAvailability = useCallback(
|
||||||
|
async (input: UpdateAvailabilityInput) => {
|
||||||
|
return await updateAdminAvailabilityMutation.mutateAsync(input);
|
||||||
|
},
|
||||||
|
[updateAdminAvailabilityMutation]
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchAppointments = useCallback(
|
||||||
|
async (email?: string) => {
|
||||||
|
const data = await listAppointments(email);
|
||||||
|
queryClient.setQueryData(["appointments", "list"], data);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
[queryClient]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Queries
|
||||||
|
availableDates: availableDatesQuery.data || [],
|
||||||
|
appointments: appointmentsQuery.data || [],
|
||||||
|
userAppointments: userAppointmentsQuery.data || [],
|
||||||
|
adminAvailability: adminAvailabilityQuery.data,
|
||||||
|
appointmentStats: appointmentStatsQuery.data,
|
||||||
|
|
||||||
|
// Query states
|
||||||
|
isLoadingAvailableDates: availableDatesQuery.isLoading,
|
||||||
|
isLoadingAppointments: appointmentsQuery.isLoading,
|
||||||
|
isLoadingUserAppointments: userAppointmentsQuery.isLoading,
|
||||||
|
isLoadingAdminAvailability: adminAvailabilityQuery.isLoading,
|
||||||
|
isLoadingStats: appointmentStatsQuery.isLoading,
|
||||||
|
|
||||||
|
// Query refetch functions
|
||||||
|
refetchAvailableDates: availableDatesQuery.refetch,
|
||||||
|
refetchAppointments: appointmentsQuery.refetch,
|
||||||
|
refetchUserAppointments: userAppointmentsQuery.refetch,
|
||||||
|
refetchAdminAvailability: adminAvailabilityQuery.refetch,
|
||||||
|
refetchStats: appointmentStatsQuery.refetch,
|
||||||
|
|
||||||
|
// Hooks for specific queries
|
||||||
|
useAppointmentDetail,
|
||||||
|
useJitsiMeetingInfo,
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
create,
|
||||||
|
schedule,
|
||||||
|
reject,
|
||||||
|
updateAvailability,
|
||||||
|
fetchAppointments,
|
||||||
|
|
||||||
|
// Mutation states
|
||||||
|
isCreating: createAppointmentMutation.isPending,
|
||||||
|
isScheduling: scheduleAppointmentMutation.isPending,
|
||||||
|
isRejecting: rejectAppointmentMutation.isPending,
|
||||||
|
isUpdatingAvailability: updateAdminAvailabilityMutation.isPending,
|
||||||
|
|
||||||
|
// Direct mutation access (if needed)
|
||||||
|
createAppointmentMutation,
|
||||||
|
scheduleAppointmentMutation,
|
||||||
|
rejectAppointmentMutation,
|
||||||
|
updateAdminAvailabilityMutation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
229
hooks/useAuth.ts
Normal file
229
hooks/useAuth.ts
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useCallback, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
loginUser,
|
||||||
|
registerUser,
|
||||||
|
verifyOtp,
|
||||||
|
resendOtp,
|
||||||
|
forgotPassword,
|
||||||
|
verifyPasswordResetOtp,
|
||||||
|
resetPassword,
|
||||||
|
refreshToken,
|
||||||
|
getStoredTokens,
|
||||||
|
getStoredUser,
|
||||||
|
storeTokens,
|
||||||
|
storeUser,
|
||||||
|
clearAuthData,
|
||||||
|
isTokenExpired,
|
||||||
|
hasValidAuth,
|
||||||
|
} from "@/lib/actions/auth";
|
||||||
|
import type {
|
||||||
|
LoginInput,
|
||||||
|
RegisterInput,
|
||||||
|
VerifyOtpInput,
|
||||||
|
ResendOtpInput,
|
||||||
|
ForgotPasswordInput,
|
||||||
|
VerifyPasswordResetOtpInput,
|
||||||
|
ResetPasswordInput,
|
||||||
|
} from "@/lib/schema/auth";
|
||||||
|
import type { User } from "@/lib/models/auth";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// Get current user from storage
|
||||||
|
const { data: user } = useQuery<User | null>({
|
||||||
|
queryKey: ["auth", "user"],
|
||||||
|
queryFn: () => getStoredUser(),
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if user is authenticated with valid token
|
||||||
|
const isAuthenticated = !!user && hasValidAuth();
|
||||||
|
|
||||||
|
// Check if user is admin (check multiple possible field names)
|
||||||
|
const isAdmin =
|
||||||
|
user?.is_admin === true ||
|
||||||
|
(user as any)?.isAdmin === true ||
|
||||||
|
(user as any)?.is_staff === true ||
|
||||||
|
(user as any)?.isStaff === true ||
|
||||||
|
(user as any)?.is_superuser === true ||
|
||||||
|
(user as any)?.isSuperuser === true;
|
||||||
|
|
||||||
|
// Login mutation
|
||||||
|
const loginMutation = useMutation({
|
||||||
|
mutationFn: (input: LoginInput) => loginUser(input),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data.tokens && data.user) {
|
||||||
|
storeTokens(data.tokens);
|
||||||
|
storeUser(data.user);
|
||||||
|
queryClient.setQueryData(["auth", "user"], data.user);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["auth"] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register mutation
|
||||||
|
const registerMutation = useMutation({
|
||||||
|
mutationFn: (input: RegisterInput) => registerUser(input),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify OTP mutation
|
||||||
|
const verifyOtpMutation = useMutation({
|
||||||
|
mutationFn: (input: VerifyOtpInput) => verifyOtp(input),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data.tokens && data.user) {
|
||||||
|
storeTokens(data.tokens);
|
||||||
|
storeUser(data.user);
|
||||||
|
queryClient.setQueryData(["auth", "user"], data.user);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["auth"] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resend OTP mutation
|
||||||
|
const resendOtpMutation = useMutation({
|
||||||
|
mutationFn: (input: ResendOtpInput) => resendOtp(input),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Forgot password mutation
|
||||||
|
const forgotPasswordMutation = useMutation({
|
||||||
|
mutationFn: (input: ForgotPasswordInput) => forgotPassword(input),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify password reset OTP mutation
|
||||||
|
const verifyPasswordResetOtpMutation = useMutation({
|
||||||
|
mutationFn: (input: VerifyPasswordResetOtpInput) => verifyPasswordResetOtp(input),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset password mutation
|
||||||
|
const resetPasswordMutation = useMutation({
|
||||||
|
mutationFn: (input: ResetPasswordInput) => resetPassword(input),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh token mutation
|
||||||
|
const refreshTokenMutation = useMutation({
|
||||||
|
mutationFn: (refresh: string) => refreshToken({ refresh }),
|
||||||
|
onSuccess: (tokens) => {
|
||||||
|
storeTokens(tokens);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["auth"] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
// If refresh fails, logout
|
||||||
|
clearAuthData();
|
||||||
|
queryClient.clear();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logout function
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
clearAuthData();
|
||||||
|
queryClient.clear();
|
||||||
|
// Don't redirect here - let components handle redirect as needed
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
|
// Auto-logout if token is expired or missing
|
||||||
|
useEffect(() => {
|
||||||
|
const checkAuth = () => {
|
||||||
|
const tokens = getStoredTokens();
|
||||||
|
const storedUser = getStoredUser();
|
||||||
|
|
||||||
|
// If user exists but no token or token is expired, logout
|
||||||
|
if (storedUser && (!tokens.access || isTokenExpired(tokens.access))) {
|
||||||
|
// Try to refresh token first if refresh token exists
|
||||||
|
if (tokens.refresh && !isTokenExpired(tokens.refresh)) {
|
||||||
|
refreshTokenMutation.mutate(tokens.refresh, {
|
||||||
|
onError: () => {
|
||||||
|
// If refresh fails, logout
|
||||||
|
clearAuthData();
|
||||||
|
queryClient.clear();
|
||||||
|
toast.error("Your session has expired. Please log in again.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// No valid refresh token, logout immediately
|
||||||
|
clearAuthData();
|
||||||
|
queryClient.clear();
|
||||||
|
toast.error("Your session has expired. Please log in again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check immediately
|
||||||
|
checkAuth();
|
||||||
|
|
||||||
|
// Check every 30 seconds
|
||||||
|
const interval = setInterval(checkAuth, 30000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [queryClient, refreshTokenMutation]);
|
||||||
|
|
||||||
|
// Login function
|
||||||
|
const login = useCallback(
|
||||||
|
async (input: LoginInput) => {
|
||||||
|
try {
|
||||||
|
const result = await loginMutation.mutateAsync(input);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loginMutation]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register function
|
||||||
|
const register = useCallback(
|
||||||
|
async (input: RegisterInput) => {
|
||||||
|
try {
|
||||||
|
const result = await registerMutation.mutateAsync(input);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[registerMutation]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify OTP function
|
||||||
|
const verifyOtpCode = useCallback(
|
||||||
|
async (input: VerifyOtpInput) => {
|
||||||
|
try {
|
||||||
|
const result = await verifyOtpMutation.mutateAsync(input);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[verifyOtpMutation]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
user,
|
||||||
|
isAuthenticated,
|
||||||
|
isAdmin,
|
||||||
|
isLoading: loginMutation.isPending || registerMutation.isPending,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
verifyOtp: verifyOtpCode,
|
||||||
|
|
||||||
|
// Mutations (for direct access if needed)
|
||||||
|
loginMutation,
|
||||||
|
registerMutation,
|
||||||
|
verifyOtpMutation,
|
||||||
|
resendOtpMutation,
|
||||||
|
forgotPasswordMutation,
|
||||||
|
verifyPasswordResetOtpMutation,
|
||||||
|
resetPasswordMutation,
|
||||||
|
refreshTokenMutation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
388
lib/actions/appointments.ts
Normal file
388
lib/actions/appointments.ts
Normal file
@ -0,0 +1,388 @@
|
|||||||
|
import { API_ENDPOINTS } from "@/lib/api_urls";
|
||||||
|
import { getStoredTokens } from "./auth";
|
||||||
|
import type {
|
||||||
|
CreateAppointmentInput,
|
||||||
|
ScheduleAppointmentInput,
|
||||||
|
RejectAppointmentInput,
|
||||||
|
UpdateAvailabilityInput,
|
||||||
|
} from "@/lib/schema/appointments";
|
||||||
|
import type {
|
||||||
|
Appointment,
|
||||||
|
AppointmentResponse,
|
||||||
|
AppointmentsListResponse,
|
||||||
|
AvailableDatesResponse,
|
||||||
|
AdminAvailability,
|
||||||
|
AppointmentStats,
|
||||||
|
JitsiMeetingInfo,
|
||||||
|
ApiError,
|
||||||
|
} from "@/lib/models/appointments";
|
||||||
|
|
||||||
|
// Helper function to extract error message from API response
|
||||||
|
function extractErrorMessage(error: ApiError): string {
|
||||||
|
if (error.detail) {
|
||||||
|
if (Array.isArray(error.detail)) {
|
||||||
|
return error.detail.join(", ");
|
||||||
|
}
|
||||||
|
return String(error.detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (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 while creating the 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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(API_ENDPOINTS.meetings.createAppointment, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${tokens.access}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: AppointmentResponse = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle different response formats
|
||||||
|
if (data.appointment) {
|
||||||
|
return data.appointment;
|
||||||
|
}
|
||||||
|
if ((data as any).data) {
|
||||||
|
return (data as any).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If appointment is returned directly
|
||||||
|
return data as unknown as Appointment;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get available dates
|
||||||
|
export async function getAvailableDates(): Promise<string[]> {
|
||||||
|
const response = await fetch(API_ENDPOINTS.meetings.availableDates, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: AvailableDatesResponse | string[] = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// API returns array of dates in YYYY-MM-DD format
|
||||||
|
if (Array.isArray(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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = email
|
||||||
|
? `${API_ENDPOINTS.meetings.listAppointments}?email=${encodeURIComponent(email)}`
|
||||||
|
: API_ENDPOINTS.meetings.listAppointments;
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${tokens.access}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(API_ENDPOINTS.meetings.userAppointments, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${tokens.access}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_ENDPOINTS.meetings.listAppointments}${id}/`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${tokens.access}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: AppointmentResponse = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.appointment) {
|
||||||
|
return data.appointment;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as unknown as 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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_ENDPOINTS.meetings.listAppointments}${id}/schedule/`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${tokens.access}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: AppointmentResponse = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.appointment) {
|
||||||
|
return data.appointment;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as unknown as 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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_ENDPOINTS.meetings.listAppointments}${id}/reject/`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${tokens.access}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: AppointmentResponse = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.base}admin/availability/`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${tokens.access}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: AdminAvailability = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update admin availability
|
||||||
|
export async function updateAdminAvailability(
|
||||||
|
input: UpdateAvailabilityInput
|
||||||
|
): Promise<AdminAvailability> {
|
||||||
|
const tokens = getStoredTokens();
|
||||||
|
|
||||||
|
if (!tokens.access) {
|
||||||
|
throw new Error("Authentication required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_ENDPOINTS.meetings.base}admin/availability/`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${tokens.access}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: AdminAvailability = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get appointment stats (Admin only)
|
||||||
|
export async function getAppointmentStats(): Promise<AppointmentStats> {
|
||||||
|
const tokens = getStoredTokens();
|
||||||
|
|
||||||
|
if (!tokens.access) {
|
||||||
|
throw new Error("Authentication required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_ENDPOINTS.meetings.listAppointments}stats/`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${tokens.access}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: AppointmentStats = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_ENDPOINTS.meetings.listAppointments}${id}/jitsi-meeting/`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${tokens.access}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: JitsiMeetingInfo = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
371
lib/actions/auth.ts
Normal file
371
lib/actions/auth.ts
Normal file
@ -0,0 +1,371 @@
|
|||||||
|
import { API_ENDPOINTS } from "@/lib/api_urls";
|
||||||
|
import type {
|
||||||
|
RegisterInput,
|
||||||
|
VerifyOtpInput,
|
||||||
|
LoginInput,
|
||||||
|
ResendOtpInput,
|
||||||
|
ForgotPasswordInput,
|
||||||
|
VerifyPasswordResetOtpInput,
|
||||||
|
ResetPasswordInput,
|
||||||
|
TokenRefreshInput,
|
||||||
|
} from "@/lib/schema/auth";
|
||||||
|
import type { AuthResponse, ApiError, AuthTokens, User } from "@/lib/models/auth";
|
||||||
|
|
||||||
|
// Helper function to extract error message from API response
|
||||||
|
function extractErrorMessage(error: ApiError): string {
|
||||||
|
// Check for main error messages
|
||||||
|
if (error.detail) {
|
||||||
|
// Handle both string and array formats
|
||||||
|
if (Array.isArray(error.detail)) {
|
||||||
|
return error.detail.join(", ");
|
||||||
|
}
|
||||||
|
return String(error.detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.message) {
|
||||||
|
if (Array.isArray(error.message)) {
|
||||||
|
return error.message.join(", ");
|
||||||
|
}
|
||||||
|
return String(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.error) {
|
||||||
|
if (Array.isArray(error.error)) {
|
||||||
|
return error.error.join(", ");
|
||||||
|
}
|
||||||
|
return String(error.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for field-specific errors (common in Django REST Framework)
|
||||||
|
const fieldErrors: string[] = [];
|
||||||
|
Object.keys(error).forEach((key) => {
|
||||||
|
if (key !== "detail" && key !== "message" && key !== "error") {
|
||||||
|
const fieldError = error[key];
|
||||||
|
if (Array.isArray(fieldError)) {
|
||||||
|
fieldErrors.push(`${key}: ${fieldError.join(", ")}`);
|
||||||
|
} else if (typeof fieldError === "string") {
|
||||||
|
fieldErrors.push(`${key}: ${fieldError}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fieldErrors.length > 0) {
|
||||||
|
return fieldErrors.join(". ");
|
||||||
|
}
|
||||||
|
|
||||||
|
return "An error occurred";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to handle API responses
|
||||||
|
async function handleResponse<T>(response: Response): Promise<T> {
|
||||||
|
let data: any;
|
||||||
|
|
||||||
|
try {
|
||||||
|
data = await response.json();
|
||||||
|
} catch {
|
||||||
|
// If response is not JSON, use status text
|
||||||
|
throw new Error(response.statusText || "An error occurred");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error: ApiError = data;
|
||||||
|
const errorMessage = extractErrorMessage(error);
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to normalize auth response
|
||||||
|
function normalizeAuthResponse(data: AuthResponse): AuthResponse {
|
||||||
|
// Normalize tokens: if tokens are at root level, move them to tokens object
|
||||||
|
if (data.access && data.refresh && !data.tokens) {
|
||||||
|
data.tokens = {
|
||||||
|
access: data.access,
|
||||||
|
refresh: data.refresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize user: only map isVerified to is_verified if needed
|
||||||
|
if (data.user) {
|
||||||
|
const user = data.user as any;
|
||||||
|
if (user.isVerified !== undefined && user.is_verified === undefined) {
|
||||||
|
user.is_verified = user.isVerified;
|
||||||
|
}
|
||||||
|
data.user = user;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register a new user
|
||||||
|
export async function registerUser(input: RegisterInput): Promise<AuthResponse> {
|
||||||
|
const response = await fetch(API_ENDPOINTS.auth.register, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle response - check if it's a 500 error that might indicate OTP sending failure
|
||||||
|
// but user registration might have succeeded
|
||||||
|
if (!response.ok && response.status === 500) {
|
||||||
|
try {
|
||||||
|
const data = await response.json();
|
||||||
|
// If the error message mentions OTP or email sending, it might be a partial success
|
||||||
|
const errorMessage = extractErrorMessage(data);
|
||||||
|
if (errorMessage.toLowerCase().includes("otp") ||
|
||||||
|
errorMessage.toLowerCase().includes("email") ||
|
||||||
|
errorMessage.toLowerCase().includes("send") ||
|
||||||
|
errorMessage.toLowerCase().includes("ssl") ||
|
||||||
|
errorMessage.toLowerCase().includes("certificate")) {
|
||||||
|
// Return a partial success response - user might be created, allow OTP resend
|
||||||
|
// This allows the user to proceed to OTP verification and use resend OTP
|
||||||
|
return {
|
||||||
|
message: "User registered, but OTP email could not be sent. Please use resend OTP.",
|
||||||
|
} as AuthResponse;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If we can't parse the error, continue to normal error handling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return handleResponse<AuthResponse>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify OTP
|
||||||
|
export async function verifyOtp(input: VerifyOtpInput): Promise<AuthResponse> {
|
||||||
|
const response = await fetch(API_ENDPOINTS.auth.verifyOtp, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await handleResponse<AuthResponse>(response);
|
||||||
|
return normalizeAuthResponse(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login user
|
||||||
|
export async function loginUser(input: LoginInput): Promise<AuthResponse> {
|
||||||
|
const response = await fetch(API_ENDPOINTS.auth.login, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await handleResponse<AuthResponse>(response);
|
||||||
|
return normalizeAuthResponse(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resend OTP
|
||||||
|
export async function resendOtp(input: ResendOtpInput): Promise<AuthResponse> {
|
||||||
|
const response = await fetch(API_ENDPOINTS.auth.resendOtp, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
|
||||||
|
return handleResponse<AuthResponse>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forgot password
|
||||||
|
export async function forgotPassword(input: ForgotPasswordInput): Promise<AuthResponse> {
|
||||||
|
const response = await fetch(API_ENDPOINTS.auth.forgotPassword, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
|
||||||
|
return handleResponse<AuthResponse>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password reset OTP
|
||||||
|
export async function verifyPasswordResetOtp(
|
||||||
|
input: VerifyPasswordResetOtpInput
|
||||||
|
): Promise<AuthResponse> {
|
||||||
|
const response = await fetch(API_ENDPOINTS.auth.verifyPasswordResetOtp, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
|
||||||
|
return handleResponse<AuthResponse>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset password
|
||||||
|
export async function resetPassword(input: ResetPasswordInput): Promise<AuthResponse> {
|
||||||
|
const response = await fetch(API_ENDPOINTS.auth.resetPassword, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
|
||||||
|
return handleResponse<AuthResponse>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh access token
|
||||||
|
export async function refreshToken(input: TokenRefreshInput): Promise<AuthTokens> {
|
||||||
|
const response = await fetch(API_ENDPOINTS.auth.tokenRefresh, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
|
||||||
|
return handleResponse<AuthTokens>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode JWT token to check expiration
|
||||||
|
function decodeJWT(token: string): { exp?: number; [key: string]: any } | null {
|
||||||
|
try {
|
||||||
|
const parts = token.split(".");
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
|
||||||
|
const payload = parts[1];
|
||||||
|
const decoded = JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/")));
|
||||||
|
return decoded;
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token is expired
|
||||||
|
export function isTokenExpired(token: string | null): boolean {
|
||||||
|
if (!token) return true;
|
||||||
|
|
||||||
|
const decoded = decodeJWT(token);
|
||||||
|
if (!decoded || !decoded.exp) return true;
|
||||||
|
|
||||||
|
// exp is in seconds, Date.now() is in milliseconds
|
||||||
|
const expirationTime = decoded.exp * 1000;
|
||||||
|
const currentTime = Date.now();
|
||||||
|
|
||||||
|
// Consider token expired if it expires within the next 5 seconds (buffer)
|
||||||
|
return currentTime >= (expirationTime - 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get stored tokens
|
||||||
|
export function getStoredTokens(): { access: string | null; refresh: string | null } {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return { access: null, refresh: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
access: localStorage.getItem("auth_access_token"),
|
||||||
|
refresh: localStorage.getItem("auth_refresh_token"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has valid authentication
|
||||||
|
export function hasValidAuth(): boolean {
|
||||||
|
const tokens = getStoredTokens();
|
||||||
|
if (!tokens.access) return false;
|
||||||
|
|
||||||
|
return !isTokenExpired(tokens.access);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store tokens
|
||||||
|
export function storeTokens(tokens: AuthTokens): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
localStorage.setItem("auth_access_token", tokens.access);
|
||||||
|
localStorage.setItem("auth_refresh_token", tokens.refresh);
|
||||||
|
|
||||||
|
// Also set cookies for middleware
|
||||||
|
document.cookie = `auth_access_token=${tokens.access}; path=/; max-age=${7 * 24 * 60 * 60}; SameSite=Lax`;
|
||||||
|
document.cookie = `auth_refresh_token=${tokens.refresh}; path=/; max-age=${30 * 24 * 60 * 60}; SameSite=Lax`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store user
|
||||||
|
export function storeUser(user: User): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
localStorage.setItem("auth_user", JSON.stringify(user));
|
||||||
|
document.cookie = `auth_user=${encodeURIComponent(JSON.stringify(user))}; path=/; max-age=${7 * 24 * 60 * 60}; SameSite=Lax`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get stored user
|
||||||
|
export function getStoredUser(): User | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
|
||||||
|
const userStr = localStorage.getItem("auth_user");
|
||||||
|
if (!userStr) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(userStr) as User;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear auth data
|
||||||
|
export function clearAuthData(): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
localStorage.removeItem("auth_access_token");
|
||||||
|
localStorage.removeItem("auth_refresh_token");
|
||||||
|
localStorage.removeItem("auth_user");
|
||||||
|
|
||||||
|
// Also clear cookies
|
||||||
|
document.cookie = "auth_access_token=; path=/; max-age=0";
|
||||||
|
document.cookie = "auth_refresh_token=; path=/; max-age=0";
|
||||||
|
document.cookie = "auth_user=; path=/; max-age=0";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get auth header for API requests
|
||||||
|
export function getAuthHeader(): { Authorization: string } | {} {
|
||||||
|
const tokens = getStoredTokens();
|
||||||
|
if (tokens.access && !isTokenExpired(tokens.access)) {
|
||||||
|
return { Authorization: `Bearer ${tokens.access}` };
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all users (Admin only)
|
||||||
|
export async function getAllUsers(): Promise<User[]> {
|
||||||
|
const tokens = getStoredTokens();
|
||||||
|
|
||||||
|
if (!tokens.access) {
|
||||||
|
throw new Error("Authentication required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(API_ENDPOINTS.auth.allUsers, {
|
||||||
|
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.users) {
|
||||||
|
return data.users;
|
||||||
|
}
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
33
lib/api_urls.ts
Normal file
33
lib/api_urls.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
// Get API base URL from environment variable
|
||||||
|
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: {
|
||||||
|
base: `${API_BASE_URL}/auth/`,
|
||||||
|
register: `${API_BASE_URL}/auth/register/`,
|
||||||
|
verifyOtp: `${API_BASE_URL}/auth/verify-otp/`,
|
||||||
|
login: `${API_BASE_URL}/auth/login/`,
|
||||||
|
resendOtp: `${API_BASE_URL}/auth/resend-otp/`,
|
||||||
|
forgotPassword: `${API_BASE_URL}/auth/forgot-password/`,
|
||||||
|
verifyPasswordResetOtp: `${API_BASE_URL}/auth/verify-password-reset-otp/`,
|
||||||
|
resetPassword: `${API_BASE_URL}/auth/reset-password/`,
|
||||||
|
tokenRefresh: `${API_BASE_URL}/auth/token/refresh/`,
|
||||||
|
allUsers: `${API_BASE_URL}/auth/all-users/`,
|
||||||
|
},
|
||||||
|
meetings: {
|
||||||
|
base: `${API_BASE_URL}/meetings/`,
|
||||||
|
availableDates: `${API_BASE_URL}/meetings/appointments/available-dates/`,
|
||||||
|
createAppointment: `${API_BASE_URL}/meetings/appointments/create/`,
|
||||||
|
listAppointments: `${API_BASE_URL}/meetings/appointments/`,
|
||||||
|
userAppointments: `${API_BASE_URL}/meetings/user/appointments/`,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
79
lib/models/appointments.ts
Normal file
79
lib/models/appointments.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
// Appointment Models
|
||||||
|
|
||||||
|
export interface Appointment {
|
||||||
|
id: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string;
|
||||||
|
reason?: string;
|
||||||
|
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;
|
||||||
|
scheduled_duration?: number;
|
||||||
|
rejection_reason?: string;
|
||||||
|
jitsi_meet_url?: string;
|
||||||
|
jitsi_room_id?: string;
|
||||||
|
has_jitsi_meeting?: boolean;
|
||||||
|
can_join_meeting?: boolean;
|
||||||
|
meeting_status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppointmentResponse {
|
||||||
|
appointment?: Appointment;
|
||||||
|
message?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppointmentsListResponse {
|
||||||
|
appointments: Appointment[];
|
||||||
|
count?: number;
|
||||||
|
next?: string | null;
|
||||||
|
previous?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AvailableDatesResponse {
|
||||||
|
dates: string[]; // YYYY-MM-DD format
|
||||||
|
available_days?: number[]; // 0-6 (Monday-Sunday)
|
||||||
|
available_days_display?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminAvailability {
|
||||||
|
available_days: number[]; // 0-6 (Monday-Sunday)
|
||||||
|
available_days_display: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppointmentStats {
|
||||||
|
total_requests: number;
|
||||||
|
pending_review: number;
|
||||||
|
scheduled: number;
|
||||||
|
rejected: number;
|
||||||
|
completion_rate: number;
|
||||||
|
users?: number; // Total users count from API
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JitsiMeetingInfo {
|
||||||
|
meeting_url: string;
|
||||||
|
room_id: string;
|
||||||
|
scheduled_time: string;
|
||||||
|
duration: string;
|
||||||
|
can_join: boolean;
|
||||||
|
meeting_status: string;
|
||||||
|
join_instructions: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
detail?: string | string[];
|
||||||
|
message?: string | string[];
|
||||||
|
error?: string;
|
||||||
|
preferred_dates?: string[];
|
||||||
|
preferred_time_slots?: string[];
|
||||||
|
email?: string[];
|
||||||
|
first_name?: string[];
|
||||||
|
last_name?: string[];
|
||||||
|
[key: string]: string | string[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
56
lib/models/auth.ts
Normal file
56
lib/models/auth.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
// Authentication Response Models
|
||||||
|
export interface AuthTokens {
|
||||||
|
access: string;
|
||||||
|
refresh: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
email: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
phone_number?: string;
|
||||||
|
is_admin?: boolean;
|
||||||
|
isAdmin?: boolean; // API uses camelCase
|
||||||
|
is_staff?: boolean;
|
||||||
|
isStaff?: boolean; // API uses camelCase
|
||||||
|
is_superuser?: boolean;
|
||||||
|
isSuperuser?: boolean; // API uses camelCase
|
||||||
|
is_verified?: boolean;
|
||||||
|
isVerified?: boolean; // API uses camelCase
|
||||||
|
is_active?: boolean;
|
||||||
|
isActive?: boolean; // API uses camelCase
|
||||||
|
date_joined?: string;
|
||||||
|
last_login?: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
message?: string;
|
||||||
|
access?: string; // Tokens can be at root level
|
||||||
|
refresh?: string; // Tokens can be at root level
|
||||||
|
tokens?: AuthTokens; // Or nested in tokens object
|
||||||
|
user?: User;
|
||||||
|
detail?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
detail?: string;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
email?: string[];
|
||||||
|
password?: string[];
|
||||||
|
password2?: string[];
|
||||||
|
otp?: string[];
|
||||||
|
[key: string]: string | string[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token Storage Keys
|
||||||
|
export const TOKEN_STORAGE_KEYS = {
|
||||||
|
ACCESS_TOKEN: "auth_access_token",
|
||||||
|
REFRESH_TOKEN: "auth_refresh_token",
|
||||||
|
USER: "auth_user",
|
||||||
|
} as const;
|
||||||
|
|
||||||
43
lib/schema/appointments.ts
Normal file
43
lib/schema/appointments.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// Create Appointment Schema
|
||||||
|
export const createAppointmentSchema = z.object({
|
||||||
|
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"))
|
||||||
|
.min(1, "At least one preferred date is required"),
|
||||||
|
preferred_time_slots: z
|
||||||
|
.array(z.enum(["morning", "afternoon", "evening"]))
|
||||||
|
.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)
|
||||||
|
export const scheduleAppointmentSchema = z.object({
|
||||||
|
scheduled_datetime: z.string().datetime("Invalid datetime format"),
|
||||||
|
scheduled_duration: z.number().int().positive().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ScheduleAppointmentInput = z.infer<typeof scheduleAppointmentSchema>;
|
||||||
|
|
||||||
|
// Reject Appointment Schema (Admin only)
|
||||||
|
export const rejectAppointmentSchema = z.object({
|
||||||
|
rejection_reason: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RejectAppointmentInput = z.infer<typeof rejectAppointmentSchema>;
|
||||||
|
|
||||||
|
// Update Admin Availability Schema
|
||||||
|
export const updateAvailabilitySchema = z.object({
|
||||||
|
available_days: z
|
||||||
|
.array(z.number().int().min(0).max(6))
|
||||||
|
.min(1, "At least one day must be selected"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpdateAvailabilityInput = z.infer<typeof updateAvailabilitySchema>;
|
||||||
|
|
||||||
80
lib/schema/auth.ts
Normal file
80
lib/schema/auth.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// Register Schema
|
||||||
|
export const registerSchema = z
|
||||||
|
.object({
|
||||||
|
email: z.string().email("Invalid email address"),
|
||||||
|
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(),
|
||||||
|
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||||
|
password2: z.string().min(8, "Password confirmation is required"),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.password2, {
|
||||||
|
message: "Passwords do not match",
|
||||||
|
path: ["password2"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RegisterInput = z.infer<typeof registerSchema>;
|
||||||
|
|
||||||
|
// Verify OTP Schema
|
||||||
|
export const verifyOtpSchema = z.object({
|
||||||
|
email: z.string().email("Invalid email address"),
|
||||||
|
otp: z.string().min(6, "OTP must be 6 digits").max(6, "OTP must be 6 digits"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type VerifyOtpInput = z.infer<typeof verifyOtpSchema>;
|
||||||
|
|
||||||
|
// Login Schema
|
||||||
|
export const loginSchema = z.object({
|
||||||
|
email: z.string().email("Invalid email address"),
|
||||||
|
password: z.string().min(1, "Password is required"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type LoginInput = z.infer<typeof loginSchema>;
|
||||||
|
|
||||||
|
// Resend OTP Schema
|
||||||
|
export const resendOtpSchema = z.object({
|
||||||
|
email: z.string().email("Invalid email address"),
|
||||||
|
context: z.enum(["registration", "password_reset"]).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ResendOtpInput = z.infer<typeof resendOtpSchema>;
|
||||||
|
|
||||||
|
// Forgot Password Schema
|
||||||
|
export const forgotPasswordSchema = z.object({
|
||||||
|
email: z.string().email("Invalid email address"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ForgotPasswordInput = z.infer<typeof forgotPasswordSchema>;
|
||||||
|
|
||||||
|
// Verify Password Reset OTP Schema
|
||||||
|
export const verifyPasswordResetOtpSchema = z.object({
|
||||||
|
email: z.string().email("Invalid email address"),
|
||||||
|
otp: z.string().min(6, "OTP must be 6 digits").max(6, "OTP must be 6 digits"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type VerifyPasswordResetOtpInput = z.infer<typeof verifyPasswordResetOtpSchema>;
|
||||||
|
|
||||||
|
// Reset Password Schema
|
||||||
|
export const resetPasswordSchema = z
|
||||||
|
.object({
|
||||||
|
email: z.string().email("Invalid email address"),
|
||||||
|
otp: z.string().min(6, "OTP must be 6 digits").max(6, "OTP must be 6 digits"),
|
||||||
|
new_password: z.string().min(8, "Password must be at least 8 characters"),
|
||||||
|
confirm_password: z.string().min(8, "Password confirmation is required"),
|
||||||
|
})
|
||||||
|
.refine((data) => data.new_password === data.confirm_password, {
|
||||||
|
message: "Passwords do not match",
|
||||||
|
path: ["confirm_password"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
|
||||||
|
|
||||||
|
// Token Refresh Schema
|
||||||
|
export const tokenRefreshSchema = z.object({
|
||||||
|
refresh: z.string().min(1, "Refresh token is required"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TokenRefreshInput = z.infer<typeof tokenRefreshSchema>;
|
||||||
|
|
||||||
75
middleware.ts
Normal file
75
middleware.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
// Get tokens from cookies
|
||||||
|
const accessToken = request.cookies.get("auth_access_token")?.value;
|
||||||
|
const userStr = request.cookies.get("auth_user")?.value;
|
||||||
|
|
||||||
|
const isAuthenticated = !!accessToken;
|
||||||
|
let isAdmin = false;
|
||||||
|
|
||||||
|
if (userStr) {
|
||||||
|
try {
|
||||||
|
// Decode the user string if it's URL encoded
|
||||||
|
const decodedUserStr = decodeURIComponent(userStr);
|
||||||
|
const user = JSON.parse(decodedUserStr);
|
||||||
|
// Check for admin status using multiple possible field names
|
||||||
|
// Admin users must be verified (is_verified or isVerified must be true)
|
||||||
|
const isVerified = user.is_verified === true || user.isVerified === true;
|
||||||
|
const hasAdminRole =
|
||||||
|
user.is_admin === true ||
|
||||||
|
user.isAdmin === true ||
|
||||||
|
user.is_staff === true ||
|
||||||
|
user.isStaff === true ||
|
||||||
|
user.is_superuser === true ||
|
||||||
|
user.isSuperuser === true;
|
||||||
|
|
||||||
|
// User is admin only if they have admin role AND are verified
|
||||||
|
isAdmin = hasAdminRole && isVerified;
|
||||||
|
} catch {
|
||||||
|
// Invalid user data - silently fail and treat as non-admin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protected routes
|
||||||
|
const isProtectedRoute = pathname.startsWith("/user") || pathname.startsWith("/admin");
|
||||||
|
const isAdminRoute = pathname.startsWith("/admin");
|
||||||
|
const isUserRoute = pathname.startsWith("/user");
|
||||||
|
const isAuthRoute = pathname.startsWith("/login") || pathname.startsWith("/signup");
|
||||||
|
|
||||||
|
// Redirect unauthenticated users away from protected routes
|
||||||
|
if (isProtectedRoute && !isAuthenticated) {
|
||||||
|
const loginUrl = new URL("/login", request.url);
|
||||||
|
loginUrl.searchParams.set("redirect", pathname);
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect authenticated users away from auth routes
|
||||||
|
if (isAuthRoute && isAuthenticated) {
|
||||||
|
// Redirect based on user role
|
||||||
|
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/dashboard", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect non-admin users away from admin routes
|
||||||
|
if (isAdminRoute && isAuthenticated && !isAdmin) {
|
||||||
|
return NextResponse.redirect(new URL("/user/dashboard", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
@ -17,10 +17,12 @@
|
|||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@tanstack/react-query": "^5.90.10",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.552.0",
|
"lucide-react": "^0.552.0",
|
||||||
"next": "16.0.1",
|
"next": "16.0.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
@ -28,7 +30,8 @@
|
|||||||
"react-day-picker": "^9.11.1",
|
"react-day-picker": "^9.11.1",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1"
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
@ -23,6 +23,9 @@ importers:
|
|||||||
'@radix-ui/react-slot':
|
'@radix-ui/react-slot':
|
||||||
specifier: ^1.2.4
|
specifier: ^1.2.4
|
||||||
version: 1.2.4(@types/react@19.2.2)(react@19.2.0)
|
version: 1.2.4(@types/react@19.2.2)(react@19.2.0)
|
||||||
|
'@tanstack/react-query':
|
||||||
|
specifier: ^5.90.10
|
||||||
|
version: 5.90.10(react@19.2.0)
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
@ -35,6 +38,9 @@ importers:
|
|||||||
framer-motion:
|
framer-motion:
|
||||||
specifier: ^12.23.24
|
specifier: ^12.23.24
|
||||||
version: 12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
version: 12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
|
input-otp:
|
||||||
|
specifier: ^1.4.2
|
||||||
|
version: 1.4.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.552.0
|
specifier: ^0.552.0
|
||||||
version: 0.552.0(react@19.2.0)
|
version: 0.552.0(react@19.2.0)
|
||||||
@ -59,6 +65,9 @@ importers:
|
|||||||
tailwind-merge:
|
tailwind-merge:
|
||||||
specifier: ^3.3.1
|
specifier: ^3.3.1
|
||||||
version: 3.3.1
|
version: 3.3.1
|
||||||
|
zod:
|
||||||
|
specifier: ^4.1.12
|
||||||
|
version: 4.1.12
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4
|
specifier: ^4
|
||||||
@ -890,6 +899,14 @@ packages:
|
|||||||
'@tailwindcss/postcss@4.1.16':
|
'@tailwindcss/postcss@4.1.16':
|
||||||
resolution: {integrity: sha512-Qn3SFGPXYQMKR/UtqS+dqvPrzEeBZHrFA92maT4zijCVggdsXnDBMsPFJo1eArX3J+O+Gi+8pV4PkqjLCNBk3A==}
|
resolution: {integrity: sha512-Qn3SFGPXYQMKR/UtqS+dqvPrzEeBZHrFA92maT4zijCVggdsXnDBMsPFJo1eArX3J+O+Gi+8pV4PkqjLCNBk3A==}
|
||||||
|
|
||||||
|
'@tanstack/query-core@5.90.10':
|
||||||
|
resolution: {integrity: sha512-EhZVFu9rl7GfRNuJLJ3Y7wtbTnENsvzp+YpcAV7kCYiXni1v8qZh++lpw4ch4rrwC0u/EZRnBHIehzCGzwXDSQ==}
|
||||||
|
|
||||||
|
'@tanstack/react-query@5.90.10':
|
||||||
|
resolution: {integrity: sha512-BKLss9Y8PQ9IUjPYQiv3/Zmlx92uxffUOX8ZZNoQlCIZBJPT5M+GOMQj7xislvVQ6l1BstBjcX0XB/aHfFYVNw==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^18 || ^19
|
||||||
|
|
||||||
'@tybys/wasm-util@0.10.1':
|
'@tybys/wasm-util@0.10.1':
|
||||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||||
|
|
||||||
@ -1633,6 +1650,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
||||||
engines: {node: '>=0.8.19'}
|
engines: {node: '>=0.8.19'}
|
||||||
|
|
||||||
|
input-otp@1.4.2:
|
||||||
|
resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
|
||||||
internal-slot@1.1.0:
|
internal-slot@1.1.0:
|
||||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@ -3193,6 +3216,13 @@ snapshots:
|
|||||||
postcss: 8.5.6
|
postcss: 8.5.6
|
||||||
tailwindcss: 4.1.16
|
tailwindcss: 4.1.16
|
||||||
|
|
||||||
|
'@tanstack/query-core@5.90.10': {}
|
||||||
|
|
||||||
|
'@tanstack/react-query@5.90.10(react@19.2.0)':
|
||||||
|
dependencies:
|
||||||
|
'@tanstack/query-core': 5.90.10
|
||||||
|
react: 19.2.0
|
||||||
|
|
||||||
'@tybys/wasm-util@0.10.1':
|
'@tybys/wasm-util@0.10.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@ -4098,6 +4128,11 @@ snapshots:
|
|||||||
|
|
||||||
imurmurhash@0.1.4: {}
|
imurmurhash@0.1.4: {}
|
||||||
|
|
||||||
|
input-otp@1.4.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
|
||||||
|
dependencies:
|
||||||
|
react: 19.2.0
|
||||||
|
react-dom: 19.2.0(react@19.2.0)
|
||||||
|
|
||||||
internal-slot@1.1.0:
|
internal-slot@1.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user