// API клиент const API_BASE = window.API_BASE || 'https://api.buhapp.mygoodservice.ru'; const Store = { access: null, refresh: null, user: null, save(tokens) { if (!tokens) return; // бэкенд отдаёт {Access, Refresh} — поддержим оба варианта this.access = tokens.Access || tokens.access; this.refresh = tokens.Refresh || tokens.refresh; try { localStorage.setItem('buhapp', JSON.stringify({ access: this.access, refresh: this.refresh, user: this.user, })); } catch {} }, load() { try { const s = JSON.parse(localStorage.getItem('buhapp') || 'null'); if (s) { this.access = s.access || s.Access; this.refresh = s.refresh || s.Refresh; this.user = s.user; } } catch {} return this.access; }, clear() { this.access = null; this.refresh = null; this.user = null; try { localStorage.removeItem('buhapp'); } catch {} }, }; async function api(path, opts = {}, _retry = 0) { // rebuild opts on every retry (body may be already stringified) const o = { ...opts, headers: { ...(opts.headers || {}) } }; // для GET — передаём токен через query (избегаем CORS preflight на Authorization) // для POST/PUT/DELETE — оставляем в headers const isGet = !o.method || o.method === 'GET' || o.method === 'HEAD'; if (Store.access && !o.headers.Authorization && !isGet) { o.headers.Authorization = `Bearer ${Store.access}`; } if (Store.access && isGet) { const sep = path.includes('?') ? '&' : '?'; path = path + sep + '_t=' + encodeURIComponent(Store.access); } if (o.body && typeof o.body === 'object' && !(o.body instanceof FormData)) { o.headers['Content-Type'] = 'application/json'; o.body = JSON.stringify(o.body); } let r; // timeout 15s — иначе UI висит вечно const controller = new AbortController(); const tid = setTimeout(() => controller.abort(), 15000); const t0 = Date.now(); try { // credentials:'include' — браузер шлёт cookies (HttpOnly buhapp_at) r = await fetch(API_BASE + path, { ...o, credentials: 'include', signal: controller.signal }); } catch (networkErr) { clearTimeout(tid); const ms = Date.now() - t0; console.warn('fetch network error:', networkErr, 'ms:', ms); // детальный лог на сервер для дебага try { fetch(API_BASE + '/api/v1/debug/log', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, status: 0, ms, ua: navigator.userAgent, err: String(networkErr), isTelegram: !!(window.Telegram && window.Telegram.WebApp), }), keepalive: true, }).catch(() => {}); } catch {} if (networkErr.name === 'AbortError') { throw new Error('Сервер не отвечает (таймаут 15с)'); } if (_retry < 1) { await new Promise((res) => setTimeout(res, 2000)); return api(path, opts, _retry + 1); } throw new Error('Нет связи с сервером'); } clearTimeout(tid); const ms = Date.now() - t0; // логируем всё на сервер для отладки с iOS try { fetch(API_BASE + '/api/v1/debug/log', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path, status: r.status, ms, ua: navigator.userAgent.substring(0, 80), isTelegram: !!(window.Telegram && window.Telegram.WebApp), }), keepalive: true, }).catch(() => {}); } catch {} if (r.status === 401 && Store.refresh && !opts._isRefresh) { try { const r2 = await fetch(API_BASE + '/api/v1/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ Refresh: Store.refresh }), }); if (r2.ok) { const tokens = await r2.json(); Store.save(tokens); o.headers.Authorization = `Bearer ${Store.access}`; return api(path, opts, _retry + 1); } else { Store.clear(); } } catch (e) { console.warn('refresh failed', e); } } if (r.status === 502 || r.status === 503 || r.status === 504) { if (_retry < 1) { await new Promise((res) => setTimeout(res, 2000)); return api(path, opts, _retry + 1); } throw new Error('Сервер перезапускается, попробуй через минуту'); } const text = await r.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch { data = text; } if (!r.ok) { const msg = (data && data.error) || `HTTP ${r.status}`; const err = new Error(msg); err.status = r.status; err.data = data; throw err; } return data; } // === Auth === const Auth = { async loginWithTelegram(initData) { const r = await api('/api/v1/auth/telegram', { method: 'POST', body: { init_data: initData }, }); Store.save(r.tokens); Store.user = r.user; return r; }, async registerEmail(data) { const r = await api('/api/v1/auth/register', { method: 'POST', body: data }); Store.save(r.tokens); Store.user = r.user; return r; }, async loginEmail(email, password) { const r = await api('/api/v1/auth/login', { method: 'POST', body: { email, password } }); Store.save(r.tokens); Store.user = r.user; return r; }, async me() { const r = await api('/api/v1/me'); Store.user = r.user; return r; }, async updateMe(p) { return api('/api/v1/me', { method: 'PUT', body: p }); }, async getPrefs() { return api('/api/v1/me/prefs'); }, async updatePrefs(p) { return api('/api/v1/me/prefs', { method: 'PUT', body: p }); }, async updateLocation(lat, lng, visible) { return api('/api/v1/me/location', { method: 'PUT', body: { lat, lng, visible } }); }, async setVisibility(v) { return api('/api/v1/me/visibility', { method: 'PUT', body: { visible: v } }); }, async searchNearby(lat, lng, radiusKm) { return api('/api/v1/search/nearby?lat=' + lat + '&lng=' + lng + '&radius=' + radiusKm); }, async getUser(id) { return api('/api/v1/users/' + id); }, }; // === Chat === const ChatApi = { async ensureChat(otherId) { return api('/api/v1/chats', { method: 'POST', body: { other_id: otherId } }); }, async listChats() { return api('/api/v1/chats'); }, async listMessages(chatId) { return api('/api/v1/chats/' + chatId + '/messages'); }, async sendMessage(chatId, body, photoUrl) { return api('/api/v1/chats/' + chatId + '/messages', { method: 'POST', body: { body, photo_url: photoUrl } }); }, async editMessage(msgId, body) { return api('/api/v1/messages/' + msgId, { method: 'PUT', body: { body } }); }, async deleteMessage(msgId) { return api('/api/v1/messages/' + msgId, { method: 'DELETE' }); }, async markRead(chatId) { return api('/api/v1/chats/' + chatId + '/read', { method: 'PUT', body: {} }); }, async block(userId) { return api('/api/v1/blocks', { method: 'POST', body: { user_id: userId } }); }, async report(type, id, reason) { return api('/api/v1/reports', { method: 'POST', body: { target_type: type, target_id: id, reason } }); }, }; // === Reviews === const ReviewApi = { async create(chatId, rating, body, anonymous) { return api('/api/v1/reviews', { method: 'POST', body: { chat_id: chatId, rating, body, anonymous } }); }, async listForUser(userId) { return api('/api/v1/users/' + userId + '/reviews'); }, async stats(userId) { return api('/api/v1/users/' + userId + '/stats'); }, }; const ConsentApi = { async status() { try { return await api('/api/v1/auth/consents'); } catch (e) { return { all_accepted: false, accepted: {} }; } }, async accept(payload) { return api('/api/v1/auth/consents', { method: 'POST', body: payload }); }, }; const PlacesApi = { async nearby(lat, lng, radius, limit = 30) { try { return await api(`/api/v1/places/nearby?lat=${lat}&lng=${lng}&radius=${radius || 5}&limit=${limit}`); } catch (e) { return { places: [], count: 0, error: e.message }; } }, }; window.API = { api, Auth, ChatApi, ReviewApi, ConsentApi, PlacesApi, Store }; // сразу восстановим сессию из localStorage (для refresh страницы) Store.load();