Files
web-petting/templates/client_portal.html
T

439 lines
21 KiB
HTML
Raw Normal View History

2026-04-29 17:49:07 +03:00
<!DOCTYPE html>
<html lang="{{ lang.code() }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ t.portal_title }} — {{ client.name }}</title>
2026-05-14 16:12:33 +03:00
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="manifest" href="/client/{{ client.media_token }}/manifest.webmanifest">
<meta name="theme-color" content="#7c6cff">
<meta name="apple-mobile-web-app-capable" content="yes">
2026-05-18 22:12:54 +03:00
{% if !turnstile_site_key.is_empty() %}
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
{% endif %}
2026-04-29 17:49:07 +03:00
<style>
2026-05-14 16:12:33 +03:00
:root { color-scheme: light; }
2026-04-29 17:49:07 +03:00
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: #2d2b55; line-height: 1.6; background: #f8f7ff;
padding: 0 0 2rem;
}
.portal-header {
position: relative;
2026-04-29 17:49:07 +03:00
background: linear-gradient(135deg, #7c6cff, #b06cff);
color: #fff; padding: 2rem 1.5rem 1.5rem; text-align: center;
}
.portal-header h1 { font-size: 1.5rem; font-weight: 700; }
.portal-header .sub { opacity: 0.85; font-size: 0.9rem; margin-top: 0.25rem; }
.container { max-width: 1100px; margin: 0 auto; padding: 0 1rem; }
.portal-grid { display: grid; grid-template-columns: minmax(0, 700px) 320px; gap: 1.25rem; align-items: start; }
.portal-settings { position: absolute; right: 1rem; bottom: 1rem; width: 38px; height: 38px; border: 0; border-radius: 50%; background: rgba(255,255,255,.2); color: #fff; font-size: 1.1rem; cursor: pointer; }
2026-04-29 17:49:07 +03:00
.section-title {
font-size: 1.15rem; font-weight: 700; margin: 1.5rem 0 0.75rem;
padding-bottom: 0.4rem; border-bottom: 2px solid #ede7f6;
}
.visit-card {
background: #fff; border-radius: 12px; padding: 1rem;
margin-bottom: 0.75rem; border: 1px solid #eee;
}
.visit-card-head {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 0.3rem;
}
.visit-card-head .date { font-weight: 700; font-size: 0.95rem; }
.visit-card-head .time { color: #7a7599; font-size: 0.85rem; }
.visit-card .admin { color: #999; font-size: 0.82rem; }
.badge-sm {
display: inline-block; padding: 0.1rem 0.5rem; border-radius: 99px;
font-size: 0.7rem; font-weight: 600;
}
.badge-completed { background: #d1fae5; color: #065f46; }
.badge-scheduled { background: #dbeafe; color: #1e40af; }
.media-row {
display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.5rem;
}
.media-row a { display: block; cursor: pointer; }
.media-row img {
width: 80px; height: 60px; object-fit: cover; border-radius: 6px;
}
2026-08-09 01:05:47 +01:00
.media-row .media-thumb-frame {
width: 80px; height: 60px; border-radius: 6px;
}
2026-04-29 17:49:07 +03:00
.media-row .vid-thumb {
2026-08-08 10:19:47 +01:00
position: relative; width: 80px; height: 60px; border-radius: 6px;
overflow: hidden; background: #111;
}
.media-row .vid-thumb video {
width: 100%; height: 100%; display: block; object-fit: cover;
}
.media-row .video-play {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
color: white; font-size: 1.35rem; line-height: 1;
text-shadow: 0 1px 4px #000; pointer-events: none;
2026-04-29 17:49:07 +03:00
}
.feedback-form { margin-top: 0.6rem; }
.feedback-form textarea {
width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #ddd; border-radius: 8px;
font-size: 0.85rem; font-family: inherit; resize: vertical; min-height: 50px;
2026-05-14 16:12:33 +03:00
background-color: #fff; color: #333;
2026-04-29 17:49:07 +03:00
}
.feedback-form textarea:focus { outline: none; border-color: #7c6cff; }
.feedback-form button {
margin-top: 0.3rem; padding: 0.35rem 1rem; border: none; border-radius: 8px;
background: #7c6cff; color: #fff; font-size: 0.82rem; font-weight: 600;
cursor: pointer;
}
.feedback-text {
margin-top: 0.4rem; padding: 0.5rem 0.75rem; background: #f0f0ff;
border-radius: 8px; font-size: 0.85rem; color: #4a4570;
display: flex; justify-content: space-between; align-items: flex-start; gap: 0.5rem;
}
.fb-edit-btn {
background: none; border: none; cursor: pointer; font-size: 0.85rem;
opacity: 0.35; padding: 0; line-height: 1; flex-shrink: 0;
}
.fb-edit-btn:hover { opacity: 0.7; }
.fb-cancel-btn {
padding: 0.35rem 0.75rem; border: 1px solid #ddd; border-radius: 8px;
background: #fff; font-size: 0.82rem; cursor: pointer; color: #999;
}
.success-msg {
background: #d1fae5; color: #065f46; padding: 0.6rem 1rem;
border-radius: 8px; margin: 1rem 0; font-size: 0.9rem; text-align: center;
}
.empty-msg { color: #999; font-size: 0.9rem; padding: 0.5rem 0; }
.lang-switch { text-align: right; padding: 0.5rem 1rem 0; }
.lang-switch a { color: #7c6cff; font-size: 0.85rem; text-decoration: none; }
/* Compact upcoming schedule */
.upcoming-list {
display: flex; flex-direction: column; gap: 0.35rem;
}
.upcoming-row {
display: flex; align-items: center; gap: 0.75rem;
background: #fff; border-radius: 8px; padding: 0.5rem 0.75rem;
border: 1px solid #eee; font-size: 0.85rem;
}
.upcoming-row .up-date {
font-weight: 700; min-width: 5.5rem;
}
.upcoming-row .up-time { color: #7a7599; }
.calendar-panel { position: sticky; top: 1rem; margin-top: 1.5rem; background: #fff; border: 1px solid #eee; border-radius: 12px; padding: .85rem; }
.calendar-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: .65rem; }
.calendar-head button { border: 0; background: #f0edff; color: #6255c7; width: 30px; height: 30px; border-radius: 50%; cursor: pointer; }
.calendar-title { font-size: .95rem; font-weight: 700; }
.calendar-weekdays, .calendar-days { display: grid; grid-template-columns: repeat(7, 1fr); gap: 3px; text-align: center; }
.calendar-weekdays { color: #999; font-size: .68rem; margin-bottom: 3px; }
.calendar-day { aspect-ratio: 1; display: flex; align-items: center; justify-content: center; border-radius: 7px; font-size: .78rem; color: #aaa; }
.calendar-day.past { background: #ede9ff; color: #5145a6; font-weight: 700; text-decoration: none; }
.calendar-day.past:hover { background: #dcd5ff; }
.calendar-day.future { background: #f3f3f3; color: #bbb; border: 1px dashed #ddd; }
.calendar-month[hidden] { display: none; }
.calendar-legend { margin-top: .65rem; font-size: .72rem; color: #999; }
.pagination { display: flex; justify-content: center; align-items: center; gap: .75rem; margin: 1rem 0; font-size: .85rem; }
.pagination a { color: #6558c8; text-decoration: none; padding: .35rem .7rem; background: #fff; border: 1px solid #e5e1ff; border-radius: 8px; }
.modal-bg { display: none; position: fixed; inset: 0; z-index: 1000; background: rgba(20,18,40,.55); align-items: center; justify-content: center; padding: 1rem; }
.modal-bg.open { display: flex; }
.notification-modal { width: min(420px, 100%); background: #fff; border-radius: 14px; padding: 1.2rem; box-shadow: 0 15px 50px rgba(0,0,0,.25); }
.notification-modal h2 { font-size: 1.15rem; margin-bottom: .45rem; }
.notification-modal p { font-size: .88rem; color: #777; }
.notification-actions { display: flex; gap: .5rem; margin-top: 1rem; }
.notification-actions button { border: 0; border-radius: 8px; padding: .55rem .9rem; cursor: pointer; }
.notification-primary { background: #7567e8; color: #fff; }
@media (max-width: 800px) {
.portal-grid { display: flex; flex-direction: column; }
.calendar-panel { position: static; order: -1; width: 100%; margin-top: 1rem; }
.visits-column { width: 100%; }
}
2026-04-29 17:49:07 +03:00
</style>
</head>
<body>
<div class="lang-switch">
<a href="?lang={{ lang.other().code() }}">{{ lang.other().label() }}</a>
</div>
<div class="portal-header">
<h1>{{ t.portal_title }}</h1>
<div class="sub">{{ client.name }}</div>
{% if notifications_enabled %}<button class="portal-settings" type="button" onclick="openNotificationSettings()" aria-label="{{ t.portal_notifications }}"></button>{% endif %}
2026-04-29 17:49:07 +03:00
</div>
<div class="container">
{% if feedback_sent %}
<div class="success-msg">{{ t.portal_feedback_thanks }}</div>
{% endif %}
<div class="portal-grid">
<main class="visits-column">
2026-04-29 17:49:07 +03:00
<!-- Past visits with media first -->
<h2 class="section-title">{{ t.portal_past }}</h2>
{% if past.is_empty() %}
<p class="empty-msg">{{ t.portal_no_past }}</p>
{% else %}
{% for pv in &past %}
<div class="visit-card" id="visit-{{ pv.visit.id.unwrap() }}">
2026-04-29 17:49:07 +03:00
<div class="visit-card-head">
<span class="date">{{ pv.visit.visit_date }}</span>
<span class="badge-sm badge-{{ pv.visit.status }}">
{{ t.visit_status(&pv.visit.status) }}
</span>
</div>
<div class="visit-card-head" style="margin-bottom:0;">
<span class="time">{{ pv.visit.time_start }} — {{ pv.visit.time_end }}</span>
<span class="admin">{{ pv.admin_name }}</span>
</div>
{% if let Some(pn) = pv.visit.public_notes.as_deref() %}
<div style="margin-top:0.4rem;padding:0.5rem 0.75rem;background:#f5f0ff;border-radius:8px;font-size:0.85rem;color:#4a4570;">{{ pn }}</div>
{% endif %}
{% if !pv.media.is_empty() %}
<div class="media-row">
{% for m in &pv.media %}
{% if m.file_type == "photo" %}
2026-08-09 00:15:40 +01:00
<a href="{{ m.url }}" data-lightbox="photo">
2026-08-09 01:05:47 +01:00
<span class="media-thumb-frame media-loading-frame is-loading">
<img src="{{ m.thumbnail_url }}" alt="" loading="lazy" data-media-load>
</span>
2026-04-29 17:49:07 +03:00
</a>
{% else %}
2026-08-09 00:15:40 +01:00
<a href="{{ m.url }}" data-lightbox="video">
2026-08-09 01:05:47 +01:00
<div class="vid-thumb media-loading-frame{% if !m.thumbnail_url.is_empty() %} is-loading{% endif %}" data-video-preview>
{% if !m.thumbnail_url.is_empty() %}
<img class="video-preview-sprite" src="{{ m.thumbnail_url }}" alt="" loading="lazy" data-media-load data-preview-frames="4">
{% endif %}
2026-08-08 10:19:47 +01:00
<span class="video-play"></span>
</div>
2026-04-29 17:49:07 +03:00
</a>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% if pv.visit.status == "completed" %}
{% if let Some(fb) = pv.visit.client_feedback.as_deref() %}
<div class="feedback-view" id="fb-view-{{ pv.visit.id }}">
<div class="feedback-text">
<span>{{ fb }}</span>
<button class="fb-edit-btn" onclick="showFbEdit({{ pv.visit.id }})" title="Edit">✏️</button>
</div>
</div>
<form class="feedback-form" id="fb-form-{{ pv.visit.id }}" style="display:none;" method="post" action="/client/{{ client.media_token }}/{{ pv.visit.id }}/feedback">
<textarea name="feedback" required>{{ fb }}</textarea>
2026-05-18 22:12:54 +03:00
{% if !turnstile_site_key.is_empty() %}
2026-05-18 22:30:36 +03:00
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" data-appearance="interaction-only" style="margin-top:0.4rem;"></div>
2026-05-18 22:12:54 +03:00
{% endif %}
2026-04-29 17:49:07 +03:00
<div style="display:flex;gap:0.4rem;">
<button type="submit">{{ t.portal_feedback_submit }}</button>
<button type="button" class="fb-cancel-btn" onclick="hideFbEdit({{ pv.visit.id }})"></button>
</div>
</form>
{% else %}
<form class="feedback-form" method="post" action="/client/{{ client.media_token }}/{{ pv.visit.id }}/feedback">
<textarea name="feedback" placeholder="{{ t.portal_feedback_placeholder }}" required></textarea>
2026-05-18 22:12:54 +03:00
{% if !turnstile_site_key.is_empty() %}
2026-05-18 22:30:36 +03:00
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" data-appearance="interaction-only" style="margin-top:0.4rem;"></div>
2026-05-18 22:12:54 +03:00
{% endif %}
2026-04-29 17:49:07 +03:00
<button type="submit">{{ t.portal_feedback_submit }}</button>
</form>
{% endif %}
{% endif %}
</div>
{% endfor %}
{% if total_pages > 1 %}
<nav class="pagination">
{% if has_previous_page %}<a href="?page={{ page - 1 }}">← {{ t.portal_previous }}</a>{% endif %}
<span>{{ page }} / {{ total_pages }}</span>
{% if has_next_page %}<a href="?page={{ page + 1 }}">{{ t.portal_next }} →</a>{% endif %}
</nav>
{% endif %}
2026-04-29 17:49:07 +03:00
{% endif %}
<!-- Compact upcoming schedule -->
{% if !upcoming.is_empty() %}
<h2 class="section-title">{{ t.portal_upcoming }}</h2>
<div class="upcoming-list">
{% for pv in &upcoming %}
<div class="upcoming-row">
<span class="up-date">{{ pv.visit.visit_date }}</span>
<span class="up-time">{{ t.portal_future_visit }}</span>
2026-04-29 17:49:07 +03:00
</div>
{% endfor %}
</div>
{% endif %}
</main>
{% if !calendar_months.is_empty() %}
<aside class="calendar-panel">
<div class="calendar-head">
<button type="button" onclick="moveCalendar(1)" aria-label="{{ t.portal_previous }}"></button>
<span class="calendar-title" id="calendarTitle">{{ t.portal_calendar }}</span>
<button type="button" onclick="moveCalendar(-1)" aria-label="{{ t.portal_next }}"></button>
</div>
<div class="calendar-weekdays"><span>Пн</span><span>Вт</span><span>Ср</span><span>Чт</span><span>Пт</span><span>Сб</span><span>Вс</span></div>
{% for month in &calendar_months %}
<div class="calendar-month" data-label="{{ month.label }}"{% if !loop.first %} hidden{% endif %}>
<div class="calendar-days">
{% for _blank in &month.leading_blanks %}<span></span>{% endfor %}
{% for day in &month.days %}
{% if let Some(href) = day.href.as_deref() %}
<a class="calendar-day {{ day.class_name }}" href="{{ href }}" title="{{ day.title }}">{{ day.number }}</a>
{% else %}
<span class="calendar-day {{ day.class_name }}" title="{{ day.title }}">{{ day.number }}</span>
{% endif %}
{% endfor %}
</div>
</div>
{% endfor %}
<div class="calendar-legend">{{ t.portal_future_visit }} — ···</div>
</aside>
{% endif %}
</div>
2026-04-29 17:49:07 +03:00
</div>
{% if notifications_enabled %}
<div class="modal-bg" id="notificationModal" onclick="if(event.target===this) closeNotificationSettings()">
<div class="notification-modal">
<h2>{{ t.portal_notifications }}</h2>
<p id="notificationText">{{ t.portal_notifications_text }}</p>
2026-08-08 11:21:23 +01:00
<p id="notificationStatus" style="display:none;margin-top:0.65rem;font-weight:600;"></p>
<div class="notification-actions">
<button type="button" class="notification-primary" id="notificationToggle">{{ t.portal_notifications_enable }}</button>
<button type="button" onclick="closeNotificationSettings()"></button>
</div>
</div>
</div>
{% endif %}
2026-04-29 17:49:07 +03:00
<script>
function showFbEdit(id) {
document.getElementById('fb-view-' + id).style.display = 'none';
document.getElementById('fb-form-' + id).style.display = '';
}
function hideFbEdit(id) {
document.getElementById('fb-form-' + id).style.display = 'none';
document.getElementById('fb-view-' + id).style.display = '';
}
var calendarIndex = 0;
function renderCalendar() {
var months = document.querySelectorAll('.calendar-month');
if (!months.length) return;
months.forEach(function(month, index) { month.hidden = index !== calendarIndex; });
document.getElementById('calendarTitle').textContent = months[calendarIndex].dataset.label;
}
function moveCalendar(delta) {
var months = document.querySelectorAll('.calendar-month');
calendarIndex = Math.max(0, Math.min(months.length - 1, calendarIndex + delta));
renderCalendar();
}
renderCalendar();
{% if notifications_enabled %}
(function() {
var toggle = document.getElementById('notificationToggle');
var registration;
var subscription;
2026-08-08 11:21:23 +01:00
var status = document.getElementById('notificationStatus');
function decodeKey(value) {
var padding = '='.repeat((4 - value.length % 4) % 4);
var raw = atob((value + padding).replace(/-/g, '+').replace(/_/g, '/'));
return Uint8Array.from(raw, function(char) { return char.charCodeAt(0); });
}
2026-08-08 11:21:23 +01:00
function sameKey(left, right) {
if (!left || left.byteLength !== right.byteLength) return false;
var a = new Uint8Array(left), b = new Uint8Array(right);
return a.every(function(value, index) { return value === b[index]; });
}
function showStatus(message, error) {
status.textContent = message;
status.style.display = '';
status.style.color = error ? '#b42318' : '#067647';
}
async function saveSubscription(value) {
var payload = value.toJSON();
payload.language = '{{ lang.code() }}';
var response = await fetch('/client/{{ client.media_token }}/push/subscribe', {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
});
if (!response.ok) throw new Error('Subscription API returned HTTP ' + response.status);
}
async function refresh() {
2026-08-08 11:21:23 +01:00
if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) {
toggle.disabled = true;
showStatus('{{ t.portal_notifications_unsupported }}', true);
return;
}
2026-08-08 11:45:29 +01:00
if (!'{{ vapid_public_key }}') {
toggle.disabled = true;
showStatus('{{ t.portal_notifications_error }}', true);
return;
}
registration = await navigator.serviceWorker.register('/service-worker.js');
2026-08-08 11:45:29 +01:00
await registration.update();
2026-08-08 11:21:23 +01:00
await navigator.serviceWorker.ready;
subscription = await registration.pushManager.getSubscription();
2026-08-08 11:21:23 +01:00
var expectedKey = decodeKey('{{ vapid_public_key }}');
if (subscription && !sameKey(subscription.options.applicationServerKey, expectedKey)) {
await subscription.unsubscribe();
subscription = null;
}
if (subscription) {
await saveSubscription(subscription);
showStatus('{{ t.portal_notifications_active }}', false);
}
toggle.textContent = subscription ? '{{ t.portal_notifications_disable }}' : '{{ t.portal_notifications_enable }}';
}
2026-08-08 11:21:23 +01:00
window.openNotificationSettings = function() {
document.getElementById('notificationModal').classList.add('open');
refresh().catch(function(error) {
console.error(error);
showStatus('{{ t.portal_notifications_error }}', true);
});
};
window.closeNotificationSettings = function() { document.getElementById('notificationModal').classList.remove('open'); };
toggle.addEventListener('click', async function() {
2026-08-08 11:21:23 +01:00
toggle.disabled = true;
try {
if (!registration) await refresh();
if (subscription) {
await fetch('/client/{{ client.media_token }}/push/unsubscribe', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({endpoint: subscription.endpoint})
});
await subscription.unsubscribe();
subscription = null;
} else {
var permission = await Notification.requestPermission();
if (permission !== 'granted') {
document.getElementById('notificationText').textContent = '{{ t.portal_notifications_denied }}';
return;
}
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: decodeKey('{{ vapid_public_key }}')
});
2026-08-08 11:21:23 +01:00
await saveSubscription(subscription);
showStatus('{{ t.portal_notifications_active }}', false);
}
toggle.textContent = subscription ? '{{ t.portal_notifications_disable }}' : '{{ t.portal_notifications_enable }}';
} catch (error) {
console.error(error);
2026-08-08 11:21:23 +01:00
showStatus('{{ t.portal_notifications_error }}', true);
} finally {
toggle.disabled = false;
}
});
2026-08-08 11:21:23 +01:00
refresh().catch(function(error) {
console.error(error);
showStatus('{{ t.portal_notifications_error }}', true);
});
})();
{% endif %}
2026-04-29 17:49:07 +03:00
</script>
{% include "partials/lightbox.html" %}
</body>
</html>