From 0297e6e3e50772bc78e2d8acc9909a388d21c8dc Mon Sep 17 00:00:00 2001
From: iamkiddy
Date: Tue, 25 Nov 2025 20:15:37 +0000
Subject: [PATCH] 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 */}
+
+
);
}
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 */}
+
+ {/* Verify OTP Dialog */}
+
);
}
diff --git a/components/VerifyOtpDialog.tsx b/components/VerifyOtpDialog.tsx
new file mode 100644
index 0000000..f83e2ea
--- /dev/null
+++ b/components/VerifyOtpDialog.tsx
@@ -0,0 +1,329 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { Button } from "@/components/ui/button";
+import { useAppTheme } from "@/components/ThemeProvider";
+import { Input } from "@/components/ui/input";
+import {
+ InputOTP,
+ InputOTPGroup,
+ InputOTPSlot,
+} from "@/components/ui/input-otp";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Loader2, X, CheckCircle2 } from "lucide-react";
+import { useAuth } from "@/hooks/useAuth";
+import { verifyOtpSchema, type VerifyOtpInput } from "@/lib/schema/auth";
+import { toast } from "sonner";
+
+interface VerifyOtpDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ email: string;
+ context?: "registration" | "password_reset";
+ onVerificationSuccess?: () => void;
+ title?: string;
+ description?: string;
+}
+
+export function VerifyOtpDialog({
+ open,
+ onOpenChange,
+ email: initialEmail,
+ context = "registration",
+ onVerificationSuccess,
+ title = "Verify your email",
+ description = "Enter the verification code sent to your email"
+}: VerifyOtpDialogProps) {
+ const { theme } = useAppTheme();
+ const isDark = theme === "dark";
+ const { verifyOtp, verifyOtpMutation, resendOtpMutation } = useAuth();
+ const [otpData, setOtpData] = useState
({
+ email: initialEmail,
+ otp: "",
+ });
+ const [email, setEmail] = useState(initialEmail);
+ const [otpSent, setOtpSent] = useState(false);
+ const [isSendingOtp, setIsSendingOtp] = useState(false);
+
+ // Update email when prop changes
+ useEffect(() => {
+ if (initialEmail) {
+ setEmail(initialEmail);
+ setOtpData(prev => ({ ...prev, email: initialEmail }));
+ }
+ }, [initialEmail]);
+
+ // Automatically send OTP when dialog opens
+ useEffect(() => {
+ if (open && !otpSent) {
+ const emailToSend = initialEmail || email;
+ if (emailToSend) {
+ setIsSendingOtp(true);
+ resendOtpMutation.mutateAsync({
+ email: emailToSend,
+ context
+ })
+ .then(() => {
+ toast.success("Verification code sent! Please check your email.");
+ setOtpSent(true);
+ setIsSendingOtp(false);
+ })
+ .catch((err) => {
+ const errorMessage = err instanceof Error ? err.message : "Failed to send verification code";
+ toast.error(errorMessage);
+ setIsSendingOtp(false);
+ // Still allow user to manually resend
+ });
+ }
+ }
+
+ // Reset when dialog closes
+ if (!open) {
+ setOtpSent(false);
+ setIsSendingOtp(false);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open, initialEmail, email, context, otpSent]);
+
+ const handleVerifyOtp = async (e: React.FormEvent) => {
+ e.preventDefault();
+
+ const emailToVerify = email || otpData.email;
+ if (!emailToVerify) {
+ toast.error("Email address is required");
+ return;
+ }
+
+ const validation = verifyOtpSchema.safeParse({
+ email: emailToVerify,
+ otp: otpData.otp,
+ });
+
+ if (!validation.success) {
+ const firstError = validation.error.issues[0];
+ toast.error(firstError.message);
+ return;
+ }
+
+ try {
+ const result = await verifyOtp({
+ email: emailToVerify,
+ otp: otpData.otp,
+ });
+
+ if (result.message || result.tokens) {
+ toast.success("Email verified successfully!");
+ // Reset form
+ setOtpData({ email: emailToVerify, otp: "" });
+ onOpenChange(false);
+ // Call success callback if provided
+ if (onVerificationSuccess) {
+ onVerificationSuccess();
+ }
+ }
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : "OTP verification failed. Please try again.";
+ toast.error(errorMessage);
+ }
+ };
+
+ const handleResendOtp = async () => {
+ const emailToResend = email || otpData.email;
+ if (!emailToResend) {
+ toast.error("Email address is required");
+ return;
+ }
+
+ try {
+ setIsSendingOtp(true);
+ await resendOtpMutation.mutateAsync({
+ email: emailToResend,
+ context
+ });
+ toast.success("OTP resent successfully! Please check your email.");
+ setOtpSent(true);
+ setIsSendingOtp(false);
+ } catch (err) {
+ const errorMessage = err instanceof Error ? err.message : "Failed to resend OTP";
+ toast.error(errorMessage);
+ setIsSendingOtp(false);
+ }
+ };
+
+ const handleOtpChange = (field: keyof VerifyOtpInput, value: string) => {
+ setOtpData((prev) => ({ ...prev, [field]: value }));
+ };
+
+ // Reset form when dialog closes
+ const handleDialogChange = (isOpen: boolean) => {
+ if (!isOpen) {
+ setOtpData({ email: initialEmail || "", otp: "" });
+ setOtpSent(false);
+ setIsSendingOtp(false);
+ }
+ onOpenChange(isOpen);
+ };
+
+ return (
+
+ );
+}
+
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;