Compare commits

...

2 Commits

5 changed files with 508 additions and 250 deletions

View File

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

View File

@ -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"}`}>

View File

@ -260,45 +260,54 @@ 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;
currentSlots.forEach(slot => {
// Get day - handle any format if (!slot) return;
let dayNum: number;
if (typeof slot.day === 'number') { // Get day - handle any format
dayNum = slot.day; let dayNum: number;
} else { if (typeof slot.day === 'number') {
dayNum = parseInt(String(slot.day || 0), 10); dayNum = slot.day;
} } else {
dayNum = parseInt(String(slot.day || 0), 10);
// Validate day }
if (isNaN(dayNum) || dayNum < 0 || dayNum > 6) {
return null; // Validate day
} if (isNaN(dayNum) || dayNum < 0 || dayNum > 6) {
return;
// Get time_slot - normalize }
const timeSlot = String(slot.time_slot || '').trim().toLowerCase();
// Get time_slot - normalize
// Validate time_slot - accept morning, afternoon, evening const timeSlot = String(slot.time_slot || '').trim().toLowerCase();
if (!timeSlot || !['morning', 'afternoon', 'evening'].includes(timeSlot)) {
return null; // Validate time_slot - accept morning, afternoon, evening
} if (!timeSlot || !['morning', 'afternoon', 'evening'].includes(timeSlot)) {
return;
return { }
day: dayNum,
time_slot: timeSlot as "morning" | "afternoon" | "evening", // Create unique key to prevent duplicates
}; const uniqueKey = `${dayNum}-${timeSlot}`;
}) uniqueSlots.set(uniqueKey, {
.filter((slot): slot is { day: number; time_slot: "morning" | "afternoon" | "evening" } => slot !== null); 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);
const lastName = formData.lastName.trim().substring(0, 100); const lastName = formData.lastName.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)

View File

@ -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"}`}>

View File

@ -138,55 +138,105 @@ 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);
if (!response.ok) { if (!response.ok) {
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" },
}); });