96a73e22b2
Erweitert das Lizenzmodul um eine Vertriebs-/Vergabe-Schicht ueber dem bestehenden Einloese-Flow (non-breaking, alle Alt-Codes + generate/redeem /assign unveraendert). Schema (Migration 2026-08-26-lizenz-vertrieb.sql, additiv): - license_customers (Besteller: Kundennummer/Email/Name) - license_batches (Bestellung/Charge: externe Bestellnummer, Typ, Menge, Gueltigkeit, Sammel-Einloesetoken, Storno) - licenses += batch_id, issued_at, valid_from/until (pro Code -> Verlaengerung), canceled_at/reason; code auf VARCHAR(32) (laengere Codes) - license_mails (Mail-Archiv, unabh. vom IMAP), license_redeem_attempts (Brute-Force-Schutz), license_events (Audit-Log) Backend (php/api/licenses.php): - issue_batch: N frische 100-Bit-Codes in Transaktion, sofort vergeben, an Kunde/Bestellung/Gueltigkeit gebunden; sticky Default Gueltig-bis - Uebersicht (view=batches mit Aggregat ausgegeben/storniert/netto/eingeloest /abgelaufen), batch_detail, Kunden-Autocomplete, sales_default - cancel_batch/cancel_license, extend_batch/extend_license - send_batch_mail (Server-Treiber via Mailer, archiviert, Anhang bei >40) - redeem_charge (Sammel-Einloeselink) - Brute-Force-Bremse + Audit + Storno-Ablehnung im redeem/redeem_batch UI (admin-licenses.html): Ausgabe-Formular (Bestaetigung bei Grossmengen), Ergebnis mit Copy/Download(txt+csv)/Mail-Composer/Sammellink, Bestellungs- Uebersicht mit Filtern + Detail-Modal (Storno/Verlaengern/Verlauf/Mails). Einloese-Landingpage (einloesen.html + pages/einloesen.php): ?charge=Token (alle auf einmal) und ?code=CODE, mit Lehrer-Login-Gate. Lib: Database Transaktionen; Mailer sendWithAttachments + renderPlainBody. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
81 lines
4.1 KiB
HTML
81 lines
4.1 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Lizenz einlösen — GeoGraSim</title>
|
|
<link rel="stylesheet" href="assets/fonts/inter.css">
|
|
<style>
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
body{font-family:'Inter',system-ui,sans-serif;background:#f0eee6;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:1.5rem;color:#2a2a2a}
|
|
.card{background:#fff;border-radius:18px;max-width:480px;width:100%;padding:2.2rem 2rem;box-shadow:0 6px 30px rgba(31,78,90,.1);border:1px solid #e6e0d0;text-align:center}
|
|
.wm{font-family:'Arial Black','Helvetica Neue',Arial,sans-serif;font-weight:900;font-size:26px;letter-spacing:-.02em;margin-bottom:.3rem}
|
|
.wm .g{color:#143b4b}.wm .r{color:#1d707b}.wm .a{color:#f17f13}
|
|
.sub{font-size:.62rem;font-weight:700;letter-spacing:.28em;color:#83a3a0;text-transform:uppercase;margin-bottom:1.6rem}
|
|
h1{font-size:1.2rem;color:#1f4e5a;margin-bottom:.6rem}
|
|
p{font-size:.9rem;color:#4a5a5f;line-height:1.6;margin-bottom:1rem}
|
|
.big{font-size:2.6rem;font-weight:900;color:#5a8a5e;line-height:1;margin:.4rem 0}
|
|
.btn{display:inline-block;padding:.7rem 1.6rem;background:#4a7c8a;color:#fff;text-decoration:none;border-radius:9px;font-weight:700;font-size:.9rem;border:none;cursor:pointer;margin-top:.4rem}
|
|
.btn:hover{background:#3a6470}
|
|
.spin{width:34px;height:34px;border:3px solid #dfe8ea;border-top-color:#4a7c8a;border-radius:50%;animation:sp 1s linear infinite;margin:1rem auto}
|
|
@keyframes sp{to{transform:rotate(360deg)}}
|
|
.err{color:#b0453a}
|
|
.foot{font-size:.72rem;color:#9aa5a9;margin-top:1.4rem}
|
|
.code{font-family:'Courier New',monospace;font-weight:700;color:#1f4e5a}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<div class="wm"><span class="g">Geo</span><span class="r">Gra</span><span class="a">Sim</span></div>
|
|
<div class="sub">Lizenz einlösen</div>
|
|
<div id="content"><div class="spin"></div></div>
|
|
<div class="foot">Bei Fragen: <a href="mailto:support@geograsim.at" style="color:#4a7c8a">support@geograsim.at</a></div>
|
|
</div>
|
|
|
|
<script>
|
|
(function(){
|
|
var G = window.__GGS__ || {};
|
|
var BASE = G.basePath || '';
|
|
var content = document.getElementById('content');
|
|
var params = new URLSearchParams(location.search);
|
|
var charge = params.get('charge');
|
|
var code = params.get('code');
|
|
|
|
function html(h){ content.innerHTML = h; }
|
|
function loginPrompt(){
|
|
html('<h1>Bitte anmelden</h1>'
|
|
+ '<p>Melde dich als <b>Lehrperson</b> an, um '+(charge?'diese Lizenzen':'diesen Lizenzcode')+' einzulösen. Öffne danach diesen Link erneut.</p>'
|
|
+ '<a class="btn" href="'+BASE+'/login">Als Lehrperson anmelden</a>');
|
|
}
|
|
|
|
if (!charge && !code) { html('<h1>Ungültiger Link</h1><p>Es fehlt ein Lizenzcode oder eine Charge.</p>'); return; }
|
|
if (!G.teacherId) { loginPrompt(); return; }
|
|
|
|
var body = charge ? {action:'redeem_charge', token:charge} : {action:'redeem', code:code};
|
|
fetch(BASE + '/api/licenses', {method:'POST', headers:{'Content-Type':'application/json'}, credentials:'same-origin', body:JSON.stringify(body)})
|
|
.then(function(r){ return r.json(); })
|
|
.then(function(r){
|
|
if (r.error) {
|
|
html('<h1 class="err">Nicht eingelöst</h1><p class="err">'+String(r.error)+'</p>'
|
|
+ '<a class="btn" href="'+BASE+'/teacher">Zum Dashboard</a>');
|
|
return;
|
|
}
|
|
if (charge) {
|
|
var n = r.redeemed || 0;
|
|
html('<h1>Geschafft!</h1><div class="big">'+n+'</div>'
|
|
+ '<p>'+(n===1?'Lizenz wurde':'Lizenzen wurden')+' deinem Konto gutgeschrieben. Du kannst sie jetzt im Dashboard deinen Schüler:innen zuweisen.</p>'
|
|
+ '<a class="btn" href="'+BASE+'/teacher">Zum Dashboard</a>');
|
|
} else {
|
|
html('<h1>Code eingelöst ✓</h1>'
|
|
+ '<p>Der Lizenzcode <span class="code">'+String(code)+'</span> wurde deinem Konto gutgeschrieben.</p>'
|
|
+ '<a class="btn" href="'+BASE+'/teacher">Zum Dashboard</a>');
|
|
}
|
|
})
|
|
.catch(function(){
|
|
html('<h1 class="err">Fehler</h1><p>Verbindung fehlgeschlagen. Bitte später erneut versuchen.</p>');
|
|
});
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|