Compare commits
2 Commits
a8ff1d377a
...
a65d03ccdd
| Author | SHA1 | Date | |
|---|---|---|---|
| a65d03ccdd | |||
|
|
01dcf9abe5 |
@ -6,12 +6,10 @@ import { usePathname, useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { 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
|
||||
|
||||
@ -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"}`}>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"}`}>
|
||||
|
||||
@ -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" },
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user