Unifi-Voucher-Tool/kiosk.php
Friederich Loheide 4159b92268
Some checks are pending
CI / PHP Lint (push) Waiting to run
CI / PHP Lint (pull_request) Waiting to run
CI / PHP Lint-1 (pull_request) Waiting to run
CI / PHP Lint-1 (push) Waiting to run
CI / Unit Tests & Static Analysis (pull_request) Waiting to run
CI / Unit Tests & Static Analysis (push) Waiting to run
Display-Seiten individuell gestalten
Jede Display-Seite bringt jetzt ihr eigenes Erscheinungsbild mit – der
Empfang sieht anders aus als der Tagungsraum nebenan:

- eigenes Logo (leer = Logo aus den Einstellungen)
- formatfüllendes Hintergrundbild mit einstellbarer Abdunklung (0–90 %),
  damit die Karte auf hellen Fotos lesbar bleibt
- eigene Akzentfarbe für den Knopf (leer = Farbe aus dem Design-Tab)
- Karte wahlweise hell oder dunkel; auf Fotos wirkt dunkel meist ruhiger

Logo und Hintergrund lassen sich hochladen oder als URL hinterlegen; beim
Löschen einer Display-Seite verschwinden die hochgeladenen Dateien mit.

Nebenbei aufgeräumt: das Bildfeld (Vorschau + Upload + URL + Entfernen)
lag als Funktion in admin/settings.php und wird jetzt von beiden Seiten
genutzt – Ui::imageField() für die Darstellung, Upload::resolveField()
für die Auswertung.

Sicherheit: die Akzentfarbe landet in einem style-Attribut, deshalb wird
sie sowohl beim Speichern als auch beim Ausgeben auf eine echte Hex-Farbe
geprüft; ein Test hält das fest.

Migration 0006, database.sql nachgezogen, 4 neue Tests (42 gesamt),
Screenshots ergänzt, Version 2.8.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 16:58:54 +00:00

245 lines
9.7 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

