Spaces:
Sleeping
Sleeping
| // 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<SheikhReview[]> { | |
| const id = typeof sheikhId === 'string' ? parseInt(sheikhId, 10) : sheikhId; | |
| try { | |
| const result = await request<SheikhReview[]>(`/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<T>(path: string, options?: RequestInit): Promise<T> { | |
| 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<AvailabilitySlotDto, 'id'>[]; | |
| } | |
| 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<AvailabilitySlotDto[]> { | |
| try { | |
| return await request<AvailabilitySlotDto[]>('/api/sheikh/availability'); | |
| } catch { | |
| return []; | |
| } | |
| } | |
| /** | |
| * PUT /api/sheikh/availability | |
| * Set sheikh's weekly availability template | |
| */ | |
| export async function setMyAvailability(slots: Omit<AvailabilitySlotDto, 'id'>[]): Promise<void> { | |
| return request<void>('/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<string[]> { | |
| // 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<string[]>(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<AvailableSlotDto[]> { | |
| // 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<AvailableSlotDto[]>(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<AvailableSlotDto[]> { | |
| // 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<AvailableSlotDto[]>(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<StudentSessionDto | null> { | |
| // Ensure sheikhId is a number | |
| const body = { | |
| ...requestData, | |
| sheikhId: typeof requestData.sheikhId === 'string' ? parseInt(requestData.sheikhId, 10) : requestData.sheikhId | |
| }; | |
| try { | |
| return await request<StudentSessionDto>('/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<SessionReviewDto | null> { | |
| try { | |
| const path = role === 'sheikh' | |
| ? `/api/reviews/sheikh/session/${sessionId}/details` | |
| : `/api/reviews/student/session/${sessionId}/details`; | |
| return await request<SessionReviewDto>(path); | |
| } catch { | |
| return null; | |
| } | |
| } | |
| export async function submitSessionReview(sessionId: number, review: SessionReviewBody): Promise<void> { | |
| try { | |
| await request<void>(`/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<StudentSessionDto[]> { | |
| const query = status ? `?sessionStatus=${status}` : ''; | |
| try { | |
| const result = await request<StudentSessionDto[]>(`/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<SessionDto[]> { | |
| const query = status ? `?sessionStatus=${status}` : ''; | |
| try { | |
| const result = await request<SessionDto[]>(`/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<string | null> { | |
| try { | |
| return await request<string>(`/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<string | null> { | |
| try { | |
| return await request<string>(`/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<string, AvailableSlotDto[]> { | |
| 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<string, AvailableSlotDto[]>); | |
| } | |
| /** | |
| * 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; |