426 lines
21 KiB
HTML
426 lines
21 KiB
HTML
<!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>
|