@@ -5,6 +5,9 @@
< 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 %}
@@ -17,12 +20,15 @@
padding : 0 0 2 rem ;
}
. portal-header {
position : relative ;
background : linear-gradient ( 135 deg , #7c6cff , #b06cff ) ;
color : #fff ; padding : 2 rem 1.5 rem 1.5 rem ; text-align : center ;
}
. portal-header h1 { font-size : 1.5 rem ; font-weight : 700 ; }
. portal-header . sub { opacity : 0.85 ; font-size : 0.9 rem ; margin-top : 0.25 rem ; }
. container { max-width : 700 px ; margin : 0 auto ; padding : 0 1 rem ; }
. container { max-width : 1100 px ; margin : 0 auto ; padding : 0 1 rem ; }
. portal-grid { display : grid ; grid-template-columns : minmax ( 0 , 700 px ) 320 px ; gap : 1.25 rem ; align-items : start ; }
. portal-settings { position : absolute ; right : 1 rem ; bottom : 1 rem ; width : 38 px ; height : 38 px ; border : 0 ; border-radius : 50 % ; background : rgba ( 255 , 255 , 255 , .2 ) ; color : #fff ; font-size : 1.1 rem ; cursor : pointer ; }
. section-title {
font-size : 1.15 rem ; font-weight : 700 ; margin : 1.5 rem 0 0.75 rem ;
padding-bottom : 0.4 rem ; border-bottom : 2 px solid #ede7f6 ;
@@ -110,6 +116,33 @@
font-weight : 700 ; min-width : 5.5 rem ;
}
. upcoming-row . up-time { color : #7a7599 ; }
. calendar-panel { position : sticky ; top : 1 rem ; margin-top : 1.5 rem ; background : #fff ; border : 1 px solid #eee ; border-radius : 12 px ; padding : .85 rem ; }
. calendar-head { display : flex ; align-items : center ; justify-content : space-between ; margin-bottom : .65 rem ; }
. calendar-head button { border : 0 ; background : #f0edff ; color : #6255c7 ; width : 30 px ; height : 30 px ; border-radius : 50 % ; cursor : pointer ; }
. calendar-title { font-size : .95 rem ; font-weight : 700 ; }
. calendar-weekdays , . calendar-days { display : grid ; grid-template-columns : repeat ( 7 , 1 fr ) ; gap : 3 px ; text-align : center ; }
. calendar-weekdays { color : #999 ; font-size : .68 rem ; margin-bottom : 3 px ; }
. calendar-day { aspect-ratio : 1 ; display : flex ; align-items : center ; justify-content : center ; border-radius : 7 px ; font-size : .78 rem ; 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 : 1 px dashed #ddd ; }
. calendar-month [ hidden ] { display : none ; }
. calendar-legend { margin-top : .65 rem ; font-size : .72 rem ; color : #999 ; }
. pagination { display : flex ; justify-content : center ; align-items : center ; gap : .75 rem ; margin : 1 rem 0 ; font-size : .85 rem ; }
. pagination a { color : #6558c8 ; text-decoration : none ; padding : .35 rem .7 rem ; background : #fff ; border : 1 px solid #e5e1ff ; border-radius : 8 px ; }
. modal-bg { display : none ; position : fixed ; inset : 0 ; z-index : 1000 ; background : rgba ( 20 , 18 , 40 , .55 ) ; align-items : center ; justify-content : center ; padding : 1 rem ; }
. modal-bg . open { display : flex ; }
. notification-modal { width : min ( 420 px , 100 % ) ; background : #fff ; border-radius : 14 px ; padding : 1.2 rem ; box-shadow : 0 15 px 50 px rgba ( 0 , 0 , 0 , .25 ) ; }
. notification-modal h2 { font-size : 1.15 rem ; margin-bottom : .45 rem ; }
. notification-modal p { font-size : .88 rem ; color : #777 ; }
. notification-actions { display : flex ; gap : .5 rem ; margin-top : 1 rem ; }
. notification-actions button { border : 0 ; border-radius : 8 px ; padding : .55 rem .9 rem ; 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 : 1 rem ; }
. visits-column { width : 100 % ; }
}
< / style >
< / head >
< body >
@@ -121,6 +154,7 @@
< 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" >
@@ -129,13 +163,15 @@
< 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" >
< 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 }}" >
@@ -156,7 +192,7 @@
{% 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() }}" alt = "" loading = "lazy" >
< 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" >
@@ -200,6 +236,13 @@
{% 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 -->
@@ -209,14 +252,55 @@
{% for pv in & upcoming %}
< div class = "upcoming-row" >
< span class = "up-date" > {{ pv.visit.visit_date }}< / span >
< span class = "up-time" > {{ pv.visit.time_start }} — {{ pv.visit.time_end }}< / 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' ;
@@ -226,6 +310,73 @@ 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 >