Security hardening: env-based config, CSRF protection, secure code generation, plus presenter management and email communications system
- 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>
This commit is contained in:
@@ -11,8 +11,8 @@
|
||||
{% if event.location %}
|
||||
<span class="event-location">{{ event.location }}</span>
|
||||
{% endif %}
|
||||
{% if event.start_date %}
|
||||
<span class="event-date">{{ event.start_date|format_date }}</span>
|
||||
{% if event.start_time %}
|
||||
<span class="event-date">{{ event.start_time|localized_date }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if event.description %}
|
||||
@@ -42,11 +42,11 @@
|
||||
{% if att.profile_picture %}
|
||||
<img src="{{ url_for('static', filename='uploads/' + att.profile_picture) }}" alt="Photo">
|
||||
{% else %}
|
||||
<div class="no-photo">{{ att.first_name[0] }}{{ att.last_name[0] }}</div>
|
||||
<div class="no-photo"><span class="first-name">{{ att.first_name[0] }}</span><span class="last-name">{{ att.last_name[0] }}</span></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="attendee-info">
|
||||
<h3>{{ att.first_name }} {{ att.last_name }}</h3>
|
||||
<h3><span class="first-name">{{ att.first_name }}</span> <span class="last-name">{{ att.last_name }}</span></h3>
|
||||
<p class="attendee-org">{{ att.organisation if att.organisation else '' }}</p>
|
||||
<p class="attendee-role">{{ att.role if att.role else '' }}</p>
|
||||
{% if att.introduction %}
|
||||
|
||||
@@ -2,16 +2,6 @@
|
||||
|
||||
{% block title %}{{ 'breakout_sessions'|t }} - {{ event.name }}{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
<style>
|
||||
.breakout-sessions {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 50px 40px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="breakout-sessions">
|
||||
<h1>{{ 'breakout_sessions'|t }} - {{ event.name }}</h1>
|
||||
@@ -64,7 +54,7 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="back-link">
|
||||
<a href="{{ url_for('attendee_dashboard') }}" class="btn btn-outline">{{ 'back_to_dashboard'|t }}</a>
|
||||
<a href="{{ url_for('attendee_dashboard') }}" class="btn btn-outline"><i data-lucide="arrow-left"></i> {{ 'back_to_dashboard'|t }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -84,12 +74,16 @@ document.querySelectorAll('.rsvp-btn').forEach(btn => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(data.error || '{{ "error_occurred"|t }}');
|
||||
showToast(data.error || '{{ "error_occurred"|t }}', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('{{ "error_processing_request"|t }}');
|
||||
showToast('{{ "error_processing_request"|t }}', 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,36 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'communications'|t }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="communications-page">
|
||||
<div class="page-header">
|
||||
<h1>{{ 'my_messages'|t }}</h1>
|
||||
</div>
|
||||
|
||||
{% if emails %}
|
||||
<div class="communications-list">
|
||||
{% for email in emails %}
|
||||
<div class="communication-card">
|
||||
<div class="communication-header">
|
||||
<span class="communication-event">{{ email.event_name }}</span>
|
||||
<span class="communication-date">{{ email.sent_at|localized_date }}</span>
|
||||
</div>
|
||||
<h3>{{ email.subject }}</h3>
|
||||
<p class="communication-body">{{ email.body }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="section-box">
|
||||
<p class="text-muted text-center">{{ 'no_messages_yet'|t }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -25,11 +25,11 @@
|
||||
{% if req.profile_picture %}
|
||||
<img src="{{ url_for('uploaded_file', filename=req.profile_picture) }}" alt="{{ req.first_name }}" class="profile-photo">
|
||||
{% else %}
|
||||
<div class="profile-photo-placeholder">{{ req.first_name[0] }}{{ req.last_name[0] }}</div>
|
||||
<div class="profile-photo-placeholder"><span class="first-name">{{ req.first_name[0] }}</span><span class="last-name">{{ req.last_name[0] }}</span></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="request-info">
|
||||
<h3>{{ req.first_name }} {{ req.last_name }}</h3>
|
||||
<h3><span class="first-name">{{ req.first_name }}</span> <span class="last-name">{{ req.last_name }}</span></h3>
|
||||
<p class="request-email">{{ req.email }}</p>
|
||||
<p class="request-org">{{ req.organisation if req.organisation else '' }}{% if req.role %} - {{ req.role }}{% endif %}</p>
|
||||
{% if req.introduction %}
|
||||
@@ -38,8 +38,8 @@
|
||||
<p class="request-time">{{ 'requested'|t }} {{ req.created_at|localized_date if req.created_at else 'recently' }}</p>
|
||||
</div>
|
||||
<div class="request-actions">
|
||||
<button class="btn btn-success respond-btn" data-connection-id="{{ req.id }}" data-action="approve">{{ 'approve'|t }}</button>
|
||||
<button class="btn btn-danger respond-btn" data-connection-id="{{ req.id }}" data-action="reject">{{ 'reject'|t }}</button>
|
||||
<button class="btn btn-success btn-sm respond-btn" data-connection-id="{{ req.id }}" data-action="approve">{{ 'approve'|t }}</button>
|
||||
<button class="btn btn-danger btn-sm respond-btn" data-connection-id="{{ req.id }}" data-action="reject">{{ 'reject'|t }}</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -70,85 +70,12 @@ document.querySelectorAll('.respond-btn').forEach(btn => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(data.error || 'Error processing request');
|
||||
showToast(data.error || 'Error processing request', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error processing request');
|
||||
showToast('Error processing request', 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.request-card {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 15px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.request-profile {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-photo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.profile-photo-placeholder {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: #e2e8f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.request-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.request-info h3 {
|
||||
margin: 0 0 5px 0;
|
||||
}
|
||||
|
||||
.request-email {
|
||||
color: #64748b;
|
||||
margin: 0 0 5px 0;
|
||||
}
|
||||
|
||||
.request-org {
|
||||
color: #475569;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.request-intro {
|
||||
font-style: italic;
|
||||
color: #64748b;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.request-time {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.request-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -3,14 +3,7 @@
|
||||
{% block title %}{{ 'attendee_dashboard'|t }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.dashboard {
|
||||
padding: 30px 20px;
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
<div class="dashboard">
|
||||
<div class="attendee-dashboard">
|
||||
<h1>{{ 'attendee_dashboard'|t }}</h1>
|
||||
|
||||
{% if event %}
|
||||
@@ -26,10 +19,10 @@
|
||||
<div class="event-card-actions">
|
||||
<a href="{{ url_for('register_event', code=event.code) }}" class="btn btn-outline">{{ 'event_details'|t }}</a>
|
||||
<a href="{{ url_for('attendee_breakout_sessions') }}" class="btn btn-outline">{{ 'breakout_sessions'|t }}</a>
|
||||
<a href="{{ url_for('attendee_scan') }}" class="btn btn-primary">📷 {{ 'scan_qr_to_connect'|t }}</a>
|
||||
<a href="{{ url_for('download_badge') }}" class="btn btn-outline">📄 {{ 'download_badge'|t }}</a>
|
||||
<form method="POST" action="{{ url_for('email_badge') }}" style="display: inline;">
|
||||
<button type="submit" class="btn btn-outline">📧 {{ 'email_badge'|t }}</button>
|
||||
<a href="{{ url_for('attendee_scan') }}" class="btn btn-primary"><i data-lucide="scan-line"></i> {{ 'scan_qr_to_connect'|t }}</a>
|
||||
<a href="{{ url_for('download_badge') }}" class="btn btn-outline"><i data-lucide="file-down"></i> {{ 'download_badge'|t }}</a>
|
||||
<form method="POST" action="{{ url_for('email_badge') }}" class="d-inline">
|
||||
<button type="submit" class="btn btn-outline"><i data-lucide="mail"></i> {{ 'email_badge'|t }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,8 +46,8 @@
|
||||
<div class="connections-grid">
|
||||
{% for conn in connections %}
|
||||
<div class="connection-card">
|
||||
<h4 style="margin-bottom: 12px;">{{ conn.first_name }} {{ conn.last_name }}</h4>
|
||||
<p class="connection-org" style="margin-bottom: 12px;">{{ conn.organisation if conn.organisation else '' }}</p>
|
||||
<h4 class="mb-3"><span class="first-name">{{ conn.first_name }}</span> <span class="last-name">{{ conn.last_name }}</span></h4>
|
||||
<p class="connection-org mb-3">{{ conn.organisation if conn.organisation else '' }}</p>
|
||||
<p class="connection-role">{{ conn.role if conn.role else '' }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -77,9 +70,9 @@
|
||||
<div class="apt-info">
|
||||
<p><strong>
|
||||
{% if apt.requester_id == session.user_id %}
|
||||
{{ 'with'|t }} {{ apt.target_first_name }} {{ apt.target_last_name }}
|
||||
{{ 'with'|t }} <span class="first-name">{{ apt.target_first_name }}</span> <span class="last-name">{{ apt.target_last_name }}</span>
|
||||
{% else %}
|
||||
{{ 'with'|t }} {{ apt.requester_first_name }} {{ apt.requester_last_name }}
|
||||
{{ 'with'|t }} <span class="first-name">{{ apt.requester_first_name }}</span> <span class="last-name">{{ apt.requester_last_name }}</span>
|
||||
{% endif %}
|
||||
</strong></p>
|
||||
<p><strong>{{ 'time'|t }}:</strong> {{ apt.appointment_time|localized_date if apt.appointment_time else 'TBD' }}</p>
|
||||
@@ -106,4 +99,4 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@@ -36,12 +36,12 @@
|
||||
<div class="registration-form-section">
|
||||
<h2>{{ 'register_as_attendee'|t }}</h2>
|
||||
{% if preselected_type %}
|
||||
<div style="background: #e8f4f8; border: 1px solid #b8daE3; border-radius: 5px; padding: 15px; margin-bottom: 20px;">
|
||||
<p style="margin: 0;"><strong>{{ 'registration_type'|t }}:</strong> {{ preselected_type.name }}
|
||||
<div class="alert alert-info">
|
||||
<p class="m-0"><strong>{{ 'registration_type'|t }}:</strong> {{ preselected_type.name }}
|
||||
{% if preselected_type.price and preselected_type.price > 0 %}
|
||||
<span style="color: #27ae60; margin-left: 10px;">{{ preselected_type.price|format_currency }}</span>
|
||||
<span class="text-success ml-2">{{ preselected_type.price|format_currency }}</span>
|
||||
{% else %}
|
||||
<span style="color: #7f8c8d; margin-left: 10px;">{{ 'free'|t }}</span>
|
||||
<span class="text-muted ml-2">{{ 'free'|t }}</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
@@ -91,7 +91,7 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">{{ 'password'|t }}</label>
|
||||
<input type="password" id="password" name="password" required minlength="6">
|
||||
<input type="password" id="password" name="password" required minlength="8">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -182,275 +182,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.event-registration-page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.event-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #eee;
|
||||
}
|
||||
|
||||
.event-header h1 {
|
||||
color: #2c3e50;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.event-meta-box {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.event-meta-box p {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.event-description-box {
|
||||
margin-top: 15px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.registration-content {
|
||||
display: grid;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.registration-form-section {
|
||||
background: #fff;
|
||||
padding: 25px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.registration-form-section h2 {
|
||||
margin-bottom: 20px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.char-count {
|
||||
display: block;
|
||||
text-align: right;
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.login-link {
|
||||
text-align: center;
|
||||
margin-top: 15px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.breakout-sessions-section {
|
||||
background: #fff;
|
||||
padding: 25px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.breakout-sessions-section h2 {
|
||||
margin-bottom: 10px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.section-intro {
|
||||
color: #666;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.session-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
margin-bottom: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #3498db;
|
||||
}
|
||||
|
||||
.session-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.session-info h3 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.session-info p {
|
||||
margin: 5px 0;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.session-actions {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.full-label {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
padding: 8px 15px;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.no-sessions {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
padding: 30px;
|
||||
}
|
||||
|
||||
.registration-complete {
|
||||
margin-top: 30px;
|
||||
padding: 20px;
|
||||
background: #d4edda;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.registration-complete p {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.registration-complete a {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.breakout-sessions-selection {
|
||||
margin-top: 25px;
|
||||
padding-top: 20px;
|
||||
border-top: 2px solid #eee;
|
||||
}
|
||||
|
||||
.breakout-sessions-selection > label {
|
||||
font-size: 18px;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.selection-hint {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.sessions-checkboxes {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.session-checkbox-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e0e0e0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.session-checkbox-item:hover {
|
||||
border-color: #3498db;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.session-checkbox-item.session-full {
|
||||
opacity: 0.6;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.session-checkbox-item input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 12px;
|
||||
margin-top: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.session-checkbox-item input[type="checkbox"]:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.session-checkbox-item label {
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.session-checkbox-item label strong {
|
||||
display: block;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.session-checkbox-item label span {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.session-checkbox-item label .session-time {
|
||||
color: #3498db;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.session-checkbox-item label .session-location {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.session-checkbox-item label .session-capacity {
|
||||
margin-top: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.session-checkbox-item label .session-capacity.full {
|
||||
color: #e74c3c;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var intro = document.getElementById('introduction');
|
||||
@@ -477,10 +208,10 @@ document.querySelectorAll('.rsvp-btn').forEach(btn => {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(data.error || '{{ "error_occurred"|t }}');
|
||||
showToast(data.error || '{{ "error_occurred"|t }}', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('{{ "error_processing_request"|t }}');
|
||||
showToast('{{ "error_processing_request"|t }}', 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,111 +3,10 @@
|
||||
{% block title %}{{ 'payment'|t }} - {{ event.name }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.payment-page {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.payment-box {
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.payment-box h1 {
|
||||
margin-bottom: 20px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.order-summary {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
.order-summary h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 15px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.order-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.order-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.order-total {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-top: 15px;
|
||||
margin-top: 10px;
|
||||
border-top: 2px solid #ddd;
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
}
|
||||
.price {
|
||||
color: #27ae60;
|
||||
font-weight: 500;
|
||||
}
|
||||
.payment-form {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.payment-form .form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.payment-form label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.payment-form input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.card-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
}
|
||||
.btn-pay {
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
background: #27ae60;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.btn-pay:hover {
|
||||
background: #219a52;
|
||||
}
|
||||
.security-note {
|
||||
text-align: center;
|
||||
margin-top: 15px;
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
}
|
||||
.back-link {
|
||||
display: block;
|
||||
margin-bottom: 20px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="payment-page">
|
||||
<a href="{{ url_for('register_event', code=event.code) }}" class="back-link">← {{ 'back_to_registration'|t }}</a>
|
||||
<a href="{{ url_for('register_event', code=event.code) }}" class="back-link"><i data-lucide="arrow-left"></i> {{ 'back_to_registration'|t }}</a>
|
||||
|
||||
<div class="payment-box">
|
||||
<div class="payment-box card">
|
||||
<h1>{{ 'complete_payment'|t }}</h1>
|
||||
|
||||
<div class="order-summary">
|
||||
@@ -122,7 +21,7 @@
|
||||
</div>
|
||||
<div class="order-item">
|
||||
<span>{{ 'attendee'|t }}:</span>
|
||||
<span>{{ pending.first_name }} {{ pending.last_name }}</span>
|
||||
<span><span class="first-name">{{ pending.first_name }}</span> <span class="last-name">{{ pending.last_name }}</span></span>
|
||||
</div>
|
||||
<div class="order-item">
|
||||
<span>{{ 'email'|t }}:</span>
|
||||
@@ -134,58 +33,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="payment-notice">
|
||||
<p><i data-lucide="info"></i> {{ 'payment_to_be_integrated'|t }}</p>
|
||||
</div>
|
||||
|
||||
<form method="POST" class="payment-form">
|
||||
<div class="form-group">
|
||||
<label for="card_name">{{ 'name_on_card'|t }}</label>
|
||||
<input type="text" id="card_name" name="card_name" required placeholder="{{ 'john_doe'|t }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="card_number">{{ 'card_number'|t }}</label>
|
||||
<input type="text" id="card_number" name="card_number" required placeholder="{{ '1234_5678_9012_3456'|t }}" maxlength="19">
|
||||
</div>
|
||||
|
||||
<div class="card-row">
|
||||
<div class="form-group">
|
||||
<label for="expiry">{{ 'expiry_date'|t }}</label>
|
||||
<input type="text" id="expiry" name="expiry" required placeholder="{{ 'mm/yy'|t }}" maxlength="5">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="cvv">{{ 'cvv'|t }}</label>
|
||||
<input type="text" id="cvv" name="cvv" required placeholder="{{ '123'|t }}" maxlength="4">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-pay">{{ 'pay_now'|t }} - {{ pending.price|format_currency }} {{ get_currency_symbol() }}</button>
|
||||
<button type="submit" class="btn btn-success btn-lg btn-block"><i data-lucide="check-circle"></i> {{ 'complete_registration'|t }}</button>
|
||||
</form>
|
||||
|
||||
<p class="security-note">{{ 'payment_secure'|t }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('card_number').addEventListener('input', function(e) {
|
||||
var value = e.target.value.replace(/\s+/g, '').replace(/[^0-9]/gi, '');
|
||||
var matches = value.match(/\d{4,16}/g);
|
||||
var match = matches && matches[0] || '';
|
||||
var parts = [];
|
||||
for (var i = 0, len = match.length; i < len; i += 4) {
|
||||
parts.push(match.substring(i, i + 4));
|
||||
}
|
||||
if (parts.length) {
|
||||
e.target.value = parts.join(' ');
|
||||
} else {
|
||||
e.target.value = value;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('expiry').addEventListener('input', function(e) {
|
||||
var value = e.target.value.replace(/\s+/g, '').replace(/[^0-9]/gi, '');
|
||||
if (value.length >= 2) {
|
||||
e.target.value = value.substring(0, 2) + '/' + value.substring(2, 4);
|
||||
} else {
|
||||
e.target.value = value;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
<p class="welcome-subtitle">{{ 'registration_confirmed'|t }}</p>
|
||||
|
||||
{% for event in events %}
|
||||
<section class="event-section">
|
||||
<section class="event-section card">
|
||||
<h2>{{ event.name }}</h2>
|
||||
<div class="event-details">
|
||||
<p><strong>{{ 'date'|t }}:</strong> {{ event.start_time|localized_date if event.start_time else 'TBD' }}</p>
|
||||
<p><strong>{{ 'location'|t }}:</strong> {{ event.location }}</p>
|
||||
<p><strong>{{ 'status'|t }}:</strong> <span class="status-registered">{{ 'registered'|t }}</span></p>
|
||||
<p><strong>{{ 'status'|t }}:</strong> <span class="status-registered"><i data-lucide="check-circle"></i> {{ 'registered'|t }}</span></p>
|
||||
</div>
|
||||
|
||||
<h3>{{ 'breakout_sessions'|t }}</h3>
|
||||
@@ -23,179 +23,36 @@
|
||||
<div class="session-item {% if session.my_rsvp_status == 'registered' %}registered{% endif %}">
|
||||
<div class="session-info">
|
||||
<strong>{{ session.name }}</strong>
|
||||
<span class="session-time">{{ session.start_time|localized_date('%H:%M') if session.start_time else '' }} - {{ session.end_time|localized_date('%H:%M') if session.end_time else '' }}</span>
|
||||
<span class="session-location">{{ session.location }}</span>
|
||||
<span class="session-time"><i data-lucide="clock"></i> {{ session.start_time|localized_date('%H:%M') if session.start_time else '' }} - {{ session.end_time|localized_date('%H:%M') if session.end_time else '' }}</span>
|
||||
<span class="session-location"><i data-lucide="map-pin"></i> {{ session.location }}</span>
|
||||
{% if session.description %}
|
||||
<span class="session-description">{{ session.description }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="session-status">
|
||||
{% if session.my_rsvp_status == 'registered' %}
|
||||
<span class="badge badge-success">{{ 'registered'|t }}</span>
|
||||
<span class="badge badge-success"><i data-lucide="check"></i> {{ 'registered'|t }}</span>
|
||||
{% else %}
|
||||
<span class="badge badge-secondary">{{ 'not_registered'|t }}</span>
|
||||
<span class="badge badge-pending">{{ 'not_registered'|t }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="no-sessions">{{ 'no_breakout_sessions'|t }}</p>
|
||||
<p class="no-sessions"><i data-lucide="calendar"></i> {{ 'no_breakout_sessions'|t }}</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endfor %}
|
||||
|
||||
<div class="actions">
|
||||
<a href="{{ url_for('attendee_dashboard') }}" class="btn btn-primary">{{ 'go_to_dashboard'|t }}</a>
|
||||
<a href="{{ url_for('attendee_dashboard') }}" class="btn btn-primary"><i data-lucide="layout-dashboard"></i> {{ 'go_to_dashboard'|t }}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
<style>
|
||||
.personal-page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 50px 40px;
|
||||
}
|
||||
|
||||
.personal-page h1 {
|
||||
color: #2c3e50;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.welcome-subtitle {
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.event-section {
|
||||
background: #fff;
|
||||
padding: 25px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.event-section h2 {
|
||||
color: #2c3e50;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 2px solid #3498db;
|
||||
}
|
||||
|
||||
.event-details {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.event-details p {
|
||||
margin: 8px 0;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.status-registered {
|
||||
color: #27ae60;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.event-section h3 {
|
||||
color: #2c3e50;
|
||||
margin: 20px 0 15px 0;
|
||||
}
|
||||
|
||||
.sessions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.session-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #95a5a6;
|
||||
}
|
||||
|
||||
.session-item.registered {
|
||||
border-left-color: #27ae60;
|
||||
background: #f0f9f4;
|
||||
}
|
||||
|
||||
.session-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.session-info strong {
|
||||
display: block;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.session-info span {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.session-info .session-time {
|
||||
color: #3498db;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.session-info .session-location {
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.session-info .session-description {
|
||||
color: #666;
|
||||
margin-top: 5px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.session-status {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: #27ae60;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
background: #95a5a6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.no-sessions {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
padding: 20px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.actions .btn {
|
||||
display: inline-block;
|
||||
padding: 12px 30px;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
+60
-138
@@ -2,61 +2,30 @@
|
||||
|
||||
{% block title %}{{ 'profile'|t }} - NetEvents{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
<style>
|
||||
.profile-page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 50px 40px;
|
||||
}
|
||||
.profile-container {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.photo-box {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.photo-box h3 {
|
||||
margin-bottom: 15px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.profile-photo-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="profile-page">
|
||||
<h1>{{ 'my_profile'|t }}</h1>
|
||||
|
||||
<div class="profile-container">
|
||||
<div class="photo-box">
|
||||
<div class="profile-photo-box">
|
||||
<h3>{{ 'profile_photo'|t }}</h3>
|
||||
<div class="profile-photo-section">
|
||||
<div class="current-photo" id="photo-container">
|
||||
{% if attendee.profile_picture %}
|
||||
<img src="{{ url_for('static', filename='uploads/' + attendee.profile_picture) }}" alt="{{ 'profile_photo'|t }}" class="profile-img" id="profile-preview">
|
||||
{% else %}
|
||||
<img src="" alt="{{ 'profile_preview'|t }}" class="profile-img" id="profile-preview" style="display: none;">
|
||||
<div class="no-photo" id="no-photo">{{ 'no_photo'|t }}</div>
|
||||
<img src="" alt="{{ 'profile_preview'|t }}" class="profile-img d-none" id="profile-preview">
|
||||
<div class="no-photo" id="no-photo"><i data-lucide="user"></i></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<canvas id="crop-canvas" style="display: none;"></canvas>
|
||||
<canvas id="crop-canvas" class="d-none"></canvas>
|
||||
|
||||
<div class="zoom-controls">
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="adjustZoom(-0.25)">-</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="adjustZoom(-0.25)"><i data-lucide="minus"></i></button>
|
||||
<span id="zoom-level">100%</span>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="adjustZoom(0.25)">+</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="handleFormSubmit()">{{ 'save'|t }}</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="adjustZoom(0.25)"><i data-lucide="plus"></i></button>
|
||||
<button type="button" class="btn btn-sm btn-primary" onclick="handleFormSubmit()">{{ 'save'|t }}</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -67,14 +36,21 @@
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('attendee_profile') }}" class="profile-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 class="form-row">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<label for="email">{{ 'email'|t }}</label>
|
||||
<input type="email" id="email" name="email" value="{{ attendee.email }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -87,14 +63,16 @@
|
||||
<input type="text" id="role" name="role" value="{{ attendee.role or '' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="phone">{{ 'phone'|t }}</label>
|
||||
<input type="tel" id="phone" name="phone" value="{{ attendee.phone or '' }}">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="phone">{{ 'phone'|t }}</label>
|
||||
<input type="tel" id="phone" name="phone" value="{{ attendee.phone or '' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="linkedin">{{ 'linkedin'|t }}</label>
|
||||
<input type="url" id="linkedin" name="linkedin" value="{{ attendee.linkedin or '' }}">
|
||||
<div class="form-group">
|
||||
<label for="linkedin">{{ 'linkedin'|t }}</label>
|
||||
<input type="url" id="linkedin" name="linkedin" value="{{ attendee.linkedin or '' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -102,14 +80,35 @@
|
||||
<textarea id="introduction" name="introduction" rows="4">{{ attendee.introduction or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="preferred_language">{{ 'language'|t }}</label>
|
||||
<select id="preferred_language" name="preferred_language">
|
||||
{% for lang in ['en', 'nl', 'de', 'fr', 'es', 'it', 'pl'] %}
|
||||
<option value="{{ lang }}" {% if attendee.preferred_language == lang %}selected{% endif %}>{{ lang }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group switch-group">
|
||||
<label for="visible_to_others">{{ 'visible_to_attendees'|t }}</label>
|
||||
<label class="switch">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="visible_to_others" name="visible_to_others" value="1" {% if attendee.visible_to_others %}checked{% endif %}>
|
||||
<span class="slider"></span>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>{{ 'attendee_code'|t }}</label>
|
||||
<input type="text" value="{{ attendee.attendee_code or '' }}" readonly class="input-readonly">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ 'member_since'|t }}</label>
|
||||
<input type="text" value="{{ attendee.created_at|localized_date if attendee.created_at else '' }}" readonly class="input-readonly">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ 'update_profile'|t }}</button>
|
||||
</div>
|
||||
@@ -117,87 +116,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.switch-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
.switch-group label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 50px;
|
||||
height: 26px;
|
||||
}
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
transition: 0.3s;
|
||||
border-radius: 26px;
|
||||
}
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: 0.3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
input:checked + .slider {
|
||||
background-color: #2ecc71;
|
||||
}
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(24px);
|
||||
}
|
||||
.current-photo {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
margin-bottom: 10px;
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
position: relative;
|
||||
background: #e0e0e0;
|
||||
}
|
||||
.profile-img {
|
||||
position: absolute;
|
||||
border-radius: 0;
|
||||
cursor: move;
|
||||
}
|
||||
.no-photo, #no-photo {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 50%;
|
||||
background: #e0e0e0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
}
|
||||
.zoom-controls {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
var zoomLevel = 1.0;
|
||||
var translateX = 0;
|
||||
@@ -334,7 +252,7 @@ function handleFormSubmit(e) {
|
||||
if (e) e.preventDefault();
|
||||
|
||||
if (!originalImage) {
|
||||
alert('Please select an image first');
|
||||
showToast('Please select an image first', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -393,16 +311,20 @@ function handleFormSubmit(e) {
|
||||
return response.json();
|
||||
}).then(function(data) {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
showToast('Profile photo updated!', 'success');
|
||||
setTimeout(function() { location.reload(); }, 1000);
|
||||
} else {
|
||||
alert(data.error || 'Upload failed');
|
||||
showToast(data.error || 'Upload failed', 'error');
|
||||
}
|
||||
}).catch(function(error) {
|
||||
alert('Error uploading photo');
|
||||
showToast('Error uploading photo', 'error');
|
||||
});
|
||||
}, 'image/jpeg', 0.9);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
init();
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
+12
-111
@@ -7,7 +7,7 @@
|
||||
<div class="scan-header">
|
||||
<h1>{{ 'scan_attendee'|t }}</h1>
|
||||
<p>{{ 'scan_qr_description'|t }}</p>
|
||||
<a href="{{ url_for('attendee_dashboard') }}" class="btn btn-outline">{{ 'back_to_dashboard'|t }}</a>
|
||||
<a href="{{ url_for('attendee_dashboard') }}" class="btn btn-outline"><i data-lucide="arrow-left"></i> {{ 'back_to_dashboard'|t }}</a>
|
||||
</div>
|
||||
|
||||
<div class="scanner-container">
|
||||
@@ -16,7 +16,7 @@
|
||||
<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>
|
||||
<button id="scan-again" class="btn btn-primary"><i data-lucide="scan-line"></i> {{ 'scan_again'|t }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,109 +26,6 @@
|
||||
</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.pending::before {
|
||||
content: '⏳';
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.result-icon.error::before {
|
||||
content: '✗';
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.result-icon.info::before {
|
||||
content: 'ℹ';
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
#result-title {
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
#result-message {
|
||||
color: #666;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.scan-info {
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.scan-info p {
|
||||
margin: 10px 0;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.scan-info strong {
|
||||
color: #1e293b;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
|
||||
<script>
|
||||
const myId = {{ session.user_id }};
|
||||
@@ -151,25 +48,29 @@ async function sendConnectionRequest(scannedId) {
|
||||
const message = document.getElementById('result-message');
|
||||
|
||||
icon.className = 'result-icon';
|
||||
icon.innerHTML = '<i data-lucide="clock"></i>';
|
||||
|
||||
if (data.success) {
|
||||
document.getElementById('qr-reader').style.display = 'none';
|
||||
resultDiv.classList.remove('hidden');
|
||||
icon.classList.add('pending');
|
||||
icon.innerHTML = '<i data-lucide="clock"></i>';
|
||||
title.textContent = '{{ "request_sent"|t }}';
|
||||
message.textContent = '{{ "request_sent_message"|t }}';
|
||||
} else {
|
||||
document.getElementById('qr-reader').style.display = 'none';
|
||||
resultDiv.classList.remove('hidden');
|
||||
icon.classList.add('error');
|
||||
icon.innerHTML = '<i data-lucide="x-circle"></i>';
|
||||
title.textContent = '{{ "error"|t }}';
|
||||
message.textContent = data.error || '{{ "failed_send_request"|t }}';
|
||||
}
|
||||
|
||||
lucide.createIcons();
|
||||
scanning = false;
|
||||
} catch (error) {
|
||||
console.error('Connection error:', error);
|
||||
alert('Error sending connection request');
|
||||
showToast('Error sending connection request', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,16 +85,16 @@ function onScanSuccess(decodedText) {
|
||||
|
||||
if (eventId === myEventId) {
|
||||
if (attendeeId === myId) {
|
||||
alert('{{ "cannot_scan_own_qr"|t }}');
|
||||
showToast('{{ "cannot_scan_own_qr"|t }}', 'warning');
|
||||
return;
|
||||
}
|
||||
sendConnectionRequest(attendeeId);
|
||||
} else {
|
||||
alert('{{ "qr_different_event"|t }}');
|
||||
showToast('{{ "qr_different_event"|t }}', 'error');
|
||||
}
|
||||
} else {
|
||||
console.log('Unknown QR format:', decodedText);
|
||||
alert('{{ "unrecognized_qr"|t }}');
|
||||
// QR format not recognized
|
||||
showToast('{{ "unrecognized_qr"|t }}', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,7 +113,7 @@ function startScanner() {
|
||||
}
|
||||
).catch(err => {
|
||||
console.error('Camera error:', err);
|
||||
alert('{{ "camera_permission_error"|t }}');
|
||||
showToast('{{ "camera_permission_error"|t }}', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
<form method="POST" action="{{ url_for('reset_password', token=token) }}">
|
||||
<div class="form-group">
|
||||
<label for="password">{{ 'new_password'|t }}</label>
|
||||
<input type="password" id="password" name="password" required minlength="6">
|
||||
<input type="password" id="password" name="password" required minlength="8">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="confirm_password">{{ 'confirm_password'|t }}</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password" required minlength="6">
|
||||
<input type="password" id="confirm_password" name="confirm_password" required minlength="8">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block">{{ 'reset_password'|t }}</button>
|
||||
|
||||
+70
-24
@@ -3,55 +3,101 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="NetEvents - Professional networking event platform for organizers, attendees, and staff. Manage events, breakout sessions, QR check-ins, and attendee connections.">
|
||||
<meta name="csrf-token" content="{{ session.csrf_token if session.csrf_token else '' }}">
|
||||
<title>{% block title %}NetEvents{% endblock %}</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='favicon.svg') }}">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.js"></script>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar sticky-navbar">
|
||||
<div class="container">
|
||||
<a href="{{ url_for('index') }}" class="logo">NetEvents</a>
|
||||
<div class="container navbar-container">
|
||||
<a href="{{ url_for('index') }}" class="logo">
|
||||
<img src="{{ url_for('static', filename='img/logo.svg') }}" alt="NetEvents" width="150" height="40" class="logo-icon">
|
||||
</a>
|
||||
<button class="hamburger" aria-label="Toggle navigation" aria-expanded="false">
|
||||
<i data-lucide="menu" class="hamburger-icon"></i>
|
||||
<i data-lucide="x" class="hamburger-close-icon d-none"></i>
|
||||
</button>
|
||||
<ul class="nav-links">
|
||||
{% if session.user_id %}
|
||||
{% if session.user_type == 'organizer' %}
|
||||
<li><a href="{{ url_for('organizer_dashboard') }}">{{ 'dashboard'|t }}</a></li>
|
||||
<li><a href="{{ url_for('create_event') }}">{{ 'create_event'|t }}</a></li>
|
||||
<li><a href="{{ url_for('all_attendee_types') }}">{{ 'attendee_types'|t }}</a></li>
|
||||
<li><a href="{{ url_for('organizer_dashboard') }}"><i data-lucide="layout-dashboard"></i><span>{{ 'dashboard'|t }}</span></a></li>
|
||||
<li><a href="{{ url_for('create_event') }}"><i data-lucide="plus-circle"></i><span>{{ 'create_event'|t }}</span></a></li>
|
||||
{% elif session.user_type == 'staff' %}
|
||||
<li><a href="{{ url_for('staff_dashboard') }}"><i data-lucide="layout-dashboard"></i><span>{{ 'dashboard'|t }}</span></a></li>
|
||||
{% elif session.user_type == 'breakout_organizer' %}
|
||||
<li><a href="{{ url_for('presenter_dashboard') }}"><i data-lucide="layout-dashboard"></i><span>{{ 'dashboard'|t }}</span></a></li>
|
||||
{% else %}
|
||||
<li><a href="{{ url_for('attendee_dashboard') }}">{{ 'dashboard'|t }}</a></li>
|
||||
<li><a href="{{ url_for('list_attendees') }}">{{ 'attendees'|t }}</a></li>
|
||||
<li><a href="{{ url_for('attendee_profile') }}">{{ 'profile'|t }}</a></li>
|
||||
<li><a href="{{ url_for('attendee_dashboard') }}"><i data-lucide="layout-dashboard"></i><span>{{ 'dashboard'|t }}</span></a></li>
|
||||
<li><a href="{{ url_for('list_attendees') }}"><i data-lucide="search"></i><span>{{ 'attendees'|t }}</span></a></li>
|
||||
<li><a href="{{ url_for('attendee_profile') }}"><i data-lucide="user"></i><span>{{ 'profile'|t }}</span></a></li>
|
||||
{% endif %}
|
||||
<li><a href="{{ url_for('logout') }}">{{ 'logout'|t }}</a></li>
|
||||
<li><a href="{{ url_for('logout') }}"><i data-lucide="log-out"></i><span>{{ 'logout'|t }}</span></a></li>
|
||||
{% else %}
|
||||
<li><a href="{{ url_for('index') }}">Home</a></li>
|
||||
<li><a href="{{ url_for('login') }}">{{ 'login'|t }}</a></li>
|
||||
<li><a href="{{ url_for('index') }}"><i data-lucide="home"></i><span>{{ 'home'|t }}</span></a></li>
|
||||
<li><a href="{{ url_for('login') }}"><i data-lucide="log-in"></i><span>{{ 'login'|t }}</span></a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% if session.user_id and session.user_name %}
|
||||
<a href="{% if session.user_type == 'attendee' %}{{ url_for('attendee_profile') }}{% elif session.user_type == 'organizer' or session.user_type == 'breakout_organizer' %}{{ url_for('organizer_profile') }}{% elif session.user_type == 'staff' %}{{ url_for('staff_profile') }}{% else %}#{% endif %}" class="nav-user">
|
||||
<i data-lucide="circle-user" class="nav-user-icon"></i>
|
||||
<span class="nav-user-name">{{ session.user_name }}</span>
|
||||
<span class="nav-user-type">{{ session.user_type }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
<div class="flash-messages">
|
||||
{% for message in messages %}
|
||||
<div class="flash-message">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<!-- Toast Notification Container -->
|
||||
<div id="toast-container" class="toast-container"></div>
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
<!-- Confirmation Modal -->
|
||||
<div id="confirm-modal" class="modal-overlay" style="display:none">
|
||||
<div class="modal-content modal-sm">
|
||||
<h3 id="confirm-title">Confirm</h3>
|
||||
<p id="confirm-message"></p>
|
||||
<div class="modal-actions">
|
||||
<button id="confirm-cancel" class="btn btn-secondary">{{ 'cancel'|t }}</button>
|
||||
<button id="confirm-ok" class="btn btn-danger">{{ 'confirm'|t }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<div class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<p>© 2024 NetEvents - Networking Event Platform</p>
|
||||
<p>© 2026 NetEvents - Networking Event Platform</p>
|
||||
<p class="footer-love">
|
||||
Made and hosted with
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="cheese-heart" aria-label="love">
|
||||
<!-- Cheese heart -->
|
||||
<path d="M12 21C12 21 3 14 3 8.5C3 5.5 5.5 3 8.5 3C10.5 3 12 4.5 12 6C12 4.5 13.5 3 15.5 3C18.5 3 21 5.5 21 8.5C21 14 12 21 12 21Z" fill="#f0c040"/>
|
||||
<!-- Cheese holes -->
|
||||
<circle cx="9" cy="8" r="1.5" fill="#d4a020"/>
|
||||
<circle cx="14" cy="7" r="1" fill="#d4a020"/>
|
||||
<circle cx="11" cy="12" r="1.2" fill="#d4a020"/>
|
||||
<circle cx="15" cy="11" r="0.8" fill="#d4a020"/>
|
||||
</svg>
|
||||
in the heart of The Netherlands
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Initialize Lucide icons
|
||||
lucide.createIcons();
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
{% block extra_styles %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
@@ -3,70 +3,12 @@
|
||||
{% 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">← {{ 'back_to_dashboard'|t }}</a>
|
||||
<a href="{{ url_for('organizer_dashboard') }}" class="back-link"><i data-lucide="arrow-left"></i> {{ '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>
|
||||
<p class="text-muted">{{ 'manage_attendee_types_across_events'|t }}</p>
|
||||
</div>
|
||||
|
||||
{% if events %}
|
||||
@@ -74,7 +16,7 @@
|
||||
<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>
|
||||
<a href="{{ url_for('event_detail', code=event.code) }}" class="btn btn-sm btn-outline"><i data-lucide="eye"></i> {{ 'view_event'|t }}</a>
|
||||
</h2>
|
||||
|
||||
{% if event.attendee_types %}
|
||||
@@ -100,17 +42,17 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<code style="background: #f0f0f0; padding: 4px 8px; border-radius: 4px; font-size: 12px;">
|
||||
<code class="code">
|
||||
{{ 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;"
|
||||
<button type="button" class="btn btn-sm btn-ghost"
|
||||
onclick="copyLink('{{ url_for('register_event', code=event.code, type_code=at.code, _external=True) }}')">
|
||||
{{ 'copy'|t }}
|
||||
<i data-lucide="copy"></i>
|
||||
</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>
|
||||
<a href="{{ url_for('manage_attendee_types', event_id=event.id) }}" class="btn btn-sm btn-outline"><i data-lucide="settings"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -122,7 +64,7 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p style="color: #888; text-align: center; padding: 40px;">
|
||||
<p class="text-muted text-center p-10">
|
||||
{{ 'no_events_yet'|t }}
|
||||
</p>
|
||||
{% endif %}
|
||||
@@ -130,16 +72,13 @@
|
||||
|
||||
<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 }}');
|
||||
showToast('{{ 'link_copied'|t }}', 'success');
|
||||
}, function(err) {
|
||||
// Fallback for HTTP sites
|
||||
fallbackCopy(url);
|
||||
});
|
||||
} else {
|
||||
// Fallback for older browsers or HTTP sites
|
||||
fallbackCopy(url);
|
||||
}
|
||||
}
|
||||
@@ -156,14 +95,18 @@ function fallbackCopy(url) {
|
||||
try {
|
||||
var successful = document.execCommand('copy');
|
||||
if (successful) {
|
||||
alert('{{ 'link_copied'|t }}');
|
||||
showToast('{{ 'link_copied'|t }}', 'success');
|
||||
} else {
|
||||
alert('{{ 'copy_failed'|t }}');
|
||||
showToast('{{ 'copy_failed'|t }}', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('{{ 'copy_failed'|t }}');
|
||||
showToast('{{ 'copy_failed'|t }}', 'error');
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@@ -3,72 +3,8 @@
|
||||
{% 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">← {{ 'back_to_event'|t }}</a>
|
||||
<a href="{{ url_for('event_detail', code=event.code) }}" class="back-link"><i data-lucide="arrow-left"></i> {{ 'back_to_event'|t }}</a>
|
||||
|
||||
<h1>{{ 'attendee_types'|t }} - {{ event.name }}</h1>
|
||||
|
||||
@@ -83,10 +19,10 @@
|
||||
<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>
|
||||
<small class="text-muted">{{ 'leave_empty_for_free'|t }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">{{ 'create_type'|t }}</button>
|
||||
<button type="submit" class="btn btn-primary"><i data-lucide="plus"></i> {{ 'create_type'|t }}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -115,17 +51,17 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<code style="background: #f0f0f0; padding: 4px 8px; border-radius: 4px; font-size: 12px;">
|
||||
<code class="code">
|
||||
{{ 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;"
|
||||
<button type="button" class="btn btn-sm btn-ghost"
|
||||
onclick="copyLink('{{ url_for('register_event', code=event.code, type_code=at.code, _external=True) }}')">
|
||||
{{ 'copy'|t }}
|
||||
<i data-lucide="copy"></i>
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<form method="GET" action="{{ url_for('edit_attendee_type', event_id=event.id, type_id=at.id) }}" style="display: inline;">
|
||||
<button type="submit" class="btn btn-sm" style="background: #2563eb; color: #fff; padding: 4px 10px; margin-right: 5px;">{{ 'edit'|t }}</button>
|
||||
<form method="GET" action="{{ url_for('edit_attendee_type', event_id=event.id, type_id=at.id) }}" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-primary"><i data-lucide="pencil"></i></button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -133,7 +69,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="no-types" style="color: #888; text-align: center; padding: 20px;">
|
||||
<p class="no-types text-muted text-center p-4">
|
||||
{{ 'no_attendee_types_yet'|t }}
|
||||
</p>
|
||||
{% endif %}
|
||||
@@ -141,17 +77,15 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copyLink(url) {
|
||||
// Try modern clipboard API first
|
||||
/* copyLink/fallbackCopy now in main.js */
|
||||
function _unused_copyLink(url) {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(url).then(function() {
|
||||
alert('{{ 'link_copied'|t }}');
|
||||
showToast('{{ 'link_copied'|t }}', 'success');
|
||||
}, function(err) {
|
||||
// Fallback for HTTP sites
|
||||
fallbackCopy(url);
|
||||
});
|
||||
} else {
|
||||
// Fallback for older browsers or HTTP sites
|
||||
fallbackCopy(url);
|
||||
}
|
||||
}
|
||||
@@ -168,14 +102,18 @@ function fallbackCopy(url) {
|
||||
try {
|
||||
var successful = document.execCommand('copy');
|
||||
if (successful) {
|
||||
alert('{{ 'link_copied'|t }}');
|
||||
showToast('{{ 'link_copied'|t }}', 'success');
|
||||
} else {
|
||||
alert('{{ 'copy_failed'|t }}');
|
||||
showToast('{{ 'copy_failed'|t }}', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('{{ 'copy_failed'|t }}');
|
||||
showToast('{{ 'copy_failed'|t }}', 'error');
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@@ -8,10 +8,10 @@
|
||||
<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>
|
||||
<a href="{{ url_for('download_rectangular_badges', event_id=event.id) }}" class="btn btn-secondary">🏷️ Rectangular Labels (80x50mm)</a>
|
||||
<a href="{{ url_for('download_attendees_excel', event_id=event.id) }}" class="btn btn-secondary">📥 Download Excel</a>
|
||||
<button onclick="window.print()" class="btn btn-primary">{{ 'print_badges'|t }}</button>
|
||||
<a href="{{ url_for('event_scan', event_id=event.id) }}" class="btn btn-secondary"><i data-lucide="scan-line"></i> {{ 'scan_qr_codes'|t }}</a>
|
||||
<a href="{{ url_for('download_rectangular_badges', event_id=event.id) }}" class="btn btn-secondary"><i data-lucide="tag"></i> Rectangular Labels (80x50mm)</a>
|
||||
<a href="{{ url_for('download_attendees_excel', event_id=event.id) }}" class="btn btn-secondary"><i data-lucide="download"></i> Download Excel</a>
|
||||
<button onclick="window.print()" class="btn btn-primary"><i data-lucide="printer"></i> {{ 'print_badges'|t }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<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 %}
|
||||
{% if attendee.checked_in %}<i data-lucide="check"></i> {{ 'checked_in'|t }}{% else %}{{ 'pending'|t }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="badge-event-bar">
|
||||
@@ -47,27 +47,6 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.badge-header h3 { margin: 0; font-size: 30pt; line-height: 1.1; }
|
||||
.badge-header h3 .first-name,
|
||||
.badge-header h3 .last-name { display: inline; line-height: 1.1; font-size: 30pt; }
|
||||
.badge-header h3 .last-name:before { content: ' '; }
|
||||
@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: 30pt; line-height: 1.1; }
|
||||
.badge-header h3 .first-name,
|
||||
.badge-header h3 .last-name { display: inline !important; line-height: 1.1; font-size: 30pt; }
|
||||
.badge-header h3 .last-name:before { content: ' '; }
|
||||
.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; }
|
||||
.badge-event-bar { background: #000; color: #fff; padding: 8px 15px; margin: 10px -15px -15px; font-size: 12pt; text-align: center; }
|
||||
.badge-event-bar { background: #000; color: #fff; padding: 8px 15px; margin: 10px -15px -15px; font-size: 12pt; text-align: center; }
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(function() {
|
||||
// Create offscreen canvas for text measurement
|
||||
|
||||
@@ -3,58 +3,190 @@
|
||||
{% block title %}{{ session.name }} - {{ 'breakout_session'|t }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="padding: 20px;">
|
||||
<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 class="session-detail-page">
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<h1>{{ session.name }}</h1>
|
||||
<p class="page-subtitle">{{ session.event_name }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<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-ghost">{{ 'back_to_sessions'|t }}</a>
|
||||
</div>
|
||||
</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 }}
|
||||
<div class="session-meta-grid">
|
||||
<div class="meta-card">
|
||||
<div class="meta-icon">
|
||||
<i data-lucide="calendar"></i>
|
||||
</div>
|
||||
<div class="meta-content">
|
||||
<span class="meta-label">{{ 'time'|t }}</span>
|
||||
<span class="meta-value">
|
||||
{% if session.start_time %}
|
||||
{{ session.start_time|localized_date('%H:%M') }} - {{ session.end_time|localized_date('%H:%M') }}
|
||||
{% else %}
|
||||
TBD
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<div class="meta-icon">
|
||||
<i data-lucide="map-pin"></i>
|
||||
</div>
|
||||
<div class="meta-content">
|
||||
<span class="meta-label">{{ 'location'|t }}</span>
|
||||
<span class="meta-value">{{ session.location or 'TBD' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta-card">
|
||||
<div class="meta-icon">
|
||||
<i data-lucide="users"></i>
|
||||
</div>
|
||||
<div class="meta-content">
|
||||
<span class="meta-label">{{ 'capacity'|t }}</span>
|
||||
<span class="meta-value">
|
||||
{% if session.max_attendees %}
|
||||
{{ rsvps|length }} / {{ session.max_attendees }}
|
||||
{% else %}
|
||||
{{ rsvps|length }} ({{ 'unlimited'|t }})
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if session.description %}
|
||||
<div class="session-description-card">
|
||||
<h3>{{ 'description'|t }}</h3>
|
||||
<p>{{ session.description }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="session-sections">
|
||||
<!-- Presenters Section -->
|
||||
<section class="section-card presenters-section">
|
||||
<div class="section-header">
|
||||
<h2>{{ 'presenters'|t }}</h2>
|
||||
<button type="button" class="btn btn-sm btn-primary" onclick="toggleAddPresenter()">
|
||||
<i data-lucide="plus"></i> {{ 'add_presenter'|t }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add Presenter Form -->
|
||||
<div id="add-presenter-form" class="add-presenter-form hidden">
|
||||
<form method="POST" action="{{ url_for('add_presenter_to_session', session_id=session.id) }}">
|
||||
<input type="hidden" name="code" value="{{ session.code }}">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="organizer_id">{{ 'select_organizer'|t }}</label>
|
||||
<select id="organizer_id" name="organizer_id" required>
|
||||
<option value="">{{ 'select_organizer'|t }}...</option>
|
||||
{% for org in available_organizers %}
|
||||
<option value="{{ org.id }}">{{ org.first_name }} {{ org.last_name }} ({{ org.email }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ 'add_presenter'|t }}</button>
|
||||
<button type="button" class="btn btn-ghost" onclick="toggleAddPresenter()">{{ 'cancel'|t }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if presenters %}
|
||||
<div class="presenters-grid">
|
||||
{% for presenter in presenters %}
|
||||
<div class="presenter-card">
|
||||
<div class="presenter-info">
|
||||
<div class="presenter-avatar">
|
||||
{{ presenter.first_name[0] }}{{ presenter.last_name[0] }}
|
||||
</div>
|
||||
<div class="presenter-details">
|
||||
<h4>{{ presenter.first_name }} {{ presenter.last_name }}</h4>
|
||||
<p>{{ presenter.email }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="presenter-qr">
|
||||
<div class="qr-code-display" data-code="{{ presenter.presenter_code }}">
|
||||
<span class="qr-label">{{ 'scan_here'|t }}</span>
|
||||
<span class="qr-code">{{ presenter.presenter_code }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="presenter-actions">
|
||||
<form method="POST" action="{{ url_for('remove_presenter_from_session', session_id=session.id, presenter_id=presenter.id) }}" class="inline-form">
|
||||
<input type="hidden" name="code" value="{{ session.code }}">
|
||||
<button type="submit" class="btn btn-sm btn-ghost btn-remove" data-confirm="{{ 'confirm_remove_presenter'|t }}">
|
||||
<i data-lucide="trash-2"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
{{ rsvps|length }} ({{ 'unlimited'|t }})
|
||||
<div class="empty-state">
|
||||
<i data-lucide="user-x"></i>
|
||||
<p>{{ 'no_presenters_yet'|t }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if session.description %}
|
||||
<p><strong>{{ 'description'|t }}:</strong> {{ session.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="attendees-section">
|
||||
<h2>{{ 'registered_attendees'|t }}</h2>
|
||||
<!-- Registered Attendees Section -->
|
||||
<section class="section-card attendees-section">
|
||||
<div class="section-header">
|
||||
<h2>{{ 'registered_attendees'|t }}</h2>
|
||||
<span class="badge badge-info">{{ rsvps|length }}</span>
|
||||
</div>
|
||||
|
||||
{% 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 %}
|
||||
{% if rsvps %}
|
||||
<div class="table-container">
|
||||
<table class="attendees-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>{{ rsvp.first_name }} {{ rsvp.last_name }}</td>
|
||||
<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>
|
||||
<div class="attendee-name-cell">
|
||||
<span class="attendee-avatar-sm">{{ rsvp.first_name[0] }}{{ rsvp.last_name[0] }}</span>
|
||||
{{ rsvp.first_name }} {{ rsvp.last_name }}
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ rsvp.email }}</td>
|
||||
<td>{{ rsvp.organisation if rsvp.organisation else '-' }}</td>
|
||||
<td>{{ rsvp.role if rsvp.role else '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="no-attendees">{{ 'no_attendees_yet'|t }}</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<i data-lucide="users"></i>
|
||||
<p>{{ 'no_attendees_registered'|t }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleAddPresenter() {
|
||||
const form = document.getElementById('add-presenter-form');
|
||||
form.classList.toggle('hidden');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,99 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'communications'|t }} - {{ event.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="communications-page">
|
||||
<div class="page-header">
|
||||
<h1>{{ 'communications'|t }}</h1>
|
||||
<a href="{{ url_for('event_detail', code=event.code) }}" class="btn btn-outline"><i data-lucide="arrow-left"></i> {{ 'back_to_event'|t }}</a>
|
||||
</div>
|
||||
|
||||
<div class="communications-section">
|
||||
<div class="section-box">
|
||||
<div class="section-header">
|
||||
<h2>{{ 'email_templates'|t }}</h2>
|
||||
<p class="text-muted">{{ 'customize_email_content'|t }}</p>
|
||||
</div>
|
||||
|
||||
<div class="templates-grid">
|
||||
{% for template_type, config in template_types.items() %}
|
||||
{% if template_type in templates_map %}
|
||||
{% set template = templates_map[template_type] %}
|
||||
<div class="template-card">
|
||||
<div class="template-header">
|
||||
<h3>{{ config.name }}</h3>
|
||||
<a href="{{ url_for('edit_email_template', event_id=event.id, template_type=template_type) }}" class="btn btn-sm btn-outline">
|
||||
<i data-lucide="pencil"></i> {{ 'edit'|t }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="template-preview">
|
||||
<p><strong>{{ 'subject'|t }}:</strong> {{ template.subject }}</p>
|
||||
<p class="template-body">{{ template.body[:200] }}...</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-box">
|
||||
<div class="section-header">
|
||||
<h2>{{ 'send_custom_email'|t }}</h2>
|
||||
<p class="text-muted">{{ 'send_custom_email_desc'|t }}</p>
|
||||
</div>
|
||||
<a href="{{ url_for('send_custom_email', event_id=event.id) }}" class="btn btn-primary">
|
||||
<i data-lucide="mail"></i> {{ 'compose_email'|t }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="section-box">
|
||||
<div class="section-header">
|
||||
<h2>{{ 'sent_emails'|t }}</h2>
|
||||
<p class="text-muted">{{ 'recent_communications'|t }}</p>
|
||||
</div>
|
||||
|
||||
{% if sent_history %}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'date'|t }}</th>
|
||||
<th>{{ 'subject'|t }}</th>
|
||||
<th>{{ 'recipient_type'|t }}</th>
|
||||
<th>{{ 'recipient'|t }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for email in sent_history %}
|
||||
<tr>
|
||||
<td>{{ email.sent_at|localized_date }}</td>
|
||||
<td>{{ email.subject }}</td>
|
||||
<td>{{ email.recipient_type|t }}</td>
|
||||
<td>
|
||||
{% if email.recipient_id %}
|
||||
{% if email.recipient_type == 'attendee' %}
|
||||
{{ email.recipient_id }}
|
||||
{% else %}
|
||||
{{ email.recipient_id }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ email.recipient_type|t }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="text-muted">{{ 'no_emails_sent_yet'|t }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,38 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'edit_template'|t }} - {{ event.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="communications-edit-page">
|
||||
<div class="page-header">
|
||||
<h1>{{ 'edit'|t }}: {{ template_type_name }}</h1>
|
||||
<a href="{{ url_for('event_communications', event_id=event.id) }}" class="btn btn-outline"><i data-lucide="arrow-left"></i> {{ 'back'|t }}</a>
|
||||
</div>
|
||||
|
||||
<div class="section-box">
|
||||
<form method="POST" class="template-edit-form">
|
||||
<div class="form-group">
|
||||
<label for="subject">{{ 'subject'|t }}</label>
|
||||
<input type="text" id="subject" name="subject" value="{{ template.subject }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="body">{{ 'body'|t }}</label>
|
||||
<textarea id="body" name="body" rows="15" required>{{ template.body }}</textarea>
|
||||
<small class="text-muted">{{ 'available_placeholders'|t }}: {staff_name}, {attendee_name}, {event_name}, {invite_url}, {reminder_count}, {event_date}, {event_location}, {changes_text}, {session_name}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary"><i data-lucide="save"></i> {{ 'save_changes'|t }}</button>
|
||||
<a href="{{ url_for('event_communications', event_id=event.id) }}" class="btn btn-secondary">{{ 'cancel'|t }}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,88 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'send_email'|t }} - {{ event.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="communications-send-page">
|
||||
<div class="page-header">
|
||||
<h1>{{ 'send_custom_email'|t }}</h1>
|
||||
<a href="{{ url_for('event_communications', event_id=event.id) }}" class="btn btn-outline"><i data-lucide="arrow-left"></i> {{ 'back'|t }}</a>
|
||||
</div>
|
||||
|
||||
<div class="section-box">
|
||||
<form method="POST" class="send-email-form">
|
||||
<div class="form-group">
|
||||
<label for="recipient_type">{{ 'recipient_type'|t }}</label>
|
||||
<select id="recipient_type" name="recipient_type" required onchange="toggleRecipientSelection()">
|
||||
<option value="">{{ 'select_recipient_type'|t }}</option>
|
||||
<option value="all_attendees">{{ 'all_attendees'|t }}</option>
|
||||
<option value="all_staff">{{ 'all_staff'|t }}</option>
|
||||
<option value="attendee">{{ 'specific_attendees'|t }}</option>
|
||||
<option value="staff">{{ 'specific_staff'|t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="recipient-selection" class="form-group d-none">
|
||||
<label>{{ 'select_recipients'|t }}</label>
|
||||
<div class="recipient-list">
|
||||
<div id="attendee-list" class="d-none">
|
||||
{% for attendee in attendees %}
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="recipient_ids" value="{{ attendee.id }}"> <span class="first-name">{{ attendee.first_name }}</span> <span class="last-name">{{ attendee.last_name }}</span> ({{ attendee.email }})
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div id="staff-list" class="d-none">
|
||||
{% for s in staff_members %}
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="recipient_ids" value="{{ s.id }}"> <span class="first-name">{{ s.first_name }}</span> <span class="last-name">{{ s.last_name }}</span> ({{ s.email }})
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="subject">{{ 'subject'|t }}</label>
|
||||
<input type="text" id="subject" name="subject" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="body">{{ 'message'|t }}</label>
|
||||
<textarea id="body" name="body" rows="10" required></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary"><i data-lucide="send"></i> {{ 'send_email'|t }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
|
||||
function toggleRecipientSelection() {
|
||||
const recipientType = document.getElementById('recipient_type').value;
|
||||
const attendeeList = document.getElementById('attendee-list');
|
||||
const staffList = document.getElementById('staff-list');
|
||||
const recipientSelection = document.getElementById('recipient-selection');
|
||||
|
||||
// Hide both first
|
||||
attendeeList.classList.add('d-none');
|
||||
staffList.classList.add('d-none');
|
||||
|
||||
if (recipientType === 'attendee') {
|
||||
recipientSelection.classList.remove('d-none');
|
||||
attendeeList.classList.remove('d-none');
|
||||
} else if (recipientType === 'staff') {
|
||||
recipientSelection.classList.remove('d-none');
|
||||
staffList.classList.remove('d-none');
|
||||
} else {
|
||||
recipientSelection.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
+137
-331
@@ -3,205 +3,142 @@
|
||||
{% block title %}{{ 'dashboard'|t }} - {{ 'organizer'|t }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.dashboard {
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 30px 20px;
|
||||
box-sizing: border-box;
|
||||
max-width: 1600px;
|
||||
}
|
||||
.events-list {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)) !important;
|
||||
gap: 1.5rem !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
.event-item {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
.event-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.event-info {
|
||||
width: 100%;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
.pagination-controls button {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
<div class="dashboard organizer-dashboard">
|
||||
<h1>{{ 'dashboard'|t }} - {{ 'organizer'|t }}</h1>
|
||||
|
||||
/* Wide screen - spread content */
|
||||
@media (min-width: 1400px) {
|
||||
.dashboard {
|
||||
max-width: 100%;
|
||||
}
|
||||
<div class="dashboard-actions">
|
||||
<a href="{{ url_for('create_event') }}" class="btn btn-primary"><i data-lucide="plus-circle"></i> {{ 'create_event'|t }}</a>
|
||||
</div>
|
||||
|
||||
.events-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
<section class="my-events">
|
||||
<h2>{{ 'my_events'|t }}</h2>
|
||||
{% if events %}
|
||||
<div class="events-list organizer-events-list">
|
||||
{% for event in events %}
|
||||
<div class="event-item organizer-event-item card-static">
|
||||
<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"><i data-lucide="eye"></i> {{ '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>
|
||||
|
||||
.event-item {
|
||||
padding: 20px 25px;
|
||||
}
|
||||
{% if event.max_attendees %}
|
||||
{% set percent = (event.attendee_count / event.max_attendees * 100)|round|int %}
|
||||
<div class="organizer-event-capacity">
|
||||
<span>{{ event.attendee_count }} / {{ event.max_attendees }} {{ 'attendees'|t }}</span>
|
||||
<span>({{ percent }}%)</span>
|
||||
</div>
|
||||
<div class="organizer-progress-bar">
|
||||
<div class="progress-fill {% if percent >= 100 %}full{% elif percent >= 80 %}warning{% endif %}" style="width: {{ percent if percent <= 100 else 100 }}%"></div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="organizer-event-capacity">
|
||||
<span>{{ event.attendee_count }} {{ 'attendees'|t }} ({{ 'unlimited'|t }})</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
.event-info {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
{% if event.staff %}
|
||||
<div class="organizer-event-section">
|
||||
<div class="section-toggle collapsed" onclick="toggleSection('staff-{{ event.id }}')">
|
||||
<strong><i data-lucide="users"></i> Staff ({{ event.staff|length }})</strong>
|
||||
</div>
|
||||
<div id="staff-{{ event.id }}" class="section-content collapsed" data-item-count="{{ event.staff|length }}" data-per-page="5">
|
||||
<div class="item-list">
|
||||
{% for s in event.staff %}
|
||||
<div class="list-item">
|
||||
<div class="list-item-info">
|
||||
<strong><span class="first-name">{{ s.first_name }}</span> <span class="last-name">{{ s.last_name }}</span></strong>
|
||||
<span>{{ 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"><i data-lucide="pencil"></i></a>
|
||||
<form method="POST" action="{{ url_for('delete_staff', event_id=event.id, staff_id=s.id) }}" class="d-inline" data-confirm="Remove {{ s.first_name }} {{ s.last_name }}?">
|
||||
<button type="submit" class="btn btn-sm btn-danger"><i data-lucide="trash-2"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="pagination-controls"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
.breakout-sessions-list {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
{% if event.breakout_sessions %}
|
||||
<div class="organizer-event-section">
|
||||
<div class="section-toggle collapsed" onclick="toggleSection('sessions-{{ event.id }}')">
|
||||
<strong><i data-lucide="calendar"></i> {{ 'breakout_sessions'|t }} ({{ event.breakout_sessions|length }})</strong>
|
||||
</div>
|
||||
<div id="sessions-{{ event.id }}" class="section-content collapsed" data-item-count="{{ event.breakout_sessions|length }}" data-per-page="5">
|
||||
<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>{{ session.rsvp_count }} / {{ session.max_attendees }}</span>
|
||||
{% else %}
|
||||
<span>{{ 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"><i data-lucide="pencil"></i></a>
|
||||
<form method="POST" action="{{ url_for('delete_breakout_session', session_id=session.id) }}" class="d-inline" data-confirm="Delete {{ session.name }}?">
|
||||
<button type="submit" class="btn btn-sm btn-danger"><i data-lucide="trash-2"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="pagination-controls"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@media (min-width: 1800px) {
|
||||
.events-list {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% if event.attendees %}
|
||||
<div class="organizer-event-section">
|
||||
<div class="section-toggle collapsed" onclick="toggleSection('attendees-{{ event.id }}')">
|
||||
<strong><i data-lucide="users"></i> {{ 'attendees'|t }} ({{ event.attendees|length }})</strong>
|
||||
</div>
|
||||
<div id="attendees-{{ event.id }}" class="section-content collapsed" data-item-count="{{ event.attendees|length }}" data-per-page="5">
|
||||
<div class="item-list">
|
||||
{% for att in event.attendees %}
|
||||
<div class="list-item">
|
||||
<div class="list-item-info">
|
||||
<strong><span class="first-name">{{ att.first_name }}</span> <span class="last-name">{{ att.last_name }}</span></strong>
|
||||
<span>{{ att.email }}</span>
|
||||
{% if att.checked_in %}
|
||||
<span class="badge badge-success badge-sm"><i data-lucide="check"></i> {{ 'checked_in'|t }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="list-item-actions">
|
||||
<a href="{{ url_for('edit_attendee', attendee_id=att.id) }}" class="btn btn-sm btn-outline"><i data-lucide="pencil"></i></a>
|
||||
<form method="POST" action="{{ url_for('delete_attendee', attendee_id=att.id) }}" class="d-inline" data-confirm="Remove {{ att.first_name }} {{ att.last_name }}?">
|
||||
<button type="submit" class="btn btn-sm btn-danger"><i data-lucide="trash-2"></i></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="pagination-controls"></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>
|
||||
|
||||
<script>
|
||||
function toggleSection(id) {
|
||||
@@ -248,143 +185,12 @@ function setupPagination(sectionId, totalItems, perPage) {
|
||||
}
|
||||
showPage(sectionId, 1, perPage);
|
||||
}
|
||||
|
||||
// Re-initialize icons after dynamic content
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
});
|
||||
</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" data-item-count="{{ event.staff|length }}" data-per-page="5">
|
||||
<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 class="pagination-controls"></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" data-item-count="{{ event.breakout_sessions|length }}" data-per-page="5">
|
||||
<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 class="pagination-controls"></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" data-item-count="{{ event.attendees|length }}" data-per-page="5">
|
||||
<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 class="pagination-controls"></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 %}
|
||||
@@ -3,51 +3,12 @@
|
||||
{% block title %}{{ 'edit_attendee_type'|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;
|
||||
}
|
||||
.back-link {
|
||||
margin-bottom: 20px;
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="edit-attendee-type-page">
|
||||
<a href="{{ url_for('manage_attendee_types', event_id=event.id) }}" class="back-link">← {{ 'back_to_types'|t }}</a>
|
||||
<a href="{{ url_for('manage_attendee_types', event_id=event.id) }}" class="back-link"><i data-lucide="arrow-left"></i> {{ 'back_to_types'|t }}</a>
|
||||
|
||||
<h1>{{ 'edit_attendee_type'|t }} - {{ event.name }}</h1>
|
||||
|
||||
<div class="section-box">
|
||||
<div class="section-box card">
|
||||
<h2>{{ 'type_details'|t }}</h2>
|
||||
<form method="POST" action="{{ url_for('edit_attendee_type', event_id=event.id, type_id=attendee_type.id) }}">
|
||||
<div class="form-row">
|
||||
@@ -58,22 +19,29 @@
|
||||
<div class="form-group">
|
||||
<label for="price">{{ 'price'|t }} ({{ 'optional'|t }})</label>
|
||||
<input type="number" id="price" name="price" step="0.01" min="0" value="{{ attendee_type.price if attendee_type.price else '' }}" placeholder="0.00">
|
||||
<small style="color: #666;">{{ 'leave_empty_for_free'|t }}</small>
|
||||
<small class="text-muted">{{ 'leave_empty_for_free'|t }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">{{ 'save_changes'|t }}</button>
|
||||
<a href="{{ url_for('manage_attendee_types', event_id=event.id) }}" class="btn btn-secondary" style="margin-left: 10px; padding: 0.75rem 1.5rem; font-size: 1rem; font-weight: 400; line-height: 1; height: auto; box-sizing: border-box; display: inline-block; text-decoration: none; border-radius: 6px; cursor: pointer; transition: all 0.2s; vertical-align: middle; color: white; background-color: #64748b;">{{ 'cancel'|t }}</a>
|
||||
{% if attendee_count == 0 %}
|
||||
<form method="POST" action="{{ url_for('delete_attendee_type', event_id=event.id, type_id=attendee_type.id) }}" style="display: inline; margin-left: 10px;"
|
||||
onsubmit="return confirm('{{ 'confirm_delete_type'|t }}');">
|
||||
<button type="submit" class="btn btn-danger">{{ 'delete'|t }}</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-danger" disabled
|
||||
title="{{ 'cannot_delete_type_with_attendees'|t|format(attendee_count) }}"
|
||||
style="margin-left: 10px; opacity: 0.5; cursor: not-allowed;">{{ 'delete'|t }}</button>
|
||||
{% endif %}
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary"><i data-lucide="save"></i> {{ 'save_changes'|t }}</button>
|
||||
<a href="{{ url_for('manage_attendee_types', event_id=event.id) }}" class="btn btn-secondary">{{ 'cancel'|t }}</a>
|
||||
{% if attendee_count == 0 %}
|
||||
<form method="POST" action="{{ url_for('delete_attendee_type', event_id=event.id, type_id=attendee_type.id) }}" class="d-inline"
|
||||
data-confirm="{{ 'confirm_delete_type'|t }}">
|
||||
<button type="submit" class="btn btn-danger"><i data-lucide="trash-2"></i> {{ 'delete'|t }}</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-danger" disabled
|
||||
title="{{ 'cannot_delete_type_with_attendees'|t|format(attendee_count) }}">{{ 'delete'|t }}</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -3,82 +3,6 @@
|
||||
{% 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">
|
||||
@@ -92,7 +16,7 @@
|
||||
<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="arrow"><i data-lucide="arrow-right"></i></span>
|
||||
<span class="new-value">{{ new_val or '(' + 'empty' + ')' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,12 +29,16 @@
|
||||
|
||||
<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>
|
||||
<button type="button" class="btn btn-primary" onclick="notifyAttendees()"><i data-lucide="mail"></i> {{ 'notify_attendees_email'|t }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
|
||||
function notifyAttendees() {
|
||||
const changesText = `{% for field, old_val, new_val in changes %}{{ field }}: {{ old_val }} → {{ new_val }}
|
||||
{% endfor %}`.trim();
|
||||
|
||||
@@ -3,82 +3,6 @@
|
||||
{% 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">
|
||||
@@ -92,7 +16,7 @@
|
||||
<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="arrow"><i data-lucide="arrow-right"></i></span>
|
||||
<span class="new-value">{{ new_val or '(' + 'empty' + ')' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,12 +29,16 @@
|
||||
|
||||
<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>
|
||||
<button type="button" class="btn btn-primary" onclick="notifyAttendees()"><i data-lucide="mail"></i> {{ 'notify_attendees_email'|t }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
|
||||
function notifyAttendees() {
|
||||
const changesText = `{% for field, old_val, new_val in changes %}{{ field }}: {{ old_val }} → {{ new_val }}
|
||||
{% endfor %}`.trim();
|
||||
|
||||
@@ -3,170 +3,12 @@
|
||||
{% block title %}{{ event.name }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="padding: 20px;">
|
||||
<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>
|
||||
<a href="{{ url_for('event_communications', event_id=event.id) }}" class="btn btn-outline"><i data-lucide="mail"></i> {{ 'communications'|t }}</a>
|
||||
<a href="{{ url_for('edit_event', event_id=event.id) }}" class="btn btn-outline"><i data-lucide="pencil"></i> {{ 'edit_event'|t }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -185,14 +27,14 @@ th.sort-desc .sort-icon::before {
|
||||
<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>
|
||||
<p><strong>{{ 'registration_link'|t }}:</strong> <span id="reg-link">{{ url_for('register_event', code=event.code, _external=True) }}</span> <button type="button" class="btn btn-sm btn-ghost" onclick="copyLink('{{ url_for('register_event', code=event.code, _external=True) }}')"><i data-lucide="copy"></i></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>
|
||||
<a href="{{ url_for('manage_attendee_types', event_id=event.id) }}" class="btn btn-primary"><i data-lucide="tag"></i> {{ 'manage_types'|t }}</a>
|
||||
</div>
|
||||
|
||||
{% if attendee_types %}
|
||||
@@ -210,18 +52,18 @@ th.sort-desc .sort-icon::before {
|
||||
<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>
|
||||
<span class="text-success font-medium">{{ at.price|format_currency }}</span>
|
||||
{% else %}
|
||||
<span style="color: #7f8c8d;">{{ 'free'|t }}</span>
|
||||
<span class="text-muted">{{ 'free'|t }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<code style="background: #f0f0f0; padding: 4px 8px; border-radius: 4px; font-size: 12px;">
|
||||
<code class="code">
|
||||
{{ 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;"
|
||||
<button type="button" class="btn btn-sm btn-ghost"
|
||||
onclick="copyLink('{{ url_for('register_event', code=event.code, type_code=at.code, _external=True) }}')">
|
||||
{{ 'copy'|t }}
|
||||
<i data-lucide="copy"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -229,7 +71,7 @@ th.sort-desc .sort-icon::before {
|
||||
</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>
|
||||
<p class="no-types">{{ '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>
|
||||
@@ -238,10 +80,10 @@ th.sort-desc .sort-icon::before {
|
||||
<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>
|
||||
<button type="button" class="btn btn-primary" id="add-staff-btn"><i data-lucide="user-plus"></i> {{ 'add_staff'|t }}</button>
|
||||
</div>
|
||||
|
||||
<div id="add-staff-form" style="display: none; margin-bottom: 20px; padding: 15px; background: #f8f9fa; border-radius: 5px;">
|
||||
<div id="add-staff-form" class="add-staff-form d-none">
|
||||
<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">
|
||||
@@ -261,9 +103,9 @@ th.sort-desc .sort-icon::before {
|
||||
<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;">
|
||||
<form method="POST" action="{{ url_for('batch_add_staff', event_id=event.id) }}" class="border-top pt-4 mt-4">
|
||||
<div class="form-row align-items-end">
|
||||
<div class="form-group">
|
||||
<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>
|
||||
@@ -282,16 +124,18 @@ th.sort-desc .sort-icon::before {
|
||||
<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="first_name" class="sortable">{{ 'first_name'|t }} <span class="sort-icon"></span></th>
|
||||
<th data-sort="last_name" class="sortable">{{ 'last_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 data-sort="invite_token" 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>
|
||||
<tr data-first-name="{{ member.first_name }}" data-last-name="{{ member.last_name }}" data-email="{{ member.email }}" data-status="{{ member.invite_token }}">
|
||||
<td>{{ member.first_name }}</td>
|
||||
<td>{{ member.last_name }}</td>
|
||||
<td>{{ member.email }}</td>
|
||||
<td>
|
||||
{% if member.invite_token %}
|
||||
@@ -301,29 +145,45 @@ th.sort-desc .sort-icon::before {
|
||||
{% 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>
|
||||
<a href="{{ url_for('edit_staff', event_id=event.id, staff_id=member.id) }}" class="btn btn-sm btn-success"><i data-lucide="eye"></i></a>
|
||||
<button type="button" class="btn btn-sm btn-danger" onclick="showDeleteModal({{ member.id }}, '{{ member.first_name }} {{ member.last_name }}', '{{ member.email }}')"><i data-lucide="trash-2"></i></button>
|
||||
{% if member.invite_token %}
|
||||
<button type="button" class="btn btn-sm btn-outline remind-btn" data-staff-id="{{ member.id }}" data-reminder-dates="{{ member.reminder_dates|tojson }}" title="{{ 'send_reminder'|t }}">
|
||||
<i data-lucide="bell"></i> <span class="reminder-count">{{ member.reminder_count|default(0) }}</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if total_staff_pages > 1 %}
|
||||
<div class="pagination mt-4" id="staff-pagination">
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="loadStaffPage({{ staff_page - 1 }})" {% if staff_page <= 1 %}disabled{% endif %}>
|
||||
<i data-lucide="chevron-left"></i>
|
||||
</button>
|
||||
<span class="pagination-info">{{ 'page'|t }} <span id="staff-current-page">{{ staff_page }}</span> {{ 'of'|t }} {{ total_staff_pages }}</span>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="loadStaffPage({{ staff_page + 1 }})" {% if staff_page >= total_staff_pages %}disabled{% endif %}>
|
||||
<i data-lucide="chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="no-staff">{{ 'no_staff_yet'|t }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="staffDeleteModal" class="modal-overlay" style="display: none;">
|
||||
<div id="staffDeleteModal" class="modal-overlay">
|
||||
<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>
|
||||
<p class="text-muted text-sm">{{ '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;">
|
||||
<form id="staffDeleteForm" method="POST" class="d-inline">
|
||||
<button type="submit" class="btn btn-danger">{{ 'remove'|t }}</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -334,7 +194,7 @@ th.sort-desc .sort-icon::before {
|
||||
<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>
|
||||
<a href="{{ url_for('create_breakout_session', event_id=event.id) }}" class="btn btn-primary"><i data-lucide="plus"></i> {{ 'add_breakout_session'|t }}</a>
|
||||
</div>
|
||||
|
||||
{% if breakout_sessions %}
|
||||
@@ -358,7 +218,7 @@ th.sort-desc .sort-icon::before {
|
||||
<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>
|
||||
<a href="{{ url_for('view_breakout_session', code=session.code) }}" class="btn btn-sm btn-success"><i data-lucide="eye"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -374,75 +234,88 @@ th.sort-desc .sort-icon::before {
|
||||
<div class="section-box">
|
||||
<h2>{{ 'registered_attendees'|t }} ({{ attendees|length }})</h2>
|
||||
<div class="section-actions">
|
||||
<a href="{{ url_for('register_attendee', code=event.code) }}" class="btn btn-primary">{{ 'add_attendee'|t }}</a>
|
||||
<a href="{{ url_for('event_badges', event_id=event.id) }}" class="btn btn-secondary">🖨️ {{ 'print_badges'|t }}</a>
|
||||
<a href="{{ url_for('download_rectangular_badges', event_id=event.id) }}" class="btn btn-secondary">🏷️ Rectangular Labels (80x50mm)</a>
|
||||
<a href="{{ url_for('download_attendees_excel', event_id=event.id) }}" class="btn btn-secondary">📥 {{ 'download_excel'|t }}</a>
|
||||
<a href="{{ url_for('register_attendee', code=event.code) }}" class="btn btn-primary"><i data-lucide="user-plus"></i> {{ 'add_attendee'|t }}</a>
|
||||
<a href="{{ url_for('event_badges', event_id=event.id) }}" class="btn btn-secondary"><i data-lucide="printer"></i> {{ 'print_badges'|t }}</a>
|
||||
<a href="{{ url_for('download_rectangular_badges', event_id=event.id) }}" class="btn btn-secondary"><i data-lucide="tag"></i> Rectangular Labels</a>
|
||||
<a href="{{ url_for('download_attendees_excel', event_id=event.id) }}" class="btn btn-secondary"><i data-lucide="download"></i> {{ 'download_excel'|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;">
|
||||
<div class="batch-select-bar">
|
||||
<input type="checkbox" id="select-all-attendees">
|
||||
<label for="select-all-attendees" class="m-0 font-semibold">{{ 'select_all'|t }}</label>
|
||||
<span class="ml-4">{{ 'assign_type'|t }}:</span>
|
||||
<select name="attendee_type_id" id="batch-type-select" class="batch-type-select">
|
||||
<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>
|
||||
<span id="selected-count" class="text-muted text-sm"></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 class="checkbox-col"></th>
|
||||
<th data-sort="first_name" class="sortable">{{ 'first_name'|t }} <span class="sort-icon"></span></th>
|
||||
<th data-sort="last_name" class="sortable">{{ 'last_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 data-sort="attendee_type_id" class="sortable">{{ 'type'|t }} <span class="sort-icon"></span></th>
|
||||
<th data-sort="checked_in" 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 '' }}">
|
||||
<tr data-first-name="{{ attendee.first_name }}" data-last-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.first_name }}</td>
|
||||
<td>{{ attendee.last_name }}</td>
|
||||
<td>{{ attendee.organisation if attendee.organisation else '-' }}</td>
|
||||
<td>{{ attendee.role if attendee.role else '-' }}</td>
|
||||
<td>
|
||||
{% if attendee.attendee_type_name %}
|
||||
<span class="badge" style="background: #3498db; color: white;">{{ attendee.attendee_type_name }}</span>
|
||||
<span class="badge badge-info">{{ attendee.attendee_type_name }}</span>
|
||||
{% else %}
|
||||
<span style="color: #aaa;">-</span>
|
||||
<span class="text-light">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if attendee.checked_in %}
|
||||
<span class="badge badge-success">{{ 'checked_in'|t }}</span>
|
||||
<span class="badge badge-success"><i data-lucide="check"></i> {{ '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>
|
||||
<button class="btn btn-sm btn-success checkin-btn" data-attendee-id="{{ attendee.id }}"><i data-lucide="user-check"></i></button>
|
||||
{% else %}
|
||||
<span class="text-muted">{{ 'checked_in'|t }}</span>
|
||||
<span class="text-muted"><i data-lucide="check"></i></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if total_pages > 1 %}
|
||||
<div class="pagination mt-4" id="attendees-pagination">
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="loadAttendeesPage({{ page - 1 }})" {% if page <= 1 %}disabled{% endif %}>
|
||||
<i data-lucide="chevron-left"></i>
|
||||
</button>
|
||||
<span class="pagination-info">{{ 'page'|t }} <span id="attendees-current-page">{{ page }}</span> {{ 'of'|t }} {{ total_pages }}</span>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="loadAttendeesPage({{ page + 1 }})" {% if page >= total_pages %}disabled{% endif %}>
|
||||
<i data-lucide="chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="no-attendees">{{ 'no_attendees_yet'|t }}</p>
|
||||
{% endif %}
|
||||
@@ -451,14 +324,18 @@ th.sort-desc .sort-icon::before {
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
|
||||
document.getElementById('add-staff-btn').addEventListener('click', function() {
|
||||
document.getElementById('add-staff-form').style.display = 'block';
|
||||
this.style.display = 'none';
|
||||
document.getElementById('add-staff-form').classList.add('shown');
|
||||
this.classList.add('d-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('add-staff-form').classList.remove('shown');
|
||||
document.getElementById('add-staff-btn').classList.remove('d-none');
|
||||
});
|
||||
|
||||
document.getElementById('staff-inline-form').addEventListener('submit', function(e) {
|
||||
@@ -476,13 +353,10 @@ document.getElementById('staff-inline-form').addEventListener('submit', function
|
||||
location.reload();
|
||||
}
|
||||
}).catch(error => {
|
||||
alert('{{ "error_adding_staff"|t }}');
|
||||
showToast('{{ "error_adding_staff"|t }}', 'error');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.checkin-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
const attendeeId = this.dataset.attendeeId;
|
||||
@@ -494,89 +368,51 @@ document.querySelectorAll('.checkin-btn').forEach(btn => {
|
||||
});
|
||||
|
||||
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>';
|
||||
row.querySelector('td:nth-child(6)').innerHTML = '<span class="badge badge-success"><i data-lucide="check"></i> {{ "checked_in"|t }}</span>';
|
||||
this.remove();
|
||||
document.getElementById('attendee-count').textContent = parseInt(document.getElementById('attendee-count').textContent) + 1;
|
||||
lucide.createIcons();
|
||||
}
|
||||
} catch (error) {
|
||||
alert('{{ "error_checking_in"|t }}');
|
||||
showToast('{{ "error_checking_in"|t }}', 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Attendee table sorting
|
||||
const sortableHeaders = document.querySelectorAll('#attendees-table th.sortable');
|
||||
let currentSort = { column: null, direction: 'asc' };
|
||||
// Attendee table sorting - AJAX-based with backend sorting
|
||||
let currentAttendeeSort = { column: 'first_name', direction: 'asc' };
|
||||
|
||||
const sortableHeaders = document.querySelectorAll('#attendees-table th.sortable');
|
||||
sortableHeaders.forEach(th => {
|
||||
th.addEventListener('click', () => {
|
||||
const column = th.dataset.sort;
|
||||
const direction = currentSort.column === column && currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
currentSort = { column, direction };
|
||||
if (!column) return;
|
||||
const direction = currentAttendeeSort.column === column && currentAttendeeSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
currentAttendeeSort = { 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));
|
||||
loadAttendeesPage(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Staff table sorting
|
||||
const staffSortableHeaders = document.querySelectorAll('#event-staff-table th.sortable');
|
||||
let staffCurrentSort = { column: null, direction: 'asc' };
|
||||
// Staff table sorting - AJAX-based with backend sorting
|
||||
let currentStaffSort = { column: 'first_name', direction: 'asc' };
|
||||
|
||||
const staffSortableHeaders = document.querySelectorAll('#event-staff-table th.sortable');
|
||||
staffSortableHeaders.forEach(th => {
|
||||
th.addEventListener('click', () => {
|
||||
const column = th.dataset.sort;
|
||||
const direction = staffCurrentSort.column === column && staffCurrentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
staffCurrentSort = { column, direction };
|
||||
if (!column) return;
|
||||
const direction = currentStaffSort.column === column && currentStaffSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
currentStaffSort = { 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));
|
||||
loadStaffPage(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -584,13 +420,46 @@ 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';
|
||||
document.getElementById('staffDeleteModal').classList.add('active');
|
||||
}
|
||||
|
||||
function closeStaffDeleteModal() {
|
||||
document.getElementById('staffDeleteModal').style.display = 'none';
|
||||
document.getElementById('staffDeleteModal').classList.remove('active');
|
||||
}
|
||||
|
||||
document.getElementById('staffDeleteModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeStaffDeleteModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Reminder button handler
|
||||
document.querySelectorAll('.remind-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
const staffId = this.dataset.staffId;
|
||||
const countSpan = this.querySelector('.reminder-count');
|
||||
|
||||
try {
|
||||
const response = await fetch(`{{ url_for('send_staff_reminder', event_id=event.id, staff_id=0) }}`.replace('0', staffId), {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
countSpan.textContent = data.reminder_count;
|
||||
// Update the data attribute with new dates
|
||||
this.dataset.reminderDates = JSON.stringify(data.reminder_dates);
|
||||
showToast('{{ "reminder_sent"|t }}', 'success');
|
||||
} else {
|
||||
showToast(data.error || '{{ "error_sending_reminder"|t }}', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('{{ "error_sending_reminder"|t }}', 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Select all attendees checkbox
|
||||
document.getElementById('select-all-attendees').addEventListener('change', function() {
|
||||
const checkboxes = document.querySelectorAll('.attendee-checkbox');
|
||||
@@ -604,49 +473,175 @@ document.querySelectorAll('.attendee-checkbox').forEach(cb => {
|
||||
|
||||
function updateSelectedCount() {
|
||||
const selected = document.querySelectorAll('.attendee-checkbox:checked').length;
|
||||
document.getElementById('selected-count').textContent = selected > 0 ? `(${selected} {{ 'selected'|t }})` : '';
|
||||
document.getElementById('selected-count').textContent = selected > 0 ? `(${selected} selected)` : '';
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
// Staff pagination via AJAX
|
||||
function loadStaffPage(pageNum) {
|
||||
const currentUrl = new URL(window.location.href);
|
||||
currentUrl.searchParams.set('staff_page', pageNum);
|
||||
currentUrl.searchParams.set('staff_sort_by', currentStaffSort.column);
|
||||
currentUrl.searchParams.set('staff_sort_order', currentStaffSort.direction);
|
||||
|
||||
fetch(currentUrl.pathname + '?' + currentUrl.searchParams.toString(), {
|
||||
headers: { 'Accept': 'text/html' }
|
||||
})
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
|
||||
const staffSection = doc.querySelector('.staff-section .section-box');
|
||||
const staffPagination = doc.querySelector('#staff-pagination');
|
||||
|
||||
if (staffSection && staffPagination) {
|
||||
const currentStaffSection = document.querySelector('.staff-section .section-box');
|
||||
currentStaffSection.innerHTML = staffSection.innerHTML;
|
||||
|
||||
const currentPagination = document.querySelector('#staff-pagination');
|
||||
if (currentPagination) {
|
||||
currentPagination.innerHTML = staffPagination.innerHTML;
|
||||
}
|
||||
|
||||
lucide.createIcons();
|
||||
|
||||
// Re-attach staff sort handlers
|
||||
const newStaffSortableHeaders = document.querySelectorAll('#event-staff-table th.sortable');
|
||||
newStaffSortableHeaders.forEach(th => {
|
||||
th.addEventListener('click', () => {
|
||||
const column = th.dataset.sort;
|
||||
if (!column) return;
|
||||
const direction = currentStaffSort.column === column && currentStaffSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
currentStaffSort = { column, direction };
|
||||
|
||||
newStaffSortableHeaders.forEach(h => h.classList.remove('sort-asc', 'sort-desc'));
|
||||
th.classList.add(direction === 'asc' ? 'sort-asc' : 'sort-desc');
|
||||
|
||||
loadStaffPage(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Re-attach reminder handlers
|
||||
document.querySelectorAll('.remind-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
const staffId = this.dataset.staffId;
|
||||
const countSpan = this.querySelector('.reminder-count');
|
||||
|
||||
try {
|
||||
const response = await fetch(`{{ url_for('send_staff_reminder', event_id=event.id, staff_id=0) }}`.replace('0', staffId), {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
countSpan.textContent = data.reminder_count;
|
||||
this.dataset.reminderDates = JSON.stringify(data.reminder_dates);
|
||||
showToast('{{ "reminder_sent"|t }}', 'success');
|
||||
} else {
|
||||
showToast(data.error || '{{ "error_sending_reminder"|t }}', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('{{ "error_sending_reminder"|t }}', 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
window.location.href = currentUrl.toString();
|
||||
});
|
||||
}
|
||||
|
||||
// Attendees pagination via AJAX
|
||||
function loadAttendeesPage(pageNum) {
|
||||
const currentUrl = new URL(window.location.href);
|
||||
currentUrl.searchParams.set('page', pageNum);
|
||||
currentUrl.searchParams.set('sort_by', currentAttendeeSort.column);
|
||||
currentUrl.searchParams.set('sort_order', currentAttendeeSort.direction);
|
||||
|
||||
fetch(currentUrl.pathname + '?' + currentUrl.searchParams.toString(), {
|
||||
headers: { 'Accept': 'text/html' }
|
||||
})
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
|
||||
const attendeesSection = doc.querySelector('.attendees-section .section-box');
|
||||
const attendeesPagination = doc.querySelector('#attendees-pagination');
|
||||
|
||||
if (attendeesSection && attendeesPagination) {
|
||||
const currentAttendeesSection = document.querySelector('.attendees-section .section-box');
|
||||
currentAttendeesSection.innerHTML = attendeesSection.innerHTML;
|
||||
|
||||
const currentPagination = document.querySelector('#attendees-pagination');
|
||||
if (currentPagination) {
|
||||
currentPagination.innerHTML = attendeesPagination.innerHTML;
|
||||
}
|
||||
|
||||
lucide.createIcons();
|
||||
|
||||
// Re-attach attendee sort handlers
|
||||
const newSortableHeaders = document.querySelectorAll('#attendees-table th.sortable');
|
||||
newSortableHeaders.forEach(th => {
|
||||
th.addEventListener('click', () => {
|
||||
const column = th.dataset.sort;
|
||||
if (!column) return;
|
||||
const direction = currentAttendeeSort.column === column && currentAttendeeSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
currentAttendeeSort = { column, direction };
|
||||
|
||||
newSortableHeaders.forEach(h => h.classList.remove('sort-asc', 'sort-desc'));
|
||||
th.classList.add(direction === 'asc' ? 'sort-asc' : 'sort-desc');
|
||||
|
||||
loadAttendeesPage(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Re-attach checkin handlers
|
||||
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) {
|
||||
const row = this.closest('tr');
|
||||
row.querySelector('td:nth-child(7)').innerHTML = '<span class="badge badge-success"><i data-lucide="check"></i> {{ "checked_in"|t }}</span>';
|
||||
this.remove();
|
||||
document.getElementById('attendee-count').textContent = parseInt(document.getElementById('attendee-count').textContent) + 1;
|
||||
lucide.createIcons();
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('{{ "error_checking_in"|t }}', 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Re-attach select all handler
|
||||
const selectAllCheckbox = document.getElementById('select-all-attendees');
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.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);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
window.location.href = currentUrl.toString();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@@ -3,71 +3,13 @@
|
||||
{% 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>
|
||||
<a href="{{ url_for('event_detail', code=event.code) }}" class="btn btn-outline"><i data-lucide="arrow-left"></i> {{ 'back_to_event'|t }}</a>
|
||||
</div>
|
||||
|
||||
<section class="add-staff-form">
|
||||
<section class="add-staff-form card">
|
||||
<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">
|
||||
@@ -85,38 +27,40 @@ th.sort-desc .sort-icon::before {
|
||||
<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>
|
||||
<button type="submit" class="btn btn-primary"><i data-lucide="user-plus"></i> {{ 'add_staff_member'|t }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="staff-list">
|
||||
<section class="staff-list card">
|
||||
<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="first_name" class="sortable">{{ 'first_name'|t }} <span class="sort-icon"></span></th>
|
||||
<th data-sort="last_name" class="sortable">{{ 'last_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 data-sort="invite_used" 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>
|
||||
<tr data-first-name="{{ staff.first_name }}" data-last-name="{{ staff.last_name }}" data-email="{{ staff.email }}" data-status="{{ staff.invite_used }}">
|
||||
<td>{{ staff.first_name }}</td>
|
||||
<td>{{ staff.last_name }}</td>
|
||||
<td>{{ staff.email }}</td>
|
||||
<td>
|
||||
{% if staff.invite_used %}
|
||||
<span class="badge badge-success">{{ 'active'|t }}</span>
|
||||
<span class="badge badge-success"><i data-lucide="check"></i> {{ 'active'|t }}</span>
|
||||
{% else %}
|
||||
<span class="badge badge-pending">{{ 'invite_pending'|t }}</span>
|
||||
<span class="badge badge-pending"><i data-lucide="clock"></i> {{ '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>
|
||||
<a href="{{ url_for('edit_staff', event_id=event.id, staff_id=staff.id) }}" class="btn btn-sm btn-outline"><i data-lucide="pencil"></i></a>
|
||||
<button type="button" class="btn btn-sm btn-danger" onclick="showDeleteModal({{ staff.id }}, '{{ staff.first_name }} {{ staff.last_name }}')"><i data-lucide="trash-2"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -128,7 +72,7 @@ th.sort-desc .sort-icon::before {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="deleteModal" class="modal-overlay" style="display: none;">
|
||||
<div id="deleteModal" class="modal-overlay">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>{{ 'remove_staff_member'|t }}</h2>
|
||||
@@ -136,7 +80,7 @@ th.sort-desc .sort-icon::before {
|
||||
</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;">
|
||||
<form id="deleteForm" method="POST" class="d-inline">
|
||||
<button type="submit" class="btn btn-danger">{{ 'remove'|t }}</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -144,12 +88,17 @@ th.sort-desc .sort-icon::before {
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
|
||||
const sortableHeaders = document.querySelectorAll('.staff-table th.sortable');
|
||||
let currentSort = { column: null, direction: 'asc' };
|
||||
let currentSort = { column: 'first_name', direction: 'asc' };
|
||||
|
||||
sortableHeaders.forEach(th => {
|
||||
th.addEventListener('click', () => {
|
||||
const column = th.dataset.sort;
|
||||
if (!column) return;
|
||||
const direction = currentSort.column === column && currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
currentSort = { column, direction };
|
||||
|
||||
@@ -189,4 +138,11 @@ function showDeleteModal(staffId, staffName) {
|
||||
function closeDeleteModal() {
|
||||
document.getElementById('deleteModal').style.display = 'none';
|
||||
}
|
||||
|
||||
document.getElementById('deleteModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeDeleteModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,49 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'my_profile'|t }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="profile-page">
|
||||
<h1>{{ 'my_profile'|t }}</h1>
|
||||
|
||||
<div class="profile-container">
|
||||
<form method="POST" class="profile-form">
|
||||
<div class="form-group">
|
||||
<label for="name">{{ 'name'|t }}</label>
|
||||
<input type="text" id="name" name="name" value="{{ organizer.name }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">{{ 'email'|t }}</label>
|
||||
<input type="email" id="email" name="email" value="{{ organizer.email }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="preferred_language">{{ 'language'|t }}</label>
|
||||
<select id="preferred_language" name="preferred_language">
|
||||
{% for lang in ['en', 'nl', 'de', 'fr', 'es', 'it', 'pl'] %}
|
||||
<option value="{{ lang }}" {% if organizer.preferred_language == lang %}selected{% endif %}>{{ lang }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>{{ 'staff_code'|t }}</label>
|
||||
<input type="text" value="{{ organizer.staff_code or '' }}" readonly class="input-readonly">
|
||||
<small class="text-muted">{{ 'staff_code_readonly'|t }}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ 'member_since'|t }}</label>
|
||||
<input type="text" value="{{ organizer.created_at|localized_date if organizer.created_at else '' }}" readonly class="input-readonly">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ 'update_profile'|t }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
+15
-160
@@ -7,7 +7,7 @@
|
||||
<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>
|
||||
<a href="{{ url_for('event_detail', code=event.code) }}" class="btn btn-outline"><i data-lucide="arrow-left"></i> {{ 'back_to_event'|t }}</a>
|
||||
</div>
|
||||
|
||||
<div class="scanner-container">
|
||||
@@ -16,7 +16,7 @@
|
||||
<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>
|
||||
<button id="scan-again" class="btn btn-primary"><i data-lucide="scan-line"></i> {{ 'scan_again'|t }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,158 +43,6 @@
|
||||
</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 }};
|
||||
@@ -223,12 +71,14 @@ function addRecentCheckin(name) {
|
||||
|
||||
const li = document.createElement('li');
|
||||
const time = new Date().toLocaleTimeString();
|
||||
li.innerHTML = `<span class="attendee-name">${name}</span><span class="checkin-time">${time}</span>`;
|
||||
li.innerHTML = `<span class="attendee-name">${name}</span><span class="checkin-time"><i data-lucide="check"></i> ${time}</span>`;
|
||||
list.insertBefore(li, list.firstChild);
|
||||
|
||||
while (list.children.length > 10) {
|
||||
list.removeChild(list.lastChild);
|
||||
}
|
||||
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
async function checkInAttendee(attendeeId) {
|
||||
@@ -244,6 +94,7 @@ async function checkInAttendee(attendeeId) {
|
||||
const message = document.getElementById('result-message');
|
||||
|
||||
icon.className = 'result-icon';
|
||||
icon.innerHTML = '';
|
||||
|
||||
if (data.success) {
|
||||
document.getElementById('qr-reader').style.display = 'none';
|
||||
@@ -251,10 +102,12 @@ async function checkInAttendee(attendeeId) {
|
||||
|
||||
if (data.already_checked_in) {
|
||||
icon.classList.add('duplicate');
|
||||
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}`;
|
||||
addRecentCheckin(data.attendee_name);
|
||||
@@ -262,14 +115,16 @@ async function checkInAttendee(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);
|
||||
alert('{{ "error_checking_in"|t }}');
|
||||
showToast('{{ "error_checking_in"|t }}', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,7 +139,7 @@ function onScanSuccess(decodedText) {
|
||||
if (qrEventId === eventId) {
|
||||
checkInAttendee(attendeeId);
|
||||
} else {
|
||||
alert('{{ "qr_different_event"|t }}');
|
||||
showToast('{{ "qr_different_event"|t }}', 'error');
|
||||
}
|
||||
} else {
|
||||
const match = decodedText.match(/\/organizer\/event\/(\d+)\/checkin\/(\d+)/);
|
||||
@@ -294,10 +149,10 @@ function onScanSuccess(decodedText) {
|
||||
if (qrEventId === eventId) {
|
||||
checkInAttendee(attendeeId);
|
||||
} else {
|
||||
alert('{{ "qr_different_event"|t }}');
|
||||
showToast('{{ "qr_different_event"|t }}', 'error');
|
||||
}
|
||||
} else {
|
||||
console.log('Unknown QR format:', decodedText);
|
||||
// QR format not recognized
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,7 +171,7 @@ function startScanner() {
|
||||
}
|
||||
).catch(err => {
|
||||
console.error('Camera error:', err);
|
||||
alert('{{ "camera_permission_error"|t }}');
|
||||
showToast('{{ "camera_permission_error"|t }}', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,31 +4,78 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="presenter-dashboard">
|
||||
<h1>{{ 'presenter_dashboard'|t }}</h1>
|
||||
<p>{{ 'welcome'|t }}, {{ presenter_name }}</p>
|
||||
<div class="dashboard-header">
|
||||
<div class="welcome-section">
|
||||
<h1>{{ 'presenter_dashboard'|t }}</h1>
|
||||
<p class="welcome-subtitle">{{ 'welcome'|t }}, <strong>{{ presenter_name }}</strong></p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a href="{{ url_for('logout') }}" class="btn btn-ghost">
|
||||
<i data-lucide="log-out"></i> {{ 'logout'|t }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="my-sessions">
|
||||
<h2>{{ 'my_breakout_sessions'|t }}</h2>
|
||||
<div class="section-header">
|
||||
<h2>{{ 'my_breakout_sessions'|t }}</h2>
|
||||
</div>
|
||||
|
||||
{% if sessions %}
|
||||
<div class="sessions-list">
|
||||
{% for session in sessions %}
|
||||
<div class="session-card">
|
||||
<h3>{{ session.name }}</h3>
|
||||
<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' }}</p>
|
||||
<p><strong>{{ 'location'|t }}:</strong> {{ session.location }}</p>
|
||||
<p><strong>{{ 'registered'|t }}:</strong> {{ session.rsvp_count }}{% if session.max_attendees %} / {{ session.max_attendees }}{% endif %}</p>
|
||||
<a href="{{ url_for('view_breakout_session', code=session.code) }}" class="btn btn-outline">{{ 'view_details'|t }}</a>
|
||||
<div class="sessions-grid">
|
||||
{% for session in sessions %}
|
||||
<div class="session-card-lg">
|
||||
<div class="session-card-header">
|
||||
<h3>{{ session.name }}</h3>
|
||||
<span class="badge badge-info">{{ session.rsvp_count }}{% if session.max_attendees %} / {{ session.max_attendees }}{% endif %}</span>
|
||||
</div>
|
||||
|
||||
<div class="session-meta-list">
|
||||
<div class="meta-item">
|
||||
<i data-lucide="calendar"></i>
|
||||
<span>{{ session.start_time|localized_date if session.start_time else 'TBD' }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="meta-item">
|
||||
<i data-lucide="clock"></i>
|
||||
<span>{{ session.start_time|localized_date('%H:%M') if session.start_time else '' }} - {{ session.end_time|localized_date('%H:%M') if session.end_time else '' }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<i data-lucide="map-pin"></i>
|
||||
<span>{{ session.location or 'TBD' }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<i data-lucide="building"></i>
|
||||
<span>{{ session.event_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="session-scan-section">
|
||||
<div class="scan-prompt">
|
||||
<i data-lucide="scan-line"></i>
|
||||
<span>{{ 'scan_attendees_at_session'|t }}</span>
|
||||
</div>
|
||||
<a href="{{ url_for('presenter_scan', code=session.presenter_code) }}" class="btn btn-primary btn-lg">
|
||||
<i data-lucide="qr-code"></i> {{ 'open_scanner'|t }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="session-card-actions">
|
||||
<a href="{{ url_for('view_breakout_session', code=session.code) }}" class="btn btn-outline btn-sm">
|
||||
<i data-lucide="eye"></i> {{ 'view_details'|t }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="no-sessions">{{ 'no_sessions_assigned'|t }}</p>
|
||||
<div class="empty-state-card">
|
||||
<div class="empty-state-icon">
|
||||
<i data-lucide="calendar-x"></i>
|
||||
</div>
|
||||
<h3>{{ 'no_sessions_assigned'|t }}</h3>
|
||||
<p>{{ 'no_sessions_assigned_desc'|t }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<div class="logout-link">
|
||||
<a href="{{ url_for('logout') }}" class="btn btn-outline">{{ 'logout'|t }}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,240 @@
|
||||
{% 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 %}
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">{{ 'password'|t }}</label>
|
||||
<input type="password" id="password" name="password" required minlength="6">
|
||||
<input type="password" id="password" name="password" required minlength="8">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -70,7 +70,7 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">{{ 'password'|t }}</label>
|
||||
<input type="password" id="password" name="password" required minlength="6">
|
||||
<input type="password" id="password" name="password" required minlength="8">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'communications'|t }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="communications-page">
|
||||
<div class="page-header">
|
||||
<h1>{{ 'my_messages'|t }}</h1>
|
||||
</div>
|
||||
|
||||
{% if emails %}
|
||||
<div class="communications-list">
|
||||
{% for email in emails %}
|
||||
<div class="communication-card">
|
||||
<div class="communication-header">
|
||||
<span class="communication-event">{{ email.event_name }}</span>
|
||||
<span class="communication-date">{{ email.sent_at|localized_date }}</span>
|
||||
</div>
|
||||
<h3>{{ email.subject }}</h3>
|
||||
<p class="communication-body">{{ email.body }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="section-box">
|
||||
<p class="text-muted text-center">{{ 'no_messages_yet'|t }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -10,8 +10,13 @@
|
||||
</div>
|
||||
|
||||
<div class="staff-actions">
|
||||
<a href="{{ url_for('event_scan', event_id=event.id) }}" class="btn btn-primary btn-lg">📷 {{ 'start_qr_scanner'|t }}</a>
|
||||
<a href="{{ url_for('event_scan', event_id=event.id) }}" class="btn btn-primary btn-lg"><i data-lucide="scan-line"></i> {{ 'start_qr_scanner'|t }}</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,68 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'my_profile'|t }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="profile-page">
|
||||
<h1>{{ 'my_profile'|t }}</h1>
|
||||
|
||||
<div class="profile-container">
|
||||
<form method="POST" class="profile-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" 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>
|
||||
|
||||
<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-group">
|
||||
<label for="preferred_language">{{ 'language'|t }}</label>
|
||||
<select id="preferred_language" name="preferred_language">
|
||||
{% for lang in ['en', 'nl', 'de', 'fr', 'es', 'it', 'pl'] %}
|
||||
<option value="{{ lang }}" {% if staff.preferred_language == lang %}selected{% endif %}>{{ lang }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>{{ 'staff_code'|t }}</label>
|
||||
<input type="text" value="{{ staff.staff_code or '' }}" readonly class="input-readonly">
|
||||
<small class="text-muted">{{ 'staff_code_readonly'|t }}</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ 'event'|t }}</label>
|
||||
<input type="text" value="{{ event_name or '' }}" readonly class="input-readonly">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>{{ 'member_since'|t }}</label>
|
||||
<input type="text" value="{{ staff.created_at|localized_date if staff.created_at else '' }}" readonly class="input-readonly">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ 'reminders_sent'|t }}</label>
|
||||
<input type="text" value="{{ staff.reminder_count or '0' }}" readonly class="input-readonly">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ 'update_profile'|t }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
+23
-163
@@ -3,11 +3,11 @@
|
||||
{% block title %}{{ 'scan_qr'|t }} - {{ event.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="scan-page">
|
||||
<div class="scan-page staff-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>
|
||||
<a href="{{ url_for('staff_event_dashboard', event_id=event.id) }}" class="btn btn-outline"><i data-lucide="arrow-left"></i> {{ 'back_to_dashboard'|t }}</a>
|
||||
</div>
|
||||
|
||||
<div class="scanner-container">
|
||||
@@ -16,7 +16,7 @@
|
||||
<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>
|
||||
<button id="scan-again" class="btn btn-primary"><i data-lucide="scan-line"></i> {{ 'scan_again'|t }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,163 +38,11 @@
|
||||
<div class="recent-checkins">
|
||||
<h3>{{ 'recent_checkins'|t }}</h3>
|
||||
<ul id="recent-list">
|
||||
<li class="empty-state">{{ 'no_checkins_yet'|t }}</li>
|
||||
<li class="empty-state"><i data-lucide="users"></i> {{ '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 }};
|
||||
@@ -223,12 +71,14 @@ function addRecentCheckin(name) {
|
||||
|
||||
const li = document.createElement('li');
|
||||
const time = new Date().toLocaleTimeString();
|
||||
li.innerHTML = `<span class="attendee-name">${name}</span><span class="checkin-time">${time}</span>`;
|
||||
li.innerHTML = `<span class="attendee-name">${name}</span><span class="checkin-time"><i data-lucide="check"></i> ${time}</span>`;
|
||||
list.insertBefore(li, list.firstChild);
|
||||
|
||||
while (list.children.length > 10) {
|
||||
list.removeChild(list.lastChild);
|
||||
}
|
||||
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
async function checkInAttendee(attendeeId) {
|
||||
@@ -244,6 +94,7 @@ async function checkInAttendee(attendeeId) {
|
||||
const message = document.getElementById('result-message');
|
||||
|
||||
icon.className = 'result-icon';
|
||||
icon.innerHTML = '';
|
||||
|
||||
if (data.success) {
|
||||
document.getElementById('qr-reader').style.display = 'none';
|
||||
@@ -251,10 +102,12 @@ async function checkInAttendee(attendeeId) {
|
||||
|
||||
if (data.already_checked_in) {
|
||||
icon.classList.add('duplicate');
|
||||
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}`;
|
||||
addRecentCheckin(data.attendee_name);
|
||||
@@ -262,14 +115,16 @@ async function checkInAttendee(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);
|
||||
alert('{{ "error_checking_in"|t }}');
|
||||
showToast('{{ "error_checking_in"|t }}', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,7 +147,7 @@ function onScanSuccess(decodedText) {
|
||||
if (qrEventId === eventId) {
|
||||
checkInAttendee(attendeeId);
|
||||
} else {
|
||||
alert('{{ "qr_different_event"|t }}');
|
||||
showToast('{{ "qr_different_event"|t }}', 'error');
|
||||
}
|
||||
} else {
|
||||
const match = decodedText.match(/\/organizer\/event\/(\d+)\/checkin\/(\d+)/);
|
||||
@@ -302,10 +157,10 @@ function onScanSuccess(decodedText) {
|
||||
if (qrEventId === eventId) {
|
||||
checkInAttendee(attendeeId);
|
||||
} else {
|
||||
alert('{{ "qr_different_event"|t }}');
|
||||
showToast('{{ "qr_different_event"|t }}', 'error');
|
||||
}
|
||||
} else {
|
||||
console.log('Unknown QR format:', decodedText);
|
||||
// QR format not recognized
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,6 +182,7 @@ async function checkInByCode(attendeeCode) {
|
||||
const message = document.getElementById('result-message');
|
||||
|
||||
icon.className = 'result-icon';
|
||||
icon.innerHTML = '';
|
||||
|
||||
if (data.success) {
|
||||
document.getElementById('qr-reader').style.display = 'none';
|
||||
@@ -334,10 +190,12 @@ async function checkInByCode(attendeeCode) {
|
||||
|
||||
if (data.already_checked_in) {
|
||||
icon.classList.add('duplicate');
|
||||
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}`;
|
||||
addRecentCheckin(data.attendee_name);
|
||||
@@ -345,14 +203,16 @@ async function checkInByCode(attendeeCode) {
|
||||
}
|
||||
} 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);
|
||||
alert('{{ "error_checking_in"|t }}');
|
||||
showToast('{{ "error_checking_in"|t }}', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,7 +230,7 @@ function startScanner() {
|
||||
}
|
||||
).catch(err => {
|
||||
console.error('Camera error:', err);
|
||||
alert('{{ "camera_permission_error"|t }}');
|
||||
showToast('{{ "camera_permission_error"|t }}', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,78 +9,33 @@
|
||||
<p>{{ 'welcome'|t }}, {{ staff_name }}</p>
|
||||
</div>
|
||||
|
||||
<div class="section-box">
|
||||
<section class="section-box card">
|
||||
<h2>{{ 'select_event'|t }}</h2>
|
||||
|
||||
{% if events %}
|
||||
<div class="events-list">
|
||||
{% for event in events %}
|
||||
<div class="event-card">
|
||||
<div class="event-card card-static">
|
||||
<div class="event-info">
|
||||
<h3>{{ event.name }}</h3>
|
||||
<p><strong>{{ 'location'|t }}:</strong> {{ event.location }}</p>
|
||||
<p><strong>{{ 'start'|t }}:</strong> {{ event.start_time|localized_date if event.start_time else 'TBD' }}</p>
|
||||
</div>
|
||||
<div class="event-actions">
|
||||
<a href="{{ url_for('staff_event_dashboard', event_id=event.id) }}" class="btn btn-primary btn-lg">{{ 'start_qr_scanner'|t }}</a>
|
||||
<a href="{{ url_for('staff_event_dashboard', event_id=event.id) }}" class="btn btn-primary btn-lg"><i data-lucide="scan-line"></i> {{ 'start_qr_scanner'|t }}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="no-events">{{ 'no_events_assigned'|t }}</p>
|
||||
<p class="no-events"><i data-lucide="calendar"></i> {{ 'no_events_assigned'|t }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.staff-events-dashboard {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.staff-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.staff-header h1 {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.events-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.event-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.event-info h3 {
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.event-info p {
|
||||
margin: 5px 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.event-actions {
|
||||
flex-shrink: 0;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.no-events {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
padding: 40px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -11,11 +11,11 @@
|
||||
<form method="POST" action="{{ url_for('staff_invite', token=staff.invite_token) }}" class="invite-form">
|
||||
<div class="form-group">
|
||||
<label for="password">{{ 'password'|t }}</label>
|
||||
<input type="password" id="password" name="password" required minlength="6">
|
||||
<input type="password" id="password" name="password" required minlength="8">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="confirm_password">{{ 'confirm_password'|t }}</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password" required minlength="6">
|
||||
<input type="password" id="confirm_password" name="confirm_password" required minlength="8">
|
||||
</div>
|
||||
{% if recaptcha_site_key %}
|
||||
<div class="form-group recaptcha-container">
|
||||
|
||||
Reference in New Issue
Block a user