Compare commits
No commits in common. "master" and "fix/landing-page-issues" have entirely different histories.
master
...
fix/landin
@ -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"
|
||||
}`}
|
||||
|
||||
@ -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
@ -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>
|
||||
);
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
### 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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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 } : {}}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -152,6 +152,7 @@ export function Finances() {
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -220,8 +220,8 @@ export function Services() {
|
||||
animate={isInView ? { opacity: 1 } : {}}
|
||||
transition={{ duration: 0.8, delay: 0.9 }}
|
||||
>
|
||||
Therapy is tailored for children (age 0–10), 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 6–10), 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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 }
|
||||
@ -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}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
229
hooks/useAuth.ts
229
hooks/useAuth.ts
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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)$).*)",
|
||||
],
|
||||
};
|
||||
10
package.json
10
package.json
@ -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",
|
||||
|
||||
962
pnpm-lock.yaml
962
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
BIN
public/section-image.png
Normal file
BIN
public/section-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/ss1.png
BIN
public/ss1.png
Binary file not shown.
|
Before Width: | Height: | Size: 718 KiB |
BIN
public/ss2.png
BIN
public/ss2.png
Binary file not shown.
|
Before Width: | Height: | Size: 627 KiB |
BIN
public/ss3.png
BIN
public/ss3.png
Binary file not shown.
|
Before Width: | Height: | Size: 614 KiB |
Loading…
Reference in New Issue
Block a user