dec6446d7d
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
387 lines
10 KiB
HTML
387 lines
10 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}{{ 'scan_qr'|t }} - {{ event.name }}{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="scan-page">
|
|
<div class="scan-header">
|
|
<h1>{{ event.name }}</h1>
|
|
<p>{{ 'qr_scanner_checkin'|t }}</p>
|
|
<a href="{{ url_for('staff_event_dashboard', event_id=event.id) }}" class="btn btn-outline">{{ 'back_to_dashboard'|t }}</a>
|
|
</div>
|
|
|
|
<div class="scanner-container">
|
|
<div id="qr-reader" class="qr-reader"></div>
|
|
<div id="scan-result" class="scan-result hidden">
|
|
<div class="result-icon" id="result-icon"></div>
|
|
<h2 id="result-title"></h2>
|
|
<p id="result-message"></p>
|
|
<button id="scan-again" class="btn btn-primary">{{ 'scan_again'|t }}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="scan-stats">
|
|
<div class="stat">
|
|
<span class="stat-number" id="total-count">{{ attendees|length }}</span>
|
|
<span class="stat-label">{{ 'total'|t }}</span>
|
|
</div>
|
|
<div class="stat">
|
|
<span class="stat-number" id="checked-in-count">{{ attendees|selectattr('checked_in')|list|length }}</span>
|
|
<span class="stat-label">{{ 'checked_in'|t }}</span>
|
|
</div>
|
|
<div class="stat">
|
|
<span class="stat-number" id="remaining-count">{{ attendees|rejectattr('checked_in')|list|length }}</span>
|
|
<span class="stat-label">{{ 'remaining'|t }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="recent-checkins">
|
|
<h3>{{ 'recent_checkins'|t }}</h3>
|
|
<ul id="recent-list">
|
|
<li class="empty-state">{{ 'no_checkins_yet'|t }}</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.scan-page {
|
|
max-width: 600px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.scan-header {
|
|
text-align: center;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.scan-header h1 {
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.scan-header p {
|
|
color: #666;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.scanner-container {
|
|
background: #000;
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
min-height: 300px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
position: relative;
|
|
}
|
|
|
|
.qr-reader {
|
|
width: 100%;
|
|
}
|
|
|
|
.qr-reader video {
|
|
width: 100% !important;
|
|
border-radius: 12px;
|
|
}
|
|
|
|
.scan-result {
|
|
text-align: center;
|
|
padding: 40px 20px;
|
|
background: #fff;
|
|
width: 100%;
|
|
}
|
|
|
|
.scan-result.hidden {
|
|
display: none;
|
|
}
|
|
|
|
.result-icon {
|
|
font-size: 64px;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.result-icon.success::before {
|
|
content: '✓';
|
|
color: #22c55e;
|
|
}
|
|
|
|
.result-icon.error::before {
|
|
content: '✗';
|
|
color: #ef4444;
|
|
}
|
|
|
|
.result-icon.duplicate::before {
|
|
content: '⏳';
|
|
color: #f59e0b;
|
|
}
|
|
|
|
#result-title {
|
|
margin: 0 0 10px 0;
|
|
}
|
|
|
|
#result-message {
|
|
color: #666;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.scan-stats {
|
|
display: flex;
|
|
justify-content: space-around;
|
|
padding: 20px;
|
|
background: #f8fafc;
|
|
border-radius: 12px;
|
|
margin: 20px 0;
|
|
}
|
|
|
|
.stat {
|
|
text-align: center;
|
|
}
|
|
|
|
.stat-number {
|
|
display: block;
|
|
font-size: 32px;
|
|
font-weight: bold;
|
|
color: #1e293b;
|
|
}
|
|
|
|
.stat-label {
|
|
font-size: 14px;
|
|
color: #64748b;
|
|
}
|
|
|
|
.recent-checkins {
|
|
background: #fff;
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
border: 1px solid #e2e8f0;
|
|
}
|
|
|
|
.recent-checkins h3 {
|
|
margin: 0 0 15px 0;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.recent-checkins ul {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.recent-checkins li {
|
|
padding: 10px;
|
|
border-bottom: 1px solid #f1f5f9;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.recent-checkins li:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.recent-checkins li.empty-state {
|
|
color: #94a3b8;
|
|
text-align: center;
|
|
padding: 20px;
|
|
}
|
|
|
|
.recent-checkins .attendee-name {
|
|
font-weight: 500;
|
|
}
|
|
|
|
.recent-checkins .checkin-time {
|
|
color: #22c55e;
|
|
font-size: 14px;
|
|
}
|
|
</style>
|
|
|
|
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
|
|
<script>
|
|
const eventId = {{ event.id }};
|
|
let html5QrCode;
|
|
let scanning = true;
|
|
|
|
function getAttendeeName(attendeeId) {
|
|
const attendees = {{ attendees|tojson }};
|
|
const attendee = attendees.find(a => a.id === attendeeId);
|
|
return attendee ? `${attendee.first_name} ${attendee.last_name}` : `Attendee #${attendeeId}`;
|
|
}
|
|
|
|
function updateStats() {
|
|
const attendees = {{ attendees|tojson }};
|
|
const total = attendees.length;
|
|
const checkedIn = attendees.filter(a => a.checked_in).length;
|
|
document.getElementById('total-count').textContent = total;
|
|
document.getElementById('checked-in-count').textContent = checkedIn;
|
|
document.getElementById('remaining-count').textContent = total - checkedIn;
|
|
}
|
|
|
|
function addRecentCheckin(name) {
|
|
const list = document.getElementById('recent-list');
|
|
const emptyState = list.querySelector('.empty-state');
|
|
if (emptyState) emptyState.remove();
|
|
|
|
const li = document.createElement('li');
|
|
const time = new Date().toLocaleTimeString();
|
|
li.innerHTML = `<span class="attendee-name">${name}</span><span class="checkin-time">${time}</span>`;
|
|
list.insertBefore(li, list.firstChild);
|
|
|
|
while (list.children.length > 10) {
|
|
list.removeChild(list.lastChild);
|
|
}
|
|
}
|
|
|
|
async function checkInAttendee(attendeeId) {
|
|
try {
|
|
const response = await fetch(`/organizer/event/${eventId}/checkin/${attendeeId}`, {
|
|
method: 'POST'
|
|
});
|
|
|
|
const data = await response.json();
|
|
const resultDiv = document.getElementById('scan-result');
|
|
const icon = document.getElementById('result-icon');
|
|
const title = document.getElementById('result-title');
|
|
const message = document.getElementById('result-message');
|
|
|
|
icon.className = 'result-icon';
|
|
|
|
if (data.success) {
|
|
document.getElementById('qr-reader').style.display = 'none';
|
|
resultDiv.classList.remove('hidden');
|
|
|
|
if (data.already_checked_in) {
|
|
icon.classList.add('duplicate');
|
|
title.textContent = '{{ "already_checked_in"|t }}';
|
|
message.textContent = `${data.attendee_name} {{ "was_already_checked_in"|t }}`;
|
|
} else {
|
|
icon.classList.add('success');
|
|
title.textContent = '{{ "checked_in"|t }}!';
|
|
message.textContent = `{{ "success_checked_in"|t }} ${data.attendee_name}`;
|
|
addRecentCheckin(data.attendee_name);
|
|
updateStats();
|
|
}
|
|
} else {
|
|
icon.classList.add('error');
|
|
title.textContent = '{{ "error"|t }}';
|
|
message.textContent = data.error || '{{ "failed_checkin"|t }}';
|
|
}
|
|
|
|
scanning = false;
|
|
} catch (error) {
|
|
console.error('Check-in error:', error);
|
|
alert('{{ "error_checking_in"|t }}');
|
|
}
|
|
}
|
|
|
|
function onScanSuccess(decodedText) {
|
|
if (!scanning) return;
|
|
|
|
// Check if it's a plain attendee code (10 alphanumeric characters)
|
|
const codeMatch = decodedText.match(/^[A-Z0-9]{10}$/i);
|
|
if (codeMatch) {
|
|
// It's an attendee code - use the new endpoint
|
|
checkInByCode(codeMatch[0]);
|
|
return;
|
|
}
|
|
|
|
const parts = decodedText.split(':');
|
|
if (parts.length === 3 && parts[0] === 'NETEVENT') {
|
|
const qrEventId = parseInt(parts[1]);
|
|
const attendeeId = parseInt(parts[2]);
|
|
|
|
if (qrEventId === eventId) {
|
|
checkInAttendee(attendeeId);
|
|
} else {
|
|
alert('{{ "qr_different_event"|t }}');
|
|
}
|
|
} else {
|
|
const match = decodedText.match(/\/organizer\/event\/(\d+)\/checkin\/(\d+)/);
|
|
if (match) {
|
|
const qrEventId = parseInt(match[1]);
|
|
const attendeeId = parseInt(match[2]);
|
|
if (qrEventId === eventId) {
|
|
checkInAttendee(attendeeId);
|
|
} else {
|
|
alert('{{ "qr_different_event"|t }}');
|
|
}
|
|
} else {
|
|
console.log('Unknown QR format:', decodedText);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function checkInByCode(attendeeCode) {
|
|
try {
|
|
const response = await fetch(`/organizer/event/${eventId}/checkin/code`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({ attendee_code: attendeeCode })
|
|
});
|
|
|
|
const data = await response.json();
|
|
const resultDiv = document.getElementById('scan-result');
|
|
const icon = document.getElementById('result-icon');
|
|
const title = document.getElementById('result-title');
|
|
const message = document.getElementById('result-message');
|
|
|
|
icon.className = 'result-icon';
|
|
|
|
if (data.success) {
|
|
document.getElementById('qr-reader').style.display = 'none';
|
|
resultDiv.classList.remove('hidden');
|
|
|
|
if (data.already_checked_in) {
|
|
icon.classList.add('duplicate');
|
|
title.textContent = '{{ "already_checked_in"|t }}';
|
|
message.textContent = `${data.attendee_name} {{ "was_already_checked_in"|t }}`;
|
|
} else {
|
|
icon.classList.add('success');
|
|
title.textContent = '{{ "checked_in"|t }}!';
|
|
message.textContent = `{{ "success_checked_in"|t }} ${data.attendee_name}`;
|
|
addRecentCheckin(data.attendee_name);
|
|
updateStats();
|
|
}
|
|
} else {
|
|
icon.classList.add('error');
|
|
title.textContent = '{{ "error"|t }}';
|
|
message.textContent = data.error || '{{ "failed_checkin"|t }}';
|
|
}
|
|
|
|
scanning = false;
|
|
} catch (error) {
|
|
console.error('Check-in error:', error);
|
|
alert('{{ "error_checking_in"|t }}');
|
|
}
|
|
}
|
|
|
|
function startScanner() {
|
|
html5QrCode = new Html5Qrcode("qr-reader");
|
|
|
|
html5QrCode.start(
|
|
{ facingMode: "environment" },
|
|
{
|
|
fps: 10,
|
|
qrbox: { width: 250, height: 250 }
|
|
},
|
|
onScanSuccess,
|
|
(errorMessage) => {
|
|
}
|
|
).catch(err => {
|
|
console.error('Camera error:', err);
|
|
alert('{{ "camera_permission_error"|t }}');
|
|
});
|
|
}
|
|
|
|
function resetScanner() {
|
|
document.getElementById('qr-reader').style.display = 'block';
|
|
document.getElementById('scan-result').classList.add('hidden');
|
|
scanning = true;
|
|
}
|
|
|
|
document.getElementById('scan-again').addEventListener('click', resetScanner);
|
|
|
|
window.addEventListener('DOMContentLoaded', startScanner);
|
|
</script>
|
|
{% endblock %} |