@@ -187,6 +195,14 @@
Исполнитель
+
+
@@ -235,24 +251,6 @@
-
-
-
+ Скан чека
- -После распознавания поля заправки заполнятся автоматически.
- Новое авто
+
+
+
+
+
+
+ Скан чека
+ +После распознавания поля заправки заполнятся автоматически.
+
diff --git a/web/static/app.js b/web/static/app.js
index 5b53a8c..3114e17 100644
--- a/web/static/app.js
+++ b/web/static/app.js
@@ -126,6 +126,7 @@ const i18n = {
"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...",
@@ -254,6 +255,7 @@ const i18n = {
"PWA установлена и работает офлайн после первого открытия.": "PWA는 첫 실행 후 오프라인에서도 작동합니다.",
"Напоминания готовы": "알림 준비 완료",
"Мы напомним о ТО, страховке и обновлении пробега.": "정비, 보험, 주행거리 업데이트를 알려드릴게요.",
+ "Напоминаний на ближайшее время нет": "다가오는 알림이 없습니다",
"Готов к работе": "준비 완료",
"Обновляю данные...": "데이터 새로고침 중...",
"Сохраняю...": "저장 중...",
@@ -305,6 +307,7 @@ function applyTranslations(root = document.body) {
const state = {
user: null,
+ authConfig: null,
cars: [],
catalog: [],
selectedCarId: null,
@@ -353,7 +356,9 @@ async function enableNotifications() {
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("Мы напомним о ТО, страховке и обновлении пробега."),
@@ -365,6 +370,28 @@ async function enableNotifications() {
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, "/");
@@ -372,15 +399,6 @@ function urlBase64ToUint8Array(base64String) {
return Uint8Array.from([...rawData].map((char) => char.charCodeAt(0)));
}
-const fallbackUser = {
- id: 1,
- username: "demo",
- first_name: "Demo",
- last_name: null,
- locale: "ru",
- currency: "RUB",
-};
-
function today() {
return new Date().toISOString().slice(0, 10);
}
@@ -402,6 +420,11 @@ async function api(path, options = {}) {
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);
@@ -466,14 +489,66 @@ async function runAction(button, statusMessage, callback) {
}
async function ensureUser() {
- const tgUser = tg?.initDataUnsafe?.user || fallbackUser;
- state.user = await api("/users", {
+ 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");
+}
+
+async function showTelegramLogin() {
+ const overlay = document.querySelector("#authOverlay");
+ const slot = document.querySelector("#telegramLoginSlot");
+ overlay?.classList.remove("hidden");
+ if (!slot || slot.dataset.ready) return;
+ const botUsername = state.authConfig?.bot_username;
+ if (!botUsername) {
+ slot.textContent = "Telegram Login временно недоступен";
+ return;
+ }
+ 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)");
+ 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({
- telegram_id: tgUser.id,
- username: tgUser.username || null,
- first_name: tgUser.first_name || null,
- last_name: tgUser.last_name || null,
+ ...subscription.toJSON(),
+ user_agent: navigator.userAgent,
}),
});
}
@@ -969,7 +1044,7 @@ async function loadSelectedCar() {
}
document.querySelectorAll('input[type="date"]').forEach((input) => {
- input.value = today();
+ if (input.name !== "next_due_date") input.value = today();
});
applyPeriodPreset("month");
@@ -1081,6 +1156,8 @@ document.querySelector("#serviceForm").addEventListener("submit", async (event)
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();
@@ -1099,13 +1176,17 @@ function setAction(action) {
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") {
- document.querySelector("#userDrawer").classList.remove("hidden");
- document.querySelector("#scanSection").classList.remove("hidden");
- document.querySelector("#scanSection").scrollIntoView({ behavior: "smooth", block: "start" });
+ openScanModal();
return;
}
setAction(button.dataset.action);
@@ -1162,8 +1243,11 @@ document.querySelector("#openNotificationsBtn").addEventListener("click", () =>
document.querySelector("#enableNotificationsBtn").addEventListener("click", enableNotifications);
document.querySelector("#openScanBtn").addEventListener("click", () => {
- document.querySelector("#scanSection").classList.remove("hidden");
- document.querySelector("#scanSection").scrollIntoView({ behavior: "smooth", block: "start" });
+ openScanModal();
+});
+
+document.querySelector("#closeScanBtn").addEventListener("click", () => {
+ document.querySelector("#scanModal").classList.add("hidden");
});
function setReceiptFile(file) {
@@ -1208,7 +1292,7 @@ document.querySelector("#ocrForm").addEventListener("submit", async (event) => {
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("#userDrawer").classList.add("hidden");
+ document.querySelector("#scanModal").classList.add("hidden");
toast("Проверь распознанные значения");
haptic("success");
});
@@ -1228,7 +1312,8 @@ window.addEventListener("resize", () => {
initPwa();
-Promise.all([ensureUser(), loadCatalog()])
+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";
@@ -1237,5 +1322,6 @@ Promise.all([ensureUser(), loadCatalog()])
return loadCars();
})
.catch((error) => {
- document.body.insertAdjacentHTML("afterbegin", `
${error.message}
`);
+ if (error.message === "Требуется вход через Telegram") return;
+ document.body.insertAdjacentHTML("afterbegin", `${error.message}
`);
});
diff --git a/web/static/styles.css b/web/static/styles.css
index fb3e802..e5f3aca 100644
--- a/web/static/styles.css
+++ b/web/static/styles.css
@@ -1158,6 +1158,47 @@ select {
display: none;
}
+.auth-overlay,
+.scan-modal {
+ position: fixed;
+ inset: 0;
+ z-index: 50;
+ display: grid;
+ place-items: center;
+ padding: 18px;
+ background: rgba(18, 24, 21, 0.46);
+ backdrop-filter: blur(8px);
+}
+
+.auth-overlay.hidden,
+.scan-modal.hidden {
+ display: none;
+}
+
+.auth-panel,
+.scan-panel {
+ width: min(420px, 100%);
+ padding: 20px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #fff;
+ box-shadow: 0 22px 60px rgba(18, 24, 21, 0.24);
+}
+
+.auth-panel p:not(.eyebrow) {
+ margin: 12px 0 18px;
+ color: var(--muted);
+}
+
+.telegram-login-slot {
+ min-height: 46px;
+}
+
+.scan-form {
+ display: grid;
+ gap: 10px;
+}
+
@keyframes toastIn {
from {
opacity: 0;
diff --git a/web/sw.js b/web/sw.js
index 5465757..806ee6f 100644
--- a/web/sw.js
+++ b/web/sw.js
@@ -1,4 +1,4 @@
-const CACHE_NAME = "drivers-garage-v1";
+const CACHE_NAME = "drivers-garage-v2";
const APP_SHELL = [
"/",
"/static/app.js",