249 lines
8.3 KiB
JavaScript
249 lines
8.3 KiB
JavaScript
// 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(); |