@@ -214,6 +228,64 @@ Während eines Updates wird die Anwendung kurz in den **Wartungsmodus** versetzt
---
+## 🖥️ Display-Seiten für Gäste
+
+Für Empfang, Lobby oder Tagungsraum lässt sich je Site eine **öffentliche Seite**
+anlegen, die auf einem Bildschirm oder Tablet läuft. Gäste tippen auf einen
+Knopf und bekommen sofort einen eigenen Zugangscode – ohne Anmeldung, ohne
+Personal am Tresen.
+
+**Anlegen:** Administration → **Display-Seiten** → *Display-Seite anlegen*
+
+
+
+| Einstellung | Wirkung |
+|---|---|
+| Site | fĂĽr welchen Standort die Codes erzeugt werden |
+| Voucher-Profil | Laufzeit, Geräteanzahl und Bandbreite der Codes (leer = Standardwerte) |
+| Ăśberschrift / Text | was auf dem Bildschirm steht |
+| Codes pro Tag | Obergrenze je Kalendertag (0 = unbegrenzt) |
+| Wartezeit | Abstand zwischen zwei Codes an diesem Display |
+| Anzeigedauer | danach springt der Bildschirm automatisch zurĂĽck |
+
+Jede Seite hat einen **eigenen, geheimen Link** (`kiosk.php?k=…`). Er lässt sich
+kopieren, als QR-Code anzeigen (praktisch, um ihn am Tablet zu öffnen) und
+jederzeit erneuern – der alte Link ist dann sofort ungültig. Den Link nicht
+öffentlich verbreiten: wer ihn hat, kann im Rahmen der Limits Codes ziehen.
+
+Auf dem Startbildschirm steht zusätzlich ein QR-Code, der auf dieselbe Seite
+zeigt. Gäste können sie damit **am eigenen Handy** öffnen – praktisch bei
+Bildschirmen ohne Touch.
+
+### Jede Seite eigenständig gestalten
+
+Jede Display-Seite bringt ihr eigenes Erscheinungsbild mit – das Hotel am
+Empfang sieht anders aus als der Tagungsraum nebenan:
+
+| Einstellung | Wirkung |
+|---|---|
+| Logo | eigenes Logo auf der Karte (leer = Logo aus den Einstellungen) |
+| Hintergrundbild | formatfĂĽllend hinter der Karte, z. B. ein Foto des Hauses |
+| Abdunklung | 0–90 % dunkle Ebene über dem Bild, damit die Karte lesbar bleibt |
+| Akzentfarbe | färbt den Knopf dieser Seite (leer = Farbe aus dem Design-Tab) |
+| Karte | hell oder dunkel – auf Fotos wirkt die dunkle Karte meist ruhiger |
+
+Logo und Hintergrund lassen sich direkt hochladen (PNG, JPG, WEBP, GIF, SVG bis
+3 MB) oder als URL hinterlegen; beim Löschen einer Display-Seite verschwinden
+die hochgeladenen Dateien mit.
+
+Die ausgegebenen Codes erscheinen normal in *Live Vouchers*, im *Reporting* und
+im *Audit-Log* (Aktion „Voucher am Display geholt"), sodass jederzeit
+nachvollziehbar bleibt, woher ein Zugang stammt.
+
+> Display-Seiten funktionieren unabhängig vom globalen öffentlichen Modus – der
+> geheime Link ist der Zugang. Webhook-Benachrichtigungen werden fĂĽr diese Codes
+> bewusst **nicht** ausgelöst, sonst wäre der Slack-Kanal voll.
+
+---
+
## ⚙️ Konfiguration
### `config.php`
@@ -605,12 +677,13 @@ dem Git-Hoster.
- [x] Branding über die Oberfläche (Farben, Logo, Login-Seite)
- [x] Assets lokal ausliefern (keine Drittanbieter-CDNs)
- [x] Vollständige englische Übersetzung des Admin-Bereichs
+- [x] Display-Seiten: Selbstbedienung für Gäste am Bildschirm
---
-**Version 2.6.0** · Autor: **Friederich Loheide** · Lizenz: **MIT**
+**Version 2.8.0** · Autor: **Friederich Loheide** · Lizenz: **MIT**
Entwickelt von **[Loheide.eu](https://loheide.eu)**
diff --git a/VERSION b/VERSION
index e70b452..834f262 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.6.0
+2.8.0
diff --git a/admin/audit_log.php b/admin/audit_log.php
index 52d2937..8edff87 100644
--- a/admin/audit_log.php
+++ b/admin/audit_log.php
@@ -50,7 +50,8 @@ $actionLabels = [];
foreach (['voucher_created', 'voucher_bulk', 'user_login', 'user_logout', 'user_created',
'user_updated', 'user_deleted', 'site_added', 'site_updated', 'site_deleted',
'settings_saved', 'password_reset', 'template_created', 'template_updated',
- 'template_deleted'] as $action) {
+ 'template_deleted', 'voucher_kiosk', 'kiosk_created', 'kiosk_updated',
+ 'kiosk_deleted'] as $action) {
$actionLabels[$action] = __('audit_action_' . $action);
}
?>
diff --git a/admin/kiosks.php b/admin/kiosks.php
new file mode 100644
index 0000000..50019af
--- /dev/null
+++ b/admin/kiosks.php
@@ -0,0 +1,500 @@
+requireAdmin();
+I18n::init();
+
+$db = Database::getInstance();
+$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
+
+$error = '';
+$success = '';
+
+/** Formularwerte einsammeln – für Anlegen und Bearbeiten identisch. */
+function kioskInput(): array
+{
+ return [
+ 'site_id' => (int)($_POST['site_id'] ?? 0),
+ 'template_id' => (int)($_POST['template_id'] ?? 0) ?: null,
+ 'name' => trim((string)($_POST['name'] ?? '')),
+ 'headline' => trim((string)($_POST['headline'] ?? '')),
+ 'subline' => trim((string)($_POST['subline'] ?? '')),
+ 'daily_limit' => max(0, (int)($_POST['daily_limit'] ?? Kiosk::DEFAULT_DAILY_LIMIT)),
+ 'cooldown_seconds' => max(0, min(3600, (int)($_POST['cooldown_seconds'] ?? Kiosk::DEFAULT_COOLDOWN))),
+ 'display_seconds' => max(10, min(600, (int)($_POST['display_seconds'] ?? Kiosk::DEFAULT_DISPLAY_SECONDS))),
+ 'is_active' => isset($_POST['is_active']) ? 1 : 0,
+ 'bg_overlay' => max(0, min(90, (int)($_POST['bg_overlay'] ?? 45))),
+ 'accent_color' => self_accent($_POST['accent_color'] ?? ''),
+ 'card_style' => ($_POST['card_style'] ?? 'light') === 'dark' ? 'dark' : 'light',
+ ];
+}
+
+/** Nur echte Hex-Farben durchlassen – der Wert landet in einem style-Attribut. */
+function self_accent($value): ?string
+{
+ $value = strtolower(trim((string)$value));
+
+ return preg_match('/^#[0-9a-f]{6}$/', $value) ? $value : null;
+}
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_kiosk'])) {
+ if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
+ $error = __('error_csrf');
+ } else {
+ try {
+ $in = kioskInput();
+ if ($in['name'] === '') throw new Exception(__('error_name_req'));
+ if ($in['site_id'] <= 0) throw new Exception(__('error_site_req'));
+
+ $logo = Upload::resolveField('logo_url', '', 'image');
+ $bg = Upload::resolveField('background_url', '', 'image');
+
+ $db->execute(
+ "INSERT INTO kiosks (site_id, template_id, name, token, headline, subline,
+ logo_url, background_url, bg_overlay, accent_color, card_style,
+ daily_limit, cooldown_seconds, display_seconds, is_active, created_by)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)",
+ [$in['site_id'], $in['template_id'], $in['name'], Kiosk::newToken(),
+ $in['headline'], $in['subline'], $logo, $bg, $in['bg_overlay'],
+ $in['accent_color'], $in['card_style'], $in['daily_limit'],
+ $in['cooldown_seconds'], $in['display_seconds'], $_SESSION['user_id']]
+ );
+ $auth->writeAuditLog($_SESSION['user_id'], 'kiosk_created', 'kiosk', null, $in['name']);
+ $success = __('kiosks_added');
+ } catch (Exception $e) {
+ $error = $e->getMessage();
+ }
+ }
+}
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['edit_kiosk'])) {
+ if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
+ $error = __('error_csrf');
+ } else {
+ try {
+ $id = (int)($_POST['kiosk_id'] ?? 0);
+ $in = kioskInput();
+ if ($in['name'] === '') throw new Exception(__('error_name_req'));
+ if ($in['site_id'] <= 0) throw new Exception(__('error_site_req'));
+
+ $current = $db->fetchOne("SELECT logo_url, background_url FROM kiosks WHERE id = ?", [$id]) ?: [];
+ $logo = Upload::resolveField('logo_url', (string)($current['logo_url'] ?? ''), 'image');
+ $bg = Upload::resolveField('background_url', (string)($current['background_url'] ?? ''), 'image');
+
+ $db->execute(
+ "UPDATE kiosks SET site_id=?, template_id=?, name=?, headline=?, subline=?,
+ logo_url=?, background_url=?, bg_overlay=?, accent_color=?, card_style=?,
+ daily_limit=?, cooldown_seconds=?, display_seconds=?, is_active=?
+ WHERE id=?",
+ [$in['site_id'], $in['template_id'], $in['name'], $in['headline'], $in['subline'],
+ $logo, $bg, $in['bg_overlay'], $in['accent_color'], $in['card_style'],
+ $in['daily_limit'], $in['cooldown_seconds'], $in['display_seconds'], $in['is_active'], $id]
+ );
+ $auth->writeAuditLog($_SESSION['user_id'], 'kiosk_updated', 'kiosk', $id, $in['name']);
+ $success = __('kiosks_updated');
+ } catch (Exception $e) {
+ $error = $e->getMessage();
+ }
+ }
+}
+
+// Neuen Link erzeugen – der alte gilt damit sofort nicht mehr.
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['renew_token'])) {
+ if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
+ $error = __('error_csrf');
+ } else {
+ $id = (int)($_POST['kiosk_id'] ?? 0);
+ $db->execute("UPDATE kiosks SET token = ? WHERE id = ?", [Kiosk::newToken(), $id]);
+ $auth->writeAuditLog($_SESSION['user_id'], 'kiosk_updated', 'kiosk', $id, 'Link erneuert');
+ $success = __('kiosks_token_renewed');
+ }
+}
+
+if (isset($_GET['delete'], $_GET['token'])) {
+ if ($auth->validateCsrfToken($_GET['token'])) {
+ $old = $db->fetchOne("SELECT logo_url, background_url FROM kiosks WHERE id = ?", [(int)$_GET['delete']]);
+ if ($old) {
+ Upload::delete((string)($old['logo_url'] ?? ''));
+ Upload::delete((string)($old['background_url'] ?? ''));
+ }
+ $db->execute("DELETE FROM kiosks WHERE id = ?", [(int)$_GET['delete']]);
+ $auth->writeAuditLog($_SESSION['user_id'], 'kiosk_deleted', 'kiosk', (int)$_GET['delete'], '');
+ $success = __('kiosks_deleted');
+ } else {
+ $error = __('error_csrf');
+ }
+}
+
+$sites = $db->fetchAll("SELECT id, name FROM sites WHERE is_active = 1 ORDER BY name");
+$templates = $db->fetchAll("SELECT id, name, max_uses, expire_minutes FROM voucher_templates WHERE is_active = 1 ORDER BY name");
+$kiosks = $db->fetchAll(
+ "SELECT k.*, s.name AS site_name, t.name AS template_name,
+ (SELECT COUNT(*) FROM vouchers v WHERE v.kiosk_id = k.id) AS total_vouchers,
+ (SELECT COUNT(*) FROM vouchers v WHERE v.kiosk_id = k.id AND DATE(v.created_at) = CURDATE()) AS today_vouchers
+ FROM kiosks k
+ INNER JOIN sites s ON s.id = k.site_id
+ LEFT JOIN voucher_templates t ON t.id = k.template_id
+ ORDER BY k.is_active DESC, k.name"
+);
+
+$csrf = $auth->getCsrfToken();
+$currentPage = 'kiosks';
+$adminBase = '';
+?>
+
+
+
+
+
+
= __('kiosks_title') ?> – = htmlspecialchars($appTitle) ?>
+= Ui::script('assets/vendor/qrcodejs/qrcode.min.js', '../') ?>
+
+
+
+
+
= htmlspecialchars($error) ?>
+
= htmlspecialchars($success) ?>
+
+
+
+
+
+
+
= __('kiosks_empty') ?>
+
+ = __('kiosks_add') ?>
+
+
+
+
+
+
+
+
+
+
+
+
+ = $k['template_name'] ? htmlspecialchars($k['template_name']) : __('kiosks_no_template') ?>
+
+
+
+ = (int)$k['today_vouchers'] ?>= (int)$k['daily_limit'] > 0 ? ' / ' . (int)$k['daily_limit'] : '' ?>
+ = __('kiosks_today') ?>
+
+
+
+ = (int)$k['total_vouchers'] ?> = __('kiosks_total') ?>
+
+
+
+
= __('kiosks_link') ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
= __('kiosks_qr_hint') ?>
+
+
+
+
+
+
+
+
+
+
+
diff --git a/admin/settings.php b/admin/settings.php
index 1f191b4..d5763aa 100644
--- a/admin/settings.php
+++ b/admin/settings.php
@@ -42,33 +42,6 @@ if (isset($_POST['ajax_smtp_test'])) {
$error = '';
$success = '';
-/**
- * Liefert den neuen Wert eines Bildfeldes: Upload schlaegt URL, und ein
- * gesetzter Entfernen-Schalter loescht die bisherige Datei.
- */
-function resolveImageField(string $name, Database $db, string $kind): string
-{
- $current = (string)$db->getSetting($name, '');
-
- $uploaded = Upload::store($_FILES[$name . '_file'] ?? [], $kind);
- if ($uploaded !== '') {
- Upload::delete($current);
- return $uploaded;
- }
-
- if (!empty($_POST[$name . '_remove'])) {
- Upload::delete($current);
- return '';
- }
-
- $value = trim($_POST[$name] ?? '');
- if ($value !== $current && Upload::isLocal($current) && !Upload::isLocal($value)) {
- Upload::delete($current);
- }
-
- return $value;
-}
-
// Einstellungen speichern
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
@@ -80,8 +53,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
if ($formType === 'general') {
$settings['app_title'] = trim($_POST['app_title'] ?? '');
- $settings['logo_url'] = resolveImageField('logo_url', $db, 'image');
- $settings['favicon_url'] = resolveImageField('favicon_url', $db, 'favicon');
+ $settings['logo_url'] = Upload::resolveField('logo_url', (string)$db->getSetting('logo_url', ''), 'image');
+ $settings['favicon_url'] = Upload::resolveField('favicon_url', (string)$db->getSetting('favicon_url', ''), 'favicon');
$settings['instruction_header'] = trim($_POST['instruction_header'] ?? '');
$settings['instruction_text'] = $_POST['instruction_text'] ?? '';
$settings['public_access'] = isset($_POST['public_access']) ? '1' : '0';
@@ -99,12 +72,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['save_settings'])) {
if ($formType === 'login') {
$settings['login_panel_enabled'] = isset($_POST['login_panel_enabled']) ? '1' : '0';
$settings['login_brand_name'] = trim($_POST['login_brand_name'] ?? '');
- $settings['login_logo_url'] = resolveImageField('login_logo_url', $db, 'image');
+ $settings['login_logo_url'] = Upload::resolveField('login_logo_url', (string)$db->getSetting('login_logo_url', ''), 'image');
$settings['login_claim_title'] = trim($_POST['login_claim_title'] ?? '');
$settings['login_claim_text'] = trim($_POST['login_claim_text'] ?? '');
$settings['login_features'] = trim($_POST['login_features'] ?? '');
$settings['login_footer'] = trim($_POST['login_footer'] ?? '');
- $settings['login_bg_image'] = resolveImageField('login_bg_image', $db, 'image');
+ $settings['login_bg_image'] = Upload::resolveField('login_bg_image', (string)$db->getSetting('login_bg_image', ''), 'image');
$settings['login_bg_from'] = trim($_POST['login_bg_from'] ?? '');
$settings['login_bg_to'] = trim($_POST['login_bg_to'] ?? '');
$overlay = (int)($_POST['login_bg_overlay'] ?? 40);
@@ -263,41 +236,6 @@ $cs = [
'last_cron_sync' => $db->getSetting('last_cron_sync', ''),
];
-/**
- * Bildfeld: Vorschau, Upload, alternativ URL – plus Entfernen-Schalter.
- */
-function imageField(string $name, string $label, ?string $value, string $hint = '', string $accept = 'image/*'): void
-{
- $value = (string)$value;
- $preview = Ui::mediaUrl($value, '../');
- ?>
-
-
@@ -350,8 +288,8 @@ $adminBase = '';
= __('settings_app_title') ?>
-
-
+ = Ui::imageField('logo_url', __('settings_logo_url'), $cs['logo_url'], __('settings_upload_hint')) ?>
+ = Ui::imageField('favicon_url', __('settings_favicon_url'), $cs['favicon_url'], __('settings_favicon_hint'), 'image/x-icon,image/png,image/svg+xml') ?>
= __('settings_instr_header') ?>
@@ -485,7 +423,7 @@ $adminBase = '';
= __('settings_login_brand_hint') ?>
-
+ = Ui::imageField('login_logo_url', __('settings_login_logo'), $cs['login_logo_url'], __('settings_login_logo_hint')) ?>