445 lines
16 KiB
JavaScript
445 lines
16 KiB
JavaScript
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, "&")
|
||
.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 = `
|
||
<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,
|
||
};
|
||
})();
|