a3546b4c01
- Move all credentials from hardcoded to .env with python-dotenv (add .env.example template and .gitignore) - Add CSRF token generation and validation on all state-changing requests - Replace random.choices with secrets.choice for all secure code generation - Add session cookie security headers (HttpOnly, Secure, SameSite) - Add presenter management: assign/remove presenters to breakout sessions, presenter QR scanning - Add email communications system: templates per event, custom email sending, sent email tracking - Add staff/organizer profile pages with staff code display - New DB tables: event_email_templates, sent_emails, user_communications - New DB columns: staff.reminder_count/reminder_dates, breakout_session_organizers.presenter_code - Expand English translation strings across all new features Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
240 lines
9.3 KiB
HTML
240 lines
9.3 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}{{ 'presenter_scan'|t }} - {{ session_name }} - NetEvents{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="presenter-scan-page">
|
|
<div class="scan-header">
|
|
<div class="session-info-header">
|
|
<h1>{{ session_name }}</h1>
|
|
<p class="session-meta">
|
|
<span><i data-lucide="calendar"></i> {{ start_time|localized_date if start_time else '' }}</span>
|
|
<span><i data-lucide="clock"></i> {{ start_time|localized_date('%H:%M') if start_time else '' }} - {{ end_time|localized_date('%H:%M') if end_time else '' }}</span>
|
|
<span><i data-lucide="map-pin"></i> {{ location or 'TBD' }}</span>
|
|
</p>
|
|
</div>
|
|
<a href="{{ url_for('presenter_dashboard') }}" class="btn btn-outline">
|
|
<i data-lucide="arrow-left"></i> {{ 'back_to_dashboard'|t }}
|
|
</a>
|
|
</div>
|
|
|
|
<div class="presenter-scan-container">
|
|
<div class="scanner-section">
|
|
<div class="scanner-box">
|
|
<div id="qr-reader" class="qr-reader"></div>
|
|
<div id="scan-result" class="scan-result hidden">
|
|
<div class="result-icon" id="result-icon"></div>
|
|
<div class="result-content">
|
|
<h2 id="result-title"></h2>
|
|
<p id="result-message"></p>
|
|
</div>
|
|
<button id="scan-again" class="btn btn-primary">
|
|
<i data-lucide="scan-line"></i> {{ 'scan_again'|t }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="stats-section">
|
|
<div class="stat-card">
|
|
<span class="stat-number" id="total-count">{{ attendees|length }}</span>
|
|
<span class="stat-label">{{ 'total_registered'|t }}</span>
|
|
</div>
|
|
<div class="stat-card stat-card-success">
|
|
<span class="stat-number" id="checked-in-count">{{ checked_in_count }}</span>
|
|
<span class="stat-label">{{ 'checked_in'|t }}</span>
|
|
</div>
|
|
<div class="stat-card stat-card-warning">
|
|
<span class="stat-number" id="remaining-count">{{ attendees|length - checked_in_count }}</span>
|
|
<span class="stat-label">{{ 'remaining'|t }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="attendees-list-section">
|
|
<h3>{{ 'attendees'|t }}</h3>
|
|
<div class="attendees-scroll-list">
|
|
<ul id="attendees-list" class="attendees-checkin-list">
|
|
{% for attendee in attendees %}
|
|
<li class="attendee-item {% if attendee.checked_in %}checked-in{% endif %}" data-id="{{ attendee.id }}" data-code="{{ attendee.attendee_code or '' }}">
|
|
<div class="attendee-info">
|
|
<span class="attendee-avatar">{{ attendee.first_name[0] }}{{ attendee.last_name[0] }}</span>
|
|
<div class="attendee-details">
|
|
<span class="attendee-name">{{ attendee.first_name }} {{ attendee.last_name }}</span>
|
|
<span class="attendee-email">{{ attendee.email }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="attendee-status">
|
|
{% if attendee.checked_in %}
|
|
<span class="status-badge status-checked-in">
|
|
<i data-lucide="check-circle"></i> {{ 'checked_in'|t }}
|
|
</span>
|
|
{% else %}
|
|
<span class="status-badge status-pending">
|
|
<i data-lucide="clock"></i> {{ 'pending'|t }}
|
|
</span>
|
|
{% endif %}
|
|
</div>
|
|
</li>
|
|
{% else %}
|
|
<li class="empty-state-item">
|
|
<p>{{ 'no_attendees_registered'|t }}</p>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
|
|
<script>
|
|
const breakoutSessionId = {{ breakout_session_id }};
|
|
const attendeesData = {{ attendees|tojson }};
|
|
let html5QrCode;
|
|
let scanning = true;
|
|
|
|
function updateStats() {
|
|
const items = document.querySelectorAll('.attendee-item');
|
|
const total = items.length;
|
|
const checkedIn = document.querySelectorAll('.attendee-item.checked-in').length;
|
|
document.getElementById('total-count').textContent = total;
|
|
document.getElementById('checked-in-count').textContent = checkedIn;
|
|
document.getElementById('remaining-count').textContent = total - checkedIn;
|
|
}
|
|
|
|
function markAttendeeCheckedIn(attendeeId) {
|
|
const item = document.querySelector(`.attendee-item[data-id="${attendeeId}"]`);
|
|
if (item) {
|
|
item.classList.add('checked-in');
|
|
const badge = item.querySelector('.status-badge');
|
|
badge.className = 'status-badge status-checked-in';
|
|
badge.innerHTML = '<i data-lucide="check-circle"></i> {{ "checked_in"|t }}';
|
|
lucide.createIcons();
|
|
|
|
// Move to top of list
|
|
const list = document.getElementById('attendees-list');
|
|
list.insertBefore(item, list.firstChild);
|
|
}
|
|
updateStats();
|
|
}
|
|
|
|
async function checkInAttendee(attendeeId) {
|
|
try {
|
|
const response = await fetch(`/presenter/session/${breakoutSessionId}/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';
|
|
icon.innerHTML = '';
|
|
|
|
if (data.success) {
|
|
document.getElementById('qr-reader').style.display = 'none';
|
|
resultDiv.classList.remove('hidden');
|
|
|
|
if (data.already_checked_in) {
|
|
icon.classList.add('warning');
|
|
icon.innerHTML = '<i data-lucide="clock"></i>';
|
|
title.textContent = '{{ "already_checked_in"|t }}';
|
|
message.textContent = `${data.attendee_name} {{ "was_already_checked_in"|t }}`;
|
|
} else {
|
|
icon.classList.add('success');
|
|
icon.innerHTML = '<i data-lucide="check-circle"></i>';
|
|
title.textContent = '{{ "checked_in"|t }}!';
|
|
message.textContent = `{{ "success_checked_in"|t }} ${data.attendee_name}`;
|
|
markAttendeeCheckedIn(attendeeId);
|
|
}
|
|
} else {
|
|
icon.classList.add('error');
|
|
icon.innerHTML = '<i data-lucide="x-circle"></i>';
|
|
title.textContent = '{{ "error"|t }}';
|
|
message.textContent = data.error || '{{ "failed_checkin"|t }}';
|
|
}
|
|
|
|
lucide.createIcons();
|
|
scanning = false;
|
|
} catch (error) {
|
|
console.error('Check-in error:', error);
|
|
showToast('{{ "error_checking_in"|t }}', 'error');
|
|
}
|
|
}
|
|
|
|
function findAttendeeByCode(code) {
|
|
return attendeesData.find(a => a.attendee_code && a.attendee_code.toUpperCase() === code.toUpperCase());
|
|
}
|
|
|
|
function onScanSuccess(decodedText) {
|
|
if (!scanning) return;
|
|
|
|
// Try NETEVENT format: NETEVENT:eventId:attendeeId
|
|
const parts = decodedText.split(':');
|
|
if (parts.length === 3 && parts[0] === 'NETEVENT') {
|
|
const attendeeId = parseInt(parts[2]);
|
|
if (!isNaN(attendeeId)) {
|
|
const attendee = attendeesData.find(a => a.id === attendeeId);
|
|
if (attendee) {
|
|
checkInAttendee(attendeeId);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try plain 10-char attendee code
|
|
const match = decodedText.match(/^[A-Z0-9]{10}$/i);
|
|
if (match) {
|
|
const code = match[0].toUpperCase();
|
|
const attendee = findAttendeeByCode(code);
|
|
if (attendee) {
|
|
checkInAttendee(attendee.id);
|
|
} else {
|
|
showToast('{{ "attendee_not_found"|t }}', 'error');
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Try legacy URL format: /organizer/event/XX/checkin/YY
|
|
const urlMatch = decodedText.match(/\/checkin\/(\d+)/);
|
|
if (urlMatch) {
|
|
const attendeeId = parseInt(urlMatch[1]);
|
|
const attendee = attendeesData.find(a => a.id === attendeeId);
|
|
if (attendee) {
|
|
checkInAttendee(attendeeId);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// QR format not recognized
|
|
}
|
|
|
|
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);
|
|
showToast('{{ "camera_permission_error"|t }}', 'error');
|
|
});
|
|
}
|
|
|
|
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 %} |