diff --git a/app/(admin)/admin/booking/page.tsx b/app/(admin)/admin/booking/page.tsx index 1b03984..89eab3d 100644 --- a/app/(admin)/admin/booking/page.tsx +++ b/app/(admin)/admin/booking/page.tsx @@ -11,9 +11,12 @@ import { X, Loader2, User, + Settings, + Check, } from "lucide-react"; import { useAppTheme } from "@/components/ThemeProvider"; import { listAppointments, scheduleAppointment, rejectAppointment } from "@/lib/actions/appointments"; +import { useAppointments } from "@/hooks/useAppointments"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { @@ -45,6 +48,171 @@ export default function Booking() { const [isRejecting, setIsRejecting] = useState(false); const { theme } = useAppTheme(); const isDark = theme === "dark"; + + // Availability management + const { adminAvailability, isLoadingAdminAvailability, updateAvailability, isUpdatingAvailability, refetchAdminAvailability } = useAppointments(); + const [selectedDays, setSelectedDays] = useState([]); + const [availabilityDialogOpen, setAvailabilityDialogOpen] = useState(false); + const [dayTimeRanges, setDayTimeRanges] = useState>({}); + + const daysOfWeek = [ + { value: 0, label: "Monday" }, + { value: 1, label: "Tuesday" }, + { value: 2, label: "Wednesday" }, + { value: 3, label: "Thursday" }, + { value: 4, label: "Friday" }, + { value: 5, label: "Saturday" }, + { value: 6, label: "Sunday" }, + ]; + + // Load time ranges from localStorage on mount + useEffect(() => { + const savedTimeRanges = localStorage.getItem("adminAvailabilityTimeRanges"); + if (savedTimeRanges) { + try { + const parsed = JSON.parse(savedTimeRanges); + setDayTimeRanges(parsed); + } catch (error) { + console.error("Failed to parse saved time ranges:", error); + } + } + }, []); + + // Initialize selected days and time ranges when availability is loaded + useEffect(() => { + if (adminAvailability?.available_days) { + setSelectedDays(adminAvailability.available_days); + // Load saved time ranges or use defaults + const savedTimeRanges = localStorage.getItem("adminAvailabilityTimeRanges"); + let initialRanges: Record = {}; + + if (savedTimeRanges) { + try { + const parsed = JSON.parse(savedTimeRanges); + // Only use saved ranges for days that are currently available + adminAvailability.available_days.forEach((day) => { + initialRanges[day] = parsed[day] || { startTime: "09:00", endTime: "17:00" }; + }); + } catch (error) { + // If parsing fails, use defaults + adminAvailability.available_days.forEach((day) => { + initialRanges[day] = { startTime: "09:00", endTime: "17:00" }; + }); + } + } else { + // No saved ranges, use defaults + adminAvailability.available_days.forEach((day) => { + initialRanges[day] = { startTime: "09:00", endTime: "17:00" }; + }); + } + + setDayTimeRanges(initialRanges); + } + }, [adminAvailability]); + + // Generate time slots for time picker + const generateTimeSlots = () => { + const slots = []; + for (let hour = 0; hour < 24; hour++) { + for (let minute = 0; minute < 60; minute += 30) { + const timeString = `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}`; + slots.push(timeString); + } + } + return slots; + }; + + const timeSlotsForPicker = generateTimeSlots(); + + const handleDayToggle = (day: number) => { + setSelectedDays((prev) => { + const newDays = prev.includes(day) + ? prev.filter((d) => d !== day) + : [...prev, day].sort(); + + // Initialize time range for newly added day + if (!prev.includes(day) && !dayTimeRanges[day]) { + setDayTimeRanges((prevRanges) => ({ + ...prevRanges, + [day]: { startTime: "09:00", endTime: "17:00" }, + })); + } + + // Remove time range for removed day + if (prev.includes(day)) { + setDayTimeRanges((prevRanges) => { + const newRanges = { ...prevRanges }; + delete newRanges[day]; + return newRanges; + }); + } + + return newDays; + }); + }; + + const handleTimeRangeChange = (day: number, field: "startTime" | "endTime", value: string) => { + setDayTimeRanges((prev) => ({ + ...prev, + [day]: { + ...prev[day], + [field]: value, + }, + })); + }; + + const handleSaveAvailability = async () => { + if (selectedDays.length === 0) { + toast.error("Please select at least one available day"); + return; + } + + // Validate all time ranges + for (const day of selectedDays) { + const timeRange = dayTimeRanges[day]; + if (!timeRange || !timeRange.startTime || !timeRange.endTime) { + toast.error(`Please set time range for ${daysOfWeek.find(d => d.value === day)?.label}`); + return; + } + if (timeRange.startTime >= timeRange.endTime) { + toast.error(`End time must be after start time for ${daysOfWeek.find(d => d.value === day)?.label}`); + return; + } + } + + try { + // Ensure selectedDays is an array of numbers + const daysToSave = selectedDays.map(day => Number(day)).sort(); + await updateAvailability({ available_days: daysToSave }); + + // Save time ranges to localStorage + localStorage.setItem("adminAvailabilityTimeRanges", JSON.stringify(dayTimeRanges)); + + toast.success("Availability updated successfully!"); + // Refresh availability data + if (refetchAdminAvailability) { + await refetchAdminAvailability(); + } + setAvailabilityDialogOpen(false); + } catch (error) { + console.error("Failed to update availability:", error); + const errorMessage = error instanceof Error ? error.message : "Failed to update availability"; + toast.error(errorMessage); + } + }; + + const handleOpenAvailabilityDialog = () => { + if (adminAvailability?.available_days) { + setSelectedDays(adminAvailability.available_days); + // Initialize time ranges for each day + const initialRanges: Record = {}; + adminAvailability.available_days.forEach((day) => { + initialRanges[day] = dayTimeRanges[day] || { startTime: "09:00", endTime: "17:00" }; + }); + setDayTimeRanges(initialRanges); + } + setAvailabilityDialogOpen(true); + }; useEffect(() => { const fetchBookings = async () => { @@ -235,7 +403,16 @@ export default function Booking() { Manage and view all appointment bookings

+ + {/* Search Bar */}
@@ -249,6 +426,60 @@ export default function Booking() {
+ {/* Available Days Display Card */} + {adminAvailability && ( +
+
+
+ +
+
+

+ Weekly Availability +

+ {adminAvailability.available_days_display && adminAvailability.available_days_display.length > 0 ? ( +
+ {adminAvailability.available_days.map((dayNum, index) => { + const dayName = daysOfWeek.find(d => d.value === dayNum)?.label || adminAvailability.available_days_display[index]; + const timeRange = dayTimeRanges[dayNum] || { startTime: "09:00", endTime: "17:00" }; + return ( +
+ + {dayName} + + ({new Date(`2000-01-01T${timeRange.startTime}`).toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + })}{" "} + -{" "} + {new Date(`2000-01-01T${timeRange.endTime}`).toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + })}) + +
+ ); + })} +
+ ) : ( +

+ No availability set. Click "Manage Availability" to set your available days. +

+ )} +
+
+
+ )} + {loading ? (
@@ -581,6 +812,202 @@ export default function Booking() { + {/* Availability Management Dialog */} + + + + + Manage Weekly Availability + + + +
+ {isLoadingAdminAvailability ? ( +
+ +
+ ) : ( + <> + {/* Days Selection with Time Ranges */} +
+
+ +

+ Select days and set time ranges for each day +

+
+ +
+ {daysOfWeek.map((day) => { + const isSelected = selectedDays.includes(day.value); + const timeRange = dayTimeRanges[day.value] || { startTime: "09:00", endTime: "17:00" }; + + return ( +
+
+ +
+ + {isSelected && ( +
+
+ {/* Start Time */} +
+ + +
+ + {/* End Time */} +
+ + +
+
+ + {/* Time Range Preview */} +
+ {new Date(`2000-01-01T${timeRange.startTime}`).toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + })}{" "} + -{" "} + {new Date(`2000-01-01T${timeRange.endTime}`).toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + })} +
+
+ )} +
+ ); + })} +
+
+ + )} +
+ + + + + +
+
+
); } diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index d7d9bfb..ebd6e09 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -8,7 +8,7 @@ import { InputOTPGroup, InputOTPSlot, } from "@/components/ui/input-otp"; -import { Heart, Eye, EyeOff, X, Loader2, CheckCircle2 } from "lucide-react"; +import { Heart, Eye, EyeOff, X, Loader2, CheckCircle2, Mail } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; @@ -34,6 +34,7 @@ function LoginContent() { const [showPassword2, setShowPassword2] = useState(false); const [rememberMe, setRememberMe] = useState(false); const [registeredEmail, setRegisteredEmail] = useState(""); + const [showResendOtp, setShowResendOtp] = useState(false); // Login form data const [loginData, setLoginData] = useState({ @@ -164,6 +165,15 @@ function LoginContent() { } catch (error) { const errorMessage = error instanceof Error ? error.message : "Login failed. Please try again."; toast.error(errorMessage); + + // Check if error is about email verification + if (errorMessage.toLowerCase().includes("verify your email") || + errorMessage.toLowerCase().includes("email address before logging")) { + setShowResendOtp(true); + } else { + setShowResendOtp(false); + } + setErrors({}); } }; @@ -479,6 +489,56 @@ function LoginContent() { )} + {/* Resend OTP - Show when email verification error occurs */} + {showResendOtp && ( +
+
+ +
+

+ Email verification required +

+

+ Please verify your email address before logging in. We can resend the verification code to {loginData.email}. +

+ +
+
+
+ )} + {/* Remember Me & Forgot Password */}
); diff --git a/components/LoginDialog.tsx b/components/LoginDialog.tsx index 85ef759..a4ee3a9 100644 --- a/components/LoginDialog.tsx +++ b/components/LoginDialog.tsx @@ -11,12 +11,13 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Eye, EyeOff, Loader2, X } from "lucide-react"; +import { Eye, EyeOff, Loader2, X, Mail } from "lucide-react"; import { useAuth } from "@/hooks/useAuth"; import { loginSchema, type LoginInput } from "@/lib/schema/auth"; import { toast } from "sonner"; import { useRouter } from "next/navigation"; import { ForgotPasswordDialog } from "./ForgotPasswordDialog"; +import { VerifyOtpDialog } from "./VerifyOtpDialog"; interface LoginDialogProps { open: boolean; @@ -38,6 +39,8 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess, prefillEmail, }); const [showPassword, setShowPassword] = useState(false); const [forgotPasswordDialogOpen, setForgotPasswordDialogOpen] = useState(false); + const [showResendOtp, setShowResendOtp] = useState(false); + const [verifyOtpDialogOpen, setVerifyOtpDialogOpen] = useState(false); // Pre-fill email if provided useEffect(() => { @@ -63,6 +66,7 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess, prefillEmail, if (result.tokens && result.user) { toast.success("Login successful!"); setShowPassword(false); + setShowResendOtp(false); onOpenChange(false); // Reset form setLoginData({ email: "", password: "" }); @@ -73,13 +77,48 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess, prefillEmail, } catch (err) { const errorMessage = err instanceof Error ? err.message : "Login failed. Please try again."; toast.error(errorMessage); + + // Check if error is about email verification + if (errorMessage.toLowerCase().includes("verify your email") || + errorMessage.toLowerCase().includes("email address before logging")) { + setShowResendOtp(true); + } else { + setShowResendOtp(false); + } } }; + // Handle resend OTP - just open the verification dialog (it will auto-send OTP) + const handleResendOtp = () => { + if (!loginData.email) { + toast.error("Email address is required to resend OTP."); + return; + } + + // Close login dialog and open OTP verification dialog + // The VerifyOtpDialog will automatically send the OTP when it opens + setShowResendOtp(false); + onOpenChange(false); + setTimeout(() => { + setVerifyOtpDialogOpen(true); + }, 100); + }; + + // Handle OTP verification success + const handleOtpVerificationSuccess = () => { + // After successful verification, user can try logging in again + setVerifyOtpDialogOpen(false); + // Optionally reopen login dialog + setTimeout(() => { + onOpenChange(true); + }, 100); + }; + // Reset form when dialog closes const handleDialogChange = (isOpen: boolean) => { if (!isOpen) { setLoginData({ email: "", password: "" }); + setShowResendOtp(false); } onOpenChange(isOpen); }; @@ -181,6 +220,31 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess, prefillEmail, )} + {/* Resend OTP - Show when email verification error occurs */} + {showResendOtp && ( +
+
+ +
+

