10-04-2026

This commit is contained in:
Kevin Adametz 2026-04-10 17:18:17 +02:00
parent 4d6b4930b2
commit 4bb89aad8c
836 changed files with 52961 additions and 5950 deletions

View file

@ -0,0 +1,612 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>CABINET Store Info</title>
<link rel="apple-touch-icon" sizes="57x57" href="../../favicon/apple-icon-57x57.png">
<link rel="apple-touch-icon" sizes="60x60" href="../../favicon/apple-icon-60x60.png">
<link rel="apple-touch-icon" sizes="72x72" href="../../favicon/apple-icon-72x72.png">
<link rel="apple-touch-icon" sizes="76x76" href="../../favicon/apple-icon-76x76.png">
<link rel="apple-touch-icon" sizes="114x114" href="../../favicon/apple-icon-114x114.png">
<link rel="apple-touch-icon" sizes="120x120" href="../../favicon/apple-icon-120x120.png">
<link rel="apple-touch-icon" sizes="144x144" href="../../favicon/apple-icon-144x144.png">
<link rel="apple-touch-icon" sizes="152x152" href="../../favicon/apple-icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="../../favicon/apple-icon-180x180.png">
<link rel="icon" type="image/png" sizes="192x192" href="../../favicon/android-icon-192x192.png">
<link rel="icon" type="image/png" sizes="96x96" href="../../favicon/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="32x32" href="../../favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="../../favicon/favicon-16x16.png">
<link rel="shortcut icon" href="../../favicon/favicon.ico">
<link rel="manifest" href="../../favicon/manifest.json">
<meta name="msapplication-TileColor" content="#ffffff">
<meta name="msapplication-TileImage" content="../../favicon/ms-icon-144x144.png">
<meta name="theme-color" content="#ffffff">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="./info-styles.css">
<style>
/* Schützt vor versehentlicher Textauswahl / Kopier-Pop-up beim Berühren des Tablets */
body {
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.touch-guard {
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: auto;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
touch-action: none;
}
</style>
</head>
<body>
<div class="touch-guard" aria-hidden="true"></div>
<main class="screen">
<!-- 1. HEADER: Logo + Date -->
<header class="header" id="header">
<div class="brand">
<img src="../logo-cabinet-300.png" alt="CABINET" class="brand-logo">
</div>
<div class="header-date">
<div class="header-weekday" id="weekday"></div>
<div class="header-datestring" id="datestring"></div>
<div class="header-updated">Akt: <span id="last-updated"></span></div>
</div>
</header>
<!-- 2. STATUS BANNER -->
<section class="status-banner" id="status-banner" data-status="open">
<div class="status-icon" id="status-icon"></div>
<div class="status-text">
<div class="status-headline" id="status-headline">Laden...</div>
<div class="status-subtext" id="status-subtext"></div>
</div>
</section>
<!-- 3. OPENING HOURS -->
<section class="hours-section">
<div class="hours-title">Öffnungszeiten</div>
<div class="hours-list" id="hours-list">
<div class="hours-row" data-day="monday">
<span class="hours-day">Montag</span>
<span class="hours-time" id="hours-monday"></span>
</div>
<div class="hours-row" data-day="tuesday">
<span class="hours-day">Dienstag</span>
<span class="hours-time" id="hours-tuesday"></span>
</div>
<div class="hours-row" data-day="wednesday">
<span class="hours-day">Mittwoch</span>
<span class="hours-time" id="hours-wednesday"></span>
</div>
<div class="hours-row" data-day="thursday">
<span class="hours-day">Donnerstag</span>
<span class="hours-time" id="hours-thursday"></span>
</div>
<div class="hours-row" data-day="friday">
<span class="hours-day">Freitag</span>
<span class="hours-time" id="hours-friday"></span>
</div>
<div class="hours-row" data-day="saturday">
<span class="hours-day">Samstag</span>
<span class="hours-time" id="hours-saturday"></span>
</div>
<div class="hours-row" data-day="sunday">
<span class="hours-day">Sonntag</span>
<span class="hours-time" id="hours-sunday"></span>
</div>
</div>
</section>
<!-- 4. APPOINTMENT CARD -->
<section class="appointment-card" id="appointment">
<div class="appointment-icon">&#128197;</div>
<div class="appointment-text">
<div class="appointment-label">Nächster freier Termin</div>
<div class="appointment-date" id="appointment-date"></div>
<div class="appointment-note">Beratung ca. 45 Min.</div>
</div>
</section>
<!-- 5. FOOTER: Contact + QR -->
<footer class="info-footer" id="footer">
<div class="contact-block">
<div class="contact-item">
<span class="contact-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M2.25 6.75C2.25 15.0343 8.96573 21.75 17.25 21.75H19.5C20.7426 21.75 21.75 20.7426 21.75 19.5V18.1284C21.75 17.6121 21.3987 17.1622 20.8979 17.037L16.4747 15.9312C16.0355 15.8214 15.5734 15.9855 15.3018 16.3476L14.3316 17.6412C14.05 18.0166 13.563 18.1827 13.1223 18.0212C9.81539 16.8098 7.19015 14.1846 5.97876 10.8777C5.81734 10.437 5.98336 9.94998 6.3588 9.6684L7.65242 8.69818C8.01453 8.4266 8.17861 7.96445 8.06883 7.52533L6.96304 3.10215C6.83783 2.60133 6.38785 2.25 5.87163 2.25H4.5C3.25736 2.25 2.25 3.25736 2.25 4.5V6.75Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
<span id="contact-phone"></span>
</div>
<div class="contact-item">
<span class="contact-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M21.75 6.75V17.25C21.75 18.4926 20.7426 19.5 19.5 19.5H4.5C3.25736 19.5 2.25 18.4926 2.25 17.25V6.75M21.75 6.75C21.75 5.50736 20.7426 4.5 19.5 4.5H4.5C3.25736 4.5 2.25 5.50736 2.25 6.75M21.75 6.75V6.99271C21.75 7.77405 21.3447 8.49945 20.6792 8.90894L13.1792 13.5243C12.4561 13.9694 11.5439 13.9694 10.8208 13.5243L3.32078 8.90894C2.65535 8.49945 2.25 7.77405 2.25 6.99271V6.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
<span id="contact-email"></span>
</div>
</div>
<div class="footer-qr">
<img id="qr-code" alt="QR Code">
<span class="footer-qr-label">Website</span>
</div>
</footer>
</main>
<!-- Offline Badge -->
<div class="offline-badge" id="offline-badge">Stand: <span id="offline-time"></span></div>
<script>
/**
* CABINET Info-Tablet App
* Polling-based display for store info in shop window.
*/
class InfoTabletApp {
constructor() {
// Configuration
this.BASE_URL = this.detectBaseUrl();
this.API_STATUS = this.BASE_URL + '/api/cabinet-tablet/status';
this.API_CHECK = this.BASE_URL + '/api/cabinet-tablet/check';
this.QR_TARGET = 'https://cabinet-bielefeld.de';
this.POLL_INTERVAL = 30000; // 30 seconds
this.FILE_CHECK_INTERVAL = 120000; // 2 minutes
this.RELOAD_INTERVAL = 6 * 3600000; // 6 hours
this.MAX_FAILURES = 3;
this.RECOVERY_WAIT = 300000; // 5 minutes
this.OFFLINE_RELOAD = 1800000; // 30 minutes
// State
this.cachedTimestamp = null;
this.fileVersion = null;
this.failureCount = 0;
this.lastSuccessTime = Date.now();
this.isRecovering = false;
this.currentData = null;
// Day mapping
this.DAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
this.DAY_NAMES = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
}
detectBaseUrl() {
const hostname = window.location.hostname;
if (hostname === 'cabinet.b2in.eu' || hostname.includes('b2in.eu')) {
return 'https://b2in.eu';
}
// Dev/test: same origin
return window.location.origin;
}
async init() {
console.log('[InfoTablet] Initializing...');
// Set date immediately
this.updateDate();
// Generate QR code
this.generateQR();
// Try to load from cache first
const cached = this.loadFromCache();
if (cached) {
console.log('[InfoTablet] Loaded from cache');
this.updateDOM(cached);
}
// Fetch fresh data
await this.fetchFullStatus();
// Record initial file version for change detection
await this.recordFileVersion();
// Start polling
this.startPolling();
// Schedule auto-reload
this.scheduleAutoReload();
// Schedule midnight update
this.scheduleMidnightUpdate();
console.log('[InfoTablet] Ready!');
}
// ========================================
// POLLING
// ========================================
startPolling() {
setInterval(() => this.checkForUpdates(), this.POLL_INTERVAL);
setInterval(() => this.checkFileVersion(), this.FILE_CHECK_INTERVAL);
}
async checkForUpdates() {
try {
const response = await fetch(this.API_CHECK);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
this.failureCount = 0;
this.lastSuccessTime = Date.now();
this.hideOfflineBadge();
// Fetch full status when settings changed OR when open/closed state transitioned
const settingsChanged = data.updated_at !== this.cachedTimestamp;
const statusChanged = this.currentData && data.store_status !== this.currentData.store_status;
if (settingsChanged || statusChanged) {
console.log('[InfoTablet] Change detected (' + (settingsChanged ? 'settings' : 'status transition') + '), fetching full status...');
await this.fetchFullStatus();
}
} catch (error) {
this.handleFetchError(error);
}
}
async recordFileVersion() {
try {
const response = await fetch(window.location.href, {
method: 'HEAD',
cache: 'no-store',
});
this.fileVersion = response.headers.get('ETag') || response.headers.get('Last-Modified');
} catch (e) {
// Ignore wird beim nächsten Check erneut versucht
}
}
async checkFileVersion() {
try {
const response = await fetch(window.location.href, {
method: 'HEAD',
cache: 'no-store',
});
const version = response.headers.get('ETag') || response.headers.get('Last-Modified');
if (this.fileVersion && version && version !== this.fileVersion) {
console.log('[InfoTablet] Datei geändert Seite wird neu geladen');
location.reload();
}
} catch (e) {
// Offline ignorieren
}
}
async fetchFullStatus() {
try {
const response = await fetch(this.API_STATUS);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
this.currentData = data;
this.cachedTimestamp = data.updated_at;
this.failureCount = 0;
this.lastSuccessTime = Date.now();
this.isRecovering = false;
this.updateDOM(data);
this.saveToCache(data);
this.hideOfflineBadge();
this.updateLastUpdated();
console.log('[InfoTablet] Status updated');
} catch (error) {
this.handleFetchError(error);
}
}
handleFetchError(error) {
this.failureCount++;
console.warn(`[InfoTablet] Fetch error (${this.failureCount}/${this.MAX_FAILURES}):`, error.message);
const offlineDuration = Date.now() - this.lastSuccessTime;
// Show offline badge
this.showOfflineBadge();
// After 30 min offline: reload page
if (offlineDuration >= this.OFFLINE_RELOAD) {
console.error('[InfoTablet] Offline for 30+ min, reloading...');
location.reload();
return;
}
// After MAX_FAILURES: enter recovery mode (wait longer)
if (this.failureCount >= this.MAX_FAILURES && !this.isRecovering) {
this.isRecovering = true;
console.warn('[InfoTablet] Entering recovery mode, waiting 5 min...');
setTimeout(() => {
this.isRecovering = false;
this.failureCount = 0;
this.fetchFullStatus();
}, this.RECOVERY_WAIT);
}
}
// ========================================
// DOM UPDATES
// ========================================
updateDOM(data) {
this.updateStatusBanner(data.store_status, data.notice_headline, data.notice_subtext, data.today_close, data.next_open);
this.updateHours(data.hours, data.override_open_today, data.override_close_today);
this.updateAppointment(data.next_appointment);
this.updateContact(data.contact);
}
updateStatusBanner(status, headline, subtext, todayClose, nextOpen) {
const banner = document.getElementById('status-banner');
const iconEl = document.getElementById('status-icon');
const headlineEl = document.getElementById('status-headline');
const subtextEl = document.getElementById('status-subtext');
banner.setAttribute('data-status', status);
if (status === 'open') {
iconEl.innerHTML = '&#10003;';
headlineEl.textContent = 'Geöffnet';
subtextEl.textContent = todayClose ? `Heute bis ${todayClose} Uhr für Sie da.` : '';
} else if (status === 'notice') {
iconEl.innerHTML = '!';
headlineEl.textContent = headline || 'Hinweis';
subtextEl.textContent = subtext || '';
} else if (status === 'warning') {
iconEl.innerHTML = '!';
headlineEl.textContent = headline || 'Wichtiger Hinweis';
subtextEl.textContent = subtext || '';
} else {
// closed
iconEl.innerHTML = '&#10005;';
headlineEl.textContent = 'Geschlossen';
if (nextOpen) {
subtextEl.textContent = `Ab ${nextOpen.label}, ${nextOpen.time} Uhr wieder für Sie da.`;
} else if (subtext) {
subtextEl.textContent = subtext;
} else {
subtextEl.textContent = '';
}
}
}
updateHours(hours, overrideOpen, overrideClose) {
if (!hours) {
return;
}
const todayIndex = this.getBerlinDate().getDay();
const todayKey = this.DAYS[todayIndex];
for (const [day, time] of Object.entries(hours)) {
const el = document.getElementById(`hours-${day}`);
if (!el) {
continue;
}
const row = el.closest('.hours-row');
// Check if today
row.classList.remove('today', 'override');
if (day === todayKey) {
row.classList.add('today');
// Apply override if exists
if (overrideOpen || overrideClose) {
row.classList.add('override');
const openTime = overrideOpen || time.split('')[0]?.trim() || '';
const closeTime = overrideClose || time.split('')[1]?.trim() || '';
el.textContent = `${openTime} ${closeTime}`;
} else {
el.textContent = time;
}
} else {
el.textContent = time;
}
}
}
updateAppointment(appointment) {
const card = document.getElementById('appointment');
const dateEl = document.getElementById('appointment-date');
if (!appointment || !appointment.date) {
card.classList.add('hidden');
return;
}
card.classList.remove('hidden');
const date = new Date(appointment.date + 'T00:00:00');
const dayName = this.DAY_NAMES[new Date(date.toLocaleString('en-US', { timeZone: 'Europe/Berlin' })).getDay()];
const formatted = date.toLocaleDateString('de-DE', {
timeZone: 'Europe/Berlin',
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
const time = appointment.time || '';
dateEl.textContent = `${dayName}, ${formatted}` + (time ? ` ${time} Uhr` : '');
}
updateContact(contact) {
if (!contact) {
return;
}
const phoneEl = document.getElementById('contact-phone');
const emailEl = document.getElementById('contact-email');
if (contact.phone) {
phoneEl.textContent = contact.phone;
}
if (contact.email) {
emailEl.textContent = contact.email;
}
}
// ========================================
// DATE
// ========================================
/**
* Gibt ein Date-Objekt zurück, dessen .getDay()/.getHours() etc.
* die Berliner Lokalzeit widerspiegeln unabhängig von der Systemzeitzone
* des Geräts (z.B. UTC auf Android-Kiosk-Displays).
*/
getBerlinDate() {
return new Date(new Date().toLocaleString('en-US', { timeZone: 'Europe/Berlin' }));
}
updateDate() {
const now = new Date();
const berlinNow = this.getBerlinDate();
const weekdayEl = document.getElementById('weekday');
const dateEl = document.getElementById('datestring');
weekdayEl.textContent = this.DAY_NAMES[berlinNow.getDay()];
dateEl.textContent = now.toLocaleDateString('de-DE', {
timeZone: 'Europe/Berlin',
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
}
updateLastUpdated() {
const el = document.getElementById('last-updated');
if (el) {
el.textContent = new Date().toLocaleTimeString('de-DE', {
timeZone: 'Europe/Berlin',
hour: '2-digit',
minute: '2-digit',
}) + ' Uhr';
}
}
scheduleMidnightUpdate() {
// Mitternacht in Berliner Zeit berechnen, nicht in Geräte-UTC
const berlinNow = this.getBerlinDate();
const berlinMidnight = new Date(berlinNow);
berlinMidnight.setDate(berlinMidnight.getDate() + 1);
berlinMidnight.setHours(0, 0, 5, 0); // 00:00:05 Berliner Zeit
const msUntilMidnight = berlinMidnight.getTime() - berlinNow.getTime();
setTimeout(() => {
console.log('[InfoTablet] Midnight update');
this.updateDate();
// Re-highlight today in hours
if (this.currentData) {
this.updateHours(this.currentData.hours, null, null);
}
// Fetch fresh data (overrides may have been reset)
this.fetchFullStatus();
// Schedule next midnight
this.scheduleMidnightUpdate();
}, msUntilMidnight);
}
// ========================================
// QR CODE
// ========================================
generateQR() {
const size = '200x200';
const color = '000000';
const bg = 'ffffff';
const url = `https://api.qrserver.com/v1/create-qr-code/?size=${size}&color=${color}&bgcolor=${bg}&margin=6&data=${encodeURIComponent(this.QR_TARGET)}`;
const img = document.getElementById('qr-code');
if (img) {
img.src = url;
}
}
// ========================================
// CACHE
// ========================================
saveToCache(data) {
try {
localStorage.setItem('cabinet-tablet-data', JSON.stringify(data));
localStorage.setItem('cabinet-tablet-time', new Date().toISOString());
} catch (e) {
// localStorage may be full or unavailable
}
}
loadFromCache() {
try {
const raw = localStorage.getItem('cabinet-tablet-data');
if (raw) {
return JSON.parse(raw);
}
} catch (e) {
// Corrupted cache
}
return null;
}
// ========================================
// OFFLINE BADGE
// ========================================
showOfflineBadge() {
const badge = document.getElementById('offline-badge');
const timeEl = document.getElementById('offline-time');
const cachedTime = localStorage.getItem('cabinet-tablet-time');
if (cachedTime) {
const date = new Date(cachedTime);
timeEl.textContent = date.toLocaleTimeString('de-DE', {
timeZone: 'Europe/Berlin',
hour: '2-digit',
minute: '2-digit',
}) + ' Uhr';
}
badge.classList.add('visible');
}
hideOfflineBadge() {
document.getElementById('offline-badge').classList.remove('visible');
}
// ========================================
// AUTO-RELOAD
// ========================================
scheduleAutoReload() {
setTimeout(() => {
console.log('[InfoTablet] Auto-reload after 6 hours');
location.reload();
}, this.RELOAD_INTERVAL);
}
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
const app = new InfoTabletApp();
app.init();
});
</script>
</body>
</html>

View file

@ -0,0 +1,444 @@
/**
* CABINET Info-Tablet - Styles
* Modern, refined design for 8-10" Android tablet in portrait mode
*/
@import '../shared/cabinet-base.css';
/* ========================================
OVERRIDE: Tablet-sized display tokens
======================================== */
:root {
--safe-area: 32px;
--radius: 10px;
--radius-sm: 6px;
/* Status palette */
--status-open: #16a34a;
--status-open-bg: linear-gradient(135deg, #f0fdf4, #ecfdf5);
--status-open-border: #d1fae5;
--status-closed: #ca8a04;
--status-closed-bg: linear-gradient(135deg, #fefce8, #fef9c3);
--status-closed-border: #fde047;
--status-notice: #ea580c;
--status-notice-bg: linear-gradient(135deg, #fff7ed, #ffedd5);
--status-notice-border: #fed7aa;
--status-warning: #dc2626;
--status-warning-bg: linear-gradient(135deg, #fef2f2, #fee2e2);
--status-warning-border: #fecaca;
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.04);
--shadow-inner: inset 0 1px 2px rgba(0, 0, 0, 0.03);
/* Refined background */
--surface: #fafafa;
--surface-raised: #ffffff;
}
body {
background: var(--surface);
padding: 0;
}
/* ========================================
SCREEN (full viewport)
======================================== */
.screen {
width: 100vw;
height: 100vh;
background: var(--surface);
display: flex;
flex-direction: column;
padding: var(--safe-area);
gap: 22px;
overflow: hidden;
}
/* ========================================
HEADER
======================================== */
.header {
min-height: auto;
padding-bottom: 16px;
align-items: center;
border-bottom: none;
}
.brand-logo {
height: 64px;
}
.header-date {
text-align: right;
line-height: 1.25;
}
.header-weekday {
font-size: var(--text-lg);
font-weight: 600;
color: var(--fg-strong);
letter-spacing: -0.01em;
}
.header-datestring {
font-size: var(--text-sm);
color: var(--muted);
font-weight: 400;
margin-top: 1px;
}
.header-updated {
font-size: var(--text-xs);
color: var(--muted-light);
font-weight: 400;
margin-top: 4px;
}
/* ========================================
STATUS BANNER
======================================== */
.status-banner {
border-radius: var(--radius);
padding: 28px 24px 32px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 12px;
transition: all 400ms cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid transparent;
border-top-width: 5px;
box-shadow: var(--shadow-lg);
position: relative;
overflow: hidden;
}
.status-banner[data-status="open"] {
background: var(--status-open-bg);
border-color: var(--status-open-border);
border-top-color: var(--status-open);
}
.status-banner[data-status="closed"] {
background: var(--status-closed-bg);
border-color: var(--status-closed-border);
border-top-color: var(--status-closed);
}
.status-banner[data-status="notice"] {
background: var(--status-notice-bg);
border-color: var(--status-notice-border);
border-top-color: var(--status-notice);
}
.status-banner[data-status="warning"] {
background: var(--status-warning-bg);
border-color: var(--status-warning-border);
border-top-color: var(--status-warning);
}
.status-icon {
width: 64px;
height: 64px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
font-weight: 700;
flex-shrink: 0;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
margin-bottom: 2px;
}
.status-banner[data-status="open"] .status-icon {
background: var(--status-open);
color: #fff;
}
.status-banner[data-status="closed"] .status-icon {
background: var(--status-closed);
color: #fff;
}
.status-banner[data-status="notice"] .status-icon {
background: var(--status-notice);
color: #fff;
}
.status-banner[data-status="warning"] .status-icon {
background: var(--status-warning);
color: #fff;
}
.status-text {
display: flex;
flex-direction: column;
gap: 6px;
}
.status-headline {
font-size: var(--text-3xl);
font-weight: 700;
color: var(--fg-strong);
letter-spacing: -0.02em;
line-height: 1.15;
}
.status-subtext {
font-size: var(--text-lg);
color: var(--muted);
font-weight: 400;
line-height: 1.35;
}
/* ========================================
OPENING HOURS
======================================== */
.hours-section {
flex: 1;
display: flex;
flex-direction: column;
background: var(--surface-raised);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 20px 22px;
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.hours-title {
font-size: 13px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 600;
margin-bottom: 6px;
padding-bottom: 8px;
border-bottom: 1px solid var(--line);
}
.hours-list {
display: flex;
flex-direction: column;
gap: 0;
flex: 1;
}
.hours-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 9px 12px;
border-radius: var(--radius-sm);
transition: all 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
.hours-day {
font-size: var(--text-base);
font-weight: 400;
color: var(--fg);
}
.hours-time {
font-size: var(--text-base);
font-weight: 400;
color: var(--fg);
font-feature-settings: 'tnum' 1;
font-variant-numeric: tabular-nums;
}
/* Today highlight */
.hours-row.today {
background: rgba(0, 159, 227, 0.06);
box-shadow: inset 3px 0 0 var(--accent);
}
.hours-row.today .hours-day {
font-weight: 600;
color: var(--accent);
}
.hours-row.today .hours-time {
font-weight: 600;
color: var(--accent);
}
/* Override styling */
.hours-row.today.override .hours-time {
color: #ea580c;
font-weight: 700;
}
/* ========================================
APPOINTMENT CARD
======================================== */
.appointment-card {
background: linear-gradient(135deg, #111111, #1a1a1a);
border-radius: var(--radius);
padding: 22px 24px;
color: #ffffff;
display: flex;
align-items: center;
gap: 18px;
transition: all 400ms cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: var(--shadow-lg);
position: relative;
overflow: hidden;
}
/* Subtle accent glow */
.appointment-card::after {
content: '';
position: absolute;
top: -50%;
right: -20%;
width: 200px;
height: 200px;
background: radial-gradient(circle, rgba(0, 159, 227, 0.08), transparent 70%);
pointer-events: none;
}
.appointment-card.hidden {
display: none;
}
.appointment-icon {
width: 44px;
height: 44px;
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.06);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
flex-shrink: 0;
}
.appointment-text {
flex: 1;
position: relative;
z-index: 1;
}
.appointment-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 600;
margin-bottom: 5px;
}
.appointment-date {
font-size: var(--text-xl);
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.2;
margin-bottom: 3px;
}
.appointment-note {
font-size: var(--text-sm);
color: rgba(255, 255, 255, 0.4);
font-weight: 400;
}
/* ========================================
FOOTER
======================================== */
.info-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding-top: 14px;
border-top: 1px solid var(--line);
}
.contact-block {
display: flex;
flex-direction: column;
gap: 6px;
}
.contact-item {
display: flex;
align-items: center;
gap: 10px;
font-size: var(--text-base);
color: var(--fg);
font-weight: 400;
}
.contact-icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--muted);
}
.footer-qr {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.footer-qr img {
width: 102px;
height: 102px;
border-radius: var(--radius-sm);
box-shadow: var(--shadow-sm);
border: 1px solid var(--line);
}
.footer-qr-label {
font-size: 11px;
color: var(--muted-light);
text-align: center;
font-weight: 500;
letter-spacing: 0.04em;
}
/* ========================================
OFFLINE INDICATOR
======================================== */
.offline-badge {
position: fixed;
bottom: 12px;
left: 50%;
transform: translateX(-50%) translateY(4px);
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: #fff;
font-size: 12px;
font-weight: 500;
padding: 6px 16px;
border-radius: 100px;
opacity: 0;
transition: opacity 400ms ease, transform 400ms ease;
pointer-events: none;
z-index: 100;
}
.offline-badge.visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
}