Files
geograsim/App/einloesen.html
T
Adminator 96a73e22b2 Lizenz-Vertrieb: Chargen-Vergabe, Storno, Verlaengerung, Mail, Einloeselink
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>
2026-08-26 01:39:14 +02:00

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>