<?php
/**
* Öffentliche Display-Seite ("Kiosk").
*
* Aufruf: kiosk.php?k=<token>
*
* Gedacht für ein Tablet oder einen Bildschirm im Empfangsbereich: ein großer
* Knopf, ein Klick, ein Zugangscode. Gäste ohne Zugriff auf den Bildschirm
* können denselben Link über den QR-Code am Handy öffnen.
*/
error_reporting(E_ALL);
ini_set('display_errors', 0);
ini_set('log_errors', 1);
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/includes/Database.php';
require_once __DIR__ . '/includes/Auth.php';
require_once __DIR__ . '/includes/I18n.php';
require_once __DIR__ . '/includes/Ui.php';
require_once __DIR__ . '/includes/Kiosk.php';
require_once __DIR__ . '/includes/VoucherService.php';
I18n::init();
try {
$db = Database::getInstance();
$auth = new Auth();
} catch (Exception $e) {
http_response_code(500);
die('Datenbankfehler');
}
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
$token = Kiosk::sanitizeToken($_GET['k'] ?? '');
$kiosk = $token !== '' ? Kiosk::findByToken($db, $token) : null;
if (!$kiosk) {
http_response_code(404);
$notFound = true;
} else {
$notFound = false;
}
$voucher = null; // erzeugter Code
$error = '';
$waitSecs = 0;
if (!$notFound && $_SERVER['REQUEST_METHOD'] === 'POST') {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = __('error_csrf');
} else {
$limits = Kiosk::checkLimits($db, $kiosk);
if (!$limits['allowed']) {
$waitSecs = (int)$limits['wait'];
$error = $limits['reason'] === 'cooldown'
? str_replace('{seconds}', (string)$waitSecs, __('kiosk_error_cooldown'))
: __('kiosk_error_limit');
} else {
try {
$site = $db->fetchOne("SELECT * FROM sites WHERE id = ? AND is_active = 1", [(int)$kiosk['site_id']]);
if (!$site) {
throw new Exception(__('error_site_not_found'));
}
$settings = Kiosk::voucherSettings($db, $kiosk);
$voucher = VoucherService::create(
$db,
$site,
$kiosk['name'],
$settings['max_uses'],
$settings['expire_minutes'],
null,
$settings['qos'],
(int)$kiosk['id']
);
Kiosk::markUsed($db, (int)$kiosk['id']);
$auth->writeAuditLog(null, 'voucher_kiosk', 'kiosk', (int)$kiosk['id'],
$kiosk['name'] . ' · ' . $voucher['code']);
} catch (Exception $e) {
error_log('Kiosk-Fehler: ' . $e->getMessage());
$error = __('kiosk_error_generic');
}
}
}
}
$headline = trim((string)($kiosk['headline'] ?? '')) ?: __('kiosk_default_headline');
$subline = trim((string)($kiosk['subline'] ?? '')) ?: __('kiosk_default_subline');
$display = max(10, (int)($kiosk['display_seconds'] ?? Kiosk::DEFAULT_DISPLAY_SECONDS));
$selfUrl = $kiosk ? Kiosk::publicUrl($kiosk['token']) : '';
// Gestaltung dieser Display-Seite (eigene Werte, sonst die des Systems)
$look = $kiosk ? Kiosk::appearance($db, $kiosk) : ['logo'=>'','background'=>'','overlay'=>0.45,'accent'=>'','card'=>'light'];
$logoUrl = $look['logo'];
$bodyClass = 'kiosk-body';
$bodyStyle = '';
if ($look['background'] !== '') {
$bodyClass .= ' has-background';
$bodyStyle .= "--kiosk-bg:url('" . htmlspecialchars(Ui::mediaUrl($look['background']), ENT_QUOTES) . "');"
. '--kiosk-overlay:' . $look['overlay'] . ';';
}
if ($look['card'] === 'dark') {
$bodyClass .= ' kiosk-dark';
}
if ($look['accent'] !== '') {
// Nur die Akzentfarbe dieser Seite überschreiben der Rest bleibt Design-System.
$bodyStyle .= '--accent:' . $look['accent'] . ';'
. '--accent-hover:color-mix(in srgb, ' . $look['accent'] . ' 84%, #000);'
. '--accent-soft:color-mix(in srgb, ' . $look['accent'] . ' 14%, #fff);'
. '--accent-border:color-mix(in srgb, ' . $look['accent'] . ' 32%, #fff);';
}
?>
<!DOCTYPE html>
<html lang="<?= I18n::getLanguage() ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex, nofollow">
<title><?= htmlspecialchars($appTitle) ?></title>
<?= Ui::head($db) ?>
<?php if (!$notFound): ?>
<?= Ui::script('assets/vendor/qrcodejs/qrcode.min.js') ?>
<?php endif; ?>
</head>
<body class="<?= $bodyClass ?>"<?= $bodyStyle !== '' ? ' style="' . $bodyStyle . '"' : '' ?>>
<?php if ($notFound): ?>
<main class="kiosk-stage">
<div class="kiosk-card">
<div class="empty-state">
<div class="empty-icon"><i class="fas fa-link-slash" aria-hidden="true"></i></div>
<h1><?= __('kiosk_unknown') ?></h1>
<p><?= __('kiosk_unknown_hint') ?></p>
</div>
</div>
</main>
<?php elseif ($voucher): ?>
<main class="kiosk-stage">
<div class="kiosk-card kiosk-result">
<?php if ($logoUrl): ?>
<img class="kiosk-logo kiosk-logo-sm" src="<?= htmlspecialchars(Ui::mediaUrl($logoUrl)) ?>" alt="<?= htmlspecialchars($appTitle) ?>">
<?php endif; ?>
<p class="kiosk-eyebrow"><i class="fas fa-circle-check" aria-hidden="true"></i> <?= __('kiosk_ready') ?></p>
<div class="kiosk-code" id="voucherCode"><?= htmlspecialchars($voucher['code']) ?></div>
<div class="kiosk-meta">
<span><i class="fas fa-location-dot" aria-hidden="true"></i> <?= htmlspecialchars($voucher['site_name']) ?></span>
<span><i class="fas fa-clock" aria-hidden="true"></i> <?= (int)$voucher['expire_min'] ?> <?= __('minutes_short') ?></span>
<span><i class="fas fa-mobile-screen" aria-hidden="true"></i> <?= (int)$voucher['max_uses'] ?> <?= __('label_devices') ?></span>
</div>
<div class="kiosk-qr">
<div id="qrcode"></div>
<p class="kiosk-qr-label"><?= __('kiosk_scan_code') ?></p>
</div>
<p class="kiosk-countdown">
<?= str_replace('{seconds}', '<span id="countdown">' . $display . '</span>', __('kiosk_reset_in')) ?>
</p>
<a class="btn btn-secondary btn-lg" href="?k=<?= htmlspecialchars($kiosk['token']) ?>"><?= __('kiosk_done') ?></a>
</div>
</main>
<?php else: ?>
<main class="kiosk-stage">
<div class="kiosk-card">
<?php if ($logoUrl): ?>
<img class="kiosk-logo" src="<?= htmlspecialchars(Ui::mediaUrl($logoUrl)) ?>" alt="<?= htmlspecialchars($appTitle) ?>">
<?php else: ?>
<span class="brand-mark kiosk-mark"><i class="fas fa-wifi" aria-hidden="true"></i></span>
<?php endif; ?>
<h1 class="kiosk-headline"><?= htmlspecialchars($headline) ?></h1>
<p class="kiosk-subline"><?= htmlspecialchars($subline) ?></p>
<?php if ($error): ?>
<div class="alert alert-error kiosk-alert"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<form method="post" id="kioskForm">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($auth->getCsrfToken()) ?>">
<button type="submit" class="btn btn-primary kiosk-button" id="kioskButton"
<?= $waitSecs > 0 ? 'disabled' : '' ?>>
<i class="fas fa-wifi" aria-hidden="true"></i>
<span><?= __('kiosk_button') ?></span>
</button>
</form>
<div class="kiosk-phone">
<div id="selfQr" class="kiosk-phone-qr"></div>
<p><?= __('kiosk_phone_hint') ?></p>
</div>
</div>
</main>
<?php endif; ?>
<footer class="kiosk-footer"><?= Ui::credit() ?></footer>
<?php if (!$notFound): ?>
<script>
(function () {
<?php if ($voucher): ?>
// Code als QR lokal erzeugt, ohne externen Dienst.
new QRCode(document.getElementById('qrcode'), {
text: <?= json_encode(str_replace('-', '', $voucher['code'])) ?>,
width: 240, height: 240,
colorDark: '#101625', colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.M
});
// Nach der Anzeigedauer zurück zum Startbildschirm, damit der nächste
// Gast nicht den Code seines Vorgängers sieht.
var left = <?= $display ?>;
var out = document.getElementById('countdown');
setInterval(function () {
left -= 1;
if (out) out.textContent = left > 0 ? left : 0;
if (left <= 0) location.href = '?k=<?= htmlspecialchars($kiosk['token'], ENT_QUOTES) ?>';
}, 1000);
<?php else: ?>
// QR auf diese Seite selbst: Gäste öffnen sie am eigenen Handy.
new QRCode(document.getElementById('selfQr'), {
text: <?= json_encode($selfUrl) ?>,
width: 150, height: 150,
colorDark: '#101625', colorLight: '#ffffff',
correctLevel: QRCode.CorrectLevel.M
});
// Doppelklicks auf dem Touch-Display verhindern.
var form = document.getElementById('kioskForm');
var button = document.getElementById('kioskButton');
if (form && button) {
form.addEventListener('submit', function () {
button.disabled = true;
button.querySelector('span').textContent = <?= json_encode(__('kiosk_working')) ?>;
});
}
<?php if ($waitSecs > 0): ?>
// Nach der Wartezeit wieder freigeben.
setTimeout(function () { location.href = '?k=<?= htmlspecialchars($kiosk['token'], ENT_QUOTES) ?>'; }, <?= $waitSecs * 1000 ?>);
<?php endif; ?>
<?php endif; ?>
})();
</script>
<?php endif; ?>
</body>
</html>