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">
|
2026-08-08 11:02:56 +01:00
|
|
|
|
<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;
|
|
|
|
|
|
}
|
2026-09-01 10:51:22 +01:00
|
|
|
|
body.portal-overlay-open { overflow: hidden; }
|
2026-04-29 17:49:07 +03:00
|
|
|
|
.portal-header {
|
2026-09-01 10:51:22 +01:00
|
|
|
|
position: relative; isolation: isolate; overflow: visible; min-height: 150px;
|
|
|
|
|
|
display: grid; place-items: center; padding: 2rem 1.5rem 1.5rem;
|
|
|
|
|
|
color: #2d2b55; text-align: center;
|
|
|
|
|
|
background: rgba(255,255,255,.38);
|
|
|
|
|
|
border-block: 1px solid rgba(124,108,255,.1);
|
2026-04-29 17:49:07 +03:00
|
|
|
|
}
|
2026-09-01 10:51:22 +01:00
|
|
|
|
.portal-header::after {
|
|
|
|
|
|
content: ""; position: absolute; inset: 0; z-index: 1; pointer-events: none;
|
|
|
|
|
|
background:
|
|
|
|
|
|
radial-gradient(circle at 18% 15%, rgba(255,255,255,.7), transparent 35%),
|
|
|
|
|
|
linear-gradient(135deg, rgba(124,108,255,.06), rgba(176,108,255,.08));
|
|
|
|
|
|
}
|
|
|
|
|
|
.portal-header.has-media { color: #fff; }
|
|
|
|
|
|
.portal-header.has-media::after {
|
|
|
|
|
|
background: linear-gradient(135deg, rgba(30,25,65,.32), rgba(78,45,105,.2));
|
|
|
|
|
|
}
|
|
|
|
|
|
.portal-header-backdrop {
|
|
|
|
|
|
position: absolute; inset: 0; z-index: 0; overflow: hidden; pointer-events: none;
|
|
|
|
|
|
}
|
|
|
|
|
|
.portal-header-content {
|
|
|
|
|
|
position: absolute; left: 50%; top: 50%; z-index: 2;
|
|
|
|
|
|
width: max-content; max-width: calc(100% - 2rem);
|
|
|
|
|
|
transform: translate(-50%, -50%);
|
|
|
|
|
|
}
|
|
|
|
|
|
.portal-header.has-media .portal-header-content {
|
|
|
|
|
|
padding: .5rem 1.25rem; border-radius: 14px;
|
|
|
|
|
|
background: rgba(18,13,42,.62); box-shadow: 0 8px 32px rgba(18,13,42,.2);
|
|
|
|
|
|
text-shadow: 0 2px 12px rgba(0,0,0,.55);
|
|
|
|
|
|
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
|
|
|
|
|
}
|
|
|
|
|
|
.header-media-grid {
|
|
|
|
|
|
width: 100%; height: 100%; display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(var(--header-cols, 5), minmax(0, 1fr));
|
|
|
|
|
|
grid-template-rows: repeat(var(--header-rows, 2), minmax(0, 1fr));
|
|
|
|
|
|
gap: 3px; background: #d9d4ee; perspective: 900px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.header-media-tile {
|
|
|
|
|
|
position: relative; min-width: 0; min-height: 0; overflow: hidden; background: #ddd8ed;
|
|
|
|
|
|
transform-style: preserve-3d;
|
|
|
|
|
|
}
|
|
|
|
|
|
.header-media-tile[hidden] { display: none; }
|
|
|
|
|
|
.header-media-tile.is-flipping { animation: header-tile-flip .8s ease-in-out; }
|
|
|
|
|
|
.header-media-image { display: block; width: 100%; height: 100%; object-fit: cover; }
|
|
|
|
|
|
.header-media-image.header-video-preview { position: absolute; max-width: none; object-fit: unset; }
|
|
|
|
|
|
.header-media-image.header-video-preview:not(.is-fitted) { opacity: 0; }
|
|
|
|
|
|
@keyframes header-tile-flip {
|
|
|
|
|
|
0%, 100% { transform: rotateY(0deg); }
|
|
|
|
|
|
49% { transform: rotateY(88deg); }
|
|
|
|
|
|
50% { transform: rotateY(-88deg); }
|
|
|
|
|
|
}
|
|
|
|
|
|
.header-decorations { position: absolute; inset: 0; overflow: hidden; }
|
|
|
|
|
|
.header-deco-item {
|
|
|
|
|
|
position: absolute; width: 72px; height: 72px; opacity: .25;
|
|
|
|
|
|
animation: header-icon-drift 58s ease-in-out infinite alternate;
|
|
|
|
|
|
}
|
|
|
|
|
|
.header-deco-item svg { width: 100%; height: 100%; animation: header-icon-spin 110s linear infinite; }
|
|
|
|
|
|
.header-deco-item:nth-child(1) { left: 3%; top: 10%; color: #7c6cff; }
|
|
|
|
|
|
.header-deco-item:nth-child(2) { left: 18%; bottom: -12%; width: 88px; height: 88px; color: #ff8c26; animation-duration: 71s; animation-delay: -18s; }
|
|
|
|
|
|
.header-deco-item:nth-child(3) { left: 38%; top: -20%; width: 96px; height: 96px; color: #00a98f; animation-duration: 83s; animation-delay: -31s; }
|
|
|
|
|
|
.header-deco-item:nth-child(4) { right: 35%; bottom: -28%; width: 104px; height: 104px; color: #b06cff; animation-duration: 67s; animation-delay: -9s; }
|
|
|
|
|
|
.header-deco-item:nth-child(5) { right: 18%; top: 8%; width: 78px; height: 78px; color: #ff5287; animation-duration: 76s; animation-delay: -42s; }
|
|
|
|
|
|
.header-deco-item:nth-child(6) { right: 3%; bottom: -5%; width: 92px; height: 92px; color: #4d90e8; animation-duration: 89s; animation-delay: -25s; }
|
|
|
|
|
|
.header-deco-item:nth-child(even) svg { animation-direction: reverse; animation-duration: 135s; }
|
|
|
|
|
|
@keyframes header-icon-drift {
|
|
|
|
|
|
from { transform: translate3d(-8px, -4px, 0); }
|
|
|
|
|
|
to { transform: translate3d(14px, 10px, 0); }
|
|
|
|
|
|
}
|
|
|
|
|
|
@keyframes header-icon-spin { to { transform: rotate(360deg); } }
|
2026-04-29 17:49:07 +03:00
|
|
|
|
.portal-header h1 { font-size: 1.5rem; font-weight: 700; }
|
2026-09-01 10:51:22 +01:00
|
|
|
|
.portal-header .sub { opacity: 0.88; font-size: 0.9rem; margin-top: 0.25rem; }
|
|
|
|
|
|
.portal-header.has-media .sub { opacity: .95; }
|
2026-08-08 11:02:56 +01:00
|
|
|
|
.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; }
|
2026-09-01 10:51:22 +01:00
|
|
|
|
.portal-settings {
|
|
|
|
|
|
position: absolute; right: 1rem; bottom: 1rem; z-index: 3;
|
|
|
|
|
|
display: grid; place-items: center; width: 42px; height: 42px;
|
|
|
|
|
|
border: 1px solid rgba(124,108,255,.2); border-radius: 50%;
|
|
|
|
|
|
background: rgba(255,255,255,.72); color: #6255c7; font-size: 1.15rem;
|
|
|
|
|
|
box-shadow: 0 8px 24px rgba(57,44,126,.14); cursor: pointer;
|
|
|
|
|
|
backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
|
|
|
|
|
|
}
|
|
|
|
|
|
.portal-header.has-media .portal-settings { color: #fff; background: rgba(30,24,60,.34); border-color: rgba(255,255,255,.3); }
|
|
|
|
|
|
.portal-settings.mobile-only-settings { display: none; }
|
|
|
|
|
|
.portal-settings:hover { transform: translateY(-1px); }
|
|
|
|
|
|
.portal-settings:focus-visible { outline: 3px solid rgba(124,108,255,.35); outline-offset: 3px; }
|
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; }
|
2026-09-01 10:51:22 +01:00
|
|
|
|
.calendar-panel { position: sticky; top: 1rem; margin-top: 1.5rem; }
|
|
|
|
|
|
.calendar-surface { background: #fff; border: 1px solid #eee; border-radius: 12px; padding: .85rem; }
|
2026-08-08 11:02:56 +01:00
|
|
|
|
.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; }
|
2026-09-01 10:51:22 +01:00
|
|
|
|
.calendar-close { display: none; }
|
2026-08-08 11:02:56 +01:00
|
|
|
|
.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; }
|
2026-09-01 10:51:22 +01:00
|
|
|
|
.calendar-notification-settings {
|
|
|
|
|
|
position: relative; margin-top: .9rem; padding-top: .9rem;
|
|
|
|
|
|
border-top: 1px solid #ece9f5;
|
|
|
|
|
|
}
|
|
|
|
|
|
.calendar-notification-settings:first-child { margin-top: 0; padding-top: 0; border-top: 0; }
|
|
|
|
|
|
.notification-setting-head {
|
|
|
|
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
|
|
|
|
gap: .75rem; margin-bottom: .65rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
.notification-setting-title { font-size: .88rem; font-weight: 700; }
|
|
|
|
|
|
.notification-info-wrap { position: relative; display: inline-flex; }
|
|
|
|
|
|
.notification-info-button {
|
|
|
|
|
|
display: grid; place-items: center; width: 24px; height: 24px;
|
|
|
|
|
|
border: 1px solid #cfc9ed; border-radius: 50%; background: #f6f4ff;
|
|
|
|
|
|
color: #6558c8; font: 700 .78rem/1 Georgia, serif; cursor: help;
|
|
|
|
|
|
}
|
|
|
|
|
|
.notification-info-button:focus-visible { outline: 3px solid rgba(124,108,255,.25); outline-offset: 2px; }
|
|
|
|
|
|
.notification-help {
|
|
|
|
|
|
display: none; position: absolute; right: 0; bottom: calc(100% + .55rem); z-index: 20;
|
|
|
|
|
|
width: min(280px, calc(100vw - 3rem)); padding: .7rem .8rem; border-radius: 10px;
|
|
|
|
|
|
color: #fff; background: #302b52; box-shadow: 0 12px 32px rgba(20,18,40,.28);
|
|
|
|
|
|
font-size: .76rem; font-weight: 400; line-height: 1.45; text-align: left;
|
|
|
|
|
|
}
|
|
|
|
|
|
.notification-help::after {
|
|
|
|
|
|
content: ""; position: absolute; right: 7px; top: 100%;
|
|
|
|
|
|
border: 6px solid transparent; border-top-color: #302b52;
|
|
|
|
|
|
}
|
|
|
|
|
|
.notification-info-wrap:hover .notification-help,
|
|
|
|
|
|
.notification-info-wrap:focus-within .notification-help,
|
|
|
|
|
|
.notification-info-wrap.is-open .notification-help { display: block; }
|
|
|
|
|
|
.notification-toggle {
|
|
|
|
|
|
width: 100%; border: 0; border-radius: 9px; padding: .58rem .75rem;
|
|
|
|
|
|
color: #fff; background: #7567e8; font: inherit; font-size: .8rem;
|
|
|
|
|
|
font-weight: 600; cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
.notification-toggle:disabled { cursor: not-allowed; opacity: .55; }
|
|
|
|
|
|
.notification-status { display: none; margin-top: .5rem; font-size: .74rem; line-height: 1.4; }
|
2026-08-08 11:02:56 +01:00
|
|
|
|
.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; }
|
|
|
|
|
|
@media (max-width: 800px) {
|
2026-09-01 10:51:22 +01:00
|
|
|
|
.portal-header { min-height: 178px; padding: 2.2rem 4rem 1.8rem; }
|
|
|
|
|
|
.header-deco-item { opacity: .2; }
|
2026-08-08 11:02:56 +01:00
|
|
|
|
.portal-grid { display: flex; flex-direction: column; }
|
|
|
|
|
|
.visits-column { width: 100%; }
|
2026-09-01 10:51:22 +01:00
|
|
|
|
.portal-settings,
|
|
|
|
|
|
.portal-settings.mobile-only-settings {
|
|
|
|
|
|
position: fixed; right: max(1rem, env(safe-area-inset-right));
|
|
|
|
|
|
bottom: max(1rem, env(safe-area-inset-bottom)); z-index: 900;
|
|
|
|
|
|
display: grid; width: 50px; height: 50px;
|
|
|
|
|
|
color: #fff; background: rgba(88,72,190,.9); border-color: rgba(255,255,255,.45);
|
|
|
|
|
|
box-shadow: 0 10px 30px rgba(57,44,126,.28);
|
|
|
|
|
|
}
|
|
|
|
|
|
.portal-header.has-media .portal-settings { background: rgba(88,72,190,.9); }
|
|
|
|
|
|
.calendar-panel {
|
|
|
|
|
|
display: none; position: fixed; inset: 0; z-index: 1000; width: auto;
|
|
|
|
|
|
margin: 0; padding: 1rem max(1rem, env(safe-area-inset-right)) max(1rem, env(safe-area-inset-bottom)) max(1rem, env(safe-area-inset-left));
|
|
|
|
|
|
align-items: flex-end; justify-content: center; overflow-y: auto;
|
|
|
|
|
|
background: rgba(20,18,40,.58); backdrop-filter: blur(5px); -webkit-backdrop-filter: blur(5px);
|
|
|
|
|
|
}
|
|
|
|
|
|
.calendar-panel.open { display: flex; }
|
|
|
|
|
|
.calendar-surface {
|
|
|
|
|
|
width: min(440px, 100%); max-height: min(88dvh, 680px); overflow-y: auto;
|
|
|
|
|
|
margin: auto auto 0; padding: 1rem; border: 0; border-radius: 20px;
|
|
|
|
|
|
box-shadow: 0 22px 70px rgba(20,18,40,.28);
|
|
|
|
|
|
}
|
|
|
|
|
|
.calendar-head { gap: .55rem; }
|
|
|
|
|
|
.calendar-title { flex: 1; text-align: center; }
|
|
|
|
|
|
.calendar-close {
|
|
|
|
|
|
display: grid; place-items: center; margin-left: .2rem;
|
|
|
|
|
|
background: #f5f3fa !important; color: #756f8f !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
.calendar-day { font-size: .84rem; }
|
|
|
|
|
|
.calendar-notification-settings { margin-top: 1rem; padding-top: 1rem; }
|
|
|
|
|
|
.notification-setting-title { font-size: .92rem; }
|
|
|
|
|
|
.notification-toggle { padding: .68rem 1rem; font-size: .86rem; }
|
|
|
|
|
|
.notification-help { font-size: .8rem; }
|
|
|
|
|
|
}
|
|
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
|
|
|
|
.header-media-tile.is-flipping,
|
|
|
|
|
|
.header-deco-item,
|
|
|
|
|
|
.header-deco-item svg { animation: none !important; }
|
2026-08-08 11:02:56 +01:00
|
|
|
|
}
|
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>
|
|
|
|
|
|
|
2026-09-01 10:51:22 +01:00
|
|
|
|
<div class="portal-header{% if !header_media.is_empty() %} has-media{% endif %}">
|
|
|
|
|
|
<div class="portal-header-backdrop" aria-hidden="true">
|
|
|
|
|
|
{% if header_media.is_empty() %}
|
|
|
|
|
|
<div class="header-decorations">
|
|
|
|
|
|
<span class="header-deco-item"><svg viewBox="0 0 80 80"><path d="M20 15 10 2 18 18M60 15 70 2 62 18M40 70C18 70 8 52 8 38 8 20 22 8 40 8s32 12 32 30c0 14-10 32-32 32Z" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"/><circle cx="28" cy="36" r="3.5" fill="currentColor"/><circle cx="52" cy="36" r="3.5" fill="currentColor"/><ellipse cx="40" cy="48" rx="4" ry="2.5" fill="currentColor"/><path d="M36 48q-4 6-8 4m16-4q4 6 8 4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></span>
|
|
|
|
|
|
<span class="header-deco-item"><svg viewBox="0 0 80 50"><path d="M8 18q0 28 32 28t32-28Z" fill="currentColor" opacity=".2"/><ellipse cx="40" cy="18" rx="34" ry="10" fill="none" stroke="currentColor" stroke-width="3"/><path d="M8 18q0 28 32 28t32-28" fill="none" stroke="currentColor" stroke-width="3" stroke-linejoin="round"/></svg></span>
|
|
|
|
|
|
<span class="header-deco-item"><svg viewBox="0 0 60 70"><ellipse cx="30" cy="46" rx="16" ry="18" fill="currentColor" opacity=".55"/><ellipse cx="14" cy="22" rx="8" ry="10" fill="currentColor" opacity=".55" transform="rotate(-10 14 22)"/><ellipse cx="46" cy="22" rx="8" ry="10" fill="currentColor" opacity=".55" transform="rotate(10 46 22)"/><ellipse cx="24" cy="8" rx="6" ry="8" fill="currentColor" opacity=".55"/><ellipse cx="38" cy="8" rx="6" ry="8" fill="currentColor" opacity=".55"/></svg></span>
|
|
|
|
|
|
<span class="header-deco-item"><svg viewBox="0 0 80 40"><path d="M60 20Q48 2 28 8 10 14 10 20t18 12q20 6 32-12Z" fill="currentColor" opacity=".25" stroke="currentColor" stroke-width="2.5"/><path d="m60 20 14-14v28Z" fill="currentColor" opacity=".25" stroke="currentColor" stroke-width="2.5" stroke-linejoin="round"/><circle cx="24" cy="18" r="2.5" fill="currentColor"/></svg></span>
|
|
|
|
|
|
<span class="header-deco-item"><svg viewBox="0 0 60 55"><path d="M30 50C10 35 0 22 0 14 0 5 7 0 15 0c6 0 11 3 15 9 4-6 9-9 15-9 8 0 15 5 15 14 0 8-10 21-30 36Z" fill="currentColor" opacity=".5"/></svg></span>
|
|
|
|
|
|
<span class="header-deco-item"><svg viewBox="0 0 70 50"><ellipse cx="32" cy="28" rx="22" ry="16" fill="currentColor" opacity=".2"/><ellipse cx="32" cy="28" rx="22" ry="16" fill="none" stroke="currentColor" stroke-width="2.5"/><path d="M12 22Q2 10 10 6m2 28Q2 40 6 46" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/><circle cx="22" cy="24" r="2" fill="currentColor"/><path d="M54 28q14-10 10 4-4 8-8-2" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"/></svg></span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{% else %}
|
|
|
|
|
|
<div class="header-media-grid" data-header-gallery>
|
|
|
|
|
|
{% for m in &header_media %}
|
|
|
|
|
|
<div class="header-media-tile" data-media-src="{{ m.thumbnail_url }}" data-media-kind="{{ m.file_type }}">
|
|
|
|
|
|
<img class="header-media-image{% if m.file_type == "video" %} header-video-preview{% endif %}" src="{{ m.thumbnail_url }}" alt="">
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{% endfor %}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{% endif %}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="portal-header-content">
|
|
|
|
|
|
<h1>{{ t.portal_title }}</h1>
|
|
|
|
|
|
<div class="sub">{{ client.name }}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{% if show_portal_settings %}
|
|
|
|
|
|
<button class="portal-settings mobile-only-settings" type="button" onclick="openPortalSettings()" aria-label="{{ t.portal_calendar }}" aria-expanded="false">📅</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 %}
|
|
|
|
|
|
|
2026-08-08 11:02:56 +01:00
|
|
|
|
<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 %}
|
2026-08-08 11:02:56 +01:00
|
|
|
|
<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 %}
|
2026-08-08 11:02:56 +01:00
|
|
|
|
{% 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>
|
2026-08-08 11:02:56 +01:00
|
|
|
|
<span class="up-time">{{ t.portal_future_visit }}</span>
|
2026-04-29 17:49:07 +03:00
|
|
|
|
</div>
|
|
|
|
|
|
{% endfor %}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{% endif %}
|
2026-08-08 11:02:56 +01:00
|
|
|
|
</main>
|
|
|
|
|
|
|
2026-09-01 10:51:22 +01:00
|
|
|
|
{% if show_portal_settings %}
|
|
|
|
|
|
<aside class="calendar-panel" id="calendarPanel" onclick="if(event.target===this) closePortalSettings()">
|
|
|
|
|
|
<div class="calendar-surface">
|
|
|
|
|
|
{% if !calendar_months.is_empty() %}
|
2026-08-08 11:02:56 +01:00
|
|
|
|
<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>
|
2026-09-01 10:51:22 +01:00
|
|
|
|
<button class="calendar-close" type="button" onclick="closePortalSettings()" aria-label="{{ t.portal_settings }}">×</button>
|
2026-08-08 11:02:56 +01:00
|
|
|
|
</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>
|
2026-09-01 10:51:22 +01:00
|
|
|
|
{% else %}
|
|
|
|
|
|
<div class="calendar-head">
|
|
|
|
|
|
<span class="calendar-title">{{ t.portal_settings }}</span>
|
|
|
|
|
|
<button class="calendar-close" type="button" onclick="closePortalSettings()" aria-label="{{ t.portal_settings }}">×</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{% endif %}
|
|
|
|
|
|
{% if notifications_enabled %}
|
|
|
|
|
|
<section class="calendar-notification-settings" aria-labelledby="notificationSettingTitle">
|
|
|
|
|
|
<div class="notification-setting-head">
|
|
|
|
|
|
<h2 class="notification-setting-title" id="notificationSettingTitle">{{ t.portal_notifications }}</h2>
|
|
|
|
|
|
<span class="notification-info-wrap">
|
|
|
|
|
|
<button class="notification-info-button" type="button" onclick="toggleNotificationHelp(event)" aria-label="{{ t.portal_notifications_text }}" aria-expanded="false" aria-describedby="notificationHelp">i</button>
|
|
|
|
|
|
<span class="notification-help" id="notificationHelp" role="tooltip">{{ t.portal_notifications_text }}</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button class="notification-toggle" type="button" id="notificationToggle">{{ t.portal_notifications_enable }}</button>
|
|
|
|
|
|
<p class="notification-status" id="notificationStatus"></p>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
{% endif %}
|
|
|
|
|
|
</div>
|
2026-08-08 11:02:56 +01:00
|
|
|
|
</aside>
|
|
|
|
|
|
{% endif %}
|
|
|
|
|
|
</div>
|
2026-04-29 17:49:07 +03:00
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<script>
|
2026-09-01 10:51:22 +01:00
|
|
|
|
(function() {
|
|
|
|
|
|
var grid = document.querySelector('[data-header-gallery]');
|
|
|
|
|
|
if (!grid) return;
|
|
|
|
|
|
|
|
|
|
|
|
var tiles = Array.from(grid.querySelectorAll('.header-media-tile'));
|
|
|
|
|
|
var sources = tiles.map(function(tile) {
|
|
|
|
|
|
return { src: tile.dataset.mediaSrc, kind: tile.dataset.mediaKind };
|
|
|
|
|
|
});
|
|
|
|
|
|
var mobile = window.matchMedia('(max-width: 800px)');
|
|
|
|
|
|
|
|
|
|
|
|
function resetHeaderImageBox(image) {
|
|
|
|
|
|
['width', 'height', 'left', 'top'].forEach(function(property) {
|
|
|
|
|
|
image.style.removeProperty(property);
|
|
|
|
|
|
});
|
|
|
|
|
|
image.classList.remove('is-fitted');
|
|
|
|
|
|
}
|
|
|
|
|
|
function fitHeaderMediaImage(image) {
|
|
|
|
|
|
resetHeaderImageBox(image);
|
|
|
|
|
|
if (!image.classList.contains('header-video-preview')) return;
|
|
|
|
|
|
|
|
|
|
|
|
var tile = image.closest('.header-media-tile');
|
|
|
|
|
|
if (!tile || !image.naturalWidth || !image.naturalHeight || !tile.clientWidth || !tile.clientHeight) return;
|
|
|
|
|
|
|
|
|
|
|
|
var frameWidth = image.naturalWidth / 4;
|
|
|
|
|
|
var scale = Math.max(tile.clientWidth / frameWidth, tile.clientHeight / image.naturalHeight);
|
|
|
|
|
|
var renderedFrameWidth = frameWidth * scale;
|
|
|
|
|
|
var renderedHeight = image.naturalHeight * scale;
|
|
|
|
|
|
image.style.width = image.naturalWidth * scale + 'px';
|
|
|
|
|
|
image.style.height = renderedHeight + 'px';
|
|
|
|
|
|
image.style.left = (tile.clientWidth - renderedFrameWidth) / 2 + 'px';
|
|
|
|
|
|
image.style.top = (tile.clientHeight - renderedHeight) / 2 + 'px';
|
|
|
|
|
|
image.classList.add('is-fitted');
|
|
|
|
|
|
}
|
|
|
|
|
|
function configureGrid() {
|
|
|
|
|
|
var limit = mobile.matches ? 6 : 10;
|
|
|
|
|
|
var visible = Math.min(limit, tiles.length);
|
|
|
|
|
|
var columns = visible === 1 ? 1 : Math.min(mobile.matches ? 3 : 5, visible);
|
|
|
|
|
|
var rows = Math.max(1, Math.ceil(visible / columns));
|
|
|
|
|
|
grid.style.setProperty('--header-cols', columns);
|
|
|
|
|
|
grid.style.setProperty('--header-rows', rows);
|
|
|
|
|
|
tiles.forEach(function(tile, index) { tile.hidden = index >= visible; });
|
|
|
|
|
|
window.requestAnimationFrame(function() {
|
|
|
|
|
|
tiles.forEach(function(tile) { fitHeaderMediaImage(tile.querySelector('img')); });
|
|
|
|
|
|
});
|
|
|
|
|
|
return visible;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var visibleCount = configureGrid();
|
|
|
|
|
|
tiles.forEach(function(tile) {
|
|
|
|
|
|
var image = tile.querySelector('img');
|
|
|
|
|
|
image.addEventListener('load', function() { fitHeaderMediaImage(image); });
|
|
|
|
|
|
if (image.complete) fitHeaderMediaImage(image);
|
|
|
|
|
|
});
|
|
|
|
|
|
mobile.addEventListener && mobile.addEventListener('change', configureGrid);
|
|
|
|
|
|
var resizeFrame;
|
|
|
|
|
|
window.addEventListener('resize', function() {
|
|
|
|
|
|
window.cancelAnimationFrame(resizeFrame);
|
|
|
|
|
|
resizeFrame = window.requestAnimationFrame(function() {
|
|
|
|
|
|
tiles.forEach(function(tile) { fitHeaderMediaImage(tile.querySelector('img')); });
|
|
|
|
|
|
});
|
|
|
|
|
|
}, { passive: true });
|
|
|
|
|
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches || sources.length <= visibleCount) return;
|
|
|
|
|
|
|
|
|
|
|
|
var nextSource = visibleCount;
|
|
|
|
|
|
function chooseSource(tile) {
|
|
|
|
|
|
var source = sources[nextSource % sources.length];
|
|
|
|
|
|
nextSource += 1;
|
|
|
|
|
|
if (source.src === tile.dataset.mediaSrc && sources.length > 1) {
|
|
|
|
|
|
source = sources[nextSource % sources.length];
|
|
|
|
|
|
nextSource += 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
return source;
|
|
|
|
|
|
}
|
|
|
|
|
|
function scheduleSwap(tile, index, initial) {
|
|
|
|
|
|
var delay = (initial ? 10000 : 19000) + index * 1700 + Math.floor(Math.random() * 6500);
|
|
|
|
|
|
window.setTimeout(function() {
|
|
|
|
|
|
if (document.hidden || tile.hidden) {
|
|
|
|
|
|
scheduleSwap(tile, index, false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
var source = chooseSource(tile);
|
|
|
|
|
|
var image = tile.querySelector('img');
|
|
|
|
|
|
tile.classList.add('is-flipping');
|
|
|
|
|
|
window.setTimeout(function() {
|
|
|
|
|
|
image.classList.toggle('header-video-preview', source.kind === 'video');
|
|
|
|
|
|
resetHeaderImageBox(image);
|
|
|
|
|
|
image.src = source.src;
|
|
|
|
|
|
tile.dataset.mediaSrc = source.src;
|
|
|
|
|
|
tile.dataset.mediaKind = source.kind;
|
|
|
|
|
|
if (image.complete) fitHeaderMediaImage(image);
|
|
|
|
|
|
}, 390);
|
|
|
|
|
|
window.setTimeout(function() {
|
|
|
|
|
|
tile.classList.remove('is-flipping');
|
|
|
|
|
|
scheduleSwap(tile, index, false);
|
|
|
|
|
|
}, 820);
|
|
|
|
|
|
}, delay);
|
|
|
|
|
|
}
|
|
|
|
|
|
tiles.slice(0, visibleCount).forEach(function(tile, index) {
|
|
|
|
|
|
scheduleSwap(tile, index, true);
|
|
|
|
|
|
});
|
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
|
|
function setSettingsButtonExpanded(expanded) {
|
|
|
|
|
|
var button = document.querySelector('.portal-settings');
|
|
|
|
|
|
if (button) button.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
|
|
|
|
|
}
|
|
|
|
|
|
function openPortalSettings() {
|
|
|
|
|
|
var calendar = document.getElementById('calendarPanel');
|
|
|
|
|
|
if (window.matchMedia('(max-width: 800px)').matches && calendar) {
|
|
|
|
|
|
calendar.classList.add('open');
|
|
|
|
|
|
document.body.classList.add('portal-overlay-open');
|
|
|
|
|
|
setSettingsButtonExpanded(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
function closePortalSettings() {
|
|
|
|
|
|
var calendar = document.getElementById('calendarPanel');
|
|
|
|
|
|
if (calendar) calendar.classList.remove('open');
|
|
|
|
|
|
document.body.classList.remove('portal-overlay-open');
|
|
|
|
|
|
setSettingsButtonExpanded(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
function toggleNotificationHelp(event) {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
var button = event.currentTarget;
|
|
|
|
|
|
var wrap = button.closest('.notification-info-wrap');
|
|
|
|
|
|
var open = !wrap.classList.contains('is-open');
|
|
|
|
|
|
wrap.classList.toggle('is-open', open);
|
|
|
|
|
|
button.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
|
|
|
|
}
|
|
|
|
|
|
function closeNotificationHelp() {
|
|
|
|
|
|
var wrap = document.querySelector('.notification-info-wrap');
|
|
|
|
|
|
if (!wrap) return;
|
|
|
|
|
|
wrap.classList.remove('is-open');
|
|
|
|
|
|
var button = wrap.querySelector('.notification-info-button');
|
|
|
|
|
|
if (button) button.setAttribute('aria-expanded', 'false');
|
|
|
|
|
|
}
|
|
|
|
|
|
document.addEventListener('click', function(event) {
|
|
|
|
|
|
if (!event.target.closest('.notification-info-wrap')) closeNotificationHelp();
|
|
|
|
|
|
});
|
2026-04-29 17:49:07 +03:00
|
|
|
|
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 = '';
|
|
|
|
|
|
}
|
2026-08-08 11:02:56 +01:00
|
|
|
|
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');
|
2026-08-08 11:02:56 +01:00
|
|
|
|
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;
|
2026-09-01 10:51:22 +01:00
|
|
|
|
status.style.display = 'block';
|
2026-08-08 11:21:23 +01:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-08-08 11:02:56 +01:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-08-08 11:02:56 +01:00
|
|
|
|
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;
|
2026-08-08 11:02:56 +01:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-08-08 11:02:56 +01:00
|
|
|
|
toggle.textContent = subscription ? '{{ t.portal_notifications_disable }}' : '{{ t.portal_notifications_enable }}';
|
|
|
|
|
|
}
|
|
|
|
|
|
toggle.addEventListener('click', async function() {
|
2026-08-08 11:21:23 +01:00
|
|
|
|
toggle.disabled = true;
|
2026-08-08 11:02:56 +01:00
|
|
|
|
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;
|
2026-09-01 10:51:22 +01:00
|
|
|
|
status.style.display = 'none';
|
2026-08-08 11:02:56 +01:00
|
|
|
|
} else {
|
|
|
|
|
|
var permission = await Notification.requestPermission();
|
|
|
|
|
|
if (permission !== 'granted') {
|
2026-09-01 10:51:22 +01:00
|
|
|
|
showStatus('{{ t.portal_notifications_denied }}', true);
|
2026-08-08 11:02:56 +01:00
|
|
|
|
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);
|
2026-08-08 11:02:56 +01:00
|
|
|
|
}
|
|
|
|
|
|
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:02:56 +01:00
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-08-08 11:21:23 +01:00
|
|
|
|
refresh().catch(function(error) {
|
|
|
|
|
|
console.error(error);
|
|
|
|
|
|
showStatus('{{ t.portal_notifications_error }}', true);
|
|
|
|
|
|
});
|
2026-08-08 11:02:56 +01:00
|
|
|
|
})();
|
|
|
|
|
|
{% endif %}
|
2026-09-01 10:51:22 +01:00
|
|
|
|
document.addEventListener('keydown', function(event) {
|
|
|
|
|
|
if (event.key !== 'Escape') return;
|
|
|
|
|
|
closeNotificationHelp();
|
|
|
|
|
|
closePortalSettings();
|
|
|
|
|
|
});
|
2026-04-29 17:49:07 +03:00
|
|
|
|
</script>
|
|
|
|
|
|
{% include "partials/lightbox.html" %}
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>
|