Compare commits

..

No commits in common. "master" and "fix/landing-page-issues" have entirely different histories.

55 changed files with 1465 additions and 11953 deletions

View File

@ -6,38 +6,27 @@ import { usePathname, useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Inbox,
Calendar,
LayoutGrid,
Heart,
UserCog,
Bell,
Settings,
LogOut,
FileText,
} from "lucide-react";
import { useAppTheme } from "@/components/ThemeProvider";
import { ThemeToggle } from "@/components/ThemeToggle";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
export function Header() {
const pathname = usePathname();
const router = useRouter();
// Notification state - commented out
// const [notificationsOpen, setNotificationsOpen] = useState(false);
const [notificationsOpen, setNotificationsOpen] = useState(false);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const { theme } = useAppTheme();
const isDark = theme === "dark";
const { logout } = useAuth();
const handleLogout = () => {
setUserMenuOpen(false);
logout();
toast.success("Logged out successfully");
router.push("/");
};
// Mock notifications data - commented out
/*
// Mock notifications data
const notifications = [
{
id: 1,
@ -58,7 +47,6 @@ export function Header() {
];
const unreadCount = notifications.filter((n) => !n.read).length;
*/
return (
<header className={`fixed top-0 left-0 right-0 z-50 ${isDark ? "bg-gray-900 border-gray-800" : "bg-white border-gray-200"} border-b`}>
@ -90,7 +78,7 @@ export function Header() {
<Link
href="/admin/booking"
className={`flex items-center gap-1 sm:gap-2 px-2 sm:px-3 md:px-4 py-1.5 sm:py-2 rounded-lg text-xs sm:text-sm font-medium transition-colors ${
pathname === "/admin/booking" || pathname.startsWith("/admin/booking/")
pathname === "/admin/booking"
? "bg-linear-to-r from-rose-500 to-pink-600 text-white"
: isDark
? "text-gray-300 hover:bg-gray-800"
@ -100,13 +88,91 @@ export function Header() {
<Calendar className="w-4 h-4 sm:w-5 sm:h-5" />
<span className="hidden sm:inline">Book Appointment</span>
</Link>
</nav>
{/* Right Side Actions */}
<div className="flex items-center gap-1.5 sm:gap-2 md:gap-3">
<ThemeToggle />
{/* Notification Popover - Commented out */}
<Popover open={notificationsOpen} onOpenChange={setNotificationsOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative w-8 h-8 sm:w-9 sm:h-9 md:w-10 md:h-10 cursor-pointer">
<Inbox className={`w-5 h-5 sm:w-6 sm:h-6 md:w-8 md:h-8 ${isDark ? "text-gray-300" : "text-gray-600"}`} />
{unreadCount > 0 && (
<span className="absolute top-0.5 right-0.5 sm:top-1 sm:right-1 w-1.5 h-1.5 sm:w-2 sm:h-2 bg-green-500 rounded-full"></span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className={`w-[calc(100vw-2rem)] sm:w-80 md:w-96 p-0 shadow-xl border ${isDark ? "bg-gray-900 border-gray-800" : "bg-white border-gray-200"}`} align="end">
{/* Thumbtack Design at Top Right */}
<div className="relative">
<div className={`absolute -top-2 right-8 w-4 h-4 rotate-45 ${isDark ? "bg-gray-900 border-l border-t border-gray-800" : "bg-white border-l border-t border-gray-200"}`}></div>
<div className={`absolute -top-1 right-8 w-2 h-2 translate-x-1/2 ${isDark ? "bg-gray-900" : "bg-white"}`}></div>
</div>
<div className={`flex items-center justify-between p-4 border-b ${isDark ? "border-gray-800" : ""}`}>
<h3 className={`font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>Notifications</h3>
{unreadCount > 0 && (
<span className="px-2 py-1 text-xs font-medium bg-rose-100 text-rose-700 rounded-full">
{unreadCount} new
</span>
)}
</div>
<div className="max-h-96 overflow-y-auto">
{notifications.length === 0 ? (
<div className={`p-8 text-center ${isDark ? "text-gray-400" : "text-gray-500"}`}>
<Bell className={`w-12 h-12 mx-auto mb-2 ${isDark ? "text-gray-600" : "text-gray-300"}`} />
<p className="text-sm">No notifications</p>
</div>
) : (
<div className={`divide-y ${isDark ? "divide-gray-800" : ""}`}>
{notifications.map((notification) => {
return (
<div
key={notification.id}
className={`p-4 transition-colors cursor-pointer ${
!notification.read
? isDark
? "bg-rose-500/10"
: "bg-rose-50/50"
: ""
}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p
className={`text-sm font-medium ${isDark ? "text-white" : "text-gray-900"} ${
!notification.read ? "font-semibold" : ""
}`}
>
{notification.title}
</p>
{!notification.read && (
<span className="shrink-0 w-2 h-2 bg-green-500 rounded-full mt-1"></span>
)}
</div>
<p className={`text-sm mt-1 ${isDark ? "text-gray-400" : "text-gray-600"}`}>
{notification.message}
</p>
<p className={`text-xs mt-1 ${isDark ? "text-gray-500" : "text-gray-400"}`}>
{notification.time}
</p>
</div>
</div>
);
})}
</div>
)}
</div>
<div className={`p-3 border-t ${isDark ? "border-gray-800 bg-gray-900/80" : "bg-gray-50"}`}>
<Link
href="/admin/notifications"
onClick={() => setNotificationsOpen(false)}
className={`block w-full text-center text-sm font-medium hover:underline transition-colors ${isDark ? "text-rose-300 hover:text-rose-200" : "text-rose-600 hover:text-rose-700"}`}
>
View all notifications
</Link>
</div>
</PopoverContent>
</Popover>
<Popover open={userMenuOpen} onOpenChange={setUserMenuOpen}>
<PopoverTrigger asChild>
<Button
@ -143,7 +209,10 @@ export function Header() {
</Button>
<Button
variant="ghost"
onClick={handleLogout}
onClick={() => {
setUserMenuOpen(false);
router.push("/");
}}
className={`w-full flex items-center gap-3 px-4 py-3 justify-start transition-colors cursor-pointer ${
isDark ? "hover:bg-gray-800" : "hover:bg-gray-50"
}`}

View File

@ -12,16 +12,12 @@ import {
Menu,
X,
Heart,
FileText,
} from "lucide-react";
import { useAppTheme } from "@/components/ThemeProvider";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
const navItems = [
{ label: "Dashboard", icon: LayoutGrid, href: "/admin/dashboard" },
{ label: "Book Appointment", icon: Calendar, href: "/admin/booking" },
{ label: "Deliverables", icon: FileText, href: "/deliverables" },
];
export default function SideNav() {
@ -30,14 +26,6 @@ export default function SideNav() {
const router = useRouter();
const { theme } = useAppTheme();
const isDark = theme === "dark";
const { logout } = useAuth();
const handleLogout = () => {
setOpen(false);
logout();
toast.success("Logged out successfully");
router.push("/");
};
const getActiveIndex = () => {
return navItems.findIndex((item) => pathname?.includes(item.href)) ?? -1;
@ -188,7 +176,10 @@ export default function SideNav() {
</div>
<Button
variant="ghost"
onClick={handleLogout}
onClick={() => {
setOpen(false);
router.push("/");
}}
className={`group flex items-center gap-2 py-2.5 pl-3 md:pl-3 pr-3 md:pr-3 transition-colors duration-200 w-[90%] md:w-[90%] ml-1 md:ml-2 cursor-pointer justify-start rounded-lg ${
isDark
? "text-gray-300 hover:bg-gray-800 hover:text-rose-300"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import {
Select,
SelectContent,
@ -9,7 +8,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import {
Users,
UserCheck,
@ -20,15 +18,8 @@ import {
TrendingUp,
ArrowUpRight,
ArrowDownRight,
FileText,
} from "lucide-react";
import { useAppTheme } from "@/components/ThemeProvider";
import { getAllUsers } from "@/lib/actions/auth";
import { getAppointmentStats, listAppointments } from "@/lib/actions/appointments";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import type { User } from "@/lib/models/auth";
import type { Appointment } from "@/lib/models/appointments";
interface DashboardStats {
total_users: number;
@ -37,25 +28,8 @@ interface DashboardStats {
upcoming_bookings: number;
completed_bookings: number;
cancelled_bookings: number;
active_upcoming_meetings: number;
total_revenue: number;
monthly_revenue: number;
// Percentage fields from API
users_pct?: number;
scheduled_pct?: number;
completed_pct?: number;
pending_review_pct?: number;
rejected_pct?: number;
trends: {
total_users: string;
active_users: string;
total_bookings: string;
upcoming_bookings: string;
completed_bookings: string;
cancelled_bookings: string;
total_revenue: string;
monthly_revenue: string;
};
}
export default function Dashboard() {
@ -63,178 +37,89 @@ export default function Dashboard() {
const [loading, setLoading] = useState(true);
const [timePeriod, setTimePeriod] = useState<string>("last_month");
const { theme } = useAppTheme();
const { user } = useAuth();
const isDark = theme === "dark";
useEffect(() => {
// Simulate API call
const fetchStats = async () => {
setLoading(true);
try {
// Fetch all data in parallel
const [users, appointmentStats, appointments] = await Promise.all([
getAllUsers().catch(() => [] as User[]),
getAppointmentStats().catch(() => null),
listAppointments().catch(() => [] as Appointment[]),
]);
// Calculate statistics
// Use users count from appointment stats if available, otherwise use getAllUsers result
const totalUsers = appointmentStats?.users ?? users.length;
const activeUsers = users.filter(
(user) => user.is_active === true || user.isActive === true
).length;
const totalBookings = appointmentStats?.total_requests || appointments.length;
const upcomingBookings = appointmentStats?.scheduled ||
appointments.filter((apt) => apt.status === "scheduled").length;
// Completed bookings from API stats
const completedBookings = appointmentStats?.completed ||
appointments.filter((apt) => apt.status === "completed").length;
const cancelledBookings = appointmentStats?.rejected ||
appointments.filter((apt) => apt.status === "rejected").length;
const activeUpcomingMeetings = appointmentStats?.active_upcoming_meetings || 0;
// Calculate revenue (assuming appointments have amount field, defaulting to 0)
const now = new Date();
const currentMonth = now.getMonth();
const currentYear = now.getFullYear();
const totalRevenue = appointments.reduce((sum, apt) => {
// If appointment has amount field, use it, otherwise default to 0
const amount = (apt as any).amount || 0;
return sum + amount;
}, 0);
const monthlyRevenue = appointments
.filter((apt) => {
if (!apt.scheduled_datetime) return false;
const aptDate = new Date(apt.scheduled_datetime);
return (
aptDate.getMonth() === currentMonth &&
aptDate.getFullYear() === currentYear
);
})
.reduce((sum, apt) => {
const amount = (apt as any).amount || 0;
return sum + amount;
}, 0);
// Trends object kept for compatibility but not used for percentage badges
// All percentage badges now use API-provided _pct values
const trends = {
total_users: "0%",
active_users: "0%",
total_bookings: "0%",
upcoming_bookings: "0",
completed_bookings: "0%",
cancelled_bookings: "0%",
total_revenue: "0%",
monthly_revenue: "0%",
};
setStats({
total_users: totalUsers,
active_users: activeUsers,
total_bookings: totalBookings,
upcoming_bookings: upcomingBookings,
completed_bookings: completedBookings,
cancelled_bookings: cancelledBookings,
active_upcoming_meetings: activeUpcomingMeetings,
total_revenue: totalRevenue,
monthly_revenue: monthlyRevenue,
// Include percentage fields from API
users_pct: appointmentStats?.users_pct,
scheduled_pct: appointmentStats?.scheduled_pct,
completed_pct: appointmentStats?.completed_pct,
pending_review_pct: appointmentStats?.pending_review_pct,
rejected_pct: appointmentStats?.rejected_pct,
trends,
});
} catch (error) {
toast.error("Failed to load dashboard statistics");
// Set default values on error
setStats({
total_users: 0,
active_users: 0,
total_bookings: 0,
upcoming_bookings: 0,
completed_bookings: 0,
cancelled_bookings: 0,
active_upcoming_meetings: 0,
total_revenue: 0,
monthly_revenue: 0,
users_pct: undefined,
scheduled_pct: undefined,
completed_pct: undefined,
pending_review_pct: undefined,
rejected_pct: undefined,
trends: {
total_users: "0%",
active_users: "0%",
total_bookings: "0%",
upcoming_bookings: "0",
completed_bookings: "0%",
cancelled_bookings: "0%",
total_revenue: "0%",
monthly_revenue: "0%",
},
});
} finally {
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 500));
// Mock API response
const mockData: DashboardStats = {
total_users: 3,
active_users: 3,
total_bookings: 6,
upcoming_bookings: 6,
completed_bookings: 0,
cancelled_bookings: 0,
total_revenue: 0,
monthly_revenue: 0,
};
setStats(mockData);
setLoading(false);
}
};
fetchStats();
}, [timePeriod]);
}, []);
const statCards = [
{
title: "Total Users",
value: stats?.total_users ?? 0,
icon: Users,
trend: stats?.users_pct !== undefined ? `${Math.round(stats.users_pct)}%` : undefined,
trend: "+12%",
trendUp: true,
},
{
title: "Active Users",
value: stats?.active_users ?? 0,
icon: UserCheck,
trend: undefined, // No _pct field from API for active users
trend: "+8%",
trendUp: true,
},
{
title: "Total Bookings",
value: stats?.total_bookings ?? 0,
icon: Calendar,
trend: undefined, // No _pct field from API for total bookings
trend: "+24%",
trendUp: true,
},
{
title: "Upcoming Bookings",
value: stats?.upcoming_bookings ?? 0,
icon: CalendarCheck,
trend: stats?.scheduled_pct !== undefined ? `${Math.round(stats.scheduled_pct)}%` : undefined,
trend: "+6",
trendUp: true,
},
{
title: "Completed Bookings",
value: stats?.completed_bookings ?? 0,
icon: CalendarCheck,
trend: stats?.completed_pct !== undefined ? `${Math.round(stats.completed_pct)}%` : undefined,
trend: "0%",
trendUp: true,
},
{
title: "Cancelled Bookings",
value: stats?.cancelled_bookings ?? 0,
icon: CalendarX,
trend: stats?.rejected_pct !== undefined ? `${Math.round(stats.rejected_pct)}%` : undefined,
trend: "0%",
trendUp: false,
},
{
title: "Active Upcoming Meetings",
value: stats?.active_upcoming_meetings ?? 0,
icon: CalendarCheck,
trend: undefined,
title: "Total Revenue",
value: `$${stats?.total_revenue.toLocaleString() ?? 0}`,
icon: DollarSign,
trend: "+18%",
trendUp: true,
},
{
title: "Monthly Revenue",
value: `$${stats?.monthly_revenue.toLocaleString() ?? 0}`,
icon: TrendingUp,
trend: "+32%",
trendUp: true,
},
];
@ -256,23 +141,13 @@ export default function Dashboard() {
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div>
<h1 className={`text-2xl font-semibold mb-1 ${isDark ? "text-white" : "text-gray-900"}`}>
Welcome Back! {user?.first_name || ""}
Welcome Back! Hammond
</h1>
<p className={`text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Here's an overview of your practice today
</p>
</div>
<div className="flex items-center gap-3">
<Link href="/deliverables">
<Button
variant="outline"
className={`flex items-center gap-2 ${isDark ? "bg-gray-800 border-gray-700 text-gray-100 hover:bg-gray-700" : "bg-white border-gray-200 text-gray-900 hover:bg-gray-50"}`}
>
<FileText className="w-4 h-4" />
<span className="hidden sm:inline">Deliverables</span>
</Button>
</Link>
<Select value={timePeriod} onValueChange={setTimePeriod}>
<Select value={timePeriod} onValueChange={setTimePeriod}>
<SelectTrigger className={`w-full sm:w-[200px] cursor-pointer ${isDark ? "bg-gray-800 border-gray-700 text-gray-100" : "bg-white border-gray-200 text-gray-900"}`}>
<SelectValue placeholder="Select period" />
</SelectTrigger>
@ -282,7 +157,6 @@ export default function Dashboard() {
<SelectItem className={isDark ? "focus:bg-gray-700" : ""} value="last_year">Last Year</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{loading ? (
@ -292,7 +166,7 @@ export default function Dashboard() {
) : (
<>
{/* Stats Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 sm:gap-4">
{statCards.map((card, index) => {
const Icon = card.icon;
return (
@ -304,29 +178,26 @@ export default function Dashboard() {
<div className={`p-2 sm:p-2.5 rounded-lg ${isDark ? "bg-gray-700" : "bg-gray-50"}`}>
<Icon className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? "text-gray-200" : "text-gray-600"}`} />
</div>
{card.trend && (
<div className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${getTrendClasses(card.trendUp)}`}>
{card.trendUp ? (
<ArrowUpRight className="w-3 h-3" />
) : (
<ArrowDownRight className="w-3 h-3" />
)}
<span className="hidden sm:inline">{card.trend}</span>
</div>
)}
<div className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${getTrendClasses(card.trendUp)}`}>
{card.trendUp ? (
<ArrowUpRight className="w-3 h-3" />
) : (
<ArrowDownRight className="w-3 h-3" />
)}
<span className="hidden sm:inline">{card.trend}</span>
</div>
</div>
<div>
<h3 className={`text-xs font-medium mb-1 sm:mb-2 uppercase tracking-wider ${isDark ? "text-rose-300" : "text-rose-600"}`}>
{card.title}
</h3>
<p className={`text-xl sm:text-2xl font-bold ${isDark ? "text-white" : "text-gray-900"}`}>
<p className={`text-xl sm:text-2xl font-bold mb-1 ${isDark ? "text-white" : "text-gray-900"}`}>
{card.value}
</p>
{/* "vs last month" text commented out */}
{/* <p className={`text-xs ${isDark ? "text-gray-400" : "text-gray-500"}`}>
vs last month
</p> */}
<p className={`text-xs ${isDark ? "text-gray-400" : "text-gray-500"}`}>
vs last month
</p>
</div>
</div>
);

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState } 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,21 +13,16 @@ 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({
firstName: "",
lastName: "",
email: "",
phone: "",
fullName: "Hammond",
email: "admin@attuneheart.com",
phone: "+1 (555) 123-4567",
});
const [passwordData, setPasswordData] = useState({
currentPassword: "",
@ -42,29 +37,6 @@ 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) {
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,
@ -87,25 +59,11 @@ export default function AdminSettingsPage() {
};
const handleSave = async () => {
if (!formData.firstName || !formData.lastName) {
toast.error("First name and last name are required");
return;
}
setLoading(true);
try {
await updateProfile({
first_name: formData.firstName,
last_name: formData.lastName,
phone_number: formData.phone || undefined,
});
toast.success("Profile updated successfully!");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Failed to update profile";
toast.error(errorMessage);
} finally {
setLoading(false);
}
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
setLoading(false);
// In a real app, you would show a success message here
};
const handlePasswordSave = async () => {
@ -155,20 +113,15 @@ export default function AdminSettingsPage() {
</div>
<Button
onClick={handleSave}
disabled={loading || fetching}
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"
disabled={loading}
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"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Save Changes
</>
<Save className="w-4 h-4 mr-2" />
)}
Save Changes
</Button>
</div>
@ -186,49 +139,21 @@ export default function AdminSettingsPage() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{fetching ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-rose-600" />
<div className="space-y-2">
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
Full 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.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 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>
<div className="space-y-2">
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
@ -239,14 +164,11 @@ export default function AdminSettingsPage() {
<Input
type="email"
value={formData.email}
disabled
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="Email address"
onChange={(e) => handleInputChange("email", e.target.value)}
className={`pl-10 ${isDark ? "bg-gray-700 border-gray-600 text-white placeholder:text-gray-400" : ""}`}
placeholder="Enter your email"
/>
</div>
<p className={`text-xs ${isDark ? "text-gray-500" : "text-gray-400"}`}>
Email address cannot be changed
</p>
</div>
<div className="space-y-2">

View File

@ -1,364 +1,20 @@
"use client";
import { useState, useEffect, Suspense } from "react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { Heart, Eye, EyeOff, X, Loader2, CheckCircle2, Mail } from "lucide-react";
import Image from "next/image";
import { Heart, Eye, EyeOff, X } from "lucide-react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useAppTheme } from "@/components/ThemeProvider";
import { useAuth } from "@/hooks/useAuth";
import {
loginSchema,
registerSchema,
verifyOtpSchema,
type LoginInput,
type RegisterInput,
type VerifyOtpInput
} from "@/lib/schema/auth";
import { toast } from "sonner";
type Step = "login" | "signup" | "verify";
function LoginContent() {
export default function Login() {
const { theme } = useAppTheme();
const isDark = theme === "dark";
const [step, setStep] = useState<Step>("login");
const [showPassword, setShowPassword] = useState(false);
const [showPassword2, setShowPassword2] = useState(false);
const [rememberMe, setRememberMe] = useState(false);
const [registeredEmail, setRegisteredEmail] = useState("");
const [showResendOtp, setShowResendOtp] = useState(false);
// Login form data
const [loginData, setLoginData] = useState<LoginInput>({
email: "",
password: "",
});
// Signup form data
const [signupData, setSignupData] = useState<RegisterInput>({
first_name: "",
last_name: "",
email: "",
phone_number: "",
password: "",
password2: "",
});
// OTP verification data
const [otpData, setOtpData] = useState<VerifyOtpInput>({
email: "",
otp: "",
});
const [errors, setErrors] = useState<Partial<Record<string, string>>>({});
const router = useRouter();
const searchParams = useSearchParams();
const {
login,
register,
verifyOtp,
isAuthenticated,
isAdmin,
loginMutation,
registerMutation,
verifyOtpMutation,
resendOtpMutation
} = useAuth();
// Check for verify step or email from query parameters
useEffect(() => {
const verifyEmail = searchParams.get("verify");
const emailParam = searchParams.get("email");
const errorParam = searchParams.get("error");
// Don't show verify step if there's an error indicating OTP sending failed
if (errorParam && errorParam.toLowerCase().includes("failed to send")) {
setStep("login");
return;
}
if (verifyEmail === "true" && emailParam) {
// Show verify step if verify=true
setStep("verify");
setRegisteredEmail(emailParam);
setOtpData({ email: emailParam, otp: "" });
} else if (emailParam && step === "login") {
// Pre-fill email in login form if email parameter is present
setLoginData(prev => ({ ...prev, email: emailParam }));
}
}, [searchParams, step]);
// Redirect if already authenticated
useEffect(() => {
if (isAuthenticated) {
// Use a small delay to ensure cookies are set and middleware has processed
const timer = setTimeout(() => {
// Always redirect based on user role, ignore redirect parameter if user is admin
const redirectParam = searchParams.get("redirect");
const defaultRedirect = isAdmin ? "/admin/booking" : "/user/dashboard";
const finalRedirect = isAdmin ? "/admin/booking" : (redirectParam || defaultRedirect);
// Use window.location.href to ensure full page reload and cookie reading
window.location.href = finalRedirect;
}, 200);
return () => clearTimeout(timer);
}
}, [isAuthenticated, isAdmin, searchParams]);
// Handle login
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
// Validate form
const validation = loginSchema.safeParse(loginData);
if (!validation.success) {
const fieldErrors: Partial<Record<string, string>> = {};
validation.error.issues.forEach((err) => {
if (err.path[0]) {
fieldErrors[err.path[0] as string] = err.message;
}
});
setErrors(fieldErrors);
return;
}
try {
const result = await login(loginData);
if (result.tokens && result.user) {
toast.success("Login successful!");
// 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 =
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(() => {
// Always redirect based on user role, ignore redirect parameter if user is admin
// This ensures admins always go to admin booking page
const defaultRedirect = userIsAdmin ? "/admin/booking" : "/user/dashboard";
// Only use redirect parameter if user is NOT admin
const redirectParam = searchParams.get("redirect");
const finalRedirect = userIsAdmin ? "/admin/booking" : (redirectParam || defaultRedirect);
// Use window.location.href instead of router.push to ensure full page reload
// This ensures cookies are read correctly by middleware
window.location.href = finalRedirect;
}, 300);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Login failed. Please try again.";
toast.error(errorMessage);
// Check if error is about email verification
if (errorMessage.toLowerCase().includes("verify your email") ||
errorMessage.toLowerCase().includes("email address before logging")) {
setShowResendOtp(true);
} else {
setShowResendOtp(false);
}
setErrors({});
}
};
// Handle signup
const handleSignup = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
// Validate form
const validation = registerSchema.safeParse(signupData);
if (!validation.success) {
const fieldErrors: Partial<Record<string, string>> = {};
validation.error.issues.forEach((err) => {
if (err.path[0]) {
fieldErrors[err.path[0] as string] = err.message;
}
});
setErrors(fieldErrors);
return;
}
try {
const result = await register(signupData);
// Check if registration was successful (user created)
// Even if OTP sending failed, we should allow user to proceed to verification
// and use resend OTP feature
if (result && result.message) {
// Registration successful - proceed to OTP verification
toast.success("Registration successful! Please check your email for OTP verification.");
setRegisteredEmail(signupData.email);
setOtpData({ email: signupData.email, otp: "" });
setStep("verify");
} else {
// If no message but no error, still proceed (some APIs might not return message)
toast.success("Registration successful! Please check your email for OTP verification.");
setRegisteredEmail(signupData.email);
setOtpData({ email: signupData.email, otp: "" });
setStep("verify");
}
} catch (error) {
// Handle different types of errors
let errorMessage = "Registration failed. Please try again.";
if (error instanceof Error) {
errorMessage = error.message;
// If OTP sending failed, don't show OTP verification - just show error
if (errorMessage.toLowerCase().includes("failed to send") ||
errorMessage.toLowerCase().includes("failed to send otp")) {
toast.error("Registration failed: OTP could not be sent. Please try again later or contact support.");
setErrors({});
return;
}
// Check if it's an OTP sending error but registration might have succeeded
if (errorMessage.toLowerCase().includes("otp") ||
errorMessage.toLowerCase().includes("email") ||
errorMessage.toLowerCase().includes("send")) {
// If OTP sending failed but user might be created, allow proceeding to verification
// User can use resend OTP
toast.warning("Registration completed, but OTP email could not be sent. You can request a new OTP on the next screen.");
setRegisteredEmail(signupData.email);
setOtpData({ email: signupData.email, otp: "" });
setStep("verify");
return;
}
}
toast.error(errorMessage);
setErrors({});
}
};
// Handle OTP verification
const handleVerifyOtp = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
// Use registeredEmail if available, otherwise use otpData.email
const emailToVerify = registeredEmail || otpData.email;
if (!emailToVerify) {
setErrors({ email: "Email address is required" });
return;
}
// Prepare OTP data with email
const otpToVerify = {
email: emailToVerify,
otp: otpData.otp,
};
// Validate OTP
const validation = verifyOtpSchema.safeParse(otpToVerify);
if (!validation.success) {
const fieldErrors: Partial<Record<string, string>> = {};
validation.error.issues.forEach((err) => {
if (err.path[0]) {
fieldErrors[err.path[0] as string] = err.message;
}
});
setErrors(fieldErrors);
return;
}
try {
const result = await verifyOtp(otpToVerify);
// If verification is successful, switch to login step
toast.success("Email verified successfully! You can now login.");
// Switch to login step and pre-fill email
setStep("login");
setLoginData(prev => ({ ...prev, email: emailToVerify }));
setOtpData({ email: "", otp: "" });
setRegisteredEmail("");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "OTP verification failed. Please try again.";
toast.error(errorMessage);
setErrors({});
}
};
// Handle resend OTP
const handleResendOtp = async () => {
const emailToUse = registeredEmail || otpData.email;
if (!emailToUse) {
toast.error("Email address is required to resend OTP.");
return;
}
try {
await resendOtpMutation.mutateAsync({ email: emailToUse, context: "registration" });
toast.success("OTP resent successfully! Please check your email.");
// Update registeredEmail if it wasn't set
if (!registeredEmail) {
setRegisteredEmail(emailToUse);
}
} catch (error) {
let errorMessage = "Failed to resend OTP. Please try again.";
if (error instanceof Error) {
errorMessage = error.message;
// Provide more helpful error messages
if (errorMessage.toLowerCase().includes("ssl") ||
errorMessage.toLowerCase().includes("certificate")) {
errorMessage = "Email service is currently unavailable. Please contact support or try again later.";
} else if (errorMessage.toLowerCase().includes("not found") ||
errorMessage.toLowerCase().includes("does not exist")) {
errorMessage = "Email address not found. Please check your email or register again.";
}
}
toast.error(errorMessage);
}
};
// Handle form field changes
const handleLoginChange = (field: keyof LoginInput, value: string) => {
setLoginData((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
const handleSignupChange = (field: keyof RegisterInput, value: string) => {
setSignupData((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
const handleOtpChange = (field: keyof VerifyOtpInput, value: string) => {
setOtpData((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
return (
<div className="min-h-screen relative flex items-center justify-center px-4 py-12">
@ -373,48 +29,46 @@ function LoginContent() {
sizes="100vw"
/>
{/* Overlay for better readability */}
<div className="absolute inset-0 bg-black/20"></div>
<div className="absolute inset-0 bg-black/50"></div>
</div>
{/* Branding - Top Left */}
<div className="absolute top-8 left-8 flex items-center gap-3 z-30">
<Heart className="w-6 h-6 text-white" fill="white" />
<span className="text-white text-xl font-semibold">Attune Heart Therapy</span>
<div className="absolute top-8 left-8 flex items-center gap-2 z-30">
<Link href="/" className="flex items-center gap-2">
<div className="bg-gradient-to-r from-rose-500 to-pink-600 p-2 rounded-xl">
<Heart className="h-5 w-5 text-white fill-white" />
</div>
<span className={`font-bold text-lg drop-shadow-lg ${isDark ? 'text-rose-400' : 'text-rose-500'}`}>
Attune Heart Therapy
</span>
</Link>
</div>
{/* Centered White Card */}
{/* Centered White Card - Login Form */}
<div className={`relative z-20 w-full max-w-md rounded-2xl shadow-2xl p-8 ${isDark ? 'bg-gray-800 border border-gray-700' : 'bg-white'}`}>
{/* Header with Close Button */}
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
{/* Heading */}
<h1 className="text-3xl font-bold bg-linear-to-r from-rose-600 via-pink-600 to-rose-600 bg-clip-text text-transparent mb-2">
{step === "login" && "Welcome back"}
{step === "verify" && "Verify your email"}
<h1 className="text-3xl font-bold bg-gradient-to-r from-rose-600 via-pink-600 to-rose-600 bg-clip-text text-transparent mb-2">
Welcome back
</h1>
{/* Subtitle */}
{step === "login" && (
{/* Sign Up Prompt */}
<p className={`mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
Sign in to access your admin dashboard
New to Attune Heart Therapy?{" "}
<Link href="/signup" className={`underline font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}>
Sign up
</Link>
</p>
)}
{step === "verify" && registeredEmail && (
<p className={`mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
We've sent a verification code to <strong>{registeredEmail}</strong>
</p>
)}
{step === "verify" && !registeredEmail && (
<p className={`mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
Enter the verification code sent to your email
</p>
)}
</div>
{/* Close Button */}
<Button
onClick={() => router.back()}
variant="ghost"
size="icon"
className={`shrink-0 w-8 h-8 rounded-full ${isDark ? 'text-gray-400 hover:text-gray-300 hover:bg-gray-700' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'}`}
className={`flex-shrink-0 w-8 h-8 rounded-full ${isDark ? 'text-gray-400 hover:text-gray-300 hover:bg-gray-700' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'}`}
aria-label="Close"
>
<X className="w-5 h-5" />
@ -422,8 +76,10 @@ function LoginContent() {
</div>
{/* Login Form */}
{step === "login" && (
<form className="space-y-6" onSubmit={handleLogin}>
<form className="space-y-6" onSubmit={(e) => {
e.preventDefault();
router.push("/");
}}>
{/* Email Field */}
<div className="space-y-2">
<label htmlFor="email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
@ -433,14 +89,9 @@ function LoginContent() {
id="email"
type="email"
placeholder="Email address"
value={loginData.email}
onChange={(e) => handleLoginChange("email", e.target.value)}
className={`h-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'} ${errors.email ? 'border-red-500' : ''}`}
className={`h-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
required
/>
{errors.email && (
<p className="text-sm text-red-500">{errors.email}</p>
)}
</div>
{/* Password Field */}
@ -453,9 +104,7 @@ function LoginContent() {
id="password"
type={showPassword ? "text" : "password"}
placeholder="Your password"
value={loginData.password}
onChange={(e) => handleLoginChange("password", e.target.value)}
className={`h-12 pr-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'} ${errors.password ? 'border-red-500' : ''}`}
className={`h-12 pr-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
required
/>
<Button
@ -473,77 +122,16 @@ function LoginContent() {
)}
</Button>
</div>
{errors.password && (
<p className="text-sm text-red-500">{errors.password}</p>
)}
</div>
{/* Submit Button */}
<Button
type="submit"
disabled={loginMutation.isPending}
className="w-full h-12 text-base font-semibold bg-linear-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full h-12 text-base font-semibold bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all"
>
{loginMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Logging in...
</>
) : (
"Log in"
)}
Log in
</Button>
{/* Resend OTP - Show when email verification error occurs */}
{showResendOtp && (
<div className={`p-4 rounded-lg border ${isDark ? 'bg-yellow-900/20 border-yellow-800' : 'bg-yellow-50 border-yellow-200'}`}>
<div className="flex items-start gap-3">
<Mail className={`w-5 h-5 mt-0.5 flex-shrink-0 ${isDark ? 'text-yellow-400' : 'text-yellow-600'}`} />
<div className="flex-1">
<p className={`text-sm font-medium ${isDark ? 'text-yellow-200' : 'text-yellow-900'}`}>
Email verification required
</p>
<p className={`text-xs sm:text-sm mt-1 ${isDark ? 'text-yellow-300' : 'text-yellow-700'}`}>
Please verify your email address before logging in. We can resend the verification code to {loginData.email}.
</p>
<Button
type="button"
variant="link"
onClick={async () => {
if (!loginData.email) {
toast.error("Email address is required to resend OTP.");
return;
}
try {
await resendOtpMutation.mutateAsync({
email: loginData.email,
context: "registration"
});
toast.success("OTP resent successfully! Please check your email.");
setShowResendOtp(false);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to resend OTP. Please try again.";
toast.error(errorMessage);
}
}}
disabled={resendOtpMutation.isPending}
className={`h-auto p-0 mt-2 text-xs sm:text-sm font-medium ${isDark ? 'text-yellow-400 hover:text-yellow-300' : 'text-yellow-700 hover:text-yellow-800'}`}
>
{resendOtpMutation.isPending ? (
<>
<Loader2 className="w-3 h-3 mr-1 animate-spin inline" />
Sending...
</>
) : (
"Resend verification code"
)}
</Button>
</div>
</div>
</div>
)}
{/* Remember Me & Forgot Password */}
<div className="flex items-center justify-between text-sm">
<label className="flex items-center gap-2 cursor-pointer">
@ -555,138 +143,16 @@ function LoginContent() {
/>
<span className={isDark ? 'text-gray-300' : 'text-black'}>Remember me</span>
</label>
<button
type="button"
<Link
href="/forgot-password"
className={`font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
>
Forgot password?
</button>
</div>
</form>
)}
{/* OTP Verification Form */}
{step === "verify" && (
<form className="space-y-6" onSubmit={handleVerifyOtp}>
<div className={`p-4 rounded-lg border ${isDark ? 'bg-blue-900/20 border-blue-800' : 'bg-blue-50 border-blue-200'}`}>
<div className="flex items-start gap-3">
<CheckCircle2 className={`w-5 h-5 mt-0.5 ${isDark ? 'text-blue-400' : 'text-blue-600'}`} />
<div>
<p className={`text-sm font-medium ${isDark ? 'text-blue-200' : 'text-blue-900'}`}>
Check your email
</p>
<p className={`text-sm mt-1 ${isDark ? 'text-blue-300' : 'text-blue-700'}`}>
We've sent a 6-digit verification code to your email address.
</p>
</div>
</div>
</div>
{/* Email Field (if not set) */}
{!registeredEmail && (
<div className="space-y-2">
<label htmlFor="verify-email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Email address *
</label>
<Input
id="verify-email"
type="email"
placeholder="Email address"
value={otpData.email}
onChange={(e) => handleOtpChange("email", e.target.value)}
className={`h-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'} ${errors.email ? 'border-red-500' : ''}`}
required
/>
{errors.email && (
<p className="text-sm text-red-500">{errors.email}</p>
)}
</div>
)}
{/* OTP Field */}
<div className="space-y-2">
<label className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Verification Code *
</label>
<div className="flex justify-center">
<InputOTP
maxLength={6}
value={otpData.otp}
onChange={(value) => handleOtpChange("otp", value)}
aria-invalid={!!errors.otp}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</div>
{errors.otp && (
<p className="text-sm text-red-500 text-center">{errors.otp}</p>
)}
</div>
{/* Resend OTP */}
<div className="text-center">
<button
type="button"
onClick={handleResendOtp}
disabled={resendOtpMutation.isPending}
className={`text-sm font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{resendOtpMutation.isPending ? "Sending..." : "Didn't receive the code? Resend"}
</button>
</Link>
</div>
{/* Submit Button */}
<Button
type="submit"
disabled={verifyOtpMutation.isPending}
className="w-full h-12 text-base font-semibold bg-linear-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{verifyOtpMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Verifying...
</>
) : (
"Verify Email"
)}
</Button>
{/* Back to login */}
<div className="text-center">
<button
type="button"
onClick={() => {
setStep("login");
setOtpData({ email: "", otp: "" });
}}
className={`text-sm font-medium ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-600 hover:text-gray-700'}`}
>
Back to login
</button>
</div>
</form>
)}
</div>
</div>
);
}
export default function Login() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-rose-600" />
</div>
}>
<LoginContent />
</Suspense>
);
}
}

View File

@ -1,470 +0,0 @@
"use client";
import { useState, useEffect, Suspense } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import { Heart, Eye, EyeOff, X, Loader2, CheckCircle2 } from "lucide-react";
import Link from "next/link";
import Image from "next/image";
import { useRouter, useSearchParams } from "next/navigation";
import { useAppTheme } from "@/components/ThemeProvider";
import { useAuth } from "@/hooks/useAuth";
import { registerSchema, verifyOtpSchema, type RegisterInput, type VerifyOtpInput } from "@/lib/schema/auth";
import { toast } from "sonner";
function SignupContent() {
const { theme } = useAppTheme();
const isDark = theme === "dark";
const [showPassword, setShowPassword] = useState(false);
const [showPassword2, setShowPassword2] = useState(false);
const [step, setStep] = useState<"register" | "verify">("register");
const [registeredEmail, setRegisteredEmail] = useState("");
const [formData, setFormData] = useState<RegisterInput>({
first_name: "",
last_name: "",
email: "",
phone_number: "",
password: "",
password2: "",
});
const [otpData, setOtpData] = useState<VerifyOtpInput>({
email: "",
otp: "",
});
const [errors, setErrors] = useState<Partial<Record<keyof RegisterInput | keyof VerifyOtpInput, string>>>({});
const router = useRouter();
const searchParams = useSearchParams();
const { register, verifyOtp, isAuthenticated, registerMutation, verifyOtpMutation, resendOtpMutation } = useAuth();
// Redirect if already authenticated
useEffect(() => {
if (isAuthenticated) {
const redirect = searchParams.get("redirect") || "/admin/booking";
router.push(redirect);
}
}, [isAuthenticated, router, searchParams]);
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
// Validate form
const validation = registerSchema.safeParse(formData);
if (!validation.success) {
const fieldErrors: Partial<Record<keyof RegisterInput, string>> = {};
validation.error.issues.forEach((err) => {
if (err.path[0]) {
fieldErrors[err.path[0] as keyof RegisterInput] = err.message;
}
});
setErrors(fieldErrors);
return;
}
try {
const result = await register(formData);
// If registration is successful, redirect to login page with verify parameter
toast.success("Registration successful! Please check your email for OTP verification.");
// Redirect to login page with verify step
router.push(`/login?verify=true&email=${encodeURIComponent(formData.email)}`);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Registration failed. Please try again.";
// If OTP sending failed, don't show OTP verification - just show error
if (errorMessage.toLowerCase().includes("failed to send") ||
errorMessage.toLowerCase().includes("failed to send otp")) {
toast.error("Registration failed: OTP could not be sent. Please try again later or contact support.");
setErrors({});
return;
}
toast.error(errorMessage);
// Don't set field errors for server errors, only show toast
setErrors({});
}
};
const handleVerifyOtp = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
// Validate OTP
const validation = verifyOtpSchema.safeParse(otpData);
if (!validation.success) {
const fieldErrors: Partial<Record<keyof VerifyOtpInput, string>> = {};
validation.error.issues.forEach((err) => {
if (err.path[0]) {
fieldErrors[err.path[0] as keyof VerifyOtpInput] = err.message;
}
});
setErrors(fieldErrors);
return;
}
try {
const result = await verifyOtp(otpData);
// If verification is successful (no error thrown), show success and redirect
toast.success("Email verified successfully! Redirecting to login...");
// Redirect to login page after OTP verification with email pre-filled
setTimeout(() => {
router.push(`/login?email=${encodeURIComponent(otpData.email)}`);
}, 1500);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "OTP verification failed. Please try again.";
toast.error(errorMessage);
// Don't set field errors for server errors, only show toast
setErrors({});
}
};
const handleResendOtp = async () => {
try {
await resendOtpMutation.mutateAsync({ email: registeredEmail, context: "registration" });
toast.success("OTP resent successfully! Please check your email.");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Failed to resend OTP. Please try again.";
toast.error(errorMessage);
}
};
const handleChange = (field: keyof RegisterInput, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
// Clear error when user starts typing
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
const handleOtpChange = (field: keyof VerifyOtpInput, value: string) => {
setOtpData((prev) => ({ ...prev, [field]: value }));
// Clear error when user starts typing
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
return (
<div className="min-h-screen relative flex items-center justify-center px-4 py-12">
{/* Background Image */}
<div className="absolute inset-0 z-0">
<Image
src="/woman.jpg"
alt="Therapy and counseling session with African American clients"
fill
className="object-cover object-center"
priority
sizes="100vw"
/>
{/* Overlay for better readability */}
<div className="absolute inset-0 bg-black/20"></div>
</div>
{/* Branding - Top Left */}
<div className="absolute top-8 left-8 flex items-center gap-3 z-30">
<Heart className="w-6 h-6 text-white" fill="white" />
<span className="text-white text-xl font-semibold">Attune Heart Therapy</span>
</div>
{/* Centered White Card - Signup Form */}
<div className={`relative z-20 w-full max-w-md rounded-2xl shadow-2xl p-8 ${isDark ? 'bg-gray-800 border border-gray-700' : 'bg-white'}`}>
{/* Header with Close Button */}
<div className="flex items-start justify-between mb-2">
<div className="flex-1">
{/* Heading */}
<h1 className="text-3xl font-bold bg-gradient-to-r from-rose-600 via-pink-600 to-rose-600 bg-clip-text text-transparent mb-2">
{step === "register" ? "Create an account" : "Verify your email"}
</h1>
{/* Login Prompt */}
{step === "register" && (
<p className={`mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
Already have an account?{" "}
<Link href="/login" className={`underline font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}>
Log in
</Link>
</p>
)}
{step === "verify" && (
<p className={`mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
We've sent a verification code to <strong>{registeredEmail}</strong>
</p>
)}
</div>
{/* Close Button */}
<Button
onClick={() => router.back()}
variant="ghost"
size="icon"
className={`flex-shrink-0 w-8 h-8 rounded-full ${isDark ? 'text-gray-400 hover:text-gray-300 hover:bg-gray-700' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'}`}
aria-label="Close"
>
<X className="w-5 h-5" />
</Button>
</div>
{step === "register" ? (
/* Registration Form */
<form className="space-y-4" onSubmit={handleRegister}>
{/* First Name Field */}
<div className="space-y-2">
<label htmlFor="firstName" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
First Name *
</label>
<Input
id="firstName"
type="text"
placeholder="John"
value={formData.first_name}
onChange={(e) => handleChange("first_name", e.target.value)}
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'} ${errors.first_name ? 'border-red-500' : ''}`}
required
/>
{errors.first_name && (
<p className="text-sm text-red-500">{errors.first_name}</p>
)}
</div>
{/* Last Name Field */}
<div className="space-y-2">
<label htmlFor="lastName" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Last Name *
</label>
<Input
id="lastName"
type="text"
placeholder="Doe"
value={formData.last_name}
onChange={(e) => handleChange("last_name", e.target.value)}
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'} ${errors.last_name ? 'border-red-500' : ''}`}
required
/>
{errors.last_name && (
<p className="text-sm text-red-500">{errors.last_name}</p>
)}
</div>
{/* Email Field */}
<div className="space-y-2">
<label htmlFor="email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Email address *
</label>
<Input
id="email"
type="email"
placeholder="Email address"
value={formData.email}
onChange={(e) => handleChange("email", e.target.value)}
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'} ${errors.email ? 'border-red-500' : ''}`}
required
/>
</div>
{/* Phone Field */}
<div className="space-y-2">
<label htmlFor="phone" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Phone Number (Optional)
</label>
<Input
id="phone"
type="tel"
placeholder="+1 (555) 123-4567"
value={formData.phone_number || ""}
onChange={(e) => handleChange("phone_number", e.target.value)}
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
/>
</div>
{/* Password Field */}
<div className="space-y-2">
<label htmlFor="password" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Password *
</label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="Password (min 8 characters)"
value={formData.password}
onChange={(e) => handleChange("password", e.target.value)}
className={`h-11 pr-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'} ${errors.password ? 'border-red-500' : ''}`}
required
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setShowPassword(!showPassword)}
className={`absolute right-4 top-1/2 -translate-y-1/2 h-auto w-auto p-0 ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-700'}`}
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</Button>
</div>
{errors.password && (
<p className="text-sm text-red-500">{errors.password}</p>
)}
</div>
{/* Confirm Password Field */}
<div className="space-y-2">
<label htmlFor="password2" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Confirm Password *
</label>
<div className="relative">
<Input
id="password2"
type={showPassword2 ? "text" : "password"}
placeholder="Confirm password"
value={formData.password2}
onChange={(e) => handleChange("password2", e.target.value)}
className={`h-11 pr-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'} ${errors.password2 ? 'border-red-500' : ''}`}
required
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setShowPassword2(!showPassword2)}
className={`absolute right-4 top-1/2 -translate-y-1/2 h-auto w-auto p-0 ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-700'}`}
aria-label={showPassword2 ? "Hide password" : "Show password"}
>
{showPassword2 ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</Button>
</div>
{errors.password2 && (
<p className="text-sm text-red-500">{errors.password2}</p>
)}
</div>
{/* Submit Button */}
<Button
type="submit"
disabled={registerMutation.isPending}
className="w-full h-12 text-base font-semibold bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed mt-6"
>
{registerMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating account...
</>
) : (
"Sign up"
)}
</Button>
</form>
) : (
/* OTP Verification Form */
<form className="space-y-6" onSubmit={handleVerifyOtp}>
<div className={`p-4 rounded-lg border ${isDark ? 'bg-blue-900/20 border-blue-800' : 'bg-blue-50 border-blue-200'}`}>
<div className="flex items-start gap-3">
<CheckCircle2 className={`w-5 h-5 mt-0.5 ${isDark ? 'text-blue-400' : 'text-blue-600'}`} />
<div>
<p className={`text-sm font-medium ${isDark ? 'text-blue-200' : 'text-blue-900'}`}>
Check your email
</p>
<p className={`text-sm mt-1 ${isDark ? 'text-blue-300' : 'text-blue-700'}`}>
We've sent a 6-digit verification code to your email address.
</p>
</div>
</div>
</div>
{/* OTP Field */}
<div className="space-y-2">
<label className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Verification Code *
</label>
<div className="flex justify-center">
<InputOTP
maxLength={6}
value={otpData.otp}
onChange={(value) => handleOtpChange("otp", value)}
aria-invalid={!!errors.otp}
>
<InputOTPGroup>
<InputOTPSlot index={0} />
<InputOTPSlot index={1} />
<InputOTPSlot index={2} />
<InputOTPSlot index={3} />
<InputOTPSlot index={4} />
<InputOTPSlot index={5} />
</InputOTPGroup>
</InputOTP>
</div>
</div>
{/* Resend OTP */}
<div className="text-center">
<button
type="button"
onClick={handleResendOtp}
disabled={resendOtpMutation.isPending}
className={`text-sm font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{resendOtpMutation.isPending ? "Sending..." : "Didn't receive the code? Resend"}
</button>
</div>
{/* Submit Button */}
<Button
type="submit"
disabled={verifyOtpMutation.isPending}
className="w-full h-12 text-base font-semibold bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{verifyOtpMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Verifying...
</>
) : (
"Verify Email"
)}
</Button>
{/* Back to registration */}
<div className="text-center">
<button
type="button"
onClick={() => {
setStep("register");
setOtpData({ email: "", otp: "" });
}}
className={`text-sm font-medium ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-600 hover:text-gray-700'}`}
>
Back to registration
</button>
</div>
</form>
)}
</div>
</div>
);
}
export default function Signup() {
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="w-8 h-8 animate-spin text-rose-600" />
</div>
}>
<SignupContent />
</Suspense>
);
}

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useMemo, useCallback } from "react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { useAppTheme } from "@/components/ThemeProvider";
import { Input } from "@/components/ui/input";
@ -23,18 +23,11 @@ import {
CheckCircle2,
CheckCircle,
Loader2,
LogOut,
CalendarCheck,
} from "lucide-react";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { LoginDialog } from "@/components/LoginDialog";
import { SignupDialog } from "@/components/SignupDialog";
import { useAuth } from "@/hooks/useAuth";
import { useAppointments } from "@/hooks/useAppointments";
import { toast } from "sonner";
import type { Appointment } from "@/lib/models/appointments";
interface User {
ID: number;
@ -80,351 +73,149 @@ export default function BookNowPage() {
const router = useRouter();
const { theme } = useAppTheme();
const isDark = theme === "dark";
const { isAuthenticated, logout } = useAuth();
const {
create,
isCreating,
weeklyAvailability,
isLoadingWeeklyAvailability,
availabilityOverview,
isLoadingAvailabilityOverview,
availabilityConfig,
} = useAppointments();
const [formData, setFormData] = useState({
firstName: "",
lastName: "",
email: "",
phone: "",
selectedSlots: [] as Array<{ day: number; time_slot: string }>, // New format
preferredDays: [] as string[],
preferredTimes: [] as string[],
message: "",
});
const [loading, setLoading] = useState(false);
const [booking, setBooking] = useState<Booking | null>(null);
const [error, setError] = useState<string | null>(null);
const [showLoginDialog, setShowLoginDialog] = useState(false);
const [showSignupDialog, setShowSignupDialog] = useState(false);
const [loginPrefillEmail, setLoginPrefillEmail] = useState<string | undefined>(undefined);
// Helper function to convert day name to day number (0-6)
const getDayNumber = (dayName: string): number => {
const dayMap: Record<string, number> = {
'monday': 0,
'tuesday': 1,
'wednesday': 2,
'thursday': 3,
'friday': 4,
'saturday': 5,
'sunday': 6,
};
return dayMap[dayName.toLowerCase()] ?? -1;
};
// Get available days from availability overview (primary) or weekly availability (fallback)
const availableDaysOfWeek = useMemo(() => {
// Try availability overview first (preferred)
if (availabilityOverview && availabilityOverview.available && availabilityOverview.next_available_dates && availabilityOverview.next_available_dates.length > 0) {
// Group by day name and get unique days with their slots from next_available_dates
const dayMap = new Map<string, { day: number; dayName: string; availableSlots: Set<string> }>();
availabilityOverview.next_available_dates.forEach((dateInfo: any) => {
if (!dateInfo || !dateInfo.day_name) return;
const dayName = String(dateInfo.day_name).trim();
const dayNum = getDayNumber(dayName);
if (dayNum >= 0 && dayNum <= 6 && dateInfo.available_slots && Array.isArray(dateInfo.available_slots) && dateInfo.available_slots.length > 0) {
const existingDay = dayMap.get(dayName);
if (existingDay) {
// Merge slots if day already exists
dateInfo.available_slots.forEach((slot: string) => {
existingDay.availableSlots.add(String(slot).toLowerCase().trim());
});
} else {
// Create new day entry
const slotsSet = new Set<string>();
dateInfo.available_slots.forEach((slot: string) => {
slotsSet.add(String(slot).toLowerCase().trim());
});
dayMap.set(dayName, {
day: dayNum,
dayName: dayName,
availableSlots: slotsSet,
});
}
}
});
// Time slot order: morning, afternoon (lunchtime), evening
const timeSlotOrder: Record<string, number> = {
morning: 0,
afternoon: 1,
evening: 2,
};
// Convert Map values to array, sort slots, and sort by day number
return Array.from(dayMap.values())
.map(day => ({
day: day.day,
dayName: day.dayName,
availableSlots: Array.from(day.availableSlots).sort((a, b) => {
const aOrder = timeSlotOrder[a.toLowerCase().trim()] ?? 999;
const bOrder = timeSlotOrder[b.toLowerCase().trim()] ?? 999;
return aOrder - bOrder;
}),
}))
.sort((a, b) => a.day - b.day);
}
// Fallback to weekly availability
if (weeklyAvailability) {
// Handle both array format and object with 'week' property
const weekArray = Array.isArray(weeklyAvailability)
? weeklyAvailability
: (weeklyAvailability as any)?.week;
if (weekArray && Array.isArray(weekArray)) {
// Time slot order: morning, afternoon (lunchtime), evening
const timeSlotOrder: Record<string, number> = {
morning: 0,
afternoon: 1,
evening: 2,
};
return weekArray
.filter(day => {
const dayNum = Number(day.day);
return day.is_available &&
day.available_slots &&
Array.isArray(day.available_slots) &&
day.available_slots.length > 0 &&
!isNaN(dayNum) &&
dayNum >= 0 &&
dayNum <= 6;
})
.map(day => ({
day: Number(day.day),
dayName: day.day_name || 'Unknown',
availableSlots: (day.available_slots || []).sort((a: string, b: string) => {
const aOrder = timeSlotOrder[String(a).toLowerCase().trim()] ?? 999;
const bOrder = timeSlotOrder[String(b).toLowerCase().trim()] ?? 999;
return aOrder - bOrder;
}),
}))
.sort((a, b) => a.day - b.day);
}
}
return [];
}, [availabilityOverview, weeklyAvailability]);
const handleLogout = () => {
logout();
toast.success("Logged out successfully");
router.push("/");
};
// Handle submit button click
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Check if user is authenticated
if (!isAuthenticated) {
// Show alert and open login dialog if not authenticated
toast.error("Please log in or sign up to book an appointment");
setShowLoginDialog(true);
return;
}
// If authenticated, proceed with booking
await submitBooking();
// Open login dialog instead of submitting directly
setShowLoginDialog(true);
};
const handleLoginSuccess = async () => {
// Close login dialog
setShowLoginDialog(false);
// If there's a pending booking submission, proceed with it
// Otherwise, just close the dialog and allow user to fill the form
if (formData.selectedSlots.length > 0 && formData.firstName && formData.lastName && formData.email) {
await submitBooking();
}
};
const handleSignupSuccess = async () => {
// Close signup dialog
setShowSignupDialog(false);
// If there's a pending booking submission, proceed with it
// Otherwise, just close the dialog and allow user to fill the form
if (formData.selectedSlots.length > 0 && formData.firstName && formData.lastName && formData.email) {
await submitBooking();
}
};
const handleSwitchToSignup = () => {
// Close login dialog and open signup dialog
setShowLoginDialog(false);
setTimeout(() => {
setShowSignupDialog(true);
}, 100);
};
const handleSwitchToLogin = (email?: string) => {
// Close signup dialog and open login dialog with email prefilled
setShowSignupDialog(false);
setLoginPrefillEmail(email);
setTimeout(() => {
setShowLoginDialog(true);
}, 100);
// After successful login, proceed with booking submission
await submitBooking();
};
const submitBooking = async () => {
setLoading(true);
setError(null);
try {
// Get current slots from formData
const currentSlots = formData.selectedSlots || [];
if (formData.preferredDays.length === 0) {
setError("Please select at least one available day.");
setLoading(false);
return;
}
if (formData.preferredTimes.length === 0) {
setError("Please select at least one preferred time.");
setLoading(false);
return;
}
// For now, we'll use the first selected day and first selected time
// This can be adjusted based on your backend requirements
const firstDay = formData.preferredDays[0];
const firstTime = formData.preferredTimes[0];
const timeMap: { [key: string]: string } = {
morning: "09:00",
lunchtime: "12:00",
afternoon: "14:00",
};
const time24 = timeMap[firstTime] || "09:00";
// Check if slots are selected
if (!currentSlots || currentSlots.length === 0) {
setError("Please select at least one day and time slot combination by clicking on the time slot buttons.");
return;
}
// Prepare and validate slots - only send exactly what user selected
// Filter out duplicates and ensure we only send the specific selected slots
const uniqueSlots = new Map<string, { day: number; time_slot: string }>();
// Get next occurrence of the first selected day
const today = new Date();
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const targetDayIndex = days.indexOf(firstDay);
let daysUntilTarget = (targetDayIndex - today.getDay() + 7) % 7;
if (daysUntilTarget === 0) daysUntilTarget = 7; // Next week if today
const targetDate = new Date(today);
targetDate.setDate(today.getDate() + daysUntilTarget);
const dateString = targetDate.toISOString().split("T")[0];
currentSlots.forEach(slot => {
if (!slot) return;
// Get day - handle any format
let dayNum: number;
if (typeof slot.day === 'number') {
dayNum = slot.day;
} else {
dayNum = parseInt(String(slot.day || 0), 10);
}
// Validate day
if (isNaN(dayNum) || dayNum < 0 || dayNum > 6) {
return;
}
// Get time_slot - normalize
const timeSlot = String(slot.time_slot || '').trim().toLowerCase();
// Validate time_slot - accept morning, afternoon, evening
if (!timeSlot || !['morning', 'afternoon', 'evening'].includes(timeSlot)) {
return;
}
// Create unique key to prevent duplicates
const uniqueKey = `${dayNum}-${timeSlot}`;
uniqueSlots.set(uniqueKey, {
day: dayNum,
time_slot: timeSlot as "morning" | "afternoon" | "evening",
});
});
// Combine date and time into scheduled_at (ISO format)
const dateTimeString = `${dateString}T${time24}:00Z`;
// Convert map to array
const validSlots = Array.from(uniqueSlots.values()).map(slot => ({
day: slot.day,
time_slot: slot.time_slot as "morning" | "afternoon" | "evening",
}));
// Final validation check
if (!validSlots || validSlots.length === 0) {
setError("Please select at least one day and time slot combination by clicking on the time slot buttons.");
return;
}
// Validate and limit field lengths to prevent database errors
const firstName = formData.firstName.trim().substring(0, 100);
const lastName = formData.lastName.trim().substring(0, 100);
const email = formData.email.trim().toLowerCase().substring(0, 100);
const phone = formData.phone ? formData.phone.trim().substring(0, 100) : undefined;
const reason = formData.message ? formData.message.trim().substring(0, 100) : undefined;
// Validate required fields
if (!firstName || firstName.length === 0) {
setError("First name is required.");
return;
}
if (!lastName || lastName.length === 0) {
setError("Last name is required.");
return;
}
if (!email || email.length === 0) {
setError("Email address is required.");
return;
}
if (!email.includes('@')) {
setError("Please enter a valid email address.");
return;
}
// Prepare payload with validated and limited fields
// CRITICAL: Only send exactly what the user selected, nothing more
const selectedSlotsPayload = validSlots.map(slot => ({
day: Number(slot.day), // Ensure it's a number (0-6)
time_slot: String(slot.time_slot).toLowerCase().trim() as "morning" | "afternoon" | "evening", // Ensure lowercase and correct type
}));
// Build payload with ONLY the fields the API requires/accepts
// API required: first_name, last_name, email, selected_slots
// API optional: phone, reason
// DO NOT include: preferred_dates, preferred_time_slots (not needed)
// Prepare request payload
const payload = {
first_name: firstName,
last_name: lastName,
email: email,
selected_slots: selectedSlotsPayload, // Only send what user explicitly selected (day + time_slot format)
...(phone && phone.length > 0 && { phone: phone }),
...(reason && reason.length > 0 && { reason: reason }),
first_name: formData.firstName,
last_name: formData.lastName,
email: formData.email,
phone: formData.phone,
scheduled_at: dateTimeString,
duration: 60, // Default to 60 minutes
preferred_days: formData.preferredDays,
preferred_times: formData.preferredTimes,
notes: formData.message || "",
};
// Call the actual API using the hook
const appointmentData = await create(payload);
// Convert API response to Booking format for display
// Use a stable ID - if appointmentData.id exists, use it, otherwise use 0
const appointmentId = appointmentData.id ? parseInt(appointmentData.id, 10) : 0;
const now = new Date().toISOString();
const bookingData: Booking = {
ID: appointmentId || 0,
CreatedAt: appointmentData.created_at || now,
UpdatedAt: appointmentData.updated_at || now,
DeletedAt: null,
user_id: 0, // API doesn't return user_id in this response
user: {
ID: 0,
first_name: appointmentData.first_name,
last_name: appointmentData.last_name,
email: appointmentData.email,
phone: appointmentData.phone || "",
location: "",
is_admin: false,
bookings: null,
// Simulate API call - Replace with actual API endpoint
const response = await fetch("/api/bookings", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
scheduled_at: appointmentData.scheduled_datetime || "",
duration: appointmentData.scheduled_duration || 60,
status: appointmentData.status || "pending_review",
jitsi_room_id: appointmentData.jitsi_room_id || "",
jitsi_room_url: appointmentData.jitsi_meet_url || "",
payment_id: "",
payment_status: "pending",
amount: 0,
notes: appointmentData.reason || "",
};
body: JSON.stringify(payload),
}).catch(() => {
// Fallback to mock data if API is not available
return null;
});
let bookingData: Booking;
if (response && response.ok) {
const data: BookingsResponse = await response.json();
bookingData = data.bookings[0];
} else {
// Mock response for development - matches the API structure provided
await new Promise((resolve) => setTimeout(resolve, 1000));
bookingData = {
ID: Math.floor(Math.random() * 1000),
CreatedAt: new Date().toISOString(),
UpdatedAt: new Date().toISOString(),
DeletedAt: null,
user_id: 1,
user: {
ID: 1,
CreatedAt: new Date().toISOString(),
UpdatedAt: new Date().toISOString(),
DeletedAt: null,
first_name: formData.firstName,
last_name: formData.lastName,
email: formData.email,
phone: formData.phone,
location: "",
date_of_birth: "0001-01-01T00:00:00Z",
is_admin: false,
bookings: null,
},
scheduled_at: dateTimeString,
duration: 60,
status: "scheduled",
jitsi_room_id: `booking-${Math.floor(Math.random() * 1000)}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
jitsi_room_url: `https://meet.jit.si/booking-${Math.floor(Math.random() * 1000)}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
payment_id: "",
payment_status: "pending",
amount: 52,
notes: formData.message || "Initial consultation session",
};
}
setBooking(bookingData);
toast.success("Appointment request submitted successfully! We'll review and get back to you soon.");
setLoading(false);
// Stay on the booking page to show the receipt - no redirect
// Redirect to home after 2 seconds
setTimeout(() => {
router.push("/");
}, 2000);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to submit booking. Please try again.";
setError(errorMessage);
toast.error(errorMessage);
setError("Failed to submit booking. Please try again.");
setLoading(false);
console.error("Booking error:", err);
}
};
@ -432,63 +223,22 @@ export default function BookNowPage() {
setFormData((prev) => ({ ...prev, [field]: value }));
};
// Handle slot selection (day + time slot combination)
// CRITICAL: Only toggle the specific slot that was clicked, nothing else
const handleSlotToggle = useCallback((day: number, timeSlot: string) => {
const normalizedDay = Number(day);
const normalizedTimeSlot = String(timeSlot).toLowerCase().trim();
// Validate inputs
if (isNaN(normalizedDay) || normalizedDay < 0 || normalizedDay > 6) {
return; // Invalid day, don't change anything
}
if (!['morning', 'afternoon', 'evening'].includes(normalizedTimeSlot)) {
return; // Invalid time slot, don't change anything
}
setFormData((prev) => {
const currentSlots = prev.selectedSlots || [];
// Helper to check if two slots match EXACTLY (both day AND time_slot)
const slotsMatch = (slot1: { day: number; time_slot: string }, slot2: { day: number; time_slot: string }) => {
return Number(slot1.day) === Number(slot2.day) &&
String(slot1.time_slot).toLowerCase().trim() === String(slot2.time_slot).toLowerCase().trim();
};
const targetSlot = { day: normalizedDay, time_slot: normalizedTimeSlot };
// Check if this EXACT slot exists (check for duplicates too)
const existingIndex = currentSlots.findIndex(slot => slotsMatch(slot, targetSlot));
if (existingIndex >= 0) {
// Remove ONLY this specific slot (also removes duplicates)
const newSlots = currentSlots.filter(slot => !slotsMatch(slot, targetSlot));
return {
...prev,
selectedSlots: newSlots,
};
} else {
// Add ONLY this specific slot if it doesn't exist (prevent duplicates)
const newSlots = [...currentSlots, targetSlot];
return {
...prev,
selectedSlots: newSlots,
};
}
const handleDayToggle = (day: string) => {
setFormData((prev) => {
const days = prev.preferredDays.includes(day)
? prev.preferredDays.filter((d) => d !== day)
: [...prev.preferredDays, day];
return { ...prev, preferredDays: days };
});
}, []);
};
// Check if a slot is selected
const isSlotSelected = (day: number, timeSlot: string): boolean => {
const normalizedDay = Number(day);
const normalizedTimeSlot = String(timeSlot).toLowerCase().trim();
return (formData.selectedSlots || []).some(
slot => Number(slot.day) === normalizedDay &&
String(slot.time_slot).toLowerCase().trim() === normalizedTimeSlot
);
const handleTimeToggle = (time: string) => {
setFormData((prev) => {
const times = prev.preferredTimes.includes(time)
? prev.preferredTimes.filter((t) => t !== time)
: [...prev.preferredTimes, time];
return { ...prev, preferredTimes: times };
});
};
const formatDateTime = (dateString: string) => {
@ -602,51 +352,77 @@ export default function BookNowPage() {
<div className="px-4 sm:px-6 lg:px-12 pb-6 sm:pb-8 lg:pb-12">
{booking ? (
<div className={`rounded-xl sm:rounded-2xl shadow-lg p-4 sm:p-6 lg:p-8 border ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}>
<div className="text-center space-y-6">
<div className="text-center space-y-4">
<div className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center ${isDark ? 'bg-green-900/30' : 'bg-green-100'}`}>
<CheckCircle className={`w-8 h-8 ${isDark ? 'text-green-400' : 'text-green-600'}`} />
</div>
<div>
<h2 className={`text-2xl font-semibold mb-2 ${isDark ? 'text-white' : 'text-gray-900'}`}>
Booking Request Submitted!
Booking Confirmed!
</h2>
<p className={`text-base ${isDark ? 'text-gray-300' : 'text-gray-600'}`}>
Your appointment request has been received.
<p className={isDark ? 'text-gray-300' : 'text-gray-600'}>
Your appointment has been successfully booked.
</p>
</div>
<div className={`rounded-lg p-6 space-y-4 text-left ${isDark ? 'bg-gray-700/50' : 'bg-gray-50'}`}>
<div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Name</p>
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Booking ID</p>
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>#{booking.ID}</p>
</div>
<div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Patient</p>
<p className={`text-base ${isDark ? 'text-white' : 'text-gray-900'}`}>
{booking.user.first_name} {booking.user.last_name}
</p>
</div>
<div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Email</p>
<p className={`text-base ${isDark ? 'text-white' : 'text-gray-900'}`}>
{booking.user.email}
</p>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Scheduled Time</p>
<p className={`text-base ${isDark ? 'text-white' : 'text-gray-900'}`}>{formatDateTime(booking.scheduled_at)}</p>
</div>
{booking.user.phone && (
<div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Phone</p>
<p className={`text-base ${isDark ? 'text-white' : 'text-gray-900'}`}>
{booking.user.phone}
</p>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Duration</p>
<p className={`text-base ${isDark ? 'text-white' : 'text-gray-900'}`}>{booking.duration} minutes</p>
</div>
<div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Status</p>
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${isDark ? 'bg-blue-900/50 text-blue-200' : 'bg-blue-100 text-blue-800'}`}>
{booking.status}
</span>
</div>
<div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Amount</p>
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>${booking.amount}</p>
</div>
{booking.notes && (
<div>
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Notes</p>
<p className={`text-base ${isDark ? 'text-white' : 'text-gray-900'}`}>{booking.notes}</p>
</div>
)}
</div>
<div className={`rounded-lg p-4 ${isDark ? 'bg-blue-900/20 border border-blue-800/50' : 'bg-blue-50 border border-blue-200'}`}>
<p className={`text-sm ${isDark ? 'text-blue-300' : 'text-blue-800'}`}>
You will be contacted shortly to confirm your appointment.
</p>
</div>
<div className="pt-4 flex justify-center">
<div className="pt-4 flex flex-col sm:flex-row gap-3 justify-center">
<Button
onClick={() => router.back()}
onClick={() => {
setBooking(null);
setFormData({
firstName: "",
lastName: "",
email: "",
phone: "",
preferredDays: [],
preferredTimes: [],
message: "",
});
}}
variant="outline"
>
Book Another Appointment
</Button>
<Button
onClick={() => router.push("/")}
className="bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white"
>
Go Back
Return to Home
</Button>
</div>
</div>
@ -683,7 +459,6 @@ export default function BookNowPage() {
onChange={(e) =>
handleChange("firstName", e.target.value)
}
maxLength={100}
required
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900 placeholder:text-gray-500'}`}
/>
@ -704,7 +479,6 @@ export default function BookNowPage() {
onChange={(e) =>
handleChange("lastName", e.target.value)
}
maxLength={100}
required
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900 placeholder:text-gray-500'}`}
/>
@ -725,7 +499,6 @@ export default function BookNowPage() {
placeholder="john.doe@example.com"
value={formData.email}
onChange={(e) => handleChange("email", e.target.value)}
maxLength={100}
required
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900 placeholder:text-gray-500'}`}
/>
@ -745,7 +518,6 @@ export default function BookNowPage() {
placeholder="+1 (555) 123-4567"
value={formData.phone}
onChange={(e) => handleChange("phone", e.target.value)}
maxLength={100}
required
className={`h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900 placeholder:text-gray-500'}`}
/>
@ -759,95 +531,75 @@ export default function BookNowPage() {
Appointment Details
</h2>
<div className="space-y-4">
<div className="space-y-4">
<div className="space-y-2">
<label
className={`text-sm font-medium flex items-center gap-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}
>
<Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-500'}`} />
Available Days & Times *
Available Days *
</label>
{(isLoadingWeeklyAvailability || isLoadingAvailabilityOverview) ? (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Loader2 className="w-4 h-4 animate-spin" />
Loading availability...
</div>
) : availableDaysOfWeek.length === 0 ? (
<p className={`text-sm ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
No available days at the moment. Please check back later.
</p>
) : (
<>
<p className={`text-xs ${isDark ? 'text-gray-400' : 'text-gray-500'} mb-3`}>
Select one or more day-time combinations that work for you. You can select multiple time slots for the same day (e.g., Monday Morning and Monday Evening).
</p>
<div className="space-y-4">
{availableDaysOfWeek.map((dayInfo, dayIndex) => {
// Ensure day is always a valid number (already validated in useMemo)
const currentDay = typeof dayInfo.day === 'number' && !isNaN(dayInfo.day)
? dayInfo.day
: dayIndex; // Fallback to index if invalid
// Skip if day is still invalid
if (isNaN(currentDay) || currentDay < 0 || currentDay > 6) {
return null;
}
return (
<div key={`day-wrapper-${currentDay}-${dayIndex}`} className="space-y-2">
<h4 className={`text-sm font-semibold ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
{dayInfo.dayName || `Day ${currentDay}`}
</h4>
<div className="flex flex-wrap gap-2">
{dayInfo.availableSlots.map((timeSlot: string, slotIndex: number) => {
if (!timeSlot) return null;
const timeSlotLabels: Record<string, string> = {
morning: "Morning",
afternoon: "Lunchtime",
evening: "Evening",
};
// Normalize time slot for consistent comparison
const normalizedTimeSlot = String(timeSlot).toLowerCase().trim();
// Create unique key combining day, time slot, and index to ensure uniqueness
const slotKey = `day-${currentDay}-slot-${normalizedTimeSlot}-${slotIndex}`;
// Check if THIS specific day-time combination is selected
const isSelected = isSlotSelected(currentDay, normalizedTimeSlot);
return (
<button
key={slotKey}
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
// Pass the specific day and time slot for this button
handleSlotToggle(currentDay, normalizedTimeSlot);
}}
aria-pressed={isSelected}
className={`flex items-center gap-2 cursor-pointer px-4 py-2 rounded-lg border-2 transition-all focus:outline-none focus:ring-2 focus:ring-rose-500 ${
isSelected
<div className="flex flex-wrap gap-3">
{['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'].map((day) => (
<label
key={day}
className={`flex items-center gap-2 cursor-pointer px-4 py-2 rounded-lg border transition-all ${
formData.preferredDays.includes(day)
? isDark
? 'bg-rose-600 border-rose-500 text-white hover:bg-rose-700'
: 'bg-rose-500 border-rose-500 text-white hover:bg-rose-600'
? 'bg-rose-600 border-rose-500 text-white'
: 'bg-rose-500 border-rose-500 text-white'
: isDark
? 'bg-gray-700 border-gray-600 text-gray-300 hover:border-rose-500 hover:bg-gray-650'
: 'bg-white border-gray-300 text-gray-700 hover:border-rose-500 hover:bg-rose-50'
}`}
>
<span className="text-sm font-medium">
{timeSlotLabels[normalizedTimeSlot] || timeSlot}
</span>
</button>
);
})}
? 'bg-gray-700 border-gray-600 text-gray-300 hover:border-rose-500'
: 'bg-white border-gray-300 text-gray-700 hover:border-rose-500'
}`}
>
<input
type="checkbox"
checked={formData.preferredDays.includes(day)}
onChange={() => handleDayToggle(day)}
className="sr-only"
/>
<span className="text-sm font-medium">{day}</span>
</label>
))}
</div>
</div>
);
})}
</div>
</>
)}
<div className="space-y-2">
<label
className={`text-sm font-medium flex items-center gap-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}
>
<Clock className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-500'}`} />
Preferred Time *
</label>
<div className="flex flex-wrap gap-3">
{[
{ value: 'morning', label: 'Morning' },
{ value: 'lunchtime', label: 'Lunchtime' },
{ value: 'afternoon', label: 'Afternoon' }
].map((time) => (
<label
key={time.value}
className={`flex items-center gap-2 cursor-pointer px-4 py-2 rounded-lg border transition-all ${
formData.preferredTimes.includes(time.value)
? isDark
? 'bg-rose-600 border-rose-500 text-white'
: 'bg-rose-500 border-rose-500 text-white'
: isDark
? 'bg-gray-700 border-gray-600 text-gray-300 hover:border-rose-500'
: 'bg-white border-gray-300 text-gray-700 hover:border-rose-500'
}`}
>
<input
type="checkbox"
checked={formData.preferredTimes.includes(time.value)}
onChange={() => handleTimeToggle(time.value)}
className="sr-only"
/>
<span className="text-sm font-medium">{time.label}</span>
</label>
))}
</div>
</div>
</div>
</div>
@ -867,7 +619,6 @@ export default function BookNowPage() {
placeholder="Tell us about any specific concerns or preferences..."
value={formData.message}
onChange={(e) => handleChange("message", e.target.value)}
maxLength={100}
className={`w-full rounded-md border px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-rose-500 focus-visible:border-rose-500 disabled:cursor-not-allowed disabled:opacity-50 ${isDark ? 'border-gray-600 bg-gray-700 text-white placeholder:text-gray-400 focus-visible:ring-rose-400 focus-visible:border-rose-400' : 'border-gray-300 bg-white text-gray-900 placeholder:text-gray-500'}`}
/>
</div>
@ -877,16 +628,16 @@ export default function BookNowPage() {
<Button
type="submit"
size="lg"
disabled={isCreating || availableDaysOfWeek.length === 0 || formData.selectedSlots.length === 0}
disabled={loading}
className="w-full bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all h-12 text-base font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
>
{isCreating ? (
{loading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Submitting...
</>
) : (
"Request Appointment"
"Submit Booking Request"
)}
</Button>
<p className={`text-xs text-center mt-4 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>
@ -897,19 +648,18 @@ export default function BookNowPage() {
</form>
</div>
{/* Logout Button - Only show when authenticated */}
{isAuthenticated && (
<div className="mt-6 flex justify-center">
<Button
onClick={handleLogout}
variant="outline"
className="bg-red-600 hover:bg-red-700 text-white border-red-600 hover:border-red-700"
{/* Contact Information */}
<div className="mt-6 text-center">
<p className={isDark ? 'text-gray-300' : 'text-gray-600'}>
Prefer to book by phone?{" "}
<a
href="tel:+19548073027"
className={`font-medium underline ${isDark ? 'text-rose-400 hover:text-rose-300' : 'text-rose-600 hover:text-rose-700'}`}
>
<LogOut className="w-4 h-4 mr-2" />
Logout
</Button>
</div>
)}
Call us at (954) 807-3027
</a>
</p>
</div>
</>
)}
</div>
@ -921,24 +671,8 @@ export default function BookNowPage() {
{/* Login Dialog */}
<LoginDialog
open={showLoginDialog}
onOpenChange={(open) => {
setShowLoginDialog(open);
if (!open) {
setLoginPrefillEmail(undefined);
}
}}
onOpenChange={setShowLoginDialog}
onLoginSuccess={handleLoginSuccess}
onSwitchToSignup={handleSwitchToSignup}
prefillEmail={loginPrefillEmail}
skipRedirect={true}
/>
{/* Signup Dialog */}
<SignupDialog
open={showSignupDialog}
onOpenChange={setShowSignupDialog}
onSignupSuccess={handleSignupSuccess}
onSwitchToLogin={handleSwitchToLogin}
/>
</div>
);

View File

@ -1,369 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Heart } from "lucide-react";
import React from "react";
import ReactMarkdown, { Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { useRouter } from "next/navigation";
const ReadmePage = () => {
const router = useRouter();
const readmeContent = `
## Attune Heart Therapy
Welcome to your Attune Heart Therapy platform! This documentation provides everything you need to understand and navigate the complete system, including the landing page, booking system, user/client dashboard, and admin dashboard.
---
## 📂 What's Included
Your Attune Heart Therapy platform includes a comprehensive system for managing therapy appointments and client interactions:
| Section | Description |
| --------------------- | ------------------------------------------------------------------------------------------------------- |
| Landing Page | Public-facing homepage with navigation, services overview, and booking access |
| Booking System | User-friendly appointment booking flow where clients can request therapy sessions |
| User Dashboard | Client portal to view appointments, manage profile, and track booking status |
| Admin Dashboard | Administrative interface to manage appointments, view statistics, and schedule sessions |
---
## 🔐 Admin Dashboard Access
### Step 1: Navigate to Login
1. Go to your website's homepage
2. Click on the **"Admin Panel"** link in the footer (under Quick Links)
3. Or navigate directly to: \`https://attunehearttherapy.com/login\`
### Step 2: Login Credentials
**Email Address:** \`admin@attunehearttherapy.com\`
### Step 3: Access Dashboard
1. Enter your admin email address
2. Enter your password
3. Click **"Sign In"**
4. You will be automatically redirected to the Admin Dashboard
---
## 🎥 Telehealth Sessions Guide
This section provides step-by-step guidance on how to access and manage telehealth therapy sessions through the Admin Dashboard of the Attune Heart Therapy platform.
### Accessing the Admin Dashboard
**Email Notification:** When a user requests an appointment, you (the admin) will receive an email notification. The image below shows an example of this email notification.
![Admin Dashboard Access - Email Notification for New Appointment Request](/ss2.png)
1. Navigate to: \`https://attunehearttherapy.com/login\`
2. Enter your admin email address: \`admin@attunehearttherapy.com\`
3. Enter your password
4. Click **"Sign In"**
5. You will be automatically redirected to the **Admin Dashboard**
6. Click on **"Bookings"** in the navigation menu to view all appointments
### Viewing Appointment Details
1. From the Bookings page, click on any appointment
2. View complete client information, preferred dates, and time slots
3. Check appointment status and review all details
### Scheduling an Appointment
1. From the appointment details page, click **"Schedule Appointment"**
2. Select the date and time for the session
3. Choose the duration (15, 30, 45, 60, or 120 minutes)
4. Click **"Schedule"** to confirm
5. The system will automatically create a Jitsi video meeting room and send a confirmation email to the client
**Confirmation Email:** After you schedule an appointment, the client receives a confirmation email with their appointment details. The image below shows an example of the confirmation email that clients receive.
![Scheduling Appointment - Client Confirmation Email](/ss1.png)
### Joining a Video Meeting
1. Meetings become available **10 minutes before** the scheduled start time
2. Navigate to any scheduled appointment's details page
3. In the sidebar, find the **"Join Meeting"** button
4. Click **"Join Meeting"** when it becomes active (10 minutes before scheduled time)
### 🔗 Quick Access Links
[Visit Attune Heart Therapy](https://attunehearttherapy.com/) - Official website
[Access Admin Dashboard](https://attunehearttherapy.com/login) - Login to manage your practice
[Book an Appointment](https://attunehearttherapy.com/book-now) - Client booking page
---
## 📞 Support & Contact
For technical assistance, questions, or issues:
**Email:** [info@BlackBusinessLabs.com](mailto:info@BlackBusinessLabs.com)
**Phone:** [(646) 895-4856](tel:+16468954856) - *CEO Tray Bailey's direct mobile*
---
*For questions or additional support, please contact Black Business Labs at the information provided above.*`;
const components: Components = {
h1: ({ node, ...props }) => (
<h1
style={{
fontSize: "2.2em",
fontWeight: "600",
marginTop: "1.2em",
marginBottom: "0.6em",
borderBottom: "1px solid #eaeaea",
paddingBottom: "0.3em",
}}
{...props}
/>
),
h2: ({ node, children, ...props }) => {
// Extract text content from children
const extractText = (child: any): string => {
if (typeof child === 'string') return child;
if (typeof child === 'number') return String(child);
if (React.isValidElement(child)) {
const childProps = child.props as any;
if (childProps?.children) {
return React.Children.toArray(childProps.children).map(extractText).join('');
}
}
return '';
};
const textContent = React.Children.toArray(children).map(extractText).join('');
// Check if this is the title heading
if (textContent.includes('Attune Heart Therapy - System Overview')) {
return (
<h2
style={{
fontSize: "1.8em",
fontWeight: "600",
marginTop: "1.2em",
marginBottom: "0.6em",
borderBottom: "1px solid #eaeaea",
paddingBottom: "0.3em",
display: "flex",
alignItems: "center",
gap: "0.5em",
}}
{...props}
>
<span>{children}</span>
</h2>
);
}
return (
<h2
style={{
fontSize: "1.8em",
fontWeight: "600",
marginTop: "1.2em",
marginBottom: "0.6em",
borderBottom: "1px solid #eaeaea",
paddingBottom: "0.3em",
}}
{...props}
>
{children}
</h2>
);
},
h3: ({ node, ...props }) => (
<h3
style={{
fontSize: "1.5em",
fontWeight: "600",
marginTop: "1.2em",
marginBottom: "0.6em",
}}
{...props}
/>
),
p: ({ node, ...props }) => (
<p style={{ marginBottom: "1.2em", lineHeight: "1.8" }} {...props} />
),
a: ({ node, ...props }) => (
<a
style={{ color: "#0366d6", textDecoration: "none", fontWeight: "500" }}
{...props}
/>
),
ul: ({ node, ...props }) => (
<ul
style={{
paddingLeft: "1.5em",
marginBottom: "1.2em",
listStyleType: "disc",
}}
{...props}
/>
),
ol: ({ node, ...props }) => (
<ol
style={{
paddingLeft: "1.5em",
marginBottom: "1.2em",
listStyleType: "decimal",
}}
{...props}
/>
),
li: ({ node, ...props }) => (
<li style={{ marginBottom: "0.4em" }} {...props} />
),
table: ({ node, ...props }) => (
<table
style={{
width: "100%",
borderCollapse: "collapse",
marginBottom: "1.2em",
boxShadow: "0 1px 3px rgba(0,0,0,0.08)",
border: "1px solid #dfe2e5",
}}
{...props}
/>
),
th: ({ node, ...props }) => (
<th
style={{
border: "1px solid #dfe2e5",
padding: "0.6em 0.8em",
textAlign: "left",
backgroundColor: "#f6f8fa",
fontWeight: "600",
}}
{...props}
/>
),
td: ({ node, ...props }) => (
<td
style={{
border: "1px solid #dfe2e5",
padding: "0.6em 0.8em",
textAlign: "left",
}}
{...props}
/>
),
pre: ({ node, children, ...props }) => (
<pre
style={{
backgroundColor: "#f6f8fa",
padding: "1em",
borderRadius: "6px",
overflowX: "auto",
fontSize: "0.9em",
lineHeight: "1.5",
}}
{...props}
>
{children}
</pre>
),
code: (props) => {
// Using `props: any` and casting to bypass TypeScript error with `inline` prop.
const {
node,
inline: isInline,
className,
children,
// Destructure known non-HTML props from react-markdown to prevent them from being spread onto the <code> tag
index,
siblingCount,
ordered,
checked,
style: _style, // if style is passed in props, avoid conflict with style object below
...htmlProps // Spread remaining props, assuming they are valid HTML attributes for <code>
} = props as any;
const codeStyleBase = {
fontFamily:
'SFMono-Regular, Consolas, "Liberation Mono", Menlo, Courier, monospace',
};
if (isInline) {
return (
<code
className={className}
style={{
...codeStyleBase,
backgroundColor: "rgba(27,31,35,0.07)", // Slightly adjusted for better visibility
padding: "0.2em 0.4em",
margin: "0 0.1em",
fontSize: "85%",
borderRadius: "3px",
}}
{...htmlProps}
>
{children}
</code>
);
}
// For block code (inside <pre>)
return (
<code
className={className} // className might contain "language-js" etc.
style={{
...codeStyleBase,
// Most styling for block code is handled by the <pre> wrapper
// However, ensure no extra padding/margin if pre handles it
padding: 0,
backgroundColor: "transparent", // Pre has the background
}}
{...htmlProps}
>
{children}
</code>
);
},
img: ({ node, ...props }: any) => (
<img
{...props}
style={{
maxWidth: "100%",
height: "auto",
borderRadius: "8px",
marginTop: "1em",
marginBottom: "1em",
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
}}
alt={props.alt || "Guide screenshot"}
/>
),
};
return (
<div className="container mx-auto px-4 py-8 max-w-4xl">
<div className="bg-white dark:bg-gray-900 p-8 rounded-lg shadow-md">
<Button
className="bg-gray-100 hover:bg-gray-50 dark:bg-gray-800 dark:hover:bg-gray-700 shadow-md text-black dark:text-white mb-6"
onClick={() => router.back()}
>
<ArrowLeft className="mr-2" />
</Button>
<ReactMarkdown components={components} remarkPlugins={[remarkGfm]}>
{readmeContent}
</ReactMarkdown>
</div>
</div>
);
};
export default ReadmePage;

View File

@ -1,72 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import Image from "next/image";
export default function SwotAnalysisPage() {
const [isClient, setIsClient] = useState(false);
// This ensures the PDF viewer only renders on the client side
useEffect(() => {
setIsClient(true);
}, []);
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-6">
<Link href="/docs">
<Button variant="outline" className="mb-4">
Back to Documentation
</Button>
</Link>
<h1 className="text-3xl font-bold mb-2">WODEY SWOT Analysis</h1>
<p className="text-gray-600 mb-6">
An in-depth analysis of Strengths, Weaknesses, Opportunities, and
Threats for the WODEY platform.
</p>
</div>
<div className="flex justify-end mb-4">
<a
href="/docs/SWOT-Analysis.pdf"
download="WODEY-SWOT-Analysis.pdf"
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded shadow-md"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
Download PDF
</a>
</div>
{isClient ? (
<div className="w-full h-[calc(100vh-200px)] rounded-lg overflow-hidden">
<Image
src="/WODEY-SWOT-Analysis.jpg"
alt="SWOT Analysis"
width={600}
height={600}
className="w-full h-full object-contain"
/>
</div>
) : (
<div className="flex items-center justify-center w-full h-[calc(100vh-200px)] bg-gray-100 rounded-lg">
Loading viewer...
</div>
)}
</div>
);
}

View File

@ -1,620 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import {
Calendar,
Clock,
User,
Video,
CalendarCheck,
Loader2,
ArrowLeft,
Mail,
Phone as PhoneIcon,
MessageSquare,
CheckCircle2,
Copy,
} from "lucide-react";
import { useAppTheme } from "@/components/ThemeProvider";
import { getAppointmentDetail, listAppointments } from "@/lib/actions/appointments";
import { Button } from "@/components/ui/button";
import { Navbar } from "@/components/Navbar";
import { toast } from "sonner";
import type { Appointment } from "@/lib/models/appointments";
export default function UserAppointmentDetailPage() {
const params = useParams();
const router = useRouter();
const appointmentId = params.id as string;
const [appointment, setAppointment] = useState<Appointment | null>(null);
const [loading, setLoading] = useState(true);
const { theme } = useAppTheme();
const isDark = theme === "dark";
useEffect(() => {
const fetchAppointment = async () => {
if (!appointmentId) return;
setLoading(true);
try {
// Fetch both detail and list to get selected_slots from list endpoint
const [detailData, listData] = await Promise.all([
getAppointmentDetail(appointmentId),
listAppointments().catch(() => []) // Fallback to empty array if list fails
]);
// Find matching appointment in list to get selected_slots
const listAppointment = Array.isArray(listData)
? listData.find((apt: Appointment) => apt.id === appointmentId)
: null;
// Merge selected_slots from list into detail data
if (listAppointment && listAppointment.selected_slots && Array.isArray(listAppointment.selected_slots) && listAppointment.selected_slots.length > 0) {
detailData.selected_slots = listAppointment.selected_slots;
}
setAppointment(detailData);
} catch (error) {
toast.error("Failed to load appointment details");
router.push("/user/dashboard");
} finally {
setLoading(false);
}
};
fetchAppointment();
}, [appointmentId, router]);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
};
const formatTime = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
};
const formatShortDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
const getStatusColor = (status: string) => {
const normalized = status.toLowerCase();
if (isDark) {
switch (normalized) {
case "scheduled":
return "bg-blue-500/20 text-blue-300 border-blue-500/30";
case "completed":
return "bg-green-500/20 text-green-300 border-green-500/30";
case "rejected":
case "cancelled":
return "bg-red-500/20 text-red-300 border-red-500/30";
case "pending_review":
case "pending":
return "bg-yellow-500/20 text-yellow-300 border-yellow-500/30";
default:
return "bg-gray-700 text-gray-200 border-gray-600";
}
}
switch (normalized) {
case "scheduled":
return "bg-blue-50 text-blue-700 border-blue-200";
case "completed":
return "bg-green-50 text-green-700 border-green-200";
case "rejected":
case "cancelled":
return "bg-red-50 text-red-700 border-red-200";
case "pending_review":
case "pending":
return "bg-yellow-50 text-yellow-700 border-yellow-200";
default:
return "bg-gray-100 text-gray-700 border-gray-300";
}
};
const formatStatus = (status: string) => {
return status.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase());
};
const copyToClipboard = (text: string, label: string) => {
navigator.clipboard.writeText(text);
toast.success(`${label} copied to clipboard`);
};
if (loading) {
return (
<div className={`min-h-screen ${isDark ? "bg-gray-900" : "bg-gray-50"}`}>
<Navbar />
<div className="min-h-[calc(100vh-4rem)] flex items-center justify-center">
<div className="text-center">
<Loader2 className={`w-12 h-12 animate-spin mx-auto mb-4 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
<p className={`text-sm ${isDark ? "text-gray-400" : "text-gray-600"}`}>Loading appointment details...</p>
</div>
</div>
</div>
);
}
if (!appointment) {
return (
<div className={`min-h-screen ${isDark ? "bg-gray-900" : "bg-gray-50"}`}>
<Navbar />
<div className="min-h-[calc(100vh-4rem)] flex items-center justify-center">
<div className="text-center">
<p className={`text-lg mb-4 ${isDark ? "text-gray-400" : "text-gray-600"}`}>Appointment not found</p>
<Button
onClick={() => router.push("/user/dashboard")}
className="bg-rose-600 hover:bg-rose-700 text-white"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Dashboard
</Button>
</div>
</div>
</div>
);
}
return (
<div className={`min-h-screen ${isDark ? "bg-gray-900" : "bg-gray-50"}`}>
<Navbar />
<main className="container mx-auto px-4 sm:px-6 lg:px-8 space-y-6 pt-20 sm:pt-24 pb-8">
{/* Page Header */}
<div className="flex flex-col gap-3 sm:gap-4">
<Button
variant="ghost"
onClick={() => router.push("/user/dashboard")}
className={`flex items-center gap-2 w-fit ${isDark ? "text-gray-300 hover:bg-gray-800 hover:text-white" : "text-gray-600 hover:bg-gray-100"}`}
>
<ArrowLeft className="w-4 h-4" />
Back to Dashboard
</Button>
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div>
<div className="flex items-center gap-3 mb-2">
<div className={`h-12 w-12 sm:h-16 sm:w-16 rounded-full flex items-center justify-center text-xl sm:text-2xl font-bold ${isDark ? "bg-gradient-to-br from-rose-500 to-pink-600 text-white" : "bg-gradient-to-br from-rose-100 to-pink-100 text-rose-600"}`}>
<CalendarCheck className="w-6 h-6 sm:w-8 sm:h-8" />
</div>
<div>
<h1 className={`text-2xl sm:text-3xl lg:text-4xl font-bold ${isDark ? "text-white" : "text-gray-900"}`}>
Appointment Details
</h1>
<p className={`text-xs sm:text-sm mt-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Request #{appointment.id.slice(0, 8)}
</p>
</div>
</div>
</div>
<div className="flex items-center gap-3">
<span
className={`px-3 sm:px-4 py-2 inline-flex items-center gap-2 text-xs sm:text-sm font-semibold rounded-full border ${getStatusColor(
appointment.status
)}`}
>
{appointment.status === "scheduled" && <CheckCircle2 className="w-3 h-3 sm:w-4 sm:h-4" />}
{formatStatus(appointment.status)}
</span>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content - Left Column (2/3) */}
<div className="lg:col-span-2 space-y-6">
{/* Appointment Information Card */}
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
<User className={`w-5 h-5 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
Appointment Information
</h2>
</div>
<div className="p-6">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="space-y-1">
<p className={`text-xs font-medium uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Full Name
</p>
<p className={`text-base font-medium ${isDark ? "text-white" : "text-gray-900"}`}>
{appointment.first_name} {appointment.last_name}
</p>
</div>
<div className="space-y-1">
<p className={`text-xs font-medium uppercase tracking-wider flex items-center gap-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
<Mail className="w-3 h-3" />
Email Address
</p>
<div className="flex items-center gap-2">
<p className={`text-base font-medium ${isDark ? "text-white" : "text-gray-900"}`}>
{appointment.email}
</p>
<button
onClick={() => copyToClipboard(appointment.email, "Email")}
className={`p-1.5 rounded-lg hover:bg-opacity-80 transition-colors ${isDark ? "hover:bg-gray-700" : "hover:bg-gray-100"}`}
title="Copy email"
>
<Copy className={`w-4 h-4 ${isDark ? "text-gray-400" : "text-gray-500"}`} />
</button>
</div>
</div>
{appointment.phone && (
<div className="space-y-1">
<p className={`text-xs font-medium uppercase tracking-wider flex items-center gap-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
<PhoneIcon className="w-3 h-3" />
Phone Number
</p>
<div className="flex items-center gap-2">
<p className={`text-base font-medium ${isDark ? "text-white" : "text-gray-900"}`}>
{appointment.phone}
</p>
<button
onClick={() => copyToClipboard(appointment.phone!, "Phone")}
className={`p-1.5 rounded-lg hover:bg-opacity-80 transition-colors ${isDark ? "hover:bg-gray-700" : "hover:bg-gray-100"}`}
title="Copy phone"
>
<Copy className={`w-4 h-4 ${isDark ? "text-gray-400" : "text-gray-500"}`} />
</button>
</div>
</div>
)}
</div>
</div>
</div>
{/* Scheduled Appointment Details */}
{appointment.scheduled_datetime && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
<Calendar className={`w-5 h-5 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
Scheduled Appointment
</h2>
</div>
<div className="p-6">
<div className="flex items-start gap-4">
<div className={`p-4 rounded-xl ${isDark ? "bg-blue-500/10 border border-blue-500/20" : "bg-blue-50 border border-blue-100"}`}>
<Calendar className={`w-6 h-6 ${isDark ? "text-blue-400" : "text-blue-600"}`} />
</div>
<div className="flex-1">
<p className={`text-2xl font-bold mb-1 ${isDark ? "text-white" : "text-gray-900"}`}>
{formatDate(appointment.scheduled_datetime)}
</p>
<div className="flex items-center gap-4 mt-2">
<div className="flex items-center gap-2">
<Clock className={`w-4 h-4 ${isDark ? "text-gray-400" : "text-gray-500"}`} />
<p className={`text-base ${isDark ? "text-gray-300" : "text-gray-700"}`}>
{formatTime(appointment.scheduled_datetime)}
</p>
</div>
{appointment.scheduled_duration && (
<div className="flex items-center gap-2">
<span className={`text-base ${isDark ? "text-gray-400" : "text-gray-500"}`}></span>
<p className={`text-base ${isDark ? "text-gray-300" : "text-gray-700"}`}>
{appointment.meeting_duration_display || `${appointment.scheduled_duration} minutes`}
</p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
)}
{/* Selected Slots */}
{appointment.selected_slots && Array.isArray(appointment.selected_slots) && appointment.selected_slots.length > 0 && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
<CalendarCheck className={`w-5 h-5 ${isDark ? "text-green-400" : "text-green-600"}`} />
Selected Time Slots
{appointment.are_preferences_available !== undefined && (
<span className={`ml-auto px-3 py-1 text-xs font-medium rounded-full ${appointment.are_preferences_available ? (isDark ? "bg-green-500/20 text-green-300 border border-green-500/30" : "bg-green-50 text-green-700 border border-green-200") : (isDark ? "bg-yellow-500/20 text-yellow-300 border border-yellow-500/30" : "bg-yellow-50 text-yellow-700 border border-yellow-200")}`}>
{appointment.are_preferences_available ? "All Available" : "Partially Available"}
</span>
)}
</h2>
</div>
<div className="p-6">
{(() => {
const dayNames: Record<number, string> = {
0: "Monday",
1: "Tuesday",
2: "Wednesday",
3: "Thursday",
4: "Friday",
5: "Saturday",
6: "Sunday",
};
const timeSlotLabels: Record<string, string> = {
morning: "Morning",
afternoon: "Lunchtime",
evening: "Evening",
};
// Time slot order: morning, afternoon (lunchtime), evening
const timeSlotOrder: Record<string, number> = {
morning: 0,
afternoon: 1,
evening: 2,
};
// Group slots by date
const slotsByDate: Record<string, typeof appointment.selected_slots> = {};
appointment.selected_slots.forEach((slot: any) => {
const date = slot.date || "";
if (!slotsByDate[date]) {
slotsByDate[date] = [];
}
slotsByDate[date].push(slot);
});
// Sort dates and slots within each date
const sortedDates = Object.keys(slotsByDate).sort((a, b) => {
return new Date(a).getTime() - new Date(b).getTime();
});
return (
<div className="space-y-4">
{sortedDates.map((date) => {
// Sort slots within this date by time slot order
const slots = slotsByDate[date].sort((a: any, b: any) => {
const aSlot = String(a.time_slot).toLowerCase().trim();
const bSlot = String(b.time_slot).toLowerCase().trim();
const aOrder = timeSlotOrder[aSlot] ?? 999;
const bOrder = timeSlotOrder[bSlot] ?? 999;
return aOrder - bOrder;
});
return (
<div key={date} className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}>
<div className="mb-3">
<p className={`text-base font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
{formatShortDate(date)}
</p>
{slots.length > 0 && slots[0]?.day !== undefined && (
<p className={`text-sm mt-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
{dayNames[slots[0].day] || `Day ${slots[0].day}`}
</p>
)}
</div>
<div className="flex flex-wrap gap-2">
{slots.map((slot: any, idx: number) => {
const timeSlot = String(slot.time_slot).toLowerCase().trim();
const timeLabel = timeSlotLabels[timeSlot] || slot.time_slot;
return (
<span
key={idx}
className={`px-3 py-1.5 rounded-lg text-sm font-medium ${isDark ? "bg-green-500/20 text-green-300 border border-green-500/30" : "bg-green-50 text-green-700 border border-green-200"}`}
>
{timeLabel}
</span>
);
})}
</div>
</div>
);
})}
</div>
);
})()}
</div>
</div>
)}
{/* Reason */}
{appointment.reason && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
<MessageSquare className={`w-5 h-5 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
Reason for Appointment
</h2>
</div>
<div className="p-6">
<p className={`text-base leading-relaxed ${isDark ? "text-gray-300" : "text-gray-700"}`}>
{appointment.reason}
</p>
</div>
</div>
)}
{/* Rejection Reason */}
{appointment.rejection_reason && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-red-900/20 border-red-800/50" : "bg-red-50 border-red-200"}`}>
<div className={`px-6 py-4 border-b ${isDark ? "border-red-800/50" : "border-red-200"}`}>
<h2 className={`text-lg font-semibold ${isDark ? "text-red-300" : "text-red-900"}`}>
Rejection Reason
</h2>
</div>
<div className="p-6">
<p className={`text-base leading-relaxed ${isDark ? "text-red-200" : "text-red-800"}`}>
{appointment.rejection_reason}
</p>
</div>
</div>
)}
{/* Meeting Information */}
{/* Video Meeting card hidden - users can join via the Join Now button in sidebar */}
{/* {appointment.participant_join_url && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gradient-to-br from-blue-900/20 to-purple-900/20 border-blue-800/30" : "bg-gradient-to-br from-blue-50 to-purple-50 border-blue-200"}`}>
<div className={`px-6 py-4 border-b ${isDark ? "border-blue-800/30" : "border-blue-200"}`}>
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
<Video className={`w-5 h-5 ${isDark ? "text-blue-400" : "text-blue-600"}`} />
Video Meeting
</h2>
</div>
<div className="p-6 space-y-4">
{appointment.can_join_as_participant !== undefined && (
<div className={`flex items-center gap-2 px-4 py-3 rounded-lg ${appointment.can_join_as_participant ? (isDark ? "bg-green-500/20 border border-green-500/30" : "bg-green-50 border border-green-200") : (isDark ? "bg-gray-800 border border-gray-700" : "bg-gray-50 border border-gray-200")}`}>
<div className={`h-2 w-2 rounded-full ${appointment.can_join_as_participant ? (isDark ? "bg-green-400" : "bg-green-600") : (isDark ? "bg-gray-500" : "bg-gray-400")}`} />
<p className={`text-sm font-medium ${appointment.can_join_as_participant ? (isDark ? "text-green-300" : "text-green-700") : (isDark ? "text-gray-400" : "text-gray-500")}`}>
{appointment.can_join_as_participant ? "Meeting is active - You can join now" : "Click here to join"}
</p>
</div>
)}
</div>
</div>
)} */}
</div>
{/* Sidebar - Right Column (1/3) */}
<div className="space-y-6">
{/* Quick Info Card */}
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
<h2 className={`text-lg font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
Quick Info
</h2>
</div>
<div className="p-6 space-y-4">
<div>
<p className={`text-xs font-medium mb-1 uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Created
</p>
<p className={`text-sm font-medium ${isDark ? "text-white" : "text-gray-900"}`}>
{formatShortDate(appointment.created_at)}
</p>
<p className={`text-xs mt-0.5 ${isDark ? "text-gray-500" : "text-gray-500"}`}>
{formatTime(appointment.created_at)}
</p>
</div>
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
<p className={`text-xs font-medium mb-1 uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Status
</p>
<span
className={`inline-flex items-center gap-2 px-3 py-1.5 text-sm font-semibold rounded-lg border ${getStatusColor(
appointment.status
)}`}
>
{appointment.status === "scheduled" && <CheckCircle2 className="w-4 h-4" />}
{formatStatus(appointment.status)}
</span>
</div>
{appointment.scheduled_datetime && (
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
<p className={`text-xs font-medium mb-2 uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Meeting Information
</p>
<div className="space-y-3">
<div>
<p className={`text-xs font-medium mb-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Meeting Start Time:
</p>
<p className={`text-sm font-medium ${isDark ? "text-white" : "text-gray-900"}`}>
{formatDate(appointment.scheduled_datetime)} at {formatTime(appointment.scheduled_datetime)}
</p>
</div>
<div>
<p className={`text-xs font-medium mb-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
How to Access:
</p>
<p className={`text-sm ${isDark ? "text-gray-300" : "text-gray-700"}`}>
{`Up to 10 minutes before the meeting is scheduled to begin, click the "Join Now" button below when the meeting becomes available. The meeting will be accessible starting at ${formatTime(appointment.scheduled_datetime)}.`}
</p>
</div>
</div>
</div>
)}
</div>
</div>
{/* Join Meeting Button */}
{appointment.status === "scheduled" && appointment.participant_join_url && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gradient-to-br from-blue-900/20 to-purple-900/20 border-blue-800/30" : "bg-gradient-to-br from-blue-50 to-purple-50 border-blue-200"}`}>
<div className="p-6">
{(() => {
// Check if meeting has ended
const endedAt = appointment.meeting_ended_at;
const hasEnded = endedAt != null && endedAt !== "";
// If meeting has ended, show "Meeting has ended"
if (hasEnded) {
return (
<button
disabled
className={`flex items-center justify-center gap-2 w-full cursor-not-allowed h-12 rounded-lg text-base font-medium transition-colors ${isDark ? "bg-gray-700 text-gray-500" : "bg-gray-300 text-gray-500"}`}
>
<Video className="w-5 h-5" />
Meeting has ended
</button>
);
}
// Check if can join as participant (handle both boolean and string values)
const canJoinAsParticipant = appointment.can_join_as_participant === true || appointment.can_join_as_participant === "true";
// Check if meeting has started (handle both field names)
const startedAt = appointment.started_at || appointment.meeting_started_at;
const hasStarted = startedAt != null && startedAt !== "";
// If can_join_as_participant != true, display "Click here to join"
if (!canJoinAsParticipant) {
return (
<button
disabled
className={`flex items-center justify-center gap-2 w-full cursor-not-allowed h-12 rounded-lg text-sm sm:text-base font-medium transition-colors ${isDark ? "bg-gray-700 text-gray-500" : "bg-gray-300 text-gray-500"}`}
>
<Video className="w-4 h-4 sm:w-5 sm:h-5 shrink-0" />
<span className="text-center">Click here to join</span>
</button>
);
}
// If can_join_as_participant == true && started_at != null, show "Join Now" button
if (hasStarted) {
return (
<a
href={appointment.participant_join_url}
target="_blank"
rel="noopener noreferrer"
className={`flex items-center justify-center gap-2 w-full bg-blue-600 hover:bg-blue-700 text-white h-12 rounded-lg text-base font-medium transition-colors`}
>
<Video className="w-5 h-5" />
Join Now
</a>
);
}
// If can_join_as_participant == true && started_at == null, show "Click here to join"
return (
<button
disabled
className={`flex items-center justify-center gap-2 w-full cursor-not-allowed h-12 rounded-lg text-sm sm:text-base font-medium transition-colors ${isDark ? "bg-gray-700 text-gray-500" : "bg-gray-300 text-gray-500"}`}
>
<Video className="w-4 h-4 sm:w-5 sm:h-5 shrink-0" />
<span className="text-center">Click here to join</span>
</button>
);
})()}
</div>
</div>
)}
</div>
</div>
</main>
</div>
);
}

View File

@ -1,7 +1,6 @@
"use client";
import { useMemo, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
Calendar,
@ -17,67 +16,56 @@ import {
CalendarCheck,
ArrowUpRight,
Settings,
Loader2,
} from "lucide-react";
import Link from "next/link";
import { Navbar } from "@/components/Navbar";
import { useAppTheme } from "@/components/ThemeProvider";
import { useAuth } from "@/hooks/useAuth";
import { getUserAppointments, getUserAppointmentStats } from "@/lib/actions/appointments";
import type { Appointment, UserAppointmentStats } from "@/lib/models/appointments";
import { toast } from "sonner";
interface Booking {
ID: number;
scheduled_at: string;
duration: number;
status: string;
amount: number;
notes: string;
jitsi_room_url?: string;
}
export default function UserDashboard() {
const router = useRouter();
const { theme } = useAppTheme();
const isDark = theme === "dark";
const { user } = useAuth();
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [bookings, setBookings] = useState<Booking[]>([]);
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<UserAppointmentStats | null>(null);
const [loadingStats, setLoadingStats] = useState(true);
// Fetch user appointments from user-specific endpoint
useEffect(() => {
const fetchAppointments = async () => {
// Simulate API call to fetch user bookings
const fetchBookings = async () => {
setLoading(true);
try {
const data = await getUserAppointments();
setAppointments(data || []);
// 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) {
toast.error("Failed to load appointments. Please try again.");
setAppointments([]);
console.error("Failed to fetch bookings:", error);
} finally {
setLoading(false);
}
};
fetchAppointments();
}, []);
// Fetch stats from API for authenticated user
useEffect(() => {
const fetchStats = async () => {
setLoadingStats(true);
try {
const statsData = await getUserAppointmentStats();
setStats(statsData);
} catch (error) {
toast.error("Failed to load appointment statistics.");
setStats({
total_requests: 0,
pending_review: 0,
scheduled: 0,
rejected: 0,
completed: 0,
completion_rate: 0,
});
} finally {
setLoadingStats(false);
}
};
fetchStats();
fetchBookings();
}, []);
const formatDate = (dateString: string) => {
@ -98,87 +86,46 @@ export default function UserDashboard() {
});
};
const formatMemberSince = (dateString?: string) => {
if (!dateString) return "N/A";
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
month: "long",
year: "numeric",
});
};
const upcomingBookings = bookings.filter(
(booking) => booking.status === "scheduled"
);
const completedBookings = bookings.filter(
(booking) => booking.status === "completed"
);
const cancelledBookings = bookings.filter(
(booking) => booking.status === "cancelled"
);
// Filter appointments by status
const upcomingAppointments = useMemo(() => {
return appointments.filter(
(appointment) => appointment.status === "scheduled"
);
}, [appointments]);
const pendingAppointments = useMemo(() => {
return appointments.filter(
(appointment) => appointment.status === "pending_review"
);
}, [appointments]);
const completedAppointments = useMemo(() => {
return appointments.filter(
(appointment) => appointment.status === "completed"
);
}, [appointments]);
const rejectedAppointments = useMemo(() => {
return appointments.filter(
(appointment) => appointment.status === "rejected"
);
}, [appointments]);
// Sort appointments by created_at (newest first)
const allAppointments = useMemo(() => {
return [...appointments].sort((a, b) => {
const dateA = new Date(a.created_at).getTime();
const dateB = new Date(b.created_at).getTime();
return dateB - dateA;
});
}, [appointments]);
// Use stats from API, fallback to calculated stats if API stats not available
const displayStats = useMemo(() => {
if (stats) {
return {
scheduled: stats.scheduled || 0,
scheduled_pct: stats.scheduled_pct,
completed: stats.completed || 0,
completed_pct: stats.completed_pct,
pending_review: stats.pending_review || 0,
pending_review_pct: stats.pending_review_pct,
rejected: stats.rejected || 0,
rejected_pct: stats.rejected_pct,
total_requests: stats.total_requests || 0,
completion_rate: stats.completion_rate || 0,
};
}
// Fallback: calculate from appointments if stats not loaded yet
// Note: Percentage values (_pct) are only available from API, not calculated
const scheduled = appointments.filter(a => a.status === "scheduled").length;
const completed = appointments.filter(a => a.status === "completed").length;
const pending_review = appointments.filter(a => a.status === "pending_review").length;
const rejected = appointments.filter(a => a.status === "rejected").length;
const total_requests = appointments.length;
const completion_rate = total_requests > 0 ? (scheduled / total_requests) * 100 : 0;
return {
scheduled,
scheduled_pct: undefined, // Only from API
completed,
completed_pct: undefined, // Only from API
pending_review,
pending_review_pct: undefined, // Only from API
rejected,
rejected_pct: undefined, // Only from API
total_requests,
completion_rate,
};
}, [stats, appointments]);
const statCards = [
{
title: "Upcoming Appointments",
value: upcomingBookings.length,
icon: CalendarCheck,
trend: "+2",
trendUp: true,
},
{
title: "Completed Sessions",
value: completedBookings.length,
icon: CheckCircle2,
trend: "+5",
trendUp: true,
},
{
title: "Total Appointments",
value: bookings.length,
icon: Calendar,
trend: "+12%",
trendUp: true,
},
{
title: "Total Spent",
value: `$${bookings.reduce((sum, b) => sum + b.amount, 0)}`,
icon: Heart,
trend: "+18%",
trendUp: true,
},
];
return (
<div className={`min-h-screen ${isDark ? 'bg-gray-900' : 'bg-gray-50'}`}>
@ -219,373 +166,177 @@ export default function UserDashboard() {
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className={`w-8 h-8 animate-spin ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
<div className={`animate-spin rounded-full h-8 w-8 border-b-2 ${isDark ? 'border-gray-600' : 'border-gray-400'}`}></div>
</div>
) : (
<>
{/* Stats Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 sm:gap-4">
{statCards.map((card, index) => {
const Icon = card.icon;
return (
<div
key={index}
className={`rounded-lg border p-4 sm:p-5 md:p-6 hover:shadow-md transition-shadow ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}
>
<div className="flex items-start justify-between mb-3 sm:mb-4">
<div className={`p-2 sm:p-2.5 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
<CalendarCheck className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
<Icon className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
</div>
{displayStats.scheduled_pct !== undefined && (
<div
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${isDark ? "bg-green-900/30 text-green-400" : "bg-green-50 text-green-700"}`}
>
<div
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${
card.trendUp
? isDark ? "bg-green-900/30 text-green-400" : "bg-green-50 text-green-700"
: isDark ? "bg-red-900/30 text-red-400" : "bg-red-50 text-red-700"
}`}
>
{card.trendUp ? (
<ArrowUpRight className="w-3 h-3" />
<span>{`${Math.round(displayStats.scheduled_pct)}%`}</span>
</div>
)}
</div>
<div>
<h3 className={`text-xs font-medium mb-1 sm:mb-2 uppercase tracking-wider ${isDark ? 'text-rose-400' : 'text-rose-600'}`}>
Upcoming Appointments
</h3>
<p className={`text-xl sm:text-2xl font-bold ${isDark ? 'text-white' : 'text-gray-900'}`}>
{displayStats.scheduled}
</p>
</div>
</div>
<div
className={`rounded-lg border p-4 sm:p-5 md:p-6 hover:shadow-md transition-shadow ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}
>
<div className="flex items-start justify-between mb-3 sm:mb-4">
<div className={`p-2 sm:p-2.5 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
<CheckCircle2 className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
</div>
{displayStats.completed_pct !== undefined && (
<div
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${isDark ? "bg-green-900/30 text-green-400" : "bg-green-50 text-green-700"}`}
>
<ArrowUpRight className="w-3 h-3" />
<span>{`${Math.round(displayStats.completed_pct)}%`}</span>
</div>
)}
</div>
<div>
<h3 className={`text-xs font-medium mb-1 sm:mb-2 uppercase tracking-wider ${isDark ? 'text-rose-400' : 'text-rose-600'}`}>
Completed Sessions
</h3>
<p className={`text-xl sm:text-2xl font-bold ${isDark ? 'text-white' : 'text-gray-900'}`}>
{displayStats.completed}
</p>
) : (
<ArrowUpRight className="w-3 h-3" />
)}
<span>{card.trend}</span>
</div>
</div>
<div
className={`rounded-lg border p-4 sm:p-5 md:p-6 hover:shadow-md transition-shadow ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}
>
<div className="flex items-start justify-between mb-3 sm:mb-4">
<div className={`p-2 sm:p-2.5 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
<Calendar className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
</div>
{/* No percentage badge for total appointments */}
</div>
<div>
<h3 className={`text-xs font-medium mb-1 sm:mb-2 uppercase tracking-wider ${isDark ? 'text-rose-400' : 'text-rose-600'}`}>
Total Appointments
{card.title}
</h3>
<p className={`text-xl sm:text-2xl font-bold ${isDark ? 'text-white' : 'text-gray-900'}`}>
{displayStats.total_requests}
<p className={`text-xl sm:text-2xl font-bold mb-1 ${isDark ? 'text-white' : 'text-gray-900'}`}>
{card.value}
</p>
<p className={`text-xs font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>vs last month</p>
</div>
</div>
<div
className={`rounded-lg border p-4 sm:p-5 md:p-6 hover:shadow-md transition-shadow ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}
>
<div className="flex items-start justify-between mb-3 sm:mb-4">
<div className={`p-2 sm:p-2.5 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
<Calendar className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
</div>
{displayStats.pending_review_pct !== undefined && (
<div
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${isDark ? "bg-yellow-900/30 text-yellow-400" : "bg-yellow-50 text-yellow-700"}`}
>
<ArrowUpRight className="w-3 h-3" />
<span>{`${Math.round(displayStats.pending_review_pct)}%`}</span>
</div>
)}
</div>
<div>
<h3 className={`text-xs font-medium mb-1 sm:mb-2 uppercase tracking-wider ${isDark ? 'text-rose-400' : 'text-rose-600'}`}>
Pending Review
</h3>
<p className={`text-xl sm:text-2xl font-bold ${isDark ? 'text-white' : 'text-gray-900'}`}>
{displayStats.pending_review}
</p>
</div>
</div>
);
})}
</div>
{/* All Appointments Section */}
{allAppointments.length > 0 ? (
<div className={`rounded-lg border overflow-hidden ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}>
<div className={`px-4 sm:px-5 md:px-6 py-4 border-b ${isDark ? 'border-gray-700' : 'border-gray-200'}`}>
<h2 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
All Appointments
{/* Upcoming Appointments Section */}
{upcomingBookings.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'}`}>
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-gray-900'}`}>
Upcoming Appointments
</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className={`${isDark ? "bg-gray-800 border-b border-gray-700" : "bg-gray-50 border-b border-gray-200"}`}>
<tr>
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Appointment
</th>
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider hidden md:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Date & Time
</th>
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider hidden lg:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Duration
</th>
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Status
</th>
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider hidden lg:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Preferred Times
</th>
<th className={`px-3 sm:px-4 md:px-6 py-3 text-left text-xs font-medium uppercase tracking-wider hidden xl:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Created
</th>
<th className={`px-3 sm:px-4 md:px-6 py-3 text-right text-xs font-medium uppercase tracking-wider ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Actions
</th>
</tr>
</thead>
<tbody className={`${isDark ? "bg-gray-800 divide-gray-700" : "bg-white divide-gray-200"}`}>
{allAppointments.map((appointment) => {
const getStatusColor = (status: string) => {
switch (status) {
case "scheduled":
return isDark ? 'bg-green-900/30 text-green-400' : 'bg-green-50 text-green-700';
case "pending_review":
return isDark ? 'bg-yellow-900/30 text-yellow-400' : 'bg-yellow-50 text-yellow-700';
case "completed":
return isDark ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-50 text-blue-700';
case "rejected":
case "cancelled":
case "canceled":
return isDark ? 'bg-red-900/30 text-red-400' : 'bg-red-50 text-red-700';
default:
return isDark ? 'bg-gray-700 text-gray-300' : 'bg-gray-100 text-gray-700';
}
};
const formatStatus = (status: string) => {
return status.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase());
};
const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const timeSlotLabels: Record<string, string> = {
morning: 'Morning',
afternoon: 'Lunchtime',
evening: 'Evening',
};
return (
<tr
key={appointment.id}
className={`transition-colors cursor-pointer ${isDark ? "hover:bg-gray-700" : "hover:bg-gray-50"}`}
onClick={() => router.push(`/user/appointments/${appointment.id}`)}
>
<td className="px-3 sm:px-4 md:px-6 py-4">
<div className="flex items-center">
<div className={`shrink-0 h-8 w-8 sm:h-10 sm:w-10 rounded-full flex items-center justify-center ${isDark ? "bg-gray-700" : "bg-gray-100"}`}>
<User className={`w-4 h-4 sm:w-5 sm:h-5 ${isDark ? "text-gray-200" : "text-gray-600"}`} />
</div>
<div className="ml-2 sm:ml-4 min-w-0">
<div className={`text-xs sm:text-sm font-medium truncate ${isDark ? "text-white" : "text-gray-900"}`}>
{appointment.first_name} {appointment.last_name}
</div>
{appointment.reason && (
<div className={`text-xs sm:text-sm truncate mt-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
{appointment.reason}
</div>
)}
{appointment.scheduled_datetime && (
<div className={`text-xs sm:hidden mt-0.5 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
{formatDate(appointment.scheduled_datetime)}
</div>
)}
</div>
</div>
</td>
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap hidden md:table-cell">
{appointment.scheduled_datetime ? (
<>
<div className={`text-xs sm:text-sm ${isDark ? "text-white" : "text-gray-900"}`}>
{formatDate(appointment.scheduled_datetime)}
</div>
<div className={`text-xs sm:text-sm flex items-center gap-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
<Clock className="w-3 h-3" />
{formatTime(appointment.scheduled_datetime)}
</div>
</>
) : (
<div className={`text-xs sm:text-sm ${isDark ? "text-gray-400" : "text-gray-500"}`}>
Not scheduled
</div>
)}
</td>
<td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm hidden lg:table-cell ${isDark ? "text-white" : "text-gray-900"}`}>
{appointment.scheduled_duration ? `${appointment.scheduled_duration} min` : "-"}
</td>
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(
appointment.status
)}`}
>
{formatStatus(appointment.status)}
</span>
</td>
<td className={`px-3 sm:px-4 md:px-6 py-4 hidden lg:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
{appointment.selected_slots && appointment.selected_slots.length > 0 ? (
<div className="flex flex-col gap-1">
{appointment.selected_slots.slice(0, 2).map((slot, idx) => (
<span key={idx} className="text-xs sm:text-sm">
{dayNames[slot.day]} - {timeSlotLabels[slot.time_slot] || slot.time_slot}
</span>
))}
{appointment.selected_slots.length > 2 && (
<span className="text-xs">+{appointment.selected_slots.length - 2} more</span>
<div className="space-y-3">
{upcomingBookings.map((booking) => (
<div
key={booking.ID}
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-1">
<div className="flex items-center gap-2 mb-2">
<Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
<span className={`font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
{formatDate(booking.scheduled_at)}
</span>
</div>
<div className="flex items-center gap-2 mb-2">
<Clock className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
<span className={isDark ? 'text-gray-300' : 'text-gray-700'}>
{formatTime(booking.scheduled_at)}
</span>
<span className={`text-sm font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
({booking.duration} minutes)
</span>
</div>
{booking.notes && (
<p className={`text-sm mt-2 font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
{booking.notes}
</p>
)}
</div>
) : (
"-"
)}
</td>
<td className={`px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-xs sm:text-sm hidden xl:table-cell ${isDark ? "text-gray-400" : "text-gray-500"}`}>
{formatDate(appointment.created_at)}
</td>
<td className="px-3 sm:px-4 md:px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end gap-1 sm:gap-2">
{appointment.participant_join_url && (
<div className="flex flex-col sm:items-end gap-3">
<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'}`}>
{booking.status.charAt(0).toUpperCase() +
booking.status.slice(1)}
</span>
<span className={`text-lg font-bold ${isDark ? 'text-white' : 'text-gray-900'}`}>
${booking.amount}
</span>
</div>
{booking.jitsi_room_url && (
<a
href={appointment.participant_join_url}
href={booking.jitsi_room_url}
target="_blank"
rel="noopener noreferrer"
className={`p-1.5 sm:p-2 rounded-lg transition-colors ${
appointment.can_join_as_participant
? isDark
? "bg-blue-600 hover:bg-blue-700 text-white"
: "bg-blue-600 hover:bg-blue-700 text-white"
: isDark
? "text-gray-400 hover:text-gray-300 hover:bg-gray-700"
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
}`}
title={appointment.can_join_as_participant ? "Join Meeting" : "Meeting Not Available"}
onClick={(e) => {
e.stopPropagation();
if (!appointment.can_join_as_participant) {
e.preventDefault();
}
}}
>
<Video className="w-4 h-4" />
</a>
)}
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"
>
<Video className="w-4 h-4" />
Join Session
</a>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</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'}`}>
No Appointments
</p>
<p className={`text-sm mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
You don't have any appointments yet. 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" />
Book Appointment
</Button>
</Link>
</div>
</div>
))}
</div>
</div>
)}
{/* Account Information */}
{user && (
<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'}`}>
Account Information
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="flex items-center gap-3">
<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 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'}`}>
Account Information
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="flex items-center gap-3">
<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 className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
<Mail 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'}`}>
Email
</p>
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
{user.email}
</p>
</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'}`}>
John Doe
</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
<Mail 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'}`}>
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>
{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>
</>
)}
</main>

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState } 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,26 +13,19 @@ import {
Lock,
Eye,
EyeOff,
Loader2,
} from "lucide-react";
import Link from "next/link";
import { Navbar } from "@/components/Navbar";
import { useAppTheme } from "@/components/ThemeProvider";
import { getProfile, updateProfile } from "@/lib/actions/auth";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
export default function SettingsPage() {
const { theme } = useAppTheme();
const isDark = theme === "dark";
const { user } = useAuth();
const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(true);
const [formData, setFormData] = useState({
firstName: "",
lastName: "",
email: "",
phone: "",
fullName: "John Doe",
email: "john.doe@example.com",
phone: "+1 (555) 123-4567",
});
const [passwordData, setPasswordData] = useState({
currentPassword: "",
@ -45,29 +38,6 @@ export default function SettingsPage() {
confirm: false,
});
// 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) {
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,
@ -90,59 +60,35 @@ export default function SettingsPage() {
};
const handleSave = async () => {
if (!formData.firstName || !formData.lastName) {
toast.error("First name and last name are required");
return;
}
setLoading(true);
try {
await updateProfile({
first_name: formData.firstName,
last_name: formData.lastName,
phone_number: formData.phone || undefined,
});
toast.success("Profile updated successfully");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Failed to update profile";
toast.error(errorMessage);
} finally {
setLoading(false);
}
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
setLoading(false);
// In a real app, you would show a success message here
};
const handlePasswordSave = async () => {
if (!passwordData.currentPassword) {
toast.error("Please enter your current password");
return;
}
if (passwordData.newPassword !== passwordData.confirmPassword) {
toast.error("New passwords do not match");
// In a real app, you would show an error message here
alert("New passwords do not match");
return;
}
if (passwordData.newPassword.length < 8) {
toast.error("Password must be at least 8 characters long");
// In a real app, you would show an error message here
alert("Password must be at least 8 characters long");
return;
}
setLoading(true);
try {
// Note: The API might not have a change password endpoint for authenticated users
// This would need to be implemented on the backend
// For now, we'll show a message that this feature is coming soon
toast.error("Password change feature is not yet available. Please use the forgot password flow.");
// Reset password fields
setPasswordData({
currentPassword: "",
newPassword: "",
confirmPassword: "",
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Failed to update password";
toast.error(errorMessage);
} finally {
setLoading(false);
}
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
setLoading(false);
// Reset password fields
setPasswordData({
currentPassword: "",
newPassword: "",
confirmPassword: "",
});
// In a real app, you would show a success message here
};
return (
@ -168,109 +114,83 @@ export default function SettingsPage() {
</p>
</div>
</div>
<Button
onClick={handleSave}
disabled={loading}
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 ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
) : (
<Save className="w-4 h-4 mr-2" />
)}
Save Changes
</Button>
</div>
{fetching ? (
<div className="flex items-center justify-center py-12">
<Loader2 className={`w-8 h-8 animate-spin ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
</div>
) : (
<div className="max-w-4xl mx-auto">
<div className="space-y-6">
{/* Profile Information */}
<Card className={isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}>
<CardHeader>
<div className="flex items-center gap-2">
<User className={`w-5 h-5 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
<CardTitle className={isDark ? 'text-white' : 'text-gray-900'}>Profile Information</CardTitle>
</div>
<CardDescription className={isDark ? 'text-gray-400' : 'text-gray-600'}>
Update your personal information and contact details
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
Full Name
</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="relative">
<Input
type="text"
value={formData.firstName}
onChange={(e) => handleInputChange("firstName", e.target.value)}
className={`${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
placeholder="First name"
/>
</div>
<div className="relative">
<Input
type="text"
value={formData.lastName}
onChange={(e) => handleInputChange("lastName", e.target.value)}
className={`${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
placeholder="Last name"
/>
</div>
</div>
<div className="max-w-4xl mx-auto">
<div className="space-y-6">
{/* Profile Information */}
<Card className={isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}>
<CardHeader>
<div className="flex items-center gap-2">
<User className={`w-5 h-5 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
<CardTitle className={isDark ? 'text-white' : 'text-gray-900'}>Profile Information</CardTitle>
</div>
<CardDescription className={isDark ? 'text-gray-400' : 'text-gray-600'}>
Update your personal information and contact details
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
Full 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.fullName}
onChange={(e) => handleInputChange("fullName", e.target.value)}
className={`pl-10 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
placeholder="Enter your full name"
/>
</div>
</div>
<div className="space-y-2">
<label className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
Email Address
</label>
<div className="relative">
<Mail 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="email"
value={formData.email}
disabled
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"
/>
</div>
<p className={`text-xs ${isDark ? 'text-gray-500' : 'text-gray-500'}`}>
Email address cannot be changed
</p>
<div className="space-y-2">
<label className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
Email Address
</label>
<div className="relative">
<Mail 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="email"
value={formData.email}
onChange={(e) => handleInputChange("email", e.target.value)}
className={`pl-10 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
placeholder="Enter your email"
/>
</div>
</div>
<div className="space-y-2">
<label className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
Phone Number
</label>
<div className="relative">
<Phone 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="tel"
value={formData.phone}
onChange={(e) => handleInputChange("phone", e.target.value)}
className={`pl-10 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
placeholder="Enter your phone number"
/>
</div>
<div className="space-y-2">
<label className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
Phone Number
</label>
<div className="relative">
<Phone 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="tel"
value={formData.phone}
onChange={(e) => handleInputChange("phone", e.target.value)}
className={`pl-10 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
placeholder="Enter your phone number"
/>
</div>
<div className="pt-2">
<Button
onClick={handleSave}
disabled={loading}
className="bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Saving...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
Save Changes
</>
)}
</Button>
</div>
</CardContent>
</Card>
</div>
</CardContent>
</Card>
{/* Change Password */}
<Card className={isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}>
@ -294,7 +214,7 @@ export default function SettingsPage() {
type={showPasswords.current ? "text" : "password"}
value={passwordData.currentPassword}
onChange={(e) => handlePasswordChange("currentPassword", e.target.value)}
className={`pl-10 pr-10 h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
className={`pl-10 pr-10 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
placeholder="Enter your current password"
/>
<button
@ -321,7 +241,7 @@ export default function SettingsPage() {
type={showPasswords.new ? "text" : "password"}
value={passwordData.newPassword}
onChange={(e) => handlePasswordChange("newPassword", e.target.value)}
className={`pl-10 pr-10 h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
className={`pl-10 pr-10 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
placeholder="Enter your new password"
/>
<button
@ -351,7 +271,7 @@ export default function SettingsPage() {
type={showPasswords.confirm ? "text" : "password"}
value={passwordData.confirmPassword}
onChange={(e) => handlePasswordChange("confirmPassword", e.target.value)}
className={`pl-10 pr-10 h-11 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
className={`pl-10 pr-10 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
placeholder="Confirm your new password"
/>
<button
@ -375,23 +295,17 @@ export default function SettingsPage() {
className="bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white"
>
{loading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Updating...
</>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
) : (
<>
<Lock className="w-4 h-4 mr-2" />
Update Password
</>
<Lock className="w-4 h-4 mr-2" />
)}
Update Password
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
)}
</div>
</main>
</div>
);

View File

@ -117,96 +117,3 @@
scrollbar-color: rgb(244 63 94) rgb(255 228 230);
}
}
/* React DatePicker Styles */
.react-datepicker {
font-family: inherit;
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
background-color: hsl(var(--background));
color: hsl(var(--foreground));
}
.react-datepicker__header {
background-color: hsl(var(--background));
border-bottom: 1px solid hsl(var(--border));
border-top-left-radius: 0.5rem;
border-top-right-radius: 0.5rem;
padding-top: 0.75rem;
}
.react-datepicker__current-month {
color: hsl(var(--foreground));
font-weight: 600;
font-size: 0.875rem;
}
.react-datepicker__day-name {
color: hsl(var(--muted-foreground));
font-weight: 500;
font-size: 0.75rem;
}
.react-datepicker__day {
color: hsl(var(--foreground));
border-radius: 0.375rem;
}
.react-datepicker__day:hover {
background-color: hsl(var(--accent));
color: hsl(var(--accent-foreground));
border-radius: 0.375rem;
}
.react-datepicker__day--selected,
.react-datepicker__day--keyboard-selected {
background-color: rgb(225 29 72);
color: white;
border-radius: 0.375rem;
}
.react-datepicker__day--selected:hover,
.react-datepicker__day--keyboard-selected:hover {
background-color: rgb(190 24 93);
}
.react-datepicker__day--disabled {
color: hsl(var(--muted-foreground));
opacity: 0.5;
cursor: not-allowed;
}
.react-datepicker__navigation {
top: 0.75rem;
}
.react-datepicker__navigation-icon::before {
border-color: hsl(var(--foreground));
}
.react-datepicker__navigation:hover *::before {
border-color: rgb(225 29 72);
}
html.dark .react-datepicker {
background-color: #1f2937;
border-color: #374151;
}
html.dark .react-datepicker__header {
background-color: #1f2937;
border-color: #374151;
}
html.dark .react-datepicker__current-month {
color: #f9fafb;
}
html.dark .react-datepicker__day {
color: #f9fafb;
}
html.dark .react-datepicker__day:hover {
background-color: #374151;
color: #f9fafb;
}

View File

@ -21,7 +21,7 @@ export default function RootLayout({
}) {
return (
<html lang="en" suppressHydrationWarning>
<body className={inter.className} suppressHydrationWarning>
<body className={inter.className}>
<Providers>
{children}
<Toaster />

View File

@ -2,26 +2,12 @@
import { ThemeProvider } from "../components/ThemeProvider";
import { type ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function Providers({ children }: { children: ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1 minute
refetchOnWindowFocus: false,
retry: 1,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>{children}</ThemeProvider>
</QueryClientProvider>
<ThemeProvider>
{children}
</ThemeProvider>
);
}

View File

@ -14,8 +14,8 @@ export function ClientFocus() {
const isDark = theme === "dark";
const ages = [
"Children (0 to 10)",
"Teens",
"Children (6 to 10)",
"Teen",
"Adults",
"Elders (65+)"
];
@ -95,7 +95,7 @@ export function ClientFocus() {
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.8, delay: 0.2 }}
>
Who I work with
Who We Serve
</motion.h2>
</motion.div>
@ -144,7 +144,7 @@ export function ClientFocus() {
<p className="text-muted-foreground">Individuals</p>
</motion.div>
{/* Ethnicity */}
{/* Communities */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}

View File

@ -1,177 +0,0 @@
'use client';
import * as React from 'react';
import { Timer } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
interface ClockDurationPickerProps {
duration: number; // Duration in minutes
setDuration: (duration: number) => void;
label?: string;
isDark?: boolean;
options?: number[]; // Optional custom duration options
}
export function ClockDurationPicker({
duration,
setDuration,
label,
isDark = false,
options = [15, 30, 45, 60, 75, 90, 105, 120]
}: ClockDurationPickerProps) {
const [isOpen, setIsOpen] = React.useState(false);
const wrapperRef = React.useRef<HTMLDivElement>(null);
// Close picker when clicking outside
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
// Handle duration selection
const handleDurationClick = (selectedDuration: number) => {
setDuration(selectedDuration);
setIsOpen(false);
};
// Calculate position for clock numbers
const getClockPosition = (index: number, total: number, radius: number = 130) => {
const angle = (index * 360) / total - 90; // Start from top (-90 degrees)
const radian = (angle * Math.PI) / 180;
const x = Math.cos(radian) * radius;
const y = Math.sin(radian) * radius;
return { x, y };
};
// Format duration display
const formatDuration = (minutes: number) => {
if (minutes < 60) {
return `${minutes}m`;
}
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
};
const displayDuration = duration ? formatDuration(duration) : 'Select duration';
return (
<div className="space-y-2">
{label && (
<label className={cn(
"text-sm font-semibold",
isDark ? "text-gray-300" : "text-gray-700"
)}>
{label}
</label>
)}
<div className="relative w-full" ref={wrapperRef}>
<Button
type="button"
variant="outline"
onClick={() => setIsOpen(!isOpen)}
className={cn(
"w-full justify-start text-left font-normal h-12 text-base",
!duration && "text-muted-foreground",
isDark
? "bg-gray-800 border-gray-600 text-white hover:bg-gray-700"
: "bg-white border-gray-300 text-gray-900 hover:bg-gray-50"
)}
>
<Timer className="mr-2 h-5 w-5" />
{displayDuration}
</Button>
{isOpen && (
<div className={cn(
"absolute z-[9999] top-full left-0 right-0 mt-2 rounded-lg shadow-xl border p-6 w-[420px] mx-auto overflow-visible",
isDark
? "bg-gray-800 border-gray-700"
: "bg-white border-gray-200"
)}>
{/* Clock face */}
<div className="relative w-[360px] h-[360px] mx-auto my-6 overflow-visible">
{/* Clock circle */}
<div className={cn(
"absolute inset-0 rounded-full border-2",
isDark ? "border-gray-600" : "border-gray-300"
)} />
{/* Center dot */}
<div className={cn(
"absolute top-1/2 left-1/2 w-2 h-2 rounded-full -translate-x-1/2 -translate-y-1/2 z-10",
isDark ? "bg-gray-400" : "bg-gray-600"
)} />
{/* Duration options arranged in a circle */}
{options.map((option, index) => {
const { x, y } = getClockPosition(index, options.length, 130);
const isSelected = duration === option;
return (
<button
key={option}
type="button"
onClick={() => handleDurationClick(option)}
className={cn(
"absolute w-16 h-16 rounded-full flex items-center justify-center text-xs font-semibold transition-all z-20 whitespace-nowrap",
isSelected
? isDark
? "bg-blue-600 text-white scale-110 shadow-lg ring-2 ring-blue-400"
: "bg-blue-600 text-white scale-110 shadow-lg ring-2 ring-blue-400"
: isDark
? "bg-gray-700 text-gray-200 hover:bg-gray-600 hover:scale-105"
: "bg-gray-100 text-gray-700 hover:bg-gray-200 hover:scale-105"
)}
style={{
left: `calc(50% + ${x}px)`,
top: `calc(50% + ${y}px)`,
transform: 'translate(-50%, -50%)',
}}
title={`${option} minutes`}
>
{formatDuration(option)}
</button>
);
})}
</div>
{/* Quick select buttons for common durations */}
<div className="flex gap-2 mt-4 justify-center flex-wrap">
{[30, 60, 90, 120].map((quickDuration) => (
<button
key={quickDuration}
type="button"
onClick={() => handleDurationClick(quickDuration)}
className={cn(
"px-3 py-1.5 rounded text-sm font-medium transition-colors",
duration === quickDuration
? isDark
? "bg-blue-600 text-white"
: "bg-blue-600 text-white"
: isDark
? "bg-gray-700 text-gray-300 hover:bg-gray-600"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
)}
>
{formatDuration(quickDuration)}
</button>
))}
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -1,278 +0,0 @@
'use client';
import * as React from 'react';
import { Clock } from 'lucide-react';
import { format } from 'date-fns';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
interface ClockTimePickerProps {
time: string; // HH:mm format (e.g., "09:00")
setTime: (time: string) => void;
label?: string;
isDark?: boolean;
}
export function ClockTimePicker({ time, setTime, label, isDark = false }: ClockTimePickerProps) {
const [isOpen, setIsOpen] = React.useState(false);
const [mode, setMode] = React.useState<'hour' | 'minute'>('hour');
const wrapperRef = React.useRef<HTMLDivElement>(null);
// Parse time string to hours and minutes
const [hours, minutes] = React.useMemo(() => {
if (!time) return [9, 0];
const parts = time.split(':').map(Number);
return [parts[0] || 9, parts[1] || 0];
}, [time]);
// Convert to 12-hour format for display
const displayHours = hours % 12 || 12;
const ampm = hours >= 12 ? 'PM' : 'AM';
// Close picker when clicking outside
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
setIsOpen(false);
setMode('hour');
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
// Handle hour selection
const handleHourClick = (selectedHour: number) => {
const newHours = ampm === 'PM' && selectedHour !== 12
? selectedHour + 12
: ampm === 'AM' && selectedHour === 12
? 0
: selectedHour;
setTime(`${newHours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`);
setMode('minute');
};
// Handle minute selection
const handleMinuteClick = (selectedMinute: number) => {
setTime(`${hours.toString().padStart(2, '0')}:${selectedMinute.toString().padStart(2, '0')}`);
setIsOpen(false);
setMode('hour');
};
// Generate hour numbers (1-12)
const hourNumbers = Array.from({ length: 12 }, (_, i) => i + 1);
// Generate minute numbers (0, 15, 30, 45 or 0-59)
const minuteNumbers = Array.from({ length: 12 }, (_, i) => i * 5); // 0, 5, 10, 15, ..., 55
// Calculate position for clock numbers
const getClockPosition = (index: number, total: number, radius: number = 90) => {
const angle = (index * 360) / total - 90; // Start from top (-90 degrees)
const radian = (angle * Math.PI) / 180;
const x = Math.cos(radian) * radius;
const y = Math.sin(radian) * radius;
return { x, y };
};
// Format display time
const displayTime = time
? `${displayHours}:${minutes.toString().padStart(2, '0')} ${ampm}`
: 'Select time';
return (
<div className="space-y-2">
{label && (
<label className={cn(
"text-sm font-semibold",
isDark ? "text-gray-300" : "text-gray-700"
)}>
{label}
</label>
)}
<div className="relative" ref={wrapperRef}>
<Button
type="button"
variant="outline"
onClick={() => setIsOpen(!isOpen)}
className={cn(
"w-full justify-start text-left font-normal h-12 text-base",
!time && "text-muted-foreground",
isDark
? "bg-gray-800 border-gray-600 text-white hover:bg-gray-700"
: "bg-white border-gray-300 text-gray-900 hover:bg-gray-50"
)}
>
<Clock className="mr-2 h-5 w-5" />
{displayTime}
</Button>
{isOpen && (
<div className={cn(
"absolute z-[9999] mt-1 rounded-lg shadow-lg border p-4 -translate-y-1",
isDark
? "bg-gray-800 border-gray-700"
: "bg-white border-gray-200"
)}>
{/* Mode selector */}
<div className="flex gap-2 mb-4">
<button
onClick={() => setMode('hour')}
className={cn(
"px-3 py-1.5 rounded text-sm font-medium transition-colors",
mode === 'hour'
? isDark
? "bg-blue-600 text-white"
: "bg-blue-600 text-white"
: isDark
? "bg-gray-700 text-gray-300 hover:bg-gray-600"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
)}
>
Hour
</button>
<button
onClick={() => setMode('minute')}
className={cn(
"px-3 py-1.5 rounded text-sm font-medium transition-colors",
mode === 'minute'
? isDark
? "bg-blue-600 text-white"
: "bg-blue-600 text-white"
: isDark
? "bg-gray-700 text-gray-300 hover:bg-gray-600"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
)}
>
Minute
</button>
</div>
{/* Clock face */}
<div className="relative w-64 h-64 mx-auto my-4">
{/* Clock circle */}
<div className={cn(
"absolute inset-0 rounded-full border-2",
isDark ? "border-gray-600" : "border-gray-300"
)} />
{/* Center dot */}
<div className={cn(
"absolute top-1/2 left-1/2 w-2 h-2 rounded-full -translate-x-1/2 -translate-y-1/2 z-10",
isDark ? "bg-gray-400" : "bg-gray-600"
)} />
{/* Hour numbers */}
{mode === 'hour' && hourNumbers.map((hour, index) => {
const { x, y } = getClockPosition(index, 12, 90);
const isSelected = displayHours === hour;
return (
<button
key={hour}
type="button"
onClick={() => handleHourClick(hour)}
className={cn(
"absolute w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold transition-all z-20",
isSelected
? isDark
? "bg-blue-600 text-white scale-110 shadow-lg"
: "bg-blue-600 text-white scale-110 shadow-lg"
: isDark
? "bg-gray-700 text-gray-200 hover:bg-gray-600 hover:scale-105"
: "bg-gray-100 text-gray-700 hover:bg-gray-200 hover:scale-105"
)}
style={{
left: `calc(50% + ${x}px)`,
top: `calc(50% + ${y}px)`,
transform: 'translate(-50%, -50%)',
}}
>
{hour}
</button>
);
})}
{/* Minute numbers */}
{mode === 'minute' && minuteNumbers.map((minute, index) => {
const { x, y } = getClockPosition(index, 12, 90);
const isSelected = minutes === minute;
return (
<button
key={minute}
type="button"
onClick={() => handleMinuteClick(minute)}
className={cn(
"absolute w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold transition-all z-20",
isSelected
? isDark
? "bg-blue-600 text-white scale-110 shadow-lg"
: "bg-blue-600 text-white scale-110 shadow-lg"
: isDark
? "bg-gray-700 text-gray-200 hover:bg-gray-600 hover:scale-105"
: "bg-gray-100 text-gray-700 hover:bg-gray-200 hover:scale-105"
)}
style={{
left: `calc(50% + ${x}px)`,
top: `calc(50% + ${y}px)`,
transform: 'translate(-50%, -50%)',
}}
>
{minute}
</button>
);
})}
</div>
{/* AM/PM toggle */}
<div className="flex gap-2 mt-4 justify-center">
<button
type="button"
onClick={() => {
const newHours = ampm === 'PM' ? hours - 12 : hours;
setTime(`${newHours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`);
}}
className={cn(
"px-4 py-2 rounded text-sm font-medium transition-colors",
ampm === 'AM'
? isDark
? "bg-blue-600 text-white"
: "bg-blue-600 text-white"
: isDark
? "bg-gray-700 text-gray-300 hover:bg-gray-600"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
)}
>
AM
</button>
<button
type="button"
onClick={() => {
const newHours = ampm === 'AM' ? hours + 12 : hours;
setTime(`${newHours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`);
}}
className={cn(
"px-4 py-2 rounded text-sm font-medium transition-colors",
ampm === 'PM'
? isDark
? "bg-blue-600 text-white"
: "bg-blue-600 text-white"
: isDark
? "bg-gray-700 text-gray-300 hover:bg-gray-600"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
)}
>
PM
</button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -9,7 +9,6 @@ import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent } from "@/components/ui/card";
import { toast } from "sonner";
import { useAppTheme } from "@/components/ThemeProvider";
import { submitContactForm } from "@/lib/actions/auth";
export function ContactSection() {
const ref = useRef(null);
@ -22,26 +21,13 @@ export function ContactSection() {
phone: "",
message: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
await submitContactForm(formData);
toast.success("Message Sent Successfully", {
description: "Thank you for reaching out. We'll get back to you soon!",
});
setFormData({ name: "", email: "", phone: "", message: "" });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Failed to send message. Please try again.";
toast.error("Error Sending Message", {
description: errorMessage,
});
} finally {
setIsSubmitting(false);
}
toast("Message Received", {
description: "Thank you for reaching out. We'll get back to you soon!",
});
setFormData({ name: "", email: "", phone: "", message: "" });
};
return (
@ -218,12 +204,11 @@ export function ContactSection() {
</div>
<Button
type="submit"
disabled={isSubmitting}
className="w-full cursor-pointer bg-gradient-to-r from-rose-500 to-pink-600 text-white transition-all hover:from-rose-600 hover:to-pink-700 hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full cursor-pointer bg-gradient-to-r from-rose-500 to-pink-600 text-white transition-all hover:from-rose-600 hover:to-pink-700 hover:scale-[1.02]"
size="lg"
>
<Send className="mr-2 h-5 w-5" />
{isSubmitting ? "Sending..." : "Send Message"}
Send Message
</Button>
</form>
</CardContent>

View File

@ -1,12 +1,22 @@
'use client';
import * as React from 'react';
import { Calendar as CalendarIcon } from 'lucide-react';
import { format } from 'date-fns';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import DatePickerLib from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import { Calendar } from '@/components/ui/calendar';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface DatePickerProps {
date: Date | undefined;
@ -15,25 +25,7 @@ interface DatePickerProps {
}
export function DatePicker({ date, setDate, label }: DatePickerProps) {
const [isOpen, setIsOpen] = React.useState(false);
const wrapperRef = React.useRef<HTMLDivElement>(null);
// Close calendar when clicking outside
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const [open, setOpen] = React.useState(false);
return (
<div className="space-y-2">
@ -42,38 +34,65 @@ export function DatePicker({ date, setDate, label }: DatePickerProps) {
{label}
</label>
)}
<div className="relative" ref={wrapperRef}>
<Button
type="button"
variant="outline"
onClick={() => setIsOpen(!isOpen)}
className={cn(
"w-full justify-start text-left font-normal h-10",
!date && "text-muted-foreground",
"bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-600"
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{date ? format(date, "PPP") : <span>Pick a date</span>}
</Button>
{isOpen && (
<div className="absolute z-[9999] mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg">
<DatePickerLib
selected={date || null}
onChange={(selectedDate: Date | null) => {
setDate(selectedDate || undefined);
if (selectedDate) {
setIsOpen(false);
}
}}
minDate={new Date()}
inline
calendarClassName="!border-0"
wrapperClassName="w-full"
/>
</div>
)}
</div>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant={'outline'}
size="sm"
className={cn(
'justify-start text-left font-normal',
!date && 'text-muted-foreground'
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{date ? format(date, 'PPP') : <span>Pick a date</span>}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-auto p-5 bg-gradient-to-br from-white to-gray-50 dark:from-gray-800 dark:to-gray-900 shadow-2xl border-2 border-gray-100 dark:border-gray-700 rounded-2xl" align="end" sideOffset={5}>
<Calendar
mode="single"
selected={date}
onSelect={(selectedDate) => {
setDate(selectedDate);
setOpen(false);
}}
initialFocus
classNames={{
months: "space-y-4",
month: "space-y-4",
caption: "flex justify-center pt-3 pb-5 relative items-center border-b border-gray-200 dark:border-gray-700 mb-4",
caption_label: "text-lg font-bold text-gray-800 dark:text-gray-100",
nav: "flex items-center justify-between absolute inset-0",
nav_button: cn(
"h-9 w-9 rounded-full bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 hover:bg-rose-50 dark:hover:bg-gray-600 hover:border-rose-300 dark:hover:border-rose-500 p-0 transition-all shadow-sm"
),
nav_button_previous: "absolute left-0",
nav_button_next: "absolute right-0",
table: "w-full border-collapse space-y-3",
head_row: "flex mb-3",
head_cell: "text-gray-600 dark:text-gray-400 rounded-md w-11 font-semibold text-xs",
row: "flex w-full mt-2",
cell: cn(
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20",
"[&>button]:h-11 [&>button]:w-11 [&>button]:p-0 [&>button]:font-semibold [&>button]:cursor-pointer [&>button]:rounded-full [&>button]:transition-all"
),
day: cn(
"h-11 w-11 p-0 font-semibold aria-selected:opacity-100 hover:bg-rose-500 hover:text-white rounded-full transition-all cursor-pointer",
"hover:scale-110 active:scale-95 hover:shadow-md"
),
day_selected:
"bg-rose-600 text-white hover:bg-rose-700 hover:text-white focus:bg-rose-600 focus:text-white font-bold shadow-xl scale-110 ring-4 ring-rose-200 dark:ring-rose-800",
day_today: "bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 font-bold border-2 border-blue-300 dark:border-blue-600",
day_outside: "text-gray-300 dark:text-gray-600 opacity-50",
day_disabled: "text-gray-200 dark:text-gray-700 opacity-30 cursor-not-allowed",
day_range_middle:
"aria-selected:bg-rose-100 dark:aria-selected:bg-rose-900/30 aria-selected:text-rose-700 dark:aria-selected:text-rose-300",
day_hidden: "invisible",
}}
/>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@ -1,72 +0,0 @@
'use client';
import * as React from 'react';
import { Timer } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
interface DurationPickerProps {
duration: number; // Duration in minutes
setDuration: (duration: number) => void;
label?: string;
isDark?: boolean;
options?: number[]; // Optional custom duration options
}
export function DurationPicker({
duration,
setDuration,
label,
isDark = false,
options = [15, 30, 45, 60, 120]
}: DurationPickerProps) {
// Format duration display
const formatDuration = (minutes: number) => {
if (minutes < 60) {
return `${minutes}m`;
}
const hours = Math.floor(minutes / 60);
const mins = minutes % 60;
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
};
const displayDuration = duration ? formatDuration(duration) : 'Select duration';
return (
<div className="space-y-3">
{label && (
<label className={cn(
"text-sm font-semibold",
isDark ? "text-gray-300" : "text-gray-700"
)}>
{label}
</label>
)}
<div className="flex flex-wrap gap-2">
{options.map((option) => {
const isSelected = duration === option;
return (
<button
key={option}
type="button"
onClick={() => setDuration(option)}
className={cn(
"px-4 py-3 rounded-lg text-sm font-medium transition-all min-w-[80px]",
isSelected
? isDark
? "bg-blue-600 text-white shadow-md"
: "bg-blue-600 text-white shadow-md"
: isDark
? "bg-gray-800 text-gray-300 hover:bg-gray-700 border border-gray-600"
: "bg-white text-gray-700 hover:bg-gray-50 border border-gray-200"
)}
>
{formatDuration(option)}
</button>
);
})}
</div>
</div>
);
}

View File

@ -152,6 +152,7 @@ export function Finances() {
))}
</div>
</motion.div>
</div>
</div>
</section>

View File

@ -3,7 +3,6 @@
import { motion } from "framer-motion";
import { Heart, Mail, Phone, MapPin } from "lucide-react";
import { useAppTheme } from "@/components/ThemeProvider";
import Link from "next/link";
export function Footer() {
const { theme } = useAppTheme();
@ -17,11 +16,10 @@ export function Footer() {
};
const quickLinks = [
{ name: 'Home', href: '#home', isScroll: true },
{ name: 'About', href: '#about', isScroll: true },
{ name: 'Services', href: '#services', isScroll: true },
{ name: 'Contact', href: '#contact', isScroll: true },
{ name: 'Admin Panel', href: '/login', isScroll: false },
{ name: 'Home', href: '#home' },
{ name: 'About', href: '#about' },
{ name: 'Services', href: '#services' },
{ name: 'Contact', href: '#contact' },
];
return (
@ -76,21 +74,12 @@ export function Footer() {
<ul className="space-y-2">
{quickLinks.map((link) => (
<li key={link.name}>
{link.isScroll ? (
<button
onClick={() => scrollToSection(link.href.replace('#', ''))}
className="text-sm text-muted-foreground hover:text-rose-600 dark:hover:text-rose-400 transition-colors cursor-pointer hover:translate-x-1 inline-block transition-transform"
>
{link.name}
</button>
) : (
<Link
href={link.href}
className="text-sm text-muted-foreground hover:text-rose-600 dark:hover:text-rose-400 transition-colors cursor-pointer hover:translate-x-1 inline-block transition-transform"
>
{link.name}
</Link>
)}
<button
onClick={() => scrollToSection(link.href.replace('#', ''))}
className="text-sm text-muted-foreground hover:text-rose-600 dark:hover:text-rose-400 transition-colors cursor-pointer hover:translate-x-1 inline-block transition-transform"
>
{link.name}
</button>
</li>
))}
</ul>
@ -109,10 +98,10 @@ export function Footer() {
<li className="flex items-start gap-3">
<Phone className="h-4 w-4 mt-1 text-rose-600 dark:text-rose-400 flex-shrink-0" />
<a
href="tel:+17548162311"
href="tel:+19548073027"
className="text-sm text-muted-foreground hover:text-rose-600 dark:hover:text-rose-400 transition-colors"
>
(754) 816-2311
(954) 807-3027
</a>
</li>
<li className="flex items-start gap-3">
@ -127,7 +116,7 @@ export function Footer() {
<li className="flex items-start gap-3">
<MapPin className="h-4 w-4 mt-1 text-rose-600 dark:text-rose-400 flex-shrink-0" />
<span className="text-sm text-muted-foreground">
Hollywood, Florida
Miami, Florida
</span>
</li>
</ul>

View File

@ -1,465 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { useAppTheme } from "@/components/ThemeProvider";
import { Input } from "@/components/ui/input";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Eye, EyeOff, Loader2, X, CheckCircle2 } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import {
forgotPasswordSchema,
verifyPasswordResetOtpSchema,
resetPasswordSchema,
type ForgotPasswordInput,
type VerifyPasswordResetOtpInput,
type ResetPasswordInput
} from "@/lib/schema/auth";
import { toast } from "sonner";
interface ForgotPasswordDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
}
type Step = "request" | "verify" | "reset";
export function ForgotPasswordDialog({ open, onOpenChange, onSuccess }: ForgotPasswordDialogProps) {
const { theme } = useAppTheme();
const isDark = theme === "dark";
const {
forgotPasswordMutation,
verifyPasswordResetOtpMutation,
resetPasswordMutation,
resendOtpMutation
} = useAuth();
const [step, setStep] = useState<Step>("request");
const [email, setEmail] = useState("");
const [otpData, setOtpData] = useState<VerifyPasswordResetOtpInput>({
email: "",
otp: "",
});
const [resetData, setResetData] = useState<ResetPasswordInput>({
email: "",
otp: "",
new_password: "",
confirm_password: "",
});
const [showPassword, setShowPassword] = useState(false);
const [showPassword2, setShowPassword2] = useState(false);
const handleRequestOtp = async (e: React.FormEvent) => {
e.preventDefault();
const validation = forgotPasswordSchema.safeParse({ email });
if (!validation.success) {
const firstError = validation.error.issues[0];
toast.error(firstError.message);
return;
}
try {
await forgotPasswordMutation.mutateAsync({ email });
setOtpData({ email, otp: "" });
setResetData({ email, otp: "", new_password: "", confirm_password: "" });
setStep("verify");
toast.success("Password reset OTP sent! Please check your email.");
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to send OTP. Please try again.";
toast.error(errorMessage);
}
};
const handleVerifyOtp = async (e: React.FormEvent) => {
e.preventDefault();
const emailToVerify = email || otpData.email;
if (!emailToVerify) {
toast.error("Email is required");
return;
}
const validation = verifyPasswordResetOtpSchema.safeParse({
email: emailToVerify,
otp: otpData.otp,
});
if (!validation.success) {
const firstError = validation.error.issues[0];
toast.error(firstError.message);
return;
}
try {
await verifyPasswordResetOtpMutation.mutateAsync({
email: emailToVerify,
otp: otpData.otp,
});
setResetData({
email: emailToVerify,
otp: otpData.otp,
new_password: "",
confirm_password: ""
});
setStep("reset");
toast.success("OTP verified! Please set your new password.");
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "OTP verification failed. Please try again.";
toast.error(errorMessage);
}
};
const handleResetPassword = async (e: React.FormEvent) => {
e.preventDefault();
const validation = resetPasswordSchema.safeParse(resetData);
if (!validation.success) {
const firstError = validation.error.issues[0];
toast.error(firstError.message);
return;
}
try {
await resetPasswordMutation.mutateAsync(resetData);
toast.success("Password reset successful! Please log in with your new password.");
handleDialogChange(false);
if (onSuccess) {
onSuccess();
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Password reset failed. Please try again.";
toast.error(errorMessage);
}
};
const handleResendOtp = async () => {
const emailToResend = email || otpData.email;
if (!emailToResend) {
toast.error("Email is required");
return;
}
try {
await resendOtpMutation.mutateAsync({
email: emailToResend,
context: "password_reset"
});
toast.success("OTP resent successfully! Please check your email.");
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to resend OTP";
toast.error(errorMessage);
}
};
const handleOtpChange = (field: keyof VerifyPasswordResetOtpInput, value: string) => {
setOtpData((prev) => ({ ...prev, [field]: value }));
};
// Reset step when dialog closes
const handleDialogChange = (isOpen: boolean) => {
if (!isOpen) {
setStep("request");
setEmail("");
setOtpData({ email: "", otp: "" });
setResetData({ email: "", otp: "", new_password: "", confirm_password: "" });
}
onOpenChange(isOpen);
};
return (
<Dialog open={open} onOpenChange={handleDialogChange}>
<DialogContent
showCloseButton={false}
className={`max-w-md max-h-[90vh] overflow-hidden flex flex-col p-0 ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}
>
{/* Header with Close Button - Fixed */}
<div className="flex items-start justify-between p-6 pb-4 flex-shrink-0 border-b border-gray-200 dark:border-gray-700">
<DialogHeader className="flex-1 pr-2">
<DialogTitle className="text-2xl sm:text-3xl font-bold bg-gradient-to-r from-rose-600 via-pink-600 to-rose-600 bg-clip-text text-transparent">
{step === "request" && "Reset Password"}
{step === "verify" && "Verify OTP"}
{step === "reset" && "Set New Password"}
</DialogTitle>
<DialogDescription className={`text-sm sm:text-base mt-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
{step === "request" && "Enter your email to receive a password reset code"}
{step === "verify" && "Enter the verification code sent to your email"}
{step === "reset" && "Enter your new password"}
</DialogDescription>
</DialogHeader>
{/* Close Button */}
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleDialogChange(false)}
className={`flex-shrink-0 w-8 h-8 rounded-full ${isDark ? 'text-gray-400 hover:text-gray-300 hover:bg-gray-700' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'}`}
aria-label="Close"
>
<X className="w-5 h-5" />
</Button>
</div>
{/* Scrollable Content */}
<div className="overflow-y-auto flex-1 px-6">
{/* Request OTP Form */}
{step === "request" && (
<form className="space-y-4 sm:space-y-5 py-4 sm:py-6" onSubmit={handleRequestOtp}>
<div className="space-y-1.5 sm:space-y-2">
<label htmlFor="forgot-email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Email address *
</label>
<Input
id="forgot-email"
type="email"
placeholder="Email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
className={`h-11 sm:h-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
required
/>
</div>
<Button
type="submit"
disabled={forgotPasswordMutation.isPending}
className="w-full h-11 sm:h-12 text-sm sm:text-base font-semibold bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed mt-4 sm:mt-6"
>
{forgotPasswordMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Sending...
</>
) : (
"Send Reset Code"
)}
</Button>
</form>
)}
{/* Verify OTP Form */}
{step === "verify" && (
<form className="space-y-4 sm:space-y-5 py-4 sm:py-6" onSubmit={handleVerifyOtp}>
<div className={`p-3 sm:p-4 rounded-lg border ${isDark ? 'bg-blue-900/20 border-blue-800' : 'bg-blue-50 border-blue-200'}`}>
<div className="flex items-start gap-3">
<CheckCircle2 className={`w-5 h-5 mt-0.5 flex-shrink-0 ${isDark ? 'text-blue-400' : 'text-blue-600'}`} />
<div>
<p className={`text-sm font-medium ${isDark ? 'text-blue-200' : 'text-blue-900'}`}>
Check your email
</p>
<p className={`text-xs sm:text-sm mt-1 ${isDark ? 'text-blue-300' : 'text-blue-700'}`}>
We've sent a 6-digit verification code to {email || otpData.email || "your email address"}.
</p>
</div>
</div>
</div>
{/* Email Field (if not set) */}
{!email && (
<div className="space-y-1.5 sm:space-y-2">
<label htmlFor="verify-email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Email address *
</label>
<Input
id="verify-email"
type="email"
placeholder="Email address"
value={otpData.email}
onChange={(e) => handleOtpChange("email", e.target.value)}
className={`h-11 sm:h-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
required
/>
</div>
)}
{/* OTP Field */}
<div className="space-y-1.5 sm:space-y-2">
<label className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Verification Code *
</label>
<div className="flex justify-center">
<InputOTP
maxLength={6}
value={otpData.otp}
onChange={(value) => handleOtpChange("otp", value)}
>
<InputOTPGroup className="gap-2 sm:gap-3">
<InputOTPSlot index={0} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
<InputOTPSlot index={1} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
<InputOTPSlot index={2} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
<InputOTPSlot index={3} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
<InputOTPSlot index={4} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
<InputOTPSlot index={5} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
</InputOTPGroup>
</InputOTP>
</div>
</div>
{/* Resend OTP */}
<div className="text-center">
<Button
type="button"
variant="link"
onClick={handleResendOtp}
disabled={resendOtpMutation?.isPending}
className={`h-auto p-0 text-xs sm:text-sm font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
>
{resendOtpMutation?.isPending ? "Sending..." : "Didn't receive the code? Resend"}
</Button>
</div>
{/* Submit Button */}
<Button
type="submit"
disabled={verifyPasswordResetOtpMutation.isPending}
className="w-full h-11 sm:h-12 text-sm sm:text-base font-semibold bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed mt-4 sm:mt-6"
>
{verifyPasswordResetOtpMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Verifying...
</>
) : (
"Verify Code"
)}
</Button>
{/* Back to request */}
<div className="text-center">
<Button
type="button"
variant="link"
onClick={() => {
setStep("request");
setOtpData({ email: "", otp: "" });
}}
className={`h-auto p-0 text-xs sm:text-sm font-medium ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-600 hover:text-gray-700'}`}
>
Back
</Button>
</div>
</form>
)}
{/* Reset Password Form */}
{step === "reset" && (
<form className="space-y-4 sm:space-y-5 py-4 sm:py-6" onSubmit={handleResetPassword}>
{/* New Password Field */}
<div className="space-y-1.5 sm:space-y-2">
<label htmlFor="reset-password" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
New Password *
</label>
<div className="relative">
<Input
id="reset-password"
type={showPassword ? "text" : "password"}
placeholder="New password (min 8 characters)"
value={resetData.new_password}
onChange={(e) => setResetData({ ...resetData, new_password: e.target.value })}
className={`h-11 sm:h-12 pr-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
required
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setShowPassword(!showPassword)}
className={`absolute right-3 sm:right-4 top-1/2 -translate-y-1/2 h-auto w-auto p-0 ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-700'}`}
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? (
<EyeOff className="w-4 h-4 sm:w-5 sm:h-5" />
) : (
<Eye className="w-4 h-4 sm:w-5 sm:h-5" />
)}
</Button>
</div>
</div>
{/* Confirm Password Field */}
<div className="space-y-1.5 sm:space-y-2">
<label htmlFor="reset-password2" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Confirm New Password *
</label>
<div className="relative">
<Input
id="reset-password2"
type={showPassword2 ? "text" : "password"}
placeholder="Confirm new password"
value={resetData.confirm_password}
onChange={(e) => setResetData({ ...resetData, confirm_password: e.target.value })}
className={`h-11 sm:h-12 pr-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
required
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setShowPassword2(!showPassword2)}
className={`absolute right-3 sm:right-4 top-1/2 -translate-y-1/2 h-auto w-auto p-0 ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-700'}`}
aria-label={showPassword2 ? "Hide password" : "Show password"}
>
{showPassword2 ? (
<EyeOff className="w-4 h-4 sm:w-5 sm:h-5" />
) : (
<Eye className="w-4 h-4 sm:w-5 sm:h-5" />
)}
</Button>
</div>
</div>
{/* Submit Button */}
<Button
type="submit"
disabled={resetPasswordMutation.isPending}
className="w-full h-11 sm:h-12 text-sm sm:text-base font-semibold bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed mt-4 sm:mt-6"
>
{resetPasswordMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Resetting password...
</>
) : (
"Reset Password"
)}
</Button>
{/* Back to verify */}
<div className="text-center">
<Button
type="button"
variant="link"
onClick={() => {
setStep("verify");
setResetData({ ...resetData, new_password: "", confirm_password: "" });
}}
className={`h-auto p-0 text-xs sm:text-sm font-medium ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-600 hover:text-gray-700'}`}
>
Back
</Button>
</div>
</form>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -29,7 +29,7 @@ export function HeroSection() {
<div
className="absolute inset-0 z-[1]"
style={{
backgroundColor: isDark ? 'rgba(0, 0, 0, 0.35)' : 'rgba(0, 0, 0, 0.25)'
backgroundColor: isDark ? 'rgba(0, 0, 0, 0.65)' : 'rgba(0, 0, 0, 0.55)'
}}
/>
@ -133,7 +133,7 @@ export function HeroSection() {
>
<a href="/book-now">
<Calendar className="mr-2 h-5 w-5" />
Request Appointment
Book Appointment
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
</a>
</Button>

View File

@ -12,6 +12,32 @@ export function Location() {
const { theme } = useAppTheme();
const isDark = theme === "dark";
const cities = [
"Hollywood, FL",
"Jacksonville, FL",
"Miami, FL",
"Pensacola, FL"
];
const counties = [
"Broward",
"Duval",
"Escambia",
"Miami-dade"
];
const zips = [
"32256",
"32503",
"33021",
"33145"
];
const neighborhoods = [
"Coral Gate",
"Coral Way",
"Royal Lakes"
];
return (
<section
@ -106,10 +132,10 @@ export function Location() {
</div>
<h3 className="text-xl font-semibold text-foreground">Primary Location</h3>
</div>
<p className="text-muted-foreground mb-2">Hollywood, FL</p>
<p className="text-muted-foreground mb-2">Miami, FL 33145</p>
<div className="flex items-center gap-2 text-muted-foreground">
<Phone className="h-4 w-4" />
<span>(754) 816-2311</span>
<span>(954) 807-3027</span>
</div>
</motion.div>
@ -126,14 +152,97 @@ export function Location() {
</div>
<h3 className="text-xl font-semibold text-foreground">Additional Location</h3>
</div>
<p className="text-muted-foreground mb-2">South Miami, FL</p>
<p className="text-muted-foreground mb-2">Hollywood, FL 33021</p>
<div className="flex items-center gap-2 text-muted-foreground">
<Phone className="h-4 w-4" />
<span>(754) 816-2311</span>
<span>(954) 807-3027</span>
</div>
</motion.div>
</div>
{/* Nearby Areas */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={isInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.8, delay: 0.4 }}
className="bg-card/50 backdrop-blur-sm rounded-2xl p-8 border border-border/50"
>
<h3 className="text-2xl font-semibold text-foreground mb-6">Nearby Areas</h3>
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Cities */}
<div>
<h4 className="font-semibold text-foreground mb-3">Cities</h4>
<ul className="space-y-2">
{cities.map((city, index) => (
<motion.li
key={city}
initial={{ opacity: 0, x: -10 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.3, delay: 0.5 + index * 0.05 }}
className="text-sm text-muted-foreground"
>
{city}
</motion.li>
))}
</ul>
</div>
{/* Counties */}
<div>
<h4 className="font-semibold text-foreground mb-3">Counties</h4>
<ul className="space-y-2">
{counties.map((county, index) => (
<motion.li
key={county}
initial={{ opacity: 0, x: -10 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.3, delay: 0.6 + index * 0.05 }}
className="text-sm text-muted-foreground"
>
{county}
</motion.li>
))}
</ul>
</div>
{/* Zips */}
<div>
<h4 className="font-semibold text-foreground mb-3">Zips</h4>
<ul className="space-y-2">
{zips.map((zip, index) => (
<motion.li
key={zip}
initial={{ opacity: 0, x: -10 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.3, delay: 0.7 + index * 0.05 }}
className="text-sm text-muted-foreground"
>
{zip}
</motion.li>
))}
</ul>
</div>
{/* Neighborhoods */}
<div>
<h4 className="font-semibold text-foreground mb-3">Neighborhoods</h4>
<ul className="space-y-2">
{neighborhoods.map((neighborhood, index) => (
<motion.li
key={neighborhood}
initial={{ opacity: 0, x: -10 }}
animate={isInView ? { opacity: 1, x: 0 } : {}}
transition={{ duration: 0.3, delay: 0.8 + index * 0.05 }}
className="text-sm text-muted-foreground"
>
{neighborhood}
</motion.li>
))}
</ul>
</div>
</div>
</motion.div>
</div>
</section>
);

View File

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { useAppTheme } from "@/components/ThemeProvider";
import { Input } from "@/components/ui/input";
@ -11,318 +11,306 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Eye, EyeOff, Loader2, X, Mail } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { loginSchema, type LoginInput } from "@/lib/schema/auth";
import { toast } from "sonner";
import { useRouter, usePathname } from "next/navigation";
import { ForgotPasswordDialog } from "./ForgotPasswordDialog";
import { VerifyOtpDialog } from "./VerifyOtpDialog";
import { Eye, EyeOff, Loader2, X } from "lucide-react";
interface LoginDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onLoginSuccess: () => void;
prefillEmail?: string;
onSwitchToSignup?: () => void;
skipRedirect?: boolean; // Option to skip automatic redirect
}
// Login Dialog component
export function LoginDialog({ open, onOpenChange, onLoginSuccess, prefillEmail, onSwitchToSignup, skipRedirect = false }: LoginDialogProps) {
export function LoginDialog({ open, onOpenChange, onLoginSuccess }: LoginDialogProps) {
const { theme } = useAppTheme();
const isDark = theme === "dark";
const router = useRouter();
const pathname = usePathname();
const { login, loginMutation } = useAuth();
const [loginData, setLoginData] = useState<LoginInput>({
const [isSignup, setIsSignup] = useState(false);
const [loginData, setLoginData] = useState({
email: "",
password: "",
});
const [signupData, setSignupData] = useState({
fullName: "",
email: "",
phone: "",
});
const [showPassword, setShowPassword] = useState(false);
const [forgotPasswordDialogOpen, setForgotPasswordDialogOpen] = useState(false);
const [showResendOtp, setShowResendOtp] = useState(false);
const [verifyOtpDialogOpen, setVerifyOtpDialogOpen] = useState(false);
// Pre-fill email if provided
useEffect(() => {
if (prefillEmail && open) {
setLoginData(prev => ({ ...prev, email: prefillEmail }));
}
}, [prefillEmail, open]);
const [rememberMe, setRememberMe] = useState(false);
const [loginLoading, setLoginLoading] = useState(false);
const [signupLoading, setSignupLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
// Validate form
const validation = loginSchema.safeParse(loginData);
if (!validation.success) {
const firstError = validation.error.issues[0];
toast.error(firstError.message);
return;
}
setLoginLoading(true);
setError(null);
try {
const result = await login(loginData);
// Simulate login API call
await new Promise((resolve) => setTimeout(resolve, 1000));
if (result.tokens && result.user) {
toast.success("Login successful!");
setShowPassword(false);
setShowResendOtp(false);
onOpenChange(false);
// Reset form
setLoginData({ email: "", password: "" });
// 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();
// Only redirect if skipRedirect is false and we're not on the booking page
if (!skipRedirect && pathname !== "/book-now") {
// Redirect based on user role
const redirectPath = userIsAdmin ? "/admin/booking" : "/user/dashboard";
setTimeout(() => {
window.location.href = redirectPath;
}, 200);
}
}
// After successful login, close dialog and call success callback
setShowPassword(false);
setLoginLoading(false);
onOpenChange(false);
onLoginSuccess();
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Login failed. Please try again.";
toast.error(errorMessage);
// Check if error is about email verification
if (errorMessage.toLowerCase().includes("verify your email") ||
errorMessage.toLowerCase().includes("email address before logging")) {
setShowResendOtp(true);
} else {
setShowResendOtp(false);
}
setError("Login failed. Please try again.");
setLoginLoading(false);
}
};
// Handle resend OTP - just open the verification dialog (it will auto-send OTP)
const handleResendOtp = () => {
if (!loginData.email) {
toast.error("Email address is required to resend OTP.");
return;
}
const handleSignup = async (e: React.FormEvent) => {
e.preventDefault();
setSignupLoading(true);
setError(null);
// Close login dialog and open OTP verification dialog
// The VerifyOtpDialog will automatically send the OTP when it opens
setShowResendOtp(false);
onOpenChange(false);
setTimeout(() => {
setVerifyOtpDialogOpen(true);
}, 100);
};
// Handle OTP verification success
const handleOtpVerificationSuccess = () => {
// After successful verification, user can try logging in again
setVerifyOtpDialogOpen(false);
// Optionally reopen login dialog
setTimeout(() => {
onOpenChange(true);
}, 100);
};
// Reset form when dialog closes
const handleDialogChange = (isOpen: boolean) => {
if (!isOpen) {
setLoginData({ email: "", password: "" });
setShowResendOtp(false);
try {
// Simulate signup API call
await new Promise((resolve) => setTimeout(resolve, 1000));
// After successful signup, automatically log in and proceed
setSignupLoading(false);
onOpenChange(false);
onLoginSuccess();
} catch (err) {
setError("Signup failed. Please try again.");
setSignupLoading(false);
}
onOpenChange(isOpen);
};
const handleSwitchToSignup = () => {
setIsSignup(true);
setError(null);
setLoginData({ email: "", password: "" });
};
const handleSwitchToLogin = () => {
setIsSignup(false);
setError(null);
setSignupData({ fullName: "", email: "", phone: "" });
};
return (
<Dialog open={open} onOpenChange={handleDialogChange}>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
className={`max-w-md max-h-[90vh] overflow-hidden flex flex-col p-0 ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}
className={`sm:max-w-md ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}
>
{/* Header with Close Button - Fixed */}
<div className="flex items-start justify-between p-6 pb-4 flex-shrink-0 border-b border-gray-200 dark:border-gray-700">
<DialogHeader className="flex-1 pr-2">
<DialogTitle className="text-2xl sm:text-3xl font-bold bg-gradient-to-r from-rose-600 via-pink-600 to-rose-600 bg-clip-text text-transparent">
Welcome back
{/* Header with Close Button */}
<div className="flex items-start justify-between mb-2">
<DialogHeader className="flex-1">
<DialogTitle className="text-3xl font-bold bg-gradient-to-r from-rose-600 via-pink-600 to-rose-600 bg-clip-text text-transparent">
{isSignup ? "Create an account" : "Welcome back"}
</DialogTitle>
<DialogDescription className={`text-sm sm:text-base mt-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
Please log in to complete your booking
<DialogDescription className={isDark ? 'text-gray-400' : 'text-gray-600'}>
{isSignup
? "Sign up to complete your booking"
: "Please log in to complete your booking"}
</DialogDescription>
</DialogHeader>
{/* Close Button */}
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleDialogChange(false)}
className={`flex-shrink-0 w-8 h-8 rounded-full ${isDark ? 'text-gray-400 hover:text-gray-300 hover:bg-gray-700' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'}`}
<button
onClick={() => onOpenChange(false)}
className={`flex-shrink-0 w-8 h-8 rounded-full flex items-center justify-center transition-colors ${isDark ? 'text-gray-400 hover:text-gray-300 hover:bg-gray-700' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'}`}
aria-label="Close"
>
<X className="w-5 h-5" />
</Button>
</button>
</div>
{/* Scrollable Content */}
<div className="overflow-y-auto flex-1 px-6">
{/* Login Form */}
<form className="space-y-4 sm:space-y-5 py-4 sm:py-6" onSubmit={handleLogin}>
{/* Email Field */}
<div className="space-y-1.5 sm:space-y-2">
<label htmlFor="login-email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Email address
{/* Signup Form */}
{isSignup ? (
<form className="space-y-6 mt-4" onSubmit={handleSignup}>
{error && (
<div className={`p-3 rounded-lg border ${isDark ? 'bg-red-900/20 border-red-800' : 'bg-red-50 border-red-200'}`}>
<p className={`text-sm ${isDark ? 'text-red-200' : 'text-red-800'}`}>{error}</p>
</div>
)}
{/* Full Name Field */}
<div className="space-y-2">
<label htmlFor="signup-fullName" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Full Name *
</label>
<Input
id="login-email"
type="email"
placeholder="Email address"
value={loginData.email}
onChange={(e) => setLoginData({ ...loginData, email: e.target.value })}
className={`h-11 sm:h-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
id="signup-fullName"
type="text"
placeholder="John Doe"
value={signupData.fullName}
onChange={(e) => setSignupData({ ...signupData, fullName: e.target.value })}
className={`h-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
required
/>
</div>
{/* Password Field */}
<div className="space-y-1.5 sm:space-y-2">
<label htmlFor="login-password" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Your password
{/* Email Field */}
<div className="space-y-2">
<label htmlFor="signup-email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Email address *
</label>
<div className="relative">
<Input
id="login-password"
type={showPassword ? "text" : "password"}
placeholder="Your password"
value={loginData.password}
onChange={(e) => setLoginData({ ...loginData, password: e.target.value })}
className={`h-11 sm:h-12 pr-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
required
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setShowPassword(!showPassword)}
className={`absolute right-3 sm:right-4 top-1/2 -translate-y-1/2 h-auto w-auto p-0 ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-700'}`}
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? (
<EyeOff className="w-4 h-4 sm:w-5 sm:h-5" />
) : (
<Eye className="w-4 h-4 sm:w-5 sm:h-5" />
)}
</Button>
</div>
<Input
id="signup-email"
type="email"
placeholder="Email address"
value={signupData.email}
onChange={(e) => setSignupData({ ...signupData, email: e.target.value })}
className={`h-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
required
/>
</div>
{/* Phone Field */}
<div className="space-y-2">
<label htmlFor="signup-phone" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Phone Number *
</label>
<Input
id="signup-phone"
type="tel"
placeholder="+1 (555) 123-4567"
value={signupData.phone}
onChange={(e) => setSignupData({ ...signupData, phone: e.target.value })}
className={`h-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
required
/>
</div>
{/* Submit Button */}
<Button
type="submit"
disabled={loginMutation.isPending}
className="w-full h-11 sm:h-12 text-sm sm:text-base font-semibold bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed mt-4 sm:mt-6"
disabled={signupLoading}
className="w-full h-12 text-base font-semibold bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loginMutation.isPending ? (
{signupLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Logging in...
Creating account...
</>
) : (
"Log in"
"Sign up"
)}
</Button>
{/* Resend OTP - Show when email verification error occurs */}
{showResendOtp && (
<div className={`p-3 sm:p-4 rounded-lg border ${isDark ? 'bg-yellow-900/20 border-yellow-800' : 'bg-yellow-50 border-yellow-200'}`}>
<div className="flex items-start gap-3">
<Mail className={`w-5 h-5 mt-0.5 flex-shrink-0 ${isDark ? 'text-yellow-400' : 'text-yellow-600'}`} />
<div className="flex-1">
<p className={`text-sm font-medium ${isDark ? 'text-yellow-200' : 'text-yellow-900'}`}>
Email verification required
</p>
<p className={`text-xs sm:text-sm mt-1 ${isDark ? 'text-yellow-300' : 'text-yellow-700'}`}>
Please verify your email address before logging in. We can resend the verification code to {loginData.email}.
</p>
<Button
type="button"
variant="link"
onClick={handleResendOtp}
className={`h-auto p-0 mt-2 text-xs sm:text-sm font-medium ${isDark ? 'text-yellow-400 hover:text-yellow-300' : 'text-yellow-700 hover:text-yellow-800'}`}
>
Resend verification code
</Button>
</div>
</div>
</div>
)}
{/* Forgot Password */}
<div className="flex items-center justify-end text-xs sm:text-sm">
<Button
{/* Switch to Login */}
<p className={`text-sm text-center ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
Already have an account?{" "}
<button
type="button"
variant="link"
className={`font-medium text-xs sm:text-sm h-auto p-0 ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
onClick={() => {
handleDialogChange(false);
setTimeout(() => {
setForgotPasswordDialogOpen(true);
}, 100);
}}
onClick={handleSwitchToLogin}
className={`underline font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
>
Forgot password?
</Button>
</div>
{/* Sign Up Prompt */}
<p className={`text-xs sm:text-sm text-center pt-2 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
New to Attune Heart Therapy?{" "}
<Button
type="button"
variant="link"
onClick={() => {
handleDialogChange(false);
if (onSwitchToSignup) {
onSwitchToSignup();
}
}}
className={`h-auto p-0 font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
>
Sign up
</Button>
Log in
</button>
</p>
</form>
</div>
</DialogContent>
{/* Forgot Password Dialog */}
<ForgotPasswordDialog
open={forgotPasswordDialogOpen}
onOpenChange={setForgotPasswordDialogOpen}
/>
) : (
/* Login Form */
<form className="space-y-6 mt-4" onSubmit={handleLogin}>
{error && (
<div className={`p-3 rounded-lg border ${isDark ? 'bg-red-900/20 border-red-800' : 'bg-red-50 border-red-200'}`}>
<p className={`text-sm ${isDark ? 'text-red-200' : 'text-red-800'}`}>{error}</p>
</div>
)}
{/* Verify OTP Dialog */}
<VerifyOtpDialog
open={verifyOtpDialogOpen}
onOpenChange={setVerifyOtpDialogOpen}
email={loginData.email}
context="registration"
onVerificationSuccess={handleOtpVerificationSuccess}
/>
{/* Email Field */}
<div className="space-y-2">
<label htmlFor="login-email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Email address
</label>
<Input
id="login-email"
type="email"
placeholder="Email address"
value={loginData.email}
onChange={(e) => setLoginData({ ...loginData, email: e.target.value })}
className={`h-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
required
/>
</div>
{/* Password Field */}
<div className="space-y-2">
<label htmlFor="login-password" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Your password
</label>
<div className="relative">
<Input
id="login-password"
type={showPassword ? "text" : "password"}
placeholder="Your password"
value={loginData.password}
onChange={(e) => setLoginData({ ...loginData, password: e.target.value })}
className={`h-12 pr-12 ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
required
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setShowPassword(!showPassword)}
className={`absolute right-4 top-1/2 -translate-y-1/2 h-auto w-auto p-0 ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-700'}`}
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</Button>
</div>
</div>
{/* Submit Button */}
<Button
type="submit"
disabled={loginLoading}
className="w-full h-12 text-base font-semibold bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loginLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Logging in...
</>
) : (
"Log in"
)}
</Button>
{/* Remember Me & Forgot Password */}
<div className="flex items-center justify-between text-sm">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className={`w-4 h-4 rounded text-rose-600 focus:ring-2 focus:ring-rose-500 cursor-pointer ${isDark ? 'border-gray-600 bg-gray-700' : 'border-gray-300'}`}
/>
<span className={isDark ? 'text-gray-300' : 'text-black'}>Remember me</span>
</label>
<button
type="button"
className={`font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
onClick={() => onOpenChange(false)}
>
Forgot password?
</button>
</div>
{/* Sign Up Prompt */}
<p className={`text-sm text-center ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
New to Attune Heart Therapy?{" "}
<button
type="button"
onClick={handleSwitchToSignup}
className={`underline font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
>
Sign up
</button>
</p>
</form>
)}
</DialogContent>
</Dialog>
);
}

View File

@ -2,33 +2,20 @@
import { motion, AnimatePresence } from "framer-motion";
import { Button } from "@/components/ui/button";
import { Heart, Menu, X, LogOut, LayoutGrid } from "lucide-react";
import { Heart, Menu, X } from "lucide-react";
import { ThemeToggle } from "@/components/ThemeToggle";
import { useEffect, useState } from "react";
import { LoginDialog } from "@/components/LoginDialog";
import { SignupDialog } from "@/components/SignupDialog";
import { useRouter, usePathname } from "next/navigation";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useAppTheme } from "@/components/ThemeProvider";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
export function Navbar() {
const { theme } = useAppTheme();
const isDark = theme === "dark";
const [loginDialogOpen, setLoginDialogOpen] = useState(false);
const [signupDialogOpen, setSignupDialogOpen] = useState(false);
const [prefillEmail, setPrefillEmail] = useState<string | undefined>(undefined);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const router = useRouter();
const pathname = usePathname();
const isUserDashboard = pathname?.startsWith("/user/dashboard");
const isAdminDashboard = pathname?.startsWith("/admin/dashboard");
const isUserSettings = pathname?.startsWith("/user/settings");
const isAdminSettings = pathname?.startsWith("/admin/settings");
const isUserAppointmentDetails = pathname?.startsWith("/user/appointments/");
const isUserRoute = pathname?.startsWith("/user/");
const { isAuthenticated, logout, user, isAdmin } = useAuth();
const scrollToSection = (id: string) => {
const element = document.getElementById(id);
@ -39,21 +26,11 @@ export function Navbar() {
};
const handleLoginSuccess = () => {
// 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/booking" : "/user/dashboard";
router.push(redirectPath);
// Redirect to user dashboard after successful login
router.push("/user/dashboard");
setMobileMenuOpen(false);
};
const handleLogout = () => {
logout();
toast.success("Logged out successfully");
setMobileMenuOpen(false);
router.push("/");
};
// Close mobile menu when clicking outside
useEffect(() => {
if (mobileMenuOpen) {
@ -83,10 +60,7 @@ export function Navbar() {
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<Link
href="/"
className="flex items-center gap-1.5 sm:gap-2"
>
<Link href="/" className="flex items-center gap-1.5 sm:gap-2">
<div className="bg-gradient-to-r from-rose-500 to-pink-600 p-1.5 sm:p-2 rounded-lg sm:rounded-xl">
<Heart className="h-4 w-4 sm:h-5 sm:w-5 text-white fill-white" />
</div>
@ -97,72 +71,41 @@ export function Navbar() {
</motion.div>
{/* Desktop Navigation */}
{!isUserRoute && (
<div className="hidden lg:flex items-center gap-4 xl:gap-6">
<button
onClick={() => scrollToSection("about")}
className={`text-sm font-medium transition-colors cursor-pointer px-3 py-2 rounded-lg ${isDark ? 'text-gray-300 hover:text-white hover:bg-gray-800' : 'text-gray-700 hover:text-primary hover:bg-gray-100'}`}
>
About
</button>
<button
onClick={() => scrollToSection("services")}
className={`text-sm font-medium transition-colors cursor-pointer px-3 py-2 rounded-lg ${isDark ? 'text-gray-300 hover:text-white hover:bg-gray-800' : 'text-gray-700 hover:text-primary hover:bg-gray-100'}`}
>
Services
</button>
<button
onClick={() => scrollToSection("contact")}
className={`text-sm font-medium transition-colors cursor-pointer px-3 py-2 rounded-lg ${isDark ? 'text-gray-300 hover:text-white hover:bg-gray-800' : 'text-gray-700 hover:text-primary hover:bg-gray-100'}`}
>
Contact
</button>
</div>
)}
<div className="hidden lg:flex items-center gap-4 xl:gap-6">
<button
onClick={() => scrollToSection("about")}
className={`text-sm font-medium transition-colors cursor-pointer px-3 py-2 rounded-lg ${isDark ? 'text-gray-300 hover:text-white hover:bg-gray-800' : 'text-gray-700 hover:text-primary hover:bg-gray-100'}`}
>
About
</button>
<button
onClick={() => scrollToSection("services")}
className={`text-sm font-medium transition-colors cursor-pointer px-3 py-2 rounded-lg ${isDark ? 'text-gray-300 hover:text-white hover:bg-gray-800' : 'text-gray-700 hover:text-primary hover:bg-gray-100'}`}
>
Services
</button>
<button
onClick={() => scrollToSection("contact")}
className={`text-sm font-medium transition-colors cursor-pointer px-3 py-2 rounded-lg ${isDark ? 'text-gray-300 hover:text-white hover:bg-gray-800' : 'text-gray-700 hover:text-primary hover:bg-gray-100'}`}
>
Contact
</button>
</div>
{/* Desktop Actions */}
<div className="hidden lg:flex items-center gap-2">
{!isAuthenticated && !isUserDashboard && (
<Button
size="sm"
variant="outline"
className={`hover:opacity-90 hover:scale-105 transition-all text-xs sm:text-sm ${isDark ? 'border-gray-700 text-gray-300 hover:bg-gray-800' : ''}`}
onClick={() => setLoginDialogOpen(true)}
>
Sign In
</Button>
)}
<Button
size="sm"
variant="outline"
className={`hover:opacity-90 hover:scale-105 transition-all text-xs sm:text-sm ${isDark ? 'border-gray-700 text-gray-300 hover:bg-gray-800' : ''}`}
onClick={() => setLoginDialogOpen(true)}
>
Sign In
</Button>
<ThemeToggle />
{!isAdmin && (
<Link
href="/book-now"
className={`text-sm font-medium transition-colors cursor-pointer px-3 py-2 rounded-lg hover:opacity-90 ${isDark ? 'text-gray-300 hover:text-white' : 'text-gray-700 hover:text-rose-600'}`}
>
Book-Now
</Link>
)}
{isAuthenticated && (
<>
{!(isUserDashboard || isAdminDashboard || isUserSettings || isAdminSettings || isUserAppointmentDetails) && (
<Link
href={isAdmin ? "/admin/dashboard" : "/user/dashboard"}
className={`text-sm font-medium transition-colors cursor-pointer px-3 py-2 rounded-lg hover:opacity-90 ${isDark ? 'text-gray-300 hover:text-white' : 'text-gray-700 hover:text-rose-600'}`}
>
<LayoutGrid className="w-4 h-4 inline mr-1.5" />
Dashboard
</Link>
)}
<Button
size="sm"
variant="outline"
className="bg-red-600 hover:bg-red-700 text-white border-red-600 hover:border-red-700 hover:opacity-90 hover:scale-105 transition-all text-xs sm:text-sm"
onClick={handleLogout}
>
<LogOut className="w-4 h-4 mr-2" />
Logout
</Button>
</>
)}
<Button size="sm" className="hover:opacity-90 hover:scale-105 transition-all text-xs sm:text-sm" asChild>
<a href="/book-now">Book Now</a>
</Button>
</div>
{/* Mobile Actions */}
@ -212,75 +155,44 @@ export function Navbar() {
>
<div className="flex flex-col p-4 sm:p-6 space-y-3 sm:space-y-4">
{/* Mobile Navigation Links */}
{!isUserRoute && (
<>
<button
onClick={() => scrollToSection("about")}
className={`text-left text-sm sm:text-base font-medium py-2.5 sm:py-3 px-3 sm:px-4 rounded-lg transition-colors ${isDark ? 'text-gray-300 hover:bg-gray-800' : 'text-gray-700 hover:bg-gray-100'}`}
>
About
</button>
<button
onClick={() => scrollToSection("services")}
className={`text-left text-sm sm:text-base font-medium py-2.5 sm:py-3 px-3 sm:px-4 rounded-lg transition-colors ${isDark ? 'text-gray-300 hover:bg-gray-800' : 'text-gray-700 hover:bg-gray-100'}`}
>
Services
</button>
<button
onClick={() => scrollToSection("contact")}
className={`text-left text-sm sm:text-base font-medium py-2.5 sm:py-3 px-3 sm:px-4 rounded-lg transition-colors ${isDark ? 'text-gray-300 hover:bg-gray-800' : 'text-gray-700 hover:bg-gray-100'}`}
>
Contact
</button>
</>
)}
<button
onClick={() => scrollToSection("about")}
className={`text-left text-sm sm:text-base font-medium py-2.5 sm:py-3 px-3 sm:px-4 rounded-lg transition-colors ${isDark ? 'text-gray-300 hover:bg-gray-800' : 'text-gray-700 hover:bg-gray-100'}`}
>
About
</button>
<button
onClick={() => scrollToSection("services")}
className={`text-left text-sm sm:text-base font-medium py-2.5 sm:py-3 px-3 sm:px-4 rounded-lg transition-colors ${isDark ? 'text-gray-300 hover:bg-gray-800' : 'text-gray-700 hover:bg-gray-100'}`}
>
Services
</button>
<button
onClick={() => scrollToSection("contact")}
className={`text-left text-sm sm:text-base font-medium py-2.5 sm:py-3 px-3 sm:px-4 rounded-lg transition-colors ${isDark ? 'text-gray-300 hover:bg-gray-800' : 'text-gray-700 hover:bg-gray-100'}`}
>
Contact
</button>
<div className={`border-t pt-3 sm:pt-4 mt-3 sm:mt-4 space-y-2 sm:space-y-3 ${isDark ? 'border-gray-700' : 'border-gray-200'}`}>
{!isAuthenticated && !isUserDashboard && (
<Button
variant="outline"
className={`w-full justify-start text-sm sm:text-base ${isDark ? 'border-gray-700 text-gray-300 hover:bg-gray-800' : ''}`}
onClick={() => {
setLoginDialogOpen(true);
setMobileMenuOpen(false);
}}
>
Sign In
</Button>
)}
{!isAdmin && (
<Link
href="/book-now"
onClick={() => setMobileMenuOpen(false)}
className={`text-left text-sm sm:text-base font-medium py-2.5 sm:py-3 px-3 sm:px-4 rounded-lg transition-colors ${isDark ? 'text-gray-300 hover:bg-gray-800' : 'text-gray-700 hover:bg-gray-100'}`}
>
Book-Now
<Button
variant="outline"
className={`w-full justify-start text-sm sm:text-base ${isDark ? 'border-gray-700 text-gray-300 hover:bg-gray-800' : ''}`}
onClick={() => {
setLoginDialogOpen(true);
setMobileMenuOpen(false);
}}
>
Sign In
</Button>
<Button
className="w-full justify-start bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white text-sm sm:text-base"
asChild
>
<Link href="/book-now" onClick={() => setMobileMenuOpen(false)}>
Book Now
</Link>
)}
{isAuthenticated && (
<>
{!(isUserDashboard || isAdminDashboard || isUserSettings || isAdminSettings || isUserAppointmentDetails) && (
<Link
href={isAdmin ? "/admin/dashboard" : "/user/dashboard"}
onClick={() => setMobileMenuOpen(false)}
className={`text-left text-sm sm:text-base font-medium py-2.5 sm:py-3 px-3 sm:px-4 rounded-lg transition-colors flex items-center gap-2 ${isDark ? 'text-gray-300 hover:bg-gray-800' : 'text-gray-700 hover:bg-gray-100'}`}
>
<LayoutGrid className="w-4 h-4" />
Dashboard
</Link>
)}
<Button
variant="outline"
className="w-full justify-start text-sm sm:text-base bg-red-600 hover:bg-red-700 text-white border-red-600 hover:border-red-700"
onClick={() => {
handleLogout();
}}
>
<LogOut className="w-4 h-4 mr-2" />
Logout
</Button>
</>
)}
</Button>
</div>
</div>
</motion.div>
@ -293,28 +205,6 @@ export function Navbar() {
open={loginDialogOpen}
onOpenChange={setLoginDialogOpen}
onLoginSuccess={handleLoginSuccess}
prefillEmail={prefillEmail}
onSwitchToSignup={() => {
setLoginDialogOpen(false);
// Small delay to ensure dialog closes before opening signup
setTimeout(() => {
setSignupDialogOpen(true);
}, 100);
}}
/>
{/* Signup Dialog */}
<SignupDialog
open={signupDialogOpen}
onOpenChange={setSignupDialogOpen}
onSwitchToLogin={(email?: string) => {
setSignupDialogOpen(false);
setPrefillEmail(email);
// Small delay to ensure dialog closes before opening login
setTimeout(() => {
setLoginDialogOpen(true);
}, 100);
}}
/>
</motion.nav>
);

View File

@ -1,174 +0,0 @@
'use client';
import * as React from 'react';
import { CalendarCheck, Loader2, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { DatePicker } from '@/components/DatePicker';
import { ClockTimePicker } from '@/components/ClockTimePicker';
import { DurationPicker } from '@/components/DurationPicker';
import type { Appointment } from '@/lib/models/appointments';
interface ScheduleAppointmentDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
appointment: Appointment | null;
scheduledDate: Date | undefined;
setScheduledDate: (date: Date | undefined) => void;
scheduledTime: string;
setScheduledTime: (time: string) => void;
scheduledDuration: number;
setScheduledDuration: (duration: number) => void;
onSchedule: () => Promise<void>;
isScheduling: boolean;
isDark?: boolean;
title?: string;
description?: string;
}
export function ScheduleAppointmentDialog({
open,
onOpenChange,
appointment,
scheduledDate,
setScheduledDate,
scheduledTime,
setScheduledTime,
scheduledDuration,
setScheduledDuration,
onSchedule,
isScheduling,
isDark = false,
title,
description,
}: ScheduleAppointmentDialogProps) {
const formatDate = (date: Date) => {
return date.toLocaleDateString("en-US", {
weekday: "long",
month: "long",
day: "numeric",
year: "numeric",
});
};
const formatTime = (timeString: string) => {
const [hours, minutes] = timeString.split(":").map(Number);
const date = new Date();
date.setHours(hours);
date.setMinutes(minutes);
return date.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={`max-w-4xl max-h-[90vh] overflow-y-auto ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<DialogHeader className="pb-4">
<DialogTitle className={`text-2xl font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
{title || "Schedule Appointment"}
</DialogTitle>
<DialogDescription className={`text-base ${isDark ? "text-gray-400" : "text-gray-500"}`}>
{description || (appointment
? `Set date and time for ${appointment.first_name} ${appointment.last_name}'s appointment`
: "Set date and time for this appointment")}
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Date Selection */}
<div className="space-y-3">
<label className={`text-sm font-semibold ${isDark ? "text-gray-300" : "text-gray-700"}`}>
Select Date *
</label>
<div className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}>
<DatePicker
date={scheduledDate}
setDate={setScheduledDate}
/>
</div>
</div>
{/* Time Selection */}
<div className="space-y-3 -mt-2">
<div className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}>
<ClockTimePicker
time={scheduledTime}
setTime={setScheduledTime}
label="Select Time *"
isDark={isDark}
/>
</div>
</div>
{/* Duration Selection */}
<div className="space-y-3">
<div className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}>
<DurationPicker
duration={scheduledDuration}
setDuration={setScheduledDuration}
label="Duration"
isDark={isDark}
/>
</div>
</div>
{/* Preview */}
{scheduledDate && scheduledTime && (
<div className={`p-4 rounded-xl border ${isDark ? "bg-blue-500/10 border-blue-500/30" : "bg-blue-50 border-blue-200"}`}>
<p className={`text-sm font-medium mb-2 ${isDark ? "text-blue-300" : "text-blue-700"}`}>
Appointment Preview
</p>
<div className="space-y-1">
<p className={`text-base font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
{formatDate(scheduledDate)}
</p>
<p className={`text-sm ${isDark ? "text-gray-300" : "text-gray-700"}`}>
{formatTime(scheduledTime)} {scheduledDuration} minutes
</p>
</div>
</div>
)}
</div>
<DialogFooter className="gap-3 pt-4">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isScheduling}
className={`h-12 px-6 ${isDark ? "border-gray-700 text-gray-300 hover:bg-gray-700" : ""}`}
>
Cancel
</Button>
<Button
onClick={onSchedule}
disabled={isScheduling || !scheduledDate || !scheduledTime}
className="h-12 px-6 bg-blue-600 hover:bg-blue-700 text-white"
>
{isScheduling ? (
<>
<Loader2 className="w-5 h-5 mr-2 animate-spin" />
Scheduling...
</>
) : (
<>
<CalendarCheck className="w-5 h-5 mr-2" />
Schedule Appointment
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -220,8 +220,8 @@ export function Services() {
animate={isInView ? { opacity: 1 } : {}}
transition={{ duration: 0.8, delay: 0.9 }}
>
Therapy is tailored for children (age 010), teens, adults, and older adults, with services offered to individuals and a
special focus on supporting Black and African American families and all of South Florida's Diverse Communities.
Therapy is tailored for children (age 610), teens, adults, and older adults, with services offered to individuals.
I welcome clients from diverse backgrounds and communities throughout Miami and Hollywood, Florida.
</motion.p>
</div>
</motion.div>

View File

@ -1,479 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { useAppTheme } from "@/components/ThemeProvider";
import { Input } from "@/components/ui/input";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Eye, EyeOff, Loader2, X, CheckCircle2 } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { registerSchema, verifyOtpSchema, type RegisterInput, type VerifyOtpInput } from "@/lib/schema/auth";
import { toast } from "sonner";
interface SignupDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSignupSuccess?: () => void;
onSwitchToLogin?: (email?: string) => void;
}
type Step = "signup" | "verify";
export function SignupDialog({ open, onOpenChange, onSignupSuccess, onSwitchToLogin }: SignupDialogProps) {
const { theme } = useAppTheme();
const isDark = theme === "dark";
const { register, verifyOtp, registerMutation, verifyOtpMutation, resendOtpMutation } = useAuth();
const [step, setStep] = useState<Step>("signup");
const [registeredEmail, setRegisteredEmail] = useState("");
const [signupData, setSignupData] = useState<RegisterInput>({
first_name: "",
last_name: "",
email: "",
phone_number: "",
password: "",
password2: "",
});
const [otpData, setOtpData] = useState<VerifyOtpInput>({
email: "",
otp: "",
});
const [showPassword, setShowPassword] = useState(false);
const [showPassword2, setShowPassword2] = useState(false);
const handleSignup = async (e: React.FormEvent) => {
e.preventDefault();
// Validate form
const validation = registerSchema.safeParse(signupData);
if (!validation.success) {
const firstError = validation.error.issues[0];
toast.error(firstError.message);
return;
}
try {
const result = await register(signupData);
// Always switch to OTP verification step after successful registration
const email = signupData.email;
setRegisteredEmail(email);
setOtpData({ email: email, otp: "" });
// Clear signup form
setSignupData({
first_name: "",
last_name: "",
email: "",
phone_number: "",
password: "",
password2: "",
});
// Switch to verify step
setStep("verify");
toast.success("Registration successful! Please check your email for OTP verification.");
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Signup failed. Please try again.";
toast.error(errorMessage);
}
};
const handleVerifyOtp = async (e: React.FormEvent) => {
e.preventDefault();
const emailToVerify = registeredEmail || otpData.email;
if (!emailToVerify) {
toast.error("Email is required");
return;
}
const validation = verifyOtpSchema.safeParse({
email: emailToVerify,
otp: otpData.otp,
});
if (!validation.success) {
const firstError = validation.error.issues[0];
toast.error(firstError.message);
return;
}
try {
const result = await verifyOtp({
email: emailToVerify,
otp: otpData.otp,
});
if (result.message) {
toast.success("Email verified successfully! Please log in.");
// Close signup dialog and open login dialog with email
const emailToPass = emailToVerify;
onOpenChange(false);
// Call onSwitchToLogin with email to open login dialog with pre-filled email
if (onSwitchToLogin) {
onSwitchToLogin(emailToPass);
}
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "OTP verification failed. Please try again.";
toast.error(errorMessage);
}
};
const handleResendOtp = async () => {
const emailToResend = registeredEmail || otpData.email;
if (!emailToResend) {
toast.error("Email is required");
return;
}
try {
await resendOtpMutation.mutateAsync({ email: emailToResend, context: "registration" });
toast.success("OTP resent successfully! Please check your email.");
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to resend OTP";
toast.error(errorMessage);
}
};
const handleOtpChange = (field: keyof VerifyOtpInput, value: string) => {
setOtpData((prev) => ({ ...prev, [field]: value }));
};
// Reset step when dialog closes
const handleDialogChange = (isOpen: boolean) => {
if (!isOpen) {
setStep("signup");
setRegisteredEmail("");
setOtpData({ email: "", otp: "" });
setSignupData({
first_name: "",
last_name: "",
email: "",
phone_number: "",
password: "",
password2: "",
});
}
onOpenChange(isOpen);
};
return (
<Dialog open={open} onOpenChange={handleDialogChange}>
<DialogContent
showCloseButton={false}
className={`max-w-md max-h-[90vh] overflow-hidden flex flex-col p-0 ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}
>
{/* Header with Close Button - Fixed */}
<div className="flex items-start justify-between p-6 pb-4 flex-shrink-0 border-b border-gray-200 dark:border-gray-700">
<DialogHeader className="flex-1 pr-2">
<DialogTitle className="text-2xl sm:text-3xl font-bold bg-gradient-to-r from-rose-600 via-pink-600 to-rose-600 bg-clip-text text-transparent">
{step === "signup" && "Create an account"}
{step === "verify" && "Verify your email"}
</DialogTitle>
<DialogDescription className={`text-sm sm:text-base mt-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
{step === "signup" && "Sign up to complete your booking"}
{step === "verify" && "Enter the verification code sent to your email"}
</DialogDescription>
</DialogHeader>
{/* Close Button */}
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleDialogChange(false)}
className={`flex-shrink-0 w-8 h-8 rounded-full ${isDark ? 'text-gray-400 hover:text-gray-300 hover:bg-gray-700' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'}`}
aria-label="Close"
>
<X className="w-5 h-5" />
</Button>
</div>
{/* Scrollable Content */}
<div className="overflow-y-auto flex-1 px-6">
{/* Signup Form */}
{step === "signup" && (
<form className="space-y-4 sm:space-y-5 py-4 sm:py-6" onSubmit={handleSignup}>
{/* First Name Field */}
<div className="space-y-1.5 sm:space-y-2">
<label htmlFor="signup-firstName" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
First Name *
</label>
<Input
id="signup-firstName"
type="text"
placeholder="John"
value={signupData.first_name}
onChange={(e) => setSignupData({ ...signupData, first_name: e.target.value })}
className={`h-11 sm:h-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
required
/>
</div>
{/* Last Name Field */}
<div className="space-y-1.5 sm:space-y-2">
<label htmlFor="signup-lastName" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Last Name *
</label>
<Input
id="signup-lastName"
type="text"
placeholder="Doe"
value={signupData.last_name}
onChange={(e) => setSignupData({ ...signupData, last_name: e.target.value })}
className={`h-11 sm:h-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
required
/>
</div>
{/* Email Field */}
<div className="space-y-1.5 sm:space-y-2">
<label htmlFor="signup-email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Email address *
</label>
<Input
id="signup-email"
type="email"
placeholder="Email address"
value={signupData.email}
onChange={(e) => setSignupData({ ...signupData, email: e.target.value })}
className={`h-11 sm:h-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
required
/>
</div>
{/* Phone Field */}
<div className="space-y-1.5 sm:space-y-2">
<label htmlFor="signup-phone" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Phone Number (Optional)
</label>
<Input
id="signup-phone"
type="tel"
placeholder="+1 (555) 123-4567"
value={signupData.phone_number || ""}
onChange={(e) => setSignupData({ ...signupData, phone_number: e.target.value })}
className={`h-11 sm:h-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
/>
</div>
{/* Password Field */}
<div className="space-y-1.5 sm:space-y-2">
<label htmlFor="signup-password" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Password *
</label>
<div className="relative">
<Input
id="signup-password"
type={showPassword ? "text" : "password"}
placeholder="Password (min 8 characters)"
value={signupData.password}
onChange={(e) => setSignupData({ ...signupData, password: e.target.value })}
className={`h-11 sm:h-12 pr-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
required
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setShowPassword(!showPassword)}
className={`absolute right-3 sm:right-4 top-1/2 -translate-y-1/2 h-auto w-auto p-0 ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-700'}`}
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? (
<EyeOff className="w-4 h-4 sm:w-5 sm:h-5" />
) : (
<Eye className="w-4 h-4 sm:w-5 sm:h-5" />
)}
</Button>
</div>
</div>
{/* Confirm Password Field */}
<div className="space-y-1.5 sm:space-y-2">
<label htmlFor="signup-password2" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Confirm Password *
</label>
<div className="relative">
<Input
id="signup-password2"
type={showPassword2 ? "text" : "password"}
placeholder="Confirm password"
value={signupData.password2}
onChange={(e) => setSignupData({ ...signupData, password2: e.target.value })}
className={`h-11 sm:h-12 pr-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
required
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setShowPassword2(!showPassword2)}
className={`absolute right-3 sm:right-4 top-1/2 -translate-y-1/2 h-auto w-auto p-0 ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-500 hover:text-gray-700'}`}
aria-label={showPassword2 ? "Hide password" : "Show password"}
>
{showPassword2 ? (
<EyeOff className="w-4 h-4 sm:w-5 sm:h-5" />
) : (
<Eye className="w-4 h-4 sm:w-5 sm:h-5" />
)}
</Button>
</div>
</div>
{/* Submit Button */}
<Button
type="submit"
disabled={registerMutation.isPending}
className="w-full h-11 sm:h-12 text-sm sm:text-base font-semibold bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed mt-4 sm:mt-6"
>
{registerMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating account...
</>
) : (
"Sign up"
)}
</Button>
{/* Switch to Login */}
<p className={`text-xs sm:text-sm text-center pt-2 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
Already have an account?{" "}
<Button
type="button"
variant="link"
onClick={() => {
handleDialogChange(false);
if (onSwitchToLogin) {
onSwitchToLogin();
}
}}
className={`h-auto p-0 font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
>
Log in
</Button>
</p>
</form>
)}
{/* OTP Verification Form */}
{step === "verify" && (
<form className="space-y-4 sm:space-y-5 py-4 sm:py-6" onSubmit={handleVerifyOtp}>
<div className={`p-3 sm:p-4 rounded-lg border ${isDark ? 'bg-blue-900/20 border-blue-800' : 'bg-blue-50 border-blue-200'}`}>
<div className="flex items-start gap-3">
<CheckCircle2 className={`w-5 h-5 mt-0.5 flex-shrink-0 ${isDark ? 'text-blue-400' : 'text-blue-600'}`} />
<div>
<p className={`text-sm font-medium ${isDark ? 'text-blue-200' : 'text-blue-900'}`}>
Check your email
</p>
<p className={`text-xs sm:text-sm mt-1 ${isDark ? 'text-blue-300' : 'text-blue-700'}`}>
We've sent a 6-digit verification code to {registeredEmail || otpData.email || "your email address"}.
</p>
</div>
</div>
</div>
{/* Email Field (if not set) */}
{!registeredEmail && (
<div className="space-y-1.5 sm:space-y-2">
<label htmlFor="verify-email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Email address *
</label>
<Input
id="verify-email"
type="email"
placeholder="Email address"
value={otpData.email}
onChange={(e) => handleOtpChange("email", e.target.value)}
className={`h-11 sm:h-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
required
/>
</div>
)}
{/* OTP Field */}
<div className="space-y-1.5 sm:space-y-2">
<label className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Verification Code *
</label>
<div className="flex justify-center">
<InputOTP
maxLength={6}
value={otpData.otp}
onChange={(value) => handleOtpChange("otp", value)}
>
<InputOTPGroup className="gap-2 sm:gap-3">
<InputOTPSlot index={0} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
<InputOTPSlot index={1} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
<InputOTPSlot index={2} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
<InputOTPSlot index={3} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
<InputOTPSlot index={4} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
<InputOTPSlot index={5} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
</InputOTPGroup>
</InputOTP>
</div>
</div>
{/* Resend OTP */}
<div className="text-center">
<Button
type="button"
variant="link"
onClick={handleResendOtp}
disabled={resendOtpMutation?.isPending}
className={`h-auto p-0 text-xs sm:text-sm font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
>
{resendOtpMutation?.isPending ? "Sending..." : "Didn't receive the code? Resend"}
</Button>
</div>
{/* Submit Button */}
<Button
type="submit"
disabled={verifyOtpMutation.isPending}
className="w-full h-11 sm:h-12 text-sm sm:text-base font-semibold bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed mt-4 sm:mt-6"
>
{verifyOtpMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Verifying...
</>
) : (
"Verify Email"
)}
</Button>
{/* Back to signup */}
<div className="text-center">
<Button
type="button"
variant="link"
onClick={() => {
setStep("signup");
setOtpData({ email: "", otp: "" });
}}
className={`h-auto p-0 text-xs sm:text-sm font-medium ${isDark ? 'text-gray-400 hover:text-gray-300' : 'text-gray-600 hover:text-gray-700'}`}
>
Back to signup
</Button>
</div>
</form>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -1,115 +0,0 @@
'use client';
import * as React from 'react';
import { Clock } from 'lucide-react';
import { format } from 'date-fns';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import DatePickerLib from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
interface TimePickerProps {
time: string; // HH:mm format (e.g., "09:00")
setTime: (time: string) => void;
label?: string;
isDark?: boolean;
}
export function TimePicker({ time, setTime, label, isDark = false }: TimePickerProps) {
const [isOpen, setIsOpen] = React.useState(false);
const wrapperRef = React.useRef<HTMLDivElement>(null);
// Convert HH:mm string to Date object for the time picker
const timeValue = React.useMemo(() => {
if (!time) return null;
const [hours, minutes] = time.split(':').map(Number);
const date = new Date();
date.setHours(hours || 9);
date.setMinutes(minutes || 0);
date.setSeconds(0);
return date;
}, [time]);
// Handle time change from the picker
const handleTimeChange = (date: Date | null) => {
if (date) {
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
setTime(`${hours}:${minutes}`);
}
};
// Close picker when clicking outside
React.useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
// Format display time
const displayTime = timeValue
? format(timeValue, 'h:mm a') // e.g., "9:00 AM"
: 'Select time';
return (
<div className="space-y-2">
{label && (
<label className={cn(
"text-sm font-medium",
isDark ? "text-gray-300" : "text-gray-700"
)}>
{label}
</label>
)}
<div className="relative" ref={wrapperRef}>
<Button
type="button"
variant="outline"
onClick={() => setIsOpen(!isOpen)}
className={cn(
"w-full justify-start text-left font-normal h-12 text-base",
!timeValue && "text-muted-foreground",
isDark
? "bg-gray-800 border-gray-600 text-white hover:bg-gray-700"
: "bg-white border-gray-300 text-gray-900 hover:bg-gray-50"
)}
>
<Clock className="mr-2 h-5 w-5" />
{displayTime}
</Button>
{isOpen && (
<div className={cn(
"absolute z-[9999] mt-2 rounded-lg shadow-lg border",
isDark
? "bg-gray-800 border-gray-700"
: "bg-white border-gray-200"
)}>
<DatePickerLib
selected={timeValue}
onChange={handleTimeChange}
showTimeSelect
showTimeSelectOnly
timeIntervals={15}
timeCaption="Time"
dateFormat="h:mm aa"
inline
className="time-picker"
wrapperClassName="time-picker-wrapper"
/>
</div>
)}
</div>
</div>
);
}

View File

@ -1,329 +0,0 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { useAppTheme } from "@/components/ThemeProvider";
import { Input } from "@/components/ui/input";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Loader2, X, CheckCircle2 } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { verifyOtpSchema, type VerifyOtpInput } from "@/lib/schema/auth";
import { toast } from "sonner";
interface VerifyOtpDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
email: string;
context?: "registration" | "password_reset";
onVerificationSuccess?: () => void;
title?: string;
description?: string;
}
export function VerifyOtpDialog({
open,
onOpenChange,
email: initialEmail,
context = "registration",
onVerificationSuccess,
title = "Verify your email",
description = "Enter the verification code sent to your email"
}: VerifyOtpDialogProps) {
const { theme } = useAppTheme();
const isDark = theme === "dark";
const { verifyOtp, verifyOtpMutation, resendOtpMutation } = useAuth();
const [otpData, setOtpData] = useState<VerifyOtpInput>({
email: initialEmail,
otp: "",
});
const [email, setEmail] = useState(initialEmail);
const [otpSent, setOtpSent] = useState(false);
const [isSendingOtp, setIsSendingOtp] = useState(false);
// Update email when prop changes
useEffect(() => {
if (initialEmail) {
setEmail(initialEmail);
setOtpData(prev => ({ ...prev, email: initialEmail }));
}
}, [initialEmail]);
// Automatically send OTP when dialog opens
useEffect(() => {
if (open && !otpSent) {
const emailToSend = initialEmail || email;
if (emailToSend) {
setIsSendingOtp(true);
resendOtpMutation.mutateAsync({
email: emailToSend,
context
})
.then(() => {
toast.success("Verification code sent! Please check your email.");
setOtpSent(true);
setIsSendingOtp(false);
})
.catch((err) => {
const errorMessage = err instanceof Error ? err.message : "Failed to send verification code";
toast.error(errorMessage);
setIsSendingOtp(false);
// Still allow user to manually resend
});
}
}
// Reset when dialog closes
if (!open) {
setOtpSent(false);
setIsSendingOtp(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, initialEmail, email, context, otpSent]);
const handleVerifyOtp = async (e: React.FormEvent) => {
e.preventDefault();
const emailToVerify = email || otpData.email;
if (!emailToVerify) {
toast.error("Email address is required");
return;
}
const validation = verifyOtpSchema.safeParse({
email: emailToVerify,
otp: otpData.otp,
});
if (!validation.success) {
const firstError = validation.error.issues[0];
toast.error(firstError.message);
return;
}
try {
const result = await verifyOtp({
email: emailToVerify,
otp: otpData.otp,
});
if (result.message || result.tokens) {
toast.success("Email verified successfully!");
// Reset form
setOtpData({ email: emailToVerify, otp: "" });
onOpenChange(false);
// Call success callback if provided
if (onVerificationSuccess) {
onVerificationSuccess();
}
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "OTP verification failed. Please try again.";
toast.error(errorMessage);
}
};
const handleResendOtp = async () => {
const emailToResend = email || otpData.email;
if (!emailToResend) {
toast.error("Email address is required");
return;
}
try {
setIsSendingOtp(true);
await resendOtpMutation.mutateAsync({
email: emailToResend,
context
});
toast.success("OTP resent successfully! Please check your email.");
setOtpSent(true);
setIsSendingOtp(false);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to resend OTP";
toast.error(errorMessage);
setIsSendingOtp(false);
}
};
const handleOtpChange = (field: keyof VerifyOtpInput, value: string) => {
setOtpData((prev) => ({ ...prev, [field]: value }));
};
// Reset form when dialog closes
const handleDialogChange = (isOpen: boolean) => {
if (!isOpen) {
setOtpData({ email: initialEmail || "", otp: "" });
setOtpSent(false);
setIsSendingOtp(false);
}
onOpenChange(isOpen);
};
return (
<Dialog open={open} onOpenChange={handleDialogChange}>
<DialogContent
showCloseButton={false}
className={`max-w-md max-h-[90vh] overflow-hidden flex flex-col p-0 ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}
>
{/* Header with Close Button - Fixed */}
<div className="flex items-start justify-between p-6 pb-4 flex-shrink-0 border-b border-gray-200 dark:border-gray-700">
<DialogHeader className="flex-1 pr-2">
<DialogTitle className="text-2xl sm:text-3xl font-bold bg-gradient-to-r from-rose-600 via-pink-600 to-rose-600 bg-clip-text text-transparent">
{title}
</DialogTitle>
<DialogDescription className={`text-sm sm:text-base mt-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
{description}
</DialogDescription>
</DialogHeader>
{/* Close Button */}
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleDialogChange(false)}
className={`flex-shrink-0 w-8 h-8 rounded-full ${isDark ? 'text-gray-400 hover:text-gray-300 hover:bg-gray-700' : 'text-gray-500 hover:text-gray-700 hover:bg-gray-100'}`}
aria-label="Close"
>
<X className="w-5 h-5" />
</Button>
</div>
{/* Scrollable Content */}
<div className="overflow-y-auto flex-1 px-6">
<form className="space-y-4 sm:space-y-5 py-4 sm:py-6" onSubmit={handleVerifyOtp}>
{/* Loading indicator while sending */}
{isSendingOtp && !otpSent && (
<div className={`p-3 sm:p-4 rounded-lg border ${isDark ? 'bg-yellow-900/20 border-yellow-800' : 'bg-yellow-50 border-yellow-200'}`}>
<div className="flex items-center gap-3">
<Loader2 className="w-5 h-5 animate-spin text-yellow-600" />
<p className={`text-sm font-medium ${isDark ? 'text-yellow-200' : 'text-yellow-900'}`}>
Sending verification code...
</p>
</div>
</div>
)}
{/* Success message after sending */}
{otpSent && (
<div className={`p-3 sm:p-4 rounded-lg border ${isDark ? 'bg-blue-900/20 border-blue-800' : 'bg-blue-50 border-blue-200'}`}>
<div className="flex items-start gap-3">
<CheckCircle2 className={`w-5 h-5 mt-0.5 flex-shrink-0 ${isDark ? 'text-blue-400' : 'text-blue-600'}`} />
<div>
<p className={`text-sm font-medium ${isDark ? 'text-blue-200' : 'text-blue-900'}`}>
Check your email
</p>
<p className={`text-xs sm:text-sm mt-1 ${isDark ? 'text-blue-300' : 'text-blue-700'}`}>
We've sent a 6-digit verification code to {email || otpData.email || "your email address"}.
</p>
</div>
</div>
</div>
)}
{/* Show info message if OTP hasn't been sent yet but dialog is open */}
{!isSendingOtp && !otpSent && (
<div className={`p-3 sm:p-4 rounded-lg border ${isDark ? 'bg-blue-900/20 border-blue-800' : 'bg-blue-50 border-blue-200'}`}>
<div className="flex items-start gap-3">
<CheckCircle2 className={`w-5 h-5 mt-0.5 flex-shrink-0 ${isDark ? 'text-blue-400' : 'text-blue-600'}`} />
<div>
<p className={`text-sm font-medium ${isDark ? 'text-blue-200' : 'text-blue-900'}`}>
Enter verification code
</p>
<p className={`text-xs sm:text-sm mt-1 ${isDark ? 'text-blue-300' : 'text-blue-700'}`}>
Enter the 6-digit verification code sent to {email || otpData.email || "your email address"}.
</p>
</div>
</div>
</div>
)}
{/* Email Field (if not provided or editable) */}
{!initialEmail && (
<div className="space-y-1.5 sm:space-y-2">
<label htmlFor="verify-email" className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Email address *
</label>
<Input
id="verify-email"
type="email"
placeholder="Email address"
value={email}
onChange={(e) => {
setEmail(e.target.value);
handleOtpChange("email", e.target.value);
}}
className={`h-11 sm:h-12 text-sm sm:text-base ${isDark ? 'bg-gray-700 border-gray-600 text-white placeholder:text-gray-400' : 'bg-white border-gray-300 text-gray-900'}`}
required
/>
</div>
)}
{/* OTP Field */}
<div className="space-y-1.5 sm:space-y-2">
<label className={`text-sm font-medium ${isDark ? 'text-gray-300' : 'text-black'}`}>
Verification Code *
</label>
<div className="flex justify-center">
<InputOTP
maxLength={6}
value={otpData.otp}
onChange={(value) => handleOtpChange("otp", value)}
>
<InputOTPGroup className="gap-2 sm:gap-3">
<InputOTPSlot index={0} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
<InputOTPSlot index={1} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
<InputOTPSlot index={2} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
<InputOTPSlot index={3} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
<InputOTPSlot index={4} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
<InputOTPSlot index={5} className="h-12 w-12 sm:h-14 sm:w-14 text-lg sm:text-xl font-semibold" />
</InputOTPGroup>
</InputOTP>
</div>
</div>
{/* Resend OTP */}
<div className="text-center">
<Button
type="button"
variant="link"
onClick={handleResendOtp}
disabled={resendOtpMutation.isPending}
className={`h-auto p-0 text-xs sm:text-sm font-medium ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
>
{resendOtpMutation.isPending ? "Sending..." : "Didn't receive the code? Resend"}
</Button>
</div>
{/* Submit Button */}
<Button
type="submit"
disabled={verifyOtpMutation.isPending}
className="w-full h-11 sm:h-12 text-sm sm:text-base font-semibold bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white shadow-lg hover:shadow-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed mt-4 sm:mt-6"
>
{verifyOtpMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Verifying...
</>
) : (
"Verify Email"
)}
</Button>
</form>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -1,77 +0,0 @@
"use client"
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { MinusIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
)
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center", className)}
{...props}
/>
)
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
)
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
)
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

View File

@ -30,7 +30,7 @@ function PopoverContent({
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[100] w-72 origin-(--radix-popover-content-transform-origin) rounded-md border border-gray-200 p-4 shadow-md outline-hidden",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border border-gray-200 p-4 shadow-md outline-hidden",
className
)}
{...props}

View File

@ -1,29 +1,8 @@
"use client";
import { Toaster as Sonner } from "sonner";
import { useAppTheme } from "@/components/ThemeProvider";
// Simple toaster component - can be enhanced later with toast notifications
export function Toaster() {
const { theme } = useAppTheme();
return (
<Sonner
theme={theme === "dark" ? "dark" : "light"}
position="top-center"
richColors
closeButton
duration={4000}
expand={true}
toastOptions={{
classNames: {
toast: "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
/>
);
return null;
}

View File

@ -1,295 +0,0 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback } from "react";
import {
createAppointment,
getAvailableDates,
listAppointments,
getUserAppointments,
getUserAppointmentStats,
getAppointmentDetail,
scheduleAppointment,
rejectAppointment,
getAdminAvailability,
updateAdminAvailability,
getAppointmentStats,
getJitsiMeetingInfo,
getWeeklyAvailability,
getAvailabilityConfig,
checkDateAvailability,
getAvailabilityOverview,
} from "@/lib/actions/appointments";
import type {
CreateAppointmentInput,
ScheduleAppointmentInput,
RejectAppointmentInput,
UpdateAvailabilityInput,
} from "@/lib/schema/appointments";
import type {
Appointment,
AdminAvailability,
AppointmentStats,
UserAppointmentStats,
AvailableDatesResponse,
JitsiMeetingInfo,
WeeklyAvailabilityResponse,
AvailabilityConfig,
CheckDateAvailabilityResponse,
AvailabilityOverview,
} from "@/lib/models/appointments";
export function useAppointments(options?: {
enableAvailableDates?: boolean;
enableStats?: boolean;
enableConfig?: boolean;
enableWeeklyAvailability?: boolean;
enableOverview?: boolean;
}) {
const queryClient = useQueryClient();
const enableAvailableDates = options?.enableAvailableDates ?? false;
const enableStats = options?.enableStats ?? true;
const enableConfig = options?.enableConfig ?? true;
const enableWeeklyAvailability = options?.enableWeeklyAvailability ?? true;
const enableOverview = options?.enableOverview ?? true;
// Get available dates query (optional, disabled by default - using weekly_availability as primary source)
// Can be enabled when explicitly needed (e.g., on admin booking page)
const availableDatesQuery = useQuery<AvailableDatesResponse>({
queryKey: ["appointments", "available-dates"],
queryFn: () => getAvailableDates(),
enabled: enableAvailableDates, // Can be enabled when needed
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 0, // Don't retry failed requests
});
// Get weekly availability query
const weeklyAvailabilityQuery = useQuery<WeeklyAvailabilityResponse>({
queryKey: ["appointments", "weekly-availability"],
queryFn: async () => {
const data = await getWeeklyAvailability();
// Normalize response format - ensure it's always an object with week array
if (Array.isArray(data)) {
return { week: data };
}
return data;
},
enabled: enableWeeklyAvailability,
staleTime: 5 * 60 * 1000, // 5 minutes
});
// Get availability config query
const availabilityConfigQuery = useQuery<AvailabilityConfig>({
queryKey: ["appointments", "availability-config"],
queryFn: () => getAvailabilityConfig(),
enabled: enableConfig,
staleTime: 60 * 60 * 1000, // 1 hour (config rarely changes)
});
// Get availability overview query
const availabilityOverviewQuery = useQuery<AvailabilityOverview>({
queryKey: ["appointments", "availability-overview"],
queryFn: () => getAvailabilityOverview(),
enabled: enableOverview,
staleTime: 5 * 60 * 1000, // 5 minutes
});
// List appointments query
const appointmentsQuery = useQuery<Appointment[]>({
queryKey: ["appointments", "list"],
queryFn: async () => {
const data = await listAppointments();
return data || [];
},
enabled: true, // Enable by default to fetch user appointments
staleTime: 30 * 1000, // 30 seconds
retry: 1, // Retry once on failure
refetchOnMount: true, // Always refetch when component mounts
});
// Get user appointments query (disabled - using listAppointments instead)
const userAppointmentsQuery = useQuery<Appointment[]>({
queryKey: ["appointments", "user"],
queryFn: () => getUserAppointments(),
enabled: false, // Disabled - using listAppointments endpoint instead
staleTime: 30 * 1000, // 30 seconds
});
// Get appointment detail query
const useAppointmentDetail = (id: string | null) => {
return useQuery<Appointment>({
queryKey: ["appointments", "detail", id],
queryFn: () => getAppointmentDetail(id!),
enabled: !!id,
});
};
// Get admin availability query
const adminAvailabilityQuery = useQuery<AdminAvailability>({
queryKey: ["appointments", "admin", "availability"],
queryFn: () => getAdminAvailability(),
staleTime: 5 * 60 * 1000, // 5 minutes
});
// Get appointment stats query
const appointmentStatsQuery = useQuery<AppointmentStats>({
queryKey: ["appointments", "stats"],
queryFn: () => getAppointmentStats(),
staleTime: 1 * 60 * 1000, // 1 minute
});
const userAppointmentStatsQuery = useQuery<UserAppointmentStats>({
queryKey: ["appointments", "user", "stats"],
queryFn: () => getUserAppointmentStats(),
enabled: enableStats,
staleTime: 1 * 60 * 1000,
});
// Get Jitsi meeting info query
const useJitsiMeetingInfo = (id: string | null) => {
return useQuery<JitsiMeetingInfo>({
queryKey: ["appointments", "jitsi", id],
queryFn: () => getJitsiMeetingInfo(id!),
enabled: !!id,
staleTime: 30 * 1000, // 30 seconds
});
};
// Create appointment mutation
const createAppointmentMutation = useMutation({
mutationFn: (input: CreateAppointmentInput) => createAppointment(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["appointments"] });
},
});
// Schedule appointment mutation
const scheduleAppointmentMutation = useMutation({
mutationFn: ({ id, input }: { id: string; input: ScheduleAppointmentInput }) =>
scheduleAppointment(id, input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["appointments"] });
},
});
// Reject appointment mutation
const rejectAppointmentMutation = useMutation({
mutationFn: ({ id, input }: { id: string; input: RejectAppointmentInput }) =>
rejectAppointment(id, input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["appointments"] });
},
});
// Update admin availability mutation
const updateAdminAvailabilityMutation = useMutation({
mutationFn: (input: UpdateAvailabilityInput) => updateAdminAvailability(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["appointments", "admin", "availability"] });
queryClient.invalidateQueries({ queryKey: ["appointments", "available-dates"] });
},
});
// Convenience functions
const create = useCallback(
async (input: CreateAppointmentInput) => {
return await createAppointmentMutation.mutateAsync(input);
},
[createAppointmentMutation]
);
const schedule = useCallback(
async (id: string, input: ScheduleAppointmentInput) => {
return await scheduleAppointmentMutation.mutateAsync({ id, input });
},
[scheduleAppointmentMutation]
);
const reject = useCallback(
async (id: string, input: RejectAppointmentInput) => {
return await rejectAppointmentMutation.mutateAsync({ id, input });
},
[rejectAppointmentMutation]
);
const updateAvailability = useCallback(
async (input: UpdateAvailabilityInput) => {
return await updateAdminAvailabilityMutation.mutateAsync(input);
},
[updateAdminAvailabilityMutation]
);
const fetchAppointments = useCallback(
async (email?: string) => {
const data = await listAppointments(email);
queryClient.setQueryData(["appointments", "list"], data);
return data;
},
[queryClient]
);
return {
// Queries
availableDates: availableDatesQuery.data?.dates || [],
availableDatesResponse: availableDatesQuery.data,
weeklyAvailability: weeklyAvailabilityQuery.data,
availabilityConfig: availabilityConfigQuery.data,
availabilityOverview: availabilityOverviewQuery.data,
appointments: appointmentsQuery.data || [],
userAppointments: userAppointmentsQuery.data || [],
adminAvailability: adminAvailabilityQuery.data,
appointmentStats: appointmentStatsQuery.data,
userAppointmentStats: userAppointmentStatsQuery.data,
// Query states
isLoadingAvailableDates: availableDatesQuery.isLoading,
isLoadingWeeklyAvailability: weeklyAvailabilityQuery.isLoading,
isLoadingAvailabilityConfig: availabilityConfigQuery.isLoading,
isLoadingAvailabilityOverview: availabilityOverviewQuery.isLoading,
isLoadingAppointments: appointmentsQuery.isLoading,
isLoadingUserAppointments: userAppointmentsQuery.isLoading,
isLoadingAdminAvailability: adminAvailabilityQuery.isLoading,
isLoadingStats: appointmentStatsQuery.isLoading,
isLoadingUserStats: userAppointmentStatsQuery.isLoading,
// Query refetch functions
refetchAvailableDates: availableDatesQuery.refetch,
refetchWeeklyAvailability: weeklyAvailabilityQuery.refetch,
refetchAvailabilityConfig: availabilityConfigQuery.refetch,
refetchAvailabilityOverview: availabilityOverviewQuery.refetch,
refetchAppointments: appointmentsQuery.refetch,
refetchUserAppointments: userAppointmentsQuery.refetch,
refetchAdminAvailability: adminAvailabilityQuery.refetch,
refetchStats: appointmentStatsQuery.refetch,
refetchUserStats: userAppointmentStatsQuery.refetch,
// Helper functions
checkDateAvailability: async (date: string) => {
return await checkDateAvailability(date);
},
// Hooks for specific queries
useAppointmentDetail,
useJitsiMeetingInfo,
// Mutations
create,
schedule,
reject,
updateAvailability,
fetchAppointments,
// Mutation states
isCreating: createAppointmentMutation.isPending,
isScheduling: scheduleAppointmentMutation.isPending,
isRejecting: rejectAppointmentMutation.isPending,
isUpdatingAvailability: updateAdminAvailabilityMutation.isPending,
// Direct mutation access (if needed)
createAppointmentMutation,
scheduleAppointmentMutation,
rejectAppointmentMutation,
updateAdminAvailabilityMutation,
};
}

View File

@ -1,229 +0,0 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useCallback, useEffect } from "react";
import {
loginUser,
registerUser,
verifyOtp,
resendOtp,
forgotPassword,
verifyPasswordResetOtp,
resetPassword,
refreshToken,
getStoredTokens,
getStoredUser,
storeTokens,
storeUser,
clearAuthData,
isTokenExpired,
hasValidAuth,
} from "@/lib/actions/auth";
import type {
LoginInput,
RegisterInput,
VerifyOtpInput,
ResendOtpInput,
ForgotPasswordInput,
VerifyPasswordResetOtpInput,
ResetPasswordInput,
} from "@/lib/schema/auth";
import type { User } from "@/lib/models/auth";
import { toast } from "sonner";
export function useAuth() {
const router = useRouter();
const queryClient = useQueryClient();
// Get current user from storage
const { data: user } = useQuery<User | null>({
queryKey: ["auth", "user"],
queryFn: () => getStoredUser(),
staleTime: Infinity,
});
// Check if user is authenticated with valid token
const isAuthenticated = !!user && hasValidAuth();
// Check if user is admin (check multiple possible field names)
const isAdmin =
user?.is_admin === true ||
(user as any)?.isAdmin === true ||
(user as any)?.is_staff === true ||
(user as any)?.isStaff === true ||
(user as any)?.is_superuser === true ||
(user as any)?.isSuperuser === true;
// Login mutation
const loginMutation = useMutation({
mutationFn: (input: LoginInput) => loginUser(input),
onSuccess: (data) => {
if (data.tokens && data.user) {
storeTokens(data.tokens);
storeUser(data.user);
queryClient.setQueryData(["auth", "user"], data.user);
queryClient.invalidateQueries({ queryKey: ["auth"] });
}
},
});
// Register mutation
const registerMutation = useMutation({
mutationFn: (input: RegisterInput) => registerUser(input),
});
// Verify OTP mutation
const verifyOtpMutation = useMutation({
mutationFn: (input: VerifyOtpInput) => verifyOtp(input),
onSuccess: (data) => {
if (data.tokens && data.user) {
storeTokens(data.tokens);
storeUser(data.user);
queryClient.setQueryData(["auth", "user"], data.user);
queryClient.invalidateQueries({ queryKey: ["auth"] });
}
},
});
// Resend OTP mutation
const resendOtpMutation = useMutation({
mutationFn: (input: ResendOtpInput) => resendOtp(input),
});
// Forgot password mutation
const forgotPasswordMutation = useMutation({
mutationFn: (input: ForgotPasswordInput) => forgotPassword(input),
});
// Verify password reset OTP mutation
const verifyPasswordResetOtpMutation = useMutation({
mutationFn: (input: VerifyPasswordResetOtpInput) => verifyPasswordResetOtp(input),
});
// Reset password mutation
const resetPasswordMutation = useMutation({
mutationFn: (input: ResetPasswordInput) => resetPassword(input),
});
// Refresh token mutation
const refreshTokenMutation = useMutation({
mutationFn: (refresh: string) => refreshToken({ refresh }),
onSuccess: (tokens) => {
storeTokens(tokens);
queryClient.invalidateQueries({ queryKey: ["auth"] });
},
onError: () => {
// If refresh fails, logout
clearAuthData();
queryClient.clear();
},
});
// Logout function
const logout = useCallback(() => {
clearAuthData();
queryClient.clear();
// Don't redirect here - let components handle redirect as needed
}, [queryClient]);
// Auto-logout if token is expired or missing
useEffect(() => {
const checkAuth = () => {
const tokens = getStoredTokens();
const storedUser = getStoredUser();
// If user exists but no token or token is expired, logout
if (storedUser && (!tokens.access || isTokenExpired(tokens.access))) {
// Try to refresh token first if refresh token exists
if (tokens.refresh && !isTokenExpired(tokens.refresh)) {
refreshTokenMutation.mutate(tokens.refresh, {
onError: () => {
// If refresh fails, logout
clearAuthData();
queryClient.clear();
toast.error("Your session has expired. Please log in again.");
},
});
} else {
// No valid refresh token, logout immediately
clearAuthData();
queryClient.clear();
toast.error("Your session has expired. Please log in again.");
}
}
};
// Check immediately
checkAuth();
// Check every 30 seconds
const interval = setInterval(checkAuth, 30000);
return () => clearInterval(interval);
}, [queryClient, refreshTokenMutation]);
// Login function
const login = useCallback(
async (input: LoginInput) => {
try {
const result = await loginMutation.mutateAsync(input);
return result;
} catch (error) {
throw error;
}
},
[loginMutation]
);
// Register function
const register = useCallback(
async (input: RegisterInput) => {
try {
const result = await registerMutation.mutateAsync(input);
return result;
} catch (error) {
throw error;
}
},
[registerMutation]
);
// Verify OTP function
const verifyOtpCode = useCallback(
async (input: VerifyOtpInput) => {
try {
const result = await verifyOtpMutation.mutateAsync(input);
return result;
} catch (error) {
throw error;
}
},
[verifyOtpMutation]
);
return {
// State
user,
isAuthenticated,
isAdmin,
isLoading: loginMutation.isPending || registerMutation.isPending,
// Actions
login,
register,
logout,
verifyOtp: verifyOtpCode,
// Mutations (for direct access if needed)
loginMutation,
registerMutation,
verifyOtpMutation,
resendOtpMutation,
forgotPasswordMutation,
verifyPasswordResetOtpMutation,
resetPasswordMutation,
refreshTokenMutation,
};
}

View File

@ -1,876 +0,0 @@
import { API_ENDPOINTS } from "@/lib/api_urls";
import { getStoredTokens } from "./auth";
import type {
CreateAppointmentInput,
ScheduleAppointmentInput,
RejectAppointmentInput,
UpdateAvailabilityInput,
} from "@/lib/schema/appointments";
import type {
Appointment,
AppointmentResponse,
AvailableDatesResponse,
AdminAvailability,
AppointmentStats,
UserAppointmentStats,
JitsiMeetingInfo,
ApiError,
WeeklyAvailabilityResponse,
AvailabilityConfig,
CheckDateAvailabilityResponse,
AvailabilityOverview,
SelectedSlot,
} from "@/lib/models/appointments";
function extractErrorMessage(error: ApiError): string {
if (error.detail) {
return Array.isArray(error.detail) ? error.detail.join(", ") : String(error.detail);
}
if (error.message) {
return Array.isArray(error.message) ? error.message.join(", ") : String(error.message);
}
if (typeof error === "string") {
return error;
}
return "An error occurred";
}
async function parseResponse(response: Response): Promise<any> {
const responseText = await response.text();
const contentType = response.headers.get("content-type") || "";
if (!responseText || responseText.trim().length === 0) {
if (response.ok) {
return null;
}
throw new Error(`Server error (${response.status}): ${response.statusText || 'Empty response'}`);
}
if (contentType.includes("application/json")) {
try {
return JSON.parse(responseText);
} catch {
throw new Error(`Server error (${response.status}): Invalid JSON format`);
}
}
const errorMatch = responseText.match(/<pre[^>]*>([\s\S]*?)<\/pre>/i) ||
responseText.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
const errorText = errorMatch?.[1]?.replace(/<[^>]*>/g, '').trim() || '';
throw new Error(`Server error (${response.status}): ${errorText || response.statusText || 'Internal Server Error'}`);
}
function extractHtmlError(responseText: string): string {
const errorMatch = responseText.match(/<pre[^>]*>([\s\S]*?)<\/pre>/i);
if (!errorMatch) return '';
const traceback = errorMatch[1].replace(/<[^>]*>/g, '');
const lines = traceback.split('\n').filter(line => line.trim());
for (let i = lines.length - 1; i >= Math.max(0, lines.length - 5); i--) {
const line = lines[i];
if (line.match(/(Error|Exception|Failed)/i)) {
return line.trim().replace(/^(Traceback|File|Error|Exception):\s*/i, '');
}
}
return lines[lines.length - 1]?.trim() || '';
}
function validateAndCleanSlots(slots: any[]): SelectedSlot[] {
return slots
.filter(slot => {
if (!slot || typeof slot !== 'object') return false;
const dayNum = Number(slot.day);
const timeSlot = String(slot.time_slot || '').toLowerCase().trim();
return !isNaN(dayNum) && dayNum >= 0 && dayNum <= 6 &&
['morning', 'afternoon', 'evening'].includes(timeSlot);
})
.map(slot => ({
day: Number(slot.day),
time_slot: String(slot.time_slot).toLowerCase().trim() as "morning" | "afternoon" | "evening",
}));
}
function normalizeAvailabilitySchedule(schedule: any): Record<string, string[]> {
if (typeof schedule === 'string') {
try {
schedule = JSON.parse(schedule);
} catch {
return {};
}
}
const numberToTimeSlot: Record<number, string> = {
0: 'morning',
1: 'afternoon',
2: 'evening',
};
const result: Record<string, string[]> = {};
Object.keys(schedule || {}).forEach(day => {
const slots = schedule[day];
if (Array.isArray(slots) && slots.length > 0) {
result[day] = typeof slots[0] === 'number'
? slots.map((num: number) => numberToTimeSlot[num]).filter(Boolean) as string[]
: slots.filter((s: string) => ['morning', 'afternoon', 'evening'].includes(s));
}
});
return result;
}
export async function createAppointment(input: CreateAppointmentInput): Promise<Appointment> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required. Please log in to book an appointment.");
}
if (!input.first_name || !input.last_name || !input.email) {
throw new Error("First name, last name, and email are required");
}
if (!input.selected_slots || input.selected_slots.length === 0) {
throw new Error("At least one time slot must be selected");
}
const validSlots = validateAndCleanSlots(input.selected_slots);
if (validSlots.length === 0) {
throw new Error("At least one valid time slot must be selected. Each slot must have both 'day' (0-6) and 'time_slot' (morning, afternoon, or evening).");
}
// Explicitly exclude legacy fields - API doesn't need preferred_dates or preferred_time_slots
// We only use selected_slots format
const truncate = (str: string, max: number) => String(str || '').trim().substring(0, max);
const selectedSlotsForPayload = validSlots.map(slot => ({
day: slot.day,
time_slot: slot.time_slot,
}));
// Build payload with ONLY the fields the API requires/accepts
// DO NOT include preferred_dates or preferred_time_slots - the API doesn't need them
const payload: {
first_name: string;
last_name: string;
email: string;
selected_slots: Array<{ day: number; time_slot: string }>;
phone?: string;
reason?: string;
} = {
first_name: truncate(input.first_name, 100),
last_name: truncate(input.last_name, 100),
email: truncate(input.email, 100).toLowerCase(),
selected_slots: selectedSlotsForPayload,
};
// Only add optional fields if they exist
if (input.phone && input.phone.length > 0) {
payload.phone = truncate(input.phone, 100);
}
if (input.reason && input.reason.length > 0) {
payload.reason = truncate(input.reason, 100);
}
const requestBody = JSON.stringify(payload);
const response = await fetch(API_ENDPOINTS.meetings.createAppointment, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
body: requestBody,
});
const data = await parseResponse(response);
if (!response.ok) {
throw new Error(extractErrorMessage(data as unknown as ApiError));
}
// Build clean response - explicitly exclude preferred_dates and preferred_time_slots
// Backend may return these legacy fields, but we only use selected_slots format (per API spec)
const rawResponse: any = data.appointment || data.data || data;
// Build appointment object from scratch with ONLY the fields we want
// Explicitly DO NOT include preferred_dates, preferred_time_slots, or their display variants
const appointmentResponse: any = {
id: data.appointment_id || rawResponse.id || '',
first_name: rawResponse.first_name || input.first_name.trim(),
last_name: rawResponse.last_name || input.last_name.trim(),
email: rawResponse.email || input.email.trim().toLowerCase(),
phone: rawResponse.phone || input.phone?.trim(),
reason: rawResponse.reason || input.reason?.trim(),
// Use selected_slots from our original input (preserve the format we sent - per API spec)
selected_slots: validSlots,
status: rawResponse.status || "pending_review",
created_at: rawResponse.created_at || new Date().toISOString(),
updated_at: rawResponse.updated_at || new Date().toISOString(),
// Include other useful fields from response
...(rawResponse.jitsi_meet_url && { jitsi_meet_url: rawResponse.jitsi_meet_url }),
...(rawResponse.jitsi_room_id && { jitsi_room_id: rawResponse.jitsi_room_id }),
...(rawResponse.matching_availability && { matching_availability: rawResponse.matching_availability }),
...(rawResponse.are_preferences_available !== undefined && { are_preferences_available: rawResponse.are_preferences_available }),
...(rawResponse.available_slots_info && { available_slots_info: rawResponse.available_slots_info }),
// Explicitly EXCLUDED: preferred_dates, preferred_time_slots, preferred_dates_display, preferred_time_slots_display
};
// Explicitly delete preferred_dates and preferred_time_slots from response object
// These are backend legacy fields - we only use selected_slots format
if ('preferred_dates' in appointmentResponse) {
delete appointmentResponse.preferred_dates;
}
if ('preferred_time_slots' in appointmentResponse) {
delete appointmentResponse.preferred_time_slots;
}
if ('preferred_dates_display' in appointmentResponse) {
delete appointmentResponse.preferred_dates_display;
}
if ('preferred_time_slots_display' in appointmentResponse) {
delete appointmentResponse.preferred_time_slots_display;
}
return appointmentResponse;
}
export async function getAvailableDates(): Promise<AvailableDatesResponse> {
try {
const response = await fetch(API_ENDPOINTS.meetings.availableDates, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
if (!response.ok) {
return { dates: [] };
}
const data = await parseResponse(response);
return Array.isArray(data) ? { dates: data } : data;
} catch {
return { dates: [] };
}
}
export async function getWeeklyAvailability(): Promise<WeeklyAvailabilityResponse> {
const response = await fetch(API_ENDPOINTS.meetings.weeklyAvailability, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
const data = await parseResponse(response);
if (!response.ok) {
throw new Error(extractErrorMessage(data as unknown as ApiError));
}
return Array.isArray(data) ? data : data;
}
export async function getAvailabilityConfig(): Promise<AvailabilityConfig> {
const response = await fetch(API_ENDPOINTS.meetings.availabilityConfig, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
const data = await parseResponse(response);
if (!response.ok) {
throw new Error(extractErrorMessage(data as unknown as ApiError));
}
return data;
}
export async function checkDateAvailability(date: string): Promise<CheckDateAvailabilityResponse> {
const response = await fetch(API_ENDPOINTS.meetings.checkDateAvailability, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ date }),
});
const data = await parseResponse(response);
if (!response.ok) {
throw new Error(extractErrorMessage(data as unknown as ApiError));
}
return data;
}
export async function getAvailabilityOverview(): Promise<AvailabilityOverview> {
const response = await fetch(API_ENDPOINTS.meetings.availabilityOverview, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
const data = await parseResponse(response);
if (!response.ok) {
throw new Error(extractErrorMessage(data as unknown as ApiError));
}
return data;
}
export async function listAppointments(email?: string): Promise<Appointment[]> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
const url = email
? `${API_ENDPOINTS.meetings.listAppointments}?email=${encodeURIComponent(email)}`
: API_ENDPOINTS.meetings.listAppointments;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
});
const data = await parseResponse(response);
if (!response.ok) {
throw new Error(extractErrorMessage(data as unknown as ApiError));
}
if (Array.isArray(data)) return data;
if (data?.appointments && Array.isArray(data.appointments)) return data.appointments;
if (data?.results && Array.isArray(data.results)) return data.results;
if (data?.id || data?.first_name) return [data];
return [];
}
export async function getUserAppointments(): Promise<Appointment[]> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
const response = await fetch(API_ENDPOINTS.meetings.userAppointments, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
});
const data = await parseResponse(response);
if (!response.ok) {
throw new Error(extractErrorMessage(data as unknown as ApiError));
}
if (Array.isArray(data)) return data;
if (data?.appointments && Array.isArray(data.appointments)) return data.appointments;
if (data?.results && Array.isArray(data.results)) return data.results;
return [];
}
export async function getAppointmentDetail(id: string): Promise<Appointment> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
const response = await fetch(`${API_ENDPOINTS.meetings.listAppointments}${id}/`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
});
const data = await parseResponse(response);
if (!response.ok) {
throw new Error(extractErrorMessage(data as unknown as ApiError));
}
return (data as AppointmentResponse).appointment || data;
}
export async function scheduleAppointment(id: string, input: ScheduleAppointmentInput): Promise<Appointment> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
// Get user's timezone
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
// Build payload with defaults
const payload: any = {
...input,
// Always include timezone when scheduling with datetime
...(input.scheduled_datetime && { timezone: input.timezone || userTimezone }),
// Default create_jitsi_meeting to true if not specified
create_jitsi_meeting: input.create_jitsi_meeting !== undefined ? input.create_jitsi_meeting : true,
};
// Remove undefined fields
Object.keys(payload).forEach(key => {
if (payload[key] === undefined) {
delete payload[key];
}
});
const response = await fetch(`${API_ENDPOINTS.meetings.listAppointments}${id}/schedule/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
body: JSON.stringify(payload),
});
const data = await parseResponse(response);
if (!response.ok) {
throw new Error(extractErrorMessage(data as unknown as ApiError));
}
return data.appointment || data;
}
export async function rejectAppointment(id: string, input: RejectAppointmentInput): Promise<Appointment> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
// Build payload - only include rejection_reason if provided
const payload: any = {};
if (input.rejection_reason) {
payload.rejection_reason = input.rejection_reason;
}
const response = await fetch(`${API_ENDPOINTS.meetings.listAppointments}${id}/reject/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
body: JSON.stringify(payload),
});
const data = await parseResponse(response);
if (!response.ok) {
throw new Error(extractErrorMessage(data as unknown as ApiError));
}
return (data as AppointmentResponse).appointment || data;
}
export async function getPublicAvailability(): Promise<AdminAvailability | null> {
try {
const weeklyAvailability = await getWeeklyAvailability();
const weekArray = Array.isArray(weeklyAvailability)
? weeklyAvailability
: (weeklyAvailability as any).week || [];
if (!weekArray || weekArray.length === 0) {
return null;
}
const availabilitySchedule: Record<string, string[]> = {};
const availableDays: number[] = [];
const availableDaysDisplay: string[] = [];
weekArray.forEach((day: any) => {
if (day.is_available && day.available_slots?.length > 0) {
availabilitySchedule[day.day.toString()] = day.available_slots;
availableDays.push(day.day);
availableDaysDisplay.push(day.day_name);
}
});
return {
available_days: availableDays,
available_days_display: availableDaysDisplay,
availability_schedule: availabilitySchedule,
all_available_slots: weekArray
.filter((d: any) => d.is_available)
.flatMap((d: any) =>
d.available_slots.map((slot: string) => ({
day: d.day,
time_slot: slot as "morning" | "afternoon" | "evening"
}))
),
} as AdminAvailability;
} catch {
return null;
}
}
export async function getAdminAvailability(): Promise<AdminAvailability> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
const response = await fetch(API_ENDPOINTS.meetings.adminAvailability, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
});
const data = await parseResponse(response);
if (!response.ok) {
throw new Error(extractErrorMessage(data as unknown as ApiError));
}
if (data.availability_schedule) {
const availabilitySchedule = normalizeAvailabilitySchedule(data.availability_schedule);
const availableDays = Object.keys(availabilitySchedule).map(Number);
const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const availableDaysDisplay = availableDays.map(day => dayNames[day] || `Day ${day}`);
return {
available_days: availableDays,
available_days_display: Array.isArray(data.availability_schedule_display)
? data.availability_schedule_display
: data.availability_schedule_display
? [data.availability_schedule_display]
: availableDaysDisplay,
availability_schedule: availabilitySchedule,
availability_schedule_display: data.availability_schedule_display,
all_available_slots: data.all_available_slots || [],
} as AdminAvailability;
}
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;
}
export async function updateAdminAvailability(input: UpdateAvailabilityInput): Promise<AdminAvailability> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
if (!input.availability_schedule) {
throw new Error("availability_schedule is required");
}
const cleanedSchedule: Record<string, string[]> = {};
Object.keys(input.availability_schedule).forEach(key => {
const dayNum = parseInt(key);
if (isNaN(dayNum) || dayNum < 0 || dayNum > 6) return;
const slots = input.availability_schedule[key];
if (Array.isArray(slots) && slots.length > 0) {
const validSlots = slots
.filter((slot: string) => typeof slot === 'string' && ['morning', 'afternoon', 'evening'].includes(slot))
.filter((slot: string, index: number, self: string[]) => self.indexOf(slot) === index);
if (validSlots.length > 0) {
cleanedSchedule[key.toString()] = validSlots;
}
}
});
if (Object.keys(cleanedSchedule).length === 0) {
throw new Error("At least one day with valid time slots must be provided");
}
const sortedSchedule: Record<string, string[]> = {};
Object.keys(cleanedSchedule)
.sort((a, b) => parseInt(a) - parseInt(b))
.forEach(key => {
sortedSchedule[key] = cleanedSchedule[key];
});
let response = await fetch(API_ENDPOINTS.meetings.adminAvailability, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
body: JSON.stringify({ availability_schedule: sortedSchedule }),
});
if (!response.ok && response.status === 500) {
response = await fetch(API_ENDPOINTS.meetings.adminAvailability, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
body: JSON.stringify({ availability_schedule: sortedSchedule }),
});
}
const responseText = await response.text();
const contentType = response.headers.get("content-type") || "";
if (!responseText || responseText.trim().length === 0) {
if (response.ok) {
return await getAdminAvailability();
}
throw new Error(`Server error (${response.status}): ${response.statusText || 'Empty response'}`);
}
let data: any;
if (contentType.includes("application/json")) {
try {
data = JSON.parse(responseText);
} catch {
throw new Error(`Server error (${response.status}): Invalid JSON format`);
}
} else {
const htmlError = extractHtmlError(responseText);
throw new Error(`Server error (${response.status}): ${htmlError || response.statusText || 'Internal Server Error'}`);
}
if (!response.ok) {
throw new Error(extractErrorMessage(data as unknown as ApiError));
}
if (data?.availability_schedule) {
const availabilitySchedule = normalizeAvailabilitySchedule(data.availability_schedule);
const availableDays = Object.keys(availabilitySchedule).map(Number);
const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const availableDaysDisplay = availableDays.map(day => dayNames[day] || `Day ${day}`);
return {
available_days: availableDays,
available_days_display: Array.isArray(data.availability_schedule_display)
? data.availability_schedule_display
: data.availability_schedule_display
? [data.availability_schedule_display]
: availableDaysDisplay,
availability_schedule: availabilitySchedule,
availability_schedule_display: data.availability_schedule_display,
all_available_slots: data.all_available_slots || [],
} as AdminAvailability;
}
if (response.ok && (!data || Object.keys(data).length === 0)) {
return await getAdminAvailability();
}
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;
}
export async function getAppointmentStats(): Promise<AppointmentStats> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
const response = await fetch(`${API_ENDPOINTS.meetings.listAppointments}stats/`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
});
const data = await parseResponse(response);
if (!response.ok) {
throw new Error(extractErrorMessage(data as unknown as ApiError));
}
return data;
}
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 = await parseResponse(response);
if (!response.ok) {
throw new Error(extractErrorMessage(data as unknown as ApiError));
}
return data;
}
export async function getJitsiMeetingInfo(id: string): Promise<JitsiMeetingInfo> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
const response = await fetch(`${API_ENDPOINTS.meetings.listAppointments}${id}/jitsi-meeting/`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
});
const data = await parseResponse(response);
if (!response.ok) {
throw new Error(extractErrorMessage(data as unknown as ApiError));
}
return data;
}
export async function startMeeting(
id: string,
options?: {
metadata?: string;
recording_url?: string;
}
): Promise<Appointment> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
const response = await fetch(API_ENDPOINTS.meetings.startMeeting(id), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
body: JSON.stringify({
action: "start",
metadata: options?.metadata,
recording_url: options?.recording_url,
}),
});
const data = await parseResponse(response);
if (!response.ok) {
throw new Error(extractErrorMessage(data as unknown as ApiError));
}
// The API returns { action, metadata, recording_url }, not an Appointment
// So we need to refetch the appointment to get the updated state
return await getAppointmentDetail(id);
}
export async function endMeeting(id: string): Promise<Appointment> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
const response = await fetch(API_ENDPOINTS.meetings.endMeeting(id), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
});
const data = await parseResponse(response);
if (!response.ok) {
throw new Error(extractErrorMessage(data as unknown as ApiError));
}
// The API returns a response object, not an Appointment
// So we need to refetch the appointment to get the updated state
return await getAppointmentDetail(id);
}
export interface RescheduleAppointmentInput {
new_scheduled_datetime: string; // ISO datetime string
new_scheduled_duration: number; // in minutes
timezone: string;
}
export async function rescheduleAppointment(id: string, input: RescheduleAppointmentInput): Promise<Appointment> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; // Get user's timezone
const payload: any = {
new_scheduled_datetime: input.new_scheduled_datetime,
new_scheduled_duration: input.new_scheduled_duration,
timezone: input.timezone !== undefined ? input.timezone : userTimezone,
};
const response = await fetch(`${API_ENDPOINTS.meetings.listAppointments}${id}/reschedule/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
body: JSON.stringify(payload),
});
const data = await parseResponse(response);
if (!response.ok) {
throw new Error(extractErrorMessage(data as unknown as ApiError));
}
// Refetch the appointment to get the updated state
return await getAppointmentDetail(id);
}
export interface CancelAppointmentInput {
action?: string;
metadata?: string;
recording_url?: string;
}
export async function cancelAppointment(id: string, input?: CancelAppointmentInput): Promise<any> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
const payload: any = {};
if (input?.action) payload.action = input.action;
if (input?.metadata) payload.metadata = input.metadata;
if (input?.recording_url) payload.recording_url = input.recording_url;
const response = await fetch(`${API_ENDPOINTS.meetings.base}meetings/${id}/cancel/`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
body: JSON.stringify(payload),
});
const data = await parseResponse(response);
if (!response.ok) {
throw new Error(extractErrorMessage(data as unknown as ApiError));
}
// Refetch the appointment to get the updated state
return await getAppointmentDetail(id);
}

View File

@ -1,457 +0,0 @@
import { API_ENDPOINTS } from "@/lib/api_urls";
import type {
RegisterInput,
VerifyOtpInput,
LoginInput,
ResendOtpInput,
ForgotPasswordInput,
VerifyPasswordResetOtpInput,
ResetPasswordInput,
TokenRefreshInput,
UpdateProfileInput,
ContactInput,
} from "@/lib/schema/auth";
import type { AuthResponse, ApiError, AuthTokens, User } from "@/lib/models/auth";
// Helper function to extract error message from API response
function extractErrorMessage(error: ApiError): string {
// Check for main error messages
if (error.detail) {
// Handle both string and array formats
if (Array.isArray(error.detail)) {
return error.detail.join(", ");
}
return String(error.detail);
}
if (error.message) {
if (Array.isArray(error.message)) {
return error.message.join(", ");
}
return String(error.message);
}
if (error.error) {
if (Array.isArray(error.error)) {
return error.error.join(", ");
}
return String(error.error);
}
// Check for field-specific errors (common in Django REST Framework)
const fieldErrors: string[] = [];
Object.keys(error).forEach((key) => {
if (key !== "detail" && key !== "message" && key !== "error") {
const fieldError = error[key];
if (Array.isArray(fieldError)) {
fieldErrors.push(`${key}: ${fieldError.join(", ")}`);
} else if (typeof fieldError === "string") {
fieldErrors.push(`${key}: ${fieldError}`);
}
}
});
if (fieldErrors.length > 0) {
return fieldErrors.join(". ");
}
return "An error occurred";
}
// Helper function to handle API responses
async function handleResponse<T>(response: Response): Promise<T> {
let data: any;
try {
data = await response.json();
} catch {
// If response is not JSON, use status text
throw new Error(response.statusText || "An error occurred");
}
if (!response.ok) {
const error: ApiError = data;
const errorMessage = extractErrorMessage(error);
throw new Error(errorMessage);
}
return data as T;
}
// Helper function to normalize auth response
function normalizeAuthResponse(data: AuthResponse): AuthResponse {
// Normalize tokens: if tokens are at root level, move them to tokens object
if (data.access && data.refresh && !data.tokens) {
data.tokens = {
access: data.access,
refresh: data.refresh,
};
}
// Normalize user: only map isVerified to is_verified if needed
if (data.user) {
const user = data.user as any;
if (user.isVerified !== undefined && user.is_verified === undefined) {
user.is_verified = user.isVerified;
}
data.user = user;
}
return data;
}
// Register a new user
export async function registerUser(input: RegisterInput): Promise<AuthResponse> {
const response = await fetch(API_ENDPOINTS.auth.register, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
// Handle response - check if it's a 500 error that might indicate OTP sending failure
// but user registration might have succeeded
if (!response.ok && response.status === 500) {
try {
const data = await response.json();
// If the error message mentions OTP or email sending, it might be a partial success
const errorMessage = extractErrorMessage(data);
if (errorMessage.toLowerCase().includes("otp") ||
errorMessage.toLowerCase().includes("email") ||
errorMessage.toLowerCase().includes("send") ||
errorMessage.toLowerCase().includes("ssl") ||
errorMessage.toLowerCase().includes("certificate")) {
// Return a partial success response - user might be created, allow OTP resend
// This allows the user to proceed to OTP verification and use resend OTP
return {
message: "User registered, but OTP email could not be sent. Please use resend OTP.",
} as AuthResponse;
}
} catch {
// If we can't parse the error, continue to normal error handling
}
}
return handleResponse<AuthResponse>(response);
}
// Verify OTP
export async function verifyOtp(input: VerifyOtpInput): Promise<AuthResponse> {
const response = await fetch(API_ENDPOINTS.auth.verifyOtp, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
const data = await handleResponse<AuthResponse>(response);
return normalizeAuthResponse(data);
}
// Login user
export async function loginUser(input: LoginInput): Promise<AuthResponse> {
const response = await fetch(API_ENDPOINTS.auth.login, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
const data = await handleResponse<AuthResponse>(response);
return normalizeAuthResponse(data);
}
// Resend OTP
export async function resendOtp(input: ResendOtpInput): Promise<AuthResponse> {
const response = await fetch(API_ENDPOINTS.auth.resendOtp, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
return handleResponse<AuthResponse>(response);
}
// Forgot password
export async function forgotPassword(input: ForgotPasswordInput): Promise<AuthResponse> {
const response = await fetch(API_ENDPOINTS.auth.forgotPassword, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
return handleResponse<AuthResponse>(response);
}
// Verify password reset OTP
export async function verifyPasswordResetOtp(
input: VerifyPasswordResetOtpInput
): Promise<AuthResponse> {
const response = await fetch(API_ENDPOINTS.auth.verifyPasswordResetOtp, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
return handleResponse<AuthResponse>(response);
}
// Reset password
export async function resetPassword(input: ResetPasswordInput): Promise<AuthResponse> {
const response = await fetch(API_ENDPOINTS.auth.resetPassword, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
return handleResponse<AuthResponse>(response);
}
// Refresh access token
export async function refreshToken(input: TokenRefreshInput): Promise<AuthTokens> {
const response = await fetch(API_ENDPOINTS.auth.tokenRefresh, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
return handleResponse<AuthTokens>(response);
}
// Decode JWT token to check expiration
function decodeJWT(token: string): { exp?: number; [key: string]: any } | null {
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
const payload = parts[1];
const decoded = JSON.parse(atob(payload.replace(/-/g, "+").replace(/_/g, "/")));
return decoded;
} catch (error) {
return null;
}
}
// Check if token is expired
export function isTokenExpired(token: string | null): boolean {
if (!token) return true;
const decoded = decodeJWT(token);
if (!decoded || !decoded.exp) return true;
// exp is in seconds, Date.now() is in milliseconds
const expirationTime = decoded.exp * 1000;
const currentTime = Date.now();
// Consider token expired if it expires within the next 5 seconds (buffer)
return currentTime >= (expirationTime - 5000);
}
// Get stored tokens
export function getStoredTokens(): { access: string | null; refresh: string | null } {
if (typeof window === "undefined") {
return { access: null, refresh: null };
}
return {
access: localStorage.getItem("auth_access_token"),
refresh: localStorage.getItem("auth_refresh_token"),
};
}
// Check if user has valid authentication
export function hasValidAuth(): boolean {
const tokens = getStoredTokens();
if (!tokens.access) return false;
return !isTokenExpired(tokens.access);
}
// Store tokens
export function storeTokens(tokens: AuthTokens): void {
if (typeof window === "undefined") return;
localStorage.setItem("auth_access_token", tokens.access);
localStorage.setItem("auth_refresh_token", tokens.refresh);
// Also set cookies for middleware
document.cookie = `auth_access_token=${tokens.access}; path=/; max-age=${7 * 24 * 60 * 60}; SameSite=Lax`;
document.cookie = `auth_refresh_token=${tokens.refresh}; path=/; max-age=${30 * 24 * 60 * 60}; SameSite=Lax`;
}
// Store user
export function storeUser(user: User): void {
if (typeof window === "undefined") return;
localStorage.setItem("auth_user", JSON.stringify(user));
document.cookie = `auth_user=${encodeURIComponent(JSON.stringify(user))}; path=/; max-age=${7 * 24 * 60 * 60}; SameSite=Lax`;
}
// Get stored user
export function getStoredUser(): User | null {
if (typeof window === "undefined") return null;
const userStr = localStorage.getItem("auth_user");
if (!userStr) return null;
try {
return JSON.parse(userStr) as User;
} catch {
return null;
}
}
// Clear auth data
export function clearAuthData(): void {
if (typeof window === "undefined") return;
localStorage.removeItem("auth_access_token");
localStorage.removeItem("auth_refresh_token");
localStorage.removeItem("auth_user");
// Also clear cookies
document.cookie = "auth_access_token=; path=/; max-age=0";
document.cookie = "auth_refresh_token=; path=/; max-age=0";
document.cookie = "auth_user=; path=/; max-age=0";
}
// Get auth header for API requests
export function getAuthHeader(): { Authorization: string } | {} {
const tokens = getStoredTokens();
if (tokens.access && !isTokenExpired(tokens.access)) {
return { Authorization: `Bearer ${tokens.access}` };
}
return {};
}
// Get all users (Admin only)
export async function getAllUsers(): Promise<User[]> {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
const response = await fetch(API_ENDPOINTS.auth.allUsers, {
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.users) {
return data.users;
}
if (Array.isArray(data)) {
return data;
}
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: "PUT",
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");
}
/**
* Submit contact form
*/
export async function submitContactForm(input: ContactInput): Promise<{ message: string }> {
const response = await fetch(API_ENDPOINTS.auth.contact, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
return handleResponse<{ message: string }>(response);
}

View File

@ -1,36 +0,0 @@
// Get API base URL from environment variable
export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL;
export const API_ENDPOINTS = {
auth: {
base: `${API_BASE_URL}/auth/`,
register: `${API_BASE_URL}/auth/register/`,
verifyOtp: `${API_BASE_URL}/auth/verify-otp/`,
login: `${API_BASE_URL}/auth/login/`,
resendOtp: `${API_BASE_URL}/auth/resend-otp/`,
forgotPassword: `${API_BASE_URL}/auth/forgot-password/`,
verifyPasswordResetOtp: `${API_BASE_URL}/auth/verify-password-reset-otp/`,
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/`,
contact: `${API_BASE_URL}/auth/contact/`,
},
meetings: {
base: `${API_BASE_URL}/meetings/`,
availableDates: `${API_BASE_URL}/meetings/appointments/available-dates/`,
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/`,
weeklyAvailability: `${API_BASE_URL}/meetings/availability/weekly/`,
availabilityConfig: `${API_BASE_URL}/meetings/availability/config/`,
checkDateAvailability: `${API_BASE_URL}/meetings/availability/check/`,
availabilityOverview: `${API_BASE_URL}/meetings/availability/overview/`,
startMeeting: (id: string) => `${API_BASE_URL}/meetings/meetings/${id}/start/`,
endMeeting: (id: string) => `${API_BASE_URL}/meetings/meetings/${id}/end/`,
},
} as const;

View File

@ -1,198 +0,0 @@
// Appointment Models
export interface Appointment {
id: string;
first_name: string;
last_name: string;
email: string;
phone?: string;
reason?: string;
preferred_dates?: string | string[]; // YYYY-MM-DD format - API can return as string or array
preferred_time_slots?: string | string[]; // "morning", "afternoon", "evening" - API can return as string or array
selected_slots?: SelectedSlot[]; // New format: day-time combinations
status: "pending_review" | "scheduled" | "rejected" | "completed" | "cancelled";
created_at: string;
updated_at: string;
scheduled_datetime?: string;
scheduled_duration?: number;
rejection_reason?: string;
jitsi_meet_url?: string;
jitsi_room_id?: string;
jitsi_meeting_created?: boolean;
meeting_started_at?: string;
started_at?: string; // Alternative field name from API
meeting_ended_at?: string;
meeting_duration_actual?: number;
meeting_info?: any;
has_jitsi_meeting?: boolean | string;
can_join_meeting?: boolean | string;
can_join_as_moderator?: boolean | string;
can_join_as_participant?: boolean | string;
moderator_join_url?: string;
participant_join_url?: string;
meeting_status?: string;
matching_availability?: MatchingAvailability | Array<{
date: string;
day_name: string;
available_slots: string[];
date_obj?: string;
}>;
are_preferences_available?: boolean | string;
// Additional fields from API response
full_name?: string;
formatted_created_at?: string;
formatted_scheduled_datetime?: string;
preferred_dates_display?: string;
preferred_time_slots_display?: string;
meeting_duration_display?: string;
}
export interface SelectedSlot {
day: number; // 0-6 (Monday-Sunday)
date?: string; // YYYY-MM-DD format
time_slot: "morning" | "afternoon" | "evening";
}
export interface AppointmentResponse {
appointment?: Appointment;
message?: string;
[key: string]: any;
}
export interface AppointmentsListResponse {
appointments: Appointment[];
count?: number;
next?: string | null;
previous?: string | null;
}
export interface AvailableDatesResponse {
dates?: string[]; // YYYY-MM-DD format (legacy)
available_days?: number[]; // 0-6 (Monday-Sunday)
available_days_display?: string[];
// New format - array of date objects with time slots
available_dates?: Array<{
date: string; // YYYY-MM-DD
day_name: string;
available_slots: string[];
available_slots_display?: string[];
is_available: boolean;
}>;
}
export interface WeeklyAvailabilityDay {
day: number; // 0-6 (Monday-Sunday)
day_name: string;
available_slots: string[]; // ["morning", "afternoon", "evening"]
available_slots_display?: string[];
is_available: boolean;
}
export type WeeklyAvailabilityResponse = WeeklyAvailabilityDay[] | {
week?: WeeklyAvailabilityDay[];
[key: string]: any; // Allow for different response formats
};
export interface AvailabilityConfig {
days_of_week: Record<string, string>; // {"0": "Monday", ...}
time_slots: Record<string, string>; // {"morning": "Morning (9AM - 12PM)", ...}
}
export interface CheckDateAvailabilityResponse {
date: string;
day_name: string;
available_slots: string[];
available_slots_display?: string[];
is_available: boolean;
}
export interface AvailabilityOverview {
available: boolean;
total_available_slots: number;
available_days: string[];
next_available_dates: Array<{
date: string;
day_name: string;
available_slots: string[];
is_available: boolean;
}>;
}
export interface AdminAvailability {
available_days?: number[]; // 0-6 (Monday-Sunday) (legacy)
available_days_display?: string[];
availability_schedule?: Record<string, string[]>; // {"0": ["morning", "evening"], "1": ["afternoon"]}
availability_schedule_display?: string;
all_available_slots?: SelectedSlot[];
}
export interface MatchingAvailability {
appointment_id: string;
preferences_match_availability: boolean;
matching_slots: Array<{
date: string; // YYYY-MM-DD
time_slot: string;
day: number;
}>;
total_matching_slots: number;
}
export interface AppointmentStats {
total_requests: number;
pending_review: number;
pending_review_pct?: number;
scheduled: number;
scheduled_pct?: number;
rejected: number;
rejected_pct?: number;
completed: number;
completed_pct?: number;
completion_rate: number;
users?: number; // Total users count from API
users_pct?: number;
active_upcoming_meetings?: number;
availability_coverage?: number;
availability_coverage_pct?: number;
available_days_count?: number;
jitsi_meetings_created?: number;
meetings_with_video?: number;
meetings_with_video_pct?: number;
video_meetings?: number;
}
export interface UserAppointmentStats {
total_requests: number;
pending_review: number;
pending_review_pct?: number;
scheduled: number;
scheduled_pct?: number;
rejected: number;
rejected_pct?: number;
completed: number;
completed_pct?: number;
completion_rate: number;
email?: string;
}
export interface JitsiMeetingInfo {
meeting_url: string;
room_id: string;
scheduled_time: string;
duration: string;
can_join: boolean;
meeting_status: string;
join_instructions: string;
}
export interface ApiError {
detail?: string | string[];
message?: string | string[];
error?: string;
preferred_dates?: string[];
preferred_time_slots?: string[];
email?: string[];
first_name?: string[];
last_name?: string[];
[key: string]: string | string[] | undefined;
}

View File

@ -1,56 +0,0 @@
// Authentication Response Models
export interface AuthTokens {
access: string;
refresh: string;
}
export interface User {
id: number;
email: string;
first_name: string;
last_name: string;
phone_number?: string;
is_admin?: boolean;
isAdmin?: boolean; // API uses camelCase
is_staff?: boolean;
isStaff?: boolean; // API uses camelCase
is_superuser?: boolean;
isSuperuser?: boolean; // API uses camelCase
is_verified?: boolean;
isVerified?: boolean; // API uses camelCase
is_active?: boolean;
isActive?: boolean; // API uses camelCase
date_joined?: string;
last_login?: string;
created_at?: string;
updated_at?: string;
}
export interface AuthResponse {
message?: string;
access?: string; // Tokens can be at root level
refresh?: string; // Tokens can be at root level
tokens?: AuthTokens; // Or nested in tokens object
user?: User;
detail?: string;
error?: string;
}
export interface ApiError {
detail?: string;
message?: string;
error?: string;
email?: string[];
password?: string[];
password2?: string[];
otp?: string[];
[key: string]: string | string[] | undefined;
}
// Token Storage Keys
export const TOKEN_STORAGE_KEYS = {
ACCESS_TOKEN: "auth_access_token",
REFRESH_TOKEN: "auth_refresh_token",
USER: "auth_user",
} as const;

View File

@ -1,85 +0,0 @@
import { z } from "zod";
// Selected Slot Schema (for new API format)
export const selectedSlotSchema = z.object({
day: z.number().int().min(0).max(6),
time_slot: z.enum(["morning", "afternoon", "evening"]),
});
export type SelectedSlotInput = z.infer<typeof selectedSlotSchema>;
// Create Appointment Schema (updated to use selected_slots)
export const createAppointmentSchema = z.object({
first_name: z.string().min(1, "First name is required").max(100, "First name must be 100 characters or less"),
last_name: z.string().min(1, "Last name is required").max(100, "Last name must be 100 characters or less"),
email: z.string().email("Invalid email address").max(100, "Email must be 100 characters or less"),
selected_slots: z
.array(selectedSlotSchema)
.min(1, "At least one time slot must be selected"),
phone: z.string().max(100, "Phone must be 100 characters or less").optional(),
reason: z.string().max(100, "Reason must be 100 characters or less").optional(),
// Legacy fields (optional, for backward compatibility - but should not be sent)
preferred_dates: z
.array(z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format"))
.optional(),
preferred_time_slots: z
.array(z.enum(["morning", "afternoon", "evening"]))
.optional(),
}).refine(
(data) => data.selected_slots && data.selected_slots.length > 0,
{
message: "At least one time slot must be selected",
path: ["selected_slots"],
}
);
export type CreateAppointmentInput = z.infer<typeof createAppointmentSchema>;
// Schedule Appointment Schema (Admin only)
// Supports two scheduling methods:
// 1. Direct datetime: scheduled_datetime + scheduled_duration + timezone
// 2. Date and slot: date_str + time_slot + scheduled_duration
export const scheduleAppointmentSchema = z.object({
scheduled_datetime: z.string().datetime("Invalid datetime format").optional(),
scheduled_duration: z.number().int().positive().optional(),
timezone: z.string().optional(),
date_str: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format").optional(),
time_slot: z.enum(["morning", "afternoon", "evening"]).optional(),
create_jitsi_meeting: z.boolean().optional(),
jitsi_custom_config: z.string().optional(),
}).refine(
(data) => {
// Either scheduled_datetime OR (date_str + time_slot) must be provided
return data.scheduled_datetime || (data.date_str && data.time_slot);
},
{
message: "Either scheduled_datetime or both date_str and time_slot must be provided",
path: ["scheduled_datetime"],
}
);
export type ScheduleAppointmentInput = z.infer<typeof scheduleAppointmentSchema>;
// Reject Appointment Schema (Admin only)
export const rejectAppointmentSchema = z.object({
rejection_reason: z.string().optional(),
});
export type RejectAppointmentInput = z.infer<typeof rejectAppointmentSchema>;
// Update Admin Availability Schema (updated to use availability_schedule)
export const updateAvailabilitySchema = z.object({
availability_schedule: z
.record(z.string(), z.array(z.enum(["morning", "afternoon", "evening"])))
.refine(
(schedule) => Object.keys(schedule).length > 0,
{ message: "At least one day must have availability" }
),
// Legacy field (optional, for backward compatibility)
available_days: z
.array(z.number().int().min(0).max(6))
.optional(),
});
export type UpdateAvailabilityInput = z.infer<typeof updateAvailabilitySchema>;

View File

@ -1,99 +0,0 @@
import { z } from "zod";
// Register Schema
export const registerSchema = z
.object({
email: z.string().email("Invalid email address"),
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(),
password: z.string().min(8, "Password must be at least 8 characters"),
password2: z.string().min(8, "Password confirmation is required"),
})
.refine((data) => data.password === data.password2, {
message: "Passwords do not match",
path: ["password2"],
});
export type RegisterInput = z.infer<typeof registerSchema>;
// Verify OTP Schema
export const verifyOtpSchema = z.object({
email: z.string().email("Invalid email address"),
otp: z.string().min(6, "OTP must be 6 digits").max(6, "OTP must be 6 digits"),
});
export type VerifyOtpInput = z.infer<typeof verifyOtpSchema>;
// Login Schema
export const loginSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(1, "Password is required"),
});
export type LoginInput = z.infer<typeof loginSchema>;
// Resend OTP Schema
export const resendOtpSchema = z.object({
email: z.string().email("Invalid email address"),
context: z.enum(["registration", "password_reset"]).optional(),
});
export type ResendOtpInput = z.infer<typeof resendOtpSchema>;
// Forgot Password Schema
export const forgotPasswordSchema = z.object({
email: z.string().email("Invalid email address"),
});
export type ForgotPasswordInput = z.infer<typeof forgotPasswordSchema>;
// Verify Password Reset OTP Schema
export const verifyPasswordResetOtpSchema = z.object({
email: z.string().email("Invalid email address"),
otp: z.string().min(6, "OTP must be 6 digits").max(6, "OTP must be 6 digits"),
});
export type VerifyPasswordResetOtpInput = z.infer<typeof verifyPasswordResetOtpSchema>;
// Reset Password Schema
export const resetPasswordSchema = z
.object({
email: z.string().email("Invalid email address"),
otp: z.string().min(6, "OTP must be 6 digits").max(6, "OTP must be 6 digits"),
new_password: z.string().min(8, "Password must be at least 8 characters"),
confirm_password: z.string().min(8, "Password confirmation is required"),
})
.refine((data) => data.new_password === data.confirm_password, {
message: "Passwords do not match",
path: ["confirm_password"],
});
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>;
// Token Refresh Schema
export const tokenRefreshSchema = z.object({
refresh: z.string().min(1, "Refresh token is required"),
});
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>;
// Contact Form Schema
export const contactSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email address"),
phone: z.string().min(1, "Phone number is required"),
message: z.string().min(1, "Message is required"),
});
export type ContactInput = z.infer<typeof contactSchema>;

View File

@ -1,237 +0,0 @@
/**
* 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) {
// 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) {
// 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)
}
}
}
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) {
// Failed to decrypt field, keep original value
}
}
// If not encrypted, keep as-is (backward compatibility)
}
}
return decrypted;
}

View File

@ -1,74 +0,0 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Get tokens from cookies
const accessToken = request.cookies.get("auth_access_token")?.value;
const userStr = request.cookies.get("auth_user")?.value;
const isAuthenticated = !!accessToken;
let isAdmin = false;
if (userStr) {
try {
// Decode the user string if it's URL encoded
const decodedUserStr = decodeURIComponent(userStr);
const user = JSON.parse(decodedUserStr);
// Check for admin status using multiple possible field names
// Admin users must be verified (is_verified or isVerified must be true)
const isVerified = user.is_verified === true || user.isVerified === true;
const hasAdminRole =
user.is_admin === true ||
user.isAdmin === true ||
user.is_staff === true ||
user.isStaff === true ||
user.is_superuser === true ||
user.isSuperuser === true;
// User is admin only if they have admin role AND are verified
isAdmin = hasAdminRole && isVerified;
} catch {
// Invalid user data - silently fail and treat as non-admin
}
}
// Protected routes
const isProtectedRoute = pathname.startsWith("/user") || pathname.startsWith("/admin");
const isAdminRoute = pathname.startsWith("/admin");
const isUserRoute = pathname.startsWith("/user");
const isAuthRoute = pathname.startsWith("/login") || pathname.startsWith("/signup");
// Redirect unauthenticated users away from protected routes
if (isProtectedRoute && !isAuthenticated) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("redirect", pathname);
return NextResponse.redirect(loginUrl);
}
// Redirect authenticated users away from auth routes
if (isAuthRoute && isAuthenticated) {
// Redirect based on user role
const redirectPath = isAdmin ? "/admin/booking" : "/user/dashboard";
return NextResponse.redirect(new URL(redirectPath, request.url));
}
// Redirect admin users away from user routes
if (isUserRoute && isAuthenticated && isAdmin) {
return NextResponse.redirect(new URL("/admin/booking", request.url));
}
// Redirect non-admin users away from admin routes
if (isAdminRoute && isAuthenticated && !isAdmin) {
return NextResponse.redirect(new URL("/user/dashboard", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: [
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};

View File

@ -17,29 +17,21 @@
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@tanstack/react-query": "^5.90.10",
"@types/react-datepicker": "^7.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"framer-motion": "^12.23.24",
"input-otp": "^1.4.2",
"lucide-react": "^0.552.0",
"next": "16.0.1",
"next-themes": "^0.4.6",
"react": "19.2.0",
"react-datepicker": "^8.9.0",
"react-day-picker": "^9.11.1",
"react-dom": "19.2.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"zod": "^4.1.12"
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/hast": "^3.0.4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",

File diff suppressed because it is too large Load Diff

BIN
public/section-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 718 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 627 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 614 KiB