Refactor appointment handling to streamline selected time slots and improve data payload structure. Update UI components to display selected slots clearly and enhance user experience by ensuring only relevant data is sent to the API. Remove legacy fields from appointment responses for cleaner data management.

This commit is contained in:
iamkiddy 2025-12-03 11:03:01 +00:00
parent 80882c80bc
commit 01dcf9abe5
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 { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Inbox,
Calendar,
LayoutGrid,
Heart,
UserCog,
Bell,
Settings,
LogOut,
FileText,
@ -24,7 +22,8 @@ import { toast } from "sonner";
export function Header() {
const pathname = usePathname();
const router = useRouter();
const [notificationsOpen, setNotificationsOpen] = useState(false);
// Notification state - commented out
// const [notificationsOpen, setNotificationsOpen] = useState(false);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const { theme } = useAppTheme();
const isDark = theme === "dark";
@ -37,7 +36,8 @@ export function Header() {
router.push("/");
};
// Mock notifications data
// Mock notifications data - commented out
/*
const notifications = [
{
id: 1,
@ -58,6 +58,7 @@ export function Header() {
];
const unreadCount = notifications.filter((n) => !n.read).length;
*/
return (
<header className={`fixed top-0 left-0 right-0 z-50 ${isDark ? "bg-gray-900 border-gray-800" : "bg-white border-gray-200"} border-b`}>
@ -105,86 +106,7 @@ export function Header() {
{/* Right Side Actions */}
<div className="flex items-center gap-1.5 sm:gap-2 md:gap-3">
<ThemeToggle />
<Popover open={notificationsOpen} onOpenChange={setNotificationsOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative w-8 h-8 sm:w-9 sm:h-9 md:w-10 md:h-10 cursor-pointer">
<Inbox className={`w-5 h-5 sm:w-6 sm:h-6 md:w-8 md:h-8 ${isDark ? "text-gray-300" : "text-gray-600"}`} />
{unreadCount > 0 && (
<span className="absolute top-0.5 right-0.5 sm:top-1 sm:right-1 w-1.5 h-1.5 sm:w-2 sm:h-2 bg-green-500 rounded-full"></span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className={`w-[calc(100vw-2rem)] sm:w-80 md:w-96 p-0 shadow-xl border ${isDark ? "bg-gray-900 border-gray-800" : "bg-white border-gray-200"}`} align="end">
{/* Thumbtack Design at Top Right */}
<div className="relative">
<div className={`absolute -top-2 right-8 w-4 h-4 rotate-45 ${isDark ? "bg-gray-900 border-l border-t border-gray-800" : "bg-white border-l border-t border-gray-200"}`}></div>
<div className={`absolute -top-1 right-8 w-2 h-2 translate-x-1/2 ${isDark ? "bg-gray-900" : "bg-white"}`}></div>
</div>
<div className={`flex items-center justify-between p-4 border-b ${isDark ? "border-gray-800" : ""}`}>
<h3 className={`font-semibold ${isDark ? "text-white" : "text-gray-900"}`}>Notifications</h3>
{unreadCount > 0 && (
<span className="px-2 py-1 text-xs font-medium bg-rose-100 text-rose-700 rounded-full">
{unreadCount} new
</span>
)}
</div>
<div className="max-h-96 overflow-y-auto">
{notifications.length === 0 ? (
<div className={`p-8 text-center ${isDark ? "text-gray-400" : "text-gray-500"}`}>
<Bell className={`w-12 h-12 mx-auto mb-2 ${isDark ? "text-gray-600" : "text-gray-300"}`} />
<p className="text-sm">No notifications</p>
</div>
) : (
<div className={`divide-y ${isDark ? "divide-gray-800" : ""}`}>
{notifications.map((notification) => {
return (
<div
key={notification.id}
className={`p-4 transition-colors cursor-pointer ${
!notification.read
? isDark
? "bg-rose-500/10"
: "bg-rose-50/50"
: ""
}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p
className={`text-sm font-medium ${isDark ? "text-white" : "text-gray-900"} ${
!notification.read ? "font-semibold" : ""
}`}
>
{notification.title}
</p>
{!notification.read && (
<span className="shrink-0 w-2 h-2 bg-green-500 rounded-full mt-1"></span>
)}
</div>
<p className={`text-sm mt-1 ${isDark ? "text-gray-400" : "text-gray-600"}`}>
{notification.message}
</p>
<p className={`text-xs mt-1 ${isDark ? "text-gray-500" : "text-gray-400"}`}>
{notification.time}
</p>
</div>
</div>
);
})}
</div>
)}
</div>
<div className={`p-3 border-t ${isDark ? "border-gray-800 bg-gray-900/80" : "bg-gray-50"}`}>
<Link
href="/admin/notifications"
onClick={() => setNotificationsOpen(false)}
className={`block w-full text-center text-sm font-medium hover:underline transition-colors ${isDark ? "text-rose-300 hover:text-rose-200" : "text-rose-600 hover:text-rose-700"}`}
>
View all notifications
</Link>
</div>
</PopoverContent>
</Popover>
{/* Notification Popover - Commented out */}
<Popover open={userMenuOpen} onOpenChange={setUserMenuOpen}>
<PopoverTrigger asChild>
<Button

View File

@ -367,64 +367,177 @@ export default function AppointmentDetailPage() {
</div>
)}
{/* Matching Availability */}
{appointment.matching_availability && Array.isArray(appointment.matching_availability) && appointment.matching_availability.length > 0 && (
{/* Selected Slots */}
{appointment.selected_slots && Array.isArray(appointment.selected_slots) && appointment.selected_slots.length > 0 && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
<CalendarCheck className={`w-5 h-5 ${isDark ? "text-green-400" : "text-green-600"}`} />
Preferred Availability
{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>
)}
<CalendarCheck className={`w-5 h-5 ${isDark ? "text-blue-400" : "text-blue-600"}`} />
Selected Time Slots
</h2>
</div>
<div className="p-6">
<div className="space-y-4">
{appointment.matching_availability.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 className="flex flex-wrap gap-3">
{appointment.selected_slots.map((slot: any, idx: number) => {
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",
};
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>
{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>
)}
{/* 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 */}
{appointment.reason && (
<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;
}
// Prepare and validate slots - be very lenient
const validSlots = currentSlots
.map(slot => {
if (!slot) return null;
// Get day - handle any format
let dayNum: number;
if (typeof slot.day === 'number') {
dayNum = slot.day;
} else {
dayNum = parseInt(String(slot.day || 0), 10);
}
// Validate day
if (isNaN(dayNum) || dayNum < 0 || dayNum > 6) {
return null;
}
// Get time_slot - normalize
const timeSlot = String(slot.time_slot || '').trim().toLowerCase();
// Validate time_slot - accept morning, afternoon, evening
if (!timeSlot || !['morning', 'afternoon', 'evening'].includes(timeSlot)) {
return null;
}
return {
day: dayNum,
time_slot: timeSlot as "morning" | "afternoon" | "evening",
};
})
.filter((slot): slot is { day: number; time_slot: "morning" | "afternoon" | "evening" } => slot !== null);
// Prepare and validate slots - only send exactly what user selected
// Filter out duplicates and ensure we only send the specific selected slots
const uniqueSlots = new Map<string, { day: number; time_slot: string }>();
currentSlots.forEach(slot => {
if (!slot) return;
// Get day - handle any format
let dayNum: number;
if (typeof slot.day === 'number') {
dayNum = slot.day;
} else {
dayNum = parseInt(String(slot.day || 0), 10);
}
// Validate day
if (isNaN(dayNum) || dayNum < 0 || dayNum > 6) {
return;
}
// Get time_slot - normalize
const timeSlot = String(slot.time_slot || '').trim().toLowerCase();
// Validate time_slot - accept morning, afternoon, evening
if (!timeSlot || !['morning', 'afternoon', 'evening'].includes(timeSlot)) {
return;
}
// Create unique key to prevent duplicates
const uniqueKey = `${dayNum}-${timeSlot}`;
uniqueSlots.set(uniqueKey, {
day: dayNum,
time_slot: timeSlot as "morning" | "afternoon" | "evening",
});
});
// Convert map to array
const validSlots = Array.from(uniqueSlots.values()).map(slot => ({
day: slot.day,
time_slot: slot.time_slot as "morning" | "afternoon" | "evening",
}));
// Final validation check
if (!validSlots || validSlots.length === 0) {
setError("Please select at least one day and time slot combination by clicking on the time slot buttons.");
return;
}
return;
}
// Validate and limit field lengths to prevent database errors
const firstName = formData.firstName.trim().substring(0, 100);
const lastName = formData.lastName.trim().substring(0, 100);
@ -325,11 +334,21 @@ export default function BookNowPage() {
}
// 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 = {
first_name: firstName,
last_name: lastName,
email: email,
selected_slots: validSlots,
selected_slots: selectedSlotsPayload, // Only send what user explicitly selected (day + time_slot format)
...(phone && phone.length > 0 && { phone: phone }),
...(reason && reason.length > 0 && { reason: reason }),
};
@ -385,13 +404,25 @@ export default function BookNowPage() {
};
// Handle slot selection (day + time slot combination)
const handleSlotToggle = (day: number, timeSlot: string) => {
setFormData((prev) => {
const normalizedDay = Number(day);
const normalizedTimeSlot = String(timeSlot).toLowerCase().trim();
// CRITICAL: Only toggle the specific slot that was clicked, nothing else
const handleSlotToggle = useCallback((day: number, timeSlot: string) => {
const normalizedDay = Number(day);
const normalizedTimeSlot = String(timeSlot).toLowerCase().trim();
// Validate inputs
if (isNaN(normalizedDay) || normalizedDay < 0 || normalizedDay > 6) {
return; // Invalid day, don't change anything
}
if (!['morning', 'afternoon', 'evening'].includes(normalizedTimeSlot)) {
return; // Invalid time slot, don't change anything
}
setFormData((prev) => {
const currentSlots = prev.selectedSlots || [];
// Helper to check if two slots match
// Helper to check if two slots match EXACTLY (both day AND time_slot)
const slotsMatch = (slot1: { day: number; time_slot: string }, slot2: { day: number; time_slot: string }) => {
return Number(slot1.day) === Number(slot2.day) &&
String(slot1.time_slot).toLowerCase().trim() === String(slot2.time_slot).toLowerCase().trim();
@ -399,25 +430,26 @@ export default function BookNowPage() {
const targetSlot = { day: normalizedDay, time_slot: normalizedTimeSlot };
// Check if this exact slot exists
const slotExists = currentSlots.some(slot => slotsMatch(slot, targetSlot));
// Check if this EXACT slot exists (check for duplicates too)
const existingIndex = currentSlots.findIndex(slot => slotsMatch(slot, targetSlot));
if (slotExists) {
// Remove the slot
if (existingIndex >= 0) {
// Remove ONLY this specific slot (also removes duplicates)
const newSlots = currentSlots.filter(slot => !slotsMatch(slot, targetSlot));
return {
...prev,
selectedSlots: newSlots,
};
} else {
// Add the slot
// Add ONLY this specific slot if it doesn't exist (prevent duplicates)
const newSlots = [...currentSlots, targetSlot];
return {
...prev,
selectedSlots: [...currentSlots, targetSlot],
selectedSlots: newSlots,
};
}
});
};
}, []);
// Check if a slot is selected
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`}>
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>
{/* 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">
{availableDaysOfWeek.map((dayInfo, dayIndex) => {
// Ensure day is always a valid number (already validated in useMemo)

View File

@ -306,64 +306,177 @@ export default function UserAppointmentDetailPage() {
</div>
)}
{/* Matching Availability */}
{appointment.matching_availability && Array.isArray(appointment.matching_availability) && appointment.matching_availability.length > 0 && (
{/* Selected Slots */}
{appointment.selected_slots && Array.isArray(appointment.selected_slots) && appointment.selected_slots.length > 0 && (
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
<CalendarCheck className={`w-5 h-5 ${isDark ? "text-green-400" : "text-green-600"}`} />
Preferred Availability
{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>
)}
<CalendarCheck className={`w-5 h-5 ${isDark ? "text-blue-400" : "text-blue-600"}`} />
Selected Time Slots
</h2>
</div>
<div className="p-6">
<div className="space-y-4">
{appointment.matching_availability.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 className="flex flex-wrap gap-3">
{appointment.selected_slots.map((slot: any, idx: number) => {
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",
};
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>
{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>
)}
{/* 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 */}
{appointment.reason && (
<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).");
}
// 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 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),
last_name: truncate(input.last_name, 100),
email: truncate(input.email, 100).toLowerCase(),
selected_slots: validSlots.map(slot => ({
day: slot.day,
time_slot: slot.time_slot,
})),
...(input.phone && { phone: truncate(input.phone, 100) }),
...(input.reason && { reason: truncate(input.reason, 100) }),
selected_slots: selectedSlotsForPayload,
};
// Only add optional fields if they exist
if (input.phone && input.phone.length > 0) {
payload.phone = truncate(input.phone, 100);
}
if (input.reason && input.reason.length > 0) {
payload.reason = truncate(input.reason, 100);
}
const requestBody = JSON.stringify(payload);
const response = await fetch(API_ENDPOINTS.meetings.createAppointment, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
body: JSON.stringify(payload),
body: requestBody,
});
const data = await parseResponse(response);
if (!response.ok) {
throw new Error(extractErrorMessage(data as unknown as ApiError));
}
if (data.appointment_id) {
return {
id: data.appointment_id,
first_name: input.first_name.trim(),
last_name: input.last_name.trim(),
email: input.email.trim().toLowerCase(),
phone: input.phone?.trim(),
reason: input.reason?.trim(),
selected_slots: validSlots,
status: "pending_review",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
// Build clean response - explicitly exclude preferred_dates and preferred_time_slots
// Backend may return these legacy fields, but we only use selected_slots format (per API spec)
const rawResponse: any = data.appointment || data.data || data;
// Build appointment object from scratch with ONLY the fields we want
// Explicitly DO NOT include preferred_dates, preferred_time_slots, or their display variants
const appointmentResponse: any = {
id: data.appointment_id || rawResponse.id || '',
first_name: rawResponse.first_name || input.first_name.trim(),
last_name: rawResponse.last_name || input.last_name.trim(),
email: rawResponse.email || input.email.trim().toLowerCase(),
phone: rawResponse.phone || input.phone?.trim(),
reason: rawResponse.reason || input.reason?.trim(),
// Use selected_slots from our original input (preserve the format we sent - per API spec)
selected_slots: validSlots,
status: rawResponse.status || "pending_review",
created_at: rawResponse.created_at || new Date().toISOString(),
updated_at: rawResponse.updated_at || new Date().toISOString(),
// Include other useful fields from response
...(rawResponse.jitsi_meet_url && { jitsi_meet_url: rawResponse.jitsi_meet_url }),
...(rawResponse.jitsi_room_id && { jitsi_room_id: rawResponse.jitsi_room_id }),
...(rawResponse.matching_availability && { matching_availability: rawResponse.matching_availability }),
...(rawResponse.are_preferences_available !== undefined && { are_preferences_available: rawResponse.are_preferences_available }),
...(rawResponse.available_slots_info && { available_slots_info: rawResponse.available_slots_info }),
// Explicitly EXCLUDED: preferred_dates, preferred_time_slots, preferred_dates_display, preferred_time_slots_display
};
// Explicitly delete preferred_dates and preferred_time_slots from response object
// These are backend legacy fields - we only use selected_slots format
if ('preferred_dates' in appointmentResponse) {
delete appointmentResponse.preferred_dates;
}
if ('preferred_time_slots' in appointmentResponse) {
delete appointmentResponse.preferred_time_slots;
}
if ('preferred_dates_display' in appointmentResponse) {
delete appointmentResponse.preferred_dates_display;
}
if ('preferred_time_slots_display' in appointmentResponse) {
delete appointmentResponse.preferred_time_slots_display;
}
return data.appointment || data.data || data;
return appointmentResponse;
}
export async function getAvailableDates(): Promise<AvailableDatesResponse> {
try {
const response = await fetch(API_ENDPOINTS.meetings.availableDates, {
const response = await fetch(API_ENDPOINTS.meetings.availableDates, {
method: "GET",
headers: { "Content-Type": "application/json" },
});