Compare commits
No commits in common. "92bd895b40649908e2c109d22b3e7a036cef0eae" and "84f0f3137c239cf6bd6dc2a1cf34eca89b8c294c" have entirely different histories.
92bd895b40
...
84f0f3137c
@ -53,7 +53,7 @@ export default function Booking() {
|
|||||||
const { adminAvailability, isLoadingAdminAvailability, updateAvailability, isUpdatingAvailability, refetchAdminAvailability } = useAppointments();
|
const { adminAvailability, isLoadingAdminAvailability, updateAvailability, isUpdatingAvailability, refetchAdminAvailability } = useAppointments();
|
||||||
const [selectedDays, setSelectedDays] = useState<number[]>([]);
|
const [selectedDays, setSelectedDays] = useState<number[]>([]);
|
||||||
const [availabilityDialogOpen, setAvailabilityDialogOpen] = useState(false);
|
const [availabilityDialogOpen, setAvailabilityDialogOpen] = useState(false);
|
||||||
const [dayTimeSlots, setDayTimeSlots] = useState<Record<number, string[]>>({});
|
const [dayTimeRanges, setDayTimeRanges] = useState<Record<number, { startTime: string; endTime: string }>>({});
|
||||||
|
|
||||||
const daysOfWeek = [
|
const daysOfWeek = [
|
||||||
{ value: 0, label: "Monday" },
|
{ value: 0, label: "Monday" },
|
||||||
@ -65,56 +65,64 @@ export default function Booking() {
|
|||||||
{ value: 6, label: "Sunday" },
|
{ value: 6, label: "Sunday" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Load time slots from localStorage on mount
|
// Load time ranges from localStorage on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedTimeSlots = localStorage.getItem("adminAvailabilityTimeSlots");
|
const savedTimeRanges = localStorage.getItem("adminAvailabilityTimeRanges");
|
||||||
if (savedTimeSlots) {
|
if (savedTimeRanges) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(savedTimeSlots);
|
const parsed = JSON.parse(savedTimeRanges);
|
||||||
setDayTimeSlots(parsed);
|
setDayTimeRanges(parsed);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to parse saved time slots:", error);
|
console.error("Failed to parse saved time ranges:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Initialize selected days and time slots when availability is loaded
|
// Initialize selected days and time ranges when availability is loaded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (adminAvailability?.available_days) {
|
if (adminAvailability?.available_days) {
|
||||||
setSelectedDays(adminAvailability.available_days);
|
setSelectedDays(adminAvailability.available_days);
|
||||||
// Load saved time slots or use defaults
|
// Load saved time ranges or use defaults
|
||||||
const savedTimeSlots = localStorage.getItem("adminAvailabilityTimeSlots");
|
const savedTimeRanges = localStorage.getItem("adminAvailabilityTimeRanges");
|
||||||
let initialSlots: Record<number, string[]> = {};
|
let initialRanges: Record<number, { startTime: string; endTime: string }> = {};
|
||||||
|
|
||||||
if (savedTimeSlots) {
|
if (savedTimeRanges) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(savedTimeSlots);
|
const parsed = JSON.parse(savedTimeRanges);
|
||||||
// Only use saved slots for days that are currently available
|
// Only use saved ranges for days that are currently available
|
||||||
adminAvailability.available_days.forEach((day) => {
|
adminAvailability.available_days.forEach((day) => {
|
||||||
initialSlots[day] = parsed[day] || ["morning", "lunchtime", "afternoon"];
|
initialRanges[day] = parsed[day] || { startTime: "09:00", endTime: "17:00" };
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If parsing fails, use defaults
|
// If parsing fails, use defaults
|
||||||
adminAvailability.available_days.forEach((day) => {
|
adminAvailability.available_days.forEach((day) => {
|
||||||
initialSlots[day] = ["morning", "lunchtime", "afternoon"];
|
initialRanges[day] = { startTime: "09:00", endTime: "17:00" };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No saved slots, use defaults
|
// No saved ranges, use defaults
|
||||||
adminAvailability.available_days.forEach((day) => {
|
adminAvailability.available_days.forEach((day) => {
|
||||||
initialSlots[day] = ["morning", "lunchtime", "afternoon"];
|
initialRanges[day] = { startTime: "09:00", endTime: "17:00" };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setDayTimeSlots(initialSlots);
|
setDayTimeRanges(initialRanges);
|
||||||
}
|
}
|
||||||
}, [adminAvailability]);
|
}, [adminAvailability]);
|
||||||
|
|
||||||
const timeSlotOptions = [
|
// Generate time slots for time picker
|
||||||
{ value: "morning", label: "Morning" },
|
const generateTimeSlots = () => {
|
||||||
{ value: "lunchtime", label: "Lunchtime" },
|
const slots = [];
|
||||||
{ value: "afternoon", label: "Evening" },
|
for (let hour = 0; hour < 24; hour++) {
|
||||||
];
|
for (let minute = 0; minute < 60; minute += 30) {
|
||||||
|
const timeString = `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}`;
|
||||||
|
slots.push(timeString);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return slots;
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeSlotsForPicker = generateTimeSlots();
|
||||||
|
|
||||||
const handleDayToggle = (day: number) => {
|
const handleDayToggle = (day: number) => {
|
||||||
setSelectedDays((prev) => {
|
setSelectedDays((prev) => {
|
||||||
@ -122,20 +130,20 @@ export default function Booking() {
|
|||||||
? prev.filter((d) => d !== day)
|
? prev.filter((d) => d !== day)
|
||||||
: [...prev, day].sort();
|
: [...prev, day].sort();
|
||||||
|
|
||||||
// Initialize time slots for newly added day
|
// Initialize time range for newly added day
|
||||||
if (!prev.includes(day) && !dayTimeSlots[day]) {
|
if (!prev.includes(day) && !dayTimeRanges[day]) {
|
||||||
setDayTimeSlots((prevSlots) => ({
|
setDayTimeRanges((prevRanges) => ({
|
||||||
...prevSlots,
|
...prevRanges,
|
||||||
[day]: ["morning", "lunchtime", "afternoon"],
|
[day]: { startTime: "09:00", endTime: "17:00" },
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove time slots for removed day
|
// Remove time range for removed day
|
||||||
if (prev.includes(day)) {
|
if (prev.includes(day)) {
|
||||||
setDayTimeSlots((prevSlots) => {
|
setDayTimeRanges((prevRanges) => {
|
||||||
const newSlots = { ...prevSlots };
|
const newRanges = { ...prevRanges };
|
||||||
delete newSlots[day];
|
delete newRanges[day];
|
||||||
return newSlots;
|
return newRanges;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -143,18 +151,14 @@ export default function Booking() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTimeSlotToggle = (day: number, slot: string) => {
|
const handleTimeRangeChange = (day: number, field: "startTime" | "endTime", value: string) => {
|
||||||
setDayTimeSlots((prev) => {
|
setDayTimeRanges((prev) => ({
|
||||||
const currentSlots = prev[day] || [];
|
...prev,
|
||||||
const newSlots = currentSlots.includes(slot)
|
[day]: {
|
||||||
? currentSlots.filter((s) => s !== slot)
|
...prev[day],
|
||||||
: [...currentSlots, slot];
|
[field]: value,
|
||||||
|
},
|
||||||
return {
|
}));
|
||||||
...prev,
|
|
||||||
[day]: newSlots,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveAvailability = async () => {
|
const handleSaveAvailability = async () => {
|
||||||
@ -163,11 +167,15 @@ export default function Booking() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate all time slots
|
// Validate all time ranges
|
||||||
for (const day of selectedDays) {
|
for (const day of selectedDays) {
|
||||||
const timeSlots = dayTimeSlots[day];
|
const timeRange = dayTimeRanges[day];
|
||||||
if (!timeSlots || timeSlots.length === 0) {
|
if (!timeRange || !timeRange.startTime || !timeRange.endTime) {
|
||||||
toast.error(`Please select at least one time slot for ${daysOfWeek.find(d => d.value === day)?.label}`);
|
toast.error(`Please set time range for ${daysOfWeek.find(d => d.value === day)?.label}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (timeRange.startTime >= timeRange.endTime) {
|
||||||
|
toast.error(`End time must be after start time for ${daysOfWeek.find(d => d.value === day)?.label}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -177,8 +185,8 @@ export default function Booking() {
|
|||||||
const daysToSave = selectedDays.map(day => Number(day)).sort();
|
const daysToSave = selectedDays.map(day => Number(day)).sort();
|
||||||
await updateAvailability({ available_days: daysToSave });
|
await updateAvailability({ available_days: daysToSave });
|
||||||
|
|
||||||
// Save time slots to localStorage
|
// Save time ranges to localStorage
|
||||||
localStorage.setItem("adminAvailabilityTimeSlots", JSON.stringify(dayTimeSlots));
|
localStorage.setItem("adminAvailabilityTimeRanges", JSON.stringify(dayTimeRanges));
|
||||||
|
|
||||||
toast.success("Availability updated successfully!");
|
toast.success("Availability updated successfully!");
|
||||||
// Refresh availability data
|
// Refresh availability data
|
||||||
@ -196,12 +204,12 @@ export default function Booking() {
|
|||||||
const handleOpenAvailabilityDialog = () => {
|
const handleOpenAvailabilityDialog = () => {
|
||||||
if (adminAvailability?.available_days) {
|
if (adminAvailability?.available_days) {
|
||||||
setSelectedDays(adminAvailability.available_days);
|
setSelectedDays(adminAvailability.available_days);
|
||||||
// Initialize time slots for each day
|
// Initialize time ranges for each day
|
||||||
const initialSlots: Record<number, string[]> = {};
|
const initialRanges: Record<number, { startTime: string; endTime: string }> = {};
|
||||||
adminAvailability.available_days.forEach((day) => {
|
adminAvailability.available_days.forEach((day) => {
|
||||||
initialSlots[day] = dayTimeSlots[day] || ["morning", "lunchtime", "afternoon"];
|
initialRanges[day] = dayTimeRanges[day] || { startTime: "09:00", endTime: "17:00" };
|
||||||
});
|
});
|
||||||
setDayTimeSlots(initialSlots);
|
setDayTimeRanges(initialRanges);
|
||||||
}
|
}
|
||||||
setAvailabilityDialogOpen(true);
|
setAvailabilityDialogOpen(true);
|
||||||
};
|
};
|
||||||
@ -433,11 +441,7 @@ export default function Booking() {
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{adminAvailability.available_days.map((dayNum, index) => {
|
{adminAvailability.available_days.map((dayNum, index) => {
|
||||||
const dayName = daysOfWeek.find(d => d.value === dayNum)?.label || adminAvailability.available_days_display[index];
|
const dayName = daysOfWeek.find(d => d.value === dayNum)?.label || adminAvailability.available_days_display[index];
|
||||||
const timeSlots = dayTimeSlots[dayNum] || [];
|
const timeRange = dayTimeRanges[dayNum] || { startTime: "09:00", endTime: "17:00" };
|
||||||
const slotLabels = timeSlots.map(slot => {
|
|
||||||
const option = timeSlotOptions.find(opt => opt.value === slot);
|
|
||||||
return option ? option.label : slot;
|
|
||||||
});
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={dayNum}
|
key={dayNum}
|
||||||
@ -449,11 +453,19 @@ export default function Booking() {
|
|||||||
>
|
>
|
||||||
<Check className={`w-3.5 h-3.5 shrink-0 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
|
<Check className={`w-3.5 h-3.5 shrink-0 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
|
||||||
<span className="font-medium shrink-0">{dayName}</span>
|
<span className="font-medium shrink-0">{dayName}</span>
|
||||||
{slotLabels.length > 0 && (
|
<span className={`text-sm shrink-0 ${isDark ? "text-rose-300" : "text-rose-600"}`}>
|
||||||
<span className={`text-sm shrink-0 ${isDark ? "text-rose-300" : "text-rose-600"}`}>
|
({new Date(`2000-01-01T${timeRange.startTime}`).toLocaleTimeString("en-US", {
|
||||||
({slotLabels.join(", ")})
|
hour: "numeric",
|
||||||
</span>
|
minute: "2-digit",
|
||||||
)}
|
hour12: true,
|
||||||
|
})}{" "}
|
||||||
|
-{" "}
|
||||||
|
{new Date(`2000-01-01T${timeRange.endTime}`).toLocaleTimeString("en-US", {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: true,
|
||||||
|
})})
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -823,13 +835,14 @@ export default function Booking() {
|
|||||||
Available Days & Times *
|
Available Days & Times *
|
||||||
</label>
|
</label>
|
||||||
<p className={`text-xs mb-3 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
<p className={`text-xs mb-3 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
Select days and choose time slots (Morning, Lunchtime, Evening) for each day
|
Select days and set time ranges for each day
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{daysOfWeek.map((day) => {
|
{daysOfWeek.map((day) => {
|
||||||
const isSelected = selectedDays.includes(day.value);
|
const isSelected = selectedDays.includes(day.value);
|
||||||
|
const timeRange = dayTimeRanges[day.value] || { startTime: "09:00", endTime: "17:00" };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -873,30 +886,80 @@ export default function Booking() {
|
|||||||
|
|
||||||
{isSelected && (
|
{isSelected && (
|
||||||
<div className="mt-3 pt-3 border-t border-gray-300 dark:border-gray-600">
|
<div className="mt-3 pt-3 border-t border-gray-300 dark:border-gray-600">
|
||||||
<label className={`text-xs font-medium mb-2 block ${isDark ? "text-gray-400" : "text-gray-600"}`}>
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
Available Time Slots
|
{/* Start Time */}
|
||||||
</label>
|
<div className="space-y-1.5">
|
||||||
<div className="flex flex-wrap gap-2">
|
<label className={`text-xs font-medium ${isDark ? "text-gray-400" : "text-gray-600"}`}>
|
||||||
{timeSlotOptions.map((slot) => {
|
Start Time
|
||||||
const isSelectedSlot = dayTimeSlots[day.value]?.includes(slot.value) || false;
|
</label>
|
||||||
return (
|
<Select
|
||||||
<button
|
value={timeRange.startTime}
|
||||||
key={slot.value}
|
onValueChange={(value) => handleTimeRangeChange(day.value, "startTime", value)}
|
||||||
type="button"
|
>
|
||||||
onClick={() => handleTimeSlotToggle(day.value, slot.value)}
|
<SelectTrigger className={`h-9 text-sm ${isDark ? "bg-gray-700 border-gray-600 text-white" : "bg-white border-gray-300"}`}>
|
||||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-all ${
|
<SelectValue />
|
||||||
isSelectedSlot
|
</SelectTrigger>
|
||||||
? isDark
|
<SelectContent className={isDark ? "bg-gray-800 border-gray-700" : "bg-white"}>
|
||||||
? "bg-rose-600 border-rose-500 text-white"
|
{timeSlotsForPicker.map((time) => (
|
||||||
: "bg-rose-500 border-rose-500 text-white"
|
<SelectItem
|
||||||
: isDark
|
key={time}
|
||||||
? "bg-gray-700 border-gray-600 text-gray-300 hover:border-rose-500"
|
value={time}
|
||||||
: "bg-white border-gray-300 text-gray-700 hover:border-rose-500"
|
className={isDark ? "text-white hover:bg-gray-700" : ""}
|
||||||
}`}
|
>
|
||||||
>
|
{new Date(`2000-01-01T${time}`).toLocaleTimeString("en-US", {
|
||||||
{slot.label}
|
hour: "numeric",
|
||||||
</button>
|
minute: "2-digit",
|
||||||
);
|
hour12: true,
|
||||||
|
})}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* End Time */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className={`text-xs font-medium ${isDark ? "text-gray-400" : "text-gray-600"}`}>
|
||||||
|
End Time
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={timeRange.endTime}
|
||||||
|
onValueChange={(value) => handleTimeRangeChange(day.value, "endTime", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className={`h-9 text-sm ${isDark ? "bg-gray-700 border-gray-600 text-white" : "bg-white border-gray-300"}`}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className={isDark ? "bg-gray-800 border-gray-700" : "bg-white"}>
|
||||||
|
{timeSlotsForPicker.map((time) => (
|
||||||
|
<SelectItem
|
||||||
|
key={time}
|
||||||
|
value={time}
|
||||||
|
className={isDark ? "text-white hover:bg-gray-700" : ""}
|
||||||
|
>
|
||||||
|
{new Date(`2000-01-01T${time}`).toLocaleTimeString("en-US", {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: true,
|
||||||
|
})}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time Range Preview */}
|
||||||
|
<div className={`mt-2 p-2 rounded text-xs ${isDark ? "bg-gray-800/50 text-gray-300" : "bg-white text-gray-600"}`}>
|
||||||
|
{new Date(`2000-01-01T${timeRange.startTime}`).toLocaleTimeString("en-US", {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: true,
|
||||||
|
})}{" "}
|
||||||
|
-{" "}
|
||||||
|
{new Date(`2000-01-01T${timeRange.endTime}`).toLocaleTimeString("en-US", {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
hour12: true,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
|
||||||
@ -13,21 +13,16 @@ import {
|
|||||||
Lock,
|
Lock,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
Loader2,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useAppTheme } from "@/components/ThemeProvider";
|
import { useAppTheme } from "@/components/ThemeProvider";
|
||||||
import { getProfile, updateProfile } from "@/lib/actions/auth";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
export default function AdminSettingsPage() {
|
export default function AdminSettingsPage() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [fetching, setFetching] = useState(true);
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
firstName: "",
|
fullName: "Hammond",
|
||||||
lastName: "",
|
email: "admin@attuneheart.com",
|
||||||
email: "",
|
phone: "+1 (555) 123-4567",
|
||||||
phone: "",
|
|
||||||
});
|
});
|
||||||
const [passwordData, setPasswordData] = useState({
|
const [passwordData, setPasswordData] = useState({
|
||||||
currentPassword: "",
|
currentPassword: "",
|
||||||
@ -42,30 +37,6 @@ export default function AdminSettingsPage() {
|
|||||||
const { theme } = useAppTheme();
|
const { theme } = useAppTheme();
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
|
|
||||||
// Fetch profile data on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchProfile = async () => {
|
|
||||||
setFetching(true);
|
|
||||||
try {
|
|
||||||
const profile = await getProfile();
|
|
||||||
setFormData({
|
|
||||||
firstName: profile.first_name || "",
|
|
||||||
lastName: profile.last_name || "",
|
|
||||||
email: profile.email || "",
|
|
||||||
phone: profile.phone_number || "",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch profile:", error);
|
|
||||||
const errorMessage = error instanceof Error ? error.message : "Failed to load profile";
|
|
||||||
toast.error(errorMessage);
|
|
||||||
} finally {
|
|
||||||
setFetching(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchProfile();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleInputChange = (field: string, value: string) => {
|
const handleInputChange = (field: string, value: string) => {
|
||||||
setFormData((prev) => ({
|
setFormData((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@ -88,26 +59,11 @@ export default function AdminSettingsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!formData.firstName || !formData.lastName) {
|
|
||||||
toast.error("First name and last name are required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
// Simulate API call
|
||||||
await updateProfile({
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
first_name: formData.firstName,
|
setLoading(false);
|
||||||
last_name: formData.lastName,
|
// In a real app, you would show a success message here
|
||||||
phone_number: formData.phone || undefined,
|
|
||||||
});
|
|
||||||
toast.success("Profile updated successfully!");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to update profile:", error);
|
|
||||||
const errorMessage = error instanceof Error ? error.message : "Failed to update profile";
|
|
||||||
toast.error(errorMessage);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePasswordSave = async () => {
|
const handlePasswordSave = async () => {
|
||||||
@ -157,20 +113,15 @@ export default function AdminSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={loading || fetching}
|
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"
|
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 ? (
|
{loading ? (
|
||||||
<>
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
Saving...
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Save className="w-4 h-4 mr-2" />
|
||||||
<Save className="w-4 h-4 mr-2" />
|
|
||||||
Save Changes
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
Save Changes
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -188,49 +139,21 @@ export default function AdminSettingsPage() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{fetching ? (
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-center py-8">
|
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||||
<Loader2 className="w-6 h-6 animate-spin text-rose-600" />
|
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>
|
||||||
) : (
|
</div>
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
|
||||||
First Name *
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<User className={`absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 ${isDark ? "text-gray-500" : "text-gray-400"}`} />
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={formData.firstName}
|
|
||||||
onChange={(e) => handleInputChange("firstName", e.target.value)}
|
|
||||||
className={`pl-10 ${isDark ? "bg-gray-700 border-gray-600 text-white placeholder:text-gray-400" : ""}`}
|
|
||||||
placeholder="Enter your first name"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
|
||||||
Last Name *
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<User className={`absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 ${isDark ? "text-gray-500" : "text-gray-400"}`} />
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={formData.lastName}
|
|
||||||
onChange={(e) => handleInputChange("lastName", e.target.value)}
|
|
||||||
className={`pl-10 ${isDark ? "bg-gray-700 border-gray-600 text-white placeholder:text-gray-400" : ""}`}
|
|
||||||
placeholder="Enter your last name"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
<label className={`text-sm font-medium ${isDark ? "text-gray-300" : "text-gray-700"}`}>
|
||||||
@ -241,14 +164,11 @@ export default function AdminSettingsPage() {
|
|||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
disabled
|
onChange={(e) => handleInputChange("email", e.target.value)}
|
||||||
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"}`}
|
className={`pl-10 ${isDark ? "bg-gray-700 border-gray-600 text-white placeholder:text-gray-400" : ""}`}
|
||||||
placeholder="Email address"
|
placeholder="Enter your email"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className={`text-xs ${isDark ? "text-gray-500" : "text-gray-400"}`}>
|
|
||||||
Email address cannot be changed
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@ -139,18 +139,13 @@ function LoginContent() {
|
|||||||
// Wait a moment for cookies to be set, then redirect
|
// Wait a moment for cookies to be set, then redirect
|
||||||
// Check if user is admin/staff/superuser - check all possible field names
|
// Check if user is admin/staff/superuser - check all possible field names
|
||||||
const user = result.user as any;
|
const user = result.user as any;
|
||||||
const isTruthy = (value: any): boolean => {
|
|
||||||
if (value === true || value === "true" || value === 1 || value === "1") return true;
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const userIsAdmin =
|
const userIsAdmin =
|
||||||
isTruthy(user.is_admin) ||
|
user.is_admin === true ||
|
||||||
isTruthy(user.isAdmin) ||
|
user.isAdmin === true ||
|
||||||
isTruthy(user.is_staff) ||
|
user.is_staff === true ||
|
||||||
isTruthy(user.isStaff) ||
|
user.isStaff === true ||
|
||||||
isTruthy(user.is_superuser) ||
|
user.is_superuser === true ||
|
||||||
isTruthy(user.isSuperuser);
|
user.isSuperuser === true;
|
||||||
|
|
||||||
// Wait longer for cookies to be set and middleware to process
|
// Wait longer for cookies to be set and middleware to process
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from "react";
|
import { useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useAppTheme } from "@/components/ThemeProvider";
|
import { useAppTheme } from "@/components/ThemeProvider";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@ -24,7 +24,6 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
Loader2,
|
Loader2,
|
||||||
LogOut,
|
LogOut,
|
||||||
CalendarCheck,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
@ -35,8 +34,6 @@ import { useAuth } from "@/hooks/useAuth";
|
|||||||
import { useAppointments } from "@/hooks/useAppointments";
|
import { useAppointments } from "@/hooks/useAppointments";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { Appointment } from "@/lib/models/appointments";
|
import type { Appointment } from "@/lib/models/appointments";
|
||||||
import { getPublicAvailability } from "@/lib/actions/appointments";
|
|
||||||
import type { AdminAvailability } from "@/lib/models/appointments";
|
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
ID: number;
|
ID: number;
|
||||||
@ -83,7 +80,7 @@ export default function BookNowPage() {
|
|||||||
const { theme } = useAppTheme();
|
const { theme } = useAppTheme();
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
const { isAuthenticated, logout } = useAuth();
|
const { isAuthenticated, logout } = useAuth();
|
||||||
const { create, isCreating, availableDates, availableDatesResponse, isLoadingAvailableDates } = useAppointments();
|
const { create, isCreating } = useAppointments();
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
firstName: "",
|
firstName: "",
|
||||||
lastName: "",
|
lastName: "",
|
||||||
@ -98,68 +95,6 @@ export default function BookNowPage() {
|
|||||||
const [showLoginDialog, setShowLoginDialog] = useState(false);
|
const [showLoginDialog, setShowLoginDialog] = useState(false);
|
||||||
const [showSignupDialog, setShowSignupDialog] = useState(false);
|
const [showSignupDialog, setShowSignupDialog] = useState(false);
|
||||||
const [loginPrefillEmail, setLoginPrefillEmail] = useState<string | undefined>(undefined);
|
const [loginPrefillEmail, setLoginPrefillEmail] = useState<string | undefined>(undefined);
|
||||||
const [publicAvailability, setPublicAvailability] = useState<AdminAvailability | null>(null);
|
|
||||||
const [availableTimeSlots, setAvailableTimeSlots] = useState<Record<number, string[]>>({});
|
|
||||||
|
|
||||||
// Fetch public availability to get time slots
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchAvailability = async () => {
|
|
||||||
try {
|
|
||||||
const availability = await getPublicAvailability();
|
|
||||||
if (availability) {
|
|
||||||
setPublicAvailability(availability);
|
|
||||||
// Try to get time slots from localStorage (if admin has set them)
|
|
||||||
// Note: This won't work for public users, but we can try
|
|
||||||
const savedTimeSlots = localStorage.getItem("adminAvailabilityTimeSlots");
|
|
||||||
if (savedTimeSlots) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(savedTimeSlots);
|
|
||||||
setAvailableTimeSlots(parsed);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to parse time slots:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch public availability:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchAvailability();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Use available_days_display from API if available, otherwise extract from dates
|
|
||||||
const availableDaysOfWeek = useMemo(() => {
|
|
||||||
// If API provides available_days_display, use it directly
|
|
||||||
if (availableDatesResponse?.available_days_display && availableDatesResponse.available_days_display.length > 0) {
|
|
||||||
return availableDatesResponse.available_days_display;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, extract from dates
|
|
||||||
if (!availableDates || availableDates.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const daysSet = new Set<string>();
|
|
||||||
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
||||||
|
|
||||||
availableDates.forEach((dateStr: string) => {
|
|
||||||
try {
|
|
||||||
// Parse date string (YYYY-MM-DD format)
|
|
||||||
const [year, month, day] = dateStr.split('-').map(Number);
|
|
||||||
const date = new Date(year, month - 1, day);
|
|
||||||
if (!isNaN(date.getTime())) {
|
|
||||||
const dayIndex = date.getDay();
|
|
||||||
daysSet.add(dayNames[dayIndex]);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Invalid date:', dateStr, e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return in weekday order (Monday first)
|
|
||||||
const weekdayOrder = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
|
||||||
return weekdayOrder.filter(day => daysSet.has(day));
|
|
||||||
}, [availableDates, availableDatesResponse]);
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
@ -631,7 +566,7 @@ export default function BookNowPage() {
|
|||||||
Appointment Details
|
Appointment Details
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label
|
<label
|
||||||
className={`text-sm font-medium flex items-center gap-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}
|
className={`text-sm font-medium flex items-center gap-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}
|
||||||
@ -639,19 +574,8 @@ export default function BookNowPage() {
|
|||||||
<Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-500'}`} />
|
<Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-500'}`} />
|
||||||
Available Days *
|
Available Days *
|
||||||
</label>
|
</label>
|
||||||
{isLoadingAvailableDates ? (
|
<div className="flex flex-wrap gap-3">
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
{['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'].map((day) => (
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
Loading available days...
|
|
||||||
</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>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
{availableDaysOfWeek.map((day: string) => (
|
|
||||||
<label
|
<label
|
||||||
key={day}
|
key={day}
|
||||||
className={`flex items-center gap-2 cursor-pointer px-4 py-2 rounded-lg border transition-all ${
|
className={`flex items-center gap-2 cursor-pointer px-4 py-2 rounded-lg border transition-all ${
|
||||||
@ -673,9 +597,7 @@ export default function BookNowPage() {
|
|||||||
<span className="text-sm font-medium">{day}</span>
|
<span className="text-sm font-medium">{day}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -686,33 +608,11 @@ export default function BookNowPage() {
|
|||||||
Preferred Time *
|
Preferred Time *
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
{(() => {
|
{[
|
||||||
// Get available time slots based on selected days
|
{ value: 'morning', label: 'Morning' },
|
||||||
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
{ value: 'lunchtime', label: 'Lunchtime' },
|
||||||
const dayIndices = formData.preferredDays.map(day => dayNames.indexOf(day));
|
{ value: 'afternoon', label: 'Afternoon' }
|
||||||
|
].map((time) => (
|
||||||
// Get all unique time slots from selected days
|
|
||||||
let allAvailableSlots = new Set<string>();
|
|
||||||
dayIndices.forEach(dayIndex => {
|
|
||||||
if (dayIndex !== -1 && availableTimeSlots[dayIndex]) {
|
|
||||||
availableTimeSlots[dayIndex].forEach(slot => allAvailableSlots.add(slot));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// If no time slots found in localStorage, show all (fallback)
|
|
||||||
const slotsToShow = allAvailableSlots.size > 0
|
|
||||||
? Array.from(allAvailableSlots)
|
|
||||||
: ['morning', 'lunchtime', 'afternoon'];
|
|
||||||
|
|
||||||
const timeSlotMap = [
|
|
||||||
{ value: 'morning', label: 'Morning' },
|
|
||||||
{ value: 'lunchtime', label: 'Lunchtime' },
|
|
||||||
{ value: 'afternoon', label: 'Evening' }
|
|
||||||
];
|
|
||||||
|
|
||||||
// Only show time slots that are available
|
|
||||||
return timeSlotMap.filter(ts => slotsToShow.includes(ts.value));
|
|
||||||
})().map((time) => (
|
|
||||||
<label
|
<label
|
||||||
key={time.value}
|
key={time.value}
|
||||||
className={`flex items-center gap-2 cursor-pointer px-4 py-2 rounded-lg border transition-all ${
|
className={`flex items-center gap-2 cursor-pointer px-4 py-2 rounded-lg border transition-all ${
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
@ -16,28 +16,57 @@ import {
|
|||||||
CalendarCheck,
|
CalendarCheck,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
Settings,
|
Settings,
|
||||||
Loader2,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Navbar } from "@/components/Navbar";
|
import { Navbar } from "@/components/Navbar";
|
||||||
import { useAppTheme } from "@/components/ThemeProvider";
|
import { useAppTheme } from "@/components/ThemeProvider";
|
||||||
import { useAppointments } from "@/hooks/useAppointments";
|
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
interface Booking {
|
||||||
import type { Appointment } from "@/lib/models/appointments";
|
ID: number;
|
||||||
import { toast } from "sonner";
|
scheduled_at: string;
|
||||||
|
duration: number;
|
||||||
|
status: string;
|
||||||
|
amount: number;
|
||||||
|
notes: string;
|
||||||
|
jitsi_room_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function UserDashboard() {
|
export default function UserDashboard() {
|
||||||
const { theme } = useAppTheme();
|
const { theme } = useAppTheme();
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
const { user } = useAuth();
|
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||||
const {
|
const [loading, setLoading] = useState(true);
|
||||||
userAppointments,
|
|
||||||
userAppointmentStats,
|
useEffect(() => {
|
||||||
isLoadingUserAppointments,
|
// Simulate API call to fetch user bookings
|
||||||
isLoadingUserStats,
|
const fetchBookings = async () => {
|
||||||
refetchUserAppointments,
|
setLoading(true);
|
||||||
refetchUserStats,
|
try {
|
||||||
} = useAppointments();
|
// Simulate network delay
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
// Mock data - in real app, this would fetch from API
|
||||||
|
const mockBookings: Booking[] = [
|
||||||
|
{
|
||||||
|
ID: 1,
|
||||||
|
scheduled_at: "2025-01-15T10:00:00Z",
|
||||||
|
duration: 60,
|
||||||
|
status: "scheduled",
|
||||||
|
amount: 150,
|
||||||
|
notes: "Initial consultation",
|
||||||
|
jitsi_room_url: "https://meet.jit.si/sample-room",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
setBookings(mockBookings);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch bookings:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchBookings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
@ -57,70 +86,47 @@ export default function UserDashboard() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatMemberSince = (dateString?: string) => {
|
const upcomingBookings = bookings.filter(
|
||||||
if (!dateString) return "N/A";
|
(booking) => booking.status === "scheduled"
|
||||||
const date = new Date(dateString);
|
);
|
||||||
return date.toLocaleDateString("en-US", {
|
const completedBookings = bookings.filter(
|
||||||
month: "long",
|
(booking) => booking.status === "completed"
|
||||||
year: "numeric",
|
);
|
||||||
});
|
const cancelledBookings = bookings.filter(
|
||||||
};
|
(booking) => booking.status === "cancelled"
|
||||||
|
);
|
||||||
// Filter appointments by status
|
|
||||||
const upcomingAppointments = useMemo(() => {
|
|
||||||
return userAppointments.filter(
|
|
||||||
(appointment) => appointment.status === "scheduled"
|
|
||||||
);
|
|
||||||
}, [userAppointments]);
|
|
||||||
|
|
||||||
const completedAppointments = useMemo(() => {
|
|
||||||
return userAppointments.filter(
|
|
||||||
(appointment) => appointment.status === "completed"
|
|
||||||
);
|
|
||||||
}, [userAppointments]);
|
|
||||||
|
|
||||||
const stats = userAppointmentStats || {
|
|
||||||
total_requests: 0,
|
|
||||||
pending_review: 0,
|
|
||||||
scheduled: 0,
|
|
||||||
rejected: 0,
|
|
||||||
completed: 0,
|
|
||||||
completion_rate: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const statCards = [
|
const statCards = [
|
||||||
{
|
{
|
||||||
title: "Upcoming Appointments",
|
title: "Upcoming Appointments",
|
||||||
value: stats.scheduled,
|
value: upcomingBookings.length,
|
||||||
icon: CalendarCheck,
|
icon: CalendarCheck,
|
||||||
trend: stats.scheduled > 0 ? `+${stats.scheduled}` : "0",
|
trend: "+2",
|
||||||
trendUp: true,
|
trendUp: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Completed Sessions",
|
title: "Completed Sessions",
|
||||||
value: stats.completed || 0,
|
value: completedBookings.length,
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
trend: stats.completed > 0 ? `+${stats.completed}` : "0",
|
trend: "+5",
|
||||||
trendUp: true,
|
trendUp: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Total Appointments",
|
title: "Total Appointments",
|
||||||
value: stats.total_requests,
|
value: bookings.length,
|
||||||
icon: Calendar,
|
icon: Calendar,
|
||||||
trend: `${Math.round(stats.completion_rate || 0)}%`,
|
trend: "+12%",
|
||||||
trendUp: true,
|
trendUp: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Pending Review",
|
title: "Total Spent",
|
||||||
value: stats.pending_review,
|
value: `$${bookings.reduce((sum, b) => sum + b.amount, 0)}`,
|
||||||
icon: Calendar,
|
icon: Heart,
|
||||||
trend: stats.pending_review > 0 ? `${stats.pending_review}` : "0",
|
trend: "+18%",
|
||||||
trendUp: false,
|
trendUp: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const loading = isLoadingUserAppointments || isLoadingUserStats;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`min-h-screen ${isDark ? 'bg-gray-900' : 'bg-gray-50'}`}>
|
<div className={`min-h-screen ${isDark ? 'bg-gray-900' : 'bg-gray-50'}`}>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
@ -152,7 +158,7 @@ export default function UserDashboard() {
|
|||||||
className="w-full sm:w-auto bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white"
|
className="w-full sm:w-auto bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white"
|
||||||
>
|
>
|
||||||
<CalendarPlus className="w-4 h-4 mr-2" />
|
<CalendarPlus className="w-4 h-4 mr-2" />
|
||||||
Request Appointment
|
Book Appointment
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@ -160,7 +166,7 @@ export default function UserDashboard() {
|
|||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@ -208,62 +214,53 @@ export default function UserDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Upcoming Appointments Section */}
|
{/* Upcoming Appointments Section */}
|
||||||
{upcomingAppointments.length > 0 ? (
|
{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'}`}>
|
<div className={`rounded-lg border p-4 sm:p-5 md:p-6 ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}>
|
||||||
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||||
Upcoming Appointments
|
Upcoming Appointments
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{upcomingAppointments.map((appointment) => (
|
{upcomingBookings.map((booking) => (
|
||||||
<div
|
<div
|
||||||
key={appointment.id}
|
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'}`}
|
className={`border rounded-lg p-4 hover:shadow-md transition-shadow ${isDark ? 'border-gray-700 bg-gray-700/50' : 'border-gray-200'}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
{appointment.scheduled_datetime && (
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<>
|
<Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<span className={`font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||||
<Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
{formatDate(booking.scheduled_at)}
|
||||||
<span className={`font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
</span>
|
||||||
{formatDate(appointment.scheduled_datetime)}
|
</div>
|
||||||
</span>
|
<div className="flex items-center gap-2 mb-2">
|
||||||
</div>
|
<Clock className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<span className={isDark ? 'text-gray-300' : 'text-gray-700'}>
|
||||||
<Clock className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
{formatTime(booking.scheduled_at)}
|
||||||
<span className={isDark ? 'text-gray-300' : 'text-gray-700'}>
|
</span>
|
||||||
{formatTime(appointment.scheduled_datetime)}
|
<span className={`text-sm font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||||
</span>
|
({booking.duration} minutes)
|
||||||
{appointment.scheduled_duration && (
|
</span>
|
||||||
<span className={`text-sm font-medium ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
</div>
|
||||||
({appointment.scheduled_duration} minutes)
|
{booking.notes && (
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{appointment.reason && (
|
|
||||||
<p className={`text-sm mt-2 font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
|
<p className={`text-sm mt-2 font-medium ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||||
{appointment.reason}
|
{booking.notes}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col sm:items-end gap-3">
|
<div className="flex flex-col sm:items-end gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
|
<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'}`}>
|
||||||
appointment.status === "scheduled"
|
{booking.status.charAt(0).toUpperCase() +
|
||||||
? isDark ? 'bg-green-900/30 text-green-400' : 'bg-green-50 text-green-700'
|
booking.status.slice(1)}
|
||||||
: appointment.status === "pending_review"
|
</span>
|
||||||
? isDark ? 'bg-yellow-900/30 text-yellow-400' : 'bg-yellow-50 text-yellow-700'
|
<span className={`text-lg font-bold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||||
: isDark ? 'bg-red-900/30 text-red-400' : 'bg-red-50 text-red-700'
|
${booking.amount}
|
||||||
}`}>
|
|
||||||
{appointment.status.charAt(0).toUpperCase() +
|
|
||||||
appointment.status.slice(1).replace('_', ' ')}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{appointment.jitsi_meet_url && appointment.can_join_meeting && (
|
{booking.jitsi_room_url && (
|
||||||
<a
|
<a
|
||||||
href={appointment.jitsi_meet_url}
|
href={booking.jitsi_room_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors"
|
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors"
|
||||||
@ -278,92 +275,68 @@ export default function UserDashboard() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : !loading && (
|
|
||||||
<div className={`rounded-lg border p-4 sm:p-5 md:p-6 ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}>
|
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
|
||||||
<CalendarCheck className={`w-12 h-12 mb-4 ${isDark ? 'text-gray-500' : 'text-gray-400'}`} />
|
|
||||||
<p className={`text-lg font-medium mb-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
|
|
||||||
Request Appointment
|
|
||||||
</p>
|
|
||||||
<p className={`text-sm mb-6 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
|
||||||
No upcoming appointments. Book an appointment to get started.
|
|
||||||
</p>
|
|
||||||
<Link href="/book-now">
|
|
||||||
<Button className="bg-gradient-to-r from-rose-500 to-pink-600 hover:from-rose-600 hover:to-pink-700 text-white">
|
|
||||||
<CalendarPlus className="w-4 h-4 mr-2" />
|
|
||||||
Request Appointment
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Account Information */}
|
{/* Account Information */}
|
||||||
{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'}`}>
|
||||||
<div className={`rounded-lg border p-4 sm:p-5 md:p-6 ${isDark ? 'bg-gray-800 border-gray-700' : 'bg-white border-gray-200'}`}>
|
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||||
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
Account Information
|
||||||
Account Information
|
</h2>
|
||||||
</h2>
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
|
||||||
<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'}`} />
|
||||||
<User className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
|
||||||
Full Name
|
|
||||||
</p>
|
|
||||||
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
|
||||||
{user.first_name} {user.last_name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div>
|
||||||
<div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
|
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||||
<Mail className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
Full Name
|
||||||
</div>
|
</p>
|
||||||
<div>
|
<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-600'}`}>
|
John Doe
|
||||||
Email
|
</p>
|
||||||
</p>
|
</div>
|
||||||
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
</div>
|
||||||
{user.email}
|
<div className="flex items-center gap-3">
|
||||||
</p>
|
<div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
{user.phone_number && (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
|
|
||||||
<Phone className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
|
||||||
Phone
|
|
||||||
</p>
|
|
||||||
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
|
||||||
{user.phone_number}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{user.date_joined && (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`p-2 rounded-lg ${isDark ? 'bg-gray-700' : 'bg-gray-50'}`}>
|
|
||||||
<Calendar className={`w-4 h-4 ${isDark ? 'text-gray-400' : 'text-gray-600'}`} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-600'}`}>
|
|
||||||
Member Since
|
|
||||||
</p>
|
|
||||||
<p className={`text-base font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
|
||||||
{formatMemberSince(user.date_joined)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@ -70,30 +70,9 @@ export function LoginDialog({ open, onOpenChange, onLoginSuccess, prefillEmail,
|
|||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
// Reset form
|
// Reset form
|
||||||
setLoginData({ email: "", password: "" });
|
setLoginData({ email: "", password: "" });
|
||||||
|
// Redirect to user dashboard
|
||||||
// Check if user is admin/staff/superuser
|
router.push("/user/dashboard");
|
||||||
const user = result.user as any;
|
|
||||||
const isTruthy = (value: any): boolean => {
|
|
||||||
if (value === true || value === "true" || value === 1 || value === "1") return true;
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const userIsAdmin =
|
|
||||||
isTruthy(user.is_admin) ||
|
|
||||||
isTruthy(user.isAdmin) ||
|
|
||||||
isTruthy(user.is_staff) ||
|
|
||||||
isTruthy(user.isStaff) ||
|
|
||||||
isTruthy(user.is_superuser) ||
|
|
||||||
isTruthy(user.isSuperuser);
|
|
||||||
|
|
||||||
// Call onLoginSuccess callback first
|
|
||||||
onLoginSuccess();
|
onLoginSuccess();
|
||||||
|
|
||||||
// Redirect based on user role
|
|
||||||
const redirectPath = userIsAdmin ? "/admin/dashboard" : "/user/dashboard";
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.href = redirectPath;
|
|
||||||
}, 200);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : "Login failed. Please try again.";
|
const errorMessage = err instanceof Error ? err.message : "Login failed. Please try again.";
|
||||||
|
|||||||
@ -25,7 +25,7 @@ export function Navbar() {
|
|||||||
const isUserDashboard = pathname?.startsWith("/user/dashboard");
|
const isUserDashboard = pathname?.startsWith("/user/dashboard");
|
||||||
const isUserSettings = pathname?.startsWith("/user/settings");
|
const isUserSettings = pathname?.startsWith("/user/settings");
|
||||||
const isUserRoute = pathname?.startsWith("/user/");
|
const isUserRoute = pathname?.startsWith("/user/");
|
||||||
const { isAuthenticated, logout, user, isAdmin } = useAuth();
|
const { isAuthenticated, logout } = useAuth();
|
||||||
|
|
||||||
const scrollToSection = (id: string) => {
|
const scrollToSection = (id: string) => {
|
||||||
const element = document.getElementById(id);
|
const element = document.getElementById(id);
|
||||||
@ -36,11 +36,8 @@ export function Navbar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleLoginSuccess = () => {
|
const handleLoginSuccess = () => {
|
||||||
// Check if user is admin/staff/superuser and redirect accordingly
|
// Redirect to admin dashboard after successful login
|
||||||
// Note: user might not be immediately available, so we check isAdmin from hook
|
router.push("/admin/dashboard");
|
||||||
// which is computed from the user data
|
|
||||||
const redirectPath = isAdmin ? "/admin/dashboard" : "/user/dashboard";
|
|
||||||
router.push(redirectPath);
|
|
||||||
setMobileMenuOpen(false);
|
setMobileMenuOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import {
|
|||||||
getAvailableDates,
|
getAvailableDates,
|
||||||
listAppointments,
|
listAppointments,
|
||||||
getUserAppointments,
|
getUserAppointments,
|
||||||
getUserAppointmentStats,
|
|
||||||
getAppointmentDetail,
|
getAppointmentDetail,
|
||||||
scheduleAppointment,
|
scheduleAppointment,
|
||||||
rejectAppointment,
|
rejectAppointment,
|
||||||
@ -26,8 +25,6 @@ import type {
|
|||||||
Appointment,
|
Appointment,
|
||||||
AdminAvailability,
|
AdminAvailability,
|
||||||
AppointmentStats,
|
AppointmentStats,
|
||||||
UserAppointmentStats,
|
|
||||||
AvailableDatesResponse,
|
|
||||||
JitsiMeetingInfo,
|
JitsiMeetingInfo,
|
||||||
} from "@/lib/models/appointments";
|
} from "@/lib/models/appointments";
|
||||||
|
|
||||||
@ -35,7 +32,7 @@ export function useAppointments() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Get available dates query
|
// Get available dates query
|
||||||
const availableDatesQuery = useQuery<AvailableDatesResponse>({
|
const availableDatesQuery = useQuery<string[]>({
|
||||||
queryKey: ["appointments", "available-dates"],
|
queryKey: ["appointments", "available-dates"],
|
||||||
queryFn: () => getAvailableDates(),
|
queryFn: () => getAvailableDates(),
|
||||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||||
@ -78,13 +75,6 @@ export function useAppointments() {
|
|||||||
staleTime: 1 * 60 * 1000, // 1 minute
|
staleTime: 1 * 60 * 1000, // 1 minute
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get user appointment stats query
|
|
||||||
const userAppointmentStatsQuery = useQuery<UserAppointmentStats>({
|
|
||||||
queryKey: ["appointments", "user", "stats"],
|
|
||||||
queryFn: () => getUserAppointmentStats(),
|
|
||||||
staleTime: 1 * 60 * 1000, // 1 minute
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get Jitsi meeting info query
|
// Get Jitsi meeting info query
|
||||||
const useJitsiMeetingInfo = (id: string | null) => {
|
const useJitsiMeetingInfo = (id: string | null) => {
|
||||||
return useQuery<JitsiMeetingInfo>({
|
return useQuery<JitsiMeetingInfo>({
|
||||||
@ -170,13 +160,11 @@ export function useAppointments() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
// Queries
|
// Queries
|
||||||
availableDates: availableDatesQuery.data?.dates || [],
|
availableDates: availableDatesQuery.data || [],
|
||||||
availableDatesResponse: availableDatesQuery.data,
|
|
||||||
appointments: appointmentsQuery.data || [],
|
appointments: appointmentsQuery.data || [],
|
||||||
userAppointments: userAppointmentsQuery.data || [],
|
userAppointments: userAppointmentsQuery.data || [],
|
||||||
adminAvailability: adminAvailabilityQuery.data,
|
adminAvailability: adminAvailabilityQuery.data,
|
||||||
appointmentStats: appointmentStatsQuery.data,
|
appointmentStats: appointmentStatsQuery.data,
|
||||||
userAppointmentStats: userAppointmentStatsQuery.data,
|
|
||||||
|
|
||||||
// Query states
|
// Query states
|
||||||
isLoadingAvailableDates: availableDatesQuery.isLoading,
|
isLoadingAvailableDates: availableDatesQuery.isLoading,
|
||||||
@ -184,7 +172,6 @@ export function useAppointments() {
|
|||||||
isLoadingUserAppointments: userAppointmentsQuery.isLoading,
|
isLoadingUserAppointments: userAppointmentsQuery.isLoading,
|
||||||
isLoadingAdminAvailability: adminAvailabilityQuery.isLoading,
|
isLoadingAdminAvailability: adminAvailabilityQuery.isLoading,
|
||||||
isLoadingStats: appointmentStatsQuery.isLoading,
|
isLoadingStats: appointmentStatsQuery.isLoading,
|
||||||
isLoadingUserStats: userAppointmentStatsQuery.isLoading,
|
|
||||||
|
|
||||||
// Query refetch functions
|
// Query refetch functions
|
||||||
refetchAvailableDates: availableDatesQuery.refetch,
|
refetchAvailableDates: availableDatesQuery.refetch,
|
||||||
@ -192,7 +179,6 @@ export function useAppointments() {
|
|||||||
refetchUserAppointments: userAppointmentsQuery.refetch,
|
refetchUserAppointments: userAppointmentsQuery.refetch,
|
||||||
refetchAdminAvailability: adminAvailabilityQuery.refetch,
|
refetchAdminAvailability: adminAvailabilityQuery.refetch,
|
||||||
refetchStats: appointmentStatsQuery.refetch,
|
refetchStats: appointmentStatsQuery.refetch,
|
||||||
refetchUserStats: userAppointmentStatsQuery.refetch,
|
|
||||||
|
|
||||||
// Hooks for specific queries
|
// Hooks for specific queries
|
||||||
useAppointmentDetail,
|
useAppointmentDetail,
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import type {
|
|||||||
AvailableDatesResponse,
|
AvailableDatesResponse,
|
||||||
AdminAvailability,
|
AdminAvailability,
|
||||||
AppointmentStats,
|
AppointmentStats,
|
||||||
UserAppointmentStats,
|
|
||||||
JitsiMeetingInfo,
|
JitsiMeetingInfo,
|
||||||
ApiError,
|
ApiError,
|
||||||
} from "@/lib/models/appointments";
|
} from "@/lib/models/appointments";
|
||||||
@ -80,7 +79,7 @@ export async function createAppointment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get available dates
|
// Get available dates
|
||||||
export async function getAvailableDates(): Promise<AvailableDatesResponse> {
|
export async function getAvailableDates(): Promise<string[]> {
|
||||||
const response = await fetch(API_ENDPOINTS.meetings.availableDates, {
|
const response = await fetch(API_ENDPOINTS.meetings.availableDates, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
@ -95,14 +94,11 @@ export async function getAvailableDates(): Promise<AvailableDatesResponse> {
|
|||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If API returns array directly, wrap it in response object
|
// API returns array of dates in YYYY-MM-DD format
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
return {
|
return data;
|
||||||
dates: data,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
return (data as AvailableDatesResponse).dates || [];
|
||||||
return data as AvailableDatesResponse;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// List appointments (Admin sees all, users see their own)
|
// List appointments (Admin sees all, users see their own)
|
||||||
@ -283,43 +279,6 @@ export async function rejectAppointment(
|
|||||||
return data as unknown as Appointment;
|
return data as unknown as Appointment;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get admin availability (public version - tries without auth first)
|
|
||||||
export async function getPublicAvailability(): Promise<AdminAvailability | null> {
|
|
||||||
try {
|
|
||||||
const response = await fetch(API_ENDPOINTS.meetings.adminAvailability, {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: any = await response.json();
|
|
||||||
|
|
||||||
// Handle both string and array formats for available_days
|
|
||||||
let availableDays: number[] = [];
|
|
||||||
if (typeof data.available_days === 'string') {
|
|
||||||
try {
|
|
||||||
availableDays = JSON.parse(data.available_days);
|
|
||||||
} catch {
|
|
||||||
availableDays = data.available_days.split(',').map((d: string) => parseInt(d.trim())).filter((d: number) => !isNaN(d));
|
|
||||||
}
|
|
||||||
} else if (Array.isArray(data.available_days)) {
|
|
||||||
availableDays = data.available_days;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
available_days: availableDays,
|
|
||||||
available_days_display: data.available_days_display || [],
|
|
||||||
} as AdminAvailability;
|
|
||||||
} catch (error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get admin availability
|
// Get admin availability
|
||||||
export async function getAdminAvailability(): Promise<AdminAvailability> {
|
export async function getAdminAvailability(): Promise<AdminAvailability> {
|
||||||
const tokens = getStoredTokens();
|
const tokens = getStoredTokens();
|
||||||
@ -452,32 +411,6 @@ export async function getAppointmentStats(): Promise<AppointmentStats> {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user appointment stats
|
|
||||||
export async function getUserAppointmentStats(): Promise<UserAppointmentStats> {
|
|
||||||
const tokens = getStoredTokens();
|
|
||||||
|
|
||||||
if (!tokens.access) {
|
|
||||||
throw new Error("Authentication required.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(API_ENDPOINTS.meetings.userAppointmentStats, {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${tokens.access}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const data: UserAppointmentStats = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorMessage = extractErrorMessage(data as unknown as ApiError);
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get Jitsi meeting info
|
// Get Jitsi meeting info
|
||||||
export async function getJitsiMeetingInfo(id: string): Promise<JitsiMeetingInfo> {
|
export async function getJitsiMeetingInfo(id: string): Promise<JitsiMeetingInfo> {
|
||||||
const tokens = getStoredTokens();
|
const tokens = getStoredTokens();
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import type {
|
|||||||
VerifyPasswordResetOtpInput,
|
VerifyPasswordResetOtpInput,
|
||||||
ResetPasswordInput,
|
ResetPasswordInput,
|
||||||
TokenRefreshInput,
|
TokenRefreshInput,
|
||||||
UpdateProfileInput,
|
|
||||||
} from "@/lib/schema/auth";
|
} from "@/lib/schema/auth";
|
||||||
import type { AuthResponse, ApiError, AuthTokens, User } from "@/lib/models/auth";
|
import type { AuthResponse, ApiError, AuthTokens, User } from "@/lib/models/auth";
|
||||||
|
|
||||||
@ -370,72 +369,3 @@ export async function getAllUsers(): Promise<User[]> {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user profile
|
|
||||||
export async function getProfile(): Promise<User> {
|
|
||||||
const tokens = getStoredTokens();
|
|
||||||
|
|
||||||
if (!tokens.access) {
|
|
||||||
throw new Error("Authentication required.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(API_ENDPOINTS.auth.getProfile, {
|
|
||||||
method: "GET",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${tokens.access}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorMessage = extractErrorMessage(data);
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle different response formats
|
|
||||||
if (data.user) {
|
|
||||||
return data.user;
|
|
||||||
}
|
|
||||||
if (data.id) {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("Invalid profile response format");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update user profile
|
|
||||||
export async function updateProfile(input: UpdateProfileInput): Promise<User> {
|
|
||||||
const tokens = getStoredTokens();
|
|
||||||
|
|
||||||
if (!tokens.access) {
|
|
||||||
throw new Error("Authentication required.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(API_ENDPOINTS.auth.updateProfile, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${tokens.access}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorMessage = extractErrorMessage(data);
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle different response formats
|
|
||||||
if (data.user) {
|
|
||||||
return data.user;
|
|
||||||
}
|
|
||||||
if (data.id) {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error("Invalid profile response format");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@ -13,8 +13,6 @@ export const API_ENDPOINTS = {
|
|||||||
resetPassword: `${API_BASE_URL}/auth/reset-password/`,
|
resetPassword: `${API_BASE_URL}/auth/reset-password/`,
|
||||||
tokenRefresh: `${API_BASE_URL}/auth/token/refresh/`,
|
tokenRefresh: `${API_BASE_URL}/auth/token/refresh/`,
|
||||||
allUsers: `${API_BASE_URL}/auth/all-users/`,
|
allUsers: `${API_BASE_URL}/auth/all-users/`,
|
||||||
getProfile: `${API_BASE_URL}/auth/profile/`,
|
|
||||||
updateProfile: `${API_BASE_URL}/auth/profile/update/`,
|
|
||||||
},
|
},
|
||||||
meetings: {
|
meetings: {
|
||||||
base: `${API_BASE_URL}/meetings/`,
|
base: `${API_BASE_URL}/meetings/`,
|
||||||
@ -22,7 +20,6 @@ export const API_ENDPOINTS = {
|
|||||||
createAppointment: `${API_BASE_URL}/meetings/appointments/create/`,
|
createAppointment: `${API_BASE_URL}/meetings/appointments/create/`,
|
||||||
listAppointments: `${API_BASE_URL}/meetings/appointments/`,
|
listAppointments: `${API_BASE_URL}/meetings/appointments/`,
|
||||||
userAppointments: `${API_BASE_URL}/meetings/user/appointments/`,
|
userAppointments: `${API_BASE_URL}/meetings/user/appointments/`,
|
||||||
userAppointmentStats: `${API_BASE_URL}/meetings/user/appointments/stats/`,
|
|
||||||
adminAvailability: `${API_BASE_URL}/meetings/admin/availability/`,
|
adminAvailability: `${API_BASE_URL}/meetings/admin/availability/`,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -9,7 +9,7 @@ export interface Appointment {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
preferred_dates: string[]; // YYYY-MM-DD format
|
preferred_dates: string[]; // YYYY-MM-DD format
|
||||||
preferred_time_slots: string[]; // "morning", "afternoon", "evening"
|
preferred_time_slots: string[]; // "morning", "afternoon", "evening"
|
||||||
status: "pending_review" | "scheduled" | "rejected" | "completed";
|
status: "pending_review" | "scheduled" | "rejected";
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
scheduled_datetime?: string;
|
scheduled_datetime?: string;
|
||||||
@ -55,15 +55,6 @@ export interface AppointmentStats {
|
|||||||
users?: number; // Total users count from API
|
users?: number; // Total users count from API
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserAppointmentStats {
|
|
||||||
total_requests: number;
|
|
||||||
pending_review: number;
|
|
||||||
scheduled: number;
|
|
||||||
rejected: number;
|
|
||||||
completed: number;
|
|
||||||
completion_rate: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JitsiMeetingInfo {
|
export interface JitsiMeetingInfo {
|
||||||
meeting_url: string;
|
meeting_url: string;
|
||||||
room_id: string;
|
room_id: string;
|
||||||
|
|||||||
@ -78,12 +78,3 @@ export const tokenRefreshSchema = z.object({
|
|||||||
|
|
||||||
export type TokenRefreshInput = z.infer<typeof tokenRefreshSchema>;
|
export type TokenRefreshInput = z.infer<typeof tokenRefreshSchema>;
|
||||||
|
|
||||||
// Update Profile Schema
|
|
||||||
export const updateProfileSchema = z.object({
|
|
||||||
first_name: z.string().min(1, "First name is required"),
|
|
||||||
last_name: z.string().min(1, "Last name is required"),
|
|
||||||
phone_number: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>;
|
|
||||||
|
|
||||||
|
|||||||
@ -1,240 +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) {
|
|
||||||
console.error("Encryption error:", error);
|
|
||||||
// If encryption fails, return original value (graceful degradation)
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decrypt a string value
|
|
||||||
export async function decryptValue(encryptedValue: string): Promise<string> {
|
|
||||||
if (!encryptedValue || typeof window === "undefined") return encryptedValue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const key = await getEncryptionKey();
|
|
||||||
|
|
||||||
// Decode from base64
|
|
||||||
const binaryString = atob(encryptedValue);
|
|
||||||
const combined = Uint8Array.from(binaryString, c => c.charCodeAt(0));
|
|
||||||
|
|
||||||
// Extract IV and encrypted data
|
|
||||||
const iv = combined.slice(0, 12);
|
|
||||||
const encrypted = combined.slice(12);
|
|
||||||
|
|
||||||
const decrypted = await crypto.subtle.decrypt(
|
|
||||||
{
|
|
||||||
name: "AES-GCM",
|
|
||||||
iv: iv,
|
|
||||||
},
|
|
||||||
key,
|
|
||||||
encrypted
|
|
||||||
);
|
|
||||||
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
return decoder.decode(decrypted);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Decryption error:", error);
|
|
||||||
// If decryption fails, try to return as-is (might be unencrypted legacy data)
|
|
||||||
return encryptedValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encrypt sensitive fields in a user object
|
|
||||||
export async function encryptUserData(user: any): Promise<any> {
|
|
||||||
if (!user || typeof window === "undefined") return user;
|
|
||||||
|
|
||||||
const encrypted = { ...user };
|
|
||||||
|
|
||||||
// Encrypt sensitive fields
|
|
||||||
const sensitiveFields = ["first_name", "last_name", "phone_number", "email"];
|
|
||||||
|
|
||||||
for (const field of sensitiveFields) {
|
|
||||||
if (encrypted[field]) {
|
|
||||||
encrypted[field] = await encryptValue(String(encrypted[field]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return encrypted;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decrypt sensitive fields in a user object
|
|
||||||
export async function decryptUserData(user: any): Promise<any> {
|
|
||||||
if (!user || typeof window === "undefined") return user;
|
|
||||||
|
|
||||||
const decrypted = { ...user };
|
|
||||||
|
|
||||||
// Decrypt sensitive fields
|
|
||||||
const sensitiveFields = ["first_name", "last_name", "phone_number", "email"];
|
|
||||||
|
|
||||||
for (const field of sensitiveFields) {
|
|
||||||
if (decrypted[field]) {
|
|
||||||
try {
|
|
||||||
decrypted[field] = await decryptValue(String(decrypted[field]));
|
|
||||||
} catch (error) {
|
|
||||||
// If decryption fails, keep original value (might be unencrypted)
|
|
||||||
console.warn(`Failed to decrypt field ${field}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return decrypted;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if a value is encrypted (heuristic check)
|
|
||||||
function isEncrypted(value: string): boolean {
|
|
||||||
// Encrypted values are base64 encoded and have a specific structure
|
|
||||||
// This is a simple heuristic - encrypted values will be longer and base64-like
|
|
||||||
if (!value || value.length < 20) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try to decode as base64
|
|
||||||
atob(value);
|
|
||||||
// If it decodes successfully and is long enough, it's likely encrypted
|
|
||||||
return value.length > 30;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Smart encrypt/decrypt that handles both encrypted and unencrypted data
|
|
||||||
export async function smartDecryptUserData(user: any): Promise<any> {
|
|
||||||
if (!user || typeof window === "undefined") return user;
|
|
||||||
|
|
||||||
const decrypted = { ...user };
|
|
||||||
const sensitiveFields = ["first_name", "last_name", "phone_number", "email"];
|
|
||||||
|
|
||||||
for (const field of sensitiveFields) {
|
|
||||||
if (decrypted[field] && typeof decrypted[field] === "string") {
|
|
||||||
if (isEncrypted(decrypted[field])) {
|
|
||||||
try {
|
|
||||||
decrypted[field] = await decryptValue(decrypted[field]);
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(`Failed to decrypt field ${field}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If not encrypted, keep as-is (backward compatibility)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return decrypted;
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -72,3 +72,4 @@ export const config = {
|
|||||||
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user