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,274 @@
# CABINET Digital Signage System - Projektdokumentation
**Stand:** Februar 2026 | **Version:** 1.3
---
## 1. Projektübersicht
Das CABINET Digital Signage System ist ein In-Store-Display-System für den CABINET Store in Bielefeld. Es besteht aus zwei eigenständigen Modulen, die auf hochkant montierten Displays (9:16 Seitenverhältnis, 1080x1920px) im Store laufen:
| Modul | Datei | Live-URL | Zweck |
|-------|-------|----------|-------|
| **Video-Display** | `index.html` | `cabinet.b2in.eu` | Zeigt eine Endlos-Playlist von Videos mit rotierenden Footer-Inhalten (Headline, Subline, QR-Code) |
| **Angebots-Display** | `offers/player.html` | (noch nicht live) | Zeigt statische Produkt-Slides (Angebote, Preise, Details) als Slide-Rotation |
| **Info-Tablet** | (in Planung) | `cabinet.b2in.eu/info` | Zeigt Store-Status, Öffnungszeiten, Termine im Schaufenster |
Alle Inhalte werden uber das B2in-Backend (Admin-Portal unter `portal.b2in.test`) gepflegt.
---
## 2. Architektur
```
public/_cabinet/
├── index.html # LIVE: Video-Display (Hauptseite)
├── index_1.html # Ältere Version
├── index_2.html # Ältere Version
├── index-dynamic.html # Dynamische Variante
├── index-static-backup.html # Statisches Fallback
├── index copy.html # Kopie/Backup
├── offer.html # Prototyp: Angebots-Slides (all-in-one)
├── offers/ # NEU: Modulares Angebots-System
│ ├── player.html # Slide-Player (lädt config.json + iFrames)
│ ├── config.json # Slide-Konfiguration (Reihenfolge, Dauer, Inhalte)
│ ├── shared-styles.css # Gemeinsame CSS-Styles für alle Slides
│ ├── slide-0-intro.html # Intro-Slide (Store-Vorstellung)
│ ├── slide-1-goya-hero.html # Produkt-Slide: GOYA Sideboard (Hero)
│ ├── slide-2-goya-details.html# Produkt-Slide: GOYA Details/Konditionen
│ └── slide-3-tando.html # Produkt-Slide: TANDO Spiegel (Impuls)
├── assets/ # Medien-Dateien
│ ├── *.mp4 # Videos (Saison-Spots)
│ ├── *.jpg # Produkt-/Hintergrundbilder
│ └── cabinet-intro.jpg # Intro-Bild
├── go.php # QR-Code Redirect + Klick-Tracking
├── logger.php # Remote-Logging-Endpoint (CORS-fähig)
├── view-logs.php # Web-basierter Log-Viewer
├── setup-logging.sh # Logging-Setup-Skript
├── test-logging.html # Test-Seite für Logging
├── logs/ # Log-Dateien (nach Level + Datum)
├── clicks.log # QR-Code-Klick-Log
├── logo-cabinet-300.png # CABINET Logo (300px)
├── logo-cabinet.png.webp # CABINET Logo (WebP)
├── .htaccess.example # Apache-Konfigurationsvorlage
├── infotablet.md # Konzept: Info-Tablet für Schaufenster
├── offer.md # Notizen zum Angebots-System
├── QUICK_START.md # Bedienungsanleitung
├── KIOSK_MODE_SETUP.md # Anleitung Kiosk-Modus (Fully Browser)
├── LOGGING_README.md # Logging-Dokumentation
├── VIDEO_OPTIMIZATION_README.md# Video-Optimierungstipps
└── CABINET_PROJECT.md # Diese Datei
```
---
## 3. Modul A: Video-Display (`index.html`)
### Funktionsweise
- Lädt die Konfiguration (Video-Playlist + Footer-Inhalte) per API vom Backend
- Spielt Videos nacheinander in einer Endlosschleife ab
- Footer rotiert unabhängig alle 30 Sekunden (Headline + Subline + QR-Code)
### API-Anbindung
- **Endpoint:** `GET /api/display/config` (auf `b2in.eu`)
- **Response:**
```json
{
"videoPlaylist": [
{ "src": "assets/fruehjahr_2025.mp4", "position": 25 }
],
"footerContent": [
{ "headline": "Text", "subline": "Subtext", "url": "https://..." }
]
}
```
- **Polling:** Alle 5 Minuten wird die Config neu geladen
- **Präventiver Reload:** Alle 6 Stunden kompletter Page-Reload
### Robustheit
- **Video-Watchdog:** Prüft alle 5 Sek. ob Video läuft, Recovery bei Stuck
- **Start-Timeout:** 10 Sek. Timeout, dann Skip zum nächsten Video
- **Memory-Management:** Cleanup nach jedem Video, Monitoring alle 10 Min.
- **Error-Recovery:** Nach 3 aufeinanderfolgenden Fehlern → Page-Reload
- **Remote-Logging:** Alle Events werden an `logger.php` gesendet
### Layout
```
┌─────────────────┐
│ │
│ VIDEO-BEREICH │ flex-grow: 1 (nimmt restliche Höhe ein)
│ (object-fit: │
│ cover) │
│ │
├──────────────────┤
│ ▬▬▬ Progress ▬▬ │ 3px Progress-Bar
│ CTA-TEXT │ QR │ ~10% Höhe
│ Headline │Code │ Footer mit rotierendem Inhalt
│ Subline │ │
└─────────────────┘
```
---
## 4. Modul B: Angebots-Display (`offers/`)
### Funktionsweise
- `player.html` ist der Haupt-Player
- Lädt `config.json` für Slide-Konfiguration
- Jeder Slide ist ein eigenständiges HTML-Dokument, eingebettet per iFrame
- Slides rotieren automatisch mit konfigurierbarer Dauer (8-12 Sek.)
- Fade-Transition (0.6s) zwischen Slides
### Slide-Typen
| Typ | Beschreibung | Beispiel |
|-----|-------------|---------|
| `intro` | Store-Vorstellung, allgemeiner Willkommenstext | slide-0-intro.html |
| `product-hero` | Produkt mit Hero-Bild, Preis, UVP | slide-1-goya-hero.html |
| `product-details` | Produkt-Details mit Bullet-Liste | slide-2-goya-details.html |
| `product-impulse` | Impulskauf-Produkt ("Jetzt mitnehmen") | slide-3-tando.html |
### Slide-Layout (shared-styles.css)
```
┌─────────────────────────────┐
│ HEADER │ Logo + Tagline
│ [CABINET Logo] [Tagline] │
├─────────────────────────────┤
│ │
│ HERO-BILD │ flex: 1 (nimmt Hauptfläche ein)
│ (Produktfoto) │
│ [Badge] │
│ │
├──────────────┬──────────────┤
│ INFO-BOX │ QR-BOX │ Unterer Bereich
│ Eyebrow │ Titel │
│ Titel │ QR-Code │
│ Preis/UVP │ Kontakt │
│ Bullets │ │
└──────────────┴──────────────┘
```
### Design-System
- **Schrift:** IBM Plex Sans (Google Fonts)
- **Akzentfarbe:** `#009FE3` (Cabinet Blau)
- **Safe-Area:** 64px Padding
- **Max. Auflösung:** 1080x1920px (9:16)
- **QR-Codes:** Werden live per `api.qrserver.com` generiert
### Status
Die Angebots-Slides sind **fertig entwickelt aber noch nicht live**. Die Inhalte (Produkte, Preise, Bilder) sind statisch in den HTML-Dateien hinterlegt. Eine Backend-Anbindung (wie beim Video-Display) fehlt noch.
---
## 5. Modul C: Info-Tablet (in Planung)
Konzept dokumentiert in `infotablet.md`. Ein kleines Tablet im Schaufenster zeigt:
- Store-Status (Geöffnet/Hinweis/Geschlossen)
- Öffnungszeiten (Wochenansicht, heutiger Tag hervorgehoben)
- Nächster freier Beratungstermin
- Kontakt + QR-Code
**API geplant:** `GET /api/cabinet-tablet/status`
---
## 6. Backend-Anbindung
### Datenbank-Tabellen
**`display_videos`** - Video-Playlist
| Spalte | Typ | Beschreibung |
|--------|-----|-------------|
| id | bigint | Primary Key |
| filename | varchar | Dateiname (z.B. `fruehjahr_2025.mp4`) |
| title | varchar | Anzeige-Titel (optional) |
| position | int | Vertikale Position des Videos (0-100, für `object-position`) |
| sort_order | int | Reihenfolge in der Playlist |
| is_active | tinyint | Aktiv/Inaktiv |
**`display_footer_contents`** - Footer-Inhalte (CTA-Texte + QR-Codes)
| Spalte | Typ | Beschreibung |
|--------|-----|-------------|
| id | bigint | Primary Key |
| headline | varchar | Überschrift (z.B. "JETZT TERMIN BUCHEN") |
| subline | varchar | Unterzeile (z.B. "Beratung vor Ort...") |
| url | varchar | Ziel-URL für QR-Code (optional) |
| short_code | varchar | 6-stelliger Code für Redirect (unique) |
| clicks | int | Klick-Zähler (via go.php) |
| sort_order | int | Reihenfolge |
| is_active | tinyint | Aktiv/Inaktiv |
### Backend-Komponenten
| Komponente | Pfad | Funktion |
|-----------|------|----------|
| API-Controller | `app/Http/Controllers/Api/DisplayConfigController.php` | Liefert JSON-Config für Video-Display |
| Video-Model | `app/Models/DisplayVideo.php` | Eloquent-Model mit `active()` Scope |
| Footer-Model | `app/Models/DisplayFooterContent.php` | Model mit Short-Code-Generierung + Short-URL |
| Admin-Seite | `app/Livewire/Admin/Cms/CabinetDisplay.php` | Livewire-Verwaltung für Videos + Footer |
| Admin-Route | `/admin/cms/cabinet` | Admin-Portal-Seite |
| API-Route | `GET /api/display/config` | Öffentlicher API-Endpoint |
### QR-Code / Redirect-System
1. Backend generiert automatisch einen 6-stelligen `short_code` pro Footer-Inhalt
2. QR-Code im Display zeigt URL wie `cabinet.b2in.eu/go.php?z=abc123`
3. `go.php` schlägt den Code in der DB nach, erhöht den Klick-Zähler, und leitet weiter
4. Fallback: Alte Legacy-Codes (t, t1, p, i, f) für Rückwärtskompatibilität
---
## 7. Logging & Monitoring
### Remote-Logging (`logger.php`)
- Empfängt POST-Requests mit JSON-Logdaten vom Display
- CORS-aktiviert für Cross-Origin-Zugriff
- Speichert Logs in drei Formaten:
- Pro Level: `logs/info_2026-02-27.log`, `logs/error_2026-02-27.log`
- Alle zusammen: `logs/all_2026-02-27.log`
- JSON: `logs/json_2026-02-27.log`
- Automatische Log-Rotation: Dateien > 30 Tage werden gelöscht
### Was wird geloggt
- Video-Lifecycle: Start, Ende, Error, Stuck, Timeout
- Heartbeat alle 5 Minuten ("Display läuft")
- Memory-Status alle 10 Minuten
- Online/Offline-Status
- JavaScript-Fehler (global error handler)
- Resource-Loading-Fehler (Bilder, Videos)
### Log-Viewer (`view-logs.php`)
Web-basierte Oberfläche zum Lesen der Logs mit Auto-Refresh und Farbcodierung.
---
## 8. Deployment & Betrieb
### Live-System
- **Domain:** `cabinet.b2in.eu` (Subdomain von b2in.eu)
- **Assets-URL:** `https://b2in.eu/_cabinet/assets/`
- **API-URL:** `https://b2in.eu/api/display/config`
### Test-System
- **URL:** `b2in.test/_cabinet/`
- **API:** `b2in.test/api/display/config`
### Kiosk-Modus
Empfohlen: **Fully Kiosk Browser** (Android) - Details in `KIOSK_MODE_SETUP.md`
- Auto-Start bei Boot
- Vollbild ohne Statusbar
- Auto-Restart bei Crash
- Zeitsteuerung (22:00 Standby, 07:00 Aufwachen)
---
## 9. Offene Punkte / TODO
- [ ] **Offers-System Backend-Anbindung**: `offers/` Slides haben noch keine API-Anbindung - Inhalte sind statisch im HTML
- [ ] **Offers live schalten**: Player + Slides auf Live-System deployen
- [ ] **Info-Tablet umsetzen**: Konzept steht (infotablet.md), Implementierung offen
- [ ] **Offers Config aus DB**: `config.json` sollte dynamisch vom Backend generiert werden
- [ ] **Bilder-Upload**: Produktbilder für Offers werden noch manuell in `assets/` abgelegt

View file

