feat/booking-panel #25

Merged
Hammond merged 3 commits from feat/booking-panel into master 2025-11-25 21:33:40 +00:00
14 changed files with 695 additions and 191 deletions
Showing only changes of commit a1611e1782 - Show all commits

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"; import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
@ -13,16 +13,21 @@ import {
Lock, Lock,
Eye, Eye,
EyeOff, EyeOff,
Loader2,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useAppTheme } from "@/components/ThemeProvider"; import { useAppTheme } from "@/components/ThemeProvider";
import { getProfile, updateProfile } from "@/lib/actions/auth";
import { toast } from "sonner";
export default function AdminSettingsPage() { export default function AdminSettingsPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(true);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
fullName: "Hammond", firstName: "",
email: "admin@attuneheart.com", lastName: "",
phone: "+1 (555) 123-4567", email: "",
phone: "",
}); });
const [passwordData, setPasswordData] = useState({ const [passwordData, setPasswordData] = useState({
currentPassword: "", currentPassword: "",
@ -37,6 +42,30 @@ export default function AdminSettingsPage() {
const { theme } = useAppTheme(); const { theme } = useAppTheme();
const isDark = theme === "dark"; 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) => { const handleInputChange = (field: string, value: string) => {
setFormData((prev) => ({ setFormData((prev) => ({
...prev, ...prev,
@ -59,11 +88,26 @@ export default function AdminSettingsPage() {
}; };
const handleSave = async () => { const handleSave = async () => {
if (!formData.firstName || !formData.lastName) {
toast.error("First name and last name are required");
return;
}
setLoading(true); setLoading(true);
// Simulate API call try {
await new Promise((resolve) => setTimeout(resolve, 1000)); await updateProfile({
setLoading(false); first_name: formData.firstName,
// In a real app, you would show a success message here 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 () => { const handlePasswordSave = async () => {
@ -113,15 +157,20 @@ export default function AdminSettingsPage() {
</div> </div>
<Button <Button
onClick={handleSave} onClick={handleSave}
disabled={loading} disabled={loading || fetching}
className="w-full sm:w-auto bg-linear-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white" 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"
> >
{loading ? ( {loading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div> <>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : ( ) : (
<Save className="w-4 h-4 mr-2" /> <>
<Save className="w-4 h-4 mr-2" />
Save Changes
</>
)} )}
Save Changes
</Button> </Button>
</div> </div>
@ -139,21 +188,49 @@ export default function AdminSettingsPage() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> {fetching ? (
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}> <div className="flex items-center justify-center py-8">
Full Name <Loader2 className="w-6 h-6 animate-spin text-rose-600" />
</label>
<div className="relative">
<User className={`absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 ${isDark ? "text-gray-500" : "text-gray-400"}`} />
<Input
type="text"
value={formData.fullName}
onChange={(e) => 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"
/>
</div> </div>
</div> ) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
First Name *
</label>
<div className="relative">
<User className={`absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 ${isDark ? "text-gray-500" : "text-gray-400"}`} />
<Input
type="text"
value={formData.firstName}
onChange={(e) => 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
/>
</div>
</div>
<div className="space-y-2">
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
Last Name *
</label>
<div className="relative">
<User className={`absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 ${isDark ? "text-gray-500" : "text-gray-400"}`} />
<Input
type="text"
value={formData.lastName}
onChange={(e) => 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
/>
</div>
</div>
</div>
</>
)}
<div className="space-y-2"> <div className="space-y-2">
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}> <label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
@ -164,11 +241,14 @@ export default function AdminSettingsPage() {
<Input <Input
type="email" type="email"
value={formData.email} value={formData.email}
onChange={(e) => handleInputChange("email", e.target.value)} disabled
className={`pl-10 ${isDark ? "bg-gray-700 border-gray-600 text-white placeholder:text-gray-400" : ""}`} className={`pl-10 ${isDark ? "bg-gray-700/50 border-gray-600 text-gray-400 cursor-not-allowed" : "bg-gray-50 border-gray-300 text-gray-500 cursor-not-allowed"}`}
placeholder="Enter your email" placeholder="Email address"
/> />
</div> </div>
<p className={`text-xs ${isDark ? "text-gray-500" : "text-gray-400"}`}>
Email address cannot be changed
</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">

View File

@ -139,13 +139,18 @@ function LoginContent() {
// Wait a moment for cookies to be set, then redirect // Wait a moment for cookies to be set, then redirect
// Check if user is admin/staff/superuser - check all possible field names // Check if user is admin/staff/superuser - check all possible field names
const user = result.user as any; 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 = const userIsAdmin =
user.is_admin === true || isTruthy(user.is_admin) ||
user.isAdmin === true || isTruthy(user.isAdmin) ||
user.is_staff === true || isTruthy(user.is_staff) ||
user.isStaff === true || isTruthy(user.isStaff) ||
user.is_superuser === true || isTruthy(user.is_superuser) ||
user.isSuperuser === true; isTruthy(user.isSuperuser);
// Wait longer for cookies to be set and middleware to process // Wait longer for cookies to be set and middleware to process
setTimeout(() => { setTimeout(() => {

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useMemo } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Calendar, Calendar,
@ -16,57 +16,28 @@ import {
CalendarCheck, CalendarCheck,
ArrowUpRight, ArrowUpRight,
Settings, Settings,
Loader2,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { Navbar } from "@/components/Navbar"; import { Navbar } from "@/components/Navbar";
import { useAppTheme } from "@/components/ThemeProvider"; import { useAppTheme } from "@/components/ThemeProvider";
import { useAppointments } from "@/hooks/useAppointments";
interface Booking { import { useAuth } from "@/hooks/useAuth";
ID: number; import type { Appointment } from "@/lib/models/appointments";
scheduled_at: string; import { toast } from "sonner";
duration: number;
status: string;
amount: number;
notes: string;
jitsi_room_url?: string;
}
export default function UserDashboard() { export default function UserDashboard() {
const { theme } = useAppTheme(); const { theme } = useAppTheme();
const isDark = theme === "dark"; const isDark = theme === "dark";
const [bookings, setBookings] = useState<Booking[]>([]); const { user } = useAuth();
const [loading, setLoading] = useState(true); const {
userAppointments,
useEffect(() => { userAppointmentStats,
// Simulate API call to fetch user bookings isLoadingUserAppointments,
const fetchBookings = async () => { isLoadingUserStats,
setLoading(true); refetchUserAppointments,
try { refetchUserStats,
// Simulate network delay } = useAppointments();
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 formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
const date = new Date(dateString); const date = new Date(dateString);
@ -86,47 +57,70 @@ export default function UserDashboard() {
}); });
}; };
const upcomingBookings = bookings.filter( const formatMemberSince = (dateString?: string) => {
(booking) => booking.status === "scheduled" if (!dateString) return "N/A";
); const date = new Date(dateString);
const completedBookings = bookings.filter( return date.toLocaleDateString("en-US", {
(booking) => booking.status === "completed" month: "long",
); year: "numeric",
const cancelledBookings = bookings.filter( });
(booking) => booking.status === "cancelled" };
);
// 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 = [ const statCards = [
{ {
title: "Upcoming Appointments", title: "Upcoming Appointments",
value: upcomingBookings.length, value: stats.scheduled,
icon: CalendarCheck, icon: CalendarCheck,
trend: "+2", trend: stats.scheduled > 0 ? `+${stats.scheduled}` : "0",
trendUp: true, trendUp: true,
}, },
{ {
title: "Completed Sessions", title: "Completed Sessions",
value: completedBookings.length, value: stats.completed || 0,
icon: CheckCircle2, icon: CheckCircle2,
trend: "+5", trend: stats.completed > 0 ? `+${stats.completed}` : "0",
trendUp: true, trendUp: true,
}, },
{ {
title: "Total Appointments", title: "Total Appointments",
value: bookings.length, value: stats.total_requests,
icon: Calendar, icon: Calendar,
trend: "+12%", trend: `${Math.round(stats.completion_rate || 0)}%`,
trendUp: true, trendUp: true,
}, },
{ {
title: "Total Spent", title: "Pending Review",
value: `$${bookings.reduce((sum, b) => sum + b.amount, 0)}`, value: stats.pending_review,
icon: Heart, icon: Calendar,
trend: "+18%", trend: stats.pending_review > 0 ? `${stats.pending_review}` : "0",
trendUp: true, trendUp: false,
}, },
]; ];
const loading = isLoadingUserAppointments || isLoadingUserStats;
return ( return (
<div className={`min-h-screen ${isDark ? 'bg-gray-900' : 'bg-gray-50'}`}> <div className={`min-h-screen ${isDark ? 'bg-gray-900' : 'bg-gray-50'}`}>
<Navbar /> <Navbar />
@ -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" 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"
> >
<CalendarPlus className="w-4 h-4 mr-2" /> <CalendarPlus className="w-4 h-4 mr-2" />
Book Appointment Request Appointment
</Button> </Button>
</Link> </Link>
</div> </div>
@ -166,7 +160,7 @@ export default function UserDashboard() {
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className={`animate-spin rounded-full h-8 w-8 border-b-2 ${isDark ? 'border-gray-600' : 'border-gray-400'}`}></div> <Loader2 className={`w-8 h-8 animate-spin ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
</div> </div>
) : ( ) : (
<> <>
@ -214,53 +208,62 @@ export default function UserDashboard() {
</div> </div>
{/* Upcoming Appointments Section */} {/* Upcoming Appointments Section */}
{upcomingBookings.length > 0 && ( {upcomingAppointments.length > 0 ? (
<div className={`rounded-lg border p-4 sm:p-5 md:p-6 ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}> <div className={`rounded-lg border p-4 sm:p-5 md:p-6 ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}>
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-gray-900'}`}> <h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-gray-900'}`}>
Upcoming Appointments Upcoming Appointments
</h2> </h2>
<div className="space-y-3"> <div className="space-y-3">
{upcomingBookings.map((booking) => ( {upcomingAppointments.map((appointment) => (
<div <div
key={booking.ID} key={appointment.id}
className={`border rounded-lg p-4 hover:shadow-md transition-shadow ${isDark ? 'border-gray-700 bg-gray-700/50' : 'border-gray-200'}`} className={`border rounded-lg p-4 hover:shadow-md transition-shadow ${isDark ? 'border-gray-700 bg-gray-700/50' : 'border-gray-200'}`}
> >
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2 mb-2"> {appointment.scheduled_datetime && (
<Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} /> <>
<span className={`font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}> <div className="flex items-center gap-2 mb-2">
{formatDate(booking.scheduled_at)} <Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
</span> <span className={`font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
</div> {formatDate(appointment.scheduled_datetime)}
<div className="flex items-center gap-2 mb-2"> </span>
<Clock className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} /> </div>
<span className={isDark ? 'text-gray-300' : 'text-gray-700'}> <div className="flex items-center gap-2 mb-2">
{formatTime(booking.scheduled_at)} <Clock className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
</span> <span className={isDark ? 'text-gray-300' : 'text-gray-700'}>
<span className={`text-sm font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}> {formatTime(appointment.scheduled_datetime)}
({booking.duration} minutes) </span>
</span> {appointment.scheduled_duration && (
</div> <span className={`text-sm font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
{booking.notes && ( ({appointment.scheduled_duration} minutes)
</span>
)}
</div>
</>
)}
{appointment.reason && (
<p className={`text-sm mt-2 font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}> <p className={`text-sm mt-2 font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
{booking.notes} {appointment.reason}
</p> </p>
)} )}
</div> </div>
<div className="flex flex-col sm:items-end gap-3"> <div className="flex flex-col sm:items-end gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`px-3 py-1 rounded-full text-sm font-medium ${isDark ? 'bg-green-900/30 text-green-400' : 'bg-green-50 text-green-700'}`}> <span className={`px-3 py-1 rounded-full text-sm font-medium ${
{booking.status.charAt(0).toUpperCase() + appointment.status === "scheduled"
booking.status.slice(1)} ? isDark ? 'bg-green-900/30 text-green-400' : 'bg-green-50 text-green-700'
</span> : appointment.status === "pending_review"
<span className={`text-lg font-bold ${isDark ? 'text-white' : 'text-gray-900'}`}> ? isDark ? 'bg-yellow-900/30 text-yellow-400' : 'bg-yellow-50 text-yellow-700'
${booking.amount} : isDark ? 'bg-red-900/30 text-red-400' : 'bg-red-50 text-red-700'
}`}>
{appointment.status.charAt(0).toUpperCase() +
appointment.status.slice(1).replace('_', ' ')}
</span> </span>
</div> </div>
{booking.jitsi_room_url && ( {appointment.jitsi_meet_url && appointment.can_join_meeting && (
<a <a
href={booking.jitsi_room_url} href={appointment.jitsi_meet_url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors" className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors"
@ -275,68 +278,92 @@ export default function UserDashboard() {
))} ))}
</div> </div>
</div> </div>
) : !loading && (
<div className={`rounded-lg border p-4 sm:p-5 md:p-6 ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}>
<div className="flex flex-col items-center justify-center py-8 text-center">
<CalendarCheck className={`w-12 h-12 mb-4 ${isDark ? 'text-gray-500' : 'text-gray-400'}`} />
<p className={`text-lg font-medium mb-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
Request Appointment
</p>
<p className={`text-sm mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
No upcoming appointments. Book an appointment to get started.
</p>
<Link href="/book-now">
<Button className="bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white">
<CalendarPlus className="w-4 h-4 mr-2" />
Request Appointment
</Button>
</Link>
</div>
</div>
)} )}
{/* Account Information */} {/* Account Information */}
<div className={`rounded-lg border p-4 sm:p-5 md:p-6 ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}> {user && (
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-gray-900'}`}> <div className={`rounded-lg border p-4 sm:p-5 md:p-6 ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}>
Account Information <h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-gray-900'}`}>
</h2> Account Information
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> </h2>
<div className="flex items-center gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}> <div className="flex items-center gap-3">
<User className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} /> <div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
<User className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
</div>
<div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
Full Name
</p>
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
{user.first_name} {user.last_name}
</p>
</div>
</div> </div>
<div> <div className="flex items-center gap-3">
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}> <div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
Full Name <Mail className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
</p> </div>
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}> <div>
John Doe <p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
</p> Email
</div> </p>
</div> <p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
<div className="flex items-center gap-3"> {user.email}
<div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}> </p>
<Mail className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} /> </div>
</div>
<div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
Email
</p>
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
john.doe@example.com
</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
<Phone className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
</div>
<div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
Phone
</p>
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
+1 (555) 123-4567
</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
<Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
</div>
<div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
Member Since
</p>
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
January 2025
</p>
</div> </div>
{user.phone_number && (
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
<Phone className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
</div>
<div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
Phone
</p>
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
{user.phone_number}
</p>
</div>
</div>
)}
{user.date_joined && (
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
<Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
</div>
<div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
Member Since
</p>
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
{formatMemberSince(user.date_joined)}
</p>
</div>
</div>
)}
</div> </div>
</div> </div>
</div> )}
</> </>
)} )}
</main> </main>

View File

@ -70,9 +70,30 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess, prefillEmail,
onOpenChange(false); onOpenChange(false);
// Reset form // Reset form
setLoginData({ email: "", password: "" }); 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(); onLoginSuccess();
// Redirect based on user role
const redirectPath = userIsAdmin ? "/admin/dashboard" : "/user/dashboard";
setTimeout(() => {
window.location.href = redirectPath;
}, 200);
} }
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : "Login failed. Please try again."; const errorMessage = err instanceof Error ? err.message : "Login failed. Please try again.";

View File

@ -25,7 +25,7 @@ export function Navbar() {
const isUserDashboard = pathname?.startsWith("/user/dashboard"); const isUserDashboard = pathname?.startsWith("/user/dashboard");
const isUserSettings = pathname?.startsWith("/user/settings"); const isUserSettings = pathname?.startsWith("/user/settings");
const isUserRoute = pathname?.startsWith("/user/"); const isUserRoute = pathname?.startsWith("/user/");
const { isAuthenticated, logout } = useAuth(); const { isAuthenticated, logout, user, isAdmin } = useAuth();
const scrollToSection = (id: string) => { const scrollToSection = (id: string) => {
const element = document.getElementById(id); const element = document.getElementById(id);
@ -36,8 +36,11 @@ export function Navbar() {
}; };
const handleLoginSuccess = () => { const handleLoginSuccess = () => {
// Redirect to admin dashboard after successful login // Check if user is admin/staff/superuser and redirect accordingly
router.push("/admin/dashboard"); // 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); setMobileMenuOpen(false);
}; };

View File

@ -75,6 +75,13 @@ export function useAppointments() {
staleTime: 1 * 60 * 1000, // 1 minute staleTime: 1 * 60 * 1000, // 1 minute
}); });
// Get user appointment stats query
const userAppointmentStatsQuery = useQuery<UserAppointmentStats>({
queryKey: ["appointments", "user", "stats"],
queryFn: () => getUserAppointmentStats(),
staleTime: 1 * 60 * 1000, // 1 minute
});
// Get Jitsi meeting info query // Get Jitsi meeting info query
const useJitsiMeetingInfo = (id: string | null) => { const useJitsiMeetingInfo = (id: string | null) => {
return useQuery<JitsiMeetingInfo>({ return useQuery<JitsiMeetingInfo>({
@ -166,6 +173,7 @@ export function useAppointments() {
userAppointments: userAppointmentsQuery.data || [], userAppointments: userAppointmentsQuery.data || [],
adminAvailability: adminAvailabilityQuery.data, adminAvailability: adminAvailabilityQuery.data,
appointmentStats: appointmentStatsQuery.data, appointmentStats: appointmentStatsQuery.data,
userAppointmentStats: userAppointmentStatsQuery.data,
// Query states // Query states
isLoadingAvailableDates: availableDatesQuery.isLoading, isLoadingAvailableDates: availableDatesQuery.isLoading,
@ -173,6 +181,7 @@ export function useAppointments() {
isLoadingUserAppointments: userAppointmentsQuery.isLoading, isLoadingUserAppointments: userAppointmentsQuery.isLoading,
isLoadingAdminAvailability: adminAvailabilityQuery.isLoading, isLoadingAdminAvailability: adminAvailabilityQuery.isLoading,
isLoadingStats: appointmentStatsQuery.isLoading, isLoadingStats: appointmentStatsQuery.isLoading,
isLoadingUserStats: userAppointmentStatsQuery.isLoading,
// Query refetch functions // Query refetch functions
refetchAvailableDates: availableDatesQuery.refetch, refetchAvailableDates: availableDatesQuery.refetch,
@ -180,6 +189,7 @@ export function useAppointments() {
refetchUserAppointments: userAppointmentsQuery.refetch, refetchUserAppointments: userAppointmentsQuery.refetch,
refetchAdminAvailability: adminAvailabilityQuery.refetch, refetchAdminAvailability: adminAvailabilityQuery.refetch,
refetchStats: appointmentStatsQuery.refetch, refetchStats: appointmentStatsQuery.refetch,
refetchUserStats: userAppointmentStatsQuery.refetch,
// Hooks for specific queries // Hooks for specific queries
useAppointmentDetail, useAppointmentDetail,

View File

@ -14,6 +14,7 @@ import {
refreshToken, refreshToken,
getStoredTokens, getStoredTokens,
getStoredUser, getStoredUser,
getStoredUserSync,
storeTokens, storeTokens,
storeUser, storeUser,
clearAuthData, clearAuthData,

View File

@ -13,6 +13,7 @@ import type {
AvailableDatesResponse, AvailableDatesResponse,
AdminAvailability, AdminAvailability,
AppointmentStats, AppointmentStats,
UserAppointmentStats,
JitsiMeetingInfo, JitsiMeetingInfo,
ApiError, ApiError,
} from "@/lib/models/appointments"; } from "@/lib/models/appointments";
@ -451,6 +452,32 @@ export async function getAppointmentStats(): Promise<AppointmentStats> {
return data; return data;
} }
// Get user appointment stats
export async function getUserAppointmentStats(): Promise<UserAppointmentStats> {
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 // Get Jitsi meeting info
export async function getJitsiMeetingInfo(id: string): Promise<JitsiMeetingInfo> { export async function getJitsiMeetingInfo(id: string): Promise<JitsiMeetingInfo> {
const tokens = getStoredTokens(); const tokens = getStoredTokens();

View File

@ -8,6 +8,7 @@ import type {
VerifyPasswordResetOtpInput, VerifyPasswordResetOtpInput,
ResetPasswordInput, ResetPasswordInput,
TokenRefreshInput, TokenRefreshInput,
UpdateProfileInput,
} from "@/lib/schema/auth"; } from "@/lib/schema/auth";
import type { AuthResponse, ApiError, AuthTokens, User } from "@/lib/models/auth"; import type { AuthResponse, ApiError, AuthTokens, User } from "@/lib/models/auth";
@ -369,3 +370,72 @@ export async function getAllUsers(): Promise<User[]> {
return []; return [];
} }
// Get user profile
export async function getProfile(): Promise<User> {
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<User> {
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");
}

View File

@ -13,6 +13,8 @@ export const API_ENDPOINTS = {
resetPassword: `${API_BASE_URL}/auth/reset-password/`, resetPassword: `${API_BASE_URL}/auth/reset-password/`,
tokenRefresh: `${API_BASE_URL}/auth/token/refresh/`, tokenRefresh: `${API_BASE_URL}/auth/token/refresh/`,
allUsers: `${API_BASE_URL}/auth/all-users/`, allUsers: `${API_BASE_URL}/auth/all-users/`,
getProfile: `${API_BASE_URL}/auth/profile/`,
updateProfile: `${API_BASE_URL}/auth/profile/update/`,
}, },
meetings: { meetings: {
base: `${API_BASE_URL}/meetings/`, base: `${API_BASE_URL}/meetings/`,
@ -20,6 +22,7 @@ export const API_ENDPOINTS = {
createAppointment: `${API_BASE_URL}/meetings/appointments/create/`, createAppointment: `${API_BASE_URL}/meetings/appointments/create/`,
listAppointments: `${API_BASE_URL}/meetings/appointments/`, listAppointments: `${API_BASE_URL}/meetings/appointments/`,
userAppointments: `${API_BASE_URL}/meetings/user/appointments/`, userAppointments: `${API_BASE_URL}/meetings/user/appointments/`,
userAppointmentStats: `${API_BASE_URL}/meetings/user/appointments/stats/`,
adminAvailability: `${API_BASE_URL}/meetings/admin/availability/`, adminAvailability: `${API_BASE_URL}/meetings/admin/availability/`,
}, },
} as const; } as const;

View File

@ -55,6 +55,15 @@ export interface AppointmentStats {
users?: number; // Total users count from API 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 { export interface JitsiMeetingInfo {
meeting_url: string; meeting_url: string;
room_id: string; room_id: string;

View File

@ -78,3 +78,12 @@ export const tokenRefreshSchema = z.object({
export type TokenRefreshInput = z.infer<typeof tokenRefreshSchema>; export type TokenRefreshInput = z.infer<typeof tokenRefreshSchema>;
// 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<typeof updateProfileSchema>;

240
lib/utils/encryption.ts Normal file
View File

@ -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<CryptoKey> {
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<CryptoKey> {
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<string> {
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<string> {
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<any> {
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<any> {
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<any> {
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;
}

View File

@ -72,4 +72,3 @@ export const config = {
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", "/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
], ],
}; };