Unifi-Voucher-Tool/index.php
Friederich Loheide 498c7e28e0
Some checks are pending
CI / PHP Lint (push) Waiting to run
CI / PHP Lint-1 (push) Waiting to run
CI / Unit Tests & Static Analysis (push) Waiting to run
CI / PHP Lint (pull_request) Waiting to run
CI / PHP Lint-1 (pull_request) Waiting to run
CI / Unit Tests & Static Analysis (pull_request) Waiting to run
Redesign: gemeinsames Design-System für Frontend und Backend
Frontend, Login/Installer/Updater und der komplette Admin-Bereich nutzen
jetzt ein einziges Stylesheet (assets/global.css) statt pro Seite
dupliziertem Inline-CSS.

Design-System
- Tokens für Flächen, Text, Linien, Marke, Status, Radien, Schatten und
  Layout-Maße; Dark Mode ausschließlich über Tokens (keine !important-
  Overrides mehr)
- Komponenten: Buttons, Formularfelder, Cards, Tabellen, Badges, Alerts,
  Tabs, Pagination, Modals, Toasts, Statistik-Kacheln, Empty States
- Schrift Inter mit System-Fallback

Oberfläche
- Admin-Shell neu: durchgehende Sidebar mit Marke, gruppierter Navigation
  und Benutzerbereich; schlanke Topbar mit Breadcrumb
- Dashboard: ruhige KPI-Kacheln mit Icon-Chips, Charts an Theme-Farben
  gekoppelt
- Öffentliche Voucher-Seite: App-Topbar, klare Formularstruktur und
  Ticket-Darstellung des erstellten Codes inkl. QR-Code
- Login/Passwort/2FA: zweispaltiges Auth-Layout bzw. Fokus-Karten
- Updater und Wartungsmodus im gleichen Look (Wartungsseite bleibt
  bewusst eigenständig ohne externe Abhängigkeiten)
- Emoji-Icons in der UI durch Font-Awesome-Icons ersetzt

Nebenbei behoben
- Falscher SRI-Hash blockierte qrcode.min.js – QR-Codes wurden auf der
  Voucher-Seite und bei der 2FA-Einrichtung nie gerendert
- assets/global.css wurde in mehreren Admin-Seiten über einen falschen
  Pfad eingebunden ($adminBase = '' statt '../')
- TinyMCE lädt ohne API-Key jetzt die GPL-Variante von cdnjs – kein
  "valid API key required"-Banner mehr im Einstellungs-Editor
- Tabellen in Cards scrollen horizontal statt zu überlaufen

Screenshots in docs/screenshots neu erstellt, README aktualisiert (neuer
Abschnitt "Design-System", Version 2.5.0).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:33:52 +00:00

