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 C… #34

Merged
Hammond merged 1 commits from feat/booking-panel into master 2025-12-01 17:36:40 +00:00
14 changed files with 206 additions and 208 deletions

View File

@ -367,76 +367,13 @@ export default function AppointmentDetailPage() {
</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 */}
{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={`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 Availability
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"}

View File

@ -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
![Admin Dashboard Access](/ss2.png)
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"
![Scheduling Appointment](/ss1.png)
### 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
@ -290,17 +330,32 @@ For technical assistance, questions, or issues:
</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 (
<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
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()}
>
<ArrowLeft className="mr-2" />
</Button>
<ReactMarkdown components={components} remarkPlugins={[remarkGfm]}>
{readmeContent}
</ReactMarkdown>

View File

@ -306,85 +306,13 @@ export default function UserAppointmentDetailPage() {
</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 */}
{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={`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 Availability
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"}

View File

@ -2,13 +2,14 @@
import { motion, useInView } from "framer-motion";
import { useRef, useState } from "react";
import { Send } from "lucide-react";
import { Send, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent } from "@/components/ui/card";
import { toast } from "sonner";
import { useAppTheme } from "@/components/ThemeProvider";
import { submitContactForm } from "@/lib/actions/auth";
export function ContactSection() {
const ref = useRef(null);
@ -21,13 +22,31 @@ export function ContactSection() {
phone: "",
message: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
toast("Message Received", {
setIsSubmitting(true);
try {
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 (
@ -204,11 +223,21 @@ export function ContactSection() {
</div>
<Button
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"
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Sending...
</>
) : (
<>
<Send className="mr-2 h-5 w-5" />
Send Message
</>
)}
</Button>
</form>
</CardContent>

View File

@ -240,7 +240,7 @@ export async function checkDateAvailability(date: string): Promise<CheckDateAvai
const data = await parseResponse(response);
if (!response.ok) {
throw new Error(extractErrorMessage(data as unknown as ApiError));
}
}
return data;
}
@ -357,7 +357,7 @@ export async function scheduleAppointment(id: string, input: ScheduleAppointment
}
return data.appointment || data;
}
}
export async function rejectAppointment(id: string, input: RejectAppointmentInput): Promise<Appointment> {
const tokens = getStoredTokens();

View File

@ -439,3 +439,51 @@ export async function updateProfile(input: UpdateProfileInput): Promise<User> {
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");
}
}

View File

@ -15,6 +15,7 @@ export const API_ENDPOINTS = {
allUsers: `${API_BASE_URL}/auth/all-users/`,
getProfile: `${API_BASE_URL}/auth/profile/`,
updateProfile: `${API_BASE_URL}/auth/profile/update/`,
contact: `${API_BASE_URL}/auth/contact/`,
},
meetings: {
base: `${API_BASE_URL}/meetings/`,

BIN
public/ss1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 718 KiB

BIN
public/ss2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 627 KiB

BIN
public/ss3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 KiB