diff --git a/17 b/17 new file mode 100644 index 0000000..e69de29 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/(admin)/admin/settings/page.tsx b/app/(admin)/admin/settings/page.tsx index d6668ba..90a17c5 100644 --- a/app/(admin)/admin/settings/page.tsx +++ b/app/(admin)/admin/settings/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"; @@ -13,16 +13,21 @@ import { Lock, Eye, EyeOff, + Loader2, } from "lucide-react"; import Link from "next/link"; import { useAppTheme } from "@/components/ThemeProvider"; +import { getProfile, updateProfile } from "@/lib/actions/auth"; +import { toast } from "sonner"; export default function AdminSettingsPage() { const [loading, setLoading] = useState(false); + const [fetching, setFetching] = useState(true); const [formData, setFormData] = useState({ - fullName: "Hammond", - email: "admin@attuneheart.com", - phone: "+1 (555) 123-4567", + firstName: "", + lastName: "", + email: "", + phone: "", }); const [passwordData, setPasswordData] = useState({ currentPassword: "", @@ -37,6 +42,30 @@ export default function AdminSettingsPage() { const { theme } = useAppTheme(); const isDark = theme === "dark"; + // Fetch profile data on mount + useEffect(() => { + const fetchProfile = async () => { + setFetching(true); + try { + const profile = await getProfile(); + setFormData({ + firstName: profile.first_name || "", + lastName: profile.last_name || "", + email: profile.email || "", + phone: profile.phone_number || "", + }); + } catch (error) { + console.error("Failed to fetch profile:", error); + const errorMessage = error instanceof Error ? error.message : "Failed to load profile"; + toast.error(errorMessage); + } finally { + setFetching(false); + } + }; + + fetchProfile(); + }, []); + const handleInputChange = (field: string, value: string) => { setFormData((prev) => ({ ...prev, @@ -59,11 +88,26 @@ export default function AdminSettingsPage() { }; const handleSave = async () => { + if (!formData.firstName || !formData.lastName) { + toast.error("First name and last name are required"); + return; + } + setLoading(true); - // Simulate API call - await new Promise((resolve) => setTimeout(resolve, 1000)); - setLoading(false); - // In a real app, you would show a success message here + try { + await updateProfile({ + first_name: formData.firstName, + last_name: formData.lastName, + phone_number: formData.phone || undefined, + }); + toast.success("Profile updated successfully!"); + } catch (error) { + console.error("Failed to update profile:", error); + const errorMessage = error instanceof Error ? error.message : "Failed to update profile"; + toast.error(errorMessage); + } finally { + setLoading(false); + } }; const handlePasswordSave = async () => { @@ -113,15 +157,20 @@ export default function AdminSettingsPage() {
@@ -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/(pages)/book-now/page.tsx b/app/(pages)/book-now/page.tsx index 3ab1a2c..2504dbe 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: string) => { + 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: string) => ( ))} -
+
+ + )}
@@ -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) => (
{/* 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 246b320..2bb7d02 100644 --- a/hooks/useAppointments.ts +++ b/hooks/useAppointments.ts @@ -7,6 +7,7 @@ import { getAvailableDates, listAppointments, getUserAppointments, + getUserAppointmentStats, getAppointmentDetail, scheduleAppointment, rejectAppointment, @@ -25,6 +26,8 @@ import type { Appointment, AdminAvailability, AppointmentStats, + UserAppointmentStats, + AvailableDatesResponse, JitsiMeetingInfo, } from "@/lib/models/appointments"; @@ -32,7 +35,7 @@ export function useAppointments() { const queryClient = useQueryClient(); // Get available dates query - const availableDatesQuery = useQuery({ + const availableDatesQuery = useQuery({ queryKey: ["appointments", "available-dates"], queryFn: () => getAvailableDates(), staleTime: 5 * 60 * 1000, // 5 minutes @@ -75,6 +78,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({ @@ -160,11 +170,13 @@ export function useAppointments() { return { // Queries - availableDates: availableDatesQuery.data || [], + availableDates: availableDatesQuery.data?.dates || [], + availableDatesResponse: availableDatesQuery.data, appointments: appointmentsQuery.data || [], userAppointments: userAppointmentsQuery.data || [], adminAvailability: adminAvailabilityQuery.data, appointmentStats: appointmentStatsQuery.data, + userAppointmentStats: userAppointmentStatsQuery.data, // Query states isLoadingAvailableDates: availableDatesQuery.isLoading, @@ -172,6 +184,7 @@ export function useAppointments() { isLoadingUserAppointments: userAppointmentsQuery.isLoading, isLoadingAdminAvailability: adminAvailabilityQuery.isLoading, isLoadingStats: appointmentStatsQuery.isLoading, + isLoadingUserStats: userAppointmentStatsQuery.isLoading, // Query refetch functions refetchAvailableDates: availableDatesQuery.refetch, @@ -179,6 +192,7 @@ export function useAppointments() { refetchUserAppointments: userAppointmentsQuery.refetch, refetchAdminAvailability: adminAvailabilityQuery.refetch, refetchStats: appointmentStatsQuery.refetch, + refetchUserStats: userAppointmentStatsQuery.refetch, // Hooks for specific queries useAppointmentDetail, diff --git a/lib/actions/appointments.ts b/lib/actions/appointments.ts index e4ecf34..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"; @@ -79,7 +80,7 @@ export async function createAppointment( } // Get available dates -export async function getAvailableDates(): Promise { +export async function getAvailableDates(): Promise { const response = await fetch(API_ENDPOINTS.meetings.availableDates, { method: "GET", headers: { @@ -94,11 +95,14 @@ export async function getAvailableDates(): Promise { throw new Error(errorMessage); } - // API returns array of dates in YYYY-MM-DD format + // If API returns array directly, wrap it in response object if (Array.isArray(data)) { - return data; + return { + dates: data, + }; } - return (data as AvailableDatesResponse).dates || []; + + return data as AvailableDatesResponse; } // List appointments (Admin sees all, users see their own) @@ -279,6 +283,43 @@ export async function rejectAppointment( return data as unknown as Appointment; } +// Get admin availability (public version - tries without auth first) +export async function getPublicAvailability(): Promise { + try { + const response = await fetch(API_ENDPOINTS.meetings.adminAvailability, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + return null; + } + + const data: any = await response.json(); + + // 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 { + 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; + } catch (error) { + return null; + } +} + // Get admin availability export async function getAdminAvailability(): Promise { const tokens = getStoredTokens(); @@ -411,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..73d8e6d 100644 --- a/lib/models/appointments.ts +++ b/lib/models/appointments.ts @@ -9,7 +9,7 @@ export interface Appointment { reason?: string; preferred_dates: string[]; // YYYY-MM-DD format preferred_time_slots: string[]; // "morning", "afternoon", "evening" - status: "pending_review" | "scheduled" | "rejected"; + status: "pending_review" | "scheduled" | "rejected" | "completed"; created_at: string; updated_at: string; scheduled_datetime?: string; @@ -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)$).*)", ], }; -