Compare commits

...

2 Commits

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"}
@ -558,22 +495,22 @@ export default function AppointmentDetailPage() {
<div className="flex items-center gap-2">
{appointment.can_join_meeting ? (
<>
<a
href={appointment.jitsi_meet_url}
target="_blank"
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"}`}
>
{appointment.jitsi_meet_url}
</a>
<a
href={appointment.jitsi_meet_url}
target="_blank"
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"}`}
>
<ExternalLink className="w-4 h-4" />
</a>
<a
href={appointment.jitsi_meet_url}
target="_blank"
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"}`}
>
{appointment.jitsi_meet_url}
</a>
<a
href={appointment.jitsi_meet_url}
target="_blank"
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"}`}
>
<ExternalLink className="w-4 h-4" />
</a>
</>
) : (
<>

View File

@ -536,29 +536,29 @@ export default function Booking() {
else if (adminAvailability.available_days && adminAvailability.available_days.length > 0) {
return adminAvailability.available_days.map((dayNum, index) => {
const dayName = daysOfWeek.find(d => d.value === dayNum)?.label || adminAvailability.available_days_display?.[index];
const timeSlots = dayTimeSlots[dayNum] || [];
const slotLabels = timeSlots.map(slot => {
const option = timeSlotOptions.find(opt => opt.value === slot);
return option ? option.label : slot;
});
return (
<div
key={dayNum}
className={`flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm whitespace-nowrap ${
isDark
? "bg-rose-500/10 text-rose-200 border border-rose-500/20"
: "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"}`} />
<span className="font-medium shrink-0">{dayName}</span>
{slotLabels.length > 0 && (
<span className={`text-sm shrink-0 ${isDark ? "text-rose-300" : "text-rose-600"}`}>
({slotLabels.join(", ")})
</span>
)}
</div>
);
const timeSlots = dayTimeSlots[dayNum] || [];
const slotLabels = timeSlots.map(slot => {
const option = timeSlotOptions.find(opt => opt.value === slot);
return option ? option.label : slot;
});
return (
<div
key={dayNum}
className={`flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-sm whitespace-nowrap ${
isDark
? "bg-rose-500/10 text-rose-200 border border-rose-500/20"
: "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"}`} />
<span className="font-medium shrink-0">{dayName}</span>
{slotLabels.length > 0 && (
<span className={`text-sm shrink-0 ${isDark ? "text-rose-300" : "text-rose-600"}`}>
({slotLabels.join(", ")})
</span>
)}
</div>
);
});
}
return null;
@ -684,10 +684,10 @@ export default function Booking() {
{Array.isArray(appointment.preferred_dates) ? (
<>
{(appointment.preferred_dates as string[]).slice(0, 2).map((date, idx) => (
<span key={idx}>{formatDate(date)}</span>
))}
{appointment.preferred_dates.length > 2 && (
<span className="text-xs">+{appointment.preferred_dates.length - 2} more</span>
<span key={idx}>{formatDate(date)}</span>
))}
{appointment.preferred_dates.length > 2 && (
<span className="text-xs">+{appointment.preferred_dates.length - 2} more</span>
)}
</>
) : (

View File

@ -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"}`}>
{card.value}
</p>
<p className={`text-xs ${isDark ? "text-gray-400" : "text-gray-500"}`}>
vs last month
</p>
<p className={`text-xs ${isDark ? "text-gray-400" : "text-gray-500"}`}>
vs last month
</p>
</div>
</div>
);

View File

@ -150,7 +150,7 @@ export default function BookNowPage() {
dayName: dayName,
availableSlots: slotsSet,
});
}
}
}
});
@ -310,7 +310,7 @@ export default function BookNowPage() {
if (!firstName || firstName.length === 0) {
setError("First name is required.");
return;
}
}
if (!lastName || lastName.length === 0) {
setError("Last name is required.");
return;
@ -567,7 +567,7 @@ export default function BookNowPage() {
</p>
</div>
{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-base ${isDark ? 'text-white' : 'text-gray-900'}`}>
{booking.user.phone}

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

@ -225,7 +225,7 @@ export default function UserDashboard() {
</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"}`}
>
>
<ArrowUpRight className="w-3 h-3" />
<span>{displayStats.scheduled > 0 ? `+${displayStats.scheduled}` : "0"}</span>
</div>

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", {
description: "Thank you for reaching out. We'll get back to you soon!",
});
setFormData({ name: "", email: "", phone: "", message: "" });
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"
>
<Send className="mr-2 h-5 w-5" />
Send Message
{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

@ -131,7 +131,7 @@ export async function createAppointment(input: CreateAppointmentInput): Promise<
if (!input.selected_slots || input.selected_slots.length === 0) {
throw new Error("At least one time slot must be selected");
}
}
const validSlots = validateAndCleanSlots(input.selected_slots);
if (validSlots.length === 0) {
@ -211,7 +211,7 @@ export async function getWeeklyAvailability(): Promise<WeeklyAvailabilityRespons
const data = await parseResponse(response);
if (!response.ok) {
throw new Error(extractErrorMessage(data as unknown as ApiError));
}
}
return Array.isArray(data) ? data : data;
}
@ -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();
@ -402,7 +402,7 @@ export async function getPublicAvailability(): Promise<AdminAvailability | null>
availabilitySchedule[day.day.toString()] = day.available_slots;
availableDays.push(day.day);
availableDaysDisplay.push(day.day_name);
}
}
});
return {
@ -533,7 +533,7 @@ export async function updateAdminAvailability(input: UpdateAvailabilityInput): P
Authorization: `Bearer ${tokens.access}`,
},
body: JSON.stringify({ availability_schedule: sortedSchedule }),
});
});
}
const responseText = await response.text();
@ -548,7 +548,7 @@ export async function updateAdminAvailability(input: UpdateAvailabilityInput): P
let data: any;
if (contentType.includes("application/json")) {
try {
try {
data = JSON.parse(responseText);
} catch {
throw new Error(`Server error (${response.status}): Invalid JSON format`);

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