Files
web-petting/templates/client_portal.html
T

384 lines
19 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>
<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;
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); });
}
async function refresh() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
registration = await navigator.serviceWorker.register('/service-worker.js');
subscription = await registration.pushManager.getSubscription();
toggle.textContent = subscription ? '{{ t.portal_notifications_disable }}' : '{{ t.portal_notifications_enable }}';
}
window.openNotificationSettings = function() { document.getElementById('notificationModal').classList.add('open'); refresh().catch(console.error); };
window.closeNotificationSettings = function() { document.getElementById('notificationModal').classList.remove('open'); };
toggle.addEventListener('click', async function() {
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 }}')
});
var payload = subscription.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) { await subscription.unsubscribe(); subscription = null; throw new Error('subscribe failed'); }
closeNotificationSettings();
}
toggle.textContent = subscription ? '{{ t.portal_notifications_disable }}' : '{{ t.portal_notifications_enable }}';
} catch (error) {
console.error(error);
}
});
refresh().catch(console.error);
})();
{% endif %}
</script>
{% include "partials/lightbox.html" %}
</body>
</html>