Fixed log mobile view, plot, privacy banner
Build and Publish / Build and Publish Docker Image (push) Successful in 1m42s

This commit is contained in:
ab
2026-09-20 21:00:03 +03:00
parent 9587622285
commit 6449d8b59a
9 changed files with 295 additions and 47 deletions
+3 -3
View File
@@ -1585,8 +1585,8 @@ async fn analytics_events(
let mut out = Vec::with_capacity(page.len());
for e in &page {
let client_name = e.client_id.as_ref().map(|fk| {
let cid = fk.primary_key().unwrap();
let client_id = e.client_id.as_ref().map(|fk| fk.primary_key().unwrap());
let client_name = client_id.map(|cid| {
clients
.iter()
.find(|c| c.id.unwrap() == cid)
@@ -1602,12 +1602,12 @@ async fn analytics_events(
"id": e.id.unwrap(),
"created_at": local.format("%Y-%m-%d %H:%M").to_string(),
"event_type": e.event_type,
"client_id": client_id,
"client_name": client_name,
"visitor": e.visitor_hash.chars().take(8).collect::<String>(),
"media": media_ref,
"user_agent": e.user_agent,
"referer": e.referer,
"path": e.path,
}));
}
+28
View File
@@ -223,6 +223,10 @@ pub struct Translations {
pub analytics_ev_landing_view: &'static str,
pub analytics_ev_portal_open: &'static str,
pub analytics_ev_media_view: &'static str,
pub analytics_ev_landing_short: &'static str,
pub analytics_ev_portal_short: &'static str,
pub analytics_ev_media_short: &'static str,
pub analytics_log_media_prefix: &'static str,
// Dashboard
pub dashboard_title: &'static str,
@@ -281,6 +285,12 @@ pub struct Translations {
pub landing_footer_text: &'static str,
pub landing_footer_copyright: &'static str,
// Cookies & privacy
pub cookie_banner_text: &'static str,
pub cookie_banner_accept: &'static str,
pub cookie_privacy_link: &'static str,
pub privacy_title: &'static str,
// Testimonials admin
pub nav_testimonials: &'static str,
pub testimonials_title: &'static str,
@@ -545,6 +555,10 @@ static RU: Translations = Translations {
analytics_ev_landing_view: "Заход на сайт",
analytics_ev_portal_open: "Открытие страницы клиента",
analytics_ev_media_view: "Просмотр фото",
analytics_ev_landing_short: "Сайт",
analytics_ev_portal_short: "Портал",
analytics_ev_media_short: "Фото",
analytics_log_media_prefix: "Просмотр медиа",
dashboard_title: "Главная",
dashboard_today_visits: "Визиты на сегодня",
@@ -680,6 +694,11 @@ static RU: Translations = Translations {
landing_footer_text: "МурНяня.РФ — Присмотрим, погладим, покормим",
landing_footer_copyright: "Все права защищены",
cookie_banner_text: "Мы используем файлы cookie для работы сайта и анонимной статистики посещений.",
cookie_banner_accept: "Принять",
cookie_privacy_link: "Политика конфиденциальности",
privacy_title: "Политика конфиденциальности и использования cookie",
nav_testimonials: "Отзывы",
testimonials_title: "Отзывы",
testimonials_empty: "Отзывов пока нет.",
@@ -857,6 +876,10 @@ static EN: Translations = Translations {
analytics_ev_landing_view: "Landing visit",
analytics_ev_portal_open: "Client portal open",
analytics_ev_media_view: "Photo view",
analytics_ev_landing_short: "Site",
analytics_ev_portal_short: "Portal",
analytics_ev_media_short: "Photos",
analytics_log_media_prefix: "Media view",
dashboard_title: "Home",
dashboard_today_visits: "Today's visits",
@@ -992,6 +1015,11 @@ static EN: Translations = Translations {
landing_footer_text: "МурНяня.РФ — Присмотрим, погладим, покормим",
landing_footer_copyright: "All rights reserved",
cookie_banner_text: "We use cookies to run the site and for anonymous visit statistics.",
cookie_banner_accept: "Accept",
cookie_privacy_link: "Privacy Policy",
privacy_title: "Privacy & Cookie Policy",
nav_testimonials: "Testimonials",
testimonials_title: "Testimonials",
testimonials_empty: "No testimonials yet.",
+14
View File
@@ -105,6 +105,19 @@ struct ThankYouTemplate<'a> {
lang: Lang,
}
#[derive(Debug, Template)]
#[template(path = "privacy.html")]
struct PrivacyTemplate<'a> {
t: &'a Translations,
lang: Lang,
}
async fn privacy_page(request: Request) -> cot::Result<Response> {
let lang = detect_lang(&request);
let body = PrivacyTemplate { t: lang.t(), lang }.render()?;
html_response(body, lang)
}
async fn landing_page(request: Request, db: Database) -> cot::Result<Response> {
let lang = detect_lang(&request);
@@ -1235,6 +1248,7 @@ pub fn public_router() -> Router {
Route::with_handler_and_name("/static/{filename}", serve_static, "static-file"),
Route::with_handler_and_name("/robots.txt", robots_txt, "robots-txt"),
Route::with_handler_and_name("/sitemap.xml", sitemap_xml, "sitemap-xml"),
Route::with_handler_and_name("/privacy", privacy_page, "privacy"),
Route::with_handler_and_name("/submit", submit_lead, "submit-lead"),
Route::with_handler_and_name(
"/testimonial-image/{id}",
+101 -42
View File
@@ -25,7 +25,8 @@
"visitor": "{{ t.analytics_log_visitor }}",
"landing_view": "{{ t.analytics_ev_landing_view }}",
"portal_open": "{{ t.analytics_ev_portal_open }}",
"media_view": "{{ t.analytics_ev_media_view }}"
"media_view": "{{ t.analytics_ev_media_view }}",
"media_prefix": "{{ t.analytics_log_media_prefix }}"
}'>
<div class="analytics-presets">
<button type="button" class="button is-small preset-btn" data-days="7">{{ t.analytics_preset_7 }}</button>
@@ -52,14 +53,6 @@
<div class="analytics-kpis" id="anKpis"></div>
<div class="analytics-metric-toggles">
<span class="toggles-label">{{ t.analytics_metrics_label }}:</span>
<label><input type="checkbox" class="metric-toggle" data-key="landing_views"> {{ t.analytics_metric_landing_views }}</label>
<label><input type="checkbox" class="metric-toggle" data-key="landing_unique" checked> {{ t.analytics_metric_landing_unique }}</label>
<label><input type="checkbox" class="metric-toggle" data-key="portal_opens" checked> {{ t.analytics_metric_portal_opens }}</label>
<label><input type="checkbox" class="metric-toggle" data-key="media_views" checked> {{ t.analytics_metric_media_views }}</label>
</div>
<div class="analytics-chart-wrap">
<div class="analytics-chart-box">
<canvas id="anChart"></canvas>
@@ -86,11 +79,19 @@
<div class="analytics-log-card">
<div class="analytics-log-head">
<h2>{{ t.analytics_log_title }}</h2>
<div class="analytics-metric-toggles analytics-log-filters">
<span class="toggles-label">{{ t.analytics_log_show }}:</span>
<label><input type="checkbox" class="log-type-toggle" data-type="landing_view"> {{ t.analytics_ev_landing_view }}</label>
<label><input type="checkbox" class="log-type-toggle" data-type="portal_open" checked> {{ t.analytics_ev_portal_open }}</label>
<label><input type="checkbox" class="log-type-toggle" data-type="media_view" checked> {{ t.analytics_ev_media_view }}</label>
<div class="analytics-log-filters">
<label class="log-chip" title="{{ t.analytics_ev_landing_view }}">
<input type="checkbox" class="log-type-toggle" data-type="landing_view">
<span>🌐 {{ t.analytics_ev_landing_short }}</span>
</label>
<label class="log-chip" title="{{ t.analytics_ev_portal_open }}">
<input type="checkbox" class="log-type-toggle" data-type="portal_open" checked>
<span>👤 {{ t.analytics_ev_portal_short }}</span>
</label>
<label class="log-chip" title="{{ t.analytics_ev_media_view }}">
<input type="checkbox" class="log-type-toggle" data-type="media_view" checked>
<span>🖼️ {{ t.analytics_ev_media_short }}</span>
</label>
</div>
</div>
<div class="analytics-log" id="anLog"></div>
@@ -127,7 +128,14 @@
.analytics-log-card { background:#fff; border-radius:12px; padding:16px; box-shadow:0 1px 4px rgba(0,0,0,.06); margin-top:20px; }
.analytics-log-card h2 { font-size:1rem; margin:0; }
.analytics-log-head { display:flex; flex-wrap:wrap; gap:10px 18px; align-items:center; justify-content:space-between; margin-bottom:10px; }
.analytics-log-filters { margin-bottom:0; }
.analytics-log-filters { display:flex; flex-wrap:wrap; gap:6px; }
.log-chip { display:inline-flex; align-items:center; gap:5px; padding:4px 11px; border-radius:999px; border:1px solid var(--admin-border,#e0e0e6); background:#f5f5f7; color:#8a8a95; font-size:.8rem; line-height:1.3; cursor:pointer; user-select:none; white-space:nowrap; transition:background .12s,color .12s,border-color .12s; }
.log-chip input { position:absolute; opacity:0; width:0; height:0; pointer-events:none; }
.log-chip:has(input:checked) { background:var(--admin-accent-soft,#eef0fb); border-color:#c9c6ee; color:#5b4fc4; font-weight:600; }
@media (max-width:600px) {
.analytics-log-head { gap:8px; margin-bottom:8px; }
.log-chip { padding:5px 12px; font-size:.82rem; }
}
.analytics-log { height:460px; overflow-y:auto; overflow-x:hidden; border:1px solid #f0f0f0; border-radius:8px; }
.an-log-row { display:grid; grid-template-columns:40px 1fr; gap:10px; align-items:start; padding:11px 12px; border-bottom:1px solid #f2f2f4; font-size:.85rem; }
.an-log-row:last-child { border-bottom:none; }
@@ -136,7 +144,10 @@
.an-log-main { min-width:0; }
.an-log-head { display:flex; flex-wrap:wrap; gap:2px 8px; align-items:baseline; }
.an-log-event { font-weight:650; }
.an-log-client { color:#7c6ed4; font-weight:600; }
.an-log-client { color:#7c6ed4; font-weight:600; text-decoration:none; }
.an-log-client:hover { text-decoration:underline; }
.an-log-num { color:#7c6ed4; font-weight:600; text-decoration:none; }
a.an-log-num:hover { text-decoration:underline; }
.an-log-time { color:#9a9aa6; font-size:.75rem; white-space:nowrap; margin-left:auto; }
.an-log-meta { color:#8a8a95; font-size:.76rem; margin-top:4px; display:flex; flex-direction:column; gap:2px; }
.an-log-meta .an-log-line { display:flex; flex-wrap:wrap; gap:4px 12px; }
@@ -157,7 +168,7 @@
var L = JSON.parse(controls.getAttribute('data-labels'));
var META = [
{ key: 'landing_views', color: '#7c6ed4' },
{ key: 'landing_views', color: '#7c6ed4', hidden: true },
{ key: 'landing_unique', color: '#00b496' },
{ key: 'portal_opens', color: '#ff8c26' },
{ key: 'media_views', color: '#ff5287' }
@@ -212,6 +223,23 @@
function metricLabel(key) { return L[key] || key; }
var MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
// Turns an ISO bucket label into a short display date:
// "2026-04-11" -> "11 Apr", "2026-04" -> "Apr 2026".
function formatLabel(raw) {
if (!raw) { return raw; }
var parts = String(raw).split('-');
if (parts.length === 3) {
return parseInt(parts[2], 10) + ' ' + MONTHS[parseInt(parts[1], 10) - 1];
}
if (parts.length === 2) {
return MONTHS[parseInt(parts[1], 10) - 1] + ' ' + parts[0];
}
return raw;
}
function hexToRgba(hex, a) {
var n = parseInt(hex.slice(1), 16);
return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + a + ')';
@@ -239,10 +267,6 @@
function initChart() {
var ctx = document.getElementById('anChart').getContext('2d');
var enabled = {};
document.querySelectorAll('.metric-toggle').forEach(function (cb) {
enabled[cb.getAttribute('data-key')] = cb.checked;
});
var datasets = META.map(function (m) {
return {
label: metricLabel(m.key),
@@ -253,7 +277,7 @@
pointRadius: 2,
tension: 0.25,
fill: true,
hidden: enabled[m.key] === false,
hidden: !!m.hidden,
_key: m.key
};
});
@@ -265,8 +289,39 @@
maintainAspectRatio: false,
animation: false,
interaction: { mode: 'index', intersect: false },
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true, ticks: { precision: 0 } } }
plugins: {
legend: {
display: true,
position: 'top',
labels: {
usePointStyle: true,
pointStyle: 'rectRounded',
boxWidth: 12,
boxHeight: 12,
padding: 12,
font: { size: 12 }
}
},
tooltip: {
callbacks: {
title: function (items) {
return items.length ? formatLabel(items[0].label) : '';
}
}
}
},
scales: {
x: {
ticks: {
autoSkip: true,
maxRotation: 0,
callback: function (value) {
return formatLabel(this.getLabelForValue(value));
}
}
},
y: { beginAtZero: true, ticks: { precision: 0 } }
}
},
plugins: [messageOverlay]
});
@@ -401,15 +456,6 @@
fromInput.addEventListener('change', load);
toInput.addEventListener('change', load);
granSelect.addEventListener('change', load);
document.querySelectorAll('.metric-toggle').forEach(function (cb) {
cb.addEventListener('change', function () {
if (!chart) { return; }
chart.data.datasets.forEach(function (ds) {
if (ds._key === cb.getAttribute('data-key')) { ds.hidden = !cb.checked; }
});
chart.update();
});
});
// --- Event log (newest first, infinite scroll of older events) ---------
var logEl = document.getElementById('anLog');
@@ -487,16 +533,37 @@
var head = document.createElement('div');
head.className = 'an-log-head';
var ev = document.createElement('span');
ev.className = 'an-log-event';
if (e.event_type === 'media_view') {
ev.textContent = L.media_prefix;
head.appendChild(ev);
if (e.media && e.media.media_id != null) {
var num = document.createElement(e.client_id != null ? 'a' : 'span');
num.className = 'an-log-num';
num.textContent = '№' + e.media.media_id;
if (e.client_id != null) { num.href = '/admin/media?client_id=' + e.client_id; }
head.appendChild(num);
}
} else {
ev.textContent = L[e.event_type] || e.event_type;
head.appendChild(ev);
}
if (e.client_name) {
var cl = document.createElement('span');
var cl;
if (e.client_id != null) {
cl = document.createElement('a');
cl.href = '/admin/clients/' + e.client_id + '/edit';
} else {
cl = document.createElement('span');
}
cl.className = 'an-log-client';
cl.textContent = e.client_name;
head.appendChild(cl);
}
var tm = document.createElement('span');
tm.className = 'an-log-time';
tm.textContent = e.created_at;
@@ -512,14 +579,6 @@
if (ua) { line1.appendChild(metaSpan(ua)); }
if (line1.childNodes.length) { meta.appendChild(line1); }
if (e.path && e.path !== '/') {
var line2 = metaLine();
var code = document.createElement('code');
code.textContent = e.path;
line2.appendChild(code);
meta.appendChild(line2);
}
if (e.referer) {
var line3 = metaLine();
var ref = document.createElement('span');
+1
View File
@@ -749,5 +749,6 @@ document.addEventListener('keydown', function(event) {
});
</script>
{% include "partials/lightbox.html" %}
{% include "partials/cookie_banner.html" %}
</body>
</html>
+1
View File
@@ -569,5 +569,6 @@
})();
</script>
{% include "partials/cookie_banner.html" %}
</body>
</html>
+47
View File
@@ -0,0 +1,47 @@
<div class="cookie-banner" id="cookieBanner">
<p class="cookie-banner-text">
{{ t.cookie_banner_text }}
<a href="/privacy?lang={{ lang.code() }}">{{ t.cookie_privacy_link }}</a>
</p>
<button type="button" class="cookie-banner-accept" id="cookieAccept">{{ t.cookie_banner_accept }}</button>
</div>
<style>
.cookie-banner {
display: none;
position: fixed; left: 0; right: 0; bottom: 0; z-index: 300;
flex-wrap: wrap; gap: .75rem 1.25rem; align-items: center; justify-content: center;
padding: .85rem 1.1rem;
background: rgba(26, 26, 46, .95); color: #fff;
box-shadow: 0 -4px 24px rgba(0, 0, 0, .18);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.cookie-banner.is-open { display: flex; }
.cookie-banner-text { margin: 0; font-size: .88rem; line-height: 1.45; max-width: 720px; }
.cookie-banner-text a { color: #b9b2ff; text-decoration: underline; }
.cookie-banner-accept {
flex: 0 0 auto; border: none; cursor: pointer;
padding: .6rem 1.6rem; border-radius: 10px;
background: #6c63ff; color: #fff; font-weight: 700; font-size: .9rem;
transition: background .18s, transform .18s;
}
.cookie-banner-accept:hover { background: #5a52d5; transform: translateY(-1px); }
@media (max-width: 600px) {
.cookie-banner.is-open { flex-direction: column; text-align: center; padding: 1rem; gap: .7rem; }
.cookie-banner-accept { width: 100%; }
}
</style>
<script>
(function () {
var KEY = 'cookie_consent';
var banner = document.getElementById('cookieBanner');
if (!banner) { return; }
var accepted;
try { accepted = window.localStorage.getItem(KEY) === '1'; } catch (e) { accepted = false; }
if (accepted) { return; }
banner.classList.add('is-open');
document.getElementById('cookieAccept').addEventListener('click', function () {
try { window.localStorage.setItem(KEY, '1'); } catch (e) {}
banner.classList.remove('is-open');
});
})();
</script>
+97
View File
@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="{{ lang.code() }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex">
<title>{{ t.nav_title }} — {{ t.privacy_title }}</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<style>
:root { color-scheme: light; }
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
color: #1a1a2e; background: linear-gradient(170deg, #f0eeff 0%, #fff 60%);
min-height: 100vh; padding: 2rem 1rem;
}
.doc {
max-width: 760px; margin: 0 auto; background: #fff; border-radius: 18px;
border: 1px solid #e8e6ff; box-shadow: 0 8px 40px rgba(108,99,255,0.08);
padding: 2.4rem 2rem;
}
h1 { font-size: 1.6rem; font-weight: 800; margin-bottom: 1.2rem; line-height: 1.25; }
h2 { font-size: 1.1rem; font-weight: 700; margin: 1.6rem 0 .5rem; }
p, li { color: #444; font-size: .98rem; line-height: 1.6; }
ul { padding-left: 1.3rem; margin: .3rem 0; }
li { margin: .25rem 0; }
.muted { color: #888; font-size: .85rem; margin-top: 1.8rem; }
.back-link {
display: inline-block; margin-top: 1.8rem; padding: .7rem 1.8rem; border-radius: 12px;
background: #6c63ff; color: #fff; font-weight: 700; text-decoration: none;
transition: background .2s, transform .2s;
}
.back-link:hover { background: #5a52d5; transform: translateY(-1px); }
</style>
</head>
<body>
<div class="doc">
<h1>{{ t.privacy_title }}</h1>
{% if lang.code() == "ru" %}
<p>Настоящая политика описывает, какие данные обрабатывает сайт «{{ t.nav_title }}» и как используются файлы cookie. Продолжая пользоваться сайтом, вы соглашаетесь с условиями, изложенными ниже.</p>
<h2>Какие данные мы собираем</h2>
<ul>
<li><strong>Данные из формы заявки</strong> — имя, телефон и комментарий, которые вы указываете добровольно, чтобы мы могли связаться с вами по поводу услуги.</li>
<li><strong>Файлы cookie и техническая информация</strong> — см. раздел ниже. IP-адреса мы не храним.</li>
</ul>
<h2>Какие файлы cookie используются</h2>
<ul>
<li><strong>Языковая настройка</strong> — запоминает выбранный язык интерфейса.</li>
<li><strong>Сессия</strong> — необходима для входа администратора в панель управления.</li>
<li><strong>Статистика посещений</strong> — анонимный идентификатор, который помогает считать число уникальных посещений и просмотров. Он не содержит персональных данных, не передаётся третьим лицам и не используется для рекламы.</li>
</ul>
<h2>Цели обработки</h2>
<p>Связь с вами по оставленной заявке, оказание услуг по уходу за животными, а также улучшение работы сайта на основе обезличенной статистики.</p>
<h2>Передача третьим лицам</h2>
<p>Мы не продаём и не передаём ваши данные третьим лицам, за исключением случаев, предусмотренных законодательством.</p>
<h2>Ваши права</h2>
<p>Вы можете запросить сведения об обработке ваших данных, их изменение или удаление, а также отозвать согласие, связавшись с нами по контактам, указанным на сайте. Файлы cookie можно отключить в настройках вашего браузера.</p>
<p class="muted">Дата последнего обновления: 2026. Оператор может уточнить реквизиты и контактные данные в этом документе.</p>
{% else %}
<p>This policy explains what data the “{{ t.nav_title }}” website processes and how cookies are used. By continuing to use the site, you agree to the terms set out below.</p>
<h2>What data we collect</h2>
<ul>
<li><strong>Request form data</strong> — the name, phone number and comment you voluntarily provide so we can contact you about the service.</li>
<li><strong>Cookies and technical information</strong> — see the section below. We do not store IP addresses.</li>
</ul>
<h2>Cookies we use</h2>
<ul>
<li><strong>Language preference</strong> — remembers your selected interface language.</li>
<li><strong>Session</strong> — required for the administrator to sign in to the control panel.</li>
<li><strong>Visit statistics</strong> — an anonymous identifier that helps count unique visits and views. It contains no personal data, is not shared with third parties, and is not used for advertising.</li>
</ul>
<h2>Purposes of processing</h2>
<p>Contacting you about your request, providing pet-sitting services, and improving the website based on anonymised statistics.</p>
<h2>Sharing with third parties</h2>
<p>We do not sell or share your data with third parties, except as required by law.</p>
<h2>Your rights</h2>
<p>You may request information about the processing of your data, its correction or deletion, and withdraw your consent by contacting us via the details provided on the site. Cookies can be disabled in your browser settings.</p>
<p class="muted">Last updated: 2026. The operator may add legal and contact details to this document.</p>
{% endif %}
<a href="/?lang={{ lang.code() }}" class="back-link">{{ t.landing_thank_you_back }}</a>
</div>
</body>
</html>
+1
View File
@@ -37,5 +37,6 @@
<p>{{ t.landing_thank_you_text }}</p>
<a href="/?lang={{ lang.code() }}" class="back-link">{{ t.landing_thank_you_back }}</a>
</div>
{% include "partials/cookie_banner.html" %}
</body>
</html>