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 { 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,44 +260,53 @@ export default function BookNowPage() {
return;
}
// Prepare and validate slots - be very lenient
const validSlots = currentSlots
.map(slot => {
if (!slot) return 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 }>();
// Get day - handle any format
let dayNum: number;
if (typeof slot.day === 'number') {
dayNum = slot.day;
} else {
dayNum = parseInt(String(slot.day || 0), 10);
}
currentSlots.forEach(slot => {
if (!slot) return;
// Validate day
if (isNaN(dayNum) || dayNum < 0 || dayNum > 6) {
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);
}
// Get time_slot - normalize
const timeSlot = String(slot.time_slot || '').trim().toLowerCase();
// Validate day
if (isNaN(dayNum) || dayNum < 0 || dayNum > 6) {
return;
}
// Validate time_slot - accept morning, afternoon, evening
if (!timeSlot || !['morning', 'afternoon', 'evening'].includes(timeSlot)) {
return null;
}
// Get time_slot - normalize
const timeSlot = String(slot.time_slot || '').trim().toLowerCase();
return {
day: dayNum,
time_slot: timeSlot as "morning" | "afternoon" | "evening",
};
})
.filter((slot): slot is { day: number; time_slot: "morning" | "afternoon" | "evening" } => slot !== null);
// 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);
@ -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,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).");
}
// 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);
@ -166,27 +189,54 @@ export async function createAppointment(input: CreateAppointmentInput): Promise<
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" },
});