diff --git a/src/admin.rs b/src/admin.rs index 7f15fe2..c3664a4 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -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::(), "media": media_ref, "user_agent": e.user_agent, "referer": e.referer, - "path": e.path, })); } diff --git a/src/i18n.rs b/src/i18n.rs index 61f06c5..91fdeaa 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -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.", diff --git a/src/public.rs b/src/public.rs index e8c978d..82f6c06 100644 --- a/src/public.rs +++ b/src/public.rs @@ -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 { + 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 { 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}", diff --git a/templates/admin/analytics.html b/templates/admin/analytics.html index 6e3fb13..1afb81d 100644 --- a/templates/admin/analytics.html +++ b/templates/admin/analytics.html @@ -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 }}" }'>
@@ -52,14 +53,6 @@
-
- {{ t.analytics_metrics_label }}: - - - - -
-
@@ -86,11 +79,19 @@

{{ t.analytics_log_title }}

-
- {{ t.analytics_log_show }}: - - - +
+ + +
@@ -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'; - ev.textContent = L[e.event_type] || e.event_type; - head.appendChild(ev); + 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'); diff --git a/templates/client_portal.html b/templates/client_portal.html index 04bd172..4aa6095 100644 --- a/templates/client_portal.html +++ b/templates/client_portal.html @@ -749,5 +749,6 @@ document.addEventListener('keydown', function(event) { }); {% include "partials/lightbox.html" %} +{% include "partials/cookie_banner.html" %} diff --git a/templates/landing.html b/templates/landing.html index 64d19e3..4850754 100644 --- a/templates/landing.html +++ b/templates/landing.html @@ -569,5 +569,6 @@ })(); +{% include "partials/cookie_banner.html" %} diff --git a/templates/partials/cookie_banner.html b/templates/partials/cookie_banner.html new file mode 100644 index 0000000..7c3b117 --- /dev/null +++ b/templates/partials/cookie_banner.html @@ -0,0 +1,47 @@ + + + diff --git a/templates/privacy.html b/templates/privacy.html new file mode 100644 index 0000000..d6cfe17 --- /dev/null +++ b/templates/privacy.html @@ -0,0 +1,97 @@ + + + + + + + {{ t.nav_title }} — {{ t.privacy_title }} + + + + +
+

{{ t.privacy_title }}

+ + {% if lang.code() == "ru" %} +

Настоящая политика описывает, какие данные обрабатывает сайт «{{ t.nav_title }}» и как используются файлы cookie. Продолжая пользоваться сайтом, вы соглашаетесь с условиями, изложенными ниже.

+ +

Какие данные мы собираем

+
    +
  • Данные из формы заявки — имя, телефон и комментарий, которые вы указываете добровольно, чтобы мы могли связаться с вами по поводу услуги.
  • +
  • Файлы cookie и техническая информация — см. раздел ниже. IP-адреса мы не храним.
  • +
+ +

Какие файлы cookie используются

+
    +
  • Языковая настройка — запоминает выбранный язык интерфейса.
  • +
  • Сессия — необходима для входа администратора в панель управления.
  • +
  • Статистика посещений — анонимный идентификатор, который помогает считать число уникальных посещений и просмотров. Он не содержит персональных данных, не передаётся третьим лицам и не используется для рекламы.
  • +
+ +

Цели обработки

+

Связь с вами по оставленной заявке, оказание услуг по уходу за животными, а также улучшение работы сайта на основе обезличенной статистики.

+ +

Передача третьим лицам

+

Мы не продаём и не передаём ваши данные третьим лицам, за исключением случаев, предусмотренных законодательством.

+ +

Ваши права

+

Вы можете запросить сведения об обработке ваших данных, их изменение или удаление, а также отозвать согласие, связавшись с нами по контактам, указанным на сайте. Файлы cookie можно отключить в настройках вашего браузера.

+ +

Дата последнего обновления: 2026. Оператор может уточнить реквизиты и контактные данные в этом документе.

+ {% else %} +

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.

+ +

What data we collect

+
    +
  • Request form data — the name, phone number and comment you voluntarily provide so we can contact you about the service.
  • +
  • Cookies and technical information — see the section below. We do not store IP addresses.
  • +
+ +

Cookies we use

+
    +
  • Language preference — remembers your selected interface language.
  • +
  • Session — required for the administrator to sign in to the control panel.
  • +
  • Visit statistics — 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.
  • +
+ +

Purposes of processing

+

Contacting you about your request, providing pet-sitting services, and improving the website based on anonymised statistics.

+ +

Sharing with third parties

+

We do not sell or share your data with third parties, except as required by law.

+ +

Your rights

+

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.

+ +

Last updated: 2026. The operator may add legal and contact details to this document.

+ {% endif %} + + {{ t.landing_thank_you_back }} +
+ + diff --git a/templates/thank_you.html b/templates/thank_you.html index a39528b..676c1ac 100644 --- a/templates/thank_you.html +++ b/templates/thank_you.html @@ -37,5 +37,6 @@

{{ t.landing_thank_you_text }}

{{ t.landing_thank_you_back }}
+{% include "partials/cookie_banner.html" %}