Scanner: SSO login, 2 scan modes (camera/security key), sound feedback, order details, force validation, staff/exposant badges
- Add SSO login button to scanner PWA with Keycloak redirect flow via session state - Add manual scan mode via security key (16 chars) alongside QR camera scan - Add audio feedback: good (accepted), warning (already scanned), refused sounds - Add unique scan counter per reference (no double counting same ticket) - Add order details display in scan results (order number, email, total, items) - Add force validation button for refused tickets (organizer/ROLE_ROOT only), sends email notification - Add already_scanned warning only for same-day scans, exit_definitive only same day - Staff and exposant tickets always validate regardless of state API: ROLE_ROOT access to all events, categories, billets, and scan endpoints - ROLE_ROOT bypasses ownership checks on all /api/live/* endpoints - ROLE_ROOT can login via API (email/password and SSO) - Scan API accepts securityKey parameter in addition to reference - Scan response includes billetType, buyerEmail, and full order details with items Event management: tickets tab, staff/exposant accreditations, attestation PDF - Add Tickets tab listing all sold tickets with search, download PDF, resend email, cancel actions - Add Staff/Exposant accreditation form in Invitations tab, generates dedicated non-buyable billet - Add Attestation tab to generate sales certificate PDF with category/billet selection - PDF billet template shows STAFF/EXPOSANT badge with distinct colors (black/purple) - Exclude invitations from all financial stats (event stats, admin dashboard, organizer finances) - Fix sold counts to exclude invitations in categories recap - Use actual Stripe fee parameters instead of hardcoded values in commission calculations - Add commission detail breakdown (E-Ticket + Stripe) in categories and stats tabs Admin: download tickets for orders - Add download button on admin orders page (single PDF or ZIP for multiple tickets) Scanner PWA fixes: CSP (unpkg -> jsdelivr), service worker scope (/scanner/) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="Scanner">
|
||||
<link rel="apple-touch-icon" href="/logo.png">
|
||||
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #111827; color: #f9fafb; min-height: 100dvh; }
|
||||
@@ -40,6 +40,8 @@
|
||||
.result-state { font-size: 20px; font-weight: 900; text-transform: uppercase; letter-spacing: 2px; }
|
||||
.result-accepted .result-state { color: #22c55e; }
|
||||
.result-refused .result-state { color: #dc2626; }
|
||||
.result-warning { border-color: #eab308; background: #713f1233; }
|
||||
.result-warning .result-state { color: #eab308; }
|
||||
.result-detail { margin-top: 16px; text-align: left; }
|
||||
.result-detail .row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #374151; font-size: 13px; }
|
||||
.result-detail .row .key { color: #9ca3af; font-weight: 700; text-transform: uppercase; font-size: 10px; letter-spacing: 1px; }
|
||||
@@ -87,6 +89,12 @@
|
||||
<input type="password" id="login-password" class="input" placeholder="Mot de passe" autocomplete="current-password">
|
||||
</div>
|
||||
<button class="btn btn-primary mt-4" id="login-btn">Se connecter</button>
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-top:20px;">
|
||||
<div style="flex:1;height:1px;background:#374151;"></div>
|
||||
<span style="font-size:10px;font-weight:900;text-transform:uppercase;letter-spacing:2px;color:#6b7280;">ou</span>
|
||||
<div style="flex:1;height:1px;background:#374151;"></div>
|
||||
</div>
|
||||
<a href="/api/auth/login/sso?from=scanner" class="btn mt-4" id="sso-btn" style="background:#2563eb;border-color:#2563eb;color:#fff;">Se connecter avec SSO E-Cosplay</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,7 +120,30 @@
|
||||
</div>
|
||||
<div class="content">
|
||||
<button class="back-btn mb-4" id="back-events">← Evenements</button>
|
||||
<div id="qr-reader" class="mb-4"></div>
|
||||
|
||||
<!-- Mode selection -->
|
||||
<div id="scan-modes" class="gap-3 mb-4">
|
||||
<button class="btn btn-primary" id="btn-mode-camera">📷 Scanner un QR code</button>
|
||||
<div style="display:flex;align-items:center;gap:12px;">
|
||||
<div style="flex:1;height:1px;background:#374151;"></div>
|
||||
<span style="font-size:10px;font-weight:900;text-transform:uppercase;letter-spacing:2px;color:#6b7280;">ou</span>
|
||||
<div style="flex:1;height:1px;background:#374151;"></div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="manual-reference">Cle de securite du billet</label>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input type="text" id="manual-reference" class="input" placeholder="Ex: A1B2C3D4E5F6G7H8" style="flex:1;text-transform:uppercase;font-family:monospace;letter-spacing:2px;" autocomplete="off" spellcheck="false" maxlength="16">
|
||||
<button class="btn btn-primary" id="btn-mode-manual" style="width:auto;padding:14px 20px;">Valider</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Camera zone (hidden by default) -->
|
||||
<div id="camera-zone" class="hidden">
|
||||
<div id="qr-reader" class="mb-4"></div>
|
||||
<button class="btn btn-sm btn-danger mb-4" id="btn-stop-camera">Arreter la camera</button>
|
||||
</div>
|
||||
|
||||
<div id="scan-result"></div>
|
||||
<div class="scan-count">
|
||||
Scans effectues : <strong id="scan-counter">0</strong>
|
||||
@@ -126,9 +157,38 @@
|
||||
let auth = JSON.parse(localStorage.getItem('scanner_auth') || 'null');
|
||||
let currentEvent = null;
|
||||
let scanner = null;
|
||||
let scanCount = parseInt(localStorage.getItem('scanner_count') || '0', 10);
|
||||
let scannedRefs = JSON.parse(localStorage.getItem('scanner_refs') || '[]');
|
||||
let scanCount = scannedRefs.length;
|
||||
let deferredPrompt = null;
|
||||
|
||||
// Audio feedback
|
||||
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
function playSound(type) {
|
||||
const osc = audioCtx.createOscillator();
|
||||
const gain = audioCtx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(audioCtx.destination);
|
||||
gain.gain.value = 0.3;
|
||||
if (type === 'good') {
|
||||
osc.frequency.value = 880;
|
||||
osc.type = 'sine';
|
||||
osc.start();
|
||||
osc.stop(audioCtx.currentTime + 0.15);
|
||||
} else if (type === 'warning') {
|
||||
osc.frequency.value = 440;
|
||||
osc.type = 'triangle';
|
||||
osc.start();
|
||||
setTimeout(() => { osc.frequency.value = 520; }, 150);
|
||||
osc.stop(audioCtx.currentTime + 0.35);
|
||||
} else {
|
||||
osc.frequency.value = 200;
|
||||
osc.type = 'square';
|
||||
osc.start();
|
||||
osc.stop(audioCtx.currentTime + 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// PWA Install
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
@@ -236,7 +296,7 @@
|
||||
function logout() {
|
||||
auth = null;
|
||||
localStorage.removeItem('scanner_auth');
|
||||
stopScanner();
|
||||
stopCamera();
|
||||
showScreen('login');
|
||||
}
|
||||
|
||||
@@ -273,7 +333,7 @@
|
||||
+ (ev.isOnline ? '<span style="color:#22c55e;">En ligne</span>' : '<span style="color:#dc2626;">Hors ligne</span>')
|
||||
+ (ev.isSecret ? ' · <span style="color:#eab308;">Secret</span>' : '')
|
||||
+ '</div>';
|
||||
card.addEventListener('click', () => startScanner(ev));
|
||||
card.addEventListener('click', () => openScanScreen(ev));
|
||||
listEl.appendChild(card);
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -283,15 +343,27 @@
|
||||
}
|
||||
|
||||
// Scanner
|
||||
document.getElementById('back-events').addEventListener('click', () => { stopScanner(); loadEvents(); });
|
||||
document.getElementById('back-events').addEventListener('click', () => { stopCamera(); loadEvents(); });
|
||||
|
||||
function startScanner(event) {
|
||||
function openScanScreen(event) {
|
||||
currentEvent = event;
|
||||
document.getElementById('scanner-title').textContent = 'Scanner';
|
||||
document.getElementById('scanner-event-badge').textContent = event.title;
|
||||
document.getElementById('scan-result').innerHTML = '';
|
||||
document.getElementById('scan-counter').textContent = scanCount;
|
||||
document.getElementById('manual-reference').value = '';
|
||||
document.getElementById('scan-modes').classList.remove('hidden');
|
||||
document.getElementById('camera-zone').classList.add('hidden');
|
||||
showScreen('scanner');
|
||||
}
|
||||
|
||||
// Camera mode
|
||||
document.getElementById('btn-mode-camera').addEventListener('click', startCamera);
|
||||
|
||||
function startCamera() {
|
||||
document.getElementById('scan-modes').classList.add('hidden');
|
||||
document.getElementById('camera-zone').classList.remove('hidden');
|
||||
document.getElementById('scan-result').innerHTML = '';
|
||||
|
||||
scanner = new Html5Qrcode('qr-reader');
|
||||
scanner.start(
|
||||
@@ -304,70 +376,173 @@
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('btn-stop-camera').addEventListener('click', () => {
|
||||
stopCamera();
|
||||
document.getElementById('scan-modes').classList.remove('hidden');
|
||||
document.getElementById('camera-zone').classList.add('hidden');
|
||||
});
|
||||
|
||||
// Manual mode
|
||||
document.getElementById('btn-mode-manual').addEventListener('click', doManualScan);
|
||||
document.getElementById('manual-reference').addEventListener('keydown', (e) => { if (e.key === 'Enter') doManualScan(); });
|
||||
|
||||
function doManualScan() {
|
||||
const key = document.getElementById('manual-reference').value.trim().toUpperCase();
|
||||
if (!key) return;
|
||||
submitScan(null, key);
|
||||
}
|
||||
|
||||
// Shared scan logic
|
||||
let scanning = false;
|
||||
async function onScanSuccess(decodedText) {
|
||||
if (scanning) return;
|
||||
scanning = true;
|
||||
|
||||
// Pause scanner
|
||||
try { await scanner.pause(true); } catch {}
|
||||
|
||||
// Vibrate
|
||||
if (navigator.vibrate) navigator.vibrate(100);
|
||||
|
||||
// Decode base64 QR -> reference
|
||||
let reference = decodedText;
|
||||
try {
|
||||
const decoded = atob(decodedText);
|
||||
if (decoded.startsWith('ETICKET-')) reference = decoded;
|
||||
} catch {}
|
||||
|
||||
const resultEl = document.getElementById('scan-result');
|
||||
resultEl.innerHTML = '<div class="spinner"></div>';
|
||||
await submitScan(reference, null);
|
||||
|
||||
try {
|
||||
const json = await api('POST', '/api/live/scan', { reference });
|
||||
if (!json.success) {
|
||||
resultEl.innerHTML = '<div class="result-box result-refused">'
|
||||
+ '<div class="result-icon">✗</div>'
|
||||
+ '<div class="result-state">' + escHtml(json.error || 'Erreur') + '</div></div>';
|
||||
} else {
|
||||
const d = json.data;
|
||||
const isOk = d.state === 'accepted';
|
||||
scanCount++;
|
||||
localStorage.setItem('scanner_count', scanCount);
|
||||
document.getElementById('scan-counter').textContent = scanCount;
|
||||
|
||||
let reasonText = '';
|
||||
if (d.reason === 'invalid') reasonText = 'Billet invalide';
|
||||
else if (d.reason === 'expired') reasonText = 'Billet expire';
|
||||
else if (d.reason === 'exit_definitive') reasonText = 'Deja scanne (sortie definitive)';
|
||||
|
||||
resultEl.innerHTML = '<div class="result-box ' + (isOk ? 'result-accepted' : 'result-refused') + '">'
|
||||
+ '<div class="result-icon">' + (isOk ? '✓' : '✗') + '</div>'
|
||||
+ '<div class="result-state">' + (isOk ? 'Accepte' : 'Refuse') + '</div>'
|
||||
+ (reasonText ? '<div style="color:#f87171;font-weight:700;margin-top:8px;">' + reasonText + '</div>' : '')
|
||||
+ '</div>'
|
||||
+ '<div class="result-detail">'
|
||||
+ row('Nom', escHtml((d.buyerFirstName || '') + ' ' + (d.buyerLastName || '')))
|
||||
+ row('Billet', escHtml(d.billetName || ''))
|
||||
+ row('Reference', '<span style="font-size:11px;font-family:monospace;">' + escHtml(d.reference || '') + '</span>')
|
||||
+ (d.isInvitation ? row('Type', '<span style="color:#eab308;">Invitation</span>') : '')
|
||||
+ (d.firstScannedAt ? row('Premier scan', new Date(d.firstScannedAt).toLocaleString('fr-FR')) : '')
|
||||
+ '</div>';
|
||||
}
|
||||
} catch (e) {
|
||||
resultEl.innerHTML = '<div class="error-msg">Erreur: ' + escHtml(e.message) + '</div>';
|
||||
}
|
||||
|
||||
// Resume after 2s
|
||||
setTimeout(() => {
|
||||
scanning = false;
|
||||
try { scanner.resume(); } catch {}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function stopScanner() {
|
||||
async function submitScan(reference, securityKey) {
|
||||
const resultEl = document.getElementById('scan-result');
|
||||
resultEl.innerHTML = '<div class="spinner"></div>';
|
||||
|
||||
const body = reference ? { reference } : { securityKey };
|
||||
try {
|
||||
const json = await api('POST', '/api/live/scan', body);
|
||||
if (!json.success) {
|
||||
playSound('refused');
|
||||
resultEl.innerHTML = '<div class="result-box result-refused">'
|
||||
+ '<div class="result-icon">✗</div>'
|
||||
+ '<div class="result-state">' + escHtml(json.error || 'Erreur') + '</div></div>';
|
||||
} else {
|
||||
const d = json.data;
|
||||
const isOk = d.state === 'accepted';
|
||||
const alreadyScanned = d.reason === 'already_scanned';
|
||||
|
||||
if (isOk && !alreadyScanned) {
|
||||
playSound('good');
|
||||
} else if (isOk && alreadyScanned) {
|
||||
playSound('warning');
|
||||
} else {
|
||||
playSound('refused');
|
||||
}
|
||||
|
||||
// Unique scan count per reference
|
||||
if (isOk && d.reference && !scannedRefs.includes(d.reference)) {
|
||||
scannedRefs.push(d.reference);
|
||||
localStorage.setItem('scanner_refs', JSON.stringify(scannedRefs));
|
||||
scanCount = scannedRefs.length;
|
||||
document.getElementById('scan-counter').textContent = scanCount;
|
||||
}
|
||||
|
||||
let reasonText = '';
|
||||
if (d.reason === 'invalid') reasonText = 'Billet invalide';
|
||||
else if (d.reason === 'expired') reasonText = 'Billet expire';
|
||||
else if (d.reason === 'exit_definitive') reasonText = 'Deja scanne (sortie definitive)';
|
||||
else if (d.reason === 'already_scanned') reasonText = 'Billet deja scanne';
|
||||
|
||||
const boxClass = !isOk ? 'result-refused' : (alreadyScanned ? 'result-warning' : 'result-accepted');
|
||||
const icon = !isOk ? '✗' : (alreadyScanned ? '⚠' : '✓');
|
||||
const stateText = !isOk ? 'Refuse' : (alreadyScanned ? 'Accepte' : 'Accepte');
|
||||
|
||||
let orderHtml = '';
|
||||
if (d.order) {
|
||||
const o = d.order;
|
||||
orderHtml = '<div style="margin-top:12px;padding-top:12px;border-top:2px solid #374151;">'
|
||||
+ row('Commande', '<span style="font-family:monospace;">' + escHtml(o.orderNumber || '') + '</span>')
|
||||
+ row('Email', escHtml(d.buyerEmail || ''))
|
||||
+ row('Total HT', o.totalHT.toFixed(2).replace('.', ',') + ' \u20ac')
|
||||
+ (o.paidAt ? row('Payee le', new Date(o.paidAt).toLocaleString('fr-FR')) : '')
|
||||
+ '<div style="margin-top:8px;">';
|
||||
(o.items || []).forEach(function(item) {
|
||||
orderHtml += '<div style="display:flex;justify-content:space-between;padding:4px 0;font-size:12px;">'
|
||||
+ '<span style="color:#9ca3af;">' + escHtml(item.billetName) + ' x' + item.quantity + '</span>'
|
||||
+ '<span style="font-weight:800;">' + item.unitPriceHT.toFixed(2).replace('.', ',') + ' \u20ac</span>'
|
||||
+ '</div>';
|
||||
});
|
||||
orderHtml += '</div></div>';
|
||||
}
|
||||
|
||||
let forceHtml = '';
|
||||
if (!isOk && d.canForce) {
|
||||
forceHtml = '<div style="margin-top:12px;"><button class="btn btn-primary" id="btn-force-validate" data-ref="' + escHtml(d.reference) + '">Forcer la validation</button></div>';
|
||||
}
|
||||
|
||||
resultEl.innerHTML = '<div class="result-box ' + boxClass + '">'
|
||||
+ '<div class="result-icon">' + icon + '</div>'
|
||||
+ '<div class="result-state">' + stateText + '</div>'
|
||||
+ (reasonText ? '<div style="color:' + (alreadyScanned ? '#eab308' : '#f87171') + ';font-weight:700;margin-top:8px;">' + reasonText + '</div>' : '')
|
||||
+ forceHtml
|
||||
+ '</div>'
|
||||
+ '<div class="result-detail">'
|
||||
+ row('Nom', escHtml((d.buyerFirstName || '') + ' ' + (d.buyerLastName || '')))
|
||||
+ row('Billet', escHtml(d.billetName || ''))
|
||||
+ row('Reference', '<span style="font-size:11px;font-family:monospace;">' + escHtml(d.reference || '') + '</span>')
|
||||
+ (d.billetType === 'staff' ? row('Type', '<span style="display:inline-block;padding:2px 8px;background:#111827;color:#fff;font-size:10px;font-weight:900;text-transform:uppercase;letter-spacing:2px;">STAFF</span>') : '')
|
||||
+ (d.billetType === 'exposant' ? row('Type', '<span style="display:inline-block;padding:2px 8px;background:#7c3aed;color:#fff;font-size:10px;font-weight:900;text-transform:uppercase;letter-spacing:2px;">EXPOSANT</span>') : '')
|
||||
+ (d.isInvitation && d.billetType === 'billet' ? row('Type', '<span style="color:#eab308;">Invitation</span>') : '')
|
||||
+ (d.firstScannedAt ? row('Premier scan', new Date(d.firstScannedAt).toLocaleString('fr-FR')) : '')
|
||||
+ orderHtml
|
||||
+ '</div>';
|
||||
|
||||
const forceBtn = document.getElementById('btn-force-validate');
|
||||
if (forceBtn) {
|
||||
forceBtn.addEventListener('click', async function() {
|
||||
forceBtn.disabled = true;
|
||||
forceBtn.textContent = 'Validation...';
|
||||
try {
|
||||
const forceJson = await api('POST', '/api/live/scan/force', { reference: forceBtn.dataset.ref });
|
||||
if (forceJson.success) {
|
||||
playSound('good');
|
||||
const fd = forceJson.data;
|
||||
resultEl.innerHTML = '<div class="result-box result-accepted">'
|
||||
+ '<div class="result-icon">✓</div>'
|
||||
+ '<div class="result-state">Force</div>'
|
||||
+ '<div style="color:#22c55e;font-weight:700;margin-top:8px;">Validation forcee</div>'
|
||||
+ '</div>'
|
||||
+ '<div class="result-detail">'
|
||||
+ row('Nom', escHtml((fd.buyerFirstName || '') + ' ' + (fd.buyerLastName || '')))
|
||||
+ row('Billet', escHtml(fd.billetName || ''))
|
||||
+ row('Reference', '<span style="font-size:11px;font-family:monospace;">' + escHtml(fd.reference || '') + '</span>')
|
||||
+ '</div>';
|
||||
if (fd.reference && !scannedRefs.includes(fd.reference)) {
|
||||
scannedRefs.push(fd.reference);
|
||||
localStorage.setItem('scanner_refs', JSON.stringify(scannedRefs));
|
||||
scanCount = scannedRefs.length;
|
||||
document.getElementById('scan-counter').textContent = scanCount;
|
||||
}
|
||||
} else {
|
||||
playSound('refused');
|
||||
forceBtn.textContent = forceJson.error || 'Erreur';
|
||||
}
|
||||
} catch (e) {
|
||||
playSound('refused');
|
||||
forceBtn.textContent = 'Erreur';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
document.getElementById('manual-reference').value = '';
|
||||
} catch (e) {
|
||||
playSound('refused');
|
||||
resultEl.innerHTML = '<div class="error-msg">Erreur: ' + escHtml(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function stopCamera() {
|
||||
if (scanner) {
|
||||
try { scanner.stop(); } catch {}
|
||||
scanner = null;
|
||||
@@ -385,14 +560,48 @@
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// SSO callback handling
|
||||
function handleSsoCallback() {
|
||||
const hash = window.location.hash.substring(1);
|
||||
if (!hash) return false;
|
||||
|
||||
const params = new URLSearchParams(hash);
|
||||
|
||||
if (params.has('sso_error')) {
|
||||
const errorMap = {
|
||||
'auth_failed': 'Echec de l\'authentification SSO.',
|
||||
'no_account': 'Aucun compte associe a ce SSO.',
|
||||
'no_access': 'Acces reserve aux organisateurs.',
|
||||
};
|
||||
const errEl = document.getElementById('login-error');
|
||||
errEl.innerHTML = '<div class="error-msg">' + (errorMap[params.get('sso_error')] || 'Erreur SSO.') + '</div>';
|
||||
window.history.replaceState(null, '', '/scanner/');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (params.has('sso_token')) {
|
||||
auth = {
|
||||
email: params.get('sso_email'),
|
||||
token: params.get('sso_token'),
|
||||
expiresAt: params.get('sso_expires'),
|
||||
};
|
||||
localStorage.setItem('scanner_auth', JSON.stringify(auth));
|
||||
window.history.replaceState(null, '', '/scanner/');
|
||||
loadEvents();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Init
|
||||
if (auth) {
|
||||
if (!handleSsoCallback() && auth) {
|
||||
loadEvents();
|
||||
}
|
||||
|
||||
// Service worker for PWA
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/scanner/sw.js', { scope: '/scanner' }).catch(() => {});
|
||||
navigator.serviceWorker.register('/scanner/sw.js', { scope: '/scanner/' }).catch(() => {});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user