// src/lib/api/bookingApi.ts // Booking & Availability API for Student Dashboard import { getApiBaseUrl } from './config'; const BASE_URL = getApiBaseUrl(); const TOKEN_KEY = 'authToken'; // ─── Custom Error Class ───────────────────────────────────────── // واجهة للمراجعة export interface SheikhReview { comment: string; date: string; rate: number; studentName: string; } /** * GET /api/sheikhs/{sheikhId}/Top-Reviews * جلب أحدث تقييمات الشيخ */ export async function fetchSheikhTopReviews(sheikhId: string | number): Promise { const id = typeof sheikhId === 'string' ? parseInt(sheikhId, 10) : sheikhId; try { const result = await request(`/api/sheikhs/${id}/Top-Reviews`); return Array.isArray(result) ? result : []; } catch (error) { console.error('Failed to fetch reviews:', error); return []; } } export class BookingApiError extends Error { public readonly status: number; public readonly path: string; constructor(status: number, message: string, path: string) { super(`[${status}] ${path} → ${message}`); this.status = status; this.path = path; this.name = 'BookingApiError'; } } // ─── Helper Functions ───────────────────────────────────────── function getHeaders(): HeadersInit { const token = localStorage.getItem(TOKEN_KEY) ?? ''; return { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), }; } /** * Enhanced request function with silent handling of 403 errors * Returns empty array for list endpoints when 403 occurs */ async function request(path: string, options?: RequestInit): Promise { const url = `${BASE_URL}${path}`; // Check token expiration first const token = localStorage.getItem(TOKEN_KEY); if (token) { try { const payload = JSON.parse(atob(token.split('.')[1])); if (payload?.exp && payload.exp * 1000 < Date.now()) { localStorage.removeItem(TOKEN_KEY); throw new BookingApiError(401, 'Session expired — please log in again', path); } } catch (e) { if (e instanceof BookingApiError) throw e; localStorage.removeItem(TOKEN_KEY); } } // Special handling for availability endpoints - suppress 403 errors completely if (path.includes('/available-dates') || path.includes('/available-slots')) { try { const res = await fetch(url, { ...options, headers: { ...getHeaders(), ...options?.headers }, }); if (!res.ok) { if (res.status === 403) { // Return empty array silently - no error logging return [] as unknown as T; } const message = await res.text().catch(() => res.statusText); throw new BookingApiError(res.status, message, path); } const text = await res.text(); if (!text) return [] as unknown as T; try { const result = JSON.parse(text) as T; return result; } catch { return text as unknown as T; } } catch (error) { // Network errors or other issues - return empty array silently if (error instanceof BookingApiError) throw error; return [] as unknown as T; } } // Regular handling for other endpoints try { const res = await fetch(url, { ...options, headers: { ...getHeaders(), ...options?.headers }, }); if (!res.ok) { const message = await res.text().catch(() => res.statusText); throw new BookingApiError(res.status, message, path); } const text = await res.text(); if (!text) return {} as T; try { return JSON.parse(text) as T; } catch { return text as unknown as T; } } catch (error) { if (error instanceof BookingApiError) throw error; throw new Error(`Network error while calling ${path}`); } } // ─── Types ──────────────────────────────────────────────────── export type DayOfWeek = | 'SATURDAY' | 'SUNDAY' | 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | 'THURSDAY' | 'FRIDAY'; export type SessionStatus = | 'REQUESTED' | 'SCHEDULED' | 'TOPAY' | 'ON_GOING' | 'CANCELLED' | 'COMPLETED' | 'MISSED'; export interface AvailabilitySlotDto { id: number; dayOfWeek: DayOfWeek; startTime: string; // "HH:mm" endTime: string; // "HH:mm" } export interface SetAvailabilityRequest { slots: Omit[]; } export interface AvailableSlotDto { date: string; // "2026-03-10" startTime: string; // "09:00:00" endTime: string; // "10:00:00" startDateTime: string; // "2026-03-10T09:00:00" endDateTime: string; // "2026-03-10T10:00:00" } export interface BookSessionRequest { sheikhId: number; startDateTime: string; // ISO string sessionDescription: string; } export interface SessionDto { sessionId: number; sheikhId: number; sheikhName: string; studentId: number; studentName: string; scheduledStart: string; // backend field name scheduledStartDate: string; scheduledStartTime: string; actualStart: string | null; actualEnd: string | null; sessionStatus: SessionStatus; // backend field name sessionDescription: string; sessionNotes: string | null; meetingLink?: string; price: number; paymentStatus: string; createdAt: string; duration: number; } export interface StudentSessionDto extends SessionDto { // Same as SessionDto, just for student view } export interface SessionReviewBody { rating: number; notes: string; } // ─── 1. Sheikh Availability APIs ────────────────────────────── /** * GET /api/sheikh/availability * Get sheikh's weekly availability template */ export async function fetchMyAvailability(): Promise { try { return await request('/api/sheikh/availability'); } catch { return []; } } /** * PUT /api/sheikh/availability * Set sheikh's weekly availability template */ export async function setMyAvailability(slots: Omit[]): Promise { return request('/api/sheikh/availability', { method: 'PUT', body: JSON.stringify({ slots }), }); } // ─── 2. Student Availability APIs ───────────────────────────── /** * GET /api/sheikhs/{sheikhId}/available-dates * Get available dates for a sheikh - with silent 403 handling */ export async function fetchAvailableDates( sheikhId: string | number, from?: string, days: number = 30 ): Promise { // Convert sheikhId to number if it's a string const id = typeof sheikhId === 'string' ? parseInt(sheikhId, 10) : sheikhId; const params = new URLSearchParams(); if (from) params.append('from', from); params.append('days', days.toString()); const path = `/api/sheikhs/${id}/available-dates?${params.toString()}`; try { const result = await request(path); return Array.isArray(result) ? result : []; } catch { return []; } } /** * GET /api/sheikhs/{sheikhId}/available-slots/day * Get available slots for a specific day - with silent 403 handling */ export async function fetchAvailableSlotsForDay( sheikhId: string | number, date: string ): Promise { // Convert sheikhId to number if it's a string const id = typeof sheikhId === 'string' ? parseInt(sheikhId, 10) : sheikhId; const path = `/api/sheikhs/${id}/available-slots/day?date=${date}`; try { const result = await request(path); return Array.isArray(result) ? result : []; } catch { return []; } } /** * GET /api/sheikhs/{sheikhId}/available-slots * Get available slots for a date range - with silent 403 handling */ export async function fetchAvailableSlotsRange( sheikhId: string | number, from: string, days: number = 30 ): Promise { // Convert sheikhId to number if it's a string const id = typeof sheikhId === 'string' ? parseInt(sheikhId, 10) : sheikhId; const params = new URLSearchParams(); params.append('from', from); params.append('days', days.toString()); const path = `/api/sheikhs/${id}/available-slots?${params.toString()}`; try { const result = await request(path); return Array.isArray(result) ? result : []; } catch { return []; } } // ─── 3. Student Booking APIs ────────────────────────────────── /** * POST /api/student/sessions * Book a session (creates REQUESTED session) */ export async function bookSession(requestData: BookSessionRequest): Promise { // Ensure sheikhId is a number const body = { ...requestData, sheikhId: typeof requestData.sheikhId === 'string' ? parseInt(requestData.sheikhId, 10) : requestData.sheikhId }; try { return await request('/api/student/sessions', { method: 'POST', body: JSON.stringify(body), }); } catch (error) { if (error instanceof BookingApiError) { throw error; // Re-throw for the UI to handle } return null; } } export interface SessionReviewDto { sessionId: number; sessionDescription: string; scheduledStart: string; scheduledEnd: string; price: number; studentName: string; sessionNotes: string | null; studentReview: string | null; studentRate: number | null; reviewedAt: string | null; } /** * Sheikh: GET /api/reviews/sheikh/session/{sessionId}/details * Student: GET /api/reviews/student/session/{sessionId}/details * Both return the same SessionDetailsDto shape. */ export async function fetchSessionReview(sessionId: number, role?: string): Promise { try { const path = role === 'sheikh' ? `/api/reviews/sheikh/session/${sessionId}/details` : `/api/reviews/student/session/${sessionId}/details`; return await request(path); } catch { return null; } } export async function submitSessionReview(sessionId: number, review: SessionReviewBody): Promise { try { await request(`/api/reviews/session/${sessionId}`, { method: 'POST', body: JSON.stringify({ reviews: review.notes, rate: review.rating, }), }); } catch (error) { if (error instanceof BookingApiError) { throw error; } throw new Error('Review submission failed'); } } /** * GET /api/student/sessions * Get student's sessions (optionally filtered by status) */ export async function fetchMySessions(status?: SessionStatus): Promise { const query = status ? `?sessionStatus=${status}` : ''; try { const result = await request(`/api/student/sessions${query}`); return Array.isArray(result) ? result : []; } catch { return []; } } // ─── 4. Sheikh Session Management APIs ──────────────────────── /** * GET /api/sheikh/sessions * Get sheikh's sessions (optionally filtered by status) */ export async function fetchSheikhSessions(status?: SessionStatus): Promise { const query = status ? `?sessionStatus=${status}` : ''; try { const result = await request(`/api/sheikh/sessions${query}`); return Array.isArray(result) ? result : []; } catch { return []; } } /** * POST /api/sheikh/sessions/{sessionId}/accept * Accept a session request */ export async function acceptSessionRequest(sessionId: number): Promise { try { return await request(`/api/sheikh/sessions/${sessionId}/accept`, { method: 'POST', }); } catch { return null; } } /** * POST /api/sheikh/sessions/{sessionId}/reject * Reject a session request */ export async function rejectSessionRequest(sessionId: number): Promise { try { return await request(`/api/sheikh/sessions/${sessionId}/reject`, { method: 'POST', }); } catch { return null; } } // ─── 5. Helper Functions ────────────────────────────────────── /** * Format date to YYYY-MM-DD */ export function formatDateToISO(date: Date): string { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${d}`; } /** * Check if a date is available (has at least one slot) */ export function isDateAvailable(availableDates: string[], date: Date): boolean { const dateStr = formatDateToISO(date); return availableDates.includes(dateStr); } /** * Group available slots by time for UI display */ export function groupSlotsByTime(slots: AvailableSlotDto[]): Record { return slots.reduce((acc, slot) => { const timeKey = slot.startTime.substring(0, 5); // "09:00" if (!acc[timeKey]) { acc[timeKey] = []; } acc[timeKey].push(slot); return acc; }, {} as Record); } /** * Convert time string to 12-hour format with AM/PM * مثال: "09:00:00" -> "9:00 AM" */ export function formatTimeTo12Hour(time: string): string { if (!time) return ''; // Extract hours and minutes from time string (HH:MM:SS or HH:MM) const timeMatch = time.match(/(\d{1,2}):(\d{2})/); if (!timeMatch) return time; const hours = parseInt(timeMatch[1], 10); const minutes = timeMatch[2]; const period = hours >= 12 ? 'PM' : 'AM'; const hour12 = hours % 12 || 12; return `${hour12}:${minutes} ${period}`; } /** * Get day name in Arabic/English */ export function getDayName(date: Date, language: 'ar' | 'en'): string { const days = { en: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], ar: ['الأحد', 'الإثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعة', 'السبت'] }; return days[language][date.getDay()]; } /** * Get month name in Arabic/English */ export function getMonthName(date: Date, language: 'ar' | 'en'): string { const months = { en: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'], ar: ['يناير', 'فبراير', 'مارس', 'إبريل', 'مايو', 'يونيو', 'يوليو', 'أغسطس', 'سبتمبر', 'أكتوبر', 'نوفمبر', 'ديسمبر'] }; return months[language][date.getMonth()]; } /** * Check if user is authenticated */ export function isAuthenticated(): boolean { return !!localStorage.getItem(TOKEN_KEY); } /** * Get auth token */ export function getToken(): string | null { return localStorage.getItem(TOKEN_KEY); } // ─── Default Export ─────────────────────────────────────────── const bookingApi = { fetchMyAvailability, setMyAvailability, fetchAvailableDates, fetchAvailableSlotsForDay, fetchAvailableSlotsRange, bookSession, fetchSessionReview, submitSessionReview, fetchMySessions, fetchSheikhSessions, acceptSessionRequest, rejectSessionRequest, formatDateToISO, isDateAvailable, groupSlotsByTime, formatTimeTo12Hour, getDayName, getMonthName, isAuthenticated, getToken, BookingApiError, }; export default bookingApi;