Files
drivers_bot/web/static/page_common.js
VPN SaaS Dev 58ff6ff614
Some checks failed
ci / test (push) Has been cancelled
compact UI and add localization switch
2026-05-19 05:05:24 +09:00

445 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = `<span class="spinner"></span><span>${t(label)}</span>`;
} 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 = `
<option value="ru">RU</option>
<option value="en">EN</option>
<option value="ko">KO</option>
`;
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,
};
})();