Für Empfang, Lobby oder Tagungsraum lässt sich je Site eine öffentliche Seite anlegen (kiosk.php), die auf einem Bildschirm oder Tablet läuft: ein großer Knopf, ein Klick, ein Zugangscode mit QR-Code. Nach einer einstellbaren Anzeigedauer springt der Bildschirm zurück, damit der nächste Gast nicht den Code seines Vorgängers sieht. Verwaltung unter Administration → Display-Seiten: - Site und optionales Voucher-Profil (bestimmt Laufzeit, Geräte, QoS) - eigene Überschrift und Text für den Bildschirm - Codes pro Tag, Wartezeit zwischen zwei Codes, Anzeigedauer - geheimer Link zum Kopieren, als QR-Code anzeigbar und jederzeit erneuerbar (der alte Link gilt dann sofort nicht mehr) Absicherung: der Link ist der Zugang, deshalb Tageslimit und Wartezeit je Display, CSRF-Token am Formular, `noindex` im Kopf und ein Eintrag im Audit-Log für jeden ausgegebenen Code. Webhooks werden für Kiosk-Codes bewusst nicht ausgelöst – ein Empfangsdisplay würde den Kanal fluten. Technik: - neue Tabelle `kiosks`, `vouchers.kiosk_id` hält die Herkunft fest (Migration 0005, database.sql nachgezogen) - includes/Kiosk.php kapselt Token, Limits und Profil-Auflösung - includes/VoucherService.php bündelt die Voucher-Erstellung, die vorher in index.php lag und für den Kiosk ein zweites Mal nötig gewesen wäre - Startbildschirm zeigt zusätzlich einen QR auf sich selbst, damit Gäste die Seite am eigenen Handy öffnen können Tests: 9 neue Fälle für Token-Prüfung, Wartezeit, Tageslimit und Profil-Auflösung (38 Tests gesamt), PHPStan deckt Kiosk.php mit ab. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
729 lines
34 KiB
PHP
729 lines
34 KiB
PHP
<?php
|
||
// Updater maintenance hook — see updater/README.md
|
||
$maintenanceFile = __DIR__ . '/updater/storage/.maintenance';
|
||
if (file_exists($maintenanceFile)) {
|
||
http_response_code(503);
|
||
require __DIR__ . '/updater/templates/maintenance.html';
|
||
exit;
|
||
}
|
||
|
||
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/UniFiController.php';
|
||
require_once __DIR__ . '/includes/Mailer.php';
|
||
require_once __DIR__ . '/includes/Notifier.php';
|
||
require_once __DIR__ . '/includes/Captcha.php';
|
||
require_once __DIR__ . '/includes/Sms.php';
|
||
require_once __DIR__ . '/includes/Ui.php';
|
||
require_once __DIR__ . '/includes/VoucherService.php';
|
||
require_once __DIR__ . '/includes/I18n.php';
|
||
|
||
$auth = new Auth();
|
||
$db = Database::getInstance();
|
||
$mailer = new Mailer();
|
||
I18n::init();
|
||
|
||
/**
|
||
* Session-basierter Throttle fuer die anonyme oeffentliche Voucher-Erstellung.
|
||
* Erlaubt max. 10 Erstellungen in 10 Minuten pro Session. Verhindert, dass
|
||
* der oeffentliche Modus zum Spammen des UniFi-Controllers missbraucht wird.
|
||
*/
|
||
function isVoucherRateLimited() {
|
||
$window = 600; // 10 Minuten
|
||
$maxRequests = 10;
|
||
$now = time();
|
||
$timestamps = $_SESSION['voucher_create_times'] ?? [];
|
||
$timestamps = array_values(array_filter($timestamps, function ($t) use ($now, $window) {
|
||
return ($now - $t) < $window;
|
||
}));
|
||
if (count($timestamps) >= $maxRequests) {
|
||
$_SESSION['voucher_create_times'] = $timestamps;
|
||
return true;
|
||
}
|
||
$timestamps[] = $now;
|
||
$_SESSION['voucher_create_times'] = $timestamps;
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Optionales Tageslimit pro (Nicht-Admin-)Benutzer (Setting
|
||
* user_daily_voucher_limit, 0 = aus). Verhindert übermäßige Erstellung.
|
||
*/
|
||
function userDailyLimitExceeded($db, $auth, $additional = 1) {
|
||
if (!$auth->isLoggedIn() || $auth->isAdmin()) {
|
||
return false;
|
||
}
|
||
$limit = (int)$db->getSetting('user_daily_voucher_limit', 0);
|
||
if ($limit <= 0) {
|
||
return false;
|
||
}
|
||
$uid = $_SESSION['user_id'] ?? 0;
|
||
$today = (int)($db->fetchOne(
|
||
"SELECT COUNT(*) c FROM vouchers WHERE user_id=? AND DATE(created_at)=CURDATE()",
|
||
[$uid]
|
||
)['c'] ?? 0);
|
||
return ($today + $additional) > $limit;
|
||
}
|
||
|
||
$appTitle = $db->getSetting('app_title', 'UniFi Voucher System');
|
||
$logoUrl = $db->getSetting('logo_url', '');
|
||
$instructionHeader = $db->getSetting('instruction_header', '');
|
||
$instructionText = $db->getSetting('instruction_text', '');
|
||
$publicAccess = $db->getSetting('public_access', 0);
|
||
$smtpEnabled = $db->getSetting('smtp_enabled', '0') === '1';
|
||
$printTemplate = $db->getSetting('print_template', Ui::defaultPrintTemplate());
|
||
$defaultExpire = max(1, (int)$db->getSetting('default_expire_minutes', 480));
|
||
$defaultMaxUses = max(1, (int)$db->getSetting('default_max_uses', 1));
|
||
$maxUsesLimit = max(1, (int)$db->getSetting('max_uses_limit', 10));
|
||
|
||
if (!$publicAccess && !$auth->isLoggedIn()) {
|
||
header('Location: login.php');
|
||
exit;
|
||
}
|
||
|
||
try {
|
||
$templates = $db->fetchAll("SELECT * FROM voucher_templates WHERE is_active = 1 ORDER BY name");
|
||
} catch (Exception $e) {
|
||
$templates = [];
|
||
}
|
||
|
||
$error = '';
|
||
$success = '';
|
||
$voucherCode = '';
|
||
$voucherCreated = false;
|
||
$voucherData = [];
|
||
$bulkVouchers = [];
|
||
$bulkCreated = false;
|
||
|
||
if ($auth->isLoggedIn()) {
|
||
if ($auth->isAdmin()) {
|
||
$sites = $db->fetchAll("SELECT * FROM sites WHERE is_active = 1 ORDER BY name");
|
||
} else {
|
||
$sites = $db->fetchAll(
|
||
"SELECT s.* FROM sites s INNER JOIN user_site_access usa ON s.id = usa.site_id
|
||
WHERE s.is_active = 1 AND usa.user_id = ? ORDER BY s.name",
|
||
[$_SESSION['user_id']]
|
||
);
|
||
}
|
||
} else {
|
||
$sites = $db->fetchAll("SELECT * FROM sites WHERE is_active = 1 AND public_access = 1 ORDER BY name");
|
||
}
|
||
|
||
$autoSelectSite = (count($sites) === 1) ? $sites[0]['id'] : 0;
|
||
|
||
// Voucher-Erstellung liegt gebuendelt in includes/VoucherService.php.
|
||
function doCreateVoucher($db, $site, $voucherName, $maxUses, $expireMinutes, $userId, $qos = []) {
|
||
return VoucherService::create($db, $site, $voucherName, $maxUses, $expireMinutes, $userId, $qos);
|
||
}
|
||
|
||
// Single voucher
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_voucher'])) {
|
||
if (!$publicAccess && !$auth->isLoggedIn()) {
|
||
$error = __('error_login_req');
|
||
} elseif (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||
// CSRF fuer ALLE (auch anonyme oeffentliche Erstellung)
|
||
$error = __('error_csrf');
|
||
} elseif (!$auth->isLoggedIn() && isVoucherRateLimited()) {
|
||
$error = 'Zu viele Anfragen. Bitte warten Sie einen Moment.';
|
||
} elseif (!$auth->isLoggedIn() && !Captcha::verify($db)) {
|
||
$error = 'Captcha-Prüfung fehlgeschlagen. Bitte erneut versuchen.';
|
||
} else {
|
||
try {
|
||
$siteId = (int)($_POST['site_id'] ?? 0);
|
||
$voucherName = trim((string)($_POST['voucher_name'] ?? ''));
|
||
$maxUses = (int)($_POST['max_uses'] ?? $defaultMaxUses);
|
||
$expireMinutes = max(1, (int)($_POST['expire_minutes'] ?? $defaultExpire));
|
||
$sendEmail = isset($_POST['send_email']) && !empty($_POST['recipient_email']);
|
||
$recipientEmail= trim((string)($_POST['recipient_email'] ?? ''));
|
||
|
||
if (empty($voucherName)) throw new Exception(__('error_name_req'));
|
||
if ($maxUses < 1 || $maxUses > $maxUsesLimit) throw new Exception(__('error_devices_range', ['max' => $maxUsesLimit]));
|
||
if ($siteId <= 0) throw new Exception(__('error_site_req'));
|
||
if ($sendEmail && !filter_var($recipientEmail, FILTER_VALIDATE_EMAIL)) throw new Exception(__('error_email_invalid'));
|
||
|
||
if ($auth->isLoggedIn() && !$auth->hasAccessToSite($siteId)) throw new Exception(__('error_site_no_perm'));
|
||
if (userDailyLimitExceeded($db, $auth, 1)) throw new Exception('Tageslimit für Voucher erreicht.');
|
||
|
||
$site = $db->fetchOne("SELECT * FROM sites WHERE id = ? AND is_active = 1", [$siteId]);
|
||
if (!$site) throw new Exception(__('error_site_not_found'));
|
||
|
||
$userId = $auth->isLoggedIn() ? ($_SESSION['user_id'] ?? null) : null;
|
||
$qos = [
|
||
'down' => max(0, (int)($_POST['qos_down'] ?? 0)),
|
||
'up' => max(0, (int)($_POST['qos_up'] ?? 0)),
|
||
'quota_mb' => max(0, (int)($_POST['qos_quota'] ?? 0)),
|
||
];
|
||
$voucherData = doCreateVoucher($db, $site, $voucherName, $maxUses, $expireMinutes, $userId, $qos);
|
||
$voucherCode = $voucherData['code'];
|
||
$voucherCreated = true;
|
||
Notifier::voucherCreated(1, $site['name'], $_SESSION['user_name'] ?? null);
|
||
|
||
$success = 'Voucher erfolgreich erstellt!';
|
||
if ($sendEmail && !empty($recipientEmail)) {
|
||
$mailer->sendVoucherEmail($recipientEmail, $voucherCode, $site['name'], $maxUses);
|
||
$success .= ' E-Mail versendet.';
|
||
}
|
||
// Optional: Code per SMS (Twilio)
|
||
$recipientPhone = trim((string)($_POST['recipient_phone'] ?? ''));
|
||
if (isset($_POST['send_sms']) && $recipientPhone !== '' && Sms::enabled($db)) {
|
||
$smsText = ($appTitle ? $appTitle . ': ' : '') . 'WLAN-Code ' . $voucherCode;
|
||
$success .= Sms::send($db, $recipientPhone, $smsText) ? ' SMS versendet.' : ' (SMS fehlgeschlagen)';
|
||
}
|
||
} catch (Exception $e) {
|
||
$error = 'Fehler: ' . $e->getMessage();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Bulk voucher creation
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['create_bulk'])) {
|
||
if (!$publicAccess && !$auth->isLoggedIn()) {
|
||
$error = __('error_login_req');
|
||
} elseif (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
|
||
// CSRF fuer ALLE (auch anonyme oeffentliche Erstellung)
|
||
$error = __('error_csrf');
|
||
} elseif (!$auth->isLoggedIn() && isVoucherRateLimited()) {
|
||
$error = 'Zu viele Anfragen. Bitte warten Sie einen Moment.';
|
||
} elseif (!$auth->isLoggedIn() && !Captcha::verify($db)) {
|
||
$error = 'Captcha-Prüfung fehlgeschlagen. Bitte erneut versuchen.';
|
||
} else {
|
||
try {
|
||
$siteId = (int)($_POST['site_id'] ?? 0);
|
||
$voucherName = trim((string)($_POST['voucher_name'] ?? ''));
|
||
$maxUses = (int)($_POST['max_uses'] ?? $defaultMaxUses);
|
||
$expireMinutes = max(1, (int)($_POST['expire_minutes'] ?? $defaultExpire));
|
||
$bulkCount = max(1, min(20, (int)($_POST['bulk_count'] ?? 1)));
|
||
|
||
if (empty($voucherName)) throw new Exception(__('error_name_req'));
|
||
if ($maxUses < 1 || $maxUses > $maxUsesLimit) throw new Exception(__('error_devices_range', ['max' => $maxUsesLimit]));
|
||
if ($siteId <= 0) throw new Exception(__('error_site_req'));
|
||
if ($auth->isLoggedIn() && !$auth->hasAccessToSite($siteId)) throw new Exception(__('error_site_no_perm'));
|
||
if (userDailyLimitExceeded($db, $auth, $bulkCount)) throw new Exception('Tageslimit für Voucher erreicht.');
|
||
|
||
$site = $db->fetchOne("SELECT * FROM sites WHERE id = ? AND is_active = 1", [$siteId]);
|
||
if (!$site) throw new Exception(__('error_site_not_found'));
|
||
|
||
$userId = $auth->isLoggedIn() ? ($_SESSION['user_id'] ?? null) : null;
|
||
$qos = [
|
||
'down' => max(0, (int)($_POST['qos_down'] ?? 0)),
|
||
'up' => max(0, (int)($_POST['qos_up'] ?? 0)),
|
||
'quota_mb' => max(0, (int)($_POST['qos_quota'] ?? 0)),
|
||
];
|
||
|
||
for ($i = 0; $i < $bulkCount; $i++) {
|
||
$bulkVouchers[] = doCreateVoucher($db, $site, $voucherName . '_' . ($i + 1), $maxUses, $expireMinutes, $userId, $qos);
|
||
}
|
||
|
||
Notifier::voucherCreated($bulkCount, $site['name'], $_SESSION['user_name'] ?? null);
|
||
$bulkCreated = true;
|
||
$success = str_replace('{count}', $bulkCount, __('bulk_success'));
|
||
} catch (Exception $e) {
|
||
$error = 'Fehler: ' . $e->getMessage();
|
||
}
|
||
}
|
||
}
|
||
|
||
$currentUser = $auth->isLoggedIn() ? $auth->getCurrentUser() : null;
|
||
|
||
// Captcha nur für anonyme öffentliche Erstellung
|
||
$captchaMode = !$auth->isLoggedIn() ? Captcha::mode($db) : 'off';
|
||
$captchaQuestion = $captchaMode === 'math' ? Captcha::newMathChallenge() : '';
|
||
$hcaptchaSiteKey = $captchaMode === 'hcaptcha' ? $db->getSetting('captcha_site_key', '') : '';
|
||
$smsEnabled = Sms::enabled($db);
|
||
$captchaHtml = '';
|
||
if ($captchaMode === 'math') {
|
||
$captchaHtml = '<div class="form-group"><label>Sicherheitsfrage: Wie viel ist ' . htmlspecialchars($captchaQuestion) . '?</label>'
|
||
. '<input type="text" name="captcha" inputmode="numeric" required></div>';
|
||
} elseif ($captchaMode === 'hcaptcha' && $hcaptchaSiteKey !== '') {
|
||
$captchaHtml = '<div class="form-group"><div class="h-captcha" data-sitekey="' . htmlspecialchars($hcaptchaSiteKey) . '"></div></div>';
|
||
}
|
||
|
||
// Build print HTML for each voucher
|
||
function buildPrintCard($template, $data, $instructionHeader, $instructionText, $appTitle) {
|
||
$instructions = $instructionHeader || $instructionText
|
||
? htmlspecialchars($instructionHeader) . "\n" . $instructionText
|
||
: '';
|
||
// {QR_CODE} wird erst im Browser gefuellt (siehe renderPrintQr()).
|
||
$qr = '<div class="print-qr" data-code="' . htmlspecialchars(str_replace('-', '', (string)$data['code']), ENT_QUOTES) . '"></div>';
|
||
|
||
return str_replace(
|
||
['{QR_CODE}', '{VOUCHER_CODE}', '{SITE_NAME}', '{MAX_USES}', '{APP_TITLE}', '{INSTRUCTIONS}', '{EXPIRY_DATE}', '{EXPIRY_TIME}'],
|
||
[$qr, $data['code'], htmlspecialchars($data['site_name']), $data['max_uses'], htmlspecialchars($appTitle), $instructions, $data['expiry_date'], $data['expiry_time']],
|
||
$template
|
||
);
|
||
}
|
||
?>
|
||
<!DOCTYPE html>
|
||
<html lang="<?= I18n::getLanguage() ?>">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title><?= htmlspecialchars($appTitle) ?></title>
|
||
<?= Ui::head($db) ?>
|
||
<?php if ($captchaMode === 'hcaptcha' && $hcaptchaSiteKey !== ''): ?>
|
||
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
|
||
<?php endif; ?>
|
||
<?php if ($voucherCreated || $bulkCreated): ?>
|
||
<?= Ui::script('assets/vendor/qrcodejs/qrcode.min.js') ?>
|
||
<?php endif; ?>
|
||
<style>
|
||
/* Seitenspezifisch: Druckansicht der Voucher-Karten.
|
||
Die Druckvorlage ist am Bildschirm ausgeblendet und erscheint nur im Druck. */
|
||
#printArea { display: none; }
|
||
@media print {
|
||
#printArea { display: block; }
|
||
body * { visibility: hidden; }
|
||
#printArea, #printArea * { visibility: visible; }
|
||
#printArea { position: absolute; left: 0; top: 0; width: 100%; background: #fff; }
|
||
.no-print { display: none !important; }
|
||
.print-page-break { page-break-after: always; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body class="app-body">
|
||
|
||
<a class="skip-link no-print" href="#main-content"><?= __('a11y_skip') ?></a>
|
||
<header class="app-topbar no-print">
|
||
<a href="index.php" class="brand">
|
||
<span class="brand-mark"><i class="fas fa-wifi" aria-hidden="true"></i></span>
|
||
<span class="brand-name"><?= htmlspecialchars($appTitle) ?></span>
|
||
</a>
|
||
<div class="app-actions">
|
||
<div class="lang-switcher" role="group" aria-label="<?= __('a11y_language') ?>">
|
||
<?php foreach (I18n::getAvailable() as $code => $label): ?>
|
||
<button class="lang-btn <?= I18n::getLanguage() === $code ? 'active' : '' ?>"
|
||
onclick="switchLanguage('<?= $code ?>')"><?= strtoupper($code) ?></button>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
<button id="darkModeBtn" class="dark-mode-toggle" onclick="toggleDarkMode()" aria-label="<?= __('a11y_theme') ?>" title="<?= __('a11y_theme') ?>">
|
||
<i class="fas fa-moon" aria-hidden="true"></i>
|
||
</button>
|
||
<?php if ($currentUser): ?>
|
||
<?php if ($auth->isAdmin()): ?>
|
||
<a href="admin/" class="btn btn-secondary">
|
||
<i class="fas fa-sliders" aria-hidden="true"></i> <span class="hide-mobile"><?= __('nav_administration') ?></span>
|
||
</a>
|
||
<?php endif; ?>
|
||
<div class="user-menu">
|
||
<div class="user-avatar"><?= strtoupper(mb_substr($currentUser['name'], 0, 1)) ?></div>
|
||
<div class="user-name hide-mobile"><?= htmlspecialchars($currentUser['name']) ?></div>
|
||
<a href="logout.php" class="icon-btn" title="<?= __('btn_logout') ?>" aria-label="<?= __('btn_logout') ?>" style="width:28px;height:28px;font-size:11px;">
|
||
<i class="fas fa-arrow-right-from-bracket" aria-hidden="true"></i>
|
||
</a>
|
||
</div>
|
||
<?php elseif ($publicAccess): ?>
|
||
<a href="login.php" class="btn btn-secondary">
|
||
<i class="fas fa-right-to-bracket" aria-hidden="true"></i> <?= __('btn_login') ?>
|
||
</a>
|
||
<?php endif; ?>
|
||
</div>
|
||
</header>
|
||
|
||
<main class="container" id="main-content">
|
||
<?php if ($logoUrl && !$voucherCreated && !$bulkCreated): ?>
|
||
<img src="<?= htmlspecialchars(Ui::mediaUrl($logoUrl)) ?>" alt="Logo" class="logo">
|
||
<?php endif; ?>
|
||
|
||
<?php if (!$voucherCreated && !$bulkCreated): ?>
|
||
<div class="app-card-head">
|
||
<h1><?= htmlspecialchars($appTitle) ?></h1>
|
||
<p><?= __('app_subtitle') ?></p>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php if ($error): ?>
|
||
<div class="alert alert-error"><?= htmlspecialchars($error) ?></div>
|
||
<?php endif; ?>
|
||
|
||
<?php if ($voucherCreated): ?>
|
||
<div id="printArea">
|
||
<?= buildPrintCard($printTemplate, $voucherData, $instructionHeader, $instructionText, $appTitle) ?>
|
||
</div>
|
||
|
||
<div class="voucher-result no-print">
|
||
<div class="result-label"><i class="fas fa-circle-check" aria-hidden="true"></i> <?= __('voucher_success_title') ?></div>
|
||
<div class="voucher-code" id="voucherCode" onclick="copyCode()" title="<?= __('js_click_to_copy') ?>">
|
||
<?= htmlspecialchars($voucherCode) ?>
|
||
</div>
|
||
<div class="voucher-info">
|
||
<i class="fas fa-copy" aria-hidden="true"></i> <?= __('voucher_copy_hint') ?>
|
||
</div>
|
||
<div class="voucher-meta">
|
||
<span><i class="fas fa-clock" aria-hidden="true"></i> <?= str_replace('{minutes}', $voucherData['expire_min'], __('voucher_validity')) ?></span>
|
||
<span><i class="fas fa-location-dot" aria-hidden="true"></i> <?= htmlspecialchars($voucherData['site_name']) ?></span>
|
||
<span><i class="fas fa-mobile-screen" aria-hidden="true"></i> <?= (int)$voucherData['max_uses'] ?> <?= __('label_devices') ?></span>
|
||
</div>
|
||
<div class="ticket-divider"></div>
|
||
<div class="qr-wrapper no-print">
|
||
<div id="qrcode"></div>
|
||
<div class="qr-label"><?= __('voucher_qr_label') ?></div>
|
||
</div>
|
||
</div>
|
||
|
||
<?php if ($instructionHeader || $instructionText): ?>
|
||
<div class="instruction-box no-print">
|
||
<?php if ($instructionHeader): ?><h3><?= htmlspecialchars($instructionHeader) ?></h3><?php endif; ?>
|
||
<?php if ($instructionText): ?><div><?= $instructionText ?></div><?php endif; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<div class="no-print" style="display:flex;gap:10px;margin-top:18px;">
|
||
<form method="get" style="flex:1;">
|
||
<button type="submit" class="btn btn-primary btn-lg btn-block">
|
||
<i class="fas fa-plus" aria-hidden="true"></i> <?= __('btn_new_code') ?>
|
||
</button>
|
||
</form>
|
||
<button onclick="window.print()" class="btn btn-secondary btn-lg">
|
||
<i class="fas fa-print" aria-hidden="true"></i> <?= __('voucher_print_btn') ?>
|
||
</button>
|
||
</div>
|
||
|
||
<?php elseif ($bulkCreated): ?>
|
||
<div class="app-card-head">
|
||
<h1><?= str_replace('{count}', count($bulkVouchers), __('bulk_results')) ?></h1>
|
||
<p><?= __('bulk_results_hint') ?></p>
|
||
</div>
|
||
|
||
<div id="printArea">
|
||
<?php foreach ($bulkVouchers as $idx => $bv): ?>
|
||
<div class="<?= $idx > 0 ? 'print-page-break' : '' ?>">
|
||
<?= buildPrintCard($printTemplate, $bv, $instructionHeader, $instructionText, $appTitle) ?>
|
||
</div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
|
||
<div class="no-print">
|
||
<table class="bulk-table">
|
||
<thead>
|
||
<tr>
|
||
<th>#</th>
|
||
<th><?= __('label_code') ?></th>
|
||
<th><?= __('label_site') ?></th>
|
||
<th><?= __('label_expires') ?></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<?php foreach ($bulkVouchers as $i => $bv): ?>
|
||
<tr>
|
||
<td><?= $i + 1 ?></td>
|
||
<td>
|
||
<code onclick="copyToClipboard('<?= addslashes($bv['code']) ?>', '<?= __('js_copied') ?>')"
|
||
title="<?= __('js_click_to_copy') ?>"><?= htmlspecialchars($bv['code']) ?></code>
|
||
</td>
|
||
<td><?= htmlspecialchars($bv['site_name']) ?></td>
|
||
<td><?= $bv['expiry_date'] ?> <?= $bv['expiry_time'] ?></td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</tbody>
|
||
</table>
|
||
<p class="copy-hint"><i class="fas fa-copy" aria-hidden="true"></i> <?= __('voucher_copy_hint') ?></p>
|
||
</div>
|
||
|
||
<div class="no-print" style="display:flex;gap:10px;margin-top:20px;">
|
||
<button onclick="window.print()" class="btn btn-primary btn-lg" style="flex:1;">
|
||
<i class="fas fa-print" aria-hidden="true"></i> <?= __('bulk_print_all') ?>
|
||
</button>
|
||
<form method="get" style="flex:1;">
|
||
<button type="submit" class="btn btn-secondary btn-lg btn-block"><?= __('btn_new_code') ?></button>
|
||
</form>
|
||
</div>
|
||
|
||
<?php elseif (empty($sites)): ?>
|
||
<div class="empty-state">
|
||
<div class="empty-icon"><i class="fas fa-wifi" aria-hidden="true"></i></div>
|
||
<p><?= __('voucher_no_sites') ?><br>
|
||
<?php if ($auth->isAdmin()): ?>
|
||
<a href="admin/" style="color:var(--accent);"><?= __('voucher_no_sites_admin') ?></a>
|
||
<?php else: ?>
|
||
<?= __('voucher_no_sites_user') ?>
|
||
<?php endif; ?>
|
||
</p>
|
||
</div>
|
||
|
||
<?php else: ?>
|
||
|
||
<?php if ($auth->isLoggedIn()): ?>
|
||
<div class="mode-tabs no-print">
|
||
<button class="mode-tab active" id="tab-single" onclick="switchMode('single')"><?= __('bulk_tab') ?></button>
|
||
<button class="mode-tab" id="tab-bulk" onclick="switchMode('bulk')"><?= __('bulk_tab_bulk') ?></button>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php if (!empty($templates)): ?>
|
||
<div class="template-dropdown">
|
||
<label for="template_select"><?= __('voucher_template_label') ?></label>
|
||
<select id="template_select" onchange="applyTemplate(this)">
|
||
<option value=""><?= __('voucher_template_select') ?></option>
|
||
<?php foreach ($templates as $tpl): ?>
|
||
<option value="<?= (int)$tpl['id'] ?>"
|
||
data-max-uses="<?= (int)$tpl['max_uses'] ?>"
|
||
data-expire="<?= (int)$tpl['expire_minutes'] ?>"
|
||
data-qos-down="<?= (int)($tpl['qos_rate_max_down'] ?? 0) ?>"
|
||
data-qos-up="<?= (int)($tpl['qos_rate_max_up'] ?? 0) ?>"
|
||
data-qos-quota="<?= (int)($tpl['qos_usage_quota'] ?? 0) ?>"
|
||
data-desc="<?= htmlspecialchars($tpl['description'] ?? '') ?>">
|
||
<?= htmlspecialchars($tpl['name']) ?> –
|
||
<?= (int)$tpl['max_uses'] ?> <?= __('label_devices') ?>,
|
||
<?= (int)$tpl['expire_minutes'] ?> <?= __('minutes_short') ?>
|
||
</option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
<div class="template-hint" id="template_desc"></div>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<!-- Single voucher form -->
|
||
<div id="mode-single">
|
||
<form method="post" id="voucherForm">
|
||
<input type="hidden" name="create_voucher" value="1">
|
||
<input type="hidden" name="expire_minutes" id="expire_minutes" value="<?= $defaultExpire ?>">
|
||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($auth->getCsrfToken()) ?>">
|
||
<input type="hidden" name="qos_down" class="qos-down-field" value="0">
|
||
<input type="hidden" name="qos_up" class="qos-up-field" value="0">
|
||
<input type="hidden" name="qos_quota" class="qos-quota-field" value="0">
|
||
<?= $captchaHtml ?>
|
||
|
||
<div class="form-group">
|
||
<label for="voucher_name"><?= __('voucher_name_label') ?></label>
|
||
<input type="text" id="voucher_name" name="voucher_name"
|
||
placeholder="<?= __('voucher_name_hint') ?>" required>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="max_uses"><?= __('voucher_devices_label') ?></label>
|
||
<input type="number" id="max_uses" name="max_uses"
|
||
min="1" max="<?= $maxUsesLimit ?>" value="<?= $defaultMaxUses ?>" required>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="site_id"><?= __('voucher_site_label') ?></label>
|
||
<select id="site_id" name="site_id" required>
|
||
<?php if (count($sites) > 1): ?>
|
||
<option value=""><?= __('voucher_site_select') ?></option>
|
||
<?php endif; ?>
|
||
<?php foreach ($sites as $site): ?>
|
||
<option value="<?= (int)$site['id'] ?>" <?= ($autoSelectSite == $site['id']) ? 'selected' : '' ?>>
|
||
<?= htmlspecialchars($site['name']) ?>
|
||
</option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</div>
|
||
|
||
<?php if ($smtpEnabled): ?>
|
||
<div class="email-option" id="email-option-box">
|
||
<div class="email-checkbox-wrapper">
|
||
<input type="checkbox" id="send_email" name="send_email" onchange="toggleEmailField()">
|
||
<label for="send_email">
|
||
<i class="fas fa-envelope" style="color:var(--accent);" aria-hidden="true"></i> <?= __('voucher_email_send') ?>
|
||
</label>
|
||
</div>
|
||
<div class="email-input-wrapper" id="email_field">
|
||
<div style="margin-top:12px;">
|
||
<label for="recipient_email"><?= __('voucher_email_label') ?></label>
|
||
<input type="email" id="recipient_email" name="recipient_email"
|
||
placeholder="<?= __('voucher_email_hint') ?>">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php if ($smsEnabled): ?>
|
||
<div class="email-option">
|
||
<div class="email-checkbox-wrapper">
|
||
<input type="checkbox" id="send_sms" name="send_sms" onchange="document.getElementById('sms_field').style.display=this.checked?'block':'none'">
|
||
<label for="send_sms"><i class="fas fa-comment-sms" style="color:var(--accent);" aria-hidden="true"></i> Code per SMS versenden</label>
|
||
</div>
|
||
<div id="sms_field" style="display:none;margin-top:12px;">
|
||
<label for="recipient_phone">Telefonnummer (international, z.B. +49170…)</label>
|
||
<input type="tel" id="recipient_phone" name="recipient_phone" placeholder="+49170123456">
|
||
</div>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<button type="submit" class="btn btn-primary btn-lg btn-block" id="submitBtn">
|
||
<i class="fas fa-ticket" aria-hidden="true"></i> <?= __('voucher_create_btn') ?>
|
||
</button>
|
||
</form>
|
||
</div>
|
||
|
||
<!-- Bulk creation form -->
|
||
<div id="mode-bulk" style="display:none;">
|
||
<form method="post" id="bulkForm">
|
||
<input type="hidden" name="create_bulk" value="1">
|
||
<input type="hidden" name="expire_minutes" id="bulk_expire_minutes" value="<?= $defaultExpire ?>">
|
||
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($auth->getCsrfToken()) ?>">
|
||
<input type="hidden" name="qos_down" class="qos-down-field" value="0">
|
||
<input type="hidden" name="qos_up" class="qos-up-field" value="0">
|
||
<input type="hidden" name="qos_quota" class="qos-quota-field" value="0">
|
||
<?= $captchaHtml ?>
|
||
|
||
<div class="form-group">
|
||
<label for="bulk_count"><?= __('bulk_quantity') ?></label>
|
||
<input type="number" id="bulk_count" name="bulk_count"
|
||
min="1" max="20" value="5" required>
|
||
<p class="form-hint"><?= __('bulk_quantity_hint') ?></p>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="bulk_voucher_name"><?= __('bulk_name_prefix') ?></label>
|
||
<input type="text" id="bulk_voucher_name" name="voucher_name"
|
||
placeholder="<?= __('voucher_name_hint') ?>" required>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="bulk_max_uses"><?= __('voucher_devices_label') ?></label>
|
||
<input type="number" id="bulk_max_uses" name="max_uses"
|
||
min="1" max="<?= $maxUsesLimit ?>" value="<?= $defaultMaxUses ?>" required>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="bulk_site_id"><?= __('voucher_site_label') ?></label>
|
||
<select id="bulk_site_id" name="site_id" required>
|
||
<?php if (count($sites) > 1): ?>
|
||
<option value=""><?= __('voucher_site_select') ?></option>
|
||
<?php endif; ?>
|
||
<?php foreach ($sites as $site): ?>
|
||
<option value="<?= (int)$site['id'] ?>" <?= ($autoSelectSite == $site['id']) ? 'selected' : '' ?>>
|
||
<?= htmlspecialchars($site['name']) ?>
|
||
</option>
|
||
<?php endforeach; ?>
|
||
</select>
|
||
</div>
|
||
|
||
<button type="submit" class="btn btn-primary btn-lg btn-block" id="bulkSubmitBtn">
|
||
<?= str_replace('{count}', '<span id="bulkCountLabel">5</span>', __('bulk_create_btn')) ?>
|
||
</button>
|
||
</form>
|
||
</div>
|
||
|
||
<?php if ($instructionHeader || $instructionText): ?>
|
||
<div class="instruction-box" style="margin-top:25px;">
|
||
<?php if ($instructionHeader): ?><h3><?= htmlspecialchars($instructionHeader) ?></h3><?php endif; ?>
|
||
<?php if ($instructionText): ?><div><?= $instructionText ?></div><?php endif; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php endif; ?>
|
||
</main>
|
||
|
||
<footer class="app-footer no-print"><?= Ui::credit() ?></footer>
|
||
|
||
<div id="toast-container" role="status" aria-live="polite"></div>
|
||
<script src="assets/global.js"></script>
|
||
<script>
|
||
<?php if ($voucherCreated || $bulkCreated): ?>
|
||
// QR-Codes der Druckkarten erzeugen (im Browser, ohne externen Dienst)
|
||
function renderPrintQr() {
|
||
document.querySelectorAll('.print-qr').forEach(function (el) {
|
||
if (el.dataset.done) return;
|
||
el.dataset.done = '1';
|
||
new QRCode(el, {
|
||
text: el.dataset.code || '',
|
||
width: 132, height: 132,
|
||
colorDark: '#101625', colorLight: '#ffffff',
|
||
correctLevel: QRCode.CorrectLevel.M
|
||
});
|
||
});
|
||
}
|
||
document.addEventListener('DOMContentLoaded', renderPrintQr);
|
||
<?php endif; ?>
|
||
|
||
<?php if ($voucherCreated): ?>
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
new QRCode(document.getElementById('qrcode'), {
|
||
text: '<?= addslashes($voucherCode) ?>',
|
||
width: 160, height: 160,
|
||
colorDark: '#101625', colorLight: '#ffffff',
|
||
correctLevel: QRCode.CorrectLevel.M
|
||
});
|
||
});
|
||
|
||
function copyCode() {
|
||
copyToClipboard('<?= addslashes($voucherCode) ?>', '<?= __('js_code_copied') ?>');
|
||
}
|
||
<?php endif; ?>
|
||
|
||
function toggleEmailField() {
|
||
const cb = document.getElementById('send_email');
|
||
const field = document.getElementById('email_field');
|
||
const box = document.getElementById('email-option-box');
|
||
const input = document.getElementById('recipient_email');
|
||
if (!cb || !field) return;
|
||
if (cb.checked) {
|
||
field.classList.add('show');
|
||
box.classList.add('active');
|
||
input.required = true;
|
||
setTimeout(() => input.focus(), 300);
|
||
} else {
|
||
field.classList.remove('show');
|
||
box.classList.remove('active');
|
||
input.required = false;
|
||
}
|
||
}
|
||
|
||
function switchMode(mode) {
|
||
document.getElementById('mode-single').style.display = mode === 'single' ? '' : 'none';
|
||
document.getElementById('mode-bulk').style.display = mode === 'bulk' ? '' : 'none';
|
||
document.getElementById('tab-single').classList.toggle('active', mode === 'single');
|
||
document.getElementById('tab-bulk').classList.toggle('active', mode === 'bulk');
|
||
}
|
||
|
||
function applyTemplate(select) {
|
||
const opt = select.selectedOptions[0];
|
||
const expire = opt.value ? parseInt(opt.dataset.expire) : <?= $defaultExpire ?>;
|
||
const maxUses = opt.value ? parseInt(opt.dataset.maxUses) : <?= $defaultMaxUses ?>;
|
||
|
||
document.getElementById('expire_minutes').value = expire;
|
||
document.getElementById('bulk_expire_minutes').value = expire;
|
||
const muEl = document.getElementById('max_uses');
|
||
if (muEl) muEl.value = maxUses;
|
||
const bmuEl = document.getElementById('bulk_max_uses');
|
||
if (bmuEl) bmuEl.value = maxUses;
|
||
|
||
const qosDown = opt.value ? (parseInt(opt.dataset.qosDown) || 0) : 0;
|
||
const qosUp = opt.value ? (parseInt(opt.dataset.qosUp) || 0) : 0;
|
||
const qosQuota = opt.value ? (parseInt(opt.dataset.qosQuota) || 0) : 0;
|
||
document.querySelectorAll('.qos-down-field').forEach(el => el.value = qosDown);
|
||
document.querySelectorAll('.qos-up-field').forEach(el => el.value = qosUp);
|
||
document.querySelectorAll('.qos-quota-field').forEach(el => el.value = qosQuota);
|
||
|
||
const descEl = document.getElementById('template_desc');
|
||
if (descEl) descEl.textContent = opt.dataset.desc || '';
|
||
}
|
||
|
||
// Bulk count label sync
|
||
const bulkCountInput = document.getElementById('bulk_count');
|
||
const bulkCountLabel = document.getElementById('bulkCountLabel');
|
||
if (bulkCountInput && bulkCountLabel) {
|
||
bulkCountInput.addEventListener('input', function() {
|
||
bulkCountLabel.textContent = this.value;
|
||
});
|
||
}
|
||
|
||
// Form submit loading states
|
||
document.getElementById('voucherForm')?.addEventListener('submit', function(e) {
|
||
const btn = document.getElementById('submitBtn');
|
||
if (!btn || btn.disabled) { e.preventDefault(); return; }
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<i class="fas fa-circle-notch fa-spin" aria-hidden="true"></i> <?= __('voucher_creating') ?>';
|
||
});
|
||
|
||
document.getElementById('bulkForm')?.addEventListener('submit', function(e) {
|
||
const btn = document.getElementById('bulkSubmitBtn');
|
||
if (!btn || btn.disabled) { e.preventDefault(); return; }
|
||
const count = document.getElementById('bulk_count').value;
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<i class="fas fa-circle-notch fa-spin" aria-hidden="true"></i> <?= addslashes(str_replace('{count}', "' + count + '", __('bulk_creating'))) ?>';
|
||
});
|
||
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
toggleEmailField?.();
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|