Compare commits

...

13 Commits

Author SHA1 Message Date
f5e867414b Merge pull request 'feat/authentication' (#21) from feat/authentication into master
Reviewed-on: http://35.207.46.142/ATTUNE-HEART-THERAPY/website/pulls/21
2025-11-24 22:09:50 +00:00
iamkiddy
7db02adbd7 Refactor Login and Signup components to utilize Suspense for improved loading states. Update error handling in LoginDialog to reference issues instead of errors for better validation feedback. Enhance appointment error handling by casting response data to unknown before extracting error messages. 2025-11-24 16:38:09 +00:00
iamkiddy
eec3976f94 Refactor login and OTP verification flow in Login component. Update success handling to switch to login step instead of redirecting, and enhance UI in LoginDialog for better responsiveness and accessibility. Adjust styling for form elements and improve layout consistency. 2025-11-24 16:21:21 +00:00
iamkiddy
c7871cfb46 Enhance Booking component with appointment scheduling and rejection functionality. Integrate dialogs for scheduling and rejecting appointments, improving user interaction. Update layout to suppress hydration warnings and refine appointment data handling in BookNowPage for consistent ID management. 2025-11-24 16:04:39 +00:00
iamkiddy
4f6e64bf99 Refactor Booking and Dashboard components to integrate appointment management and enhance data fetching logic. Replace mock data with API calls for appointments and user statistics, improving error handling and user feedback. Update UI elements for better search functionality and display of appointment details. 2025-11-23 22:28:02 +00:00
iamkiddy
43d0eae01f Refactor Navbar component to simplify button class names by removing conditional logic for styling. This improves readability and maintains consistent styling for logout buttons based on user state. 2025-11-23 21:58:14 +00:00
iamkiddy
37531f2b2b Refactor booking process in BookNowPage to integrate appointment creation via useAppointments hook. Enhance form submission logic to check user authentication before proceeding. Update API endpoint configurations for appointment management, including available dates and user appointments. Improve error handling and user feedback with toast notifications. 2025-11-23 21:43:13 +00:00
iamkiddy
041c36079d Enhance authentication and middleware logic by improving user role checks, adding OTP verification steps, and refining redirection based on user roles. Update login and signup forms to handle multiple user attributes and streamline error handling. Integrate logout functionality across components for better user experience. 2025-11-23 21:13:18 +00:00
iamkiddy
7b5f57ea89 Add Next.js Link import to Footer component for improved navigation 2025-11-23 19:14:23 +00:00
iamkiddy
41b60c930e Merge branch 'feat/login-integrations' into feat/authentication 2025-11-23 19:13:56 +00:00
iamkiddy
3180e48fe1 Merge branch 'master' of http://35.207.46.142/ATTUNE-HEART-THERAPY/website into feat/authentication 2025-11-23 19:06:39 +00:00
iamkiddy
72860dc63d Enhance Footer component by adding an Admin Panel link and updating quick links to support scroll behavior. Replace button elements with Next.js Link for navigation to the Admin Panel. 2025-11-23 19:06:09 +00:00
iamkiddy
eb8a800eb7 Enhance authentication flow by integrating @tanstack/react-query for improved data fetching, adding form validation in Login and LoginDialog components, and updating user redirection logic post-login. Also, include new dependencies: input-otp and zod for OTP input handling and schema validation. 2025-11-23 13:29:31 +00:00
27 changed files with 4597 additions and 426 deletions

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@
import { motion } from "framer-motion";
import { Heart, Mail, Phone, MapPin } from "lucide-react";
import { useAppTheme } from "@/components/ThemeProvider";
import Link from "next/link";
export function Footer() {
const { theme } = useAppTheme();
@ -16,10 +17,11 @@ export function Footer() {
};
const quickLinks = [
{ name: 'Home', href: '#home' },
{ name: 'About', href: '#about' },
{ name: 'Services', href: '#services' },
{ name: 'Contact', href: '#contact' },
{ name: 'Home', href: '#home', isScroll: true },
{ name: 'About', href: '#about', isScroll: true },
{ name: 'Services', href: '#services', isScroll: true },
{ name: 'Contact', href: '#contact', isScroll: true },
{ name: 'Admin Panel', href: '/login', isScroll: false },
];
return (
@ -74,12 +76,21 @@ export function Footer() {
<ul className="space-y-2">
{quickLinks.map((link) => (
<li key={link.name}>
<button
onClick={() => scrollToSection(link.href.replace('#', ''))}
className="text-sm text-muted-foreground hover:text-rose-600 dark:hover:text-rose-400 transition-colors cursor-pointer hover:translate-x-1 inline-block transition-transform"
>
{link.name}
</button>
{link.isScroll ? (
<button
onClick={() => scrollToSection(link.href.replace('#', ''))}
className="text-sm text-muted-foreground hover:text-rose-600 dark:hover:text-rose-400 transition-colors cursor-pointer hover:translate-x-1 inline-block transition-transform"
>
{link.name}
</button>
) : (
<Link
href={link.href}
className="text-sm text-muted-foreground hover:text-rose-600 dark:hover:text-rose-400 transition-colors cursor-pointer hover:translate-x-1 inline-block transition-transform"
>
{link.name}
</Link>
)}
</li>
))}
</ul>

View File

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

View File

@ -2,13 +2,15 @@
import { motion, AnimatePresence } from "framer-motion";
import { Button } from "@/components/ui/button";
import { Heart, Menu, X } from "lucide-react";
import { Heart, Menu, X, LogOut } from "lucide-react";
import { ThemeToggle } from "@/components/ThemeToggle";
import { useEffect, useState } from "react";
import { LoginDialog } from "@/components/LoginDialog";
import { useRouter, usePathname } from "next/navigation";
import Link from "next/link";
import { useAppTheme } from "@/components/ThemeProvider";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
export function Navbar() {
const { theme } = useAppTheme();
@ -18,6 +20,9 @@ export function Navbar() {
const router = useRouter();
const pathname = usePathname();
const isUserDashboard = pathname?.startsWith("/user/dashboard");
const isUserSettings = pathname?.startsWith("/user/settings");
const isUserRoute = pathname?.startsWith("/user/");
const { isAuthenticated, logout } = useAuth();
const scrollToSection = (id: string) => {
const element = document.getElementById(id);
@ -28,11 +33,18 @@ export function Navbar() {
};
const handleLoginSuccess = () => {
// Redirect to user dashboard after successful login
router.push("/user/dashboard");
// Redirect to admin dashboard after successful login
router.push("/admin/dashboard");
setMobileMenuOpen(false);
};
const handleLogout = () => {
logout();
toast.success("Logged out successfully");
setMobileMenuOpen(false);
router.push("/");
};
// Close mobile menu when clicking outside
useEffect(() => {
if (mobileMenuOpen) {
@ -73,7 +85,7 @@ export function Navbar() {
</motion.div>
{/* Desktop Navigation */}
{!isUserDashboard && (
{!isUserRoute && (
<div className="hidden lg:flex items-center gap-4 xl:gap-6">
<button
onClick={() => scrollToSection("about")}
@ -98,7 +110,7 @@ export function Navbar() {
{/* Desktop Actions */}
<div className="hidden lg:flex items-center gap-2">
{!isUserDashboard && (
{!isAuthenticated && !isUserDashboard && (
<Button
size="sm"
variant="outline"
@ -109,9 +121,25 @@ export function Navbar() {
</Button>
)}
<ThemeToggle />
<Button size="sm" className="hover:opacity-90 hover:scale-105 transition-all text-xs sm:text-sm" asChild>
<a href="/book-now">Book Now</a>
</Button>
{!isUserDashboard && (
<Link
href="/book-now"
className={`text-sm font-medium transition-colors cursor-pointer px-3 py-2 rounded-lg hover:opacity-90 ${isDark ? 'text-gray-300 hover:text-white' : 'text-gray-700 hover:text-rose-600'}`}
>
Book-Now
</Link>
)}
{isAuthenticated && (
<Button
size="sm"
variant="outline"
className="bg-red-600 hover:bg-red-700 text-white border-red-600 hover:border-red-700 hover:opacity-90 hover:scale-105 transition-all text-xs sm:text-sm"
onClick={handleLogout}
>
<LogOut className="w-4 h-4 mr-2" />
Logout
</Button>
)}
</div>
{/* Mobile Actions */}
@ -161,7 +189,7 @@ export function Navbar() {
>
<div className="flex flex-col p-4 sm:p-6 space-y-3 sm:space-y-4">
{/* Mobile Navigation Links */}
{!isUserDashboard && (
{!isUserRoute && (
<>
<button
onClick={() => scrollToSection("about")}
@ -185,7 +213,7 @@ export function Navbar() {
)}
<div className={`border-t pt-3 sm:pt-4 mt-3 sm:mt-4 space-y-2 sm:space-y-3 ${isDark ? 'border-gray-700' : 'border-gray-200'}`}>
{!isUserDashboard && (
{!isAuthenticated && !isUserDashboard && (
<Button
variant="outline"
className={`w-full justify-start text-sm sm:text-base ${isDark ? 'border-gray-700 text-gray-300 hover:bg-gray-800' : ''}`}
@ -197,14 +225,27 @@ export function Navbar() {
Sign In
</Button>
)}
<Button
className="w-full justify-start bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white text-sm sm:text-base"
asChild
>
<Link href="/book-now" onClick={() => setMobileMenuOpen(false)}>
Book Now
{!isUserDashboard && (
<Link
href="/book-now"
onClick={() => setMobileMenuOpen(false)}
className={`text-left text-sm sm:text-base font-medium py-2.5 sm:py-3 px-3 sm:px-4 rounded-lg transition-colors ${isDark ? 'text-gray-300 hover:bg-gray-800' : 'text-gray-700 hover:bg-gray-100'}`}
>
Book-Now
</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>
</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";
// Simple toaster component - can be enhanced later with toast notifications
import { Toaster as Sonner } from "sonner";
import { useAppTheme } from "@/components/ThemeProvider";
export function Toaster() {
return null;
const { theme } = useAppTheme();
return (
<Sonner
theme={theme === "dark" ? "dark" : "light"}
position="top-center"
richColors
closeButton
duration={4000}
expand={true}
toastOptions={{
classNames: {
toast: "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
/>
);
}

207
hooks/useAppointments.ts Normal file
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-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@tanstack/react-query": "^5.90.10",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"framer-motion": "^12.23.24",
"input-otp": "^1.4.2",
"lucide-react": "^0.552.0",
"next": "16.0.1",
"next-themes": "^0.4.6",
@ -28,7 +30,8 @@
"react-day-picker": "^9.11.1",
"react-dom": "19.2.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1"
"tailwind-merge": "^3.3.1",
"zod": "^4.1.12"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",

View File

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