@ -0,0 +1,175 @@
# B2in Display Frontend-Implementation
**Ziel:** Frontend-Webapp für das B2in Schaufenster-Display (9:16 Portrait, 4355 Zoll).
**Pfad:** `public/_cabinet/b2in/index.html`
**Konzept-Basis:** `public/_cabinet/_docs/b2in-displays.md`
---
## Ist-Zustand
Es gibt bereits drei Display-Typen unter `public/_cabinet/`:
| Typ | Pfad | Status | Beschreibung |
|-----|------|--------|-------------|
| **Video-Display** | `index.html` | Live, stabil | CABINET-Branding. Videos + Footer-Rotation. API: `/api/display/config` |
| **Offers** | `offers/index.html` | Entwickelt, nicht live | CABINET-Branding. iFrame-basierte Slide-Rotation mit `config.json` |
| **Info-Tablet** | `info/index.html` | Entwickelt | CABINET-Branding. Store-Status/Öffnungszeiten. API: `/api/cabinet-tablet/status` |
| **B2in Display** | `b2in/` | **Neu wird jetzt gebaut** | B2in-Branding. Playlist mit Videos/Bildern + Text. API: `/api/b2in-display/playlist` |
---
## Architektur-Übersicht
```
public/_cabinet/b2in/
├── index.html ← Haupt-Webapp (Playlist-Engine + UI)
├── b2in-styles.css ← Display-spezifische Styles
└── (assets/) ← Medien werden per API/URL referenziert, nicht lokal
```
Shared CSS aus `public/_cabinet/shared/cabinet-base.css` wird **nicht** importiert das B2in-Display hat ein eigenständiges Branding (dunkel, B2in statt CABINET). Es ist ein komplett eigenes Design-System.
---
## Schritte
### Schritt 1: HTML-Grundgerüst + CSS
**Datei:** `public/_cabinet/b2in/index.html` + `b2in-styles.css`
Layout gemäß Konzept (Abschnitt 2.1):
```
┌─────────────────────────┐
│ HEADER │ ← B2in-Logo (links) + Claim (rechts)
│ B2in · Claim │ Fest, immer sichtbar
├─────────────────────────┤
│ │
│ ┌─────────────────┐ │ ← Gradient oben (dunkel → transparent)
│ │ VIDEO / BILD │ │ 16:9 Content-Bereich
│ │ (16:9 Media) │ │ object-fit: cover
│ └─────────────────┘ │ ← Gradient unten (transparent → dunkel)
│ │
├─────────────────────────┤
│ TEXTFELD │ ← Headline (max 40 Zeichen)
│ Headline + Subline │ Subline (max 80 Zeichen)
├─────────────────────────┤ Wechselt synchron mit Media
│ FOOTER │ ← "Marcel Scheibe" + "b2in.de" + QR-Code
│ Name · URL · QR │ Fest, immer sichtbar
└─────────────────────────┘
```
**Design-Entscheidungen:**
- Dunkler Hintergrund (#0a0a0a) B2in-Branding, nicht CABINET-weiß
- 9:16 Aspect Ratio Container (wie bestehende Displays)
- Gradient-Overlays oben/unten über dem Media-Bereich
- IBM Plex Sans Font (wie alle Cabinet-Displays)
- Akzentfarbe: B2in-Blau (wird aus Branding-Guide übernommen)
- Kein CABINET-Logo, kein Accent #009FE3
### Schritt 2: Playlist-Engine (JavaScript)
**Klasse:** `B2inDisplayApp`
Kernfunktionalität:
1. **API laden**`GET /api/b2in-display/playlist` (wird später gebaut, erstmal Mock/Fallback)
2. **Playlist sortieren** → nach `sort_order`, nur `is_active === true`
3. **Gewichtung anwenden** → 70/30 Immobilien/Möbel-Verteilung berechnen
4. **Rotation starten** → Item für Item durchspielen
Item-Wechsel-Logik:
- **Video:** Abspielen bis Ende → nächstes Item
- **Bild:** `duration_seconds` abwarten → nächstes Item
- **Am Ende der Playlist:** Von vorne beginnen
### Schritt 3: Video-Handling
Bewährtes Pattern aus dem bestehenden `index.html` übernehmen:
- `autoplay muted playsinline` für Browser-Autoplay-Policy
- Memory-Management: `src` leeren + `load()` nach jedem Video
- Preloading: Nächstes Item im Hintergrund vorladen
- Start-Timeout (10s) → bei Timeout überspringen
- Watchdog: Prüft alle 5s ob Video noch läuft
- Error-Handling: Bei Fehler → Item überspringen, nie schwarzer Screen
### Schritt 4: Bild-Handling
- `<img>` Element mit `object-fit: cover`
- Duration aus Item oder globaler `default_image_duration`
- Ken-Burns-Effekt (optionaler langsamer Zoom per CSS)
- Preload via `new Image()` im Hintergrund
### Schritt 5: Transitions
Drei Typen (per CMS-Setting steuerbar):
| Typ | Umsetzung |
|-----|-----------|
| `fade` | Opacity 1→0, dann 0→1 |
| `crossfade` | Neues Element über dem alten einblenden (empfohlen, Standard) |
| `slide` | CSS transform translateX |
Text-Synchronisation:
1. Text fade-out (400ms)
2. Neuen Text setzen
3. Text fade-in (400ms)
4. Startet 200ms vor Media-Wechsel
### Schritt 6: Polling + Stabilität
Gleicher Ansatz wie Info-Tablet, aber mit 60s Intervall:
- **Lightweight Check** alle 60s → `GET /api/b2in-display/check`
- **Full Fetch** nur bei Timestamp-Änderung
- Laufendes Video/Bild wird zu Ende gespielt, dann neue Playlist
- localStorage-Cache als Offline-Fallback
- Auto-Reload alle 6 Stunden
- Connection-Recovery: 3 Fehler → 5 Min Pause → Retry
- 30 Min offline → Page-Reload
### Schritt 7: Standby-Modus
Wenn `display_active === false`:
- Nur B2in-Logo auf dunklem Hintergrund
- Kein Content, kein Textfeld, kein Footer-Text
- Polling läuft weiter (wartet auf Aktivierung)
### Schritt 8: Error-Overlay
Bei kritischen Fehlern (keine Playlist, kein Media):
- Dezentes B2in-Logo auf dunklem Hintergrund
- Niemals Browser-Fehler oder weißer Screen
- Automatischer Retry im Hintergrund
---
## API-Abhängigkeit
Das Frontend wird **zunächst mit Mock-Daten** gebaut. Die API (`/api/b2in-display/playlist` + `/check`) wird als separater Schritt im Backend implementiert. Das Frontend erkennt automatisch, ob die API verfügbar ist, und fällt auf eingebettete Demo-Daten zurück.
**Mock-Playlist für Entwicklung:**
```javascript
const MOCK_PLAYLIST = {
settings: {
display_active: true,
footer_name: "Marcel Scheibe",
footer_url: "b2in.de",
transition: { type: "crossfade", duration_ms: 800 },
default_image_duration: 10
},
items: [
// Demo-Items mit Platzhalter-Medien
],
updated_at: new Date().toISOString()
};
```
---
## Abgrenzung (was NICHT in diesem Schritt passiert)
- Kein CMS-Backend (Model, Migration, Controller, Admin-UI) → separater Schritt
- Kein Media-Upload → Videos/Bilder werden per URL referenziert
- Keine Gewichtungs-Logik im Backend → wird im Frontend berechnet
- Keine Änderung am bestehenden Video-Display (`index.html`) → bleibt unberührt

View file

@ -0,0 +1,386 @@
# B2in Schaufenster-Display Entwicklungskonzept
**Status:** Entwicklungsvorlage | Februar 2026
---
## 1. Projektübersicht
### 1.1 Zweck
Ein großformatiges Display im Schaufenster des CABINET Stores in Bielefeld zeigt B2in-Content im Hochformat (9:16). Ziel: Laufkundschaft in 35 Sekunden eine klare Botschaft vermitteln B2in als Marke für internationale Immobilien und exklusive Einrichtungskonzepte. Das Display läuft parallel zum CABINET-Schaufenster und ist eigenständig gebrandet.
### 1.2 Technischer Ansatz
Die Anzeige wird als Webapp unter einer Subdomain ( Subdomain (`cabinet.b2in.eu/info <- live / [`https://b2in.test/_cabinet](https://b2in.test/_cabinet)/info ← testserver) bereitgestellt. Ein Smart TV mit android greift im Vollbildmodus über den Browser darauf zu. Inhalte (Videos, Bilder, Texte) werden über das B2in-Backend (CMS) gepflegt und rotieren automatisch.
### 1.3 Branding
Ausschließlich B2in-Branding. Kein CABINET-Logo, kein Azizi, keine Partnerlogos. Der Frame zeigt: B2in-Logo, Claim "Connecting Design & Property", Marcel Scheibe als Person, b2in.de und QR-Code.
---
## 2. Display-Architektur
### 2.1 Formatierung
Das Display steht im **9:16 Hochformat (Portrait)**. Das Videomaterial liegt im **16:9 Querformat** vor. Daraus ergibt sich ein natürlicher Split:
`┌─────────────────────────┐
│ HEADER │ ← Fester Bereich: Logo + Claim
│ B2in · Claim │
├─────────────────────────┤
│ │
│ │ ← Oberer Leerraum (Gradient zum Video)
│ ┌───────────────────┐ │
│ │ │ │
│ │ VIDEO / BILD │ │ ← 16:9 Content-Bereich (rotierend)
│ │ (16:9) │ │
│ │ │ │
│ └───────────────────┘ │
│ │ ← Unterer Leerraum (Gradient zum Text)
│ │
├─────────────────────────┤
│ TEXTFELD │ ← Rotierender Text (Headline + Subline)
│ Headline + Subline │
├─────────────────────────┤
│ FOOTER │ ← Fester Bereich: Name + URL + QR
│ Marcel Scheibe · QR │
└─────────────────────────┘`
### 2.2 Bereiche im Detail
| # | Bereich | Inhalt | Verhalten |
| --- | --- | --- | --- |
| 1 | **Header** | B2in-Logo (links) + Claim "Connecting Design & Property" (rechts). | Fest. Steht permanent, unabhängig vom Content. |
| 2 | **Content-Bereich** | 16:9 Video (MP4) oder Einzelbild (JPG/PNG). Nimmt ca. 5060% der Screenhöhe ein. Oben und unten Gradient-Übergang zum dunklen Hintergrund. | Rotiert automatisch. Reihenfolge und Dauer per CMS steuerbar. |
| 3 | **Textfeld** | Headline (max. 40 Zeichen) + Subline (max. 80 Zeichen). Passt sich dem aktuellen Content an. | Wechselt synchron mit dem Video/Bild. Sanfte Fade-Transition. |
| 4 | **Footer** | "Marcel Scheibe" (Name) + "b2in.de" (URL) + QR-Code (rechts). | Fest. Steht permanent. |
---
## 3. Content-Rotation (Playlist-System)
### 3.1 Konzept
Das Display zeigt eine Playlist von Content-Items, die automatisch durchrotieren. Jedes Item besteht aus einem Medienelement (Video oder Bild) plus passendem Text.
### 3.2 Gewichtung
Die Rotation folgt einer definierten Gewichtung:
| Kategorie | Anteil | Beispiel-Content |
| --- | --- | --- |
| **Immobilien** | ~70% | Dubai-Projekte, internationale Lifestyle-Aufnahmen, Architektur |
| **Möbel / Einrichtung** | ~30% | Lokale Händler-Highlights, Einrichtungskonzepte, Interior-Design |
Die Gewichtung wird über ein `category`-Feld pro Item gesteuert. Das Frontend berechnet die Rotation so, dass die Verteilung über einen Durchlauf eingehalten wird (z. B. bei 10 Items: 7× Immobilien, 3× Möbel).
### 3.3 Content-Item Struktur
Jedes Playlist-Item hat folgende Eigenschaften:
| Feld | Typ | Beschreibung |
| --- | --- | --- |
| `id` | Auto | Eindeutige ID |
| `title` | Text (intern) | Interner Name zur Identifikation im CMS (wird nicht angezeigt) |
| `category` | Dropdown | `immobilien` | `moebel` bestimmt die Gewichtung in der Rotation |
| `media_type` | Dropdown | `video` | `image` |
| `media_file` | Upload (MP4/JPG/PNG) | Mediendatei. Videos: MP4, H.264, max. 1080p. Bilder: JPG/PNG, min. 1920x1080. |
| `media_url` | URL (optional) | Alternative zu Upload: Externe Video-/Bild-URL. Wird bevorzugt wenn vorhanden. |
| `headline` | Text | Max. 40 Zeichen. Wird im Textfeld angezeigt. |
| `subline` | Text | Max. 80 Zeichen. Erklärender Text unter der Headline. |
| `duration_seconds` | Zahl | Anzeigedauer in Sekunden. Für Bilder: empfohlen 812 Sek. Für Videos: wird ignoriert, Dauer ergibt sich aus Videolänge. |
| `sort_order` | Zahl | Reihenfolge in der Playlist. Niedrigste Zahl zuerst. |
| `is_active` | Toggle | Aktiv/Inaktiv. Inaktive Items werden übersprungen. |
### 3.4 Beispiel-Playlist
| # | Titel (intern) | Kategorie | Typ | Headline | Subline | Dauer |
| --- | --- | --- | --- | --- | --- | --- |
| 1 | Dubai Skyline Video | immobilien | video | Internationale Immobilien — Ihr Einstieg. | Beratung, Begleitung und Vermittlung. Persönlich. Transparent. | (Video) |
| 2 | Dubai Villa Rendering | immobilien | image | Ihr Zuhause. Weltweit. | Von Dubai bis Europa wir finden Ihre Immobilie. | 10s |
| 3 | Möbel Showroom | moebel | image | Exklusive Einrichtung — Lokal. Für Sie. | Kuratierte Möbelkonzepte von lokalen Fachhändlern. | 10s |
| 4 | Dubai Pool Lifestyle | immobilien | video | Dubai. Lissabon. Und morgen? | Internationale Immobilien als Kapitalanlage. | (Video) |
| 5 | Dubai Apartment Tour | immobilien | video | Neubau ab 3.000 €/m² | Wertsteigerung. Steuervorteile. Persönliche Begleitung. | (Video) |
| 6 | Interior Design Mood | moebel | image | Lokale Händler. Echte Stücke. | Ausstellungsstücke und Designmöbel aus Ihrer Region. | 10s |
| 7 | Dubai Cityscape Drone | immobilien | video | Ihr Immobilien-Dolmetscher. | Marcel Scheibe begleitet Sie durch den gesamten Kaufprozess. | (Video) |
---
## 4. CMS-Felder (B2in-Backend)
Im B2in-Backend wird ein eigener Bereich „B2in Display" angelegt mit zwei Ebenen: globale Einstellungen und die Playlist-Items.
### 4.1 Globale Einstellungen
| Feldname | Typ | Beschreibung |
| --- | --- | --- |
| `display_active` | Toggle | Master-Schalter. Wenn deaktiviert, zeigt das Display einen Standby-Screen (nur Logo). |
| `footer_name` | Text | Name im Footer. Standard: "Marcel Scheibe" |
| `footer_url` | Text | URL im Footer. Standard: "b2in.de" |
| `qr_code_image` | Upload (PNG/SVG) | QR-Code Asset für den Footer. |
| `rotation_weight_immobilien` | Zahl (%) | Soll-Anteil Immobilien-Content. Standard: 70. |
| `rotation_weight_moebel` | Zahl (%) | Soll-Anteil Möbel-Content. Standard: 30. |
| `default_image_duration` | Zahl (Sek.) | Standard-Anzeigedauer für Bilder, wenn beim Item nichts eingetragen. Standard: 10. |
| `transition_type` | Dropdown | `fade` | `slide` | `crossfade` Übergangseffekt zwischen Items. Standard: crossfade. |
| `transition_duration_ms` | Zahl | Dauer der Transition in Millisekunden. Standard: 800. |
### 4.2 Playlist-Items
Wiederholbare Einträge (Repeater-Feld oder eigene Collection), Felder wie in Abschnitt 3.3 definiert.
### 4.3 Zeichenlimit-Hinweise für Marcel
Im CMS sollten bei den Textfeldern visuelle Hinweise erscheinen:
- **Headline:** Zähler „12/40 Zeichen" wird rot ab 35
- **Subline:** Zähler „45/80 Zeichen" wird rot ab 70
- **Begründung:** Auf dem Display müssen die Texte in 3 Sekunden lesbar sein. Längere Texte werden nicht gelesen.
---
## 5. API-Endpoint
### 5.1 Endpoint-Struktur
`GET /api/b2in-display/playlist`
**Response (JSON):**
json
`{
"settings": {
"display_active": true,
"footer_name": "Marcel Scheibe",
"footer_url": "b2in.de",
"qr_code_url": "/assets/qr-b2in.svg",
"rotation_weights": {
"immobilien": 70,
"moebel": 30
},
"default_image_duration": 10,
"transition": {
"type": "crossfade",
"duration_ms": 800
}
},
"items": [
{
"id": 1,
"category": "immobilien",
"media_type": "video",
"media_url": "/media/display/dubai-skyline.mp4",
"headline": "Internationale Immobilien — Ihr Einstieg.",
"subline": "Beratung, Begleitung und Vermittlung. Persönlich. Transparent.",
"duration_seconds": null,
"sort_order": 1,
"is_active": true
},
{
"id": 2,
"category": "moebel",
"media_type": "image",
"media_url": "/media/display/showroom-lokal.jpg",
"headline": "Exklusive Einrichtung — Lokal. Für Sie.",
"subline": "Kuratierte Möbelkonzepte von lokalen Fachhändlern.",
"duration_seconds": 10,
"sort_order": 3,
"is_active": true
}
],
"updated_at": "2026-02-26T14:30:00Z"
}`
### 5.2 Update-Mechanismus (Polling)
Gleicher Ansatz wie beim CABINET Info-Tablet:
**1. Lightweight Check (alle 60 Sekunden):**
`GET /api/b2in-display/check`
json
`{ "updated_at": "2026-02-26T14:30:00Z" }`
**2. Full Fetch (nur bei Änderung):**
Wenn Timestamp abweicht → komplette Playlist neu laden. Laufendes Video wird zu Ende gespielt, dann greift die neue Playlist.
**Warum 60 statt 30 Sekunden?** Das Display zeigt eine Playlist, keine Echtzeit-Infos. Wenn Marcel ein neues Video hochlädt, ist eine Verzögerung von maximal 60 Sekunden völlig akzeptabel.
---
## 6. Frontend-Logik
### 6.1 Playlist-Engine
Das Frontend implementiert eine einfache Playlist-Engine:
`Ablauf:
1. Playlist laden (API-Call)
2. Nur aktive Items filtern (is_active === true)
3. Items nach sort_order sortieren
4. Gewichtung anwenden:
- Items nach Kategorie gruppieren
- Rotation so mischen, dass die %-Verteilung eingehalten wird
- Beispiel bei 70/30: I, I, M, I, I, I, M, I, I, M (bei 10 Items)
5. Erstes Item anzeigen
6. Bei Video: Warten bis Video endet → nächstes Item
7. Bei Bild: duration_seconds abwarten → nächstes Item
8. Am Ende der Playlist: Von vorne beginnen
9. Zwischen Items: Transition (crossfade/fade/slide)`
### 6.2 Video-Handling
| Aspekt | Spezifikation |
| --- | --- |
| **Format** | MP4, H.264 Codec, max. 1080p (1920x1080) |
| **Autoplay** | Videos starten automatisch, gemutet (Browser-Policy). Ton ist nicht relevant für Schaufenster-Display. |
| **Ladezeit** | Videos werden im Hintergrund vorgeladen (nächstes Item in der Playlist wird gepreloaded während das aktuelle läuft). |
| **Fehlerfälle** | Wenn ein Video nicht lädt (404, Netzwerkfehler): Item überspringen, nächstes anzeigen. Fehler loggen. |
| **Loop** | Kein Loop pro Video. Jedes Video spielt einmal, dann kommt das nächste Playlist-Item. |
### 6.3 Bild-Handling
| Aspekt | Spezifikation |
| --- | --- |
| **Format** | JPG oder PNG, min. 1920x1080 Pixel |
| **Skalierung** | `object-fit: cover` Bild füllt den 16:9-Bereich, wird bei Bedarf beschnitten. |
| **Dauer** | Aus `duration_seconds` des Items oder `default_image_duration` aus den globalen Settings. |
| **Ken-Burns-Effekt (optional)** | Langsamer Zoom-In während der Anzeigedauer. Macht statische Bilder lebendiger. Per CSS-Animation, kein JavaScript nötig. |
### 6.4 Text-Synchronisation
Headline und Subline wechseln synchron mit dem Media-Content:
1. Aktuelles Textfeld ausblenden (fade-out, 400ms)
2. Neuen Text setzen
3. Neues Textfeld einblenden (fade-in, 400ms)
4. Timing: Text-Transition startet 200ms vor dem Media-Wechsel, damit beides gleichzeitig erscheint.
---
## 7. Technische Stabilität & Kiosk-Betrieb
### 7.1 Hardware-Setup
| Komponente | Empfehlung |
| --- | --- |
| **Display** | TV/Monitor im Hochformat (9:16), 4355 Zoll. Full HD ausreichend (1080x1920 in Portrait). LED/LCD mit hoher Helligkeit (min. 350 nits, ideal 500+ nits für Schaufenster mit Sonneneinstrahlung). |
| **Media Player** | Dedizierter Android-Stick oder -Box (z. B. Amazon Fire TV Stick 4K, Xiaomi Mi Box, oder professionell: BrightSign). Alternativ: Smart TV mit Browser. |
| **Kiosk-Software** | Fully Kiosk Browser (wenn Android). Bei BrightSign: eigene Kiosk-Funktionalität. |
| **Halterung** | VESA-Wandhalterung, Portrait-Montage. Ggf. mit Blickwinkelschutz-Folie wenn das Display zu nah an der Scheibe steht. |
| **Stromversorgung** | Dauerstrom. Optional: Zeitschaltuhr oder Smart Plug für automatisches Ein/Aus (z. B. 07:0022:00). |
| **WLAN** | Stabiles Store-WLAN. Bei Verbindungsproblemen: LAN-Adapter als Fallback. |
### 7.2 Webapp-seitige Stabilität
| Maßnahme | Beschreibung |
| --- | --- |
| **Auto-Reload** | Kompletter Page-Reload alle 6 Stunden. Räumt Speicher auf besonders wichtig bei Video-Wiedergabe, die Memory Leaks verursachen kann. |
| **Video-Memory-Management** | Nach jedem Video: `src` des Video-Elements leeren und neu setzen, damit der Browser den Speicher freigibt. Kein Stapeln von Video-Elementen. |
| **Offline-Fallback** | Wenn API nicht erreichbar: Letzte Playlist aus localStorage abspielen. Kein schwarzer Bildschirm. |
| **Connection-Recovery** | Polling-Fehler 3x hintereinander → 5 Min. Pause → erneut versuchen. Nach 30 Min. ohne Verbindung → Page-Reload. |
| **Lokaler Cache** | Playlist-JSON wird in localStorage gespeichert. Mediendateien werden im Browser-Cache gehalten (Cache-Control Headers serverseitig setzen, z. B. `max-age=86400`). |
| **Standby-Modus** | Optionaler CMS-Toggle: Wenn `display_active = false`, zeigt die Webapp nur das B2in-Logo auf dunklem Hintergrund. Kein Content, kein Textfeld. |
| **Error-Overlay** | Bei kritischen Fehlern (keine Playlist, kein Media): Dezentes B2in-Logo auf dunklem Hintergrund. Niemals ein Browser-Fehlerbild oder weißer Screen. |
| **Automatischer Playlist-Neustart** | Wenn die Playlist durchgelaufen ist und das nächste Polling eine Änderung zeigt: Neue Playlist nahtlos übernehmen. |
### 7.3 Performance-Hinweise
- Videos sollten für Web optimiert sein: MP4, H.264, AAC Audio (auch wenn gemutet), `moov atom` am Anfang der Datei (für schnellen Start)
- Empfohlene Bitrate: 58 Mbit/s für 1080p
- Bilder sollten als WebP oder komprimiertes JPG vorliegen (max. 500 KB pro Bild)
- Preloading: Immer das nächste Item vorladen, während das aktuelle abgespielt wird
---
## 8. Frontend-Spezifikation
### 8.1 Technologie
- HTML/CSS/JS (Vanilla). Kein Framework nötig.
- Responsive für gängige Display-Auflösungen im 9:16 Portrait (1080x1920, 720x1280)
### 8.3 Transitions zwischen Items
| Transition-Typ | Beschreibung |
| --- | --- |
| `fade` | Aktuelles Item blendet aus (opacity 1→0), neues blendet ein (opacity 0→1). |
| `crossfade` | Neues Item blendet über dem aktuellen ein. Sanfter, da kein schwarzer Zwischenzustand. **Empfohlen.** |
| `slide` | Aktuelles Item gleitet nach links raus, neues kommt von rechts rein. |
Standard: **crossfade** mit 800ms. Per CMS änderbar.
---
## 9. Leitplanken für Content-Pflege
**Diese Regeln sollten im CMS als Hinweistext sichtbar sein:**
### ✓ Ja, so machen wir es:
- **Ein Item, eine Botschaft.** Jedes Playlist-Item hat eine klare Aussage: Immobilien ODER Möbel.
- **Texte kurz halten.** Headline max. 40 Zeichen, Subline max. 80 Zeichen. Was in 3 Sekunden nicht gelesen werden kann, wird nicht gelesen.
- **Nur B2in-Content.** Keine Partnerlogos im Video/Bild. Kein Azizi-Branding. Kein CABINET.
- **Hochwertige Medien.** Nur professionelles Video-/Bildmaterial. Keine Handy-Fotos, keine Screenshots.
- **Regelmäßig aktualisieren.** Playlist mindestens quartalsweise prüfen. Abgelaufene Projekte rausnehmen.
### ✗ Das vermeiden wir:
- **Zu viele Items.** Max. 810 Items in der Playlist. Mehr führt zu langer Rotation, Wiederholung wird selten.
- **Text im Video.** Wenn das Video bereits Text enthält, Headline und Subline reduzieren oder leer lassen sonst doppelt sich die Information.
- **Preislisten oder Grundrisse.** Das Display weckt Neugier, es informiert nicht. Details gehören auf die Website.
- **Verschiedene Botschaften mischen.** Kein Item, das gleichzeitig Dubai, Möbel und CABINET bewirbt.
---
## 10. Umsetzungs-Checkliste
| # | Aufgabe | Verantwortlich | Status |
| --- | --- | --- | --- |
| 1 | CMS-Bereich "B2in Display" anlegen: Globale Settings + Playlist-Repeater (Abschnitt 4) | Backend-Entwicklung | Offen |
| 2 | API-Endpoint `/api/b2in-display/playlist` + `/check` implementieren (Abschnitt 5) | Backend-Entwicklung | Offen |
| 3 | Media-Upload-Funktion im CMS (MP4 + JPG/PNG) mit Größen-Validierung | Backend-Entwicklung | Offen |
| 4 | Frontend-Webapp mit Playlist-Engine bauen (Abschnitt 6) | Frontend-Entwicklung | Offen |
| 5 | Video-Preloading + Memory-Management implementieren (Abschnitt 6.2 + 7.2) | Frontend-Entwicklung | Offen |
| 6 | Polling-Mechanismus + Offline-Fallback + Auto-Reload (Abschnitt 5.2 + 7.2) | Frontend-Entwicklung | Offen |
| 7 | Subdomain einrichten + SSL + Deployment | DevOps | Offen |
| 8 | Display-Hardware beschaffen + montieren (Abschnitt 7.1) | Marcel / Hardware | Offen |
| 9 | Kiosk-Software einrichten (Fully Kiosk oder BrightSign) | Technik | Offen |
| 10 | QR-Code generieren (Ziel: b2in.de) + als Asset hinterlegen | Design | Offen |
| 11 | Initiales Video-/Bildmaterial aufbereiten (Format, Komprimierung, Qualität) | Content / Design | Offen |
| 12 | Beispiel-Playlist im CMS anlegen + End-to-End-Test | QA | Offen |
| 13 | Installation im Store: Position, Helligkeit, Blickwinkel, WLAN-Stabilität | Marcel + Technik | Offen |
---
## Anhang A: Zusammenfassung der Auto-Logiken
Folgende Dinge passieren automatisch, ohne dass Marcel etwas pflegen muss:
- Playlist rotiert endlos durch alle aktiven Items
- Gewichtung (70/30) wird automatisch aus den Kategorien berechnet
- Videos starten automatisch (gemutet) und gehen nach Ende zum nächsten Item
- Bilder werden nach definierter Dauer gewechselt
- Polling prüft alle 60 Sekunden auf CMS-Änderungen
- Bei Playlist-Änderung: Laufendes Item wird zu Ende gespielt, dann greift neue Playlist
- Auto-Reload alle 6 Stunden für Speicher-Hygiene
- Offline: Letzte Playlist läuft weiter aus dem Cache
- Bei Video-Fehler: Item wird übersprungen, kein schwarzer Screen
- Standby-Modus bei `display_active = false`: Nur Logo auf dunklem Hintergrund
## Anhang B: Unterschiede zum CABINET Info-Tablet
| Aspekt | CABINET Info-Tablet | B2in Display |
| --- | --- | --- |
| **Zweck** | Store-Information | Marken-/Content-Anzeige |
| **Branding** | CABINET | B2in |
| **Content-Typ** | Text/Daten (statisch) | Video/Bild (Playlist) |
| **Interaktion** | Keine | Keine |
| **Update-Frequenz** | Bei Bedarf (12x/Tag) | Playlist rotiert dauerhaft |
| **Polling-Intervall** | 30 Sekunden | 60 Sekunden |
| **Hardware** | Android-Tablet 810" | TV/Monitor 4355" + Media Player |
| **CMS-Komplexität** | 12 einfache Felder | Globale Settings + Playlist-Repeater |

View file

@ -0,0 +1,200 @@
# CABINET Info-Tablet Entwicklungskonzept
**Status:** Entwicklungsvorlage | Februar 2026
---
## 1. Projektübersicht
### 1.1 Zweck
Ein kleines Tablet im Schaufenster neben dem Eingang des CABINET Stores in Bielefeld zeigt Passanten und Kunden auf einen Blick die wichtigsten Store-Informationen: aktueller Öffnungsstatus, Öffnungszeiten, Sonderhinweise und den nächsten freien Beratungstermin.
### 1.2 Technischer Ansatz
Die Anzeige wird als responsive Webapp unter einer Subdomain (`cabinet.b2in.eu/info <- live / [`https://b2in.test/_cabinet](https://b2in.test/_cabinet)/info ← testserver) bereitgestellt. Das Tablet greift im Vollbild-Kioskmodus über den Browser darauf zu. Die Inhalte werden über das bestehende B2in-Backend (CMS) gepflegt.
Ordner /info
### 1.3 Branding
Ausschließlich CABINET-Branding. Kein B2in-Logo, kein Hinweis auf andere Marken. Das Tablet ist ein reines Store-Informationstool.
---
## 2. Content-Struktur & Layout
Das Display ist in fünf feste Bereiche unterteilt, von oben nach unten:
| # | Bereich | Inhalt | Verhalten |
| --- | --- | --- | --- |
| 1 | **Header** | CABINET Logo (links) + aktuelles Datum mit Wochentag (rechts). | Datum aktualisiert sich automatisch um Mitternacht. |
| 2 | **Status-Banner** | Drei Zustände: **Geöffnet** (grün): „Wir sind geöffnet" + „Heute bis [Uhrzeit] für Sie da." · **Hinweis** (gelb): Frei definierbare Headline + Subtext. Z. B. „Heute erst ab 11:00 Uhr" · **Geschlossen** (rot): Frei definierbare Headline + Subtext. Z. B. „Betriebsurlaub bis 03.03." | Farbe, Icon und Text wechseln je nach Status. CMS-gesteuert. |
| 3 | **Öffnungszeiten** | Wochenansicht MoSo. Heutiger Tag visuell hervorgehoben (fetter Text, leichter Hintergrund). Wenn Sonderöffnung aktiv: heutige Zeit wird in Orange angezeigt. | Heutiger Tag wird automatisch erkannt. Sonderöffnung überschreibt Standard-Zeit für heute. |
| 4 | **Termin-Karte** | Dunkle Karte mit Kalender-Icon. Zeigt: „Nächster freier Termin" + Datum/Uhrzeit + „Beratung ca. 45 Min." | CMS-gesteuert. Optional: Kann später an Kalendersystem angebunden werden. |
| 5 | **Footer** | Telefonnummer (links) + E-Mail (links) + QR-Code (rechts). QR-Code führt zur Website oder Terminbuchung. | Statisch. QR-Code wird einmalig generiert und als Asset hinterlegt. |
---
## 3. CMS-Felder (B2in-Backend)
Folgende Felder werden im B2in-Backend als eigener Bereich „CABINET Info-Tablet" angelegt. Alle Felder sind einfache Eingabefelder ohne komplexe Logik.
| Feldname | Typ | Validierung | Beschreibung |
| --- | --- | --- | --- |
| `store_status` | Dropdown | `open` | `notice` | `closed` | Bestimmt Farbe und Grundtext des Status-Banners. |
| `notice_headline` | Text | Max. 40 Zeichen | Headline im Status-Banner bei Status „notice" oder „closed". Wird bei „open" ignoriert. |
| `notice_subtext` | Text | Max. 80 Zeichen | Erklärender Subtext unter der Headline. Optional. |
| `override_open_today` | Zeit (HH:MM) | Optional, Format HH:MM | Sonder-Öffnungszeit für heute. Überschreibt die Standard-Öffnungszeit in der Wochenansicht. |
| `override_close_today` | Zeit (HH:MM) | Optional, Format HH:MM | Sonder-Schlusszeit für heute. |
| `next_appointment_date` | Datum | TT.MM.JJJJ | Datum des nächsten freien Beratungstermins. |
| `next_appointment_time` | Zeit (HH:MM) | Format HH:MM | Uhrzeit des nächsten freien Termins. |
| `hours_monday` `hours_sunday` | Text | z. B. „10:0018:00" oder „Geschlossen" | Standard-Öffnungszeiten pro Wochentag. 7 Felder. |
| `contact_phone` | Text | Freitext | Telefonnummer im Footer. |
| `contact_email` | Text | E-Mail-Format | E-Mail-Adresse im Footer. |
**Wichtig:** Die Felder `override_open_today` und `override_close_today` sollten sich automatisch um Mitternacht zurücksetzen (auf leer), damit die Sonderöffnung nicht versehentlich am nächsten Tag weiterläuft.
---
## 4. API-Endpoint
Das Backend stellt einen einfachen JSON-Endpoint bereit, den die Webapp abfragt.
### 4.1 Endpoint-Struktur
`GET /api/cabinet-tablet/status`
**Response (JSON):**
json
`{
"store_status": "notice",
"notice_headline": "Heute erst ab 11:00 Uhr",
"notice_subtext": "Wegen eines Kundentermins öffnen wir heute später.",
"override_open_today": "11:00",
"override_close_today": null,
"next_appointment": {
"date": "2026-02-27",
"time": "14:00"
},
"hours": {
"monday": "10:00 18:00",
"tuesday": "10:00 18:00",
"wednesday": "10:00 18:00",
"thursday": "10:00 18:00",
"friday": "10:00 18:00",
"saturday": "10:00 14:00",
"sunday": "Geschlossen"
},
"contact": {
"phone": "0521 123 456 0",
"email": "info@cabinet-bielefeld.de"
},
"updated_at": "2026-02-26T09:15:00Z"
}`
### 4.2 Update-Mechanismus (Polling)
Die Webapp fragt den Endpoint in zwei Stufen ab:
**1. Lightweight Check (alle 30 Sekunden):**
Die Webapp ruft einen minimalen Endpoint ab, der nur den `updated_at` Timestamp zurückgibt.
`GET /api/cabinet-tablet/check`
json
`{ "updated_at": "2026-02-26T09:15:00Z" }`
**2. Full Fetch (nur bei Änderung):**
Wenn der Timestamp sich vom lokal gespeicherten unterscheidet, wird der komplette Status-Endpoint abgerufen und die Anzeige aktualisiert kein Page-Reload, nur DOM-Update per JavaScript.
**Warum Polling statt WebSockets?** Für ein einzelnes Tablet, das auf ein CMS reagiert, das vielleicht 12x am Tag geändert wird, ist Polling robuster und einfacher zu warten. WebSockets wären Overkill und eine zusätzliche Fehlerquelle.
---
## 5. Technische Stabilität & Kiosk-Betrieb
### 5.1 Kiosk-App (Android)
**Empfehlung: Fully Kiosk Browser** (ca. 7 € einmalig pro Gerät, Industriestandard für Digital Signage).
Funktionen, die wir nutzen:
- Vollbild-/Kioskmodus: Kein Zugriff auf Android-UI, Statusbar ausgeblendet
- Auto-Restart bei Browser-Absturz: App startet sich automatisch neu und lädt die URL
- Zeitsteuerung: Tablet geht nachts (z. B. 22:00) in Standby, wacht morgens (z. B. 07:00) auf
- Remote-Management: Über Fully Cloud kann die URL und Einstellungen remote geändert werden
- Bildschirm-Timeout: Screen bleibt dauerhaft an während der definierten Betriebszeit
- Motion Detection (optional): Bildschirm wird heller wenn jemand davor steht
### 5.2 Webapp-seitige Stabilität
Zusätzlich zur Kiosk-App bauen wir folgende Sicherheiten direkt in die Webapp ein:
| Maßnahme | Beschreibung |
| --- | --- |
| **Auto-Reload** | Kompletter Page-Reload alle 6 Stunden (z. B. um 03:00, 09:00, 15:00, 21:00). Räumt Speicher auf und verhindert Memory Leaks durch langlebige Browser-Sessions. |
| **Offline-Fallback** | Wenn die API nicht erreichbar ist, zeigt die Webapp den letzten bekannten Stand an + dezenten Hinweis „Stand: [Zeitpunkt]". Kein Fehlerbildschirm, der Passanten verwirrt. |
| **Connection-Recovery** | Wenn das Polling 3x hintereinander fehlschlägt, wartet die Webapp 5 Minuten, dann versucht sie es erneut. Nach 30 Minuten ohne Verbindung: automatischer Page-Reload. |
| **Lokaler Cache** | Die letzte API-Response wird im localStorage gespeichert. Bei Neustart (z. B. nach Browser-Crash) wird sofort der Cache angezeigt, während im Hintergrund frische Daten geladen werden. |
| **Keine Interaktion** | Die Webapp hat keine klickbaren Elemente (außer dem QR-Code, der ohnehin nur visuell ist). Kein Scrollen, kein Touch-Event. Verhindert versehentliche Bedienung. |
| **Automatische Datumsaktualisierung** | Um Mitternacht: Neuen Wochentag setzen, heutigen Tag in der Öffnungszeiten-Liste aktualisieren, Sonderöffnungszeiten zurücksetzen. |
---
## 6. Frontend-Spezifikation
### 6.1 Technologie
- Einfache statische HTML/CSS/JS-Seite (kein Framework notwendig)
- Responsive für Tablet-Größen hochkannt (ca. 1.600 x 2.456** Pixel **, 256 PPI**)
- Keine externen Abhängigkeiten außer einer Google-Fonts-Einbindung (Fallback auf System-Fonts)
- JavaScript Vanilla kein React, Vue o. Ä. nötig
### 6.3 Animations & Transitions
Wenn sich der Status ändert (z. B. von „open" zu „notice"), soll der Übergang sanft per CSS-Transition (300ms ease) erfolgen kein harter Wechsel. Gleiches gilt für Änderungen an Öffnungszeiten oder Termin.
---
## 7. Hardware-Empfehlung
| Komponente | Empfehlung |
| --- | --- |
| **Tablet** | Android-Tablet, 810 Zoll, min. 2 GB RAM. Muss nicht high-end sein es zeigt nur eine Webseite. HUAWEI MatePad T |
| **Kiosk-App** | Fully Kiosk Browser (ca. 7 € Lizenz). |
| **Halterung** | Wandhalterung oder Standfuß mit Diebstahlschutz. Hochkant montiert. |
| **Stromversorgung** | Dauerhaft am Strom (Ladekabel mit Kabelkanal). Akku-Ladung auf 80 % begrenzen (Fully Kiosk unterstützt das), um Akku-Verschleiß zu minimieren. |
| **WLAN** | Stabiles Store-WLAN. Empfehlung: Festes WLAN-Profil im Tablet hinterlegen, Auto-Reconnect aktivieren. |
---
## 8. Umsetzungs-Checkliste
| # | Aufgabe | Verantwortlich | Status |
| --- | --- | --- | --- |
| 1 | CMS-Felder im B2in-Backend anlegen (Abschnitt 3) | Backend-Entwicklung | Offen |
| 2 | API-Endpoint implementieren (Abschnitt 4) | Backend-Entwicklung | Offen |
| 3 | Frontend-Webapp bauen (Abschnitt 2 + 6) | Frontend-Entwicklung | Offen |
| 4 | Polling-Mechanismus + Stabilität implementieren (Abschnitt 4.2 + 5.2) | Frontend-Entwicklung | Offen |
| 5 | Subdomain einrichten + Deployment | DevOps | Offen |
| 6 | Tablet beschaffen + Fully Kiosk einrichten (Abschnitt 7) | Marcel / Hardware | Offen |
| 7 | QR-Code generieren + als Asset hinterlegen | Design | Offen |
| 8 | End-to-End-Test: CMS-Änderung → Tablet zeigt Update | QA | Offen |
| 9 | Installation im Store + Feintuning Helligkeit/Position | Marcel + Technik | Offen |
---
## Anhang: Zusammenfassung der Auto-Logiken
Folgende Dinge passieren automatisch, ohne dass Marcel etwas pflegen muss:
- Datum und Wochentag im Header aktualisieren sich um Mitternacht
- Heutiger Tag in der Öffnungszeitenliste wird automatisch hervorgehoben
- Sonderöffnungszeiten (override-Felder) setzen sich um Mitternacht zurück
- Bei Status „open": Banner-Text generiert sich automatisch aus der heutigen Schlusszeit
- Polling läuft im Hintergrund (alle 30 Sek.), DOM-Updates ohne Flackern
- Auto-Reload alle 6 Stunden für Speicher-Hygiene
- Offline-Fallback: Letzter Stand wird angezeigt, kein leerer Bildschirm
- Fully Kiosk: Auto-Restart bei Crash, Standby-Zeiten, Bildschirm-Management

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_1" data-name="Ebene 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 283.46 160.63">
<defs>
<style>
.cls-1 {
fill: #2c9fda;
}
.cls-2 {
fill: #314052;
}
</style>
</defs>
<path class="cls-1" d="M36.8,55.75C45.39,25.8,75.26,3.7,110.8,3.7s65.41,22.1,74,52.04h13.94C185.49,23.2,151.12,0,110.8,0S36.12,23.2,22.87,55.75h13.94Z"/>
<g>
<path class="cls-2" d="M0,160.63V60.03h39.61c7.33,0,13.44,1.11,18.33,3.34,4.89,2.23,8.56,5.28,10.99,9.15,2.43,3.87,3.65,8.33,3.65,13.37,0,3.96-.78,7.42-2.34,10.36-1.57,2.95-3.7,5.36-6.4,7.22-2.7,1.87-5.77,3.21-9.22,4.02v1.01c3.75.14,7.29,1.2,10.62,3.21,3.33,2,6.03,4.79,8.11,8.37,2.08,3.58,3.12,7.84,3.12,12.79,0,5.31-1.3,10.06-3.89,14.25-2.59,4.19-6.42,7.48-11.49,9.89-5.07,2.41-11.38,3.61-18.93,3.61H0ZM20.44,102.29h16.15c2.9,0,5.52-.53,7.84-1.59,2.32-1.06,4.14-2.57,5.46-4.52,1.32-1.96,1.98-4.29,1.98-6.99,0-3.65-1.29-6.62-3.85-8.91-2.57-2.3-6.24-3.44-11.02-3.44h-16.55v25.45ZM20.44,143.68h17.69c5.99,0,10.36-1.17,13.14-3.51,2.77-2.34,4.16-5.42,4.16-9.25,0-2.84-.68-5.34-2.04-7.53-1.36-2.18-3.29-3.89-5.8-5.13-2.5-1.24-5.47-1.86-8.91-1.86h-18.23v27.28Z"/>
<path class="cls-2" d="M90.07,160.63v-14.85l35.38-33.42c3.04-2.97,5.59-5.66,7.67-8.07,2.08-2.41,3.66-4.77,4.76-7.09,1.09-2.32,1.64-4.83,1.64-7.53,0-3.01-.67-5.6-2.01-7.76s-3.17-3.83-5.49-5c-2.32-1.17-4.98-1.75-7.97-1.75s-5.79.63-8.11,1.89c-2.32,1.26-4.11,3.06-5.36,5.4s-1.88,5.15-1.88,8.44h-19.57c0-6.53,1.48-12.21,4.46-17.05,2.97-4.84,7.09-8.57,12.36-11.21,5.27-2.63,11.35-3.95,18.23-3.95s13.22,1.27,18.5,3.81c5.27,2.54,9.37,6.04,12.3,10.5,2.93,4.46,4.39,9.59,4.39,15.39,0,3.74-.73,7.43-2.18,11.07-1.45,3.65-4.05,7.73-7.77,12.25s-9.01,9.94-15.85,16.24l-15.08,14.99v.74h42.22v16.95h-70.63Z"/>
<path class="cls-2" d="M207.81,160.63h-33.41v-12.72h10.4v-71.07h-10.4v-12.72h33.41v12.72h-10.51v71.07h10.51v12.72Z"/>
<path class="cls-2" d="M269.53,160.63l-25.22-53.23-8.52-20.6h-.33v73.84h-11.95v-96.51h14.05l25.11,53.23,8.52,20.6h.33v-73.83h11.95v96.51h-13.94Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

@ -0,0 +1,499 @@
/**
* B2in Schaufenster-Display Styles
* 9:16 Portrait, 4355 Zoll, B2in-Branding
*
* Eigenständiges Design NICHT cabinet-base.css importiert.
* B2in hat ein eigenes Farbschema und Branding.
*
* Themes: data-theme="dark" (default) | data-theme="light"
*/
:root {
/* B2in Brand Colors */
--b2in-blue: #20a0da;
--b2in-dark: #2b3f51;
--b2in-blue-glow: rgba(32, 160, 218, 0.15);
/* Surface Colors (Dark default) */
--bg: #0a0a0a;
--bg-raised: #111111;
--bg-card: #161616;
--fg: #ffffff;
--fg-muted: rgba(255, 255, 255, 0.55);
--fg-subtle: rgba(255, 255, 255, 0.35);
--line: rgba(255, 255, 255, 0.08);
/* Typography */
--font-main: 'IBM Plex Sans', ui-sans-serif, system-ui, -apple-system, sans-serif;
/* Spacing */
--safe-area: 48px;
--header-height: 100px;
--footer-height: 120px;
--text-area-height: 160px;
/* Transitions */
--transition-fast: 300ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-medium: 600ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 800ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* ========================================
LIGHT THEME
======================================== */
[data-theme="light"] {
--bg: #f7f8fa;
--bg-raised: #ffffff;
--bg-card: #eef0f3;
--fg: #2b3f51;
--fg-muted: rgba(43, 63, 81, 0.6);
--fg-subtle: rgba(43, 63, 81, 0.4);
--line: rgba(43, 63, 81, 0.1);
--b2in-blue-glow: rgba(32, 160, 218, 0.1);
}
/* ========================================
RESET
======================================== */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
font-family: var(--font-main);
background: var(--bg);
color: var(--fg);
display: flex;
align-items: center;
justify-content: center;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
/* ========================================
9:16 DISPLAY FRAME
======================================== */
.display-frame {
width: 100%;
height: 100%;
max-width: 1080px;
max-height: 1920px;
aspect-ratio: 9 / 16;
display: flex;
flex-direction: column;
background: var(--bg);
position: relative;
overflow: hidden;
}
@media (min-aspect-ratio: 9/16) {
.display-frame {
width: auto;
height: 100vh;
}
}
@media (max-aspect-ratio: 9/16) {
.display-frame {
width: 100vw;
height: auto;
}
}
/* ========================================
HEADER
======================================== */
.display-header {
height: var(--header-height);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--safe-area);
position: relative;
z-index: 10;
flex-shrink: 0;
}
.display-header::after {
content: '';
position: absolute;
bottom: 0;
left: var(--safe-area);
right: var(--safe-area);
height: 1px;
background: var(--line);
}
.header-logo img {
height: 48px;
width: auto;
}
.header-claim {
font-size: 16px;
font-weight: 300;
color: var(--fg-muted);
letter-spacing: 0.04em;
text-align: right;
}
/* ========================================
MEDIA AREA (Video / Bild)
======================================== */
.media-area {
flex: 1;
position: relative;
overflow: hidden;
}
/* Gradient Overlays: dunkel → transparent → dunkel */
.media-area::before,
.media-area::after {
content: '';
position: absolute;
left: 0;
right: 0;
height: 25%;
z-index: 2;
pointer-events: none;
}
.media-area::before {
top: 0;
background: linear-gradient(180deg, var(--bg) 0%, transparent 100%);
}
.media-area::after {
bottom: 0;
background: linear-gradient(0deg, var(--bg) 0%, transparent 100%);
}
/* Light theme: Softer gradients */
[data-theme="light"] .media-area::before {
height: 20%;
background: linear-gradient(180deg, var(--bg) 0%, transparent 100%);
}
[data-theme="light"] .media-area::after {
height: 20%;
background: linear-gradient(0deg, var(--bg) 0%, transparent 100%);
}
/* Media Container für Crossfade (zwei Layer übereinander) */
.media-layer {
position: absolute;
inset: 0;
z-index: 1;
}
.media-layer video,
.media-layer img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* Transitions */
.media-layer {
opacity: 0;
transition: opacity var(--transition-slow);
}
.media-layer.active {
opacity: 1;
}
/* Ken-Burns Effekt für Bilder */
.media-layer.ken-burns img {
animation: kenBurns 12s ease-in-out forwards;
transform-origin: center center;
}
@keyframes kenBurns {
from {
transform: scale(1);
}
to {
transform: scale(1.06);
}
}
/* Slide transition */
.media-layer.slide-out {
animation: slideOut var(--transition-slow) forwards;
}
.media-layer.slide-in {
animation: slideIn var(--transition-slow) forwards;
}
@keyframes slideOut {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(-100%); opacity: 0; }
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* ========================================
TEXT AREA (Headline + Subline)
======================================== */
.text-area {
height: var(--text-area-height);
display: flex;
flex-direction: column;
justify-content: center;
padding: 0 var(--safe-area);
position: relative;
z-index: 10;
flex-shrink: 0;
}
.text-headline {
font-size: 36px;
font-weight: 600;
letter-spacing: -0.01em;
line-height: 1.15;
color: var(--fg);
margin-bottom: 8px;
opacity: 1;
transition: opacity 400ms ease;
}
.text-subline {
font-size: 22px;
font-weight: 300;
line-height: 1.35;
color: var(--fg-muted);
opacity: 1;
transition: opacity 400ms ease;
}
.text-area.fade-out .text-headline,
.text-area.fade-out .text-subline {
opacity: 0;
}
/* ========================================
FOOTER
======================================== */
.display-footer {
height: var(--footer-height);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--safe-area);
position: relative;
z-index: 10;
flex-shrink: 0;
}
.display-footer::before {
content: '';
position: absolute;
top: 0;
left: var(--safe-area);
right: var(--safe-area);
height: 1px;
background: var(--line);
}
.footer-person {
display: flex;
flex-direction: column;
gap: 2px;
}
.footer-name {
font-size: 16px;
font-weight: 400;
color: var(--fg);
letter-spacing: 0.01em;
}
.footer-url {
font-size: 20px;
font-weight: 600;
color: var(--b2in-blue);
letter-spacing: 0.02em;
}
.footer-qr {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
}
.footer-qr img {
width: 72px;
height: 72px;
border-radius: 6px;
background: #ffffff;
padding: 4px;
}
[data-theme="light"] .footer-qr img {
border: 1px solid var(--line);
}
.footer-qr-label {
font-size: 11px;
font-weight: 500;
color: var(--fg-subtle);
text-transform: uppercase;
letter-spacing: 0.08em;
}
/* ========================================
PROGRESS INDICATOR
======================================== */
.progress-track {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: rgba(255, 255, 255, 0.05);
z-index: 20;
}
[data-theme="light"] .progress-track {
background: rgba(0, 0, 0, 0.06);
}
.progress-fill {
height: 100%;
width: 0%;
background: var(--b2in-blue);
transition: none;
}
.progress-fill.animate {
transition: width linear;
}
/* ========================================
STANDBY MODE
======================================== */
.standby-overlay {
position: absolute;
inset: 0;
background: var(--bg);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
opacity: 0;
pointer-events: none;
transition: opacity var(--transition-medium);
}
.standby-overlay.active {
opacity: 1;
pointer-events: auto;
}
.standby-logo img {
height: 80px;
width: auto;
opacity: 1;
}
/* ========================================
ERROR OVERLAY
======================================== */
.error-overlay {
position: absolute;
inset: 0;
background: var(--bg);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
z-index: 40;
opacity: 0;
pointer-events: none;
transition: opacity var(--transition-medium);
}
.error-overlay.active {
opacity: 1;
pointer-events: auto;
}
.error-overlay img {
height: 60px;
width: auto;
opacity: 0.3;
}
.error-overlay span {
font-size: 14px;
color: var(--fg-subtle);
font-weight: 400;
}
/* ========================================
OFFLINE BADGE
======================================== */
.offline-badge {
position: fixed;
bottom: 16px;
left: 50%;
transform: translateX(-50%) translateY(4px);
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
color: var(--fg-muted);
font-size: 12px;
font-weight: 500;
padding: 6px 18px;
border-radius: 100px;
border: 1px solid var(--line);
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);
}
[data-theme="light"] .offline-badge {
background: rgba(0, 0, 0, 0.06);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: var(--fg-muted);
border-color: var(--line);
}

View file

@ -0,0 +1,819 @@
<!DOCTYPE html>
<html lang="de" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>B2in Display</title>
<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="./b2in-styles.css">
</head>
<body>
<div class="display-frame" id="display">
<!-- HEADER: Logo + Claim -->
<header class="display-header">
<div class="header-logo">
<img src="../assets/b2in-logo-positive.svg" alt="B2in">
</div>
<div class="header-claim">Connecting Design &amp; Property</div>
</header>
<!-- MEDIA AREA: Video / Bild -->
<section class="media-area" id="media-area">
<div class="media-layer" id="media-layer-a"></div>
<div class="media-layer" id="media-layer-b"></div>
</section>
<!-- TEXT AREA: Headline + Subline -->
<section class="text-area" id="text-area">
<div class="text-headline" id="headline"></div>
<div class="text-subline" id="subline"></div>
</section>
<!-- FOOTER: Person + QR -->
<footer class="display-footer">
<div class="footer-person">
<span class="footer-url" id="footer-url">B2in.eu</span>
<span class="footer-name" id="footer-name">by Marcel Scheibe</span>
</div>
<div class="footer-qr">
<img id="qr-code" src="" alt="QR Code">
<span class="footer-qr-label">Website</span>
</div>
</footer>
<!-- Progress -->
<div class="progress-track">
<div class="progress-fill" id="progress"></div>
</div>
<!-- Standby Overlay -->
<div class="standby-overlay" id="standby">
<div class="standby-logo">
<img src="../assets/b2in-logo-positive.svg" alt="B2in">
</div>
</div>
<!-- Error Overlay -->
<div class="error-overlay" id="error-overlay">
<img src="../assets/b2in-logo-positive.svg" alt="B2in">
<span id="error-message"></span>
</div>
</div>
<!-- Offline Badge -->
<div class="offline-badge" id="offline-badge">Offline Stand: <span id="offline-time"></span></div>
<script>
/**
* B2in Schaufenster-Display Playlist Engine
*
* Features:
* - Video/Bild Playlist mit automatischer Rotation
* - Gewichtete Kategorie-Verteilung (Immobilien/Möbel)
* - Crossfade/Fade/Slide Transitions
* - Video Memory Management + Preloading
* - Polling (60s) mit Offline-Fallback
* - Standby-Modus + Error-Overlay
* - Auto-Reload alle 6 Stunden
*/
class B2inDisplayApp {
constructor() {
// API Configuration
this.BASE_URL = this.detectBaseUrl();
this.API_PLAYLIST = this.BASE_URL + '/api/b2in-display/playlist';
this.API_CHECK = this.BASE_URL + '/api/b2in-display/check';
this.QR_TARGET = 'https://b2in.eu';
// Timing
this.POLL_INTERVAL = 60000; // 60 seconds
this.RELOAD_INTERVAL = 6 * 3600000; // 6 hours
this.MAX_FAILURES = 3;
this.RECOVERY_WAIT = 300000; // 5 minutes
this.OFFLINE_RELOAD = 1800000; // 30 minutes
this.VIDEO_START_TIMEOUT = 10000; // 10 seconds
this.VIDEO_WATCHDOG_INTERVAL = 5000; // 5 seconds
// State
this.settings = null;
this.playlist = [];
this.weightedPlaylist = [];
this.currentIndex = 0;
this.activeLayer = 'a';
this.cachedTimestamp = null;
this.failureCount = 0;
this.lastSuccessTime = Date.now();
this.isRecovering = false;
this.isStandby = false;
this.itemTimer = null;
this.videoStartTimer = null;
this.videoWatchdogTimer = null;
this.lastVideoTime = 0;
this.videoStuckCount = 0;
// Theme: URL param override or default dark
this.theme = new URLSearchParams(window.location.search).get('theme') || 'dark';
this.applyTheme(this.theme);
// DOM
this.display = document.getElementById('display');
this.mediaArea = document.getElementById('media-area');
this.layerA = document.getElementById('media-layer-a');
this.layerB = document.getElementById('media-layer-b');
this.textArea = document.getElementById('text-area');
this.headline = document.getElementById('headline');
this.subline = document.getElementById('subline');
this.progress = document.getElementById('progress');
this.standby = document.getElementById('standby');
this.errorOverlay = document.getElementById('error-overlay');
this.errorMessage = document.getElementById('error-message');
this.footerName = document.getElementById('footer-name');
this.footerUrl = document.getElementById('footer-url');
}
detectBaseUrl() {
const hostname = window.location.hostname;
if (hostname === 'cabinet.b2in.eu' || hostname.includes('b2in.eu')) {
return 'https://b2in.eu';
}
return window.location.origin;
}
// ========================================
// INIT
// ========================================
async init() {
console.log('[B2in] Initializing...');
this.generateQR();
// Try cache first
const cached = this.loadFromCache();
if (cached) {
console.log('[B2in] Loaded from cache');
this.applyData(cached);
}
// Fetch fresh data
await this.fetchPlaylist();
// Start if we have data
if (this.playlist.length > 0) {
this.startPlayback();
} else if (!cached) {
this.showError('Keine Inhalte verfügbar');
}
// Start polling
this.startPolling();
// Auto-reload
this.scheduleAutoReload();
console.log('[B2in] Ready!');
}
// ========================================
// DATA FETCHING
// ========================================
async fetchPlaylist() {
try {
const response = await fetch(this.API_PLAYLIST);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
this.failureCount = 0;
this.lastSuccessTime = Date.now();
this.isRecovering = false;
this.cachedTimestamp = data.updated_at;
this.applyData(data);
this.saveToCache(data);
this.hideOfflineBadge();
this.hideError();
console.log(`[B2in] Playlist loaded: ${this.playlist.length} items`);
} catch (error) {
console.warn('[B2in] Fetch error:', error.message);
// Try mock data in development
if (this.playlist.length === 0) {
this.applyMockData();
}
this.handleFetchError(error);
}
}
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();
if (data.updated_at !== this.cachedTimestamp) {
console.log('[B2in] Change detected, fetching playlist...');
await this.fetchPlaylist();
}
} catch (error) {
this.handleFetchError(error);
}
}
handleFetchError(error) {
this.failureCount++;
console.warn(`[B2in] Fetch error (${this.failureCount}/${this.MAX_FAILURES}):`, error.message);
const offlineDuration = Date.now() - this.lastSuccessTime;
this.showOfflineBadge();
if (offlineDuration >= this.OFFLINE_RELOAD) {
console.error('[B2in] Offline 30+ min, reloading...');
location.reload();
return;
}
if (this.failureCount >= this.MAX_FAILURES && !this.isRecovering) {
this.isRecovering = true;
console.warn('[B2in] Recovery mode, waiting 5 min...');
setTimeout(() => {
this.isRecovering = false;
this.failureCount = 0;
this.fetchPlaylist();
}, this.RECOVERY_WAIT);
}
}
startPolling() {
setInterval(() => this.checkForUpdates(), this.POLL_INTERVAL);
}
// ========================================
// THEME
// ========================================
applyTheme(theme) {
const valid = theme === 'light' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', valid);
this.theme = valid;
console.log(`[B2in] Theme: ${valid}`);
}
// ========================================
// DATA PROCESSING
// ========================================
applyData(data) {
this.settings = data.settings || {};
const items = (data.items || [])
.filter(item => item.is_active)
.sort((a, b) => a.sort_order - b.sort_order);
this.playlist = items;
this.weightedPlaylist = this.buildWeightedPlaylist(items);
// Apply theme from API (URL param takes priority)
const urlTheme = new URLSearchParams(window.location.search).get('theme');
if (!urlTheme && this.settings.theme) {
this.applyTheme(this.settings.theme);
}
// Apply settings
if (this.settings.footer_name) {
this.footerName.textContent = this.settings.footer_name;
}
if (this.settings.footer_url) {
this.footerUrl.textContent = this.settings.footer_url;
}
// Standby check
if (this.settings.display_active === false) {
this.enterStandby();
} else if (this.isStandby) {
this.exitStandby();
}
}
buildWeightedPlaylist(items) {
if (items.length === 0) {
return [];
}
const immobilien = items.filter(i => i.category === 'immobilien');
const moebel = items.filter(i => i.category === 'moebel');
// If only one category, just return sorted items
if (immobilien.length === 0 || moebel.length === 0) {
return [...items];
}
// Build weighted rotation
const targetImmo = (this.settings?.rotation_weights?.immobilien || 70) / 100;
const totalSlots = Math.max(items.length, 10);
const immoSlots = Math.round(totalSlots * targetImmo);
const moebelSlots = totalSlots - immoSlots;
const weighted = [];
let immoIdx = 0;
let moebelIdx = 0;
for (let i = 0; i < totalSlots; i++) {
// Determine which category to pick
const currentImmoRatio = weighted.filter(w => w.category === 'immobilien').length / (weighted.length || 1);
const needImmo = currentImmoRatio < targetImmo && immoIdx < immoSlots;
if (needImmo && immobilien.length > 0) {
weighted.push(immobilien[immoIdx % immobilien.length]);
immoIdx++;
} else if (moebel.length > 0) {
weighted.push(moebel[moebelIdx % moebel.length]);
moebelIdx++;
} else if (immobilien.length > 0) {
weighted.push(immobilien[immoIdx % immobilien.length]);
immoIdx++;
}
}
return weighted;
}
applyMockData() {
console.log('[B2in] Using mock data for development');
this.applyData({
settings: {
display_active: true,
theme: 'dark',
footer_name: 'Marcel Scheibe',
footer_url: 'b2in.de',
transition: { type: 'crossfade', duration_ms: 800 },
default_image_duration: 10,
rotation_weights: { immobilien: 70, moebel: 30 }
},
items: [
{
id: 1,
category: 'immobilien',
media_type: 'video',
media_url: '../assets/334716_medium.mp4',
headline: 'Internationale Immobilien — Ihr Einstieg.',
subline: 'Beratung, Begleitung und Vermittlung. Persönlich. Transparent.',
sort_order: 1,
is_active: true
},
{
id: 2,
category: 'immobilien',
media_type: 'video',
media_url: '../assets/48504-454713939_medium.mp4',
headline: 'Ihr Zuhause. Weltweit.',
subline: 'Von Dubai bis Europa wir finden Ihre Immobilie.',
sort_order: 2,
is_active: true
},
{
id: 3,
category: 'moebel',
media_type: 'image',
media_url: 'https://images.unsplash.com/photo-1618221195710-dd6b41faaea6?w=1920&h=1080&fit=crop',
headline: 'Exklusive Einrichtung — Lokal. Für Sie.',
subline: 'Kuratierte Möbelkonzepte von lokalen Fachhändlern.',
duration_seconds: 10,
sort_order: 3,
is_active: true
}
],
updated_at: new Date().toISOString()
});
}
// ========================================
// PLAYBACK ENGINE
// ========================================
startPlayback() {
this.currentIndex = 0;
this.showItem(this.currentIndex);
}
showItem(index) {
if (this.weightedPlaylist.length === 0) {
return;
}
// Clear previous timers
clearTimeout(this.itemTimer);
clearTimeout(this.videoStartTimer);
const item = this.weightedPlaylist[index];
if (!item) {
this.currentIndex = 0;
this.showItem(0);
return;
}
console.log(`[B2in] Showing item ${index}: ${item.headline || item.id} (${item.media_type})`);
// Determine transition settings
const transitionType = this.settings?.transition?.type || 'crossfade';
const transitionDuration = this.settings?.transition?.duration_ms || 800;
// Update CSS transition duration
document.documentElement.style.setProperty('--transition-slow', `${transitionDuration}ms cubic-bezier(0.4, 0, 0.2, 1)`);
// Text transition: start 200ms before media
this.transitionText(item.headline, item.subline);
// Media transition after 200ms
setTimeout(() => {
if (item.media_type === 'video') {
this.showVideo(item, transitionType);
} else {
this.showImage(item, transitionType);
}
}, 200);
// Preload next item
const nextIndex = (index + 1) % this.weightedPlaylist.length;
this.preloadItem(this.weightedPlaylist[nextIndex]);
}
advanceToNext() {
this.currentIndex = (this.currentIndex + 1) % this.weightedPlaylist.length;
// If playlist looped, check for updates
if (this.currentIndex === 0) {
console.log('[B2in] Playlist looped');
}
this.showItem(this.currentIndex);
}
// ========================================
// VIDEO HANDLING
// ========================================
showVideo(item, transitionType) {
const incomingLayer = this.getIncomingLayer();
const outgoingLayer = this.getActiveLayer();
// Create video element
const video = document.createElement('video');
video.autoplay = true;
video.muted = true;
video.playsInline = true;
video.preload = 'auto';
video.style.cssText = 'width:100%;height:100%;object-fit:cover;display:block;';
// Guard: prevent multiple advanceToNext calls from racing events
let handled = false;
const finishVideo = (reason) => {
if (handled) return;
handled = true;
console.log(`[B2in] Video finished: ${reason}`);
this.cleanupVideo(video);
this.advanceToNext();
};
// Clear incoming layer and add video
incomingLayer.innerHTML = '';
incomingLayer.appendChild(video);
incomingLayer.classList.remove('ken-burns');
// Video event: ended → next item
video.addEventListener('ended', () => finishVideo('ended'), { once: true });
// Video event: error → skip
video.addEventListener('error', () => finishVideo('error'), { once: true });
// Start timeout: if video doesn't start in 10s, skip
this.videoStartTimer = setTimeout(() => finishVideo('start-timeout'), this.VIDEO_START_TIMEOUT);
// Video event: playing → clear start timeout + start watchdog
video.addEventListener('playing', () => {
clearTimeout(this.videoStartTimer);
this.startVideoWatchdog(video, () => finishVideo('stuck'));
}, { once: true });
// Set source and play
video.src = item.media_url;
video.play().catch(() => finishVideo('autoplay-blocked'));
// Apply transition
this.applyTransition(incomingLayer, outgoingLayer, transitionType);
// Progress: no bar for videos (duration unknown)
this.hideProgress();
}
cleanupVideo(video) {
clearTimeout(this.videoStartTimer);
this.stopVideoWatchdog();
try {
video.pause();
// Remove src without triggering new error events
video.onended = null;
video.onerror = null;
video.src = '';
video.removeAttribute('src');
} catch (e) {
// Ignore cleanup errors
}
}
startVideoWatchdog(video, onStuck) {
this.stopVideoWatchdog();
this.lastVideoTime = 0;
this.videoStuckCount = 0;
this.videoWatchdogTimer = setInterval(() => {
const currentTime = video.currentTime;
const isStuck = (currentTime === this.lastVideoTime && !video.paused && !video.ended);
if (isStuck) {
this.videoStuckCount++;
if (this.videoStuckCount >= 2) {
clearInterval(this.videoWatchdogTimer);
onStuck();
}
} else {
this.videoStuckCount = 0;
}
this.lastVideoTime = currentTime;
}, this.VIDEO_WATCHDOG_INTERVAL);
}
stopVideoWatchdog() {
if (this.videoWatchdogTimer) {
clearInterval(this.videoWatchdogTimer);
this.videoWatchdogTimer = null;
}
}
// ========================================
// IMAGE HANDLING
// ========================================
showImage(item, transitionType) {
const incomingLayer = this.getIncomingLayer();
const outgoingLayer = this.getActiveLayer();
// Create image element
const img = document.createElement('img');
img.src = item.media_url;
img.alt = item.headline || '';
img.style.cssText = 'width:100%;height:100%;object-fit:cover;display:block;';
// Clear incoming layer and add image
incomingLayer.innerHTML = '';
incomingLayer.appendChild(img);
incomingLayer.classList.add('ken-burns');
// Apply transition
this.applyTransition(incomingLayer, outgoingLayer, transitionType);
// Duration
const duration = (item.duration_seconds || this.settings?.default_image_duration || 10) * 1000;
// Progress bar
this.showProgress(duration);
// Timer for next item
this.itemTimer = setTimeout(() => {
this.advanceToNext();
}, duration);
}
// ========================================
// TRANSITIONS
// ========================================
applyTransition(incoming, outgoing, type) {
// Remove old animation classes
incoming.classList.remove('slide-in', 'slide-out');
if (outgoing) {
outgoing.classList.remove('slide-in', 'slide-out');
}
switch (type) {
case 'slide':
incoming.style.transition = 'none';
incoming.style.opacity = '1';
incoming.classList.add('slide-in');
if (outgoing) {
outgoing.classList.add('slide-out');
}
break;
case 'fade':
// Fade out first, then fade in
if (outgoing) {
outgoing.classList.remove('active');
}
setTimeout(() => {
incoming.classList.add('active');
}, 400);
break;
case 'crossfade':
default:
// Crossfade: incoming fades in over outgoing
incoming.classList.add('active');
if (outgoing) {
outgoing.classList.remove('active');
}
break;
}
// Swap active layer reference
this.activeLayer = this.activeLayer === 'a' ? 'b' : 'a';
}
transitionText(headlineText, sublineText) {
// Fade out
this.textArea.classList.add('fade-out');
setTimeout(() => {
this.headline.textContent = headlineText || '';
this.subline.textContent = sublineText || '';
// Fade in
this.textArea.classList.remove('fade-out');
}, 400);
}
getActiveLayer() {
return this.activeLayer === 'a' ? this.layerA : this.layerB;
}
getIncomingLayer() {
return this.activeLayer === 'a' ? this.layerB : this.layerA;
}
// ========================================
// PRELOADING
// ========================================
preloadItem(item) {
if (!item) {
return;
}
if (item.media_type === 'image') {
const img = new Image();
img.src = item.media_url;
}
// Videos: browser handles preloading via metadata
}
// ========================================
// PROGRESS BAR
// ========================================
showProgress(duration) {
// Reset to 0 without animation
this.progress.style.transition = 'none';
this.progress.style.width = '0%';
// Force reflow
void this.progress.offsetWidth;
// Animate to 100%
this.progress.style.transition = `width ${duration}ms linear`;
this.progress.style.width = '100%';
}
hideProgress() {
this.progress.style.transition = 'none';
this.progress.style.width = '0%';
}
// ========================================
// STANDBY MODE
// ========================================
enterStandby() {
this.isStandby = true;
this.standby.classList.add('active');
clearTimeout(this.itemTimer);
console.log('[B2in] Standby mode');
}
exitStandby() {
this.isStandby = false;
this.standby.classList.remove('active');
if (this.playlist.length > 0) {
this.startPlayback();
}
console.log('[B2in] Exiting standby');
}
// ========================================
// ERROR OVERLAY
// ========================================
showError(message) {
this.errorMessage.textContent = message;
this.errorOverlay.classList.add('active');
}
hideError() {
this.errorOverlay.classList.remove('active');
}
// ========================================
// 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('b2in-display-data', JSON.stringify(data));
localStorage.setItem('b2in-display-time', new Date().toISOString());
} catch (e) {
// localStorage may be full
}
}
loadFromCache() {
try {
const raw = localStorage.getItem('b2in-display-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('b2in-display-time');
if (cachedTime) {
const date = new Date(cachedTime);
timeEl.textContent = date.toLocaleTimeString('de-DE', { 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('[B2in] Auto-reload after 6 hours');
location.reload();
}, this.RELOAD_INTERVAL);
}
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
const app = new B2inDisplayApp();
app.init();
});
</script>
</body>
</html>

View file

@ -0,0 +1,47 @@
{
"videoPlaylist": [
{
"src": "assets\/fruehjahr_2024.mp4",
"position": 40
},
{
"src": "assets\/fruehjahr_2025.mp4",
"position": 10
},
{
"src": "assets\/herbst_2025.mp4",
"position": 25
},
{
"src": "assets\/herbst_2024.mp4",
"position": 25
}
],
"footerContent": [
{
"headline": "Beratung & Termin",
"subline": "Jetzt Termin vereinbaren.",
"url": "https:\/\/b2in.eu\/_cabinet\/go.php?z=c59kjb"
},
{
"headline": "Beratung vor Ort",
"subline": "Einfach reinkommen.",
"url": "https:\/\/b2in.eu\/_cabinet\/go.php?z=3bi07j"
},
{
"headline": "Pinterest",
"subline": "Inspirationen entdecken.",
"url": "https:\/\/b2in.eu\/_cabinet\/go.php?z=1cl8so"
},
{
"headline": "Instagram",
"subline": "T\u00e4gliche Einblicke & Design.",
"url": "https:\/\/b2in.eu\/_cabinet\/go.php?z=hz1tx2"
},
{
"headline": "Facebook",
"subline": "News, Aktionen & Community.",
"url": "https:\/\/b2in.eu\/_cabinet\/go.php?z=almb7t"
}
]
}

File diff suppressed because it is too large Load diff

View file

@ -312,7 +312,7 @@
</div>
<div class="qr-container" id="qr-area">
<img src="" id="qr-image" class="qr-code-img" alt="QR Code">
<img id="qr-image" class="qr-code-img" alt="QR Code">
</div>
</div>
</div>
@ -329,8 +329,8 @@
// Basis-URL für Assets und API (b2in.eu Server)
const BASE_URL = 'https://b2in.eu';
// API-URL für die Konfiguration (CORS ist aktiviert für cabinet.b2in.eu)
const API_URL = BASE_URL + '/api/display/config';
// Lokale JSON-Datei statt API (Übergangsweise bis neues Display-Modul freigegeben)
const CONFIG_URL = 'default.json';
/* ==============================================
KONFIGURATION LADEN
@ -338,9 +338,9 @@
async function loadConfiguration() {
try {
window.displayLogger?.log('Lade Konfiguration...', { url: API_URL });
window.displayLogger?.log('Lade Konfiguration...', { url: CONFIG_URL });
const response = await fetch(API_URL);
const response = await fetch(CONFIG_URL);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
@ -416,6 +416,7 @@
let lastVideoTime = 0;
let videoStuckCount = 0;
let consecutiveErrors = 0;
let isTransitioning = false;
const MAX_CONSECUTIVE_ERRORS = 3;
const VIDEO_START_TIMEOUT = 10000; // 10 Sekunden
const VIDEO_WATCHDOG_INTERVAL = 5000; // Alle 5 Sekunden prüfen
@ -424,13 +425,12 @@
videoElement.setAttribute('preload', 'metadata'); // Nur Metadaten vorladen, nicht ganzes Video
function cleanupVideo() {
// Wichtig: Stoppt Video und gibt Speicher frei
isTransitioning = true;
try {
videoElement.pause();
videoElement.removeAttribute('src');
videoElement.load(); // Triggert Garbage Collection des alten Videos
videoElement.load();
// Timeouts clearen
if (videoStartTimeout) {
clearTimeout(videoStartTimeout);
videoStartTimeout = null;
@ -445,54 +445,68 @@
function playNextVideo() {
if (videoPlaylist.length === 0) return;
// Watchdog zurücksetzen
// Verhindert gleichzeitige, sich überschneidende Übergänge
if (isTransitioning) {
window.displayLogger?.log('playNextVideo übersprungen - Übergang läuft noch');
return;
}
lastVideoTime = 0;
videoStuckCount = 0;
const video = videoPlaylist[currentVideoIndex];
const videoSrc = BASE_URL + "/_cabinet/" + video.src;
// Kontext aktualisieren
window.displayLogger?.setContext('currentVideo', video.src);
window.displayLogger?.setContext('currentVideoIndex', currentVideoIndex);
// WICHTIG: Altes Video cleanup BEVOR neues geladen wird
// Index VOR dem asynchronen Cleanup weiterschalten
currentVideoIndex++;
if (currentVideoIndex >= videoPlaylist.length) {
currentVideoIndex = 0;
window.displayLogger?.log('Playlist-Loop abgeschlossen, starte von vorne');
}
// Altes Video stoppen und Speicher freigeben (setzt isTransitioning = true)
cleanupVideo();
// Kleiner Delay um Cleanup abzuschließen
// Delay damit Cleanup vollständig abgeschlossen ist
setTimeout(() => {
isTransitioning = false;
try {
// Neues Video laden
videoElement.src = videoSrc;
if(footerContentLength !== 0 && video.position !== undefined) {
if (footerContentLength !== 0 && video.position !== undefined) {
videoElement.style.objectPosition = `center ${video.position}%`;
}
// Timeout für Video-Start
videoStartTimeout = setTimeout(() => {
window.displayLogger?.error('Video start timeout', {
video: video.src,
timeout: VIDEO_START_TIMEOUT
});
// Nächstes Video probieren
skipToNextVideo('timeout');
}, VIDEO_START_TIMEOUT);
// Video abspielen
videoElement.play()
.then(() => {
window.displayLogger?.log(`Video started: ${video.src}`);
consecutiveErrors = 0; // Erfolg → Error-Counter zurücksetzen
consecutiveErrors = 0;
// Start-Timeout clearen
if (videoStartTimeout) {
clearTimeout(videoStartTimeout);
videoStartTimeout = null;
}
})
.catch(e => {
console.log("Autoplay blocked/failed", e);
// AbortError = play() wurde absichtlich durch pause()/load() unterbrochen.
// Das ist kein echter Fehler, sondern erwartetes Verhalten beim Cleanup.
if (e.name === 'AbortError') {
window.displayLogger?.log(`Video play absichtlich unterbrochen: ${video.src}`);
return;
}
window.displayLogger?.error(`Video play failed: ${video.src}`, {
error: e.message
});
@ -502,10 +516,8 @@
window.displayLogger?.error('Zu viele aufeinanderfolgende Fehler', {
count: consecutiveErrors
});
// Seite nach 30 Sekunden neu laden
setTimeout(() => location.reload(), 30000);
} else {
// Nächstes Video probieren
skipToNextVideo('play_failed');
}
});
@ -516,15 +528,7 @@
});
skipToNextVideo('exception');
}
}, 100); // 100ms Delay für Cleanup
// Index weiterschalten
currentVideoIndex++;
if (currentVideoIndex >= videoPlaylist.length) {
currentVideoIndex = 0;
// Playlist-Loop abgeschlossen → Log für Monitoring
window.displayLogger?.log('Playlist-Loop abgeschlossen, starte von vorne');
}
}, 200); // 200ms für zuverlässiges Cleanup
}
function skipToNextVideo(reason) {
@ -557,8 +561,8 @@
src: videoElement.src
});
// Wenn 2x hintereinander stuck → Recovery
if (videoStuckCount >= 2) {
// Wenn 2x hintereinander stuck → Recovery (nur wenn kein Übergang läuft)
if (videoStuckCount >= 2 && !isTransitioning) {
window.displayLogger?.error('Video definitiv stuck - starte nächstes', {
currentTime: currentTime,
src: videoElement.src
@ -586,6 +590,9 @@
});
videoElement.addEventListener('error', (e) => {
// Fehler während eines laufenden Übergangs ignorieren (z.B. nach removeAttribute('src'))
if (isTransitioning) return;
const error = videoElement.error;
const errorCode = error?.code;
const errorMessage = {
@ -602,7 +609,6 @@
mediaError: errorMessage
});
// Bei Fehler → Nächstes Video
consecutiveErrors++;
skipToNextVideo(`error_${errorMessage}`);
});
@ -814,6 +820,31 @@
// Beim Laden der Seite initialisieren
initialize();
// Datei-Versions-Check: Erkennt ob index.html auf dem Server geändert wurde
let fileVersion = null;
async function 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 (fileVersion === null) {
fileVersion = version;
} else if (version && version !== fileVersion) {
window.displayLogger?.log('Datei geändert Seite wird neu geladen');
location.reload();
}
} catch (e) {
// Offline ignorieren
}
}
checkFileVersion(); // Initiale Version merken
setInterval(checkFileVersion, 2 * 60 * 1000); // Alle 2 Minuten prüfen
// Auto-Reload alle 5 Minuten, um neue Inhalte zu laden
setInterval(async () => {
console.log('Prüfe auf neue Konfiguration...');

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);
}

View file

@ -1,70 +1,11 @@
/**
* CABINET Display - Shared Styles
* Format: 9:16 (1080×1920px)
* Safe-Area: 64px
* CABINET Display - Offer Slides Styles
* Format: 9:16 (1080x1920px)
*
* Imports shared CABINET base tokens and adds offer-specific layout.
*/
:root {
/* Colors */
--bg: #ffffff;
--fg: #1a1a1a;
--fg-strong: #000000;
--muted: #737373;
--muted-light: #999999;
--line: #e8e8e8;
--card: #f5f5f5;
--accent: #009FE3; /* Cabinet Blau */
/* Spacing */
--safe-area: 64px;
--radius: 24px;
--radius-sm: 16px;
/* Typography Scale (modular) */
--text-xs: 16px;
--text-sm: 18px;
--text-base: 20px;
--text-lg: 24px;
--text-xl: 28px;
--text-2xl: 32px;
--text-3xl: 42px;
--text-4xl: 54px;
--text-5xl: 64px;
--text-6xl: 84px;
/* Font */
--font-main: 'IBM Plex Sans', ui-sans-serif, system-ui, -apple-system, sans-serif;
/* Dimensions */
--max-width: 1080px;
--max-height: 1920px;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
font-family: var(--font-main);
background: #0a0a0a;
color: var(--fg);
display: flex;
align-items: center;
justify-content: center;
/* Text Rendering */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
@import '../shared/cabinet-base.css';
/* ========================================
SCREEN CONTAINER (9:16 Frame)
@ -110,47 +51,6 @@ body {
background: var(--bg);
}
/* ========================================
HEADER
======================================== */
.header {
display: flex;
align-items: flex-end;
justify-content: space-between;
padding-bottom: 24px;
border-bottom: 1px solid var(--line);
min-height: 100px;
}
.brand {
display: flex;
align-items: center;
gap: 14px;
}
.brand-logo {
height: 82px;
width: auto;
}
.brand-text {
font-size: var(--text-xl);
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--fg-strong);
}
.tagline {
font-size: var(--text-lg);
color: var(--muted);
text-align: right;
line-height: 1.4;
font-weight: 400;
letter-spacing: 0.01em;
}
/* ========================================
HERO SECTION
======================================== */
@ -228,15 +128,6 @@ body {
flex: 1;
}
.eyebrow {
font-size: var(--text-sm);
color: var(--muted);
letter-spacing: 0.14em;
text-transform: uppercase;
margin-bottom: 14px;
font-weight: 500;
}
.title {
font-size: var(--text-4xl);
line-height: 1.08;
@ -344,100 +235,6 @@ body {
gap: 20px;
}
/* ========================================
QR BOX
======================================== */
.qr-box {
display: flex;
flex-direction: column;
background: var(--card);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 20px;
gap: 12px;
}
.qr-header {
text-align: center;
}
.qr-title {
font-size: var(--text-lg);
font-weight: 600;
color: var(--fg-strong);
margin-bottom: 6px;
letter-spacing: -0.01em;
}
.qr-subtitle {
font-size: var(--text-sm);
color: var(--muted);
font-weight: 400;
}
.qr-code-wrapper {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #ffffff;
border-radius: var(--radius-sm);
border: 1px dashed #ddd;
padding: 16px;
min-height: 180px;
}
.qr-code-wrapper img {
width: 100%;
max-width: 180px;
height: auto;
aspect-ratio: 1;
}
/* QR Placeholder */
.qr-placeholder {
width: 140px;
height: 140px;
background:
repeating-linear-gradient(
0deg,
#e0e0e0,
#e0e0e0 12px,
transparent 12px,
transparent 16px
),
repeating-linear-gradient(
90deg,
#e0e0e0,
#e0e0e0 12px,
transparent 12px,
transparent 16px
);
opacity: 0.5;
border-radius: 8px;
}
.qr-contact {
font-size: var(--text-sm);
color: var(--muted);
text-align: center;
line-height: 1.5;
font-weight: 400;
}
/* ========================================
DISCLAIMER
======================================== */
.disclaimer {
font-size: var(--text-xs);
color: var(--muted-light);
margin-top: 12px;
font-weight: 400;
letter-spacing: 0.01em;
}
/* ========================================
ANIMATIONS (for player integration)
======================================== */
@ -468,26 +265,3 @@ body {
}
}
/* ========================================
UTILITY CLASSES
======================================== */
.text-accent {
color: var(--accent);
}
.text-muted {
color: var(--muted);
}
.font-bold {
font-weight: 700;
}
.font-semibold {
font-weight: 600;
}
.mt-auto {
margin-top: auto;
}

View file

@ -34,7 +34,7 @@
<!-- HEADER -->
<header class="header">
<div class="brand">
<img src="./logo-cabinet-300.png" alt="CABINET" class="brand-logo">
<img src="../logo-cabinet-300.png" alt="CABINET" class="brand-logo">
<span class="brand-text">Bielefeld</span>
</div>
<div class="tagline">

View file

@ -42,7 +42,7 @@
<!-- HEADER -->
<header class="header">
<div class="brand">
<img src="./logo-cabinet-300.png" alt="CABINET" class="brand-logo">
<img src="../logo-cabinet-300.png" alt="CABINET" class="brand-logo">
</div>
</header>

View file

@ -61,7 +61,7 @@
<!-- HEADER -->
<header class="header">
<div class="brand">
<img src="./logo-cabinet-300.png" alt="CABINET" class="brand-logo">
<img src="../logo-cabinet-300.png" alt="CABINET" class="brand-logo">
</div>
</header>

View file

@ -45,7 +45,7 @@
<!-- HEADER -->
<header class="header">
<div class="brand">
<img src="./logo-cabinet-300.png" alt="CABINET" class="brand-logo">
<img src="../logo-cabinet-300.png" alt="CABINET" class="brand-logo">
</div>
</header>

View file

@ -0,0 +1,240 @@
/**
* CABINET Display - Base Design Tokens & Shared Components
* Shared across all CABINET display projects (offers, info-tablet, etc.)
*
* Import this file in project-specific stylesheets:
* @import '../shared/cabinet-base.css';
*/
:root {
/* Colors */
--bg: #ffffff;
--fg: #1a1a1a;
--fg-strong: #000000;
--muted: #737373;
--muted-light: #999999;
--line: #e8e8e8;
--card: #f5f5f5;
--accent: #009FE3; /* Cabinet Blau */
/* Spacing */
--safe-area: 64px;
--radius: 24px;
--radius-sm: 16px;
/* Typography Scale (modular) */
--text-xs: 16px;
--text-sm: 18px;
--text-base: 20px;
--text-lg: 24px;
--text-xl: 28px;
--text-2xl: 32px;
--text-3xl: 38px;
--text-4xl: 54px;
--text-5xl: 64px;
--text-6xl: 84px;
/* Font */
--font-main: 'IBM Plex Sans', ui-sans-serif, system-ui, -apple-system, sans-serif;
/* Dimensions (default for 9:16 displays, override in project CSS) */
--max-width: 1080px;
--max-height: 1920px;
}
/* ========================================
RESET
======================================== */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
font-family: var(--font-main);
background: #0a0a0a;
color: var(--fg);
display: flex;
align-items: center;
justify-content: center;
/* Text Rendering */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
/* ========================================
HEADER
======================================== */
.header {
display: flex;
align-items: flex-end;
justify-content: space-between;
padding-bottom: 24px;
border-bottom: 1px solid var(--line);
min-height: 100px;
}
.brand {
display: flex;
align-items: center;
gap: 14px;
}
.brand-logo {
height: 82px;
width: auto;
}
.brand-text {
font-size: var(--text-xl);
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--fg-strong);
}
.tagline {
font-size: var(--text-lg);
color: var(--muted);
text-align: right;
line-height: 1.4;
font-weight: 400;
letter-spacing: 0.01em;
}
/* ========================================
QR BOX
======================================== */
.qr-box {
display: flex;
flex-direction: column;
background: var(--card);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 20px;
gap: 12px;
}
.qr-header {
text-align: center;
}
.qr-title {
font-size: var(--text-lg);
font-weight: 600;
color: var(--fg-strong);
margin-bottom: 6px;
letter-spacing: -0.01em;
}
.qr-subtitle {
font-size: var(--text-sm);
color: var(--muted);
font-weight: 400;
}
.qr-code-wrapper {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #ffffff;
border-radius: var(--radius-sm);
border: 1px dashed #ddd;
padding: 16px;
min-height: 180px;
}
.qr-code-wrapper img {
width: 100%;
max-width: 180px;
height: auto;
aspect-ratio: 1;
}
.qr-placeholder {
width: 140px;
height: 140px;
background:
repeating-linear-gradient(
0deg,
#e0e0e0,
#e0e0e0 12px,
transparent 12px,
transparent 16px
),
repeating-linear-gradient(
90deg,
#e0e0e0,
#e0e0e0 12px,
transparent 12px,
transparent 16px
);
opacity: 0.5;
border-radius: 8px;
}
.qr-contact {
font-size: var(--text-sm);
color: var(--muted);
text-align: center;
line-height: 1.5;
font-weight: 400;
}
/* ========================================
SHARED TEXT STYLES
======================================== */
.eyebrow {
font-size: var(--text-sm);
color: var(--muted);
letter-spacing: 0.14em;
text-transform: uppercase;
margin-bottom: 14px;
font-weight: 500;
}
.disclaimer {
font-size: var(--text-xs);
color: var(--muted-light);
margin-top: 12px;
font-weight: 400;
letter-spacing: 0.01em;
}
/* ========================================
UTILITY CLASSES
======================================== */
.text-accent {
color: var(--accent);
}
.text-muted {
color: var(--muted);
}
.font-bold {
font-weight: 700;
}
.font-semibold {
font-weight: 600;
}
.mt-auto {
margin-top: auto;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -1,3 +1,25 @@
<svg width="166" height="166" viewBox="0 0 166 166" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M162.041 38.7592C162.099 38.9767 162.129 39.201 162.13 39.4264V74.4524C162.13 74.9019 162.011 75.3435 161.786 75.7325C161.561 76.1216 161.237 76.4442 160.847 76.6678L131.462 93.5935V127.141C131.462 128.054 130.977 128.897 130.186 129.357L68.8474 164.683C68.707 164.763 68.5538 164.814 68.4007 164.868C68.3432 164.887 68.289 164.922 68.2284 164.938C67.7996 165.051 67.3489 165.051 66.9201 164.938C66.8499 164.919 66.7861 164.881 66.7191 164.855C66.5787 164.804 66.4319 164.76 66.2979 164.683L4.97219 129.357C4.58261 129.133 4.2589 128.81 4.0337 128.421C3.8085 128.032 3.68976 127.591 3.68945 127.141L3.68945 22.0634C3.68945 21.8336 3.72136 21.6101 3.7788 21.393C3.79794 21.3196 3.84262 21.2526 3.86814 21.1791C3.91601 21.0451 3.96068 20.9078 4.03088 20.7833C4.07874 20.7003 4.14894 20.6333 4.20638 20.5566C4.27977 20.4545 4.34678 20.3491 4.43293 20.2598C4.50632 20.1863 4.60205 20.1321 4.68501 20.0682C4.77755 19.9916 4.86051 19.9086 4.96581 19.848L35.6334 2.18492C36.0217 1.96139 36.4618 1.84375 36.9098 1.84375C37.3578 1.84375 37.7979 1.96139 38.1862 2.18492L68.8506 19.848H68.857C68.9591 19.9118 69.0452 19.9916 69.1378 20.065C69.2207 20.1289 69.3133 20.1863 69.3867 20.2566C69.476 20.3491 69.5398 20.4545 69.6164 20.5566C69.6707 20.6333 69.7441 20.7003 69.7887 20.7833C69.8621 20.911 69.9036 21.0451 69.9546 21.1791C69.9802 21.2526 70.0248 21.3196 70.044 21.3962C70.1027 21.6138 70.1328 21.8381 70.1333 22.0634V87.6941L95.686 72.9743V39.4232C95.686 39.1997 95.7179 38.9731 95.7753 38.7592C95.7977 38.6826 95.8391 38.6155 95.8647 38.5421C95.9157 38.408 95.9604 38.2708 96.0306 38.1463C96.0785 38.0633 96.1487 37.9962 96.2029 37.9196C96.2795 37.8175 96.3433 37.7121 96.4326 37.6227C96.506 37.5493 96.5986 37.495 96.6815 37.4312C96.7773 37.3546 96.8602 37.2716 96.9623 37.2109L127.633 19.5479C128.021 19.324 128.461 19.2062 128.91 19.2062C129.358 19.2062 129.798 19.324 130.186 19.5479L160.85 37.2109C160.959 37.2748 161.042 37.3546 161.137 37.428C161.217 37.4918 161.31 37.5493 161.383 37.6195C161.473 37.7121 161.536 37.8175 161.613 37.9196C161.67 37.9962 161.741 38.0633 161.785 38.1463C161.859 38.2708 161.9 38.408 161.951 38.5421C161.98 38.6155 162.021 38.6826 162.041 38.7592ZM157.018 72.9743V43.8477L146.287 50.028L131.462 58.5675V87.6941L157.021 72.9743H157.018ZM126.354 125.663V96.5176L111.771 104.85L70.1301 128.626V158.046L126.354 125.663ZM8.80126 26.4848V125.663L65.0183 158.043V128.629L35.6494 112L35.6398 111.994L35.6271 111.988C35.5281 111.93 35.4452 111.847 35.3526 111.777C35.2729 111.713 35.1803 111.662 35.1101 111.592L35.1038 111.582C35.0208 111.502 34.9634 111.403 34.8932 111.314C34.8293 111.228 34.7528 111.154 34.7017 111.065L34.6985 111.055C34.6411 110.96 34.606 110.845 34.5645 110.736C34.523 110.64 34.4688 110.551 34.4432 110.449C34.4113 110.328 34.4049 110.197 34.3922 110.072C34.3794 109.976 34.3539 109.881 34.3539 109.785V109.778V41.2045L19.5322 32.6619L8.80126 26.4848ZM36.913 7.35007L11.3635 22.0634L36.9066 36.7768L62.4529 22.0602L36.9066 7.35007H36.913ZM50.1999 99.1736L65.0215 90.6374V26.4848L54.2906 32.6651L39.4657 41.2045V105.357L50.1999 99.1736ZM128.91 24.713L103.363 39.4264L128.91 54.1397L154.453 39.4232L128.91 24.713ZM126.354 58.5675L111.529 50.028L100.798 43.8477V72.9743L115.619 81.5106L126.354 87.6941V58.5675ZM67.5711 124.205L105.042 102.803L123.772 92.109L98.2451 77.4053L68.8538 94.3341L42.0663 109.762L67.5711 124.205Z" fill="#FF2D20"/>
</svg>
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 166 166">
<!-- Generator: Adobe Illustrator 30.2.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 1) -->
<defs>
<style>
.st0 {
fill: #314052;
}
.st1 {
fill: #2c9fda;
}
</style>
</defs>
<g>
<path class="st0" d="M2.4,123.8v-59.2h23.3c4.3,0,7.9.6,10.8,1.9,2.9,1.3,5,3.1,6.5,5.3,1.4,2.3,2.2,4.9,2.2,7.9s-.4,4.3-1.3,6.1c-.9,1.7-2.2,3.1-3.8,4.2s-3.3,1.9-5.5,2.3v.6c2.2,0,4.3.7,6.2,1.9s3.6,2.8,4.8,4.9c1.2,2.1,1.8,4.6,1.8,7.6s-.8,5.9-2.3,8.4c-1.6,2.5-3.8,4.4-6.8,5.8-3,1.4-6.7,2.1-11.1,2.1H2.4ZM14.4,89.5h9.5c1.7,0,3.2-.3,4.6-.9,1.4-.6,2.5-1.5,3.2-2.7.8-1.1,1.1-2.5,1.1-4.1s-.8-3.9-2.3-5.2-3.7-2-6.5-2h-9.7v15h0ZM14.4,113.8h10.4c3.6,0,6.1-.7,7.7-2.1s2.5-3.2,2.5-5.5-.4-3.1-1.2-4.4-1.9-2.3-3.4-3c-1.5-.8-3.2-1.1-5.2-1.1h-10.9v16h0Z"/>
<path class="st0" d="M55.4,123.8v-8.7l20.8-19.7c1.8-1.8,3.3-3.3,4.5-4.8,1.2-1.4,2.2-2.8,2.8-4.2s1-2.9,1-4.4-.4-3.3-1.2-4.6-1.9-2.2-3.2-2.9-2.9-1-4.7-1-3.4.3-4.8,1.1-2.4,1.8-3.1,3.2c-.8,1.3-1.1,3-1.1,5h-11.5c0-3.8.9-7.2,2.6-10,1.8-2.9,4.2-5,7.2-6.6,3.1-1.6,6.7-2.3,10.7-2.3s7.8.8,10.9,2.2c3.1,1.5,5.5,3.6,7.2,6.2,1.7,2.6,2.6,5.7,2.6,9s-.4,4.3-1.2,6.5c-.9,2.2-2.4,4.6-4.6,7.2-2.2,2.7-5.3,5.8-9.3,9.6l-8.9,8.8v.4h24.8v9.9h-41.6Z"/>
</g>
<g>
<path class="st0" d="M109.9,123.6v-42.3h10.4v42.3h-10.4Z"/>
<path class="st0" d="M138.6,123.6h-10.4v-42.3h10.4v7.1h.4c.9-2.3,2.2-4.2,4.1-5.7s4.5-2.3,7.8-2.3,7.7,1.4,10,4.3c2.3,2.9,3.6,7,3.6,12.2v26.7h-10.4v-25.7c0-3-.5-5.3-1.6-6.8-1.1-1.5-2.9-2.3-5.3-2.3s-2.8.3-4,.8c-1.3.5-2.3,1.2-3.1,2.3s-1.2,2.3-1.2,3.8v27.9h0Z"/>
</g>
<path class="st1" d="M115.1,60.9c1.4,0,2.7.2,3.8.4-8-18.7-28-32-51.4-32S23.6,43,15.8,62h8.2c5-17.6,22.6-30.6,43.5-30.6s38.1,12.7,43.3,30c1.3-.4,2.7-.6,4.4-.6Z"/>
<path class="st0" d="M115.1,75.6c-2.1,0-3.7-.5-4.6-1.5s-1.5-2.2-1.5-3.7v-1.6c0-1.5.5-2.8,1.5-3.7s2.5-1.5,4.6-1.5,3.7.5,4.6,1.5,1.5,2.2,1.5,3.7v1.6c0,1.5-.5,2.7-1.5,3.7s-2.5,1.5-4.6,1.5Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 2 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig><msapplication><tile><square70x70logo src="/ms-icon-70x70.png"/><square150x150logo src="/ms-icon-150x150.png"/><square310x310logo src="/ms-icon-310x310.png"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
public/favicon/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,41 @@
{
"name": "App",
"icons": [
{
"src": "\/android-icon-36x36.png",
"sizes": "36x36",
"type": "image\/png",
"density": "0.75"
},
{
"src": "\/android-icon-48x48.png",
"sizes": "48x48",
"type": "image\/png",
"density": "1.0"
},
{
"src": "\/android-icon-72x72.png",
"sizes": "72x72",
"type": "image\/png",
"density": "1.5"
},
{
"src": "\/android-icon-96x96.png",
"sizes": "96x96",
"type": "image\/png",
"density": "2.0"
},
{
"src": "\/android-icon-144x144.png",
"sizes": "144x144",
"type": "image\/png",
"density": "3.0"
},
{
"src": "\/android-icon-192x192.png",
"sizes": "192x192",
"type": "image\/png",
"density": "4.0"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

View file

@ -0,0 +1,90 @@
/* eb-garamond-regular - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'EB Garamond';
font-style: normal;
font-weight: 400;
src: url('../fonts/eb-garamond-v32-latin-regular.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
url('../fonts/eb-garamond-v32-latin-regular.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */
}
/* eb-garamond-italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'EB Garamond';
font-style: italic;
font-weight: 400;
src: url('../fonts/eb-garamond-v32-latin-italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
url('../fonts/eb-garamond-v32-latin-italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */
}
/* eb-garamond-500 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'EB Garamond';
font-style: normal;
font-weight: 500;
src: url('../fonts/eb-garamond-v32-latin-500.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
url('../fonts/eb-garamond-v32-latin-500.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */
}
/* eb-garamond-500italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'EB Garamond';
font-style: italic;
font-weight: 500;
src: url('../fonts/eb-garamond-v32-latin-500italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
url('../fonts/eb-garamond-v32-latin-500italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */
}
/* eb-garamond-600 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'EB Garamond';
font-style: normal;
font-weight: 600;
src: url('../fonts/eb-garamond-v32-latin-600.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
url('../fonts/eb-garamond-v32-latin-600.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */
}
/* eb-garamond-600italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'EB Garamond';
font-style: italic;
font-weight: 600;
src: url('../fonts/eb-garamond-v32-latin-600italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
url('../fonts/eb-garamond-v32-latin-600italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */
}
/* eb-garamond-700 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'EB Garamond';
font-style: normal;
font-weight: 700;
src: url('../fonts/eb-garamond-v32-latin-700.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
url('../fonts/eb-garamond-v32-latin-700.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */
}
/* eb-garamond-700italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'EB Garamond';
font-style: italic;
font-weight: 700;
src: url('../fonts/eb-garamond-v32-latin-700italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
url('../fonts/eb-garamond-v32-latin-700italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */
}
/* eb-garamond-800 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'EB Garamond';
font-style: normal;
font-weight: 800;
src: url('../fonts/eb-garamond-v32-latin-800.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
url('../fonts/eb-garamond-v32-latin-800.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */
}
/* eb-garamond-800italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'EB Garamond';
font-style: italic;
font-weight: 800;
src: url('../fonts/eb-garamond-v32-latin-800italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
url('../fonts/eb-garamond-v32-latin-800italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,9 @@
/* ephesis-regular - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Ephesis';
font-style: normal;
font-weight: 400;
src: url('../fonts/ephesis-v11-latin-regular.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
url('../fonts/ephesis-v11-latin-regular.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */
}

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,126 @@
/* ibm-plex-sans-100 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 100;
src: url('../fonts/ibm-plex-sans-v23-latin-100.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
url('../fonts/ibm-plex-sans-v23-latin-100.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */
}
/* ibm-plex-sans-100italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 100;
src: url('../fonts/ibm-plex-sans-v23-latin-100italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
url('../fonts/ibm-plex-sans-v23-latin-100italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */
}
/* ibm-plex-sans-200 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 200;
src: url('../fonts/ibm-plex-sans-v23-latin-200.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
url('../fonts/ibm-plex-sans-v23-latin-200.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */
}
/* ibm-plex-sans-200italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 200;
src: url('../fonts/ibm-plex-sans-v23-latin-200italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
url('../fonts/ibm-plex-sans-v23-latin-200italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */
}
/* ibm-plex-sans-300 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 300;
src: url('../fonts/ibm-plex-sans-v23-latin-300.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
url('../fonts/ibm-plex-sans-v23-latin-300.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */
}
/* ibm-plex-sans-300italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 300;
src: url('../fonts/ibm-plex-sans-v23-latin-300italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
url('../fonts/ibm-plex-sans-v23-latin-300italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */
}
/* ibm-plex-sans-regular - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 400;
src: url('../fonts/ibm-plex-sans-v23-latin-regular.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
url('../fonts/ibm-plex-sans-v23-latin-regular.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */
}
/* ibm-plex-sans-italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 400;
src: url('../fonts/ibm-plex-sans-v23-latin-italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
url('../fonts/ibm-plex-sans-v23-latin-italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */
}
/* ibm-plex-sans-500 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 500;
src: url('../fonts/ibm-plex-sans-v23-latin-500.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
url('../fonts/ibm-plex-sans-v23-latin-500.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */
}
/* ibm-plex-sans-500italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 500;
src: url('../fonts/ibm-plex-sans-v23-latin-500italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
url('../fonts/ibm-plex-sans-v23-latin-500italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */
}
/* ibm-plex-sans-600 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 600;
src: url('../fonts/ibm-plex-sans-v23-latin-600.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
url('../fonts/ibm-plex-sans-v23-latin-600.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */
}
/* ibm-plex-sans-600italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 600;
src: url('../fonts/ibm-plex-sans-v23-latin-600italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
url('../fonts/ibm-plex-sans-v23-latin-600italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */
}
/* ibm-plex-sans-700 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: normal;
font-weight: 700;
src: url('../fonts/ibm-plex-sans-v23-latin-700.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
url('../fonts/ibm-plex-sans-v23-latin-700.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */
}
/* ibm-plex-sans-700italic - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'IBM Plex Sans';
font-style: italic;
font-weight: 700;
src: url('../fonts/ibm-plex-sans-v23-latin-700italic.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
url('../fonts/ibm-plex-sans-v23-latin-700italic.ttf') format('truetype'); /* Chrome 4+, Firefox 3.5+, IE 9+, Safari 3.1+, iOS 4.2+, Android Browser 2.2+ */
}

Some files were not shown because too many files have changed in this diff Show more