Unifi-Voucher-Tool/index.php
Friederich Loheide e28527ed91 E-Mails und Druckvorlage im neuen Design
E-Mail:
- Nachrichten werden in ein markentreues HTML-Gerüst gelegt (Tabellen +
  Inline-Styles, damit Outlook & Co. es zuverlässig darstellen); die
  Kopfleiste nutzt den eingestellten Markenverlauf
- neuer Platzhalter {VOUCHER_CARD} rendert den Code als hervorgehobene
  Karte; bestehende Vorlagen mit {VOUCHER_CODE} funktionieren unverändert
  weiter, sie werden lediglich eingerahmt
- Zeilenumbrüche der Vorlage werden vor dem Einsetzen der Platzhalter
  umgewandelt, Markup aus dem WYSIWYG-Editor bleibt unangetastet

Druck:
- neue Standardvorlage als Ticket mit QR-Code, Code, Gültigkeit und
  Anleitung; zentral in Ui::defaultPrintTemplate()
- neuer Platzhalter {QR_CODE}; der QR-Code wird lokal im Browser erzeugt
  (qrcodejs aus assets/vendor), auch bei Bulk-Erstellung für jede Karte
- die Druckvorlage ist am Bildschirm ausgeblendet und erscheint nur im
  Ausdruck

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 06:42:45 +00:00

751 lines
35 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
// 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/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;
// 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
: '';
// {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>
<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>