From a864d909a80a93776d4386f318f495489f3322d3 Mon Sep 17 00:00:00 2001
From: iamkiddy
Date: Thu, 27 Nov 2025 19:53:35 +0000
Subject: [PATCH 1/2] Refactor appointment handling in appointments.ts to
improve error extraction, response parsing, and slot validation. Introduce
new helper functions for better readability and maintainability, while
ensuring compatibility with updated API response formats.
---
lib/actions/appointments.ts | 969 +++++++++---------------------------
1 file changed, 227 insertions(+), 742 deletions(-)
diff --git a/lib/actions/appointments.ts b/lib/actions/appointments.ts
index 64ea7b6..ba86a38 100644
--- a/lib/actions/appointments.ts
+++ b/lib/actions/appointments.ts
@@ -9,7 +9,6 @@ import type {
import type {
Appointment,
AppointmentResponse,
- AppointmentsListResponse,
AvailableDatesResponse,
AdminAvailability,
AppointmentStats,
@@ -23,125 +22,133 @@ import type {
SelectedSlot,
} from "@/lib/models/appointments";
-// Helper function to extract error message from API response
function extractErrorMessage(error: ApiError): string {
if (error.detail) {
- if (Array.isArray(error.detail)) {
- return error.detail.join(", ");
- }
- return String(error.detail);
+ return Array.isArray(error.detail) ? error.detail.join(", ") : String(error.detail);
}
-
if (error.message) {
- if (Array.isArray(error.message)) {
- return error.message.join(", ");
- }
- return String(error.message);
+ return Array.isArray(error.message) ? error.message.join(", ") : String(error.message);
}
-
if (typeof error === "string") {
return error;
}
-
- return "An error occurred while creating the appointment";
+ return "An error occurred";
}
-// Create appointment
-export async function createAppointment(
- input: CreateAppointmentInput
-): Promise {
- const tokens = getStoredTokens();
-
- if (!tokens.access) {
- throw new Error("Authentication required. Please log in to book an appointment.");
+async function parseResponse(response: Response): Promise {
+ const responseText = await response.text();
+ const contentType = response.headers.get("content-type") || "";
+
+ if (!responseText || responseText.trim().length === 0) {
+ if (response.ok) {
+ return null;
+ }
+ throw new Error(`Server error (${response.status}): ${response.statusText || 'Empty response'}`);
}
- // Validate required fields
- if (!input.first_name || !input.last_name || !input.email) {
- throw new Error("First name, last name, and email are required");
- }
-
- // New API format: use selected_slots
- if (!input.selected_slots || input.selected_slots.length === 0) {
- throw new Error("At least one time slot must be selected");
+ if (contentType.includes("application/json")) {
+ try {
+ return JSON.parse(responseText);
+ } catch {
+ throw new Error(`Server error (${response.status}): Invalid JSON format`);
+ }
}
- // Validate and clean selected_slots to ensure all have day and time_slot
- // This filters out any invalid slots and ensures proper format
- const validSlots: SelectedSlot[] = input.selected_slots
- .filter((slot, index) => {
- // Check if slot exists and is an object
- if (!slot || typeof slot !== 'object') {
- return false;
- }
- // Check if both day and time_slot properties exist
- if (typeof slot.day === 'undefined' || typeof slot.time_slot === 'undefined') {
- return false;
- }
- // Validate day is a number between 0-6
+ const errorMatch = responseText.match(/]*>([\s\S]*?)<\/pre>/i) ||
+ responseText.match(/]*>([\s\S]*?)<\/h1>/i);
+ const errorText = errorMatch?.[1]?.replace(/<[^>]*>/g, '').trim() || '';
+ throw new Error(`Server error (${response.status}): ${errorText || response.statusText || 'Internal Server Error'}`);
+}
+
+function extractHtmlError(responseText: string): string {
+ const errorMatch = responseText.match(/]*>([\s\S]*?)<\/pre>/i);
+ if (!errorMatch) return '';
+
+ const traceback = errorMatch[1].replace(/<[^>]*>/g, '');
+ const lines = traceback.split('\n').filter(line => line.trim());
+
+ for (let i = lines.length - 1; i >= Math.max(0, lines.length - 5); i--) {
+ const line = lines[i];
+ if (line.match(/(Error|Exception|Failed)/i)) {
+ return line.trim().replace(/^(Traceback|File|Error|Exception):\s*/i, '');
+ }
+ }
+
+ return lines[lines.length - 1]?.trim() || '';
+}
+
+function validateAndCleanSlots(slots: any[]): SelectedSlot[] {
+ return slots
+ .filter(slot => {
+ if (!slot || typeof slot !== 'object') return false;
const dayNum = Number(slot.day);
- if (isNaN(dayNum) || dayNum < 0 || dayNum > 6) {
- return false;
- }
- // Validate time_slot is a valid string (normalize to lowercase)
- const timeSlot = String(slot.time_slot).toLowerCase().trim();
- if (!['morning', 'afternoon', 'evening'].includes(timeSlot)) {
- return false;
- }
- return true;
+ const timeSlot = String(slot.time_slot || '').toLowerCase().trim();
+ return !isNaN(dayNum) && dayNum >= 0 && dayNum <= 6 &&
+ ['morning', 'afternoon', 'evening'].includes(timeSlot);
})
.map(slot => ({
day: Number(slot.day),
time_slot: String(slot.time_slot).toLowerCase().trim() as "morning" | "afternoon" | "evening",
}));
+}
+function normalizeAvailabilitySchedule(schedule: any): Record {
+ if (typeof schedule === 'string') {
+ try {
+ schedule = JSON.parse(schedule);
+ } catch {
+ return {};
+ }
+ }
+
+ const numberToTimeSlot: Record = {
+ 0: 'morning',
+ 1: 'afternoon',
+ 2: 'evening',
+ };
+
+ const result: Record = {};
+ Object.keys(schedule || {}).forEach(day => {
+ const slots = schedule[day];
+ if (Array.isArray(slots) && slots.length > 0) {
+ result[day] = typeof slots[0] === 'number'
+ ? slots.map((num: number) => numberToTimeSlot[num]).filter(Boolean) as string[]
+ : slots.filter((s: string) => ['morning', 'afternoon', 'evening'].includes(s));
+ }
+ });
+ return result;
+}
+
+export async function createAppointment(input: CreateAppointmentInput): Promise {
+ const tokens = getStoredTokens();
+ if (!tokens.access) {
+ throw new Error("Authentication required. Please log in to book an appointment.");
+ }
+
+ if (!input.first_name || !input.last_name || !input.email) {
+ throw new Error("First name, last name, and email are required");
+ }
+
+ 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) {
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).");
}
- // Limit field lengths to prevent database errors (100 char limit for all string fields)
- // Truncate all string fields BEFORE trimming to handle edge cases
- const firstName = input.first_name ? String(input.first_name).trim().substring(0, 100) : '';
- const lastName = input.last_name ? String(input.last_name).trim().substring(0, 100) : '';
- const email = input.email ? String(input.email).trim().toLowerCase().substring(0, 100) : '';
- const phone = input.phone ? String(input.phone).trim().substring(0, 100) : undefined;
- const reason = input.reason ? String(input.reason).trim().substring(0, 100) : undefined;
-
- // Build payload with only the fields the API expects - no extra fields
- const payload: {
- first_name: string;
- last_name: string;
- email: string;
- selected_slots: Array<{ day: number; time_slot: string }>;
- phone?: string;
- reason?: string;
- } = {
- first_name: firstName,
- last_name: lastName,
- email: email,
+ const truncate = (str: string, max: number) => String(str || '').trim().substring(0, max);
+ const payload = {
+ 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: Number(slot.day),
- time_slot: String(slot.time_slot).toLowerCase().trim(),
+ day: slot.day,
+ time_slot: slot.time_slot,
})),
- };
-
- // Only add optional fields if they have values (and are within length limits)
- if (phone && phone.length > 0 && phone.length <= 100) {
- payload.phone = phone;
- }
- if (reason && reason.length > 0 && reason.length <= 100) {
- payload.reason = reason;
- }
-
- // Final validation: ensure all string fields in payload are exactly 100 chars or less
- // This is a safety check to prevent any encoding or serialization issues
- const finalPayload = {
- first_name: payload.first_name.substring(0, 100),
- last_name: payload.last_name.substring(0, 100),
- email: payload.email.substring(0, 100),
- selected_slots: payload.selected_slots,
- ...(payload.phone && { phone: payload.phone.substring(0, 100) }),
- ...(payload.reason && { reason: payload.reason.substring(0, 100) }),
+ ...(input.phone && { phone: truncate(input.phone, 100) }),
+ ...(input.reason && { reason: truncate(input.reason, 100) }),
};
const response = await fetch(API_ENDPOINTS.meetings.createAppointment, {
@@ -150,57 +157,17 @@ export async function createAppointment(
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
- body: JSON.stringify(finalPayload),
+ body: JSON.stringify(payload),
});
- // Read response text first (can only be read once)
- const responseText = await response.text();
-
- // Check content type before parsing
- const contentType = response.headers.get("content-type");
- let data: any;
-
- if (contentType && contentType.includes("application/json")) {
- try {
- if (!responseText) {
- throw new Error(`Server returned empty response (${response.status})`);
- }
- data = JSON.parse(responseText);
- } catch (e) {
- throw new Error(`Server error (${response.status}): ${response.statusText || 'Invalid response format'}`);
- }
- } else {
- // Response is not JSON (likely HTML error page)
- // Try to extract error message from HTML if possible
- let errorMessage = `Server error (${response.status}): ${response.statusText || 'Internal Server Error'}`;
-
- // Try to find error details in HTML
- // Use [\s\S] instead of . with s flag for better compatibility
- const errorMatch = responseText.match(/]*>([\s\S]*?)<\/pre>/i) ||
- responseText.match(/]*>([\s\S]*?)<\/h1>/i) ||
- responseText.match(/]*>([\s\S]*?)<\/title>/i);
-
- if (errorMatch && errorMatch[1]) {
- const htmlError = errorMatch[1].replace(/<[^>]*>/g, '').trim();
- if (htmlError) {
- errorMessage += `. ${htmlError}`;
- }
- }
-
- throw new Error(errorMessage);
- }
+ const data = await parseResponse(response);
if (!response.ok) {
- const errorMessage = extractErrorMessage(data as unknown as ApiError);
- throw new Error(errorMessage);
+ throw new Error(extractErrorMessage(data as unknown as ApiError));
}
- // Handle API response format: { appointment_id, message }
- // According to API docs, response includes appointment_id and message
if (data.appointment_id) {
- // Construct a minimal Appointment object from the response
- // We'll use the input data plus the appointment_id from response
- const appointment: Appointment = {
+ return {
id: data.appointment_id,
first_name: input.first_name.trim(),
last_name: input.last_name.trim(),
@@ -212,159 +179,88 @@ export async function createAppointment(
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
- return appointment;
}
- // Handle different response formats for backward compatibility
- if (data.appointment) {
- return data.appointment;
- }
- if ((data as any).data) {
- return (data as any).data;
- }
-
- // If appointment is returned directly
- return data as unknown as Appointment;
+ return data.appointment || data.data || data;
}
-// Get available dates (optional endpoint - may fail if admin hasn't set availability)
export async function getAvailableDates(): Promise {
try {
- const response = await fetch(API_ENDPOINTS.meetings.availableDates, {
- method: "GET",
- headers: {
- "Content-Type": "application/json",
- },
- });
+ const response = await fetch(API_ENDPOINTS.meetings.availableDates, {
+ method: "GET",
+ headers: { "Content-Type": "application/json" },
+ });
- // Handle different response formats
- const contentType = response.headers.get("content-type");
- let data: any;
-
- if (contentType && contentType.includes("application/json")) {
- const responseText = await response.text();
- if (!responseText) {
- throw new Error(`Server returned empty response (${response.status})`);
- }
- try {
- data = JSON.parse(responseText);
- } catch (parseError) {
- throw new Error(`Invalid response format (${response.status})`);
- }
- } else {
- throw new Error(`Server error (${response.status}): ${response.statusText || 'Invalid response'}`);
+ if (!response.ok) {
+ return { dates: [] };
}
- if (!response.ok) {
- // Return empty response instead of throwing - this endpoint is optional
- return {
- dates: [],
- };
- }
-
- // If API returns array directly, wrap it in response object
- if (Array.isArray(data)) {
- return {
- dates: data,
- };
- }
-
- return data as AvailableDatesResponse;
- } catch (error) {
- // Return empty response - don't break the app
- return {
- dates: [],
- };
+ const data = await parseResponse(response);
+ return Array.isArray(data) ? { dates: data } : data;
+ } catch {
+ return { dates: [] };
}
}
-// Get weekly availability (Public)
export async function getWeeklyAvailability(): Promise {
const response = await fetch(API_ENDPOINTS.meetings.weeklyAvailability, {
method: "GET",
- headers: {
- "Content-Type": "application/json",
- },
+ headers: { "Content-Type": "application/json" },
});
- const data: any = await response.json();
-
+ const data = await parseResponse(response);
if (!response.ok) {
- const errorMessage = extractErrorMessage(data as unknown as ApiError);
- throw new Error(errorMessage);
+ throw new Error(extractErrorMessage(data as unknown as ApiError));
}
- // Handle different response formats - API might return array directly or wrapped
- if (Array.isArray(data)) {
- return data;
- }
-
- // If wrapped in an object, return as is (our interface supports it)
- return data;
+ return Array.isArray(data) ? data : data;
}
-// Get availability configuration (Public)
export async function getAvailabilityConfig(): Promise {
const response = await fetch(API_ENDPOINTS.meetings.availabilityConfig, {
method: "GET",
- headers: {
- "Content-Type": "application/json",
- },
+ headers: { "Content-Type": "application/json" },
});
- const data: AvailabilityConfig = await response.json();
-
+ const data = await parseResponse(response);
if (!response.ok) {
- const errorMessage = extractErrorMessage(data as unknown as ApiError);
- throw new Error(errorMessage);
+ throw new Error(extractErrorMessage(data as unknown as ApiError));
}
return data;
}
-// Check date availability (Public)
export async function checkDateAvailability(date: string): Promise {
const response = await fetch(API_ENDPOINTS.meetings.checkDateAvailability, {
method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
+ headers: { "Content-Type": "application/json" },
body: JSON.stringify({ date }),
});
- const data: CheckDateAvailabilityResponse = await response.json();
-
+ const data = await parseResponse(response);
if (!response.ok) {
- const errorMessage = extractErrorMessage(data as unknown as ApiError);
- throw new Error(errorMessage);
+ throw new Error(extractErrorMessage(data as unknown as ApiError));
}
return data;
}
-// Get availability overview (Public)
export async function getAvailabilityOverview(): Promise {
const response = await fetch(API_ENDPOINTS.meetings.availabilityOverview, {
method: "GET",
- headers: {
- "Content-Type": "application/json",
- },
+ headers: { "Content-Type": "application/json" },
});
- const data: AvailabilityOverview = await response.json();
-
+ const data = await parseResponse(response);
if (!response.ok) {
- const errorMessage = extractErrorMessage(data as unknown as ApiError);
- throw new Error(errorMessage);
+ throw new Error(extractErrorMessage(data as unknown as ApiError));
}
return data;
}
-// List appointments (Admin sees all, users see their own)
export async function listAppointments(email?: string): Promise {
const tokens = getStoredTokens();
-
if (!tokens.access) {
throw new Error("Authentication required.");
}
@@ -381,58 +277,20 @@ export async function listAppointments(email?: string): Promise {
},
});
- const responseText = await response.text();
-
+ const data = await parseResponse(response);
if (!response.ok) {
- let errorData: any;
- try {
- errorData = JSON.parse(responseText);
- } catch {
- throw new Error(`Server error (${response.status}): ${response.statusText || 'Internal Server Error'}`);
- }
- const errorMessage = extractErrorMessage(errorData as unknown as ApiError);
- throw new Error(errorMessage);
+ throw new Error(extractErrorMessage(data as unknown as ApiError));
}
- // Parse JSON response
- let data: any;
- try {
- if (!responseText || responseText.trim().length === 0) {
- return [];
- }
- data = JSON.parse(responseText);
- } catch (error) {
- throw new Error(`Failed to parse response: Invalid JSON format`);
- }
-
- // Handle different response formats
- // API returns array directly: [{ id, first_name, ... }, ...]
- if (Array.isArray(data)) {
- return data;
- }
- // Handle wrapped responses (if any)
- if (data && typeof data === 'object') {
- if (data.appointments && Array.isArray(data.appointments)) {
- return data.appointments;
- }
- if (data.results && Array.isArray(data.results)) {
- return data.results;
- }
- // If data is an object but not an array and doesn't have appointments/results, return empty
- // This shouldn't happen but handle gracefully
- if (data.id || data.first_name) {
- // Single appointment object, wrap in array
- return [data];
- }
- }
-
+ if (Array.isArray(data)) return data;
+ if (data?.appointments && Array.isArray(data.appointments)) return data.appointments;
+ if (data?.results && Array.isArray(data.results)) return data.results;
+ if (data?.id || data?.first_name) return [data];
return [];
}
-// Get user appointments
export async function getUserAppointments(): Promise {
const tokens = getStoredTokens();
-
if (!tokens.access) {
throw new Error("Authentication required.");
}
@@ -445,32 +303,19 @@ export async function getUserAppointments(): Promise {
},
});
- const data = await response.json();
-
+ const data = await parseResponse(response);
if (!response.ok) {
- const errorMessage = extractErrorMessage(data as unknown as ApiError);
- throw new Error(errorMessage);
+ throw new Error(extractErrorMessage(data as unknown as ApiError));
}
- // Handle different response formats
- // API might return array directly or wrapped in an object
- if (Array.isArray(data)) {
- return data;
- }
- if (data.appointments && Array.isArray(data.appointments)) {
- return data.appointments;
- }
- if (data.results && Array.isArray(data.results)) {
- return data.results;
- }
-
+ if (Array.isArray(data)) return data;
+ if (data?.appointments && Array.isArray(data.appointments)) return data.appointments;
+ if (data?.results && Array.isArray(data.results)) return data.results;
return [];
}
-// Get appointment detail
export async function getAppointmentDetail(id: string): Promise {
const tokens = getStoredTokens();
-
if (!tokens.access) {
throw new Error("Authentication required.");
}
@@ -483,27 +328,16 @@ export async function getAppointmentDetail(id: string): Promise {
},
});
- const data: AppointmentResponse = await response.json();
-
+ const data = await parseResponse(response);
if (!response.ok) {
- const errorMessage = extractErrorMessage(data as unknown as ApiError);
- throw new Error(errorMessage);
+ throw new Error(extractErrorMessage(data as unknown as ApiError));
}
- if (data.appointment) {
- return data.appointment;
- }
-
- return data as unknown as Appointment;
+ return (data as AppointmentResponse).appointment || data;
}
-// Schedule appointment (Admin only)
-export async function scheduleAppointment(
- id: string,
- input: ScheduleAppointmentInput
-): Promise {
+export async function scheduleAppointment(id: string, input: ScheduleAppointmentInput): Promise {
const tokens = getStoredTokens();
-
if (!tokens.access) {
throw new Error("Authentication required.");
}
@@ -517,77 +351,16 @@ export async function scheduleAppointment(
body: JSON.stringify(input),
});
- let data: any;
- const contentType = response.headers.get("content-type");
-
- if (contentType && contentType.includes("application/json")) {
- try {
- const text = await response.text();
- data = text ? JSON.parse(text) : {};
- } catch (e) {
- data = {};
- }
- } else {
- const text = await response.text();
- data = text || {};
- }
-
+ const data = await parseResponse(response);
if (!response.ok) {
- // Try to extract detailed error information
- let errorMessage = `Failed to schedule appointment (${response.status})`;
-
- if (data && Object.keys(data).length > 0) {
- // Check for common error formats
- if (data.detail) {
- errorMessage = Array.isArray(data.detail) ? data.detail.join(", ") : String(data.detail);
- } else if (data.message) {
- errorMessage = Array.isArray(data.message) ? data.message.join(", ") : String(data.message);
- } else if (data.error) {
- errorMessage = Array.isArray(data.error) ? data.error.join(", ") : String(data.error);
- } else if (typeof data === "string") {
- errorMessage = data;
- } else {
- // Check for field-specific errors
- const fieldErrors: string[] = [];
- Object.keys(data).forEach((key) => {
- if (key !== "detail" && key !== "message" && key !== "error") {
- const fieldError = data[key];
- if (Array.isArray(fieldError)) {
- fieldErrors.push(`${key}: ${fieldError.join(", ")}`);
- } else if (typeof fieldError === "string") {
- fieldErrors.push(`${key}: ${fieldError}`);
- }
- }
- });
- if (fieldErrors.length > 0) {
- errorMessage = fieldErrors.join(". ");
- } else {
- // If we have data but can't parse it, show the status
- errorMessage = `Server error: ${response.status} ${response.statusText}`;
- }
- }
- } else {
- // No data in response
- errorMessage = `Server error: ${response.status} ${response.statusText || 'Unknown error'}`;
- }
-
- throw new Error(errorMessage);
+ throw new Error(extractErrorMessage(data as unknown as ApiError));
}
- if (data.appointment) {
- return data.appointment;
- }
-
- return data as unknown as Appointment;
+ return data.appointment || data;
}
-// Reject appointment (Admin only)
-export async function rejectAppointment(
- id: string,
- input: RejectAppointmentInput
-): Promise {
+export async function rejectAppointment(id: string, input: RejectAppointmentInput): Promise {
const tokens = getStoredTokens();
-
if (!tokens.access) {
throw new Error("Authentication required.");
}
@@ -601,27 +374,17 @@ export async function rejectAppointment(
body: JSON.stringify(input),
});
- const data: AppointmentResponse = await response.json();
-
+ const data = await parseResponse(response);
if (!response.ok) {
- const errorMessage = extractErrorMessage(data as unknown as ApiError);
- throw new Error(errorMessage);
+ throw new Error(extractErrorMessage(data as unknown as ApiError));
}
- if (data.appointment) {
- return data.appointment;
- }
-
- return data as unknown as Appointment;
+ return (data as AppointmentResponse).appointment || data;
}
-// Get admin availability (public version - uses weekly availability endpoint instead)
export async function getPublicAvailability(): Promise {
try {
- // Use weekly availability endpoint which is public
const weeklyAvailability = await getWeeklyAvailability();
-
- // Normalize to array format
const weekArray = Array.isArray(weeklyAvailability)
? weeklyAvailability
: (weeklyAvailability as any).week || [];
@@ -630,13 +393,12 @@ export async function getPublicAvailability(): Promise
return null;
}
- // Convert weekly availability to AdminAvailability format
const availabilitySchedule: Record = {};
const availableDays: number[] = [];
const availableDaysDisplay: string[] = [];
weekArray.forEach((day: any) => {
- if (day.is_available && day.available_slots && day.available_slots.length > 0) {
+ if (day.is_available && day.available_slots?.length > 0) {
availabilitySchedule[day.day.toString()] = day.available_slots;
availableDays.push(day.day);
availableDaysDisplay.push(day.day_name);
@@ -649,17 +411,20 @@ export async function getPublicAvailability(): Promise
availability_schedule: availabilitySchedule,
all_available_slots: weekArray
.filter((d: any) => d.is_available)
- .flatMap((d: any) => d.available_slots.map((slot: string) => ({ day: d.day, time_slot: slot as "morning" | "afternoon" | "evening" }))),
+ .flatMap((d: any) =>
+ d.available_slots.map((slot: string) => ({
+ day: d.day,
+ time_slot: slot as "morning" | "afternoon" | "evening"
+ }))
+ ),
} as AdminAvailability;
- } catch (error) {
+ } catch {
return null;
}
}
-// Get admin availability
export async function getAdminAvailability(): Promise {
const tokens = getStoredTokens();
-
if (!tokens.access) {
throw new Error("Authentication required.");
}
@@ -672,80 +437,35 @@ export async function getAdminAvailability(): Promise {
},
});
- const data: any = await response.json();
-
+ const data = await parseResponse(response);
if (!response.ok) {
- const errorMessage = extractErrorMessage(data as unknown as ApiError);
- throw new Error(errorMessage);
+ throw new Error(extractErrorMessage(data as unknown as ApiError));
}
- // Handle new format with availability_schedule
- // API returns availability_schedule, which may be a JSON string or object
- // Time slots are strings: "morning", "afternoon", "evening"
if (data.availability_schedule) {
- let availabilitySchedule: Record;
-
- // Map numeric indices to string names (in case API returns numeric indices)
- const numberToTimeSlot: Record = {
- 0: 'morning',
- 1: 'afternoon',
- 2: 'evening',
- };
-
- // Parse if it's a string, otherwise use as-is
- let rawSchedule: Record;
- if (typeof data.availability_schedule === 'string') {
- try {
- rawSchedule = JSON.parse(data.availability_schedule);
- } catch (parseError) {
- rawSchedule = {};
- }
- } else {
- rawSchedule = data.availability_schedule;
- }
-
- // Convert to string format, handling both numeric indices and string values
- availabilitySchedule = {};
- Object.keys(rawSchedule).forEach(day => {
- const slots = rawSchedule[day];
- if (Array.isArray(slots) && slots.length > 0) {
- // Check if slots are numbers (indices) or already strings
- if (typeof slots[0] === 'number') {
- // Convert numeric indices to string names
- availabilitySchedule[day] = (slots as number[])
- .map((num: number) => numberToTimeSlot[num])
- .filter((slot: string | undefined) => slot !== undefined) as string[];
- } else {
- // Already strings, validate and use as-is
- availabilitySchedule[day] = (slots as string[]).filter(slot =>
- ['morning', 'afternoon', 'evening'].includes(slot)
- );
- }
- }
- });
-
+ const availabilitySchedule = normalizeAvailabilitySchedule(data.availability_schedule);
const availableDays = Object.keys(availabilitySchedule).map(Number);
-
- // Generate available_days_display if not provided
const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const availableDaysDisplay = availableDays.map(day => dayNames[day] || `Day ${day}`);
return {
available_days: availableDays,
- available_days_display: data.availability_schedule_display ? [data.availability_schedule_display] : availableDaysDisplay,
+ available_days_display: Array.isArray(data.availability_schedule_display)
+ ? data.availability_schedule_display
+ : data.availability_schedule_display
+ ? [data.availability_schedule_display]
+ : availableDaysDisplay,
availability_schedule: availabilitySchedule,
availability_schedule_display: data.availability_schedule_display,
all_available_slots: data.all_available_slots || [],
} as AdminAvailability;
}
- // Handle legacy format
let availableDays: number[] = [];
if (typeof data.available_days === 'string') {
try {
availableDays = JSON.parse(data.available_days);
} catch {
- // If parsing fails, try splitting by comma
availableDays = data.available_days.split(',').map((d: string) => parseInt(d.trim())).filter((d: number) => !isNaN(d));
}
} else if (Array.isArray(data.available_days)) {
@@ -758,88 +478,53 @@ export async function getAdminAvailability(): Promise {
} as AdminAvailability;
}
-// Update admin availability
-export async function updateAdminAvailability(
- input: UpdateAvailabilityInput
-): Promise {
+export async function updateAdminAvailability(input: UpdateAvailabilityInput): Promise {
const tokens = getStoredTokens();
-
if (!tokens.access) {
throw new Error("Authentication required.");
}
- // Prepare payload using new format (availability_schedule)
- // API expects availability_schedule as an object with string keys (day numbers) and string arrays (time slot names)
- // Format: { "0": ["morning", "afternoon"], "1": ["evening"], ... }
- const payload: any = {};
-
- if (input.availability_schedule) {
- // Validate and clean the schedule object
- // API expects: { "0": ["morning", "evening"], "1": ["afternoon"], ... }
- // Time slots are strings: "morning", "afternoon", "evening"
- const cleanedSchedule: Record = {};
- Object.keys(input.availability_schedule).forEach(key => {
- // Ensure key is a valid day (0-6)
- const dayNum = parseInt(key);
- if (isNaN(dayNum) || dayNum < 0 || dayNum > 6) {
- return;
- }
-
- const slots = input.availability_schedule[key];
- if (Array.isArray(slots) && slots.length > 0) {
- // Filter to only valid time slot strings and remove duplicates
- const validSlots = slots
- .filter((slot: string) =>
- typeof slot === 'string' && ['morning', 'afternoon', 'evening'].includes(slot)
- )
- .filter((slot: string, index: number, self: string[]) =>
- self.indexOf(slot) === index
- ); // Remove duplicates
-
- if (validSlots.length > 0) {
- // Ensure day key is a string (as per API spec)
- cleanedSchedule[key.toString()] = validSlots;
- }
- }
- });
-
- if (Object.keys(cleanedSchedule).length === 0) {
- throw new Error("At least one day with valid time slots must be provided");
- }
-
- // Sort the schedule keys for consistency
- const sortedSchedule: Record = {};
- Object.keys(cleanedSchedule)
- .sort((a, b) => parseInt(a) - parseInt(b))
- .forEach(key => {
- sortedSchedule[key] = cleanedSchedule[key];
- });
-
- // IMPORTANT: API expects availability_schedule as an object (not stringified)
- // Format: { "0": ["morning", "afternoon"], "1": ["evening"], ... }
- payload.availability_schedule = sortedSchedule;
- } else if (input.available_days) {
- // Legacy format: available_days
- payload.available_days = Array.isArray(input.available_days)
- ? input.available_days.map(day => Number(day))
- : input.available_days;
- } else {
- throw new Error("Either availability_schedule or available_days must be provided");
+ if (!input.availability_schedule) {
+ throw new Error("availability_schedule is required");
}
+ const cleanedSchedule: Record = {};
+ Object.keys(input.availability_schedule).forEach(key => {
+ const dayNum = parseInt(key);
+ if (isNaN(dayNum) || dayNum < 0 || dayNum > 6) return;
+
+ const slots = input.availability_schedule[key];
+ if (Array.isArray(slots) && slots.length > 0) {
+ const validSlots = slots
+ .filter((slot: string) => typeof slot === 'string' && ['morning', 'afternoon', 'evening'].includes(slot))
+ .filter((slot: string, index: number, self: string[]) => self.indexOf(slot) === index);
+
+ if (validSlots.length > 0) {
+ cleanedSchedule[key.toString()] = validSlots;
+ }
+ }
+ });
+
+ if (Object.keys(cleanedSchedule).length === 0) {
+ throw new Error("At least one day with valid time slots must be provided");
+ }
+
+ const sortedSchedule: Record = {};
+ Object.keys(cleanedSchedule)
+ .sort((a, b) => parseInt(a) - parseInt(b))
+ .forEach(key => {
+ sortedSchedule[key] = cleanedSchedule[key];
+ });
- // Try PUT first, fallback to PATCH if needed
- // The payload object will be JSON stringified, including availability_schedule as an object
let response = await fetch(API_ENDPOINTS.meetings.adminAvailability, {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
- body: JSON.stringify(payload),
+ body: JSON.stringify({ availability_schedule: sortedSchedule }),
});
- // If PUT fails with 500, try PATCH (some APIs prefer PATCH for updates)
if (!response.ok && response.status === 500) {
response = await fetch(API_ENDPOINTS.meetings.adminAvailability, {
method: "PATCH",
@@ -847,249 +532,64 @@ export async function updateAdminAvailability(
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
- body: JSON.stringify(payload),
+ body: JSON.stringify({ availability_schedule: sortedSchedule }),
});
}
- // Read response text first (can only be read once)
const responseText = await response.text();
- let data: any;
-
- // Get content type
const contentType = response.headers.get("content-type") || "";
-
- // Handle empty response
+
if (!responseText || responseText.trim().length === 0) {
- // If successful status but empty response, refetch the availability
if (response.ok) {
return await getAdminAvailability();
}
-
- throw new Error(`Server error (${response.status}): ${response.statusText || 'Empty response from server'}`);
+ throw new Error(`Server error (${response.status}): ${response.statusText || 'Empty response'}`);
}
-
- // Try to parse as JSON
+
+ let data: any;
if (contentType.includes("application/json")) {
- try {
- data = JSON.parse(responseText);
- } catch (parseError) {
- throw new Error(`Server error (${response.status}): Invalid JSON response format`);
- }
+ try {
+ data = JSON.parse(responseText);
+ } catch {
+ throw new Error(`Server error (${response.status}): Invalid JSON format`);
+ }
} else {
- // Response is not JSON - try to extract useful information
- // Try to extract error message from HTML if it's an HTML error page
- let errorMessage = `Server error (${response.status}): ${response.statusText || 'Internal Server Error'}`;
- let actualError = '';
- let errorType = '';
- let fullTraceback = '';
-
- if (responseText) {
- // Extract Django error details from HTML
- const titleMatch = responseText.match(/]*>(.*?)<\/title>/i);
- const h1Match = responseText.match(/]*>(.*?)<\/h1>/i);
-
- // Try to find the actual error traceback in tags (Django debug pages)
- const tracebackMatch = responseText.match(/]*class="[^"]*traceback[^"]*"[^>]*>([\s\S]*?)<\/pre>/i) ||
- responseText.match(/]*>([\s\S]*?)<\/pre>/i);
-
- // Extract the actual error type and message
- const errorTypeMatch = responseText.match(/]*>(.*?)<\/h2>/i);
-
- if (tracebackMatch && tracebackMatch[1]) {
- // Extract the full traceback and find the actual error
- const tracebackText = tracebackMatch[1].replace(/<[^>]*>/g, ''); // Remove HTML tags
- fullTraceback = tracebackText;
- const tracebackLines = tracebackText.split('\n').filter(line => line.trim());
-
- // Look for the actual error message - Django errors usually appear at the end
- // First, try to find error patterns in the entire traceback
- const errorPatterns = [
- // Database column errors
- /column\s+[\w.]+\.(\w+)\s+(does not exist|already exists|is missing)/i,
- // Programming errors
- /(ProgrammingError|OperationalError|IntegrityError|DatabaseError|ValueError|TypeError|AttributeError|KeyError):\s*(.+?)(?:\n|$)/i,
- // Generic error patterns
- /^(\w+Error):\s*(.+)$/i,
- // Error messages without type
- /^(.+Error[:\s]+.+)$/i,
- ];
-
- // Search from the end backwards (errors are usually at the end)
- for (let i = tracebackLines.length - 1; i >= 0; i--) {
- const line = tracebackLines[i];
-
- // Check each pattern
- for (const pattern of errorPatterns) {
- const match = line.match(pattern);
- if (match) {
- // For database column errors, capture the full message
- if (pattern.source.includes('column')) {
- actualError = match[0];
- errorType = 'DatabaseError';
- } else if (match[1] && match[2]) {
- errorType = match[1];
- actualError = match[2].trim();
- } else {
- actualError = match[0];
- }
- break;
- }
- }
- if (actualError) break;
- }
-
- // If no pattern match, look for lines containing "Error" or common error keywords
- if (!actualError) {
- for (let i = tracebackLines.length - 1; i >= Math.max(0, tracebackLines.length - 10); i--) {
- const line = tracebackLines[i];
- if (line.match(/(Error|Exception|Failed|Invalid|Missing|does not exist|already exists)/i)) {
- actualError = line;
- break;
- }
- }
- }
-
- // Last resort: get the last line
- if (!actualError && tracebackLines.length > 0) {
- actualError = tracebackLines[tracebackLines.length - 1];
- }
-
- // Clean up the error message
- if (actualError) {
- actualError = actualError.trim();
- // Remove common prefixes
- actualError = actualError.replace(/^(Traceback|File|Error|Exception):\s*/i, '');
- }
- } else if (errorTypeMatch && errorTypeMatch[1]) {
- errorType = errorTypeMatch[1].replace(/<[^>]*>/g, '').trim();
- actualError = errorType;
- if (errorType && errorType.length < 200) {
- errorMessage += `. ${errorType}`;
- }
- } else if (h1Match && h1Match[1]) {
- actualError = h1Match[1].replace(/<[^>]*>/g, '').trim();
- if (actualError && actualError.length < 200) {
- errorMessage += `. ${actualError}`;
- }
- } else if (titleMatch && titleMatch[1]) {
- actualError = titleMatch[1].replace(/<[^>]*>/g, '').trim();
- if (actualError && actualError.length < 200) {
- errorMessage += `. ${actualError}`;
- }
- }
- }
-
- // Update error message with the extracted error
- if (actualError) {
- errorMessage = `Server error (${response.status}): ${actualError}`;
- }
-
- throw new Error(errorMessage);
+ const htmlError = extractHtmlError(responseText);
+ throw new Error(`Server error (${response.status}): ${htmlError || response.statusText || 'Internal Server Error'}`);
}
if (!response.ok) {
- const errorMessage = extractErrorMessage(data as unknown as ApiError);
-
- // Build detailed error message
- let detailedError = `Server error (${response.status}): `;
- if (data && typeof data === 'object') {
- if (data.detail) {
- detailedError += Array.isArray(data.detail) ? data.detail.join(", ") : String(data.detail);
- } else if (data.error) {
- detailedError += Array.isArray(data.error) ? data.error.join(", ") : String(data.error);
- } else if (data.message) {
- detailedError += Array.isArray(data.message) ? data.message.join(", ") : String(data.message);
- } else {
- detailedError += response.statusText || 'Failed to update availability';
- }
- } else if (responseText && responseText.length > 0) {
- // Try to extract error from HTML response if it's not JSON
- detailedError += responseText.substring(0, 200);
- } else {
- detailedError += response.statusText || 'Failed to update availability';
- }
-
- throw new Error(detailedError);
+ throw new Error(extractErrorMessage(data as unknown as ApiError));
}
- // Handle new format with availability_schedule in response
- // API returns availability_schedule, which may be a JSON string or object
- // Time slots may be strings or numeric indices
- if (data && data.availability_schedule) {
- let availabilitySchedule: Record;
-
- // Map numeric indices to string names (in case API returns numeric indices)
- const numberToTimeSlot: Record = {
- 0: 'morning',
- 1: 'afternoon',
- 2: 'evening',
- };
-
- // Parse if it's a string, otherwise use as-is
- let rawSchedule: Record;
- if (typeof data.availability_schedule === 'string') {
- try {
- rawSchedule = JSON.parse(data.availability_schedule);
- } catch (parseError) {
- rawSchedule = {};
- }
- } else if (typeof data.availability_schedule === 'object') {
- rawSchedule = data.availability_schedule;
- } else {
- rawSchedule = {};
- }
-
- // Convert to string format, handling both numeric indices and string values
- availabilitySchedule = {};
- Object.keys(rawSchedule).forEach(day => {
- const slots = rawSchedule[day];
- if (Array.isArray(slots) && slots.length > 0) {
- // Check if slots are numbers (indices) or already strings
- if (typeof slots[0] === 'number') {
- // Convert numeric indices to string names
- availabilitySchedule[day] = (slots as number[])
- .map((num: number) => numberToTimeSlot[num])
- .filter((slot: string | undefined) => slot !== undefined) as string[];
- } else {
- // Already strings, validate and use as-is
- availabilitySchedule[day] = (slots as string[]).filter(slot =>
- ['morning', 'afternoon', 'evening'].includes(slot)
- );
- }
- }
- });
-
+ if (data?.availability_schedule) {
+ const availabilitySchedule = normalizeAvailabilitySchedule(data.availability_schedule);
const availableDays = Object.keys(availabilitySchedule).map(Number);
const dayNames = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const availableDaysDisplay = availableDays.map(day => dayNames[day] || `Day ${day}`);
return {
available_days: availableDays,
- available_days_display: data.availability_schedule_display ?
- (Array.isArray(data.availability_schedule_display) ?
- data.availability_schedule_display :
- [data.availability_schedule_display]) :
- availableDaysDisplay,
+ available_days_display: Array.isArray(data.availability_schedule_display)
+ ? data.availability_schedule_display
+ : data.availability_schedule_display
+ ? [data.availability_schedule_display]
+ : availableDaysDisplay,
availability_schedule: availabilitySchedule,
availability_schedule_display: data.availability_schedule_display,
all_available_slots: data.all_available_slots || [],
} as AdminAvailability;
}
-
- // If response is empty but successful (200), return empty availability
- // This might happen if the server doesn't return data on success
+
if (response.ok && (!data || Object.keys(data).length === 0)) {
- // Refetch the availability to get the updated data
- return getAdminAvailability();
+ return await getAdminAvailability();
}
- // Handle legacy format
let availableDays: number[] = [];
if (typeof data.available_days === 'string') {
try {
availableDays = JSON.parse(data.available_days);
} catch {
- // If parsing fails, try splitting by comma
availableDays = data.available_days.split(',').map((d: string) => parseInt(d.trim())).filter((d: number) => !isNaN(d));
}
} else if (Array.isArray(data.available_days)) {
@@ -1102,10 +602,8 @@ export async function updateAdminAvailability(
} as AdminAvailability;
}
-// Get appointment stats (Admin only)
export async function getAppointmentStats(): Promise {
const tokens = getStoredTokens();
-
if (!tokens.access) {
throw new Error("Authentication required.");
}
@@ -1118,20 +616,16 @@ export async function getAppointmentStats(): Promise {
},
});
- const data: AppointmentStats = await response.json();
-
+ const data = await parseResponse(response);
if (!response.ok) {
- const errorMessage = extractErrorMessage(data as unknown as ApiError);
- throw new Error(errorMessage);
+ throw new Error(extractErrorMessage(data as unknown as ApiError));
}
return data;
}
-// Get user appointment stats
export async function getUserAppointmentStats(email: string): Promise {
const tokens = getStoredTokens();
-
if (!tokens.access) {
throw new Error("Authentication required.");
}
@@ -1158,27 +652,21 @@ export async function getUserAppointmentStats(email: string): Promise {
const tokens = getStoredTokens();
-
if (!tokens.access) {
throw new Error("Authentication required.");
}
@@ -1191,13 +679,10 @@ export async function getJitsiMeetingInfo(id: string): Promise
},
});
- const data: JitsiMeetingInfo = await response.json();
-
+ const data = await parseResponse(response);
if (!response.ok) {
- const errorMessage = extractErrorMessage(data as unknown as ApiError);
- throw new Error(errorMessage);
+ throw new Error(extractErrorMessage(data as unknown as ApiError));
}
return data;
}
-
From cea4747da50cc688fdc8f98507747d63120ac4a2 Mon Sep 17 00:00:00 2001
From: iamkiddy
Date: Thu, 27 Nov 2025 20:35:26 +0000
Subject: [PATCH 2/2] Enhance appointment detail and user dashboard components
to conditionally render meeting links and buttons based on availability.
Update appointment stats fetching logic to remove email dependency, improving
user experience and API interaction. Refactor UI elements for better
accessibility and clarity.
---
app/(admin)/admin/booking/[id]/page.tsx | 55 +-
app/(user)/user/appointments/[id]/page.tsx | 623 +++++++++++++++++++++
app/(user)/user/dashboard/page.tsx | 97 ++--
hooks/useAppointments.ts | 12 +-
lib/actions/appointments.ts | 29 +-
5 files changed, 714 insertions(+), 102 deletions(-)
create mode 100644 app/(user)/user/appointments/[id]/page.tsx
diff --git a/app/(admin)/admin/booking/[id]/page.tsx b/app/(admin)/admin/booking/[id]/page.tsx
index eda1ac4..c4c6d10 100644
--- a/app/(admin)/admin/booking/[id]/page.tsx
+++ b/app/(admin)/admin/booking/[id]/page.tsx
@@ -541,9 +541,10 @@ export default function AppointmentDetailPage() {
{appointment.jitsi_room_id}
@@ -555,22 +556,38 @@ export default function AppointmentDetailPage() {
Meeting Link
{appointment.can_join_meeting !== undefined && (
diff --git a/app/(user)/user/appointments/[id]/page.tsx b/app/(user)/user/appointments/[id]/page.tsx
new file mode 100644
index 0000000..9334a01
--- /dev/null
+++ b/app/(user)/user/appointments/[id]/page.tsx
@@ -0,0 +1,623 @@
+"use client";
+
+import { useState, useEffect } from "react";
+import { useParams, useRouter } from "next/navigation";
+import {
+ Calendar,
+ Clock,
+ User,
+ Video,
+ CalendarCheck,
+ Loader2,
+ ArrowLeft,
+ Mail,
+ Phone as PhoneIcon,
+ MessageSquare,
+ CheckCircle2,
+ ExternalLink,
+ Copy,
+} from "lucide-react";
+import { useAppTheme } from "@/components/ThemeProvider";
+import { getAppointmentDetail } from "@/lib/actions/appointments";
+import { Button } from "@/components/ui/button";
+import { Navbar } from "@/components/Navbar";
+import { toast } from "sonner";
+import type { Appointment } from "@/lib/models/appointments";
+
+export default function UserAppointmentDetailPage() {
+ const params = useParams();
+ const router = useRouter();
+ const appointmentId = params.id as string;
+
+ const [appointment, setAppointment] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const { theme } = useAppTheme();
+ const isDark = theme === "dark";
+
+ useEffect(() => {
+ const fetchAppointment = async () => {
+ if (!appointmentId) return;
+
+ setLoading(true);
+ try {
+ const data = await getAppointmentDetail(appointmentId);
+ setAppointment(data);
+ } catch (error) {
+ toast.error("Failed to load appointment details");
+ router.push("/user/dashboard");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchAppointment();
+ }, [appointmentId, router]);
+
+ const formatDate = (dateString: string) => {
+ const date = new Date(dateString);
+ return date.toLocaleDateString("en-US", {
+ weekday: "long",
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ });
+ };
+
+ const formatTime = (dateString: string) => {
+ const date = new Date(dateString);
+ return date.toLocaleTimeString("en-US", {
+ hour: "numeric",
+ minute: "2-digit",
+ hour12: true,
+ });
+ };
+
+ const formatShortDate = (dateString: string) => {
+ const date = new Date(dateString);
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ });
+ };
+
+ const getStatusColor = (status: string) => {
+ const normalized = status.toLowerCase();
+ if (isDark) {
+ switch (normalized) {
+ case "scheduled":
+ return "bg-blue-500/20 text-blue-300 border-blue-500/30";
+ case "completed":
+ return "bg-green-500/20 text-green-300 border-green-500/30";
+ case "rejected":
+ case "cancelled":
+ return "bg-red-500/20 text-red-300 border-red-500/30";
+ case "pending_review":
+ case "pending":
+ return "bg-yellow-500/20 text-yellow-300 border-yellow-500/30";
+ default:
+ return "bg-gray-700 text-gray-200 border-gray-600";
+ }
+ }
+ switch (normalized) {
+ case "scheduled":
+ return "bg-blue-50 text-blue-700 border-blue-200";
+ case "completed":
+ return "bg-green-50 text-green-700 border-green-200";
+ case "rejected":
+ case "cancelled":
+ return "bg-red-50 text-red-700 border-red-200";
+ case "pending_review":
+ case "pending":
+ return "bg-yellow-50 text-yellow-700 border-yellow-200";
+ default:
+ return "bg-gray-100 text-gray-700 border-gray-300";
+ }
+ };
+
+ const formatStatus = (status: string) => {
+ return status.replace("_", " ").replace(/\b\w/g, (l) => l.toUpperCase());
+ };
+
+ const copyToClipboard = (text: string, label: string) => {
+ navigator.clipboard.writeText(text);
+ toast.success(`${label} copied to clipboard`);
+ };
+
+ if (loading) {
+ return (
+
+
+
+
+
+
Loading appointment details...
+
+
+
+ );
+ }
+
+ if (!appointment) {
+ return (
+
+
+
+
+
Appointment not found
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {/* Page Header */}
+
+
+
+
+
+
+
+
+
+
+
+ Appointment Details
+
+
+ Request #{appointment.id.slice(0, 8)}
+
+
+
+
+
+
+
+ {appointment.status === "scheduled" && }
+ {formatStatus(appointment.status)}
+
+
+
+
+
+
+ {/* Main Content - Left Column (2/3) */}
+
+ {/* Appointment Information Card */}
+
+
+
+
+ Appointment Information
+
+
+
+
+
+
+ Full Name
+
+
+ {appointment.first_name} {appointment.last_name}
+
+
+
+
+
+ Email Address
+
+
+
+ {appointment.email}
+
+
+
+
+ {appointment.phone && (
+
+
+
+ Phone Number
+
+
+
+ {appointment.phone}
+
+
+
+
+ )}
+
+
+
+
+ {/* Scheduled Appointment Details */}
+ {appointment.scheduled_datetime && (
+
+
+
+
+ Scheduled Appointment
+
+
+
+
+
+
+
+
+
+ {formatDate(appointment.scheduled_datetime)}
+
+
+
+
+
+ {formatTime(appointment.scheduled_datetime)}
+
+
+ {appointment.scheduled_duration && (
+
+
•
+
+ {appointment.meeting_duration_display || `${appointment.scheduled_duration} minutes`}
+
+
+ )}
+
+
+
+
+
+ )}
+
+ {/* 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))) && (
+
+
+
+ Preferred Availability
+
+
+
+ {appointment.preferred_dates && (
+
+
+ Preferred Dates
+
+
+ {Array.isArray(appointment.preferred_dates) ? (
+ (appointment.preferred_dates as string[]).map((date, idx) => (
+
+ {formatShortDate(date)}
+
+ ))
+ ) : (
+
+ {appointment.preferred_dates_display || appointment.preferred_dates || 'N/A'}
+
+ )}
+
+
+ )}
+ {appointment.preferred_time_slots && (
+
+
+ Preferred Time Slots
+
+
+ {Array.isArray(appointment.preferred_time_slots) ? (
+ (appointment.preferred_time_slots as string[]).map((slot, idx) => {
+ const timeSlotLabels: Record = {
+ morning: "Morning",
+ afternoon: "Lunchtime",
+ evening: "Evening",
+ };
+ const normalizedSlot = String(slot).toLowerCase().trim();
+ return (
+
+ {timeSlotLabels[normalizedSlot] || slot}
+
+ );
+ })
+ ) : (
+
+ {appointment.preferred_time_slots_display || appointment.preferred_time_slots || 'N/A'}
+
+ )}
+
+
+ )}
+
+
+ )}
+
+ {/* Matching Availability */}
+ {appointment.matching_availability && Array.isArray(appointment.matching_availability) && appointment.matching_availability.length > 0 && (
+
+
+
+
+ Matching Availability
+ {appointment.are_preferences_available !== undefined && (
+
+ {appointment.are_preferences_available ? "Available" : "Partially Available"}
+
+ )}
+
+
+
+
+ {appointment.matching_availability.map((match: any, idx: number) => (
+
+
+
+
+ {match.day_name || "Unknown Day"}
+
+
+ {formatShortDate(match.date || match.date_obj || "")}
+
+
+
+ {match.available_slots && Array.isArray(match.available_slots) && match.available_slots.length > 0 && (
+
+ {match.available_slots.map((slot: string, slotIdx: number) => {
+ const timeSlotLabels: Record = {
+ morning: "Morning",
+ afternoon: "Lunchtime",
+ evening: "Evening",
+ };
+ const normalizedSlot = String(slot).toLowerCase().trim();
+ return (
+
+ {timeSlotLabels[normalizedSlot] || slot}
+
+ );
+ })}
+
+ )}
+
+ ))}
+
+
+
+ )}
+
+ {/* Reason */}
+ {appointment.reason && (
+
+
+
+
+ Reason for Appointment
+
+
+
+
+ {appointment.reason}
+
+
+
+ )}
+
+ {/* Rejection Reason */}
+ {appointment.rejection_reason && (
+
+
+
+ Rejection Reason
+
+
+
+
+ {appointment.rejection_reason}
+
+
+
+ )}
+
+ {/* Meeting Information */}
+ {appointment.jitsi_meet_url && (
+
+
+
+
+ Video Meeting
+
+
+
+ {appointment.jitsi_room_id && (
+
+
+ Meeting Room ID
+
+
+
+ {appointment.jitsi_room_id}
+
+
+
+
+ )}
+
+ {appointment.can_join_meeting !== undefined && (
+
+
+
+ {appointment.can_join_meeting ? "Meeting is active - You can join now" : "Meeting is not available yet"}
+
+
+ )}
+
+
+ )}
+
+
+ {/* Sidebar - Right Column (1/3) */}
+
+ {/* Quick Info Card */}
+
+
+
+ Quick Info
+
+
+
+
+
+ Created
+
+
+ {formatShortDate(appointment.created_at)}
+
+
+ {formatTime(appointment.created_at)}
+
+
+
+
+ Status
+
+
+ {appointment.status === "scheduled" && }
+ {formatStatus(appointment.status)}
+
+
+
+
+
+ {/* Join Meeting Button */}
+ {appointment.status === "scheduled" && appointment.jitsi_meet_url && (
+
+
+ {appointment.can_join_meeting ? (
+
+
+ Join Meeting
+
+ ) : (
+
+ )}
+
+
+ )}
+
+
+
+
+ );
+}
+
diff --git a/app/(user)/user/dashboard/page.tsx b/app/(user)/user/dashboard/page.tsx
index b51c275..d5217b9 100644
--- a/app/(user)/user/dashboard/page.tsx
+++ b/app/(user)/user/dashboard/page.tsx
@@ -1,6 +1,7 @@
"use client";
import { useMemo, useEffect, useState } from "react";
+import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
Calendar,
@@ -22,11 +23,12 @@ import Link from "next/link";
import { Navbar } from "@/components/Navbar";
import { useAppTheme } from "@/components/ThemeProvider";
import { useAuth } from "@/hooks/useAuth";
-import { listAppointments, getUserAppointmentStats } from "@/lib/actions/appointments";
+import { getUserAppointments, getUserAppointmentStats } from "@/lib/actions/appointments";
import type { Appointment, UserAppointmentStats } from "@/lib/models/appointments";
import { toast } from "sonner";
export default function UserDashboard() {
+ const router = useRouter();
const { theme } = useAppTheme();
const isDark = theme === "dark";
const { user } = useAuth();
@@ -35,12 +37,12 @@ export default function UserDashboard() {
const [stats, setStats] = useState(null);
const [loadingStats, setLoadingStats] = useState(true);
- // Fetch appointments using the same endpoint as admin booking table
+ // Fetch user appointments from user-specific endpoint
useEffect(() => {
const fetchAppointments = async () => {
setLoading(true);
try {
- const data = await listAppointments();
+ const data = await getUserAppointments();
setAppointments(data || []);
} catch (error) {
toast.error("Failed to load appointments. Please try again.");
@@ -53,21 +55,15 @@ export default function UserDashboard() {
fetchAppointments();
}, []);
- // Fetch stats from API using user email
+ // Fetch stats from API for authenticated user
useEffect(() => {
const fetchStats = async () => {
- if (!user?.email) {
- setLoadingStats(false);
- return;
- }
-
setLoadingStats(true);
try {
- const statsData = await getUserAppointmentStats(user.email);
+ const statsData = await getUserAppointmentStats();
setStats(statsData);
} catch (error) {
toast.error("Failed to load appointment statistics.");
- // Set default stats on error
setStats({
total_requests: 0,
pending_review: 0,
@@ -75,7 +71,6 @@ export default function UserDashboard() {
rejected: 0,
completed: 0,
completion_rate: 0,
- email: user.email,
});
} finally {
setLoadingStats(false);
@@ -83,7 +78,7 @@ export default function UserDashboard() {
};
fetchStats();
- }, [user?.email]);
+ }, []);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
@@ -221,17 +216,17 @@ export default function UserDashboard() {
<>
{/* Stats Grid */}
-
-
-
-
-
+
+
+
+
+
-
+
{displayStats.scheduled > 0 ? `+${displayStats.scheduled}` : "0"}
@@ -256,7 +251,7 @@ export default function UserDashboard() {
-
+
{displayStats.completed > 0 ? `+${displayStats.completed}` : "0"}
@@ -268,8 +263,8 @@ export default function UserDashboard() {
{displayStats.completed}
vs last month
-
-
+
+
{`${Math.round(displayStats.completion_rate || 0)}%`}
-
-
+
+
Total Appointments
-
-
+
+
{displayStats.total_requests}
-
-
vs last month
-
+
+
vs last month
+
All Appointments
-
+
@@ -387,8 +382,9 @@ export default function UserDashboard() {
return (
router.push(`/user/appointments/${appointment.id}`)}
>
|
@@ -404,9 +400,9 @@ export default function UserDashboard() {
{appointment.reason}
)}
- {appointment.scheduled_datetime && (
+ {appointment.scheduled_datetime && (
- {formatDate(appointment.scheduled_datetime)}
+ {formatDate(appointment.scheduled_datetime)}
)}
@@ -420,9 +416,9 @@ export default function UserDashboard() {
- {formatTime(appointment.scheduled_datetime)}
-
- >
+ {formatTime(appointment.scheduled_datetime)}
+
+ >
) : (
Not scheduled
@@ -451,8 +447,8 @@ export default function UserDashboard() {
))}
{appointment.selected_slots.length > 2 && (
+{appointment.selected_slots.length - 2} more
- )}
-
+ )}
+
) : (
"-"
)}
@@ -463,10 +459,10 @@ export default function UserDashboard() {
|
+ >
+
+
+ )}
+
|
);
diff --git a/hooks/useAppointments.ts b/hooks/useAppointments.ts
index 0ab8800..728f586 100644
--- a/hooks/useAppointments.ts
+++ b/hooks/useAppointments.ts
@@ -138,17 +138,11 @@ export function useAppointments(options?: {
staleTime: 1 * 60 * 1000, // 1 minute
});
- // Get user appointment stats query - disabled because it requires email parameter
- // Use getUserAppointmentStats(email) directly where email is available
const userAppointmentStatsQuery = useQuery({
queryKey: ["appointments", "user", "stats"],
- queryFn: async () => {
- // This query is disabled - getUserAppointmentStats requires email parameter
- // Use getUserAppointmentStats(email) directly in components where email is available
- return {} as UserAppointmentStats;
- },
- enabled: false, // Disabled - requires email parameter which hook doesn't have access to
- staleTime: 1 * 60 * 1000, // 1 minute
+ queryFn: () => getUserAppointmentStats(),
+ enabled: enableStats,
+ staleTime: 1 * 60 * 1000,
});
// Get Jitsi meeting info query
diff --git a/lib/actions/appointments.ts b/lib/actions/appointments.ts
index ba86a38..4fde1ae 100644
--- a/lib/actions/appointments.ts
+++ b/lib/actions/appointments.ts
@@ -624,45 +624,26 @@ export async function getAppointmentStats(): Promise {
return data;
}
-export async function getUserAppointmentStats(email: string): Promise {
+export async function getUserAppointmentStats(): Promise {
const tokens = getStoredTokens();
if (!tokens.access) {
throw new Error("Authentication required.");
}
- if (!email) {
- throw new Error("Email is required to fetch user appointment stats.");
- }
-
const response = await fetch(API_ENDPOINTS.meetings.userAppointmentStats, {
- method: "POST",
+ method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${tokens.access}`,
},
- body: JSON.stringify({ email }),
});
- const responseText = await response.text();
-
+ const data = await parseResponse(response);
if (!response.ok) {
- let errorData: any;
- try {
- errorData = JSON.parse(responseText);
- } catch {
- throw new Error(`Server error (${response.status}): ${response.statusText || 'Internal Server Error'}`);
- }
- throw new Error(extractErrorMessage(errorData as unknown as ApiError));
+ throw new Error(extractErrorMessage(data as unknown as ApiError));
}
- try {
- if (!responseText || responseText.trim().length === 0) {
- throw new Error("Empty response from server");
- }
- return JSON.parse(responseText);
- } catch {
- throw new Error("Failed to parse response: Invalid JSON format");
- }
+ return data;
}
export async function getJitsiMeetingInfo(id: string): Promise {