Compare commits
2 Commits
a8ff1d377a
...
a65d03ccdd
| Author | SHA1 | Date | |
|---|---|---|---|
| a65d03ccdd | |||
|
|
01dcf9abe5 |
@ -6,12 +6,10 @@ import { usePathname, useRouter } from "next/navigation";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import {
|
import {
|
||||||
Inbox,
|
|
||||||
Calendar,
|
Calendar,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Heart,
|
Heart,
|
||||||
UserCog,
|
UserCog,
|
||||||
Bell,
|
|
||||||
Settings,
|
Settings,
|
||||||
LogOut,
|
LogOut,
|
||||||
FileText,
|
FileText,
|
||||||
@ -24,7 +22,8 @@ import { toast } from "sonner";
|
|||||||
export function Header() {
|
export function Header() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
// Notification state - commented out
|
||||||
|
// const [notificationsOpen, setNotificationsOpen] = useState(false);
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||||
const { theme } = useAppTheme();
|
const { theme } = useAppTheme();
|
||||||
const isDark = theme === "dark";
|
const isDark = theme === "dark";
|
||||||
@ -37,7 +36,8 @@ export function Header() {
|
|||||||
router.push("/");
|
router.push("/");
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock notifications data
|
// Mock notifications data - commented out
|
||||||
|
/*
|
||||||
const notifications = [
|
const notifications = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
@ -58,6 +58,7 @@ export function Header() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const unreadCount = notifications.filter((n) => !n.read).length;
|
const unreadCount = notifications.filter((n) => !n.read).length;
|
||||||
|
*/
|
||||||
|
|
||||||
return (
|
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`}>
|
<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`}>
|
||||||
@ -105,86 +106,7 @@ export function Header() {
|
|||||||
{/* Right Side Actions */}
|
{/* Right Side Actions */}
|
||||||
<div className="flex items-center gap-1.5 sm:gap-2 md:gap-3">
|
<div className="flex items-center gap-1.5 sm:gap-2 md:gap-3">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<Popover open={notificationsOpen} onOpenChange={setNotificationsOpen}>
|
{/* Notification Popover - Commented out */}
|
||||||
<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}>
|
<Popover open={userMenuOpen} onOpenChange={setUserMenuOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@ -367,64 +367,177 @@ export default function AppointmentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Matching Availability */}
|
{/* Selected Slots */}
|
||||||
{appointment.matching_availability && Array.isArray(appointment.matching_availability) && appointment.matching_availability.length > 0 && (
|
{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={`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"}`}>
|
<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"}`}>
|
<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"}`} />
|
<CalendarCheck className={`w-5 h-5 ${isDark ? "text-blue-400" : "text-blue-600"}`} />
|
||||||
Preferred Availability
|
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 ? "Available" : "Partially Available"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="space-y-4">
|
<div className="flex flex-wrap gap-3">
|
||||||
{appointment.matching_availability.map((match: any, idx: number) => (
|
{appointment.selected_slots.map((slot: any, idx: number) => {
|
||||||
<div
|
const dayNames: Record<number, string> = {
|
||||||
key={idx}
|
0: "Monday",
|
||||||
className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}
|
1: "Tuesday",
|
||||||
>
|
2: "Wednesday",
|
||||||
<div className="flex items-start justify-between mb-3">
|
3: "Thursday",
|
||||||
<div>
|
4: "Friday",
|
||||||
<p className={`text-base font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
|
5: "Saturday",
|
||||||
{match.day_name || "Unknown Day"}
|
6: "Sunday",
|
||||||
</p>
|
};
|
||||||
<p className={`text-sm mt-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
const timeSlotLabels: Record<string, string> = {
|
||||||
{formatShortDate(match.date || match.date_obj || "")}
|
morning: "Morning",
|
||||||
</p>
|
afternoon: "Lunchtime",
|
||||||
</div>
|
evening: "Evening",
|
||||||
|
};
|
||||||
|
const dayName = dayNames[slot.day] || `Day ${slot.day}`;
|
||||||
|
const timeSlot = String(slot.time_slot).toLowerCase().trim();
|
||||||
|
const timeLabel = timeSlotLabels[timeSlot] || slot.time_slot;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`px-4 py-3 rounded-xl border ${isDark ? "bg-blue-500/10 border-blue-500/30" : "bg-blue-50 border-blue-200"}`}
|
||||||
|
>
|
||||||
|
<p className={`text-sm font-semibold ${isDark ? "text-blue-300" : "text-blue-700"}`}>
|
||||||
|
{dayName}
|
||||||
|
</p>
|
||||||
|
<p className={`text-xs mt-1 ${isDark ? "text-blue-400" : "text-blue-600"}`}>
|
||||||
|
{timeLabel}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{match.available_slots && Array.isArray(match.available_slots) && match.available_slots.length > 0 && (
|
);
|
||||||
<div className="flex flex-wrap gap-2 mt-3">
|
})}
|
||||||
{match.available_slots.map((slot: string, slotIdx: number) => {
|
</div>
|
||||||
const timeSlotLabels: Record<string, string> = {
|
|
||||||
morning: "Morning",
|
|
||||||
afternoon: "Lunchtime",
|
|
||||||
evening: "Evening",
|
|
||||||
};
|
|
||||||
const normalizedSlot = String(slot).toLowerCase().trim();
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={slotIdx}
|
|
||||||
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"}`}
|
|
||||||
>
|
|
||||||
{timeSlotLabels[normalizedSlot] || slot}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Matching Slots */}
|
||||||
|
{(() => {
|
||||||
|
// Check if matching_availability is a MatchingAvailability object with matching_slots
|
||||||
|
const matchingAvailability = appointment.matching_availability as any;
|
||||||
|
const hasMatchingSlots = matchingAvailability && matchingAvailability.matching_slots && Array.isArray(matchingAvailability.matching_slots) && matchingAvailability.matching_slots.length > 0;
|
||||||
|
const isArrayFormat = Array.isArray(matchingAvailability) && matchingAvailability.length > 0;
|
||||||
|
|
||||||
|
if (!hasMatchingSlots && !isArrayFormat) return null;
|
||||||
|
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get matching slots from MatchingAvailability object
|
||||||
|
const matchingSlots = hasMatchingSlots ? matchingAvailability.matching_slots : null;
|
||||||
|
const totalMatchingSlots = hasMatchingSlots ? matchingAvailability.total_matching_slots : null;
|
||||||
|
const preferencesMatch = hasMatchingSlots ? matchingAvailability.preferences_match_availability : appointment.are_preferences_available;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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"}`} />
|
||||||
|
Matching Slots
|
||||||
|
{preferencesMatch !== undefined && (
|
||||||
|
<span className={`ml-auto px-3 py-1 text-xs font-medium rounded-full ${preferencesMatch ? (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")}`}>
|
||||||
|
{preferencesMatch ? "All Available" : "Partially Available"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
{hasMatchingSlots && totalMatchingSlots && (
|
||||||
|
<p className={`text-sm mb-4 ${isDark ? "text-gray-400" : "text-gray-600"}`}>
|
||||||
|
Found {totalMatchingSlots} matching time slot{totalMatchingSlots !== 1 ? 's' : ''} that match your selected preferences:
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!hasMatchingSlots && (
|
||||||
|
<p className={`text-sm mb-4 ${isDark ? "text-gray-400" : "text-gray-600"}`}>
|
||||||
|
These are the available time slots that match your selected preferences:
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasMatchingSlots ? (
|
||||||
|
// Display matching_slots from MatchingAvailability object
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{matchingSlots.map((slot: any, idx: number) => {
|
||||||
|
const dayName = dayNames[slot.day] || `Day ${slot.day}`;
|
||||||
|
const timeSlot = String(slot.time_slot).toLowerCase().trim();
|
||||||
|
const timeLabel = timeSlotLabels[timeSlot] || slot.time_slot;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`px-4 py-3 rounded-xl border ${isDark ? "bg-green-500/10 border-green-500/30" : "bg-green-50 border-green-200"}`}
|
||||||
|
>
|
||||||
|
<p className={`text-sm font-semibold ${isDark ? "text-green-300" : "text-green-700"}`}>
|
||||||
|
{dayName}
|
||||||
|
</p>
|
||||||
|
<p className={`text-xs mt-1 ${isDark ? "text-green-400" : "text-green-600"}`}>
|
||||||
|
{timeLabel}
|
||||||
|
</p>
|
||||||
|
<p className={`text-xs mt-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
|
{formatShortDate(slot.date)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Display array format (legacy)
|
||||||
|
<div className="space-y-4">
|
||||||
|
{(matchingAvailability as any[]).map((match: any, idx: number) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<p className={`text-base font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||||
|
{match.day_name || "Unknown Day"}
|
||||||
|
</p>
|
||||||
|
<p className={`text-sm mt-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
|
{formatShortDate(match.date || match.date_obj || "")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{match.available_slots && Array.isArray(match.available_slots) && match.available_slots.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mt-3">
|
||||||
|
{match.available_slots.map((slot: string, slotIdx: number) => {
|
||||||
|
const normalizedSlot = String(slot).toLowerCase().trim();
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={slotIdx}
|
||||||
|
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"}`}
|
||||||
|
>
|
||||||
|
{timeSlotLabels[normalizedSlot] || slot}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Reason */}
|
{/* Reason */}
|
||||||
{appointment.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={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
||||||
|
|||||||
@ -260,44 +260,53 @@ export default function BookNowPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare and validate slots - be very lenient
|
// Prepare and validate slots - only send exactly what user selected
|
||||||
const validSlots = currentSlots
|
// Filter out duplicates and ensure we only send the specific selected slots
|
||||||
.map(slot => {
|
const uniqueSlots = new Map<string, { day: number; time_slot: string }>();
|
||||||
if (!slot) return null;
|
|
||||||
|
|
||||||
// Get day - handle any format
|
currentSlots.forEach(slot => {
|
||||||
let dayNum: number;
|
if (!slot) return;
|
||||||
if (typeof slot.day === 'number') {
|
|
||||||
dayNum = slot.day;
|
|
||||||
} else {
|
|
||||||
dayNum = parseInt(String(slot.day || 0), 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate day
|
// Get day - handle any format
|
||||||
if (isNaN(dayNum) || dayNum < 0 || dayNum > 6) {
|
let dayNum: number;
|
||||||
return null;
|
if (typeof slot.day === 'number') {
|
||||||
}
|
dayNum = slot.day;
|
||||||
|
} else {
|
||||||
|
dayNum = parseInt(String(slot.day || 0), 10);
|
||||||
|
}
|
||||||
|
|
||||||
// Get time_slot - normalize
|
// Validate day
|
||||||
const timeSlot = String(slot.time_slot || '').trim().toLowerCase();
|
if (isNaN(dayNum) || dayNum < 0 || dayNum > 6) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Validate time_slot - accept morning, afternoon, evening
|
// Get time_slot - normalize
|
||||||
if (!timeSlot || !['morning', 'afternoon', 'evening'].includes(timeSlot)) {
|
const timeSlot = String(slot.time_slot || '').trim().toLowerCase();
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
// Validate time_slot - accept morning, afternoon, evening
|
||||||
day: dayNum,
|
if (!timeSlot || !['morning', 'afternoon', 'evening'].includes(timeSlot)) {
|
||||||
time_slot: timeSlot as "morning" | "afternoon" | "evening",
|
return;
|
||||||
};
|
}
|
||||||
})
|
|
||||||
.filter((slot): slot is { day: number; time_slot: "morning" | "afternoon" | "evening" } => slot !== null);
|
// Create unique key to prevent duplicates
|
||||||
|
const uniqueKey = `${dayNum}-${timeSlot}`;
|
||||||
|
uniqueSlots.set(uniqueKey, {
|
||||||
|
day: dayNum,
|
||||||
|
time_slot: timeSlot as "morning" | "afternoon" | "evening",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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
|
// Final validation check
|
||||||
if (!validSlots || validSlots.length === 0) {
|
if (!validSlots || validSlots.length === 0) {
|
||||||
setError("Please select at least one day and time slot combination by clicking on the time slot buttons.");
|
setError("Please select at least one day and time slot combination by clicking on the time slot buttons.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate and limit field lengths to prevent database errors
|
// Validate and limit field lengths to prevent database errors
|
||||||
const firstName = formData.firstName.trim().substring(0, 100);
|
const firstName = formData.firstName.trim().substring(0, 100);
|
||||||
@ -325,11 +334,21 @@ export default function BookNowPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Prepare payload with validated and limited fields
|
// 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)
|
||||||
const payload = {
|
const payload = {
|
||||||
first_name: firstName,
|
first_name: firstName,
|
||||||
last_name: lastName,
|
last_name: lastName,
|
||||||
email: email,
|
email: email,
|
||||||
selected_slots: validSlots,
|
selected_slots: selectedSlotsPayload, // Only send what user explicitly selected (day + time_slot format)
|
||||||
...(phone && phone.length > 0 && { phone: phone }),
|
...(phone && phone.length > 0 && { phone: phone }),
|
||||||
...(reason && reason.length > 0 && { reason: reason }),
|
...(reason && reason.length > 0 && { reason: reason }),
|
||||||
};
|
};
|
||||||
@ -385,13 +404,25 @@ export default function BookNowPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Handle slot selection (day + time slot combination)
|
// Handle slot selection (day + time slot combination)
|
||||||
const handleSlotToggle = (day: number, timeSlot: string) => {
|
// CRITICAL: Only toggle the specific slot that was clicked, nothing else
|
||||||
setFormData((prev) => {
|
const handleSlotToggle = useCallback((day: number, timeSlot: string) => {
|
||||||
const normalizedDay = Number(day);
|
const normalizedDay = Number(day);
|
||||||
const normalizedTimeSlot = String(timeSlot).toLowerCase().trim();
|
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 || [];
|
const currentSlots = prev.selectedSlots || [];
|
||||||
|
|
||||||
// Helper to check if two slots match
|
// 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 }) => {
|
const slotsMatch = (slot1: { day: number; time_slot: string }, slot2: { day: number; time_slot: string }) => {
|
||||||
return Number(slot1.day) === Number(slot2.day) &&
|
return Number(slot1.day) === Number(slot2.day) &&
|
||||||
String(slot1.time_slot).toLowerCase().trim() === String(slot2.time_slot).toLowerCase().trim();
|
String(slot1.time_slot).toLowerCase().trim() === String(slot2.time_slot).toLowerCase().trim();
|
||||||
@ -399,25 +430,26 @@ export default function BookNowPage() {
|
|||||||
|
|
||||||
const targetSlot = { day: normalizedDay, time_slot: normalizedTimeSlot };
|
const targetSlot = { day: normalizedDay, time_slot: normalizedTimeSlot };
|
||||||
|
|
||||||
// Check if this exact slot exists
|
// Check if this EXACT slot exists (check for duplicates too)
|
||||||
const slotExists = currentSlots.some(slot => slotsMatch(slot, targetSlot));
|
const existingIndex = currentSlots.findIndex(slot => slotsMatch(slot, targetSlot));
|
||||||
|
|
||||||
if (slotExists) {
|
if (existingIndex >= 0) {
|
||||||
// Remove the slot
|
// Remove ONLY this specific slot (also removes duplicates)
|
||||||
const newSlots = currentSlots.filter(slot => !slotsMatch(slot, targetSlot));
|
const newSlots = currentSlots.filter(slot => !slotsMatch(slot, targetSlot));
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
selectedSlots: newSlots,
|
selectedSlots: newSlots,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Add the slot
|
// Add ONLY this specific slot if it doesn't exist (prevent duplicates)
|
||||||
|
const newSlots = [...currentSlots, targetSlot];
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
selectedSlots: [...currentSlots, targetSlot],
|
selectedSlots: newSlots,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
// Check if a slot is selected
|
// Check if a slot is selected
|
||||||
const isSlotSelected = (day: number, timeSlot: string): boolean => {
|
const isSlotSelected = (day: number, timeSlot: string): boolean => {
|
||||||
@ -718,8 +750,36 @@ export default function BookNowPage() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className={`text-xs ${isDark ? 'text-gray-400' : 'text-gray-500'} mb-3`}>
|
<p className={`text-xs ${isDark ? 'text-gray-400' : 'text-gray-500'} mb-3`}>
|
||||||
Select one or more day-time combinations that work for you
|
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>
|
</p>
|
||||||
|
{/* Selected Slots Summary */}
|
||||||
|
{formData.selectedSlots && formData.selectedSlots.length > 0 && (
|
||||||
|
<div className={`mb-4 p-3 rounded-lg border ${isDark ? 'bg-gray-800/50 border-gray-700' : 'bg-rose-50/50 border-rose-200'}`}>
|
||||||
|
<p className={`text-xs font-medium mb-2 ${isDark ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||||
|
Selected: {formData.selectedSlots.length} time slot{formData.selectedSlots.length !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{formData.selectedSlots.map((slot, idx) => {
|
||||||
|
const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||||
|
const timeSlotLabels: Record<string, string> = {
|
||||||
|
morning: "Morning",
|
||||||
|
afternoon: "Lunchtime",
|
||||||
|
evening: "Evening",
|
||||||
|
};
|
||||||
|
const dayName = dayNames[slot.day] || `Day ${slot.day}`;
|
||||||
|
const timeLabel = timeSlotLabels[String(slot.time_slot).toLowerCase()] || slot.time_slot;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className={`px-2 py-1 rounded text-xs font-medium ${isDark ? 'bg-rose-600/30 text-rose-300 border border-rose-500/30' : 'bg-rose-100 text-rose-700 border border-rose-200'}`}
|
||||||
|
>
|
||||||
|
{dayName} {timeLabel}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{availableDaysOfWeek.map((dayInfo, dayIndex) => {
|
{availableDaysOfWeek.map((dayInfo, dayIndex) => {
|
||||||
// Ensure day is always a valid number (already validated in useMemo)
|
// Ensure day is always a valid number (already validated in useMemo)
|
||||||
|
|||||||
@ -306,64 +306,177 @@ export default function UserAppointmentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Matching Availability */}
|
{/* Selected Slots */}
|
||||||
{appointment.matching_availability && Array.isArray(appointment.matching_availability) && appointment.matching_availability.length > 0 && (
|
{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={`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"}`}>
|
<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"}`}>
|
<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"}`} />
|
<CalendarCheck className={`w-5 h-5 ${isDark ? "text-blue-400" : "text-blue-600"}`} />
|
||||||
Preferred Availability
|
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 ? "Available" : "Partially Available"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="space-y-4">
|
<div className="flex flex-wrap gap-3">
|
||||||
{appointment.matching_availability.map((match: any, idx: number) => (
|
{appointment.selected_slots.map((slot: any, idx: number) => {
|
||||||
<div
|
const dayNames: Record<number, string> = {
|
||||||
key={idx}
|
0: "Monday",
|
||||||
className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}
|
1: "Tuesday",
|
||||||
>
|
2: "Wednesday",
|
||||||
<div className="flex items-start justify-between mb-3">
|
3: "Thursday",
|
||||||
<div>
|
4: "Friday",
|
||||||
<p className={`text-base font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
|
5: "Saturday",
|
||||||
{match.day_name || "Unknown Day"}
|
6: "Sunday",
|
||||||
</p>
|
};
|
||||||
<p className={`text-sm mt-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
const timeSlotLabels: Record<string, string> = {
|
||||||
{formatShortDate(match.date || match.date_obj || "")}
|
morning: "Morning",
|
||||||
</p>
|
afternoon: "Lunchtime",
|
||||||
</div>
|
evening: "Evening",
|
||||||
|
};
|
||||||
|
const dayName = dayNames[slot.day] || `Day ${slot.day}`;
|
||||||
|
const timeSlot = String(slot.time_slot).toLowerCase().trim();
|
||||||
|
const timeLabel = timeSlotLabels[timeSlot] || slot.time_slot;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`px-4 py-3 rounded-xl border ${isDark ? "bg-blue-500/10 border-blue-500/30" : "bg-blue-50 border-blue-200"}`}
|
||||||
|
>
|
||||||
|
<p className={`text-sm font-semibold ${isDark ? "text-blue-300" : "text-blue-700"}`}>
|
||||||
|
{dayName}
|
||||||
|
</p>
|
||||||
|
<p className={`text-xs mt-1 ${isDark ? "text-blue-400" : "text-blue-600"}`}>
|
||||||
|
{timeLabel}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{match.available_slots && Array.isArray(match.available_slots) && match.available_slots.length > 0 && (
|
);
|
||||||
<div className="flex flex-wrap gap-2 mt-3">
|
})}
|
||||||
{match.available_slots.map((slot: string, slotIdx: number) => {
|
|
||||||
const timeSlotLabels: Record<string, string> = {
|
|
||||||
morning: "Morning",
|
|
||||||
afternoon: "Lunchtime",
|
|
||||||
evening: "Evening",
|
|
||||||
};
|
|
||||||
const normalizedSlot = String(slot).toLowerCase().trim();
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={slotIdx}
|
|
||||||
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"}`}
|
|
||||||
>
|
|
||||||
{timeSlotLabels[normalizedSlot] || slot}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Matching Slots */}
|
||||||
|
{(() => {
|
||||||
|
// Check if matching_availability is a MatchingAvailability object with matching_slots
|
||||||
|
const matchingAvailability = appointment.matching_availability as any;
|
||||||
|
const hasMatchingSlots = matchingAvailability && matchingAvailability.matching_slots && Array.isArray(matchingAvailability.matching_slots) && matchingAvailability.matching_slots.length > 0;
|
||||||
|
const isArrayFormat = Array.isArray(matchingAvailability) && matchingAvailability.length > 0;
|
||||||
|
|
||||||
|
if (!hasMatchingSlots && !isArrayFormat) return null;
|
||||||
|
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get matching slots from MatchingAvailability object
|
||||||
|
const matchingSlots = hasMatchingSlots ? matchingAvailability.matching_slots : null;
|
||||||
|
const totalMatchingSlots = hasMatchingSlots ? matchingAvailability.total_matching_slots : null;
|
||||||
|
const preferencesMatch = hasMatchingSlots ? matchingAvailability.preferences_match_availability : appointment.are_preferences_available;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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"}`} />
|
||||||
|
Matching Slots
|
||||||
|
{preferencesMatch !== undefined && (
|
||||||
|
<span className={`ml-auto px-3 py-1 text-xs font-medium rounded-full ${preferencesMatch ? (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")}`}>
|
||||||
|
{preferencesMatch ? "All Available" : "Partially Available"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
{hasMatchingSlots && totalMatchingSlots && (
|
||||||
|
<p className={`text-sm mb-4 ${isDark ? "text-gray-400" : "text-gray-600"}`}>
|
||||||
|
Found {totalMatchingSlots} matching time slot{totalMatchingSlots !== 1 ? 's' : ''} that match your selected preferences:
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!hasMatchingSlots && (
|
||||||
|
<p className={`text-sm mb-4 ${isDark ? "text-gray-400" : "text-gray-600"}`}>
|
||||||
|
These are the available time slots that match your selected preferences:
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasMatchingSlots ? (
|
||||||
|
// Display matching_slots from MatchingAvailability object
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{matchingSlots.map((slot: any, idx: number) => {
|
||||||
|
const dayName = dayNames[slot.day] || `Day ${slot.day}`;
|
||||||
|
const timeSlot = String(slot.time_slot).toLowerCase().trim();
|
||||||
|
const timeLabel = timeSlotLabels[timeSlot] || slot.time_slot;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`px-4 py-3 rounded-xl border ${isDark ? "bg-green-500/10 border-green-500/30" : "bg-green-50 border-green-200"}`}
|
||||||
|
>
|
||||||
|
<p className={`text-sm font-semibold ${isDark ? "text-green-300" : "text-green-700"}`}>
|
||||||
|
{dayName}
|
||||||
|
</p>
|
||||||
|
<p className={`text-xs mt-1 ${isDark ? "text-green-400" : "text-green-600"}`}>
|
||||||
|
{timeLabel}
|
||||||
|
</p>
|
||||||
|
<p className={`text-xs mt-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
|
{formatShortDate(slot.date)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Display array format (legacy)
|
||||||
|
<div className="space-y-4">
|
||||||
|
{(matchingAvailability as any[]).map((match: any, idx: number) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`p-4 rounded-xl border ${isDark ? "bg-gray-700/50 border-gray-600" : "bg-gray-50 border-gray-200"}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<p className={`text-base font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||||
|
{match.day_name || "Unknown Day"}
|
||||||
|
</p>
|
||||||
|
<p className={`text-sm mt-1 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
|
{formatShortDate(match.date || match.date_obj || "")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{match.available_slots && Array.isArray(match.available_slots) && match.available_slots.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mt-3">
|
||||||
|
{match.available_slots.map((slot: string, slotIdx: number) => {
|
||||||
|
const normalizedSlot = String(slot).toLowerCase().trim();
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
key={slotIdx}
|
||||||
|
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"}`}
|
||||||
|
>
|
||||||
|
{timeSlotLabels[normalizedSlot] || slot}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Reason */}
|
{/* Reason */}
|
||||||
{appointment.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={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
||||||
|
|||||||
@ -138,26 +138,49 @@ export async function createAppointment(input: CreateAppointmentInput): Promise<
|
|||||||
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).");
|
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 truncate = (str: string, max: number) => String(str || '').trim().substring(0, max);
|
||||||
const payload = {
|
|
||||||
|
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),
|
first_name: truncate(input.first_name, 100),
|
||||||
last_name: truncate(input.last_name, 100),
|
last_name: truncate(input.last_name, 100),
|
||||||
email: truncate(input.email, 100).toLowerCase(),
|
email: truncate(input.email, 100).toLowerCase(),
|
||||||
selected_slots: validSlots.map(slot => ({
|
selected_slots: selectedSlotsForPayload,
|
||||||
day: slot.day,
|
|
||||||
time_slot: slot.time_slot,
|
|
||||||
})),
|
|
||||||
...(input.phone && { phone: truncate(input.phone, 100) }),
|
|
||||||
...(input.reason && { reason: truncate(input.reason, 100) }),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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, {
|
const response = await fetch(API_ENDPOINTS.meetings.createAppointment, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${tokens.access}`,
|
Authorization: `Bearer ${tokens.access}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload),
|
body: requestBody,
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await parseResponse(response);
|
const data = await parseResponse(response);
|
||||||
@ -166,27 +189,54 @@ export async function createAppointment(input: CreateAppointmentInput): Promise<
|
|||||||
throw new Error(extractErrorMessage(data as unknown as ApiError));
|
throw new Error(extractErrorMessage(data as unknown as ApiError));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.appointment_id) {
|
// Build clean response - explicitly exclude preferred_dates and preferred_time_slots
|
||||||
return {
|
// Backend may return these legacy fields, but we only use selected_slots format (per API spec)
|
||||||
id: data.appointment_id,
|
const rawResponse: any = data.appointment || data.data || data;
|
||||||
first_name: input.first_name.trim(),
|
|
||||||
last_name: input.last_name.trim(),
|
// Build appointment object from scratch with ONLY the fields we want
|
||||||
email: input.email.trim().toLowerCase(),
|
// Explicitly DO NOT include preferred_dates, preferred_time_slots, or their display variants
|
||||||
phone: input.phone?.trim(),
|
const appointmentResponse: any = {
|
||||||
reason: input.reason?.trim(),
|
id: data.appointment_id || rawResponse.id || '',
|
||||||
selected_slots: validSlots,
|
first_name: rawResponse.first_name || input.first_name.trim(),
|
||||||
status: "pending_review",
|
last_name: rawResponse.last_name || input.last_name.trim(),
|
||||||
created_at: new Date().toISOString(),
|
email: rawResponse.email || input.email.trim().toLowerCase(),
|
||||||
updated_at: new Date().toISOString(),
|
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 data.appointment || data.data || data;
|
return appointmentResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAvailableDates(): Promise<AvailableDatesResponse> {
|
export async function getAvailableDates(): Promise<AvailableDatesResponse> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(API_ENDPOINTS.meetings.availableDates, {
|
const response = await fetch(API_ENDPOINTS.meetings.availableDates, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user