feat/authentication #21
@ -17,6 +17,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useAppTheme } from "@/components/ThemeProvider";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function Header() {
|
||||
const pathname = usePathname();
|
||||
@ -25,6 +27,14 @@ export function Header() {
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
const { theme } = useAppTheme();
|
||||
const isDark = theme === "dark";
|
||||
const { logout } = useAuth();
|
||||
|
||||
const handleLogout = () => {
|
||||
setUserMenuOpen(false);
|
||||
logout();
|
||||
toast.success("Logged out successfully");
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
// Mock notifications data
|
||||
const notifications = [
|
||||
@ -209,10 +219,7 @@ export function Header() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
router.push("/");
|
||||
}}
|
||||
onClick={handleLogout}
|
||||
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"
|
||||
}`}
|
||||
|
||||
@ -14,6 +14,8 @@ import {
|
||||
Heart,
|
||||
} from "lucide-react";
|
||||
import { useAppTheme } from "@/components/ThemeProvider";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const navItems = [
|
||||
{ label: "Dashboard", icon: LayoutGrid, href: "/admin/dashboard" },
|
||||
@ -26,6 +28,14 @@ export default function SideNav() {
|
||||
const router = useRouter();
|
||||
const { theme } = useAppTheme();
|
||||
const isDark = theme === "dark";
|
||||
const { logout } = useAuth();
|
||||
|
||||
const handleLogout = () => {
|
||||
setOpen(false);
|
||||
logout();
|
||||
toast.success("Logged out successfully");
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
const getActiveIndex = () => {
|
||||
return navItems.findIndex((item) => pathname?.includes(item.href)) ?? -1;
|
||||
@ -176,10 +186,7 @@ export default function SideNav() {
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
router.push("/");
|
||||
}}
|
||||
onClick={handleLogout}
|
||||
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
|
||||
? "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";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
User,
|
||||
Video,
|
||||
FileText,
|
||||
MoreVertical,
|
||||
Search,
|
||||
CalendarCheck,
|
||||
X,
|
||||
Loader2,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useAppTheme } from "@/components/ThemeProvider";
|
||||
|
||||
interface User {
|
||||
ID: number;
|
||||
CreatedAt?: string;
|
||||
UpdatedAt?: string;
|
||||
DeletedAt?: string | null;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
location: string;
|
||||
date_of_birth?: string;
|
||||
is_admin?: boolean;
|
||||
bookings?: null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
import { listAppointments, scheduleAppointment, rejectAppointment } from "@/lib/actions/appointments";
|
||||
import { Input } from "@/components/ui/input";
|
||||
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 Booking() {
|
||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||
const router = useRouter();
|
||||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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 isDark = theme === "dark";
|
||||
|
||||
useEffect(() => {
|
||||
// Simulate API call
|
||||
const fetchBookings = async () => {
|
||||
setLoading(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// Mock API response
|
||||
const mockData: BookingsResponse = {
|
||||
bookings: [
|
||||
{
|
||||
ID: 1,
|
||||
CreatedAt: "2025-11-06T11:33:45.704633Z",
|
||||
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);
|
||||
try {
|
||||
const data = await listAppointments();
|
||||
setAppointments(data || []);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch appointments:", error);
|
||||
toast.error("Failed to load appointments. Please try again.");
|
||||
setAppointments([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchBookings();
|
||||
@ -137,8 +90,10 @@ export default function Booking() {
|
||||
return "bg-blue-500/20 text-blue-200";
|
||||
case "completed":
|
||||
return "bg-green-500/20 text-green-200";
|
||||
case "rejected":
|
||||
case "cancelled":
|
||||
return "bg-red-500/20 text-red-200";
|
||||
case "pending_review":
|
||||
case "pending":
|
||||
return "bg-yellow-500/20 text-yellow-200";
|
||||
default:
|
||||
@ -150,8 +105,10 @@ export default function Booking() {
|
||||
return "bg-blue-100 text-blue-700";
|
||||
case "completed":
|
||||
return "bg-green-100 text-green-700";
|
||||
case "rejected":
|
||||
case "cancelled":
|
||||
return "bg-red-100 text-red-700";
|
||||
case "pending_review":
|
||||
case "pending":
|
||||
return "bg-yellow-100 text-yellow-700";
|
||||
default:
|
||||
@ -159,41 +116,107 @@ export default function Booking() {
|
||||
}
|
||||
};
|
||||
|
||||
const getPaymentStatusColor = (status: string) => {
|
||||
const normalized = status.toLowerCase();
|
||||
if (isDark) {
|
||||
switch (normalized) {
|
||||
case "paid":
|
||||
return "bg-green-500/20 text-green-200";
|
||||
case "pending":
|
||||
return "bg-yellow-500/20 text-yellow-200";
|
||||
case "failed":
|
||||
return "bg-red-500/20 text-red-200";
|
||||
default:
|
||||
return "bg-gray-700 text-gray-200";
|
||||
const formatStatus = (status: string) => {
|
||||
return status.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase());
|
||||
};
|
||||
|
||||
const handleViewDetails = (appointment: Appointment) => {
|
||||
router.push(`/admin/booking/${appointment.id}`);
|
||||
};
|
||||
|
||||
const handleScheduleClick = (appointment: Appointment) => {
|
||||
setSelectedAppointment(appointment);
|
||||
setScheduledDate(undefined);
|
||||
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) {
|
||||
case "paid":
|
||||
return "bg-green-100 text-green-700";
|
||||
case "pending":
|
||||
return "bg-yellow-100 text-yellow-700";
|
||||
case "failed":
|
||||
return "bg-red-100 text-red-700";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-700";
|
||||
|
||||
setIsScheduling(true);
|
||||
try {
|
||||
// Combine date and time into ISO datetime string
|
||||
const [hours, minutes] = scheduledTime.split(":");
|
||||
const datetime = new Date(scheduledDate);
|
||||
datetime.setHours(parseInt(hours), parseInt(minutes), 0, 0);
|
||||
const isoString = datetime.toISOString();
|
||||
|
||||
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(
|
||||
(booking) =>
|
||||
booking.user.first_name
|
||||
const handleReject = async () => {
|
||||
if (!selectedAppointment) {
|
||||
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()
|
||||
.includes(searchTerm.toLowerCase()) ||
|
||||
booking.user.last_name
|
||||
appointment.last_name
|
||||
.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 (
|
||||
@ -202,7 +225,8 @@ export default function Booking() {
|
||||
{/* Main Content */}
|
||||
<main className="p-3 sm:p-4 md:p-6 lg:p-8">
|
||||
{/* Page Header */}
|
||||
<div className="mb-4 sm:mb-6 flex flex-col 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>
|
||||
<h1 className={`text-xl sm:text-2xl font-semibold mb-1 ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
Bookings
|
||||
@ -211,25 +235,32 @@ export default function Booking() {
|
||||
Manage and view all appointment bookings
|
||||
</p>
|
||||
</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 ${
|
||||
isDark ? "bg-rose-500 text-white hover:bg-rose-600" : "bg-gray-900 text-white hover:bg-gray-800"
|
||||
}`}>
|
||||
+ New Booking
|
||||
</button>
|
||||
</div>
|
||||
{/* Search Bar */}
|
||||
<div className="relative">
|
||||
<Search className={`absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 ${isDark ? "text-gray-400" : "text-gray-500"}`} />
|
||||
<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>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className={`animate-spin rounded-full h-8 w-8 border-b-2 ${isDark ? "border-gray-600" : "border-gray-400"}`}></div>
|
||||
</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"}`}>
|
||||
<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={`text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
{searchTerm
|
||||
? "Try adjusting your search terms"
|
||||
: "Create a new booking to get started"}
|
||||
: "No appointments have been created yet"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
@ -251,10 +282,10 @@ export default function Booking() {
|
||||
Status
|
||||
</th>
|
||||
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider hidden lg:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
Payment
|
||||
Preferred Dates
|
||||
</th>
|
||||
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider hidden xl:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
Amount
|
||||
Created
|
||||
</th>
|
||||
<th className={`px-3 sm:px-4 md:px-6 py-3 text-right text-xs font-medium uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
Actions
|
||||
@ -262,10 +293,11 @@ export default function Booking() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={`${isDark ? "bg-gray-800 divide-gray-700" : "bg-white divide-gray-200"}`}>
|
||||
{filteredBookings.map((booking) => (
|
||||
{filteredAppointments.map((appointment) => (
|
||||
<tr
|
||||
key={booking.ID}
|
||||
className={`transition-colors ${isDark ? "hover:bg-gray-700" : "hover:bg-gray-50"}`}
|
||||
key={appointment.id}
|
||||
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">
|
||||
<div className="flex items-center">
|
||||
@ -274,55 +306,103 @@ export default function Booking() {
|
||||
</div>
|
||||
<div className="ml-2 sm:ml-4 min-w-0">
|
||||
<div className={`text-xs sm:text-sm font-medium truncate ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
{booking.user.first_name} {booking.user.last_name}
|
||||
{appointment.first_name} {appointment.last_name}
|
||||
</div>
|
||||
<div className={`text-xs sm:text-sm truncate hidden sm:block ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
{booking.user.email}
|
||||
{appointment.email}
|
||||
</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"}`}>
|
||||
{formatDate(booking.scheduled_at)}
|
||||
{formatDate(appointment.scheduled_datetime)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap hidden md:table-cell">
|
||||
{appointment.scheduled_datetime ? (
|
||||
<>
|
||||
<div className={`text-xs sm:text-sm ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
{formatDate(booking.scheduled_at)}
|
||||
{formatDate(appointment.scheduled_datetime)}
|
||||
</div>
|
||||
<div className={`text-xs sm:text-sm flex items-center gap-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatTime(booking.scheduled_at)}
|
||||
{formatTime(appointment.scheduled_datetime)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={`text-xs sm:text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
Not scheduled
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm hidden lg:table-cell ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
{booking.duration} min
|
||||
{appointment.scheduled_duration ? `${appointment.scheduled_duration} min` : "-"}
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(
|
||||
booking.status
|
||||
appointment.status
|
||||
)}`}
|
||||
>
|
||||
{booking.status}
|
||||
{formatStatus(appointment.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap hidden lg:table-cell">
|
||||
<span
|
||||
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getPaymentStatusColor(
|
||||
booking.payment_status
|
||||
)}`}
|
||||
>
|
||||
{booking.payment_status}
|
||||
</span>
|
||||
<td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm hidden lg:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
{appointment.preferred_dates && appointment.preferred_dates.length > 0 ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{appointment.preferred_dates.slice(0, 2).map((date, idx) => (
|
||||
<span key={idx}>{formatDate(date)}</span>
|
||||
))}
|
||||
{appointment.preferred_dates.length > 2 && (
|
||||
<span className="text-xs">+{appointment.preferred_dates.length - 2} more</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
"-"
|
||||
)}
|
||||
</td>
|
||||
<td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm font-medium hidden xl:table-cell ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||
${booking.amount}
|
||||
<td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm hidden xl:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||
{formatDate(appointment.created_at)}
|
||||
</td>
|
||||
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex items-center justify-end gap-1 sm:gap-2">
|
||||
{booking.jitsi_room_url && (
|
||||
<div className="flex items-center justify-end gap-1 sm:gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
{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
|
||||
href={booking.jitsi_room_url}
|
||||
href={appointment.jitsi_meet_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`p-1.5 sm:p-2 rounded-lg transition-colors ${isDark ? "text-gray-300 hover:text-white hover:bg-gray-700" : "text-gray-600 hover:text-gray-900 hover:bg-gray-100"}`}
|
||||
@ -331,17 +411,6 @@ export default function Booking() {
|
||||
<Video className="w-4 h-4" />
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
@ -352,6 +421,166 @@ export default function Booking() {
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -20,6 +20,11 @@ import {
|
||||
ArrowDownRight,
|
||||
} from "lucide-react";
|
||||
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 {
|
||||
total_users: number;
|
||||
@ -30,6 +35,16 @@ interface DashboardStats {
|
||||
cancelled_bookings: number;
|
||||
total_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() {
|
||||
@ -40,86 +55,166 @@ export default function Dashboard() {
|
||||
const isDark = theme === "dark";
|
||||
|
||||
useEffect(() => {
|
||||
// Simulate API call
|
||||
const fetchStats = async () => {
|
||||
setLoading(true);
|
||||
// Simulate network delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
try {
|
||||
// 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
|
||||
const mockData: DashboardStats = {
|
||||
total_users: 3,
|
||||
active_users: 3,
|
||||
total_bookings: 6,
|
||||
upcoming_bookings: 6,
|
||||
// Calculate statistics
|
||||
// Use users count from appointment stats if available, otherwise use getAllUsers result
|
||||
const totalUsers = appointmentStats?.users ?? users.length;
|
||||
const activeUsers = users.filter(
|
||||
(user) => user.is_active === true || user.isActive === true
|
||||
).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,
|
||||
cancelled_bookings: 0,
|
||||
total_revenue: 0,
|
||||
monthly_revenue: 0,
|
||||
};
|
||||
|
||||
setStats(mockData);
|
||||
trends: {
|
||||
total_users: "0%",
|
||||
active_users: "0%",
|
||||
total_bookings: "0%",
|
||||
upcoming_bookings: "0",
|
||||
completed_bookings: "0%",
|
||||
cancelled_bookings: "0%",
|
||||
total_revenue: "0%",
|
||||
monthly_revenue: "0%",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
}, []);
|
||||
}, [timePeriod]);
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
title: "Total Users",
|
||||
value: stats?.total_users ?? 0,
|
||||
icon: Users,
|
||||
trend: "+12%",
|
||||
trend: stats?.trends.total_users ?? "0%",
|
||||
trendUp: true,
|
||||
},
|
||||
{
|
||||
title: "Active Users",
|
||||
value: stats?.active_users ?? 0,
|
||||
icon: UserCheck,
|
||||
trend: "+8%",
|
||||
trend: stats?.trends.active_users ?? "0%",
|
||||
trendUp: true,
|
||||
},
|
||||
{
|
||||
title: "Total Bookings",
|
||||
value: stats?.total_bookings ?? 0,
|
||||
icon: Calendar,
|
||||
trend: "+24%",
|
||||
trend: stats?.trends.total_bookings ?? "0%",
|
||||
trendUp: true,
|
||||
},
|
||||
{
|
||||
title: "Upcoming Bookings",
|
||||
value: stats?.upcoming_bookings ?? 0,
|
||||
icon: CalendarCheck,
|
||||
trend: "+6",
|
||||
trend: stats?.trends.upcoming_bookings ?? "0",
|
||||
trendUp: true,
|
||||
},
|
||||
{
|
||||
title: "Completed Bookings",
|
||||
value: stats?.completed_bookings ?? 0,
|
||||
icon: CalendarCheck,
|
||||
trend: "0%",
|
||||
trend: stats?.trends.completed_bookings ?? "0%",
|
||||
trendUp: true,
|
||||
},
|
||||
{
|
||||
title: "Cancelled Bookings",
|
||||
value: stats?.cancelled_bookings ?? 0,
|
||||
icon: CalendarX,
|
||||
trend: "0%",
|
||||
trend: stats?.trends.cancelled_bookings ?? "0%",
|
||||
trendUp: false,
|
||||
},
|
||||
{
|
||||
title: "Total Revenue",
|
||||
value: `$${stats?.total_revenue.toLocaleString() ?? 0}`,
|
||||
icon: DollarSign,
|
||||
trend: "+18%",
|
||||
trend: stats?.trends.total_revenue ?? "0%",
|
||||
trendUp: true,
|
||||
},
|
||||
{
|
||||
title: "Monthly Revenue",
|
||||
value: `$${stats?.monthly_revenue.toLocaleString() ?? 0}`,
|
||||
icon: TrendingUp,
|
||||
trend: "+32%",
|
||||
trend: stats?.trends.monthly_revenue ?? "0%",
|
||||
trendUp: true,
|
||||
},
|
||||
];
|
||||
|
||||
@ -1,20 +1,349 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, Suspense } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Heart, Eye, EyeOff, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from "@/components/ui/input-otp";
|
||||
import { Heart, Eye, EyeOff, X, Loader2, CheckCircle2 } from "lucide-react";
|
||||
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 { 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 isDark = theme === "dark";
|
||||
const [step, setStep] = useState<Step>("login");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showPassword2, setShowPassword2] = 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 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 (
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Centered White Card - Login Form */}
|
||||
{/* Centered White Card */}
|
||||
<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">
|
||||
Welcome back
|
||||
<h1 className="text-3xl font-bold bg-linear-to-r from-rose-600 via-pink-600 to-rose-600 bg-clip-text text-transparent mb-2">
|
||||
{step === "login" && "Welcome back"}
|
||||
{step === "signup" && "Create an account"}
|
||||
{step === "verify" && "Verify your email"}
|
||||
</h1>
|
||||
{/* Sign Up Prompt */}
|
||||
{/* Subtitle */}
|
||||
{step === "login" && (
|
||||
<p className={`mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||
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
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
{step === "signup" && (
|
||||
<p className={`mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||
Already have an account?{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStep("login")}
|
||||
className={`underline font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
|
||||
>
|
||||
Log in
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{step === "verify" && registeredEmail && (
|
||||
<p className={`mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||
We've sent a verification code to <strong>{registeredEmail}</strong>
|
||||
</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>
|
||||
{/* 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'}`}
|
||||
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"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
@ -70,10 +426,8 @@ export default function Login() {
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<form className="space-y-6" onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
router.push("/");
|
||||
}}>
|
||||
{step === "login" && (
|
||||
<form className="space-y-6" onSubmit={handleLogin}>
|
||||
{/* Email Field */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
@ -83,9 +437,14 @@ export default function Login() {
|
||||
id="email"
|
||||
type="email"
|
||||
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
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-red-500">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
@ -98,7 +457,9 @@ export default function Login() {
|
||||
id="password"
|
||||
type={showPassword ? "text" : "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
|
||||
/>
|
||||
<Button
|
||||
@ -116,14 +477,25 @@ export default function Login() {
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-red-500">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
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>
|
||||
|
||||
{/* Remember Me & Forgot Password */}
|
||||
@ -137,16 +509,300 @@ export default function Login() {
|
||||
/>
|
||||
<span className={isDark ? 'text-gray-300' : 'text-black'}>Remember me</span>
|
||||
</label>
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
<button
|
||||
type="button"
|
||||
className={`font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
|
||||
>
|
||||
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>
|
||||
|
||||
{/* Last Name Field */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="lastName" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Last Name *
|
||||
</label>
|
||||
<Input
|
||||
id="lastName"
|
||||
type="text"
|
||||
placeholder="Doe"
|
||||
value={signupData.last_name}
|
||||
onChange={(e) => handleSignupChange("last_name", e.target.value)}
|
||||
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'} ${errors.last_name ? 'border-red-500' : ''}`}
|
||||
required
|
||||
/>
|
||||
{errors.last_name && (
|
||||
<p className="text-sm text-red-500">{errors.last_name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email Field */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="signup-email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Email address *
|
||||
</label>
|
||||
<Input
|
||||
id="signup-email"
|
||||
type="email"
|
||||
placeholder="Email address"
|
||||
value={signupData.email}
|
||||
onChange={(e) => handleSignupChange("email", e.target.value)}
|
||||
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'} ${errors.email ? 'border-red-500' : ''}`}
|
||||
required
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-red-500">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phone Field */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="phone" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Phone Number (Optional)
|
||||
</label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
placeholder="+1 (555) 123-4567"
|
||||
value={signupData.phone_number || ""}
|
||||
onChange={(e) => handleSignupChange("phone_number", e.target.value)}
|
||||
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="signup-password" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Password *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="signup-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Password (min 8 characters)"
|
||||
value={signupData.password}
|
||||
onChange={(e) => handleSignupChange("password", e.target.value)}
|
||||
className={`h-11 pr-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'} ${errors.password ? 'border-red-500' : ''}`}
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className={`absolute right-4 top-1/2 -translate-y-1/2 h-auto w-auto p-0 ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-700'}`}
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-5 h-5" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-red-500">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password Field */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="signup-password2" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Confirm Password *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="signup-password2"
|
||||
type={showPassword2 ? "text" : "password"}
|
||||
placeholder="Confirm password"
|
||||
value={signupData.password2}
|
||||
onChange={(e) => handleSignupChange("password2", e.target.value)}
|
||||
className={`h-11 pr-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'} ${errors.password2 ? 'border-red-500' : ''}`}
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowPassword2(!showPassword2)}
|
||||
className={`absolute right-4 top-1/2 -translate-y-1/2 h-auto w-auto p-0 ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-700'}`}
|
||||
aria-label={showPassword2 ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword2 ? (
|
||||
<EyeOff className="w-5 h-5" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{errors.password2 && (
|
||||
<p className="text-sm text-red-500">{errors.password2}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={registerMutation.isPending}
|
||||
className="w-full h-12 text-base font-semibold bg-linear-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed mt-6"
|
||||
>
|
||||
{registerMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating account...
|
||||
</>
|
||||
) : (
|
||||
"Sign up"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* OTP Verification Form */}
|
||||
{step === "verify" && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
LogOut,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
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 {
|
||||
ID: number;
|
||||
@ -73,6 +78,8 @@ export default function BookNowPage() {
|
||||
const router = useRouter();
|
||||
const { theme } = useAppTheme();
|
||||
const isDark = theme === "dark";
|
||||
const { isAuthenticated, logout } = useAuth();
|
||||
const { create, isCreating } = useAppointments();
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
@ -82,139 +89,140 @@ export default function BookNowPage() {
|
||||
preferredTimes: [] as string[],
|
||||
message: "",
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [booking, setBooking] = useState<Booking | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showLoginDialog, setShowLoginDialog] = useState(false);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
toast.success("Logged out successfully");
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
// Handle submit button click
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Open login dialog instead of submitting directly
|
||||
|
||||
// Check if user is authenticated
|
||||
if (!isAuthenticated) {
|
||||
// Open login dialog if not authenticated
|
||||
setShowLoginDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// If authenticated, proceed with booking
|
||||
await submitBooking();
|
||||
};
|
||||
|
||||
const handleLoginSuccess = async () => {
|
||||
// Close login dialog
|
||||
setShowLoginDialog(false);
|
||||
// After successful login, proceed with booking submission
|
||||
await submitBooking();
|
||||
};
|
||||
|
||||
const submitBooking = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (formData.preferredDays.length === 0) {
|
||||
setError("Please select at least one available day.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.preferredTimes.length === 0) {
|
||||
setError("Please select at least one preferred time.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// For now, we'll use the first selected day and first selected time
|
||||
// This can be adjusted based on your backend requirements
|
||||
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
|
||||
// Convert day names to dates (YYYY-MM-DD format)
|
||||
// Get next occurrence of each selected day
|
||||
const today = new Date();
|
||||
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
const 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;
|
||||
if (daysUntilTarget === 0) daysUntilTarget = 7; // Next week if today
|
||||
|
||||
const targetDate = new Date(today);
|
||||
targetDate.setDate(today.getDate() + daysUntilTarget);
|
||||
const dateString = targetDate.toISOString().split("T")[0];
|
||||
preferredDates.push(dateString);
|
||||
});
|
||||
|
||||
// Combine date and time into scheduled_at (ISO format)
|
||||
const dateTimeString = `${dateString}T${time24}:00Z`;
|
||||
// Map time slots - API expects "morning", "afternoon", "evening"
|
||||
// Form has "morning", "lunchtime", "afternoon"
|
||||
const timeSlotMap: { [key: string]: "morning" | "afternoon" | "evening" } = {
|
||||
morning: "morning",
|
||||
lunchtime: "afternoon", // Map lunchtime to afternoon
|
||||
afternoon: "afternoon",
|
||||
};
|
||||
|
||||
// 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 = {
|
||||
first_name: formData.firstName,
|
||||
last_name: formData.lastName,
|
||||
email: formData.email,
|
||||
phone: formData.phone,
|
||||
scheduled_at: dateTimeString,
|
||||
duration: 60, // Default to 60 minutes
|
||||
preferred_days: formData.preferredDays,
|
||||
preferred_times: formData.preferredTimes,
|
||||
notes: formData.message || "",
|
||||
preferred_dates: preferredDates,
|
||||
preferred_time_slots: preferredTimeSlots,
|
||||
...(formData.phone && { phone: formData.phone }),
|
||||
...(formData.message && { reason: formData.message }),
|
||||
};
|
||||
|
||||
// Simulate API call - Replace with actual API endpoint
|
||||
const response = await fetch("/api/bookings", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(() => {
|
||||
// Fallback to mock data if API is not available
|
||||
return null;
|
||||
});
|
||||
// Call the actual API using the hook
|
||||
const appointmentData = await create(payload);
|
||||
|
||||
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 data: BookingsResponse = await response.json();
|
||||
bookingData = data.bookings[0];
|
||||
} else {
|
||||
// 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(),
|
||||
const bookingData: Booking = {
|
||||
ID: appointmentId || 0,
|
||||
CreatedAt: appointmentData.created_at || now,
|
||||
UpdatedAt: appointmentData.updated_at || now,
|
||||
DeletedAt: null,
|
||||
user_id: 1,
|
||||
user_id: 0, // API doesn't return user_id in this response
|
||||
user: {
|
||||
ID: 1,
|
||||
CreatedAt: new Date().toISOString(),
|
||||
UpdatedAt: new Date().toISOString(),
|
||||
DeletedAt: null,
|
||||
first_name: formData.firstName,
|
||||
last_name: formData.lastName,
|
||||
email: formData.email,
|
||||
phone: formData.phone,
|
||||
ID: 0,
|
||||
first_name: appointmentData.first_name,
|
||||
last_name: appointmentData.last_name,
|
||||
email: appointmentData.email,
|
||||
phone: appointmentData.phone || "",
|
||||
location: "",
|
||||
date_of_birth: "0001-01-01T00:00:00Z",
|
||||
is_admin: false,
|
||||
bookings: null,
|
||||
},
|
||||
scheduled_at: dateTimeString,
|
||||
duration: 60,
|
||||
status: "scheduled",
|
||||
jitsi_room_id: `booking-${Math.floor(Math.random() * 1000)}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
jitsi_room_url: `https://meet.jit.si/booking-${Math.floor(Math.random() * 1000)}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
scheduled_at: appointmentData.scheduled_datetime || "",
|
||||
duration: appointmentData.scheduled_duration || 60,
|
||||
status: appointmentData.status || "pending_review",
|
||||
jitsi_room_id: appointmentData.jitsi_room_id || "",
|
||||
jitsi_room_url: appointmentData.jitsi_meet_url || "",
|
||||
payment_id: "",
|
||||
payment_status: "pending",
|
||||
amount: 52,
|
||||
notes: formData.message || "Initial consultation session",
|
||||
amount: 0,
|
||||
notes: appointmentData.reason || "",
|
||||
};
|
||||
}
|
||||
|
||||
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(() => {
|
||||
router.push("/");
|
||||
}, 2000);
|
||||
router.push("/user/dashboard");
|
||||
}, 3000);
|
||||
} catch (err) {
|
||||
setError("Failed to submit booking. Please try again.");
|
||||
setLoading(false);
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to submit booking. Please try again.";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
console.error("Booking error:", err);
|
||||
}
|
||||
};
|
||||
@ -628,10 +636,10 @@ export default function BookNowPage() {
|
||||
<Button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
{loading ? (
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Submitting...
|
||||
@ -660,6 +668,20 @@ export default function BookNowPage() {
|
||||
</a>
|
||||
</p>
|
||||
</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>
|
||||
|
||||
@ -21,7 +21,7 @@ export default function RootLayout({
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={inter.className}>
|
||||
<body className={inter.className} suppressHydrationWarning>
|
||||
<Providers>
|
||||
{children}
|
||||
<Toaster />
|
||||
|
||||
@ -2,12 +2,26 @@
|
||||
|
||||
import { ThemeProvider } from "../components/ThemeProvider";
|
||||
import { type ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Heart, Mail, Phone, MapPin } from "lucide-react";
|
||||
import { useAppTheme } from "@/components/ThemeProvider";
|
||||
import Link from "next/link";
|
||||
|
||||
export function Footer() {
|
||||
const { theme } = useAppTheme();
|
||||
@ -16,10 +17,11 @@ export function Footer() {
|
||||
};
|
||||
|
||||
const quickLinks = [
|
||||
{ name: 'Home', href: '#home' },
|
||||
{ name: 'About', href: '#about' },
|
||||
{ name: 'Services', href: '#services' },
|
||||
{ name: 'Contact', href: '#contact' },
|
||||
{ name: 'Home', href: '#home', isScroll: true },
|
||||
{ name: 'About', href: '#about', isScroll: true },
|
||||
{ name: 'Services', href: '#services', isScroll: true },
|
||||
{ name: 'Contact', href: '#contact', isScroll: true },
|
||||
{ name: 'Admin Panel', href: '/login', isScroll: false },
|
||||
];
|
||||
|
||||
return (
|
||||
@ -74,12 +76,21 @@ export function Footer() {
|
||||
<ul className="space-y-2">
|
||||
{quickLinks.map((link) => (
|
||||
<li key={link.name}>
|
||||
{link.isScroll ? (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{link.name}
|
||||
</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>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@ -12,6 +12,10 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
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 {
|
||||
open: boolean;
|
||||
@ -23,58 +27,87 @@ interface LoginDialogProps {
|
||||
export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogProps) {
|
||||
const { theme } = useAppTheme();
|
||||
const isDark = theme === "dark";
|
||||
const router = useRouter();
|
||||
const { login, register, loginMutation, registerMutation } = useAuth();
|
||||
const [isSignup, setIsSignup] = useState(false);
|
||||
const [loginData, setLoginData] = useState({
|
||||
const [loginData, setLoginData] = useState<LoginInput>({
|
||||
email: "",
|
||||
password: "",
|
||||
});
|
||||
const [signupData, setSignupData] = useState({
|
||||
fullName: "",
|
||||
const [signupData, setSignupData] = useState<RegisterInput>({
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
phone_number: "",
|
||||
password: "",
|
||||
password2: "",
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showPassword2, setShowPassword2] = 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 handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoginLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Simulate login API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
// Validate form
|
||||
const validation = loginSchema.safeParse(loginData);
|
||||
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);
|
||||
setLoginLoading(false);
|
||||
onOpenChange(false);
|
||||
onLoginSuccess();
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Login failed. Please try again.");
|
||||
setLoginLoading(false);
|
||||
const errorMessage = err instanceof Error ? err.message : "Login failed. Please try again.";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSignup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSignupLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Simulate signup API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
// Validate form
|
||||
const validation = registerSchema.safeParse(signupData);
|
||||
if (!validation.success) {
|
||||
const firstError = validation.error.issues[0];
|
||||
setError(firstError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// After successful signup, automatically log in and proceed
|
||||
setSignupLoading(false);
|
||||
onOpenChange(false);
|
||||
onLoginSuccess();
|
||||
try {
|
||||
const result = await register(signupData);
|
||||
|
||||
if (result.message) {
|
||||
toast.success("Registration successful! Please check your email for OTP verification.");
|
||||
// Switch to login after successful registration
|
||||
setIsSignup(false);
|
||||
setLoginData({ email: signupData.email, password: "" });
|
||||
setSignupData({
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
email: "",
|
||||
phone_number: "",
|
||||
password: "",
|
||||
password2: "",
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
setError("Signup failed. Please try again.");
|
||||
setSignupLoading(false);
|
||||
const errorMessage = err instanceof Error ? err.message : "Signup failed. Please try again.";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
@ -87,22 +120,29 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
|
||||
const handleSwitchToLogin = () => {
|
||||
setIsSignup(false);
|
||||
setError(null);
|
||||
setSignupData({ fullName: "", email: "", phone: "" });
|
||||
setSignupData({
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
email: "",
|
||||
phone_number: "",
|
||||
password: "",
|
||||
password2: "",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
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 */}
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<DialogHeader className="flex-1">
|
||||
<DialogTitle className="text-3xl font-bold bg-gradient-to-r from-rose-600 via-pink-600 to-rose-600 bg-clip-text text-transparent">
|
||||
{/* Header with Close Button - Fixed */}
|
||||
<div className="flex items-start justify-between p-6 pb-4 flex-shrink-0 border-b border-gray-200 dark:border-gray-700">
|
||||
<DialogHeader className="flex-1 pr-2">
|
||||
<DialogTitle className="text-2xl sm:text-3xl font-bold bg-gradient-to-r from-rose-600 via-pink-600 to-rose-600 bg-clip-text text-transparent">
|
||||
{isSignup ? "Create an account" : "Welcome back"}
|
||||
</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
|
||||
? "Sign up to complete your booking"
|
||||
: "Please log in to complete your booking"}
|
||||
@ -118,33 +158,51 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="overflow-y-auto flex-1 px-6">
|
||||
{/* Signup Form */}
|
||||
{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 && (
|
||||
<div className={`p-3 rounded-lg border ${isDark ? 'bg-red-900/20 border-red-800' : 'bg-red-50 border-red-200'}`}>
|
||||
<p className={`text-sm ${isDark ? 'text-red-200' : 'text-red-800'}`}>{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full Name Field */}
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="signup-fullName" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Full Name *
|
||||
{/* First Name Field */}
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<label htmlFor="signup-firstName" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
First Name *
|
||||
</label>
|
||||
<Input
|
||||
id="signup-fullName"
|
||||
id="signup-firstName"
|
||||
type="text"
|
||||
placeholder="John Doe"
|
||||
value={signupData.fullName}
|
||||
onChange={(e) => setSignupData({ ...signupData, fullName: 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'}`}
|
||||
placeholder="John"
|
||||
value={signupData.first_name}
|
||||
onChange={(e) => setSignupData({ ...signupData, first_name: e.target.value })}
|
||||
className={`h-11 sm:h-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Last Name Field */}
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<label htmlFor="signup-lastName" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Last Name *
|
||||
</label>
|
||||
<Input
|
||||
id="signup-lastName"
|
||||
type="text"
|
||||
placeholder="Doe"
|
||||
value={signupData.last_name}
|
||||
onChange={(e) => setSignupData({ ...signupData, last_name: e.target.value })}
|
||||
className={`h-11 sm:h-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email Field */}
|
||||
<div className="space-y-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'}`}>
|
||||
Email address *
|
||||
</label>
|
||||
@ -154,34 +212,97 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
|
||||
placeholder="Email address"
|
||||
value={signupData.email}
|
||||
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
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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'}`}>
|
||||
Phone Number *
|
||||
Phone Number (Optional)
|
||||
</label>
|
||||
<Input
|
||||
id="signup-phone"
|
||||
type="tel"
|
||||
placeholder="+1 (555) 123-4567"
|
||||
value={signupData.phone}
|
||||
onChange={(e) => setSignupData({ ...signupData, phone: 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'}`}
|
||||
value={signupData.phone_number || ""}
|
||||
onChange={(e) => setSignupData({ ...signupData, phone_number: e.target.value })}
|
||||
className={`h-11 sm:h-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<label htmlFor="signup-password" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Password *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="signup-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Password (min 8 characters)"
|
||||
value={signupData.password}
|
||||
onChange={(e) => setSignupData({ ...signupData, password: e.target.value })}
|
||||
className={`h-11 sm:h-12 pr-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className={`absolute right-3 sm:right-4 top-1/2 -translate-y-1/2 h-auto w-auto p-0 ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-700'}`}
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm Password Field */}
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
<label htmlFor="signup-password2" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
|
||||
Confirm Password *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="signup-password2"
|
||||
type={showPassword2 ? "text" : "password"}
|
||||
placeholder="Confirm password"
|
||||
value={signupData.password2}
|
||||
onChange={(e) => setSignupData({ ...signupData, password2: e.target.value })}
|
||||
className={`h-11 sm:h-12 pr-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setShowPassword2(!showPassword2)}
|
||||
className={`absolute right-3 sm:right-4 top-1/2 -translate-y-1/2 h-auto w-auto p-0 ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-700'}`}
|
||||
aria-label={showPassword2 ? "Hide password" : "Show password"}
|
||||
>
|
||||
{showPassword2 ? (
|
||||
<EyeOff className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
) : (
|
||||
<Eye className="w-4 h-4 sm:w-5 sm:h-5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={signupLoading}
|
||||
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"
|
||||
disabled={registerMutation.isPending}
|
||||
className="w-full h-11 sm:h-12 text-sm sm:text-base font-semibold bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed mt-4 sm:mt-6"
|
||||
>
|
||||
{signupLoading ? (
|
||||
{registerMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating account...
|
||||
@ -192,7 +313,7 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
|
||||
</Button>
|
||||
|
||||
{/* 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?{" "}
|
||||
<button
|
||||
type="button"
|
||||
@ -205,7 +326,7 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
|
||||
</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 && (
|
||||
<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>
|
||||
@ -213,7 +334,7 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
|
||||
)}
|
||||
|
||||
{/* 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'}`}>
|
||||
Email address
|
||||
</label>
|
||||
@ -223,13 +344,13 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
|
||||
placeholder="Email address"
|
||||
value={loginData.email}
|
||||
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
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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'}`}>
|
||||
Your password
|
||||
</label>
|
||||
@ -240,7 +361,7 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
|
||||
placeholder="Your password"
|
||||
value={loginData.password}
|
||||
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
|
||||
/>
|
||||
<Button
|
||||
@ -248,13 +369,13 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
|
||||
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'}`}
|
||||
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-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>
|
||||
</div>
|
||||
@ -263,10 +384,10 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
|
||||
{/* Submit Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loginLoading}
|
||||
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"
|
||||
disabled={loginMutation.isPending}
|
||||
className="w-full h-11 sm:h-12 text-sm sm:text-base font-semibold bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed mt-4 sm:mt-6"
|
||||
>
|
||||
{loginLoading ? (
|
||||
{loginMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Logging in...
|
||||
@ -277,7 +398,7 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
|
||||
</Button>
|
||||
|
||||
{/* 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">
|
||||
<input
|
||||
type="checkbox"
|
||||
@ -289,7 +410,7 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
|
||||
</label>
|
||||
<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)}
|
||||
>
|
||||
Forgot password?
|
||||
@ -297,7 +418,7 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
|
||||
</div>
|
||||
|
||||
{/* 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?{" "}
|
||||
<button
|
||||
type="button"
|
||||
@ -309,6 +430,7 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@ -2,13 +2,15 @@
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
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 { useEffect, useState } from "react";
|
||||
import { LoginDialog } from "@/components/LoginDialog";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useAppTheme } from "@/components/ThemeProvider";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function Navbar() {
|
||||
const { theme } = useAppTheme();
|
||||
@ -18,6 +20,9 @@ export function Navbar() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
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 element = document.getElementById(id);
|
||||
@ -28,11 +33,18 @@ export function Navbar() {
|
||||
};
|
||||
|
||||
const handleLoginSuccess = () => {
|
||||
// Redirect to user dashboard after successful login
|
||||
router.push("/user/dashboard");
|
||||
// Redirect to admin dashboard after successful login
|
||||
router.push("/admin/dashboard");
|
||||
setMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
toast.success("Logged out successfully");
|
||||
setMobileMenuOpen(false);
|
||||
router.push("/");
|
||||
};
|
||||
|
||||
// Close mobile menu when clicking outside
|
||||
useEffect(() => {
|
||||
if (mobileMenuOpen) {
|
||||
@ -73,7 +85,7 @@ export function Navbar() {
|
||||
</motion.div>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
{!isUserDashboard && (
|
||||
{!isUserRoute && (
|
||||
<div className="hidden lg:flex items-center gap-4 xl:gap-6">
|
||||
<button
|
||||
onClick={() => scrollToSection("about")}
|
||||
@ -98,7 +110,7 @@ export function Navbar() {
|
||||
|
||||
{/* Desktop Actions */}
|
||||
<div className="hidden lg:flex items-center gap-2">
|
||||
{!isUserDashboard && (
|
||||
{!isAuthenticated && !isUserDashboard && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@ -109,9 +121,25 @@ export function Navbar() {
|
||||
</Button>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
<Button size="sm" className="hover:opacity-90 hover:scale-105 transition-all text-xs sm:text-sm" asChild>
|
||||
<a href="/book-now">Book Now</a>
|
||||
{!isUserDashboard && (
|
||||
<Link
|
||||
href="/book-now"
|
||||
className={`text-sm font-medium transition-colors cursor-pointer px-3 py-2 rounded-lg hover:opacity-90 ${isDark ? 'text-gray-300 hover:text-white' : 'text-gray-700 hover:text-rose-600'}`}
|
||||
>
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{/* Mobile Navigation Links */}
|
||||
{!isUserDashboard && (
|
||||
{!isUserRoute && (
|
||||
<>
|
||||
<button
|
||||
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'}`}>
|
||||
{!isUserDashboard && (
|
||||
{!isAuthenticated && !isUserDashboard && (
|
||||
<Button
|
||||
variant="outline"
|
||||
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
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
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"
|
||||
asChild
|
||||
{!isUserDashboard && (
|
||||
<Link
|
||||
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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
</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";
|
||||
|
||||
// Simple toaster component - can be enhanced later with toast notifications
|
||||
import { Toaster as Sonner } from "sonner";
|
||||
import { useAppTheme } from "@/components/ThemeProvider";
|
||||
|
||||
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-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tanstack/react-query": "^5.90.10",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.23.24",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.552.0",
|
||||
"next": "16.0.1",
|
||||
"next-themes": "^0.4.6",
|
||||
@ -28,7 +30,8 @@
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dom": "19.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zod": "^4.1.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
@ -23,6 +23,9 @@ importers:
|
||||
'@radix-ui/react-slot':
|
||||
specifier: ^1.2.4
|
||||
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:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@ -35,6 +38,9 @@ importers:
|
||||
framer-motion:
|
||||
specifier: ^12.23.24
|
||||
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:
|
||||
specifier: ^0.552.0
|
||||
version: 0.552.0(react@19.2.0)
|
||||
@ -59,6 +65,9 @@ importers:
|
||||
tailwind-merge:
|
||||
specifier: ^3.3.1
|
||||
version: 3.3.1
|
||||
zod:
|
||||
specifier: ^4.1.12
|
||||
version: 4.1.12
|
||||
devDependencies:
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4
|
||||
@ -890,6 +899,14 @@ packages:
|
||||
'@tailwindcss/postcss@4.1.16':
|
||||
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':
|
||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||
|
||||
@ -1633,6 +1650,12 @@ packages:
|
||||
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@ -3193,6 +3216,13 @@ snapshots:
|
||||
postcss: 8.5.6
|
||||
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':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@ -4098,6 +4128,11 @@ snapshots:
|
||||
|
||||
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:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
|
||||
Loading…
Reference in New Issue
Block a user