Initial commit: conference app with Flask

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 14:53:41 +00:00
commit dec6446d7d
48 changed files with 10644 additions and 0 deletions
+169
View File
@@ -0,0 +1,169 @@
{% extends "base.html" %}
{% block title %}{{ 'attendee_types'|t }} - NetEvents{% endblock %}
{% block content %}
<style>
.page-header {
margin-bottom: 30px;
}
.page-header h1 {
margin-bottom: 10px;
}
.event-card {
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.event-card h2 {
margin-top: 0;
margin-bottom: 15px;
color: #2c3e50;
display: flex;
justify-content: space-between;
align-items: center;
}
.event-card h2 a {
font-size: 16px;
font-weight: normal;
}
.types-table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
.types-table th,
.types-table td {
padding: 10px;
text-align: left;
border-bottom: 1px solid #eee;
}
.types-table th {
background: #f8f9fa;
font-weight: 600;
}
.price {
font-weight: 500;
color: #27ae60;
}
.price.free {
color: #7f8c8d;
}
.no-types {
color: #888;
font-style: italic;
}
.back-link {
margin-bottom: 20px;
display: inline-block;
}
</style>
<div class="all-attendee-types-page">
<a href="{{ url_for('organizer_dashboard') }}" class="back-link">&larr; {{ 'back_to_dashboard'|t }}</a>
<div class="page-header">
<h1>{{ 'attendee_types'|t }}</h1>
<p style="color: #666;">{{ 'manage_attendee_types_across_events'|t }}</p>
</div>
{% if events %}
{% for event in events %}
<div class="event-card">
<h2>
{{ event.name }}
<a href="{{ url_for('event_detail', code=event.code) }}" class="btn btn-sm btn-outline">{{ 'view_event'|t }}</a>
</h2>
{% if event.attendee_types %}
<table class="types-table">
<thead>
<tr>
<th>{{ 'type_name'|t }}</th>
<th>{{ 'price'|t }}</th>
<th>{{ 'registration_link'|t }}</th>
<th>{{ 'attendees'|t }}</th>
<th>{{ 'actions'|t }}</th>
</tr>
</thead>
<tbody>
{% for at in event.attendee_types %}
<tr>
<td><strong>{{ at.name }}</strong></td>
<td>
{% if at.price and at.price > 0 %}
<span class="price">{{ at.price|format_currency }}</span>
{% else %}
<span class="price free">{{ 'free'|t }}</span>
{% endif %}
</td>
<td>
<code style="background: #f0f0f0; padding: 4px 8px; border-radius: 4px; font-size: 12px;">
{{ url_for('register_event', code=event.code, type_code=at.code, _external=True) }}
</code>
<button type="button" class="btn btn-sm" style="padding: 2px 8px; margin-left: 8px;"
onclick="copyLink('{{ url_for('register_event', code=event.code, type_code=at.code, _external=True) }}')">
{{ 'copy'|t }}
</button>
</td>
<td>{{ at.attendee_count }}</td>
<td>
<a href="{{ url_for('manage_attendee_types', event_id=event.id) }}" class="btn btn-sm">{{ 'manage'|t }}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="no-types">{{ 'no_attendee_types_for_event'|t }} <a href="{{ url_for('manage_attendee_types', event_id=event.id) }}">{{ 'create_first_type'|t }}</a></p>
{% endif %}
</div>
{% endfor %}
{% else %}
<p style="color: #888; text-align: center; padding: 40px;">
{{ 'no_events_yet'|t }}
</p>
{% endif %}
</div>
<script>
function copyLink(url) {
// Try modern clipboard API first
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(function() {
alert('{{ 'link_copied'|t }}');
}, function(err) {
// Fallback for HTTP sites
fallbackCopy(url);
});
} else {
// Fallback for older browsers or HTTP sites
fallbackCopy(url);
}
}
function fallbackCopy(url) {
var textArea = document.createElement('textarea');
textArea.value = url;
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
textArea.style.top = '-9999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
var successful = document.execCommand('copy');
if (successful) {
alert('{{ 'link_copied'|t }}');
} else {
alert('{{ 'copy_failed'|t }}');
}
} catch (err) {
alert('{{ 'copy_failed'|t }}');
}
document.body.removeChild(textArea);
}
</script>
{% endblock %}
+182
View File
@@ -0,0 +1,182 @@
{% extends "base.html" %}
{% block title %}{{ 'attendee_types'|t }} - {{ event.name }} - NetEvents{% endblock %}
{% block content %}
<style>
.section-box {
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.section-box h2 {
margin-top: 0;
margin-bottom: 15px;
color: #2c3e50;
}
.form-row {
display: flex;
gap: 15px;
margin-bottom: 15px;
}
.form-group {
flex: 1;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
.types-table {
width: 100%;
border-collapse: collapse;
margin-top: 15px;
}
.types-table th,
.types-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
.types-table th {
background: #f8f9fa;
font-weight: 600;
}
.types-table tr:hover {
background: #f8f9fa;
}
.price {
font-weight: 500;
color: #27ae60;
}
.price.free {
color: #7f8c8d;
}
.back-link {
margin-bottom: 20px;
display: inline-block;
}
</style>
<div class="attendee-types-page">
<a href="{{ url_for('event_detail', code=event.code) }}" class="back-link">&larr; {{ 'back_to_event'|t }}</a>
<h1>{{ 'attendee_types'|t }} - {{ event.name }}</h1>
<div class="section-box">
<h2>{{ 'create_attendee_type'|t }}</h2>
<form method="POST" action="{{ url_for('manage_attendee_types', event_id=event.id) }}">
<div class="form-row">
<div class="form-group">
<label for="name">{{ 'type_name'|t }} *</label>
<input type="text" id="name" name="name" placeholder="{{ 'e_g_vip_speaker_student'|t }}" required>
</div>
<div class="form-group">
<label for="price">{{ 'price'|t }} ({{ 'optional'|t }})</label>
<input type="number" id="price" name="price" step="0.01" min="0" placeholder="0.00">
<small style="color: #666;">{{ 'leave_empty_for_free'|t }}</small>
</div>
</div>
<button type="submit" class="btn btn-primary">{{ 'create_type'|t }}</button>
</form>
</div>
<div class="section-box">
<h2>{{ 'existing_types'|t }}</h2>
{% if attendee_types %}
<table class="types-table">
<thead>
<tr>
<th>{{ 'name'|t }}</th>
<th>{{ 'price'|t }}</th>
<th>{{ 'registration_link'|t }}</th>
<th>{{ 'actions'|t }}</th>
</tr>
</thead>
<tbody>
{% for at in attendee_types %}
<tr>
<td><strong>{{ at.name }}</strong></td>
<td>
{% if at.price and at.price > 0 %}
<span class="price">{{ at.price|format_currency }}</span>
{% else %}
<span class="price free">{{ 'free'|t }}</span>
{% endif %}
</td>
<td>
<code style="background: #f0f0f0; padding: 4px 8px; border-radius: 4px; font-size: 12px;">
{{ url_for('register_event', code=event.code, type_code=at.code, _external=True) }}
</code>
<button type="button" class="btn btn-sm" style="padding: 2px 8px; margin-left: 8px;"
onclick="copyLink('{{ url_for('register_event', code=event.code, type_code=at.code, _external=True) }}')">
{{ 'copy'|t }}
</button>
</td>
<td>
<form method="POST" action="{{ url_for('delete_attendee_type', event_id=event.id, type_id=at.id) }}" style="display: inline;"
onsubmit="return confirm('{{ 'confirm_delete_type'|t }}');">
<button type="submit" class="btn btn-sm btn-danger">{{ 'delete'|t }}</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="no-types" style="color: #888; text-align: center; padding: 20px;">
{{ 'no_attendee_types_yet'|t }}
</p>
{% endif %}
</div>
</div>
<script>
function copyLink(url) {
// Try modern clipboard API first
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(function() {
alert('{{ 'link_copied'|t }}');
}, function(err) {
// Fallback for HTTP sites
fallbackCopy(url);
});
} else {
// Fallback for older browsers or HTTP sites
fallbackCopy(url);
}
}
function fallbackCopy(url) {
var textArea = document.createElement('textarea');
textArea.value = url;
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
textArea.style.top = '-9999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
var successful = document.execCommand('copy');
if (successful) {
alert('{{ 'link_copied'|t }}');
} else {
alert('{{ 'copy_failed'|t }}');
}
} catch (err) {
alert('{{ 'copy_failed'|t }}');
}
document.body.removeChild(textArea);
}
</script>
{% endblock %}
+104
View File
@@ -0,0 +1,104 @@
{% extends "base.html" %}
{% block title %}{{ 'badges'|t }} - {{ event.name }}{% endblock %}
{% block content %}
<div class="badges-page">
<div class="badges-header">
<h1>{{ event.name }}</h1>
<p>{{ 'attendee_badges'|t }}</p>
<div class="header-actions">
<a href="{{ url_for('event_scan', event_id=event.id) }}" class="btn btn-secondary">📷 {{ 'scan_qr_codes'|t }}</a>
<button onclick="window.print()" class="btn btn-primary">{{ 'print_badges'|t }}</button>
</div>
</div>
<div class="badges-grid">
{% for attendee in attendees %}
<div class="badge-card" data-first="{{ attendee.first_name }}" data-last="{{ attendee.last_name }}">
<div class="badge-header">
<h3 class="badge-name">
<span class="first-name">{{ attendee.first_name }}</span><br>
<span class="last-name">{{ attendee.last_name }}</span>
</h3>
</div>
<div class="badge-qr">
<img src="{{ attendee.qr_code }}" alt="QR Code" width="100" height="100">
</div>
<div class="badge-body">
<p class="badge-org">{{ (attendee.organisation)|spacify if attendee.organisation else '' }}</p>
<p class="badge-role">{{ (attendee.role)|spacify if attendee.role else '' }}</p>
{% if attendee.introduction %}
<p class="badge-intro">{{ attendee.introduction[:80] }}{% if attendee.introduction|length > 80 %}...{% endif %}</p>
{% endif %}
</div>
<div class="badge-footer">
<span class="badge-id">#{{ attendee.id }}</span>
<span class="badge-check {% if attendee.checked_in %}checked-in{% endif %}">
{% if attendee.checked_in %}✓ {{ 'checked_in'|t }}{% else %}{{ 'pending'|t }}{% endif %}
</span>
</div>
</div>
{% endfor %}
</div>
<style>
.badge-header h3 { margin: 0; font-size: 1rem; line-height: 1.2; }
.badge-header h3 .first-name,
.badge-header h3 .last-name { display: block; line-height: 1.1; }
.badge-header h3 .last-name { font-size: 0.8rem; }
@media print {
.navbar, .footer, .badges-header .header-actions button, .flash-messages { display: none !important; }
.badges-page { padding: 0; }
.badges-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; }
.badge-card { border: 2px solid #000; padding: 15px; page-break-inside: avoid; }
.badge-header h3 { margin: 0; font-size: 1rem; line-height: 1.2; }
.badge-header h3 .first-name,
.badge-header h3 .last-name { display: block !important; line-height: 1.1; }
.badge-header h3 .last-name { font-size: 0.8rem !important; }
.badge-qr img { width: 80px !important; height: 80px !important; }
.badge-body p { margin: 5px 0; }
.badge-footer { display: flex; justify-content: space-between; margin-top: 10px; padding-top: 10px; border-top: 1px solid #ccc; }
}
</style>
<script>
(function() {
// Create offscreen canvas for text measurement
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// Width limit for last name before it needs font reduction
const LAST_NAME_WIDTH_LIMIT = 120;
function measureText(text, fontSize, fontFamily) {
ctx.font = fontSize + 'px ' + (fontFamily || 'sans-serif');
return ctx.measureText(text).width;
}
function formatBadgeNames() {
const cards = document.querySelectorAll('.badge-card');
cards.forEach(card => {
const lastName = card.dataset.last || '';
const lastSpan = card.querySelector('.last-name');
// Measure last name at reduced size (0.8rem = 12.8px)
const reducedFontSize = 12.8;
const reducedLastWidth = measureText(lastName, reducedFontSize);
// If still too wide, reduce further
if (reducedLastWidth > LAST_NAME_WIDTH_LIMIT) {
lastSpan.style.fontSize = '0.7rem';
}
});
}
// Run after DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', formatBadgeNames);
} else {
formatBadgeNames();
}
})();
</script>
</div>
{% endblock %}
@@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}{{ session.name }} - {{ 'breakout_session'|t }}{% endblock %}
{% block content %}
<div class="breakout-session-detail">
<div class="session-header">
<h1>{{ session.name }}</h1>
<a href="{{ url_for('edit_breakout_session', session_id=session.id) }}" class="btn btn-outline">{{ 'edit_session'|t }}</a>
<a href="{{ url_for('list_breakout_sessions', event_id=session.event_id) }}" class="btn btn-outline">{{ 'back_to_sessions'|t }}</a>
</div>
<div class="session-meta-box">
<p><strong>{{ 'event'|t }}:</strong> {{ session.event_name }}</p>
<p><strong>{{ 'time'|t }}:</strong> {{ session.start_time|localized_date if session.start_time else 'TBD' }} - {{ session.end_time|localized_date('%H:%M') if session.end_time else 'TBD' }}</p>
<p><strong>{{ 'location'|t }}:</strong> {{ session.location }}</p>
<p><strong>{{ 'capacity'|t }}:</strong>
{% if session.max_attendees %}
{{ rsvps|length }} / {{ session.max_attendees }}
{% else %}
{{ rsvps|length }} ({{ 'unlimited'|t }})
{% endif %}
</p>
{% if session.description %}
<p><strong>{{ 'description'|t }}:</strong> {{ session.description }}</p>
{% endif %}
</div>
<section class="attendees-section">
<h2>{{ 'registered_attendees'|t }}</h2>
{% if rsvps %}
<table class="attendees-table">
<thead>
<tr>
<th>{{ 'name'|t }}</th>
<th>{{ 'email'|t }}</th>
<th>{{ 'organisation'|t }}</th>
<th>{{ 'role'|t }}</th>
</tr>
</thead>
<tbody>
{% for rsvp in rsvps %}
<tr>
<td>{{ rsvp.first_name }} {{ rsvp.last_name }}</td>
<td>{{ rsvp.email }}</td>
<td>{{ (rsvp.organisation)|spacify if rsvp.organisation else '-' }}</td>
<td>{{ (rsvp.role)|spacify if rsvp.role else '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="no-attendees">{{ 'no_attendees_yet'|t }}</p>
{% endif %}
</section>
</div>
{% endblock %}
@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% block title %}{{ 'breakout_sessions'|t }} - {{ event.name }}{% endblock %}
{% block content %}
<div class="breakout-sessions">
<div class="page-header">
<h1>{{ 'breakout_sessions'|t }} - {{ event.name }}</h1>
<a href="{{ url_for('create_breakout_session', event_id=event.id) }}" class="btn btn-primary">{{ 'create_breakout_session'|t }}</a>
</div>
{% if sessions %}
<div class="sessions-list">
{% for session in sessions %}
<div class="session-card">
<div class="session-info">
<h3>{{ session.name }}</h3>
<p><strong>{{ 'time'|t }}:</strong> {{ session.start_time|localized_date('%H:%M') if session.start_time else '' }} - {{ session.end_time|localized_date('%H:%M') if session.end_time else '' }}</p>
<p><strong>{{ 'location'|t }}:</strong> {{ session.location }}</p>
{% if session.max_attendees %}
<p><strong>{{ 'capacity'|t }}:</strong> {{ session.rsvp_count }} / {{ session.max_attendees }}</p>
{% else %}
<p><strong>{{ 'registered'|t }}:</strong> {{ session.rsvp_count }}</p>
{% endif %}
{% if session.description %}
<p>{{ session.description }}</p>
{% endif %}
</div>
<div class="session-actions">
<a href="{{ url_for('view_breakout_session', code=session.code) }}" class="btn btn-secondary">{{ 'view_attendees'|t }}</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="no-sessions">{{ 'no_breakout_sessions_yet'|t }}</p>
{% endif %}
<div class="back-link">
<a href="{{ url_for('event_detail', code=event.code) }}" class="btn btn-outline">{{ 'back_to_event'|t }}</a>
</div>
</div>
{% endblock %}
@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %}{{ 'create_breakout_session'|t }} - NetEvents{% endblock %}
{% block content %}
<div class="form-container">
<h1>{{ 'create_breakout_session'|t }}</h1>
<p class="event-info">{{ 'event'|t }}: {{ event.name }}</p>
<form method="POST" action="{{ url_for('create_breakout_session', event_id=event.id) }}" class="session-form">
<div class="form-group">
<label for="name">{{ 'session_name'|t }}</label>
<input type="text" id="name" name="name" required>
</div>
<div class="form-group">
<label for="description">{{ 'description'|t }}</label>
<textarea id="description" name="description" rows="3"></textarea>
</div>
<div class="form-group">
<label for="start_time">{{ 'start_time'|t }}</label>
<input type="datetime-local" id="start_time" name="start_time" required>
</div>
<div class="form-group">
<label for="end_time">{{ 'end_time'|t }}</label>
<input type="datetime-local" id="end_time" name="end_time" required>
</div>
<div class="form-group">
<label for="location">{{ 'location'|t }}</label>
<input type="text" id="location" name="location" required>
</div>
<div class="form-group">
<label for="max_attendees">{{ 'max_attendees_event_hint'|t }} {{ event.max_attendees or 'unlimited' }})</label>
<input type="number" id="max_attendees" name="max_attendees" min="1"{% if event.max_attendees %} max="{{ event.max_attendees }}"{% endif %}>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'create_session'|t }}</button>
<a href="{{ url_for('list_breakout_sessions', event_id=event.id) }}" class="btn btn-outline">{{ 'cancel'|t }}</a>
</div>
</form>
</div>
{% endblock %}
+46
View File
@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}{{ 'create_event'|t }} - NetEvents{% endblock %}
{% block content %}
<div class="form-container">
<h1>{{ 'create_new_event'|t }}</h1>
<form method="POST" action="{{ url_for('create_event') }}" class="event-form">
<div class="form-group">
<label for="name">{{ 'event_name'|t }}</label>
<input type="text" id="name" name="name" required>
</div>
<div class="form-group">
<label for="description">{{ 'description'|t }}</label>
<textarea id="description" name="description" rows="4"></textarea>
</div>
<div class="form-group">
<label for="start_time">{{ 'start_date_time'|t }}</label>
<input type="datetime-local" id="start_time" name="start_time" required>
</div>
<div class="form-group">
<label for="end_time">{{ 'end_date_time_optional'|t }}</label>
<input type="datetime-local" id="end_time" name="end_time">
</div>
<div class="form-group">
<label for="location">{{ 'location'|t }}</label>
<input type="text" id="location" name="location" required>
</div>
<div class="form-group">
<label for="max_attendees">{{ 'max_attendees_hint'|t }}</label>
<input type="number" id="max_attendees" name="max_attendees" min="1">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'create_event'|t }}</button>
<a href="{{ url_for('organizer_dashboard') }}" class="btn btn-outline">{{ 'cancel'|t }}</a>
</div>
</form>
</div>
{% endblock %}
+307
View File
@@ -0,0 +1,307 @@
{% extends "base.html" %}
{% block title %}{{ 'dashboard'|t }} - {{ 'organizer'|t }} - NetEvents{% endblock %}
{% block content %}
<style>
.progress-bar-container {
background: #e9ecef;
border-radius: 4px;
height: 20px;
width: 100%;
overflow: hidden;
margin-top: 5px;
}
.progress-bar-fill {
height: 100%;
background: #28a745;
transition: width 0.3s ease;
}
.progress-bar-fill.warning {
background: #ffc107;
}
.progress-bar-fill.full {
background: #dc3545;
}
.event-capacity {
font-size: 14px;
color: #666;
margin-top: 3px;
}
.breakout-sessions-list {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #e9ecef;
width: 100%;
}
.breakout-session-item {
background: #f8f9fa;
border-radius: 6px;
padding: 10px 12px;
margin-bottom: 8px;
}
.breakout-session-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
}
.breakout-session-name {
font-weight: 500;
color: #495057;
}
.breakout-session-capacity {
font-size: 13px;
color: #666;
}
.event-item-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
width: 100%;
}
.event-item {
display: flex;
flex-direction: column;
width: 100%;
}
.event-actions-inline {
display: flex;
gap: 8px;
}
.event-actions-inline .btn {
padding: 4px 10px;
font-size: 12px;
}
.section-toggle {
cursor: pointer;
padding: 8px 12px;
background: #e9ecef;
border-radius: 4px;
margin-bottom: 5px;
display: flex;
justify-content: space-between;
align-items: center;
}
.section-toggle:hover {
background: #dee2e6;
}
.section-toggle::after {
content: '▼';
font-size: 10px;
}
.section-toggle.collapsed::after {
content: '▶';
}
.section-content {
padding: 10px 0;
width: 100%;
box-sizing: border-box;
}
.section-content.collapsed {
display: none;
}
.item-list {
display: flex;
flex-direction: column;
gap: 5px;
width: 100%;
}
.list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: #f8f9fa;
border-radius: 4px;
width: 100%;
box-sizing: border-box;
}
.list-item-info {
flex: 1;
}
.list-item-actions {
display: flex;
gap: 5px;
}
/* Wide screen - spread content */
@media (min-width: 1400px) {
.dashboard {
max-width: 100%;
}
.events-list {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
.event-item {
padding: 20px 25px;
}
.event-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.breakout-sessions-list {
grid-column: 1 / -1;
}
}
@media (min-width: 1800px) {
.events-list {
grid-template-columns: repeat(4, 1fr);
}
}
</style>
<script>
function toggleSection(id) {
const content = document.getElementById(id);
const toggle = content.previousElementSibling;
content.classList.toggle('collapsed');
toggle.classList.toggle('collapsed');
}
</script>
<div class="dashboard">
<h1>{{ 'dashboard'|t }} - {{ 'organizer'|t }}</h1>
<div class="dashboard-actions">
<a href="{{ url_for('create_event') }}" class="btn btn-primary">{{ 'create_event'|t }}</a>
</div>
<section class="my-events">
<h2>{{ 'my_events'|t }}</h2>
{% if events %}
<div class="events-list">
{% for event in events %}
<div class="event-item">
<div class="event-item-header">
<h3>{{ event.name }}</h3>
<div class="event-actions-inline">
<a href="{{ url_for('event_detail', code=event.code) }}" class="btn btn-sm btn-outline">{{ 'details'|t }}</a>
</div>
</div>
<div class="event-info">
<p class="event-date">{{ event.start_time|localized_date if event.start_time else 'TBD' }}</p>
<p class="event-location">{{ event.location }}</p>
{% if event.max_attendees %}
{% set percent = (event.attendee_count / event.max_attendees * 100)|round|int %}
<div class="event-capacity">
<span>{{ event.attendee_count }} / {{ event.max_attendees }} {{ 'attendees'|t }}</span>
<span>({{ percent }}%)</span>
</div>
<div class="progress-bar-container">
<div class="progress-bar-fill {% if percent >= 100 %}full{% elif percent >= 80 %}warning{% endif %}"
style="width: {{ percent if percent <= 100 else 100 }}%"></div>
</div>
{% else %}
<div class="event-capacity">
<span>{{ event.attendee_count }} {{ 'attendees'|t }} ({{ 'unlimited'|t }})</span>
</div>
{% endif %}
</div>
{% if event.staff %}
<div class="breakout-sessions-list">
<div class="section-toggle" onclick="toggleSection('staff-{{ event.id }}')">
<strong>Staff ({{ event.staff|length }})</strong>
</div>
<div id="staff-{{ event.id }}" class="section-content collapsed">
<div class="item-list">
{% for s in event.staff %}
<div class="list-item">
<div class="list-item-info">
<strong>{{ s.first_name }} {{ s.last_name }}</strong>
<span style="color: #666; font-size: 12px;">{{ s.email }}</span>
</div>
<div class="list-item-actions">
<a href="{{ url_for('edit_staff', event_id=event.id, staff_id=s.id) }}" class="btn btn-sm btn-outline">Edit</a>
<form method="POST" action="{{ url_for('delete_staff', event_id=event.id, staff_id=s.id) }}" style="display: inline;" onsubmit="return confirm('Remove {{ s.first_name }} {{ s.last_name }}?');">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% if event.breakout_sessions %}
<div class="breakout-sessions-list">
<div class="section-toggle" onclick="toggleSection('sessions-{{ event.id }}')">
<strong>{{ 'breakout_sessions'|t }} ({{ event.breakout_sessions|length }})</strong>
</div>
<div id="sessions-{{ event.id }}" class="section-content collapsed">
<div class="item-list">
{% for session in event.breakout_sessions %}
<div class="list-item">
<div class="list-item-info">
<strong>{{ session.name }}</strong>
{% if session.max_attendees %}
<span style="color: #666; font-size: 12px;">{{ session.rsvp_count }} / {{ session.max_attendees }}</span>
{% else %}
<span style="color: #666; font-size: 12px;">{{ session.rsvp_count }} registered</span>
{% endif %}
</div>
<div class="list-item-actions">
<a href="{{ url_for('edit_breakout_session', session_id=session.id) }}" class="btn btn-sm btn-outline">Edit</a>
<form method="POST" action="{{ url_for('delete_breakout_session', session_id=session.id) }}" style="display: inline;" onsubmit="return confirm('Delete {{ session.name }}?');">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{% if event.attendees %}
<div class="breakout-sessions-list">
<div class="section-toggle" onclick="toggleSection('attendees-{{ event.id }}')">
<strong>{{ 'attendees'|t }} ({{ event.attendees|length }})</strong>
</div>
<div id="attendees-{{ event.id }}" class="section-content collapsed">
<div class="item-list">
{% for att in event.attendees %}
<div class="list-item">
<div class="list-item-info">
<strong>{{ att.first_name }} {{ att.last_name }}</strong>
<span style="color: #666; font-size: 12px;">{{ att.email }}</span>
{% if att.checked_in %}
<span class="badge badge-success" style="font-size: 10px;">Checked In</span>
{% endif %}
</div>
<div class="list-item-actions">
<a href="{{ url_for('edit_attendee', attendee_id=att.id) }}" class="btn btn-sm btn-outline">Edit</a>
<form method="POST" action="{{ url_for('delete_attendee', attendee_id=att.id) }}" style="display: inline;" onsubmit="return confirm('Remove {{ att.first_name }} {{ att.last_name }}?');">
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<p class="no-events">{{ 'no_events_yet'|t }}
<a href="{{ url_for('create_event') }}">{{ 'create_first_event'|t }}</a>
</p>
{% endif %}
</section>
</div>
{% endblock %}
+46
View File
@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}{{ 'edit_attendee'|t }} - NetEvents{% endblock %}
{% block content %}
<div class="form-container">
<h1>{{ 'edit_attendee'|t }}</h1>
<form method="POST" action="{{ url_for('edit_attendee', attendee_id=attendee.id) }}" class="attendee-form">
<div class="form-group">
<label for="first_name">{{ 'first_name'|t }}</label>
<input type="text" id="first_name" name="first_name" value="{{ attendee.first_name }}" required>
</div>
<div class="form-group">
<label for="last_name">{{ 'last_name'|t }}</label>
<input type="text" id="last_name" name="last_name" value="{{ attendee.last_name }}" required>
</div>
<div class="form-group">
<label for="email">{{ 'email'|t }}</label>
<input type="email" id="email" name="email" value="{{ attendee.email }}" required>
</div>
<div class="form-group">
<label for="organisation">{{ 'organisation'|t }}</label>
<input type="text" id="organisation" name="organisation" value="{{ attendee.organisation or '' }}">
</div>
<div class="form-group">
<label for="role">{{ 'role_profession'|t }}</label>
<input type="text" id="role" name="role" value="{{ attendee.role or '' }}">
</div>
<div class="form-group">
<label for="introduction">{{ 'short_introduction'|t }}</label>
<textarea id="introduction" name="introduction" rows="4">{{ attendee.introduction or '' }}</textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'save_changes'|t }}</button>
<a href="{{ url_for('organizer_dashboard') }}" class="btn btn-outline">{{ 'cancel'|t }}</a>
</div>
</form>
</div>
{% endblock %}
@@ -0,0 +1,47 @@
{% extends "base.html" %}
{% block title %}{{ 'edit_breakout_session'|t }} - NetEvents{% endblock %}
{% block content %}
<div class="form-container">
<h1>{{ 'edit_breakout_session'|t }}</h1>
<p class="event-info">{{ 'event'|t }}: {{ event.name }}</p>
<form method="POST" action="{{ url_for('edit_breakout_session', session_id=session.id) }}" class="session-form">
<div class="form-group">
<label for="name">{{ 'session_name'|t }}</label>
<input type="text" id="name" name="name" value="{{ session.name }}" required>
</div>
<div class="form-group">
<label for="description">{{ 'description'|t }}</label>
<textarea id="description" name="description" rows="3">{{ session.description or '' }}</textarea>
</div>
<div class="form-group">
<label for="start_time">{{ 'start_time'|t }}</label>
<input type="datetime-local" id="start_time" name="start_time" value="{{ session.start_time.strftime('%Y-%m-%dT%H:%M') }}" required>
</div>
<div class="form-group">
<label for="end_time">{{ 'end_time'|t }}</label>
<input type="datetime-local" id="end_time" name="end_time" value="{{ session.end_time.strftime('%Y-%m-%dT%H:%M') }}" required>
</div>
<div class="form-group">
<label for="location">{{ 'location'|t }}</label>
<input type="text" id="location" name="location" value="{{ session.location }}" required>
</div>
<div class="form-group">
<label for="max_attendees">{{ 'max_attendees_event_hint'|t }} {{ event.max_attendees or 'unlimited' }})</label>
<input type="number" id="max_attendees" name="max_attendees" min="1"{% if event.max_attendees %} max="{{ event.max_attendees }}"{% endif %} value="{{ session.max_attendees or '' }}">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'save_changes'|t }}</button>
<a href="{{ url_for('view_breakout_session', code=session.code) }}" class="btn btn-outline">{{ 'cancel'|t }}</a>
</div>
</form>
</div>
{% endblock %}
@@ -0,0 +1,126 @@
{% extends "base.html" %}
{% block title %}{{ 'confirm_changes'|t }} - {{ session.name }}{% endblock %}
{% block content %}
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: #fff;
border-radius: 12px;
padding: 30px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
.modal-header {
margin-bottom: 20px;
}
.modal-header h2 {
margin: 0 0 10px 0;
color: #2c3e50;
}
.modal-header p {
margin: 0;
color: #666;
}
.changes-list {
background: #f8f9fa;
border-radius: 8px;
padding: 15px;
margin-bottom: 20px;
}
.change-item {
padding: 10px 0;
border-bottom: 1px solid #e9ecef;
}
.change-item:last-child {
border-bottom: none;
}
.change-label {
font-weight: 600;
color: #495057;
margin-bottom: 5px;
}
.change-values {
display: flex;
gap: 10px;
align-items: center;
}
.old-value {
color: #dc3545;
text-decoration: line-through;
opacity: 0.7;
}
.arrow {
color: #666;
}
.new-value {
color: #28a745;
font-weight: 500;
}
.modal-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
</style>
<div class="modal-overlay">
<div class="modal-content">
<div class="modal-header">
<h2>{{ 'session_updated'|t }}</h2>
<p>{{ 'changes_made_to'|t }} "{{ session.name }}":</p>
</div>
<div class="changes-list">
{% for field, old_val, new_val in changes %}
<div class="change-item">
<div class="change-label">{{ field }}</div>
<div class="change-values">
<span class="old-value">{{ old_val or '(' + 'empty' + ')' }}</span>
<span class="arrow"></span>
<span class="new-value">{{ new_val or '(' + 'empty' + ')' }}</span>
</div>
</div>
{% endfor %}
</div>
<form method="POST" action="{{ url_for('notify_breakout_session_attendees', session_id=session.id) }}" id="notify-form">
<input type="hidden" name="changes_text" id="changes-text">
</form>
<div class="modal-actions">
<button type="button" class="btn btn-outline" onclick="skipNotify()">{{ 'skip'|t }}</button>
<button type="button" class="btn btn-primary" onclick="notifyAttendees()">{{ 'notify_attendees_email'|t }}</button>
</div>
</div>
</div>
<script>
function notifyAttendees() {
const changesText = `{% for field, old_val, new_val in changes %}{{ field }}: {{ old_val }} → {{ new_val }}
{% endfor %}`.trim();
document.getElementById('changes-text').value = changesText;
document.getElementById('notify-form').submit();
}
function skipNotify() {
window.location.href = "{{ url_for('view_breakout_session', code=session.code) }}";
}
</script>
{% endblock %}
+36
View File
@@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block title %}{{ 'edit_event'|t }} - NetEvents{% endblock %}
{% block content %}
<div class="form-container">
<h1>{{ 'edit_event'|t }}</h1>
<form method="POST" action="{{ url_for('edit_event', event_id=event.id) }}" class="event-form">
<div class="form-group">
<label for="name">{{ 'event_name'|t }}</label>
<input type="text" id="name" name="name" value="{{ event.name }}" required>
</div>
<div class="form-group">
<label for="description">{{ 'description'|t }}</label>
<textarea id="description" name="description" rows="4">{{ event.description or '' }}</textarea>
</div>
<div class="form-group">
<label for="location">{{ 'location'|t }}</label>
<input type="text" id="location" name="location" value="{{ event.location }}" required>
</div>
<div class="form-group">
<label for="max_attendees">{{ 'max_attendees_hint'|t }}</label>
<input type="number" id="max_attendees" name="max_attendees" min="1" value="{{ event.max_attendees or '' }}">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'save_changes'|t }}</button>
<a href="{{ url_for('event_detail', code=event.code) }}" class="btn btn-outline">{{ 'cancel'|t }}</a>
</div>
</form>
</div>
{% endblock %}
+126
View File
@@ -0,0 +1,126 @@
{% extends "base.html" %}
{% block title %}{{ 'confirm_changes'|t }} - {{ event.name }}{% endblock %}
{% block content %}
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: #fff;
border-radius: 12px;
padding: 30px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
.modal-header {
margin-bottom: 20px;
}
.modal-header h2 {
margin: 0 0 10px 0;
color: #2c3e50;
}
.modal-header p {
margin: 0;
color: #666;
}
.changes-list {
background: #f8f9fa;
border-radius: 8px;
padding: 15px;
margin-bottom: 20px;
}
.change-item {
padding: 10px 0;
border-bottom: 1px solid #e9ecef;
}
.change-item:last-child {
border-bottom: none;
}
.change-label {
font-weight: 600;
color: #495057;
margin-bottom: 5px;
}
.change-values {
display: flex;
gap: 10px;
align-items: center;
}
.old-value {
color: #dc3545;
text-decoration: line-through;
opacity: 0.7;
}
.arrow {
color: #666;
}
.new-value {
color: #28a745;
font-weight: 500;
}
.modal-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
</style>
<div class="modal-overlay">
<div class="modal-content">
<div class="modal-header">
<h2>{{ 'event_updated'|t }}</h2>
<p>{{ 'changes_made_to'|t }} "{{ event.name }}":</p>
</div>
<div class="changes-list">
{% for field, old_val, new_val in changes %}
<div class="change-item">
<div class="change-label">{{ field }}</div>
<div class="change-values">
<span class="old-value">{{ old_val or '(' + 'empty' + ')' }}</span>
<span class="arrow"></span>
<span class="new-value">{{ new_val or '(' + 'empty' + ')' }}</span>
</div>
</div>
{% endfor %}
</div>
<form method="POST" action="{{ url_for('notify_event_attendees', event_id=event.id) }}" id="notify-form">
<input type="hidden" name="changes_text" id="changes-text">
</form>
<div class="modal-actions">
<button type="button" class="btn btn-outline" onclick="skipNotify()">{{ 'skip'|t }}</button>
<button type="button" class="btn btn-primary" onclick="notifyAttendees()">{{ 'notify_attendees_email'|t }}</button>
</div>
</div>
</div>
<script>
function notifyAttendees() {
const changesText = `{% for field, old_val, new_val in changes %}{{ field }}: {{ old_val }} → {{ new_val }}
{% endfor %}`.trim();
document.getElementById('changes-text').value = changesText;
document.getElementById('notify-form').submit();
}
function skipNotify() {
window.location.href = "{{ url_for('event_detail', code=event.code) }}";
}
</script>
{% endblock %}
+31
View File
@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}{{ 'edit_staff'|t }} - {{ event.name }}{% endblock %}
{% block content %}
<div class="form-container">
<h1>{{ 'edit_staff_member'|t }}</h1>
<form method="POST" action="{{ url_for('edit_staff', event_id=event.id, staff_id=staff.id) }}" class="staff-form">
<div class="form-group">
<label for="first_name">{{ 'first_name'|t }}</label>
<input type="text" id="first_name" name="first_name" value="{{ staff.first_name }}" required>
</div>
<div class="form-group">
<label for="last_name">{{ 'last_name'|t }}</label>
<input type="text" id="last_name" name="last_name" value="{{ staff.last_name }}" required>
</div>
<div class="form-group">
<label for="email">{{ 'email'|t }}</label>
<input type="email" id="email" name="email" value="{{ staff.email }}" required>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'save_changes'|t }}</button>
<a href="{{ url_for('manage_event_staff', event_id=event.id) }}" class="btn btn-outline">{{ 'cancel'|t }}</a>
</div>
</form>
</div>
{% endblock %}
+647
View File
@@ -0,0 +1,647 @@
{% extends "base.html" %}
{% block title %}{{ event.name }} - NetEvents{% endblock %}
{% block content %}
<style>
.section-box {
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.section-box h2 {
margin-top: 0;
margin-bottom: 15px;
color: #2c3e50;
}
.section-actions {
margin-bottom: 15px;
}
.staff-table,
.sessions-table,
.attendees-table {
width: 100%;
}
.form-row {
display: flex;
gap: 15px;
margin-bottom: 15px;
}
.form-group {
flex: 1;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
th.sortable {
cursor: pointer;
user-select: none;
}
th.sortable:hover {
background: #f0f0f0;
}
.sort-icon::before {
content: '\2195';
margin-left: 5px;
opacity: 0.4;
}
th.sort-asc .sort-icon::before {
content: '\2191';
opacity: 1;
}
th.sort-desc .sort-icon::before {
content: '\2193';
opacity: 1;
}
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal-content {
background: #fff;
border-radius: 8px;
padding: 24px;
max-width: 400px;
width: 90%;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.modal-header h2 {
margin-top: 0;
color: #dc3545;
}
.modal-actions {
margin-top: 20px;
display: flex;
gap: 10px;
justify-content: flex-end;
}
/* Wide screen - spread content */
@media (min-width: 1400px) {
.event-detail {
max-width: 100%;
}
.event-detail .section-box {
padding: 30px 40px;
}
.event-meta-box {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 15px;
}
.staff-section .section-box,
.breakout-sessions-section .section-box,
.attendees-section .section-box {
padding: 25px 30px;
}
}
/* Responsive styles */
@media (max-width: 768px) {
.event-header {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
.event-meta-box p {
margin: 8px 0;
}
.form-row {
flex-direction: column;
gap: 10px;
}
.staff-table,
.sessions-table,
.attendees-table {
display: block;
overflow-x: auto;
white-space: nowrap;
width: 100%;
}
.section-box {
padding: 15px;
}
.modal-content {
padding: 16px;
margin: 10px;
}
.btn {
padding: 8px 12px;
font-size: 14px;
}
}
</style>
<div class="event-detail">
<div class="event-header">
<h1>{{ event.name }}</h1>
<div class="event-actions">
<a href="{{ url_for('edit_event', event_id=event.id) }}" class="btn btn-outline">{{ 'edit_event'|t }}</a>
</div>
</div>
{% if event.description %}
<div class="event-description-box">
<h3>{{ 'description'|t }}</h3>
<p>{{ event.description }}</p>
</div>
{% endif %}
<div class="event-meta-box">
<p><strong>{{ 'start'|t }}:</strong> {{ event.start_time|localized_date if event.start_time else 'TBD' }}</p>
{% if event.end_time %}
<p><strong>{{ 'end'|t }}:</strong> {{ event.end_time|localized_date }}</p>
{% endif %}
<p><strong>{{ 'location'|t }}:</strong> {{ event.location }}</p>
<p><strong>{{ 'max_attendees'|t }}:</strong> {{ event.max_attendees or ('unlimited'|t) }}</p>
<p><strong>{{ 'registered_attendees'|t }}:</strong> <span id="attendee-count">{{ attendees|length }}</span></p>
<p><strong>{{ 'registration_link'|t }}:</strong> <span id="reg-link">{{ url_for('register_event', code=event.code, _external=True) }}</span> <button type="button" style="padding: 2px 6px; font-size: 11px;" onclick="navigator.clipboard.writeText('{{ url_for('register_event', code=event.code, _external=True) }}').then(() => this.textContent = 'Copied!')">Copy</button></p>
</div>
<section class="attendee-types-section">
<div class="section-box">
<h2>{{ 'attendee_types'|t }}</h2>
<div class="section-actions">
<a href="{{ url_for('manage_attendee_types', event_id=event.id) }}" class="btn btn-primary">{{ 'manage_types'|t }}</a>
</div>
{% if attendee_types %}
<table class="types-table">
<thead>
<tr>
<th>{{ 'type_name'|t }}</th>
<th>{{ 'price'|t }}</th>
<th>{{ 'registration_link'|t }}</th>
</tr>
</thead>
<tbody>
{% for at in attendee_types %}
<tr>
<td><strong>{{ at.name }}</strong></td>
<td>
{% if at.price and at.price > 0 %}
<span style="color: #27ae60; font-weight: 500;">{{ at.price|format_currency }}</span>
{% else %}
<span style="color: #7f8c8d;">{{ 'free'|t }}</span>
{% endif %}
</td>
<td>
<code style="background: #f0f0f0; padding: 4px 8px; border-radius: 4px; font-size: 12px;">
{{ url_for('register_event', code=event.code, type_code=at.code, _external=True) }}
</code>
<button type="button" class="btn btn-sm" style="padding: 2px 8px; margin-left: 8px;"
onclick="copyLink('{{ url_for('register_event', code=event.code, type_code=at.code, _external=True) }}')">
{{ 'copy'|t }}
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="no-types" style="color: #888;">{{ 'no_attendee_types_defined'|t }} <a href="{{ url_for('manage_attendee_types', event_id=event.id) }}">{{ 'create_first_type'|t }}</a></p>
{% endif %}
</div>
</section>
<section class="staff-section">
<div class="section-box">
<h2>{{ 'staff'|t }}</h2>
<div class="section-actions">
<button type="button" class="btn btn-primary" id="add-staff-btn">{{ 'add_staff'|t }}</button>
</div>
<div id="add-staff-form" style="display: none; margin-bottom: 20px; padding: 15px; background: #f8f9fa; border-radius: 5px;">
<form method="POST" action="{{ url_for('manage_event_staff', event_id=event.id) }}" id="staff-inline-form">
<div class="form-row">
<div class="form-group">
<label for="staff_first_name">{{ 'first_name'|t }}</label>
<input type="text" id="staff_first_name" name="first_name" required>
</div>
<div class="form-group">
<label for="staff_last_name">{{ 'last_name'|t }}</label>
<input type="text" id="staff_last_name" name="last_name" required>
</div>
<div class="form-group">
<label for="staff_email">{{ 'email'|t }}</label>
<input type="email" id="staff_email" name="email" required>
</div>
</div>
<button type="submit" class="btn btn-primary">{{ 'add_staff_member'|t }}</button>
<button type="button" class="btn btn-outline" id="cancel-staff-btn">{{ 'cancel'|t }}</button>
</form>
{% if other_events %}
<form method="POST" action="{{ url_for('batch_add_staff', event_id=event.id) }}" style="margin-top: 15px; padding-top: 15px; border-top: 1px solid #ddd;">
<div class="form-row" style="align-items: flex-end;">
<div class="form-group" style="flex: 1;">
<label for="batch_source_event">Or add staff from another event:</label>
<select name="source_event_id" id="batch_source_event" required>
<option value="">Select event...</option>
{% for other_event in other_events %}
<option value="{{ other_event.id }}">{{ other_event.name }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-secondary">Batch Add</button>
</div>
</form>
{% endif %}
</div>
{% if staff %}
<table class="staff-table" id="event-staff-table">
<thead>
<tr>
<th data-sort="name" class="sortable">{{ 'name'|t }} <span class="sort-icon"></span></th>
<th data-sort="email" class="sortable">{{ 'email'|t }} <span class="sort-icon"></span></th>
<th data-sort="status" class="sortable">{{ 'status'|t }} <span class="sort-icon"></span></th>
<th>{{ 'actions'|t }}</th>
</tr>
</thead>
<tbody id="event-staff-tbody">
{% for member in staff %}
<tr data-name="{{ member.first_name }} {{ member.last_name }}" data-email="{{ member.email }}" data-status="{{ member.invite_token }}">
<td>{{ member.first_name }} {{ member.last_name }}</td>
<td>{{ member.email }}</td>
<td>
{% if member.invite_token %}
<span class="badge badge-pending">{{ 'invite_pending'|t }}</span>
{% else %}
<span class="badge badge-success">{{ 'active'|t }}</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('edit_staff', event_id=event.id, staff_id=member.id) }}" class="btn btn-sm btn-success">{{ 'view'|t }}</a>
<button type="button" class="btn btn-sm btn-danger" onclick="showDeleteModal({{ member.id }}, '{{ member.first_name }} {{ member.last_name }}', '{{ member.email }}')">{{ 'remove'|t }}</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="no-staff">{{ 'no_staff_yet'|t }}</p>
{% endif %}
</div>
</section>
<div id="staffDeleteModal" class="modal-overlay" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h2>{{ 'remove_staff_member'|t }}</h2>
<p>{{ 'confirm_remove_staff'|t }} <strong id="staffName"></strong> {{ 'from_event'|t }}?</p>
<p style="color: #666; font-size: 14px;">{{ 'email'|t }}: <span id="staffEmail"></span></p>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-outline" onclick="closeStaffDeleteModal()">{{ 'cancel'|t }}</button>
<form id="staffDeleteForm" method="POST" style="display: inline;">
<button type="submit" class="btn btn-danger">{{ 'remove'|t }}</button>
</form>
</div>
</div>
</div>
<section class="breakout-sessions-section">
<div class="section-box">
<h2>{{ 'breakout_sessions'|t }}</h2>
<div class="section-actions">
<a href="{{ url_for('create_breakout_session', event_id=event.id) }}" class="btn btn-primary">{{ 'add_breakout_session'|t }}</a>
</div>
{% if breakout_sessions %}
<table class="sessions-table">
<thead>
<tr>
<th>{{ 'name'|t }}</th>
<th>{{ 'time'|t }}</th>
<th>{{ 'location'|t }}</th>
<th>{{ 'max_attendees'|t }}</th>
<th>{{ 'registered'|t }}</th>
<th>{{ 'actions'|t }}</th>
</tr>
</thead>
<tbody>
{% for session in breakout_sessions %}
<tr>
<td>{{ session.name }}</td>
<td>{{ session.start_time|localized_date }}</td>
<td>{{ session.location or '-' }}</td>
<td>{{ session.max_attendees or ('unlimited'|t) }}</td>
<td>{{ session.registered_count|default(0) }}</td>
<td>
<a href="{{ url_for('view_breakout_session', code=session.code) }}" class="btn btn-sm btn-success">{{ 'view'|t }}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="no-sessions">{{ 'no_breakout_sessions_yet'|t }}</p>
{% endif %}
</div>
</section>
<section class="attendees-section">
<div class="section-box">
<h2>{{ 'registered_attendees'|t }}</h2>
<div class="section-actions">
<a href="{{ url_for('register_attendee', code=event.code) }}" class="btn btn-primary">{{ 'add_attendee'|t }}</a>
</div>
{% if attendees %}
<form method="POST" action="{{ url_for('batch_assign_attendee_type', event_id=event.id) }}" id="batch-assign-form">
<div style="margin-bottom: 15px; padding: 10px; background: #f8f9fa; border-radius: 5px; display: flex; gap: 10px; align-items: center;">
<input type="checkbox" id="select-all-attendees" style="width: auto;">
<label for="select-all-attendees" style="margin: 0; font-weight: 500;">{{ 'select_all'|t }}</label>
<span style="margin-left: 20px;">{{ 'assign_type'|t }}:</span>
<select name="attendee_type_id" id="batch-type-select" style="padding: 5px 10px;">
<option value="">{{ 'no_type'|t }}</option>
{% for at in attendee_types %}
<option value="{{ at.id }}">{{ at.name }} {% if at.price and at.price > 0 %}({{ at.price|format_currency }}){% endif %}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-sm btn-primary">{{ 'apply'|t }}</button>
<span id="selected-count" style="color: #888; font-size: 12px;"></span>
</div>
</form>
<table class="attendees-table" id="attendees-table">
<thead>
<tr>
<th style="width: 40px;"></th>
<th data-sort="name" class="sortable">{{ 'name'|t }} <span class="sort-icon"></span></th>
<th data-sort="organisation" class="sortable">{{ 'organisation'|t }} <span class="sort-icon"></span></th>
<th data-sort="role" class="sortable">{{ 'role'|t }} <span class="sort-icon"></span></th>
<th data-sort="type" class="sortable">{{ 'type'|t }} <span class="sort-icon"></span></th>
<th data-sort="status" class="sortable">{{ 'status'|t }} <span class="sort-icon"></span></th>
<th>{{ 'actions'|t }}</th>
</tr>
</thead>
<tbody id="attendees-tbody">
{% for attendee in attendees %}
<tr data-name="{{ attendee.first_name }} {{ attendee.last_name }}" data-organisation="{{ attendee.organisation or '' }}" data-role="{{ attendee.role or '' }}" data-status="{{ attendee.checked_in }}" data-type="{{ attendee.attendee_type_name or '' }}">
<td>
<input type="checkbox" name="attendee_ids" value="{{ attendee.id }}" class="attendee-checkbox" form="batch-assign-form">
</td>
<td>{{ attendee.first_name }} {{ attendee.last_name }}</td>
<td>{{ (attendee.organisation)|spacify if attendee.organisation else '-' }}</td>
<td>{{ (attendee.role)|spacify if attendee.role else '-' }}</td>
<td>
{% if attendee.attendee_type_name %}
<span class="badge" style="background: #3498db; color: white;">{{ attendee.attendee_type_name }}</span>
{% else %}
<span style="color: #aaa;">-</span>
{% endif %}
</td>
<td>
{% if attendee.checked_in %}
<span class="badge badge-success">{{ 'checked_in'|t }}</span>
{% else %}
<span class="badge badge-pending">{{ 'not_checked_in'|t }}</span>
{% endif %}
</td>
<td>
{% if not attendee.checked_in %}
<button class="btn btn-sm btn-success checkin-btn" data-attendee-id="{{ attendee.id }}">{{ 'check_in'|t }}</button>
{% else %}
<span class="text-muted">{{ 'checked_in'|t }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="no-attendees">{{ 'no_attendees_yet'|t }}</p>
{% endif %}
</div>
</section>
</div>
<script>
document.getElementById('add-staff-btn').addEventListener('click', function() {
document.getElementById('add-staff-form').style.display = 'block';
this.style.display = 'none';
});
document.getElementById('cancel-staff-btn').addEventListener('click', function() {
document.getElementById('add-staff-form').style.display = 'none';
document.getElementById('add-staff-btn').style.display = 'inline-block';
});
document.getElementById('staff-inline-form').addEventListener('submit', function(e) {
e.preventDefault();
const form = this;
const formData = new FormData(form);
fetch(form.action, {
method: 'POST',
body: formData
}).then(response => {
if (response.redirected) {
window.location.href = response.url;
} else {
location.reload();
}
}).catch(error => {
alert('{{ "error_adding_staff"|t }}');
});
});
</script>
</div>
<script>
document.querySelectorAll('.checkin-btn').forEach(btn => {
btn.addEventListener('click', async function() {
const attendeeId = this.dataset.attendeeId;
const eventId = {{ event.id }};
try {
const response = await fetch(`/organizer/event/${eventId}/checkin/${attendeeId}`, {
method: 'POST'
});
if (response.ok) {
// Update status cell (6th td) and remove checkin button
const row = this.closest('tr');
row.querySelector('td:nth-child(6)').innerHTML = '<span class="badge badge-success">{{ "checked_in"|t }}</span>';
this.remove();
document.getElementById('attendee-count').textContent = parseInt(document.getElementById('attendee-count').textContent) + 1;
}
} catch (error) {
alert('{{ "error_checking_in"|t }}');
}
});
});
// Attendee table sorting
const sortableHeaders = document.querySelectorAll('#attendees-table th.sortable');
let currentSort = { column: null, direction: 'asc' };
sortableHeaders.forEach(th => {
th.addEventListener('click', () => {
const column = th.dataset.sort;
const direction = currentSort.column === column && currentSort.direction === 'asc' ? 'desc' : 'asc';
currentSort = { column, direction };
sortableHeaders.forEach(h => h.classList.remove('sort-asc', 'sort-desc'));
th.classList.add(direction === 'asc' ? 'sort-asc' : 'sort-desc');
const tbody = document.getElementById('attendees-tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
rows.sort((a, b) => {
let valA = a.dataset[column] || '';
let valB = b.dataset[column] || '';
if (column === 'status') {
valA = valA === 'True' ? 1 : 0;
valB = valB === 'True' ? 1 : 0;
} else {
valA = valA.toLowerCase();
valB = valB.toLowerCase();
}
if (valA < valB) return direction === 'asc' ? -1 : 1;
if (valA > valB) return direction === 'asc' ? 1 : -1;
return 0;
});
rows.forEach(row => tbody.appendChild(row));
});
});
// Staff table sorting
const staffSortableHeaders = document.querySelectorAll('#event-staff-table th.sortable');
let staffCurrentSort = { column: null, direction: 'asc' };
staffSortableHeaders.forEach(th => {
th.addEventListener('click', () => {
const column = th.dataset.sort;
const direction = staffCurrentSort.column === column && staffCurrentSort.direction === 'asc' ? 'desc' : 'asc';
staffCurrentSort = { column, direction };
staffSortableHeaders.forEach(h => h.classList.remove('sort-asc', 'sort-desc'));
th.classList.add(direction === 'asc' ? 'sort-asc' : 'sort-desc');
const tbody = document.getElementById('event-staff-tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
rows.sort((a, b) => {
let valA = a.dataset[column] || '';
let valB = b.dataset[column] || '';
if (column === 'status') {
valA = valA === '' ? 1 : 0;
valB = valB === '' ? 1 : 0;
} else {
valA = valA.toLowerCase();
valB = valB.toLowerCase();
}
if (valA < valB) return direction === 'asc' ? -1 : 1;
if (valA > valB) return direction === 'asc' ? 1 : -1;
return 0;
});
rows.forEach(row => tbody.appendChild(row));
});
});
function showDeleteModal(staffId, staffName, staffEmail) {
document.getElementById('staffName').textContent = staffName;
document.getElementById('staffEmail').textContent = staffEmail;
document.getElementById('staffDeleteForm').action = '{{ url_for("delete_staff", event_id=event.id, staff_id=0) }}'.replace('0', staffId);
document.getElementById('staffDeleteModal').style.display = 'flex';
}
function closeStaffDeleteModal() {
document.getElementById('staffDeleteModal').style.display = 'none';
}
// Select all attendees checkbox
document.getElementById('select-all-attendees').addEventListener('change', function() {
const checkboxes = document.querySelectorAll('.attendee-checkbox');
checkboxes.forEach(cb => cb.checked = this.checked);
updateSelectedCount();
});
document.querySelectorAll('.attendee-checkbox').forEach(cb => {
cb.addEventListener('change', updateSelectedCount);
});
function updateSelectedCount() {
const selected = document.querySelectorAll('.attendee-checkbox:checked').length;
document.getElementById('selected-count').textContent = selected > 0 ? `(${selected} {{ 'selected'|t }})` : '';
}
// Copy link function
function copyLink(url) {
// Try modern clipboard API first
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(function() {
alert('{{ 'link_copied'|t }}');
}, function(err) {
// Fallback for HTTP sites
fallbackCopy(url);
});
} else {
// Fallback for older browsers or HTTP sites
fallbackCopy(url);
}
}
function fallbackCopy(url) {
var textArea = document.createElement('textarea');
textArea.value = url;
textArea.style.position = 'fixed';
textArea.style.left = '-9999px';
textArea.style.top = '-9999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
var successful = document.execCommand('copy');
if (successful) {
alert('{{ 'link_copied'|t }}');
} else {
alert('{{ 'copy_failed'|t }}');
}
} catch (err) {
alert('{{ 'copy_failed'|t }}');
}
document.body.removeChild(textArea);
}
// Initial selected count
updateSelectedCount();
</script>
{% endblock %}
+192
View File
@@ -0,0 +1,192 @@
{% extends "base.html" %}
{% block title %}{{ 'staff'|t }} - {{ event.name }}{% endblock %}
{% block content %}
<style>
.staff-table th.sortable {
cursor: pointer;
user-select: none;
}
.staff-table th.sortable:hover {
background: #f0f0f0;
}
.sort-icon::before {
content: '\2195';
margin-left: 5px;
opacity: 0.4;
}
th.sort-asc .sort-icon::before {
content: '\2191';
opacity: 1;
}
th.sort-desc .sort-icon::before {
content: '\2193';
opacity: 1;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: #fff;
border-radius: 12px;
padding: 30px;
max-width: 500px;
width: 90%;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
.modal-header {
margin-bottom: 20px;
}
.modal-header h2 {
margin: 0 0 10px 0;
color: #2c3e50;
}
.modal-header p {
margin: 0;
color: #666;
}
.modal-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
</style>
<div class="event-staff">
<div class="staff-header">
<h1>{{ 'staff_for'|t }} {{ event.name }}</h1>
<a href="{{ url_for('event_detail', code=event.code) }}" class="btn btn-outline">{{ 'back_to_event'|t }}</a>
</div>
<section class="add-staff-form">
<h2>{{ 'add_staff_member'|t }}</h2>
<form method="POST" action="{{ url_for('manage_event_staff', event_id=event.id) }}" class="staff-form">
<div class="form-row">
<div class="form-group">
<label for="first_name">{{ 'first_name'|t }}</label>
<input type="text" id="first_name" name="first_name" required>
</div>
<div class="form-group">
<label for="last_name">{{ 'last_name'|t }}</label>
<input type="text" id="last_name" name="last_name" required>
</div>
</div>
<div class="form-group">
<label for="email">{{ 'email'|t }}</label>
<input type="email" id="email" name="email" required>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'add_staff_member'|t }}</button>
</div>
</form>
</section>
<section class="staff-list">
<h2>{{ 'current_staff'|t }} ({{ staff_members|length }})</h2>
{% if staff_members %}
<table class="staff-table">
<thead>
<tr>
<th data-sort="name" class="sortable">{{ 'name'|t }} <span class="sort-icon"></span></th>
<th data-sort="email" class="sortable">{{ 'email'|t }} <span class="sort-icon"></span></th>
<th data-sort="status" class="sortable">{{ 'status'|t }} <span class="sort-icon"></span></th>
<th>{{ 'actions'|t }}</th>
</tr>
</thead>
<tbody id="staff-tbody">
{% for staff in staff_members %}
<tr data-name="{{ staff.first_name }} {{ staff.last_name }}" data-email="{{ staff.email }}" data-status="{{ staff.invite_used }}">
<td>{{ staff.first_name }} {{ staff.last_name }}</td>
<td>{{ staff.email }}</td>
<td>
{% if staff.invite_used %}
<span class="badge badge-success">{{ 'active'|t }}</span>
{% else %}
<span class="badge badge-pending">{{ 'invite_pending'|t }}</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('edit_staff', event_id=event.id, staff_id=staff.id) }}" class="btn btn-sm btn-outline">{{ 'edit'|t }}</a>
<button type="button" class="btn btn-sm btn-danger" onclick="showDeleteModal({{ staff.id }}, '{{ staff.first_name }} {{ staff.last_name }}')">{{ 'remove'|t }}</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="no-staff">{{ 'no_staff_yet'|t }}</p>
{% endif %}
</section>
</div>
<div id="deleteModal" class="modal-overlay" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h2>{{ 'remove_staff_member'|t }}</h2>
<p>{{ 'confirm_remove_staff'|t }} <strong id="staffName"></strong>?</p>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-outline" onclick="closeDeleteModal()">{{ 'cancel'|t }}</button>
<form id="deleteForm" method="POST" style="display: inline;">
<button type="submit" class="btn btn-danger">{{ 'remove'|t }}</button>
</form>
</div>
</div>
</div>
<script>
const sortableHeaders = document.querySelectorAll('.staff-table th.sortable');
let currentSort = { column: null, direction: 'asc' };
sortableHeaders.forEach(th => {
th.addEventListener('click', () => {
const column = th.dataset.sort;
const direction = currentSort.column === column && currentSort.direction === 'asc' ? 'desc' : 'asc';
currentSort = { column, direction };
sortableHeaders.forEach(h => h.classList.remove('sort-asc', 'sort-desc'));
th.classList.add(direction === 'asc' ? 'sort-asc' : 'sort-desc');
const tbody = document.getElementById('staff-tbody');
const rows = Array.from(tbody.querySelectorAll('tr'));
rows.sort((a, b) => {
let valA = a.dataset[column] || '';
let valB = b.dataset[column] || '';
if (column === 'status') {
valA = valA === 'True' ? 1 : 0;
valB = valB === 'True' ? 1 : 0;
} else {
valA = valA.toLowerCase();
valB = valB.toLowerCase();
}
if (valA < valB) return direction === 'asc' ? -1 : 1;
if (valA > valB) return direction === 'asc' ? 1 : -1;
return 0;
});
rows.forEach(row => tbody.appendChild(row));
});
});
function showDeleteModal(staffId, staffName) {
document.getElementById('staffName').textContent = staffName;
document.getElementById('deleteForm').action = '{{ url_for("delete_staff", event_id=event.id, staff_id=0) }}'.replace('0', staffId);
document.getElementById('deleteModal').style.display = 'flex';
}
function closeDeleteModal() {
document.getElementById('deleteModal').style.display = 'none';
}
{% endblock %}
+333
View File
@@ -0,0 +1,333 @@
{% 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('event_detail', code=event.code) }}" class="btn btn-outline">{{ 'back_to_event'|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;
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);
}
}
}
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 %}