10-04-2026
274
public/_cabinet/_docs/CABINET_PROJECT.md
Normal 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
|
||||
175
public/_cabinet/_docs/b2in-display-implementation.md
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
# B2in Display – Frontend-Implementation
|
||||
|
||||
**Ziel:** Frontend-Webapp für das B2in Schaufenster-Display (9:16 Portrait, 43–55 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
|
||||
386
public/_cabinet/_docs/b2in-displays.md
Normal 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 3–5 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. 50–60% 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 8–12 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), 43–55 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:00–22: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: 5–8 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. 8–10 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 (1–2x/Tag) | Playlist rotiert dauerhaft |
|
||||
| **Polling-Intervall** | 30 Sekunden | 60 Sekunden |
|
||||
| **Hardware** | Android-Tablet 8–10" | TV/Monitor 43–55" + Media Player |
|
||||
| **CMS-Komplexität** | 12 einfache Felder | Globale Settings + Playlist-Repeater |
|
||||
200
public/_cabinet/_docs/infotablet.md
Normal 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 Mo–So. 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:00–18: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 1–2x 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, 8–10 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
|
||||
BIN
public/_cabinet/assets/334716_medium.mp4
Normal file
BIN
public/_cabinet/assets/48504-454713939_medium.mp4
Normal file
21
public/_cabinet/assets/b2in-logo-positive.svg
Normal 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 |
BIN
public/_cabinet/assets/cabinet_logo.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
public/_cabinet/assets/csm_cabinet_c7258a627c.avif
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
499
public/_cabinet/b2in/b2in-styles.css
Normal file
|
|
@ -0,0 +1,499 @@
|
|||
/**
|
||||
* B2in Schaufenster-Display – Styles
|
||||
* 9:16 Portrait, 43–55 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);
|
||||
}
|
||||
819
public/_cabinet/b2in/index.html
Normal 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 & 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>
|
||||
47
public/_cabinet/default.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
1423
public/_cabinet/display/index.html
Normal 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...');
|
||||
|
|
|
|||
612
public/_cabinet/info/index.html
Normal 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">📅</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 = '✓';
|
||||
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 = '✕';
|
||||
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>
|
||||
444
public/_cabinet/info/info-styles.css
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
240
public/_cabinet/shared/cabinet-base.css
Normal 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;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 1.1 KiB |
BIN
public/favicon.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -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 |
BIN
public/favicon/android-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/favicon/android-icon-192x192.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
public/favicon/android-icon-36x36.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
public/favicon/android-icon-48x48.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
public/favicon/android-icon-72x72.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
public/favicon/android-icon-96x96.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
public/favicon/apple-icon-114x114.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
public/favicon/apple-icon-120x120.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
public/favicon/apple-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/favicon/apple-icon-152x152.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
public/favicon/apple-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
public/favicon/apple-icon-57x57.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/favicon/apple-icon-60x60.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
public/favicon/apple-icon-72x72.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
public/favicon/apple-icon-76x76.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/favicon/apple-icon-precomposed.png
Normal file
|
After Width: | Height: | Size: 6 KiB |
BIN
public/favicon/apple-icon.png
Normal file
|
After Width: | Height: | Size: 6 KiB |
2
public/favicon/browserconfig.xml
Normal 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>
|
||||
BIN
public/favicon/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/favicon/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
public/favicon/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
public/favicon/favicon.ico
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
41
public/favicon/manifest.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
public/favicon/ms-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/favicon/ms-icon-150x150.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
public/favicon/ms-icon-310x310.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/favicon/ms-icon-70x70.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
90
public/fonts/eb-garamond/_eb-garamond.css
Normal 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+ */
|
||||
}
|
||||
BIN
public/fonts/eb-garamond/eb-garamond-v32-latin-500.ttf
Normal file
BIN
public/fonts/eb-garamond/eb-garamond-v32-latin-500.woff2
Normal file
BIN
public/fonts/eb-garamond/eb-garamond-v32-latin-500italic.ttf
Normal file
BIN
public/fonts/eb-garamond/eb-garamond-v32-latin-500italic.woff2
Normal file
BIN
public/fonts/eb-garamond/eb-garamond-v32-latin-600.ttf
Normal file
BIN
public/fonts/eb-garamond/eb-garamond-v32-latin-600.woff2
Normal file
BIN
public/fonts/eb-garamond/eb-garamond-v32-latin-600italic.ttf
Normal file
BIN
public/fonts/eb-garamond/eb-garamond-v32-latin-600italic.woff2
Normal file
BIN
public/fonts/eb-garamond/eb-garamond-v32-latin-700.ttf
Normal file
BIN
public/fonts/eb-garamond/eb-garamond-v32-latin-700.woff2
Normal file
BIN
public/fonts/eb-garamond/eb-garamond-v32-latin-700italic.ttf
Normal file
BIN
public/fonts/eb-garamond/eb-garamond-v32-latin-700italic.woff2
Normal file
BIN
public/fonts/eb-garamond/eb-garamond-v32-latin-800.ttf
Normal file
BIN
public/fonts/eb-garamond/eb-garamond-v32-latin-800.woff2
Normal file
BIN
public/fonts/eb-garamond/eb-garamond-v32-latin-800italic.ttf
Normal file
BIN
public/fonts/eb-garamond/eb-garamond-v32-latin-800italic.woff2
Normal file
BIN
public/fonts/eb-garamond/eb-garamond-v32-latin-italic.ttf
Normal file
BIN
public/fonts/eb-garamond/eb-garamond-v32-latin-italic.woff2
Normal file
BIN
public/fonts/eb-garamond/eb-garamond-v32-latin-regular.ttf
Normal file
BIN
public/fonts/eb-garamond/eb-garamond-v32-latin-regular.woff2
Normal file
9
public/fonts/ephesis/_ephesis.css
Normal 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+ */
|
||||
}
|
||||
BIN
public/fonts/ephesis/ephesis-v11-latin-regular.ttf
Normal file
BIN
public/fonts/ephesis/ephesis-v11-latin-regular.woff2
Normal file
126
public/fonts/ibm-plex-sans/_ibm-plex-sans.css
Normal 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+ */
|
||||
}
|
||||