improve mini app UX and analytics
This commit is contained in:
@@ -126,6 +126,17 @@ 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.",
|
||||
"Готов к работе": "Ready",
|
||||
"Обновляю данные...": "Refreshing data...",
|
||||
"Сохраняю...": "Saving...",
|
||||
"Сохранено": "Saved",
|
||||
"Распознаю чек...": "Recognizing receipt...",
|
||||
"Выбери файл чека": "Choose receipt file",
|
||||
"Проверь распознанные значения": "Check recognized values",
|
||||
"Ошибка": "Error",
|
||||
"Прогноз цены": "Price forecast",
|
||||
"Текущая цена": "Current price",
|
||||
"Средняя цена": "Average price",
|
||||
},
|
||||
ko: {
|
||||
"Гараж": "차고",
|
||||
@@ -243,6 +254,17 @@ const i18n = {
|
||||
"PWA установлена и работает офлайн после первого открытия.": "PWA는 첫 실행 후 오프라인에서도 작동합니다.",
|
||||
"Напоминания готовы": "알림 준비 완료",
|
||||
"Мы напомним о ТО, страховке и обновлении пробега.": "정비, 보험, 주행거리 업데이트를 알려드릴게요.",
|
||||
"Готов к работе": "준비 완료",
|
||||
"Обновляю данные...": "데이터 새로고침 중...",
|
||||
"Сохраняю...": "저장 중...",
|
||||
"Сохранено": "저장됨",
|
||||
"Распознаю чек...": "영수증 인식 중...",
|
||||
"Выбери файл чека": "영수증 파일을 선택하세요",
|
||||
"Проверь распознанные значения": "인식된 값을 확인하세요",
|
||||
"Ошибка": "오류",
|
||||
"Прогноз цены": "가격 예측",
|
||||
"Текущая цена": "현재 가격",
|
||||
"Средняя цена": "평균 가격",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -380,6 +402,69 @@ async function api(path, options = {}) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
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 = `<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, 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() {
|
||||
const tgUser = tg?.initDataUnsafe?.user || fallbackUser;
|
||||
state.user = await api("/users", {
|
||||
@@ -491,6 +576,11 @@ function updateHero(stats) {
|
||||
: "-";
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -660,6 +750,8 @@ function openReport(type = "summary") {
|
||||
${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)} / л` : "-")}
|
||||
</div>
|
||||
<div class="tip-card">${analytics?.insight || t("Лучший рост точности даст привычка заносить одометр при каждой заправке и сервисе.")}</div>
|
||||
`,
|
||||
@@ -829,14 +921,19 @@ function roundRect(ctx, x, y, width, height, radius) {
|
||||
|
||||
async function loadCars() {
|
||||
document.body.classList.add("loading");
|
||||
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;
|
||||
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();
|
||||
await loadSelectedCar();
|
||||
} finally {
|
||||
document.body.classList.remove("loading");
|
||||
setStatus("Готов к работе");
|
||||
}
|
||||
renderCars();
|
||||
await loadSelectedCar();
|
||||
document.body.classList.remove("loading");
|
||||
}
|
||||
|
||||
async function selectCar(carId) {
|
||||
@@ -877,93 +974,121 @@ document.querySelectorAll('input[type="date"]').forEach((input) => {
|
||||
|
||||
applyPeriodPreset("month");
|
||||
|
||||
document.querySelector("#refreshBtn").addEventListener("click", loadCars);
|
||||
document.querySelector("#refreshBtn").addEventListener("click", (event) => {
|
||||
runAction(event.currentTarget, "Обновляю данные...", loadCars).then(() => {
|
||||
toast("Готов к работе");
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector("#periodPreset").addEventListener("change", async (event) => {
|
||||
applyPeriodPreset(event.currentTarget.value);
|
||||
await loadSelectedCar();
|
||||
await runAction(event.currentTarget, "Обновляю данные...", async () => {
|
||||
applyPeriodPreset(event.currentTarget.value);
|
||||
await loadSelectedCar();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("#periodFrom, #periodTo").forEach((input) => {
|
||||
input.addEventListener("change", async () => {
|
||||
document.querySelector("#periodPreset").value = "custom";
|
||||
applyPeriodPreset("custom");
|
||||
await loadSelectedCar();
|
||||
await runAction(input, "Обновляю данные...", async () => {
|
||||
document.querySelector("#periodPreset").value = "custom";
|
||||
applyPeriodPreset("custom");
|
||||
await loadSelectedCar();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector("#carForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const data = formData(event.currentTarget);
|
||||
await api("/cars", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
owner_id: state.user.id,
|
||||
name: data.name,
|
||||
make: data.make || null,
|
||||
model: data.model || null,
|
||||
year: data.year ? Number(data.year) : null,
|
||||
}),
|
||||
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,
|
||||
year: data.year ? Number(data.year) : null,
|
||||
}),
|
||||
});
|
||||
form.reset();
|
||||
resetCarCatalog();
|
||||
document.querySelector("#userDrawer").classList.add("hidden");
|
||||
await loadCars();
|
||||
toast("Сохранено");
|
||||
haptic("success");
|
||||
});
|
||||
event.currentTarget.reset();
|
||||
resetCarCatalog();
|
||||
document.querySelector("#userDrawer").classList.add("hidden");
|
||||
await loadCars();
|
||||
});
|
||||
|
||||
document.querySelector("#settingsForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const data = formData(event.currentTarget);
|
||||
state.user = await api(`/users/${state.user.id}/preferences`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ locale: data.locale, currency: data.currency }),
|
||||
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");
|
||||
});
|
||||
applyTranslations();
|
||||
initCarCatalog();
|
||||
await loadSelectedCar();
|
||||
document.querySelector("#userDrawer").classList.add("hidden");
|
||||
});
|
||||
|
||||
document.querySelector("#fuelForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (!state.selectedCarId) return;
|
||||
const data = formData(event.currentTarget);
|
||||
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),
|
||||
}),
|
||||
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");
|
||||
});
|
||||
event.currentTarget.reset();
|
||||
event.currentTarget.entry_date.value = today();
|
||||
await loadSelectedCar();
|
||||
});
|
||||
|
||||
document.querySelector("#serviceForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
if (!state.selectedCarId) return;
|
||||
const data = formData(event.currentTarget);
|
||||
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,
|
||||
}),
|
||||
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,
|
||||
}),
|
||||
});
|
||||
form.reset();
|
||||
form.entry_date.value = today();
|
||||
await loadSelectedCar();
|
||||
toast("Сохранено");
|
||||
haptic("success");
|
||||
});
|
||||
event.currentTarget.reset();
|
||||
event.currentTarget.entry_date.value = today();
|
||||
await loadSelectedCar();
|
||||
});
|
||||
|
||||
function setAction(action) {
|
||||
@@ -976,6 +1101,7 @@ function setAction(action) {
|
||||
|
||||
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");
|
||||
@@ -995,6 +1121,7 @@ document.querySelectorAll("[data-report]").forEach((button) => {
|
||||
|
||||
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;
|
||||
@@ -1063,16 +1190,28 @@ document.querySelector("#receiptFileInput").addEventListener("change", (event) =
|
||||
document.querySelector("#ocrForm").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const file = state.receiptFile;
|
||||
if (!file) return;
|
||||
const payload = new FormData();
|
||||
payload.append("file", file);
|
||||
const response = await fetch("/api/ocr/fuel-receipt", { method: "POST", body: payload });
|
||||
const result = await response.json();
|
||||
document.querySelector("#ocrResult").textContent = result.message;
|
||||
const form = document.querySelector("#fuelForm");
|
||||
if (result.liters) form.liters.value = result.liters;
|
||||
if (result.price_per_liter) form.price_per_liter.value = result.price_per_liter;
|
||||
setAction("fuel");
|
||||
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("#userDrawer").classList.add("hidden");
|
||||
toast("Проверь распознанные значения");
|
||||
haptic("success");
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector("#closeMenuBtn").addEventListener("click", () => {
|
||||
|
||||
Reference in New Issue
Block a user