const tg = window.Telegram?.WebApp; tg?.ready(); tg?.expand(); const CarPassI18n = (() => { const textNodes = new WeakMap(); const attrOriginals = new WeakMap(); let observer = null; let timer = null; const dictionaries = { en: { "Гараж": "Garage", "Автомобили": "Vehicles", "Автомобиль": "Vehicle", "Авто": "Vehicles", "Заправка": "Fuel", "Сервис": "Service", "Расход": "Expense", "Расходы": "Expenses", "ТО и ремонт": "Maintenance and repair", "Дата": "Date", "Одометр, км": "Odometer, km", "Одометр": "Odometer", "Литры": "Liters", "Цена за литр": "Price per liter", "АЗС": "Fuel station", "Полный бак": "Full tank", "Стоимость": "Cost", "Валюта": "Currency", "Категория": "Category", "Название": "Title", "Комментарий": "Comment", "Сохранить": "Save", "Сохранить запись": "Save entry", "Сохранить расход": "Save expense", "Сохранить заправку": "Save fuel entry", "Сохранить настройки": "Save settings", "Создать запись": "Create booking", "Запись в СТО": "Book service", "СТО": "Service centers", "Сервисы": "Services", "Каталог": "Catalog", "Заявка": "Request", "Выберите сервис": "Choose service", "Город": "City", "Специализация": "Specialization", "Найти": "Search", "Что нужно сделать": "What needs to be done", "Услуга": "Service", "Длительность": "Duration", "Свободное окно": "Available slot", "Окно записи": "Booking slot", "Отправить заявку": "Send request", "Проверить карточку авто": "Check vehicle card", "Меню": "Menu", "Проверить вход": "Check login", "Открыть в Telegram": "Open in Telegram", "Обновить": "Refresh", "Настройки": "Settings", "Язык": "Language", "Панель СТО": "Service workplace", "Записи клиентов": "Client bookings", "Заказ-наряды": "Work orders", "Сотрудники": "Staff", "Пригласить": "Invite", "Заказ-наряд": "Work order", "Работы": "Labor", "Запчасти и жидкости": "Parts and fluids", "Проверьте смету": "Review estimate", "Согласовать": "Approve", "Отклонить": "Reject", "Админ-панель": "Admin panel", "Операционный обзор": "Operational overview", "Последние события": "Latest events", "Быстрые переходы": "Quick links", "Заявки СТО": "Service applications", "Записи": "Bookings", "Фильтр": "Filter", "Фильтры": "Filters", "Запросить": "Query", "Показать": "Show", "Импорт и экспорт": "Import and export", "Скачать JSON": "Download JSON", "Проверить файл": "Preview file", "Импортировать": "Import", "Паспорт автомобиля": "Vehicle passport", "Выберите автомобиль": "Choose vehicle", "Параметры авто": "Vehicle settings", "Сохранить паспорт": "Save passport", "Удалить автомобиль": "Delete vehicle", "Готов к работе": "Ready", "Сохраняю...": "Saving...", "Сохранено": "Saved", "Ошибка": "Error", "Нет данных": "No data", "Нет доступа": "No access", }, ko: { "Гараж": "차고", "Автомобили": "차량", "Автомобиль": "차량", "Авто": "차량", "Заправка": "주유", "Сервис": "정비", "Расход": "지출", "Расходы": "지출", "ТО и ремонт": "정비 및 수리", "Дата": "날짜", "Одометр, км": "주행거리, km", "Одометр": "주행거리", "Литры": "리터", "Цена за литр": "리터당 가격", "АЗС": "주유소", "Полный бак": "가득 주유", "Стоимость": "비용", "Валюта": "통화", "Категория": "카테고리", "Название": "제목", "Комментарий": "메모", "Сохранить": "저장", "Сохранить запись": "기록 저장", "Сохранить расход": "지출 저장", "Сохранить заправку": "주유 저장", "Сохранить настройки": "설정 저장", "Создать запись": "예약 생성", "Запись в СТО": "정비소 예약", "СТО": "정비소", "Сервисы": "서비스", "Каталог": "목록", "Заявка": "요청", "Выберите сервис": "정비소 선택", "Город": "도시", "Специализация": "전문 분야", "Найти": "검색", "Что нужно сделать": "필요한 작업", "Услуга": "서비스", "Длительность": "소요 시간", "Свободное окно": "예약 가능 시간", "Окно записи": "예약 시간", "Отправить заявку": "요청 보내기", "Проверить карточку авто": "차량 카드 확인", "Меню": "메뉴", "Проверить вход": "로그인 확인", "Открыть в Telegram": "텔레그램에서 열기", "Обновить": "새로고침", "Настройки": "설정", "Язык": "언어", "Панель СТО": "정비소 작업실", "Записи клиентов": "고객 예약", "Заказ-наряды": "작업지시서", "Сотрудники": "직원", "Пригласить": "초대", "Заказ-наряд": "작업지시서", "Работы": "공임", "Запчасти и жидкости": "부품 및 오일", "Проверьте смету": "견적 확인", "Согласовать": "승인", "Отклонить": "거절", "Админ-панель": "관리자 패널", "Операционный обзор": "운영 요약", "Последние события": "최근 이벤트", "Быстрые переходы": "빠른 이동", "Заявки СТО": "정비소 신청", "Записи": "예약", "Фильтр": "필터", "Фильтры": "필터", "Запросить": "조회", "Показать": "보기", "Импорт и экспорт": "가져오기/내보내기", "Скачать JSON": "JSON 다운로드", "Проверить файл": "파일 확인", "Импортировать": "가져오기", "Паспорт автомобиля": "차량 패스포트", "Выберите автомобиль": "차량 선택", "Параметры авто": "차량 설정", "Сохранить паспорт": "패스포트 저장", "Удалить автомобиль": "차량 삭제", "Готов к работе": "준비 완료", "Сохраняю...": "저장 중...", "Сохранено": "저장됨", "Ошибка": "오류", "Нет данных": "데이터 없음", "Нет доступа": "접근 불가", }, }; function t(text, locale = currentLocale()) { return dictionaries[locale]?.[text] || text; } function currentLocale() { return window.CarPassPage?.state?.user?.locale || localStorage.getItem("carpassLocale") || "ru"; } function apply(root = document.body, locale = currentLocale()) { document.documentElement.lang = locale; const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(node) { const parent = node.parentElement; if (!parent || ["SCRIPT", "STYLE", "TEXTAREA", "INPUT", "SELECT"].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, locale)); } 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], locale)); }); }); } function observe(root = document.body) { if (observer || !root) return; observer = new MutationObserver(() => { window.clearTimeout(timer); timer = window.setTimeout(() => apply(root), 40); }); observer.observe(root, { childList: true, subtree: true }); } return { apply, t, currentLocale, observe }; })(); const CarPassPage = (() => { const state = { user: null, authConfig: null }; function authHeaders(extra = {}) { const headers = { ...extra }; if (tg?.initData) headers["X-Telegram-Init-Data"] = tg.initData; if (!tg?.initData && state.authConfig?.allow_dev_auth) { headers["X-Dev-Telegram-Id"] = localStorage.getItem("driversDevTelegramId") || "1"; } return headers; } async function api(path, options = {}) { const { headers: optionHeaders = {}, ...fetchOptions } = options; const headers = { "Content-Type": "application/json", ...authHeaders(optionHeaders) }; if (options.body instanceof FormData) delete headers["Content-Type"]; const response = await fetch(`/api${path}`, { ...fetchOptions, headers }); if (!response.ok) throw new Error(await response.text() || response.statusText); if (response.status === 204) return null; return response.json(); } async function loadAuthConfig() { state.authConfig = await api("/users/auth/config"); } function showAuthOverlay() { document.body.classList.add("auth-required"); const link = document.querySelector("#telegramLoginLink"); if (state.authConfig?.bot_username && link) { link.href = `https://t.me/${state.authConfig.bot_username}`; link.classList.remove("hidden"); } } async function ensureUser() { if (tg?.initData) { state.user = await api("/users/webapp-auth", { method: "POST", body: JSON.stringify({ init_data: tg.initData }), }); } else if (state.authConfig?.allow_dev_auth) { state.user = await api("/users/me"); } else { showAuthOverlay(); throw new Error("Требуется вход через Telegram"); } document.body.classList.remove("auth-required"); document.querySelector("#authOverlay")?.classList.add("hidden"); localStorage.setItem("carpassLocale", state.user.locale || "ru"); return state.user; } 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 setBusy(button, busy, label = "Сохраняю...") { if (!button) 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, label, callback) { setBusy(button, true, label); try { const result = await callback(); return result; } catch (error) { toast(error.message || "Ошибка", "error"); throw error; } finally { setBusy(button, false, label); } } function escapeHtml(value) { return String(value ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function formData(form) { return Object.fromEntries(new FormData(form).entries()); } function numberOrNull(value) { return value === "" || value == null ? null : Number(value); } function csvList(value) { return value ? value.split(",").map((item) => item.trim()).filter(Boolean) : null; } function formatDateTime(value) { if (!value) return "-"; const date = new Date(value); if (Number.isNaN(date.getTime())) return String(value).slice(0, 16).replace("T", " "); const locale = { ru: "ru-RU", en: "en-US", ko: "ko-KR" }[state.user?.locale] || "ru-RU"; return date.toLocaleString(locale, { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" }); } function today() { return new Date().toISOString().slice(0, 10); } function t(text) { return CarPassI18n.t(text, state.user?.locale || "ru"); } function applyTranslations(root = document.body) { CarPassI18n.apply(root, state.user?.locale || "ru"); } async function updateLocale(locale) { if (!state.user || state.user.locale === locale) return; state.user = await api(`/users/${state.user.id}/preferences`, { method: "PATCH", body: JSON.stringify({ locale }), }); localStorage.setItem("carpassLocale", locale); applyTranslations(); } function installLocaleSwitch() { const topbar = document.querySelector(".topbar"); if (!topbar || document.querySelector("#globalLocaleSelect")) return; let host = topbar.querySelector(".top-actions"); if (!host) { host = document.createElement("div"); host.className = "top-actions"; topbar.appendChild(host); } const select = document.createElement("select"); select.id = "globalLocaleSelect"; select.className = "locale-switch"; select.setAttribute("aria-label", "Язык"); select.innerHTML = ` `; select.value = state.user?.locale || "ru"; select.addEventListener("change", async () => { try { await updateLocale(select.value); toast("Сохранено"); } catch (error) { toast(error.message || "Ошибка", "error"); } }); const primarySelect = host.querySelector("select:not(.locale-switch)"); if (primarySelect) primarySelect.insertAdjacentElement("afterend", select); else host.prepend(select); } async function boot(init) { try { await loadAuthConfig(); await ensureUser(); installLocaleSwitch(); applyTranslations(); CarPassI18n.observe(); await init(); applyTranslations(); } catch (error) { if (error.message === "Требуется вход через Telegram") return; console.error(error); toast(error.message || "Ошибка", "error"); } } document.querySelector("#telegramRetryBtn")?.addEventListener("click", () => window.location.reload()); return { state, api, boot, toast, runAction, escapeHtml, formData, numberOrNull, csvList, formatDateTime, today, t, applyTranslations, installLocaleSwitch, updateLocale, }; })();