Refactor Booking and AppointmentDetail components to enhance rendering of preferred dates and time slots. Improve UI consistency by updating conditional rendering logic and ensuring proper handling of availability states. Additionally, streamline the ContactSection component to include form submission functionality with error handling and loading states for better user experience.
This commit is contained in:
parent
91bece7174
commit
5318522f37
@ -367,76 +367,13 @@ export default function AppointmentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Preferred Dates & Times */}
|
|
||||||
{((appointment.preferred_dates && appointment.preferred_dates.length > 0) || (appointment.preferred_time_slots && appointment.preferred_time_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 ${isDark ? "text-white" : "text-gray-900"}`}>
|
|
||||||
Preferred Availability
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 space-y-6">
|
|
||||||
{appointment.preferred_dates && (
|
|
||||||
<div>
|
|
||||||
<p className={`text-sm font-medium mb-3 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
|
||||||
Preferred Dates
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{Array.isArray(appointment.preferred_dates) ? (
|
|
||||||
(appointment.preferred_dates as string[]).map((date, idx) => (
|
|
||||||
<span
|
|
||||||
key={idx}
|
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium ${isDark ? "bg-gray-700 text-gray-200 border border-gray-600" : "bg-gray-100 text-gray-700 border border-gray-200"}`}
|
|
||||||
>
|
|
||||||
{formatShortDate(date)}
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium ${isDark ? "bg-gray-700 text-gray-200 border border-gray-600" : "bg-gray-100 text-gray-700 border border-gray-200"}`}
|
|
||||||
>
|
|
||||||
{appointment.preferred_dates_display || appointment.preferred_dates || 'N/A'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{appointment.preferred_time_slots && (
|
|
||||||
<div>
|
|
||||||
<p className={`text-sm font-medium mb-3 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
|
||||||
Preferred Time Slots
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{Array.isArray(appointment.preferred_time_slots) ? (
|
|
||||||
(appointment.preferred_time_slots as string[]).map((slot, idx) => (
|
|
||||||
<span
|
|
||||||
key={idx}
|
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium capitalize ${isDark ? "bg-rose-500/20 text-rose-300 border border-rose-500/30" : "bg-rose-50 text-rose-700 border border-rose-200"}`}
|
|
||||||
>
|
|
||||||
{slot}
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium capitalize ${isDark ? "bg-rose-500/20 text-rose-300 border border-rose-500/30" : "bg-rose-50 text-rose-700 border border-rose-200"}`}
|
|
||||||
>
|
|
||||||
{appointment.preferred_time_slots_display || appointment.preferred_time_slots || 'N/A'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Matching Availability */}
|
{/* Matching Availability */}
|
||||||
{appointment.matching_availability && Array.isArray(appointment.matching_availability) && appointment.matching_availability.length > 0 && (
|
{appointment.matching_availability && Array.isArray(appointment.matching_availability) && appointment.matching_availability.length > 0 && (
|
||||||
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
||||||
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
|
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
|
||||||
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
|
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||||
<CalendarCheck className={`w-5 h-5 ${isDark ? "text-green-400" : "text-green-600"}`} />
|
<CalendarCheck className={`w-5 h-5 ${isDark ? "text-green-400" : "text-green-600"}`} />
|
||||||
Matching Availability
|
Preferred Availability
|
||||||
{appointment.are_preferences_available !== undefined && (
|
{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")}`}>
|
<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"}
|
{appointment.are_preferences_available ? "Available" : "Partially Available"}
|
||||||
@ -558,22 +495,22 @@ export default function AppointmentDetailPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{appointment.can_join_meeting ? (
|
{appointment.can_join_meeting ? (
|
||||||
<>
|
<>
|
||||||
<a
|
<a
|
||||||
href={appointment.jitsi_meet_url}
|
href={appointment.jitsi_meet_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={`flex-1 text-sm px-3 py-2 rounded-lg truncate ${isDark ? "bg-gray-800 text-blue-400 hover:bg-gray-700" : "bg-white text-blue-600 hover:bg-gray-50 border border-gray-200"}`}
|
className={`flex-1 text-sm px-3 py-2 rounded-lg truncate ${isDark ? "bg-gray-800 text-blue-400 hover:bg-gray-700" : "bg-white text-blue-600 hover:bg-gray-50 border border-gray-200"}`}
|
||||||
>
|
>
|
||||||
{appointment.jitsi_meet_url}
|
{appointment.jitsi_meet_url}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={appointment.jitsi_meet_url}
|
href={appointment.jitsi_meet_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={`px-4 py-2 rounded-lg font-medium transition-colors ${isDark ? "bg-blue-600 hover:bg-blue-700 text-white" : "bg-blue-600 hover:bg-blue-700 text-white"}`}
|
className={`px-4 py-2 rounded-lg font-medium transition-colors ${isDark ? "bg-blue-600 hover:bg-blue-700 text-white" : "bg-blue-600 hover:bg-blue-700 text-white"}`}
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-4 h-4" />
|
<ExternalLink className="w-4 h-4" />
|
||||||
</a>
|
</a>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -536,29 +536,29 @@ export default function Booking() {
|
|||||||
else if (adminAvailability.available_days && adminAvailability.available_days.length > 0) {
|
else if (adminAvailability.available_days && adminAvailability.available_days.length > 0) {
|
||||||
return adminAvailability.available_days.map((dayNum, index) => {
|
return adminAvailability.available_days.map((dayNum, index) => {
|
||||||
const dayName = daysOfWeek.find(d => d.value === dayNum)?.label || adminAvailability.available_days_display?.[index];
|
const dayName = daysOfWeek.find(d => d.value === dayNum)?.label || adminAvailability.available_days_display?.[index];
|
||||||
const timeSlots = dayTimeSlots[dayNum] || [];
|
const timeSlots = dayTimeSlots[dayNum] || [];
|
||||||
const slotLabels = timeSlots.map(slot => {
|
const slotLabels = timeSlots.map(slot => {
|
||||||
const option = timeSlotOptions.find(opt => opt.value === slot);
|
const option = timeSlotOptions.find(opt => opt.value === slot);
|
||||||
return option ? option.label : slot;
|
return option ? option.label : slot;
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={dayNum}
|
key={dayNum}
|
||||||
className={`flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm whitespace-nowrap ${
|
className={`flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm whitespace-nowrap ${
|
||||||
isDark
|
isDark
|
||||||
? "bg-rose-500/10 text-rose-200 border border-rose-500/20"
|
? "bg-rose-500/10 text-rose-200 border border-rose-500/20"
|
||||||
: "bg-rose-50 text-rose-700 border border-rose-200"
|
: "bg-rose-50 text-rose-700 border border-rose-200"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Check className={`w-3.5 h-3.5 shrink-0 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
|
<Check className={`w-3.5 h-3.5 shrink-0 ${isDark ? "text-rose-400" : "text-rose-600"}`} />
|
||||||
<span className="font-medium shrink-0">{dayName}</span>
|
<span className="font-medium shrink-0">{dayName}</span>
|
||||||
{slotLabels.length > 0 && (
|
{slotLabels.length > 0 && (
|
||||||
<span className={`text-sm shrink-0 ${isDark ? "text-rose-300" : "text-rose-600"}`}>
|
<span className={`text-sm shrink-0 ${isDark ? "text-rose-300" : "text-rose-600"}`}>
|
||||||
({slotLabels.join(", ")})
|
({slotLabels.join(", ")})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@ -684,10 +684,10 @@ export default function Booking() {
|
|||||||
{Array.isArray(appointment.preferred_dates) ? (
|
{Array.isArray(appointment.preferred_dates) ? (
|
||||||
<>
|
<>
|
||||||
{(appointment.preferred_dates as string[]).slice(0, 2).map((date, idx) => (
|
{(appointment.preferred_dates as string[]).slice(0, 2).map((date, idx) => (
|
||||||
<span key={idx}>{formatDate(date)}</span>
|
<span key={idx}>{formatDate(date)}</span>
|
||||||
))}
|
))}
|
||||||
{appointment.preferred_dates.length > 2 && (
|
{appointment.preferred_dates.length > 2 && (
|
||||||
<span className="text-xs">+{appointment.preferred_dates.length - 2} more</span>
|
<span className="text-xs">+{appointment.preferred_dates.length - 2} more</span>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -291,9 +291,9 @@ export default function Dashboard() {
|
|||||||
<p className={`text-xl sm:text-2xl font-bold mb-1 ${isDark ? "text-white" : "text-gray-900"}`}>
|
<p className={`text-xl sm:text-2xl font-bold mb-1 ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||||
{card.value}
|
{card.value}
|
||||||
</p>
|
</p>
|
||||||
<p className={`text-xs ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
<p className={`text-xs ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
||||||
vs last month
|
vs last month
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -150,7 +150,7 @@ export default function BookNowPage() {
|
|||||||
dayName: dayName,
|
dayName: dayName,
|
||||||
availableSlots: slotsSet,
|
availableSlots: slotsSet,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -310,7 +310,7 @@ export default function BookNowPage() {
|
|||||||
if (!firstName || firstName.length === 0) {
|
if (!firstName || firstName.length === 0) {
|
||||||
setError("First name is required.");
|
setError("First name is required.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!lastName || lastName.length === 0) {
|
if (!lastName || lastName.length === 0) {
|
||||||
setError("Last name is required.");
|
setError("Last name is required.");
|
||||||
return;
|
return;
|
||||||
@ -567,7 +567,7 @@ export default function BookNowPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{booking.user.phone && (
|
{booking.user.phone && (
|
||||||
<div>
|
<div>
|
||||||
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Phone</p>
|
<p className={`text-sm font-medium mb-1 ${isDark ? 'text-gray-400' : 'text-gray-500'}`}>Phone</p>
|
||||||
<p className={`text-base ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
<p className={`text-base ${isDark ? 'text-white' : 'text-gray-900'}`}>
|
||||||
{booking.user.phone}
|
{booking.user.phone}
|
||||||
|
|||||||
@ -51,7 +51,47 @@ Your Attune Heart Therapy platform includes a comprehensive system for managing
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔗 Quick Access Links
|
## 🎥 Telehealth Sessions Guide
|
||||||
|
|
||||||
|
This section provides step-by-step guidance on how to access and manage telehealth therapy sessions through the Admin Dashboard of the Attune Heart Therapy platform.
|
||||||
|
|
||||||
|
### Accessing the Admin Dashboard
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
1. Navigate to: \`https://attunehearttherapy.com/login\`
|
||||||
|
2. Enter your admin email address: \`admin@attunehearttherapy.com\`
|
||||||
|
3. Enter your password
|
||||||
|
4. Click **"Sign In"**
|
||||||
|
5. You will be automatically redirected to the **Admin Dashboard**
|
||||||
|
6. Click on **"Bookings"** in the navigation menu to view all appointments
|
||||||
|
|
||||||
|
### Viewing Appointment Details
|
||||||
|
|
||||||
|
1. From the Bookings page, click on any appointment
|
||||||
|
2. View complete client information, preferred dates, and time slots
|
||||||
|
3. Check appointment status and review all details
|
||||||
|
|
||||||
|
### Scheduling an Appointment
|
||||||
|
|
||||||
|
1. From the appointment details page, click **"Schedule Appointment"**
|
||||||
|
2. Select the date and time for the session
|
||||||
|
3. Choose the duration (15, 30, 45, 60, or 120 minutes)
|
||||||
|
4. Click **"Schedule"** to confirm
|
||||||
|
5. The system will automatically create a Jitsi video meeting room, generate a unique meeting URL, send a confirmation email to the client, and update the appointment status to "Scheduled"
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Joining a Video Meeting
|
||||||
|
|
||||||
|
1. Meetings become available **10 minutes before** the scheduled start time
|
||||||
|
2. Navigate to any scheduled appointment's details page
|
||||||
|
3. In the sidebar, find the **"Join Meeting"** button
|
||||||
|
4. Click **"Join Meeting"** when it becomes active (10 minutes before scheduled time)
|
||||||
|
5. The meeting will open in a new browser tab
|
||||||
|
6. Both you and the client use the same link to join the session
|
||||||
|
|
||||||
|
### 🔗 Quick Access Links
|
||||||
|
|
||||||
[Visit Attune Heart Therapy](https://attunehearttherapy.com/) - Official website
|
[Visit Attune Heart Therapy](https://attunehearttherapy.com/) - Official website
|
||||||
|
|
||||||
@ -290,17 +330,32 @@ For technical assistance, questions, or issues:
|
|||||||
</code>
|
</code>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
img: ({ node, ...props }: any) => (
|
||||||
|
<img
|
||||||
|
{...props}
|
||||||
|
style={{
|
||||||
|
maxWidth: "100%",
|
||||||
|
height: "auto",
|
||||||
|
borderRadius: "8px",
|
||||||
|
marginTop: "1em",
|
||||||
|
marginBottom: "1em",
|
||||||
|
boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
|
||||||
|
}}
|
||||||
|
alt={props.alt || "Guide screenshot"}
|
||||||
|
/>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||||
<div className="bg-white p-8 rounded-lg shadow-md">
|
<div className="bg-white dark:bg-gray-900 p-8 rounded-lg shadow-md">
|
||||||
<Button
|
<Button
|
||||||
className="bg-gray-100 hover:bg-gray-50 shadow-md text-black"
|
className="bg-gray-100 hover:bg-gray-50 dark:bg-gray-800 dark:hover:bg-gray-700 shadow-md text-black dark:text-white mb-6"
|
||||||
onClick={() => router.back()}
|
onClick={() => router.back()}
|
||||||
>
|
>
|
||||||
<ArrowLeft className="mr-2" />
|
<ArrowLeft className="mr-2" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<ReactMarkdown components={components} remarkPlugins={[remarkGfm]}>
|
<ReactMarkdown components={components} remarkPlugins={[remarkGfm]}>
|
||||||
{readmeContent}
|
{readmeContent}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
|
|||||||
@ -306,85 +306,13 @@ export default function UserAppointmentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Preferred Dates & Times */}
|
|
||||||
{((appointment.preferred_dates && (Array.isArray(appointment.preferred_dates) ? appointment.preferred_dates.length > 0 : appointment.preferred_dates)) ||
|
|
||||||
(appointment.preferred_time_slots && (Array.isArray(appointment.preferred_time_slots) ? appointment.preferred_time_slots.length > 0 : appointment.preferred_time_slots))) && (
|
|
||||||
<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 ${isDark ? "text-white" : "text-gray-900"}`}>
|
|
||||||
Preferred Availability
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div className="p-6 space-y-6">
|
|
||||||
{appointment.preferred_dates && (
|
|
||||||
<div>
|
|
||||||
<p className={`text-sm font-medium mb-3 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
|
||||||
Preferred Dates
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{Array.isArray(appointment.preferred_dates) ? (
|
|
||||||
(appointment.preferred_dates as string[]).map((date, idx) => (
|
|
||||||
<span
|
|
||||||
key={idx}
|
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium ${isDark ? "bg-gray-700 text-gray-200 border border-gray-600" : "bg-gray-100 text-gray-700 border border-gray-200"}`}
|
|
||||||
>
|
|
||||||
{formatShortDate(date)}
|
|
||||||
</span>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium ${isDark ? "bg-gray-700 text-gray-200 border border-gray-600" : "bg-gray-100 text-gray-700 border border-gray-200"}`}
|
|
||||||
>
|
|
||||||
{appointment.preferred_dates_display || appointment.preferred_dates || 'N/A'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{appointment.preferred_time_slots && (
|
|
||||||
<div>
|
|
||||||
<p className={`text-sm font-medium mb-3 ${isDark ? "text-gray-400" : "text-gray-500"}`}>
|
|
||||||
Preferred Time Slots
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{Array.isArray(appointment.preferred_time_slots) ? (
|
|
||||||
(appointment.preferred_time_slots as string[]).map((slot, idx) => {
|
|
||||||
const timeSlotLabels: Record<string, string> = {
|
|
||||||
morning: "Morning",
|
|
||||||
afternoon: "Lunchtime",
|
|
||||||
evening: "Evening",
|
|
||||||
};
|
|
||||||
const normalizedSlot = String(slot).toLowerCase().trim();
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={idx}
|
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium capitalize ${isDark ? "bg-rose-500/20 text-rose-300 border border-rose-500/30" : "bg-rose-50 text-rose-700 border border-rose-200"}`}
|
|
||||||
>
|
|
||||||
{timeSlotLabels[normalizedSlot] || slot}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium capitalize ${isDark ? "bg-rose-500/20 text-rose-300 border border-rose-500/30" : "bg-rose-50 text-rose-700 border border-rose-200"}`}
|
|
||||||
>
|
|
||||||
{appointment.preferred_time_slots_display || appointment.preferred_time_slots || 'N/A'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Matching Availability */}
|
{/* Matching Availability */}
|
||||||
{appointment.matching_availability && Array.isArray(appointment.matching_availability) && appointment.matching_availability.length > 0 && (
|
{appointment.matching_availability && Array.isArray(appointment.matching_availability) && appointment.matching_availability.length > 0 && (
|
||||||
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
<div className={`rounded-2xl border shadow-sm overflow-hidden ${isDark ? "bg-gray-800 border-gray-700" : "bg-white border-gray-200"}`}>
|
||||||
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
|
<div className={`px-6 py-4 border-b ${isDark ? "border-gray-700 bg-gray-800/50" : "border-gray-200 bg-gray-50/50"}`}>
|
||||||
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
|
<h2 className={`text-lg font-semibold flex items-center gap-2 ${isDark ? "text-white" : "text-gray-900"}`}>
|
||||||
<CalendarCheck className={`w-5 h-5 ${isDark ? "text-green-400" : "text-green-600"}`} />
|
<CalendarCheck className={`w-5 h-5 ${isDark ? "text-green-400" : "text-green-600"}`} />
|
||||||
Matching Availability
|
Preferred Availability
|
||||||
{appointment.are_preferences_available !== undefined && (
|
{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")}`}>
|
<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"}
|
{appointment.are_preferences_available ? "Available" : "Partially Available"}
|
||||||
|
|||||||
@ -225,7 +225,7 @@ export default function UserDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${isDark ? "bg-green-900/30 text-green-400" : "bg-green-50 text-green-700"}`}
|
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${isDark ? "bg-green-900/30 text-green-400" : "bg-green-50 text-green-700"}`}
|
||||||
>
|
>
|
||||||
<ArrowUpRight className="w-3 h-3" />
|
<ArrowUpRight className="w-3 h-3" />
|
||||||
<span>{displayStats.scheduled > 0 ? `+${displayStats.scheduled}` : "0"}</span>
|
<span>{displayStats.scheduled > 0 ? `+${displayStats.scheduled}` : "0"}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import { motion, useInView } from "framer-motion";
|
import { motion, useInView } from "framer-motion";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { Send } from "lucide-react";
|
import { Send, Loader2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useAppTheme } from "@/components/ThemeProvider";
|
import { useAppTheme } from "@/components/ThemeProvider";
|
||||||
|
import { submitContactForm } from "@/lib/actions/auth";
|
||||||
|
|
||||||
export function ContactSection() {
|
export function ContactSection() {
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
@ -21,13 +22,31 @@ export function ContactSection() {
|
|||||||
phone: "",
|
phone: "",
|
||||||
message: "",
|
message: "",
|
||||||
});
|
});
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
toast("Message Received", {
|
setIsSubmitting(true);
|
||||||
description: "Thank you for reaching out. We'll get back to you soon!",
|
|
||||||
});
|
try {
|
||||||
setFormData({ name: "", email: "", phone: "", message: "" });
|
await submitContactForm({
|
||||||
|
name: formData.name,
|
||||||
|
email: formData.email,
|
||||||
|
phone: formData.phone,
|
||||||
|
message: formData.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Message Sent Successfully", {
|
||||||
|
description: "Thank you for reaching out. We'll get back to you soon!",
|
||||||
|
});
|
||||||
|
setFormData({ name: "", email: "", phone: "", message: "" });
|
||||||
|
} catch (error) {
|
||||||
|
toast.error("Failed to Send Message", {
|
||||||
|
description: error instanceof Error ? error.message : "Please try again later.",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -204,11 +223,21 @@ export function ContactSection() {
|
|||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="w-full cursor-pointer bg-gradient-to-r from-rose-500 to-pink-600 text-white transition-all hover:from-rose-600 hover:to-pink-700 hover:scale-[1.02]"
|
disabled={isSubmitting}
|
||||||
|
className="w-full cursor-pointer bg-gradient-to-r from-rose-500 to-pink-600 text-white transition-all hover:from-rose-600 hover:to-pink-700 hover:scale-[1.02] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
<Send className="mr-2 h-5 w-5" />
|
{isSubmitting ? (
|
||||||
Send Message
|
<>
|
||||||
|
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||||
|
Sending...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send className="mr-2 h-5 w-5" />
|
||||||
|
Send Message
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -131,7 +131,7 @@ export async function createAppointment(input: CreateAppointmentInput): Promise<
|
|||||||
|
|
||||||
if (!input.selected_slots || input.selected_slots.length === 0) {
|
if (!input.selected_slots || input.selected_slots.length === 0) {
|
||||||
throw new Error("At least one time slot must be selected");
|
throw new Error("At least one time slot must be selected");
|
||||||
}
|
}
|
||||||
|
|
||||||
const validSlots = validateAndCleanSlots(input.selected_slots);
|
const validSlots = validateAndCleanSlots(input.selected_slots);
|
||||||
if (validSlots.length === 0) {
|
if (validSlots.length === 0) {
|
||||||
@ -211,7 +211,7 @@ export async function getWeeklyAvailability(): Promise<WeeklyAvailabilityRespons
|
|||||||
const data = await parseResponse(response);
|
const data = await parseResponse(response);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(extractErrorMessage(data as unknown as ApiError));
|
throw new Error(extractErrorMessage(data as unknown as ApiError));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.isArray(data) ? data : data;
|
return Array.isArray(data) ? data : data;
|
||||||
}
|
}
|
||||||
@ -240,7 +240,7 @@ export async function checkDateAvailability(date: string): Promise<CheckDateAvai
|
|||||||
const data = await parseResponse(response);
|
const data = await parseResponse(response);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(extractErrorMessage(data as unknown as ApiError));
|
throw new Error(extractErrorMessage(data as unknown as ApiError));
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@ -357,7 +357,7 @@ export async function scheduleAppointment(id: string, input: ScheduleAppointment
|
|||||||
}
|
}
|
||||||
|
|
||||||
return data.appointment || data;
|
return data.appointment || data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function rejectAppointment(id: string, input: RejectAppointmentInput): Promise<Appointment> {
|
export async function rejectAppointment(id: string, input: RejectAppointmentInput): Promise<Appointment> {
|
||||||
const tokens = getStoredTokens();
|
const tokens = getStoredTokens();
|
||||||
@ -402,7 +402,7 @@ export async function getPublicAvailability(): Promise<AdminAvailability | null>
|
|||||||
availabilitySchedule[day.day.toString()] = day.available_slots;
|
availabilitySchedule[day.day.toString()] = day.available_slots;
|
||||||
availableDays.push(day.day);
|
availableDays.push(day.day);
|
||||||
availableDaysDisplay.push(day.day_name);
|
availableDaysDisplay.push(day.day_name);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -533,7 +533,7 @@ export async function updateAdminAvailability(input: UpdateAvailabilityInput): P
|
|||||||
Authorization: `Bearer ${tokens.access}`,
|
Authorization: `Bearer ${tokens.access}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ availability_schedule: sortedSchedule }),
|
body: JSON.stringify({ availability_schedule: sortedSchedule }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const responseText = await response.text();
|
const responseText = await response.text();
|
||||||
@ -548,7 +548,7 @@ export async function updateAdminAvailability(input: UpdateAvailabilityInput): P
|
|||||||
|
|
||||||
let data: any;
|
let data: any;
|
||||||
if (contentType.includes("application/json")) {
|
if (contentType.includes("application/json")) {
|
||||||
try {
|
try {
|
||||||
data = JSON.parse(responseText);
|
data = JSON.parse(responseText);
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(`Server error (${response.status}): Invalid JSON format`);
|
throw new Error(`Server error (${response.status}): Invalid JSON format`);
|
||||||
|
|||||||
@ -439,3 +439,51 @@ export async function updateProfile(input: UpdateProfileInput): Promise<User> {
|
|||||||
throw new Error("Invalid profile response format");
|
throw new Error("Invalid profile response format");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ContactFormInput {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactFormResponse {
|
||||||
|
message?: string;
|
||||||
|
success?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit contact form
|
||||||
|
*/
|
||||||
|
export async function submitContactForm(
|
||||||
|
data: ContactFormInput
|
||||||
|
): Promise<ContactFormResponse> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(API_ENDPOINTS.auth.contact, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: data.name,
|
||||||
|
email: data.email,
|
||||||
|
phone: data.phone,
|
||||||
|
message: data.message,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseData = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error: ApiError = responseData;
|
||||||
|
throw new Error(extractErrorMessage(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
return responseData;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error("Failed to submit contact form");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,7 @@ export const API_ENDPOINTS = {
|
|||||||
allUsers: `${API_BASE_URL}/auth/all-users/`,
|
allUsers: `${API_BASE_URL}/auth/all-users/`,
|
||||||
getProfile: `${API_BASE_URL}/auth/profile/`,
|
getProfile: `${API_BASE_URL}/auth/profile/`,
|
||||||
updateProfile: `${API_BASE_URL}/auth/profile/update/`,
|
updateProfile: `${API_BASE_URL}/auth/profile/update/`,
|
||||||
|
contact: `${API_BASE_URL}/auth/contact/`,
|
||||||
},
|
},
|
||||||
meetings: {
|
meetings: {
|
||||||
base: `${API_BASE_URL}/meetings/`,
|
base: `${API_BASE_URL}/meetings/`,
|
||||||
|
|||||||
BIN
public/ss1.png
Normal file
BIN
public/ss1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 718 KiB |
BIN
public/ss2.png
Normal file
BIN
public/ss2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 627 KiB |
BIN
public/ss3.png
Normal file
BIN
public/ss3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 614 KiB |
Loading…
Reference in New Issue
Block a user