Quran_Tech_Server / F_Pro /src /lib /api /bookingApi.ts
aboalaa147's picture
Initial deployment
eb6a2f9
Raw
History Blame Contribute Delete
15.8 kB
// 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;