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
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>
245 lines
9.7 KiB
PHP
245 lines
9.7 KiB
PHP
<?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>
|