feat/authentication #21

Merged
Hammond merged 12 commits from feat/authentication into master 2025-11-24 22:09:51 +00:00
27 changed files with 4597 additions and 426 deletions

View File

@ -17,6 +17,8 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useAppTheme } from "@/components/ThemeProvider"; import { useAppTheme } from "@/components/ThemeProvider";
import { ThemeToggle } from "@/components/ThemeToggle"; import { ThemeToggle } from "@/components/ThemeToggle";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
export function Header() { export function Header() {
const pathname = usePathname(); const pathname = usePathname();
@ -25,6 +27,14 @@ export function Header() {
const [userMenuOpen, setUserMenuOpen] = useState(false); const [userMenuOpen, setUserMenuOpen] = useState(false);
const { theme } = useAppTheme(); const { theme } = useAppTheme();
const isDark = theme === "dark"; const isDark = theme === "dark";
const { logout } = useAuth();
const handleLogout = () => {
setUserMenuOpen(false);
logout();
toast.success("Logged out successfully");
router.push("/");
};
// Mock notifications data // Mock notifications data
const notifications = [ const notifications = [
@ -209,10 +219,7 @@ export function Header() {
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={handleLogout}
setUserMenuOpen(false);
router.push("/");
}}
className={`w-full flex items-center gap-3 px-4 py-3 justify-start transition-colors cursor-pointer ${ className={`w-full flex items-center gap-3 px-4 py-3 justify-start transition-colors cursor-pointer ${
isDark ? "hover:bg-gray-800" : "hover:bg-gray-50" isDark ? "hover:bg-gray-800" : "hover:bg-gray-50"
}`} }`}

View File

@ -14,6 +14,8 @@ import {
Heart, Heart,
} from "lucide-react"; } from "lucide-react";
import { useAppTheme } from "@/components/ThemeProvider"; import { useAppTheme } from "@/components/ThemeProvider";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
const navItems = [ const navItems = [
{ label: "Dashboard", icon: LayoutGrid, href: "/admin/dashboard" }, { label: "Dashboard", icon: LayoutGrid, href: "/admin/dashboard" },
@ -26,6 +28,14 @@ export default function SideNav() {
const router = useRouter(); const router = useRouter();
const { theme } = useAppTheme(); const { theme } = useAppTheme();
const isDark = theme === "dark"; const isDark = theme === "dark";
const { logout } = useAuth();
const handleLogout = () => {
setOpen(false);
logout();
toast.success("Logged out successfully");
router.push("/");
};
const getActiveIndex = () => { const getActiveIndex = () => {
return navItems.findIndex((item) => pathname?.includes(item.href)) ?? -1; return navItems.findIndex((item) => pathname?.includes(item.href)) ?? -1;
@ -176,10 +186,7 @@ export default function SideNav() {
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
onClick={() => { onClick={handleLogout}
setOpen(false);
router.push("/");
}}
className={`group flex items-center gap-2 py-2.5 pl-3 md:pl-3 pr-3 md:pr-3 transition-colors duration-200 w-[90%] md:w-[90%] ml-1 md:ml-2 cursor-pointer justify-start rounded-lg ${ className={`group flex items-center gap-2 py-2.5 pl-3 md:pl-3 pr-3 md:pr-3 transition-colors duration-200 w-[90%] md:w-[90%] ml-1 md:ml-2 cursor-pointer justify-start rounded-lg ${
isDark isDark
? "text-gray-300 hover:bg-gray-800 hover:text-rose-300" ? "text-gray-300 hover:bg-gray-800 hover:text-rose-300"

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

View File

@ -1,111 +1,64 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { import {
Calendar, Calendar,
Clock, Clock,
User,
Video, Video,
FileText, Search,
MoreVertical, CalendarCheck,
X,
Loader2,
User,
} from "lucide-react"; } from "lucide-react";
import { useAppTheme } from "@/components/ThemeProvider"; import { useAppTheme } from "@/components/ThemeProvider";
import { listAppointments, scheduleAppointment, rejectAppointment } from "@/lib/actions/appointments";
interface User { import { Input } from "@/components/ui/input";
ID: number; import { Button } from "@/components/ui/button";
CreatedAt?: string; import {
UpdatedAt?: string; Dialog,
DeletedAt?: string | null; DialogContent,
first_name: string; DialogDescription,
last_name: string; DialogFooter,
email: string; DialogHeader,
phone: string; DialogTitle,
location: string; } from "@/components/ui/dialog";
date_of_birth?: string; import { DatePicker } from "@/components/DatePicker";
is_admin?: boolean; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
bookings?: null; import { toast } from "sonner";
} import type { Appointment } from "@/lib/models/appointments";
interface Booking {
ID: number;
CreatedAt: string;
UpdatedAt: string;
DeletedAt: string | null;
user_id: number;
user: User;
scheduled_at: string;
duration: number;
status: string;
jitsi_room_id: string;
jitsi_room_url: string;
payment_id: string;
payment_status: string;
amount: number;
notes: string;
}
interface BookingsResponse {
bookings: Booking[];
limit: number;
offset: number;
total: number;
}
export default function Booking() { export default function Booking() {
const [bookings, setBookings] = useState<Booking[]>([]); const router = useRouter();
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false);
const [rejectDialogOpen, setRejectDialogOpen] = useState(false);
const [selectedAppointment, setSelectedAppointment] = useState<Appointment | null>(null);
const [scheduledDate, setScheduledDate] = useState<Date | undefined>(undefined);
const [scheduledTime, setScheduledTime] = useState<string>("09:00");
const [scheduledDuration, setScheduledDuration] = useState<number>(60);
const [rejectionReason, setRejectionReason] = useState<string>("");
const [isScheduling, setIsScheduling] = useState(false);
const [isRejecting, setIsRejecting] = useState(false);
const { theme } = useAppTheme(); const { theme } = useAppTheme();
const isDark = theme === "dark"; const isDark = theme === "dark";
useEffect(() => { useEffect(() => {
// Simulate API call
const fetchBookings = async () => { const fetchBookings = async () => {
setLoading(true); setLoading(true);
await new Promise((resolve) => setTimeout(resolve, 500)); try {
const data = await listAppointments();
// Mock API response setAppointments(data || []);
const mockData: BookingsResponse = { } catch (error) {
bookings: [ console.error("Failed to fetch appointments:", error);
{ toast.error("Failed to load appointments. Please try again.");
ID: 1, setAppointments([]);
CreatedAt: "2025-11-06T11:33:45.704633Z", } finally {
UpdatedAt: "2025-11-06T11:33:45.707543Z", setLoading(false);
DeletedAt: null, }
user_id: 3,
user: {
ID: 3,
CreatedAt: "2025-11-06T10:43:01.299311Z",
UpdatedAt: "2025-11-06T10:43:48.427284Z",
DeletedAt: null,
first_name: "John",
last_name: "Smith",
email: "john.doe@example.com",
phone: "+1234567891",
location: "Los Angeles, CA",
date_of_birth: "0001-01-01T00:00:00Z",
is_admin: true,
bookings: null,
},
scheduled_at: "2025-11-07T10:00:00Z",
duration: 60,
status: "scheduled",
jitsi_room_id: "booking-1-1762428825-22c92ced2870c17c",
jitsi_room_url:
"https://meet.jit.si/booking-1-1762428825-22c92ced2870c17c",
payment_id: "",
payment_status: "pending",
amount: 52,
notes: "Initial consultation session",
},
],
limit: 50,
offset: 0,
total: 1,
};
setBookings(mockData.bookings);
setLoading(false);
}; };
fetchBookings(); fetchBookings();
@ -137,8 +90,10 @@ export default function Booking() {
return "bg-blue-500/20 text-blue-200"; return "bg-blue-500/20 text-blue-200";
case "completed": case "completed":
return "bg-green-500/20 text-green-200"; return "bg-green-500/20 text-green-200";
case "rejected":
case "cancelled": case "cancelled":
return "bg-red-500/20 text-red-200"; return "bg-red-500/20 text-red-200";
case "pending_review":
case "pending": case "pending":
return "bg-yellow-500/20 text-yellow-200"; return "bg-yellow-500/20 text-yellow-200";
default: default:
@ -150,8 +105,10 @@ export default function Booking() {
return "bg-blue-100 text-blue-700"; return "bg-blue-100 text-blue-700";
case "completed": case "completed":
return "bg-green-100 text-green-700"; return "bg-green-100 text-green-700";
case "rejected":
case "cancelled": case "cancelled":
return "bg-red-100 text-red-700"; return "bg-red-100 text-red-700";
case "pending_review":
case "pending": case "pending":
return "bg-yellow-100 text-yellow-700"; return "bg-yellow-100 text-yellow-700";
default: default:
@ -159,41 +116,107 @@ export default function Booking() {
} }
}; };
const getPaymentStatusColor = (status: string) => { const formatStatus = (status: string) => {
const normalized = status.toLowerCase(); return status.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase());
if (isDark) { };
switch (normalized) {
case "paid": const handleViewDetails = (appointment: Appointment) => {
return "bg-green-500/20 text-green-200"; router.push(`/admin/booking/${appointment.id}`);
case "pending": };
return "bg-yellow-500/20 text-yellow-200";
case "failed": const handleScheduleClick = (appointment: Appointment) => {
return "bg-red-500/20 text-red-200"; setSelectedAppointment(appointment);
default: setScheduledDate(undefined);
return "bg-gray-700 text-gray-200"; setScheduledTime("09:00");
} setScheduledDuration(60);
setScheduleDialogOpen(true);
};
const handleRejectClick = (appointment: Appointment) => {
setSelectedAppointment(appointment);
setRejectionReason("");
setRejectDialogOpen(true);
};
const handleSchedule = async () => {
if (!selectedAppointment || !scheduledDate) {
toast.error("Please select a date and time");
return;
} }
switch (normalized) {
case "paid": setIsScheduling(true);
return "bg-green-100 text-green-700"; try {
case "pending": // Combine date and time into ISO datetime string
return "bg-yellow-100 text-yellow-700"; const [hours, minutes] = scheduledTime.split(":");
case "failed": const datetime = new Date(scheduledDate);
return "bg-red-100 text-red-700"; datetime.setHours(parseInt(hours), parseInt(minutes), 0, 0);
default: const isoString = datetime.toISOString();
return "bg-gray-100 text-gray-700";
await scheduleAppointment(selectedAppointment.id, {
scheduled_datetime: isoString,
scheduled_duration: scheduledDuration,
});
toast.success("Appointment scheduled successfully!");
setScheduleDialogOpen(false);
// Refresh appointments list
const data = await listAppointments();
setAppointments(data || []);
} catch (error) {
console.error("Failed to schedule appointment:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to schedule appointment";
toast.error(errorMessage);
} finally {
setIsScheduling(false);
} }
}; };
const filteredBookings = bookings.filter( const handleReject = async () => {
(booking) => if (!selectedAppointment) {
booking.user.first_name return;
}
setIsRejecting(true);
try {
await rejectAppointment(selectedAppointment.id, {
rejection_reason: rejectionReason || undefined,
});
toast.success("Appointment rejected successfully");
setRejectDialogOpen(false);
// Refresh appointments list
const data = await listAppointments();
setAppointments(data || []);
} catch (error) {
console.error("Failed to reject appointment:", error);
const errorMessage = error instanceof Error ? error.message : "Failed to reject appointment";
toast.error(errorMessage);
} finally {
setIsRejecting(false);
}
};
// Generate time slots
const timeSlots = [];
for (let hour = 8; hour <= 18; hour++) {
for (let minute = 0; minute < 60; minute += 30) {
const timeString = `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}`;
timeSlots.push(timeString);
}
}
const filteredAppointments = appointments.filter(
(appointment) =>
appointment.first_name
.toLowerCase() .toLowerCase()
.includes(searchTerm.toLowerCase()) || .includes(searchTerm.toLowerCase()) ||
booking.user.last_name appointment.last_name
.toLowerCase() .toLowerCase()
.includes(searchTerm.toLowerCase()) || .includes(searchTerm.toLowerCase()) ||
booking.user.email.toLowerCase().includes(searchTerm.toLowerCase()) appointment.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
(appointment.phone && appointment.phone.toLowerCase().includes(searchTerm.toLowerCase()))
); );
return ( return (
@ -202,34 +225,42 @@ export default function Booking() {
{/* Main Content */} {/* Main Content */}
<main className="p-3 sm:p-4 md:p-6 lg:p-8"> <main className="p-3 sm:p-4 md:p-6 lg:p-8">
{/* Page Header */} {/* Page Header */}
<div className="mb-4 sm:mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4"> <div className="mb-4 sm:mb-6 flex flex-col gap-3 sm:gap-4">
<div> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<h1 className={`text-xl sm:text-2xl font-semibold mb-1 ${isDark ? "text-white" : "text-gray-900"}`}> <div>
Bookings <h1 className={`text-xl sm:text-2xl font-semibold mb-1 ${isDark ? "text-white" : "text-gray-900"}`}>
</h1> Bookings
<p className={`text-xs sm:text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}> </h1>
Manage and view all appointment bookings <p className={`text-xs sm:text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}>
</p> Manage and view all appointment bookings
</p>
</div>
</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>
<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> </div>
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className={`animate-spin rounded-full h-8 w-8 border-b-2 ${isDark ? "border-gray-600" : "border-gray-400"}`}></div> <div className={`animate-spin rounded-full h-8 w-8 border-b-2 ${isDark ? "border-gray-600" : "border-gray-400"}`}></div>
</div> </div>
) : filteredBookings.length === 0 ? ( ) : filteredAppointments.length === 0 ? (
<div className={`rounded-lg border p-12 text-center ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}> <div className={`rounded-lg border p-12 text-center ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<Calendar className={`w-12 h-12 mx-auto mb-4 ${isDark ? "text-gray-500" : "text-gray-400"}`} /> <Calendar className={`w-12 h-12 mx-auto mb-4 ${isDark ? "text-gray-500" : "text-gray-400"}`} />
<p className={`font-medium mb-1 ${isDark ? "text-gray-200" : "text-gray-600"}`}>No bookings found</p> <p className={`font-medium mb-1 ${isDark ? "text-gray-200" : "text-gray-600"}`}>No bookings found</p>
<p className={`text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}> <p className={`text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}>
{searchTerm {searchTerm
? "Try adjusting your search terms" ? "Try adjusting your search terms"
: "Create a new booking to get started"} : "No appointments have been created yet"}
</p> </p>
</div> </div>
) : ( ) : (
@ -251,10 +282,10 @@ export default function Booking() {
Status Status
</th> </th>
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider hidden lg:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}> <th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider hidden lg:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Payment Preferred Dates
</th> </th>
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider hidden xl:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}> <th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider hidden xl:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Amount Created
</th> </th>
<th className={`px-3 sm:px-4 md:px-6 py-3 text-right text-xs font-medium uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}> <th className={`px-3 sm:px-4 md:px-6 py-3 text-right text-xs font-medium uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Actions Actions
@ -262,10 +293,11 @@ export default function Booking() {
</tr> </tr>
</thead> </thead>
<tbody className={`${isDark ? "bg-gray-800 divide-gray-700" : "bg-white divide-gray-200"}`}> <tbody className={`${isDark ? "bg-gray-800 divide-gray-700" : "bg-white divide-gray-200"}`}>
{filteredBookings.map((booking) => ( {filteredAppointments.map((appointment) => (
<tr <tr
key={booking.ID} key={appointment.id}
className={`transition-colors ${isDark ? "hover:bg-gray-700" : "hover:bg-gray-50"}`} className={`transition-colors cursor-pointer ${isDark ? "hover:bg-gray-700" : "hover:bg-gray-50"}`}
onClick={() => handleViewDetails(appointment)}
> >
<td className="px-3 sm:px-4 md:px-6 py-4"> <td className="px-3 sm:px-4 md:px-6 py-4">
<div className="flex items-center"> <div className="flex items-center">
@ -274,55 +306,103 @@ export default function Booking() {
</div> </div>
<div className="ml-2 sm:ml-4 min-w-0"> <div className="ml-2 sm:ml-4 min-w-0">
<div className={`text-xs sm:text-sm font-medium truncate ${isDark ? "text-white" : "text-gray-900"}`}> <div className={`text-xs sm:text-sm font-medium truncate ${isDark ? "text-white" : "text-gray-900"}`}>
{booking.user.first_name} {booking.user.last_name} {appointment.first_name} {appointment.last_name}
</div> </div>
<div className={`text-xs sm:text-sm truncate hidden sm:block ${isDark ? "text-gray-400" : "text-gray-500"}`}> <div className={`text-xs sm:text-sm truncate hidden sm:block ${isDark ? "text-gray-400" : "text-gray-500"}`}>
{booking.user.email} {appointment.email}
</div>
<div className={`text-xs sm:hidden mt-0.5 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
{formatDate(booking.scheduled_at)}
</div> </div>
{appointment.phone && (
<div className={`text-xs truncate hidden sm:block ${isDark ? "text-gray-500" : "text-gray-400"}`}>
{appointment.phone}
</div>
)}
{appointment.scheduled_datetime && (
<div className={`text-xs sm:hidden mt-0.5 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
{formatDate(appointment.scheduled_datetime)}
</div>
)}
</div> </div>
</div> </div>
</td> </td>
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap hidden md:table-cell"> <td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap hidden md:table-cell">
<div className={`text-xs sm:text-sm ${isDark ? "text-white" : "text-gray-900"}`}> {appointment.scheduled_datetime ? (
{formatDate(booking.scheduled_at)} <>
</div> <div className={`text-xs sm:text-sm ${isDark ? "text-white" : "text-gray-900"}`}>
<div className={`text-xs sm:text-sm flex items-center gap-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}> {formatDate(appointment.scheduled_datetime)}
<Clock className="w-3 h-3" /> </div>
{formatTime(booking.scheduled_at)} <div className={`text-xs sm:text-sm flex items-center gap-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
</div> <Clock className="w-3 h-3" />
{formatTime(appointment.scheduled_datetime)}
</div>
</>
) : (
<div className={`text-xs sm:text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Not scheduled
</div>
)}
</td> </td>
<td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm hidden lg:table-cell ${isDark ? "text-white" : "text-gray-900"}`}> <td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm hidden lg:table-cell ${isDark ? "text-white" : "text-gray-900"}`}>
{booking.duration} min {appointment.scheduled_duration ? `${appointment.scheduled_duration} min` : "-"}
</td> </td>
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap"> <td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap">
<span <span
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor( className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(
booking.status appointment.status
)}`} )}`}
> >
{booking.status} {formatStatus(appointment.status)}
</span> </span>
</td> </td>
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap hidden lg:table-cell"> <td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm hidden lg:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
<span {appointment.preferred_dates && appointment.preferred_dates.length > 0 ? (
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getPaymentStatusColor( <div className="flex flex-col gap-1">
booking.payment_status {appointment.preferred_dates.slice(0, 2).map((date, idx) => (
)}`} <span key={idx}>{formatDate(date)}</span>
> ))}
{booking.payment_status} {appointment.preferred_dates.length > 2 && (
</span> <span className="text-xs">+{appointment.preferred_dates.length - 2} more</span>
)}
</div>
) : (
"-"
)}
</td> </td>
<td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm font-medium hidden xl:table-cell ${isDark ? "text-white" : "text-gray-900"}`}> <td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm hidden xl:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
${booking.amount} {formatDate(appointment.created_at)}
</td> </td>
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end gap-1 sm:gap-2"> <div className="flex items-center justify-end gap-1 sm:gap-2" onClick={(e) => e.stopPropagation()}>
{booking.jitsi_room_url && ( {appointment.status === "pending_review" && (
<>
<button
onClick={() => handleScheduleClick(appointment)}
className={`px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-lg transition-colors ${
isDark
? "bg-blue-600 hover:bg-blue-700 text-white"
: "bg-blue-600 hover:bg-blue-700 text-white"
}`}
title="Schedule Appointment"
>
<span className="hidden sm:inline">Schedule</span>
<CalendarCheck className="w-4 h-4 sm:hidden" />
</button>
<button
onClick={() => handleRejectClick(appointment)}
className={`px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-lg transition-colors ${
isDark
? "bg-red-600 hover:bg-red-700 text-white"
: "bg-red-600 hover:bg-red-700 text-white"
}`}
title="Reject Appointment"
>
<span className="hidden sm:inline">Reject</span>
<X className="w-4 h-4 sm:hidden" />
</button>
</>
)}
{appointment.jitsi_meet_url && (
<a <a
href={booking.jitsi_room_url} href={appointment.jitsi_meet_url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={`p-1.5 sm:p-2 rounded-lg transition-colors ${isDark ? "text-gray-300 hover:text-white hover:bg-gray-700" : "text-gray-600 hover:text-gray-900 hover:bg-gray-100"}`} className={`p-1.5 sm:p-2 rounded-lg transition-colors ${isDark ? "text-gray-300 hover:text-white hover:bg-gray-700" : "text-gray-600 hover:text-gray-900 hover:bg-gray-100"}`}
@ -331,17 +411,6 @@ export default function Booking() {
<Video className="w-4 h-4" /> <Video className="w-4 h-4" />
</a> </a>
)} )}
{booking.notes && (
<button
className={`p-1.5 sm:p-2 rounded-lg transition-colors ${isDark ? "text-gray-300 hover:text-white hover:bg-gray-700" : "text-gray-600 hover:text-gray-900 hover:bg-gray-100"}`}
title="View Notes"
>
<FileText className="w-4 h-4" />
</button>
)}
<button className={`p-1.5 sm:p-2 rounded-lg transition-colors ${isDark ? "text-gray-300 hover:text-white hover:bg-gray-700" : "text-gray-600 hover:text-gray-900 hover:bg-gray-100"}`}>
<MoreVertical className="w-4 h-4" />
</button>
</div> </div>
</td> </td>
</tr> </tr>
@ -352,6 +421,166 @@ export default function Booking() {
</div> </div>
)} )}
</main> </main>
{/* Schedule Appointment Dialog */}
<Dialog open={scheduleDialogOpen} onOpenChange={setScheduleDialogOpen}>
<DialogContent className={`${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<DialogHeader>
<DialogTitle className={isDark ? "text-white" : "text-gray-900"}>
Schedule Appointment
</DialogTitle>
<DialogDescription className={isDark ? "text-gray-400" : "text-gray-500"}>
{selectedAppointment && (
<>Schedule appointment for {selectedAppointment.first_name} {selectedAppointment.last_name}</>
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
Date *
</label>
<DatePicker
date={scheduledDate}
setDate={setScheduledDate}
/>
</div>
<div className="space-y-2">
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
Time *
</label>
<Select value={scheduledTime} onValueChange={setScheduledTime}>
<SelectTrigger className={isDark ? "bg-gray-700 border-gray-600 text-white" : "bg-white border-gray-300"}>
<SelectValue placeholder="Select time" />
</SelectTrigger>
<SelectContent className={isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}>
{timeSlots.map((time) => (
<SelectItem
key={time}
value={time}
className={isDark ? "focus:bg-gray-700" : ""}
>
{new Date(`2000-01-01T${time}`).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
})}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
Duration (minutes)
</label>
<Select
value={scheduledDuration.toString()}
onValueChange={(value) => setScheduledDuration(parseInt(value))}
>
<SelectTrigger className={isDark ? "bg-gray-700 border-gray-600 text-white" : "bg-white border-gray-300"}>
<SelectValue placeholder="Select duration" />
</SelectTrigger>
<SelectContent className={isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}>
<SelectItem value="30" className={isDark ? "focus:bg-gray-700" : ""}>30 minutes</SelectItem>
<SelectItem value="60" className={isDark ? "focus:bg-gray-700" : ""}>60 minutes</SelectItem>
<SelectItem value="90" className={isDark ? "focus:bg-gray-700" : ""}>90 minutes</SelectItem>
<SelectItem value="120" className={isDark ? "focus:bg-gray-700" : ""}>120 minutes</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setScheduleDialogOpen(false)}
disabled={isScheduling}
className={isDark ? "border-gray-700 text-gray-300 hover:bg-gray-700" : ""}
>
Cancel
</Button>
<Button
onClick={handleSchedule}
disabled={isScheduling || !scheduledDate}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
{isScheduling ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Scheduling...
</>
) : (
"Schedule Appointment"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Reject Appointment Dialog */}
<Dialog open={rejectDialogOpen} onOpenChange={setRejectDialogOpen}>
<DialogContent className={`${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<DialogHeader>
<DialogTitle className={isDark ? "text-white" : "text-gray-900"}>
Reject Appointment
</DialogTitle>
<DialogDescription className={isDark ? "text-gray-400" : "text-gray-500"}>
{selectedAppointment && (
<>Reject appointment request from {selectedAppointment.first_name} {selectedAppointment.last_name}</>
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
Rejection Reason (Optional)
</label>
<textarea
value={rejectionReason}
onChange={(e) => setRejectionReason(e.target.value)}
placeholder="Enter reason for rejection..."
rows={4}
className={`w-full rounded-md border px-3 py-2 text-sm ${
isDark
? "bg-gray-700 border-gray-600 text-white placeholder:text-gray-400"
: "bg-white border-gray-300 text-gray-900 placeholder:text-gray-500"
}`}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setRejectDialogOpen(false)}
disabled={isRejecting}
className={isDark ? "border-gray-700 text-gray-300 hover:bg-gray-700" : ""}
>
Cancel
</Button>
<Button
onClick={handleReject}
disabled={isRejecting}
className="bg-red-600 hover:bg-red-700 text-white"
>
{isRejecting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Rejecting...
</>
) : (
"Reject Appointment"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@ -20,6 +20,11 @@ import {
ArrowDownRight, ArrowDownRight,
} from "lucide-react"; } from "lucide-react";
import { useAppTheme } from "@/components/ThemeProvider"; import { useAppTheme } from "@/components/ThemeProvider";
import { getAllUsers } from "@/lib/actions/auth";
import { getAppointmentStats, listAppointments } from "@/lib/actions/appointments";
import { toast } from "sonner";
import type { User } from "@/lib/models/auth";
import type { Appointment } from "@/lib/models/appointments";
interface DashboardStats { interface DashboardStats {
total_users: number; total_users: number;
@ -30,6 +35,16 @@ interface DashboardStats {
cancelled_bookings: number; cancelled_bookings: number;
total_revenue: number; total_revenue: number;
monthly_revenue: number; monthly_revenue: number;
trends: {
total_users: string;
active_users: string;
total_bookings: string;
upcoming_bookings: string;
completed_bookings: string;
cancelled_bookings: string;
total_revenue: string;
monthly_revenue: string;
};
} }
export default function Dashboard() { export default function Dashboard() {
@ -40,86 +55,166 @@ export default function Dashboard() {
const isDark = theme === "dark"; const isDark = theme === "dark";
useEffect(() => { useEffect(() => {
// Simulate API call
const fetchStats = async () => { const fetchStats = async () => {
setLoading(true); setLoading(true);
// Simulate network delay try {
await new Promise((resolve) => setTimeout(resolve, 500)); // Fetch all data in parallel
const [users, appointmentStats, appointments] = await Promise.all([
getAllUsers().catch(() => [] as User[]),
getAppointmentStats().catch(() => null),
listAppointments().catch(() => [] as Appointment[]),
]);
// Mock API response // Calculate statistics
const mockData: DashboardStats = { // Use users count from appointment stats if available, otherwise use getAllUsers result
total_users: 3, const totalUsers = appointmentStats?.users ?? users.length;
active_users: 3, const activeUsers = users.filter(
total_bookings: 6, (user) => user.is_active === true || user.isActive === true
upcoming_bookings: 6, ).length;
const totalBookings = appointmentStats?.total_requests || appointments.length;
const upcomingBookings = appointmentStats?.scheduled ||
appointments.filter((apt) => apt.status === "scheduled").length;
// Completed bookings - not in API status types, so set to 0
const completedBookings = 0;
const cancelledBookings = appointmentStats?.rejected ||
appointments.filter((apt) => apt.status === "rejected").length;
// Calculate revenue (assuming appointments have amount field, defaulting to 0)
const now = new Date();
const currentMonth = now.getMonth();
const currentYear = now.getFullYear();
const totalRevenue = appointments.reduce((sum, apt) => {
// If appointment has amount field, use it, otherwise default to 0
const amount = (apt as any).amount || 0;
return sum + amount;
}, 0);
const monthlyRevenue = appointments
.filter((apt) => {
if (!apt.scheduled_datetime) return false;
const aptDate = new Date(apt.scheduled_datetime);
return (
aptDate.getMonth() === currentMonth &&
aptDate.getFullYear() === currentYear
);
})
.reduce((sum, apt) => {
const amount = (apt as any).amount || 0;
return sum + amount;
}, 0);
// For now, use static trends (in a real app, you'd calculate these from historical data)
const trends = {
total_users: "+12%",
active_users: "+8%",
total_bookings: "+24%",
upcoming_bookings: "+6",
completed_bookings: "0%",
cancelled_bookings: "0%",
total_revenue: "+18%",
monthly_revenue: "+32%",
};
setStats({
total_users: totalUsers,
active_users: activeUsers,
total_bookings: totalBookings,
upcoming_bookings: upcomingBookings,
completed_bookings: completedBookings,
cancelled_bookings: cancelledBookings,
total_revenue: totalRevenue,
monthly_revenue: monthlyRevenue,
trends,
});
} catch (error) {
console.error("Failed to fetch dashboard stats:", error);
toast.error("Failed to load dashboard statistics");
// Set default values on error
setStats({
total_users: 0,
active_users: 0,
total_bookings: 0,
upcoming_bookings: 0,
completed_bookings: 0, completed_bookings: 0,
cancelled_bookings: 0, cancelled_bookings: 0,
total_revenue: 0, total_revenue: 0,
monthly_revenue: 0, monthly_revenue: 0,
}; trends: {
total_users: "0%",
setStats(mockData); active_users: "0%",
total_bookings: "0%",
upcoming_bookings: "0",
completed_bookings: "0%",
cancelled_bookings: "0%",
total_revenue: "0%",
monthly_revenue: "0%",
},
});
} finally {
setLoading(false); setLoading(false);
}
}; };
fetchStats(); fetchStats();
}, []); }, [timePeriod]);
const statCards = [ const statCards = [
{ {
title: "Total Users", title: "Total Users",
value: stats?.total_users ?? 0, value: stats?.total_users ?? 0,
icon: Users, icon: Users,
trend: "+12%", trend: stats?.trends.total_users ?? "0%",
trendUp: true, trendUp: true,
}, },
{ {
title: "Active Users", title: "Active Users",
value: stats?.active_users ?? 0, value: stats?.active_users ?? 0,
icon: UserCheck, icon: UserCheck,
trend: "+8%", trend: stats?.trends.active_users ?? "0%",
trendUp: true, trendUp: true,
}, },
{ {
title: "Total Bookings", title: "Total Bookings",
value: stats?.total_bookings ?? 0, value: stats?.total_bookings ?? 0,
icon: Calendar, icon: Calendar,
trend: "+24%", trend: stats?.trends.total_bookings ?? "0%",
trendUp: true, trendUp: true,
}, },
{ {
title: "Upcoming Bookings", title: "Upcoming Bookings",
value: stats?.upcoming_bookings ?? 0, value: stats?.upcoming_bookings ?? 0,
icon: CalendarCheck, icon: CalendarCheck,
trend: "+6", trend: stats?.trends.upcoming_bookings ?? "0",
trendUp: true, trendUp: true,
}, },
{ {
title: "Completed Bookings", title: "Completed Bookings",
value: stats?.completed_bookings ?? 0, value: stats?.completed_bookings ?? 0,
icon: CalendarCheck, icon: CalendarCheck,
trend: "0%", trend: stats?.trends.completed_bookings ?? "0%",
trendUp: true, trendUp: true,
}, },
{ {
title: "Cancelled Bookings", title: "Cancelled Bookings",
value: stats?.cancelled_bookings ?? 0, value: stats?.cancelled_bookings ?? 0,
icon: CalendarX, icon: CalendarX,
trend: "0%", trend: stats?.trends.cancelled_bookings ?? "0%",
trendUp: false, trendUp: false,
}, },
{ {
title: "Total Revenue", title: "Total Revenue",
value: `$${stats?.total_revenue.toLocaleString() ?? 0}`, value: `$${stats?.total_revenue.toLocaleString() ?? 0}`,
icon: DollarSign, icon: DollarSign,
trend: "+18%", trend: stats?.trends.total_revenue ?? "0%",
trendUp: true, trendUp: true,
}, },
{ {
title: "Monthly Revenue", title: "Monthly Revenue",
value: `$${stats?.monthly_revenue.toLocaleString() ?? 0}`, value: `$${stats?.monthly_revenue.toLocaleString() ?? 0}`,
icon: TrendingUp, icon: TrendingUp,
trend: "+32%", trend: stats?.trends.monthly_revenue ?? "0%",
trendUp: true, trendUp: true,
}, },
]; ];

View File

@ -1,20 +1,349 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect, Suspense } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Heart, Eye, EyeOff, X } from "lucide-react"; import {
import Link from "next/link"; InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { Heart, Eye, EyeOff, X, Loader2, CheckCircle2 } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation"; import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useAppTheme } from "@/components/ThemeProvider"; import { useAppTheme } from "@/components/ThemeProvider";
import { useAuth } from "@/hooks/useAuth";
import {
loginSchema,
registerSchema,
verifyOtpSchema,
type LoginInput,
type RegisterInput,
type VerifyOtpInput
} from "@/lib/schema/auth";
import { toast } from "sonner";
export default function Login() { type Step = "login" | "signup" | "verify";
function LoginContent() {
const { theme } = useAppTheme(); const { theme } = useAppTheme();
const isDark = theme === "dark"; const isDark = theme === "dark";
const [step, setStep] = useState<Step>("login");
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [showPassword2, setShowPassword2] = useState(false);
const [rememberMe, setRememberMe] = useState(false); const [rememberMe, setRememberMe] = useState(false);
const [registeredEmail, setRegisteredEmail] = useState("");
// Login form data
const [loginData, setLoginData] = useState<LoginInput>({
email: "",
password: "",
});
// Signup form data
const [signupData, setSignupData] = useState<RegisterInput>({
first_name: "",
last_name: "",
email: "",
phone_number: "",
password: "",
password2: "",
});
// OTP verification data
const [otpData, setOtpData] = useState<VerifyOtpInput>({
email: "",
otp: "",
});
const [errors, setErrors] = useState<Partial<Record<string, string>>>({});
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams();
const {
login,
register,
verifyOtp,
isAuthenticated,
isAdmin,
loginMutation,
registerMutation,
verifyOtpMutation,
resendOtpMutation
} = useAuth();
// Check for verify step or email from query parameters
useEffect(() => {
const verifyEmail = searchParams.get("verify");
const emailParam = searchParams.get("email");
const errorParam = searchParams.get("error");
// Don't show verify step if there's an error indicating OTP sending failed
if (errorParam && errorParam.toLowerCase().includes("failed to send")) {
setStep("login");
return;
}
if (verifyEmail === "true" && emailParam) {
// Show verify step if verify=true
setStep("verify");
setRegisteredEmail(emailParam);
setOtpData({ email: emailParam, otp: "" });
} else if (emailParam && step === "login") {
// Pre-fill email in login form if email parameter is present
setLoginData(prev => ({ ...prev, email: emailParam }));
}
}, [searchParams, step]);
// Redirect if already authenticated
useEffect(() => {
if (isAuthenticated) {
// Use a small delay to ensure cookies are set and middleware has processed
const timer = setTimeout(() => {
// Always redirect based on user role, ignore redirect parameter if user is admin
const redirectParam = searchParams.get("redirect");
const defaultRedirect = isAdmin ? "/admin/dashboard" : "/user/dashboard";
const finalRedirect = isAdmin ? "/admin/dashboard" : (redirectParam || defaultRedirect);
// Use window.location.href to ensure full page reload and cookie reading
window.location.href = finalRedirect;
}, 200);
return () => clearTimeout(timer);
}
}, [isAuthenticated, isAdmin, searchParams]);
// Handle login
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
// Validate form
const validation = loginSchema.safeParse(loginData);
if (!validation.success) {
const fieldErrors: Partial<Record<string, string>> = {};
validation.error.issues.forEach((err) => {
if (err.path[0]) {
fieldErrors[err.path[0] as string] = err.message;
}
});
setErrors(fieldErrors);
return;
}
try {
const result = await login(loginData);
if (result.tokens && result.user) {
toast.success("Login successful!");
// Wait a moment for cookies to be set, then redirect
// Check if user is admin/staff/superuser - check all possible field names
const user = result.user as any;
const userIsAdmin =
user.is_admin === true ||
user.isAdmin === true ||
user.is_staff === true ||
user.isStaff === true ||
user.is_superuser === true ||
user.isSuperuser === true;
// Wait longer for cookies to be set and middleware to process
setTimeout(() => {
// Always redirect based on user role, ignore redirect parameter if user is admin
// This ensures admins always go to admin dashboard
const defaultRedirect = userIsAdmin ? "/admin/dashboard" : "/user/dashboard";
// Only use redirect parameter if user is NOT admin
const redirectParam = searchParams.get("redirect");
const finalRedirect = userIsAdmin ? "/admin/dashboard" : (redirectParam || defaultRedirect);
// Use window.location.href instead of router.push to ensure full page reload
// This ensures cookies are read correctly by middleware
window.location.href = finalRedirect;
}, 300);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Login failed. Please try again.";
toast.error(errorMessage);
setErrors({});
}
};
// Handle signup
const handleSignup = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
// Validate form
const validation = registerSchema.safeParse(signupData);
if (!validation.success) {
const fieldErrors: Partial<Record<string, string>> = {};
validation.error.issues.forEach((err) => {
if (err.path[0]) {
fieldErrors[err.path[0] as string] = err.message;
}
});
setErrors(fieldErrors);
return;
}
try {
const result = await register(signupData);
// Check if registration was successful (user created)
// Even if OTP sending failed, we should allow user to proceed to verification
// and use resend OTP feature
if (result && result.message) {
// Registration successful - proceed to OTP verification
toast.success("Registration successful! Please check your email for OTP verification.");
setRegisteredEmail(signupData.email);
setOtpData({ email: signupData.email, otp: "" });
setStep("verify");
} else {
// If no message but no error, still proceed (some APIs might not return message)
toast.success("Registration successful! Please check your email for OTP verification.");
setRegisteredEmail(signupData.email);
setOtpData({ email: signupData.email, otp: "" });
setStep("verify");
}
} catch (error) {
// Handle different types of errors
let errorMessage = "Registration failed. Please try again.";
if (error instanceof Error) {
errorMessage = error.message;
// If OTP sending failed, don't show OTP verification - just show error
if (errorMessage.toLowerCase().includes("failed to send") ||
errorMessage.toLowerCase().includes("failed to send otp")) {
toast.error("Registration failed: OTP could not be sent. Please try again later or contact support.");
setErrors({});
return;
}
// Check if it's an OTP sending error but registration might have succeeded
if (errorMessage.toLowerCase().includes("otp") ||
errorMessage.toLowerCase().includes("email") ||
errorMessage.toLowerCase().includes("send")) {
// If OTP sending failed but user might be created, allow proceeding to verification
// User can use resend OTP
toast.warning("Registration completed, but OTP email could not be sent. You can request a new OTP on the next screen.");
setRegisteredEmail(signupData.email);
setOtpData({ email: signupData.email, otp: "" });
setStep("verify");
return;
}
}
toast.error(errorMessage);
setErrors({});
}
};
// Handle OTP verification
const handleVerifyOtp = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
// Use registeredEmail if available, otherwise use otpData.email
const emailToVerify = registeredEmail || otpData.email;
if (!emailToVerify) {
setErrors({ email: "Email address is required" });
return;
}
// Prepare OTP data with email
const otpToVerify = {
email: emailToVerify,
otp: otpData.otp,
};
// Validate OTP
const validation = verifyOtpSchema.safeParse(otpToVerify);
if (!validation.success) {
const fieldErrors: Partial<Record<string, string>> = {};
validation.error.issues.forEach((err) => {
if (err.path[0]) {
fieldErrors[err.path[0] as string] = err.message;
}
});
setErrors(fieldErrors);
return;
}
try {
const result = await verifyOtp(otpToVerify);
// If verification is successful, switch to login step
toast.success("Email verified successfully! You can now login.");
// Switch to login step and pre-fill email
setStep("login");
setLoginData(prev => ({ ...prev, email: emailToVerify }));
setOtpData({ email: "", otp: "" });
setRegisteredEmail("");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "OTP verification failed. Please try again.";
toast.error(errorMessage);
setErrors({});
}
};
// Handle resend OTP
const handleResendOtp = async () => {
const emailToUse = registeredEmail || otpData.email;
if (!emailToUse) {
toast.error("Email address is required to resend OTP.");
return;
}
try {
await resendOtpMutation.mutateAsync({ email: emailToUse, context: "registration" });
toast.success("OTP resent successfully! Please check your email.");
// Update registeredEmail if it wasn't set
if (!registeredEmail) {
setRegisteredEmail(emailToUse);
}
} catch (error) {
let errorMessage = "Failed to resend OTP. Please try again.";
if (error instanceof Error) {
errorMessage = error.message;
// Provide more helpful error messages
if (errorMessage.toLowerCase().includes("ssl") ||
errorMessage.toLowerCase().includes("certificate")) {
errorMessage = "Email service is currently unavailable. Please contact support or try again later.";
} else if (errorMessage.toLowerCase().includes("not found") ||
errorMessage.toLowerCase().includes("does not exist")) {
errorMessage = "Email address not found. Please check your email or register again.";
}
}
toast.error(errorMessage);
}
};
// Handle form field changes
const handleLoginChange = (field: keyof LoginInput, value: string) => {
setLoginData((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
const handleSignupChange = (field: keyof RegisterInput, value: string) => {
setSignupData((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
const handleOtpChange = (field: keyof VerifyOtpInput, value: string) => {
setOtpData((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
return ( return (
<div className="min-h-screen relative flex items-center justify-center px-4 py-12"> <div className="min-h-screen relative flex items-center justify-center px-4 py-12">
@ -38,31 +367,58 @@ export default function Login() {
<span className="text-white text-xl font-semibold">Attune Heart Therapy</span> <span className="text-white text-xl font-semibold">Attune Heart Therapy</span>
</div> </div>
{/* Centered White Card */}
{/* Centered White Card - Login Form */}
<div className={`relative z-20 w-full max-w-md rounded-2xl shadow-2xl p-8 ${isDark ? 'bg-gray-800 border border-gray-700' : 'bg-white'}`}> <div className={`relative z-20 w-full max-w-md rounded-2xl shadow-2xl p-8 ${isDark ? 'bg-gray-800 border border-gray-700' : 'bg-white'}`}>
{/* Header with Close Button */} {/* Header with Close Button */}
<div className="flex items-start justify-between mb-2"> <div className="flex items-start justify-between mb-2">
<div className="flex-1"> <div className="flex-1">
{/* Heading */} {/* Heading */}
<h1 className="text-3xl font-bold bg-gradient-to-r from-rose-600 via-pink-600 to-rose-600 bg-clip-text text-transparent mb-2"> <h1 className="text-3xl font-bold bg-linear-to-r from-rose-600 via-pink-600 to-rose-600 bg-clip-text text-transparent mb-2">
Welcome back {step === "login" && "Welcome back"}
{step === "signup" && "Create an account"}
{step === "verify" && "Verify your email"}
</h1> </h1>
{/* Sign Up Prompt */} {/* Subtitle */}
{step === "login" && (
<p className={`mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}> <p className={`mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
New to Attune Heart Therapy?{" "} New to Attune Heart Therapy?{" "}
<Link href="/signup" className={`underline font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}> <Link
href="/signup"
className={`underline font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
>
Sign up Sign up
</Link> </Link>
</p> </p>
)}
{step === "signup" && (
<p className={`mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
Already have an account?{" "}
<button
type="button"
onClick={() => setStep("login")}
className={`underline font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
>
Log in
</button>
</p>
)}
{step === "verify" && registeredEmail && (
<p className={`mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
We've sent a verification code to <strong>{registeredEmail}</strong>
</p>
)}
{step === "verify" && !registeredEmail && (
<p className={`mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
Enter the verification code sent to your email
</p>
)}
</div> </div>
{/* Close Button */} {/* Close Button */}
<Button <Button
onClick={() => router.back()} onClick={() => router.back()}
variant="ghost" variant="ghost"
size="icon" size="icon"
className={`flex-shrink-0 w-8 h-8 rounded-full ${isDark ? 'text-gray-400 hover:text-gray-300 hover:bg-gray-700' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'}`} className={`shrink-0 w-8 h-8 rounded-full ${isDark ? 'text-gray-400 hover:text-gray-300 hover:bg-gray-700' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'}`}
aria-label="Close" aria-label="Close"
> >
<X className="w-5 h-5" /> <X className="w-5 h-5" />
@ -70,10 +426,8 @@ export default function Login() {
</div> </div>
{/* Login Form */} {/* Login Form */}
<form className="space-y-6" onSubmit={(e) => { {step === "login" && (
e.preventDefault(); <form className="space-y-6" onSubmit={handleLogin}>
router.push("/");
}}>
{/* Email Field */} {/* Email Field */}
<div className="space-y-2"> <div className="space-y-2">
<label htmlFor="email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}> <label htmlFor="email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
@ -83,9 +437,14 @@ export default function Login() {
id="email" id="email"
type="email" type="email"
placeholder="Email address" placeholder="Email address"
className={`h-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`} value={loginData.email}
onChange={(e) => handleLoginChange("email", e.target.value)}
className={`h-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'} ${errors.email ? 'border-red-500' : ''}`}
required required
/> />
{errors.email && (
<p className="text-sm text-red-500">{errors.email}</p>
)}
</div> </div>
{/* Password Field */} {/* Password Field */}
@ -98,7 +457,9 @@ export default function Login() {
id="password" id="password"
type={showPassword ? "text" : "password"} type={showPassword ? "text" : "password"}
placeholder="Your password" placeholder="Your password"
className={`h-12 pr-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`} value={loginData.password}
onChange={(e) => handleLoginChange("password", e.target.value)}
className={`h-12 pr-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'} ${errors.password ? 'border-red-500' : ''}`}
required required
/> />
<Button <Button
@ -116,14 +477,25 @@ export default function Login() {
)} )}
</Button> </Button>
</div> </div>
{errors.password && (
<p className="text-sm text-red-500">{errors.password}</p>
)}
</div> </div>
{/* Submit Button */} {/* Submit Button */}
<Button <Button
type="submit" type="submit"
className="w-full h-12 text-base font-semibold bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all" disabled={loginMutation.isPending}
className="w-full h-12 text-base font-semibold bg-linear-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
> >
Log in {loginMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Logging in...
</>
) : (
"Log in"
)}
</Button> </Button>
{/* Remember Me & Forgot Password */} {/* Remember Me & Forgot Password */}
@ -137,16 +509,300 @@ export default function Login() {
/> />
<span className={isDark ? 'text-gray-300' : 'text-black'}>Remember me</span> <span className={isDark ? 'text-gray-300' : 'text-black'}>Remember me</span>
</label> </label>
<Link <button
href="/forgot-password" type="button"
className={`font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`} className={`font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
> >
Forgot password? Forgot password?
</Link> </button>
</div>
</form>
)}
{/* Signup Form */}
{step === "signup" && (
<form className="space-y-4" onSubmit={handleSignup}>
{/* First Name Field */}
<div className="space-y-2">
<label htmlFor="firstName" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
First Name *
</label>
<Input
id="firstName"
type="text"
placeholder="John"
value={signupData.first_name}
onChange={(e) => handleSignupChange("first_name", e.target.value)}
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'} ${errors.first_name ? 'border-red-500' : ''}`}
required
/>
{errors.first_name && (
<p className="text-sm text-red-500">{errors.first_name}</p>
)}
</div>
{/* 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> </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> </form>
)}
</div> </div>
</div> </div>
); );
} }
export default function Login() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-rose-600" />
</div>
}>
<LoginContent />
</Suspense>
);
}

470
app/(auth)/signup/page.tsx Normal file
View 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>
);
}

View File

@ -23,11 +23,16 @@ import {
CheckCircle2, CheckCircle2,
CheckCircle, CheckCircle,
Loader2, Loader2,
LogOut,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { LoginDialog } from "@/components/LoginDialog"; import { LoginDialog } from "@/components/LoginDialog";
import { useAuth } from "@/hooks/useAuth";
import { useAppointments } from "@/hooks/useAppointments";
import { toast } from "sonner";
import type { Appointment } from "@/lib/models/appointments";
interface User { interface User {
ID: number; ID: number;
@ -73,6 +78,8 @@ export default function BookNowPage() {
const router = useRouter(); const router = useRouter();
const { theme } = useAppTheme(); const { theme } = useAppTheme();
const isDark = theme === "dark"; const isDark = theme === "dark";
const { isAuthenticated, logout } = useAuth();
const { create, isCreating } = useAppointments();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
firstName: "", firstName: "",
lastName: "", lastName: "",
@ -82,139 +89,140 @@ export default function BookNowPage() {
preferredTimes: [] as string[], preferredTimes: [] as string[],
message: "", message: "",
}); });
const [loading, setLoading] = useState(false);
const [booking, setBooking] = useState<Booking | null>(null); const [booking, setBooking] = useState<Booking | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showLoginDialog, setShowLoginDialog] = useState(false); const [showLoginDialog, setShowLoginDialog] = useState(false);
const handleLogout = () => {
logout();
toast.success("Logged out successfully");
router.push("/");
};
// Handle submit button click // Handle submit button click
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
// Open login dialog instead of submitting directly
setShowLoginDialog(true); // 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 () => { const handleLoginSuccess = async () => {
// Close login dialog
setShowLoginDialog(false);
// After successful login, proceed with booking submission // After successful login, proceed with booking submission
await submitBooking(); await submitBooking();
}; };
const submitBooking = async () => { const submitBooking = async () => {
setLoading(true);
setError(null); setError(null);
try { try {
if (formData.preferredDays.length === 0) { if (formData.preferredDays.length === 0) {
setError("Please select at least one available day."); setError("Please select at least one available day.");
setLoading(false);
return; return;
} }
if (formData.preferredTimes.length === 0) { if (formData.preferredTimes.length === 0) {
setError("Please select at least one preferred time."); setError("Please select at least one preferred time.");
setLoading(false);
return; return;
} }
// For now, we'll use the first selected day and first selected time // Convert day names to dates (YYYY-MM-DD format)
// This can be adjusted based on your backend requirements // Get next occurrence of each selected day
const firstDay = formData.preferredDays[0];
const firstTime = formData.preferredTimes[0];
const timeMap: { [key: string]: string } = {
morning: "09:00",
lunchtime: "12:00",
afternoon: "14:00",
};
const time24 = timeMap[firstTime] || "09:00";
// Get next occurrence of the first selected day
const today = new Date(); const today = new Date();
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const targetDayIndex = days.indexOf(firstDay); const preferredDates: string[] = [];
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];
// Combine date and time into scheduled_at (ISO format) formData.preferredDays.forEach((dayName) => {
const dateTimeString = `${dateString}T${time24}:00Z`; const targetDayIndex = days.indexOf(dayName);
if (targetDayIndex === -1) return;
// Prepare request payload 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);
});
// 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",
};
const preferredTimeSlots = formData.preferredTimes
.map((time) => timeSlotMap[time] || "morning")
.filter((time, index, self) => self.indexOf(time) === index) as ("morning" | "afternoon" | "evening")[]; // Remove duplicates
// Prepare request payload according to API spec
const payload = { const payload = {
first_name: formData.firstName, first_name: formData.firstName,
last_name: formData.lastName, last_name: formData.lastName,
email: formData.email, email: formData.email,
phone: formData.phone, preferred_dates: preferredDates,
scheduled_at: dateTimeString, preferred_time_slots: preferredTimeSlots,
duration: 60, // Default to 60 minutes ...(formData.phone && { phone: formData.phone }),
preferred_days: formData.preferredDays, ...(formData.message && { reason: formData.message }),
preferred_times: formData.preferredTimes,
notes: formData.message || "",
}; };
// Simulate API call - Replace with actual API endpoint // Call the actual API using the hook
const response = await fetch("/api/bookings", { const appointmentData = await create(payload);
method: "POST",
headers: { // Convert API response to Booking format for display
"Content-Type": "application/json", // 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();
const bookingData: Booking = {
ID: appointmentId || 0,
CreatedAt: appointmentData.created_at || now,
UpdatedAt: appointmentData.updated_at || now,
DeletedAt: null,
user_id: 0, // API doesn't return user_id in this response
user: {
ID: 0,
first_name: appointmentData.first_name,
last_name: appointmentData.last_name,
email: appointmentData.email,
phone: appointmentData.phone || "",
location: "",
is_admin: false,
bookings: null,
}, },
body: JSON.stringify(payload), scheduled_at: appointmentData.scheduled_datetime || "",
}).catch(() => { duration: appointmentData.scheduled_duration || 60,
// Fallback to mock data if API is not available status: appointmentData.status || "pending_review",
return null; jitsi_room_id: appointmentData.jitsi_room_id || "",
}); jitsi_room_url: appointmentData.jitsi_meet_url || "",
payment_id: "",
let bookingData: Booking; payment_status: "pending",
amount: 0,
if (response && response.ok) { notes: appointmentData.reason || "",
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(),
DeletedAt: null,
user_id: 1,
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,
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)}`,
payment_id: "",
payment_status: "pending",
amount: 52,
notes: formData.message || "Initial consultation session",
};
}
setBooking(bookingData); setBooking(bookingData);
setLoading(false); toast.success("Appointment request submitted successfully! We'll review and get back to you soon.");
// Redirect to home after 2 seconds // Redirect to user dashboard after 3 seconds
setTimeout(() => { setTimeout(() => {
router.push("/"); router.push("/user/dashboard");
}, 2000); }, 3000);
} catch (err) { } catch (err) {
setError("Failed to submit booking. Please try again."); const errorMessage = err instanceof Error ? err.message : "Failed to submit booking. Please try again.";
setLoading(false); setError(errorMessage);
toast.error(errorMessage);
console.error("Booking error:", err); console.error("Booking error:", err);
} }
}; };
@ -628,10 +636,10 @@ export default function BookNowPage() {
<Button <Button
type="submit" type="submit"
size="lg" size="lg"
disabled={loading} disabled={isCreating}
className="w-full bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all h-12 text-base font-semibold disabled:opacity-50 disabled:cursor-not-allowed" className="w-full bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all h-12 text-base font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
> >
{loading ? ( {isCreating ? (
<> <>
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> <Loader2 className="w-4 h-4 mr-2 animate-spin" />
Submitting... Submitting...
@ -660,6 +668,20 @@ export default function BookNowPage() {
</a> </a>
</p> </p>
</div> </div>
{/* Logout Button - Only show when authenticated */}
{isAuthenticated && (
<div className="mt-6 flex justify-center">
<Button
onClick={handleLogout}
variant="outline"
className="bg-red-600 hover:bg-red-700 text-white border-red-600 hover:border-red-700"
>
<LogOut className="w-4 h-4 mr-2" />
Logout
</Button>
</div>
)}
</> </>
)} )}
</div> </div>

View File

@ -21,7 +21,7 @@ export default function RootLayout({
}) { }) {
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body className={inter.className}> <body className={inter.className} suppressHydrationWarning>
<Providers> <Providers>
{children} {children}
<Toaster /> <Toaster />

View File

@ -2,12 +2,26 @@
import { ThemeProvider } from "../components/ThemeProvider"; import { ThemeProvider } from "../components/ThemeProvider";
import { type ReactNode } from "react"; import { type ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function Providers({ children }: { children: ReactNode }) { export function Providers({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
refetchOnWindowFocus: false,
retry: 1,
},
},
})
);
return ( return (
<ThemeProvider> <QueryClientProvider client={queryClient}>
{children} <ThemeProvider>{children}</ThemeProvider>
</ThemeProvider> </QueryClientProvider>
); );
} }

View File

@ -3,6 +3,7 @@
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { Heart, Mail, Phone, MapPin } from "lucide-react"; import { Heart, Mail, Phone, MapPin } from "lucide-react";
import { useAppTheme } from "@/components/ThemeProvider"; import { useAppTheme } from "@/components/ThemeProvider";
import Link from "next/link";
export function Footer() { export function Footer() {
const { theme } = useAppTheme(); const { theme } = useAppTheme();
@ -16,10 +17,11 @@ export function Footer() {
}; };
const quickLinks = [ const quickLinks = [
{ name: 'Home', href: '#home' }, { name: 'Home', href: '#home', isScroll: true },
{ name: 'About', href: '#about' }, { name: 'About', href: '#about', isScroll: true },
{ name: 'Services', href: '#services' }, { name: 'Services', href: '#services', isScroll: true },
{ name: 'Contact', href: '#contact' }, { name: 'Contact', href: '#contact', isScroll: true },
{ name: 'Admin Panel', href: '/login', isScroll: false },
]; ];
return ( return (
@ -74,12 +76,21 @@ export function Footer() {
<ul className="space-y-2"> <ul className="space-y-2">
{quickLinks.map((link) => ( {quickLinks.map((link) => (
<li key={link.name}> <li key={link.name}>
<button {link.isScroll ? (
onClick={() => scrollToSection(link.href.replace('#', ''))} <button
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" 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.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> </li>
))} ))}
</ul> </ul>

View File

@ -12,6 +12,10 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Eye, EyeOff, Loader2, X } from "lucide-react"; import { Eye, EyeOff, Loader2, X } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { loginSchema, registerSchema, type LoginInput, type RegisterInput } from "@/lib/schema/auth";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
interface LoginDialogProps { interface LoginDialogProps {
open: boolean; open: boolean;
@ -23,58 +27,87 @@ interface LoginDialogProps {
export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogProps) { export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogProps) {
const { theme } = useAppTheme(); const { theme } = useAppTheme();
const isDark = theme === "dark"; const isDark = theme === "dark";
const router = useRouter();
const { login, register, loginMutation, registerMutation } = useAuth();
const [isSignup, setIsSignup] = useState(false); const [isSignup, setIsSignup] = useState(false);
const [loginData, setLoginData] = useState({ const [loginData, setLoginData] = useState<LoginInput>({
email: "", email: "",
password: "", password: "",
}); });
const [signupData, setSignupData] = useState({ const [signupData, setSignupData] = useState<RegisterInput>({
fullName: "", first_name: "",
last_name: "",
email: "", email: "",
phone: "", phone_number: "",
password: "",
password2: "",
}); });
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [showPassword2, setShowPassword2] = useState(false);
const [rememberMe, setRememberMe] = useState(false); const [rememberMe, setRememberMe] = useState(false);
const [loginLoading, setLoginLoading] = useState(false);
const [signupLoading, setSignupLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const handleLogin = async (e: React.FormEvent) => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setLoginLoading(true);
setError(null); setError(null);
try { // Validate form
// Simulate login API call const validation = loginSchema.safeParse(loginData);
await new Promise((resolve) => setTimeout(resolve, 1000)); if (!validation.success) {
const firstError = validation.error.issues[0];
setError(firstError.message);
return;
}
// After successful login, close dialog and call success callback try {
setShowPassword(false); const result = await login(loginData);
setLoginLoading(false);
onOpenChange(false); if (result.tokens && result.user) {
onLoginSuccess(); toast.success("Login successful!");
setShowPassword(false);
onOpenChange(false);
onLoginSuccess();
}
} catch (err) { } catch (err) {
setError("Login failed. Please try again."); const errorMessage = err instanceof Error ? err.message : "Login failed. Please try again.";
setLoginLoading(false); setError(errorMessage);
toast.error(errorMessage);
} }
}; };
const handleSignup = async (e: React.FormEvent) => { const handleSignup = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setSignupLoading(true);
setError(null); setError(null);
try { // Validate form
// Simulate signup API call const validation = registerSchema.safeParse(signupData);
await new Promise((resolve) => setTimeout(resolve, 1000)); if (!validation.success) {
const firstError = validation.error.issues[0];
setError(firstError.message);
return;
}
// After successful signup, automatically log in and proceed try {
setSignupLoading(false); const result = await register(signupData);
onOpenChange(false);
onLoginSuccess(); if (result.message) {
toast.success("Registration successful! Please check your email for OTP verification.");
// Switch to login after successful registration
setIsSignup(false);
setLoginData({ email: signupData.email, password: "" });
setSignupData({
first_name: "",
last_name: "",
email: "",
phone_number: "",
password: "",
password2: "",
});
}
} catch (err) { } catch (err) {
setError("Signup failed. Please try again."); const errorMessage = err instanceof Error ? err.message : "Signup failed. Please try again.";
setSignupLoading(false); setError(errorMessage);
toast.error(errorMessage);
} }
}; };
@ -87,22 +120,29 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
const handleSwitchToLogin = () => { const handleSwitchToLogin = () => {
setIsSignup(false); setIsSignup(false);
setError(null); setError(null);
setSignupData({ fullName: "", email: "", phone: "" }); setSignupData({
first_name: "",
last_name: "",
email: "",
phone_number: "",
password: "",
password2: "",
});
}; };
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent <DialogContent
showCloseButton={false} showCloseButton={false}
className={`sm:max-w-md ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`} className={`max-w-md max-h-[90vh] overflow-hidden flex flex-col p-0 ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}
> >
{/* Header with Close Button */} {/* Header with Close Button - Fixed */}
<div className="flex items-start justify-between mb-2"> <div className="flex items-start justify-between p-6 pb-4 flex-shrink-0 border-b border-gray-200 dark:border-gray-700">
<DialogHeader className="flex-1"> <DialogHeader className="flex-1 pr-2">
<DialogTitle className="text-3xl font-bold bg-gradient-to-r from-rose-600 via-pink-600 to-rose-600 bg-clip-text text-transparent"> <DialogTitle className="text-2xl sm:text-3xl font-bold bg-gradient-to-r from-rose-600 via-pink-600 to-rose-600 bg-clip-text text-transparent">
{isSignup ? "Create an account" : "Welcome back"} {isSignup ? "Create an account" : "Welcome back"}
</DialogTitle> </DialogTitle>
<DialogDescription className={isDark ? 'text-gray-400' : 'text-gray-600'}> <DialogDescription className={`text-sm sm:text-base mt-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
{isSignup {isSignup
? "Sign up to complete your booking" ? "Sign up to complete your booking"
: "Please log in to complete your booking"} : "Please log in to complete your booking"}
@ -118,33 +158,51 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
</button> </button>
</div> </div>
{/* Signup Form */} {/* Scrollable Content */}
{isSignup ? ( <div className="overflow-y-auto flex-1 px-6">
<form className="space-y-6 mt-4" onSubmit={handleSignup}> {/* Signup Form */}
{isSignup ? (
<form className="space-y-4 sm:space-y-5 py-4 sm:py-6" onSubmit={handleSignup}>
{error && ( {error && (
<div className={`p-3 rounded-lg border ${isDark ? 'bg-red-900/20 border-red-800' : 'bg-red-50 border-red-200'}`}> <div className={`p-3 rounded-lg border ${isDark ? 'bg-red-900/20 border-red-800' : 'bg-red-50 border-red-200'}`}>
<p className={`text-sm ${isDark ? 'text-red-200' : 'text-red-800'}`}>{error}</p> <p className={`text-sm ${isDark ? 'text-red-200' : 'text-red-800'}`}>{error}</p>
</div> </div>
)} )}
{/* Full Name Field */} {/* First Name Field */}
<div className="space-y-2"> <div className="space-y-1.5 sm:space-y-2">
<label htmlFor="signup-fullName" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}> <label htmlFor="signup-firstName" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Full Name * First Name *
</label> </label>
<Input <Input
id="signup-fullName" id="signup-firstName"
type="text" type="text"
placeholder="John Doe" placeholder="John"
value={signupData.fullName} value={signupData.first_name}
onChange={(e) => setSignupData({ ...signupData, fullName: e.target.value })} onChange={(e) => setSignupData({ ...signupData, first_name: e.target.value })}
className={`h-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`} className={`h-11 sm:h-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
required
/>
</div>
{/* Last Name Field */}
<div className="space-y-1.5 sm:space-y-2">
<label htmlFor="signup-lastName" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Last Name *
</label>
<Input
id="signup-lastName"
type="text"
placeholder="Doe"
value={signupData.last_name}
onChange={(e) => setSignupData({ ...signupData, last_name: e.target.value })}
className={`h-11 sm:h-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
required required
/> />
</div> </div>
{/* Email Field */} {/* Email Field */}
<div className="space-y-2"> <div className="space-y-1.5 sm:space-y-2">
<label htmlFor="signup-email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}> <label htmlFor="signup-email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Email address * Email address *
</label> </label>
@ -154,34 +212,97 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
placeholder="Email address" placeholder="Email address"
value={signupData.email} value={signupData.email}
onChange={(e) => setSignupData({ ...signupData, email: e.target.value })} onChange={(e) => setSignupData({ ...signupData, email: e.target.value })}
className={`h-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`} className={`h-11 sm:h-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
required required
/> />
</div> </div>
{/* Phone Field */} {/* Phone Field */}
<div className="space-y-2"> <div className="space-y-1.5 sm:space-y-2">
<label htmlFor="signup-phone" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}> <label htmlFor="signup-phone" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Phone Number * Phone Number (Optional)
</label> </label>
<Input <Input
id="signup-phone" id="signup-phone"
type="tel" type="tel"
placeholder="+1 (555) 123-4567" placeholder="+1 (555) 123-4567"
value={signupData.phone} value={signupData.phone_number || ""}
onChange={(e) => setSignupData({ ...signupData, phone: e.target.value })} onChange={(e) => setSignupData({ ...signupData, phone_number: e.target.value })}
className={`h-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`} className={`h-11 sm:h-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
required
/> />
</div> </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 */} {/* Submit Button */}
<Button <Button
type="submit" type="submit"
disabled={signupLoading} disabled={registerMutation.isPending}
className="w-full h-12 text-base font-semibold bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed" className="w-full h-11 sm:h-12 text-sm sm:text-base font-semibold bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed mt-4 sm:mt-6"
> >
{signupLoading ? ( {registerMutation.isPending ? (
<> <>
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> <Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating account... Creating account...
@ -192,7 +313,7 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
</Button> </Button>
{/* Switch to Login */} {/* Switch to Login */}
<p className={`text-sm text-center ${isDark ? 'text-gray-400' : 'text-gray-600'}`}> <p className={`text-xs sm:text-sm text-center pt-2 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
Already have an account?{" "} Already have an account?{" "}
<button <button
type="button" type="button"
@ -205,7 +326,7 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
</form> </form>
) : ( ) : (
/* Login Form */ /* Login Form */
<form className="space-y-6 mt-4" onSubmit={handleLogin}> <form className="space-y-4 sm:space-y-5 py-4 sm:py-6" onSubmit={handleLogin}>
{error && ( {error && (
<div className={`p-3 rounded-lg border ${isDark ? 'bg-red-900/20 border-red-800' : 'bg-red-50 border-red-200'}`}> <div className={`p-3 rounded-lg border ${isDark ? 'bg-red-900/20 border-red-800' : 'bg-red-50 border-red-200'}`}>
<p className={`text-sm ${isDark ? 'text-red-200' : 'text-red-800'}`}>{error}</p> <p className={`text-sm ${isDark ? 'text-red-200' : 'text-red-800'}`}>{error}</p>
@ -213,7 +334,7 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
)} )}
{/* Email Field */} {/* Email Field */}
<div className="space-y-2"> <div className="space-y-1.5 sm:space-y-2">
<label htmlFor="login-email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}> <label htmlFor="login-email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Email address Email address
</label> </label>
@ -223,13 +344,13 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
placeholder="Email address" placeholder="Email address"
value={loginData.email} value={loginData.email}
onChange={(e) => setLoginData({ ...loginData, email: e.target.value })} onChange={(e) => setLoginData({ ...loginData, email: e.target.value })}
className={`h-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`} className={`h-11 sm:h-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
required required
/> />
</div> </div>
{/* Password Field */} {/* Password Field */}
<div className="space-y-2"> <div className="space-y-1.5 sm:space-y-2">
<label htmlFor="login-password" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}> <label htmlFor="login-password" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Your password Your password
</label> </label>
@ -240,7 +361,7 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
placeholder="Your password" placeholder="Your password"
value={loginData.password} value={loginData.password}
onChange={(e) => setLoginData({ ...loginData, password: e.target.value })} onChange={(e) => setLoginData({ ...loginData, password: e.target.value })}
className={`h-12 pr-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`} className={`h-11 sm:h-12 pr-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
required required
/> />
<Button <Button
@ -248,13 +369,13 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword(!showPassword)}
className={`absolute right-4 top-1/2 -translate-y-1/2 h-auto w-auto p-0 ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-700'}`} className={`absolute right-3 sm:right-4 top-1/2 -translate-y-1/2 h-auto w-auto p-0 ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-700'}`}
aria-label={showPassword ? "Hide password" : "Show password"} aria-label={showPassword ? "Hide password" : "Show password"}
> >
{showPassword ? ( {showPassword ? (
<EyeOff className="w-5 h-5" /> <EyeOff className="w-4 h-4 sm:w-5 sm:h-5" />
) : ( ) : (
<Eye className="w-5 h-5" /> <Eye className="w-4 h-4 sm:w-5 sm:h-5" />
)} )}
</Button> </Button>
</div> </div>
@ -263,10 +384,10 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
{/* Submit Button */} {/* Submit Button */}
<Button <Button
type="submit" type="submit"
disabled={loginLoading} disabled={loginMutation.isPending}
className="w-full h-12 text-base font-semibold bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed" className="w-full h-11 sm:h-12 text-sm sm:text-base font-semibold bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed mt-4 sm:mt-6"
> >
{loginLoading ? ( {loginMutation.isPending ? (
<> <>
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> <Loader2 className="w-4 h-4 mr-2 animate-spin" />
Logging in... Logging in...
@ -277,7 +398,7 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
</Button> </Button>
{/* Remember Me & Forgot Password */} {/* Remember Me & Forgot Password */}
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-xs sm:text-sm">
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
@ -289,7 +410,7 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
</label> </label>
<button <button
type="button" type="button"
className={`font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`} className={`font-medium text-xs sm:text-sm ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
> >
Forgot password? Forgot password?
@ -297,7 +418,7 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
</div> </div>
{/* Sign Up Prompt */} {/* Sign Up Prompt */}
<p className={`text-sm text-center ${isDark ? 'text-gray-400' : 'text-gray-600'}`}> <p className={`text-xs sm:text-sm text-center pt-2 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
New to Attune Heart Therapy?{" "} New to Attune Heart Therapy?{" "}
<button <button
type="button" type="button"
@ -309,6 +430,7 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP
</p> </p>
</form> </form>
)} )}
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@ -2,13 +2,15 @@
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Heart, Menu, X } from "lucide-react"; import { Heart, Menu, X, LogOut } from "lucide-react";
import { ThemeToggle } from "@/components/ThemeToggle"; import { ThemeToggle } from "@/components/ThemeToggle";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LoginDialog } from "@/components/LoginDialog"; import { LoginDialog } from "@/components/LoginDialog";
import { useRouter, usePathname } from "next/navigation"; import { useRouter, usePathname } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { useAppTheme } from "@/components/ThemeProvider"; import { useAppTheme } from "@/components/ThemeProvider";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
export function Navbar() { export function Navbar() {
const { theme } = useAppTheme(); const { theme } = useAppTheme();
@ -18,6 +20,9 @@ export function Navbar() {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const isUserDashboard = pathname?.startsWith("/user/dashboard"); const isUserDashboard = pathname?.startsWith("/user/dashboard");
const isUserSettings = pathname?.startsWith("/user/settings");
const isUserRoute = pathname?.startsWith("/user/");
const { isAuthenticated, logout } = useAuth();
const scrollToSection = (id: string) => { const scrollToSection = (id: string) => {
const element = document.getElementById(id); const element = document.getElementById(id);
@ -28,11 +33,18 @@ export function Navbar() {
}; };
const handleLoginSuccess = () => { const handleLoginSuccess = () => {
// Redirect to user dashboard after successful login // Redirect to admin dashboard after successful login
router.push("/user/dashboard"); router.push("/admin/dashboard");
setMobileMenuOpen(false); setMobileMenuOpen(false);
}; };
const handleLogout = () => {
logout();
toast.success("Logged out successfully");
setMobileMenuOpen(false);
router.push("/");
};
// Close mobile menu when clicking outside // Close mobile menu when clicking outside
useEffect(() => { useEffect(() => {
if (mobileMenuOpen) { if (mobileMenuOpen) {
@ -73,7 +85,7 @@ export function Navbar() {
</motion.div> </motion.div>
{/* Desktop Navigation */} {/* Desktop Navigation */}
{!isUserDashboard && ( {!isUserRoute && (
<div className="hidden lg:flex items-center gap-4 xl:gap-6"> <div className="hidden lg:flex items-center gap-4 xl:gap-6">
<button <button
onClick={() => scrollToSection("about")} onClick={() => scrollToSection("about")}
@ -98,7 +110,7 @@ export function Navbar() {
{/* Desktop Actions */} {/* Desktop Actions */}
<div className="hidden lg:flex items-center gap-2"> <div className="hidden lg:flex items-center gap-2">
{!isUserDashboard && ( {!isAuthenticated && !isUserDashboard && (
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
@ -109,9 +121,25 @@ export function Navbar() {
</Button> </Button>
)} )}
<ThemeToggle /> <ThemeToggle />
<Button size="sm" className="hover:opacity-90 hover:scale-105 transition-all text-xs sm:text-sm" asChild> {!isUserDashboard && (
<a href="/book-now">Book Now</a> <Link
</Button> 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> </div>
{/* Mobile Actions */} {/* Mobile Actions */}
@ -161,7 +189,7 @@ export function Navbar() {
> >
<div className="flex flex-col p-4 sm:p-6 space-y-3 sm:space-y-4"> <div className="flex flex-col p-4 sm:p-6 space-y-3 sm:space-y-4">
{/* Mobile Navigation Links */} {/* Mobile Navigation Links */}
{!isUserDashboard && ( {!isUserRoute && (
<> <>
<button <button
onClick={() => scrollToSection("about")} onClick={() => scrollToSection("about")}
@ -185,7 +213,7 @@ export function Navbar() {
)} )}
<div className={`border-t pt-3 sm:pt-4 mt-3 sm:mt-4 space-y-2 sm:space-y-3 ${isDark ? 'border-gray-700' : 'border-gray-200'}`}> <div className={`border-t pt-3 sm:pt-4 mt-3 sm:mt-4 space-y-2 sm:space-y-3 ${isDark ? 'border-gray-700' : 'border-gray-200'}`}>
{!isUserDashboard && ( {!isAuthenticated && !isUserDashboard && (
<Button <Button
variant="outline" variant="outline"
className={`w-full justify-start text-sm sm:text-base ${isDark ? 'border-gray-700 text-gray-300 hover:bg-gray-800' : ''}`} className={`w-full justify-start text-sm sm:text-base ${isDark ? 'border-gray-700 text-gray-300 hover:bg-gray-800' : ''}`}
@ -197,14 +225,27 @@ export function Navbar() {
Sign In Sign In
</Button> </Button>
)} )}
<Button {!isUserDashboard && (
className="w-full justify-start bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white text-sm sm:text-base" <Link
asChild href="/book-now"
> onClick={() => setMobileMenuOpen(false)}
<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'}`}
Book Now >
Book-Now
</Link> </Link>
</Button> )}
{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>
</div> </div>
</motion.div> </motion.div>

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

View File

@ -1,8 +1,29 @@
"use client"; "use client";
// Simple toaster component - can be enhanced later with toast notifications import { Toaster as Sonner } from "sonner";
import { useAppTheme } from "@/components/ThemeProvider";
export function Toaster() { export function Toaster() {
return null; const { theme } = useAppTheme();
return (
<Sonner
theme={theme === "dark" ? "dark" : "light"}
position="top-center"
richColors
closeButton
duration={4000}
expand={true}
toastOptions={{
classNames: {
toast: "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
/>
);
} }

207
hooks/useAppointments.ts Normal file
View 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
View 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
View 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
View 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
View 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;

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

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

View File

@ -17,10 +17,12 @@
"@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@tanstack/react-query": "^5.90.10",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"framer-motion": "^12.23.24", "framer-motion": "^12.23.24",
"input-otp": "^1.4.2",
"lucide-react": "^0.552.0", "lucide-react": "^0.552.0",
"next": "16.0.1", "next": "16.0.1",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
@ -28,7 +30,8 @@
"react-day-picker": "^9.11.1", "react-day-picker": "^9.11.1",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1" "tailwind-merge": "^3.3.1",
"zod": "^4.1.12"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",

View File

@ -23,6 +23,9 @@ importers:
'@radix-ui/react-slot': '@radix-ui/react-slot':
specifier: ^1.2.4 specifier: ^1.2.4
version: 1.2.4(@types/react@19.2.2)(react@19.2.0) version: 1.2.4(@types/react@19.2.2)(react@19.2.0)
'@tanstack/react-query':
specifier: ^5.90.10
version: 5.90.10(react@19.2.0)
class-variance-authority: class-variance-authority:
specifier: ^0.7.1 specifier: ^0.7.1
version: 0.7.1 version: 0.7.1
@ -35,6 +38,9 @@ importers:
framer-motion: framer-motion:
specifier: ^12.23.24 specifier: ^12.23.24
version: 12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0) version: 12.23.24(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
input-otp:
specifier: ^1.4.2
version: 1.4.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
lucide-react: lucide-react:
specifier: ^0.552.0 specifier: ^0.552.0
version: 0.552.0(react@19.2.0) version: 0.552.0(react@19.2.0)
@ -59,6 +65,9 @@ importers:
tailwind-merge: tailwind-merge:
specifier: ^3.3.1 specifier: ^3.3.1
version: 3.3.1 version: 3.3.1
zod:
specifier: ^4.1.12
version: 4.1.12
devDependencies: devDependencies:
'@tailwindcss/postcss': '@tailwindcss/postcss':
specifier: ^4 specifier: ^4
@ -890,6 +899,14 @@ packages:
'@tailwindcss/postcss@4.1.16': '@tailwindcss/postcss@4.1.16':
resolution: {integrity: sha512-Qn3SFGPXYQMKR/UtqS+dqvPrzEeBZHrFA92maT4zijCVggdsXnDBMsPFJo1eArX3J+O+Gi+8pV4PkqjLCNBk3A==} resolution: {integrity: sha512-Qn3SFGPXYQMKR/UtqS+dqvPrzEeBZHrFA92maT4zijCVggdsXnDBMsPFJo1eArX3J+O+Gi+8pV4PkqjLCNBk3A==}
'@tanstack/query-core@5.90.10':
resolution: {integrity: sha512-EhZVFu9rl7GfRNuJLJ3Y7wtbTnENsvzp+YpcAV7kCYiXni1v8qZh++lpw4ch4rrwC0u/EZRnBHIehzCGzwXDSQ==}
'@tanstack/react-query@5.90.10':
resolution: {integrity: sha512-BKLss9Y8PQ9IUjPYQiv3/Zmlx92uxffUOX8ZZNoQlCIZBJPT5M+GOMQj7xislvVQ6l1BstBjcX0XB/aHfFYVNw==}
peerDependencies:
react: ^18 || ^19
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@ -1633,6 +1650,12 @@ packages:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'} engines: {node: '>=0.8.19'}
input-otp@1.4.2:
resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==}
peerDependencies:
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
internal-slot@1.1.0: internal-slot@1.1.0:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -3193,6 +3216,13 @@ snapshots:
postcss: 8.5.6 postcss: 8.5.6
tailwindcss: 4.1.16 tailwindcss: 4.1.16
'@tanstack/query-core@5.90.10': {}
'@tanstack/react-query@5.90.10(react@19.2.0)':
dependencies:
'@tanstack/query-core': 5.90.10
react: 19.2.0
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@ -4098,6 +4128,11 @@ snapshots:
imurmurhash@0.1.4: {} imurmurhash@0.1.4: {}
input-otp@1.4.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
internal-slot@1.1.0: internal-slot@1.1.0:
dependencies: dependencies:
es-errors: 1.3.0 es-errors: 1.3.0