From f6bd813c07b4e1ce78f896fa5eab3d30dd0a3224 Mon Sep 17 00:00:00 2001 From: iamkiddy Date: Tue, 25 Nov 2025 21:04:22 +0000 Subject: [PATCH 1/3] Refactor Booking component to manage time slots instead of time ranges. Update localStorage handling for time slots, enhance UI for selecting time slots, and improve validation for selected slots. Integrate public availability fetching in BookNowPage to display available time slots based on admin settings. --- app/(admin)/admin/booking/page.tsx | 245 +++++++++++------------------ app/(pages)/book-now/page.tsx | 122 ++++++++++++-- hooks/useAppointments.ts | 5 +- lib/actions/appointments.ts | 48 +++++- 4 files changed, 249 insertions(+), 171 deletions(-) diff --git a/app/(admin)/admin/booking/page.tsx b/app/(admin)/admin/booking/page.tsx index 89eab3d..12c68af 100644 --- a/app/(admin)/admin/booking/page.tsx +++ b/app/(admin)/admin/booking/page.tsx @@ -53,7 +53,7 @@ export default function Booking() { const { adminAvailability, isLoadingAdminAvailability, updateAvailability, isUpdatingAvailability, refetchAdminAvailability } = useAppointments(); const [selectedDays, setSelectedDays] = useState([]); const [availabilityDialogOpen, setAvailabilityDialogOpen] = useState(false); - const [dayTimeRanges, setDayTimeRanges] = useState>({}); + const [dayTimeSlots, setDayTimeSlots] = useState>({}); const daysOfWeek = [ { value: 0, label: "Monday" }, @@ -65,64 +65,56 @@ export default function Booking() { { value: 6, label: "Sunday" }, ]; - // Load time ranges from localStorage on mount + // Load time slots from localStorage on mount useEffect(() => { - const savedTimeRanges = localStorage.getItem("adminAvailabilityTimeRanges"); - if (savedTimeRanges) { + const savedTimeSlots = localStorage.getItem("adminAvailabilityTimeSlots"); + if (savedTimeSlots) { try { - const parsed = JSON.parse(savedTimeRanges); - setDayTimeRanges(parsed); + const parsed = JSON.parse(savedTimeSlots); + setDayTimeSlots(parsed); } catch (error) { - console.error("Failed to parse saved time ranges:", error); + console.error("Failed to parse saved time slots:", error); } } }, []); - // Initialize selected days and time ranges when availability is loaded + // Initialize selected days and time slots 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 = {}; + // Load saved time slots or use defaults + const savedTimeSlots = localStorage.getItem("adminAvailabilityTimeSlots"); + let initialSlots: Record = {}; - if (savedTimeRanges) { + if (savedTimeSlots) { try { - const parsed = JSON.parse(savedTimeRanges); - // Only use saved ranges for days that are currently available + const parsed = JSON.parse(savedTimeSlots); + // Only use saved slots for days that are currently available adminAvailability.available_days.forEach((day) => { - initialRanges[day] = parsed[day] || { startTime: "09:00", endTime: "17:00" }; + initialSlots[day] = parsed[day] || ["morning", "lunchtime", "afternoon"]; }); } catch (error) { // If parsing fails, use defaults adminAvailability.available_days.forEach((day) => { - initialRanges[day] = { startTime: "09:00", endTime: "17:00" }; + initialSlots[day] = ["morning", "lunchtime", "afternoon"]; }); } } else { - // No saved ranges, use defaults + // No saved slots, use defaults adminAvailability.available_days.forEach((day) => { - initialRanges[day] = { startTime: "09:00", endTime: "17:00" }; + initialSlots[day] = ["morning", "lunchtime", "afternoon"]; }); } - setDayTimeRanges(initialRanges); + setDayTimeSlots(initialSlots); } }, [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 timeSlotOptions = [ + { value: "morning", label: "Morning" }, + { value: "lunchtime", label: "Lunchtime" }, + { value: "afternoon", label: "Evening" }, + ]; const handleDayToggle = (day: number) => { setSelectedDays((prev) => { @@ -130,20 +122,20 @@ export default function Booking() { ? 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" }, + // Initialize time slots for newly added day + if (!prev.includes(day) && !dayTimeSlots[day]) { + setDayTimeSlots((prevSlots) => ({ + ...prevSlots, + [day]: ["morning", "lunchtime", "afternoon"], })); } - // Remove time range for removed day + // Remove time slots for removed day if (prev.includes(day)) { - setDayTimeRanges((prevRanges) => { - const newRanges = { ...prevRanges }; - delete newRanges[day]; - return newRanges; + setDayTimeSlots((prevSlots) => { + const newSlots = { ...prevSlots }; + delete newSlots[day]; + return newSlots; }); } @@ -151,14 +143,18 @@ export default function Booking() { }); }; - const handleTimeRangeChange = (day: number, field: "startTime" | "endTime", value: string) => { - setDayTimeRanges((prev) => ({ - ...prev, - [day]: { - ...prev[day], - [field]: value, - }, - })); + const handleTimeSlotToggle = (day: number, slot: string) => { + setDayTimeSlots((prev) => { + const currentSlots = prev[day] || []; + const newSlots = currentSlots.includes(slot) + ? currentSlots.filter((s) => s !== slot) + : [...currentSlots, slot]; + + return { + ...prev, + [day]: newSlots, + }; + }); }; const handleSaveAvailability = async () => { @@ -167,15 +163,11 @@ export default function Booking() { return; } - // Validate all time ranges + // Validate all time slots 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}`); + const timeSlots = dayTimeSlots[day]; + if (!timeSlots || timeSlots.length === 0) { + toast.error(`Please select at least one time slot for ${daysOfWeek.find(d => d.value === day)?.label}`); return; } } @@ -185,8 +177,8 @@ export default function Booking() { const daysToSave = selectedDays.map(day => Number(day)).sort(); await updateAvailability({ available_days: daysToSave }); - // Save time ranges to localStorage - localStorage.setItem("adminAvailabilityTimeRanges", JSON.stringify(dayTimeRanges)); + // Save time slots to localStorage + localStorage.setItem("adminAvailabilityTimeSlots", JSON.stringify(dayTimeSlots)); toast.success("Availability updated successfully!"); // Refresh availability data @@ -204,12 +196,12 @@ export default function Booking() { const handleOpenAvailabilityDialog = () => { if (adminAvailability?.available_days) { setSelectedDays(adminAvailability.available_days); - // Initialize time ranges for each day - const initialRanges: Record = {}; + // Initialize time slots for each day + const initialSlots: Record = {}; adminAvailability.available_days.forEach((day) => { - initialRanges[day] = dayTimeRanges[day] || { startTime: "09:00", endTime: "17:00" }; + initialSlots[day] = dayTimeSlots[day] || ["morning", "lunchtime", "afternoon"]; }); - setDayTimeRanges(initialRanges); + setDayTimeSlots(initialSlots); } setAvailabilityDialogOpen(true); }; @@ -441,7 +433,11 @@ export default function Booking() {
{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" }; + const timeSlots = dayTimeSlots[dayNum] || []; + const slotLabels = timeSlots.map(slot => { + const option = timeSlotOptions.find(opt => opt.value === slot); + return option ? option.label : slot; + }); 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, - })}) - + {slotLabels.length > 0 && ( + + ({slotLabels.join(", ")}) + + )}
); })} @@ -835,14 +823,13 @@ export default function Booking() { Available Days & Times *

- Select days and set time ranges for each day + Select days and choose time slots (Morning, Lunchtime, Evening) for each day

{daysOfWeek.map((day) => { const isSelected = selectedDays.includes(day.value); - const timeRange = dayTimeRanges[day.value] || { startTime: "09:00", endTime: "17:00" }; return (
-
- {/* 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, + +
+ {timeSlotOptions.map((slot) => { + const isSelectedSlot = dayTimeSlots[day.value]?.includes(slot.value) || false; + return ( + + ); })}
diff --git a/app/(pages)/book-now/page.tsx b/app/(pages)/book-now/page.tsx index 3ab1a2c..f2a7967 100644 --- a/app/(pages)/book-now/page.tsx +++ b/app/(pages)/book-now/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { useAppTheme } from "@/components/ThemeProvider"; import { Input } from "@/components/ui/input"; @@ -24,6 +24,7 @@ import { CheckCircle, Loader2, LogOut, + CalendarCheck, } from "lucide-react"; import Link from "next/link"; import Image from "next/image"; @@ -34,6 +35,8 @@ import { useAuth } from "@/hooks/useAuth"; import { useAppointments } from "@/hooks/useAppointments"; import { toast } from "sonner"; import type { Appointment } from "@/lib/models/appointments"; +import { getPublicAvailability } from "@/lib/actions/appointments"; +import type { AdminAvailability } from "@/lib/models/appointments"; interface User { ID: number; @@ -80,7 +83,7 @@ export default function BookNowPage() { const { theme } = useAppTheme(); const isDark = theme === "dark"; const { isAuthenticated, logout } = useAuth(); - const { create, isCreating } = useAppointments(); + const { create, isCreating, availableDates, availableDatesResponse, isLoadingAvailableDates } = useAppointments(); const [formData, setFormData] = useState({ firstName: "", lastName: "", @@ -95,6 +98,68 @@ export default function BookNowPage() { const [showLoginDialog, setShowLoginDialog] = useState(false); const [showSignupDialog, setShowSignupDialog] = useState(false); const [loginPrefillEmail, setLoginPrefillEmail] = useState(undefined); + const [publicAvailability, setPublicAvailability] = useState(null); + const [availableTimeSlots, setAvailableTimeSlots] = useState>({}); + + // Fetch public availability to get time slots + useEffect(() => { + const fetchAvailability = async () => { + try { + const availability = await getPublicAvailability(); + if (availability) { + setPublicAvailability(availability); + // Try to get time slots from localStorage (if admin has set them) + // Note: This won't work for public users, but we can try + const savedTimeSlots = localStorage.getItem("adminAvailabilityTimeSlots"); + if (savedTimeSlots) { + try { + const parsed = JSON.parse(savedTimeSlots); + setAvailableTimeSlots(parsed); + } catch (e) { + console.error("Failed to parse time slots:", e); + } + } + } + } catch (error) { + console.error("Failed to fetch public availability:", error); + } + }; + fetchAvailability(); + }, []); + + // Use available_days_display from API if available, otherwise extract from dates + const availableDaysOfWeek = useMemo(() => { + // If API provides available_days_display, use it directly + if (availableDatesResponse?.available_days_display && availableDatesResponse.available_days_display.length > 0) { + return availableDatesResponse.available_days_display; + } + + // Otherwise, extract from dates + if (!availableDates || availableDates.length === 0) { + return []; + } + + const daysSet = new Set(); + const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + + availableDates.forEach((dateStr) => { + try { + // Parse date string (YYYY-MM-DD format) + const [year, month, day] = dateStr.split('-').map(Number); + const date = new Date(year, month - 1, day); + if (!isNaN(date.getTime())) { + const dayIndex = date.getDay(); + daysSet.add(dayNames[dayIndex]); + } + } catch (e) { + console.error('Invalid date:', dateStr, e); + } + }); + + // Return in weekday order (Monday first) + const weekdayOrder = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; + return weekdayOrder.filter(day => daysSet.has(day)); + }, [availableDates, availableDatesResponse]); const handleLogout = () => { logout(); @@ -566,7 +631,7 @@ export default function BookNowPage() { Appointment Details -
+
-
- {['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'].map((day) => ( + {isLoadingAvailableDates ? ( +
+ + Loading available days... +
+ ) : availableDaysOfWeek.length === 0 ? ( +

+ No available days at the moment. Please check back later. +

+ ) : ( + <> +
+ {availableDaysOfWeek.map((day) => ( ))} -
+
+ + )}
@@ -608,11 +686,33 @@ export default function BookNowPage() { Preferred Time *
- {[ - { value: 'morning', label: 'Morning' }, - { value: 'lunchtime', label: 'Lunchtime' }, - { value: 'afternoon', label: 'Afternoon' } - ].map((time) => ( + {(() => { + // Get available time slots based on selected days + const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + const dayIndices = formData.preferredDays.map(day => dayNames.indexOf(day)); + + // Get all unique time slots from selected days + let allAvailableSlots = new Set(); + dayIndices.forEach(dayIndex => { + if (dayIndex !== -1 && availableTimeSlots[dayIndex]) { + availableTimeSlots[dayIndex].forEach(slot => allAvailableSlots.add(slot)); + } + }); + + // If no time slots found in localStorage, show all (fallback) + const slotsToShow = allAvailableSlots.size > 0 + ? Array.from(allAvailableSlots) + : ['morning', 'lunchtime', 'afternoon']; + + const timeSlotMap = [ + { value: 'morning', label: 'Morning' }, + { value: 'lunchtime', label: 'Lunchtime' }, + { value: 'afternoon', label: 'Evening' } + ]; + + // Only show time slots that are available + return timeSlotMap.filter(ts => slotsToShow.includes(ts.value)); + })().map((time) => (
@@ -139,21 +188,49 @@ export default function AdminSettingsPage() { -
- -
- - handleInputChange("fullName", e.target.value)} - className={`pl-10 ${isDark ? "bg-gray-700 border-gray-600 text-white placeholder:text-gray-400" : ""}`} - placeholder="Enter your full name" - /> + {fetching ? ( +
+
-
+ ) : ( + <> +
+
+ +
+ + handleInputChange("firstName", e.target.value)} + className={`pl-10 ${isDark ? "bg-gray-700 border-gray-600 text-white placeholder:text-gray-400" : ""}`} + placeholder="Enter your first name" + required + /> +
+
+ +
+ +
+ + handleInputChange("lastName", e.target.value)} + className={`pl-10 ${isDark ? "bg-gray-700 border-gray-600 text-white placeholder:text-gray-400" : ""}`} + placeholder="Enter your last name" + required + /> +
+
+
+ + )}
+

+ Email address cannot be changed +

diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index ebd6e09..935a9a6 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -139,13 +139,18 @@ function LoginContent() { // Wait a moment for cookies to be set, then redirect // Check if user is admin/staff/superuser - check all possible field names const user = result.user as any; + const isTruthy = (value: any): boolean => { + if (value === true || value === "true" || value === 1 || value === "1") return true; + return false; + }; + const userIsAdmin = - user.is_admin === true || - user.isAdmin === true || - user.is_staff === true || - user.isStaff === true || - user.is_superuser === true || - user.isSuperuser === true; + isTruthy(user.is_admin) || + isTruthy(user.isAdmin) || + isTruthy(user.is_staff) || + isTruthy(user.isStaff) || + isTruthy(user.is_superuser) || + isTruthy(user.isSuperuser); // Wait longer for cookies to be set and middleware to process setTimeout(() => { diff --git a/app/(user)/user/dashboard/page.tsx b/app/(user)/user/dashboard/page.tsx index 6fc605f..d2e231c 100644 --- a/app/(user)/user/dashboard/page.tsx +++ b/app/(user)/user/dashboard/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Calendar, @@ -16,57 +16,28 @@ import { CalendarCheck, ArrowUpRight, Settings, + Loader2, } from "lucide-react"; import Link from "next/link"; import { Navbar } from "@/components/Navbar"; import { useAppTheme } from "@/components/ThemeProvider"; - -interface Booking { - ID: number; - scheduled_at: string; - duration: number; - status: string; - amount: number; - notes: string; - jitsi_room_url?: string; -} +import { useAppointments } from "@/hooks/useAppointments"; +import { useAuth } from "@/hooks/useAuth"; +import type { Appointment } from "@/lib/models/appointments"; +import { toast } from "sonner"; export default function UserDashboard() { const { theme } = useAppTheme(); const isDark = theme === "dark"; - const [bookings, setBookings] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - // Simulate API call to fetch user bookings - const fetchBookings = async () => { - setLoading(true); - try { - // Simulate network delay - await new Promise((resolve) => setTimeout(resolve, 500)); - - // Mock data - in real app, this would fetch from API - const mockBookings: Booking[] = [ - { - ID: 1, - scheduled_at: "2025-01-15T10:00:00Z", - duration: 60, - status: "scheduled", - amount: 150, - notes: "Initial consultation", - jitsi_room_url: "https://meet.jit.si/sample-room", - }, - ]; - setBookings(mockBookings); - } catch (error) { - console.error("Failed to fetch bookings:", error); - } finally { - setLoading(false); - } - }; - - fetchBookings(); - }, []); + const { user } = useAuth(); + const { + userAppointments, + userAppointmentStats, + isLoadingUserAppointments, + isLoadingUserStats, + refetchUserAppointments, + refetchUserStats, + } = useAppointments(); const formatDate = (dateString: string) => { const date = new Date(dateString); @@ -86,47 +57,70 @@ export default function UserDashboard() { }); }; - const upcomingBookings = bookings.filter( - (booking) => booking.status === "scheduled" - ); - const completedBookings = bookings.filter( - (booking) => booking.status === "completed" - ); - const cancelledBookings = bookings.filter( - (booking) => booking.status === "cancelled" - ); + const formatMemberSince = (dateString?: string) => { + if (!dateString) return "N/A"; + const date = new Date(dateString); + return date.toLocaleDateString("en-US", { + month: "long", + year: "numeric", + }); + }; + + // Filter appointments by status + const upcomingAppointments = useMemo(() => { + return userAppointments.filter( + (appointment) => appointment.status === "scheduled" + ); + }, [userAppointments]); + + const completedAppointments = useMemo(() => { + return userAppointments.filter( + (appointment) => appointment.status === "completed" + ); + }, [userAppointments]); + + const stats = userAppointmentStats || { + total_requests: 0, + pending_review: 0, + scheduled: 0, + rejected: 0, + completed: 0, + completion_rate: 0, + }; const statCards = [ { title: "Upcoming Appointments", - value: upcomingBookings.length, + value: stats.scheduled, icon: CalendarCheck, - trend: "+2", + trend: stats.scheduled > 0 ? `+${stats.scheduled}` : "0", trendUp: true, }, { title: "Completed Sessions", - value: completedBookings.length, + value: stats.completed || 0, icon: CheckCircle2, - trend: "+5", + trend: stats.completed > 0 ? `+${stats.completed}` : "0", trendUp: true, }, { title: "Total Appointments", - value: bookings.length, + value: stats.total_requests, icon: Calendar, - trend: "+12%", + trend: `${Math.round(stats.completion_rate || 0)}%`, trendUp: true, }, { - title: "Total Spent", - value: `$${bookings.reduce((sum, b) => sum + b.amount, 0)}`, - icon: Heart, - trend: "+18%", - trendUp: true, + title: "Pending Review", + value: stats.pending_review, + icon: Calendar, + trend: stats.pending_review > 0 ? `${stats.pending_review}` : "0", + trendUp: false, }, ]; + const loading = isLoadingUserAppointments || isLoadingUserStats; + return (
@@ -158,7 +152,7 @@ export default function UserDashboard() { className="w-full sm:w-auto bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white" > - Book Appointment + Request Appointment
@@ -166,7 +160,7 @@ export default function UserDashboard() { {loading ? (
-
+
) : ( <> @@ -214,53 +208,62 @@ export default function UserDashboard() {
{/* Upcoming Appointments Section */} - {upcomingBookings.length > 0 && ( + {upcomingAppointments.length > 0 ? (

Upcoming Appointments

- {upcomingBookings.map((booking) => ( + {upcomingAppointments.map((appointment) => (
-
- - - {formatDate(booking.scheduled_at)} - -
-
- - - {formatTime(booking.scheduled_at)} - - - ({booking.duration} minutes) - -
- {booking.notes && ( + {appointment.scheduled_datetime && ( + <> +
+ + + {formatDate(appointment.scheduled_datetime)} + +
+
+ + + {formatTime(appointment.scheduled_datetime)} + + {appointment.scheduled_duration && ( + + ({appointment.scheduled_duration} minutes) + + )} +
+ + )} + {appointment.reason && (

- {booking.notes} + {appointment.reason}

)}
- - {booking.status.charAt(0).toUpperCase() + - booking.status.slice(1)} - - - ${booking.amount} + + {appointment.status.charAt(0).toUpperCase() + + appointment.status.slice(1).replace('_', ' ')}
- {booking.jitsi_room_url && ( + {appointment.jitsi_meet_url && appointment.can_join_meeting && (
+ ) : !loading && ( +
+
+ +

+ Request Appointment +

+

+ No upcoming appointments. Book an appointment to get started. +

+ + + +
+
)} {/* Account Information */} -
-

- Account Information -

-
-
-
- + {user && ( +
+

+ Account Information +

+
+
+
+ +
+
+

+ Full Name +

+

+ {user.first_name} {user.last_name} +

+
-
-

- Full Name -

-

- John Doe -

-
-
-
-
- -
-
-

- Email -

-

- john.doe@example.com -

-
-
-
-
- -
-
-

- Phone -

-

- +1 (555) 123-4567 -

-
-
-
-
- -
-
-

- Member Since -

-

- January 2025 -

+
+
+ +
+
+

+ Email +

+

+ {user.email} +

+
+ {user.phone_number && ( +
+
+ +
+
+

+ Phone +

+

+ {user.phone_number} +

+
+
+ )} + {user.date_joined && ( +
+
+ +
+
+

+ Member Since +

+

+ {formatMemberSince(user.date_joined)} +

+
+
+ )}
-
+ )} )} diff --git a/components/LoginDialog.tsx b/components/LoginDialog.tsx index a4ee3a9..fa5fc3c 100644 --- a/components/LoginDialog.tsx +++ b/components/LoginDialog.tsx @@ -70,9 +70,30 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess, prefillEmail, onOpenChange(false); // Reset form setLoginData({ email: "", password: "" }); - // Redirect to user dashboard - router.push("/user/dashboard"); + + // Check if user is admin/staff/superuser + const user = result.user as any; + const isTruthy = (value: any): boolean => { + if (value === true || value === "true" || value === 1 || value === "1") return true; + return false; + }; + + const userIsAdmin = + isTruthy(user.is_admin) || + isTruthy(user.isAdmin) || + isTruthy(user.is_staff) || + isTruthy(user.isStaff) || + isTruthy(user.is_superuser) || + isTruthy(user.isSuperuser); + + // Call onLoginSuccess callback first onLoginSuccess(); + + // Redirect based on user role + const redirectPath = userIsAdmin ? "/admin/dashboard" : "/user/dashboard"; + setTimeout(() => { + window.location.href = redirectPath; + }, 200); } } catch (err) { const errorMessage = err instanceof Error ? err.message : "Login failed. Please try again."; diff --git a/components/Navbar.tsx b/components/Navbar.tsx index 04a8a73..ef2ccf0 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -25,7 +25,7 @@ export function Navbar() { const isUserDashboard = pathname?.startsWith("/user/dashboard"); const isUserSettings = pathname?.startsWith("/user/settings"); const isUserRoute = pathname?.startsWith("/user/"); - const { isAuthenticated, logout } = useAuth(); + const { isAuthenticated, logout, user, isAdmin } = useAuth(); const scrollToSection = (id: string) => { const element = document.getElementById(id); @@ -36,8 +36,11 @@ export function Navbar() { }; const handleLoginSuccess = () => { - // Redirect to admin dashboard after successful login - router.push("/admin/dashboard"); + // Check if user is admin/staff/superuser and redirect accordingly + // Note: user might not be immediately available, so we check isAdmin from hook + // which is computed from the user data + const redirectPath = isAdmin ? "/admin/dashboard" : "/user/dashboard"; + router.push(redirectPath); setMobileMenuOpen(false); }; diff --git a/hooks/useAppointments.ts b/hooks/useAppointments.ts index 71b66fb..3d65fe4 100644 --- a/hooks/useAppointments.ts +++ b/hooks/useAppointments.ts @@ -75,6 +75,13 @@ export function useAppointments() { staleTime: 1 * 60 * 1000, // 1 minute }); + // Get user appointment stats query + const userAppointmentStatsQuery = useQuery({ + queryKey: ["appointments", "user", "stats"], + queryFn: () => getUserAppointmentStats(), + staleTime: 1 * 60 * 1000, // 1 minute + }); + // Get Jitsi meeting info query const useJitsiMeetingInfo = (id: string | null) => { return useQuery({ @@ -166,6 +173,7 @@ export function useAppointments() { userAppointments: userAppointmentsQuery.data || [], adminAvailability: adminAvailabilityQuery.data, appointmentStats: appointmentStatsQuery.data, + userAppointmentStats: userAppointmentStatsQuery.data, // Query states isLoadingAvailableDates: availableDatesQuery.isLoading, @@ -173,6 +181,7 @@ export function useAppointments() { isLoadingUserAppointments: userAppointmentsQuery.isLoading, isLoadingAdminAvailability: adminAvailabilityQuery.isLoading, isLoadingStats: appointmentStatsQuery.isLoading, + isLoadingUserStats: userAppointmentStatsQuery.isLoading, // Query refetch functions refetchAvailableDates: availableDatesQuery.refetch, @@ -180,6 +189,7 @@ export function useAppointments() { refetchUserAppointments: userAppointmentsQuery.refetch, refetchAdminAvailability: adminAvailabilityQuery.refetch, refetchStats: appointmentStatsQuery.refetch, + refetchUserStats: userAppointmentStatsQuery.refetch, // Hooks for specific queries useAppointmentDetail, diff --git a/hooks/useAuth.ts b/hooks/useAuth.ts index ab59df6..9b549c9 100644 --- a/hooks/useAuth.ts +++ b/hooks/useAuth.ts @@ -14,6 +14,7 @@ import { refreshToken, getStoredTokens, getStoredUser, + getStoredUserSync, storeTokens, storeUser, clearAuthData, diff --git a/lib/actions/appointments.ts b/lib/actions/appointments.ts index f4a1c29..a8ad586 100644 --- a/lib/actions/appointments.ts +++ b/lib/actions/appointments.ts @@ -13,6 +13,7 @@ import type { AvailableDatesResponse, AdminAvailability, AppointmentStats, + UserAppointmentStats, JitsiMeetingInfo, ApiError, } from "@/lib/models/appointments"; @@ -451,6 +452,32 @@ export async function getAppointmentStats(): Promise { return data; } +// Get user appointment stats +export async function getUserAppointmentStats(): Promise { + const tokens = getStoredTokens(); + + if (!tokens.access) { + throw new Error("Authentication required."); + } + + const response = await fetch(API_ENDPOINTS.meetings.userAppointmentStats, { + method: "GET", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokens.access}`, + }, + }); + + const data: UserAppointmentStats = await response.json(); + + if (!response.ok) { + const errorMessage = extractErrorMessage(data as unknown as ApiError); + throw new Error(errorMessage); + } + + return data; +} + // Get Jitsi meeting info export async function getJitsiMeetingInfo(id: string): Promise { const tokens = getStoredTokens(); diff --git a/lib/actions/auth.ts b/lib/actions/auth.ts index 4056bf1..229e722 100644 --- a/lib/actions/auth.ts +++ b/lib/actions/auth.ts @@ -8,6 +8,7 @@ import type { VerifyPasswordResetOtpInput, ResetPasswordInput, TokenRefreshInput, + UpdateProfileInput, } from "@/lib/schema/auth"; import type { AuthResponse, ApiError, AuthTokens, User } from "@/lib/models/auth"; @@ -369,3 +370,72 @@ export async function getAllUsers(): Promise { return []; } +// Get user profile +export async function getProfile(): Promise { + const tokens = getStoredTokens(); + + if (!tokens.access) { + throw new Error("Authentication required."); + } + + const response = await fetch(API_ENDPOINTS.auth.getProfile, { + 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.user) { + return data.user; + } + if (data.id) { + return data; + } + + throw new Error("Invalid profile response format"); +} + +// Update user profile +export async function updateProfile(input: UpdateProfileInput): Promise { + const tokens = getStoredTokens(); + + if (!tokens.access) { + throw new Error("Authentication required."); + } + + const response = await fetch(API_ENDPOINTS.auth.updateProfile, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${tokens.access}`, + }, + body: JSON.stringify(input), + }); + + const data = await response.json(); + + if (!response.ok) { + const errorMessage = extractErrorMessage(data); + throw new Error(errorMessage); + } + + // Handle different response formats + if (data.user) { + return data.user; + } + if (data.id) { + return data; + } + + throw new Error("Invalid profile response format"); +} + diff --git a/lib/api_urls.ts b/lib/api_urls.ts index 0dab8aa..a4ffa32 100644 --- a/lib/api_urls.ts +++ b/lib/api_urls.ts @@ -13,6 +13,8 @@ export const API_ENDPOINTS = { resetPassword: `${API_BASE_URL}/auth/reset-password/`, tokenRefresh: `${API_BASE_URL}/auth/token/refresh/`, allUsers: `${API_BASE_URL}/auth/all-users/`, + getProfile: `${API_BASE_URL}/auth/profile/`, + updateProfile: `${API_BASE_URL}/auth/profile/update/`, }, meetings: { base: `${API_BASE_URL}/meetings/`, @@ -20,6 +22,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/`, + userAppointmentStats: `${API_BASE_URL}/meetings/user/appointments/stats/`, adminAvailability: `${API_BASE_URL}/meetings/admin/availability/`, }, } as const; diff --git a/lib/models/appointments.ts b/lib/models/appointments.ts index 7fef918..4810fe1 100644 --- a/lib/models/appointments.ts +++ b/lib/models/appointments.ts @@ -55,6 +55,15 @@ export interface AppointmentStats { users?: number; // Total users count from API } +export interface UserAppointmentStats { + total_requests: number; + pending_review: number; + scheduled: number; + rejected: number; + completed: number; + completion_rate: number; +} + export interface JitsiMeetingInfo { meeting_url: string; room_id: string; diff --git a/lib/schema/auth.ts b/lib/schema/auth.ts index f0d9b3f..f77813f 100644 --- a/lib/schema/auth.ts +++ b/lib/schema/auth.ts @@ -78,3 +78,12 @@ export const tokenRefreshSchema = z.object({ export type TokenRefreshInput = z.infer; +// Update Profile Schema +export const updateProfileSchema = z.object({ + first_name: z.string().min(1, "First name is required"), + last_name: z.string().min(1, "Last name is required"), + phone_number: z.string().optional(), +}); + +export type UpdateProfileInput = z.infer; + diff --git a/lib/utils/encryption.ts b/lib/utils/encryption.ts new file mode 100644 index 0000000..5dd9986 --- /dev/null +++ b/lib/utils/encryption.ts @@ -0,0 +1,240 @@ +/** + * Encryption utilities for securing sensitive user data + * Uses Web Crypto API with AES-GCM for authenticated encryption + */ + +// Generate a key from a password using PBKDF2 +async function deriveKey(password: string, salt: BufferSource): Promise { + const encoder = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey( + "raw", + encoder.encode(password), + "PBKDF2", + false, + ["deriveBits", "deriveKey"] + ); + + return crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: salt, + iterations: 100000, + hash: "SHA-256", + }, + keyMaterial, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ); +} + +// Get or create encryption key from localStorage +async function getEncryptionKey(): Promise { + const STORAGE_KEY = "encryption_salt"; + const PASSWORD_KEY = "encryption_password"; + + // Generate a unique password based on user's browser fingerprint + // This creates a consistent key per browser/device + const getBrowserFingerprint = (): string => { + try { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.textBaseline = "top"; + ctx.font = "14px 'Arial'"; + ctx.textBaseline = "alphabetic"; + ctx.fillStyle = "#f60"; + ctx.fillRect(125, 1, 62, 20); + ctx.fillStyle = "#069"; + ctx.fillText("Browser fingerprint", 2, 15); + ctx.fillStyle = "rgba(102, 204, 0, 0.7)"; + ctx.fillText("Browser fingerprint", 4, 17); + } + const fingerprint = (canvas.toDataURL() || "") + + (navigator.userAgent || "") + + (navigator.language || "") + + (screen.width || 0) + + (screen.height || 0) + + (new Date().getTimezoneOffset() || 0); + return fingerprint; + } catch (error) { + // Fallback if canvas fingerprinting fails + return (navigator.userAgent || "") + + (navigator.language || "") + + (screen.width || 0) + + (screen.height || 0); + } + }; + + let salt = localStorage.getItem(STORAGE_KEY); + let password = localStorage.getItem(PASSWORD_KEY); + + if (!salt || !password) { + // Generate new salt and password + const saltBytes = crypto.getRandomValues(new Uint8Array(16)); + salt = Array.from(saltBytes) + .map(b => b.toString(16).padStart(2, "0")) + .join(""); + + password = getBrowserFingerprint(); + + localStorage.setItem(STORAGE_KEY, salt); + localStorage.setItem(PASSWORD_KEY, password); + } + + // Convert hex string back to Uint8Array + const saltBytes = salt.match(/.{1,2}/g)?.map(byte => parseInt(byte, 16)) || []; + const saltArray = new Uint8Array(saltBytes); + + return deriveKey(password, saltArray); +} + +// Encrypt a string value +export async function encryptValue(value: string): Promise { + if (!value || typeof window === "undefined") return value; + + try { + const key = await getEncryptionKey(); + const encoder = new TextEncoder(); + const data = encoder.encode(value); + + // Generate a random IV for each encryption + const iv = crypto.getRandomValues(new Uint8Array(12)); + + const encrypted = await crypto.subtle.encrypt( + { + name: "AES-GCM", + iv: iv, + }, + key, + data + ); + + // Combine IV and encrypted data + const combined = new Uint8Array(iv.length + encrypted.byteLength); + combined.set(iv); + combined.set(new Uint8Array(encrypted), iv.length); + + // Convert to base64 for storage + const binaryString = String.fromCharCode(...combined); + return btoa(binaryString); + } catch (error) { + console.error("Encryption error:", error); + // If encryption fails, return original value (graceful degradation) + return value; + } +} + +// Decrypt a string value +export async function decryptValue(encryptedValue: string): Promise { + if (!encryptedValue || typeof window === "undefined") return encryptedValue; + + try { + const key = await getEncryptionKey(); + + // Decode from base64 + const binaryString = atob(encryptedValue); + const combined = Uint8Array.from(binaryString, c => c.charCodeAt(0)); + + // Extract IV and encrypted data + const iv = combined.slice(0, 12); + const encrypted = combined.slice(12); + + const decrypted = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv: iv, + }, + key, + encrypted + ); + + const decoder = new TextDecoder(); + return decoder.decode(decrypted); + } catch (error) { + console.error("Decryption error:", error); + // If decryption fails, try to return as-is (might be unencrypted legacy data) + return encryptedValue; + } +} + +// Encrypt sensitive fields in a user object +export async function encryptUserData(user: any): Promise { + if (!user || typeof window === "undefined") return user; + + const encrypted = { ...user }; + + // Encrypt sensitive fields + const sensitiveFields = ["first_name", "last_name", "phone_number", "email"]; + + for (const field of sensitiveFields) { + if (encrypted[field]) { + encrypted[field] = await encryptValue(String(encrypted[field])); + } + } + + return encrypted; +} + +// Decrypt sensitive fields in a user object +export async function decryptUserData(user: any): Promise { + if (!user || typeof window === "undefined") return user; + + const decrypted = { ...user }; + + // Decrypt sensitive fields + const sensitiveFields = ["first_name", "last_name", "phone_number", "email"]; + + for (const field of sensitiveFields) { + if (decrypted[field]) { + try { + decrypted[field] = await decryptValue(String(decrypted[field])); + } catch (error) { + // If decryption fails, keep original value (might be unencrypted) + console.warn(`Failed to decrypt field ${field}:`, error); + } + } + } + + return decrypted; +} + +// Check if a value is encrypted (heuristic check) +function isEncrypted(value: string): boolean { + // Encrypted values are base64 encoded and have a specific structure + // This is a simple heuristic - encrypted values will be longer and base64-like + if (!value || value.length < 20) return false; + + try { + // Try to decode as base64 + atob(value); + // If it decodes successfully and is long enough, it's likely encrypted + return value.length > 30; + } catch { + return false; + } +} + +// Smart encrypt/decrypt that handles both encrypted and unencrypted data +export async function smartDecryptUserData(user: any): Promise { + if (!user || typeof window === "undefined") return user; + + const decrypted = { ...user }; + const sensitiveFields = ["first_name", "last_name", "phone_number", "email"]; + + for (const field of sensitiveFields) { + if (decrypted[field] && typeof decrypted[field] === "string") { + if (isEncrypted(decrypted[field])) { + try { + decrypted[field] = await decryptValue(decrypted[field]); + } catch (error) { + console.warn(`Failed to decrypt field ${field}:`, error); + } + } + // If not encrypted, keep as-is (backward compatibility) + } + } + + return decrypted; +} + diff --git a/middleware.ts b/middleware.ts index 630a3ac..884c367 100644 --- a/middleware.ts +++ b/middleware.ts @@ -72,4 +72,3 @@ export const config = { "/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", ], }; - From 9d3cc8481631fde301e05f690aa3b02f1e7e0c5c Mon Sep 17 00:00:00 2001 From: iamkiddy Date: Tue, 25 Nov 2025 21:31:20 +0000 Subject: [PATCH 3/3] Enhance type safety in BookNowPage and useAppointments hook. Update availableDates and availableDaysOfWeek mapping to specify string types. Add getUserAppointmentStats and AvailableDatesResponse types to useAppointments for improved type definitions. Modify Appointment interface to include 'completed' status for better appointment management. --- 17 | 0 app/(pages)/book-now/page.tsx | 4 ++-- hooks/useAppointments.ts | 3 +++ hooks/useAuth.ts | 1 - lib/models/appointments.ts | 2 +- 5 files changed, 6 insertions(+), 4 deletions(-) create mode 100644 17 diff --git a/17 b/17 new file mode 100644 index 0000000..e69de29 diff --git a/app/(pages)/book-now/page.tsx b/app/(pages)/book-now/page.tsx index f2a7967..2504dbe 100644 --- a/app/(pages)/book-now/page.tsx +++ b/app/(pages)/book-now/page.tsx @@ -142,7 +142,7 @@ export default function BookNowPage() { const daysSet = new Set(); const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; - availableDates.forEach((dateStr) => { + availableDates.forEach((dateStr: string) => { try { // Parse date string (YYYY-MM-DD format) const [year, month, day] = dateStr.split('-').map(Number); @@ -651,7 +651,7 @@ export default function BookNowPage() { ) : ( <>
- {availableDaysOfWeek.map((day) => ( + {availableDaysOfWeek.map((day: string) => (