+ Email verification required +

+

+ Please verify your email address before logging in. We can resend the verification code to {loginData.email}. +

+ +
+
+
+ )} + {/* Forgot Password */}
+
+ + {/* Scrollable Content */} +
+
+ {/* Loading indicator while sending */} + {isSendingOtp && !otpSent && ( +
+
+ +

+ Sending verification code... +

+
+
+ )} + + {/* Success message after sending */} + {otpSent && ( +
+
+ +
+

+ Check your email +

+

+ We've sent a 6-digit verification code to {email || otpData.email || "your email address"}. +

+
+
+
+ )} + + {/* Show info message if OTP hasn't been sent yet but dialog is open */} + {!isSendingOtp && !otpSent && ( +
+
+ +
+

+ Enter verification code +

+

+ Enter the 6-digit verification code sent to {email || otpData.email || "your email address"}. +

+
+
+
+ )} + + {/* Email Field (if not provided or editable) */} + {!initialEmail && ( +
+ + { + setEmail(e.target.value); + handleOtpChange("email", 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 + /> +
+ )} + + {/* OTP Field */} +
+ +
+ handleOtpChange("otp", value)} + > + + + + + + + + + +
+
+ + {/* Resend OTP */} +
+ +
+ + {/* Submit Button */} + +
+
+ + + ); +} + diff --git a/lib/actions/appointments.ts b/lib/actions/appointments.ts index 0b4d195..e4ecf34 100644 --- a/lib/actions/appointments.ts +++ b/lib/actions/appointments.ts @@ -287,7 +287,7 @@ export async function getAdminAvailability(): Promise { throw new Error("Authentication required."); } - const response = await fetch(`${API_ENDPOINTS.meetings.base}admin/availability/`, { + const response = await fetch(API_ENDPOINTS.meetings.adminAvailability, { method: "GET", headers: { "Content-Type": "application/json", @@ -295,14 +295,30 @@ export async function getAdminAvailability(): Promise { }, }); - const data: AdminAvailability = await response.json(); + const data: any = await response.json(); if (!response.ok) { const errorMessage = extractErrorMessage(data as unknown as ApiError); throw new Error(errorMessage); } - return data; + // Handle both string and array formats for available_days + let availableDays: number[] = []; + if (typeof data.available_days === 'string') { + try { + availableDays = JSON.parse(data.available_days); + } catch { + // If parsing fails, try splitting by comma + availableDays = data.available_days.split(',').map((d: string) => parseInt(d.trim())).filter((d: number) => !isNaN(d)); + } + } else if (Array.isArray(data.available_days)) { + availableDays = data.available_days; + } + + return { + available_days: availableDays, + available_days_display: data.available_days_display || [], + } as AdminAvailability; } // Update admin availability @@ -315,23 +331,58 @@ export async function updateAdminAvailability( throw new Error("Authentication required."); } - const response = await fetch(`${API_ENDPOINTS.meetings.base}admin/availability/`, { + // Ensure available_days is an array of numbers + const payload = { + available_days: Array.isArray(input.available_days) + ? input.available_days.map(day => Number(day)) + : input.available_days + }; + + const response = await fetch(API_ENDPOINTS.meetings.adminAvailability, { method: "PUT", headers: { "Content-Type": "application/json", Authorization: `Bearer ${tokens.access}`, }, - body: JSON.stringify(input), + body: JSON.stringify(payload), }); - const data: AdminAvailability = await response.json(); + let data: any; + try { + data = await response.json(); + } catch (parseError) { + // If response is not JSON, use status text + throw new Error(response.statusText || "Failed to update availability"); + } if (!response.ok) { const errorMessage = extractErrorMessage(data as unknown as ApiError); + console.error("Availability update error:", { + status: response.status, + statusText: response.statusText, + data, + payload + }); throw new Error(errorMessage); } - return data; + // Handle both string and array formats for available_days in response + let availableDays: number[] = []; + if (typeof data.available_days === 'string') { + try { + availableDays = JSON.parse(data.available_days); + } catch { + // If parsing fails, try splitting by comma + availableDays = data.available_days.split(',').map((d: string) => parseInt(d.trim())).filter((d: number) => !isNaN(d)); + } + } else if (Array.isArray(data.available_days)) { + availableDays = data.available_days; + } + + return { + available_days: availableDays, + available_days_display: data.available_days_display || [], + } as AdminAvailability; } // Get appointment stats (Admin only) diff --git a/lib/api_urls.ts b/lib/api_urls.ts index 564b998..0dab8aa 100644 --- a/lib/api_urls.ts +++ b/lib/api_urls.ts @@ -20,6 +20,7 @@ export const API_ENDPOINTS = { createAppointment: `${API_BASE_URL}/meetings/appointments/create/`, listAppointments: `${API_BASE_URL}/meetings/appointments/`, userAppointments: `${API_BASE_URL}/meetings/user/appointments/`, + adminAvailability: `${API_BASE_URL}/meetings/admin/availability/`, }, } as const;