')
+def presenter_scan(code):
+ """QR scan page for presenters to check in at session entrance."""
+ if session.get('user_type') != 'breakout_organizer':
+ flash('Access denied.')
+ return redirect(url_for('index'))
+
+ try:
+ conn = get_db_connection()
+ cursor = conn.cursor(dictionary=True)
+
+ # Get the breakout session organizer record
+ cursor.execute("""
+ SELECT bso.*, bs.name as session_name, bs.code as session_code,
+ bs.start_time, bs.end_time, bs.location, bs.event_id,
+ e.name as event_name, e.code as event_code
+ FROM breakout_session_organizers bso
+ JOIN breakout_sessions bs ON bso.breakout_session_id = bs.id
+ JOIN events e ON bs.event_id = e.id
+ WHERE bso.presenter_code = %s AND bso.organizer_id = %s
+ """, (code, session['user_id']))
+ presenter_record = cursor.fetchone()
+
+ if not presenter_record:
+ flash('Invalid presenter code.')
+ cursor.close()
+ conn.close()
+ return redirect(url_for('presenter_dashboard'))
+
+ # Get all attendees checked in to this session
+ cursor.execute("""
+ SELECT a.id, a.first_name, a.last_name, a.email, a.organisation, a.role,
+ a.checked_in, a.check_in_time
+ FROM attendees a
+ JOIN breakout_session_rsvps bsr ON a.id = bsr.attendee_id
+ WHERE bsr.breakout_session_id = %s AND bsr.status = 'registered'
+ ORDER BY a.last_name, a.first_name
+ """, (presenter_record['breakout_session_id'],))
+ attendees = cursor.fetchall()
+
+ # Count checked in
+ checked_in_count = sum(1 for a in attendees if a.get('checked_in'))
+
+ cursor.close()
+ conn.close()
+
+ return render_template('presenter/scan.html',
+ presenter=presenter_record,
+ attendees=attendees,
+ checked_in_count=checked_in_count,
+ breakout_session_id=presenter_record['breakout_session_id'],
+ session_name=presenter_record['session_name'],
+ start_time=presenter_record['start_time'],
+ end_time=presenter_record['end_time'],
+ location=presenter_record['location'],
+ event_name=presenter_record['event_name'])
+
+ except Error as e:
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
+ return redirect(url_for('presenter_dashboard'))
+
+
+
+@app.route('/presenter/session//checkin/', methods=['POST'])
+def presenter_checkin_attendee(session_id, attendee_id):
+ """Check in an attendee from presenter scan page."""
+ if session.get('user_type') != 'breakout_organizer':
+ return jsonify({'success': False, 'error': 'Access denied'}), 403
+
+ try:
+ conn = get_db_connection()
+ cursor = conn.cursor(dictionary=True)
+
+ # Verify the presenter has access to this session
+ cursor.execute("""
+ SELECT bso.id FROM breakout_session_organizers bso
+ WHERE bso.breakout_session_id = %s AND bso.organizer_id = %s
+ """, (session_id, session['user_id']))
+ presenter = cursor.fetchone()
+
+ if not presenter:
+ cursor.close()
+ conn.close()
+ return jsonify({'success': False, 'error': 'Not authorized'}), 403
+
+ # Get attendee info
+ cursor.execute("SELECT id, first_name, last_name, checked_in FROM attendees WHERE id = %s", (attendee_id,))
+ attendee = cursor.fetchone()
+
+ if not attendee:
+ cursor.close()
+ conn.close()
+ return jsonify({'success': False, 'error': 'Attendee not found'}), 404
+
+ already_checked_in = attendee['checked_in']
+ check_in_time = datetime.now() if not already_checked_in else attendee.get('check_in_time')
+
+ # Update check-in status
+ cursor.execute("""
+ UPDATE attendees SET checked_in = 1, check_in_time = %s WHERE id = %s
+ """, (check_in_time, attendee_id))
+ conn.commit()
+
+ cursor.close()
+ conn.close()
+
+ return jsonify({
+ 'success': True,
+ 'attendee_name': f'{attendee["first_name"]} {attendee["last_name"]}',
+ 'already_checked_in': already_checked_in
+ })
+
+ except Error as e:
+ return jsonify({'success': False, 'error': str(e)}), 500
+
+
+@app.route('/organizer/breakout-session//presenter//regenerate-code', methods=['POST'])
+def regenerate_presenter_code(session_id, presenter_id):
+ """Regenerate the presenter code for a breakout session presenter."""
+ if session.get('user_type') != 'organizer':
+ return jsonify({'success': False, 'error': 'Access denied'}), 403
+
+ try:
+ conn = get_db_connection()
+ cursor = conn.cursor(dictionary=True)
+
+ # Verify organizer owns this session
+ cursor.execute("""
+ SELECT bs.*, e.organizer_id as event_organizer_id
+ FROM breakout_sessions bs
+ JOIN events e ON bs.event_id = e.id
+ WHERE bs.id = %s
+ """, (session_id,))
+ session_obj = cursor.fetchone()
+
+ if not session_obj or session_obj['event_organizer_id'] != session['user_id']:
+ cursor.close()
+ conn.close()
+ return jsonify({'success': False, 'error': 'Access denied'}), 403
+
+ # Regenerate code
+ new_code = generate_presenter_code()
+ cursor.execute("""
+ UPDATE breakout_session_organizers
+ SET presenter_code = %s
+ WHERE id = %s AND breakout_session_id = %s
+ """, (new_code, presenter_id, session_id))
+ conn.commit()
+
+ cursor.close()
+ conn.close()
+
+ return jsonify({'success': True, 'presenter_code': new_code})
+
+ except Error as e:
+ return jsonify({'success': False, 'error': str(e)}), 500
+
+
def get_current_user():
"""Get current logged-in user from session."""
if 'user_id' not in session or 'user_type' not in session:
@@ -901,10 +1291,28 @@ def ensure_staff_staff_code_column():
cursor.execute("ALTER TABLE staff ADD COLUMN staff_code VARCHAR(10) UNIQUE DEFAULT NULL")
conn.commit()
print("Added staff_code column to staff table")
+
+ cursor.execute("""
+ SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'staff' AND COLUMN_NAME = 'reminder_count'
+ """, (Config.DB_NAME,))
+ if not cursor.fetchone():
+ cursor.execute("ALTER TABLE staff ADD COLUMN reminder_count INT DEFAULT 0")
+ conn.commit()
+ print("Added reminder_count column to staff table")
+
+ cursor.execute("""
+ SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
+ WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'staff' AND COLUMN_NAME = 'reminder_dates'
+ """, (Config.DB_NAME,))
+ if not cursor.fetchone():
+ cursor.execute("ALTER TABLE staff ADD COLUMN reminder_dates JSON DEFAULT NULL")
+ conn.commit()
+ print("Added reminder_dates column to staff table")
cursor.close()
conn.close()
except Error as e:
- print(f"Error ensuring staff staff_code column: {e}")
+ print(f"Error ensuring staff reminder columns: {e}")
def ensure_all_staff_have_staff_code():
@@ -933,15 +1341,15 @@ def allowed_file(filename):
def generate_invite_token():
"""Generate a unique invite token for staff."""
- return uuid.uuid4().hex
+ return secrets.token_urlsafe(32)
-def send_staff_invite_email(staff_email, staff_name, event_name, invite_token, organizer_email):
+def send_staff_invite_email(staff_email, staff_first_name, staff_last_name, event_name, invite_token, organizer_email):
"""Send invite email to staff member."""
invite_url = url_for('staff_invite', token=invite_token, _external=True)
subject = f"You're invited to join {event_name} as Staff"
- body = f"""Hello {staff_name},
+ body = f"""Hello {staff_first_name} {staff_last_name},
You've been invited to join the event "{event_name}" as a staff member.
@@ -973,6 +1381,45 @@ The Event Team
return False
+def send_staff_reminder_email(staff_email, staff_first_name, staff_last_name, event_name, invite_token, organizer_email, reminder_count):
+ """Send reminder email to staff member."""
+ invite_url = url_for('staff_invite', token=invite_token, _external=True)
+
+ subject = f"Reminder: Please confirm your staff role for {event_name}"
+ body = f"""Hello {staff_first_name} {staff_last_name},
+
+This is a reminder to confirm your staff role for the event "{event_name}".
+
+You have received {reminder_count} reminder(s) about this invitation.
+
+Click the link below to complete your registration and set your password:
+{invite_url}
+
+This link will expire in 7 days.
+
+Best regards,
+The Event Team
+"""
+
+ msg = MIMEMultipart()
+ msg['From'] = Config.MAIL_DEFAULT_SENDER
+ msg['To'] = staff_email
+ msg['Subject'] = subject
+ msg.attach(MIMEText(body, 'plain'))
+
+ try:
+ with smtplib.SMTP(Config.MAIL_SERVER, Config.MAIL_PORT) as server:
+ if Config.MAIL_USE_TLS:
+ server.starttls()
+ if Config.MAIL_USERNAME:
+ server.login(Config.MAIL_USERNAME, Config.MAIL_PASSWORD)
+ server.send_message(msg)
+ return True
+ except Exception as e:
+ print(f"Email error: {e}")
+ return False
+
+
def send_attendee_confirmation_email(attendee_email, attendee_name, event_name, event_date, event_location, personal_page_url=None):
"""Send confirmation email to attendee after registration."""
subject = f"Registration confirmed for {event_name}"
@@ -1493,16 +1940,20 @@ def staff_login(staff_code):
# Log in the staff member
session['user_id'] = staff_member['id']
session['user_type'] = 'staff'
+ session['user_name'] = f"{staff_member['first_name']} {staff_member['last_name']}"
session['event_id'] = staff_member['event_id']
flash(f'Welcome back, {staff_member["first_name"]}!')
return redirect(url_for('staff_dashboard'))
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('index'))
+
# Routes - Auth
@app.route('/login', methods=['GET', 'POST'])
def login():
@@ -1542,9 +1993,17 @@ def login():
if user and verify_password(password, user['password_hash']):
clear_failed_logins(client_ip)
+ # Regenerate session ID to prevent session fixation
+ session.permanent = True
session['user_id'] = user['id']
session['user_type'] = user_type
session['event_id'] = user.get('event_id') if user_type in ['attendee', 'staff'] else None
+ if user_type in ('organizer', 'breakout_organizer'):
+ session['user_name'] = user['name']
+ elif user_type == 'staff':
+ session['user_name'] = f"{user['first_name']} {user['last_name']}"
+ else:
+ session['user_name'] = f"{user['first_name']} {user['last_name']}"
if user_type == 'organizer':
flash(f'Welcome back, {user["name"]}!')
return redirect(url_for('organizer_dashboard'))
@@ -1561,7 +2020,8 @@ def login():
record_failed_login(client_ip)
flash('Invalid email or password.')
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
# Handle pre-selection of user type from query string
default_type = request.args.get('type', 'attendee')
@@ -1616,7 +2076,8 @@ def forgot_password():
return redirect(url_for('login'))
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
return render_template('auth/forgot_password.html')
return render_template('auth/forgot_password.html')
@@ -1646,8 +2107,8 @@ def reset_password(token):
password = request.form.get('password', '')
confirm_password = request.form.get('confirm_password', '')
- if not password or len(password) < 6:
- flash('Password must be at least 6 characters.')
+ if not password or len(password) < 8:
+ flash(t('password_too_short'))
return render_template('auth/reset_password.html', token=token)
if password != confirm_password:
@@ -1673,10 +2134,13 @@ def reset_password(token):
return render_template('auth/reset_password.html', token=token)
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('login'))
+
@app.route('/presenter/dashboard')
@login_required
def presenter_dashboard():
@@ -1691,7 +2155,7 @@ def presenter_dashboard():
# Get sessions where this organizer is assigned as presenter
cursor.execute("""
- SELECT bs.*, e.name as event_name
+ SELECT bs.*, e.name as event_name, bso.presenter_code
FROM breakout_sessions bs
JOIN breakout_session_organizers bso ON bs.id = bso.breakout_session_id
JOIN events e ON bs.event_id = e.id
@@ -1700,19 +2164,24 @@ def presenter_dashboard():
""", (session['user_id'],))
sessions = cursor.fetchall()
- cursor.execute("SELECT name FROM organizers WHERE id = %s", (session['user_id'],))
+ cursor.execute("SELECT first_name, last_name FROM organizers WHERE id = %s", (session['user_id'],))
organizer = cursor.fetchone()
cursor.close()
conn.close()
+ presenter_name = f"{organizer['first_name']} {organizer['last_name']}"
+
return render_template('presenter/dashboard.html',
sessions=sessions,
- presenter_name=organizer['name'])
+ presenter_name=presenter_name)
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('index'))
+
@app.route('/staff/dashboard')
@login_required
def staff_dashboard():
@@ -1736,10 +2205,13 @@ def staff_dashboard():
return render_template('staff/staff_events.html', events=events, staff_name=f"{staff_member['first_name']} {staff_member['last_name']}")
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('index'))
+
@app.route('/staff//dashboard')
@login_required
def staff_event_dashboard(event_id):
@@ -1776,10 +2248,13 @@ def staff_event_dashboard(event_id):
return render_template('staff/dashboard.html', event=event, staff_name=f"{staff_member['first_name']} {staff_member['last_name']}")
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('staff_dashboard'))
+
@app.route('/register/organizer', methods=['GET', 'POST'])
def register_organizer():
"""Register a new organizer."""
@@ -1797,8 +2272,8 @@ def register_organizer():
flash('Passwords do not match.')
return render_template('register.html', user_type='organizer')
- if len(password) < 6:
- flash('Password must be at least 6 characters.')
+ if len(password) < 8:
+ flash(t('password_too_short'))
return render_template('register.html', user_type='organizer')
try:
@@ -1827,7 +2302,8 @@ def register_organizer():
flash('Registration successful! Please log in.')
return redirect(url_for('login'))
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
return render_template('register.html', user_type='organizer')
@@ -1848,9 +2324,12 @@ def register_attendee(code):
flash('Event not found.')
return redirect(url_for('index'))
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('index'))
+
if request.method == 'POST':
first_name = request.form.get('first_name', '').strip()
last_name = request.form.get('last_name', '').strip()
@@ -1898,7 +2377,8 @@ def register_attendee(code):
flash('Registration successful! Please log in.')
return redirect(url_for('login'))
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
return render_template('register.html', user_type='attendee', event=event)
@@ -1950,10 +2430,13 @@ def organizer_dashboard():
conn.close()
return render_template('organizer/dashboard.html', events=events)
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('index'))
+
@app.route('/organizer/attendee-types')
@login_required
def all_attendee_types():
@@ -1985,10 +2468,13 @@ def all_attendee_types():
conn.close()
return render_template('organizer/all_attendee_types.html', events=events)
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('index'))
+
@app.route('/organizer/event/create', methods=['GET', 'POST'])
@login_required
def create_event():
@@ -2038,7 +2524,8 @@ def create_event():
flash('Event created successfully!')
return redirect(url_for('organizer_dashboard'))
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
return render_template('organizer/create_event.html')
@@ -2115,10 +2602,13 @@ def edit_event(event_id):
conn.close()
return render_template('organizer/edit_event.html', event=event)
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('organizer_dashboard'))
+
@app.route('/organizer/event//notify', methods=['POST'])
@login_required
def notify_event_attendees(event_id):
@@ -2167,10 +2657,13 @@ def notify_event_attendees(event_id):
return redirect(url_for('event_detail', code=event['code']))
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('organizer_dashboard'))
+
@app.route('/organizer/event//breakout-sessions')
@login_required
def list_breakout_sessions(event_id):
@@ -2205,10 +2698,13 @@ def list_breakout_sessions(event_id):
return render_template('organizer/breakout_sessions.html', event=event, sessions=sessions)
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('organizer_dashboard'))
+
@app.route('/organizer/event//breakout-session/create', methods=['GET', 'POST'])
@login_required
def create_breakout_session(event_id):
@@ -2229,9 +2725,12 @@ def create_breakout_session(event_id):
flash('Event not found.')
return redirect(url_for('organizer_dashboard'))
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('organizer_dashboard'))
+
# Check if event is single-day
is_single_day = False
prefilled_date = ''
@@ -2282,9 +2781,9 @@ def create_breakout_session(event_id):
# Add event organizer as breakout session organizer
cursor.execute("""
- INSERT INTO breakout_session_organizers (breakout_session_id, organizer_id)
- VALUES (%s, %s)
- """, (breakout_session_id, session['user_id']))
+ INSERT INTO breakout_session_organizers (breakout_session_id, organizer_id, presenter_code)
+ VALUES (%s, %s, %s)
+ """, (breakout_session_id, session['user_id'], generate_presenter_code()))
conn.commit()
cursor.close()
@@ -2293,7 +2792,8 @@ def create_breakout_session(event_id):
flash('Breakout session created successfully!')
return redirect(url_for('list_breakout_sessions', event_id=event_id))
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
return render_template('organizer/create_breakout_session.html', event=event, prefilled_date=prefilled_date)
@@ -2415,10 +2915,13 @@ def edit_breakout_session(session_id):
return render_template('organizer/edit_breakout_session.html', session=breakout_session, event=event)
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('organizer_dashboard'))
+
@app.route('/organizer/breakout-session//notify', methods=['POST'])
@login_required
def notify_breakout_session_attendees(session_id):
@@ -2485,10 +2988,13 @@ def notify_breakout_session_attendees(session_id):
return redirect(url_for('view_breakout_session', code=breakout_session['code']))
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('organizer_dashboard'))
+
@app.route('/breakout-session/')
@login_required
def view_breakout_session(code):
@@ -2538,15 +3044,41 @@ def view_breakout_session(code):
ORDER BY a.last_name, a.first_name
""", (session_data['id'],))
rsvps = cursor.fetchall()
+
+ # Get presenters
+ cursor.execute("""
+ SELECT bso.id, bso.presenter_code, o.id as organizer_id, o.email, o.first_name, o.last_name
+ FROM breakout_session_organizers bso
+ JOIN organizers o ON bso.organizer_id = o.id
+ WHERE bso.breakout_session_id = %s
+ ORDER BY o.last_name, o.first_name
+ """, (session_data['id'],))
+ presenters = cursor.fetchall()
+
+ # Get all organizers for adding more presenters
+ cursor.execute("""
+ SELECT id, email, first_name, last_name FROM organizers
+ WHERE id NOT IN (
+ SELECT organizer_id FROM breakout_session_organizers WHERE breakout_session_id = %s
+ )
+ ORDER BY last_name, first_name
+ """, (session_data['id'],))
+ available_organizers = cursor.fetchall()
+
cursor.close()
conn.close()
- return render_template('organizer/breakout_session_detail.html', session=session_data, rsvps=rsvps)
+ return render_template('organizer/breakout_session_detail.html',
+ session=session_data, rsvps=rsvps, presenters=presenters,
+ available_organizers=available_organizers)
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('organizer_dashboard'))
+
@app.route('/attendee/breakout-sessions')
@login_required
def attendee_breakout_sessions():
@@ -2576,10 +3108,13 @@ def attendee_breakout_sessions():
return render_template('attendee/breakout_sessions.html', event=event, sessions=sessions)
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('attendee_dashboard'))
+
@app.route('/attendee/breakout-session//rsvp', methods=['POST'])
@login_required
def rsvp_breakout_session(code):
@@ -2589,30 +3124,30 @@ def rsvp_breakout_session(code):
try:
conn = get_db_connection()
+ conn.start_transaction()
cursor = conn.cursor(dictionary=True)
- # Get breakout session
- cursor.execute("SELECT * FROM breakout_sessions WHERE code = %s", (code,))
+ # Get breakout session with row lock
+ cursor.execute("SELECT * FROM breakout_sessions WHERE code = %s FOR UPDATE", (code,))
breakout = cursor.fetchone()
- cursor.close()
if not breakout:
+ conn.rollback()
conn.close()
return jsonify({'error': 'Breakout session not found'}), 404
session_id = breakout['id']
- # Check capacity
+ # Check capacity (within transaction to prevent race conditions)
if breakout['max_attendees']:
- cursor = conn.cursor(dictionary=True)
cursor.execute("""
SELECT COUNT(*) as count FROM breakout_session_rsvps
WHERE breakout_session_id = %s AND status = 'registered'
""", (session_id,))
current_count = cursor.fetchone()['count']
- cursor.close()
if current_count >= breakout['max_attendees']:
+ conn.rollback()
conn.close()
return jsonify({'error': 'Breakout session is full'}), 400
@@ -2638,25 +3173,24 @@ def rsvp_breakout_session(code):
if overlapping:
names = ', '.join([s['name'] for s in overlapping])
+ conn.rollback()
conn.close()
return jsonify({'error': f'Overlaps with: {names}'}), 400
# Check if already registered
- cursor = conn.cursor(dictionary=True)
cursor.execute("""
SELECT id, status FROM breakout_session_rsvps
WHERE breakout_session_id = %s AND attendee_id = %s
""", (session_id, session['user_id']))
existing = cursor.fetchone()
- cursor.close()
if existing:
if existing['status'] == 'registered':
+ conn.rollback()
conn.close()
return jsonify({'error': 'Already registered'}), 400
else:
# Reactivate cancelled RSVP
- cursor = conn.cursor()
cursor.execute("""
UPDATE breakout_session_rsvps SET status = 'registered'
WHERE id = %s
@@ -2667,7 +3201,6 @@ def rsvp_breakout_session(code):
return jsonify({'success': True, 'message': 'RSVP restored'})
else:
# Create new RSVP
- cursor = conn.cursor()
cursor.execute("""
INSERT INTO breakout_session_rsvps (breakout_session_id, attendee_id, status)
VALUES (%s, %s, 'registered')
@@ -2762,6 +3295,28 @@ def event_detail(code):
flash('Access denied.')
return redirect(url_for('index'))
+ # Pagination and sorting defaults
+ per_page = 10
+ page = request.args.get('page', 1, type=int)
+ staff_page = request.args.get('staff_page', 1, type=int)
+
+ # Sort parameters
+ sort_by = request.args.get('sort_by', 'last_name')
+ sort_order = request.args.get('sort_order', 'asc')
+ staff_sort_by = request.args.get('staff_sort_by', 'last_name')
+ staff_sort_order = request.args.get('staff_sort_order', 'asc')
+
+ # Validate sort columns to prevent SQL injection
+ allowed_sort_columns = ['last_name', 'first_name', 'email', 'organisation', 'role', 'attendee_type_id', 'checked_in', 'created_at']
+ if sort_by not in allowed_sort_columns:
+ sort_by = 'last_name'
+ if staff_sort_by not in allowed_sort_columns:
+ staff_sort_by = 'last_name'
+ if sort_order not in ['asc', 'desc']:
+ sort_order = 'asc'
+ if staff_sort_order not in ['asc', 'desc']:
+ staff_sort_order = 'asc'
+
try:
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
@@ -2775,7 +3330,19 @@ def event_detail(code):
conn.close()
return redirect(url_for('organizer_dashboard'))
- cursor.execute("SELECT * FROM attendees WHERE event_id = %s", (event['id'],))
+ # Get total attendee count
+ cursor.execute("SELECT COUNT(*) as total FROM attendees WHERE event_id = %s", (event['id'],))
+ total_attendees = cursor.fetchone()['total']
+ total_attendee_pages = (total_attendees + per_page - 1) // per_page if total_attendees > 0 else 1
+
+ # Get paginated attendees with sorting
+ offset = (page - 1) * per_page
+ cursor.execute(f"""
+ SELECT * FROM attendees
+ WHERE event_id = %s
+ ORDER BY {sort_by} {sort_order}, first_name asc
+ LIMIT %s OFFSET %s
+ """, (event['id'], per_page, offset))
attendees = cursor.fetchall()
cursor.execute("""
@@ -2787,7 +3354,19 @@ def event_detail(code):
""", (event['id'],))
breakout_sessions = cursor.fetchall()
- cursor.execute("SELECT * FROM staff WHERE event_id = %s ORDER BY last_name, first_name", (event['id'],))
+ # Get total staff count
+ cursor.execute("SELECT COUNT(*) as total FROM staff WHERE event_id = %s", (event['id'],))
+ total_staff = cursor.fetchone()['total']
+ total_staff_pages = (total_staff + per_page - 1) // per_page if total_staff > 0 else 1
+
+ # Get paginated staff with sorting
+ staff_offset = (staff_page - 1) * per_page
+ cursor.execute(f"""
+ SELECT * FROM staff
+ WHERE event_id = %s
+ ORDER BY {staff_sort_by} {staff_sort_order}, first_name asc
+ LIMIT %s OFFSET %s
+ """, (event['id'], per_page, staff_offset))
staff = cursor.fetchall()
# Get attendee types for this event
@@ -2807,12 +3386,26 @@ def event_detail(code):
cursor.close()
conn.close()
- return render_template('organizer/event_detail.html', event=event, attendees=attendees, breakout_sessions=breakout_sessions, staff=staff, other_events=other_events, attendee_types=attendee_types)
+ return render_template('organizer/event_detail.html',
+ event=event,
+ attendees=attendees,
+ breakout_sessions=breakout_sessions,
+ staff=staff,
+ other_events=other_events,
+ attendee_types=attendee_types,
+ page=page,
+ per_page=per_page,
+ total_pages=total_attendee_pages,
+ staff_page=staff_page,
+ total_staff_pages=total_staff_pages)
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('organizer_dashboard'))
+
@app.route('/organizer/event//attendee-types', methods=['GET', 'POST'])
@login_required
def manage_attendee_types(event_id):
@@ -2867,10 +3460,13 @@ def manage_attendee_types(event_id):
conn.close()
return render_template('organizer/attendee_types.html', event=event, attendee_types=attendee_types)
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('organizer_dashboard'))
+
@app.route('/organizer/event//attendee-type//edit', methods=['GET', 'POST'])
@login_required
def edit_attendee_type(event_id, type_id):
@@ -2933,10 +3529,13 @@ def edit_attendee_type(event_id, type_id):
conn.close()
return render_template('organizer/edit_attendee_type.html', event=event, attendee_type=attendee_type, attendee_count=attendee_count)
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('organizer_dashboard'))
+
@app.route('/organizer/event//attendee-type//delete', methods=['POST'])
@login_required
def delete_attendee_type(event_id, type_id):
@@ -2979,10 +3578,13 @@ def delete_attendee_type(event_id, type_id):
conn.close()
return redirect(url_for('manage_attendee_types', event_id=event_id))
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('organizer_dashboard'))
+
@app.route('/organizer/event//attendees/batch-assign-type', methods=['POST'])
@login_required
def batch_assign_attendee_type(event_id):
@@ -3039,10 +3641,13 @@ def batch_assign_attendee_type(event_id):
conn.close()
return redirect(url_for('event_detail', code=event['code']))
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('organizer_dashboard'))
+
@app.route('/organizer/event//staff', methods=['GET', 'POST'])
@login_required
def manage_event_staff(event_id):
@@ -3104,8 +3709,7 @@ def manage_event_staff(event_id):
conn.close()
# Send invite email
- full_name = f"{first_name} {last_name}"
- if send_staff_invite_email(email, full_name, event['name'], invite_token, organizer_email):
+ if send_staff_invite_email(email, first_name, last_name, event['name'], invite_token, organizer_email):
flash(f'Staff member added! Invite email sent to {email}.')
else:
flash(f'Staff member added but failed to send invite email.')
@@ -3114,10 +3718,13 @@ def manage_event_staff(event_id):
return render_template('organizer/event_staff.html', event=event, staff_members=staff_members)
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('organizer_dashboard'))
+
@app.route('/staff/invite/', methods=['GET', 'POST'])
def staff_invite(token):
"""Staff invite acceptance page."""
@@ -3149,8 +3756,8 @@ def staff_invite(token):
flash('Please complete the reCAPTCHA verification.')
return render_template('staff_invite.html', staff=staff_member, event=event, recaptcha_site_key=recaptcha_site_key)
- if not password or len(password) < 6:
- flash('Password must be at least 6 characters.')
+ if not password or len(password) < 8:
+ flash(t('password_too_short'))
return render_template('staff_invite.html', staff=staff_member, event=event, recaptcha_site_key=recaptcha_site_key)
if password != confirm_password:
@@ -3173,10 +3780,13 @@ def staff_invite(token):
return render_template('staff_invite.html', staff=staff_member, event=event, recaptcha_site_key=recaptcha_site_key)
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('index'))
+
@app.route('/organizer/event//staff//edit', methods=['GET', 'POST'])
@login_required
def edit_staff(event_id, staff_id):
@@ -3233,10 +3843,527 @@ def edit_staff(event_id, staff_id):
conn.close()
return render_template('organizer/edit_staff.html', event=event, staff=staff_member)
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('organizer_dashboard'))
+
+@app.route('/organizer/event//staff//remind', methods=['POST'])
+@login_required
+def send_staff_reminder(event_id, staff_id):
+ """Send a reminder email to a staff member."""
+ if session.get('user_type') != 'organizer':
+ return jsonify({'success': False, 'error': 'Access denied'}), 403
+
+ try:
+ conn = get_db_connection()
+ cursor = conn.cursor(dictionary=True)
+
+ # Verify event belongs to organizer
+ cursor.execute("SELECT * FROM events WHERE id = %s AND organizer_id = %s", (event_id, session['user_id']))
+ event = cursor.fetchone()
+ if not event:
+ cursor.close()
+ conn.close()
+ return jsonify({'success': False, 'error': 'Event not found'}), 404
+
+ # Get staff member
+ cursor.execute("SELECT * FROM staff WHERE id = %s AND event_id = %s", (staff_id, event_id))
+ staff = cursor.fetchone()
+ if not staff:
+ cursor.close()
+ conn.close()
+ return jsonify({'success': False, 'error': 'Staff not found'}), 404
+
+ # Get organizer email
+ cursor.execute("SELECT email FROM organizers WHERE id = %s", (session['user_id'],))
+ organizer = cursor.fetchone()
+ organizer_email = organizer['email'] if organizer else 'noreply@netevents.com'
+
+ # Increment reminder count and store date
+ new_reminder_count = (staff.get('reminder_count') or 0) + 1
+ reminder_date = datetime.now().strftime('%d %b %Y %H:%M')
+
+ # Get existing dates
+ existing_dates = staff.get('reminder_dates')
+ if existing_dates:
+ if isinstance(existing_dates, str):
+ dates_list = json.loads(existing_dates)
+ else:
+ dates_list = existing_dates
+ else:
+ dates_list = []
+ dates_list.append(reminder_date)
+
+ cursor.execute("UPDATE staff SET reminder_count = %s, reminder_dates = %s WHERE id = %s",
+ (new_reminder_count, json.dumps(dates_list), staff_id))
+ conn.commit()
+
+ # Send reminder email if staff has invite_token
+ if staff.get('invite_token'):
+ email_sent = send_staff_reminder_email(
+ staff['email'],
+ staff['first_name'],
+ staff['last_name'],
+ event['name'],
+ staff['invite_token'],
+ organizer_email,
+ new_reminder_count
+ )
+ else:
+ email_sent = False
+
+ cursor.close()
+ conn.close()
+
+ return jsonify({'success': True, 'reminder_count': new_reminder_count, 'reminder_dates': dates_list, 'email_sent': email_sent})
+ except Error as e:
+ return jsonify({'success': False, 'error': str(e)}), 500
+
+
+@app.route('/organizer/event//communications')
+@login_required
+def event_communications(event_id):
+ """Manage email templates for an event."""
+ if session.get('user_type') != 'organizer':
+ flash('Access denied.')
+ return redirect(url_for('index'))
+
+ try:
+ conn = get_db_connection()
+ cursor = conn.cursor(dictionary=True)
+
+ cursor.execute("SELECT * FROM events WHERE id = %s AND organizer_id = %s", (event_id, session['user_id']))
+ event = cursor.fetchone()
+
+ if not event:
+ flash('Event not found.')
+ cursor.close()
+ conn.close()
+ return redirect(url_for('organizer_dashboard'))
+
+ # Get email templates for this event
+ cursor.execute("SELECT * FROM event_email_templates WHERE event_id = %s", (event_id,))
+ templates = cursor.fetchall()
+ templates_map = {t['template_type']: t for t in templates}
+
+ # Get sent emails history
+ cursor.execute("""
+ SELECT * FROM sent_emails
+ WHERE event_id = %s
+ ORDER BY sent_at DESC
+ LIMIT 50
+ """, (event_id,))
+ sent_history = cursor.fetchall()
+
+ # Template types with their default content
+ template_types = {
+ 'staff_invite': {
+ 'name': 'Staff Invite',
+ 'subject_default': "You're invited to join {event_name} as Staff",
+ 'body_default': """Hello {staff_first_name} {staff_last_name},
+
+You've been invited to join the event "{event_name}" as a staff member.
+
+Click the link below to complete your registration and set your password:
+{invite_url}
+
+This link will expire in 7 days.
+
+Best regards,
+The Event Team"""
+ },
+ 'staff_reminder': {
+ 'name': 'Staff Reminder',
+ 'subject_default': "Reminder: Please confirm your staff role for {event_name}",
+ 'body_default': """Hello {staff_first_name} {staff_last_name},
+
+This is a reminder to confirm your staff role for the event "{event_name}".
+
+You have received {reminder_count} reminder(s) about this invitation.
+
+Click the link below to complete your registration and set your password:
+{invite_url}
+
+This link will expire in 7 days.
+
+Best regards,
+The Event Team"""
+ },
+ 'attendee_confirmation': {
+ 'name': 'Attendee Confirmation',
+ 'subject_default': "Registration confirmed for {event_name}",
+ 'body_default': """Hello {attendee_first_name} {attendee_last_name},
+
+Your registration for {event_name} has been confirmed!
+
+Event Details:
+- Date: {event_date}
+- Location: {event_location}
+
+You can now log in to:
+- View event details
+- RSVP for break-out sessions
+- Connect with other attendees
+
+Best regards,
+The Event Team"""
+ },
+ 'event_update': {
+ 'name': 'Event Update',
+ 'subject_default': "Update on Event: {event_name}",
+ 'body_default': """Hello {attendee_first_name} {attendee_last_name},
+
+There have been updates to the event "{event_name}".
+
+Changes:
+{changes_text}
+
+Best regards,
+The Event Team"""
+ },
+ 'breakout_update': {
+ 'name': 'Breakout Session Update',
+ 'subject_default': "Update on Breakout Session: {session_name}",
+ 'body_default': """Hello {attendee_first_name} {attendee_last_name},
+
+There have been updates to the breakout session "{session_name}".
+
+Changes:
+{changes_text}
+
+Best regards,
+The Event Team"""
+ }
+ }
+
+ # Ensure all template types exist
+ for template_type, config in template_types.items():
+ if template_type not in templates_map:
+ cursor.execute("""
+ INSERT INTO event_email_templates (event_id, template_type, subject, body)
+ VALUES (%s, %s, %s, %s)
+ """, (event_id, template_type, config['subject_default'], config['body_default']))
+ conn.commit()
+
+ # Refetch templates
+ cursor.execute("SELECT * FROM event_email_templates WHERE event_id = %s", (event_id,))
+ templates = cursor.fetchall()
+ templates_map = {t['template_type']: t for t in templates}
+
+ cursor.close()
+ conn.close()
+
+ return render_template('organizer/communications.html',
+ event=event,
+ templates=templates,
+ templates_map=templates_map,
+ template_types=template_types,
+ sent_history=sent_history)
+ except Error as e:
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
+ return redirect(url_for('organizer_dashboard'))
+
+
+
+@app.route('/organizer/event//communications/edit/', methods=['GET', 'POST'])
+@login_required
+def edit_email_template(event_id, template_type):
+ """Edit a specific email template."""
+ if session.get('user_type') != 'organizer':
+ flash('Access denied.')
+ return redirect(url_for('index'))
+
+ try:
+ conn = get_db_connection()
+ cursor = conn.cursor(dictionary=True)
+
+ cursor.execute("SELECT * FROM events WHERE id = %s AND organizer_id = %s", (event_id, session['user_id']))
+ event = cursor.fetchone()
+
+ if not event:
+ flash('Event not found.')
+ cursor.close()
+ conn.close()
+ return redirect(url_for('organizer_dashboard'))
+
+ if request.method == 'POST':
+ subject = request.form.get('subject', '')
+ body = request.form.get('body', '')
+
+ cursor.execute("""
+ UPDATE event_email_templates
+ SET subject = %s, body = %s
+ WHERE event_id = %s AND template_type = %s
+ """, (subject, body, event_id, template_type))
+ conn.commit()
+ flash('Template saved successfully!')
+ cursor.close()
+ conn.close()
+ return redirect(url_for('event_communications', event_id=event_id))
+
+ cursor.execute("SELECT * FROM event_email_templates WHERE event_id = %s AND template_type = %s", (event_id, template_type))
+ template = cursor.fetchone()
+ cursor.close()
+ conn.close()
+
+ if not template:
+ flash('Template not found.')
+ return redirect(url_for('event_communications', event_id=event_id))
+
+ template_types = {
+ 'staff_invite': {'name': 'Staff Invite'},
+ 'staff_reminder': {'name': 'Staff Reminder'},
+ 'attendee_confirmation': {'name': 'Attendee Confirmation'},
+ 'event_update': {'name': 'Event Update'},
+ 'breakout_update': {'name': 'Breakout Session Update'}
+ }
+
+ return render_template('organizer/communications_edit.html',
+ event=event,
+ template=template,
+ template_type_name=template_types.get(template_type, {}).get('name', template_type))
+ except Error as e:
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
+ return redirect(url_for('organizer_dashboard'))
+
+
+
+@app.route('/organizer/event//communications/send', methods=['GET', 'POST'])
+@login_required
+def send_custom_email(event_id):
+ """Send a custom email to attendees or staff."""
+ if session.get('user_type') != 'organizer':
+ flash('Access denied.')
+ return redirect(url_for('index'))
+
+ try:
+ conn = get_db_connection()
+ cursor = conn.cursor(dictionary=True)
+
+ cursor.execute("SELECT * FROM events WHERE id = %s AND organizer_id = %s", (event_id, session['user_id']))
+ event = cursor.fetchone()
+
+ if not event:
+ flash('Event not found.')
+ cursor.close()
+ conn.close()
+ return redirect(url_for('organizer_dashboard'))
+
+ # Get attendees
+ cursor.execute("SELECT id, email, first_name, last_name FROM attendees WHERE event_id = %s", (event_id,))
+ attendees = cursor.fetchall()
+
+ # Get staff
+ cursor.execute("SELECT id, email, first_name, last_name FROM staff WHERE event_id = %s", (event_id,))
+ staff_members = cursor.fetchall()
+
+ if request.method == 'POST':
+ recipient_type = request.form.get('recipient_type')
+ recipient_ids = request.form.getlist('recipient_ids')
+ subject = request.form.get('subject', '')
+ body = request.form.get('body', '')
+
+ if not subject or not body:
+ flash('Subject and body are required.')
+ cursor.close()
+ conn.close()
+ return render_template('organizer/communications_send.html',
+ event=event,
+ attendees=attendees,
+ staff_members=staff_members)
+
+ cursor.execute("SELECT email FROM organizers WHERE id = %s", (session['user_id'],))
+ organizer = cursor.fetchone()
+ sender_email = organizer['email'] if organizer else 'noreply@netevents.com'
+
+ sent_count = 0
+
+ if recipient_type == 'all_attendees':
+ for att in attendees:
+ send_custom_email_to_recipient(att['email'], subject, body)
+ # Store sent email
+ cursor.execute("""
+ INSERT INTO sent_emails (event_id, sender_id, sender_type, recipient_type, recipient_id, subject, body)
+ VALUES (%s, %s, 'organizer', 'attendee', %s, %s, %s)
+ """, (event_id, session['user_id'], att['id'], subject, body))
+ # Link to user
+ cursor.execute("""
+ INSERT INTO user_communications (user_type, user_id, sent_email_id)
+ VALUES ('attendee', %s, LAST_INSERT_ID())
+ """, (att['id'],))
+ sent_count += 1
+
+ elif recipient_type == 'all_staff':
+ for s in staff_members:
+ send_custom_email_to_recipient(s['email'], subject, body)
+ cursor.execute("""
+ INSERT INTO sent_emails (event_id, sender_id, sender_type, recipient_type, recipient_id, subject, body)
+ VALUES (%s, %s, 'organizer', 'staff', %s, %s, %s)
+ """, (event_id, session['user_id'], s['id'], subject, body))
+ cursor.execute("""
+ INSERT INTO user_communications (user_type, user_id, sent_email_id)
+ VALUES ('staff', %s, LAST_INSERT_ID())
+ """, (s['id'],))
+ sent_count += 1
+
+ elif recipient_type in ['attendee', 'staff']:
+ for rid in recipient_ids:
+ user_type_db = 'attendee' if recipient_type == 'attendee' else 'staff'
+ # Get email
+ cursor.execute(f"SELECT email, first_name, last_name FROM {recipient_type}s WHERE id = %s", (rid,))
+ user = cursor.fetchone()
+ if user:
+ send_custom_email_to_recipient(user['email'], subject, body)
+ cursor.execute("""
+ INSERT INTO sent_emails (event_id, sender_id, sender_type, recipient_type, recipient_id, subject, body)
+ VALUES (%s, %s, 'organizer', %s, %s, %s, %s)
+ """, (event_id, session['user_id'], user_type_db, rid, subject, body))
+ cursor.execute("""
+ INSERT INTO user_communications (user_type, user_id, sent_email_id)
+ VALUES (%s, %s, LAST_INSERT_ID())
+ """, (user_type_db, rid))
+ sent_count += 1
+
+ conn.commit()
+ cursor.close()
+ conn.close()
+
+ flash(f'Email sent to {sent_count} recipient(s)!')
+ return redirect(url_for('event_communications', event_id=event_id))
+
+ cursor.close()
+ conn.close()
+
+ return render_template('organizer/communications_send.html',
+ event=event,
+ attendees=attendees,
+ staff_members=staff_members)
+ except Error as e:
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
+ return redirect(url_for('organizer_dashboard'))
+
+
+
+def send_custom_email_to_recipient(email, subject, body):
+ """Send a custom email to a recipient."""
+ msg = MIMEMultipart()
+ msg['From'] = Config.MAIL_DEFAULT_SENDER
+ msg['To'] = email
+ msg['Subject'] = subject
+ msg.attach(MIMEText(body, 'plain'))
+
+ try:
+ with smtplib.SMTP(Config.MAIL_SERVER, Config.MAIL_PORT) as server:
+ if Config.MAIL_USE_TLS:
+ server.starttls()
+ if Config.MAIL_USERNAME:
+ server.login(Config.MAIL_USERNAME, Config.MAIL_PASSWORD)
+ server.send_message(msg)
+ return True
+ except Exception as e:
+ print(f"Email error: {e}")
+ return False
+
+
+@app.route('/attendee/communications')
+@login_required
+def attendee_communications():
+ """View received communications for an attendee."""
+ if session.get('user_type') != 'attendee':
+ flash('Access denied.')
+ return redirect(url_for('index'))
+
+ try:
+ conn = get_db_connection()
+ cursor = conn.cursor(dictionary=True)
+
+ # Get user's events
+ cursor.execute("""
+ SELECT DISTINCT e.id, e.name, e.code
+ FROM events e
+ JOIN attendees a ON a.event_id = e.id
+ WHERE a.id = %s
+ """, (session['user_id'],))
+ events = cursor.fetchall()
+
+ # Get received emails
+ cursor.execute("""
+ SELECT se.*, e.name as event_name
+ FROM sent_emails se
+ JOIN events e ON e.id = se.event_id
+ JOIN user_communications uc ON uc.sent_email_id = se.id
+ WHERE uc.user_type = 'attendee' AND uc.user_id = %s
+ ORDER BY se.sent_at DESC
+ LIMIT 100
+ """, (session['user_id'],))
+ emails = cursor.fetchall()
+
+ cursor.close()
+ conn.close()
+
+ return render_template('attendee/communications.html', emails=emails, events=events)
+ except Error as e:
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
+ return redirect(url_for('attendee_dashboard'))
+
+
+
+@app.route('/staff/communications')
+@login_required
+def staff_communications():
+ """View received communications for staff."""
+ if session.get('user_type') != 'staff':
+ flash('Access denied.')
+ return redirect(url_for('index'))
+
+ try:
+ conn = get_db_connection()
+ cursor = conn.cursor(dictionary=True)
+
+ # Get user's events
+ cursor.execute("""
+ SELECT DISTINCT e.id, e.name, e.code
+ FROM events e
+ JOIN staff s ON s.event_id = e.id
+ WHERE s.id = %s
+ """, (session['user_id'],))
+ events = cursor.fetchall()
+
+ # Get received emails
+ cursor.execute("""
+ SELECT se.*, e.name as event_name
+ FROM sent_emails se
+ JOIN events e ON e.id = se.event_id
+ JOIN user_communications uc ON uc.sent_email_id = se.id
+ WHERE uc.user_type = 'staff' AND uc.user_id = %s
+ ORDER BY se.sent_at DESC
+ LIMIT 100
+ """, (session['user_id'],))
+ emails = cursor.fetchall()
+
+ cursor.close()
+ conn.close()
+
+ return render_template('staff/communications.html', emails=emails, events=events)
+ except Error as e:
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
+ return redirect(url_for('staff_dashboard'))
+
+
+
@app.route('/organizer/event//staff//delete', methods=['POST'])
@login_required
def delete_staff(event_id, staff_id):
@@ -3275,10 +4402,13 @@ def delete_staff(event_id, staff_id):
flash('Staff member removed successfully!')
return redirect(url_for('organizer_dashboard'))
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('organizer_dashboard'))
+
@app.route('/organizer/event//batch-add-staff', methods=['POST'])
@login_required
def batch_add_staff(event_id):
@@ -3348,10 +4478,13 @@ def batch_add_staff(event_id):
return redirect(url_for('event_detail', code=target_event['code']))
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('organizer_dashboard'))
+
@app.route('/organizer/breakout-session//delete', methods=['POST'])
@login_required
def delete_breakout_session(session_id):
@@ -3387,10 +4520,13 @@ def delete_breakout_session(session_id):
flash('Breakout session deleted successfully!')
return redirect(url_for('organizer_dashboard'))
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('organizer_dashboard'))
+
@app.route('/organizer/attendee//edit', methods=['GET', 'POST'])
@login_required
def edit_attendee(attendee_id):
@@ -3447,10 +4583,13 @@ def edit_attendee(attendee_id):
conn.close()
return render_template('organizer/edit_attendee.html', attendee=attendee, attendee_types=attendee_types)
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('organizer_dashboard'))
+
@app.route('/organizer/attendee//delete', methods=['POST'])
@login_required
def delete_attendee(attendee_id):
@@ -3485,10 +4624,13 @@ def delete_attendee(attendee_id):
flash('Attendee deleted successfully!')
return redirect(url_for('organizer_dashboard'))
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('organizer_dashboard'))
+
@app.route('/organizer/event//badges')
@login_required
def event_badges(event_id):
@@ -3526,10 +4668,13 @@ def event_badges(event_id):
return render_template('organizer/badges.html', event=event, attendees=attendees)
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('organizer_dashboard'))
+
@app.route('/organizer/event//badges/rectangular/download')
@login_required
def download_rectangular_badges(event_id):
@@ -3577,10 +4722,13 @@ def download_rectangular_badges(event_id):
download_name=filename
)
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('organizer_dashboard'))
+
@app.route('/organizer/event//attendees/excel/download')
@login_required
def download_attendees_excel(event_id):
@@ -3627,7 +4775,7 @@ def download_attendees_excel(event_id):
header_fill = PatternFill("solid", fgColor="333333")
header_alignment = Alignment(horizontal="center")
- headers = ['ID', 'Voornaam', 'Achternaam', 'Email', 'Organisatie', 'Rol', 'Type', 'Ingecheckt', 'Aangemeld op']
+ headers = ['ID', 'First Name', 'Last Name', 'Email', 'Organisation', 'Role', 'Type', 'Checked In', 'Registered At']
for col, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=header)
cell.font = header_font
@@ -3643,7 +4791,7 @@ def download_attendees_excel(event_id):
ws.cell(row=row, column=5, value=attendee.get('organisation', ''))
ws.cell(row=row, column=6, value=attendee.get('role', ''))
ws.cell(row=row, column=7, value=attendee_types_map.get(attendee.get('attendee_type_id'), {}).get('name', ''))
- ws.cell(row=row, column=8, value='Ja' if attendee.get('checked_in') else 'Nee')
+ ws.cell(row=row, column=8, value='Yes' if attendee.get('checked_in') else 'No')
ws.cell(row=row, column=9, value=str(attendee.get('created_at', ''))[:19] if attendee.get('created_at') else '')
# Auto-fit column widths
@@ -3667,8 +4815,13 @@ def download_attendees_excel(event_id):
download_name=filename
)
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('organizer_dashboard'))
+
+
+@app.route('/organizer/event//stats')
@login_required
def event_stats(event_id):
"""Attendance statistics for event."""
@@ -3772,6 +4925,17 @@ def checkin_by_code(event_id):
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
+ # Verify staff belongs to this event
+ if session.get('user_type') == 'staff':
+ cursor.execute(
+ "SELECT 1 FROM staff WHERE id = %s AND event_id = %s",
+ (session['user_id'], event_id)
+ )
+ if not cursor.fetchone():
+ cursor.close()
+ conn.close()
+ return jsonify({'error': 'Access denied'}), 403
+
# Find attendee by code and event
cursor.execute(
"SELECT id, first_name, last_name, checked_in FROM attendees WHERE attendee_code = %s AND event_id = %s",
@@ -3846,10 +5010,13 @@ def event_scan(event_id):
return render_template('staff/scan.html', event=event, attendees=attendees)
return render_template('organizer/scan.html', event=event, attendees=attendees)
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('organizer_dashboard'))
+
# Routes - Attendee
@app.route('/attendee/dashboard')
@login_required
@@ -3914,10 +5081,13 @@ def attendee_dashboard():
connections=connections, pending_connections=pending_connections,
appointments=appointments)
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('index'))
+
@app.route('/attendee/badge/download')
@login_required
def download_badge():
@@ -3955,10 +5125,13 @@ def download_badge():
download_name=f'badge_{attendee["first_name"]}_{attendee["last_name"]}.pdf'
)
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('attendee_dashboard'))
+
@app.route('/attendee/badge/email', methods=['POST'])
@login_required
def email_badge():
@@ -3998,10 +5171,13 @@ def email_badge():
return redirect(url_for('attendee_dashboard'))
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('attendee_dashboard'))
+
@app.route('/attendee/profile', methods=['GET', 'POST'])
@login_required
def attendee_profile():
@@ -4022,17 +5198,19 @@ def attendee_profile():
if request.method == 'POST':
first_name = request.form.get('first_name', '').strip()
last_name = request.form.get('last_name', '').strip()
+ email = request.form.get('email', '').strip()
organisation = request.form.get('organisation', '').strip()
role = request.form.get('role', '').strip()
introduction = request.form.get('introduction', '').strip()
visible_to_others = request.form.get('visible_to_others') == '1'
+ preferred_language = request.form.get('preferred_language', 'en')
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
- UPDATE attendees SET first_name = %s, last_name = %s, organisation = %s, role = %s, introduction = %s, phone = %s, linkedin = %s, visible_to_others = %s
+ UPDATE attendees SET first_name = %s, last_name = %s, email = %s, organisation = %s, role = %s, introduction = %s, phone = %s, linkedin = %s, visible_to_others = %s, preferred_language = %s
WHERE id = %s
- """, (first_name, last_name, organisation, role, introduction, request.form.get('phone', '').strip(), request.form.get('linkedin', '').strip(), visible_to_others, session['user_id']))
+ """, (first_name, last_name, email, organisation, role, introduction, request.form.get('phone', '').strip(), request.form.get('linkedin', '').strip(), visible_to_others, preferred_language, session['user_id']))
conn.commit()
cursor.close()
conn.close()
@@ -4042,10 +5220,115 @@ def attendee_profile():
return render_template('attendee/profile.html', attendee=attendee)
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('attendee_dashboard'))
+@app.route('/organizer/profile', methods=['GET', 'POST'])
+@login_required
+def organizer_profile():
+ """Organizer profile page."""
+ if session.get('user_type') not in ('organizer', 'breakout_organizer'):
+ flash('Access denied.')
+ return redirect(url_for('index'))
+
+ try:
+ conn = get_db_connection()
+ cursor = conn.cursor(dictionary=True)
+ cursor.execute("SELECT * FROM organizers WHERE id = %s", (session['user_id'],))
+ organizer = cursor.fetchone()
+ cursor.close()
+ conn.close()
+
+ if not organizer:
+ flash('Organizer not found.')
+ return redirect(url_for('index'))
+
+ if request.method == 'POST':
+ name = request.form.get('name', '').strip()
+ email = request.form.get('email', '').strip()
+ preferred_language = request.form.get('preferred_language', 'en')
+
+ if not name or not email:
+ flash('Name and email are required.')
+ return render_template('organizer/profile.html', organizer=organizer)
+
+ conn = get_db_connection()
+ cursor = conn.cursor()
+ cursor.execute("""
+ UPDATE organizers SET name = %s, email = %s, preferred_language = %s
+ WHERE id = %s
+ """, (name, email, preferred_language, session['user_id']))
+ conn.commit()
+ cursor.close()
+ conn.close()
+
+ session['user_name'] = name
+ flash('Profile updated successfully!')
+ return redirect(url_for('organizer_profile'))
+
+ return render_template('organizer/profile.html', organizer=organizer)
+ except Error as e:
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+ return redirect(url_for('organizer_dashboard'))
+
+
+@app.route('/staff/profile', methods=['GET', 'POST'])
+@login_required
+def staff_profile():
+ """Staff profile page."""
+ if session.get('user_type') != 'staff':
+ flash('Access denied.')
+ return redirect(url_for('index'))
+
+ try:
+ conn = get_db_connection()
+ cursor = conn.cursor(dictionary=True)
+ cursor.execute("SELECT s.*, e.name as event_name FROM staff s LEFT JOIN events e ON s.event_id = e.id WHERE s.id = %s", (session['user_id'],))
+ staff_member = cursor.fetchone()
+ cursor.close()
+ conn.close()
+
+ if not staff_member:
+ flash('Staff member not found.')
+ return redirect(url_for('index'))
+
+ event_name = staff_member.get('event_name', '')
+
+ if request.method == 'POST':
+ first_name = request.form.get('first_name', '').strip()
+ last_name = request.form.get('last_name', '').strip()
+ email = request.form.get('email', '').strip()
+ preferred_language = request.form.get('preferred_language', 'en')
+
+ if not first_name or not last_name or not email:
+ flash('First name, last name, and email are required.')
+ return render_template('staff/profile.html', staff=staff_member, event_name=event_name)
+
+ conn = get_db_connection()
+ cursor = conn.cursor()
+ cursor.execute("""
+ UPDATE staff SET first_name = %s, last_name = %s, email = %s, preferred_language = %s
+ WHERE id = %s
+ """, (first_name, last_name, email, preferred_language, session['user_id']))
+ conn.commit()
+ cursor.close()
+ conn.close()
+
+ session['user_name'] = f"{first_name} {last_name}"
+ flash('Profile updated successfully!')
+ return redirect(url_for('staff_profile'))
+
+ return render_template('staff/profile.html', staff=staff_member, event_name=event_name)
+ except Error as e:
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+ return redirect(url_for('staff_dashboard'))
+
+
@app.route('/attendee/photo', methods=['POST'])
@login_required
def upload_photo():
@@ -4060,22 +5343,35 @@ def upload_photo():
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
- if file:
+ if file and allowed_file(file.filename):
+ # Read file content for validation
+ file_content = file.read()
+ try:
+ from PIL import Image
+ img = Image.open(io.BytesIO(file_content))
+ img.verify()
+ except Exception:
+ return jsonify({'error': 'Invalid image file'}), 400
+
# Generate unique filename
- filename = f"{uuid.uuid4()}.jpg"
+ ext = file.filename.rsplit('.', 1)[1].lower()
+ filename = f"{uuid.uuid4()}.{ext}"
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
# Delete old photo if exists
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT profile_picture FROM attendees WHERE id = %s", (session['user_id'],))
- old_photo = cursor.fetchone()['profile_picture']
+ row = cursor.fetchone()
+ old_photo = row['profile_picture'] if row else None
if old_photo:
old_path = os.path.join(app.config['UPLOAD_FOLDER'], old_photo)
if os.path.exists(old_path):
os.remove(old_path)
- file.save(filepath)
+ # Save the file
+ with open(filepath, 'wb') as f:
+ f.write(file_content)
cursor.execute("UPDATE attendees SET profile_picture = %s WHERE id = %s", (filename, session['user_id']))
conn.commit()
@@ -4148,10 +5444,13 @@ def list_attendees():
return render_template('attendee/attendees.html', attendees=attendees, event=event,
search_query=search_query, too_broad=too_broad, min_chars=min_chars)
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('attendee_dashboard'))
+
@app.route('/connections', methods=['POST'])
@login_required
def create_connection():
@@ -4333,10 +5632,13 @@ def connection_requests():
return render_template('attendee/connection_requests.html', requests=requests)
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('attendee_dashboard'))
+
@app.route('/attendee/connection-request/', methods=['POST'])
@login_required
def respond_to_connection(connection_id):
@@ -4477,10 +5779,13 @@ def list_events():
conn.close()
return render_template('index.html', events=events, show_events=True)
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return render_template('index.html')
+
@app.route('/event/register/', methods=['GET', 'POST'])
@app.route('/event/register//', methods=['GET', 'POST'])
def register_event(code, type_code=None):
@@ -4500,7 +5805,8 @@ def register_event(code, type_code=None):
flash('Invalid attendee type.')
return redirect(url_for('index'))
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
return redirect(url_for('index'))
try:
@@ -4520,9 +5826,12 @@ def register_event(code, type_code=None):
flash('Invalid attendee type for this event.')
return redirect(url_for('index'))
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('index'))
+
# Get breakout sessions for the event
try:
conn = get_db_connection()
@@ -4609,7 +5918,7 @@ def register_event(code, type_code=None):
session['pending_registration'] = {
'event_id': event['id'],
'email': email,
- 'password': password,
+ 'password_hash': hash_password(password),
'first_name': first_name,
'last_name': last_name,
'organisation': organisation,
@@ -4627,7 +5936,7 @@ def register_event(code, type_code=None):
return redirect(url_for('payment_page', event_code=event['code']))
# Complete registration directly for free types
- confirmation_token = uuid.uuid4().hex
+ confirmation_token = secrets.token_urlsafe(32)
password_hash = hash_password(password)
attendee_code = generate_attendee_code()
cursor.execute("""
@@ -4667,12 +5976,14 @@ def register_event(code, type_code=None):
session['user_id'] = attendee_id
session['user_type'] = 'attendee'
+ session['user_name'] = f"{first_name} {last_name}"
session['event_id'] = event['id']
flash('Registration successful!')
return redirect(url_for('attendee_personal_page', token=confirmation_token))
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
return render_template('attendee/event_register.html', event=event, sessions=sessions, registered=registered, preselected_type=preselected_type)
@@ -4698,9 +6009,12 @@ def payment_page(code):
flash('Event not found.')
return redirect(url_for('index'))
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('index'))
+
if request.method == 'POST':
# Process payment (simulated - in production, integrate with payment gateway like Stripe)
# For now, we just complete the registration
@@ -4709,8 +6023,8 @@ def payment_page(code):
cursor = conn.cursor(dictionary=True)
# Generate confirmation token
- confirmation_token = uuid.uuid4().hex
- password_hash = hash_password(pending['password'])
+ confirmation_token = secrets.token_urlsafe(32)
+ password_hash = pending['password_hash']
attendee_code = generate_attendee_code()
cursor.execute("""
@@ -4757,12 +6071,14 @@ def payment_page(code):
# Auto-login the attendee
session['user_id'] = attendee_id
session['user_type'] = 'attendee'
+ session['user_name'] = f"{pending['first_name']} {pending['last_name']}"
session['event_id'] = pending['event_id']
flash('Registration and payment successful!')
return redirect(url_for('attendee_personal_page', token=confirmation_token))
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
return render_template('attendee/payment.html', event=event, pending=pending)
@@ -4858,6 +6174,7 @@ def attendee_personal_page(token):
# Note: confirmation_token is kept so the link remains valid indefinitely
session['user_id'] = attendee['id']
session['user_type'] = 'attendee'
+ session['user_name'] = f"{attendee['first_name']} {attendee['last_name']}"
session['event_id'] = attendee['event_id']
# Get all events for this attendee
@@ -4887,10 +6204,13 @@ def attendee_personal_page(token):
return render_template('attendee/personal.html', attendee=attendee, events=events)
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('index'))
+
@app.route('/event/')
def view_event(event_id):
"""View event details."""
@@ -4914,10 +6234,13 @@ def view_event(event_id):
return render_template('attendee/event.html', event=event)
except Error as e:
- flash(f'Database error: {e}')
+ logging.error(f'Database error: {e}')
+ flash('An unexpected error occurred. Please try again.')
+
return redirect(url_for('index'))
+
if __name__ == '__main__':
# Ensure staff_code column exists in organizers and staff tables
ensure_staff_code_column()
@@ -4925,4 +6248,4 @@ if __name__ == '__main__':
ensure_staff_staff_code_column()
ensure_all_staff_have_staff_code()
- app.run(debug=True, host='0.0.0.0', port=5002)
+ app.run(debug=app.config['DEBUG'], host='0.0.0.0', port=int(os.environ.get('PORT', '5002')))
diff --git a/config.py b/config.py
index 98d655c..3c1c3b0 100644
--- a/config.py
+++ b/config.py
@@ -1,3 +1,6 @@
+from dotenv import load_dotenv
+load_dotenv()
+
import os
class Config:
@@ -5,26 +8,31 @@ class Config:
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'
+
+ # Security
+ SECRET_KEY = os.environ.get('SECRET_KEY')
+ SESSION_COOKIE_HTTPONLY = True
+ SESSION_COOKIE_SECURE = os.environ.get('SESSION_COOKIE_SECURE', 'false').lower() == 'true'
+ SESSION_COOKIE_SAMESITE = 'Lax'
# MySQL Database Configuration
- DB_HOST = 'roast.duckdns.org'
- DB_PORT = 33062
- DB_USER = 'root'
- DB_PASSWORD = 'Tiegl!!!111...'
- DB_NAME = 'netevent'
+ DB_HOST = os.environ.get('DB_HOST', 'localhost')
+ DB_PORT = int(os.environ.get('DB_PORT', '3306'))
+ DB_USER = os.environ.get('DB_USER', 'root')
+ DB_PASSWORD = os.environ.get('DB_PASSWORD')
+ DB_NAME = os.environ.get('DB_NAME', 'netevent')
# Dedicated database user (created by init_db.py)
- DB_APP_USER = 'netevent_app'
- DB_APP_PASSWORD = 'netevent_pass_2024'
+ DB_APP_USER = os.environ.get('DB_APP_USER', 'netevent_app')
+ DB_APP_PASSWORD = os.environ.get('DB_APP_PASSWORD')
# 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'
+ MAIL_SERVER = os.environ.get('MAIL_SERVER', 'smtp-relay.brevo.com')
+ MAIL_PORT = int(os.environ.get('MAIL_PORT', '587'))
+ MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'true').lower() == 'true'
+ MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
+ MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
+ MAIL_DEFAULT_SENDER = os.environ.get('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
@@ -33,3 +41,6 @@ class Config:
# Google reCAPTCHA Configuration
RECAPTCHA_SITE_KEY = os.environ.get('RECAPTCHA_SITE_KEY', '')
RECAPTCHA_SECRET_KEY = os.environ.get('RECAPTCHA_SECRET_KEY', '')
+
+ # Debug mode - must be explicitly enabled
+ DEBUG = os.environ.get('FLASK_DEBUG', 'false').lower() == 'true'
diff --git a/init_db.py b/init_db.py
index 7c645be..580c512 100644
--- a/init_db.py
+++ b/init_db.py
@@ -1,16 +1,21 @@
#!/usr/bin/env python3
"""Database initialization script for NetEvent platform."""
+import os
+import sys
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
import mysql.connector
from mysql.connector import Error
+from config import Config
-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'
+DB_HOST = Config.DB_HOST
+DB_PORT = Config.DB_PORT
+DB_USER = Config.DB_USER
+DB_PASSWORD = Config.DB_PASSWORD
+DB_NAME = Config.DB_NAME
+APP_USER = Config.DB_APP_USER
+APP_PASSWORD = Config.DB_APP_PASSWORD
def create_database():
"""Create the database and dedicated user."""
@@ -196,6 +201,46 @@ def create_tables():
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
UNIQUE KEY unique_type_code_per_event (event_id, code)
)
+ """,
+ # Email templates per event
+ """
+ CREATE TABLE IF NOT EXISTS event_email_templates (
+ id INT PRIMARY KEY AUTO_INCREMENT,
+ event_id INT NOT NULL,
+ template_type VARCHAR(50) NOT NULL,
+ subject VARCHAR(500),
+ body TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
+ UNIQUE KEY unique_event_template (event_id, template_type)
+ )
+ """,
+ # Sent custom emails
+ """
+ CREATE TABLE IF NOT EXISTS sent_emails (
+ id INT PRIMARY KEY AUTO_INCREMENT,
+ event_id INT NOT NULL,
+ sender_id INT NOT NULL,
+ sender_type VARCHAR(20) NOT NULL,
+ recipient_type VARCHAR(30) NOT NULL,
+ recipient_id INT DEFAULT NULL,
+ subject VARCHAR(500) NOT NULL,
+ body TEXT NOT NULL,
+ sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE
+ )
+ """,
+ # User communications (links users to received emails)
+ """
+ CREATE TABLE IF NOT EXISTS user_communications (
+ id INT PRIMARY KEY AUTO_INCREMENT,
+ user_type VARCHAR(20) NOT NULL,
+ user_id INT NOT NULL,
+ sent_email_id INT NOT NULL,
+ read_at TIMESTAMP NULL,
+ FOREIGN KEY (sent_email_id) REFERENCES sent_emails(id) ON DELETE CASCADE
+ )
"""
]
@@ -209,6 +254,9 @@ def create_tables():
"ALTER TABLE attendees ADD COLUMN attendee_type_id INT DEFAULT NULL",
"ALTER TABLE organizers ADD COLUMN reset_token VARCHAR(64) DEFAULT NULL",
"ALTER TABLE organizers ADD COLUMN reset_token_expiry DATETIME DEFAULT NULL",
+ "ALTER TABLE staff ADD COLUMN reminder_count INT DEFAULT 0",
+ "ALTER TABLE staff ADD COLUMN reminder_dates JSON DEFAULT NULL",
+ "ALTER TABLE breakout_session_organizers ADD COLUMN presenter_code VARCHAR(10) UNIQUE DEFAULT NULL",
]
try:
diff --git a/static/css/style.css b/static/css/style.css
index 8b05384..c82f38d 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -1,52 +1,173 @@
-/* Reset and Base Styles */
-* {
+/* ===================================================================
+ NetEvents Professional Design System
+ =================================================================== */
+
+/* ----- CSS Custom Properties (Design Tokens) ----- */
+:root {
+ /* Colors - Classy, Professional Palette */
+ --primary-color: #1e3a5f; /* Deep navy - professional, authoritative */
+ --primary-hover: #152a45; /* Darker navy for hover */
+ --primary-light: #dbeafe;
+ --secondary-color: #6b7280; /* Sophisticated slate gray */
+ --secondary-hover: #4b5563;
+ --success-color: #166534; /* Deep forest green - refined */
+ --success-light: #dcfce7;
+ --danger-color: #991b1b; /* Elegant burgundy - not harsh red */
+ --danger-hover: #7f1d1d;
+ --danger-light: #fee2e2;
+ --warning-color: #78350f; /* Warm amber brown */
+ --warning-light: #fef3c7;
+
+ /* Backgrounds - Warm neutrals, not cold gray */
+ --bg-color: #f9fafb; /* Subtle warm off-white */
+ --card-bg: #ffffff;
+ --card-hover-bg: #f9fafb;
+
+ /* Text - Rich, readable */
+ --text-color: #111827; /* Near-black with warmth */
+ --text-muted: #4b5563; /* Medium slate gray */
+ --text-light: #5a6370; /* Medium gray — WCAG AA compliant (4.6:1 on #f9fafb) */
+
+ /* Borders - Subtle definition */
+ --border-color: #e5e7eb;
+ --border-light: #f3f4f6;
+
+ /* Shadows */
+ --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
+ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
+ --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
+
+ /* Spacing Scale (4px base) */
+ --space-1: 0.25rem;
+ --space-2: 0.5rem;
+ --space-3: 0.75rem;
+ --space-4: 1rem;
+ --space-5: 1.25rem;
+ --space-6: 1.5rem;
+ --space-8: 2rem;
+ --space-10: 2.5rem;
+ --space-12: 3rem;
+ --space-16: 4rem;
+
+ /* Border Radius */
+ --radius-sm: 4px;
+ --radius-md: 6px;
+ --radius-lg: 8px;
+ --radius-xl: 12px;
+ --radius-2xl: 16px;
+ --radius-full: 9999px;
+
+ /* Transitions */
+ --transition-fast: 150ms ease;
+ --transition-base: 200ms ease;
+ --transition-slow: 300ms ease;
+
+ /* Typography */
+ --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ --font-size-xs: 0.75rem;
+ --font-size-sm: 0.875rem;
+ --font-size-base: 1rem;
+ --font-size-lg: 1.125rem;
+ --font-size-xl: 1.25rem;
+ --font-size-2xl: 1.5rem;
+ --font-size-3xl: 1.875rem;
+ --font-size-4xl: 2.25rem;
+}
+
+/* ----- Reset & Base ----- */
+*, *::before, *::after {
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;
+html {
+ scroll-behavior: smooth;
}
body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
+ font-family: var(--font-sans);
+ font-size: var(--font-size-base);
line-height: 1.6;
color: var(--text-color);
background-color: var(--bg-color);
min-height: 100vh;
display: flex;
flex-direction: column;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ overflow-x: hidden;
}
+/* Custom selection color */
+::selection {
+ background-color: var(--primary-light);
+ color: var(--primary-color);
+}
+
+/* ----- Typography ----- */
+h1, h2, h3, h4, h5, h6 {
+ font-weight: 600;
+ color: var(--text-color);
+}
+
+h1 { font-size: var(--font-size-3xl); line-height: 1.2; margin-bottom: var(--space-8); font-weight: 700; letter-spacing: -0.03em; }
+h2 { font-size: var(--font-size-2xl); line-height: 1.2; margin-bottom: var(--space-6); margin-top: var(--space-10); letter-spacing: -0.02em; }
+h3 { font-size: var(--font-size-xl); line-height: 1.3; margin-bottom: var(--space-5); margin-top: var(--space-8); font-weight: 600; }
+h4 { font-size: var(--font-size-lg); line-height: 1.3; margin-bottom: var(--space-4); font-weight: 600; }
+h5 { font-size: var(--font-size-base); line-height: 1.4; margin-bottom: var(--space-3); font-weight: 600; }
+h6 { font-size: var(--font-size-xs); line-height: 1.4; margin-bottom: var(--space-2); font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; }
+
+h2:first-child,
+h3:first-child { margin-top: 0; }
+
+p { margin-bottom: var(--space-4); }
+p:last-child { margin-bottom: 0; }
+
+a {
+ color: var(--primary-color);
+ text-decoration: none;
+ transition: color var(--transition-fast);
+}
+
+a:hover {
+ color: var(--primary-hover);
+}
+
+/* ----- Layout ----- */
.container {
max-width: 1200px;
margin: 0 auto;
- padding: 0 20px;
+ padding: 0 var(--space-8);
}
@media (min-width: 1400px) {
.container {
- max-width: none;
- padding: 0 40px;
+ max-width: 1320px;
+ padding: 0 var(--space-12);
}
}
-/* Navigation */
+/* Tablet breakpoint */
+@media (min-width: 768px) and (max-width: 1024px) {
+ .container {
+ padding: 0 var(--space-6);
+ }
+}
+
+@media (max-width: 768px) {
+ .container {
+ padding: 0 var(--space-4);
+ }
+}
+
+/* ----- Navigation ----- */
.navbar {
background-color: var(--card-bg);
- box-shadow: 0 1px 3px rgba(0,0,0,0.1);
- padding: 1rem 0;
- margin-bottom: 2rem;
+ box-shadow: var(--shadow-sm);
+ padding: var(--space-4) 0;
+ margin-bottom: var(--space-8);
}
.sticky-navbar {
@@ -54,89 +175,392 @@ body {
top: 0;
z-index: 1000;
background-color: var(--card-bg);
- box-shadow: 0 1px 3px rgba(0,0,0,0.1);
- padding: 1rem 0;
+ box-shadow: var(--shadow-md);
+ padding: var(--space-4) 0;
}
-.navbar .container {
+.navbar-container {
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
- font-size: 1.5rem;
- font-weight: bold;
+ display: flex;
+ align-items: center;
+ font-size: var(--font-size-xl);
+ font-weight: 800;
color: var(--primary-color);
text-decoration: none;
+ transition: opacity var(--transition-fast);
+}
+
+.logo-icon {
+ margin-right: var(--space-2);
+}
+
+.logo:hover {
+ opacity: 0.9;
+ color: var(--primary-color);
+}
+
+/* Hamburger Menu */
+.hamburger {
+ display: none;
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: var(--space-2);
+ color: var(--text-color);
+}
+
+.hamburger svg {
+ width: 24px;
+ height: 24px;
+ stroke: currentColor;
+ stroke-width: 2;
+ fill: none;
}
.nav-links {
display: flex;
+ align-items: center;
list-style: none;
- gap: 1.5rem;
+ gap: var(--space-2);
}
.nav-links a {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-3) var(--space-4);
color: var(--text-color);
text-decoration: none;
- transition: color 0.2s;
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+ border-radius: var(--radius-lg);
+ transition: all var(--transition-fast);
+ min-height: 44px;
+ letter-spacing: -0.01em;
+}
+
+.nav-links a svg {
+ width: 18px;
+ height: 18px;
+ stroke: currentColor;
+ stroke-width: 2;
+ fill: none;
+}
+
+.nav-links a span {
+ display: inline;
+}
+
+/* Nav User Info */
+.nav-user {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ margin-left: var(--space-4);
+ padding-left: var(--space-4);
+ border-left: 1px solid var(--border-color);
+ text-decoration: none;
+ border-radius: var(--radius-lg);
+ padding: var(--space-2) var(--space-3);
+ transition: background-color var(--transition-fast);
+}
+
+.nav-user:hover {
+ background-color: var(--bg-color);
+}
+
+.nav-user-icon {
+ width: 20px;
+ height: 20px;
+ stroke: var(--primary-color);
+ stroke-width: 2;
+ fill: none;
+}
+
+.nav-user-name {
+ font-size: var(--font-size-sm);
+ font-weight: 600;
+ color: var(--text-color);
+ white-space: nowrap;
+}
+
+.nav-user-type {
+ font-size: var(--font-size-xs);
+ font-weight: 500;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ background: var(--bg-color);
+ padding: 2px 6px;
+ border-radius: var(--radius-sm);
+}
+
+@media (max-width: 768px) {
+ .nav-user {
+ margin-left: auto;
+ border-left: none;
+ padding-left: 0;
+ }
+
+ .nav-user-type {
+ display: none;
+ }
}
.nav-links a:hover {
+ background-color: var(--bg-color);
color: var(--primary-color);
}
-/* Main Content */
-main {
- flex: 1;
- padding-bottom: 2rem;
+.nav-links a:hover svg {
+ stroke: var(--primary-color);
}
-/* Footer */
+/* Mobile Navigation */
+@media (max-width: 768px) {
+ .hamburger {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .nav-links {
+ position: fixed;
+ top: 65px;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: var(--card-bg);
+ flex-direction: column;
+ align-items: stretch;
+ padding: var(--space-4);
+ gap: var(--space-2);
+ transform: translateX(-100%);
+ transition: transform var(--transition-slow);
+ overflow-y: auto;
+ z-index: 999;
+ }
+
+ .nav-links.active {
+ transform: translateX(0);
+ }
+
+ .nav-links a {
+ padding: var(--space-4);
+ font-size: var(--font-size-base);
+ border-bottom: 1px solid var(--border-light);
+ }
+
+ .nav-links a span {
+ display: inline;
+ }
+}
+
+/* Tablet nav adjustments */
+@media (min-width: 769px) and (max-width: 1024px) {
+ .nav-links {
+ gap: var(--space-1);
+ }
+
+ .nav-links a {
+ padding: var(--space-2) var(--space-3);
+ font-size: var(--font-size-sm);
+ }
+
+ .nav-links a svg {
+ width: 16px;
+ height: 16px;
+ }
+}
+
+/* ----- Main Content ----- */
+main {
+ flex: 1;
+ padding-top: var(--space-6);
+ padding-bottom: var(--space-12);
+}
+
+/* ----- Footer ----- */
.footer {
background-color: var(--card-bg);
border-top: 1px solid var(--border-color);
- padding: 1.5rem 0;
+ padding: var(--space-6) 0;
margin-top: auto;
text-align: center;
color: var(--text-muted);
+ font-size: var(--font-size-sm);
}
-/* Flash Messages */
-.flash-messages {
- margin-bottom: 1.5rem;
+.footer-love {
+ margin-top: var(--space-2);
+ font-size: var(--font-size-xs);
+ color: var(--text-light);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-1);
}
-.flash-message {
- padding: 1rem;
- border-radius: 6px;
- margin-bottom: 0.5rem;
- background-color: #fef3c7;
- border-left: 4px solid var(--warning-color);
+.cheese-heart {
+ vertical-align: middle;
+ flex-shrink: 0;
}
-/* Buttons */
+/* ----- Toast Notifications ----- */
+.toast-container {
+ position: fixed;
+ top: var(--space-6);
+ right: var(--space-6);
+ z-index: 9999;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ max-width: 400px;
+}
+
+.toast {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-3);
+ padding: var(--space-4) var(--space-5);
+ background-color: var(--card-bg);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow-lg);
+ border-left: 4px solid var(--primary-color);
+ animation: toastIn 0.3s ease forwards;
+}
+
+.toast.toast-out {
+ animation: toastOut 0.3s ease forwards;
+}
+
+.toast-success {
+ border-left-color: var(--success-color);
+}
+
+.toast-error {
+ border-left-color: var(--danger-color);
+}
+
+.toast-warning {
+ border-left-color: var(--warning-color);
+}
+
+.toast-icon {
+ flex-shrink: 0;
+ width: 20px;
+ height: 20px;
+}
+
+.toast-success .toast-icon { color: var(--success-color); }
+.toast-error .toast-icon { color: var(--danger-color); }
+.toast-warning .toast-icon { color: var(--warning-color); }
+
+.toast-content {
+ flex: 1;
+}
+
+.toast-message {
+ font-size: var(--font-size-sm);
+ color: var(--text-color);
+ line-height: 1.5;
+}
+
+.toast-close {
+ flex-shrink: 0;
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: var(--text-muted);
+ padding: var(--space-1);
+ border-radius: var(--radius-md);
+ transition: all var(--transition-fast);
+}
+
+.toast-close:hover {
+ background-color: var(--bg-color);
+ color: var(--text-color);
+}
+
+.toast-close svg {
+ width: 16px;
+ height: 16px;
+ stroke: currentColor;
+ stroke-width: 2;
+ fill: none;
+}
+
+@keyframes toastIn {
+ from {
+ opacity: 0;
+ transform: translateX(100%);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+@keyframes toastOut {
+ from {
+ opacity: 1;
+ transform: translateX(0);
+ }
+ to {
+ opacity: 0;
+ transform: translateX(100%);
+ }
+}
+
+/* ----- Buttons ----- */
.btn {
- display: inline-block;
- padding: 0.75rem 1.5rem;
- border-radius: 6px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+ padding: var(--space-3) var(--space-6);
+ border-radius: var(--radius-lg);
+ font-family: var(--font-sans);
+ font-size: var(--font-size-sm);
+ font-weight: 500;
text-decoration: none;
- font-weight: 400;
text-align: center;
cursor: pointer;
border: none;
- transition: all 0.2s;
+ transition: all var(--transition-fast);
+ min-height: 44px;
vertical-align: middle;
}
+.btn svg {
+ width: 18px;
+ height: 18px;
+ stroke: currentColor;
+ stroke-width: 2;
+ fill: none;
+}
+
.btn-primary {
background-color: var(--primary-color);
color: white;
+ box-shadow: 0 1px 3px rgba(30, 58, 95, 0.15);
}
.btn-primary:hover {
- background-color: #1d4ed8;
+ background-color: var(--primary-hover);
+ box-shadow: 0 4px 12px rgba(30, 58, 95, 0.2);
+ transform: translateY(-1px);
+ color: white;
+}
+
+.btn-primary:active {
+ transform: translateY(0);
+ box-shadow: 0 1px 3px rgba(30, 58, 95, 0.1);
}
.btn-secondary {
@@ -145,12 +569,13 @@ main {
}
.btn-secondary:hover {
- background-color: #475569;
+ background-color: var(--secondary-hover);
+ color: white;
}
.btn-outline {
background-color: transparent;
- border: 1px solid var(--primary-color);
+ border: 2px solid var(--primary-color);
color: var(--primary-color);
}
@@ -164,241 +589,405 @@ main {
color: white;
}
+.btn-success:hover {
+ background-color: #16a34a;
+ color: white;
+}
+
.btn-danger {
background-color: var(--danger-color);
color: white;
}
+.btn-danger:hover {
+ background-color: var(--danger-hover);
+ color: white;
+}
+
+.btn-ghost {
+ background-color: transparent;
+ color: var(--text-muted);
+}
+
+.btn-ghost:hover {
+ background-color: var(--bg-color);
+ color: var(--text-color);
+}
+
.btn-block {
- display: block;
+ display: flex;
width: 100%;
}
.btn-sm {
- padding: 0.35rem 1rem;
- font-size: 0.875rem;
- line-height: 1.2;
- box-sizing: border-box;
- vertical-align: middle;
+ padding: var(--space-2) var(--space-4);
+ font-size: var(--font-size-xs);
+ min-height: 36px;
}
-/* Hero Section */
-.hero {
- text-align: center;
- padding: 3rem 0;
+/* Reminder Button Tooltip */
+.remind-btn {
+ position: relative;
}
-.hero h1 {
- font-size: 2.5rem;
- margin-bottom: 1rem;
+.remind-btn:hover::after {
+ content: attr(data-reminder-dates);
+ position: absolute;
+ bottom: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ background: var(--text-color);
+ color: var(--card-bg);
+ padding: var(--space-2) var(--space-3);
+ border-radius: var(--radius-md);
+ font-size: var(--font-size-xs);
+ white-space: nowrap;
+ z-index: 100;
+ margin-bottom: var(--space-2);
+ box-shadow: var(--shadow-lg);
}
-.hero-buttons {
- display: flex;
- gap: 1rem;
- justify-content: center;
- margin-top: 2rem;
+.remind-btn:hover::before {
+ content: '';
+ position: absolute;
+ bottom: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ border: 6px solid transparent;
+ border-top-color: var(--text-color);
+ z-index: 100;
+ margin-bottom: -6px;
+}
+
+/* Show "No reminders sent yet" when dates array is empty */
+.remind-btn[data-reminder-dates="[]"]::after {
+ content: "No reminders sent yet";
+}
+
+.btn-lg {
+ padding: var(--space-4) var(--space-8);
+ font-size: var(--font-size-base);
+ min-height: 52px;
+}
+
+/* Button loading state */
+.btn-loading {
+ position: relative;
+ color: transparent !important;
+ pointer-events: none;
+}
+
+.btn-loading::after {
+ content: '';
+ position: absolute;
+ width: 18px;
+ height: 18px;
+ top: 50%;
+ left: 50%;
+ margin-top: -9px;
+ margin-left: -9px;
+ border: 2px solid transparent;
+ border-top-color: currentColor;
+ border-radius: 50%;
+ animation: spin 0.6s linear infinite;
+}
+
+.btn-primary.btn-loading::after {
+ border-top-color: white;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* Focus ring for accessibility - WCAG 2.1 AA compliant */
+.btn:focus-visible,
+input:focus-visible,
+select:focus-visible,
+textarea:focus-visible,
+a:focus-visible,
+.toggle input:focus-visible + label {
+ outline: none;
+ box-shadow: 0 0 0 3px var(--primary-light), 0 0 0 5px var(--primary-color);
+}
+
+/* ----- Cards ----- */
+.card {
+ background-color: var(--card-bg);
+ border-radius: var(--radius-xl);
+ padding: var(--space-8);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+ border: 1px solid var(--border-color);
+ transition: all var(--transition-base);
+}
+
+.card:hover {
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
+ transform: translateY(-2px);
+}
+
+.card-static {
+ background-color: var(--card-bg);
+ border-radius: var(--radius-xl);
+ padding: var(--space-8);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+ border: 1px solid var(--border-color);
}
-/* 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);
+ border-radius: var(--radius-xl);
+ padding: var(--space-8);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+ border: 1px solid var(--border-color);
+ transition: all var(--transition-base);
}
-.event-card h3, .connection-card h4 {
- margin-bottom: 0.5rem;
+.event-card:hover, .connection-card:hover, .attendee-card:hover {
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
+ transform: translateY(-2px);
+}
+
+.event-card h3,
+.connection-card h4,
+.feature-card h3 {
+ color: var(--primary-color);
+ margin-bottom: var(--space-2);
}
.event-date, .event-location {
color: var(--text-muted);
- margin-bottom: 0.5rem;
+ font-size: var(--font-size-sm);
+ margin-bottom: var(--space-2);
}
.event-meta {
display: flex;
- gap: 1rem;
+ flex-wrap: wrap;
+ gap: var(--space-4);
color: var(--text-muted);
- font-size: 0.875rem;
- margin-top: 1rem;
+ font-size: var(--font-size-sm);
+ margin-top: var(--space-4);
}
.event-card-actions {
display: flex;
flex-wrap: wrap;
- gap: 0.5rem;
- margin-top: 1rem;
+ gap: var(--space-3);
+ margin-top: var(--space-4);
}
.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;
+ min-width: 140px;
+ padding: var(--space-3);
+ font-size: var(--font-size-sm);
}
.event-card-actions form {
- display: flex !important;
- width: 160px;
- min-height: 44px;
- box-sizing: border-box;
+ display: inline-block;
}
-.event-card-actions form .btn {
- width: 100%;
- font-size: inherit;
- font-family: inherit;
- font-weight: 500;
- line-height: inherit;
-}
-
-/* Grids */
+/* ----- 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;
+ gap: var(--space-6);
+ margin-top: var(--space-6);
}
-/* Auth Forms */
+@media (min-width: 768px) and (max-width: 1024px) {
+ .events-grid, .features-grid, .connections-grid, .attendees-grid {
+ grid-template-columns: repeat(2, 1fr);
+ gap: var(--space-4);
+ }
+}
+
+@media (max-width: 480px) {
+ .events-grid, .features-grid, .connections-grid, .attendees-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* ----- Auth Forms ----- */
.auth-container {
display: flex;
justify-content: center;
align-items: center;
- min-height: 60vh;
+ min-height: calc(100vh - 200px);
+ padding: var(--space-6) 0;
}
.auth-box {
background-color: var(--card-bg);
- padding: 2rem;
- border-radius: 8px;
- box-shadow: 0 4px 6px rgba(0,0,0,0.1);
+ padding: var(--space-8);
+ border-radius: var(--radius-2xl);
+ box-shadow: var(--shadow-xl);
width: 100%;
- max-width: 400px;
+ max-width: 420px;
}
.auth-box h2 {
text-align: center;
- margin-bottom: 1.5rem;
+ margin-bottom: var(--space-6);
+ color: var(--text-color);
}
.user-type-tabs {
display: flex;
- margin-bottom: 1.5rem;
+ margin-bottom: var(--space-6);
+ background-color: var(--bg-color);
+ border-radius: var(--radius-lg);
+ padding: var(--space-1);
}
.tab-btn {
flex: 1;
- padding: 0.75rem;
- border: 1px solid var(--border-color);
- background: var(--bg-color);
+ padding: var(--space-3);
+ border: none;
+ background: transparent;
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;
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+ color: var(--text-muted);
+ border-radius: var(--radius-md);
+ transition: all var(--transition-fast);
}
.tab-btn.active {
- background-color: var(--primary-color);
- color: white;
- border-color: var(--primary-color);
+ background-color: var(--card-bg);
+ color: var(--primary-color);
+ box-shadow: var(--shadow-sm);
+}
+
+.tab-btn:hover:not(.active) {
+ color: var(--text-color);
}
.auth-footer {
text-align: center;
- margin-top: 1.5rem;
+ margin-top: var(--space-6);
color: var(--text-muted);
+ font-size: var(--font-size-sm);
}
.attendee-link-box {
- background-color: #f0f7ff;
+ background-color: var(--primary-light);
border: 2px solid var(--primary-color);
- border-radius: 8px;
- padding: 1rem;
- margin-top: 1.5rem;
+ border-radius: var(--radius-xl);
+ padding: var(--space-4);
+ margin-top: var(--space-6);
text-align: center;
}
.attendee-link-box p {
margin: 0;
color: var(--text-color);
+ font-size: var(--font-size-sm);
}
.attendee-link-box a {
color: var(--primary-color);
+ font-weight: 500;
}
-/* Forms */
+/* ----- Forms ----- */
.form-group {
- margin-bottom: 1.25rem;
+ margin-bottom: var(--space-6);
}
.form-group label {
display: block;
- margin-bottom: 0.5rem;
- font-weight: 500;
+ margin-bottom: var(--space-3);
+ font-weight: 600;
+ font-size: var(--font-size-sm);
+ color: var(--text-color);
+ letter-spacing: 0.01em;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
- padding: 0.75rem;
+ padding: var(--space-3) var(--space-4);
border: 1px solid var(--border-color);
- border-radius: 6px;
- font-size: 1rem;
- font-family: inherit;
+ border-radius: var(--radius-lg);
+ font-size: var(--font-size-base);
+ font-family: var(--font-sans);
+ color: var(--text-color);
+ background-color: var(--card-bg);
+ transition: all var(--transition-fast);
+}
+
+.form-group input::placeholder,
+.form-group textarea::placeholder {
+ color: var(--text-light);
}
.form-group input:focus,
-.form-group textarea:focus {
+.form-group textarea:focus,
+.form-group select:focus {
outline: none;
border-color: var(--primary-color);
- box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
+ box-shadow: 0 0 0 3px rgba(30, 58, 95, 0.1);
+}
+
+.form-group textarea {
+ min-height: 120px;
+ resize: vertical;
+}
+
+.input-readonly {
+ background-color: var(--bg-color);
+ color: var(--text-muted);
+ cursor: not-allowed;
}
.form-actions {
display: flex;
- gap: 1rem;
- margin-top: 1.5rem;
+ gap: var(--space-4);
+ margin-top: var(--space-6);
}
-/* reCAPTCHA */
-.recaptcha-container {
- margin-bottom: 1rem;
+.form-actions .btn {
+ min-width: 120px;
}
-.recaptcha-container .g-recaptcha {
- transform-origin: left top;
+.form-row {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: var(--space-5);
+}
+
+@media (max-width: 768px) {
+ .form-row {
+ grid-template-columns: 1fr;
+ }
+}
+
+/* Inline forms */
+.inline-form {
+ display: inline-flex;
+ align-items: center;
+}
+
+/* ----- Tables ----- */
+.table-container {
+ overflow-x: auto;
+ background-color: var(--card-bg);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow-md);
}
-/* Tables */
.attendees-table {
width: 100%;
border-collapse: collapse;
- background-color: var(--card-bg);
- border-radius: 8px;
- overflow: hidden;
+ min-width: 600px;
}
.attendees-table th,
.attendees-table td {
- padding: 1rem;
+ padding: var(--space-4);
text-align: left;
border-bottom: 1px solid var(--border-color);
}
@@ -406,396 +995,194 @@ main {
.attendees-table th {
background-color: var(--bg-color);
font-weight: 600;
+ font-size: var(--font-size-xs);
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
}
.attendees-table tr:last-child td {
border-bottom: none;
}
-/* Badges */
+.attendees-table tr:hover td {
+ background-color: var(--bg-color);
+}
+
+@media (min-width: 768px) and (max-width: 1024px) {
+ .attendees-table th,
+ .attendees-table td {
+ padding: var(--space-3);
+ font-size: var(--font-size-sm);
+ }
+}
+
+@media (max-width: 768px) {
+ .table-container {
+ border-radius: var(--radius-lg);
+ }
+
+ .attendees-table {
+ font-size: var(--font-size-sm);
+ }
+
+ .attendees-table th,
+ .attendees-table td {
+ padding: var(--space-3) var(--space-2);
+ }
+}
+
+/* ----- Badges / Pills ----- */
.badge {
- display: inline-block;
- padding: 0.25rem 0.75rem;
- border-radius: 9999px;
- font-size: 0.75rem;
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ padding: var(--space-1) var(--space-3);
+ border-radius: var(--radius-full);
+ font-size: var(--font-size-xs);
font-weight: 500;
}
+.badge svg {
+ width: 12px;
+ height: 12px;
+ stroke: currentColor;
+ stroke-width: 2;
+ fill: none;
+}
+
.badge-success {
- background-color: #dcfce7;
+ background-color: var(--success-light);
color: #166534;
}
.badge-pending {
- background-color: #fef3c7;
+ background-color: var(--warning-light);
color: #92400e;
}
.badge-rejected {
- background-color: #fee2e2;
+ background-color: var(--danger-light);
color: #991b1b;
}
-/* Dashboard */
-.dashboard h1 {
- margin-bottom: 1.5rem;
+.badge-info {
+ background-color: var(--primary-light);
+ color: #1e40af;
+}
+
+.badge-sm {
+ font-size: 10px;
+ padding: 2px 6px;
+}
+
+/* ----- Dashboard ----- */
+.dashboard h1,
+.event-detail .event-header h1,
+.profile-page h1,
+.badges-page .badges-header h1 {
+ padding-bottom: var(--space-4);
+ border-bottom: 2px solid var(--border-color);
+ margin-bottom: var(--space-6);
+}
+
+.dashboard-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--space-4);
+ margin-bottom: var(--space-6);
}
.dashboard-actions {
display: flex;
- gap: 1rem;
- margin-bottom: 2rem;
+ flex-wrap: wrap;
+ gap: var(--space-3);
+ margin-bottom: var(--space-6);
}
.events-list {
display: flex;
flex-direction: column;
- gap: 1rem;
+ gap: var(--space-4);
}
.event-item {
display: flex;
justify-content: space-between;
align-items: center;
+ gap: var(--space-4);
background-color: var(--card-bg);
- padding: 1.5rem;
- border-radius: 8px;
- box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+ padding: var(--space-5);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow-md);
+ transition: all var(--transition-fast);
}
-/* Form Container */
+.event-item:hover {
+ box-shadow: var(--shadow-lg);
+}
+
+.event-item-info {
+ flex: 1;
+}
+
+.event-item-info h3 {
+ margin-bottom: var(--space-2);
+ font-size: var(--font-size-lg);
+}
+
+.event-item-info p {
+ color: var(--text-muted);
+ font-size: var(--font-size-sm);
+ margin-bottom: 0;
+}
+
+.event-item-actions {
+ display: flex;
+ gap: var(--space-2);
+ flex-shrink: 0;
+}
+
+@media (max-width: 768px) {
+ .event-item {
+ flex-direction: column;
+ align-items: stretch;
+ gap: var(--space-4);
+ }
+
+ .event-item-actions {
+ justify-content: flex-start;
+ }
+
+ .dashboard-header {
+ flex-direction: column;
+ align-items: stretch;
+ }
+}
+
+/* ----- Form Container ----- */
.form-container,
.event-staff {
background-color: var(--card-bg);
- padding: 2rem;
- border-radius: 8px;
- box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+ padding: var(--space-8);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow-md);
+ max-width: 800px;
}
-.event-actions {
- display: flex;
- gap: 0.5rem;
+.form-container h1,
+.event-staff h1 {
+ margin-bottom: var(--space-6);
}
-/* 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 {
- padding: 0 var(--page-padding, 2rem);
-}
-
-.attendees-page h1 {
- margin-bottom: 0.5rem;
-}
-
-.event-info-header {
- margin-bottom: 1.5rem;
- padding-bottom: 1rem;
- border-bottom: 1px solid var(--border-color);
-}
-
-.event-info-header h1 {
- margin-bottom: 0.5rem;
-}
-
-.event-meta {
- display: flex;
- gap: 1.5rem;
- color: var(--text-muted);
- font-size: 0.9rem;
- margin-bottom: 0.5rem;
-}
-
-.event-description {
- margin-top: 0.5rem;
- color: var(--text-muted);
-}
-
-.page-subtitle {
- color: var(--text-muted);
- margin-bottom: 2rem;
-}
-
-.attendee-search {
- margin-bottom: 2rem;
-}
-
-.attendee-search form {
- display: flex;
- gap: 1rem;
-}
-
-.attendee-search input {
- flex: 1;
- max-width: 400px;
- padding: 0.75rem;
- border: 1px solid var(--border-color);
- border-radius: 4px;
- font-size: 1rem;
-}
-
-.search-hint {
- color: var(--text-muted);
- font-size: 0.85rem;
- margin-top: 0.5rem;
-}
-
-.search-too-broad {
- color: var(--error-color, #e74c3c);
- padding: 1rem;
- background: #fdf2f2;
- border-radius: 4px;
-}
-
-.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 Detail Page ----- */
.event-header {
display: flex;
flex-direction: column;
- align-items: flex-start;
- gap: 1rem;
- margin-bottom: 2rem;
+ gap: var(--space-4);
+ margin-bottom: var(--space-6);
}
.event-header h1 {
@@ -805,27 +1192,3795 @@ main {
.event-actions {
display: flex;
flex-wrap: wrap;
- gap: 0.5rem;
+ gap: var(--space-3);
+}
+
+/* Event Meta Box */
+.event-meta-box,
+.event-description-box {
+ background-color: var(--card-bg);
+ padding: var(--space-5);
+ border-radius: var(--radius-xl);
+ margin-bottom: var(--space-5);
+}
+
+.event-meta-box p,
+.event-description-box p {
+ margin-bottom: var(--space-2);
+}
+
+.event-meta-box p:last-child,
+.event-description-box p:last-child {
+ margin-bottom: 0;
+}
+
+/* ----- Badges Print Page ----- */
+.badges-page {
+ padding: var(--space-6) 0;
+}
+
+.badges-header {
+ text-align: center;
+ margin-bottom: var(--space-6);
+ padding-bottom: var(--space-4);
+ border-bottom: 2px solid var(--border-color);
+}
+
+.badges-header h1 {
+ margin-bottom: var(--space-2);
+}
+
+.badges-header p {
+ color: var(--text-muted);
+}
+
+.badges-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ gap: var(--space-5);
+}
+
+.badge-card {
+ border: 2px solid #333;
+ border-radius: var(--radius-lg);
+ background: white;
+ overflow: hidden;
+}
+
+.badge-header {
+ background-color: var(--bg-color);
+ padding: var(--space-4);
+ border-bottom: 1px solid var(--border-color);
+}
+
+.badge-header h3 {
+ margin: 0;
+ font-size: var(--font-size-lg);
+ font-weight: 600;
+}
+
+.badge-body {
+ padding: var(--space-4);
+ min-height: 100px;
+}
+
+.badge-org {
+ font-weight: 600;
+ font-size: var(--font-size-base);
+ margin-bottom: var(--space-1);
+}
+
+.badge-role {
+ color: var(--text-muted);
+ font-size: var(--font-size-sm);
+ margin-bottom: var(--space-2);
+}
+
+.badge-intro {
+ font-size: var(--font-size-xs);
+ color: var(--text-muted);
+ font-style: italic;
+}
+
+.badge-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--space-3) var(--space-4);
+ border-top: 1px solid var(--border-color);
+ font-size: var(--font-size-xs);
+ color: var(--text-muted);
+}
+
+.badge-check {
+ display: flex;
+ align-items: center;
+ gap: var(--space-1);
+}
+
+.badge-check.checked-in {
+ color: var(--success-color);
+ font-weight: 600;
+}
+
+.badge-check svg {
+ width: 14px;
+ height: 14px;
+ stroke: currentColor;
+ stroke-width: 2;
+ fill: none;
+}
+
+/* Print styles for badges */
+@media print {
+ body * {
+ visibility: hidden;
+ }
+
+ .badges-page, .badges-page * {
+ visibility: visible;
+ }
+
+ .badges-page {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ }
+
+ .badge-card {
+ page-break-inside: avoid;
+ border: 1px solid #000;
+ }
+
+ .badges-header {
+ display: none;
+ }
+
+ .btn, .no-print {
+ display: none !important;
+ }
+}
+
+/* ----- Profile Page ----- */
+.profile-page h1 {
+ margin-bottom: var(--space-6);
+}
+
+.profile-container {
+ display: grid;
+ grid-template-columns: 280px 1fr;
+ gap: var(--space-8);
+ background-color: var(--card-bg);
+ padding: var(--space-8);
+ border-radius: var(--radius-2xl);
+ box-shadow: var(--shadow-lg);
}
-/* 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;
+ gap: var(--space-6);
+ padding: var(--space-6);
}
}
+
+.profile-photo-section {
+ text-align: center;
+}
+
+.current-photo {
+ margin-bottom: var(--space-4);
+}
+
+.profile-img {
+ width: 160px;
+ height: 160px;
+ border-radius: 50%;
+ object-fit: cover;
+ border: 4px solid var(--border-color);
+}
+
+.no-photo {
+ width: 160px;
+ height: 160px;
+ border-radius: 50%;
+ background-color: var(--bg-color);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin: 0 auto;
+ font-size: 3rem;
+ color: var(--text-muted);
+ border: 4px solid var(--border-color);
+}
+
+.no-photo svg {
+ width: 48px;
+ height: 48px;
+ stroke: currentColor;
+ stroke-width: 1.5;
+ fill: none;
+}
+
+.photo-upload-form {
+ margin-top: var(--space-4);
+}
+
+/* ----- Attendees Page ----- */
+.attendees-page {
+ padding: 0;
+}
+
+.attendees-page h1 {
+ margin-bottom: var(--space-3);
+}
+
+.event-info-header {
+ margin-bottom: var(--space-6);
+ padding-bottom: var(--space-4);
+ border-bottom: 1px solid var(--border-color);
+}
+
+.event-info-header h1 {
+ margin-bottom: var(--space-2);
+}
+
+.event-description {
+ color: var(--text-muted);
+ margin-top: var(--space-2);
+}
+
+.page-subtitle {
+ color: var(--text-muted);
+ margin-bottom: var(--space-6);
+}
+
+.attendee-search {
+ margin-bottom: var(--space-6);
+}
+
+.attendee-search form {
+ display: flex;
+ gap: var(--space-4);
+ flex-wrap: wrap;
+}
+
+.attendee-search input {
+ flex: 1;
+ min-width: 250px;
+ max-width: 400px;
+ padding: var(--space-3) var(--space-4);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ font-size: var(--font-size-base);
+}
+
+.search-hint {
+ color: var(--text-muted);
+ font-size: var(--font-size-sm);
+ margin-top: var(--space-2);
+}
+
+.search-too-broad {
+ color: var(--danger-color);
+ padding: var(--space-4);
+ background: var(--danger-light);
+ border-radius: var(--radius-lg);
+ margin-top: var(--space-3);
+}
+
+.attendee-card {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ padding: var(--space-5);
+}
+
+.attendee-photo {
+ width: 80px;
+ height: 80px;
+ margin-bottom: var(--space-4);
+}
+
+.attendee-photo img {
+ width: 100%;
+ height: 100%;
+ border-radius: 50%;
+ object-fit: cover;
+ border: 3px solid var(--border-color);
+}
+
+.attendee-photo .no-photo {
+ width: 80px;
+ height: 80px;
+ font-size: 1.5rem;
+}
+
+.attendee-info h3 {
+ margin-bottom: var(--space-1);
+}
+
+.attendee-org {
+ font-weight: 600;
+ color: var(--primary-color);
+ font-size: var(--font-size-sm);
+}
+
+.attendee-role {
+ color: var(--text-muted);
+ font-size: var(--font-size-sm);
+}
+
+.attendee-intro {
+ font-size: var(--font-size-xs);
+ color: var(--text-muted);
+ margin-top: var(--space-2);
+ line-height: 1.5;
+}
+
+.attendee-actions {
+ margin-top: var(--space-4);
+}
+
+/* ----- Appointments & Connections ----- */
+.pending-section,
+.connections-section,
+.appointments-section {
+ margin-top: var(--space-8);
+}
+
+.pending-section h2,
+.connections-section h2,
+.appointments-section h2 {
+ margin-bottom: var(--space-4);
+}
+
+.pending-list,
+.appointments-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+}
+
+.pending-item,
+.appointment-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ background-color: var(--card-bg);
+ padding: var(--space-5);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow-md);
+ gap: var(--space-4);
+}
+
+.pending-info,
+.apt-info {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+}
+
+.pending-actions,
+.apt-actions {
+ display: flex;
+ gap: var(--space-2);
+}
+
+.section-actions {
+ margin-bottom: var(--space-4);
+}
+
+.text-muted {
+ color: var(--text-muted);
+ font-size: var(--font-size-sm);
+}
+
+.text-light {
+ color: var(--text-light);
+ font-size: var(--font-size-sm);
+}
+
+.text-sm {
+ font-size: var(--font-size-sm);
+}
+
+.text-xs {
+ font-size: var(--font-size-xs);
+}
+
+.text-uppercase {
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.font-medium {
+ font-weight: 500;
+}
+
+.font-semibold {
+ font-weight: 600;
+}
+
+/* ----- Display Utilities ----- */
+.d-none { display: none; }
+.d-inline { display: inline; }
+.d-block { display: block; }
+.d-flex { display: flex; }
+.d-grid { display: grid; }
+
+/* ----- Vertical Alignment ----- */
+.align-middle { vertical-align: middle; }
+
+/* ----- Code Style ----- */
+.code {
+ background: var(--bg-color);
+ padding: var(--space-1) var(--space-2);
+ border-radius: var(--radius-md);
+ font-size: var(--font-size-xs);
+ font-family: 'SF Mono', Monaco, 'Courier New', monospace;
+}
+
+/* ----- Text Colors ----- */
+.text-success { color: var(--success-color); }
+.text-danger { color: var(--danger-color); }
+.text-warning { color: var(--warning-color); }
+.text-primary { color: var(--primary-color); }
+.text-muted { color: var(--text-muted); }
+
+/* ----- Font Weights ----- */
+.font-medium { font-weight: 500; }
+.font-bold { font-weight: 700; }
+
+/* ----- Spacing Utilities ----- */
+.m-0 { margin: 0; }
+.mt-2 { margin-top: var(--space-2); }
+.mt-4 { margin-top: var(--space-4); }
+.mb-0 { margin-bottom: 0; }
+.ml-2 { margin-left: var(--space-2); }
+.ml-4 { margin-left: var(--space-4); }
+.p-0 { padding: 0; }
+.p-4 { padding: var(--space-4); }
+.px-4 { padding-left: var(--space-4); padding-right: var(--space-4); }
+.py-2 { padding-top: var(--space-2); padding-bottom: var(--space-2); }
+.py-4 { padding-top: var(--space-4); padding-bottom: var(--space-4); }
+
+/* ----- Flexbox Utilities ----- */
+.flex-wrap { flex-wrap: wrap; }
+.items-center { align-items: center; }
+.items-end { align-items: flex-end; }
+.justify-between { justify-content: space-between; }
+.gap-2 { gap: var(--space-2); }
+.gap-3 { gap: var(--space-3); }
+.gap-4 { gap: var(--space-4); }
+
+/* ----- Width Utilities ----- */
+.w-full { width: 100%; }
+.w-auto { width: auto; }
+
+/* ----- Text Alignment ----- */
+.text-center { text-align: center; }
+.text-left { text-align: left; }
+.text-right { text-align: right; }
+
+/* ----- Border ----- */
+.border-top {
+ border-top: 1px solid var(--border-color);
+ padding-top: var(--space-4);
+ margin-top: var(--space-4);
+}
+
+.border-bottom {
+ border-bottom: 1px solid var(--border-color);
+}
+
+/* ----- Overflow ----- */
+.overflow-hidden { overflow: hidden; }
+
+/* ----- Forms ----- */
+select {
+ padding: var(--space-2) var(--space-3);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ font-size: var(--font-size-sm);
+ font-family: var(--font-sans);
+ background-color: var(--card-bg);
+ cursor: pointer;
+}
+
+select:focus {
+ outline: none;
+ border-color: var(--primary-color);
+ box-shadow: 0 0 0 3px rgba(30, 58, 95, 0.1);
+}
+
+/* ----- Table Column Widths ----- */
+.checkbox-col { width: 40px; }
+.th-40 { width: 40px; }
+
+/* ----- Batch Type Select ----- */
+.batch-type-select {
+ padding: var(--space-2) var(--space-3);
+}
+
+/* ----- Add Staff Form ----- */
+.add-staff-form {
+ margin-bottom: var(--space-5);
+ padding: var(--space-5);
+ background: var(--bg-color);
+ border-radius: var(--radius-lg);
+}
+
+.add-staff-form.shown {
+ display: block;
+}
+
+/* ----- Modal Overlay ----- */
+.modal-overlay {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.5);
+ align-items: center;
+ justify-content: center;
+ z-index: 9999;
+}
+
+.modal-overlay.active {
+ display: flex;
+}
+
+.no-data {
+ color: var(--text-muted);
+ font-style: italic;
+ padding: var(--space-6);
+ text-align: center;
+ background-color: var(--card-bg);
+ border-radius: var(--radius-xl);
+}
+
+/* ----- Hero Section ----- */
+.hero {
+ text-align: center;
+ padding: var(--space-16) 0;
+}
+
+.hero h1 {
+ font-size: var(--font-size-4xl);
+ margin-bottom: var(--space-4);
+}
+
+.hero p {
+ color: var(--text-muted);
+ font-size: var(--font-size-lg);
+ max-width: 600px;
+ margin: 0 auto var(--space-8);
+}
+
+.hero-buttons {
+ display: flex;
+ gap: var(--space-4);
+ justify-content: center;
+ flex-wrap: wrap;
+}
+
+@media (max-width: 768px) {
+ .hero {
+ padding: var(--space-10) 0;
+ }
+
+ .hero h1 {
+ font-size: var(--font-size-3xl);
+ }
+
+ .hero-buttons {
+ flex-direction: column;
+ align-items: center;
+ }
+
+ .hero-buttons .btn {
+ width: 100%;
+ max-width: 300px;
+ }
+}
+
+/* ----- Empty States ----- */
+.empty-state {
+ text-align: center;
+ padding: var(--space-12) var(--space-6);
+}
+
+.empty-state-icon {
+ width: 64px;
+ height: 64px;
+ margin: 0 auto var(--space-4);
+ color: var(--text-light);
+}
+
+.empty-state-icon svg {
+ width: 100%;
+ height: 100%;
+ stroke: currentColor;
+ stroke-width: 1.5;
+ fill: none;
+}
+
+.empty-state h3 {
+ color: var(--text-muted);
+ margin-bottom: var(--space-2);
+}
+
+.empty-state p {
+ color: var(--text-light);
+ font-size: var(--font-size-sm);
+}
+
+/* ----- Page Headers ----- */
+.page-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--space-5);
+ margin-bottom: var(--space-8);
+}
+
+.page-header h1 {
+ margin-bottom: 0;
+}
+
+.page-header .page-subtitle {
+ color: var(--text-muted);
+ font-size: var(--font-size-base);
+ font-weight: 400;
+ margin-top: var(--space-2);
+}
+
+.page-label {
+ display: block;
+ font-size: var(--font-size-xs);
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--text-light);
+ margin-bottom: var(--space-3);
+}
+
+/* ----- Alert / Info Boxes ----- */
+.alert {
+ padding: var(--space-4) var(--space-5);
+ border-radius: var(--radius-lg);
+ margin-bottom: var(--space-4);
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-3);
+}
+
+.alert svg {
+ width: 20px;
+ height: 20px;
+ flex-shrink: 0;
+ stroke: currentColor;
+ stroke-width: 2;
+ fill: none;
+}
+
+.alert-info {
+ background-color: var(--primary-light);
+ color: #1e40af;
+}
+
+.alert-success {
+ background-color: var(--success-light);
+ color: #166534;
+}
+
+.alert-warning {
+ background-color: var(--warning-light);
+ color: #92400e;
+}
+
+.alert-danger {
+ background-color: var(--danger-light);
+ color: #991b1b;
+}
+
+/* ----- Modal ----- */
+.modal-overlay {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.5);
+ align-items: center;
+ justify-content: center;
+ z-index: 9999;
+ padding: var(--space-4);
+}
+
+.modal-overlay.active {
+ display: flex;
+ animation: fadeIn 0.2s ease;
+}
+
+.modal {
+ background-color: var(--card-bg);
+ border-radius: var(--radius-2xl);
+ padding: var(--space-6);
+ max-width: 500px;
+ width: 100%;
+ max-height: 90vh;
+ overflow-y: auto;
+ box-shadow: var(--shadow-xl);
+ animation: slideUp 0.3s ease;
+}
+
+.modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: var(--space-5);
+}
+
+.modal-header h2 {
+ margin: 0;
+}
+
+.modal-close {
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: var(--text-muted);
+ padding: var(--space-2);
+ border-radius: var(--radius-md);
+ transition: all var(--transition-fast);
+}
+
+.modal-close:hover {
+ background-color: var(--bg-color);
+ color: var(--text-color);
+}
+
+.modal-close svg {
+ width: 20px;
+ height: 20px;
+ stroke: currentColor;
+ stroke-width: 2;
+ fill: none;
+}
+
+.modal-body {
+ margin-bottom: var(--space-5);
+}
+
+.modal-footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: var(--space-3);
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+@keyframes slideUp {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+/* ----- Tabs / Tab Content ----- */
+.tabs {
+ display: flex;
+ border-bottom: 2px solid var(--border-color);
+ margin-bottom: var(--space-6);
+ gap: var(--space-1);
+}
+
+.tab {
+ padding: var(--space-3) var(--space-5);
+ color: var(--text-muted);
+ font-weight: 500;
+ font-size: var(--font-size-sm);
+ border-bottom: 2px solid transparent;
+ margin-bottom: -2px;
+ cursor: pointer;
+ transition: all var(--transition-fast);
+}
+
+.tab:hover {
+ color: var(--text-color);
+}
+
+.tab.active {
+ color: var(--primary-color);
+ border-bottom-color: var(--primary-color);
+}
+
+.tab-content {
+ display: none;
+}
+
+.tab-content.active {
+ display: block;
+}
+
+/* ----- Progress Indicators ----- */
+.progress-bar {
+ height: 8px;
+ background-color: var(--border-color);
+ border-radius: var(--radius-full);
+ overflow: hidden;
+}
+
+.progress-bar-fill {
+ height: 100%;
+ border-radius: var(--radius-full);
+ transition: width var(--transition-slow);
+}
+
+.progress-bar-fill.low { background-color: var(--danger-color); }
+.progress-bar-fill.medium { background-color: var(--warning-color); }
+.progress-bar-fill.high { background-color: var(--success-color); }
+
+/* ----- Loading Skeleton ----- */
+.skeleton {
+ background: linear-gradient(90deg, var(--border-light) 25%, var(--bg-color) 50%, var(--border-light) 75%);
+ background-size: 200% 100%;
+ animation: shimmer 1.5s infinite;
+ border-radius: var(--radius-md);
+}
+
+@keyframes shimmer {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
+/* ----- Scan Page Styles ----- */
+.scan-page {
+ max-width: 600px;
+ margin: 0 auto;
+ padding: var(--space-6);
+}
+
+.scan-header {
+ text-align: center;
+ margin-bottom: var(--space-6);
+}
+
+.scan-header h1 {
+ margin-bottom: var(--space-2);
+}
+
+.scan-stats {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: var(--space-4);
+ margin-bottom: var(--space-6);
+}
+
+.scan-stat {
+ background-color: var(--card-bg);
+ padding: var(--space-4);
+ border-radius: var(--radius-xl);
+ text-align: center;
+ box-shadow: var(--shadow-md);
+}
+
+.scan-stat-value {
+ font-size: var(--font-size-2xl);
+ font-weight: 700;
+ color: var(--text-color);
+}
+
+.scan-stat-label {
+ font-size: var(--font-size-xs);
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin-top: var(--space-1);
+}
+
+.scan-stat-value.success { color: var(--success-color); }
+.scan-stat-value.error { color: var(--danger-color); }
+.scan-stat-value.warning { color: var(--warning-color); }
+
+.scan-result {
+ background-color: var(--card-bg);
+ padding: var(--space-4);
+ border-radius: var(--radius-xl);
+ margin-bottom: var(--space-4);
+ display: flex;
+ align-items: center;
+ gap: var(--space-4);
+}
+
+.scan-result-icon {
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.scan-result-icon svg {
+ width: 24px;
+ height: 24px;
+ stroke: currentColor;
+ stroke-width: 2;
+ fill: none;
+}
+
+.scan-result-icon.success {
+ background-color: var(--success-light);
+ color: var(--success-color);
+}
+
+.scan-result-icon.error {
+ background-color: var(--danger-light);
+ color: var(--danger-color);
+}
+
+.scan-result-icon.warning {
+ background-color: var(--warning-light);
+ color: var(--warning-color);
+}
+
+.scan-result-info {
+ flex: 1;
+}
+
+.scan-result-name {
+ font-weight: 600;
+ font-size: var(--font-size-lg);
+ margin-bottom: var(--space-1);
+}
+
+.scan-result-meta {
+ color: var(--text-muted);
+ font-size: var(--font-size-sm);
+}
+
+.recent-scans {
+ margin-top: var(--space-6);
+}
+
+.recent-scans h3 {
+ font-size: var(--font-size-sm);
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin-bottom: var(--space-3);
+}
+
+.recent-scan-item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-3);
+ background-color: var(--card-bg);
+ border-radius: var(--radius-lg);
+ margin-bottom: var(--space-2);
+}
+
+.recent-scan-item .check-icon {
+ width: 20px;
+ height: 20px;
+ color: var(--success-color);
+}
+
+.recent-scan-item .check-icon svg {
+ width: 100%;
+ height: 100%;
+ stroke: currentColor;
+ stroke-width: 2;
+ fill: none;
+}
+
+/* ----- Breakout Sessions ----- */
+.session-card {
+ background-color: var(--card-bg);
+ border-radius: var(--radius-xl);
+ padding: var(--space-5);
+ margin-bottom: var(--space-4);
+ box-shadow: var(--shadow-md);
+ border-left: 4px solid var(--primary-color);
+}
+
+.session-card.breakout-full {
+ border-left-color: var(--danger-color);
+}
+
+.session-card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: var(--space-4);
+ margin-bottom: var(--space-3);
+}
+
+.session-card-title {
+ font-size: var(--font-size-lg);
+ font-weight: 600;
+ margin: 0;
+}
+
+.session-card-meta {
+ color: var(--text-muted);
+ font-size: var(--font-size-sm);
+ margin-bottom: var(--space-3);
+}
+
+.session-card-meta span {
+ margin-right: var(--space-4);
+}
+
+.session-card-meta svg {
+ width: 14px;
+ height: 14px;
+ stroke: currentColor;
+ stroke-width: 2;
+ fill: none;
+ vertical-align: middle;
+ margin-right: var(--space-1);
+}
+
+.session-card-description {
+ color: var(--text-muted);
+ font-size: var(--font-size-sm);
+ margin-bottom: var(--space-4);
+}
+
+.session-card-actions {
+ display: flex;
+ gap: var(--space-3);
+}
+
+/* ----- Photo Cropper ----- */
+.photo-cropper {
+ max-width: 400px;
+ margin: 0 auto;
+}
+
+.cropper-container {
+ position: relative;
+ width: 100%;
+ height: 300px;
+ background-color: var(--bg-color);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+ margin-bottom: var(--space-4);
+}
+
+.cropper-container img {
+ position: absolute;
+ max-width: 100%;
+ max-height: 100%;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+}
+
+.crop-controls {
+ display: flex;
+ justify-content: center;
+ gap: var(--space-4);
+ margin-bottom: var(--space-4);
+}
+
+.zoom-control {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.zoom-control input[type="range"] {
+ width: 150px;
+}
+
+/* ----- Toggle Switch ----- */
+.toggle {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ cursor: pointer;
+}
+
+.toggle input {
+ position: absolute;
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.toggle-slider {
+ width: 48px;
+ height: 26px;
+ background-color: var(--border-color);
+ border-radius: var(--radius-full);
+ transition: all var(--transition-fast);
+ position: relative;
+}
+
+.toggle-slider::before {
+ content: '';
+ position: absolute;
+ width: 20px;
+ height: 20px;
+ background-color: white;
+ border-radius: 50%;
+ top: 3px;
+ left: 3px;
+ transition: all var(--transition-fast);
+ box-shadow: var(--shadow-sm);
+}
+
+.toggle input:checked + .toggle-slider {
+ background-color: var(--success-color);
+}
+
+.toggle input:checked + .toggle-slider::before {
+ transform: translateX(22px);
+}
+
+.toggle-label {
+ margin-left: var(--space-3);
+ font-size: var(--font-size-sm);
+ color: var(--text-muted);
+}
+
+/* ----- Pagination ----- */
+.pagination {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: var(--space-2);
+ margin-top: var(--space-6);
+}
+
+.pagination-btn {
+ min-width: 40px;
+ height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--radius-lg);
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+ color: var(--text-muted);
+ background-color: var(--card-bg);
+ border: 1px solid var(--border-color);
+ transition: all var(--transition-fast);
+}
+
+.pagination-btn:hover:not(:disabled) {
+ background-color: var(--bg-color);
+ color: var(--text-color);
+ border-color: var(--text-muted);
+}
+
+.pagination-btn.active {
+ background-color: var(--primary-color);
+ color: white;
+ border-color: var(--primary-color);
+}
+
+.pagination-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.pagination-btn svg {
+ width: 16px;
+ height: 16px;
+ stroke: currentColor;
+ stroke-width: 2;
+ fill: none;
+}
+
+/* ----- Responsive Utilities ----- */
+@media (max-width: 768px) {
+ .hide-mobile { display: none !important; }
+ .container { padding: 0 var(--space-4); }
+ .card { padding: var(--space-4); }
+ .form-container { padding: var(--space-5); }
+}
+
+@media (min-width: 769px) and (max-width: 1024px) {
+ .hide-tablet { display: none !important; }
+}
+
+@media (min-width: 1025px) {
+ .hide-desktop { display: none !important; }
+}
+
+/* ----- Connection Requests Page ----- */
+.connection-requests {
+ max-width: 900px;
+ margin: 0 auto;
+}
+
+.connection-requests h1 {
+ margin-bottom: var(--space-2);
+}
+
+.connection-requests > p {
+ color: var(--text-muted);
+ margin-bottom: var(--space-6);
+}
+
+.requests-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+}
+
+.request-card {
+ display: flex;
+ gap: var(--space-5);
+ padding: var(--space-5);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-xl);
+ background: var(--card-bg);
+ box-shadow: var(--shadow-md);
+ transition: all var(--transition-fast);
+}
+
+.request-card:hover {
+ box-shadow: var(--shadow-lg);
+}
+
+@media (max-width: 768px) {
+ .request-card {
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ }
+}
+
+.request-profile {
+ flex-shrink: 0;
+}
+
+.request-profile .profile-photo {
+ width: 80px;
+ height: 80px;
+ border-radius: 50%;
+ object-fit: cover;
+ border: 3px solid var(--border-color);
+}
+
+.request-profile .profile-photo-placeholder {
+ width: 80px;
+ height: 80px;
+ border-radius: 50%;
+ background: var(--bg-color);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: var(--font-size-xl);
+ font-weight: 700;
+ color: var(--text-muted);
+ border: 3px solid var(--border-color);
+}
+
+.request-info {
+ flex: 1;
+}
+
+.request-info h3 {
+ margin: 0 0 var(--space-1) 0;
+ font-size: var(--font-size-lg);
+}
+
+.request-email {
+ color: var(--text-muted);
+ margin: 0 0 var(--space-1) 0;
+ font-size: var(--font-size-sm);
+}
+
+.request-org {
+ color: var(--secondary-color);
+ margin: 0 0 var(--space-3) 0;
+ font-size: var(--font-size-sm);
+}
+
+.request-intro {
+ font-style: italic;
+ color: var(--text-muted);
+ margin: 0 0 var(--space-3) 0;
+ font-size: var(--font-size-sm);
+ padding-left: var(--space-3);
+ border-left: 3px solid var(--border-color);
+}
+
+.request-time {
+ font-size: var(--font-size-xs);
+ color: var(--text-light);
+ margin: 0;
+}
+
+.request-actions {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ justify-content: center;
+}
+
+@media (max-width: 768px) {
+ .request-actions {
+ flex-direction: row;
+ width: 100%;
+ }
+
+ .request-actions .btn {
+ flex: 1;
+ }
+}
+
+.no-requests {
+ text-align: center;
+ padding: var(--space-10);
+ color: var(--text-muted);
+ background: var(--card-bg);
+ border-radius: var(--radius-xl);
+}
+
+.back-link {
+ margin-top: var(--space-6);
+}
+
+/* ----- Attendee Dashboard ----- */
+.attendee-dashboard {
+ padding: var(--space-6) 0;
+ max-width: 1600px;
+ margin: 0 auto;
+}
+
+.attendee-dashboard h1 {
+ margin-bottom: var(--space-6);
+}
+
+.event-info {
+ margin-bottom: var(--space-8);
+}
+
+.event-info h2 {
+ margin-bottom: var(--space-4);
+}
+
+.connection-org {
+ font-weight: 500;
+ color: var(--primary-color);
+}
+
+.connection-role {
+ color: var(--text-muted);
+ font-size: var(--font-size-sm);
+}
+
+/* ----- Badge Print Styles ----- */
+.badge-header h3 {
+ margin: 0;
+ font-size: 30pt;
+ line-height: 1.1;
+}
+
+.badge-header h3 .first-name,
+.badge-header h3 .last-name {
+ display: inline;
+ line-height: 1.1;
+ font-size: 30pt;
+}
+
+.badge-header h3 .last-name:before {
+ content: ' ';
+}
+
+.badge-qr img {
+ width: 100px;
+ height: 100px;
+}
+
+.header-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-3);
+ justify-content: center;
+ margin-top: var(--space-4);
+}
+
+.badge-event-bar {
+ background: #000;
+ color: #fff;
+ padding: var(--space-2) var(--space-4);
+ font-size: 12pt;
+ text-align: center;
+ margin-top: var(--space-3);
+}
+
+/* ----- Organizer Dashboard ----- */
+.organizer-dashboard {
+ width: 100%;
+ margin: 0 auto;
+ padding: var(--space-6) 0;
+ max-width: 1600px;
+}
+
+.organizer-dashboard h1 {
+ margin-bottom: var(--space-6);
+}
+
+.organizer-dashboard .dashboard-actions {
+ margin-bottom: var(--space-6);
+}
+
+.my-events {
+ margin-top: var(--space-8);
+}
+
+.my-events h2 {
+ margin-bottom: var(--space-4);
+}
+
+.organizer-events-list {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
+ gap: var(--space-5);
+ width: 100%;
+}
+
+@media (max-width: 768px) {
+ .organizer-events-list {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (min-width: 1400px) {
+ .organizer-events-list {
+ grid-template-columns: repeat(3, 1fr);
+ gap: var(--space-6);
+ }
+}
+
+@media (min-width: 1800px) {
+ .organizer-events-list {
+ grid-template-columns: repeat(4, 1fr);
+ }
+}
+
+.organizer-event-item {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ padding: var(--space-5);
+ box-sizing: border-box;
+}
+
+.organizer-event-item .event-item-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ flex-wrap: wrap;
+ gap: var(--space-3);
+ width: 100%;
+ margin-bottom: var(--space-4);
+}
+
+.organizer-event-item .event-item-header h3 {
+ margin: 0;
+ font-size: var(--font-size-lg);
+}
+
+.organizer-event-item .event-info {
+ width: 100%;
+}
+
+.organizer-event-item .event-info p {
+ margin-bottom: var(--space-1);
+ font-size: var(--font-size-sm);
+ color: var(--text-muted);
+}
+
+.organizer-progress-bar {
+ background: var(--border-color);
+ border-radius: var(--radius-full);
+ height: 8px;
+ width: 100%;
+ overflow: hidden;
+ margin-top: var(--space-2);
+}
+
+.organizer-progress-bar .progress-fill {
+ height: 100%;
+ background: var(--success-color);
+ transition: width var(--transition-slow);
+ border-radius: var(--radius-full);
+}
+
+.organizer-progress-bar .progress-fill.warning {
+ background: var(--warning-color);
+}
+
+.organizer-progress-bar .progress-fill.full {
+ background: var(--danger-color);
+}
+
+.organizer-event-capacity {
+ font-size: var(--font-size-sm);
+ color: var(--text-muted);
+ margin-top: var(--space-2);
+}
+
+.organizer-event-section {
+ margin-top: var(--space-4);
+ padding-top: var(--space-4);
+ border-top: 1px solid var(--border-color);
+}
+
+.section-toggle {
+ cursor: pointer;
+ padding: var(--space-3) var(--space-4);
+ background: var(--bg-color);
+ border-radius: var(--radius-lg);
+ margin-bottom: var(--space-3);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ transition: all var(--transition-fast);
+}
+
+.section-toggle:hover {
+ background: var(--border-color);
+}
+
+.section-toggle::after {
+ content: '';
+ width: 0;
+ height: 0;
+ border-left: 5px solid transparent;
+ border-right: 5px solid transparent;
+ border-top: 6px solid var(--text-muted);
+ transition: transform var(--transition-fast);
+}
+
+.section-toggle.collapsed::after {
+ transform: rotate(180deg);
+}
+
+.section-content {
+ padding: var(--space-3) 0;
+ width: 100%;
+}
+
+.section-content.collapsed {
+ display: none;
+}
+
+.item-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ width: 100%;
+}
+
+.list-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--space-3) var(--space-4);
+ background: var(--bg-color);
+ border-radius: var(--radius-lg);
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.list-item-info {
+ flex: 1;
+}
+
+.list-item-info strong {
+ display: block;
+ font-size: var(--font-size-sm);
+}
+
+.list-item-info span {
+ font-size: var(--font-size-xs);
+ color: var(--text-muted);
+}
+
+.list-item-actions {
+ display: flex;
+ gap: var(--space-2);
+}
+
+.pagination-controls {
+ display: flex;
+ justify-content: center;
+ gap: var(--space-2);
+ margin-top: var(--space-4);
+ padding-top: var(--space-4);
+ border-top: 1px solid var(--border-color);
+ flex-wrap: wrap;
+}
+
+.pagination-controls button {
+ min-width: 36px;
+ height: 36px;
+ font-size: var(--font-size-sm);
+}
+
+.no-events {
+ text-align: center;
+ padding: var(--space-10);
+ color: var(--text-muted);
+ background: var(--card-bg);
+ border-radius: var(--radius-xl);
+ box-shadow: var(--shadow-md);
+}
+
+.no-events a {
+ font-weight: 500;
+}
+
+/* ----- Profile Page ----- */
+.profile-page {
+ max-width: 900px;
+ margin: 0 auto;
+ padding: var(--space-6) 0;
+}
+
+.profile-page h1 {
+ margin-bottom: var(--space-6);
+}
+
+.profile-page .profile-container {
+ display: grid;
+ grid-template-columns: 280px 1fr;
+ gap: var(--space-8);
+ background: var(--card-bg);
+ padding: var(--space-8);
+ border-radius: var(--radius-2xl);
+ box-shadow: var(--shadow-lg);
+}
+
+@media (max-width: 768px) {
+ .profile-page .profile-container {
+ grid-template-columns: 1fr;
+ gap: var(--space-6);
+ padding: var(--space-6);
+ }
+}
+
+.profile-photo-box {
+ background: var(--bg-color);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-xl);
+ padding: var(--space-5);
+ text-align: center;
+}
+
+.profile-photo-box h3 {
+ margin-bottom: var(--space-4);
+ color: var(--text-color);
+}
+
+.profile-page .profile-photo-section {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.profile-page .current-photo {
+ width: 150px;
+ height: 150px;
+ margin-bottom: var(--space-4);
+ overflow: hidden;
+ position: relative;
+ background: var(--border-color);
+}
+
+.profile-page .current-photo .profile-img {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ cursor: move;
+}
+
+.profile-page .no-photo {
+ width: 150px;
+ height: 150px;
+ border-radius: 50%;
+ background: var(--bg-color);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-muted);
+ font-size: var(--font-size-sm);
+ border: 3px solid var(--border-color);
+}
+
+.zoom-controls {
+ display: flex;
+ gap: var(--space-2);
+ align-items: center;
+ margin-bottom: var(--space-4);
+}
+
+.zoom-controls span {
+ font-size: var(--font-size-sm);
+ color: var(--text-muted);
+ min-width: 50px;
+ text-align: center;
+}
+
+.switch-group {
+ display: flex;
+ align-items: center;
+ gap: var(--space-4);
+}
+
+.switch-group label {
+ margin-bottom: 0;
+}
+
+.profile-form {
+ width: 100%;
+}
+
+@media (max-width: 768px) {
+ .profile-form .form-actions {
+ flex-direction: column;
+ }
+
+ .profile-form .form-actions .btn {
+ width: 100%;
+ }
+}
+
+/* ----- Event Registration Page ----- */
+.event-registration-page {
+ max-width: 900px;
+ margin: 0 auto;
+ padding: var(--space-6) 0;
+}
+
+.event-registration-page .event-header {
+ text-align: center;
+ margin-bottom: var(--space-8);
+ padding-bottom: var(--space-6);
+ border-bottom: 2px solid var(--border-color);
+}
+
+.event-registration-page .event-header h1 {
+ color: var(--text-color);
+ margin-bottom: var(--space-4);
+}
+
+.registration-content {
+ display: grid;
+ gap: var(--space-8);
+}
+
+.registration-form-section,
+.breakout-sessions-section {
+ background: var(--card-bg);
+ padding: var(--space-6);
+ border-radius: var(--radius-2xl);
+ box-shadow: var(--shadow-lg);
+}
+
+.registration-form-section h2,
+.breakout-sessions-section h2 {
+ margin-bottom: var(--space-4);
+ color: var(--text-color);
+}
+
+.section-intro {
+ color: var(--text-muted);
+ margin-bottom: var(--space-5);
+}
+
+.char-count {
+ display: block;
+ text-align: right;
+ color: var(--text-muted);
+ font-size: var(--font-size-xs);
+ margin-top: var(--space-1);
+}
+
+.login-link {
+ text-align: center;
+ margin-top: var(--space-5);
+ color: var(--text-muted);
+}
+
+.session-card {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--space-5);
+ margin-bottom: var(--space-4);
+ background: var(--bg-color);
+ border-radius: var(--radius-xl);
+ border-left: 4px solid var(--primary-color);
+ gap: var(--space-4);
+}
+
+.session-card.session-full {
+ border-left-color: var(--danger-color);
+ opacity: 0.8;
+}
+
+.session-info {
+ flex: 1;
+}
+
+.session-info h3 {
+ margin: 0 0 var(--space-2) 0;
+ color: var(--text-color);
+}
+
+.session-info p {
+ margin: var(--space-1) 0;
+ color: var(--text-muted);
+ font-size: var(--font-size-sm);
+}
+
+.session-actions {
+ flex-shrink: 0;
+}
+
+.full-label {
+ background: var(--danger-color);
+ color: white;
+ padding: var(--space-2) var(--space-4);
+ border-radius: var(--radius-lg);
+ font-size: var(--font-size-sm);
+}
+
+.no-sessions {
+ text-align: center;
+ color: var(--text-muted);
+ padding: var(--space-8);
+}
+
+.registration-complete {
+ margin-top: var(--space-8);
+ padding: var(--space-5);
+ background: var(--success-light);
+ border-radius: var(--radius-xl);
+ text-align: center;
+}
+
+.registration-complete p {
+ margin-bottom: var(--space-4);
+}
+
+.breakout-sessions-selection {
+ margin-top: var(--space-6);
+ padding-top: var(--space-5);
+ border-top: 2px solid var(--border-color);
+}
+
+.breakout-sessions-selection > label {
+ font-size: var(--font-size-lg);
+ color: var(--text-color);
+ margin-bottom: var(--space-2);
+ display: block;
+}
+
+.selection-hint {
+ color: var(--text-muted);
+ font-size: var(--font-size-sm);
+ margin-bottom: var(--space-4);
+}
+
+.sessions-checkboxes {
+ display: grid;
+ gap: var(--space-3);
+ max-height: 400px;
+ overflow-y: auto;
+ padding: var(--space-4);
+ background: var(--bg-color);
+ border-radius: var(--radius-xl);
+}
+
+.session-checkbox-item {
+ display: flex;
+ align-items: flex-start;
+ padding: var(--space-4);
+ background: var(--card-bg);
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--border-color);
+ transition: all var(--transition-fast);
+}
+
+.session-checkbox-item:hover {
+ border-color: var(--primary-color);
+ box-shadow: var(--shadow-md);
+}
+
+.session-checkbox-item.session-full {
+ opacity: 0.6;
+ background: var(--bg-color);
+}
+
+.session-checkbox-item input[type="checkbox"] {
+ width: 20px;
+ height: 20px;
+ margin-right: var(--space-3);
+ margin-top: var(--space-1);
+ cursor: pointer;
+ accent-color: var(--primary-color);
+}
+
+.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: var(--text-color);
+ margin-bottom: var(--space-1);
+}
+
+.session-checkbox-item label span {
+ display: block;
+ font-size: var(--font-size-sm);
+ color: var(--text-muted);
+}
+
+.session-checkbox-item label .session-time {
+ color: var(--primary-color);
+ font-weight: 500;
+}
+
+.session-checkbox-item label .session-location {
+ color: var(--text-light);
+}
+
+.session-checkbox-item label .session-capacity {
+ margin-top: var(--space-2);
+ font-weight: 500;
+}
+
+.session-checkbox-item label .session-capacity.full {
+ color: var(--danger-color);
+}
+
+@media (max-width: 768px) {
+ .session-card {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .session-actions {
+ margin-left: 0;
+ margin-top: var(--space-3);
+ }
+
+ .session-actions .btn {
+ width: 100%;
+ }
+}
+
+/* ----- Event Detail Page ----- */
+.event-detail {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: var(--space-6);
+}
+
+.event-detail .section-box {
+ background: var(--card-bg);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-2xl);
+ padding: var(--space-8);
+ margin-bottom: var(--space-6);
+}
+
+.event-detail .section-box h2 {
+ margin-top: 0;
+ margin-bottom: var(--space-5);
+ color: var(--text-color);
+}
+
+.event-detail .section-actions {
+ margin-bottom: var(--space-4);
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-3);
+}
+
+.event-detail .staff-table,
+.event-detail .sessions-table,
+.event-detail .types-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.event-detail .staff-table th,
+.event-detail .sessions-table th,
+.event-detail .types-table th,
+.event-detail .staff-table td,
+.event-detail .sessions-table td,
+.event-detail .types-table td {
+ padding: var(--space-3) var(--space-4);
+ text-align: left;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.event-detail .staff-table th,
+.event-detail .sessions-table th,
+.event-detail .types-table th {
+ background: var(--bg-color);
+ font-weight: 600;
+ font-size: var(--font-size-sm);
+ color: var(--text-muted);
+}
+
+.event-detail th.sortable {
+ cursor: pointer;
+ user-select: none;
+ transition: background var(--transition-fast);
+}
+
+.event-detail th.sortable:hover {
+ background: var(--border-color);
+}
+
+.event-detail .sort-icon {
+ margin-left: var(--space-2);
+ opacity: 0.4;
+ display: inline-block;
+ width: 0;
+ height: 0;
+ vertical-align: middle;
+}
+
+.event-detail th.sort-asc .sort-icon {
+ opacity: 1;
+ border-left: 4px solid transparent;
+ border-right: 4px solid transparent;
+ border-bottom: 6px solid var(--text-color);
+}
+
+.event-detail th.sort-desc .sort-icon {
+ opacity: 1;
+ border-left: 4px solid transparent;
+ border-right: 4px solid transparent;
+ border-top: 6px solid var(--text-color);
+}
+
+.event-detail .event-meta-box {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: var(--space-4);
+ background: var(--bg-color);
+ padding: var(--space-5);
+ border-radius: var(--radius-xl);
+ margin-bottom: var(--space-5);
+}
+
+.event-detail .event-meta-box p {
+ margin: var(--space-1) 0;
+}
+
+.no-types, .no-staff, .no-sessions, .no-attendees {
+ color: var(--text-muted);
+ font-style: italic;
+}
+
+@media (min-width: 1400px) {
+ .event-detail .event-meta-box {
+ grid-template-columns: repeat(3, 1fr);
+ }
+}
+
+@media (max-width: 768px) {
+ .event-detail {
+ padding: var(--space-4);
+ }
+
+ .event-detail .section-box {
+ padding: var(--space-4);
+ }
+
+ .event-detail .event-meta-box {
+ grid-template-columns: 1fr;
+ }
+
+ .event-detail .section-actions {
+ flex-direction: column;
+ }
+
+ .event-detail .section-actions .btn {
+ width: 100%;
+ justify-content: center;
+ }
+
+ .staff-table,
+ .sessions-table,
+ .types-table,
+ .attendees-table {
+ display: block;
+ overflow-x: auto;
+ white-space: nowrap;
+ }
+}
+
+/* ----- Batch Select Bar ----- */
+.batch-select-bar {
+ margin-bottom: var(--space-4);
+ padding: var(--space-4);
+ background: var(--bg-color);
+ border-radius: var(--radius-lg);
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-4);
+ align-items: center;
+}
+
+.batch-select-bar input[type="checkbox"] {
+ width: auto;
+ accent-color: var(--primary-color);
+}
+
+.batch-select-bar label {
+ margin: 0;
+ font-weight: 500;
+}
+
+.batch-select-bar select {
+ padding: var(--space-2) var(--space-3);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ font-size: var(--font-size-sm);
+}
+
+/* ----- All Attendee Types Page ----- */
+.all-attendee-types-page h1 {
+ margin-bottom: var(--space-2);
+}
+
+.all-attendee-types-page > p {
+ color: var(--text-muted);
+ margin-bottom: var(--space-6);
+}
+
+.all-attendee-types-page .event-card {
+ background: var(--card-bg);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-xl);
+ padding: var(--space-5);
+ margin-bottom: var(--space-5);
+}
+
+.all-attendee-types-page .event-card h2 {
+ margin-top: 0;
+ margin-bottom: var(--space-4);
+ color: var(--text-color);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--space-3);
+}
+
+.all-attendee-types-page .event-card h2 a {
+ font-size: var(--font-size-sm);
+ font-weight: normal;
+}
+
+.back-link {
+ margin-bottom: var(--space-5);
+ display: inline-block;
+ color: var(--primary-color);
+ font-weight: 500;
+}
+
+.back-link:hover {
+ color: var(--primary-hover);
+}
+
+/* ----- Attendee Types Page ----- */
+.attendee-types-page .section-box {
+ background: var(--card-bg);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-xl);
+ padding: var(--space-8);
+ margin-bottom: var(--space-6);
+}
+
+.attendee-types-page .section-box h2 {
+ margin-top: 0;
+ margin-bottom: var(--space-5);
+ color: var(--text-color);
+}
+
+/* ----- Staff Scan Page ----- */
+.staff-scan-page .scanner-container {
+ background: #000;
+ border-radius: var(--radius-xl);
+ overflow: hidden;
+ min-height: 300px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ margin-bottom: var(--space-5);
+}
+
+.staff-scan-page .qr-reader {
+ width: 100%;
+}
+
+.staff-scan-page .qr-reader video {
+ width: 100% !important;
+ border-radius: var(--radius-xl);
+}
+
+.staff-scan-page .scan-result {
+ text-align: center;
+ padding: var(--space-8) var(--space-4);
+ background: var(--card-bg);
+ width: 100%;
+}
+
+.staff-scan-page .scan-result.hidden {
+ display: none;
+}
+
+.staff-scan-page .result-icon {
+ width: 64px;
+ height: 64px;
+ margin: 0 auto var(--space-4);
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.staff-scan-page .result-icon svg {
+ width: 32px;
+ height: 32px;
+ stroke-width: 2;
+ fill: none;
+}
+
+.staff-scan-page .result-icon.success {
+ background: var(--success-light);
+ color: var(--success-color);
+}
+
+.staff-scan-page .result-icon.error {
+ background: var(--danger-light);
+ color: var(--danger-color);
+}
+
+.staff-scan-page .result-icon.duplicate {
+ background: var(--warning-light);
+ color: var(--warning-color);
+}
+
+.staff-scan-page .stat {
+ text-align: center;
+ flex: 1;
+}
+
+.staff-scan-page .stat-number {
+ display: block;
+ font-size: var(--font-size-3xl);
+ font-weight: 700;
+ color: var(--text-color);
+}
+
+.staff-scan-page .stat-label {
+ font-size: var(--font-size-sm);
+ color: var(--text-muted);
+}
+
+.staff-scan-page .recent-checkins {
+ background: var(--card-bg);
+ border-radius: var(--radius-xl);
+ padding: var(--space-5);
+ border: 1px solid var(--border-color);
+}
+
+.staff-scan-page .recent-checkins h3 {
+ margin: 0 0 var(--space-4) 0;
+ font-size: var(--font-size-base);
+}
+
+.staff-scan-page .recent-checkins ul {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ max-height: 200px;
+ overflow-y: auto;
+}
+
+.staff-scan-page .recent-checkins li {
+ padding: var(--space-3) 0;
+ border-bottom: 1px solid var(--border-light);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.staff-scan-page .recent-checkins li:last-child {
+ border-bottom: none;
+}
+
+.staff-scan-page .attendee-name {
+ font-weight: 500;
+}
+
+.staff-scan-page .checkin-time {
+ color: var(--success-color);
+ font-size: var(--font-size-sm);
+}
+
+/* ----- Payment Page ----- */
+.payment-page {
+ max-width: 600px;
+ margin: 0 auto;
+ padding: var(--space-6) 0;
+}
+
+.payment-page .back-link {
+ display: block;
+ margin-bottom: var(--space-5);
+ color: var(--primary-color);
+ font-weight: 500;
+}
+
+.payment-page .back-link:hover {
+ color: var(--primary-hover);
+}
+
+.payment-box {
+ padding: var(--space-8);
+}
+
+.payment-box h1 {
+ margin-bottom: var(--space-5);
+}
+
+.order-summary {
+ background: var(--bg-color);
+ border-radius: var(--radius-xl);
+ padding: var(--space-5);
+ margin-bottom: var(--space-6);
+}
+
+.order-summary h3 {
+ margin-top: 0;
+ margin-bottom: var(--space-4);
+}
+
+.order-item {
+ display: flex;
+ justify-content: space-between;
+ padding: var(--space-3) 0;
+ border-bottom: 1px solid var(--border-color);
+ font-size: var(--font-size-sm);
+}
+
+.order-item:last-child {
+ border-bottom: none;
+}
+
+.order-total {
+ display: flex;
+ justify-content: space-between;
+ padding-top: var(--space-4);
+ margin-top: var(--space-3);
+ border-top: 2px solid var(--border-color);
+ font-size: var(--font-size-lg);
+ font-weight: 600;
+}
+
+.payment-box .price {
+ color: var(--success-color);
+ font-weight: 600;
+}
+
+.card-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: var(--space-4);
+}
+
+@media (max-width: 480px) {
+ .card-row {
+ grid-template-columns: 1fr;
+ }
+}
+
+.payment-notice {
+ background: var(--primary-light);
+ border: 1px solid var(--primary-color);
+ border-radius: var(--radius-lg);
+ padding: var(--space-4);
+ margin-bottom: var(--space-6);
+ color: var(--primary-color);
+ font-size: var(--font-size-sm);
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-2);
+}
+
+.payment-notice p {
+ margin: 0;
+}
+
+.payment-notice svg {
+ width: 18px;
+ height: 18px;
+ flex-shrink: 0;
+ margin-top: 1px;
+}
+
+.security-note {
+ text-align: center;
+ margin-top: var(--space-4);
+ color: var(--text-muted);
+ font-size: var(--font-size-xs);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+}
+
+.security-note svg {
+ width: 14px;
+ height: 14px;
+ stroke: currentColor;
+ stroke-width: 2;
+ fill: none;
+}
+
+/* ----- Event Staff Page ----- */
+.event-staff {
+ max-width: 1000px;
+ margin: 0 auto;
+ padding: var(--space-6);
+}
+
+.event-staff .staff-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--space-4);
+ margin-bottom: var(--space-6);
+}
+
+.event-staff .staff-header h1 {
+ margin: 0;
+}
+
+.event-staff .add-staff-form,
+.event-staff .staff-list {
+ padding: var(--space-6);
+ margin-bottom: var(--space-5);
+}
+
+.event-staff .add-staff-form h2,
+.event-staff .staff-list h2 {
+ margin-bottom: var(--space-4);
+}
+
+.staff-table th.sortable {
+ cursor: pointer;
+ user-select: none;
+ transition: background var(--transition-fast);
+}
+
+.staff-table th.sortable:hover {
+ background: var(--bg-color);
+}
+
+.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);
+ align-items: center;
+ justify-content: center;
+ z-index: 9999;
+}
+
+.modal-overlay.active {
+ display: flex;
+}
+
+.modal-content {
+ background: var(--card-bg);
+ border-radius: var(--radius-2xl);
+ padding: var(--space-8);
+ max-width: 500px;
+ width: 90%;
+ box-shadow: var(--shadow-xl);
+}
+
+.modal-sm {
+ max-width: 400px;
+}
+
+.modal-content h3 {
+ margin-bottom: var(--space-4);
+}
+
+.modal-content p {
+ margin-bottom: var(--space-6);
+ color: var(--text-muted);
+}
+
+.modal-actions {
+ display: flex;
+ gap: var(--space-3);
+ justify-content: flex-end;
+}
+
+.modal-header {
+ margin-bottom: var(--space-6);
+}
+
+.modal-header h2 {
+ margin: 0 0 var(--space-3) 0;
+}
+
+.modal-header p {
+ margin: 0;
+ color: var(--text-muted);
+}
+
+/* ----- Personal Page ----- */
+.personal-page {
+ max-width: 900px;
+ margin: 0 auto;
+ padding: var(--space-6) 0;
+}
+
+.personal-page h1 {
+ color: var(--text-color);
+ margin-bottom: var(--space-1);
+}
+
+.welcome-subtitle {
+ color: var(--text-muted);
+ margin-bottom: var(--space-6);
+}
+
+.personal-page .event-section {
+ padding: var(--space-6);
+ margin-bottom: var(--space-5);
+}
+
+.personal-page .event-section h2 {
+ color: var(--text-color);
+ margin-bottom: var(--space-4);
+ padding-bottom: var(--space-3);
+ border-bottom: 2px solid var(--primary-color);
+}
+
+.personal-page .event-details {
+ background: var(--bg-color);
+ padding: var(--space-4);
+ border-radius: var(--radius-xl);
+ margin-bottom: var(--space-5);
+}
+
+.personal-page .event-details p {
+ margin: var(--space-2) 0;
+ color: var(--text-muted);
+}
+
+.status-registered {
+ color: var(--success-color);
+ font-weight: 600;
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+}
+
+.status-registered svg {
+ width: 16px;
+ height: 16px;
+ stroke: currentColor;
+ stroke-width: 2;
+ fill: none;
+}
+
+.personal-page h3 {
+ margin: var(--space-5) 0 var(--space-4) 0;
+}
+
+.sessions-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+}
+
+.session-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--space-4);
+ background: var(--bg-color);
+ border-radius: var(--radius-xl);
+ border-left: 4px solid var(--secondary-color);
+ gap: var(--space-4);
+}
+
+.session-item.registered {
+ border-left-color: var(--success-color);
+ background: var(--success-light);
+}
+
+.session-info {
+ flex: 1;
+}
+
+.session-info strong {
+ display: block;
+ color: var(--text-color);
+ margin-bottom: var(--space-1);
+}
+
+.session-info span {
+ display: block;
+ font-size: var(--font-size-sm);
+ color: var(--text-muted);
+}
+
+.session-info .session-time {
+ color: var(--primary-color);
+ font-weight: 500;
+ display: flex;
+ align-items: center;
+ gap: var(--space-1);
+}
+
+.session-info .session-time svg {
+ width: 14px;
+ height: 14px;
+ stroke: currentColor;
+ stroke-width: 2;
+ fill: none;
+}
+
+.session-info .session-location {
+ color: var(--text-light);
+ display: flex;
+ align-items: center;
+ gap: var(--space-1);
+}
+
+.session-info .session-location svg {
+ width: 14px;
+ height: 14px;
+ stroke: currentColor;
+ stroke-width: 2;
+ fill: none;
+}
+
+.session-info .session-description {
+ color: var(--text-muted);
+ margin-top: var(--space-2);
+ font-style: italic;
+}
+
+.session-status {
+ flex-shrink: 0;
+}
+
+.no-sessions {
+ text-align: center;
+ color: var(--text-muted);
+ padding: var(--space-6);
+ background: var(--bg-color);
+ border-radius: var(--radius-xl);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+}
+
+.no-sessions svg {
+ width: 20px;
+ height: 20px;
+ stroke: currentColor;
+ stroke-width: 2;
+ fill: none;
+}
+
+.personal-page .actions {
+ text-align: center;
+ margin-top: var(--space-8);
+}
+
+/* ----- Breakout Sessions Page ----- */
+.breakout-sessions {
+ max-width: 900px;
+ margin: 0 auto;
+ padding: var(--space-6) 0;
+}
+
+.breakout-sessions h1 {
+ margin-bottom: var(--space-6);
+}
+
+.breakout-sessions .back-link {
+ margin-top: var(--space-6);
+ display: block;
+}
+
+/* ----- Change Confirmation Modal ----- */
+.confirm-changes-page .changes-list {
+ background: var(--bg-color);
+ border-radius: var(--radius-xl);
+ padding: var(--space-4);
+ margin-bottom: var(--space-5);
+}
+
+.confirm-changes-page .change-item {
+ padding: var(--space-3) 0;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.confirm-changes-page .change-item:last-child {
+ border-bottom: none;
+}
+
+.confirm-changes-page .change-label {
+ font-weight: 600;
+ color: var(--text-color);
+ margin-bottom: var(--space-2);
+}
+
+.confirm-changes-page .change-values {
+ display: flex;
+ gap: var(--space-3);
+ align-items: center;
+ font-size: var(--font-size-sm);
+}
+
+.confirm-changes-page .change-values .arrow {
+ color: var(--text-muted);
+}
+
+.confirm-changes-page .change-values .arrow svg {
+ width: 16px;
+ height: 16px;
+ stroke: currentColor;
+ stroke-width: 2;
+ fill: none;
+}
+
+.confirm-changes-page .old-value {
+ color: var(--danger-color);
+ text-decoration: line-through;
+ opacity: 0.7;
+}
+
+.confirm-changes-page .new-value {
+ color: var(--success-color);
+ font-weight: 500;
+}
+
+/* ----- Staff Events Dashboard ----- */
+.staff-events-dashboard {
+ max-width: 800px;
+ margin: 0 auto;
+ padding: var(--space-6) 0;
+}
+
+.staff-events-dashboard .staff-header {
+ margin-bottom: var(--space-6);
+}
+
+.staff-events-dashboard .staff-header h1 {
+ margin-bottom: var(--space-1);
+}
+
+.staff-events-dashboard .staff-header p {
+ color: var(--text-muted);
+ margin: 0;
+}
+
+.staff-events-dashboard .section-box {
+ padding: var(--space-8);
+}
+
+.staff-events-dashboard .events-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-5);
+}
+
+.staff-events-dashboard .event-card {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--space-6);
+ gap: var(--space-5);
+}
+
+.staff-events-dashboard .event-info h3 {
+ margin: 0 0 var(--space-2) 0;
+}
+
+.staff-events-dashboard .event-info p {
+ margin: var(--space-1) 0;
+ color: var(--text-muted);
+ font-size: var(--font-size-sm);
+}
+
+.staff-events-dashboard .event-actions {
+ flex-shrink: 0;
+}
+
+.staff-events-dashboard .no-events {
+ text-align: center;
+ color: var(--text-muted);
+ padding: var(--space-8);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-2);
+}
+
+.staff-events-dashboard .no-events svg {
+ width: 20px;
+ height: 20px;
+ stroke: currentColor;
+ stroke-width: 2;
+ fill: none;
+}
+
+@media (max-width: 768px) {
+ .staff-events-dashboard .event-card {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .staff-events-dashboard .event-actions {
+ margin-left: 0;
+ margin-top: var(--space-4);
+ }
+
+ .staff-events-dashboard .event-actions .btn {
+ width: 100%;
+ justify-content: center;
+ }
+}
+
+/* ----- Edit Attendee Type Page ----- */
+.edit-attendee-type-page h1 {
+ margin-bottom: var(--space-5);
+}
+
+/* ----- Communications Page ----- */
+.communications-page .page-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: var(--space-8);
+}
+
+.communications-section {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-6);
+}
+
+.section-header {
+ margin-bottom: var(--space-5);
+}
+
+.section-header h2 {
+ margin-bottom: var(--space-2);
+}
+
+.templates-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ gap: var(--space-5);
+}
+
+.template-card {
+ background: var(--card-bg);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-xl);
+ padding: var(--space-5);
+}
+
+.template-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: var(--space-4);
+}
+
+.template-header h3 {
+ margin: 0;
+}
+
+.template-preview p {
+ margin-bottom: var(--space-2);
+ font-size: var(--font-size-sm);
+}
+
+.template-body {
+ color: var(--text-muted);
+ font-size: var(--font-size-xs);
+ background: var(--bg-color);
+ padding: var(--space-3);
+ border-radius: var(--radius-md);
+ max-height: 80px;
+ overflow: hidden;
+}
+
+.send-email-form .checkbox-label {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) 0;
+ cursor: pointer;
+}
+
+.recipient-list {
+ max-height: 300px;
+ overflow-y: auto;
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-lg);
+ padding: var(--space-4);
+}
+
+/* ----- Attendee Communications ----- */
+.communications-list {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+}
+
+.communication-card {
+ background: var(--card-bg);
+ border: 1px solid var(--border-color);
+ border-radius: var(--radius-xl);
+ padding: var(--space-6);
+}
+
+.communication-header {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: var(--space-3);
+}
+
+.communication-event {
+ font-size: var(--font-size-xs);
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--primary-color);
+}
+
+.communication-date {
+ font-size: var(--font-size-xs);
+ color: var(--text-muted);
+}
+
+.communication-card h3 {
+ margin-bottom: var(--space-3);
+}
+
+.communication-body {
+ color: var(--text-muted);
+ white-space: pre-wrap;
+}
+
+/* ----- Data Table ----- */
+.data-table {
+ width: 100%;
+ border-collapse: collapse;
+}
+
+.data-table th,
+.data-table td {
+ padding: var(--space-3) var(--space-4);
+ text-align: left;
+ border-bottom: 1px solid var(--border-color);
+}
+
+.data-table th {
+ font-weight: 600;
+ font-size: var(--font-size-sm);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-muted);
+}
+
+.data-table tbody tr:hover {
+ background-color: var(--bg-color);
+}
+
+/* ----- Pagination ----- */
+.pagination {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-3);
+ margin-top: var(--space-6);
+ padding: var(--space-4) 0;
+}
+
+.pagination .btn {
+ padding: var(--space-2) var(--space-3);
+}
+
+.pagination .btn.disabled {
+ opacity: 0.5;
+ pointer-events: none;
+}
+
+.pagination-info {
+ color: var(--text-muted);
+ font-size: var(--font-size-sm);
+ padding: 0 var(--space-3);
+}
+
+/* ----- Session Detail Page ----- */
+.session-detail-page {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: var(--space-6);
+}
+
+.session-detail-page .page-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ flex-wrap: wrap;
+ gap: var(--space-6);
+ margin-bottom: var(--space-8);
+}
+
+.session-detail-page .header-content h1 {
+ margin-bottom: var(--space-2);
+}
+
+.session-detail-page .header-content .page-subtitle {
+ color: var(--text-muted);
+ margin-bottom: 0;
+}
+
+.session-detail-page .header-actions {
+ display: flex;
+ gap: var(--space-3);
+ flex-wrap: wrap;
+}
+
+.session-meta-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: var(--space-4);
+ margin-bottom: var(--space-6);
+}
+
+.meta-card {
+ display: flex;
+ align-items: center;
+ gap: var(--space-4);
+ background: var(--card-bg);
+ padding: var(--space-5);
+ border-radius: var(--radius-xl);
+ border: 1px solid var(--border-color);
+ box-shadow: var(--shadow-sm);
+}
+
+.meta-icon {
+ width: 48px;
+ height: 48px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--primary-light);
+ border-radius: var(--radius-lg);
+ color: var(--primary-color);
+}
+
+.meta-icon svg {
+ width: 24px;
+ height: 24px;
+ stroke: currentColor;
+ stroke-width: 2;
+ fill: none;
+}
+
+.meta-content {
+ display: flex;
+ flex-direction: column;
+}
+
+.meta-label {
+ font-size: var(--font-size-xs);
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ font-weight: 600;
+}
+
+.meta-value {
+ font-size: var(--font-size-lg);
+ font-weight: 600;
+ color: var(--text-color);
+}
+
+.session-description-card {
+ background: var(--card-bg);
+ padding: var(--space-6);
+ border-radius: var(--radius-xl);
+ border: 1px solid var(--border-color);
+ margin-bottom: var(--space-6);
+}
+
+.session-description-card h3 {
+ margin-bottom: var(--space-3);
+ color: var(--text-muted);
+ font-size: var(--font-size-sm);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.session-description-card p {
+ color: var(--text-color);
+ margin-bottom: 0;
+}
+
+.session-sections {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-6);
+}
+
+.section-card {
+ background: var(--card-bg);
+ border-radius: var(--radius-xl);
+ padding: var(--space-6);
+ border: 1px solid var(--border-color);
+ box-shadow: var(--shadow-sm);
+}
+
+.section-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--space-4);
+ margin-bottom: var(--space-5);
+ padding-bottom: var(--space-4);
+ border-bottom: 1px solid var(--border-color);
+}
+
+.section-header h2 {
+ margin-bottom: 0;
+ font-size: var(--font-size-lg);
+}
+
+/* Presenters Section */
+.add-presenter-form {
+ background: var(--bg-color);
+ padding: var(--space-5);
+ border-radius: var(--radius-lg);
+ margin-bottom: var(--space-5);
+}
+
+.add-presenter-form .form-row {
+ margin-bottom: var(--space-4);
+}
+
+.presenters-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
+ gap: var(--space-4);
+}
+
+.presenter-card {
+ display: flex;
+ align-items: center;
+ gap: var(--space-4);
+ padding: var(--space-4);
+ background: var(--bg-color);
+ border-radius: var(--radius-lg);
+ border: 1px solid var(--border-color);
+}
+
+.presenter-info {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ flex: 1;
+}
+
+.presenter-avatar {
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ background: var(--primary-color);
+ color: white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 600;
+ font-size: var(--font-size-sm);
+}
+
+.presenter-details h4 {
+ margin: 0 0 var(--space-1) 0;
+ font-size: var(--font-size-sm);
+}
+
+.presenter-details p {
+ margin: 0;
+ font-size: var(--font-size-xs);
+ color: var(--text-muted);
+}
+
+.presenter-qr {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-1);
+}
+
+.qr-code-display {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: var(--space-2) var(--space-3);
+ background: white;
+ border: 2px dashed var(--border-color);
+ border-radius: var(--radius-md);
+}
+
+.qr-label {
+ font-size: 9px;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.qr-code {
+ font-family: 'SF Mono', Monaco, 'Courier New', monospace;
+ font-size: var(--font-size-xs);
+ font-weight: 600;
+ letter-spacing: 0.1em;
+ color: var(--primary-color);
+}
+
+.presenter-actions {
+ display: flex;
+ gap: var(--space-2);
+}
+
+.btn-remove:hover {
+ color: var(--danger-color);
+}
+
+/* Attendees Section */
+.attendees-table {
+ width: 100%;
+}
+
+.attendees-table th {
+ background: var(--bg-color);
+ font-size: var(--font-size-xs);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-muted);
+}
+
+.attendee-name-cell {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+}
+
+.attendee-avatar-sm {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ background: var(--primary-light);
+ color: var(--primary-color);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: var(--font-size-xs);
+ font-weight: 600;
+}
+
+/* ----- Presenter Dashboard ----- */
+.presenter-dashboard {
+ max-width: 1100px;
+ margin: 0 auto;
+ padding: var(--space-6);
+}
+
+.presenter-dashboard .dashboard-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--space-5);
+ margin-bottom: var(--space-8);
+}
+
+.presenter-dashboard .welcome-section h1 {
+ margin-bottom: var(--space-2);
+}
+
+.welcome-subtitle {
+ color: var(--text-muted);
+ margin-bottom: 0;
+}
+
+.presenter-dashboard .my-sessions {
+ margin-top: var(--space-6);
+}
+
+.presenter-dashboard .section-header {
+ margin-bottom: var(--space-6);
+}
+
+.sessions-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
+ gap: var(--space-6);
+}
+
+@media (max-width: 480px) {
+ .sessions-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+.session-card-lg {
+ background: var(--card-bg);
+ border-radius: var(--radius-xl);
+ padding: var(--space-6);
+ border: 1px solid var(--border-color);
+ box-shadow: var(--shadow-md);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-5);
+}
+
+.session-card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: var(--space-4);
+}
+
+.session-card-header h3 {
+ margin: 0;
+ font-size: var(--font-size-xl);
+}
+
+.session-meta-list {
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ gap: var(--space-3);
+}
+
+@media (max-width: 480px) {
+ .session-meta-list {
+ grid-template-columns: 1fr;
+ }
+}
+
+.meta-item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ font-size: var(--font-size-sm);
+ color: var(--text-muted);
+}
+
+.meta-item svg {
+ width: 16px;
+ height: 16px;
+ stroke: currentColor;
+ stroke-width: 2;
+ fill: none;
+ flex-shrink: 0;
+}
+
+.session-scan-section {
+ background: var(--primary-light);
+ border-radius: var(--radius-lg);
+ padding: var(--space-5);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-4);
+ text-align: center;
+}
+
+.scan-prompt {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: var(--space-2);
+ color: var(--primary-color);
+}
+
+.scan-prompt svg {
+ width: 32px;
+ height: 32px;
+ stroke: currentColor;
+ stroke-width: 2;
+ fill: none;
+}
+
+.scan-prompt span {
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+}
+
+.btn-lg {
+ padding: var(--space-4) var(--space-8);
+ font-size: var(--font-size-base);
+}
+
+.session-card-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: var(--space-3);
+ padding-top: var(--space-4);
+ border-top: 1px solid var(--border-color);
+}
+
+.empty-state-card {
+ background: var(--card-bg);
+ border-radius: var(--radius-xl);
+ padding: var(--space-10);
+ text-align: center;
+ border: 1px solid var(--border-color);
+}
+
+.empty-state-card .empty-state-icon {
+ width: 64px;
+ height: 64px;
+ margin: 0 auto var(--space-4);
+ color: var(--text-light);
+}
+
+.empty-state-card .empty-state-icon svg {
+ width: 100%;
+ height: 100%;
+ stroke: currentColor;
+ stroke-width: 1.5;
+ fill: none;
+}
+
+.empty-state-card h3 {
+ color: var(--text-muted);
+ margin-bottom: var(--space-2);
+}
+
+.empty-state-card p {
+ color: var(--text-light);
+ margin-bottom: 0;
+}
+
+/* ----- Presenter Scan Page ----- */
+.presenter-scan-page {
+ max-width: 800px;
+ margin: 0 auto;
+ padding: var(--space-6);
+}
+
+.presenter-scan-page .scan-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ flex-wrap: wrap;
+ gap: var(--space-5);
+ margin-bottom: var(--space-8);
+}
+
+.session-info-header h1 {
+ margin-bottom: var(--space-3);
+}
+
+.session-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-4);
+ color: var(--text-muted);
+ font-size: var(--font-size-sm);
+}
+
+.session-meta span {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.session-meta svg {
+ width: 16px;
+ height: 16px;
+ stroke: currentColor;
+ stroke-width: 2;
+ fill: none;
+}
+
+.presenter-scan-container {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-6);
+}
+
+.scanner-section {
+ background: var(--card-bg);
+ border-radius: var(--radius-xl);
+ padding: var(--space-6);
+ border: 1px solid var(--border-color);
+}
+
+.scanner-box {
+ position: relative;
+ background: #000;
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+ min-height: 300px;
+}
+
+.scanner-box .qr-reader {
+ width: 100%;
+}
+
+.scanner-box .qr-reader video {
+ width: 100%;
+ border-radius: var(--radius-lg);
+}
+
+.scan-result {
+ text-align: center;
+ padding: var(--space-8) var(--space-4);
+ background: var(--card-bg);
+ width: 100%;
+}
+
+.scan-result.hidden {
+ display: none;
+}
+
+.scan-result .result-icon {
+ width: 64px;
+ height: 64px;
+ margin: 0 auto var(--space-4);
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.scan-result .result-icon svg {
+ width: 32px;
+ height: 32px;
+ stroke-width: 2;
+ fill: none;
+}
+
+.scan-result .result-icon.success {
+ background: var(--success-light);
+ color: var(--success-color);
+}
+
+.scan-result .result-icon.warning {
+ background: var(--warning-light);
+ color: var(--warning-color);
+}
+
+.scan-result .result-icon.error {
+ background: var(--danger-light);
+ color: var(--danger-color);
+}
+
+.scan-result h2 {
+ margin-bottom: var(--space-2);
+}
+
+.scan-result p {
+ color: var(--text-muted);
+ margin-bottom: var(--space-5);
+}
+
+.stats-section {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: var(--space-4);
+}
+
+.stat-card {
+ background: var(--card-bg);
+ padding: var(--space-5);
+ border-radius: var(--radius-xl);
+ text-align: center;
+ border: 1px solid var(--border-color);
+}
+
+.stat-card .stat-number {
+ display: block;
+ font-size: var(--font-size-3xl);
+ font-weight: 700;
+ color: var(--text-color);
+}
+
+.stat-card .stat-label {
+ font-size: var(--font-size-xs);
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+}
+
+.stat-card-success {
+ background: var(--success-light);
+ border-color: var(--success-color);
+}
+
+.stat-card-success .stat-number {
+ color: var(--success-color);
+}
+
+.stat-card-warning {
+ background: var(--warning-light);
+ border-color: var(--warning-color);
+}
+
+.attendees-list-section {
+ background: var(--card-bg);
+ border-radius: var(--radius-xl);
+ padding: var(--space-6);
+ border: 1px solid var(--border-color);
+}
+
+.attendees-list-section h3 {
+ margin-bottom: var(--space-4);
+ font-size: var(--font-size-base);
+}
+
+.attendees-scroll-list {
+ max-height: 400px;
+ overflow-y: auto;
+}
+
+.attendees-checkin-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+.attendee-item {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--space-4);
+ border-bottom: 1px solid var(--border-light);
+ transition: all var(--transition-fast);
+}
+
+.attendee-item:last-child {
+ border-bottom: none;
+}
+
+.attendee-item.checked-in {
+ background: var(--success-light);
+}
+
+.attendee-item .attendee-info {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+}
+
+.attendee-item .attendee-avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: var(--primary-light);
+ color: var(--primary-color);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 600;
+ font-size: var(--font-size-sm);
+}
+
+.attendee-item.checked-in .attendee-avatar {
+ background: var(--success-color);
+ color: white;
+}
+
+.attendee-details {
+ display: flex;
+ flex-direction: column;
+}
+
+.attendee-name {
+ font-weight: 500;
+ font-size: var(--font-size-sm);
+}
+
+.attendee-email {
+ font-size: var(--font-size-xs);
+ color: var(--text-muted);
+}
+
+.status-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-1);
+ padding: var(--space-1) var(--space-3);
+ border-radius: var(--radius-full);
+ font-size: var(--font-size-xs);
+ font-weight: 500;
+}
+
+.status-badge svg {
+ width: 14px;
+ height: 14px;
+ stroke: currentColor;
+ stroke-width: 2;
+ fill: none;
+}
+
+.status-checked-in {
+ background: var(--success-light);
+ color: var(--success-color);
+}
+
+.status-pending {
+ background: var(--warning-light);
+ color: var(--warning-color);
+}
+
+.empty-state-item {
+ text-align: center;
+ padding: var(--space-8);
+ color: var(--text-muted);
+}
\ No newline at end of file
diff --git a/static/favicon.svg b/static/favicon.svg
index 6cc9cd5..57b2a9e 100644
--- a/static/favicon.svg
+++ b/static/favicon.svg
@@ -1,9 +1,13 @@
-
\ No newline at end of file
+
diff --git a/static/img/logo.svg b/static/img/logo.svg
new file mode 100644
index 0000000..5521d3d
--- /dev/null
+++ b/static/img/logo.svg
@@ -0,0 +1,18 @@
+
diff --git a/static/js/main.js b/static/js/main.js
index 10bb70b..3357201 100644
--- a/static/js/main.js
+++ b/static/js/main.js
@@ -1,45 +1,186 @@
// NetEvent - Main JavaScript
document.addEventListener('DOMContentLoaded', function() {
- // Auto-hide flash messages after 5 seconds
+ // ----- CSRF Token Injection -----
+ var csrfMeta = document.querySelector('meta[name="csrf-token"]');
+ var csrfToken = csrfMeta ? csrfMeta.getAttribute('content') : '';
+ if (csrfToken) {
+ document.querySelectorAll('form[method="POST"], form[method="post"]').forEach(function(form) {
+ if (!form.querySelector('input[name="_csrf_token"]')) {
+ var input = document.createElement('input');
+ input.type = 'hidden';
+ input.name = '_csrf_token';
+ input.value = csrfToken;
+ form.appendChild(input);
+ }
+ });
+ }
+
+ // ----- Toast Notification System -----
+ const toastContainer = document.getElementById('toast-container');
+
+ function showToast(message, type = 'info', duration = 5000) {
+ const toast = document.createElement('div');
+ toast.className = `toast toast-${type}`;
+
+ const iconMap = {
+ success: 'check-circle',
+ error: 'x-circle',
+ warning: 'alert-triangle',
+ info: 'info'
+ };
+
+ toast.innerHTML = `
+
+
+
+
+
+ `;
+
+ toastContainer.appendChild(toast);
+ lucide.createIcons({ icons: { 'check-circle': lucide.icons['check-circle'], 'x-circle': lucide.icons['x-circle'], 'alert-triangle': lucide.icons['alert-triangle'], 'info': lucide.icons['info'], 'x': lucide.icons['x'] } });
+
+ // Close button handler
+ toast.querySelector('.toast-close').addEventListener('click', () => {
+ dismissToast(toast);
+ });
+
+ // Auto dismiss
+ if (duration > 0) {
+ setTimeout(() => {
+ dismissToast(toast);
+ }, duration);
+ }
+
+ return toast;
+ }
+
+ function dismissToast(toast) {
+ toast.classList.add('toast-out');
+ setTimeout(() => {
+ if (toast.parentNode) {
+ toast.parentNode.removeChild(toast);
+ }
+ }, 300);
+ }
+
+ // Convert existing flash messages to toasts
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);
+ const message = msg.textContent.trim();
+ const typeMatch = message.toLowerCase().match(/\b(success|error|warning|info)\b/);
+ const type = typeMatch ? typeMatch[1] : 'info';
+
+ // Create toast (no auto-dismiss for flash messages, they persist)
+ showToast(message, type, 0);
+
+ // Remove original flash message
+ msg.remove();
});
- // Form validation
+ // Remove flash-messages container if empty
+ const flashContainer = document.querySelector('.flash-messages');
+ if (flashContainer && flashContainer.children.length === 0) {
+ flashContainer.remove();
+ }
+
+ // ----- Hamburger Menu Toggle -----
+ const hamburger = document.querySelector('.hamburger');
+ const navLinks = document.querySelector('.nav-links');
+
+ if (hamburger && navLinks) {
+ hamburger.addEventListener('click', function() {
+ navLinks.classList.toggle('active');
+
+ const isExpanded = navLinks.classList.contains('active');
+ hamburger.setAttribute('aria-expanded', isExpanded);
+
+ // Toggle icon visibility using CSS classes
+ const menuIcon = hamburger.querySelector('.hamburger-icon');
+ const closeIcon = hamburger.querySelector('.hamburger-close-icon');
+
+ if (isExpanded) {
+ menuIcon.classList.add('d-none');
+ closeIcon.classList.remove('d-none');
+ } else {
+ menuIcon.classList.remove('d-none');
+ closeIcon.classList.add('d-none');
+ }
+ });
+
+ // Close menu when clicking a link (for mobile)
+ navLinks.querySelectorAll('a').forEach(link => {
+ link.addEventListener('click', () => {
+ navLinks.classList.remove('active');
+ hamburger.setAttribute('aria-expanded', 'false');
+ hamburger.querySelector('.hamburger-icon').classList.remove('d-none');
+ hamburger.querySelector('.hamburger-close-icon').classList.add('d-none');
+ });
+ });
+
+ // Close menu when clicking outside
+ document.addEventListener('click', function(e) {
+ if (!hamburger.contains(e.target) && !navLinks.contains(e.target)) {
+ navLinks.classList.remove('active');
+ hamburger.setAttribute('aria-expanded', 'false');
+ const menuIcon = hamburger.querySelector('.hamburger-icon');
+ const closeIcon = hamburger.querySelector('.hamburger-close-icon');
+ if (menuIcon) menuIcon.classList.remove('d-none');
+ if (closeIcon) closeIcon.classList.add('d-none');
+ }
+ });
+ }
+
+ // ----- Form Validation with Toast -----
const forms = document.querySelectorAll('form');
forms.forEach(form => {
form.addEventListener('submit', function(e) {
+ // Skip if already handled or has data-no-validate
+ if (e.target.dataset.noValidate) return;
+
const requiredInputs = form.querySelectorAll('[required]');
let isValid = true;
+ let firstInvalid = null;
requiredInputs.forEach(input => {
if (!input.value.trim()) {
isValid = false;
- input.style.borderColor = '#ef4444';
+ input.style.borderColor = 'var(--danger-color)';
+ input.style.boxShadow = '0 0 0 3px var(--danger-light)';
+ if (!firstInvalid) firstInvalid = input;
} else {
input.style.borderColor = '';
+ input.style.boxShadow = '';
}
});
- if (!isValid && !e.target.dataset.noValidate) {
+ if (!isValid) {
e.preventDefault();
- alert('Please fill in all required fields.');
+ showToast('Please fill in all required fields.', 'error');
+ if (firstInvalid) firstInvalid.focus();
}
});
});
- // Photo upload form
+ // ----- Photo Upload Form -----
const photoForm = document.querySelector('.photo-upload-form');
if (photoForm) {
+ const submitBtn = photoForm.querySelector('button[type="submit"]');
+
photoForm.addEventListener('submit', async function(e) {
e.preventDefault();
+ // Add loading state
+ if (submitBtn) {
+ submitBtn.classList.add('btn-loading');
+ submitBtn.disabled = true;
+ }
+
const formData = new FormData(this);
try {
@@ -51,23 +192,38 @@ document.addEventListener('DOMContentLoaded', function() {
const data = await response.json();
if (data.success) {
+ showToast('Photo updated successfully!', 'success');
// Reload page to show new photo
- location.reload();
+ setTimeout(() => location.reload(), 1000);
} else {
- alert(data.error || 'Error uploading photo');
+ showToast(data.error || 'Error uploading photo', 'error');
+ if (submitBtn) {
+ submitBtn.classList.remove('btn-loading');
+ submitBtn.disabled = false;
+ }
}
} catch (error) {
- alert('Error uploading photo');
+ showToast('Error uploading photo', 'error');
+ if (submitBtn) {
+ submitBtn.classList.remove('btn-loading');
+ submitBtn.disabled = false;
+ }
}
});
}
- // Connection request buttons
+ // ----- Connection Request Buttons -----
const connectForms = document.querySelectorAll('.connect-form');
connectForms.forEach(form => {
form.addEventListener('submit', async function(e) {
e.preventDefault();
+ const submitBtn = this.querySelector('button[type="submit"]');
+ if (submitBtn) {
+ submitBtn.classList.add('btn-loading');
+ submitBtn.disabled = true;
+ }
+
const formData = new FormData(this);
try {
@@ -81,13 +237,164 @@ document.addEventListener('DOMContentLoaded', function() {
if (data.success) {
const card = this.closest('.attendee-card');
const actionsDiv = card.querySelector('.attendee-actions');
- actionsDiv.innerHTML = 'Request Pending';
+ actionsDiv.innerHTML = ' Request Pending';
+ lucide.createIcons();
+
+ showToast('Connection request sent!', 'success');
} else {
- alert(data.error || 'Error sending connection request');
+ showToast(data.error || 'Error sending connection request', 'error');
+ if (submitBtn) {
+ submitBtn.classList.remove('btn-loading');
+ submitBtn.disabled = false;
+ }
}
} catch (error) {
- alert('Error sending connection request');
+ showToast('Error sending connection request', 'error');
+ if (submitBtn) {
+ submitBtn.classList.remove('btn-loading');
+ submitBtn.disabled = false;
+ }
}
});
});
-});
+
+ // ----- Button Loading States on Form Submit -----
+ document.querySelectorAll('form').forEach(form => {
+ form.addEventListener('submit', function(e) {
+ // Skip if already loading or novalidate
+ if (this.dataset.noValidate) return;
+
+ const submitBtn = this.querySelector('button[type="submit"]:not([data-no-loading])');
+ if (submitBtn && !submitBtn.classList.contains('btn-loading')) {
+ submitBtn.classList.add('btn-loading');
+ submitBtn.disabled = true;
+ }
+ });
+ });
+
+ // ----- Toggle Switch Initialization -----
+ const toggles = document.querySelectorAll('.toggle input');
+ toggles.forEach(toggle => {
+ toggle.addEventListener('change', function() {
+ const form = this.closest('form');
+ if (form) {
+ form.submit();
+ }
+ });
+ });
+
+ // ----- Modal Close on Escape -----
+ document.addEventListener('keydown', function(e) {
+ if (e.key === 'Escape') {
+ const modals = document.querySelectorAll('.modal-overlay');
+ modals.forEach(modal => {
+ const closeBtn = modal.querySelector('.modal-close');
+ if (closeBtn) closeBtn.click();
+ });
+ }
+ });
+
+ // ----- Copy Link Utility (shared across templates) -----
+ window.copyLink = function(url) {
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(url).then(function() {
+ showToast('Link copied!', 'success');
+ }).catch(function() {
+ fallbackCopy(url);
+ });
+ } else {
+ fallbackCopy(url);
+ }
+ };
+
+ window.fallbackCopy = function(url) {
+ var textArea = document.createElement('textarea');
+ textArea.value = url;
+ textArea.style.position = 'fixed';
+ textArea.style.left = '-9999px';
+ document.body.appendChild(textArea);
+ textArea.select();
+ try {
+ document.execCommand('copy');
+ showToast('Link copied!', 'success');
+ } catch (err) {
+ showToast('Failed to copy link.', 'error');
+ }
+ document.body.removeChild(textArea);
+ };
+
+ // ----- Smooth scroll for anchor links -----
+ document.querySelectorAll('a[href^="#"]').forEach(anchor => {
+ anchor.addEventListener('click', function(e) {
+ const targetId = this.getAttribute('href');
+ if (targetId === '#') return;
+
+ const target = document.querySelector(targetId);
+ if (target) {
+ e.preventDefault();
+ target.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+ });
+ });
+
+ // ----- Custom Confirmation Modal (replaces native confirm()) -----
+ const confirmModal = document.getElementById('confirm-modal');
+ const confirmTitle = document.getElementById('confirm-title');
+ const confirmMessage = document.getElementById('confirm-message');
+ const confirmOk = document.getElementById('confirm-ok');
+ const confirmCancel = document.getElementById('confirm-cancel');
+ let confirmCallback = null;
+
+ function showConfirm(message, callback, title) {
+ confirmMessage.textContent = message;
+ confirmTitle.textContent = title || 'Confirm';
+ confirmModal.style.display = 'flex';
+ confirmCallback = callback;
+ confirmOk.focus();
+ }
+
+ function hideConfirm() {
+ confirmModal.style.display = 'none';
+ confirmCallback = null;
+ }
+
+ if (confirmOk) {
+ confirmOk.addEventListener('click', function() {
+ if (confirmCallback) confirmCallback();
+ hideConfirm();
+ });
+ }
+
+ if (confirmCancel) {
+ confirmCancel.addEventListener('click', hideConfirm);
+ }
+
+ if (confirmModal) {
+ confirmModal.addEventListener('click', function(e) {
+ if (e.target === confirmModal) hideConfirm();
+ });
+ }
+
+ // Intercept data-confirm on forms and buttons
+ document.addEventListener('click', function(e) {
+ const target = e.target.closest('[data-confirm]');
+ if (!target) return;
+
+ const message = target.getAttribute('data-confirm');
+ if (!message) return;
+
+ e.preventDefault();
+ showConfirm(message, function() {
+ if (target.tagName === 'FORM') {
+ target.submit();
+ } else if (target.tagName === 'A') {
+ window.location.href = target.getAttribute('href');
+ } else {
+ // For buttons, remove data-confirm and re-click
+ target.removeAttribute('data-confirm');
+ target.click();
+ target.setAttribute('data-confirm', message);
+ }
+ });
+ }, true);
+});
\ No newline at end of file
diff --git a/templates/attendee/attendees.html b/templates/attendee/attendees.html
index e8f30ca..50d72d7 100644
--- a/templates/attendee/attendees.html
+++ b/templates/attendee/attendees.html
@@ -11,8 +11,8 @@
{% if event.location %}
{{ event.location }}
{% endif %}
- {% if event.start_date %}
- {{ event.start_date|format_date }}
+ {% if event.start_time %}
+ {{ event.start_time|localized_date }}
{% endif %}
{% if event.description %}
@@ -42,11 +42,11 @@
{% if att.profile_picture %}
{% else %}
- {{ att.first_name[0] }}{{ att.last_name[0] }}
+ {{ att.first_name[0] }}{{ att.last_name[0] }}
{% endif %}
- {{ att.first_name }} {{ att.last_name }}
+ {{ att.first_name }} {{ att.last_name }}
{{ att.organisation if att.organisation else '' }}
{{ att.role if att.role else '' }}
{% if att.introduction %}
diff --git a/templates/attendee/breakout_sessions.html b/templates/attendee/breakout_sessions.html
index 86348ea..0a3b0b5 100644
--- a/templates/attendee/breakout_sessions.html
+++ b/templates/attendee/breakout_sessions.html
@@ -2,16 +2,6 @@
{% block title %}{{ 'breakout_sessions'|t }} - {{ event.name }}{% endblock %}
-{% block extra_styles %}
-
-{% endblock %}
-
{% block content %}
{{ 'breakout_sessions'|t }} - {{ event.name }}
@@ -64,7 +54,7 @@
{% endif %}
@@ -84,12 +74,16 @@ document.querySelectorAll('.rsvp-btn').forEach(btn => {
if (data.success) {
location.reload();
} else {
- alert(data.error || '{{ "error_occurred"|t }}');
+ showToast(data.error || '{{ "error_occurred"|t }}', 'error');
}
} catch (error) {
- alert('{{ "error_processing_request"|t }}');
+ showToast('{{ "error_processing_request"|t }}', 'error');
}
});
});
+
+document.addEventListener('DOMContentLoaded', function() {
+ lucide.createIcons();
+});
{% endblock %}
\ No newline at end of file
diff --git a/templates/attendee/communications.html b/templates/attendee/communications.html
new file mode 100644
index 0000000..c04be6f
--- /dev/null
+++ b/templates/attendee/communications.html
@@ -0,0 +1,36 @@
+{% extends "base.html" %}
+
+{% block title %}{{ 'communications'|t }} - NetEvents{% endblock %}
+
+{% block content %}
+
+
+ {{ 'my_messages'|t }}
+
+
+ {% if emails %}
+
+ {% for email in emails %}
+
+
+ {{ email.event_name }}
+ {{ email.sent_at|localized_date }}
+
+ {{ email.subject }}
+ {{ email.body }}
+
+ {% endfor %}
+
+ {% else %}
+
+ {{ 'no_messages_yet'|t }}
+
+ {% endif %}
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/templates/attendee/connection_requests.html b/templates/attendee/connection_requests.html
index 36a81e4..24a2a33 100644
--- a/templates/attendee/connection_requests.html
+++ b/templates/attendee/connection_requests.html
@@ -25,11 +25,11 @@
{% if req.profile_picture %}
{% else %}
- {{ req.first_name[0] }}{{ req.last_name[0] }}
+ {{ req.first_name[0] }}{{ req.last_name[0] }}
{% endif %}
- {{ req.first_name }} {{ req.last_name }}
+ {{ req.first_name }} {{ req.last_name }}
{{ req.email }}
{{ req.organisation if req.organisation else '' }}{% if req.role %} - {{ req.role }}{% endif %}
{% if req.introduction %}
@@ -38,8 +38,8 @@
{{ 'requested'|t }} {{ req.created_at|localized_date if req.created_at else 'recently' }}
-
-
+
+
{% endfor %}
@@ -70,85 +70,12 @@ document.querySelectorAll('.respond-btn').forEach(btn => {
if (data.success) {
location.reload();
} else {
- alert(data.error || 'Error processing request');
+ showToast(data.error || 'Error processing request', 'error');
}
} catch (error) {
- alert('Error processing request');
+ showToast('Error processing request', 'error');
}
});
});
-
-
{% endblock %}
\ No newline at end of file
diff --git a/templates/attendee/dashboard.html b/templates/attendee/dashboard.html
index 3d3fcf3..40a27c5 100644
--- a/templates/attendee/dashboard.html
+++ b/templates/attendee/dashboard.html
@@ -3,14 +3,7 @@
{% block title %}{{ 'attendee_dashboard'|t }} - NetEvents{% endblock %}
{% block content %}
-
-
+
{{ 'attendee_dashboard'|t }}
{% if event %}
@@ -26,10 +19,10 @@
@@ -53,8 +46,8 @@
{% for conn in connections %}
- {{ conn.first_name }} {{ conn.last_name }}
- {{ conn.organisation if conn.organisation else '' }}
+ {{ conn.first_name }} {{ conn.last_name }}
+ {{ conn.organisation if conn.organisation else '' }}
{{ conn.role if conn.role else '' }}
{% endfor %}
@@ -77,9 +70,9 @@
{% if apt.requester_id == session.user_id %}
- {{ 'with'|t }} {{ apt.target_first_name }} {{ apt.target_last_name }}
+ {{ 'with'|t }} {{ apt.target_first_name }} {{ apt.target_last_name }}
{% else %}
- {{ 'with'|t }} {{ apt.requester_first_name }} {{ apt.requester_last_name }}
+ {{ 'with'|t }} {{ apt.requester_first_name }} {{ apt.requester_last_name }}
{% endif %}
{{ 'time'|t }}: {{ apt.appointment_time|localized_date if apt.appointment_time else 'TBD' }}
@@ -106,4 +99,4 @@
{% endif %}
-{% endblock %}
+{% endblock %}
\ No newline at end of file
diff --git a/templates/attendee/event_register.html b/templates/attendee/event_register.html
index dbe89d5..84682c9 100644
--- a/templates/attendee/event_register.html
+++ b/templates/attendee/event_register.html
@@ -36,12 +36,12 @@
{{ 'register_as_attendee'|t }}
{% if preselected_type %}
-
- {{ 'registration_type'|t }}: {{ preselected_type.name }}
+
+ {{ 'registration_type'|t }}: {{ preselected_type.name }}
{% if preselected_type.price and preselected_type.price > 0 %}
- {{ preselected_type.price|format_currency }}
+ {{ preselected_type.price|format_currency }}
{% else %}
- {{ 'free'|t }}
+ {{ 'free'|t }}
{% endif %}
@@ -91,7 +91,7 @@
-
+
@@ -182,275 +182,6 @@
-
-
{% endblock %}
diff --git a/templates/attendee/personal.html b/templates/attendee/personal.html
index 7ab07e3..70e6bd1 100644
--- a/templates/attendee/personal.html
+++ b/templates/attendee/personal.html
@@ -8,12 +8,12 @@
{{ 'registration_confirmed'|t }}
{% for event in events %}
-
+
{{ event.name }}
{{ 'date'|t }}: {{ event.start_time|localized_date if event.start_time else 'TBD' }}
{{ 'location'|t }}: {{ event.location }}
- {{ 'status'|t }}: {{ 'registered'|t }}
+ {{ 'status'|t }}: {{ 'registered'|t }}
{{ 'breakout_sessions'|t }}
@@ -23,179 +23,36 @@
{{ session.name }}
- {{ session.start_time|localized_date('%H:%M') if session.start_time else '' }} - {{ session.end_time|localized_date('%H:%M') if session.end_time else '' }}
- {{ session.location }}
+ {{ session.start_time|localized_date('%H:%M') if session.start_time else '' }} - {{ session.end_time|localized_date('%H:%M') if session.end_time else '' }}
+ {{ session.location }}
{% if session.description %}
{{ session.description }}
{% endif %}
{% if session.my_rsvp_status == 'registered' %}
- {{ 'registered'|t }}
+ {{ 'registered'|t }}
{% else %}
- {{ 'not_registered'|t }}
+ {{ 'not_registered'|t }}
{% endif %}
{% endfor %}
{% else %}
- {{ 'no_breakout_sessions'|t }}
+ {{ 'no_breakout_sessions'|t }}
{% endif %}
{% endfor %}
-{% endblock %}
-{% block extra_styles %}
-
+
{% endblock %}
\ No newline at end of file
diff --git a/templates/attendee/profile.html b/templates/attendee/profile.html
index 77357f7..7893347 100644
--- a/templates/attendee/profile.html
+++ b/templates/attendee/profile.html
@@ -2,61 +2,30 @@
{% block title %}{{ 'profile'|t }} - NetEvents{% endblock %}
-{% block extra_styles %}
-
-{% endblock %}
-
{% block content %}
{{ 'my_profile'|t }}
-
+
{{ 'profile_photo'|t }}
{% if attendee.profile_picture %}
{% else %}
-
- {{ 'no_photo'|t }}
+
+
{% endif %}
-
+
-
+
100%
-
-
+
+
@@ -67,14 +36,21 @@
@@ -26,109 +26,6 @@
-
-