b2in/public/_cabinet/display/index.html
Kevin Adametz 6c6d683b9a Display CMS Optimierungen 29-05-2026
- Mediathek: Video-Vorschaubilder statt Icons (FFmpeg-Thumbnails + Backfill-Command), Kategorie "Sonstiges"
- B2in Media-Picker zeigt alle Medientypen, Typ wird automatisch erkannt; Thumbnail-Preview vor allen Medien-URL-Feldern
- B2in Marke/Footer: Footer ein/aus, Logo+Claim frei positionierbar (Ecken) mit Constraints, separate Anzeige-Schalter
- Angebote-Modul dynamisch: kein Slide-Typ mehr, einheitliches Detail-Layout mit ein-/ausblendbaren Bloecken, Logo/Brand pro Slide, Streichpreis-Option
- Player: leere Module stoppen Endlosschleife, dynamische Layout-Anpassung bei verstecktem Footer/Header
- Fix: Script-Ladereihenfolge (Livewire vor Flux), entfernte stale public/flux/flux.js, Modal-Crash beim Aktualisieren behoben

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 15:57:33 +00:00

1792 lines
59 KiB
HTML
Raw 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 Display Player</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">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
width: 100%; height: 100%;
overflow: hidden;
background: #000;
font-family: 'IBM Plex Sans', -apple-system, sans-serif;
cursor: none;
}
/* ========================================
PLAYER FRAME 9:16 Container
======================================== */
.player-frame {
position: fixed; inset: 0;
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
background: #000;
}
.player-viewport {
width: min(100vw, calc(100vh * 9 / 16));
height: min(100vh, calc(100vw * 16 / 9));
max-width: 1080px; max-height: 1920px;
aspect-ratio: 9 / 16;
position: relative;
overflow: hidden;
background: #000;
container-type: size;
}
/* ========================================
VERSION LAYERS
Each version type renders in its own layer
======================================== */
.version-layer {
position: absolute;
inset: 0;
opacity: 0;
transition: opacity 0.8s ease;
pointer-events: none;
}
.version-layer.active {
opacity: 1;
pointer-events: auto;
}
/* ========================================
VIDEO-DISPLAY TYPE
======================================== */
.vd-layer {
display: flex;
flex-direction: column;
background: #000;
}
.vd-video-area {
flex: 1;
position: relative;
overflow: hidden;
}
.vd-video {
width: 100%; height: 100%;
object-fit: cover;
object-position: center 25%;
display: block;
}
.vd-footer {
height: 9.67%;
background: #1a1a1a; color: #fff;
display: flex; align-items: center; justify-content: space-between;
padding: 0 3%;
font-size: clamp(5px, 0.92cqw, 10px);
position: relative;
}
.vd-footer.hidden { display: none; }
.vd-progress {
position: absolute; top: 0; left: 0;
height: 3px; background: #009FE3; width: 0%;
}
.vd-text { width: 75%; }
.vd-headline {
font-size: 2em; font-weight: 300; margin-bottom: 0.3em;
text-transform: uppercase; letter-spacing: 0.05em; color: #bbb;
}
.vd-subline { font-size: 2.4em; font-weight: 700; line-height: 1.1; }
.vd-qr {
width: 25%; display: flex; flex-direction: column; align-items: center;
}
.vd-qr img {
width: clamp(40px, 8.5cqw, 100px); aspect-ratio: 1;
object-fit: contain; background: #fff;
padding: 0.4em; border-radius: 0.6em;
}
.vd-qr-label {
margin-top: 0.8em; font-size: 1.3em;
text-transform: uppercase; letter-spacing: 0.05em;
font-weight: 600; color: #009FE3;
}
/* ========================================
B2IN TYPE
======================================== */
.b2in-layer { display: flex; flex-direction: column; }
/* B2in Header */
.b2in-header {
position: absolute; top: 0; left: 0; right: 0; z-index: 10;
display: flex; align-items: center; justify-content: space-between;
padding: 2.5vh 3vh;
background: linear-gradient(to bottom, rgba(0,0,0,0.6), transparent);
}
.b2in-header img { height: 3.5vh; }
.b2in-claim {
font-size: 1.3vh; font-weight: 300; letter-spacing: 0.15em;
text-transform: uppercase; color: rgba(255,255,255,0.7);
}
/* B2in Brand (positionable logo + claim) */
.b2in-brand { position: absolute; z-index: 12; display: flex; align-items: center; max-width: 60%; }
.b2in-brand-logo img { height: 3.5vh; display: block; filter: drop-shadow(0 1px 4px rgba(0,0,0,0.45)); }
.b2in-brand-claim {
font-size: 1.3vh; font-weight: 300; letter-spacing: 0.15em;
text-transform: uppercase; color: rgba(255,255,255,0.85);
text-shadow: 0 1px 4px rgba(0,0,0,0.55);
}
.b2in-brand.pos-top-left { top: 2.5vh; left: 3vh; }
.b2in-brand.pos-top-right { top: 2.5vh; right: 3vh; }
.b2in-brand.pos-bottom-left { bottom: 2.5vh; left: 3vh; }
.b2in-brand.pos-bottom-right { bottom: 2.5vh; right: 3vh; }
/* Legibility scrims behind positioned brand elements */
.b2in-scrim { position: absolute; left: 0; right: 0; height: 12vh; z-index: 11; pointer-events: none; }
.b2in-scrim-top { top: 0; background: linear-gradient(to bottom, rgba(0,0,0,0.6), transparent); }
.b2in-scrim-bottom { bottom: 0; background: linear-gradient(to top, rgba(0,0,0,0.6), transparent); }
/* B2in Media */
.b2in-media {
flex: 1; position: relative; overflow: hidden;
}
.b2in-media-layer {
position: absolute; inset: 0;
opacity: 0; transition: opacity 0.8s ease;
}
.b2in-media-layer.active { opacity: 1; }
.b2in-media-layer img,
.b2in-media-layer video {
width: 100%; height: 100%; object-fit: cover;
}
/* B2in Text */
.b2in-text {
position: absolute; bottom: 0; left: 0; right: 0; z-index: 10;
padding: 4vh 3vh 12vh;
background: linear-gradient(to top, rgba(0,0,0,0.7) 40%, transparent);
}
.b2in-headline {
font-size: 3vh; font-weight: 600; line-height: 1.2;
color: #fff; margin-bottom: 1vh;
}
.b2in-subline {
font-size: 1.8vh; font-weight: 300; color: rgba(255,255,255,0.7);
line-height: 1.4;
}
/* Without footer the text reclaims the footer's space at the bottom */
.b2in-layer.no-footer .b2in-text { padding-bottom: 4vh; }
/* B2in Footer */
.b2in-footer {
position: absolute; bottom: 0; left: 0; right: 0; z-index: 10;
display: flex; align-items: center; justify-content: space-between;
padding: 1.5vh 3vh;
background: rgba(0,0,0,0.4);
backdrop-filter: blur(10px);
}
.b2in-footer-url { font-size: 1.5vh; font-weight: 600; color: #20a0da; }
.b2in-footer-name { font-size: 1.2vh; color: rgba(255,255,255,0.5); margin-left: 1vh; }
.b2in-footer-qr img {
height: clamp(32px, 5cqh, 96px); aspect-ratio: 1; border-radius: 0.5vh;
background: #fff; padding: 0.3vh;
}
/* B2in Progress */
.b2in-progress-track {
position: absolute; bottom: 0; left: 0; right: 0;
height: 3px; background: rgba(255,255,255,0.1); z-index: 20;
}
.b2in-progress-fill {
height: 100%; width: 0%; background: #20a0da;
}
/* B2in Light Theme overrides */
.b2in-layer[data-theme="light"] {
background: #f7f8fa;
}
.b2in-layer[data-theme="light"] .b2in-header {
background: linear-gradient(to bottom, rgba(247,248,250,0.9), transparent);
}
.b2in-layer[data-theme="light"] .b2in-claim { color: rgba(43,63,81,0.6); }
.b2in-layer[data-theme="light"] .b2in-brand-claim { color: rgba(43,63,81,0.75); text-shadow: 0 1px 3px rgba(255,255,255,0.5); }
.b2in-layer[data-theme="light"] .b2in-scrim-top { background: linear-gradient(to bottom, rgba(247,248,250,0.9), transparent); }
.b2in-layer[data-theme="light"] .b2in-scrim-bottom { background: linear-gradient(to top, rgba(247,248,250,0.9), transparent); }
.b2in-layer[data-theme="light"] .b2in-text {
background: linear-gradient(to top, rgba(247,248,250,0.85) 40%, transparent);
}
.b2in-layer[data-theme="light"] .b2in-headline { color: #2b3f51; }
.b2in-layer[data-theme="light"] .b2in-subline { color: rgba(43,63,81,0.6); }
.b2in-layer[data-theme="light"] .b2in-footer {
background: rgba(247,248,250,0.6);
}
.b2in-layer[data-theme="light"] .b2in-footer-name { color: rgba(43,63,81,0.5); }
/* ========================================
OFFERS TYPE (DOM-based slides)
======================================== */
.offers-layer {
background: #fff;
overflow: hidden;
}
.offers-slide-container {
position: absolute; inset: 0;
opacity: 0; transition: opacity 0.6s ease;
pointer-events: none;
display: flex; align-items: flex-start; justify-content: flex-start;
overflow: hidden;
}
.offers-slide-container.active { opacity: 1; }
/*
* Slide renders at fixed 1080x1920 design size.
* JS applies transform:scale() to fit any container.
*/
.offer-slide {
width: 1080px; height: 1920px;
padding: 64px;
display: grid;
grid-template-rows: auto 1fr auto;
gap: 32px;
background: #fff;
font-family: 'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif;
color: #1a1a1a;
transform-origin: top left;
flex-shrink: 0;
}
/* Offer Header */
.offer-header {
display: flex; align-items: flex-end; justify-content: space-between;
padding-bottom: 24px; border-bottom: 1px solid #e8e8e8;
min-height: 100px;
}
.offer-brand { display: flex; align-items: center; gap: 14px; }
.offer-brand-logo { height: 82px; width: auto; }
.offer-brand-text {
font-size: 28px; font-weight: 600; letter-spacing: 0.12em;
text-transform: uppercase; color: #000;
}
.offer-tagline {
font-size: 24px; color: #737373; text-align: right;
line-height: 1.4; font-weight: 400;
}
/* Offer Hero */
.offer-hero {
border-radius: 24px; overflow: hidden; position: relative;
display: flex; align-items: flex-end; justify-content: flex-start;
padding: 32px; border: 1px solid #e8e8e8;
background: linear-gradient(145deg, #f5f5f5, #fafafa);
background-size: cover; background-position: center;
}
.offer-hero-badge {
font-size: 20px; font-weight: 500; color: #1a1a1a;
background: rgba(255,255,255,0.92); border: 1px solid rgba(0,0,0,0.08);
border-radius: 100px; padding: 14px 24px;
backdrop-filter: blur(16px); box-shadow: 0 2px 12px rgba(0,0,0,0.06);
}
.offer-hero-badge.large { font-size: 24px; padding: 16px 28px; }
/* Offer Bottom: Info + QR */
.offer-bottom {
display: grid; grid-template-columns: 1fr 300px; gap: 24px;
align-items: stretch;
}
/* Info Box */
.offer-info {
display: flex; flex-direction: column; justify-content: space-between;
background: linear-gradient(180deg, #fff, #fafafa);
border: 1px solid #e8e8e8; border-radius: 24px;
padding: 28px; min-height: 340px;
}
.offer-info-content { flex: 1; }
.offer-eyebrow {
font-size: 18px; color: #737373; letter-spacing: 0.14em;
text-transform: uppercase; margin-bottom: 14px; font-weight: 500;
}
.offer-title {
font-size: 54px; line-height: 1.08; font-weight: 700;
margin-bottom: 14px; color: #000; letter-spacing: -0.02em;
}
.offer-title.large { font-size: 64px; letter-spacing: -0.025em; }
.offer-title.medium { font-size: 42px; letter-spacing: -0.015em; }
.offer-subline {
font-size: 28px; color: #737373; line-height: 1.35;
margin-bottom: 16px; font-weight: 400;
}
/* Price */
.offer-price-block {
margin-top: auto; padding-top: 24px; border-top: 1px solid #e8e8e8;
}
.offer-price-row {
display: flex; align-items: baseline; justify-content: space-between; gap: 20px;
}
.offer-price {
font-size: 84px; font-weight: 700; letter-spacing: -0.03em;
color: #000; line-height: 1; font-feature-settings: 'tnum' 1;
}
.offer-price-note {
font-size: 24px; color: #737373; text-align: right;
line-height: 1.35; font-weight: 400;
}
.offer-price-note.strike {
color: #dc2626; text-decoration: line-through;
text-decoration-color: #dc2626; text-decoration-thickness: 3px;
}
/* Bullets */
.offer-bullets {
list-style: none; display: flex; flex-direction: column;
gap: 14px; margin-top: 20px; padding: 0;
}
.offer-bullets li {
font-size: 28px; line-height: 1.35; display: flex;
align-items: flex-start; gap: 16px; color: #1a1a1a;
}
.offer-bullet-dot {
width: 6px; height: 6px; border-radius: 50%;
background: #009FE3; margin-top: 13px; flex-shrink: 0;
}
/* Impulse Tag */
.offer-impulse-tag {
display: inline-block; background: #009FE3; color: #fff;
font-size: 18px; font-weight: 600; padding: 10px 18px;
border-radius: 10px; margin-top: 12px;
letter-spacing: 0.03em; text-transform: uppercase;
}
/* Footer within info */
.offer-info-footer {
margin-top: auto; padding-top: 20px; border-top: 1px solid #e8e8e8;
}
.offer-disclaimer {
font-size: 16px; color: #999; font-weight: 400; letter-spacing: 0.01em;
}
/* QR Box */
.offer-qr-box {
display: flex; flex-direction: column; background: #f5f5f5;
border: 1px solid #e8e8e8; border-radius: 24px;
padding: 20px; gap: 12px;
}
.offer-qr-header { text-align: center; }
.offer-qr-title {
font-size: 24px; font-weight: 600; color: #000;
margin-bottom: 6px; letter-spacing: -0.01em;
}
.offer-qr-subtitle { font-size: 18px; color: #737373; font-weight: 400; }
.offer-qr-wrapper {
flex: 1; display: flex; align-items: center; justify-content: center;
background: #fff; border-radius: 16px; border: 1px dashed #ddd;
padding: 16px; min-height: 180px;
}
.offer-qr-wrapper img { width: 100%; max-width: 180px; aspect-ratio: 1; }
.offer-qr-contact {
font-size: 18px; color: #737373; text-align: center;
line-height: 1.5; font-weight: 400;
}
/* Offers progress bar */
.offers-progress-bar {
position: absolute; bottom: 0; left: 0; right: 0;
height: 4px; background: rgba(0,0,0,0.1); z-index: 20;
}
.offers-progress-fill {
height: 100%; width: 0%; background: #009FE3;
}
/* ========================================
LOADING / ERROR STATES
======================================== */
.status-overlay {
position: fixed; inset: 0; z-index: 9999;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
background: #000; color: #fff;
font-size: 2vh;
}
.status-overlay.hidden { display: none; }
.status-spinner {
width: 5vh; height: 5vh;
border: 3px solid rgba(255,255,255,0.2);
border-top-color: #009FE3;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 3vh;
}
@keyframes spin { to { transform: rotate(360deg); } }
.status-message { font-weight: 300; opacity: 0.7; }
.status-error { color: #ef4444; font-weight: 500; }
.status-sub { font-size: 1.4vh; opacity: 0.4; margin-top: 1vh; }
.display-empty {
position: absolute; inset: 0; z-index: 10;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
text-align: center; padding: 6vw;
background: #000; color: #fff;
}
.display-empty__title { font-size: 2.4vh; font-weight: 600; }
.display-empty__hint { font-size: 1.6vh; opacity: 0.5; margin-top: 1.2vh; font-weight: 300; }
.display-overview {
position: fixed; inset: 0; z-index: 10000;
overflow-y: auto; background: radial-gradient(circle at top, #12364d 0, #05070a 42%, #000 100%);
color: #fff; cursor: auto; padding: clamp(24px, 5vw, 72px);
}
.display-overview.hidden { display: none; }
.display-overview__inner { width: min(1120px, 100%); margin: 0 auto; }
.display-overview__eyebrow {
color: #38bdf8; font-size: 13px; font-weight: 700;
letter-spacing: 0.16em; text-transform: uppercase; margin-bottom: 12px;
}
.display-overview h1 {
font-size: clamp(34px, 6vw, 76px); line-height: 0.95;
letter-spacing: -0.05em; margin-bottom: 18px;
}
.display-overview__intro {
max-width: 720px; color: rgba(255,255,255,0.68);
font-size: clamp(16px, 2vw, 22px); line-height: 1.5; margin-bottom: 36px;
}
.display-overview__grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 18px;
}
.display-card {
display: flex; flex-direction: column; gap: 16px;
min-height: 220px; padding: 24px; border-radius: 28px;
border: 1px solid rgba(255,255,255,0.14);
background: rgba(255,255,255,0.08); color: #fff; text-decoration: none;
box-shadow: 0 24px 70px rgba(0,0,0,0.24);
transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease;
}
.display-card:hover {
transform: translateY(-2px);
border-color: rgba(56,189,248,0.55);
background: rgba(255,255,255,0.12);
}
.display-card__badges { display: flex; flex-wrap: wrap; gap: 8px; }
.display-badge {
border-radius: 999px; padding: 6px 10px; font-size: 12px; font-weight: 700;
background: rgba(34,197,94,0.18); color: #86efac; border: 1px solid rgba(134,239,172,0.28);
}
.display-badge--live { background: rgba(56,189,248,0.18); color: #7dd3fc; border-color: rgba(125,211,252,0.28); }
.display-card__title { font-size: 28px; font-weight: 700; letter-spacing: -0.03em; }
.display-card__meta { display: grid; gap: 6px; color: rgba(255,255,255,0.62); font-size: 15px; }
.display-card__action { margin-top: auto; color: #7dd3fc; font-weight: 700; }
.display-overview__empty {
border: 1px dashed rgba(255,255,255,0.24); border-radius: 28px;
padding: 32px; color: rgba(255,255,255,0.62);
}
</style>
</head>
<body>
<div class="player-frame">
<div class="player-viewport" id="viewport">
<!-- Version layers are dynamically created here -->
</div>
</div>
<!-- Loading Overlay -->
<div class="status-overlay" id="loading">
<div class="status-spinner"></div>
<div class="status-message">Display wird geladen...</div>
<div class="status-sub" id="loading-info"></div>
</div>
<!-- Error Overlay -->
<div class="status-overlay hidden" id="error-overlay">
<div class="status-error" id="error-message">Fehler</div>
<div class="status-sub">Neustart in Kürze...</div>
</div>
<div class="display-overview hidden" id="display-overview">
<div class="display-overview__inner">
<div class="display-overview__eyebrow">Cabinet Display Player</div>
<h1>Aktive Live-Displays</h1>
<p class="display-overview__intro">
Wählen Sie ein Display aus, um die veröffentlichte Live-Bespielung zu öffnen.
Angezeigt werden nur aktive Displays mit veröffentlichter Live-Konfiguration.
</p>
<div class="display-overview__grid" id="display-overview-list"></div>
</div>
</div>
<script>
function escapeHtml(value) {
const div = document.createElement('div');
div.textContent = value ?? '';
return div.innerHTML;
}
function normalizeQrUrl(url) {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://')) return url;
return `https://${url}`;
}
/**
* Cabinet Display Player
*
* Loads a display config from the API and plays all assigned versions
* in sequence as an infinite loop. Supports: video-display, b2in, offers.
*
* URL: /display/{id} or /display/?id={id}
*/
class DisplayPlayer {
constructor() {
// Detect display context from URL
this.previewToken = this.detectPreviewToken();
this.moduleId = this.detectModuleId();
this.itemId = this.detectItemId();
this.displayId = this.detectDisplayId();
// API
this.BASE_URL = this.detectBaseUrl();
this.API_CONFIG = this.detectConfigUrl();
this.API_CHECK = this.detectCheckUrl();
this.API_OVERVIEW = `${this.BASE_URL}/api/display/overview`;
// Timing
this.POLL_INTERVAL = 60000;
this.FILE_CHECK_INTERVAL = 120000;
this.RELOAD_INTERVAL = 6 * 3600000;
this.MAX_FAILURES = 5;
this.RECOVERY_WAIT = 300000;
this.VIDEO_START_TIMEOUT = 10000;
this.VIDEO_WATCHDOG_INTERVAL = 5000;
// State
this.playlist = [];
this.currentVersionIndex = 0;
this.cachedTimestamp = null;
this.fileVersion = null;
this.failureCount = 0;
this.lastSuccessTime = Date.now();
this.isRunning = false;
this.activeVersionRenderer = null;
this.emptyVersionStreak = 0;
// DOM
this.viewport = document.getElementById('viewport');
this.loadingOverlay = document.getElementById('loading');
this.loadingInfo = document.getElementById('loading-info');
this.errorOverlay = document.getElementById('error-overlay');
this.errorMessage = document.getElementById('error-message');
this.overviewOverlay = document.getElementById('display-overview');
this.overviewList = document.getElementById('display-overview-list');
this.loadingInfo.textContent = this.detectLoadingLabel();
this.init();
}
detectDisplayId() {
// Try URL param: ?id=1
const params = new URLSearchParams(window.location.search);
if (params.get('id')) {
return params.get('id');
}
// Try path: /display/1 (if served via rewrite)
const pathMatch = window.location.pathname.match(/\/display\/(\d+)/);
if (pathMatch) {
return pathMatch[1];
}
return null;
}
detectPreviewToken() {
const params = new URLSearchParams(window.location.search);
if (params.get('preview')) {
return params.get('preview');
}
const pathMatch = window.location.pathname.match(/\/preview\/([^/]+)/);
if (pathMatch && pathMatch[1] !== 'module') {
return pathMatch[1];
}
return null;
}
detectModuleId() {
const params = new URLSearchParams(window.location.search);
if (params.get('module')) {
return params.get('module');
}
const pathMatch = window.location.pathname.match(/\/preview\/module\/(\d+)/);
if (pathMatch) {
return pathMatch[1];
}
return null;
}
detectItemId() {
const params = new URLSearchParams(window.location.search);
if (params.get('item')) {
return params.get('item');
}
const pathMatch = window.location.pathname.match(/\/preview\/module\/\d+\/item\/(\d+)/);
if (pathMatch) {
return pathMatch[1];
}
return null;
}
detectConfigUrl() {
if (this.previewToken) {
return `${this.BASE_URL}/api/display/preview/${this.previewToken}`;
}
if (this.moduleId && this.itemId) {
return `${this.BASE_URL}/api/display/module/${this.moduleId}/item/${this.itemId}/preview`;
}
if (this.moduleId) {
return `${this.BASE_URL}/api/display/module/${this.moduleId}/preview`;
}
return `${this.BASE_URL}/api/display/${this.displayId}/config`;
}
detectCheckUrl() {
if (this.previewToken || this.moduleId) {
return null;
}
return `${this.BASE_URL}/api/display/${this.displayId}/check`;
}
detectLoadingLabel() {
if (this.previewToken) {
return 'Display-Entwurf';
}
if (this.moduleId) {
if (this.itemId) {
return `Modul #${this.moduleId} / Item #${this.itemId}`;
}
return `Modul #${this.moduleId}`;
}
if (!this.displayId) {
return 'Display-Übersicht';
}
return `Display #${this.displayId}`;
}
detectBaseUrl() {
const hostname = window.location.hostname;
if (hostname === 'cabinet.b2in.eu') {
return 'https://portal.b2in.eu';
}
return window.location.origin;
}
// ========================================
// INIT
// ========================================
async init() {
console.log(`[Display] Initializing ${this.detectLoadingLabel()}`);
try {
if (!this.displayId && !this.previewToken && !this.moduleId) {
await this.fetchOverview();
return;
}
await this.fetchConfig();
if (this.playlist.length === 0) {
this.showError('Keine Versionen zugewiesen');
this.scheduleRetry();
return;
}
this.hideLoading();
this.startPlaylist();
} catch (error) {
console.error('[Display] Init failed:', error);
this.showError('Konfiguration konnte nicht geladen werden');
this.scheduleRetry();
}
this.startPolling();
this.recordFileVersion();
this.scheduleAutoReload();
}
// ========================================
// DATA FETCHING
// ========================================
async fetchConfig() {
const response = await fetch(this.API_CONFIG);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
this.failureCount = 0;
this.lastSuccessTime = Date.now();
this.cachedTimestamp = data.updated_at;
this.playlist = data.playlist || [];
console.log(`[Display] Loaded ${this.playlist.length} version(s)`);
}
async fetchOverview() {
const response = await fetch(this.API_OVERVIEW);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
this.renderOverview(data.displays || []);
}
startPolling() {
if (!this.API_CHECK) {
return;
}
setInterval(async () => {
try {
const response = await fetch(this.API_CHECK);
if (!response.ok) return;
const data = await response.json();
if (data.updated_at !== this.cachedTimestamp) {
console.log('[Display] Change detected, reloading...');
location.reload();
}
} catch (error) {
this.failureCount++;
console.warn('[Display] Poll failed:', error.message);
if (this.failureCount >= this.MAX_FAILURES) {
console.error('[Display] Too many failures, reloading in 30s...');
setTimeout(() => location.reload(), 30000);
}
}
}, this.POLL_INTERVAL);
setInterval(() => this.checkFileVersion(), this.FILE_CHECK_INTERVAL);
}
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) {
// Ignorieren 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('[Display] Datei geändert Seite wird neu geladen');
location.reload();
}
} catch (e) {
// Offline ignorieren
}
}
scheduleAutoReload() {
setTimeout(() => {
console.log('[Display] Scheduled reload (6h)');
location.reload();
}, this.RELOAD_INTERVAL);
}
scheduleRetry() {
setTimeout(() => location.reload(), 30000);
}
// ========================================
// PLAYLIST SEQUENCER
// ========================================
startPlaylist() {
this.isRunning = true;
this.currentVersionIndex = 0;
this.playCurrentVersion();
}
async playCurrentVersion() {
if (!this.isRunning) return;
const version = this.playlist[this.currentVersionIndex];
if (!version) {
this.currentVersionIndex = 0;
this.playCurrentVersion();
return;
}
// Guard against a delay-free infinite restart loop when no version in the
// playlist has any playable content (e.g. a freshly created, empty module).
if (!this.versionHasContent(version)) {
this.emptyVersionStreak++;
if (this.emptyVersionStreak >= this.playlist.length) {
this.showEmptyPlaylist();
return;
}
this.advanceVersion();
return;
}
this.emptyVersionStreak = 0;
console.log(`[Display] Playing version ${this.currentVersionIndex + 1}/${this.playlist.length}: ${version.version_name} (${version.type})`);
// Clean up previous renderer
if (this.activeVersionRenderer) {
this.activeVersionRenderer.destroy();
this.activeVersionRenderer = null;
}
// Clear viewport
this.viewport.innerHTML = '';
// Create renderer for this version type
switch (version.type) {
case 'video-display':
this.activeVersionRenderer = new VideoDisplayRenderer(this.viewport, version, () => this.advanceVersion());
break;
case 'b2in':
this.activeVersionRenderer = new B2inRenderer(this.viewport, version, () => this.advanceVersion());
break;
case 'offers':
this.activeVersionRenderer = new OffersRenderer(this.viewport, version, () => this.advanceVersion());
break;
default:
console.warn(`[Display] Unknown type: ${version.type}, skipping`);
this.advanceVersion();
return;
}
this.activeVersionRenderer.start();
}
advanceVersion() {
this.currentVersionIndex++;
if (this.currentVersionIndex >= this.playlist.length) {
this.currentVersionIndex = 0;
console.log('[Display] Playlist loop complete, restarting');
}
this.playCurrentVersion();
}
versionHasContent(version) {
if (!version) return false;
switch (version.type) {
case 'video-display':
return (version.videoPlaylist || []).length > 0;
case 'b2in':
return (version.items || []).some(item => item.is_active);
case 'offers':
return (version.slides || []).length > 0;
default:
return false;
}
}
showEmptyPlaylist() {
this.isRunning = false;
this.emptyVersionStreak = 0;
if (this.activeVersionRenderer) {
this.activeVersionRenderer.destroy();
this.activeVersionRenderer = null;
}
this.viewport.innerHTML = `
<div class="display-empty">
<p class="display-empty__title">Noch keine Inhalte vorhanden</p>
<p class="display-empty__hint">Sobald Inhalte angelegt und aktiviert sind, erscheinen sie hier.</p>
</div>
`;
console.log('[Display] Playlist has no playable content playback stopped.');
}
// ========================================
// UI HELPERS
// ========================================
hideLoading() {
this.loadingOverlay.classList.add('hidden');
}
renderOverview(displays) {
this.hideLoading();
this.errorOverlay.classList.add('hidden');
this.overviewOverlay.classList.remove('hidden');
if (displays.length === 0) {
this.overviewList.innerHTML = `
<div class="display-overview__empty">
Es sind aktuell keine aktiven Live-Displays veröffentlicht.
</div>
`;
return;
}
this.overviewList.innerHTML = displays.map(display => `
<a class="display-card" href="${this.escapeHtml(display.url)}">
<div class="display-card__badges">
<span class="display-badge">Aktiv</span>
<span class="display-badge display-badge--live">Live</span>
</div>
<div>
<div class="display-card__title">${this.escapeHtml(display.name)}</div>
<div class="display-card__meta">
<span>Display-ID: ${this.escapeHtml(display.id)}</span>
${display.location ? `<span>Standort: ${this.escapeHtml(display.location)}</span>` : ''}
<span>${this.escapeHtml(display.module_count)} Modul(e) veröffentlicht</span>
</div>
</div>
<div class="display-card__action">Display öffnen</div>
</a>
`).join('');
}
showError(msg) {
this.loadingOverlay.classList.add('hidden');
this.errorOverlay.classList.remove('hidden');
this.errorMessage.textContent = msg;
}
escapeHtml(value) {
const div = document.createElement('div');
div.textContent = value ?? '';
return div.innerHTML;
}
}
// ============================================================
// VIDEO-DISPLAY RENDERER
// Plays all videos in sequence, then calls onComplete
// ============================================================
class VideoDisplayRenderer {
constructor(container, data, onComplete) {
this.container = container;
this.data = data;
this.settings = data.settings || {};
this.onComplete = onComplete;
this.videos = data.videoPlaylist || [];
this.footerContent = data.footerContent || [];
this.currentVideoIndex = 0;
this.currentFooterIndex = 0;
this.footerTimer = null;
this.destroyed = false;
this.videoElement = null;
this.videoTimeout = null;
this.build();
}
build() {
const layer = document.createElement('div');
layer.className = 'version-layer vd-layer active';
// Video area
const videoArea = document.createElement('div');
videoArea.className = 'vd-video-area';
this.videoElement = document.createElement('video');
this.videoElement.className = 'vd-video';
this.videoElement.autoplay = true;
this.videoElement.muted = true;
this.videoElement.playsInline = true;
this.videoElement.setAttribute('preload', 'metadata');
videoArea.appendChild(this.videoElement);
layer.appendChild(videoArea);
// Footer
if (this.footerContent.length > 0) {
this.footerEl = document.createElement('div');
this.footerEl.className = 'vd-footer';
this.footerEl.innerHTML = `
<div class="vd-progress" id="vd-progress"></div>
<div class="vd-text">
<div class="vd-headline"></div>
<div class="vd-subline"></div>
</div>
<div class="vd-qr">
<img alt="QR">
<span class="vd-qr-label">${escapeHtml(this.settings.qr_label || 'Website')}</span>
</div>
`;
layer.appendChild(this.footerEl);
this.progressEl = this.footerEl.querySelector('.vd-progress');
} else {
videoArea.style.height = '100%';
}
this.container.appendChild(layer);
this.layer = layer;
}
start() {
if (this.videos.length === 0) {
this.onComplete();
return;
}
this.playVideo(0);
if (this.footerContent.length > 0) {
this.showFooter(0);
this.footerTimer = setInterval(() => {
this.currentFooterIndex = (this.currentFooterIndex + 1) % this.footerContent.length;
this.showFooter(this.currentFooterIndex);
}, 30000);
}
}
playVideo(index) {
if (this.destroyed) return;
if (index >= this.videos.length) {
this.onComplete();
return;
}
// Vorherigen Fallback-Timeout immer clearen
if (this.videoTimeout) {
clearTimeout(this.videoTimeout);
this.videoTimeout = null;
}
this.currentVideoIndex = index;
const video = this.videos[index];
const src = this.resolveAssetUrl(video.src);
// Clean previous
this.videoElement.onended = null;
this.videoElement.onerror = null;
this.videoElement.pause();
this.videoElement.removeAttribute('src');
this.videoElement.load();
// Set position
if (video.position !== undefined) {
this.videoElement.style.objectPosition = `center ${video.position}%`;
}
// Progress
if (this.progressEl) {
this.progressEl.style.transition = 'none';
this.progressEl.style.width = '0%';
}
let handled = false;
const finish = () => {
if (handled || this.destroyed) return;
handled = true;
if (this.videoTimeout) {
clearTimeout(this.videoTimeout);
this.videoTimeout = null;
}
this.playVideo(index + 1);
};
this.videoElement.onended = finish;
this.videoElement.onerror = () => {
console.warn(`[VD] Video error: ${video.src}`);
finish();
};
this.videoElement.src = src;
this.videoElement.play().then(() => {
// Start progress based on duration
if (this.progressEl && this.videoElement.duration) {
const dur = this.videoElement.duration * 1000;
void this.progressEl.offsetWidth;
this.progressEl.style.transition = `width ${dur}ms linear`;
this.progressEl.style.width = '100%';
}
}).catch(e => {
// AbortError = play() wurde absichtlich durch pause()/load() unterbrochen kein Fehler
if (e && e.name === 'AbortError') return;
finish();
});
// Timeout fallback: Metadaten laden asynchron, daher auf loadedmetadata warten
this.videoElement.addEventListener('loadedmetadata', () => {
if (handled || this.destroyed) return;
if (this.videoTimeout) clearTimeout(this.videoTimeout);
this.videoTimeout = setTimeout(() => finish(), this.videoElement.duration * 1000 + 2000);
}, { once: true });
// Absoluter Fallback wenn Metadaten nie laden (z.B. offline)
this.videoTimeout = setTimeout(() => finish(), 60000);
}
showFooter(index) {
const content = this.footerContent[index];
if (!content || !this.footerEl) return;
const headline = this.footerEl.querySelector('.vd-headline');
const subline = this.footerEl.querySelector('.vd-subline');
const qrContainer = this.footerEl.querySelector('.vd-qr');
const qrImg = this.footerEl.querySelector('.vd-qr img');
headline.textContent = content.headline || '';
subline.textContent = content.subline || '';
if (content.url) {
qrImg.src = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&margin=10&data=${encodeURIComponent(content.url)}`;
qrContainer.style.display = 'flex';
} else {
qrContainer.style.display = 'none';
}
}
resolveAssetUrl(src) {
if (!src) return '';
if (src.startsWith('http')) return src;
if (src.startsWith('/')) return src;
if (src.startsWith('../')) return src;
// Assets relative to _cabinet
return `../${src}`;
}
destroy() {
this.destroyed = true;
if (this.footerTimer) clearInterval(this.footerTimer);
if (this.videoTimeout) {
clearTimeout(this.videoTimeout);
this.videoTimeout = null;
}
if (this.videoElement) {
this.videoElement.onended = null;
this.videoElement.onerror = null;
this.videoElement.pause();
this.videoElement.removeAttribute('src');
this.videoElement.load();
}
if (this.layer) this.layer.remove();
}
}
// ============================================================
// B2IN RENDERER
// Plays all media items in sequence, then calls onComplete
// ============================================================
class B2inRenderer {
constructor(container, data, onComplete) {
this.container = container;
this.data = data;
this.onComplete = onComplete;
this.settings = data.settings || {};
this.items = (data.items || []).filter(i => i.is_active).sort((a, b) => a.sort_order - b.sort_order);
this.currentIndex = 0;
this.itemTimer = null;
this.destroyed = false;
this.theme = this.settings.theme || 'dark';
this.activeLayer = 'a';
this.build();
}
build() {
const layer = document.createElement('div');
layer.className = 'version-layer b2in-layer active' + (this.settings.show_footer === false ? ' no-footer' : '');
layer.setAttribute('data-theme', this.theme);
const headerLogoUrl = this.resolveUrl(this.settings.header_logo_url || '../assets/b2in-logo-positive.svg');
const headerClaim = this.settings.header_claim || 'Connecting Design & Property';
const footerUrl = this.settings.footer_url || 'B2in.eu';
const footerName = this.settings.footer_name || '';
const footerPrefix = this.settings.footer_prefix || 'by';
const footerNameHtml = footerName
? `<span class="b2in-footer-name">${escapeHtml(footerPrefix ? `${footerPrefix} ${footerName}` : footerName)}</span>`
: '';
const qrUrl = normalizeQrUrl(this.settings.qr_url || footerUrl || 'b2in.eu');
// Footer visibility, brand element visibility + corner positioning
const showFooter = this.settings.show_footer !== false;
const showLogo = this.settings.show_logo !== false;
const showClaim = this.settings.show_claim !== false;
const validPositions = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
let logoPos = validPositions.includes(this.settings.logo_position) ? this.settings.logo_position : 'top-left';
let claimPos = validPositions.includes(this.settings.claim_position) ? this.settings.claim_position : 'top-right';
// Bottom corners only allowed when the footer is hidden
if (showFooter) {
if (logoPos.startsWith('bottom')) logoPos = logoPos.replace('bottom-', 'top-');
if (claimPos.startsWith('bottom')) claimPos = claimPos.replace('bottom-', 'top-');
}
// Claim must not share the logo corner
if (claimPos === logoPos) {
claimPos = (validPositions.filter(p => (showFooter ? p.startsWith('top') : true) && p !== logoPos)[0]) || claimPos;
}
const logoVisible = showLogo;
const claimVisible = showClaim && !!headerClaim;
const hasTop = (logoVisible && logoPos.startsWith('top')) || (claimVisible && claimPos.startsWith('top'));
const hasBottom = (logoVisible && logoPos.startsWith('bottom')) || (claimVisible && claimPos.startsWith('bottom'));
const logoHtml = logoVisible
? `<div class="b2in-brand b2in-brand-logo pos-${logoPos}">
<img src="${escapeHtml(headerLogoUrl)}" alt="B2in">
</div>`
: '';
const claimHtml = claimVisible
? `<div class="b2in-brand b2in-brand-claim pos-${claimPos}">${escapeHtml(headerClaim)}</div>`
: '';
const footerHtml = showFooter
? `<footer class="b2in-footer">
<div>
<span class="b2in-footer-url">${escapeHtml(footerUrl)}</span>
${footerNameHtml}
</div>
<div class="b2in-footer-qr">
<img src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&margin=5&data=${encodeURIComponent(qrUrl)}" alt="QR">
</div>
</footer>`
: '';
layer.innerHTML = `
${hasTop ? '<div class="b2in-scrim b2in-scrim-top"></div>' : ''}
${(hasBottom && !showFooter) ? '<div class="b2in-scrim b2in-scrim-bottom"></div>' : ''}
${logoHtml}
${claimHtml}
<section class="b2in-media">
<div class="b2in-media-layer active" id="b2in-layer-a"></div>
<div class="b2in-media-layer" id="b2in-layer-b"></div>
</section>
<section class="b2in-text">
<div class="b2in-headline" id="b2in-headline"></div>
<div class="b2in-subline" id="b2in-subline"></div>
</section>
${footerHtml}
<div class="b2in-progress-track">
<div class="b2in-progress-fill" id="b2in-progress"></div>
</div>
`;
this.container.appendChild(layer);
this.layer = layer;
this.layerA = layer.querySelector('#b2in-layer-a');
this.layerB = layer.querySelector('#b2in-layer-b');
this.headline = layer.querySelector('#b2in-headline');
this.subline = layer.querySelector('#b2in-subline');
this.progress = layer.querySelector('#b2in-progress');
}
start() {
if (this.items.length === 0) {
this.onComplete();
return;
}
this.showItem(0);
}
showItem(index) {
if (this.destroyed) return;
if (index >= this.items.length) {
this.onComplete();
return;
}
this.currentIndex = index;
const item = this.items[index];
const transitionDuration = this.settings.transition?.duration_ms || 800;
// Text
this.headline.textContent = item.headline || '';
this.subline.textContent = item.subline || '';
// Media crossfade
const incoming = this.activeLayer === 'a' ? this.layerB : this.layerA;
const outgoing = this.activeLayer === 'a' ? this.layerA : this.layerB;
incoming.style.transition = `opacity ${transitionDuration}ms ease`;
outgoing.style.transition = `opacity ${transitionDuration}ms ease`;
// Create media element
incoming.innerHTML = '';
if (item.media_type === 'video') {
const video = document.createElement('video');
video.autoplay = true;
video.muted = true;
video.playsInline = true;
video.style.cssText = 'width:100%;height:100%;object-fit:cover;';
video.src = this.resolveUrl(item.media_url);
let handled = false;
const finish = () => {
if (handled || this.destroyed) return;
handled = true;
this.showItem(index + 1);
};
video.onended = finish;
video.onerror = () => { console.warn('[B2in] Video error'); finish(); };
// Progress: hide for videos (duration unknown upfront)
this.hideProgress();
incoming.appendChild(video);
video.play().then(() => {
if (video.duration && this.progress) {
this.showProgress(video.duration * 1000);
}
}).catch(e => {
// AbortError = play() wurde absichtlich unterbrochen kein Fehler
if (e && e.name === 'AbortError') return;
finish();
});
// Timeout fallback
setTimeout(() => finish(), 120000);
} else {
// Image
const img = document.createElement('img');
img.style.cssText = 'width:100%;height:100%;object-fit:cover;';
img.src = this.resolveUrl(item.media_url);
incoming.appendChild(img);
const duration = (item.duration_seconds || this.settings.default_image_duration || 10) * 1000;
this.showProgress(duration);
this.itemTimer = setTimeout(() => {
if (!this.destroyed) this.showItem(index + 1);
}, duration);
}
// Crossfade
incoming.classList.add('active');
outgoing.classList.remove('active');
this.activeLayer = this.activeLayer === 'a' ? 'b' : 'a';
}
showProgress(duration) {
if (!this.progress) return;
this.progress.style.transition = 'none';
this.progress.style.width = '0%';
void this.progress.offsetWidth;
this.progress.style.transition = `width ${duration}ms linear`;
this.progress.style.width = '100%';
}
hideProgress() {
if (!this.progress) return;
this.progress.style.transition = 'none';
this.progress.style.width = '0%';
}
resolveUrl(url) {
if (!url) return '';
if (url.startsWith('http')) return url;
if (url.startsWith('/')) return url;
if (url.startsWith('../')) return url;
return `../${url}`;
}
destroy() {
this.destroyed = true;
clearTimeout(this.itemTimer);
// Cleanup videos
this.layer.querySelectorAll('video').forEach(v => {
v.onended = null;
v.onerror = null;
v.pause();
v.removeAttribute('src');
v.load();
});
this.layer.remove();
}
}
// ============================================================
// OFFERS RENDERER
// Renders slides from CMS data as DOM, then calls onComplete
// ============================================================
class OffersRenderer {
// Design dimensions (slides are authored at this resolution)
static DESIGN_W = 1080;
static DESIGN_H = 1920;
constructor(container, data, onComplete) {
this.container = container;
this.data = data;
this.onComplete = onComplete;
this.settings = data.settings || {};
this.slides = data.slides || [];
this.currentIndex = 0;
this.slideTimer = null;
this.destroyed = false;
this.slideElements = [];
this.slideArticles = [];
this._onResize = () => this.scaleSlides();
this.build();
}
build() {
const layer = document.createElement('div');
layer.className = 'version-layer offers-layer active';
// Build DOM for each slide
this.slides.forEach((slide, i) => {
const el = this.buildSlide(slide);
layer.appendChild(el);
this.slideElements.push(el);
this.slideArticles.push(el.querySelector('.offer-slide'));
});
// Progress bar
const progressBar = document.createElement('div');
progressBar.className = 'offers-progress-bar';
progressBar.innerHTML = '<div class="offers-progress-fill"></div>';
layer.appendChild(progressBar);
this.container.appendChild(layer);
this.layer = layer;
this.progress = layer.querySelector('.offers-progress-fill');
// Scale slides to fit container
this.scaleSlides();
window.addEventListener('resize', this._onResize);
}
scaleSlides() {
const cw = this.container.clientWidth || OffersRenderer.DESIGN_W;
const ch = this.container.clientHeight || OffersRenderer.DESIGN_H;
const scale = Math.min(cw / OffersRenderer.DESIGN_W, ch / OffersRenderer.DESIGN_H);
const offsetX = Math.max(0, (cw - OffersRenderer.DESIGN_W * scale) / 2);
const offsetY = Math.max(0, (ch - OffersRenderer.DESIGN_H * scale) / 2);
this.slideArticles.forEach(article => {
if (article) {
article.style.transform = `translate(${offsetX}px, ${offsetY}px) scale(${scale})`;
}
});
}
buildSlide(slide) {
// Single dynamic detail layout: every block is toggleable. Older slides
// without explicit show_* flags fall back to "is there content?".
const has = v => v !== undefined && v !== null && v !== '';
const qrUrl = slide.qr_url || this.settings.footer_url || '';
const contactText = slide.contact || this.settings.footer_claim || '';
const show = {
logo: slide.show_logo ?? true,
badge: (slide.show_badge ?? has(slide.badge_text)) && has(slide.badge_text),
eyebrow: (slide.show_eyebrow ?? has(slide.eyebrow)) && has(slide.eyebrow),
subline: (slide.show_subline ?? has(slide.subline)) && has(slide.subline),
bullets: (slide.show_bullets ?? (slide.bullets && slide.bullets.length > 0)) && (slide.bullets && slide.bullets.length > 0),
price: (slide.show_price ?? has(slide.price)) && has(slide.price),
disclaimer: (slide.show_disclaimer ?? has(slide.disclaimer)) && has(slide.disclaimer),
qr: (slide.show_qr ?? has(slide.qr_url)) && has(qrUrl),
contact: (slide.show_contact ?? has(slide.contact)) && has(contactText),
};
const wrapper = document.createElement('div');
wrapper.className = 'offers-slide-container';
const article = document.createElement('article');
article.className = 'offer-slide';
// --- HEADER (Logo & Marke) per slide, toggleable ---
if (show.logo) {
const header = document.createElement('header');
header.className = 'offer-header';
const brand = document.createElement('div');
brand.className = 'offer-brand';
const brandLogo = document.createElement('img');
brandLogo.src = this.resolveUrl(slide.logo_url || '../logo-cabinet-300.png');
brandLogo.alt = 'Logo';
brandLogo.className = 'offer-brand-logo';
brand.appendChild(brandLogo);
if (has(slide.brand_text)) {
const brandText = document.createElement('span');
brandText.className = 'offer-brand-text';
brandText.textContent = slide.brand_text;
brand.appendChild(brandText);
}
header.appendChild(brand);
if (has(slide.brand_tagline)) {
const tagline = document.createElement('div');
tagline.className = 'offer-tagline';
tagline.innerHTML = slide.brand_tagline.replace(/\n/g, '<br>');
header.appendChild(tagline);
}
article.appendChild(header);
} else {
// Without the header row, let the hero stay the flexible middle row.
article.style.gridTemplateRows = '1fr auto';
}
// --- HERO ---
const hero = document.createElement('section');
hero.className = 'offer-hero';
if (slide.image_url) {
const imgUrl = this.resolveUrl(slide.image_url);
hero.style.background = `url('${imgUrl}') center/cover no-repeat`;
}
if (show.badge) {
const badge = document.createElement('span');
badge.className = 'offer-hero-badge large';
badge.textContent = slide.badge_text;
hero.appendChild(badge);
}
article.appendChild(hero);
// --- BOTTOM: Info + QR ---
const bottom = document.createElement('section');
bottom.className = 'offer-bottom';
// Info
const info = document.createElement('div');
info.className = 'offer-info';
const infoContent = document.createElement('div');
infoContent.className = 'offer-info-content';
if (show.eyebrow) {
const eyebrow = document.createElement('p');
eyebrow.className = 'offer-eyebrow';
eyebrow.textContent = slide.eyebrow;
infoContent.appendChild(eyebrow);
}
if (slide.title) {
const title = document.createElement('h1');
title.className = 'offer-title medium';
title.innerHTML = slide.title.replace(/\n/g, '<br>');
infoContent.appendChild(title);
}
if (show.subline) {
const subline = document.createElement('p');
subline.className = 'offer-subline';
subline.textContent = slide.subline;
infoContent.appendChild(subline);
}
if (show.bullets) {
const ul = document.createElement('ul');
ul.className = 'offer-bullets';
slide.bullets.forEach(text => {
if (!text) return;
const li = document.createElement('li');
li.innerHTML = `<span class="offer-bullet-dot"></span><span>${this.escapeHtml(text)}</span>`;
ul.appendChild(li);
});
infoContent.appendChild(ul);
}
info.appendChild(infoContent);
// Price block
if (show.price) {
const priceBlock = document.createElement('div');
priceBlock.className = 'offer-price-block';
const priceRow = document.createElement('div');
priceRow.className = 'offer-price-row';
const price = document.createElement('span');
price.className = 'offer-price';
price.textContent = slide.price;
priceRow.appendChild(price);
if (slide.original_price) {
const note = document.createElement('div');
note.className = slide.strike_original_price ? 'offer-price-note strike' : 'offer-price-note';
note.textContent = slide.original_price;
priceRow.appendChild(note);
}
if (slide.tag_text) {
const note = document.createElement('div');
note.className = 'offer-price-note';
const tag = document.createElement('span');
tag.className = 'offer-impulse-tag';
tag.textContent = slide.tag_text;
note.appendChild(tag);
priceRow.appendChild(note);
}
priceBlock.appendChild(priceRow);
info.appendChild(priceBlock);
}
// Disclaimer
if (show.disclaimer) {
const footer = document.createElement('div');
footer.className = 'offer-info-footer';
const disc = document.createElement('span');
disc.className = 'offer-disclaimer';
disc.textContent = slide.disclaimer;
footer.appendChild(disc);
info.appendChild(footer);
}
bottom.appendChild(info);
// QR / Contact box only when at least one of the two is enabled.
if (show.qr || show.contact) {
const qrBox = document.createElement('aside');
qrBox.className = 'offer-qr-box';
if (show.qr) {
const qrHeader = document.createElement('div');
qrHeader.className = 'offer-qr-header';
qrHeader.innerHTML = `
<p class="offer-qr-title">${this.escapeHtml(slide.qr_title || this.settings.qr_default_title || 'Kontakt')}</p>
<p class="offer-qr-subtitle">${this.escapeHtml(this.settings.qr_subtitle || 'QR scannen')}</p>
`;
qrBox.appendChild(qrHeader);
const qrWrapper = document.createElement('div');
qrWrapper.className = 'offer-qr-wrapper';
const qrImg = document.createElement('img');
qrImg.src = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&color=000000&bgcolor=ffffff&margin=8&data=${encodeURIComponent(normalizeQrUrl(qrUrl))}`;
qrImg.alt = 'QR Code';
qrWrapper.appendChild(qrImg);
qrBox.appendChild(qrWrapper);
}
if (show.contact) {
const contact = document.createElement('p');
contact.className = 'offer-qr-contact';
contact.innerHTML = contactText.replace(/\n/g, '<br>');
qrBox.appendChild(contact);
}
bottom.appendChild(qrBox);
} else {
bottom.style.gridTemplateColumns = '1fr';
}
article.appendChild(bottom);
wrapper.appendChild(article);
return wrapper;
}
start() {
if (this.slides.length === 0) {
this.onComplete();
return;
}
this.showSlide(0);
}
showSlide(index) {
if (this.destroyed) return;
if (index >= this.slides.length) {
this.onComplete();
return;
}
this.currentIndex = index;
const slide = this.slides[index];
const duration = slide.duration || 8000;
const transitionDuration = this.settings.transition?.duration || 600;
// Show active slide, hide others
this.slideElements.forEach((el, i) => {
el.style.transition = `opacity ${transitionDuration}ms ease`;
if (i === index) {
el.classList.add('active');
} else {
el.classList.remove('active');
}
});
// Progress
this.showProgress(duration);
// Timer to next slide
this.slideTimer = setTimeout(() => {
if (!this.destroyed) this.showSlide(index + 1);
}, duration);
}
showProgress(duration) {
if (!this.progress) return;
this.progress.style.transition = 'none';
this.progress.style.width = '0%';
void this.progress.offsetWidth;
this.progress.style.transition = `width ${duration}ms linear`;
this.progress.style.width = '100%';
}
resolveUrl(url) {
if (!url) return '';
if (url.startsWith('http')) return url;
if (url.startsWith('/')) return url;
if (url.startsWith('../')) return url;
return `../${url}`;
}
escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
destroy() {
this.destroyed = true;
clearTimeout(this.slideTimer);
window.removeEventListener('resize', this._onResize);
this.slideElements = [];
this.slideArticles = [];
this.layer.remove();
}
}
// ============================================================
// BOOT
// ============================================================
document.addEventListener('DOMContentLoaded', () => {
new DisplayPlayer();
});
// Prevent right-click context menu (kiosk mode)
document.addEventListener('contextmenu', e => e.preventDefault());
</script>
</body>
</html>