From 0297e6e3e50772bc78e2d8acc9909a388d21c8dc Mon Sep 17 00:00:00 2001 From: iamkiddy Date: Tue, 25 Nov 2025 20:15:37 +0000 Subject: [PATCH 1/3] Implement admin availability management in Booking component. Add functionality to manage weekly availability, including day and time selection, and integrate a dialog for updating availability. Enhance API integration for fetching and updating admin availability data. Update UI elements for better user experience. --- app/(admin)/admin/booking/page.tsx | 267 +++++++++++++++++++++++ app/(auth)/login/page.tsx | 62 +++++- app/(pages)/book-now/page.tsx | 44 +++- components/LoginDialog.tsx | 75 ++++++- components/VerifyOtpDialog.tsx | 329 +++++++++++++++++++++++++++++ lib/actions/appointments.ts | 24 ++- lib/api_urls.ts | 1 + 7 files changed, 795 insertions(+), 7 deletions(-) create mode 100644 components/VerifyOtpDialog.tsx diff --git a/app/(admin)/admin/booking/page.tsx b/app/(admin)/admin/booking/page.tsx index 1b03984..7a85db0 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,79 @@ export default function Booking() { const [isRejecting, setIsRejecting] = useState(false); const { theme } = useAppTheme(); const isDark = theme === "dark"; + + // Availability management + const { adminAvailability, isLoadingAdminAvailability, updateAdminAvailability, isUpdatingAvailability } = useAppointments(); + const [selectedDays, setSelectedDays] = useState([]); + const [availabilityDialogOpen, setAvailabilityDialogOpen] = useState(false); + const [startTime, setStartTime] = useState("09:00"); + const [endTime, setEndTime] = useState("17:00"); + + 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" }, + ]; + + // Initialize selected days when availability is loaded + useEffect(() => { + if (adminAvailability?.available_days) { + setSelectedDays(adminAvailability.available_days); + } + }, [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) => + prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day].sort() + ); + }; + + const handleSaveAvailability = async () => { + if (selectedDays.length === 0) { + toast.error("Please select at least one available day"); + return; + } + + if (startTime >= endTime) { + toast.error("End time must be after start time"); + return; + } + + try { + await updateAdminAvailability({ available_days: selectedDays }); + toast.success("Availability updated successfully!"); + 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); + } + setAvailabilityDialogOpen(true); + }; useEffect(() => { const fetchBookings = async () => { @@ -235,7 +311,16 @@ export default function Booking() { Manage and view all appointment bookings

+ + {/* Search Bar */}
@@ -581,6 +666,188 @@ export default function Booking() { + {/* Availability Management Dialog */} + + + + + Manage Weekly Availability + + + +
+ {isLoadingAdminAvailability ? ( +
+ +
+ ) : ( + <> + {/* Current Availability Display */} + {adminAvailability?.available_days_display && adminAvailability.available_days_display.length > 0 && ( +
+

+ Current availability: {adminAvailability.available_days_display.join(", ")} +

+
+ )} + + {/* Days Selection */} +
+ +

+ Select the days of the week when you accept appointment requests +

+
+ {daysOfWeek.map((day) => ( + + ))} +
+
+ + {/* Time Selection */} +
+
+ +

+ Set the time range when appointments can be scheduled +

+
+ +
+ {/* Start Time */} +
+ + +
+ + {/* End Time */} +
+ + +
+
+ + {/* Time Range Display */} +
+

+ Available hours:{" "} + {new Date(`2000-01-01T${startTime}`).toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + })}{" "} + -{" "} + {new Date(`2000-01-01T${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..b720369 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,7 +331,7 @@ export async function updateAdminAvailability( throw new Error("Authentication required."); } - const response = await fetch(`${API_ENDPOINTS.meetings.base}admin/availability/`, { + const response = await fetch(API_ENDPOINTS.meetings.adminAvailability, { method: "PUT", headers: { "Content-Type": "application/json", 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; -- 2.39.5 From 85afc8c8401f11bb2382a191c9180d7161435926 Mon Sep 17 00:00:00 2001 From: iamkiddy Date: Tue, 25 Nov 2025 20:38:37 +0000 Subject: [PATCH 2/3] Enhance Booking component for admin availability management. Implement time range selection for each day, load saved time ranges from localStorage, and validate time inputs. Update API integration to ensure available_days is an array of numbers and improve error handling for availability updates. Refactor UI for better user experience in managing weekly availability. --- app/(admin)/admin/booking/page.tsx | 414 ++++++++++++++++++++--------- lib/actions/appointments.ts | 41 ++- 2 files changed, 325 insertions(+), 130 deletions(-) diff --git a/app/(admin)/admin/booking/page.tsx b/app/(admin)/admin/booking/page.tsx index 7a85db0..86c8c98 100644 --- a/app/(admin)/admin/booking/page.tsx +++ b/app/(admin)/admin/booking/page.tsx @@ -50,11 +50,10 @@ export default function Booking() { const isDark = theme === "dark"; // Availability management - const { adminAvailability, isLoadingAdminAvailability, updateAdminAvailability, isUpdatingAvailability } = useAppointments(); + const { adminAvailability, isLoadingAdminAvailability, updateAvailability, isUpdatingAvailability, refetchAdminAvailability } = useAppointments(); const [selectedDays, setSelectedDays] = useState([]); const [availabilityDialogOpen, setAvailabilityDialogOpen] = useState(false); - const [startTime, setStartTime] = useState("09:00"); - const [endTime, setEndTime] = useState("17:00"); + const [dayTimeRanges, setDayTimeRanges] = useState>({}); const daysOfWeek = [ { value: 0, label: "Monday" }, @@ -66,10 +65,48 @@ export default function Booking() { { value: 6, label: "Sunday" }, ]; - // Initialize selected days when availability is loaded + // 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]); @@ -88,9 +125,40 @@ export default function Booking() { const timeSlotsForPicker = generateTimeSlots(); const handleDayToggle = (day: number) => { - setSelectedDays((prev) => - prev.includes(day) ? prev.filter((d) => d !== day) : [...prev, day].sort() - ); + 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 () => { @@ -99,14 +167,32 @@ export default function Booking() { return; } - if (startTime >= endTime) { - toast.error("End time must be after start time"); - 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 { - await updateAdminAvailability({ available_days: selectedDays }); + // 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); @@ -118,6 +204,12 @@ export default function Booking() { 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); }; @@ -334,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 ? (
@@ -682,131 +828,145 @@ export default function Booking() {
) : ( <> - {/* Current Availability Display */} - {adminAvailability?.available_days_display && adminAvailability.available_days_display.length > 0 && ( -
-

- Current availability: {adminAvailability.available_days_display.join(", ")} -

-
- )} - - {/* Days Selection */} -
- -

- Select the days of the week when you accept appointment requests -

-
- {daysOfWeek.map((day) => ( - - ))} -
-
- - {/* Time Selection */} + {/* Days Selection with Time Ranges */}

- Set the time range when appointments can be scheduled + Select days and set time ranges for each day

- -
- {/* Start Time */} -
- - -
- {/* End Time */} -
- - -
-
+
+ {isSelected && } +
+ + {day.label} + + +
- {/* Time Range Display */} -
-

- Available hours:{" "} - {new Date(`2000-01-01T${startTime}`).toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - hour12: true, - })}{" "} - -{" "} - {new Date(`2000-01-01T${endTime}`).toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - hour12: true, - })} -

+ {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, + })} +
+
+ )} +
+ ); + })} @@ -829,7 +989,7 @@ export default function Booking() {