734 lines
35 KiB
PHP
Raw 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
// 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/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', '<div style="text-align:center;padding:40px;font-family:sans-serif;"><h1>{APP_TITLE}</h1><h2>WLAN Zugangscode</h2><div style="font-size:48px;font-weight:bold;margin:30px 0;font-family:monospace;letter-spacing:4px;">{VOUCHER_CODE}</div><p><strong>Gültig bis:</strong> {EXPIRY_DATE} {EXPIRY_TIME}</p><p><strong>Standort:</strong> {SITE_NAME}</p><p><strong>Maximale Geräte:</strong> {MAX_USES}</p><hr style="margin:30px 0;"><div style="font-size:14px;text-align:left;">{INSTRUCTIONS}</div></div>');
$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;
// Helper: create one voucher and save to DB
function doCreateVoucher($db, $site, $voucherName, $maxUses, $expireMinutes, $userId, $qos = []) {
$datum = date('Y-m-d');
$fullName = $datum . '_' . $voucherName;
$controller = new UniFiController(
$site['unifi_controller_url'],
$site['unifi_username'],
Crypto::decrypt($site['unifi_password']),
$site['site_id']
);
$voucher = $controller->createVoucher($fullName, $maxUses, $expireMinutes, $qos);
if (!is_array($voucher) || empty($voucher['formatted_code'])) {
throw new Exception(__('error_voucher_invalid'));
}
$db->execute(
"INSERT INTO vouchers (site_id, user_id, voucher_code, voucher_name, max_uses, expire_minutes, unifi_voucher_id)
VALUES (?, ?, ?, ?, ?, ?, ?)",
[$site['id'], $userId, $voucher['code'], $fullName, $maxUses, $expireMinutes, $voucher['unifi_id'] ?? null]
);
$expiryTs = time() + ($expireMinutes * 60);
return [
'code' => $voucher['formatted_code'],
'site_name' => $site['name'],
'max_uses' => $maxUses,
'expire_min' => $expireMinutes,
'expiry_date' => date('d.m.Y', $expiryTs),
'expiry_time' => date('H:i', $expiryTs),
];
}
// 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
: '';
return str_replace(
['{VOUCHER_CODE}', '{SITE_NAME}', '{MAX_USES}', '{APP_TITLE}', '{INSTRUCTIONS}', '{EXPIRY_DATE}', '{EXPIRY_TIME}'],
[$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>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="assets/global.css">
<?php if ($captchaMode === 'hcaptcha' && $hcaptchaSiteKey !== ''): ?>
<script src="https://js.hcaptcha.com/1/api.js" async defer></script>
<?php endif; ?>
<script>(function(){ const t=localStorage.getItem('theme')||'light'; document.documentElement.setAttribute('data-theme',t); })();</script>
<?php if ($voucherCreated): ?>
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcodejs/1.0.0/qrcode.min.js" integrity="sha512-CNgIRecGo7nphbeZ04Sc13ka07paqdeTu0WR1IM4kNcpmBAUSHSQX0FslNhTDadL4O5SAGapGt4FodqL8My0mA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<?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">
<header class="app-topbar no-print">
<a href="index.php" class="brand">
<span class="brand-mark"><i class="fas fa-wifi"></i></span>
<span class="brand-name"><?= htmlspecialchars($appTitle) ?></span>
</a>
<div class="app-actions">
<div class="lang-switcher">
<?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()" title="Dark Mode">
<i class="fas fa-moon"></i>
</button>
<?php if ($currentUser): ?>
<?php if ($auth->isAdmin()): ?>
<a href="admin/" class="btn btn-secondary">
<i class="fas fa-sliders"></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') ?>" style="width:28px;height:28px;font-size:11px;">
<i class="fas fa-arrow-right-from-bracket"></i>
</a>
</div>
<?php elseif ($publicAccess): ?>
<a href="login.php" class="btn btn-secondary">
<i class="fas fa-right-to-bracket"></i> <?= __('btn_login') ?>
</a>
<?php endif; ?>
</div>
</header>
<div class="container">
<?php if ($logoUrl && !$voucherCreated && !$bulkCreated): ?>
<img src="<?= htmlspecialchars($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"></i> <?= __('voucher_success_title') ?></div>
<div class="voucher-code" id="voucherCode" onclick="copyCode()" title="Klicken zum Kopieren">
<?= htmlspecialchars($voucherCode) ?>
</div>
<div class="voucher-info">
<i class="fas fa-copy"></i> <?= __('voucher_copy_hint') ?>
</div>
<div class="voucher-meta">
<span><i class="fas fa-clock"></i> <?= str_replace('{minutes}', $voucherData['expire_min'], __('voucher_validity')) ?></span>
<span><i class="fas fa-location-dot"></i> <?= htmlspecialchars($voucherData['site_name']) ?></span>
<span><i class="fas fa-mobile-screen"></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"></i> <?= __('btn_new_code') ?>
</button>
</form>
<button onclick="window.print()" class="btn btn-secondary btn-lg">
<i class="fas fa-print"></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']) ?>', 'Kopiert!')"
title="Klicken zum Kopieren"><?= 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"></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"></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"></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);"></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);"></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"></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; ?>
</div>
<div id="toast-container"></div>
<script src="assets/global.js"></script>
<script>
<?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) ?>', 'Code kopiert!');
}
<?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"></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"></i> <?= addslashes(str_replace('{count}', "' + count + '", __('bulk_creating'))) ?>';
});
document.addEventListener('DOMContentLoaded', function() {
toggleEmailField?.();
});
</script>
</body>
</html>