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 */}
+
+
);
}
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..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;