Merge pull request '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 fiel…' (#40) from feat/booking-panel into master
Reviewed-on: http://35.207.46.142/ATTUNE-HEART-THERAPY/website/pulls/40
This commit is contained in:
commit
a65d03ccdd
@ -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,23 +367,139 @@ 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-blue-400" : "text-blue-600"}`} />
|
||||
Selected Time Slots
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</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"}`} />
|
||||
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"}
|
||||
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">
|
||||
{appointment.matching_availability.map((match: any, idx: number) => (
|
||||
{(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"}`}
|
||||
@ -401,11 +517,6 @@ export default function AppointmentDetailPage() {
|
||||
{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
|
||||
@ -421,9 +532,11 @@ export default function AppointmentDetailPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Reason */}
|
||||
{appointment.reason && (
|
||||
|
||||
@ -260,10 +260,12 @@ 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 }>();
|
||||
|
||||
currentSlots.forEach(slot => {
|
||||
if (!slot) return;
|
||||
|
||||
// Get day - handle any format
|
||||
let dayNum: number;
|
||||
@ -275,7 +277,7 @@ export default function BookNowPage() {
|
||||
|
||||
// Validate day
|
||||
if (isNaN(dayNum) || dayNum < 0 || dayNum > 6) {
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Get time_slot - normalize
|
||||
@ -283,15 +285,22 @@ export default function BookNowPage() {
|
||||
|
||||
// Validate time_slot - accept morning, afternoon, evening
|
||||
if (!timeSlot || !['morning', 'afternoon', 'evening'].includes(timeSlot)) {
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
// Create unique key to prevent duplicates
|
||||
const uniqueKey = `${dayNum}-${timeSlot}`;
|
||||
uniqueSlots.set(uniqueKey, {
|
||||
day: dayNum,
|
||||
time_slot: timeSlot as "morning" | "afternoon" | "evening",
|
||||
};
|
||||
})
|
||||
.filter((slot): slot is { day: number; time_slot: "morning" | "afternoon" | "evening" } => slot !== null);
|
||||
});
|
||||
});
|
||||
|
||||
// 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) {
|
||||
@ -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) => {
|
||||
// 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,23 +306,139 @@ 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-blue-400" : "text-blue-600"}`} />
|
||||
Selected Time Slots
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</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"}`} />
|
||||
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"}
|
||||
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">
|
||||
{appointment.matching_availability.map((match: any, idx: number) => (
|
||||
{(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"}`}
|
||||
@ -340,11 +456,6 @@ export default function UserAppointmentDetailPage() {
|
||||
{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
|
||||
@ -360,9 +471,11 @@ export default function UserAppointmentDetailPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Reason */}
|
||||
{appointment.reason && (
|
||||
|
||||
@ -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,22 +189,49 @@ 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(),
|
||||
// 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: "pending_review",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
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> {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user