Add PWA scanner app for organizers at /scanner
Standalone installable PWA with: - JWT login via /api/auth/login - Event list from /api/live/events - QR code camera scanning (html5-qrcode library) - Scan results with accepted/refused state and ticket details - Auto token refresh on expiry - Offline caching via service worker - Dark theme optimized for outdoor scanning - Vibration feedback on scan Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
400
templates/scanner/index.html.twig
Normal file
400
templates/scanner/index.html.twig
Normal file
@@ -0,0 +1,400 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>E-Ticket Scanner</title>
|
||||
<link rel="manifest" href="{{ path('app_scanner_manifest') }}">
|
||||
<meta name="theme-color" content="#111827">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<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>
|
||||
<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; }
|
||||
.screen { display: none; min-height: 100dvh; flex-direction: column; }
|
||||
.screen.active { display: flex; }
|
||||
.header { background: #1f2937; padding: 16px 20px; display: flex; align-items: center; justify-content: space-between; border-bottom: 2px solid #fabf04; }
|
||||
.header h1 { font-size: 16px; font-weight: 900; text-transform: uppercase; letter-spacing: 2px; }
|
||||
.header .badge { background: #fabf04; color: #111827; font-size: 10px; font-weight: 900; padding: 4px 10px; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.content { flex: 1; padding: 20px; overflow-y: auto; }
|
||||
.btn { display: block; width: 100%; padding: 16px; border: 2px solid #374151; background: #1f2937; color: #f9fafb; font-size: 14px; font-weight: 800; text-transform: uppercase; letter-spacing: 1px; cursor: pointer; transition: all .15s; text-align: center; text-decoration: none; }
|
||||
.btn:active { transform: scale(0.98); }
|
||||
.btn-primary { background: #fabf04; color: #111827; border-color: #fabf04; }
|
||||
.btn-danger { background: #dc2626; border-color: #dc2626; }
|
||||
.btn-sm { padding: 10px 16px; font-size: 12px; width: auto; display: inline-block; }
|
||||
.input { width: 100%; padding: 14px 16px; background: #1f2937; border: 2px solid #374151; color: #f9fafb; font-size: 16px; font-weight: 600; outline: none; }
|
||||
.input:focus { border-color: #fabf04; }
|
||||
.input::placeholder { color: #6b7280; }
|
||||
.label { display: block; font-size: 10px; font-weight: 900; text-transform: uppercase; letter-spacing: 2px; color: #9ca3af; margin-bottom: 8px; }
|
||||
.card { background: #1f2937; border: 2px solid #374151; padding: 16px; margin-bottom: 12px; cursor: pointer; transition: all .15s; }
|
||||
.card:active { border-color: #fabf04; }
|
||||
.card-title { font-size: 15px; font-weight: 800; margin-bottom: 4px; }
|
||||
.card-sub { font-size: 12px; color: #9ca3af; }
|
||||
.result-box { padding: 24px; text-align: center; border: 3px solid; margin-bottom: 16px; }
|
||||
.result-accepted { border-color: #22c55e; background: #14532d33; }
|
||||
.result-refused { border-color: #dc2626; background: #7f1d1d33; }
|
||||
.result-icon { font-size: 48px; margin-bottom: 8px; }
|
||||
.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-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; }
|
||||
.result-detail .row .val { font-weight: 800; }
|
||||
.spinner { width: 32px; height: 32px; border: 3px solid #374151; border-top-color: #fabf04; border-radius: 50%; animation: spin .6s linear infinite; margin: 40px auto; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.error-msg { background: #7f1d1d; border: 2px solid #dc2626; padding: 12px 16px; font-size: 13px; font-weight: 700; margin-bottom: 16px; }
|
||||
.scan-count { text-align: center; margin-top: 16px; font-size: 12px; color: #9ca3af; }
|
||||
.scan-count strong { color: #fabf04; font-size: 20px; }
|
||||
#qr-reader { width: 100%; }
|
||||
#qr-reader video { border: 2px solid #374151 !important; }
|
||||
.back-btn { background: none; border: none; color: #9ca3af; font-size: 12px; font-weight: 800; text-transform: uppercase; letter-spacing: 1px; cursor: pointer; padding: 8px 0; display: flex; align-items: center; gap: 6px; }
|
||||
.mt-4 { margin-top: 16px; }
|
||||
.mb-4 { margin-bottom: 16px; }
|
||||
.mb-6 { margin-bottom: 24px; }
|
||||
.gap-3 { display: flex; flex-direction: column; gap: 12px; }
|
||||
.text-center { text-align: center; }
|
||||
.text-muted { color: #6b7280; font-size: 13px; }
|
||||
.install-banner { background: #1f2937; border: 2px solid #fabf04; padding: 12px 16px; margin-bottom: 16px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.install-banner p { font-size: 12px; font-weight: 700; }
|
||||
.hidden { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- LOGIN -->
|
||||
<div id="screen-login" class="screen active">
|
||||
<div class="header">
|
||||
<h1>E-Ticket Scanner</h1>
|
||||
</div>
|
||||
<div class="content" style="display:flex;flex-direction:column;justify-content:center;">
|
||||
<div id="install-banner" class="install-banner hidden">
|
||||
<p>Installer l'app sur votre telephone</p>
|
||||
<button class="btn btn-primary btn-sm" id="install-btn">Installer</button>
|
||||
</div>
|
||||
<p class="label" style="text-align:center;margin-bottom:24px;font-size:12px;color:#9ca3af;">Connectez-vous avec votre compte organisateur</p>
|
||||
<div id="login-error"></div>
|
||||
<div class="gap-3">
|
||||
<div>
|
||||
<label class="label" for="login-email">Email</label>
|
||||
<input type="email" id="login-email" class="input" placeholder="organisateur@email.fr" autocomplete="email">
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="login-password">Mot de passe</label>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- EVENTS -->
|
||||
<div id="screen-events" class="screen">
|
||||
<div class="header">
|
||||
<h1>Evenements</h1>
|
||||
<button class="btn btn-sm btn-danger" id="logout-btn">Deconnexion</button>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div id="events-list"></div>
|
||||
<div id="events-loading" class="hidden"><div class="spinner"></div></div>
|
||||
<div id="events-error"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SCANNER -->
|
||||
<div id="screen-scanner" class="screen">
|
||||
<div class="header">
|
||||
<h1 id="scanner-title">Scanner</h1>
|
||||
<span class="badge" id="scanner-event-badge"></span>
|
||||
</div>
|
||||
<div class="content">
|
||||
<button class="back-btn mb-4" id="back-events">← Evenements</button>
|
||||
<div id="qr-reader" class="mb-4"></div>
|
||||
<div id="scan-result"></div>
|
||||
<div class="scan-count">
|
||||
Scans effectues : <strong id="scan-counter">0</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
const API_BASE = window.location.origin;
|
||||
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 deferredPrompt = null;
|
||||
|
||||
// PWA Install
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault();
|
||||
deferredPrompt = e;
|
||||
document.getElementById('install-banner').classList.remove('hidden');
|
||||
});
|
||||
document.getElementById('install-btn').addEventListener('click', async () => {
|
||||
if (!deferredPrompt) return;
|
||||
deferredPrompt.prompt();
|
||||
await deferredPrompt.userChoice;
|
||||
deferredPrompt = null;
|
||||
document.getElementById('install-banner').classList.add('hidden');
|
||||
});
|
||||
|
||||
// Screens
|
||||
function showScreen(id) {
|
||||
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
|
||||
document.getElementById('screen-' + id).classList.add('active');
|
||||
}
|
||||
|
||||
// API
|
||||
async function api(method, path, body) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (auth) {
|
||||
headers['ETicket-Email'] = auth.email;
|
||||
headers['ETicket-JWT'] = auth.token;
|
||||
}
|
||||
const opts = { method, headers };
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
const res = await fetch(API_BASE + path, opts);
|
||||
const json = await res.json();
|
||||
if (!json.success && res.status === 401) {
|
||||
const refreshed = await tryRefresh();
|
||||
if (refreshed) return api(method, path, body);
|
||||
logout();
|
||||
throw new Error('Session expiree');
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
async function tryRefresh() {
|
||||
if (!auth) return false;
|
||||
try {
|
||||
const res = await fetch(API_BASE + '/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'ETicket-Email': auth.email,
|
||||
'ETicket-JWT': auth.token,
|
||||
},
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.success) {
|
||||
auth.token = json.data.token;
|
||||
auth.expiresAt = json.data.expiresAt;
|
||||
localStorage.setItem('scanner_auth', JSON.stringify(auth));
|
||||
return true;
|
||||
}
|
||||
} catch {}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Login
|
||||
document.getElementById('login-btn').addEventListener('click', doLogin);
|
||||
document.getElementById('login-password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
|
||||
|
||||
async function doLogin() {
|
||||
const email = document.getElementById('login-email').value.trim();
|
||||
const password = document.getElementById('login-password').value;
|
||||
const errEl = document.getElementById('login-error');
|
||||
errEl.innerHTML = '';
|
||||
|
||||
if (!email || !password) {
|
||||
errEl.innerHTML = '<div class="error-msg">Veuillez remplir tous les champs.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('login-btn').disabled = true;
|
||||
document.getElementById('login-btn').textContent = 'Connexion...';
|
||||
|
||||
try {
|
||||
const res = await fetch(API_BASE + '/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.success) {
|
||||
errEl.innerHTML = '<div class="error-msg">' + (json.error || 'Erreur de connexion.') + '</div>';
|
||||
return;
|
||||
}
|
||||
auth = { email, token: json.data.token, expiresAt: json.data.expiresAt };
|
||||
localStorage.setItem('scanner_auth', JSON.stringify(auth));
|
||||
loadEvents();
|
||||
} catch (e) {
|
||||
errEl.innerHTML = '<div class="error-msg">Impossible de se connecter au serveur.</div>';
|
||||
} finally {
|
||||
document.getElementById('login-btn').disabled = false;
|
||||
document.getElementById('login-btn').textContent = 'Se connecter';
|
||||
}
|
||||
}
|
||||
|
||||
// Logout
|
||||
document.getElementById('logout-btn').addEventListener('click', logout);
|
||||
function logout() {
|
||||
auth = null;
|
||||
localStorage.removeItem('scanner_auth');
|
||||
stopScanner();
|
||||
showScreen('login');
|
||||
}
|
||||
|
||||
// Events
|
||||
async function loadEvents() {
|
||||
showScreen('events');
|
||||
const listEl = document.getElementById('events-list');
|
||||
const loadEl = document.getElementById('events-loading');
|
||||
const errEl = document.getElementById('events-error');
|
||||
listEl.innerHTML = '';
|
||||
errEl.innerHTML = '';
|
||||
loadEl.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
const json = await api('GET', '/api/live/events');
|
||||
loadEl.classList.add('hidden');
|
||||
if (!json.success) {
|
||||
errEl.innerHTML = '<div class="error-msg">' + json.error + '</div>';
|
||||
return;
|
||||
}
|
||||
if (!json.data.length) {
|
||||
listEl.innerHTML = '<p class="text-center text-muted mt-4">Aucun evenement.</p>';
|
||||
return;
|
||||
}
|
||||
json.data.forEach(ev => {
|
||||
const d = new Date(ev.startAt);
|
||||
const dateStr = d.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
const timeStr = d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
card.innerHTML = '<div class="card-title">' + escHtml(ev.title) + '</div>'
|
||||
+ '<div class="card-sub">' + dateStr + ' a ' + timeStr + ' - ' + escHtml(ev.city || '') + '</div>'
|
||||
+ '<div class="card-sub" style="margin-top:4px;">'
|
||||
+ (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));
|
||||
listEl.appendChild(card);
|
||||
});
|
||||
} catch (e) {
|
||||
loadEl.classList.add('hidden');
|
||||
errEl.innerHTML = '<div class="error-msg">Erreur de chargement.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// Scanner
|
||||
document.getElementById('back-events').addEventListener('click', () => { stopScanner(); loadEvents(); });
|
||||
|
||||
function startScanner(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;
|
||||
showScreen('scanner');
|
||||
|
||||
scanner = new Html5Qrcode('qr-reader');
|
||||
scanner.start(
|
||||
{ facingMode: 'environment' },
|
||||
{ fps: 10, qrbox: { width: 250, height: 250 }, aspectRatio: 1 },
|
||||
onScanSuccess,
|
||||
() => {}
|
||||
).catch(err => {
|
||||
document.getElementById('scan-result').innerHTML = '<div class="error-msg">Impossible d\'acceder a la camera: ' + escHtml(err.toString()) + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
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>';
|
||||
|
||||
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() {
|
||||
if (scanner) {
|
||||
try { scanner.stop(); } catch {}
|
||||
scanner = null;
|
||||
}
|
||||
document.getElementById('qr-reader').innerHTML = '';
|
||||
}
|
||||
|
||||
// Helpers
|
||||
function row(key, val) {
|
||||
return '<div class="row"><span class="key">' + key + '</span><span class="val">' + val + '</span></div>';
|
||||
}
|
||||
function escHtml(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// Init
|
||||
if (auth) {
|
||||
loadEvents();
|
||||
}
|
||||
|
||||
// Service worker for PWA
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/scanner/sw.js', { scope: '/scanner' }).catch(() => {});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user