const tg = window.Telegram?.WebApp;
tg?.ready();
tg?.expand();
const textNodes = new WeakMap();
const attrOriginals = new WeakMap();
const i18n = {
en: {
"Гараж": "Garage",
"Автомобиль": "Vehicle",
"Не выбран": "Not selected",
"Добавь авто или выбери из списка": "Add a vehicle or choose one",
"Расходы": "Expenses",
"топливо, сервис и ремонты": "fuel, service and repairs",
"Средний расход": "Average consumption",
"л/100 км по полным данным": "L/100 km from complete data",
"Автомобили": "Vehicles",
"Профиль учета": "Tracking profile",
"Старт": "Start",
"Добавь авто и первую запись, чтобы видеть точные отчеты": "Add a vehicle and first entry to see accurate reports",
"Отчет": "Report",
"Стоимость владения": "Ownership cost",
"Весь срок": "All time",
"Выбери марку": "Choose make",
"Выбери модель": "Choose model",
"Сначала марка": "Choose make first",
"Топливо": "Fuel",
"Эффективность": "Efficiency",
"Месяц": "Month",
"День": "Day",
"Квартал": "Quarter",
"Год": "Year",
"Свой период": "Custom period",
"Заправка": "Fuel",
"Сервис": "Service",
"Скан чека": "Receipt scan",
"30 сек": "30 sec",
"ТО / ремонт": "Maintenance / repair",
"Динамика расходов": "Expense trend",
"Структура": "Breakdown",
"Дата": "Date",
"Одометр, км": "Odometer, km",
"Литры": "Liters",
"Цена за литр": "Price per liter",
"АЗС": "Fuel station",
"Не выбрано": "Not selected",
"Полный бак": "Full tank",
"Сохранить заправку": "Save fuel entry",
"Тип": "Type",
"Обслуживание": "Maintenance",
"Ремонт": "Repair",
"Жидкости": "Fluids",
"Шины": "Tires",
"Осмотр": "Inspection",
"Страховка": "Insurance",
"Налог": "Tax",
"Другое": "Other",
"Что сделано": "Work done",
"Масло": "Oil",
"Стоимость": "Cost",
"Исполнитель": "Vendor",
"Сохранить запись": "Save entry",
"Меню": "Menu",
"Добавить автомобиль": "Add vehicle",
"Локаль и валюта": "Language and currency",
"Уведомления": "Notifications",
"Сканировать чек": "Scan receipt",
"Настройки": "Settings",
"Язык": "Language",
"Валюта": "Currency",
"Сохранить настройки": "Save settings",
"Напомним о ТО, страховке и регулярном внесении пробега.": "We'll remind you about maintenance, insurance and regular odometer updates.",
"Включить уведомления": "Enable notifications",
"Фото или файл чека": "Receipt photo or file",
"Сфотографировать": "Take photo",
"Выбрать файл": "Choose file",
"Файл не выбран": "No file selected",
"Распознать": "Recognize",
"После распознавания поля заправки заполнятся автоматически.": "After recognition, fuel fields will be filled automatically.",
"Новое авто": "New vehicle",
"Название авто": "Vehicle name",
"Марка": "Make",
"Модель": "Model",
"Добавить авто": "Add vehicle",
"За весь срок": "All time",
"За месяц": "This month",
"За день": "Per day",
"За квартал": "Quarter",
"За год": "Year",
"За период": "Period",
"На 100 км": "Per 100 km",
"На 1 км": "Per 1 km",
"записей": "entries",
"среднее в периоде": "average in period",
"нет данных": "no data",
"Выбери автомобиль": "Choose a vehicle",
"Выбери автомобиль для статистики": "Choose a vehicle for stats",
"Добавь первый автомобиль": "Add your first vehicle",
"Без деталей": "No details",
"Профиль точный": "Accurate profile",
"Хороший учет": "Good tracking",
"Набираем данные": "Collecting data",
"Отчеты уже достаточно надежны для решений по расходам": "Reports are reliable enough for expense decisions",
"Чем регулярнее записи, тем точнее расход, цена километра и напоминания": "More regular entries make consumption, cost per km and reminders more accurate",
"Итого": "Total",
"Стоимость 1 км": "Cost per km",
"Пробег": "Mileage",
"Записей": "Entries",
"Потрачено": "Spent",
"Литров": "Liters",
"Средняя заправка": "Average refill",
"Расход": "Consumption",
"Главная категория": "Top category",
"Макс. категория": "Max category",
"Прогноз сегодня": "Today forecast",
"+30 дней": "+30 days",
"Лучший рост точности даст привычка заносить одометр при каждой заправке и сервисе.": "Accuracy improves most when odometer is entered at every fuel and service record.",
"Нет записей за выбранный период": "No entries for selected period",
"Добавь заправку или сервисную запись": "Add fuel or service entry",
"Нет расходов": "No expenses",
"топливо": "fuel",
"Уведомления включены": "Notifications enabled",
"Уведомления запрещены в настройках браузера": "Notifications are blocked in browser settings",
"Браузер не поддерживает уведомления": "Browser does not support notifications",
"PWA установлена и работает офлайн после первого открытия.": "PWA is installed and works offline after first open.",
"Напоминания готовы": "Reminders are ready",
"Мы напомним о ТО, страховке и обновлении пробега.": "We'll remind you about maintenance, insurance and mileage updates.",
"Напоминаний на ближайшее время нет": "No reminders due soon",
"Готов к работе": "Ready",
"Обновляю данные...": "Refreshing data...",
"Сохраняю...": "Saving...",
"Сохранено": "Saved",
"Распознаю чек...": "Recognizing receipt...",
"Выбери файл чека": "Choose receipt file",
"Проверь распознанные значения": "Check recognized values",
"Ошибка": "Error",
"Прогноз цены": "Price forecast",
"Текущая цена": "Current price",
"Средняя цена": "Average price",
},
ko: {
"Гараж": "차고",
"Автомобиль": "차량",
"Не выбран": "선택 안 됨",
"Добавь авто или выбери из списка": "차량을 추가하거나 목록에서 선택하세요",
"Расходы": "지출",
"топливо, сервис и ремонты": "연료, 정비, 수리",
"Средний расход": "평균 연비",
"л/100 км по полным данным": "완전한 데이터 기준 L/100km",
"Автомобили": "차량",
"Профиль учета": "기록 프로필",
"Старт": "시작",
"Отчет": "리포트",
"Стоимость владения": "소유 비용",
"Весь срок": "전체",
"Выбери марку": "브랜드 선택",
"Выбери модель": "모델 선택",
"Сначала марка": "브랜드를 먼저 선택하세요",
"Топливо": "연료",
"Эффективность": "효율",
"Месяц": "월",
"День": "일",
"Квартал": "분기",
"Год": "년",
"Свой период": "직접 선택",
"Заправка": "주유",
"Сервис": "정비",
"Скан чека": "영수증 스캔",
"30 сек": "30초",
"ТО / ремонт": "정비 / 수리",
"Динамика расходов": "지출 추이",
"Структура": "구성",
"Дата": "날짜",
"Одометр, км": "주행거리, km",
"Литры": "리터",
"Цена за литр": "리터당 가격",
"АЗС": "주유소",
"Не выбрано": "선택 안 됨",
"Полный бак": "가득 주유",
"Сохранить заправку": "주유 저장",
"Тип": "유형",
"Обслуживание": "정비",
"Ремонт": "수리",
"Жидкости": "오일/액체",
"Шины": "타이어",
"Осмотр": "점검",
"Страховка": "보험",
"Налог": "세금",
"Другое": "기타",
"Что сделано": "작업 내용",
"Масло": "오일",
"Стоимость": "비용",
"Исполнитель": "업체",
"Сохранить запись": "기록 저장",
"Меню": "메뉴",
"Добавить автомобиль": "차량 추가",
"Локаль и валюта": "언어와 통화",
"Уведомления": "알림",
"Сканировать чек": "영수증 스캔",
"Настройки": "설정",
"Язык": "언어",
"Валюта": "통화",
"Сохранить настройки": "설정 저장",
"Напомним о ТО, страховке и регулярном внесении пробега.": "정비, 보험, 주행거리 입력을 알려드릴게요.",
"Включить уведомления": "알림 켜기",
"Фото или файл чека": "영수증 사진 또는 파일",
"Сфотографировать": "사진 촬영",
"Выбрать файл": "파일 선택",
"Файл не выбран": "선택된 파일 없음",
"Распознать": "인식",
"После распознавания поля заправки заполнятся автоматически.": "인식 후 주유 입력란이 자동으로 채워집니다.",
"Новое авто": "새 차량",
"Название авто": "차량 이름",
"Марка": "브랜드",
"Модель": "모델",
"Добавить авто": "차량 추가",
"За весь срок": "전체",
"За месяц": "월",
"За день": "일 평균",
"За квартал": "분기",
"За год": "년",
"За период": "기간",
"На 100 км": "100km당",
"На 1 км": "1km당",
"записей": "개 기록",
"среднее в периоде": "기간 평균",
"нет данных": "데이터 없음",
"Выбери автомобиль": "차량을 선택하세요",
"Выбери автомобиль для статистики": "통계를 볼 차량을 선택하세요",
"Добавь первый автомобиль": "첫 차량을 추가하세요",
"Без деталей": "상세 정보 없음",
"Профиль точный": "정확한 프로필",
"Хороший учет": "좋은 기록",
"Набираем данные": "데이터 수집 중",
"Итого": "합계",
"Стоимость 1 км": "1km 비용",
"Пробег": "주행거리",
"Записей": "기록",
"Потрачено": "지출",
"Литров": "리터",
"Средняя заправка": "평균 주유",
"Расход": "연비",
"Главная категория": "주요 카테고리",
"Макс. категория": "최대 카테고리",
"Прогноз сегодня": "오늘 예측",
"+30 дней": "+30일",
"Нет записей за выбранный период": "선택한 기간에 기록이 없습니다",
"Добавь заправку или сервисную запись": "주유 또는 정비 기록을 추가하세요",
"Нет расходов": "지출 없음",
"топливо": "연료",
"Уведомления включены": "알림이 켜졌습니다",
"Уведомления запрещены в настройках браузера": "브라우저 설정에서 알림이 차단되었습니다",
"Браузер не поддерживает уведомления": "브라우저가 알림을 지원하지 않습니다",
"PWA установлена и работает офлайн после первого открытия.": "PWA는 첫 실행 후 오프라인에서도 작동합니다.",
"Напоминания готовы": "알림 준비 완료",
"Мы напомним о ТО, страховке и обновлении пробега.": "정비, 보험, 주행거리 업데이트를 알려드릴게요.",
"Напоминаний на ближайшее время нет": "다가오는 알림이 없습니다",
"Готов к работе": "준비 완료",
"Обновляю данные...": "데이터 새로고침 중...",
"Сохраняю...": "저장 중...",
"Сохранено": "저장됨",
"Распознаю чек...": "영수증 인식 중...",
"Выбери файл чека": "영수증 파일을 선택하세요",
"Проверь распознанные значения": "인식된 값을 확인하세요",
"Ошибка": "오류",
"Прогноз цены": "가격 예측",
"Текущая цена": "현재 가격",
"Средняя цена": "평균 가격",
},
};
function t(text) {
return i18n[state.user?.locale]?.[text] || text;
}
function applyTranslations(root = document.body) {
document.documentElement.lang = state.user?.locale || "ru";
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const parent = node.parentElement;
if (!parent || ["SCRIPT", "STYLE"].includes(parent.tagName)) return NodeFilter.FILTER_REJECT;
return node.nodeValue.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
},
});
while (walker.nextNode()) {
const node = walker.currentNode;
if (!textNodes.has(node)) textNodes.set(node, node.nodeValue.trim());
const original = textNodes.get(node);
node.nodeValue = node.nodeValue.replace(node.nodeValue.trim(), t(original));
}
root.querySelectorAll?.("[placeholder], [aria-label], [title]").forEach((element) => {
["placeholder", "aria-label", "title"].forEach((attr) => {
const value = element.getAttribute(attr);
if (!value) return;
let originals = attrOriginals.get(element);
if (!originals) {
originals = {};
attrOriginals.set(element, originals);
}
originals[attr] ||= value;
element.setAttribute(attr, t(originals[attr]));
});
});
}
const state = {
user: null,
authConfig: null,
cars: [],
catalog: [],
selectedCarId: null,
latestFuel: [],
latestService: [],
latestStats: null,
allStats: null,
analytics: null,
receiptFile: null,
serviceWorkerRegistration: null,
period: {
preset: "month",
dateFrom: null,
dateTo: null,
},
};
async function initPwa() {
if (!("serviceWorker" in navigator)) return;
try {
state.serviceWorkerRegistration = await navigator.serviceWorker.register("/sw.js");
} catch (error) {
console.warn("Service worker registration failed", error);
}
}
function updateNotificationStatus(message) {
const node = document.querySelector("#notificationStatus");
if (node) node.textContent = t(message);
}
async function enableNotifications() {
if (!("Notification" in window)) {
updateNotificationStatus("Браузер не поддерживает уведомления");
return;
}
const permission = await Notification.requestPermission();
if (permission !== "granted") {
updateNotificationStatus("Уведомления запрещены в настройках браузера");
return;
}
const registration = state.serviceWorkerRegistration || (await navigator.serviceWorker?.ready);
if (registration?.pushManager && window.APP_VAPID_PUBLIC_KEY) {
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(window.APP_VAPID_PUBLIC_KEY),
});
localStorage.setItem("driversPushSubscription", JSON.stringify(subscription));
await savePushSubscription(subscription);
}
await showDueReminders(registration);
if (registration?.showNotification) {
await registration.showNotification(t("Напоминания готовы"), {
body: t("Мы напомним о ТО, страховке и обновлении пробега."),
icon: "/static/icon.svg",
badge: "/static/icon.svg",
tag: "drivers-bot-ready",
});
}
updateNotificationStatus("Уведомления включены");
}
async function showDueReminders(registration) {
if (!state.user) return;
const reminders = await api(`/users/${state.user.id}/reminders`);
if (!reminders.length) {
updateNotificationStatus("Напоминаний на ближайшее время нет");
return;
}
updateNotificationStatus(`Есть напоминаний: ${reminders.length}`);
if (Notification.permission !== "granted" || !registration?.showNotification) return;
const item = reminders[0];
const body = item.due_odometer
? `${item.car_name}: ${item.title}, срок ${item.due_odometer} км`
: `${item.car_name}: ${item.title}, срок ${item.due_date}`;
await registration.showNotification(t("Напоминания готовы"), {
body,
icon: "/static/icon.svg",
badge: "/static/icon.svg",
tag: `drivers-reminder-${item.id}`,
data: "/",
});
}
function urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = window.atob(base64);
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}
function today() {
return new Date().toISOString().slice(0, 10);
}
function formData(form) {
return Object.fromEntries(new FormData(form).entries());
}
async function api(path, options = {}) {
const response = await fetch(`/api${path}`, {
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
...options,
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || response.statusText);
}
if (response.status === 204) return null;
return response.json();
}
async function loadAuthConfig() {
state.authConfig = await api("/users/auth/config");
window.APP_VAPID_PUBLIC_KEY = state.authConfig.vapid_public_key || "";
}
function setStatus(message = "Готов к работе") {
const node = document.querySelector("#statusBar");
if (node) node.textContent = t(message);
}
function toast(message, tone = "success") {
const node = document.querySelector("#toast");
if (!node) return;
node.textContent = t(message);
node.className = `toast ${tone}`;
window.clearTimeout(toast.timer);
toast.timer = window.setTimeout(() => node.classList.add("hidden"), 2600);
}
function haptic(type = "light") {
try {
if (type === "error") tg?.HapticFeedback?.notificationOccurred("error");
else if (type === "success") tg?.HapticFeedback?.notificationOccurred("success");
else tg?.HapticFeedback?.impactOccurred(type);
} catch (_) {
// Telegram haptics are best-effort and absent in regular browsers.
}
}
function setButtonBusy(button, busy, label = "Сохраняю...") {
if (!button) return;
if (button.tagName !== "BUTTON") {
button.disabled = busy;
button.classList.toggle("is-busy", busy);
return;
}
if (busy) {
button.dataset.label = button.textContent;
button.disabled = true;
button.classList.add("is-busy");
button.innerHTML = `${t(label)}`;
} else {
button.disabled = false;
button.classList.remove("is-busy");
button.textContent = button.dataset.label || button.textContent;
delete button.dataset.label;
}
}
async function runAction(button, statusMessage, callback) {
haptic();
setStatus(statusMessage);
setButtonBusy(button, true, statusMessage);
try {
const result = await callback();
setStatus("Готов к работе");
return result;
} catch (error) {
console.error(error);
setStatus("Ошибка");
toast(error.message || "Ошибка", "error");
haptic("error");
throw error;
} finally {
setButtonBusy(button, false);
}
}
async function ensureUser() {
if (tg?.initData) {
state.user = await api("/users/webapp-auth", {
method: "POST",
body: JSON.stringify({ init_data: tg.initData }),
});
hideAuthOverlay();
return;
}
const stored = localStorage.getItem("driversUser");
if (stored) {
state.user = JSON.parse(stored);
hideAuthOverlay();
return;
}
await showTelegramLogin();
throw new Error("Требуется вход через Telegram");
}
function hideAuthOverlay() {
document.querySelector("#authOverlay")?.classList.add("hidden");
document.body.classList.remove("auth-required");
}
async function showTelegramLogin() {
const overlay = document.querySelector("#authOverlay");
const slot = document.querySelector("#telegramLoginSlot");
const link = document.querySelector("#telegramLoginLink");
overlay?.classList.remove("hidden");
document.body.classList.add("auth-required");
if (!slot || slot.dataset.ready) return;
const botUsername = state.authConfig?.bot_username;
if (!botUsername) {
slot.textContent = "Telegram Login временно недоступен";
return;
}
if (link) {
link.href = `https://t.me/${botUsername}?start=web_login`;
link.classList.remove("hidden");
}
window.onTelegramAuth = async (user) => {
state.user = await api("/users/telegram-login", {
method: "POST",
body: JSON.stringify(user),
});
localStorage.setItem("driversUser", JSON.stringify(state.user));
hideAuthOverlay();
await loadCars();
};
const script = document.createElement("script");
script.async = true;
script.src = "https://telegram.org/js/telegram-widget.js?22";
script.setAttribute("data-telegram-login", botUsername);
script.setAttribute("data-size", "large");
script.setAttribute("data-radius", "8");
script.setAttribute("data-request-access", "write");
script.setAttribute("data-onauth", "onTelegramAuth(user)");
script.addEventListener("error", () => {
slot.textContent = "Кнопка Telegram не загрузилась. Используй вход ниже.";
});
slot.dataset.ready = "true";
slot.appendChild(script);
}
async function savePushSubscription(subscription) {
if (!state.user || !subscription) return;
await api(`/users/${state.user.id}/push-subscriptions`, {
method: "POST",
body: JSON.stringify({
...subscription.toJSON(),
user_agent: navigator.userAgent,
}),
});
}
function money(value) {
const currency = state.user?.currency || "RUB";
return Number(value || 0).toLocaleString(
{ ru: "ru-RU", en: "en-US", ko: "ko-KR" }[state.user?.locale] || "ru-RU",
{ style: "currency", currency, maximumFractionDigits: currency === "KRW" ? 0 : 2 },
);
}
function selectedCar() {
return state.cars.find((car) => car.id === state.selectedCarId) || null;
}
function numberOrNull(value) {
return value === "" || value == null ? null : Number(value);
}
function shiftMonths(base, count) {
const copy = new Date(base);
copy.setMonth(copy.getMonth() + count);
return copy;
}
function dateValue(date) {
return date.toISOString().slice(0, 10);
}
function applyPeriodPreset(preset = "month") {
document.querySelector("#periodPreset").value = preset;
const now = new Date();
const to = dateValue(now);
let fromDate = new Date(now.getFullYear(), now.getMonth(), 1);
if (preset === "all") fromDate = new Date(2000, 0, 1);
if (preset === "day") fromDate = now;
if (preset === "quarter") fromDate = shiftMonths(now, -3);
if (preset === "year") fromDate = shiftMonths(now, -12);
if (preset !== "custom") {
document.querySelector("#periodFrom").value = dateValue(fromDate);
document.querySelector("#periodTo").value = to;
}
state.period = {
preset,
dateFrom: document.querySelector("#periodFrom").value,
dateTo: document.querySelector("#periodTo").value,
};
}
function periodQuery() {
const params = new URLSearchParams();
if (state.period.dateFrom) params.set("date_from", state.period.dateFrom);
if (state.period.dateTo) params.set("date_to", state.period.dateTo);
const query = params.toString();
return query ? `?${query}` : "";
}
function allPeriodQuery() {
return "?date_from=2000-01-01&date_to=2100-01-01";
}
async function loadCatalog() {
state.catalog = await api("/catalog/makes");
}
function initCarCatalog() {
const makeSelect = document.querySelector("#makeSelect");
const modelSelect = document.querySelector("#modelSelect");
const trimSelect = document.querySelector("#trimSelect");
const fuelTypeSelect = document.querySelector("#fuelTypeSelect");
const preview = document.querySelector("#catalogPreview");
const makes = [...state.catalog].sort((a, b) => a.name.localeCompare(b.name, "ru"));
makeSelect.innerHTML = `` + makes
.map((make) => ``)
.join("");
function selectedModel() {
const make = state.catalog.find((item) => item.name === makeSelect.value);
return make?.models.find((model) => model.name === modelSelect.value) || null;
}
function syncPreview() {
const model = selectedModel();
const trim = model?.trims?.find((item) => item.name === trimSelect.value);
if (!model) {
preview.innerHTML = `${t("Выбери модель")}Покажем кузов, топливо, привод и годы выпуска.`;
return;
}
const chips = [
trim?.body_type,
trim?.fuel_type,
trim?.transmission,
trim?.drive_type,
trim?.year_from && trim?.year_to ? `${trim.year_from}-${trim.year_to}` : null,
].filter(Boolean);
preview.innerHTML = `
${makeSelect.value} ${model.name}${trim ? ` · ${trim.name}` : ""}
${chips.length ? chips.join(" · ") : "Базовые параметры можно уточнить позже"}
`;
if (trim?.fuel_type && !fuelTypeSelect.value) fuelTypeSelect.value = trim.fuel_type;
}
function syncTrims() {
const model = selectedModel();
const trims = model?.trims || [];
trimSelect.disabled = !trims.length;
trimSelect.innerHTML = trims.length
? `` + trims.map((trim) => ``).join("")
: ``;
syncPreview();
}
function syncModels() {
const make = makeSelect.value;
const models = state.catalog.find((item) => item.name === make)?.models || [];
modelSelect.disabled = !models.length;
modelSelect.innerHTML = models.length
? `` + models.map((model) => ``).join("")
: ``;
syncTrims();
}
makeSelect.addEventListener("change", syncModels);
modelSelect.addEventListener("change", syncTrims);
trimSelect.addEventListener("change", syncPreview);
syncModels();
}
function resetCarCatalog() {
document.querySelector("#makeSelect").value = "";
const modelSelect = document.querySelector("#modelSelect");
const trimSelect = document.querySelector("#trimSelect");
modelSelect.disabled = true;
modelSelect.innerHTML = ``;
trimSelect.disabled = true;
trimSelect.innerHTML = ``;
document.querySelector("#catalogPreview").innerHTML =
`${t("Выбери модель")}Покажем кузов, топливо, привод и годы выпуска.`;
}
function updateHero(stats) {
const car = selectedCar();
document.querySelector("#selectedCarTitle").textContent = car?.name || t("Не выбран");
document.querySelector("#selectedCarMeta").textContent = car
? [car.make, car.model, car.trim, car.year, car.fuel_type].filter(Boolean).join(" ") || t("Без деталей")
: t("Добавь авто или выбери из списка");
document.querySelector("#summaryTotal").textContent = money(stats?.total_cost);
document.querySelector("#summaryConsumption").textContent = stats?.avg_consumption_l_per_100km
? `${stats.avg_consumption_l_per_100km.toFixed(2)} л`
: "-";
}
function formatFuelPrice(value) {
if (!value) return "-";
return money(value).replace(/\s?₽|RUB/i, "").trim();
}
function renderCars() {
const root = document.querySelector("#cars");
if (!state.cars.length) {
root.innerHTML = `
${t("Добавь первый автомобиль")}
`;
updateHero(null);
return;
}
root.innerHTML = state.cars
.map(
(car) => `
`,
)
.join("");
root.querySelectorAll("[data-car]").forEach((button) => {
button.addEventListener("click", () => selectCar(Number(button.dataset.car)));
});
}
function setInputValue(form, name, value) {
if (form?.elements[name]) form.elements[name].value = value ?? "";
}
function fillCarProfileForm() {
const form = document.querySelector("#carProfileForm");
const hint = document.querySelector("#carProfileHint");
const car = selectedCar();
form.querySelectorAll("input, select, button").forEach((node) => {
node.disabled = !car;
});
if (!car) {
form.reset();
hint.textContent = t("Выбери автомобиль, чтобы настроить жидкости, расход и сервисные нормы.");
return;
}
hint.textContent = [car.make, car.model, car.trim, car.year].filter(Boolean).join(" ") || car.name;
[
"fuel_type",
"target_consumption_l_per_100km",
"fuel_tank_volume_l",
"engine_oil_type",
"engine_oil_volume_l",
"transmission_fluid_type",
"transmission_fluid_volume_l",
"coolant_type",
"brake_fluid_type",
"tire_pressure_front_bar",
"tire_pressure_rear_bar",
].forEach((name) => setInputValue(form, name, car[name]));
}
function openCarProfile() {
document.querySelector("#userDrawer").classList.remove("hidden");
document.querySelector("#carProfileSection").classList.remove("hidden");
fillCarProfileForm();
document.querySelector("#carProfileSection").scrollIntoView({ behavior: "smooth", block: "start" });
}
function renderStats(stats) {
const root = document.querySelector("#stats");
if (!stats) {
root.innerHTML = `${t("Выбери автомобиль для статистики")}
`;
updateHero(null);
updateScore();
drawCharts([], [], null);
return;
}
updateHero(stats);
updateScore();
const all = state.allStats || stats;
const periodDays = Math.max(
1,
Math.ceil((new Date(stats.date_to) - new Date(stats.date_from)) / 86400000) + 1,
);
const costPerDay = Number(stats.total_cost || 0) / periodDays;
const costPer100 = stats.cost_per_km ? stats.cost_per_km * 100 : null;
const periodTitles = {
all: t("За весь срок"),
month: t("За месяц"),
day: t("За день"),
quarter: t("За квартал"),
year: t("За год"),
custom: t("За период"),
};
const periodTitle = periodTitles[state.period.preset] || t("За период");
root.innerHTML = `
`;
root.querySelectorAll("[data-report]").forEach((button) => {
button.addEventListener("click", () => openReport(button.dataset.report));
});
}
function recordsForPeriod() {
return [
...state.latestFuel.map((item) => ({
date: item.entry_date,
type: "fuel",
title: `Заправка ${Number(item.liters).toFixed(1)} л`,
meta: item.station || `${item.odometer} км`,
cost: item.total_cost,
})),
...state.latestService.map((item) => ({
date: item.entry_date,
type: "service",
title: item.title,
meta: item.vendor || serviceLabel(item.service_type),
cost: item.total_cost,
})),
].sort((a, b) => b.date.localeCompare(a.date));
}
function updateScore() {
const car = selectedCar();
const fuelCount = state.latestFuel.length;
const serviceCount = state.latestService.length;
let score = 0;
if (car) score += 25;
if (fuelCount > 0) score += 25;
if (serviceCount > 0) score += 20;
if (state.latestStats?.distance_km > 0) score += 20;
if (fuelCount + serviceCount >= 6) score += 10;
const title = score >= 90 ? t("Профиль точный") : score >= 60 ? t("Хороший учет") : score >= 30 ? t("Набираем данные") : t("Старт");
document.querySelector("#scoreTitle").textContent = title;
document.querySelector("#scoreBar").style.width = `${score}%`;
document.querySelector("#scoreHint").textContent =
score >= 90
? t("Отчеты уже достаточно надежны для решений по расходам")
: t("Чем регулярнее записи, тем точнее расход, цена километра и напоминания");
}
function openReport(type = "summary") {
const stats = state.latestStats;
const sheet = document.querySelector("#reportSheet");
const title = document.querySelector("#reportTitle");
const body = document.querySelector("#reportBody");
const records = recordsForPeriod();
const titles = {
summary: t("Стоимость владения"),
fuel: t("Топливо"),
service: t("Сервис"),
efficiency: t("Эффективность"),
};
title.textContent = titles[type] || t("Отчет");
if (!stats) {
body.innerHTML = `${t("Выбери автомобиль")}
`;
sheet.classList.remove("hidden");
return;
}
const fuelLiters = Number(stats.liters || 0);
const fuelRecords = state.latestFuel;
const serviceRecords = state.latestService;
const avgFill = fuelRecords.length ? fuelLiters / fuelRecords.length : 0;
const serviceByType = serviceRecords.reduce((acc, item) => {
const key = serviceLabel(item.service_type);
acc[key] = (acc[key] || 0) + Number(item.total_cost || 0);
return acc;
}, {});
const topService = Object.entries(serviceByType).sort((a, b) => b[1] - a[1])[0];
const analytics = state.analytics;
const blocks = {
summary: `
${reportMetric(t("Итого"), money(stats.total_cost))}
${reportMetric(t("Стоимость 1 км"), stats.cost_per_km ? money(stats.cost_per_km) : "-")}
${reportMetric(t("Пробег"), `${stats.distance_km} км`)}
${reportMetric(t("Записей"), `${stats.fuel_entries_count + stats.service_entries_count}`)}
${reportRecords(records.slice(0, 8))}
`,
fuel: `
${reportMetric(t("Потрачено"), money(stats.fuel_cost))}
${reportMetric(t("Литров"), fuelLiters.toFixed(1))}
${reportMetric(t("Средняя заправка"), avgFill ? `${avgFill.toFixed(1)} л` : "-")}
${reportMetric(t("Расход"), stats.avg_consumption_l_per_100km ? `${stats.avg_consumption_l_per_100km.toFixed(2)} л/100` : "-")}
${reportRecords(records.filter((item) => item.type === "fuel").slice(0, 10))}
`,
service: `
${reportMetric(t("Потрачено"), money(stats.service_cost))}
${reportMetric(t("Записей"), stats.service_entries_count)}
${reportMetric(t("Главная категория"), topService ? topService[0] : "-")}
${reportMetric(t("Макс. категория"), topService ? money(topService[1]) : "-")}
${reportRecords(records.filter((item) => item.type === "service").slice(0, 10))}
`,
efficiency: `
${reportMetric("1 км", stats.cost_per_km ? money(stats.cost_per_km) : "-")}
${reportMetric("100 км", stats.cost_per_km ? money(stats.cost_per_km * 100) : "-")}
${reportMetric(t("Расход"), stats.avg_consumption_l_per_100km ? `${stats.avg_consumption_l_per_100km.toFixed(2)} л/100` : "-")}
${reportMetric(t("Пробег"), `${stats.distance_km} км`)}
${reportMetric(t("Прогноз сегодня"), analytics?.predicted_today ? `${analytics.predicted_today} км` : "-")}
${reportMetric(t("+30 дней"), analytics?.predicted_30_days ? `${analytics.predicted_30_days} км` : "-")}
${reportMetric(t("Текущая цена"), analytics?.current_price_per_liter ? `${formatFuelPrice(analytics.current_price_per_liter)} / л` : "-")}
${reportMetric(t("Прогноз цены"), analytics?.predicted_price_per_liter_30_days ? `${formatFuelPrice(analytics.predicted_price_per_liter_30_days)} / л` : "-")}
${analytics?.insight || t("Лучший рост точности даст привычка заносить одометр при каждой заправке и сервисе.")}
`,
};
body.innerHTML = blocks[type] || blocks.summary;
applyTranslations(body);
sheet.classList.remove("hidden");
}
function reportMetric(label, value) {
return `${label}${value}
`;
}
function reportRecords(records) {
if (!records.length) return `${t("Нет записей за выбранный период")}
`;
return `${records
.map(
(item) => `
${item.date}
${item.title}
${item.meta || ""}
${money(item.cost)}
`,
)
.join("")}
`;
}
function serviceLabel(value) {
return {
maintenance: t("Обслуживание"),
repair: t("Ремонт"),
fluid: t("Жидкости"),
tire: t("Шины"),
inspection: t("Осмотр"),
insurance: t("Страховка"),
tax: t("Налог"),
other: t("Другое"),
}[value] || value;
}
function monthlySeries(fuel, service) {
const map = new Map();
[...fuel.map((item) => ({ ...item, type: "fuel" })), ...service.map((item) => ({ ...item, type: "service" }))].forEach((item) => {
const key = item.entry_date.slice(0, 7);
const current = map.get(key) || { label: key, fuel: 0, service: 0 };
current[item.type] += Number(item.total_cost || 0);
map.set(key, current);
});
return [...map.values()].sort((a, b) => a.label.localeCompare(b.label)).slice(-8);
}
function drawCharts(fuel, service, stats) {
drawExpensesChart(monthlySeries(fuel, service));
drawSplitChart(Number(stats?.fuel_cost || 0), Number(stats?.service_cost || 0));
}
function setupCanvas(canvas) {
const ctx = canvas.getContext("2d");
const ratio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * ratio;
canvas.height = rect.height * ratio;
ctx.scale(ratio, ratio);
return { ctx, width: rect.width, height: rect.height };
}
function drawEmpty(ctx, width, height, text) {
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = "#7c8783";
ctx.font = "14px system-ui";
ctx.textAlign = "center";
ctx.fillText(t(text), width / 2, height / 2);
}
function drawExpensesChart(series) {
const canvas = document.querySelector("#expensesChart");
const { ctx, width, height } = setupCanvas(canvas);
if (!series.length) {
drawEmpty(ctx, width, height, "Добавь заправку или сервисную запись");
return;
}
ctx.clearRect(0, 0, width, height);
const pad = 28;
const chartH = height - pad * 2;
const max = Math.max(...series.map((item) => item.fuel + item.service), 1);
const barGap = 12;
const barW = Math.max(18, (width - pad * 2 - barGap * (series.length - 1)) / series.length);
ctx.strokeStyle = "#e1e7e4";
ctx.lineWidth = 1;
for (let i = 0; i < 4; i += 1) {
const y = pad + (chartH / 3) * i;
ctx.beginPath();
ctx.moveTo(pad, y);
ctx.lineTo(width - pad, y);
ctx.stroke();
}
series.forEach((item, index) => {
const x = pad + index * (barW + barGap);
const total = item.fuel + item.service;
const totalH = (total / max) * chartH;
const fuelH = total ? (item.fuel / total) * totalH : 0;
const serviceH = totalH - fuelH;
const y = height - pad - totalH;
ctx.fillStyle = "#36a388";
roundRect(ctx, x, y + serviceH, barW, fuelH, 6);
ctx.fill();
ctx.fillStyle = "#3f7fba";
roundRect(ctx, x, y, barW, serviceH, 6);
ctx.fill();
ctx.fillStyle = "#7c8783";
ctx.font = "12px system-ui";
ctx.textAlign = "center";
ctx.fillText(item.label.slice(5), x + barW / 2, height - 8);
});
}
function drawSplitChart(fuelCost, serviceCost) {
const canvas = document.querySelector("#splitChart");
const { ctx, width, height } = setupCanvas(canvas);
const total = fuelCost + serviceCost;
if (!total) {
drawEmpty(ctx, width, height, "Нет расходов");
return;
}
ctx.clearRect(0, 0, width, height);
const cx = width / 2;
const cy = height / 2 - 8;
const radius = Math.min(width, height) * 0.31;
const fuelAngle = (fuelCost / total) * Math.PI * 2;
ctx.lineWidth = 22;
ctx.lineCap = "round";
ctx.strokeStyle = "#36a388";
ctx.beginPath();
ctx.arc(cx, cy, radius, -Math.PI / 2, -Math.PI / 2 + fuelAngle);
ctx.stroke();
ctx.strokeStyle = "#3f7fba";
ctx.beginPath();
ctx.arc(cx, cy, radius, -Math.PI / 2 + fuelAngle + 0.05, Math.PI * 1.5 - 0.05);
ctx.stroke();
ctx.fillStyle = "#1d2522";
ctx.font = "700 22px system-ui";
ctx.textAlign = "center";
ctx.fillText(`${Math.round((fuelCost / total) * 100)}%`, cx, cy + 5);
ctx.fillStyle = "#7c8783";
ctx.font = "12px system-ui";
ctx.fillText(t("топливо"), cx, cy + 25);
}
function roundRect(ctx, x, y, width, height, radius) {
const r = Math.min(radius, Math.abs(height) / 2, width / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.arcTo(x + width, y, x + width, y + height, r);
ctx.arcTo(x + width, y + height, x, y + height, r);
ctx.arcTo(x, y + height, x, y, r);
ctx.arcTo(x, y, x + width, y, r);
ctx.closePath();
}
async function loadCars() {
document.body.classList.add("loading");
setStatus("Обновляю данные...");
try {
state.cars = await api(`/cars?owner_id=${state.user.id}`);
if (!state.selectedCarId && state.cars.length) state.selectedCarId = state.cars[0].id;
if (state.selectedCarId && !state.cars.some((car) => car.id === state.selectedCarId)) {
state.selectedCarId = state.cars[0]?.id || null;
}
renderCars();
fillCarProfileForm();
await loadSelectedCar();
} finally {
document.body.classList.remove("loading");
setStatus("Готов к работе");
}
}
async function selectCar(carId) {
state.selectedCarId = carId;
renderCars();
fillCarProfileForm();
await loadSelectedCar();
}
async function loadSelectedCar() {
if (!state.selectedCarId) {
state.latestFuel = [];
state.latestService = [];
state.latestStats = null;
state.allStats = null;
state.analytics = null;
renderStats(null);
return;
}
const [stats, allStats, fuel, service, analytics] = await Promise.all([
api(`/cars/${state.selectedCarId}/stats${periodQuery()}`),
api(`/cars/${state.selectedCarId}/stats${allPeriodQuery()}`),
api(`/cars/${state.selectedCarId}/fuel${periodQuery()}`),
api(`/cars/${state.selectedCarId}/service${periodQuery()}`),
api(`/cars/${state.selectedCarId}/analytics`),
]);
state.latestStats = stats;
state.allStats = allStats;
state.latestFuel = fuel;
state.latestService = service;
state.analytics = analytics;
renderStats(stats);
drawCharts(fuel, service, stats);
}
document.querySelectorAll('input[type="date"]').forEach((input) => {
if (input.name !== "next_due_date") input.value = today();
});
applyPeriodPreset("month");
document.querySelector("#refreshBtn").addEventListener("click", (event) => {
runAction(event.currentTarget, "Обновляю данные...", loadCars).then(() => {
toast("Готов к работе");
});
});
document.querySelector("#periodPreset").addEventListener("change", async (event) => {
await runAction(event.currentTarget, "Обновляю данные...", async () => {
applyPeriodPreset(event.currentTarget.value);
await loadSelectedCar();
});
});
document.querySelectorAll("#periodFrom, #periodTo").forEach((input) => {
input.addEventListener("change", async () => {
await runAction(input, "Обновляю данные...", async () => {
document.querySelector("#periodPreset").value = "custom";
applyPeriodPreset("custom");
await loadSelectedCar();
});
});
});
document.querySelector("#carForm").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
const data = formData(form);
await api("/cars", {
method: "POST",
body: JSON.stringify({
owner_id: state.user.id,
name: data.name,
make: data.make || null,
model: data.model || null,
trim: data.trim || null,
year: data.year ? Number(data.year) : null,
fuel_type: data.fuel_type || null,
}),
});
form.reset();
resetCarCatalog();
document.querySelector("#userDrawer").classList.add("hidden");
await loadCars();
toast("Сохранено");
haptic("success");
});
});
document.querySelector("#carProfileForm").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const car = selectedCar();
if (!car) {
toast("Выбери автомобиль", "error");
return;
}
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
const data = formData(form);
const updated = await api(`/cars/${car.id}`, {
method: "PATCH",
body: JSON.stringify({
fuel_type: data.fuel_type || null,
target_consumption_l_per_100km: numberOrNull(data.target_consumption_l_per_100km),
fuel_tank_volume_l: numberOrNull(data.fuel_tank_volume_l),
engine_oil_type: data.engine_oil_type || null,
engine_oil_volume_l: numberOrNull(data.engine_oil_volume_l),
transmission_fluid_type: data.transmission_fluid_type || null,
transmission_fluid_volume_l: numberOrNull(data.transmission_fluid_volume_l),
coolant_type: data.coolant_type || null,
brake_fluid_type: data.brake_fluid_type || null,
tire_pressure_front_bar: numberOrNull(data.tire_pressure_front_bar),
tire_pressure_rear_bar: numberOrNull(data.tire_pressure_rear_bar),
}),
});
state.cars = state.cars.map((item) => (item.id === updated.id ? updated : item));
renderCars();
fillCarProfileForm();
await loadSelectedCar();
toast("Параметры сохранены");
haptic("success");
});
});
document.querySelector("#settingsForm").addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
const data = formData(form);
state.user = await api(`/users/${state.user.id}/preferences`, {
method: "PATCH",
body: JSON.stringify({ locale: data.locale, currency: data.currency }),
});
applyTranslations();
initCarCatalog();
await loadSelectedCar();
document.querySelector("#userDrawer").classList.add("hidden");
toast("Сохранено");
haptic("success");
});
});
document.querySelector("#fuelForm").addEventListener("submit", async (event) => {
event.preventDefault();
if (!state.selectedCarId) return;
const form = event.currentTarget;
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
const data = formData(form);
await api("/fuel", {
method: "POST",
body: JSON.stringify({
car_id: state.selectedCarId,
entry_date: data.entry_date,
odometer: Number(data.odometer),
liters: Number(data.liters),
price_per_liter: Number(data.price_per_liter),
station: data.station || null,
is_full_tank: Boolean(data.is_full_tank),
}),
});
form.reset();
form.entry_date.value = today();
await loadSelectedCar();
toast("Сохранено");
haptic("success");
});
});
document.querySelector("#serviceForm").addEventListener("submit", async (event) => {
event.preventDefault();
if (!state.selectedCarId) return;
const form = event.currentTarget;
await runAction(form.querySelector('button[type="submit"]'), "Сохраняю...", async () => {
const data = formData(form);
await api("/service", {
method: "POST",
body: JSON.stringify({
car_id: state.selectedCarId,
entry_date: data.entry_date,
odometer: data.odometer ? Number(data.odometer) : null,
service_type: data.service_type,
title: data.title,
total_cost: Number(data.total_cost),
vendor: data.vendor || null,
next_due_date: data.next_due_date || null,
next_due_odometer: data.next_due_odometer ? Number(data.next_due_odometer) : null,
}),
});
form.reset();
form.entry_date.value = today();
await loadSelectedCar();
toast("Сохранено");
haptic("success");
});
});
function setAction(action) {
document.querySelectorAll(".action-card[data-action]").forEach((button) => {
button.classList.toggle("active", button.dataset.action === action);
});
document.querySelector("#fuelForm").classList.toggle("hidden", action !== "fuel");
document.querySelector("#serviceForm").classList.toggle("hidden", action !== "service");
}
function openScanModal() {
haptic();
document.querySelector("#userDrawer").classList.add("hidden");
document.querySelector("#scanModal").classList.remove("hidden");
}
document.querySelectorAll("[data-action]").forEach((button) => {
button.addEventListener("click", () => {
haptic();
if (button.dataset.action === "scan") {
openScanModal();
return;
}
setAction(button.dataset.action);
});
});
document.querySelectorAll("[data-report]").forEach((button) => {
button.addEventListener("click", () => {
document.querySelector("#userDrawer").classList.add("hidden");
openReport(button.dataset.report);
});
});
document.querySelectorAll("[data-service-title]").forEach((button) => {
button.addEventListener("click", () => {
haptic();
const form = document.querySelector("#serviceForm");
form.title.value = button.dataset.serviceTitle;
form.service_type.value = button.dataset.serviceType;
});
});
document.querySelector("#menuBtn").addEventListener("click", () => {
document.querySelector("#userDrawer").classList.remove("hidden");
});
document.querySelector("#addCarQuickBtn").addEventListener("click", () => {
document.querySelector("#userDrawer").classList.remove("hidden");
document.querySelector("#carFormSection").scrollIntoView({ behavior: "smooth", block: "start" });
});
document.querySelector("#openCarFormBtn").addEventListener("click", () => {
document.querySelector("#carFormSection").classList.remove("hidden");
document.querySelector("#carFormSection").scrollIntoView({ behavior: "smooth", block: "start" });
});
document.querySelector("#openCarProfileBtn").addEventListener("click", openCarProfile);
document.querySelector("#openSettingsBtn").addEventListener("click", () => {
document.querySelector("#settingsSection").classList.remove("hidden");
document.querySelector("#localeSelect").value = state.user?.locale || "ru";
document.querySelector("#currencySelect").value = state.user?.currency || "RUB";
document.querySelector("#settingsSection").scrollIntoView({ behavior: "smooth", block: "start" });
});
document.querySelector("#openNotificationsBtn").addEventListener("click", () => {
document.querySelector("#notificationsSection").classList.remove("hidden");
updateNotificationStatus(
"Notification" in window && Notification.permission === "granted"
? "Уведомления включены"
: "Напомним о ТО, страховке и регулярном внесении пробега.",
);
document.querySelector("#notificationsSection").scrollIntoView({ behavior: "smooth", block: "start" });
});
document.querySelector("#enableNotificationsBtn").addEventListener("click", enableNotifications);
document.querySelector("#openScanBtn").addEventListener("click", () => {
openScanModal();
});
document.querySelector("#closeScanBtn").addEventListener("click", () => {
document.querySelector("#scanModal").classList.add("hidden");
});
function setReceiptFile(file) {
state.receiptFile = file || null;
document.querySelector("#receiptFileName").textContent = file?.name || t("Файл не выбран");
}
document.querySelector("#scanCameraBtn").addEventListener("click", () => {
document.querySelector("#receiptCameraInput").click();
});
document.querySelector("#scanFileBtn").addEventListener("click", () => {
document.querySelector("#receiptFileInput").click();
});
document.querySelector("#receiptCameraInput").addEventListener("change", (event) => {
setReceiptFile(event.currentTarget.files[0]);
});
document.querySelector("#receiptFileInput").addEventListener("change", (event) => {
setReceiptFile(event.currentTarget.files[0]);
});
document.querySelector("#ocrForm").addEventListener("submit", async (event) => {
event.preventDefault();
const file = state.receiptFile;
if (!file) {
toast("Выбери файл чека", "error");
haptic("error");
return;
}
const formButton = event.currentTarget.querySelector('button[type="submit"]');
await runAction(formButton, "Распознаю чек...", async () => {
const payload = new FormData();
payload.append("file", file);
const response = await fetch("/api/ocr/fuel-receipt", { method: "POST", body: payload });
if (!response.ok) throw new Error(await response.text());
const result = await response.json();
document.querySelector("#ocrResult").textContent = `${result.message} ${Math.round((result.confidence || 0) * 100)}%`;
const fuelForm = document.querySelector("#fuelForm");
if (result.liters) fuelForm.liters.value = result.liters;
if (result.price_per_liter) fuelForm.price_per_liter.value = result.price_per_liter;
if (result.station) fuelForm.station.value = result.station;
setAction("fuel");
document.querySelector("#scanModal").classList.add("hidden");
toast("Проверь распознанные значения");
haptic("success");
});
});
document.querySelector("#closeMenuBtn").addEventListener("click", () => {
document.querySelector("#userDrawer").classList.add("hidden");
});
document.querySelector("#closeReportBtn").addEventListener("click", () => {
document.querySelector("#reportSheet").classList.add("hidden");
});
window.addEventListener("resize", () => {
drawCharts(state.latestFuel, state.latestService, state.latestStats);
});
initPwa();
Promise.all([loadAuthConfig()])
.then(() => Promise.all([ensureUser(), loadCatalog()]))
.then(() => {
document.querySelector("#localeSelect").value = state.user?.locale || "ru";
document.querySelector("#currencySelect").value = state.user?.currency || "RUB";
applyTranslations();
initCarCatalog();
return loadCars();
})
.catch((error) => {
if (error.message === "Требуется вход через Telegram") return;
document.body.insertAdjacentHTML("afterbegin", `${error.message}
`);
});