import type { Room, Booking, HousekeepingTask, Channel, ChannelName, Hotel, User } from '../types' // ── Base URL ──────────────────────────────────────────────────────────────── const BASE = (import.meta.env.VITE_API_URL as string | undefined) ?? 'https://api.hotelsync.ru' // ── Errors ────────────────────────────────────────────────────────────────── export class ApiError extends Error { constructor(public status: number, message: string) { super(message) } } // ── Token helpers ──────────────────────────────────────────────────────────── function getToken(): string | null { try { const s = localStorage.getItem('hotelsync-session') return s ? (JSON.parse(s) as { token: string }).token : null } catch { return null } } function saveToken(token: string) { try { const s = localStorage.getItem('hotelsync-session') if (!s) return const parsed = JSON.parse(s) as Record parsed.token = token localStorage.setItem('hotelsync-session', JSON.stringify(parsed)) // Notify AuthContext so React state stays in sync (WS reconnects with fresh token) window.dispatchEvent(new CustomEvent('hotelsync:token-updated', { detail: { token } })) } catch { // ignore } } // ── Snake ↔ camelCase transform ────────────────────────────────────────────── function toCamel(s: string): string { return s.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()) } function transformKeys(val: unknown): unknown { if (Array.isArray(val)) return val.map(transformKeys) if (val && typeof val === 'object' && !(val instanceof Date)) { const result: Record = {} for (const [k, v] of Object.entries(val as Record)) { result[toCamel(k)] = transformKeys(v) } return result } return val } // ── Core request ───────────────────────────────────────────────────────────── async function req( method: string, path: string, body?: unknown, ): Promise { const headers: Record = {} if (body !== undefined) headers['Content-Type'] = 'application/json' const token = getToken() if (token) headers['Authorization'] = `Bearer ${token}` const doFetch = (t: string | null) => fetch(`${BASE}${path}`, { method, headers: t ? { ...headers, Authorization: `Bearer ${t}` } : headers, credentials: 'include', body: body !== undefined ? JSON.stringify(body) : undefined, }) let res = await doFetch(token) // Auto-refresh on 401 if (res.status === 401) { const refreshRes = await fetch(`${BASE}/api/auth/refresh`, { method: 'POST', credentials: 'include', }) if (refreshRes.ok) { const { access_token } = (await refreshRes.json()) as { access_token: string } saveToken(access_token) res = await doFetch(access_token) } else { localStorage.removeItem('hotelsync-session') window.location.href = '/login' throw new ApiError(401, 'Session expired') } } if (res.status === 204) return undefined as T const data: unknown = await res.json() if (!res.ok) { const msg = (data as Record)?.error ?? 'Request failed' throw new ApiError(res.status, msg) } return transformKeys(data) as T } // ── Channel normalization ──────────────────────────────────────────────────── const CHANNEL_DISPLAY_NAMES: Record = { booking_com: 'Booking.com', airbnb: 'Airbnb', expedia: 'Expedia', vrbo: 'VRBO', yandex_travel: 'Яндекс Путешествия', ostrovok: 'Островок', sutochno: 'Суточно.ру', onetwotrip: 'OneTwoTrip', } function normalizeChannel(raw: Record): Channel { const name = raw.name as ChannelName return { id: raw.id as string, hotelId: (raw.hotelId as string) ?? '', name, displayName: CHANNEL_DISPLAY_NAMES[name as string] ?? String(name), isEnabled: Boolean(raw.enabled), lastSyncAt: (raw.lastSyncedAt as string | null) ?? null, lastSyncStatus: raw.lastSyncedAt ? 'success' : 'idle', bookingsImported: 0, mappings: [], } } // ── API methods ────────────────────────────────────────────────────────────── export const api = { // ── Auth ───────────────────────────────────────────────────────────────── auth: { login: (email: string, password: string) => req<{ access_token: string; user: User }>('POST', '/api/auth/login', { email, password }), logout: () => req('POST', '/api/auth/logout'), me: () => req('GET', '/api/auth/me'), register: (data: { hotelName: string address?: string contact: string email: string phone?: string password: string }) => req<{ ok: boolean; message: string }>('POST', '/api/auth/register', data), forgotPassword: (email: string) => req<{ ok: boolean }>('POST', '/api/auth/forgot-password', { email }), resetPassword: (token: string, password: string) => req<{ ok: boolean }>('POST', '/api/auth/reset-password', { token, password }), resendConfirmation: (email: string) => req<{ ok: boolean }>('POST', '/api/auth/resend-confirmation', { email }), }, // ── Rooms ───────────────────────────────────────────────────────────────── rooms: { list: (slug: string) => req('GET', `/api/hotels/${slug}/rooms`), create: (slug: string, data: RoomPayload) => req('POST', `/api/hotels/${slug}/rooms`, toRoomPayload(data)), update: (slug: string, id: string, data: Partial) => req('PATCH', `/api/hotels/${slug}/rooms/${id}`, toRoomPayload(data)), delete: (slug: string, id: string) => req('DELETE', `/api/hotels/${slug}/rooms/${id}`), }, // ── Bookings ────────────────────────────────────────────────────────────── bookings: { list: (slug: string, params?: { start?: string; end?: string; status?: string }) => { const qs = new URLSearchParams() if (params?.start) qs.set('start', params.start) if (params?.end) qs.set('end', params.end) if (params?.status) qs.set('status', params.status) const q = qs.toString() return req('GET', `/api/hotels/${slug}/bookings${q ? `?${q}` : ''}`) }, create: (slug: string, data: BookingPayload) => req('POST', `/api/hotels/${slug}/bookings`, toBookingPayload(data)), update: (slug: string, id: string, data: Partial) => req('PATCH', `/api/hotels/${slug}/bookings/${id}`, toBookingPayload(data)), delete: (slug: string, id: string) => req('DELETE', `/api/hotels/${slug}/bookings/${id}`), }, // ── Housekeeping ────────────────────────────────────────────────────────── housekeeping: { list: (slug: string, params?: { date?: string; status?: string }) => { const qs = new URLSearchParams() if (params?.date) qs.set('date', params.date) if (params?.status) qs.set('status', params.status) const q = qs.toString() return req('GET', `/api/hotels/${slug}/housekeeping${q ? `?${q}` : ''}`) }, create: (slug: string, data: HkPayload) => req('POST', `/api/hotels/${slug}/housekeeping`, data), update: (slug: string, id: string, data: Partial) => req('PATCH', `/api/hotels/${slug}/housekeeping/${id}`, data), delete: (slug: string, id: string) => req('DELETE', `/api/hotels/${slug}/housekeeping/${id}`), }, // ── Channels ────────────────────────────────────────────────────────────── channels: { list: async (slug: string): Promise => { const raw = await req[]>('GET', `/api/hotels/${slug}/channels`) return raw.map(normalizeChannel) }, update: async (slug: string, id: string, data: { enabled?: boolean; api_key?: string }): Promise => { const raw = await req>('PATCH', `/api/hotels/${slug}/channels/${id}`, data) return normalizeChannel(raw) }, sync: async (slug: string, id: string): Promise<{ channel: Channel; syncedBookings: number }> => { const raw = await req>('POST', `/api/hotels/${slug}/channels/${id}/sync`) return { channel: normalizeChannel(raw), syncedBookings: (raw.syncedBookings as number) ?? 0 } }, }, // ── Users ───────────────────────────────────────────────────────────────── users: { list: (slug: string) => req('GET', `/api/hotels/${slug}/users`), create: (slug: string, data: { email: string; password: string; name: string; role: string }) => req('POST', `/api/hotels/${slug}/users`, data), update: (slug: string, id: string, data: Partial<{ name: string; email: string; password: string }>) => req('PATCH', `/api/hotels/${slug}/users/${id}`, data), delete: (slug: string, id: string) => req('DELETE', `/api/hotels/${slug}/users/${id}`), }, // ── Hotels ──────────────────────────────────────────────────────────────── hotels: { get: (slug: string) => req('GET', `/api/hotels/${slug}`), update: (slug: string, data: HotelPayload) => req('PATCH', `/api/hotels/${slug}`, toHotelPayload(data)), }, // ── Booking Guests ──────────────────────────────────────────────────────── bookingGuests: { list: (slug: string, bookingId: string) => req('GET', `/api/hotels/${slug}/bookings/${bookingId}/guests`), add: (slug: string, bookingId: string, data: BookingGuestPayload) => req('POST', `/api/hotels/${slug}/bookings/${bookingId}/guests`, data), update: (slug: string, bookingId: string, id: string, data: BookingGuestPayload) => req('PATCH', `/api/hotels/${slug}/bookings/${bookingId}/guests/${id}`, data), remove: (slug: string, bookingId: string, id: string) => req('DELETE', `/api/hotels/${slug}/bookings/${bookingId}/guests/${id}`), }, // ── Hotel Settings ──────────────────────────────────────────────────────── hotelSettings: { get: (slug: string) => req('GET', `/api/hotels/${slug}/hotel-settings`), update: (slug: string, data: Partial) => req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/hotel-settings`, data), }, // ── Guests ──────────────────────────────────────────────────────────────── guests: { list: (slug: string, q?: string, passport?: string) => { const params = passport ? `?passport=${encodeURIComponent(passport)}` : q ? `?q=${encodeURIComponent(q)}` : '' return req('GET', `/api/hotels/${slug}/guests${params}`) }, get: (slug: string, id: string) => req('GET', `/api/hotels/${slug}/guests/${id}`), create: (slug: string, data: GuestPayload) => req('POST', `/api/hotels/${slug}/guests`, data), update: (slug: string, id: string, data: Partial) => req('PATCH', `/api/hotels/${slug}/guests/${id}`, data), delete: (slug: string, id: string) => req('DELETE', `/api/hotels/${slug}/guests/${id}`), }, // ── Rental ──────────────────────────────────────────────────────────────── rental: { listObjects: (slug: string) => req('GET', `/api/hotels/${slug}/rental-objects`), createObject: (slug: string, data: RentalObjectPayload) => req('POST', `/api/hotels/${slug}/rental-objects`, data), updateObject: (slug: string, id: string, data: Partial) => req('PATCH', `/api/hotels/${slug}/rental-objects/${id}`, data), deleteObject: (slug: string, id: string) => req('DELETE', `/api/hotels/${slug}/rental-objects/${id}`), listBookings: (slug: string, from?: string, to?: string) => { const qs = new URLSearchParams() if (from) qs.set('from', from) if (to) qs.set('to', to) const q = qs.toString() return req('GET', `/api/hotels/${slug}/rental-bookings${q ? `?${q}` : ''}`) }, createBooking: (slug: string, data: RentalBookingPayload) => req('POST', `/api/hotels/${slug}/rental-bookings`, data), updateBooking: (slug: string, id: string, data: Partial) => req('PATCH', `/api/hotels/${slug}/rental-bookings/${id}`, data), deleteBooking: (slug: string, id: string) => req('DELETE', `/api/hotels/${slug}/rental-bookings/${id}`), }, // ── File Upload ─────────────────────────────────────────────────────────── upload: { photo: async (file: File, folder: 'categories' | 'rooms' | 'hotels' | 'guests' = 'rooms'): Promise => { const token = localStorage.getItem('access_token') const fd = new FormData() fd.append('file', file) const res = await fetch(`${BASE}/api/upload?folder=${folder}`, { method: 'POST', headers: token ? { Authorization: `Bearer ${token}` } : {}, body: fd, }) if (!res.ok) throw new Error(`Upload failed: ${res.statusText}`) const data = await res.json() as { url: string } return data.url }, }, // ── Categories ──────────────────────────────────────────────────────────── categories: { list: (slug: string) => req('GET', `/api/hotels/${slug}/categories`), create: (slug: string, data: CategoryPayload) => req('POST', `/api/hotels/${slug}/categories`, data), update: (slug: string, id: string, data: Partial) => req('PATCH', `/api/hotels/${slug}/categories/${id}`, data), delete: (slug: string, id: string) => req('DELETE', `/api/hotels/${slug}/categories/${id}`), }, // ── Tariffs ─────────────────────────────────────────────────────────────── tariffs: { list: (slug: string) => req('GET', `/api/hotels/${slug}/tariffs`), create: (slug: string, data: TariffPayload) => req('POST', `/api/hotels/${slug}/tariffs`, data), update: (slug: string, id: string, data: Partial) => req('PATCH', `/api/hotels/${slug}/tariffs/${id}`, data), delete: (slug: string, id: string) => req('DELETE', `/api/hotels/${slug}/tariffs/${id}`), }, // ── NetUP IPTV ──────────────────────────────────────────────────────────── netup: { getSettings: (slug: string) => req<{ serverUrl: string; username: string; password: string; defaultLanguage: string; enabled: boolean; tlToken: string }>( 'GET', `/api/hotels/${slug}/netup/settings`), saveSettings: (slug: string, data: { server_url: string; username: string; password?: string; default_language: string; enabled: boolean; tl_token: string }) => req<{ ok: boolean }>('PATCH', `/api/hotels/${slug}/netup/settings`, data), testConnection: (slug: string) => req<{ ok: boolean; message: string }>('POST', `/api/hotels/${slug}/netup/test`), getRoomMappings: (slug: string) => req<{ id: string; number: string; type: string; netupRoomNumber: string }[]>( 'GET', `/api/hotels/${slug}/netup/rooms`), saveRoomMappings: (slug: string, mappings: { pms_room_id: string; netup_room_number: string }[]) => req<{ ok: boolean }>('POST', `/api/hotels/${slug}/netup/rooms`, { mappings }), sendMessage: (slug: string, roomId: string, message: string, guestName?: string) => req<{ ok: boolean }>('POST', `/api/hotels/${slug}/netup/message`, { room_id: roomId, message, guest_name: guestName }), getLog: (slug: string) => req<{ pushEvents: { ts: string; action: string; roomNumber: string; netupRoom: string; url: string; requestBody?: unknown; status: 'ok' | 'error' | 'skipped'; httpStatus?: number; responseBody?: string; error?: string }[] pullRequests: { ts: string; method: string; url: string; headers: Record; query: Record; body: unknown }[] }>('GET', `/api/hotels/${slug}/netup/log`), clearLog: (slug: string) => req<{ ok: boolean }>('DELETE', `/api/hotels/${slug}/netup/log`), testCheckin: (slug: string, roomId: string) => req<{ ok: boolean; message: string }>('POST', `/api/hotels/${slug}/netup/test-checkin`, { room_id: roomId }), }, } // ── Payload types & converters ─────────────────────────────────────────────── export interface RoomPayload { number?: string; type?: string; floor?: number maxGuests?: number; baseRate?: number; status?: string amenities?: string[]; name?: string; categoryId?: string bedType?: string; beds?: unknown; housekeepingStatus?: string sortOrder?: number; allowHourly?: boolean; hourlyRate?: number extraPlace?: unknown; childPolicy?: unknown description?: string; photos?: string[] earlyCheckinFee?: number | null; lateCheckoutFee?: number | null } function toRoomPayload(r: Partial): Record { const out: Record = {} if (r.number !== undefined) out.number = r.number if (r.type !== undefined) out.type = r.type if (r.floor !== undefined) out.floor = r.floor if (r.maxGuests !== undefined) out.max_guests = r.maxGuests if (r.baseRate !== undefined) out.base_rate = r.baseRate if (r.status !== undefined) out.status = r.status if (r.amenities !== undefined) out.amenities = r.amenities if (r.name !== undefined) out.name = r.name if (r.categoryId !== undefined) out.category_id = r.categoryId if (r.bedType !== undefined) out.bed_type = r.bedType if (r.beds !== undefined) out.beds = r.beds if (r.housekeepingStatus !== undefined) out.housekeeping_status = r.housekeepingStatus if (r.sortOrder !== undefined) out.sort_order = r.sortOrder if (r.allowHourly !== undefined) out.allow_hourly = r.allowHourly if (r.hourlyRate !== undefined) out.hourly_rate = r.hourlyRate if (r.extraPlace !== undefined) out.extra_place = r.extraPlace if (r.childPolicy !== undefined) out.child_policy = r.childPolicy if (r.description !== undefined) out.description = r.description if (r.photos !== undefined) out.photos = r.photos if (r.earlyCheckinFee !== undefined) out.early_checkin_fee = r.earlyCheckinFee if (r.lateCheckoutFee !== undefined) out.late_checkout_fee = r.lateCheckoutFee return out } export interface BookingPayload { roomId?: string; guestName?: string; guestEmail?: string; guestPhone?: string checkIn?: string; checkOut?: string; adults?: number; children?: number status?: string; source?: string; totalAmount?: number; paidAmount?: number; notes?: string } function toBookingPayload(b: Partial): Record { const out: Record = {} if (b.roomId !== undefined) out.room_id = b.roomId if (b.guestName !== undefined) out.guest_name = b.guestName if (b.guestEmail !== undefined) out.guest_email = b.guestEmail if (b.guestPhone !== undefined) out.guest_phone = b.guestPhone if (b.checkIn !== undefined) out.check_in = b.checkIn if (b.checkOut !== undefined) out.check_out = b.checkOut if (b.adults !== undefined) out.adults = b.adults if (b.children !== undefined) out.children = b.children if (b.status !== undefined) out.status = b.status if (b.source !== undefined) out.source = b.source if (b.totalAmount !== undefined) out.total_amount = b.totalAmount if (b.paidAmount !== undefined) out.paid_amount = b.paidAmount if (b.notes !== undefined) out.notes = b.notes return out } export interface HkPayload { room_id?: string; type?: string; priority?: string status?: string; assignee_id?: string; notes?: string; due_date?: string } export interface HotelPayload { name?: string; address?: string; phone?: string timezone?: string; currency?: string checkInTime?: string; checkOutTime?: string } export interface BookingGuest { id: string bookingId: string hotelId: string guestId: string | null firstName: string lastName: string middleName: string | null birthDate: string | null isChild: boolean isMain: boolean passportSeries: string | null passportNumber: string | null passportIssuedBy: string | null passportIssueDate: string | null nationality: string | null createdAt: string updatedAt: string } export interface BookingGuestPayload { first_name?: string last_name?: string middle_name?: string birth_date?: string is_child?: boolean is_main?: boolean passport_series?: string passport_number?: string passport_issued_by?: string passport_issue_date?: string nationality?: string } export interface HotelSettings { require_guest_docs?: boolean [key: string]: unknown } export interface GuestApiType { id: string hotelId: string firstName: string lastName: string middleName: string | null email: string | null phone: string | null passport: string | null passportSeries: string | null passportNumber: string | null passportIssuedBy: string | null passportIssueDate: string | null birthDate: string | null nationality: string | null gender: string | null city: string | null notes: string tags: string[] loyaltyTier: string loyaltyPoints: number rating: number totalStays: number totalSpent: number lastVisit: string | null createdAt: string updatedAt: string history?: { id: string checkIn: string checkOut: string status: string totalAmount: number | null paidAmount: number roomNumber: string | null roomType: string | null }[] } export interface GuestPayload { first_name?: string last_name?: string middle_name?: string email?: string phone?: string passport?: string passport_series?: string passport_number?: string birth_date?: string nationality?: string gender?: string city?: string notes?: string tags?: string[] rating?: number } export interface RentalObjectApi { id: string hotelId: string name: string icon: string color: string textColor: string pricePerHour: number pricePerDay: number openHour: number closeHour: number maxHoursPerSlot: number | null bufferMinutes: number sortOrder: number createdAt: string } export interface RentalBookingApi { id: string hotelId: string objectId: string date: string isFullDay: boolean startHour: number endHour: number guestName: string guestPhone: string linkedRoomId: string | null totalAmount: number paidAmount: number status: 'confirmed' | 'cancelled' notes: string | null createdAt: string } export interface RentalBookingPayload { object_id: string date: string is_full_day?: boolean start_hour?: number end_hour?: number guest_name: string guest_phone?: string linked_room_id?: string total_amount?: number paid_amount?: number status?: string notes?: string } export interface RentalObjectPayload { name: string icon?: string color?: string text_color?: string price_per_hour?: number price_per_day?: number open_hour?: number close_hour?: number max_hours_per_slot?: number | null buffer_minutes?: number sort_order?: number } export interface CategoryApi { id: string hotel_id: string name: string description: string color: string amenities: string[] photos: string[] sort_order: number created_at: string } export interface CategoryPayload { name: string description?: string color?: string amenities?: string[] photos?: string[] sort_order?: number } export interface TariffApi { id: string hotel_id: string name: string code: string meal_plan: string inclusions: string[] modifier_type: 'fixed' | 'percent' modifier_value: number min_nights: number cancellation_policy: 'flexible' | 'moderate' | 'strict' | 'nonrefundable' description: string is_active: boolean created_at: string } export interface TariffPayload { name: string code: string meal_plan?: string inclusions?: string[] modifier_type?: string modifier_value?: number min_nights?: number cancellation_policy?: string description?: string is_active?: boolean } function toHotelPayload(h: HotelPayload): Record { const out: Record = {} if (h.name !== undefined) out.name = h.name if (h.address !== undefined) out.address = h.address if (h.phone !== undefined) out.phone = h.phone if (h.timezone !== undefined) out.timezone = h.timezone if (h.currency !== undefined) out.currency = h.currency if (h.checkInTime !== undefined) out.check_in_time = h.checkInTime if (h.checkOutTime !== undefined) out.check_out_time = h.checkOutTime return out }