b2in/public/_cabinet/info/index.html
2026-04-10 17:18:17 +02:00

612 lines
22 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="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>