improve mini app UX and analytics

This commit is contained in:
VPN SaaS Dev
2026-05-12 04:26:24 +09:00
parent 53c3dc42ca
commit a6cdc98f7b
6 changed files with 748 additions and 91 deletions

View File

@@ -60,6 +60,7 @@
<div class="progress-track"><span id="scoreBar"></span></div>
<small id="scoreHint">Добавь авто и первую запись, чтобы видеть точные отчеты</small>
</section>
<div class="status-bar" id="statusBar" aria-live="polite">Готов к работе</div>
<section class="report-bar">
<div>
@@ -286,6 +287,7 @@
<div id="reportBody"></div>
</div>
</div>
<div class="toast hidden" id="toast" role="status" aria-live="polite"></div>
<script src="/static/app.js"></script>
</body>
</html>

View File

@@ -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", () => {

View File

@@ -876,3 +876,352 @@ select:disabled {
grid-template-columns: 1fr;
}
}
/* Modern app pass */
:root {
--bg: #f4f7f5;
--text: #121815;
--muted: #6f7a75;
--line: #dfe7e3;
--surface: #ffffff;
--soft: #f8fbf9;
--accent: #12735f;
--accent-2: #2f6f9f;
--fuel: #1f987d;
--service: #2f6f9f;
--warning: #c26b33;
--danger: #b8423a;
--shadow: 0 16px 42px rgba(27, 38, 34, 0.09);
--press-shadow: 0 8px 18px rgba(18, 115, 95, 0.18);
}
body {
background:
linear-gradient(180deg, #ffffff 0, #f4f7f5 250px),
var(--bg);
-webkit-font-smoothing: antialiased;
}
button,
.car-item,
.stat,
.action-card,
.menu-row {
position: relative;
-webkit-tap-highlight-color: transparent;
}
button {
font-weight: 750;
border-radius: 8px;
box-shadow: 0 10px 24px rgba(18, 115, 95, 0.18);
}
button:hover {
box-shadow: var(--press-shadow);
}
button:active,
.car-item:active,
.stat:active,
.action-card:active,
.menu-row:active {
transform: translateY(1px) scale(0.99);
}
button:disabled,
input:disabled,
select:disabled {
cursor: progress;
opacity: 0.68;
}
.is-busy {
pointer-events: none;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.45);
border-top-color: #fff;
border-radius: 50%;
animation: spin 720ms linear infinite;
}
button.is-busy {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.shell {
padding-top: 16px;
}
.topbar {
position: sticky;
top: 0;
z-index: 12;
margin: 0 -18px 14px;
padding: 12px 18px;
background: rgba(244, 247, 245, 0.88);
backdrop-filter: blur(14px);
border-bottom: 1px solid rgba(223, 231, 227, 0.78);
}
.topbar h1 {
font-size: clamp(28px, 6vw, 42px);
}
.icon-btn,
.ghost-btn,
.preset-row button,
.menu-row {
background: #fff;
color: var(--text);
border: 1px solid var(--line);
box-shadow: 0 6px 18px rgba(27, 38, 34, 0.06);
}
.icon-btn {
display: grid;
place-items: center;
}
.hero-grid {
gap: 14px;
}
.summary-card,
.panel,
.workspace,
.chart-card,
.report-metric,
.tip-card {
border-color: rgba(208, 220, 214, 0.9);
}
.summary-card {
min-height: 118px;
background:
linear-gradient(135deg, rgba(18, 115, 95, 0.1), rgba(255, 255, 255, 0) 54%),
var(--surface);
}
.summary-card.accent {
background:
linear-gradient(135deg, rgba(31, 152, 125, 0.14), rgba(255, 255, 255, 0) 58%),
var(--surface);
}
.summary-card.blue {
background:
linear-gradient(135deg, rgba(47, 111, 159, 0.14), rgba(255, 255, 255, 0) 58%),
var(--surface);
}
.summary-card::after {
display: none;
}
.progress-strip {
border-radius: 8px;
background:
linear-gradient(90deg, rgba(18, 115, 95, 0.08), rgba(47, 111, 159, 0.08)),
#fff;
}
.status-bar {
min-height: 36px;
display: flex;
align-items: center;
margin: -4px 0 14px;
padding: 8px 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
color: var(--muted);
font-size: 13px;
}
.report-bar,
.entry-form {
border-color: rgba(223, 231, 227, 0.86);
}
.period-controls select,
.period-controls input,
input,
select {
background: #fbfdfc;
}
.car-item,
.action-card,
.stat {
border-radius: 8px;
background: #fff;
border-color: var(--line);
transition:
transform 160ms ease,
border-color 160ms ease,
box-shadow 160ms ease,
background 160ms ease;
}
.car-item:hover,
.action-card:hover,
.stat:hover {
box-shadow: 0 12px 28px rgba(27, 38, 34, 0.08);
}
.car-item.active {
border-color: rgba(18, 115, 95, 0.55);
background: #eef8f4;
box-shadow: 0 12px 26px rgba(18, 115, 95, 0.12);
}
.car-badge {
background: linear-gradient(135deg, #d9f0e9, #dceaf5);
}
.action-card.active {
background:
linear-gradient(135deg, #12735f, #2f6f9f);
border-color: transparent;
box-shadow: 0 14px 30px rgba(18, 115, 95, 0.22);
}
.stats {
grid-template-columns: repeat(5, minmax(120px, 1fr));
}
.stat strong {
font-size: clamp(18px, 2.4vw, 24px);
}
.chart-card {
background: #fff;
}
.entry-form {
margin-top: 4px;
padding: 16px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
}
.drawer,
.report-sheet {
background: rgba(18, 24, 21, 0.42);
}
.drawer-panel,
.sheet-panel {
border-radius: 18px 18px 0 0;
}
.menu-row {
min-height: 48px;
}
.file-hint {
padding: 8px 10px;
border: 1px dashed var(--line);
border-radius: 8px;
background: #fbfdfc;
}
.toast {
position: fixed;
left: 50%;
bottom: 18px;
z-index: 40;
width: min(420px, calc(100% - 28px));
transform: translateX(-50%);
padding: 12px 14px;
border-radius: 8px;
background: #12211c;
color: #fff;
box-shadow: 0 18px 42px rgba(18, 24, 21, 0.22);
animation: toastIn 180ms ease both;
}
.toast.error {
background: #8f2f29;
}
.toast.hidden {
display: none;
}
@keyframes toastIn {
from {
opacity: 0;
transform: translate(-50%, 10px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
@media (max-width: 980px) {
.topbar {
margin: 0 -12px 12px;
padding: 10px 12px;
}
.hero-grid {
gap: 10px;
}
.summary-card {
min-width: 82vw;
}
.panel,
.workspace,
.chart-card {
padding: 14px;
}
.status-bar {
margin-top: 0;
}
.quick-actions {
top: 61px;
margin-inline: -2px;
padding: 8px 0;
background: rgba(244, 247, 245, 0.92);
}
.action-card {
min-height: 64px;
}
.stats {
display: flex;
overflow-x: auto;
scroll-snap-type: x mandatory;
padding-bottom: 4px;
}
.stat {
min-width: 68vw;
scroll-snap-align: start;
}
.entry-form {
padding: 14px;
}
.drawer-panel,
.sheet-panel {
max-height: 92vh;
}
}