Files
web-petting/templates/client_portal.html
T
Ultradesu d6e6075469
Build and Publish / Build and Publish Docker Image (push) Successful in 1m26s
Fixed notifications VAPID
2026-08-08 11:21:23 +01:00

426 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>
<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">
{% if !turnstile_site_key.is_empty() %}
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
{% endif %}
<style>
:root { color-scheme: light; }
*, *::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;
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; }
.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;
}
.media-row .vid-thumb {
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;
}
.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;
background-color: #fff; color: #333;
}
.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%; }
}
</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 %}
</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">
<!-- 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() }}">
<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" %}
<a href="/client/{{ client.media_token }}/media/{{ m.id.unwrap() }}" data-lightbox="photo">
<img src="/client/{{ client.media_token }}/media/{{ m.id.unwrap() }}/thumbnail" alt="" loading="lazy">
</a>
{% else %}
<a href="/client/{{ client.media_token }}/media/{{ m.id.unwrap() }}" data-lightbox="video">
<div class="vid-thumb">
<video src="/client/{{ client.media_token }}/media/{{ m.id.unwrap() }}#t=0.1" preload="metadata" muted playsinline></video>
<span class="video-play"></span>
</div>
</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>
{% if !turnstile_site_key.is_empty() %}
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" data-appearance="interaction-only" style="margin-top:0.4rem;"></div>
{% endif %}
<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>
{% if !turnstile_site_key.is_empty() %}
<div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="light" data-appearance="interaction-only" style="margin-top:0.4rem;"></div>
{% endif %}
<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 %}
{% 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>
</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>
</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>
<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 %}
<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;
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); });
}
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() {
if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) {
toggle.disabled = true;
showStatus('{{ t.portal_notifications_unsupported }}', true);
return;
}
registration = await navigator.serviceWorker.register('/service-worker.js');
await navigator.serviceWorker.ready;
subscription = await registration.pushManager.getSubscription();
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 }}';
}
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() {
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 }}')
});
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);
showStatus('{{ t.portal_notifications_error }}', true);
} finally {
toggle.disabled = false;
}
});
refresh().catch(function(error) {
console.error(error);
showStatus('{{ t.portal_notifications_error }}', true);
});
})();
{% endif %}
</script>
{% include "partials/lightbox.html" %}
</body>
</html>