Initial commit: conference app with Flask
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
# Networking Event Platform - Specification
|
||||
|
||||
## Project Overview
|
||||
- **Project Name**: NetEvent
|
||||
- **Type**: Full-stack web application (Flask + MySQL)
|
||||
- **Core Functionality**: A platform for organizing networking events where attendees can RSVP, connect, and schedule appointments
|
||||
- **Target Users**: Event organizers and event attendees
|
||||
|
||||
## Technology Stack
|
||||
- **Backend**: Python Flask
|
||||
- **Database**: MySQL (roast.duckdns.org:33062)
|
||||
- **Frontend**: HTML/CSS/JavaScript with Jinja2 templates
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Tables
|
||||
|
||||
#### `organizers`
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | INT PRIMARY KEY AUTO_INCREMENT | Organizer ID |
|
||||
| email | VARCHAR(255) UNIQUE | Organizer email |
|
||||
| password_hash | VARCHAR(255) | Hashed password |
|
||||
| name | VARCHAR(255) | Organizer name |
|
||||
| created_at | TIMESTAMP | Creation timestamp |
|
||||
|
||||
#### `events`
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | INT PRIMARY KEY AUTO_INCREMENT | Event ID |
|
||||
| organizer_id | INT FOREIGN KEY | Reference to organizers |
|
||||
| code | VARCHAR(10) UNIQUE | Unique 10-char alphanumeric event code for deep linking |
|
||||
| name | VARCHAR(255) | Event name |
|
||||
| description | TEXT | Event description |
|
||||
| start_time | DATETIME | Event start date/time |
|
||||
| end_time | DATETIME | Event end date/time |
|
||||
| location | VARCHAR(255) | Event location |
|
||||
| max_attendees | INT | Maximum attendees (NULL = unlimited) |
|
||||
| created_at | TIMESTAMP | Creation timestamp |
|
||||
|
||||
#### `attendees`
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | INT PRIMARY KEY AUTO_INCREMENT | Attendee ID |
|
||||
| event_id | INT FOREIGN KEY | Reference to events |
|
||||
| email | VARCHAR(255) | Attendee email |
|
||||
| password_hash | VARCHAR(255) | Hashed password |
|
||||
| first_name | VARCHAR(100) | First name |
|
||||
| last_name | VARCHAR(100) | Last name |
|
||||
| organisation | VARCHAR(255) | Organization/Company |
|
||||
| role | VARCHAR(255) | Role/Profession |
|
||||
| introduction | TEXT | Short introduction |
|
||||
| profile_picture | VARCHAR(255) | Profile picture path |
|
||||
| created_at | TIMESTAMP | Creation timestamp |
|
||||
|
||||
#### `connections`
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | INT PRIMARY KEY AUTO_INCREMENT | Connection ID |
|
||||
| attendee_id | INT FOREIGN KEY | Requester attendee |
|
||||
| connected_attendee_id | INT FOREIGN KEY | Connect target attendee |
|
||||
| status | ENUM('pending','accepted','rejected') | Connection status |
|
||||
| created_at | TIMESTAMP | Creation timestamp |
|
||||
|
||||
#### `appointments`
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | INT PRIMARY KEY AUTO_INCREMENT | Appointment ID |
|
||||
| event_id | INT FOREIGN KEY | Reference to events |
|
||||
| requester_id | INT FOREIGN KEY | Requester attendee |
|
||||
| target_id | INT FOREIGN KEY | Target attendee |
|
||||
| appointment_time | DATETIME | Proposed meeting time |
|
||||
| location | VARCHAR(255) | Meeting location |
|
||||
| notes | TEXT | Appointment notes |
|
||||
| status | ENUM('pending','accepted','rejected') | Appointment status |
|
||||
| created_at | TIMESTAMP | Creation timestamp |
|
||||
|
||||
## Functionality Specification
|
||||
|
||||
### Organiser Features
|
||||
1. **Authentication**: Login/logout for organizers
|
||||
2. **Event Management**: Create, edit, delete events
|
||||
3. **Attendee List**: View all attendees for their events
|
||||
4. **Badge Printing**: Generate printable badge list (PDF-ready HTML)
|
||||
5. **Attendance Stats**: See check-in counts and attendee statistics
|
||||
6. **QR Code Scanning**: Mobile camera-based check-in by scanning attendee QR codes
|
||||
|
||||
### Attendee Features
|
||||
1. **Authentication**: Register/login for attendees
|
||||
2. **Event RSVP**: Register for events
|
||||
3. **Profile Management**: Update profile with name, org, role, intro, photo
|
||||
4. **Connections**: Send/accept/reject connection requests
|
||||
5. **Appointments**: Request/accept/reject meeting appointments
|
||||
|
||||
### User Interactions & Flows
|
||||
|
||||
#### Organiser Flow
|
||||
1. Login → Dashboard → Create Event → View Attendees → Print Badges
|
||||
|
||||
#### Attendee Flow
|
||||
1. Register → Login → RSVP to Event → Manage Profile → Connect with Attendees → Request Appointments
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Auth
|
||||
- `POST /api/auth/organizer/register` - Register organizer
|
||||
- `POST /api/auth/organizer/login` - Login organizer
|
||||
- `POST /api/auth/attendee/register` - Register attendee
|
||||
- `POST /api/auth/attendee/login` - Login attendee
|
||||
- `POST /api/auth/logout` - Logout
|
||||
|
||||
### Events
|
||||
- `GET /api/events` - List public events
|
||||
- `POST /api/events` - Create event (organizer)
|
||||
- `GET /api/events/<id>` - Get event details
|
||||
- `PUT /api/events/<id>` - Update event
|
||||
- `DELETE /api/events/<id>` - Delete event
|
||||
|
||||
### Attendees
|
||||
- `GET /api/events/<id>/attendees` - List attendees for event
|
||||
- `GET /api/attendees/<id>` - Get attendee profile
|
||||
- `PUT /api/attendees/<id>` - Update attendee profile
|
||||
- `POST /api/attendees/<id>/photo` - Upload profile photo
|
||||
|
||||
### Connections
|
||||
- `GET /api/connections` - List my connections
|
||||
- `POST /api/connections` - Send connection request
|
||||
- `PUT /api/connections/<id>` - Accept/reject connection
|
||||
- `GET /api/attendees` - Search attendees
|
||||
|
||||
### Appointments
|
||||
- `GET /api/appointments` - List my appointments
|
||||
- `POST /api/appointments` - Request appointment
|
||||
- `PUT /api/appointments/<id>` - Accept/reject appointment
|
||||
|
||||
### Organizer Tools
|
||||
- `GET /api/organizer/events/<id>/badges` - Get badge printable view
|
||||
- `GET /api/organizer/events/<id>/stats` - Get attendance stats
|
||||
- `GET /api/organizer/events/<id>/scan` - QR code scanner page for check-in
|
||||
|
||||
## Security
|
||||
- Password hashing with bcrypt
|
||||
- Session-based authentication
|
||||
- CSRF protection
|
||||
- SQL injection prevention via parameterized queries
|
||||
|
||||
## File Structure
|
||||
```
|
||||
/home/paul/conference/
|
||||
├── app.py # Flask application
|
||||
├── config.py # Configuration
|
||||
├── init_db.py # Database initialization script
|
||||
├── requirements.txt # Python dependencies
|
||||
├── static/
|
||||
│ ├── css/
|
||||
│ │ └── style.css
|
||||
│ └── js/
|
||||
│ └── main.js
|
||||
└── templates/
|
||||
├── base.html
|
||||
├── index.html
|
||||
├── auth/
|
||||
│ ├── login.html
|
||||
│ └── register.html
|
||||
├── organizer/
|
||||
│ ├── dashboard.html
|
||||
│ ├── create_event.html
|
||||
│ ├── event_detail.html
|
||||
│ ├── badges.html
|
||||
│ └── scan.html
|
||||
└── attendee/
|
||||
├── dashboard.html
|
||||
├── event.html
|
||||
├── profile.html
|
||||
├── connections.html
|
||||
└── appointments.html
|
||||
```
|
||||
@@ -0,0 +1,35 @@
|
||||
import os
|
||||
|
||||
class Config:
|
||||
# Internationalization
|
||||
BABEL_DEFAULT_LOCALE = 'en'
|
||||
BABEL_SUPPORTED_LOCALES = ['en', 'nl', 'de', 'fr', 'es', 'it', 'pl']
|
||||
BABEL_TRANSLATION_DIRECTORIES = 'translations'
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY') or 'netevent-secret-key-change-in-production'
|
||||
|
||||
# MySQL Database Configuration
|
||||
DB_HOST = 'roast.duckdns.org'
|
||||
DB_PORT = 33062
|
||||
DB_USER = 'root'
|
||||
DB_PASSWORD = 'Tiegl!!!111...'
|
||||
DB_NAME = 'netevent'
|
||||
|
||||
# Dedicated database user (created by init_db.py)
|
||||
DB_APP_USER = 'netevent_app'
|
||||
DB_APP_PASSWORD = 'netevent_pass_2024'
|
||||
|
||||
# Email Configuration (Brevo/Sendinblue)
|
||||
MAIL_SERVER = 'smtp-relay.brevo.com'
|
||||
MAIL_PORT = 587
|
||||
MAIL_USE_TLS = True
|
||||
MAIL_USERNAME = 'a6000b001@smtp-brevo.com'
|
||||
MAIL_PASSWORD = 'xsmtpsib-c242e6135185589b9d66ea911d84696b7582fc9ac4d8fd27ace4c5e745bd5f49-xtHwZO3Hu9KCN3W9'
|
||||
MAIL_DEFAULT_SENDER = 'paul@bokel.nl'
|
||||
|
||||
UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static', 'uploads')
|
||||
MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB max file size
|
||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'}
|
||||
|
||||
# Google reCAPTCHA Configuration
|
||||
RECAPTCHA_SITE_KEY = os.environ.get('RECAPTCHA_SITE_KEY', '')
|
||||
RECAPTCHA_SECRET_KEY = os.environ.get('RECAPTCHA_SECRET_KEY', '')
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#1a1a2e"/>
|
||||
<text x="16" y="22" font-family="Arial, sans-serif" font-size="14" font-weight="700" fill="#ffffff" text-anchor="middle">NE</text>
|
||||
<circle cx="6" cy="10" r="2" fill="#3b82f6"/>
|
||||
<circle cx="26" cy="10" r="2" fill="#3b82f6"/>
|
||||
<circle cx="16" cy="6" r="2" fill="#3b82f6"/>
|
||||
<line x1="6" y1="10" x2="16" y2="6" stroke="#3b82f6" stroke-width="1.5"/>
|
||||
<line x1="26" y1="10" x2="16" y2="6" stroke="#3b82f6" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 553 B |
+302
@@ -0,0 +1,302 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Database initialization script for NetEvent platform."""
|
||||
|
||||
import mysql.connector
|
||||
from mysql.connector import Error
|
||||
|
||||
DB_HOST = 'roast.duckdns.org'
|
||||
DB_PORT = 33062
|
||||
DB_USER = 'root'
|
||||
DB_PASSWORD = 'Tiegl!!!111...'
|
||||
DB_NAME = 'netevent'
|
||||
APP_USER = 'netevent_app'
|
||||
APP_PASSWORD = 'netevent_pass_2024'
|
||||
|
||||
def create_database():
|
||||
"""Create the database and dedicated user."""
|
||||
try:
|
||||
conn = mysql.connector.connect(
|
||||
host=DB_HOST,
|
||||
port=DB_PORT,
|
||||
user=DB_USER,
|
||||
password=DB_PASSWORD
|
||||
)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create database if not exists
|
||||
cursor.execute(f"CREATE DATABASE IF NOT EXISTS {DB_NAME}")
|
||||
print(f"Database '{DB_NAME}' created or already exists.")
|
||||
|
||||
# Create dedicated application user
|
||||
cursor.execute(f"CREATE USER IF NOT EXISTS '{APP_USER}'@'%' IDENTIFIED BY '{APP_PASSWORD}'")
|
||||
cursor.execute(f"GRANT ALL PRIVILEGES ON {DB_NAME}.* TO '{APP_USER}'@'%'")
|
||||
cursor.execute("FLUSH PRIVILEGES")
|
||||
print(f"User '{APP_USER}' created with privileges on '{DB_NAME}'.")
|
||||
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
return True
|
||||
except Error as e:
|
||||
print(f"Error creating database/user: {e}")
|
||||
return False
|
||||
|
||||
def create_tables():
|
||||
"""Create all required tables."""
|
||||
tables = [
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS organizers (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
organizer_id INT NOT NULL,
|
||||
code VARCHAR(10) UNIQUE NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
start_time DATETIME NOT NULL,
|
||||
end_time DATETIME,
|
||||
location VARCHAR(255),
|
||||
max_attendees INT DEFAULT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (organizer_id) REFERENCES organizers(id) ON DELETE CASCADE
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS attendees (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
event_id INT NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
first_name VARCHAR(100) NOT NULL,
|
||||
last_name VARCHAR(100) NOT NULL,
|
||||
organisation VARCHAR(255),
|
||||
role VARCHAR(255),
|
||||
introduction TEXT,
|
||||
profile_picture VARCHAR(255) DEFAULT NULL,
|
||||
checked_in BOOLEAN DEFAULT FALSE,
|
||||
attendance_status ENUM('attending', 'not_attending') DEFAULT 'attending',
|
||||
confirmation_token VARCHAR(64) DEFAULT NULL,
|
||||
attendee_code VARCHAR(10) UNIQUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY unique_attendee_event (event_id, email)
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS connections (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
attendee_id INT NOT NULL,
|
||||
connected_attendee_id INT NOT NULL,
|
||||
status ENUM('pending', 'accepted', 'rejected') DEFAULT 'pending',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (attendee_id) REFERENCES attendees(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (connected_attendee_id) REFERENCES attendees(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY unique_connection (attendee_id, connected_attendee_id)
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS appointments (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
event_id INT NOT NULL,
|
||||
requester_id INT NOT NULL,
|
||||
target_id INT NOT NULL,
|
||||
appointment_time DATETIME NOT NULL,
|
||||
location VARCHAR(255),
|
||||
notes TEXT,
|
||||
status ENUM('pending', 'accepted', 'rejected') DEFAULT 'pending',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (requester_id) REFERENCES attendees(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (target_id) REFERENCES attendees(id) ON DELETE CASCADE
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS breakout_sessions (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
code VARCHAR(10) NOT NULL UNIQUE,
|
||||
event_id INT NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
start_time DATETIME NOT NULL,
|
||||
end_time DATETIME NOT NULL,
|
||||
location VARCHAR(255),
|
||||
max_attendees INT DEFAULT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS breakout_session_organizers (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
breakout_session_id INT NOT NULL,
|
||||
organizer_id INT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (breakout_session_id) REFERENCES breakout_sessions(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (organizer_id) REFERENCES organizers(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY unique_session_organizer (breakout_session_id, organizer_id)
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS breakout_session_rsvps (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
breakout_session_id INT NOT NULL,
|
||||
attendee_id INT NOT NULL,
|
||||
status ENUM('registered', 'cancelled') DEFAULT 'registered',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (breakout_session_id) REFERENCES breakout_sessions(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (attendee_id) REFERENCES attendees(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY unique_rsvp (breakout_session_id, attendee_id)
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS staff (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
event_id INT NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
password_hash VARCHAR(255) DEFAULT NULL,
|
||||
first_name VARCHAR(100) NOT NULL,
|
||||
last_name VARCHAR(100) NOT NULL,
|
||||
invite_token VARCHAR(64) DEFAULT NULL,
|
||||
invite_used BOOLEAN DEFAULT FALSE,
|
||||
preferred_language VARCHAR(5) DEFAULT 'en',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
|
||||
UNIQUE KEY unique_staff_event (event_id, email)
|
||||
)
|
||||
""",
|
||||
# Languages table
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS languages (
|
||||
code VARCHAR(5) PRIMARY KEY,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
native_name VARCHAR(50) NOT NULL,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
is_default BOOLEAN DEFAULT FALSE,
|
||||
date_format VARCHAR(30) DEFAULT '%B %d, %Y at %H:%M',
|
||||
sort_order INT DEFAULT 0
|
||||
)
|
||||
""",
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS attendee_types (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
event_id INT NOT NULL,
|
||||
code VARCHAR(10) UNIQUE NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
price DECIMAL(10,2) DEFAULT 0.00,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE
|
||||
)
|
||||
"""
|
||||
]
|
||||
|
||||
# Alter existing tables to add preferred_language column
|
||||
alter_statements = [
|
||||
"ALTER TABLE organizers ADD COLUMN preferred_language VARCHAR(5) DEFAULT 'en'",
|
||||
"ALTER TABLE attendees ADD COLUMN preferred_language VARCHAR(5) DEFAULT 'en'",
|
||||
"ALTER TABLE attendees ADD COLUMN attendee_code VARCHAR(10) UNIQUE",
|
||||
"ALTER TABLE attendees ADD COLUMN phone VARCHAR(50) DEFAULT ''",
|
||||
"ALTER TABLE attendees ADD COLUMN linkedin VARCHAR(255) DEFAULT ''",
|
||||
"ALTER TABLE attendees ADD COLUMN attendee_type_id INT DEFAULT NULL",
|
||||
]
|
||||
|
||||
try:
|
||||
conn = mysql.connector.connect(
|
||||
host=DB_HOST,
|
||||
port=DB_PORT,
|
||||
user=APP_USER,
|
||||
password=APP_PASSWORD,
|
||||
database=DB_NAME
|
||||
)
|
||||
cursor = conn.cursor()
|
||||
|
||||
for table_sql in tables:
|
||||
cursor.execute(table_sql)
|
||||
|
||||
# Alter existing tables
|
||||
for alter_sql in alter_statements:
|
||||
try:
|
||||
cursor.execute(alter_sql)
|
||||
except Error as e:
|
||||
# Column might already exist
|
||||
if e.errno != 1060: # Duplicate column name
|
||||
print(f"Warning: {e}")
|
||||
|
||||
# Add foreign key constraint for attendee_type_id
|
||||
try:
|
||||
cursor.execute("""
|
||||
ALTER TABLE attendees
|
||||
ADD CONSTRAINT fk_attendee_type
|
||||
FOREIGN KEY (attendee_type_id) REFERENCES attendee_types(id) ON DELETE SET NULL
|
||||
""")
|
||||
except Error as e:
|
||||
if e.errno != 1060 and e.errno != 1826: # Not duplicate column or duplicate FK
|
||||
print(f"Warning adding FK: {e}")
|
||||
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
print("All tables created successfully.")
|
||||
return True
|
||||
except Error as e:
|
||||
print(f"Error creating tables: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def seed_languages():
|
||||
"""Seed the languages table with EU languages."""
|
||||
languages = [
|
||||
('en', 'English', 'English', True, True, '%B %d, %Y at %H:%M', 1),
|
||||
('nl', 'Dutch', 'Nederlands', True, False, '%d %B %Y om %H:%M', 2),
|
||||
('de', 'German', 'Deutsch', True, False, '%d. %B %Y um %H:%M', 3),
|
||||
('fr', 'French', 'Français', True, False, '%d %B %Y à %H:%M', 4),
|
||||
('es', 'Spanish', 'Español', True, False, '%d de %B de %Y a las %H:%M', 5),
|
||||
('it', 'Italian', 'Italiano', True, False, '%d %B %Y alle %H:%M', 6),
|
||||
('pl', 'Polish', 'Polski', True, False, '%d %B %Y o %H:%M', 7),
|
||||
]
|
||||
|
||||
try:
|
||||
conn = mysql.connector.connect(
|
||||
host=DB_HOST,
|
||||
port=DB_PORT,
|
||||
user=APP_USER,
|
||||
password=APP_PASSWORD,
|
||||
database=DB_NAME
|
||||
)
|
||||
cursor = conn.cursor()
|
||||
|
||||
for lang in languages:
|
||||
cursor.execute("""
|
||||
INSERT IGNORE INTO languages (code, name, native_name, is_active, is_default, date_format, sort_order)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
""", lang)
|
||||
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
print("Languages seeded successfully.")
|
||||
return True
|
||||
except Error as e:
|
||||
print(f"Error seeding languages: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def init_database():
|
||||
"""Initialize the complete database."""
|
||||
print("Initializing NetEvent database...")
|
||||
if create_database():
|
||||
if create_tables():
|
||||
seed_languages()
|
||||
print("Database initialization complete!")
|
||||
return True
|
||||
print("Database initialization failed.")
|
||||
return False
|
||||
|
||||
if __name__ == '__main__':
|
||||
init_database()
|
||||
@@ -0,0 +1,8 @@
|
||||
Flask==3.0.0
|
||||
Flask-Cors==4.0.0
|
||||
mysql-connector-python==8.2.0
|
||||
bcrypt==4.1.2
|
||||
Werkzeug==3.0.1
|
||||
python-dateutil==2.8.2
|
||||
qrcode==7.4.2
|
||||
reportlab==4.0.7
|
||||
@@ -0,0 +1,745 @@
|
||||
/* Reset and Base Styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary-color: #2563eb;
|
||||
--secondary-color: #64748b;
|
||||
--success-color: #22c55e;
|
||||
--danger-color: #ef4444;
|
||||
--warning-color: #f59e0b;
|
||||
--bg-color: #f8fafc;
|
||||
--card-bg: #ffffff;
|
||||
--text-color: #1e293b;
|
||||
--text-muted: #64748b;
|
||||
--border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--text-color);
|
||||
background-color: var(--bg-color);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
@media (min-width: 1400px) {
|
||||
.container {
|
||||
max-width: none;
|
||||
padding: 0 40px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.navbar {
|
||||
background-color: var(--card-bg);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
padding: 1rem 0;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.sticky-navbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
background-color: var(--card-bg);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.navbar .container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
main {
|
||||
flex: 1;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
background-color: var(--card-bg);
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 1.5rem 0;
|
||||
margin-top: auto;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Flash Messages */
|
||||
.flash-messages {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.flash-message {
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.5rem;
|
||||
background-color: #fef3c7;
|
||||
border-left: 4px solid var(--warning-color);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #1d4ed8;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--secondary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #475569;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.35rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.2;
|
||||
box-sizing: border-box;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Hero Section */
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding: 3rem 0;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.hero-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
.event-card, .feature-card, .connection-card, .attendee-card {
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.event-card h3, .connection-card h4 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.event-date, .event-location {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.event-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.event-card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.event-card-actions .btn,
|
||||
.event-card-actions form .btn {
|
||||
width: 160px;
|
||||
min-height: 44px;
|
||||
height: auto;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
white-space: normal;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.event-card-actions form {
|
||||
display: flex !important;
|
||||
width: 160px;
|
||||
min-height: 44px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.event-card-actions form .btn {
|
||||
width: 100%;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
/* Grids */
|
||||
.events-grid, .features-grid, .connections-grid, .attendees-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
/* Auth Forms */
|
||||
.auth-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.auth-box {
|
||||
background-color: var(--card-bg);
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.auth-box h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.user-type-tabs {
|
||||
display: flex;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-btn:first-child {
|
||||
border-radius: 6px 0 0 6px;
|
||||
}
|
||||
|
||||
.tab-btn:last-child {
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.auth-footer {
|
||||
text-align: center;
|
||||
margin-top: 1.5rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
/* reCAPTCHA */
|
||||
.recaptcha-container {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.recaptcha-container .g-recaptcha {
|
||||
transform-origin: left top;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.attendees-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background-color: var(--card-bg);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.attendees-table th,
|
||||
.attendees-table td {
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.attendees-table th {
|
||||
background-color: var(--bg-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.attendees-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.badge-pending {
|
||||
background-color: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.badge-rejected {
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
/* Dashboard */
|
||||
.dashboard h1 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.dashboard-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.events-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: var(--card-bg);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.event-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Badges Print Page */
|
||||
.badges-page {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.badges-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.badges-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.badge-card {
|
||||
border: 2px solid #333;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.badge-header {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.badge-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.badge-body {
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.badge-org {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.badge-role {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.badge-intro {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.badge-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.badge-check {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.badge-check.checked-in {
|
||||
color: var(--success-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Profile Page */
|
||||
.profile-page h1 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.profile-container {
|
||||
display: grid;
|
||||
grid-template-columns: 250px 1fr;
|
||||
gap: 2rem;
|
||||
background-color: var(--card-bg);
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.profile-photo-section {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.current-photo {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.profile-img {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.no-photo {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--bg-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto;
|
||||
font-size: 2rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.photo-upload-form {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Attendees Page */
|
||||
.attendees-page h1 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.attendees-page > p {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.attendee-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.attendee-photo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.attendee-photo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.attendee-photo .no-photo {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.attendee-info h3 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.attendee-org {
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.attendee-role {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.attendee-intro {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.attendee-actions {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Profile Page */
|
||||
.profile-page h1 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Appointments & Connections */
|
||||
.pending-section, .connections-section, .appointments-section {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.pending-section h2, .connections-section h2, .appointments-section h2 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.pending-list, .appointments-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.pending-item, .appointment-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: var(--card-bg);
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.pending-info, .apt-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.pending-actions, .apt-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.section-actions {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Event Meta Box */
|
||||
.event-meta-box, .event-description-box {
|
||||
background-color: var(--card-bg);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.event-meta-box p, .event-description-box p {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Event Detail Page */
|
||||
.event-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.event-header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.event-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.profile-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.attendees-table {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.attendees-table th,
|
||||
.attendees-table td {
|
||||
padding: 0.75rem 0.5rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#1a1a2e"/>
|
||||
<text x="16" y="22" font-family="Arial, sans-serif" font-size="14" font-weight="700" fill="#ffffff" text-anchor="middle">NE</text>
|
||||
<circle cx="6" cy="10" r="2" fill="#3b82f6"/>
|
||||
<circle cx="26" cy="10" r="2" fill="#3b82f6"/>
|
||||
<circle cx="16" cy="6" r="2" fill="#3b82f6"/>
|
||||
<line x1="6" y1="10" x2="16" y2="6" stroke="#3b82f6" stroke-width="1.5"/>
|
||||
<line x1="26" y1="10" x2="16" y2="6" stroke="#3b82f6" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 553 B |
@@ -0,0 +1,93 @@
|
||||
// NetEvent - Main JavaScript
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Auto-hide flash messages after 5 seconds
|
||||
const flashMessages = document.querySelectorAll('.flash-message');
|
||||
flashMessages.forEach(msg => {
|
||||
setTimeout(() => {
|
||||
msg.style.transition = 'opacity 0.5s';
|
||||
msg.style.opacity = '0';
|
||||
setTimeout(() => msg.remove(), 500);
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
// Form validation
|
||||
const forms = document.querySelectorAll('form');
|
||||
forms.forEach(form => {
|
||||
form.addEventListener('submit', function(e) {
|
||||
const requiredInputs = form.querySelectorAll('[required]');
|
||||
let isValid = true;
|
||||
|
||||
requiredInputs.forEach(input => {
|
||||
if (!input.value.trim()) {
|
||||
isValid = false;
|
||||
input.style.borderColor = '#ef4444';
|
||||
} else {
|
||||
input.style.borderColor = '';
|
||||
}
|
||||
});
|
||||
|
||||
if (!isValid && !e.target.dataset.noValidate) {
|
||||
e.preventDefault();
|
||||
alert('Please fill in all required fields.');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Photo upload form
|
||||
const photoForm = document.querySelector('.photo-upload-form');
|
||||
if (photoForm) {
|
||||
photoForm.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
|
||||
try {
|
||||
const response = await fetch(this.action, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Reload page to show new photo
|
||||
location.reload();
|
||||
} else {
|
||||
alert(data.error || 'Error uploading photo');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error uploading photo');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Connection request buttons
|
||||
const connectForms = document.querySelectorAll('.connect-form');
|
||||
connectForms.forEach(form => {
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
|
||||
try {
|
||||
const response = await fetch(this.action, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
const card = this.closest('.attendee-card');
|
||||
const actionsDiv = card.querySelector('.attendee-actions');
|
||||
actionsDiv.innerHTML = '<span class="badge badge-pending">Request Pending</span>';
|
||||
} else {
|
||||
alert(data.error || 'Error sending connection request');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error sending connection request');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,50 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'attendees'|t }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="attendees-page">
|
||||
<h1>{{ 'event_attendees'|t }}</h1>
|
||||
<p>{{ 'connect_with_attendees'|t }}</p>
|
||||
|
||||
{% if attendees %}
|
||||
<div class="attendees-grid">
|
||||
{% for att in attendees %}
|
||||
<div class="attendee-card">
|
||||
<div class="attendee-photo">
|
||||
{% 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>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="attendee-info">
|
||||
<h3>{{ att.first_name }} {{ att.last_name }}</h3>
|
||||
<p class="attendee-org">{{ (att.organisation)|spacify if att.organisation else '' }}</p>
|
||||
<p class="attendee-role">{{ (att.role)|spacify if att.role else '' }}</p>
|
||||
{% if att.introduction %}
|
||||
<p class="attendee-intro">{{ att.introduction[:100] }}{% if att.introduction|length > 100 %}...{% endif %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="attendee-actions">
|
||||
{% if att.my_status == 'accepted' %}
|
||||
<span class="badge badge-success">{{ 'connected'|t }}</span>
|
||||
{% elif att.my_status == 'pending' %}
|
||||
<span class="badge badge-pending">{{ 'request_pending'|t }}</span>
|
||||
{% elif att.my_status == 'rejected' %}
|
||||
<span class="badge badge-rejected">{{ 'rejected'|t }}</span>
|
||||
{% else %}
|
||||
<form method="POST" action="{{ url_for('create_connection') }}" class="connect-form">
|
||||
<input type="hidden" name="connected_attendee_id" value="{{ att.id }}">
|
||||
<button type="submit" class="btn btn-sm btn-primary">{{ 'connect'|t }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="no-attendees">{{ 'no_other_attendees'|t }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,85 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'breakout_sessions'|t }} - {{ event.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="breakout-sessions">
|
||||
<h1>{{ 'breakout_sessions'|t }} - {{ event.name }}</h1>
|
||||
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
<div class="alert alert-info">
|
||||
{% for message in messages %}
|
||||
<p>{{ message }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% if sessions %}
|
||||
<div class="sessions-list">
|
||||
{% for session in sessions %}
|
||||
<div class="session-card" data-session-code="{{ session.code }}">
|
||||
<div class="session-info">
|
||||
<h3>{{ session.name }}</h3>
|
||||
<p><strong>{{ 'time'|t }}:</strong> {{ session.start_time|localized_date('%H:%M') if session.start_time else '' }} - {{ session.end_time|localized_date('%H:%M') if session.end_time else '' }}</p>
|
||||
<p><strong>{{ 'location'|t }}:</strong> {{ session.location }}</p>
|
||||
{% if session.max_attendees %}
|
||||
<p><strong>{{ 'capacity'|t }}:</strong> <span class="rsvp-count">{{ session.rsvp_count }}</span> / {{ session.max_attendees }}</p>
|
||||
{% else %}
|
||||
<p><strong>{{ 'registered'|t }}:</strong> {{ session.rsvp_count }}</p>
|
||||
{% endif %}
|
||||
{% if session.description %}
|
||||
<p>{{ session.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="session-actions">
|
||||
{% if session.my_rsvp_status == 'registered' %}
|
||||
<button class="btn btn-secondary rsvp-btn" data-session-code="{{ session.id }}" data-action="cancel">{{ 'cancel_rsvp'|t }}</button>
|
||||
{% elif session.my_rsvp_status == 'cancelled' %}
|
||||
<button class="btn btn-primary rsvp-btn" data-session-code="{{ session.id }}" data-action="rsvp">{{ 'rsvp'|t }}</button>
|
||||
{% else %}
|
||||
{% if not session.max_attendees or session.rsvp_count < session.max_attendees %}
|
||||
<button class="btn btn-primary rsvp-btn" data-session-code="{{ session.id }}" data-action="rsvp">{{ 'rsvp'|t }}</button>
|
||||
{% else %}
|
||||
<span class="full-label">{{ 'session_full'|t }}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="no-sessions">{{ 'no_breakout_sessions'|t }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="back-link">
|
||||
<a href="{{ url_for('attendee_dashboard') }}" class="btn btn-outline">{{ 'back_to_dashboard'|t }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.rsvp-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
const sessionCode = this.dataset.sessionCode;
|
||||
const action = this.dataset.action;
|
||||
const endpoint = action === 'rsvp'
|
||||
? `/attendee/breakout-session/${sessionCode}/rsvp`
|
||||
: `/attendee/breakout-session/${sessionCode}/cancel-rsvp`;
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(data.error || '{{ "error_occurred"|t }}');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('{{ "error_processing_request"|t }}');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,154 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'connection_requests'|t }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="connection-requests">
|
||||
<h1>{{ 'connection_requests'|t }}</h1>
|
||||
<p>{{ 'people_scanned_qr'|t }}</p>
|
||||
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
<div class="alert alert-info">
|
||||
{% for message in messages %}
|
||||
<p>{{ message }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% if requests %}
|
||||
<div class="requests-list">
|
||||
{% for req in requests %}
|
||||
<div class="request-card">
|
||||
<div class="request-profile">
|
||||
{% 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>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="request-info">
|
||||
<h3>{{ req.first_name }} {{ req.last_name }}</h3>
|
||||
<p class="request-email">{{ req.email }}</p>
|
||||
<p class="request-org">{{ (req.organisation)|spacify if req.organisation else '' }}{% if req.role %} - {{ (req.role)|spacify }}{% endif %}</p>
|
||||
{% if req.introduction %}
|
||||
<p class="request-intro">"{{ req.introduction }}"</p>
|
||||
{% endif %}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="no-requests">{{ 'no_pending_requests'|t }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="back-link">
|
||||
<a href="{{ url_for('attendee_dashboard') }}" class="btn btn-outline">{{ 'back_to_dashboard'|t }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.respond-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
const connectionId = this.dataset.connectionId;
|
||||
const action = this.dataset.action;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/attendee/connection-request/${connectionId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: action })
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(data.error || 'Error processing request');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error processing request');
|
||||
}
|
||||
});
|
||||
});
|
||||
</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 %}
|
||||
@@ -0,0 +1,102 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'attendee_dashboard'|t }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="dashboard">
|
||||
<h1>{{ 'attendee_dashboard'|t }}</h1>
|
||||
|
||||
{% if event %}
|
||||
<section class="event-info">
|
||||
<h2>{{ 'current_event'|t }}</h2>
|
||||
<div class="event-card">
|
||||
<h3>{{ event.name }}</h3>
|
||||
<p><strong>{{ 'start'|t }}:</strong> {{ event.start_time|localized_date if event.start_time else 'TBD' }}</p>
|
||||
{% if event.end_time %}
|
||||
<p><strong>{{ 'end'|t }}:</strong> {{ event.end_time|localized_date }}</p>
|
||||
{% endif %}
|
||||
<p><strong>{{ 'location'|t }}:</strong> {{ event.location }}</p>
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if pending_connections %}
|
||||
<section class="pending-section">
|
||||
<h2>{{ 'incoming_connection_requests'|t }}</h2>
|
||||
<p><a href="{{ url_for('connection_requests') }}">{{ 'review_scan_requests'|t }}</a></p>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="connections-section">
|
||||
<h2>{{ 'my_connections'|t }}</h2>
|
||||
<div class="section-actions">
|
||||
<a href="{{ url_for('list_attendees') }}" class="btn btn-primary">{{ 'find_attendees'|t }}</a>
|
||||
</div>
|
||||
|
||||
{% if connections %}
|
||||
<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)|spacify if conn.organisation else '' }}</p>
|
||||
<p class="connection-role">{{ (conn.role)|spacify if conn.role else '' }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="no-data">{{ 'no_connections_yet'|t }}</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="appointments-section">
|
||||
<h2>{{ 'my_appointments'|t }}</h2>
|
||||
<div class="section-actions">
|
||||
<a href="{{ url_for('list_attendees') }}" class="btn btn-outline">{{ 'schedule_appointment'|t }}</a>
|
||||
</div>
|
||||
|
||||
{% if appointments %}
|
||||
<div class="appointments-list">
|
||||
{% for apt in appointments %}
|
||||
<div class="appointment-item {% if apt.status %}status-{{ apt.status }}{% endif %}">
|
||||
<div class="apt-info">
|
||||
<p><strong>
|
||||
{% if apt.requester_id == session.user_id %}
|
||||
{{ 'with'|t }} {{ apt.target_first_name }} {{ apt.target_last_name }}
|
||||
{% else %}
|
||||
{{ 'with'|t }} {{ apt.requester_first_name }} {{ apt.requester_last_name }}
|
||||
{% endif %}
|
||||
</strong></p>
|
||||
<p><strong>{{ 'time'|t }}:</strong> {{ apt.appointment_time|localized_date if apt.appointment_time else 'TBD' }}</p>
|
||||
<p><strong>{{ 'location'|t }}:</strong> {{ apt.location or 'TBD' }}</p>
|
||||
<p class="apt-status status-{{ apt.status }}">{{ (apt.status)|spacify if apt.status else '' }}</p>
|
||||
</div>
|
||||
{% if apt.status == 'pending' and apt.target_id == session.user_id %}
|
||||
<div class="apt-actions">
|
||||
<form method="POST" action="{{ url_for('update_appointment', appointment_id=apt.id) }}" class="inline-form">
|
||||
<input type="hidden" name="status" value="accepted">
|
||||
<button type="submit" class="btn btn-sm btn-success">{{ 'accept'|t }}</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('update_appointment', appointment_id=apt.id) }}" class="inline-form">
|
||||
<input type="hidden" name="status" value="rejected">
|
||||
<button type="submit" class="btn btn-sm btn-danger">{{ 'reject'|t }}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="no-data">{{ 'no_appointments'|t }}</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ event.name }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="event-public">
|
||||
<h1>{{ event.name }}</h1>
|
||||
|
||||
<div class="event-meta-box">
|
||||
<p><strong>{{ 'start'|t }}:</strong> {{ event.start_time|localized_date if event.start_time else 'TBD' }}</p>
|
||||
{% if event.end_time %}
|
||||
<p><strong>{{ 'end'|t }}:</strong> {{ event.end_time|localized_date }}</p>
|
||||
{% endif %}
|
||||
<p><strong>{{ 'location'|t }}:</strong> {{ event.location }}</p>
|
||||
<p><strong>{{ 'organizer'|t }}:</strong> {{ event.organizer_name }}</p>
|
||||
<p><strong>{{ 'attendees'|t }}:</strong> {{ event.attendee_count }}</p>
|
||||
{% if event.max_attendees %}
|
||||
<p><strong>{{ 'spots_remaining'|t }}:</strong> {{ event.max_attendees - event.attendee_count }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if event.description %}
|
||||
<div class="event-description-box">
|
||||
<h3>{{ 'about_this_event'|t }}</h3>
|
||||
<p>{{ event.description }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not session.user_id %}
|
||||
<div class="event-cta">
|
||||
<p>{{ 'want_to_attend'|t }}</p>
|
||||
<a href="{{ url_for('register_attendee', code=event.code) }}" class="btn btn-primary">{{ 'register_as_attendee'|t }}</a>
|
||||
<p class="login-link">{{ 'already_registered'|t }} <a href="{{ url_for('login') }}">{{ 'login'|t }}</a></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,488 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'register_for'|t }} {{ event.name }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="event-registration-page">
|
||||
<div class="event-header">
|
||||
<h1>{{ event.name }}</h1>
|
||||
<div class="event-meta-box">
|
||||
<p><strong>{{ 'start'|t }}:</strong> {{ event.start_time|localized_date if event.start_time else 'TBD' }}</p>
|
||||
{% if event.end_time %}
|
||||
<p><strong>{{ 'end'|t }}:</strong> {{ event.end_time|localized_date }}</p>
|
||||
{% endif %}
|
||||
<p><strong>{{ 'location'|t }}:</strong> {{ event.location }}</p>
|
||||
</div>
|
||||
{% if event.description %}
|
||||
<div class="event-description-box">
|
||||
<p>{{ event.description }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="registration-content">
|
||||
{% with messages = get_flashed_messages() %}
|
||||
{% if messages %}
|
||||
<div class="alert alert-info">
|
||||
{% for message in messages %}
|
||||
<p>{{ message }}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% if not registered %}
|
||||
<!-- Registration Form with Breakout Sessions -->
|
||||
<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 }}
|
||||
{% if preselected_type.price and preselected_type.price > 0 %}
|
||||
<span style="color: #27ae60; margin-left: 10px;">{{ preselected_type.price|format_currency }}</span>
|
||||
{% else %}
|
||||
<span style="color: #7f8c8d; margin-left: 10px;">{{ 'free'|t }}</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="POST" action="{{ url_for('register_event', code=event.code) }}" id="registration-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="first_name">{{ 'first_name'|t }}</label>
|
||||
<input type="text" id="first_name" name="first_name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="last_name">{{ 'last_name'|t }}</label>
|
||||
<input type="text" id="last_name" name="last_name" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">{{ 'email'|t }}</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="organisation">{{ 'organisation'|t }}</label>
|
||||
<input type="text" id="organisation" name="organisation">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="role">{{ 'role_profession'|t }}</label>
|
||||
<input type="text" id="role" name="role">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="phone">{{ 'phone'|t }}</label>
|
||||
<input type="tel" id="phone" name="phone">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="linkedin">{{ 'linkedin'|t }}</label>
|
||||
<input type="url" id="linkedin" name="linkedin" placeholder="https://linkedin.com/in/...">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="introduction">{{ 'about_me'|t }}</label>
|
||||
<textarea id="introduction" name="introduction" maxlength="254" rows="3"></textarea>
|
||||
<small class="char-count"><span id="introduction_count">0</span>/254</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">{{ 'password'|t }}</label>
|
||||
<input type="password" id="password" name="password" required minlength="6">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirm_password">{{ 'confirm_password'|t }}</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password" required>
|
||||
</div>
|
||||
|
||||
{% if sessions %}
|
||||
<div class="form-group breakout-sessions-selection">
|
||||
<label>{{ 'select_breakout_sessions'|t }}</label>
|
||||
<p class="selection-hint">{{ 'select_breakout_sessions_hint'|t }}</p>
|
||||
<div class="sessions-checkboxes">
|
||||
{% for session in sessions %}
|
||||
<div class="session-checkbox-item {% if session.max_attendees and session.rsvp_count >= session.max_attendees %}session-full{% endif %}">
|
||||
<input type="checkbox" id="session_{{ session.id }}" name="breakout_sessions" value="{{ session.id }}"
|
||||
{% if session.max_attendees and session.rsvp_count >= session.max_attendees %}disabled{% endif %}>
|
||||
<label for="session_{{ session.id }}">
|
||||
<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>
|
||||
{% if session.max_attendees %}
|
||||
<span class="session-capacity {% if session.rsvp_count >= session.max_attendees %}full{% endif %}">
|
||||
{{ session.rsvp_count }}/{{ session.max_attendees }} {{ 'spots'|t }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="session-capacity">{{ session.rsvp_count }} {{ 'registered'|t }}</span>
|
||||
{% endif %}
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block">{{ 'register_for_event'|t }}</button>
|
||||
</form>
|
||||
<p class="login-link">{{ 'already_registered'|t }} <a href="{{ url_for('login') }}">{{ 'login'|t }}</a></p>
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Breakout Sessions Section -->
|
||||
<div class="breakout-sessions-section">
|
||||
<h2>{{ 'breakout_sessions'|t }}</h2>
|
||||
<p class="section-intro">{{ 'choose_sessions_intro'|t }}</p>
|
||||
|
||||
{% if sessions %}
|
||||
<div class="sessions-list">
|
||||
{% for session in sessions %}
|
||||
<div class="session-card" data-session-code="{{ session.id }}">
|
||||
<div class="session-info">
|
||||
<h3>{{ session.name }}</h3>
|
||||
<p><strong>{{ 'time'|t }}:</strong> {{ session.start_time|localized_date('%H:%M') if session.start_time else '' }} - {{ session.end_time|localized_date('%H:%M') if session.end_time else '' }}</p>
|
||||
<p><strong>{{ 'location'|t }}:</strong> {{ session.location }}</p>
|
||||
{% if session.max_attendees %}
|
||||
<p><strong>{{ 'capacity'|t }}:</strong> <span class="rsvp-count">{{ session.rsvp_count }}</span> / {{ session.max_attendees }}</p>
|
||||
{% else %}
|
||||
<p><strong>{{ 'registered'|t }}:</strong> {{ session.rsvp_count }}</p>
|
||||
{% endif %}
|
||||
{% if session.description %}
|
||||
<p>{{ session.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="session-actions">
|
||||
{% if session.my_rsvp_status == 'registered' %}
|
||||
<button class="btn btn-secondary rsvp-btn" data-session-code="{{ session.id }}" data-action="cancel">{{ 'cancel_rsvp'|t }}</button>
|
||||
{% elif session.my_rsvp_status == 'cancelled' %}
|
||||
<button class="btn btn-primary rsvp-btn" data-session-code="{{ session.id }}" data-action="rsvp">{{ 'rsvp'|t }}</button>
|
||||
{% else %}
|
||||
{% if not session.max_attendees or session.rsvp_count < session.max_attendees %}
|
||||
<button class="btn btn-primary rsvp-btn" data-session-code="{{ session.id }}" data-action="rsvp">{{ 'rsvp'|t }}</button>
|
||||
{% else %}
|
||||
<span class="full-label">{{ 'session_full'|t }}</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="no-sessions">{{ 'no_breakout_sessions'|t }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="registration-complete">
|
||||
<p>{{ 'you_are_registered'|t }} <strong>{{ event.name }}</strong>!</p>
|
||||
<a href="{{ url_for('login') }}" class="btn btn-outline">{{ 'go_to_login'|t }}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</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');
|
||||
var countEl = document.getElementById('introduction_count');
|
||||
if (intro && countEl) {
|
||||
intro.addEventListener('input', function() {
|
||||
countEl.textContent = intro.value.length;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.rsvp-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
const sessionCode = this.dataset.sessionCode;
|
||||
const action = this.dataset.action;
|
||||
const endpoint = action === 'rsvp'
|
||||
? `/attendee/breakout-session/${sessionCode}/rsvp`
|
||||
: `/attendee/breakout-session/${sessionCode}/cancel-rsvp`;
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, { method: 'POST' });
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(data.error || '{{ "error_occurred"|t }}');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('{{ "error_processing_request"|t }}');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,191 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% 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>
|
||||
|
||||
<div class="payment-box">
|
||||
<h1>{{ 'complete_payment'|t }}</h1>
|
||||
|
||||
<div class="order-summary">
|
||||
<h3>{{ 'order_summary'|t }}</h3>
|
||||
<div class="order-item">
|
||||
<span>{{ 'event_registration'|t }}</span>
|
||||
<span>{{ event.name }}</span>
|
||||
</div>
|
||||
<div class="order-item">
|
||||
<span>{{ 'attendee_type'|t }}:</span>
|
||||
<span>{{ pending.type_name }}</span>
|
||||
</div>
|
||||
<div class="order-item">
|
||||
<span>{{ 'attendee'|t }}:</span>
|
||||
<span>{{ pending.first_name }} {{ pending.last_name }}</span>
|
||||
</div>
|
||||
<div class="order-item">
|
||||
<span>{{ 'email'|t }}:</span>
|
||||
<span>{{ pending.email }}</span>
|
||||
</div>
|
||||
<div class="order-total">
|
||||
<span>{{ 'total'|t }}:</span>
|
||||
<span class="price">{{ pending.price|format_currency }} {{ get_currency_symbol() }}</span>
|
||||
</div>
|
||||
</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>
|
||||
</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 %}
|
||||
@@ -0,0 +1,201 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'my_events'|t }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="personal-page">
|
||||
<h1>{{ 'welcome'|t }}, {{ attendee.first_name }}!</h1>
|
||||
<p class="welcome-subtitle">{{ 'registration_confirmed'|t }}</p>
|
||||
|
||||
{% for event in events %}
|
||||
<section class="event-section">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<h3>{{ 'breakout_sessions'|t }}</h3>
|
||||
{% if event.breakout_sessions %}
|
||||
<div class="sessions-list">
|
||||
{% for session in event.breakout_sessions %}
|
||||
<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>
|
||||
{% 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>
|
||||
{% else %}
|
||||
<span class="badge badge-secondary">{{ 'not_registered'|t }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="no-sessions">{{ '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>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
<style>
|
||||
.personal-page {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.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>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,321 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'profile'|t }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="profile-page">
|
||||
<h1>{{ 'my_profile'|t }}</h1>
|
||||
|
||||
<div class="profile-container">
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<canvas id="crop-canvas" style="display: none;"></canvas>
|
||||
|
||||
<div class="zoom-controls">
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="adjustZoom(-0.25)">{{ 'zoom_out'|t }}</button>
|
||||
<span id="zoom-level">100%</span>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="adjustZoom(0.25)">{{ 'zoom_in'|t }}</button>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="resetImage()">{{ 'reset'|t }}</button>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('upload_photo') }}" enctype="multipart/form-data" class="photo-upload-form" id="photo-form">
|
||||
<div class="form-group">
|
||||
<label for="photo">{{ 'upload_photo'|t }}</label>
|
||||
<input type="file" id="photo" name="photo" accept="image/*">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm btn-primary" id="upload-btn">{{ 'upload'|t }}</button>
|
||||
</form>
|
||||
</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>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="last_name">{{ 'last_name'|t }}</label>
|
||||
<input type="text" id="last_name" name="last_name" value="{{ attendee.last_name }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="organisation">{{ 'organisation'|t }}</label>
|
||||
<input type="text" id="organisation" name="organisation" value="{{ attendee.organisation or '' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="role">{{ 'role_profession'|t }}</label>
|
||||
<input type="text" id="role" name="role" value="{{ attendee.role or '' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="introduction">{{ 'short_introduction'|t }}</label>
|
||||
<textarea id="introduction" name="introduction" rows="4">{{ attendee.introduction or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ 'update_profile'|t }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.current-photo {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
margin-bottom: 10px;
|
||||
overflow: hidden;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
background: #e0e0e0;
|
||||
}
|
||||
.profile-img {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
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;
|
||||
var panX = 0;
|
||||
var panY = 0;
|
||||
var isDragging = false;
|
||||
var dragStartX = 0;
|
||||
var dragStartY = 0;
|
||||
var originalImage = null;
|
||||
var containerSize = 150;
|
||||
|
||||
var container, preview, photoInput;
|
||||
|
||||
function init() {
|
||||
container = document.getElementById('photo-container');
|
||||
preview = document.getElementById('profile-preview');
|
||||
photoInput = document.getElementById('photo');
|
||||
|
||||
if (photoInput) {
|
||||
photoInput.addEventListener('change', handleFileSelect);
|
||||
}
|
||||
|
||||
if (container && preview) {
|
||||
container.addEventListener('mousedown', startDrag);
|
||||
document.addEventListener('mousemove', doDrag);
|
||||
document.addEventListener('mouseup', endDrag);
|
||||
container.addEventListener('wheel', handleWheel);
|
||||
}
|
||||
|
||||
document.getElementById('photo-form').addEventListener('submit', handleFormSubmit);
|
||||
}
|
||||
|
||||
function handleFileSelect(e) {
|
||||
var file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
var reader = new FileReader();
|
||||
reader.onload = function(event) {
|
||||
var img = new Image();
|
||||
img.onload = function() {
|
||||
originalImage = img;
|
||||
resetImage();
|
||||
preview.src = event.target.result;
|
||||
preview.style.display = 'block';
|
||||
var noPhoto = document.getElementById('no-photo');
|
||||
if (noPhoto) noPhoto.style.display = 'none';
|
||||
updateDisplay();
|
||||
};
|
||||
img.src = event.target.result;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function adjustZoom(delta) {
|
||||
var newZoom = zoomLevel + delta;
|
||||
if (newZoom < 1) newZoom = 1;
|
||||
if (newZoom > 3) newZoom = 3;
|
||||
zoomLevel = newZoom;
|
||||
document.getElementById('zoom-level').textContent = Math.round(zoomLevel * 100) + '%';
|
||||
|
||||
if (zoomLevel === 1) {
|
||||
panX = 0;
|
||||
panY = 0;
|
||||
}
|
||||
updateDisplay();
|
||||
}
|
||||
|
||||
function resetImage() {
|
||||
zoomLevel = 1;
|
||||
panX = 0;
|
||||
panY = 0;
|
||||
document.getElementById('zoom-level').textContent = '100%';
|
||||
updateDisplay();
|
||||
}
|
||||
|
||||
function updateDisplay() {
|
||||
if (!preview || !originalImage) return;
|
||||
|
||||
var img = originalImage;
|
||||
var imgW = img.naturalWidth;
|
||||
var imgH = img.naturalHeight;
|
||||
var imgAspect = imgW / imgH;
|
||||
|
||||
// Calculate display size (object-fit: cover)
|
||||
var dispW, dispH;
|
||||
if (imgAspect > 1) {
|
||||
dispH = containerSize;
|
||||
dispW = dispH * imgAspect;
|
||||
} else {
|
||||
dispW = containerSize;
|
||||
dispH = dispW / imgAspect;
|
||||
}
|
||||
|
||||
// Center offset for object-fit: cover
|
||||
var offsetX = (containerSize - dispW) / 2;
|
||||
var offsetY = (containerSize - dispH) / 2;
|
||||
|
||||
// Apply pan (drag right = see right side of image)
|
||||
var totalX = offsetX + panX;
|
||||
var totalY = offsetY + panY;
|
||||
|
||||
preview.style.width = dispW + 'px';
|
||||
preview.style.height = dispH + 'px';
|
||||
preview.style.left = totalX + 'px';
|
||||
preview.style.top = totalY + 'px';
|
||||
preview.style.transform = 'scale(' + zoomLevel + ')';
|
||||
preview.style.transformOrigin = 'center center';
|
||||
}
|
||||
|
||||
function startDrag(e) {
|
||||
if (!originalImage) return;
|
||||
isDragging = true;
|
||||
dragStartX = e.clientX - panX;
|
||||
dragStartY = e.clientY - panY;
|
||||
preview.style.cursor = 'grabbing';
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function doDrag(e) {
|
||||
if (!isDragging) return;
|
||||
panX = e.clientX - dragStartX;
|
||||
panY = e.clientY - dragStartY;
|
||||
updateDisplay();
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
isDragging = false;
|
||||
if (preview) preview.style.cursor = 'move';
|
||||
}
|
||||
|
||||
function handleWheel(e) {
|
||||
e.preventDefault();
|
||||
if (e.deltaY < 0) {
|
||||
adjustZoom(0.1);
|
||||
} else {
|
||||
adjustZoom(-0.1);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFormSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!originalImage) {
|
||||
alert('Please select an image first');
|
||||
return;
|
||||
}
|
||||
|
||||
var canvas = document.getElementById('crop-canvas');
|
||||
var ctx = canvas.getContext('2d');
|
||||
canvas.width = 300;
|
||||
canvas.height = 300;
|
||||
|
||||
var img = originalImage;
|
||||
var imgW = img.naturalWidth;
|
||||
var imgH = img.naturalHeight;
|
||||
var imgAspect = imgW / imgH;
|
||||
|
||||
// Calculate display size (object-fit: cover)
|
||||
var dispW, dispH;
|
||||
if (imgAspect > 1) {
|
||||
dispH = containerSize;
|
||||
dispW = dispH * imgAspect;
|
||||
} else {
|
||||
dispW = containerSize;
|
||||
dispH = dispW / imgAspect;
|
||||
}
|
||||
|
||||
// Centering offsets
|
||||
var offsetX = (containerSize - dispW) / 2;
|
||||
var offsetY = (containerSize - dispH) / 2;
|
||||
|
||||
// Scale factors from display to image coordinates
|
||||
var scaleX = imgW / dispW;
|
||||
var scaleY = imgH / dispH;
|
||||
|
||||
// Source crop region
|
||||
var srcX = (offsetX + panX + containerSize / 2) / (scaleX * zoomLevel) - containerSize / (2 * scaleX);
|
||||
var srcY = (offsetY + panY + containerSize / 2) / (scaleY * zoomLevel) - containerSize / (2 * scaleY);
|
||||
var srcW = containerSize / (scaleX * zoomLevel);
|
||||
var srcH = containerSize / (scaleY * zoomLevel);
|
||||
|
||||
// Clamp to image bounds
|
||||
srcX = Math.max(0, srcX);
|
||||
srcY = Math.max(0, srcY);
|
||||
srcW = Math.min(imgW - srcX, srcW);
|
||||
srcH = Math.min(imgH - srcY, srcH);
|
||||
|
||||
// Draw with circular clip
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.beginPath();
|
||||
ctx.arc(150, 150, 150, 0, Math.PI * 2);
|
||||
ctx.clip();
|
||||
ctx.drawImage(img, srcX, srcY, srcW, srcH, 0, 0, 300, 300);
|
||||
|
||||
canvas.toBlob(function(blob) {
|
||||
var formData = new FormData();
|
||||
formData.append('photo', blob, 'profile.jpg');
|
||||
|
||||
fetch('{{ url_for("upload_photo") }}', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}).then(function(response) {
|
||||
return response.json();
|
||||
}).then(function(data) {
|
||||
if (data.success) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(data.error || 'Upload failed');
|
||||
}
|
||||
}).catch(function(error) {
|
||||
alert('Error uploading photo');
|
||||
});
|
||||
}, 'image/jpeg', 0.9);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,229 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'scan_qr'|t }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="scan-page">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="scanner-container">
|
||||
<div id="qr-reader" class="qr-reader"></div>
|
||||
<div id="scan-result" class="scan-result hidden">
|
||||
<div class="result-icon" id="result-icon"></div>
|
||||
<h2 id="result-title"></h2>
|
||||
<p id="result-message"></p>
|
||||
<button id="scan-again" class="btn btn-primary">{{ 'scan_again'|t }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scan-info">
|
||||
<p>{{ 'scan_info_text'|t }}</p>
|
||||
<p><strong>{{ 'scan_info_warning'|t }}</strong></p>
|
||||
</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 }};
|
||||
const myEventId = {{ session.event_id }};
|
||||
let html5QrCode;
|
||||
let scanning = true;
|
||||
|
||||
async function sendConnectionRequest(scannedId) {
|
||||
try {
|
||||
const response = await fetch('/attendee/scan-request', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ scanned_id: scannedId })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const resultDiv = document.getElementById('scan-result');
|
||||
const icon = document.getElementById('result-icon');
|
||||
const title = document.getElementById('result-title');
|
||||
const message = document.getElementById('result-message');
|
||||
|
||||
icon.className = 'result-icon';
|
||||
|
||||
if (data.success) {
|
||||
document.getElementById('qr-reader').style.display = 'none';
|
||||
resultDiv.classList.remove('hidden');
|
||||
icon.classList.add('pending');
|
||||
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');
|
||||
title.textContent = '{{ "error"|t }}';
|
||||
message.textContent = data.error || '{{ "failed_send_request"|t }}';
|
||||
}
|
||||
|
||||
scanning = false;
|
||||
} catch (error) {
|
||||
console.error('Connection error:', error);
|
||||
alert('Error sending connection request');
|
||||
}
|
||||
}
|
||||
|
||||
function onScanSuccess(decodedText) {
|
||||
if (!scanning) return;
|
||||
|
||||
// Expected format: "NETEVENT:{event_id}:{attendee_id}"
|
||||
const parts = decodedText.split(':');
|
||||
if (parts.length === 3 && parts[0] === 'NETEVENT') {
|
||||
const eventId = parseInt(parts[1]);
|
||||
const attendeeId = parseInt(parts[2]);
|
||||
|
||||
if (eventId === myEventId) {
|
||||
if (attendeeId === myId) {
|
||||
alert('{{ "cannot_scan_own_qr"|t }}');
|
||||
return;
|
||||
}
|
||||
sendConnectionRequest(attendeeId);
|
||||
} else {
|
||||
alert('{{ "qr_different_event"|t }}');
|
||||
}
|
||||
} else {
|
||||
console.log('Unknown QR format:', decodedText);
|
||||
alert('{{ "unrecognized_qr"|t }}');
|
||||
}
|
||||
}
|
||||
|
||||
function startScanner() {
|
||||
html5QrCode = new Html5Qrcode("qr-reader");
|
||||
|
||||
html5QrCode.start(
|
||||
{ facingMode: "environment" },
|
||||
{
|
||||
fps: 10,
|
||||
qrbox: { width: 250, height: 250 }
|
||||
},
|
||||
onScanSuccess,
|
||||
(errorMessage) => {
|
||||
// Ignore scan errors
|
||||
}
|
||||
).catch(err => {
|
||||
console.error('Camera error:', err);
|
||||
alert('{{ "camera_permission_error"|t }}');
|
||||
});
|
||||
}
|
||||
|
||||
function resetScanner() {
|
||||
document.getElementById('qr-reader').style.display = 'block';
|
||||
document.getElementById('scan-result').classList.add('hidden');
|
||||
scanning = true;
|
||||
}
|
||||
|
||||
document.getElementById('scan-again').addEventListener('click', resetScanner);
|
||||
|
||||
window.addEventListener('DOMContentLoaded', startScanner);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,61 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'login'|t }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-container">
|
||||
<div class="auth-box">
|
||||
<h2>{{ 'login'|t }}</h2>
|
||||
|
||||
<div class="user-type-tabs">
|
||||
<button type="button" class="tab-btn" data-type="breakout_organizer">{{ 'presenter'|t }}</button>
|
||||
<button type="button" class="tab-btn" data-type="staff">{{ 'staff'|t }}</button>
|
||||
<button type="button" class="tab-btn" data-type="attendee">{{ 'visitor'|t }}</button>
|
||||
<button type="button" class="tab-btn" data-type="organizer">{{ 'organizer'|t }}</button>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('login') }}">
|
||||
<input type="hidden" name="user_type" id="user_type" value="attendee">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">{{ 'email'|t }}</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">{{ 'password'|t }}</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block">{{ 'login'|t }}</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-footer">
|
||||
{{ 'dont_have_account'|t }}
|
||||
<a href="{{ url_for('register_organizer') }}">{{ 'register_as_organizer'|t }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
document.getElementById('user_type').value = this.dataset.type;
|
||||
});
|
||||
});
|
||||
|
||||
// Pre-select tab based on passed default_type
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const type = params.get('type');
|
||||
if (type) {
|
||||
const btn = document.querySelector(`.tab-btn[data-type="${type}"]`);
|
||||
if (btn) {
|
||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.getElementById('user_type').value = type;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ session.locale if session.locale else 'en' }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}NetEvents{% endblock %}</title>
|
||||
<link rel="icon" href="{{ url_for('static', filename='favicon.svg') }}">
|
||||
<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>
|
||||
<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>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
<li><a href="{{ url_for('logout') }}">{{ 'logout'|t }}</a></li>
|
||||
{% else %}
|
||||
<li><a href="{{ url_for('index') }}">Home</a></li>
|
||||
<li><a href="{{ url_for('login') }}">{{ 'login'|t }}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container">
|
||||
{% 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 %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<p>© 2024 NetEvents - Networking Event Platform</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,75 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'welcome'|t }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="hero">
|
||||
<h1>{{ 'welcome'|t }}</h1>
|
||||
<p>{{ 'connect_with_professionals'|t }}</p>
|
||||
{% if not session.user_id %}
|
||||
<div class="hero-buttons">
|
||||
<a href="{{ url_for('register_organizer') }}" class="btn btn-primary">{{ 'register_as_organizer'|t }}</a>
|
||||
</div>
|
||||
<div class="login-options">
|
||||
<p>{{ 'already_have_account'|t }}</p>
|
||||
<div class="login-buttons">
|
||||
<a href="{{ url_for('login') }}?type=breakout_organizer" class="btn btn-outline">{{ 'presenter'|t }}</a>
|
||||
<a href="{{ url_for('login') }}?type=staff" class="btn btn-outline">{{ 'staff'|t }}</a>
|
||||
<a href="{{ url_for('login') }}?type=attendee" class="btn btn-outline">{{ 'visitor'|t }}</a>
|
||||
<a href="{{ url_for('login') }}?type=organizer" class="btn btn-outline">{{ 'organiser'|t }}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if show_events %}
|
||||
<section class="events-section">
|
||||
<h2>{{ 'upcoming_events'|t }}</h2>
|
||||
{% if events %}
|
||||
<div class="events-grid">
|
||||
{% for event in events %}
|
||||
<div class="event-card">
|
||||
<h3>{{ event.name }}</h3>
|
||||
<p class="event-date">{{ event.start_time|localized_date if event.start_time else 'TBD' }}</p>
|
||||
<p class="event-location">{{ event.location }}</p>
|
||||
<p class="event-description">{{ event.description[:150] }}{% if event.description and event.description|length > 150 %}...{% endif %}</p>
|
||||
<div class="event-meta">
|
||||
<span>{{ 'organiser'|t }}: {{ event.organizer_name }}</span>
|
||||
<span>{{ event.attendee_count }} {{ 'attendees'|t }}</span>
|
||||
</div>
|
||||
<a href="{{ url_for('register_event', code=event.code) }}" class="btn btn-outline">{{ 'view_event'|t }}</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="no-events">{{ 'no_upcoming_events'|t }}</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if not session.user_id and not show_events %}
|
||||
<section class="features">
|
||||
<h2>{{ 'platform_features'|t }}</h2>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<h3>{{ 'for_organizers'|t }}</h3>
|
||||
<ul>
|
||||
<li>{{ 'create_manage_events'|t }}</li>
|
||||
<li>{{ 'view_attendee_lists'|t }}</li>
|
||||
<li>{{ 'print_badges'|t }}</li>
|
||||
<li>{{ 'track_statistics'|t }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3>{{ 'for_attendees'|t }}</h3>
|
||||
<ul>
|
||||
<li>{{ 'rsvp_events'|t }}</li>
|
||||
<li>{{ 'manage_profile'|t }}</li>
|
||||
<li>{{ 'connect_attendees'|t }}</li>
|
||||
<li>{{ 'schedule_appointments'|t }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,169 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'attendee_types'|t }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.page-header {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.page-header h1 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.event-card {
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.event-card h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 15px;
|
||||
color: #2c3e50;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.event-card h2 a {
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
}
|
||||
.types-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.types-table th,
|
||||
.types-table td {
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.types-table th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
.price {
|
||||
font-weight: 500;
|
||||
color: #27ae60;
|
||||
}
|
||||
.price.free {
|
||||
color: #7f8c8d;
|
||||
}
|
||||
.no-types {
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
}
|
||||
.back-link {
|
||||
margin-bottom: 20px;
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="all-attendee-types-page">
|
||||
<a href="{{ url_for('organizer_dashboard') }}" class="back-link">← {{ 'back_to_dashboard'|t }}</a>
|
||||
|
||||
<div class="page-header">
|
||||
<h1>{{ 'attendee_types'|t }}</h1>
|
||||
<p style="color: #666;">{{ 'manage_attendee_types_across_events'|t }}</p>
|
||||
</div>
|
||||
|
||||
{% if events %}
|
||||
{% for event in events %}
|
||||
<div class="event-card">
|
||||
<h2>
|
||||
{{ event.name }}
|
||||
<a href="{{ url_for('event_detail', code=event.code) }}" class="btn btn-sm btn-outline">{{ 'view_event'|t }}</a>
|
||||
</h2>
|
||||
|
||||
{% if event.attendee_types %}
|
||||
<table class="types-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'type_name'|t }}</th>
|
||||
<th>{{ 'price'|t }}</th>
|
||||
<th>{{ 'registration_link'|t }}</th>
|
||||
<th>{{ 'attendees'|t }}</th>
|
||||
<th>{{ 'actions'|t }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for at in event.attendee_types %}
|
||||
<tr>
|
||||
<td><strong>{{ at.name }}</strong></td>
|
||||
<td>
|
||||
{% if at.price and at.price > 0 %}
|
||||
<span class="price">{{ at.price|format_currency }}</span>
|
||||
{% else %}
|
||||
<span class="price free">{{ 'free'|t }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<code style="background: #f0f0f0; padding: 4px 8px; border-radius: 4px; font-size: 12px;">
|
||||
{{ url_for('register_event', code=event.code, type_code=at.code, _external=True) }}
|
||||
</code>
|
||||
<button type="button" class="btn btn-sm" style="padding: 2px 8px; margin-left: 8px;"
|
||||
onclick="copyLink('{{ url_for('register_event', code=event.code, type_code=at.code, _external=True) }}')">
|
||||
{{ 'copy'|t }}
|
||||
</button>
|
||||
</td>
|
||||
<td>{{ at.attendee_count }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('manage_attendee_types', event_id=event.id) }}" class="btn btn-sm">{{ 'manage'|t }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="no-types">{{ 'no_attendee_types_for_event'|t }} <a href="{{ url_for('manage_attendee_types', event_id=event.id) }}">{{ 'create_first_type'|t }}</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p style="color: #888; text-align: center; padding: 40px;">
|
||||
{{ 'no_events_yet'|t }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copyLink(url) {
|
||||
// Try modern clipboard API first
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(url).then(function() {
|
||||
alert('{{ 'link_copied'|t }}');
|
||||
}, function(err) {
|
||||
// Fallback for HTTP sites
|
||||
fallbackCopy(url);
|
||||
});
|
||||
} else {
|
||||
// Fallback for older browsers or HTTP sites
|
||||
fallbackCopy(url);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(url) {
|
||||
var textArea = document.createElement('textarea');
|
||||
textArea.value = url;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-9999px';
|
||||
textArea.style.top = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
var successful = document.execCommand('copy');
|
||||
if (successful) {
|
||||
alert('{{ 'link_copied'|t }}');
|
||||
} else {
|
||||
alert('{{ 'copy_failed'|t }}');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('{{ 'copy_failed'|t }}');
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,182 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'attendee_types'|t }} - {{ event.name }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.section-box {
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.section-box h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 15px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.form-group {
|
||||
flex: 1;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.types-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 15px;
|
||||
}
|
||||
.types-table th,
|
||||
.types-table td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
.types-table th {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
.types-table tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.price {
|
||||
font-weight: 500;
|
||||
color: #27ae60;
|
||||
}
|
||||
.price.free {
|
||||
color: #7f8c8d;
|
||||
}
|
||||
.back-link {
|
||||
margin-bottom: 20px;
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="attendee-types-page">
|
||||
<a href="{{ url_for('event_detail', code=event.code) }}" class="back-link">← {{ 'back_to_event'|t }}</a>
|
||||
|
||||
<h1>{{ 'attendee_types'|t }} - {{ event.name }}</h1>
|
||||
|
||||
<div class="section-box">
|
||||
<h2>{{ 'create_attendee_type'|t }}</h2>
|
||||
<form method="POST" action="{{ url_for('manage_attendee_types', event_id=event.id) }}">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="name">{{ 'type_name'|t }} *</label>
|
||||
<input type="text" id="name" name="name" placeholder="{{ 'e_g_vip_speaker_student'|t }}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="price">{{ 'price'|t }} ({{ 'optional'|t }})</label>
|
||||
<input type="number" id="price" name="price" step="0.01" min="0" placeholder="0.00">
|
||||
<small style="color: #666;">{{ 'leave_empty_for_free'|t }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">{{ 'create_type'|t }}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="section-box">
|
||||
<h2>{{ 'existing_types'|t }}</h2>
|
||||
|
||||
{% if attendee_types %}
|
||||
<table class="types-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'name'|t }}</th>
|
||||
<th>{{ 'price'|t }}</th>
|
||||
<th>{{ 'registration_link'|t }}</th>
|
||||
<th>{{ 'actions'|t }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for at in attendee_types %}
|
||||
<tr>
|
||||
<td><strong>{{ at.name }}</strong></td>
|
||||
<td>
|
||||
{% if at.price and at.price > 0 %}
|
||||
<span class="price">{{ at.price|format_currency }}</span>
|
||||
{% else %}
|
||||
<span class="price free">{{ 'free'|t }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<code style="background: #f0f0f0; padding: 4px 8px; border-radius: 4px; font-size: 12px;">
|
||||
{{ url_for('register_event', code=event.code, type_code=at.code, _external=True) }}
|
||||
</code>
|
||||
<button type="button" class="btn btn-sm" style="padding: 2px 8px; margin-left: 8px;"
|
||||
onclick="copyLink('{{ url_for('register_event', code=event.code, type_code=at.code, _external=True) }}')">
|
||||
{{ 'copy'|t }}
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<form method="POST" action="{{ url_for('delete_attendee_type', event_id=event.id, type_id=at.id) }}" style="display: inline;"
|
||||
onsubmit="return confirm('{{ 'confirm_delete_type'|t }}');">
|
||||
<button type="submit" class="btn btn-sm btn-danger">{{ 'delete'|t }}</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="no-types" style="color: #888; text-align: center; padding: 20px;">
|
||||
{{ 'no_attendee_types_yet'|t }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function copyLink(url) {
|
||||
// Try modern clipboard API first
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(url).then(function() {
|
||||
alert('{{ 'link_copied'|t }}');
|
||||
}, function(err) {
|
||||
// Fallback for HTTP sites
|
||||
fallbackCopy(url);
|
||||
});
|
||||
} else {
|
||||
// Fallback for older browsers or HTTP sites
|
||||
fallbackCopy(url);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(url) {
|
||||
var textArea = document.createElement('textarea');
|
||||
textArea.value = url;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-9999px';
|
||||
textArea.style.top = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
var successful = document.execCommand('copy');
|
||||
if (successful) {
|
||||
alert('{{ 'link_copied'|t }}');
|
||||
} else {
|
||||
alert('{{ 'copy_failed'|t }}');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('{{ 'copy_failed'|t }}');
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,104 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'badges'|t }} - {{ event.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="badges-page">
|
||||
<div class="badges-header">
|
||||
<h1>{{ event.name }}</h1>
|
||||
<p>{{ 'attendee_badges'|t }}</p>
|
||||
<div class="header-actions">
|
||||
<a href="{{ url_for('event_scan', event_id=event.id) }}" class="btn btn-secondary">📷 {{ 'scan_qr_codes'|t }}</a>
|
||||
<button onclick="window.print()" class="btn btn-primary">{{ 'print_badges'|t }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="badges-grid">
|
||||
{% for attendee in attendees %}
|
||||
<div class="badge-card" data-first="{{ attendee.first_name }}" data-last="{{ attendee.last_name }}">
|
||||
<div class="badge-header">
|
||||
<h3 class="badge-name">
|
||||
<span class="first-name">{{ attendee.first_name }}</span><br>
|
||||
<span class="last-name">{{ attendee.last_name }}</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="badge-qr">
|
||||
<img src="{{ attendee.qr_code }}" alt="QR Code" width="100" height="100">
|
||||
</div>
|
||||
<div class="badge-body">
|
||||
<p class="badge-org">{{ (attendee.organisation)|spacify if attendee.organisation else '' }}</p>
|
||||
<p class="badge-role">{{ (attendee.role)|spacify if attendee.role else '' }}</p>
|
||||
{% if attendee.introduction %}
|
||||
<p class="badge-intro">{{ attendee.introduction[:80] }}{% if attendee.introduction|length > 80 %}...{% endif %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="badge-footer">
|
||||
<span class="badge-id">#{{ attendee.id }}</span>
|
||||
<span class="badge-check {% if attendee.checked_in %}checked-in{% endif %}">
|
||||
{% if attendee.checked_in %}✓ {{ 'checked_in'|t }}{% else %}{{ 'pending'|t }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.badge-header h3 { margin: 0; font-size: 1rem; line-height: 1.2; }
|
||||
.badge-header h3 .first-name,
|
||||
.badge-header h3 .last-name { display: block; line-height: 1.1; }
|
||||
.badge-header h3 .last-name { font-size: 0.8rem; }
|
||||
@media print {
|
||||
.navbar, .footer, .badges-header .header-actions button, .flash-messages { display: none !important; }
|
||||
.badges-page { padding: 0; }
|
||||
.badges-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; }
|
||||
.badge-card { border: 2px solid #000; padding: 15px; page-break-inside: avoid; }
|
||||
.badge-header h3 { margin: 0; font-size: 1rem; line-height: 1.2; }
|
||||
.badge-header h3 .first-name,
|
||||
.badge-header h3 .last-name { display: block !important; line-height: 1.1; }
|
||||
.badge-header h3 .last-name { font-size: 0.8rem !important; }
|
||||
.badge-qr img { width: 80px !important; height: 80px !important; }
|
||||
.badge-body p { margin: 5px 0; }
|
||||
.badge-footer { display: flex; justify-content: space-between; margin-top: 10px; padding-top: 10px; border-top: 1px solid #ccc; }
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(function() {
|
||||
// Create offscreen canvas for text measurement
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Width limit for last name before it needs font reduction
|
||||
const LAST_NAME_WIDTH_LIMIT = 120;
|
||||
|
||||
function measureText(text, fontSize, fontFamily) {
|
||||
ctx.font = fontSize + 'px ' + (fontFamily || 'sans-serif');
|
||||
return ctx.measureText(text).width;
|
||||
}
|
||||
|
||||
function formatBadgeNames() {
|
||||
const cards = document.querySelectorAll('.badge-card');
|
||||
cards.forEach(card => {
|
||||
const lastName = card.dataset.last || '';
|
||||
const lastSpan = card.querySelector('.last-name');
|
||||
|
||||
// Measure last name at reduced size (0.8rem = 12.8px)
|
||||
const reducedFontSize = 12.8;
|
||||
const reducedLastWidth = measureText(lastName, reducedFontSize);
|
||||
|
||||
// If still too wide, reduce further
|
||||
if (reducedLastWidth > LAST_NAME_WIDTH_LIMIT) {
|
||||
lastSpan.style.fontSize = '0.7rem';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Run after DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', formatBadgeNames);
|
||||
} else {
|
||||
formatBadgeNames();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,58 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ session.name }} - {{ 'breakout_session'|t }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="breakout-session-detail">
|
||||
<div class="session-header">
|
||||
<h1>{{ session.name }}</h1>
|
||||
<a href="{{ url_for('edit_breakout_session', session_id=session.id) }}" class="btn btn-outline">{{ 'edit_session'|t }}</a>
|
||||
<a href="{{ url_for('list_breakout_sessions', event_id=session.event_id) }}" class="btn btn-outline">{{ 'back_to_sessions'|t }}</a>
|
||||
</div>
|
||||
|
||||
<div class="session-meta-box">
|
||||
<p><strong>{{ 'event'|t }}:</strong> {{ session.event_name }}</p>
|
||||
<p><strong>{{ 'time'|t }}:</strong> {{ session.start_time|localized_date if session.start_time else 'TBD' }} - {{ session.end_time|localized_date('%H:%M') if session.end_time else 'TBD' }}</p>
|
||||
<p><strong>{{ 'location'|t }}:</strong> {{ session.location }}</p>
|
||||
<p><strong>{{ 'capacity'|t }}:</strong>
|
||||
{% if session.max_attendees %}
|
||||
{{ rsvps|length }} / {{ session.max_attendees }}
|
||||
{% else %}
|
||||
{{ rsvps|length }} ({{ 'unlimited'|t }})
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if session.description %}
|
||||
<p><strong>{{ 'description'|t }}:</strong> {{ session.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<section class="attendees-section">
|
||||
<h2>{{ 'registered_attendees'|t }}</h2>
|
||||
|
||||
{% if rsvps %}
|
||||
<table class="attendees-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'name'|t }}</th>
|
||||
<th>{{ 'email'|t }}</th>
|
||||
<th>{{ 'organisation'|t }}</th>
|
||||
<th>{{ 'role'|t }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for rsvp in rsvps %}
|
||||
<tr>
|
||||
<td>{{ rsvp.first_name }} {{ rsvp.last_name }}</td>
|
||||
<td>{{ rsvp.email }}</td>
|
||||
<td>{{ (rsvp.organisation)|spacify if rsvp.organisation else '-' }}</td>
|
||||
<td>{{ (rsvp.role)|spacify if rsvp.role else '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="no-attendees">{{ 'no_attendees_yet'|t }}</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,43 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'breakout_sessions'|t }} - {{ event.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="breakout-sessions">
|
||||
<div class="page-header">
|
||||
<h1>{{ 'breakout_sessions'|t }} - {{ event.name }}</h1>
|
||||
<a href="{{ url_for('create_breakout_session', event_id=event.id) }}" class="btn btn-primary">{{ 'create_breakout_session'|t }}</a>
|
||||
</div>
|
||||
|
||||
{% if sessions %}
|
||||
<div class="sessions-list">
|
||||
{% for session in sessions %}
|
||||
<div class="session-card">
|
||||
<div class="session-info">
|
||||
<h3>{{ session.name }}</h3>
|
||||
<p><strong>{{ 'time'|t }}:</strong> {{ session.start_time|localized_date('%H:%M') if session.start_time else '' }} - {{ session.end_time|localized_date('%H:%M') if session.end_time else '' }}</p>
|
||||
<p><strong>{{ 'location'|t }}:</strong> {{ session.location }}</p>
|
||||
{% if session.max_attendees %}
|
||||
<p><strong>{{ 'capacity'|t }}:</strong> {{ session.rsvp_count }} / {{ session.max_attendees }}</p>
|
||||
{% else %}
|
||||
<p><strong>{{ 'registered'|t }}:</strong> {{ session.rsvp_count }}</p>
|
||||
{% endif %}
|
||||
{% if session.description %}
|
||||
<p>{{ session.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="session-actions">
|
||||
<a href="{{ url_for('view_breakout_session', code=session.code) }}" class="btn btn-secondary">{{ 'view_attendees'|t }}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="no-sessions">{{ 'no_breakout_sessions_yet'|t }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="back-link">
|
||||
<a href="{{ url_for('event_detail', code=event.code) }}" class="btn btn-outline">{{ 'back_to_event'|t }}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,47 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'create_breakout_session'|t }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<h1>{{ 'create_breakout_session'|t }}</h1>
|
||||
<p class="event-info">{{ 'event'|t }}: {{ event.name }}</p>
|
||||
|
||||
<form method="POST" action="{{ url_for('create_breakout_session', event_id=event.id) }}" class="session-form">
|
||||
<div class="form-group">
|
||||
<label for="name">{{ 'session_name'|t }}</label>
|
||||
<input type="text" id="name" name="name" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">{{ 'description'|t }}</label>
|
||||
<textarea id="description" name="description" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="start_time">{{ 'start_time'|t }}</label>
|
||||
<input type="datetime-local" id="start_time" name="start_time" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="end_time">{{ 'end_time'|t }}</label>
|
||||
<input type="datetime-local" id="end_time" name="end_time" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="location">{{ 'location'|t }}</label>
|
||||
<input type="text" id="location" name="location" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="max_attendees">{{ 'max_attendees_event_hint'|t }} {{ event.max_attendees or 'unlimited' }})</label>
|
||||
<input type="number" id="max_attendees" name="max_attendees" min="1"{% if event.max_attendees %} max="{{ event.max_attendees }}"{% endif %}>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ 'create_session'|t }}</button>
|
||||
<a href="{{ url_for('list_breakout_sessions', event_id=event.id) }}" class="btn btn-outline">{{ 'cancel'|t }}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'create_event'|t }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<h1>{{ 'create_new_event'|t }}</h1>
|
||||
|
||||
<form method="POST" action="{{ url_for('create_event') }}" class="event-form">
|
||||
<div class="form-group">
|
||||
<label for="name">{{ 'event_name'|t }}</label>
|
||||
<input type="text" id="name" name="name" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">{{ 'description'|t }}</label>
|
||||
<textarea id="description" name="description" rows="4"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="start_time">{{ 'start_date_time'|t }}</label>
|
||||
<input type="datetime-local" id="start_time" name="start_time" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="end_time">{{ 'end_date_time_optional'|t }}</label>
|
||||
<input type="datetime-local" id="end_time" name="end_time">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="location">{{ 'location'|t }}</label>
|
||||
<input type="text" id="location" name="location" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="max_attendees">{{ 'max_attendees_hint'|t }}</label>
|
||||
<input type="number" id="max_attendees" name="max_attendees" min="1">
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ 'create_event'|t }}</button>
|
||||
<a href="{{ url_for('organizer_dashboard') }}" class="btn btn-outline">{{ 'cancel'|t }}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,307 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'dashboard'|t }} - {{ 'organizer'|t }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.progress-bar-container {
|
||||
background: #e9ecef;
|
||||
border-radius: 4px;
|
||||
height: 20px;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background: #28a745;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
.progress-bar-fill.warning {
|
||||
background: #ffc107;
|
||||
}
|
||||
.progress-bar-fill.full {
|
||||
background: #dc3545;
|
||||
}
|
||||
.event-capacity {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.breakout-sessions-list {
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
width: 100%;
|
||||
}
|
||||
.breakout-session-item {
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.breakout-session-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.breakout-session-name {
|
||||
font-weight: 500;
|
||||
color: #495057;
|
||||
}
|
||||
.breakout-session-capacity {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
.event-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
.event-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
.event-actions-inline {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.event-actions-inline .btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.section-toggle {
|
||||
cursor: pointer;
|
||||
padding: 8px 12px;
|
||||
background: #e9ecef;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 5px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.section-toggle:hover {
|
||||
background: #dee2e6;
|
||||
}
|
||||
.section-toggle::after {
|
||||
content: '▼';
|
||||
font-size: 10px;
|
||||
}
|
||||
.section-toggle.collapsed::after {
|
||||
content: '▶';
|
||||
}
|
||||
.section-content {
|
||||
padding: 10px 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.section-content.collapsed {
|
||||
display: none;
|
||||
}
|
||||
.item-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
}
|
||||
.list-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.list-item-info {
|
||||
flex: 1;
|
||||
}
|
||||
.list-item-actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
/* Wide screen - spread content */
|
||||
@media (min-width: 1400px) {
|
||||
.dashboard {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.events-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.event-item {
|
||||
padding: 20px 25px;
|
||||
}
|
||||
|
||||
.event-info {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.breakout-sessions-list {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1800px) {
|
||||
.events-list {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function toggleSection(id) {
|
||||
const content = document.getElementById(id);
|
||||
const toggle = content.previousElementSibling;
|
||||
content.classList.toggle('collapsed');
|
||||
toggle.classList.toggle('collapsed');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="dashboard">
|
||||
<h1>{{ 'dashboard'|t }} - {{ 'organizer'|t }}</h1>
|
||||
|
||||
<div class="dashboard-actions">
|
||||
<a href="{{ url_for('create_event') }}" class="btn btn-primary">{{ 'create_event'|t }}</a>
|
||||
</div>
|
||||
|
||||
<section class="my-events">
|
||||
<h2>{{ 'my_events'|t }}</h2>
|
||||
{% if events %}
|
||||
<div class="events-list">
|
||||
{% for event in events %}
|
||||
<div class="event-item">
|
||||
<div class="event-item-header">
|
||||
<h3>{{ event.name }}</h3>
|
||||
<div class="event-actions-inline">
|
||||
<a href="{{ url_for('event_detail', code=event.code) }}" class="btn btn-sm btn-outline">{{ 'details'|t }}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="event-info">
|
||||
<p class="event-date">{{ event.start_time|localized_date if event.start_time else 'TBD' }}</p>
|
||||
<p class="event-location">{{ event.location }}</p>
|
||||
|
||||
{% if event.max_attendees %}
|
||||
{% set percent = (event.attendee_count / event.max_attendees * 100)|round|int %}
|
||||
<div class="event-capacity">
|
||||
<span>{{ event.attendee_count }} / {{ event.max_attendees }} {{ 'attendees'|t }}</span>
|
||||
<span>({{ percent }}%)</span>
|
||||
</div>
|
||||
<div class="progress-bar-container">
|
||||
<div class="progress-bar-fill {% if percent >= 100 %}full{% elif percent >= 80 %}warning{% endif %}"
|
||||
style="width: {{ percent if percent <= 100 else 100 }}%"></div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="event-capacity">
|
||||
<span>{{ event.attendee_count }} {{ 'attendees'|t }} ({{ 'unlimited'|t }})</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if event.staff %}
|
||||
<div class="breakout-sessions-list">
|
||||
<div class="section-toggle" onclick="toggleSection('staff-{{ event.id }}')">
|
||||
<strong>Staff ({{ event.staff|length }})</strong>
|
||||
</div>
|
||||
<div id="staff-{{ event.id }}" class="section-content collapsed">
|
||||
<div class="item-list">
|
||||
{% for s in event.staff %}
|
||||
<div class="list-item">
|
||||
<div class="list-item-info">
|
||||
<strong>{{ s.first_name }} {{ s.last_name }}</strong>
|
||||
<span style="color: #666; font-size: 12px;">{{ s.email }}</span>
|
||||
</div>
|
||||
<div class="list-item-actions">
|
||||
<a href="{{ url_for('edit_staff', event_id=event.id, staff_id=s.id) }}" class="btn btn-sm btn-outline">Edit</a>
|
||||
<form method="POST" action="{{ url_for('delete_staff', event_id=event.id, staff_id=s.id) }}" style="display: inline;" onsubmit="return confirm('Remove {{ s.first_name }} {{ s.last_name }}?');">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if event.breakout_sessions %}
|
||||
<div class="breakout-sessions-list">
|
||||
<div class="section-toggle" onclick="toggleSection('sessions-{{ event.id }}')">
|
||||
<strong>{{ 'breakout_sessions'|t }} ({{ event.breakout_sessions|length }})</strong>
|
||||
</div>
|
||||
<div id="sessions-{{ event.id }}" class="section-content collapsed">
|
||||
<div class="item-list">
|
||||
{% for session in event.breakout_sessions %}
|
||||
<div class="list-item">
|
||||
<div class="list-item-info">
|
||||
<strong>{{ session.name }}</strong>
|
||||
{% if session.max_attendees %}
|
||||
<span style="color: #666; font-size: 12px;">{{ session.rsvp_count }} / {{ session.max_attendees }}</span>
|
||||
{% else %}
|
||||
<span style="color: #666; font-size: 12px;">{{ session.rsvp_count }} registered</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="list-item-actions">
|
||||
<a href="{{ url_for('edit_breakout_session', session_id=session.id) }}" class="btn btn-sm btn-outline">Edit</a>
|
||||
<form method="POST" action="{{ url_for('delete_breakout_session', session_id=session.id) }}" style="display: inline;" onsubmit="return confirm('Delete {{ session.name }}?');">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if event.attendees %}
|
||||
<div class="breakout-sessions-list">
|
||||
<div class="section-toggle" onclick="toggleSection('attendees-{{ event.id }}')">
|
||||
<strong>{{ 'attendees'|t }} ({{ event.attendees|length }})</strong>
|
||||
</div>
|
||||
<div id="attendees-{{ event.id }}" class="section-content collapsed">
|
||||
<div class="item-list">
|
||||
{% for att in event.attendees %}
|
||||
<div class="list-item">
|
||||
<div class="list-item-info">
|
||||
<strong>{{ att.first_name }} {{ att.last_name }}</strong>
|
||||
<span style="color: #666; font-size: 12px;">{{ att.email }}</span>
|
||||
{% if att.checked_in %}
|
||||
<span class="badge badge-success" style="font-size: 10px;">Checked In</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="list-item-actions">
|
||||
<a href="{{ url_for('edit_attendee', attendee_id=att.id) }}" class="btn btn-sm btn-outline">Edit</a>
|
||||
<form method="POST" action="{{ url_for('delete_attendee', attendee_id=att.id) }}" style="display: inline;" onsubmit="return confirm('Remove {{ att.first_name }} {{ att.last_name }}?');">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="no-events">{{ 'no_events_yet'|t }}
|
||||
<a href="{{ url_for('create_event') }}">{{ 'create_first_event'|t }}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'edit_attendee'|t }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<h1>{{ 'edit_attendee'|t }}</h1>
|
||||
|
||||
<form method="POST" action="{{ url_for('edit_attendee', attendee_id=attendee.id) }}" class="attendee-form">
|
||||
<div class="form-group">
|
||||
<label for="first_name">{{ 'first_name'|t }}</label>
|
||||
<input type="text" id="first_name" name="first_name" value="{{ attendee.first_name }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="last_name">{{ 'last_name'|t }}</label>
|
||||
<input type="text" id="last_name" name="last_name" value="{{ attendee.last_name }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">{{ 'email'|t }}</label>
|
||||
<input type="email" id="email" name="email" value="{{ attendee.email }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="organisation">{{ 'organisation'|t }}</label>
|
||||
<input type="text" id="organisation" name="organisation" value="{{ attendee.organisation or '' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="role">{{ 'role_profession'|t }}</label>
|
||||
<input type="text" id="role" name="role" value="{{ attendee.role or '' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="introduction">{{ 'short_introduction'|t }}</label>
|
||||
<textarea id="introduction" name="introduction" rows="4">{{ attendee.introduction or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ 'save_changes'|t }}</button>
|
||||
<a href="{{ url_for('organizer_dashboard') }}" class="btn btn-outline">{{ 'cancel'|t }}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,47 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'edit_breakout_session'|t }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<h1>{{ 'edit_breakout_session'|t }}</h1>
|
||||
<p class="event-info">{{ 'event'|t }}: {{ event.name }}</p>
|
||||
|
||||
<form method="POST" action="{{ url_for('edit_breakout_session', session_id=session.id) }}" class="session-form">
|
||||
<div class="form-group">
|
||||
<label for="name">{{ 'session_name'|t }}</label>
|
||||
<input type="text" id="name" name="name" value="{{ session.name }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">{{ 'description'|t }}</label>
|
||||
<textarea id="description" name="description" rows="3">{{ session.description or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="start_time">{{ 'start_time'|t }}</label>
|
||||
<input type="datetime-local" id="start_time" name="start_time" value="{{ session.start_time.strftime('%Y-%m-%dT%H:%M') }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="end_time">{{ 'end_time'|t }}</label>
|
||||
<input type="datetime-local" id="end_time" name="end_time" value="{{ session.end_time.strftime('%Y-%m-%dT%H:%M') }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="location">{{ 'location'|t }}</label>
|
||||
<input type="text" id="location" name="location" value="{{ session.location }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="max_attendees">{{ 'max_attendees_event_hint'|t }} {{ event.max_attendees or 'unlimited' }})</label>
|
||||
<input type="number" id="max_attendees" name="max_attendees" min="1"{% if event.max_attendees %} max="{{ event.max_attendees }}"{% endif %} value="{{ session.max_attendees or '' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ 'save_changes'|t }}</button>
|
||||
<a href="{{ url_for('view_breakout_session', code=session.code) }}" class="btn btn-outline">{{ 'cancel'|t }}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,126 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'confirm_changes'|t }} - {{ session.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-content {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||
}
|
||||
.modal-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.modal-header h2 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.modal-header p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
}
|
||||
.changes-list {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.change-item {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
.change-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.change-label {
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.change-values {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.old-value {
|
||||
color: #dc3545;
|
||||
text-decoration: line-through;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.arrow {
|
||||
color: #666;
|
||||
}
|
||||
.new-value {
|
||||
color: #28a745;
|
||||
font-weight: 500;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="modal-overlay">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>{{ 'session_updated'|t }}</h2>
|
||||
<p>{{ 'changes_made_to'|t }} "{{ session.name }}":</p>
|
||||
</div>
|
||||
|
||||
<div class="changes-list">
|
||||
{% for field, old_val, new_val in changes %}
|
||||
<div class="change-item">
|
||||
<div class="change-label">{{ field }}</div>
|
||||
<div class="change-values">
|
||||
<span class="old-value">{{ old_val or '(' + 'empty' + ')' }}</span>
|
||||
<span class="arrow">→</span>
|
||||
<span class="new-value">{{ new_val or '(' + 'empty' + ')' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('notify_breakout_session_attendees', session_id=session.id) }}" id="notify-form">
|
||||
<input type="hidden" name="changes_text" id="changes-text">
|
||||
</form>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" onclick="skipNotify()">{{ 'skip'|t }}</button>
|
||||
<button type="button" class="btn btn-primary" onclick="notifyAttendees()">{{ 'notify_attendees_email'|t }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function notifyAttendees() {
|
||||
const changesText = `{% for field, old_val, new_val in changes %}{{ field }}: {{ old_val }} → {{ new_val }}
|
||||
{% endfor %}`.trim();
|
||||
|
||||
document.getElementById('changes-text').value = changesText;
|
||||
document.getElementById('notify-form').submit();
|
||||
}
|
||||
|
||||
function skipNotify() {
|
||||
window.location.href = "{{ url_for('view_breakout_session', code=session.code) }}";
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,36 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'edit_event'|t }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<h1>{{ 'edit_event'|t }}</h1>
|
||||
|
||||
<form method="POST" action="{{ url_for('edit_event', event_id=event.id) }}" class="event-form">
|
||||
<div class="form-group">
|
||||
<label for="name">{{ 'event_name'|t }}</label>
|
||||
<input type="text" id="name" name="name" value="{{ event.name }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">{{ 'description'|t }}</label>
|
||||
<textarea id="description" name="description" rows="4">{{ event.description or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="location">{{ 'location'|t }}</label>
|
||||
<input type="text" id="location" name="location" value="{{ event.location }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="max_attendees">{{ 'max_attendees_hint'|t }}</label>
|
||||
<input type="number" id="max_attendees" name="max_attendees" min="1" value="{{ event.max_attendees or '' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ 'save_changes'|t }}</button>
|
||||
<a href="{{ url_for('event_detail', code=event.code) }}" class="btn btn-outline">{{ 'cancel'|t }}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,126 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'confirm_changes'|t }} - {{ event.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-content {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||
}
|
||||
.modal-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.modal-header h2 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.modal-header p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
}
|
||||
.changes-list {
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.change-item {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
.change-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.change-label {
|
||||
font-weight: 600;
|
||||
color: #495057;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.change-values {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.old-value {
|
||||
color: #dc3545;
|
||||
text-decoration: line-through;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.arrow {
|
||||
color: #666;
|
||||
}
|
||||
.new-value {
|
||||
color: #28a745;
|
||||
font-weight: 500;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="modal-overlay">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>{{ 'event_updated'|t }}</h2>
|
||||
<p>{{ 'changes_made_to'|t }} "{{ event.name }}":</p>
|
||||
</div>
|
||||
|
||||
<div class="changes-list">
|
||||
{% for field, old_val, new_val in changes %}
|
||||
<div class="change-item">
|
||||
<div class="change-label">{{ field }}</div>
|
||||
<div class="change-values">
|
||||
<span class="old-value">{{ old_val or '(' + 'empty' + ')' }}</span>
|
||||
<span class="arrow">→</span>
|
||||
<span class="new-value">{{ new_val or '(' + 'empty' + ')' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<form method="POST" action="{{ url_for('notify_event_attendees', event_id=event.id) }}" id="notify-form">
|
||||
<input type="hidden" name="changes_text" id="changes-text">
|
||||
</form>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" onclick="skipNotify()">{{ 'skip'|t }}</button>
|
||||
<button type="button" class="btn btn-primary" onclick="notifyAttendees()">{{ 'notify_attendees_email'|t }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function notifyAttendees() {
|
||||
const changesText = `{% for field, old_val, new_val in changes %}{{ field }}: {{ old_val }} → {{ new_val }}
|
||||
{% endfor %}`.trim();
|
||||
|
||||
document.getElementById('changes-text').value = changesText;
|
||||
document.getElementById('notify-form').submit();
|
||||
}
|
||||
|
||||
function skipNotify() {
|
||||
window.location.href = "{{ url_for('event_detail', code=event.code) }}";
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,31 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'edit_staff'|t }} - {{ event.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="form-container">
|
||||
<h1>{{ 'edit_staff_member'|t }}</h1>
|
||||
|
||||
<form method="POST" action="{{ url_for('edit_staff', event_id=event.id, staff_id=staff.id) }}" class="staff-form">
|
||||
<div class="form-group">
|
||||
<label for="first_name">{{ 'first_name'|t }}</label>
|
||||
<input type="text" id="first_name" name="first_name" value="{{ staff.first_name }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="last_name">{{ 'last_name'|t }}</label>
|
||||
<input type="text" id="last_name" name="last_name" value="{{ staff.last_name }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">{{ 'email'|t }}</label>
|
||||
<input type="email" id="email" name="email" value="{{ staff.email }}" required>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ 'save_changes'|t }}</button>
|
||||
<a href="{{ url_for('manage_event_staff', event_id=event.id) }}" class="btn btn-outline">{{ 'cancel'|t }}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,647 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ event.name }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.section-box {
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.section-box h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 15px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.section-actions {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.staff-table,
|
||||
.sessions-table,
|
||||
.attendees-table {
|
||||
width: 100%;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.form-group {
|
||||
flex: 1;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
th.sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
th.sortable:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
.sort-icon::before {
|
||||
content: '\2195';
|
||||
margin-left: 5px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
th.sort-asc .sort-icon::before {
|
||||
content: '\2191';
|
||||
opacity: 1;
|
||||
}
|
||||
th.sort-desc .sort-icon::before {
|
||||
content: '\2193';
|
||||
opacity: 1;
|
||||
}
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.modal-content {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.modal-header h2 {
|
||||
margin-top: 0;
|
||||
color: #dc3545;
|
||||
}
|
||||
.modal-actions {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Wide screen - spread content */
|
||||
@media (min-width: 1400px) {
|
||||
.event-detail {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.event-detail .section-box {
|
||||
padding: 30px 40px;
|
||||
}
|
||||
|
||||
.event-meta-box {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.staff-section .section-box,
|
||||
.breakout-sessions-section .section-box,
|
||||
.attendees-section .section-box {
|
||||
padding: 25px 30px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive styles */
|
||||
@media (max-width: 768px) {
|
||||
.event-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.event-meta-box p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.staff-table,
|
||||
.sessions-table,
|
||||
.attendees-table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.section-box {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: 16px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<div class="event-detail">
|
||||
<div class="event-header">
|
||||
<h1>{{ event.name }}</h1>
|
||||
<div class="event-actions">
|
||||
<a href="{{ url_for('edit_event', event_id=event.id) }}" class="btn btn-outline">{{ 'edit_event'|t }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if event.description %}
|
||||
<div class="event-description-box">
|
||||
<h3>{{ 'description'|t }}</h3>
|
||||
<p>{{ event.description }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="event-meta-box">
|
||||
<p><strong>{{ 'start'|t }}:</strong> {{ event.start_time|localized_date if event.start_time else 'TBD' }}</p>
|
||||
{% if event.end_time %}
|
||||
<p><strong>{{ 'end'|t }}:</strong> {{ event.end_time|localized_date }}</p>
|
||||
{% endif %}
|
||||
<p><strong>{{ 'location'|t }}:</strong> {{ event.location }}</p>
|
||||
<p><strong>{{ 'max_attendees'|t }}:</strong> {{ event.max_attendees or ('unlimited'|t) }}</p>
|
||||
<p><strong>{{ 'registered_attendees'|t }}:</strong> <span id="attendee-count">{{ attendees|length }}</span></p>
|
||||
<p><strong>{{ 'registration_link'|t }}:</strong> <span id="reg-link">{{ url_for('register_event', code=event.code, _external=True) }}</span> <button type="button" style="padding: 2px 6px; font-size: 11px;" onclick="navigator.clipboard.writeText('{{ url_for('register_event', code=event.code, _external=True) }}').then(() => this.textContent = 'Copied!')">Copy</button></p>
|
||||
</div>
|
||||
|
||||
<section class="attendee-types-section">
|
||||
<div class="section-box">
|
||||
<h2>{{ 'attendee_types'|t }}</h2>
|
||||
<div class="section-actions">
|
||||
<a href="{{ url_for('manage_attendee_types', event_id=event.id) }}" class="btn btn-primary">{{ 'manage_types'|t }}</a>
|
||||
</div>
|
||||
|
||||
{% if attendee_types %}
|
||||
<table class="types-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'type_name'|t }}</th>
|
||||
<th>{{ 'price'|t }}</th>
|
||||
<th>{{ 'registration_link'|t }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for at in attendee_types %}
|
||||
<tr>
|
||||
<td><strong>{{ at.name }}</strong></td>
|
||||
<td>
|
||||
{% if at.price and at.price > 0 %}
|
||||
<span style="color: #27ae60; font-weight: 500;">{{ at.price|format_currency }}</span>
|
||||
{% else %}
|
||||
<span style="color: #7f8c8d;">{{ 'free'|t }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<code style="background: #f0f0f0; padding: 4px 8px; border-radius: 4px; font-size: 12px;">
|
||||
{{ url_for('register_event', code=event.code, type_code=at.code, _external=True) }}
|
||||
</code>
|
||||
<button type="button" class="btn btn-sm" style="padding: 2px 8px; margin-left: 8px;"
|
||||
onclick="copyLink('{{ url_for('register_event', code=event.code, type_code=at.code, _external=True) }}')">
|
||||
{{ 'copy'|t }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="no-types" style="color: #888;">{{ 'no_attendee_types_defined'|t }} <a href="{{ url_for('manage_attendee_types', event_id=event.id) }}">{{ 'create_first_type'|t }}</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="staff-section">
|
||||
<div class="section-box">
|
||||
<h2>{{ 'staff'|t }}</h2>
|
||||
<div class="section-actions">
|
||||
<button type="button" class="btn btn-primary" id="add-staff-btn">{{ 'add_staff'|t }}</button>
|
||||
</div>
|
||||
|
||||
<div id="add-staff-form" style="display: none; margin-bottom: 20px; padding: 15px; background: #f8f9fa; border-radius: 5px;">
|
||||
<form method="POST" action="{{ url_for('manage_event_staff', event_id=event.id) }}" id="staff-inline-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="staff_first_name">{{ 'first_name'|t }}</label>
|
||||
<input type="text" id="staff_first_name" name="first_name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="staff_last_name">{{ 'last_name'|t }}</label>
|
||||
<input type="text" id="staff_last_name" name="last_name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="staff_email">{{ 'email'|t }}</label>
|
||||
<input type="email" id="staff_email" name="email" required>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">{{ 'add_staff_member'|t }}</button>
|
||||
<button type="button" class="btn btn-outline" id="cancel-staff-btn">{{ 'cancel'|t }}</button>
|
||||
</form>
|
||||
{% if other_events %}
|
||||
<form method="POST" action="{{ url_for('batch_add_staff', event_id=event.id) }}" style="margin-top: 15px; padding-top: 15px; border-top: 1px solid #ddd;">
|
||||
<div class="form-row" style="align-items: flex-end;">
|
||||
<div class="form-group" style="flex: 1;">
|
||||
<label for="batch_source_event">Or add staff from another event:</label>
|
||||
<select name="source_event_id" id="batch_source_event" required>
|
||||
<option value="">Select event...</option>
|
||||
{% for other_event in other_events %}
|
||||
<option value="{{ other_event.id }}">{{ other_event.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-secondary">Batch Add</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if staff %}
|
||||
<table class="staff-table" id="event-staff-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="name" class="sortable">{{ 'name'|t }} <span class="sort-icon"></span></th>
|
||||
<th data-sort="email" class="sortable">{{ 'email'|t }} <span class="sort-icon"></span></th>
|
||||
<th data-sort="status" class="sortable">{{ 'status'|t }} <span class="sort-icon"></span></th>
|
||||
<th>{{ 'actions'|t }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="event-staff-tbody">
|
||||
{% for member in staff %}
|
||||
<tr data-name="{{ member.first_name }} {{ member.last_name }}" data-email="{{ member.email }}" data-status="{{ member.invite_token }}">
|
||||
<td>{{ member.first_name }} {{ member.last_name }}</td>
|
||||
<td>{{ member.email }}</td>
|
||||
<td>
|
||||
{% if member.invite_token %}
|
||||
<span class="badge badge-pending">{{ 'invite_pending'|t }}</span>
|
||||
{% else %}
|
||||
<span class="badge badge-success">{{ 'active'|t }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('edit_staff', event_id=event.id, staff_id=member.id) }}" class="btn btn-sm btn-success">{{ 'view'|t }}</a>
|
||||
<button type="button" class="btn btn-sm btn-danger" onclick="showDeleteModal({{ member.id }}, '{{ member.first_name }} {{ member.last_name }}', '{{ member.email }}')">{{ 'remove'|t }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="no-staff">{{ 'no_staff_yet'|t }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="staffDeleteModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>{{ 'remove_staff_member'|t }}</h2>
|
||||
<p>{{ 'confirm_remove_staff'|t }} <strong id="staffName"></strong> {{ 'from_event'|t }}?</p>
|
||||
<p style="color: #666; font-size: 14px;">{{ 'email'|t }}: <span id="staffEmail"></span></p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" onclick="closeStaffDeleteModal()">{{ 'cancel'|t }}</button>
|
||||
<form id="staffDeleteForm" method="POST" style="display: inline;">
|
||||
<button type="submit" class="btn btn-danger">{{ 'remove'|t }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="breakout-sessions-section">
|
||||
<div class="section-box">
|
||||
<h2>{{ 'breakout_sessions'|t }}</h2>
|
||||
<div class="section-actions">
|
||||
<a href="{{ url_for('create_breakout_session', event_id=event.id) }}" class="btn btn-primary">{{ 'add_breakout_session'|t }}</a>
|
||||
</div>
|
||||
|
||||
{% if breakout_sessions %}
|
||||
<table class="sessions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'name'|t }}</th>
|
||||
<th>{{ 'time'|t }}</th>
|
||||
<th>{{ 'location'|t }}</th>
|
||||
<th>{{ 'max_attendees'|t }}</th>
|
||||
<th>{{ 'registered'|t }}</th>
|
||||
<th>{{ 'actions'|t }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for session in breakout_sessions %}
|
||||
<tr>
|
||||
<td>{{ session.name }}</td>
|
||||
<td>{{ session.start_time|localized_date }}</td>
|
||||
<td>{{ session.location or '-' }}</td>
|
||||
<td>{{ session.max_attendees or ('unlimited'|t) }}</td>
|
||||
<td>{{ session.registered_count|default(0) }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('view_breakout_session', code=session.code) }}" class="btn btn-sm btn-success">{{ 'view'|t }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="no-sessions">{{ 'no_breakout_sessions_yet'|t }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="attendees-section">
|
||||
<div class="section-box">
|
||||
<h2>{{ 'registered_attendees'|t }}</h2>
|
||||
<div class="section-actions">
|
||||
<a href="{{ url_for('register_attendee', code=event.code) }}" class="btn btn-primary">{{ 'add_attendee'|t }}</a>
|
||||
</div>
|
||||
|
||||
{% if attendees %}
|
||||
<form method="POST" action="{{ url_for('batch_assign_attendee_type', event_id=event.id) }}" id="batch-assign-form">
|
||||
<div style="margin-bottom: 15px; padding: 10px; background: #f8f9fa; border-radius: 5px; display: flex; gap: 10px; align-items: center;">
|
||||
<input type="checkbox" id="select-all-attendees" style="width: auto;">
|
||||
<label for="select-all-attendees" style="margin: 0; font-weight: 500;">{{ 'select_all'|t }}</label>
|
||||
<span style="margin-left: 20px;">{{ 'assign_type'|t }}:</span>
|
||||
<select name="attendee_type_id" id="batch-type-select" style="padding: 5px 10px;">
|
||||
<option value="">{{ 'no_type'|t }}</option>
|
||||
{% for at in attendee_types %}
|
||||
<option value="{{ at.id }}">{{ at.name }} {% if at.price and at.price > 0 %}({{ at.price|format_currency }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-sm btn-primary">{{ 'apply'|t }}</button>
|
||||
<span id="selected-count" style="color: #888; font-size: 12px;"></span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<table class="attendees-table" id="attendees-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40px;"></th>
|
||||
<th data-sort="name" class="sortable">{{ 'name'|t }} <span class="sort-icon"></span></th>
|
||||
<th data-sort="organisation" class="sortable">{{ 'organisation'|t }} <span class="sort-icon"></span></th>
|
||||
<th data-sort="role" class="sortable">{{ 'role'|t }} <span class="sort-icon"></span></th>
|
||||
<th data-sort="type" class="sortable">{{ 'type'|t }} <span class="sort-icon"></span></th>
|
||||
<th data-sort="status" class="sortable">{{ 'status'|t }} <span class="sort-icon"></span></th>
|
||||
<th>{{ 'actions'|t }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="attendees-tbody">
|
||||
{% for attendee in attendees %}
|
||||
<tr data-name="{{ attendee.first_name }} {{ attendee.last_name }}" data-organisation="{{ attendee.organisation or '' }}" data-role="{{ attendee.role or '' }}" data-status="{{ attendee.checked_in }}" data-type="{{ attendee.attendee_type_name or '' }}">
|
||||
<td>
|
||||
<input type="checkbox" name="attendee_ids" value="{{ attendee.id }}" class="attendee-checkbox" form="batch-assign-form">
|
||||
</td>
|
||||
<td>{{ attendee.first_name }} {{ attendee.last_name }}</td>
|
||||
<td>{{ (attendee.organisation)|spacify if attendee.organisation else '-' }}</td>
|
||||
<td>{{ (attendee.role)|spacify if attendee.role else '-' }}</td>
|
||||
<td>
|
||||
{% if attendee.attendee_type_name %}
|
||||
<span class="badge" style="background: #3498db; color: white;">{{ attendee.attendee_type_name }}</span>
|
||||
{% else %}
|
||||
<span style="color: #aaa;">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if attendee.checked_in %}
|
||||
<span class="badge badge-success">{{ 'checked_in'|t }}</span>
|
||||
{% else %}
|
||||
<span class="badge badge-pending">{{ 'not_checked_in'|t }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if not attendee.checked_in %}
|
||||
<button class="btn btn-sm btn-success checkin-btn" data-attendee-id="{{ attendee.id }}">{{ 'check_in'|t }}</button>
|
||||
{% else %}
|
||||
<span class="text-muted">{{ 'checked_in'|t }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="no-attendees">{{ 'no_attendees_yet'|t }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('add-staff-btn').addEventListener('click', function() {
|
||||
document.getElementById('add-staff-form').style.display = 'block';
|
||||
this.style.display = 'none';
|
||||
});
|
||||
|
||||
document.getElementById('cancel-staff-btn').addEventListener('click', function() {
|
||||
document.getElementById('add-staff-form').style.display = 'none';
|
||||
document.getElementById('add-staff-btn').style.display = 'inline-block';
|
||||
});
|
||||
|
||||
document.getElementById('staff-inline-form').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
const form = this;
|
||||
const formData = new FormData(form);
|
||||
|
||||
fetch(form.action, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}).then(response => {
|
||||
if (response.redirected) {
|
||||
window.location.href = response.url;
|
||||
} else {
|
||||
location.reload();
|
||||
}
|
||||
}).catch(error => {
|
||||
alert('{{ "error_adding_staff"|t }}');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.checkin-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
const attendeeId = this.dataset.attendeeId;
|
||||
const eventId = {{ event.id }};
|
||||
|
||||
try {
|
||||
const response = await fetch(`/organizer/event/${eventId}/checkin/${attendeeId}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Update status cell (6th td) and remove checkin button
|
||||
const row = this.closest('tr');
|
||||
row.querySelector('td:nth-child(6)').innerHTML = '<span class="badge badge-success">{{ "checked_in"|t }}</span>';
|
||||
this.remove();
|
||||
document.getElementById('attendee-count').textContent = parseInt(document.getElementById('attendee-count').textContent) + 1;
|
||||
}
|
||||
} catch (error) {
|
||||
alert('{{ "error_checking_in"|t }}');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Attendee table sorting
|
||||
const sortableHeaders = document.querySelectorAll('#attendees-table th.sortable');
|
||||
let currentSort = { column: null, direction: 'asc' };
|
||||
|
||||
sortableHeaders.forEach(th => {
|
||||
th.addEventListener('click', () => {
|
||||
const column = th.dataset.sort;
|
||||
const direction = currentSort.column === column && currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
currentSort = { column, direction };
|
||||
|
||||
sortableHeaders.forEach(h => h.classList.remove('sort-asc', 'sort-desc'));
|
||||
th.classList.add(direction === 'asc' ? 'sort-asc' : 'sort-desc');
|
||||
|
||||
const tbody = document.getElementById('attendees-tbody');
|
||||
const rows = Array.from(tbody.querySelectorAll('tr'));
|
||||
|
||||
rows.sort((a, b) => {
|
||||
let valA = a.dataset[column] || '';
|
||||
let valB = b.dataset[column] || '';
|
||||
|
||||
if (column === 'status') {
|
||||
valA = valA === 'True' ? 1 : 0;
|
||||
valB = valB === 'True' ? 1 : 0;
|
||||
} else {
|
||||
valA = valA.toLowerCase();
|
||||
valB = valB.toLowerCase();
|
||||
}
|
||||
|
||||
if (valA < valB) return direction === 'asc' ? -1 : 1;
|
||||
if (valA > valB) return direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
rows.forEach(row => tbody.appendChild(row));
|
||||
});
|
||||
});
|
||||
|
||||
// Staff table sorting
|
||||
const staffSortableHeaders = document.querySelectorAll('#event-staff-table th.sortable');
|
||||
let staffCurrentSort = { column: null, direction: 'asc' };
|
||||
|
||||
staffSortableHeaders.forEach(th => {
|
||||
th.addEventListener('click', () => {
|
||||
const column = th.dataset.sort;
|
||||
const direction = staffCurrentSort.column === column && staffCurrentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
staffCurrentSort = { column, direction };
|
||||
|
||||
staffSortableHeaders.forEach(h => h.classList.remove('sort-asc', 'sort-desc'));
|
||||
th.classList.add(direction === 'asc' ? 'sort-asc' : 'sort-desc');
|
||||
|
||||
const tbody = document.getElementById('event-staff-tbody');
|
||||
const rows = Array.from(tbody.querySelectorAll('tr'));
|
||||
|
||||
rows.sort((a, b) => {
|
||||
let valA = a.dataset[column] || '';
|
||||
let valB = b.dataset[column] || '';
|
||||
|
||||
if (column === 'status') {
|
||||
valA = valA === '' ? 1 : 0;
|
||||
valB = valB === '' ? 1 : 0;
|
||||
} else {
|
||||
valA = valA.toLowerCase();
|
||||
valB = valB.toLowerCase();
|
||||
}
|
||||
|
||||
if (valA < valB) return direction === 'asc' ? -1 : 1;
|
||||
if (valA > valB) return direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
rows.forEach(row => tbody.appendChild(row));
|
||||
});
|
||||
});
|
||||
|
||||
function showDeleteModal(staffId, staffName, staffEmail) {
|
||||
document.getElementById('staffName').textContent = staffName;
|
||||
document.getElementById('staffEmail').textContent = staffEmail;
|
||||
document.getElementById('staffDeleteForm').action = '{{ url_for("delete_staff", event_id=event.id, staff_id=0) }}'.replace('0', staffId);
|
||||
document.getElementById('staffDeleteModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeStaffDeleteModal() {
|
||||
document.getElementById('staffDeleteModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// Select all attendees checkbox
|
||||
document.getElementById('select-all-attendees').addEventListener('change', function() {
|
||||
const checkboxes = document.querySelectorAll('.attendee-checkbox');
|
||||
checkboxes.forEach(cb => cb.checked = this.checked);
|
||||
updateSelectedCount();
|
||||
});
|
||||
|
||||
document.querySelectorAll('.attendee-checkbox').forEach(cb => {
|
||||
cb.addEventListener('change', updateSelectedCount);
|
||||
});
|
||||
|
||||
function updateSelectedCount() {
|
||||
const selected = document.querySelectorAll('.attendee-checkbox:checked').length;
|
||||
document.getElementById('selected-count').textContent = selected > 0 ? `(${selected} {{ 'selected'|t }})` : '';
|
||||
}
|
||||
|
||||
// Copy link function
|
||||
function copyLink(url) {
|
||||
// Try modern clipboard API first
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(url).then(function() {
|
||||
alert('{{ 'link_copied'|t }}');
|
||||
}, function(err) {
|
||||
// Fallback for HTTP sites
|
||||
fallbackCopy(url);
|
||||
});
|
||||
} else {
|
||||
// Fallback for older browsers or HTTP sites
|
||||
fallbackCopy(url);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(url) {
|
||||
var textArea = document.createElement('textarea');
|
||||
textArea.value = url;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-9999px';
|
||||
textArea.style.top = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
var successful = document.execCommand('copy');
|
||||
if (successful) {
|
||||
alert('{{ 'link_copied'|t }}');
|
||||
} else {
|
||||
alert('{{ 'copy_failed'|t }}');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('{{ 'copy_failed'|t }}');
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
// Initial selected count
|
||||
updateSelectedCount();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,192 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'staff'|t }} - {{ event.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.staff-table th.sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.staff-table th.sortable:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
.sort-icon::before {
|
||||
content: '\2195';
|
||||
margin-left: 5px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
th.sort-asc .sort-icon::before {
|
||||
content: '\2191';
|
||||
opacity: 1;
|
||||
}
|
||||
th.sort-desc .sort-icon::before {
|
||||
content: '\2193';
|
||||
opacity: 1;
|
||||
}
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
.modal-content {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||||
}
|
||||
.modal-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.modal-header h2 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.modal-header p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
<div class="event-staff">
|
||||
<div class="staff-header">
|
||||
<h1>{{ 'staff_for'|t }} {{ event.name }}</h1>
|
||||
<a href="{{ url_for('event_detail', code=event.code) }}" class="btn btn-outline">{{ 'back_to_event'|t }}</a>
|
||||
</div>
|
||||
|
||||
<section class="add-staff-form">
|
||||
<h2>{{ 'add_staff_member'|t }}</h2>
|
||||
<form method="POST" action="{{ url_for('manage_event_staff', event_id=event.id) }}" class="staff-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="first_name">{{ 'first_name'|t }}</label>
|
||||
<input type="text" id="first_name" name="first_name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="last_name">{{ 'last_name'|t }}</label>
|
||||
<input type="text" id="last_name" name="last_name" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">{{ 'email'|t }}</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ 'add_staff_member'|t }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="staff-list">
|
||||
<h2>{{ 'current_staff'|t }} ({{ staff_members|length }})</h2>
|
||||
{% if staff_members %}
|
||||
<table class="staff-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="name" class="sortable">{{ 'name'|t }} <span class="sort-icon"></span></th>
|
||||
<th data-sort="email" class="sortable">{{ 'email'|t }} <span class="sort-icon"></span></th>
|
||||
<th data-sort="status" class="sortable">{{ 'status'|t }} <span class="sort-icon"></span></th>
|
||||
<th>{{ 'actions'|t }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="staff-tbody">
|
||||
{% for staff in staff_members %}
|
||||
<tr data-name="{{ staff.first_name }} {{ staff.last_name }}" data-email="{{ staff.email }}" data-status="{{ staff.invite_used }}">
|
||||
<td>{{ staff.first_name }} {{ staff.last_name }}</td>
|
||||
<td>{{ staff.email }}</td>
|
||||
<td>
|
||||
{% if staff.invite_used %}
|
||||
<span class="badge badge-success">{{ 'active'|t }}</span>
|
||||
{% else %}
|
||||
<span class="badge badge-pending">{{ 'invite_pending'|t }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('edit_staff', event_id=event.id, staff_id=staff.id) }}" class="btn btn-sm btn-outline">{{ 'edit'|t }}</a>
|
||||
<button type="button" class="btn btn-sm btn-danger" onclick="showDeleteModal({{ staff.id }}, '{{ staff.first_name }} {{ staff.last_name }}')">{{ 'remove'|t }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="no-staff">{{ 'no_staff_yet'|t }}</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="deleteModal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>{{ 'remove_staff_member'|t }}</h2>
|
||||
<p>{{ 'confirm_remove_staff'|t }} <strong id="staffName"></strong>?</p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" onclick="closeDeleteModal()">{{ 'cancel'|t }}</button>
|
||||
<form id="deleteForm" method="POST" style="display: inline;">
|
||||
<button type="submit" class="btn btn-danger">{{ 'remove'|t }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const sortableHeaders = document.querySelectorAll('.staff-table th.sortable');
|
||||
let currentSort = { column: null, direction: 'asc' };
|
||||
|
||||
sortableHeaders.forEach(th => {
|
||||
th.addEventListener('click', () => {
|
||||
const column = th.dataset.sort;
|
||||
const direction = currentSort.column === column && currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
currentSort = { column, direction };
|
||||
|
||||
sortableHeaders.forEach(h => h.classList.remove('sort-asc', 'sort-desc'));
|
||||
th.classList.add(direction === 'asc' ? 'sort-asc' : 'sort-desc');
|
||||
|
||||
const tbody = document.getElementById('staff-tbody');
|
||||
const rows = Array.from(tbody.querySelectorAll('tr'));
|
||||
|
||||
rows.sort((a, b) => {
|
||||
let valA = a.dataset[column] || '';
|
||||
let valB = b.dataset[column] || '';
|
||||
|
||||
if (column === 'status') {
|
||||
valA = valA === 'True' ? 1 : 0;
|
||||
valB = valB === 'True' ? 1 : 0;
|
||||
} else {
|
||||
valA = valA.toLowerCase();
|
||||
valB = valB.toLowerCase();
|
||||
}
|
||||
|
||||
if (valA < valB) return direction === 'asc' ? -1 : 1;
|
||||
if (valA > valB) return direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
rows.forEach(row => tbody.appendChild(row));
|
||||
});
|
||||
});
|
||||
|
||||
function showDeleteModal(staffId, staffName) {
|
||||
document.getElementById('staffName').textContent = staffName;
|
||||
document.getElementById('deleteForm').action = '{{ url_for("delete_staff", event_id=event.id, staff_id=0) }}'.replace('0', staffId);
|
||||
document.getElementById('deleteModal').style.display = 'flex';
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
document.getElementById('deleteModal').style.display = 'none';
|
||||
}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,333 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'scan_qr'|t }} - {{ event.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="scan-page">
|
||||
<div class="scan-header">
|
||||
<h1>{{ event.name }}</h1>
|
||||
<p>{{ 'qr_scanner_checkin'|t }}</p>
|
||||
<a href="{{ url_for('event_detail', code=event.code) }}" class="btn btn-outline">{{ 'back_to_event'|t }}</a>
|
||||
</div>
|
||||
|
||||
<div class="scanner-container">
|
||||
<div id="qr-reader" class="qr-reader"></div>
|
||||
<div id="scan-result" class="scan-result hidden">
|
||||
<div class="result-icon" id="result-icon"></div>
|
||||
<h2 id="result-title"></h2>
|
||||
<p id="result-message"></p>
|
||||
<button id="scan-again" class="btn btn-primary">{{ 'scan_again'|t }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scan-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-number" id="total-count">{{ attendees|length }}</span>
|
||||
<span class="stat-label">{{ 'total'|t }}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-number" id="checked-in-count">{{ attendees|selectattr('checked_in')|list|length }}</span>
|
||||
<span class="stat-label">{{ 'checked_in'|t }}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-number" id="remaining-count">{{ attendees|rejectattr('checked_in')|list|length }}</span>
|
||||
<span class="stat-label">{{ 'remaining'|t }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="recent-checkins">
|
||||
<h3>{{ 'recent_checkins'|t }}</h3>
|
||||
<ul id="recent-list">
|
||||
<li class="empty-state">{{ 'no_checkins_yet'|t }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scan-page {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.scan-header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.scan-header h1 {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.scan-header p {
|
||||
color: #666;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.scanner-container {
|
||||
background: #000;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
min-height: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.qr-reader {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.qr-reader video {
|
||||
width: 100% !important;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.scan-result {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
background: #fff;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scan-result.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.result-icon.success::before {
|
||||
content: '✓';
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.result-icon.error::before {
|
||||
content: '✗';
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.result-icon.duplicate::before {
|
||||
content: '⏳';
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
#result-title {
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
#result-message {
|
||||
color: #666;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.scan-stats {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 20px;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.stat {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
display: block;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.recent-checkins {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.recent-checkins h3 {
|
||||
margin: 0 0 15px 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.recent-checkins ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.recent-checkins li {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.recent-checkins li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.recent-checkins li.empty-state {
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.recent-checkins .attendee-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.recent-checkins .checkin-time {
|
||||
color: #22c55e;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
|
||||
<script>
|
||||
const eventId = {{ event.id }};
|
||||
let html5QrCode;
|
||||
let scanning = true;
|
||||
|
||||
function getAttendeeName(attendeeId) {
|
||||
const attendees = {{ attendees|tojson }};
|
||||
const attendee = attendees.find(a => a.id === attendeeId);
|
||||
return attendee ? `${attendee.first_name} ${attendee.last_name}` : `Attendee #${attendeeId}`;
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
const attendees = {{ attendees|tojson }};
|
||||
const total = attendees.length;
|
||||
const checkedIn = attendees.filter(a => a.checked_in).length;
|
||||
document.getElementById('total-count').textContent = total;
|
||||
document.getElementById('checked-in-count').textContent = checkedIn;
|
||||
document.getElementById('remaining-count').textContent = total - checkedIn;
|
||||
}
|
||||
|
||||
function addRecentCheckin(name) {
|
||||
const list = document.getElementById('recent-list');
|
||||
const emptyState = list.querySelector('.empty-state');
|
||||
if (emptyState) emptyState.remove();
|
||||
|
||||
const li = document.createElement('li');
|
||||
const time = new Date().toLocaleTimeString();
|
||||
li.innerHTML = `<span class="attendee-name">${name}</span><span class="checkin-time">${time}</span>`;
|
||||
list.insertBefore(li, list.firstChild);
|
||||
|
||||
while (list.children.length > 10) {
|
||||
list.removeChild(list.lastChild);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkInAttendee(attendeeId) {
|
||||
try {
|
||||
const response = await fetch(`/organizer/event/${eventId}/checkin/${attendeeId}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const resultDiv = document.getElementById('scan-result');
|
||||
const icon = document.getElementById('result-icon');
|
||||
const title = document.getElementById('result-title');
|
||||
const message = document.getElementById('result-message');
|
||||
|
||||
icon.className = 'result-icon';
|
||||
|
||||
if (data.success) {
|
||||
document.getElementById('qr-reader').style.display = 'none';
|
||||
resultDiv.classList.remove('hidden');
|
||||
|
||||
if (data.already_checked_in) {
|
||||
icon.classList.add('duplicate');
|
||||
title.textContent = '{{ "already_checked_in"|t }}';
|
||||
message.textContent = `${data.attendee_name} {{ "was_already_checked_in"|t }}`;
|
||||
} else {
|
||||
icon.classList.add('success');
|
||||
title.textContent = '{{ "checked_in"|t }}!';
|
||||
message.textContent = `{{ "success_checked_in"|t }} ${data.attendee_name}`;
|
||||
addRecentCheckin(data.attendee_name);
|
||||
updateStats();
|
||||
}
|
||||
} else {
|
||||
icon.classList.add('error');
|
||||
title.textContent = '{{ "error"|t }}';
|
||||
message.textContent = data.error || '{{ "failed_checkin"|t }}';
|
||||
}
|
||||
|
||||
scanning = false;
|
||||
} catch (error) {
|
||||
console.error('Check-in error:', error);
|
||||
alert('{{ "error_checking_in"|t }}');
|
||||
}
|
||||
}
|
||||
|
||||
function onScanSuccess(decodedText) {
|
||||
if (!scanning) return;
|
||||
|
||||
const parts = decodedText.split(':');
|
||||
if (parts.length === 3 && parts[0] === 'NETEVENT') {
|
||||
const qrEventId = parseInt(parts[1]);
|
||||
const attendeeId = parseInt(parts[2]);
|
||||
|
||||
if (qrEventId === eventId) {
|
||||
checkInAttendee(attendeeId);
|
||||
} else {
|
||||
alert('{{ "qr_different_event"|t }}');
|
||||
}
|
||||
} else {
|
||||
const match = decodedText.match(/\/organizer\/event\/(\d+)\/checkin\/(\d+)/);
|
||||
if (match) {
|
||||
const qrEventId = parseInt(match[1]);
|
||||
const attendeeId = parseInt(match[2]);
|
||||
if (qrEventId === eventId) {
|
||||
checkInAttendee(attendeeId);
|
||||
} else {
|
||||
alert('{{ "qr_different_event"|t }}');
|
||||
}
|
||||
} else {
|
||||
console.log('Unknown QR format:', decodedText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startScanner() {
|
||||
html5QrCode = new Html5Qrcode("qr-reader");
|
||||
|
||||
html5QrCode.start(
|
||||
{ facingMode: "environment" },
|
||||
{
|
||||
fps: 10,
|
||||
qrbox: { width: 250, height: 250 }
|
||||
},
|
||||
onScanSuccess,
|
||||
(errorMessage) => {
|
||||
}
|
||||
).catch(err => {
|
||||
console.error('Camera error:', err);
|
||||
alert('{{ "camera_permission_error"|t }}');
|
||||
});
|
||||
}
|
||||
|
||||
function resetScanner() {
|
||||
document.getElementById('qr-reader').style.display = 'block';
|
||||
document.getElementById('scan-result').classList.add('hidden');
|
||||
scanning = true;
|
||||
}
|
||||
|
||||
document.getElementById('scan-again').addEventListener('click', resetScanner);
|
||||
|
||||
window.addEventListener('DOMContentLoaded', startScanner);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'presenter_dashboard'|t }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="presenter-dashboard">
|
||||
<h1>{{ 'presenter_dashboard'|t }}</h1>
|
||||
<p>{{ 'welcome'|t }}, {{ presenter_name }}</p>
|
||||
|
||||
<section class="my-sessions">
|
||||
<h2>{{ 'my_breakout_sessions'|t }}</h2>
|
||||
{% 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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="no-sessions">{{ 'no_sessions_assigned'|t }}</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<div class="logout-link">
|
||||
<a href="{{ url_for('logout') }}" class="btn btn-outline">{{ 'logout'|t }}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,85 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'register'|t }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-container">
|
||||
<div class="auth-box">
|
||||
<h2>{{ 'register'|t }}{% if user_type == 'organizer' %} {{ 'as_organizer'|t }}{% elif event %} {{ 'for'|t }} {{ event.name }}{% endif %}</h2>
|
||||
|
||||
{% if user_type == 'organizer' %}
|
||||
<form method="POST" action="{{ url_for('register_organizer') }}">
|
||||
<div class="form-group">
|
||||
<label for="name">{{ 'full_name'|t }}</label>
|
||||
<input type="text" id="name" name="name" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">{{ 'email'|t }}</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">{{ 'password'|t }}</label>
|
||||
<input type="password" id="password" name="password" required minlength="6">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirm_password">{{ 'confirm_password'|t }}</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block">{{ 'register'|t }}</button>
|
||||
</form>
|
||||
{% elif event %}
|
||||
<form method="POST" action="{{ url_for('register_attendee', code=event.code) }}">
|
||||
<div class="form-group">
|
||||
<label for="first_name">{{ 'first_name'|t }}</label>
|
||||
<input type="text" id="first_name" name="first_name" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="last_name">{{ 'last_name'|t }}</label>
|
||||
<input type="text" id="last_name" name="last_name" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">{{ 'email'|t }}</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="organisation">{{ 'organisation'|t }}</label>
|
||||
<input type="text" id="organisation" name="organisation">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="role">{{ 'role_profession'|t }}</label>
|
||||
<input type="text" id="role" name="role">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="introduction">{{ 'short_introduction'|t }}</label>
|
||||
<textarea id="introduction" name="introduction" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">{{ 'password'|t }}</label>
|
||||
<input type="password" id="password" name="password" required minlength="6">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirm_password">{{ 'confirm_password'|t }}</label>
|
||||
<input type="password" id="confirm_password" name="confirm_password" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-block">{{ 'register'|t }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<p class="auth-footer">
|
||||
{{ 'already_have_account'|t }} <a href="{{ url_for('login') }}">{{ 'login'|t }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'staff_dashboard'|t }} - {{ event.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="staff-dashboard">
|
||||
<div class="staff-header">
|
||||
<h1>{{ 'staff'|t }}: {{ event.name }}</h1>
|
||||
<p>{{ 'welcome'|t }}, {{ staff_name }}</p>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,387 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'scan_qr'|t }} - {{ event.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="scan-page">
|
||||
<div class="scan-header">
|
||||
<h1>{{ event.name }}</h1>
|
||||
<p>{{ 'qr_scanner_checkin'|t }}</p>
|
||||
<a href="{{ url_for('staff_event_dashboard', event_id=event.id) }}" class="btn btn-outline">{{ 'back_to_dashboard'|t }}</a>
|
||||
</div>
|
||||
|
||||
<div class="scanner-container">
|
||||
<div id="qr-reader" class="qr-reader"></div>
|
||||
<div id="scan-result" class="scan-result hidden">
|
||||
<div class="result-icon" id="result-icon"></div>
|
||||
<h2 id="result-title"></h2>
|
||||
<p id="result-message"></p>
|
||||
<button id="scan-again" class="btn btn-primary">{{ 'scan_again'|t }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scan-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-number" id="total-count">{{ attendees|length }}</span>
|
||||
<span class="stat-label">{{ 'total'|t }}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-number" id="checked-in-count">{{ attendees|selectattr('checked_in')|list|length }}</span>
|
||||
<span class="stat-label">{{ 'checked_in'|t }}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-number" id="remaining-count">{{ attendees|rejectattr('checked_in')|list|length }}</span>
|
||||
<span class="stat-label">{{ 'remaining'|t }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="recent-checkins">
|
||||
<h3>{{ 'recent_checkins'|t }}</h3>
|
||||
<ul id="recent-list">
|
||||
<li class="empty-state">{{ 'no_checkins_yet'|t }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.scan-page {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.scan-header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.scan-header h1 {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.scan-header p {
|
||||
color: #666;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.scanner-container {
|
||||
background: #000;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
min-height: 300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.qr-reader {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.qr-reader video {
|
||||
width: 100% !important;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.scan-result {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
background: #fff;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scan-result.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.result-icon.success::before {
|
||||
content: '✓';
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.result-icon.error::before {
|
||||
content: '✗';
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.result-icon.duplicate::before {
|
||||
content: '⏳';
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
#result-title {
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
#result-message {
|
||||
color: #666;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.scan-stats {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 20px;
|
||||
background: #f8fafc;
|
||||
border-radius: 12px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.stat {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
display: block;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.recent-checkins {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.recent-checkins h3 {
|
||||
margin: 0 0 15px 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.recent-checkins ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.recent-checkins li {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.recent-checkins li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.recent-checkins li.empty-state {
|
||||
color: #94a3b8;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.recent-checkins .attendee-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.recent-checkins .checkin-time {
|
||||
color: #22c55e;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
|
||||
<script>
|
||||
const eventId = {{ event.id }};
|
||||
let html5QrCode;
|
||||
let scanning = true;
|
||||
|
||||
function getAttendeeName(attendeeId) {
|
||||
const attendees = {{ attendees|tojson }};
|
||||
const attendee = attendees.find(a => a.id === attendeeId);
|
||||
return attendee ? `${attendee.first_name} ${attendee.last_name}` : `Attendee #${attendeeId}`;
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
const attendees = {{ attendees|tojson }};
|
||||
const total = attendees.length;
|
||||
const checkedIn = attendees.filter(a => a.checked_in).length;
|
||||
document.getElementById('total-count').textContent = total;
|
||||
document.getElementById('checked-in-count').textContent = checkedIn;
|
||||
document.getElementById('remaining-count').textContent = total - checkedIn;
|
||||
}
|
||||
|
||||
function addRecentCheckin(name) {
|
||||
const list = document.getElementById('recent-list');
|
||||
const emptyState = list.querySelector('.empty-state');
|
||||
if (emptyState) emptyState.remove();
|
||||
|
||||
const li = document.createElement('li');
|
||||
const time = new Date().toLocaleTimeString();
|
||||
li.innerHTML = `<span class="attendee-name">${name}</span><span class="checkin-time">${time}</span>`;
|
||||
list.insertBefore(li, list.firstChild);
|
||||
|
||||
while (list.children.length > 10) {
|
||||
list.removeChild(list.lastChild);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkInAttendee(attendeeId) {
|
||||
try {
|
||||
const response = await fetch(`/organizer/event/${eventId}/checkin/${attendeeId}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const resultDiv = document.getElementById('scan-result');
|
||||
const icon = document.getElementById('result-icon');
|
||||
const title = document.getElementById('result-title');
|
||||
const message = document.getElementById('result-message');
|
||||
|
||||
icon.className = 'result-icon';
|
||||
|
||||
if (data.success) {
|
||||
document.getElementById('qr-reader').style.display = 'none';
|
||||
resultDiv.classList.remove('hidden');
|
||||
|
||||
if (data.already_checked_in) {
|
||||
icon.classList.add('duplicate');
|
||||
title.textContent = '{{ "already_checked_in"|t }}';
|
||||
message.textContent = `${data.attendee_name} {{ "was_already_checked_in"|t }}`;
|
||||
} else {
|
||||
icon.classList.add('success');
|
||||
title.textContent = '{{ "checked_in"|t }}!';
|
||||
message.textContent = `{{ "success_checked_in"|t }} ${data.attendee_name}`;
|
||||
addRecentCheckin(data.attendee_name);
|
||||
updateStats();
|
||||
}
|
||||
} else {
|
||||
icon.classList.add('error');
|
||||
title.textContent = '{{ "error"|t }}';
|
||||
message.textContent = data.error || '{{ "failed_checkin"|t }}';
|
||||
}
|
||||
|
||||
scanning = false;
|
||||
} catch (error) {
|
||||
console.error('Check-in error:', error);
|
||||
alert('{{ "error_checking_in"|t }}');
|
||||
}
|
||||
}
|
||||
|
||||
function onScanSuccess(decodedText) {
|
||||
if (!scanning) return;
|
||||
|
||||
// Check if it's a plain attendee code (10 alphanumeric characters)
|
||||
const codeMatch = decodedText.match(/^[A-Z0-9]{10}$/i);
|
||||
if (codeMatch) {
|
||||
// It's an attendee code - use the new endpoint
|
||||
checkInByCode(codeMatch[0]);
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = decodedText.split(':');
|
||||
if (parts.length === 3 && parts[0] === 'NETEVENT') {
|
||||
const qrEventId = parseInt(parts[1]);
|
||||
const attendeeId = parseInt(parts[2]);
|
||||
|
||||
if (qrEventId === eventId) {
|
||||
checkInAttendee(attendeeId);
|
||||
} else {
|
||||
alert('{{ "qr_different_event"|t }}');
|
||||
}
|
||||
} else {
|
||||
const match = decodedText.match(/\/organizer\/event\/(\d+)\/checkin\/(\d+)/);
|
||||
if (match) {
|
||||
const qrEventId = parseInt(match[1]);
|
||||
const attendeeId = parseInt(match[2]);
|
||||
if (qrEventId === eventId) {
|
||||
checkInAttendee(attendeeId);
|
||||
} else {
|
||||
alert('{{ "qr_different_event"|t }}');
|
||||
}
|
||||
} else {
|
||||
console.log('Unknown QR format:', decodedText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkInByCode(attendeeCode) {
|
||||
try {
|
||||
const response = await fetch(`/organizer/event/${eventId}/checkin/code`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ attendee_code: attendeeCode })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const resultDiv = document.getElementById('scan-result');
|
||||
const icon = document.getElementById('result-icon');
|
||||
const title = document.getElementById('result-title');
|
||||
const message = document.getElementById('result-message');
|
||||
|
||||
icon.className = 'result-icon';
|
||||
|
||||
if (data.success) {
|
||||
document.getElementById('qr-reader').style.display = 'none';
|
||||
resultDiv.classList.remove('hidden');
|
||||
|
||||
if (data.already_checked_in) {
|
||||
icon.classList.add('duplicate');
|
||||
title.textContent = '{{ "already_checked_in"|t }}';
|
||||
message.textContent = `${data.attendee_name} {{ "was_already_checked_in"|t }}`;
|
||||
} else {
|
||||
icon.classList.add('success');
|
||||
title.textContent = '{{ "checked_in"|t }}!';
|
||||
message.textContent = `{{ "success_checked_in"|t }} ${data.attendee_name}`;
|
||||
addRecentCheckin(data.attendee_name);
|
||||
updateStats();
|
||||
}
|
||||
} else {
|
||||
icon.classList.add('error');
|
||||
title.textContent = '{{ "error"|t }}';
|
||||
message.textContent = data.error || '{{ "failed_checkin"|t }}';
|
||||
}
|
||||
|
||||
scanning = false;
|
||||
} catch (error) {
|
||||
console.error('Check-in error:', error);
|
||||
alert('{{ "error_checking_in"|t }}');
|
||||
}
|
||||
}
|
||||
|
||||
function startScanner() {
|
||||
html5QrCode = new Html5Qrcode("qr-reader");
|
||||
|
||||
html5QrCode.start(
|
||||
{ facingMode: "environment" },
|
||||
{
|
||||
fps: 10,
|
||||
qrbox: { width: 250, height: 250 }
|
||||
},
|
||||
onScanSuccess,
|
||||
(errorMessage) => {
|
||||
}
|
||||
).catch(err => {
|
||||
console.error('Camera error:', err);
|
||||
alert('{{ "camera_permission_error"|t }}');
|
||||
});
|
||||
}
|
||||
|
||||
function resetScanner() {
|
||||
document.getElementById('qr-reader').style.display = 'block';
|
||||
document.getElementById('scan-result').classList.add('hidden');
|
||||
scanning = true;
|
||||
}
|
||||
|
||||
document.getElementById('scan-again').addEventListener('click', resetScanner);
|
||||
|
||||
window.addEventListener('DOMContentLoaded', startScanner);
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,86 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'staff_dashboard'|t }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="staff-events-dashboard">
|
||||
<div class="staff-header">
|
||||
<h1>{{ 'staff_dashboard'|t }}</h1>
|
||||
<p>{{ 'welcome'|t }}, {{ staff_name }}</p>
|
||||
</div>
|
||||
|
||||
<div class="section-box">
|
||||
<h2>{{ 'select_event'|t }}</h2>
|
||||
|
||||
{% if events %}
|
||||
<div class="events-list">
|
||||
{% for event in events %}
|
||||
<div class="event-card">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="no-events">{{ 'no_events_assigned'|t }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</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 %}
|
||||
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'complete_registration'|t }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="invite-page">
|
||||
<div class="invite-box">
|
||||
<h1>{{ 'youre_invited'|t }} {{ event.name }}</h1>
|
||||
<p class="invite-info">{{ 'set_password_complete'|t }}</p>
|
||||
|
||||
<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">
|
||||
</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">
|
||||
</div>
|
||||
{% if recaptcha_site_key %}
|
||||
<div class="form-group recaptcha-container">
|
||||
<div class="g-recaptcha" data-sitekey="{{ recaptcha_site_key }}"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{{ 'complete_registration'|t }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% if recaptcha_site_key %}
|
||||
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user