From eb8a800eb76e64dedbc3236a9cb905d6bed258c9 Mon Sep 17 00:00:00 2001 From: iamkiddy Date: Sun, 23 Nov 2025 13:29:31 +0000 Subject: [PATCH 01/10] 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. --- app/(auth)/login/page.tsx | 109 ++++++++- app/(auth)/signup/page.tsx | 450 ++++++++++++++++++++++++++++++++++++ app/providers.tsx | 22 +- components/LoginDialog.tsx | 201 ++++++++++++---- components/Navbar.tsx | 4 +- components/ui/input-otp.tsx | 77 ++++++ components/ui/toaster.tsx | 25 +- hooks/useAuth.ts | 178 ++++++++++++++ lib/actions/auth.ts | 289 +++++++++++++++++++++++ lib/api_urls.ts | 28 +++ lib/models/auth.ts | 48 ++++ lib/schema/auth.ts | 80 +++++++ middleware.ts | 53 +++++ package.json | 5 +- pnpm-lock.yaml | 35 +++ 15 files changed, 1543 insertions(+), 61 deletions(-) create mode 100644 app/(auth)/signup/page.tsx create mode 100644 components/ui/input-otp.tsx create mode 100644 hooks/useAuth.ts create mode 100644 lib/actions/auth.ts create mode 100644 lib/api_urls.ts create mode 100644 lib/models/auth.ts create mode 100644 lib/schema/auth.ts create mode 100644 middleware.ts diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index e67b95f..5aefcdd 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -1,20 +1,95 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Heart, Eye, EyeOff, X } from "lucide-react"; +import { Heart, Eye, EyeOff, X, Loader2 } from "lucide-react"; import Link from "next/link"; import Image from "next/image"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { useAppTheme } from "@/components/ThemeProvider"; +import { useAuth } from "@/hooks/useAuth"; +import { loginSchema, type LoginInput } from "@/lib/schema/auth"; +import { toast } from "sonner"; export default function Login() { const { theme } = useAppTheme(); const isDark = theme === "dark"; const [showPassword, setShowPassword] = useState(false); const [rememberMe, setRememberMe] = useState(false); + const [formData, setFormData] = useState({ + email: "", + password: "", + }); + const [errors, setErrors] = useState>>({}); const router = useRouter(); + const searchParams = useSearchParams(); + const { login, isAuthenticated, loginMutation } = useAuth(); + + // Redirect if already authenticated + useEffect(() => { + if (isAuthenticated) { + const redirect = searchParams.get("redirect") || "/admin/dashboard"; + router.push(redirect); + } + }, [isAuthenticated, router, searchParams]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setErrors({}); + + // Validate form + const validation = loginSchema.safeParse(formData); + if (!validation.success) { + const fieldErrors: Partial> = {}; + validation.error.issues.forEach((err) => { + if (err.path[0]) { + fieldErrors[err.path[0] as keyof LoginInput] = err.message; + } + }); + setErrors(fieldErrors); + return; + } + + try { + const result = await login(formData); + + if (result.tokens && result.user) { + toast.success("Login successful!"); + + // Normalize user data + const user = result.user; + // Check for admin status - 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; + + // Redirect based on user role + const redirect = searchParams.get("redirect"); + if (redirect) { + router.push(redirect); + } else { + // Default to admin dashboard + router.push("/admin/dashboard"); + } + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Login failed. Please try again."; + toast.error(errorMessage); + // Don't set field errors for server errors, only show toast + setErrors({}); + } + }; + + const handleChange = (field: keyof LoginInput, value: string) => { + setFormData((prev) => ({ ...prev, [field]: value })); + // Clear error when user starts typing + if (errors[field]) { + setErrors((prev) => ({ ...prev, [field]: undefined })); + } + }; return (
@@ -70,10 +145,7 @@ export default function Login() {
{/* Login Form */} -
{ - e.preventDefault(); - router.push("/"); - }}> + {/* Email Field */}
@@ -98,7 +172,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={formData.password} + onChange={(e) => handleChange("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 /> {/* Remember Me & Forgot Password */} diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx new file mode 100644 index 0000000..898cd16 --- /dev/null +++ b/app/(auth)/signup/page.tsx @@ -0,0 +1,450 @@ +"use client"; + +import { useState, useEffect } 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"; + +export default function Signup() { + 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({ + first_name: "", + last_name: "", + email: "", + phone_number: "", + password: "", + password2: "", + }); + const [otpData, setOtpData] = useState({ + email: "", + otp: "", + }); + const [errors, setErrors] = useState>>({}); + 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> = {}; + 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 (no error thrown), show OTP verification + toast.success("Registration successful! Please check your email for OTP verification."); + setRegisteredEmail(formData.email); + setOtpData({ email: formData.email, otp: "" }); + setStep("verify"); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : "Registration failed. Please try again."; + 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> = {}; + 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 + setTimeout(() => { + router.push("/login"); + }, 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 ( +
+ {/* Background Image */} +
+ Therapy and counseling session with African American clients + {/* Overlay for better readability */} +
+
+ + {/* Branding - Top Left */} +
+ + Attune Heart Therapy +
+ + {/* Centered White Card - Signup Form */} +
+ {/* Header with Close Button */} +
+
+ {/* Heading */} +

+ {step === "register" ? "Create an account" : "Verify your email"} +

+ {/* Login Prompt */} + {step === "register" && ( +

+ Already have an account?{" "} + + Log in + +

+ )} + {step === "verify" && ( +

+ We've sent a verification code to {registeredEmail} +

+ )} +
+ {/* Close Button */} + +
+ + {step === "register" ? ( + /* Registration Form */ + + {/* First Name Field */} +
+ + 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 && ( +

{errors.first_name}

+ )} +
+ + {/* Last Name Field */} +
+ + 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 && ( +

{errors.last_name}

+ )} +
+ + {/* Email Field */} +
+ + 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 + /> +
+ + {/* Phone Field */} +
+ + 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'}`} + /> +
+ + {/* Password Field */} +
+ +
+ 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 + /> + +
+ {errors.password && ( +

{errors.password}

+ )} +
+ + {/* Confirm Password Field */} +
+ +
+ 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 + /> + +
+ {errors.password2 && ( +

{errors.password2}

+ )} +
+ + {/* Submit Button */} + + + ) : ( + /* OTP Verification Form */ +
+
+
+ +
+

+ Check your email +

+

+ We've sent a 6-digit verification code to your email address. +

+
+
+
+ + {/* OTP Field */} +
+ +
+ handleOtpChange("otp", value)} + aria-invalid={!!errors.otp} + > + + + + + + + + + +
+
+ + {/* Resend OTP */} +
+ +
+ + {/* Submit Button */} + + + {/* Back to registration */} +
+ +
+
+ )} +
+
+ ); +} + diff --git a/app/providers.tsx b/app/providers.tsx index 9707b40..7d67f31 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -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 ( - - {children} - + + {children} + ); } - diff --git a/components/LoginDialog.tsx b/components/LoginDialog.tsx index b5fa24e..fb6f883 100644 --- a/components/LoginDialog.tsx +++ b/components/LoginDialog.tsx @@ -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({ email: "", password: "", }); - const [signupData, setSignupData] = useState({ - fullName: "", + const [signupData, setSignupData] = useState({ + 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(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.errors[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.errors[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,7 +120,14 @@ 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 ( @@ -127,17 +167,33 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP )} - {/* Full Name Field */} + {/* First Name Field */}
-
+ + {/* Last Name Field */} +
+ + setSignupData({ ...signupData, last_name: e.target.value })} className={`h-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`} required /> @@ -162,26 +218,89 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogP {/* Phone Field */}
setSignupData({ ...signupData, phone: e.target.value })} + value={signupData.phone_number || ""} + onChange={(e) => setSignupData({ ...signupData, phone_number: e.target.value })} className={`h-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`} - required />
+ {/* Password Field */} +
+ +
+ setSignupData({ ...signupData, 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'}`} + required + /> + +
+
+ + {/* Confirm Password Field */} +
+ +
+ setSignupData({ ...signupData, password2: 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'}`} + required + /> + +
+
+ {/* Submit Button */} + {link.isScroll ? ( + + ) : ( + + {link.name} + + )} ))} From 7b5f57ea8997e20dfb68e089abb4c6dbe6df28b6 Mon Sep 17 00:00:00 2001 From: iamkiddy Date: Sun, 23 Nov 2025 19:14:23 +0000 Subject: [PATCH 03/10] Add Next.js Link import to Footer component for improved navigation --- components/Footer.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/components/Footer.tsx b/components/Footer.tsx index 947ed3d..fc3d23e 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -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(); From 041c36079dbc2ff625d1a3d61d8e8fc1aef79315 Mon Sep 17 00:00:00 2001 From: iamkiddy Date: Sun, 23 Nov 2025 21:13:18 +0000 Subject: [PATCH 04/10] 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. --- app/(admin)/_components/header.tsx | 15 +- app/(admin)/_components/side-nav.tsx | 15 +- app/(auth)/login/page.tsx | 660 ++++++++++++++++++++++++--- app/(auth)/signup/page.tsx | 20 +- app/(pages)/book-now/page.tsx | 24 + components/Navbar.tsx | 83 +++- hooks/useAuth.ts | 14 +- lib/actions/auth.ts | 85 ++-- lib/models/auth.ts | 8 + middleware.ts | 32 +- 10 files changed, 829 insertions(+), 127 deletions(-) diff --git a/app/(admin)/_components/header.tsx b/app/(admin)/_components/header.tsx index ef68227..a5f5976 100644 --- a/app/(admin)/_components/header.tsx +++ b/app/(admin)/_components/header.tsx @@ -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() {
+

+ )} + {step === "verify" && registeredEmail && ( +

+ We've sent a verification code to {registeredEmail} +

+ )} + {step === "verify" && !registeredEmail && ( +

+ Enter the verification code sent to your email +

+ )} {/* Close Button */} + + + )} + + {/* Signup Form */} + {step === "signup" && ( +
+ {/* First Name Field */} +
+ + 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 && ( +

{errors.first_name}

+ )} +
+ + {/* Last Name Field */} +
+ + 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 && ( +

{errors.last_name}

+ )} +
+ + {/* Email Field */} +
+ + 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 && ( +

{errors.email}

+ )} +
+ + {/* Phone Field */} +
+ + 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'}`} + /> +
+ + {/* Password Field */} +
+ +
+ 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 + /> + +
+ {errors.password && ( +

{errors.password}

+ )} +
+ + {/* Confirm Password Field */} +
+ +
+ 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 + /> + +
+ {errors.password2 && ( +

{errors.password2}

+ )} +
+ + {/* Submit Button */} + +
+ )} + + {/* OTP Verification Form */} + {step === "verify" && ( +
+
+
+ +
+

+ Check your email +

+

+ We've sent a 6-digit verification code to your email address. +

+
+
+
+ + {/* Email Field (if not set) */} + {!registeredEmail && ( +
+ + 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 && ( +

{errors.email}

+ )} +
+ )} + + {/* OTP Field */} +
+ +
+ handleOtpChange("otp", value)} + aria-invalid={!!errors.otp} + > + + + + + + + + + +
+ {errors.otp && ( +

{errors.otp}

+ )} +
+ + {/* Resend OTP */} +
+
+ {/* Submit Button */} + + + {/* Back to signup */} +
+ +
+ )} ); -} \ No newline at end of file +} diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx index 898cd16..ca29c90 100644 --- a/app/(auth)/signup/page.tsx +++ b/app/(auth)/signup/page.tsx @@ -69,13 +69,21 @@ export default function Signup() { try { const result = await register(formData); - // If registration is successful (no error thrown), show OTP verification + // If registration is successful, redirect to login page with verify parameter toast.success("Registration successful! Please check your email for OTP verification."); - setRegisteredEmail(formData.email); - setOtpData({ email: formData.email, otp: "" }); - setStep("verify"); + // 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({}); @@ -105,9 +113,9 @@ export default function Signup() { // 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 + // Redirect to login page after OTP verification with email pre-filled setTimeout(() => { - router.push("/login"); + router.push(`/login?email=${encodeURIComponent(otpData.email)}`); }, 1500); } catch (error) { const errorMessage = error instanceof Error ? error.message : "OTP verification failed. Please try again."; diff --git a/app/(pages)/book-now/page.tsx b/app/(pages)/book-now/page.tsx index 07e50e0..b8528af 100644 --- a/app/(pages)/book-now/page.tsx +++ b/app/(pages)/book-now/page.tsx @@ -23,11 +23,14 @@ 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 { toast } from "sonner"; interface User { ID: number; @@ -73,6 +76,7 @@ export default function BookNowPage() { const router = useRouter(); const { theme } = useAppTheme(); const isDark = theme === "dark"; + const { isAuthenticated, logout } = useAuth(); const [formData, setFormData] = useState({ firstName: "", lastName: "", @@ -87,6 +91,12 @@ export default function BookNowPage() { const [error, setError] = useState(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(); @@ -660,6 +670,20 @@ export default function BookNowPage() {

+ + {/* Logout Button - Only show when authenticated */} + {isAuthenticated && ( +
+ +
+ )} )} diff --git a/components/Navbar.tsx b/components/Navbar.tsx index a3a55a2..dcd863a 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -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); @@ -33,6 +38,13 @@ export function Navbar() { 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() { {/* Desktop Navigation */} - {!isUserDashboard && ( + {!isUserRoute && (
+ {!isUserDashboard && ( + + Book-Now + + )} + {isAuthenticated && ( + + )}
{/* Mobile Actions */} @@ -161,7 +195,7 @@ export function Navbar() { >
{/* Mobile Navigation Links */} - {!isUserDashboard && ( + {!isUserRoute && ( <> + )} + {isAuthenticated && ( + + )}
diff --git a/hooks/useAuth.ts b/hooks/useAuth.ts index 160e42c..cabc484 100644 --- a/hooks/useAuth.ts +++ b/hooks/useAuth.ts @@ -43,8 +43,14 @@ export function useAuth() { // Check if user is authenticated const isAuthenticated = !!user && !!getStoredTokens().access; - // Check if user is admin (check both is_admin and isAdmin) - const isAdmin = user?.is_admin === true || (user as any)?.isAdmin === true; + // 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({ @@ -109,8 +115,8 @@ export function useAuth() { const logout = useCallback(() => { clearAuthData(); queryClient.clear(); - router.push("/login"); - }, [queryClient, router]); + // Don't redirect here - let components handle redirect as needed + }, [queryClient]); // Login function const login = useCallback( diff --git a/lib/actions/auth.ts b/lib/actions/auth.ts index a97aa4f..93a0bf2 100644 --- a/lib/actions/auth.ts +++ b/lib/actions/auth.ts @@ -76,6 +76,28 @@ async function handleResponse(response: Response): Promise { 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 { const response = await fetch(API_ENDPOINTS.auth.register, { @@ -86,6 +108,29 @@ export async function registerUser(input: RegisterInput): Promise 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(response); } @@ -100,23 +145,7 @@ export async function verifyOtp(input: VerifyOtpInput): Promise { }); const data = await handleResponse(response); - - // Normalize response: 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: map isVerified to is_verified if needed - if (data.user) { - if (data.user.isVerified !== undefined && data.user.is_verified === undefined) { - data.user.is_verified = data.user.isVerified; - } - } - - return data; + return normalizeAuthResponse(data); } // Login user @@ -130,23 +159,7 @@ export async function loginUser(input: LoginInput): Promise { }); const data = await handleResponse(response); - - // Normalize response: 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: map isVerified to is_verified if needed - if (data.user) { - if (data.user.isVerified !== undefined && data.user.is_verified === undefined) { - data.user.is_verified = data.user.isVerified; - } - } - - return data; + return normalizeAuthResponse(data); } // Resend OTP @@ -245,9 +258,7 @@ export function storeUser(user: User): void { if (typeof window === "undefined") return; localStorage.setItem("auth_user", JSON.stringify(user)); - - // Also set cookie for middleware - document.cookie = `auth_user=${JSON.stringify(user)}; path=/; max-age=${7 * 24 * 60 * 60}; SameSite=Lax`; + document.cookie = `auth_user=${encodeURIComponent(JSON.stringify(user))}; path=/; max-age=${7 * 24 * 60 * 60}; SameSite=Lax`; } // Get stored user diff --git a/lib/models/auth.ts b/lib/models/auth.ts index 6c7921e..2af43b9 100644 --- a/lib/models/auth.ts +++ b/lib/models/auth.ts @@ -11,9 +11,17 @@ export interface User { 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; } diff --git a/middleware.ts b/middleware.ts index 4c2bfa4..630a3ac 100644 --- a/middleware.ts +++ b/middleware.ts @@ -13,16 +13,31 @@ export function middleware(request: NextRequest) { if (userStr) { try { - const user = JSON.parse(userStr); - isAdmin = user.is_admin === true; + // 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 + // 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 @@ -34,12 +49,19 @@ export function middleware(request: NextRequest) { // 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("/admin/dashboard", request.url)); + if (isAdminRoute && isAuthenticated && !isAdmin) { + return NextResponse.redirect(new URL("/user/dashboard", request.url)); } return NextResponse.next(); From 37531f2b2b3be722a4c582d0659a01758ee0de95 Mon Sep 17 00:00:00 2001 From: iamkiddy Date: Sun, 23 Nov 2025 21:43:13 +0000 Subject: [PATCH 05/10] 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. --- app/(pages)/book-now/page.tsx | 176 ++++++++-------- hooks/useAppointments.ts | 207 +++++++++++++++++++ lib/actions/appointments.ts | 364 ++++++++++++++++++++++++++++++++++ lib/api_urls.ts | 4 + lib/models/appointments.ts | 78 ++++++++ lib/schema/appointments.ts | 43 ++++ 6 files changed, 781 insertions(+), 91 deletions(-) create mode 100644 hooks/useAppointments.ts create mode 100644 lib/actions/appointments.ts create mode 100644 lib/models/appointments.ts create mode 100644 lib/schema/appointments.ts diff --git a/app/(pages)/book-now/page.tsx b/app/(pages)/book-now/page.tsx index b8528af..63f28a3 100644 --- a/app/(pages)/book-now/page.tsx +++ b/app/(pages)/book-now/page.tsx @@ -30,7 +30,9 @@ 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; @@ -77,6 +79,7 @@ export default function BookNowPage() { const { theme } = useAppTheme(); const isDark = theme === "dark"; const { isAuthenticated, logout } = useAuth(); + const { create, isCreating } = useAppointments(); const [formData, setFormData] = useState({ firstName: "", lastName: "", @@ -86,7 +89,6 @@ export default function BookNowPage() { preferredTimes: [] as string[], message: "", }); - const [loading, setLoading] = useState(false); const [booking, setBooking] = useState(null); const [error, setError] = useState(null); const [showLoginDialog, setShowLoginDialog] = useState(false); @@ -100,131 +102,123 @@ export default function BookNowPage() { // 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 + const bookingData: Booking = { + ID: parseInt(appointmentData.id) || Math.floor(Math.random() * 1000), + CreatedAt: appointmentData.created_at || new Date().toISOString(), + UpdatedAt: appointmentData.updated_at || new Date().toISOString(), + 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); } }; @@ -638,10 +632,10 @@ export default function BookNowPage() { {loading ? (
- ) : filteredBookings.length === 0 ? ( + ) : filteredAppointments.length === 0 ? (

No bookings found

{searchTerm ? "Try adjusting your search terms" - : "Create a new booking to get started"} + : "No appointments have been created yet"}

) : ( @@ -251,10 +174,10 @@ export default function Booking() { Status - Payment + Preferred Dates - Amount + Created Actions @@ -262,9 +185,9 @@ export default function Booking() { - {filteredBookings.map((booking) => ( + {filteredAppointments.map((appointment) => ( @@ -274,55 +197,75 @@ export default function Booking() {
- {booking.user.first_name} {booking.user.last_name} + {appointment.first_name} {appointment.last_name}
-
- {formatDate(booking.scheduled_at)} + {appointment.email}
+ {appointment.phone && ( + + )} + {appointment.scheduled_datetime && ( +
+ {formatDate(appointment.scheduled_datetime)} +
+ )}
-
- {formatDate(booking.scheduled_at)} -
-
- - {formatTime(booking.scheduled_at)} -
+ {appointment.scheduled_datetime ? ( + <> +
+ {formatDate(appointment.scheduled_datetime)} +
+
+ + {formatTime(appointment.scheduled_datetime)} +
+ + ) : ( +
+ Not scheduled +
+ )} - {booking.duration} min + {appointment.scheduled_duration ? `${appointment.scheduled_duration} min` : "-"} - {booking.status} + {formatStatus(appointment.status)} - - - {booking.payment_status} - + + {appointment.preferred_dates && appointment.preferred_dates.length > 0 ? ( +
+ {appointment.preferred_dates.slice(0, 2).map((date, idx) => ( + {formatDate(date)} + ))} + {appointment.preferred_dates.length > 2 && ( + +{appointment.preferred_dates.length - 2} more + )} +
+ ) : ( + "-" + )} - - ${booking.amount} + + {formatDate(appointment.created_at)}
- {booking.jitsi_room_url && ( + {appointment.jitsi_meet_url && ( )} - {booking.notes && ( + {appointment.reason && ( diff --git a/app/(admin)/admin/dashboard/page.tsx b/app/(admin)/admin/dashboard/page.tsx index 2bb5fc4..d500554 100644 --- a/app/(admin)/admin/dashboard/page.tsx +++ b/app/(admin)/admin/dashboard/page.tsx @@ -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, }, ]; diff --git a/hooks/useAuth.ts b/hooks/useAuth.ts index cabc484..ab59df6 100644 --- a/hooks/useAuth.ts +++ b/hooks/useAuth.ts @@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; -import { useCallback } from "react"; +import { useCallback, useEffect } from "react"; import { loginUser, registerUser, @@ -17,6 +17,8 @@ import { storeTokens, storeUser, clearAuthData, + isTokenExpired, + hasValidAuth, } from "@/lib/actions/auth"; import type { LoginInput, @@ -28,6 +30,7 @@ import type { ResetPasswordInput, } from "@/lib/schema/auth"; import type { User } from "@/lib/models/auth"; +import { toast } from "sonner"; export function useAuth() { const router = useRouter(); @@ -40,8 +43,8 @@ export function useAuth() { staleTime: Infinity, }); - // Check if user is authenticated - const isAuthenticated = !!user && !!getStoredTokens().access; + // Check if user is authenticated with valid token + const isAuthenticated = !!user && hasValidAuth(); // Check if user is admin (check multiple possible field names) const isAdmin = @@ -108,6 +111,12 @@ export function useAuth() { mutationFn: (refresh: string) => refreshToken({ refresh }), onSuccess: (tokens) => { storeTokens(tokens); + queryClient.invalidateQueries({ queryKey: ["auth"] }); + }, + onError: () => { + // If refresh fails, logout + clearAuthData(); + queryClient.clear(); }, }); @@ -118,6 +127,42 @@ export function useAuth() { // 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) => { diff --git a/lib/actions/appointments.ts b/lib/actions/appointments.ts index de02ce6..a5b5c46 100644 --- a/lib/actions/appointments.ts +++ b/lib/actions/appointments.ts @@ -121,14 +121,26 @@ export async function listAppointments(email?: string): Promise { }, }); - const data: AppointmentsListResponse = await response.json(); + const data = await response.json(); if (!response.ok) { const errorMessage = extractErrorMessage(data as ApiError); throw new Error(errorMessage); } - return data.appointments || []; + // 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 @@ -147,14 +159,26 @@ export async function getUserAppointments(): Promise { }, }); - const data: AppointmentsListResponse = await response.json(); + const data = await response.json(); if (!response.ok) { const errorMessage = extractErrorMessage(data as ApiError); throw new Error(errorMessage); } - return data.appointments || []; + // 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 diff --git a/lib/actions/auth.ts b/lib/actions/auth.ts index 93a0bf2..4056bf1 100644 --- a/lib/actions/auth.ts +++ b/lib/actions/auth.ts @@ -229,6 +229,35 @@ export async function refreshToken(input: TokenRefreshInput): Promise(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") { @@ -241,6 +270,14 @@ export function getStoredTokens(): { access: string | null; refresh: string | nu }; } +// 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; @@ -292,9 +329,43 @@ export function clearAuthData(): void { // Get auth header for API requests export function getAuthHeader(): { Authorization: string } | {} { const tokens = getStoredTokens(); - if (tokens.access) { + if (tokens.access && !isTokenExpired(tokens.access)) { return { Authorization: `Bearer ${tokens.access}` }; } return {}; } +// Get all users (Admin only) +export async function getAllUsers(): Promise { + 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 []; +} + diff --git a/lib/api_urls.ts b/lib/api_urls.ts index 593e509..297fc83 100644 --- a/lib/api_urls.ts +++ b/lib/api_urls.ts @@ -20,6 +20,7 @@ export const API_ENDPOINTS = { 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/`, diff --git a/lib/models/appointments.ts b/lib/models/appointments.ts index 26cbd0e..7fef918 100644 --- a/lib/models/appointments.ts +++ b/lib/models/appointments.ts @@ -52,6 +52,7 @@ export interface AppointmentStats { scheduled: number; rejected: number; completion_rate: number; + users?: number; // Total users count from API } export interface JitsiMeetingInfo { From c7871cfb4636e0091cc4287927061b9e740ad560 Mon Sep 17 00:00:00 2001 From: iamkiddy Date: Mon, 24 Nov 2025 16:04:39 +0000 Subject: [PATCH 08/10] 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. --- app/(admin)/admin/booking/[id]/page.tsx | 800 ++++++++++++++++++++++++ app/(admin)/admin/booking/page.tsx | 324 +++++++++- app/(pages)/book-now/page.tsx | 10 +- app/layout.tsx | 2 +- 4 files changed, 1113 insertions(+), 23 deletions(-) create mode 100644 app/(admin)/admin/booking/[id]/page.tsx diff --git a/app/(admin)/admin/booking/[id]/page.tsx b/app/(admin)/admin/booking/[id]/page.tsx new file mode 100644 index 0000000..da9587d --- /dev/null +++ b/app/(admin)/admin/booking/[id]/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false); + const [rejectDialogOpen, setRejectDialogOpen] = useState(false); + const [scheduledDate, setScheduledDate] = useState(undefined); + const [scheduledTime, setScheduledTime] = useState("09:00"); + const [scheduledDuration, setScheduledDuration] = useState(60); + const [rejectionReason, setRejectionReason] = useState(""); + 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 ( +
+
+ +

Loading appointment details...

+
+
+ ); + } + + if (!appointment) { + return ( +
+
+

Appointment not found

+ +
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+ + +
+
+
+
+ {appointment.first_name[0]}{appointment.last_name[0]} +
+
+

+ {appointment.first_name} {appointment.last_name} +

+

+ Appointment Request +

+
+
+
+ +
+ + {appointment.status === "scheduled" && } + {formatStatus(appointment.status)} + +
+
+
+ +
+ {/* Main Content - Left Column (2/3) */} +
+ {/* Patient Information Card */} +
+
+

+ + Patient Information +

+
+
+
+
+

+ Full Name +

+

+ {appointment.first_name} {appointment.last_name} +

+
+
+

+ + Email Address +

+
+

+ {appointment.email} +

+ +
+
+ {appointment.phone && ( +
+

+ + Phone Number +

+
+

+ {appointment.phone} +

+ +
+
+ )} +
+
+
+ + {/* Appointment Details Card */} + {appointment.scheduled_datetime && ( +
+
+

+ + Scheduled Appointment +

+
+
+
+
+ +
+
+

+ {formatDate(appointment.scheduled_datetime)} +

+
+
+ +

+ {formatTime(appointment.scheduled_datetime)} +

+
+ {appointment.scheduled_duration && ( +
+ +

+ {appointment.scheduled_duration} minutes +

+
+ )} +
+
+
+
+
+ )} + + {/* Preferred Dates & Times */} + {(appointment.preferred_dates?.length > 0 || appointment.preferred_time_slots?.length > 0) && ( +
+
+

+ Preferred Availability +

+
+
+ {appointment.preferred_dates && appointment.preferred_dates.length > 0 && ( +
+

+ Preferred Dates +

+
+ {appointment.preferred_dates.map((date, idx) => ( + + {formatShortDate(date)} + + ))} +
+
+ )} + {appointment.preferred_time_slots && appointment.preferred_time_slots.length > 0 && ( +
+

+ Preferred Time Slots +

+
+ {appointment.preferred_time_slots.map((slot, idx) => ( + + {slot} + + ))} +
+
+ )} +
+
+ )} + + {/* Reason */} + {appointment.reason && ( +
+
+

+ + Reason for Appointment +

+
+
+

+ {appointment.reason} +

+
+
+ )} + + {/* Rejection Reason */} + {appointment.rejection_reason && ( +
+
+

+ Rejection Reason +

+
+
+

+ {appointment.rejection_reason} +

+
+
+ )} + + {/* Meeting Information */} + {appointment.jitsi_meet_url && ( +
+
+

+

+
+
+ {appointment.jitsi_room_id && ( +
+

+ Meeting Room ID +

+
+

+ {appointment.jitsi_room_id} +

+ +
+
+ )} +
+

+ Meeting Link +

+ +
+ {appointment.can_join_meeting !== undefined && ( +
+
+

+ {appointment.can_join_meeting ? "Meeting is active - You can join now" : "Meeting is not available yet"} +

+
+ )} +
+
+ )} +
+ + {/* Sidebar - Right Column (1/3) */} +
+ {/* Quick Info Card */} +
+
+

+ Quick Info +

+
+
+
+

+ Created +

+

+ {formatShortDate(appointment.created_at)} +

+

+ {formatTime(appointment.created_at)} +

+
+
+

+ Status +

+ + {appointment.status === "scheduled" && } + {formatStatus(appointment.status)} + +
+
+
+ + {/* Action Buttons */} + {appointment.status === "pending_review" && ( +
+
+ + +
+
+ )} + + {/* Join Meeting Button (if scheduled) */} + {appointment.status === "scheduled" && appointment.jitsi_meet_url && ( + + )} +
+
+
+ + {/* Google Meet Style Schedule Dialog */} + + + + + Schedule Appointment + + + Set date and time for {appointment.first_name} {appointment.last_name}'s appointment + + + +
+ {/* Date Selection */} +
+ +
+ +
+
+ + {/* Time Selection */} +
+ +
+ +
+
+ + {/* Duration Selection */} +
+ +
+
+ {[30, 60, 90, 120].map((duration) => ( + + ))} +
+
+
+ + {/* Preview */} + {scheduledDate && ( +
+

+ Appointment Preview +

+
+

+ {formatDate(scheduledDate.toISOString())} +

+

+ {new Date(`2000-01-01T${scheduledTime}`).toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + })} • {scheduledDuration} minutes +

+
+
+ )} +
+ + + + + +
+
+ + {/* Reject Appointment Dialog */} + + + + + Reject Appointment Request + + + Reject appointment request from {appointment.first_name} {appointment.last_name} + + + +
+
+ +