Add connection review page with optional message on connection requests

- New GET route and template for reviewing individual connection requests
- Optional personal message field when sending connection requests
- Message visible to recipient on review page and connection list
- Dashboard "Review" button links to per-connection review page
- QR scan flow shows message prompt before sending request
- Fix accepted connections query: include both sent and received
- Fix url_for('uploaded_file') -> url_for('static', 'uploads/')
- Fix request.json -> request.is_json to avoid 415 on form POST
- Remove card from DOM on approve/reject instead of page reload

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 08:28:21 +00:00
parent 3c26f1310e
commit 009b7d896b
9 changed files with 579 additions and 64 deletions
+98 -34
View File
@@ -192,6 +192,7 @@ ENGLISH_TRANSLATIONS = {
'time': 'Time', 'time': 'Time',
'organisation': 'Organisation', 'organisation': 'Organisation',
'role': 'Role', 'role': 'Role',
'introduction': 'Introduction',
'role_profession': 'Role / Profession', 'role_profession': 'Role / Profession',
'phone': 'Phone', 'phone': 'Phone',
'linkedin': 'LinkedIn', 'linkedin': 'LinkedIn',
@@ -267,9 +268,20 @@ ENGLISH_TRANSLATIONS = {
'save_profile': 'Save Profile', 'save_profile': 'Save Profile',
'profile_updated': 'Profile updated successfully', 'profile_updated': 'Profile updated successfully',
'connection_requests': 'Connection Requests', 'connection_requests': 'Connection Requests',
'connection_requests_sent': 'Connection Requests Sent',
'connection_request_pending': 'Connection Request Pending',
'my_connections': 'My Connections', 'my_connections': 'My Connections',
'send_connection_request': 'Send Connection Request', 'send_connection_request': 'Send Connection Request',
'connection_sent': 'Connection request sent', 'connection_sent': 'Connection request sent',
'review': 'Review',
'review_connection_request': 'Review Connection Request',
'back_to_connection_requests': 'Back to Connection Requests',
'request_pending': 'Request Pending',
'connected': 'Connected',
'incoming_connection_requests': 'Incoming Connection Requests',
'no_connections_yet': 'No connections yet.',
'find_attendees': 'Find Attendees',
'view_all_incoming_requests': 'View all incoming requests',
'accept': 'Accept', 'accept': 'Accept',
'reject': 'Reject', 'reject': 'Reject',
'pending': 'Pending', 'pending': 'Pending',
@@ -411,6 +423,11 @@ ENGLISH_TRANSLATIONS = {
'specific_staff': 'Specific Staff', 'specific_staff': 'Specific Staff',
'select_recipients': 'Select Recipients', 'select_recipients': 'Select Recipients',
'message': 'Message', 'message': 'Message',
'optional_message': 'Add a personal message (optional)',
'message_placeholder': 'Would be great to connect...',
'send_request': 'Send Request',
'scanned_successfully': 'Scanned Successfully',
'scan_add_message_hint': 'Add an optional message to your connection request.',
'send_email': 'Send Email', 'send_email': 'Send Email',
'edit_template': 'Edit Template', 'edit_template': 'Edit Template',
'subject': 'Subject', 'subject': 'Subject',
@@ -5042,16 +5059,21 @@ def attendee_dashboard():
cursor.execute("SELECT * FROM attendees WHERE id = %s", (session['user_id'],)) cursor.execute("SELECT * FROM attendees WHERE id = %s", (session['user_id'],))
attendee = cursor.fetchone() attendee = cursor.fetchone()
# Get connections # Get accepted connections (both sent and received)
cursor.execute(""" cursor.execute("""
SELECT c.*, a.first_name, a.last_name, a.organisation, a.role SELECT c.*, a.first_name, a.last_name, a.organisation, a.role
FROM connections c FROM connections c
JOIN attendees a ON c.connected_attendee_id = a.id JOIN attendees a ON c.connected_attendee_id = a.id
WHERE c.attendee_id = %s AND c.status = 'accepted' WHERE c.attendee_id = %s AND c.status = 'accepted'
""", (session['user_id'],)) UNION
SELECT c.*, a.first_name, a.last_name, a.organisation, a.role
FROM connections c
JOIN attendees a ON c.attendee_id = a.id
WHERE c.connected_attendee_id = %s AND c.status = 'accepted'
""", (session['user_id'], session['user_id']))
connections = cursor.fetchall() connections = cursor.fetchall()
# Get pending connections # Get pending connections (incoming requests from others)
cursor.execute(""" cursor.execute("""
SELECT c.*, a.first_name, a.last_name, a.organisation, a.role SELECT c.*, a.first_name, a.last_name, a.organisation, a.role
FROM connections c FROM connections c
@@ -5060,6 +5082,15 @@ def attendee_dashboard():
""", (session['user_id'],)) """, (session['user_id'],))
pending_connections = cursor.fetchall() pending_connections = cursor.fetchall()
# Get outgoing pending connections (requests sent by this attendee)
cursor.execute("""
SELECT c.*, a.first_name, a.last_name, a.organisation, a.role
FROM connections c
JOIN attendees a ON c.connected_attendee_id = a.id
WHERE c.attendee_id = %s AND c.status = 'pending'
""", (session['user_id'],))
sent_pending_connections = cursor.fetchall()
# Get appointments # Get appointments
cursor.execute(""" cursor.execute("""
SELECT ap.*, a.first_name as requester_first_name, a.last_name as requester_last_name, SELECT ap.*, a.first_name as requester_first_name, a.last_name as requester_last_name,
@@ -5083,6 +5114,7 @@ def attendee_dashboard():
return render_template('attendee/dashboard.html', event=event, attendee=attendee, return render_template('attendee/dashboard.html', event=event, attendee=attendee,
connections=connections, pending_connections=pending_connections, connections=connections, pending_connections=pending_connections,
sent_pending_connections=sent_pending_connections,
appointments=appointments) appointments=appointments)
except Error as e: except Error as e:
logging.error(f'Database error: {e}') logging.error(f'Database error: {e}')
@@ -5443,20 +5475,31 @@ def list_attendees():
event = cursor.fetchone() event = cursor.fetchone()
if len(search_query) >= min_chars: if len(search_query) >= min_chars:
# Search in first_name, last_name, and organisation # Split into words, search each word across first_name, last_name, and organisation (AND logic)
search_pattern = f'%{search_query}%' words = search_query.split()
cursor.execute(""" word_conditions = []
params = [session['user_id'], session['event_id'], session['user_id']]
for word in words:
pattern = f'%{word}%'
word_conditions.append(
'(LOWER(a.first_name) LIKE LOWER(%s) OR LOWER(a.last_name) LIKE LOWER(%s) OR LOWER(a.organisation) LIKE LOWER(%s))'
)
params.extend([pattern, pattern, pattern])
query = """
SELECT a.*, SELECT a.*,
(SELECT status FROM connections WHERE attendee_id = %s AND connected_attendee_id = a.id) as my_status (SELECT status FROM connections WHERE attendee_id = %s AND connected_attendee_id = a.id) as my_status
FROM attendees a FROM attendees a
WHERE a.event_id = %s AND a.id != %s WHERE a.event_id = %s AND a.id != %s
AND a.visible_to_others = TRUE AND a.visible_to_others = TRUE
AND (a.first_name LIKE %s OR a.last_name LIKE %s OR a.organisation LIKE %s) AND (""" + ' AND '.join(word_conditions) + ')'
""", (session['user_id'], session['event_id'], session['user_id'], search_pattern, search_pattern, search_pattern))
cursor.execute(query, tuple(params))
attendees = cursor.fetchall() attendees = cursor.fetchall()
# Check if more than 2 attendees found # Check if more than 5 attendees found
if len(attendees) > 2: if len(attendees) > 5:
too_broad = True too_broad = True
attendees = [] attendees = []
else: else:
@@ -5487,6 +5530,8 @@ def create_connection():
if not connected_id: if not connected_id:
return jsonify({'error': 'Attendee ID required'}), 400 return jsonify({'error': 'Attendee ID required'}), 400
message = request.form.get('message', '').strip() or None
try: try:
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
@@ -5502,9 +5547,9 @@ def create_connection():
return jsonify({'error': 'Connection already exists'}), 400 return jsonify({'error': 'Connection already exists'}), 400
cursor.execute(""" cursor.execute("""
INSERT INTO connections (attendee_id, connected_attendee_id, status) INSERT INTO connections (attendee_id, connected_attendee_id, status, message)
VALUES (%s, %s, 'pending') VALUES (%s, %s, 'pending', %s)
""", (session['user_id'], connected_id)) """, (session['user_id'], connected_id, message))
conn.commit() conn.commit()
cursor.close() cursor.close()
conn.close() conn.close()
@@ -5570,6 +5615,7 @@ def scan_request_connection():
if not scanned_id: if not scanned_id:
return jsonify({'error': 'Scanned attendee ID required'}), 400 return jsonify({'error': 'Scanned attendee ID required'}), 400
message = (request.json.get('message', '') if request.json else request.form.get('message', '')).strip() or None
scanned_id = int(scanned_id) scanned_id = int(scanned_id)
# Can't connect to yourself # Can't connect to yourself
@@ -5608,9 +5654,9 @@ def scan_request_connection():
else: else:
# Rejected - allow new request # Rejected - allow new request
cursor.execute(""" cursor.execute("""
UPDATE connections SET status = 'pending', attendee_id = %s, connected_attendee_id = %s UPDATE connections SET status = 'pending', attendee_id = %s, connected_attendee_id = %s, message = %s
WHERE id = %s WHERE id = %s
""", (session['user_id'], scanned_id, existing['id'])) """, (session['user_id'], scanned_id, message, existing['id']))
conn.commit() conn.commit()
cursor.close() cursor.close()
conn.close() conn.close()
@@ -5618,9 +5664,9 @@ def scan_request_connection():
else: else:
# Create new connection request # Create new connection request
cursor.execute(""" cursor.execute("""
INSERT INTO connections (attendee_id, connected_attendee_id, status) INSERT INTO connections (attendee_id, connected_attendee_id, status, message)
VALUES (%s, %s, 'pending') VALUES (%s, %s, 'pending', %s)
""", (session['user_id'], scanned_id)) """, (session['user_id'], scanned_id, message))
conn.commit() conn.commit()
cursor.close() cursor.close()
conn.close() conn.close()
@@ -5664,25 +5710,22 @@ def connection_requests():
@app.route('/attendee/connection-request/<int:connection_id>', methods=['POST']) @app.route('/attendee/connection-request/<int:connection_id>', methods=['GET', 'POST'])
@login_required @login_required
def respond_to_connection(connection_id): def respond_to_connection(connection_id):
"""Approve or reject a connection request.""" """Show connection request details and handle approve/reject."""
if session.get('user_type') != 'attendee': if session.get('user_type') != 'attendee':
return jsonify({'error': 'Access denied'}), 403 flash('Access denied.')
return redirect(url_for('index'))
action = request.json.get('action') if request.json else request.form.get('action')
if action not in ['approve', 'reject']:
return jsonify({'error': 'Invalid action'}), 400
try: try:
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
# Verify this request is for the current user # Fetch the connection request
cursor.execute(""" cursor.execute("""
SELECT c.*, a.first_name, a.last_name, a.profile_picture, SELECT c.*, a.first_name, a.last_name, a.profile_picture,
a.email, a.organisation, a.role, a.introduction a.email, a.organisation, a.role, a.introduction, a.phone, a.linkedin
FROM connections c FROM connections c
JOIN attendees a ON c.attendee_id = a.id JOIN attendees a ON c.attendee_id = a.id
WHERE c.id = %s AND c.connected_attendee_id = %s AND c.status = 'pending' WHERE c.id = %s AND c.connected_attendee_id = %s AND c.status = 'pending'
@@ -5692,23 +5735,44 @@ def respond_to_connection(connection_id):
if not connection: if not connection:
cursor.close() cursor.close()
conn.close() conn.close()
return jsonify({'error': 'Connection request not found'}), 404 flash('Connection request not found.')
return redirect(url_for('connection_requests'))
# GET request: show the review page
if request.method == 'GET':
cursor.close()
conn.close()
return render_template('attendee/connection_review.html', connection=connection)
# POST request: approve or reject
if request.is_json:
data = request.get_json(silent=True) or {}
action = data.get('action')
else:
action = request.form.get('action')
if action not in ['approve', 'reject']:
cursor.close()
conn.close()
flash('Invalid action.')
return redirect(url_for('respond_to_connection', connection_id=connection_id))
if action == 'approve': if action == 'approve':
cursor.execute("UPDATE connections SET status = 'accepted' WHERE id = %s", (connection_id,)) cursor.execute("UPDATE connections SET status = 'accepted' WHERE id = %s", (connection_id,))
conn.commit()
cursor.close()
conn.close()
return jsonify({'success': True, 'status': 'accepted'})
else: else:
cursor.execute("UPDATE connections SET status = 'rejected' WHERE id = %s", (connection_id,)) cursor.execute("UPDATE connections SET status = 'rejected' WHERE id = %s", (connection_id,))
conn.commit() conn.commit()
cursor.close() cursor.close()
conn.close() conn.close()
return jsonify({'success': True, 'status': 'rejected'})
if request.is_json:
return jsonify({'success': True, 'status': 'accepted' if action == 'approve' else 'rejected'})
flash(f"Connection request {action}d.")
return redirect(url_for('connection_requests'))
except Error as e: except Error as e:
return jsonify({'error': str(e)}), 500 logging.error(f'Database error: {e}')
flash('An unexpected error occurred. Please try again.')
return redirect(url_for('attendee_dashboard'))
# Routes - Appointments # Routes - Appointments
+1
View File
@@ -257,6 +257,7 @@ def create_tables():
"ALTER TABLE staff ADD COLUMN reminder_count INT DEFAULT 0", "ALTER TABLE staff ADD COLUMN reminder_count INT DEFAULT 0",
"ALTER TABLE staff ADD COLUMN reminder_dates JSON DEFAULT NULL", "ALTER TABLE staff ADD COLUMN reminder_dates JSON DEFAULT NULL",
"ALTER TABLE breakout_session_organizers ADD COLUMN presenter_code VARCHAR(10) UNIQUE DEFAULT NULL", "ALTER TABLE breakout_session_organizers ADD COLUMN presenter_code VARCHAR(10) UNIQUE DEFAULT NULL",
"ALTER TABLE connections ADD COLUMN message TEXT DEFAULT NULL",
] ]
try: try:
+263
View File
@@ -2533,6 +2533,269 @@ select:focus {
margin-top: var(--space-6); margin-top: var(--space-6);
} }
/* ----- Connection Review Page ----- */
.breadcrumb-nav {
margin-bottom: var(--space-4);
font-size: var(--font-size-sm);
color: var(--text-muted);
}
.breadcrumb-nav a {
color: var(--secondary-color);
text-decoration: none;
}
.breadcrumb-nav a:hover {
text-decoration: underline;
}
.breadcrumb-nav .separator {
margin: 0 var(--space-2);
}
.breadcrumb-nav .current {
color: var(--text-color);
}
.review-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-md);
overflow: hidden;
}
.review-header {
display: flex;
align-items: center;
gap: var(--space-5);
padding: var(--space-8) var(--space-8) var(--space-4);
background: var(--bg-color);
border-bottom: 1px solid var(--border-color);
}
.review-photo {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
border: 4px solid var(--border-color);
}
.review-photo-placeholder {
width: 120px;
height: 120px;
border-radius: 50%;
background: var(--bg-color);
display: flex;
align-items: center;
justify-content: center;
font-size: var(--font-size-3xl);
font-weight: 700;
color: var(--text-muted);
border: 4px solid var(--border-color);
flex-shrink: 0;
}
.review-name-org h2 {
margin: 0 0 var(--space-1) 0;
font-size: var(--font-size-2xl);
}
.review-title {
color: var(--secondary-color);
margin: 0;
font-size: var(--font-size-lg);
}
.review-details {
padding: var(--space-6) var(--space-8);
}
.detail-row {
display: flex;
padding: var(--space-3) 0;
border-bottom: 1px solid var(--border-light, #f0f0f0);
}
.detail-row:last-child {
border-bottom: none;
}
.detail-label {
width: 140px;
flex-shrink: 0;
font-weight: 600;
color: var(--text-muted);
font-size: var(--font-size-sm);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.detail-value {
flex: 1;
color: var(--text-color);
word-break: break-word;
}
.detail-intro {
align-items: flex-start;
}
.intro-text {
font-style: italic;
color: var(--text-muted);
padding-left: var(--space-3);
border-left: 3px solid var(--border-color);
}
.message-text {
font-style: italic;
color: var(--text-color);
padding: var(--space-3);
background: var(--bg-highlight, #f0f7ff);
border-radius: var(--radius-md);
border-left: 3px solid var(--primary-color);
}
.detail-message .detail-label {
color: var(--primary-color);
}
/* ----- Connect Form (attendees list) ----- */
.connect-form.hidden {
display: none;
}
.connect-message-input {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
resize: vertical;
min-height: 40px;
font-family: inherit;
margin-bottom: var(--space-2);
}
.connect-message-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px var(--primary-color-alpha, rgba(67, 97, 238, 0.15));
}
.connect-form-actions {
display: flex;
gap: var(--space-2);
}
.btn-ghost {
background: transparent;
color: var(--text-muted);
border: 1px solid transparent;
padding: var(--space-1) var(--space-3);
border-radius: var(--radius-md);
cursor: pointer;
font-size: var(--font-size-sm);
}
.btn-ghost:hover {
background: var(--bg-color);
color: var(--text-color);
}
/* ----- Request Message (list page) ----- */
.request-message {
font-style: italic;
color: var(--text-color);
margin: 0 0 var(--space-2) 0;
font-size: var(--font-size-sm);
padding: var(--space-2) var(--space-3);
background: var(--bg-highlight, #f0f7ff);
border-radius: var(--radius-md);
border-left: 3px solid var(--primary-color);
}
/* ----- Scan Result Actions ----- */
.result-actions {
margin-top: var(--space-4);
}
.message-input {
width: 100%;
padding: var(--space-3);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
font-size: var(--font-size-base);
resize: vertical;
min-height: 48px;
font-family: inherit;
margin-bottom: var(--space-3);
box-sizing: border-box;
}
.message-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 2px var(--primary-color-alpha, rgba(67, 97, 238, 0.15));
}
.result-buttons {
display: flex;
gap: var(--space-3);
justify-content: center;
}
.review-actions {
display: flex;
gap: var(--space-4);
padding: var(--space-6) var(--space-8);
background: var(--bg-color);
border-top: 1px solid var(--border-color);
justify-content: center;
}
.review-actions .btn-lg {
padding: var(--space-3) var(--space-8);
font-size: var(--font-size-lg);
min-width: 160px;
}
.review-form {
display: inline;
}
@media (max-width: 768px) {
.review-header {
flex-direction: column;
text-align: center;
padding: var(--space-6) var(--space-4) var(--space-4);
}
.review-details {
padding: var(--space-4);
}
.detail-row {
flex-direction: column;
gap: var(--space-1);
}
.detail-label {
width: 100%;
}
.review-actions {
flex-direction: column;
padding: var(--space-4);
}
.review-actions .btn-lg {
width: 100%;
}
}
/* ----- Attendee Dashboard ----- */ /* ----- Attendee Dashboard ----- */
.attendee-dashboard { .attendee-dashboard {
padding: var(--space-6) 0; padding: var(--space-6) 0;
+27 -2
View File
@@ -61,9 +61,14 @@
{% elif att.my_status == 'rejected' %} {% elif att.my_status == 'rejected' %}
<span class="badge badge-rejected">{{ 'rejected'|t }}</span> <span class="badge badge-rejected">{{ 'rejected'|t }}</span>
{% else %} {% else %}
<form method="POST" action="{{ url_for('create_connection') }}" class="connect-form"> <button type="button" class="btn btn-sm btn-primary connect-toggle-btn" data-attendee-id="{{ att.id }}">{{ 'connect'|t }}</button>
<form method="POST" action="{{ url_for('create_connection') }}" class="connect-form hidden" id="connect-form-{{ att.id }}">
<input type="hidden" name="connected_attendee_id" value="{{ att.id }}"> <input type="hidden" name="connected_attendee_id" value="{{ att.id }}">
<button type="submit" class="btn btn-sm btn-primary">{{ 'connect'|t }}</button> <textarea name="message" class="connect-message-input" placeholder="{{ 'optional_message'|t }}" rows="2" maxlength="500"></textarea>
<div class="connect-form-actions">
<button type="submit" class="btn btn-sm btn-primary">{{ 'send_request'|t }}</button>
<button type="button" class="btn btn-sm btn-ghost connect-cancel-btn">{{ 'cancel'|t }}</button>
</div>
</form> </form>
{% endif %} {% endif %}
</div> </div>
@@ -76,3 +81,23 @@
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %}
<script>
document.querySelectorAll('.connect-toggle-btn').forEach(btn => {
btn.addEventListener('click', function() {
const attendeeId = this.dataset.attendeeId;
this.classList.add('hidden');
document.getElementById('connect-form-' + attendeeId).classList.remove('hidden');
});
});
document.querySelectorAll('.connect-cancel-btn').forEach(btn => {
btn.addEventListener('click', function() {
const form = this.closest('.connect-form');
form.classList.add('hidden');
form.closest('.attendee-actions').querySelector('.connect-toggle-btn').classList.remove('hidden');
});
});
</script>
{% endblock %}
+9 -1
View File
@@ -23,7 +23,7 @@
<div class="request-card"> <div class="request-card">
<div class="request-profile"> <div class="request-profile">
{% if req.profile_picture %} {% if req.profile_picture %}
<img src="{{ url_for('uploaded_file', filename=req.profile_picture) }}" alt="{{ req.first_name }}" class="profile-photo"> <img src="{{ url_for('static', filename='uploads/' + req.profile_picture) }}" alt="{{ req.first_name }}" class="profile-photo">
{% else %} {% else %}
<div class="profile-photo-placeholder"><span class="first-name">{{ req.first_name[0] }}</span><span class="last-name">{{ req.last_name[0] }}</span></div> <div class="profile-photo-placeholder"><span class="first-name">{{ req.first_name[0] }}</span><span class="last-name">{{ req.last_name[0] }}</span></div>
{% endif %} {% endif %}
@@ -32,12 +32,16 @@
<h3><span class="first-name">{{ req.first_name }}</span> <span class="last-name">{{ req.last_name }}</span></h3> <h3><span class="first-name">{{ req.first_name }}</span> <span class="last-name">{{ req.last_name }}</span></h3>
<p class="request-email">{{ req.email }}</p> <p class="request-email">{{ req.email }}</p>
<p class="request-org">{{ req.organisation if req.organisation else '' }}{% if req.role %} - {{ req.role }}{% endif %}</p> <p class="request-org">{{ req.organisation if req.organisation else '' }}{% if req.role %} - {{ req.role }}{% endif %}</p>
{% if req.message %}
<p class="request-message">"{{ req.message[:150] }}{% if req.message|length > 150 %}...{% endif %}"</p>
{% endif %}
{% if req.introduction %} {% if req.introduction %}
<p class="request-intro">"{{ req.introduction }}"</p> <p class="request-intro">"{{ req.introduction }}"</p>
{% endif %} {% endif %}
<p class="request-time">{{ 'requested'|t }} {{ req.created_at|localized_date if req.created_at else 'recently' }}</p> <p class="request-time">{{ 'requested'|t }} {{ req.created_at|localized_date if req.created_at else 'recently' }}</p>
</div> </div>
<div class="request-actions"> <div class="request-actions">
<a href="{{ url_for('respond_to_connection', connection_id=req.id) }}" class="btn btn-outline btn-sm">{{ 'review'|t }}</a>
<button class="btn btn-success btn-sm respond-btn" data-connection-id="{{ req.id }}" data-action="approve">{{ 'approve'|t }}</button> <button class="btn btn-success btn-sm respond-btn" data-connection-id="{{ req.id }}" data-action="approve">{{ 'approve'|t }}</button>
<button class="btn btn-danger btn-sm respond-btn" data-connection-id="{{ req.id }}" data-action="reject">{{ 'reject'|t }}</button> <button class="btn btn-danger btn-sm respond-btn" data-connection-id="{{ req.id }}" data-action="reject">{{ 'reject'|t }}</button>
</div> </div>
@@ -68,7 +72,11 @@ document.querySelectorAll('.respond-btn').forEach(btn => {
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
this.closest('.request-card').remove();
// If no requests left, reload to show the empty state
if (!document.querySelector('.request-card')) {
location.reload(); location.reload();
}
} else { } else {
showToast(data.error || 'Error processing request', 'error'); showToast(data.error || 'Error processing request', 'error');
} }
+89
View File
@@ -0,0 +1,89 @@
{% extends "base.html" %}
{% block title %}{{ 'review'|t }} - NetEvents{% endblock %}
{% block content %}
<div class="connection-review">
<nav class="breadcrumb-nav">
<a href="{{ url_for('attendee_dashboard') }}">{{ 'attendee_dashboard'|t }}</a>
<span class="separator">/</span>
<a href="{{ url_for('connection_requests') }}">{{ 'connection_requests'|t }}</a>
<span class="separator">/</span>
<span class="current">{{ 'review'|t }}</span>
</nav>
<h1>{{ 'review_connection_request'|t }}</h1>
<div class="review-card">
<div class="review-header">
<div class="review-profile">
{% if connection.profile_picture %}
<img src="{{ url_for('static', filename='uploads/' + connection.profile_picture) }}" alt="{{ connection.first_name }}" class="review-photo">
{% else %}
<div class="review-photo-placeholder"><span class="first-name">{{ connection.first_name[0] if connection.first_name else '?' }}</span><span class="last-name">{{ connection.last_name[0] if connection.last_name else '' }}</span></div>
{% endif %}
</div>
<div class="review-name-org">
<h2><span class="first-name">{{ connection.first_name }}</span> <span class="last-name">{{ connection.last_name }}</span></h2>
{% if connection.organisation or connection.role %}
<p class="review-title">{{ connection.organisation }}{% if connection.organisation and connection.role %} - {% endif %}{{ connection.role }}</p>
{% endif %}
</div>
</div>
<div class="review-details">
<div class="detail-row">
<span class="detail-label">{{ 'email'|t }}</span>
<span class="detail-value">{{ connection.email }}</span>
</div>
{% if connection.phone %}
<div class="detail-row">
<span class="detail-label">{{ 'phone'|t }}</span>
<span class="detail-value">{{ connection.phone }}</span>
</div>
{% endif %}
{% if connection.linkedin %}
<div class="detail-row">
<span class="detail-label">LinkedIn</span>
<span class="detail-value"><a href="{{ connection.linkedin if connection.linkedin.startswith('http') else 'https://' + connection.linkedin }}" target="_blank" rel="noopener">{{ connection.linkedin }}</a></span>
</div>
{% endif %}
{% if connection.message %}
<div class="detail-row detail-message">
<span class="detail-label">{{ 'message'|t }}</span>
<span class="detail-value message-text">"{{ connection.message }}"</span>
</div>
{% endif %}
{% if connection.introduction %}
<div class="detail-row detail-intro">
<span class="detail-label">{{ 'introduction'|t }}</span>
<span class="detail-value intro-text">"{{ connection.introduction }}"</span>
</div>
{% endif %}
<div class="detail-row">
<span class="detail-label">{{ 'requested'|t }}</span>
<span class="detail-value">{{ connection.created_at|localized_date if connection.created_at else '' }}</span>
</div>
</div>
<div class="review-actions">
<form method="POST" action="{{ url_for('respond_to_connection', connection_id=connection.id) }}" class="review-form">
<input type="hidden" name="action" value="approve">
<button type="submit" class="btn btn-success btn-lg">
<i data-lucide="user-check"></i> {{ 'approve'|t }}
</button>
</form>
<form method="POST" action="{{ url_for('respond_to_connection', connection_id=connection.id) }}" class="review-form">
<input type="hidden" name="action" value="reject">
<button type="submit" class="btn btn-danger btn-lg">
<i data-lucide="user-x"></i> {{ 'reject'|t }}
</button>
</form>
</div>
</div>
<div class="back-link">
<a href="{{ url_for('connection_requests') }}" class="btn btn-outline">&larr; {{ 'back_to_connection_requests'|t }}</a>
</div>
</div>
{% endblock %}
+28 -1
View File
@@ -29,10 +29,36 @@
</section> </section>
{% endif %} {% endif %}
{% if sent_pending_connections %}
<section class="sent-pending-section">
<h2>{{ 'connection_requests_sent'|t }}</h2>
<div class="connections-list">
{% for conn in sent_pending_connections %}
<div class="connection-card connection-pending">
<h4><span class="first-name">{{ conn.first_name }}</span> <span class="last-name">{{ conn.last_name }}</span></h4>
<p>{{ conn.organisation if conn.organisation else '' }}</p>
<p class="connection-role">{{ conn.role if conn.role else '' }}</p>
<span class="badge badge-pending">{{ 'request_pending'|t }}</span>
</div>
{% endfor %}
</div>
</section>
{% endif %}
{% if pending_connections %} {% if pending_connections %}
<section class="pending-section"> <section class="pending-section">
<h2>{{ 'incoming_connection_requests'|t }}</h2> <h2>{{ 'incoming_connection_requests'|t }}</h2>
<p><a href="{{ url_for('connection_requests') }}">{{ 'review_scan_requests'|t }}</a></p> <div class="connections-list">
{% for conn in pending_connections %}
<div class="connection-card connection-incoming">
<h4><span class="first-name">{{ conn.first_name }}</span> <span class="last-name">{{ conn.last_name }}</span></h4>
<p>{{ conn.organisation if conn.organisation else '' }}</p>
<p class="connection-role">{{ conn.role if conn.role else '' }}</p>
<a href="{{ url_for('respond_to_connection', connection_id=conn.id) }}" class="btn btn-sm btn-outline">{{ 'review'|t }}</a>
</div>
{% endfor %}
</div>
<p><a href="{{ url_for('connection_requests') }}">{{ 'view_all_incoming_requests'|t }}</a></p>
</section> </section>
{% endif %} {% endif %}
@@ -49,6 +75,7 @@
<h4 class="mb-3"><span class="first-name">{{ conn.first_name }}</span> <span class="last-name">{{ conn.last_name }}</span></h4> <h4 class="mb-3"><span class="first-name">{{ conn.first_name }}</span> <span class="last-name">{{ conn.last_name }}</span></h4>
<p class="connection-org mb-3">{{ conn.organisation if conn.organisation else '' }}</p> <p class="connection-org mb-3">{{ conn.organisation if conn.organisation else '' }}</p>
<p class="connection-role">{{ conn.role if conn.role else '' }}</p> <p class="connection-role">{{ conn.role if conn.role else '' }}</p>
<span class="badge badge-success">{{ 'connected'|t }}</span>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
+57 -20
View File
@@ -16,7 +16,13 @@
<div class="result-icon" id="result-icon"></div> <div class="result-icon" id="result-icon"></div>
<h2 id="result-title"></h2> <h2 id="result-title"></h2>
<p id="result-message"></p> <p id="result-message"></p>
<button id="scan-again" class="btn btn-primary"><i data-lucide="scan-line"></i> {{ 'scan_again'|t }}</button> <div id="result-actions" class="result-actions hidden">
<textarea id="message-input" class="message-input" placeholder="{{ 'optional_message'|t }}" rows="2" maxlength="500"></textarea>
<div class="result-buttons">
<button id="send-request-btn" class="btn btn-primary"><i data-lucide="send"></i> {{ 'send_request'|t }}</button>
<button id="scan-again" class="btn btn-outline">{{ 'scan_again'|t }}</button>
</div>
</div>
</div> </div>
</div> </div>
@@ -32,13 +38,14 @@ const myId = {{ session.user_id }};
const myEventId = {{ session.event_id }}; const myEventId = {{ session.event_id }};
let html5QrCode; let html5QrCode;
let scanning = true; let scanning = true;
let pendingScannedId = null;
async function sendConnectionRequest(scannedId) { async function sendConnectionRequest(scannedId, message) {
try { try {
const response = await fetch('/attendee/scan-request', { const response = await fetch('/attendee/scan-request', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scanned_id: scannedId }) body: JSON.stringify({ scanned_id: scannedId, message: message })
}); });
const data = await response.json(); const data = await response.json();
@@ -46,17 +53,19 @@ async function sendConnectionRequest(scannedId) {
const icon = document.getElementById('result-icon'); const icon = document.getElementById('result-icon');
const title = document.getElementById('result-title'); const title = document.getElementById('result-title');
const message = document.getElementById('result-message'); const message = document.getElementById('result-message');
const resultActions = document.getElementById('result-actions');
icon.className = 'result-icon'; icon.className = 'result-icon';
icon.innerHTML = '<i data-lucide="clock"></i>'; resultActions.classList.add('hidden');
if (data.success) { if (data.success) {
document.getElementById('qr-reader').style.display = 'none'; document.getElementById('qr-reader').style.display = 'none';
resultDiv.classList.remove('hidden'); resultDiv.classList.remove('hidden');
icon.classList.add('pending'); icon.classList.add('success');
icon.innerHTML = '<i data-lucide="clock"></i>'; icon.innerHTML = '<i data-lucide="check-circle"></i>';
title.textContent = '{{ "request_sent"|t }}'; title.textContent = '{{ "request_sent"|t }}';
message.textContent = '{{ "request_sent_message"|t }}'; message.textContent = '{{ "request_sent_message"|t }}';
document.getElementById('message-input').value = '';
} else { } else {
document.getElementById('qr-reader').style.display = 'none'; document.getElementById('qr-reader').style.display = 'none';
resultDiv.classList.remove('hidden'); resultDiv.classList.remove('hidden');
@@ -68,6 +77,7 @@ async function sendConnectionRequest(scannedId) {
lucide.createIcons(); lucide.createIcons();
scanning = false; scanning = false;
pendingScannedId = null;
} catch (error) { } catch (error) {
console.error('Connection error:', error); console.error('Connection error:', error);
showToast('Error sending connection request', 'error'); showToast('Error sending connection request', 'error');
@@ -77,7 +87,6 @@ async function sendConnectionRequest(scannedId) {
function onScanSuccess(decodedText) { function onScanSuccess(decodedText) {
if (!scanning) return; if (!scanning) return;
// Expected format: "NETEVENT:{event_id}:{attendee_id}"
const parts = decodedText.split(':'); const parts = decodedText.split(':');
if (parts.length === 3 && parts[0] === 'NETEVENT') { if (parts.length === 3 && parts[0] === 'NETEVENT') {
const eventId = parseInt(parts[1]); const eventId = parseInt(parts[1]);
@@ -88,16 +97,54 @@ function onScanSuccess(decodedText) {
showToast('{{ "cannot_scan_own_qr"|t }}', 'warning'); showToast('{{ "cannot_scan_own_qr"|t }}', 'warning');
return; return;
} }
sendConnectionRequest(attendeeId); pendingScannedId = attendeeId;
showMessagePrompt();
} else { } else {
showToast('{{ "qr_different_event"|t }}', 'error'); showToast('{{ "qr_different_event"|t }}', 'error');
} }
} else { } else {
// QR format not recognized
showToast('{{ "unrecognized_qr"|t }}', 'error'); showToast('{{ "unrecognized_qr"|t }}', 'error');
} }
} }
function showMessagePrompt() {
const resultDiv = document.getElementById('scan-result');
const icon = document.getElementById('result-icon');
const title = document.getElementById('result-title');
const msg = document.getElementById('result-message');
const resultActions = document.getElementById('result-actions');
document.getElementById('qr-reader').style.display = 'none';
resultDiv.classList.remove('hidden');
resultActions.classList.remove('hidden');
icon.className = 'result-icon pending';
icon.innerHTML = '<i data-lucide="message-circle"></i>';
title.textContent = '{{ "scanned_successfully"|t }}';
msg.textContent = '{{ "scan_add_message_hint"|t }}';
document.getElementById('message-input').value = '';
document.getElementById('message-input').focus();
lucide.createIcons();
scanning = false;
}
document.getElementById('send-request-btn').addEventListener('click', function() {
if (pendingScannedId) {
const message = document.getElementById('message-input').value.trim();
sendConnectionRequest(pendingScannedId, message || null);
}
});
document.getElementById('scan-again').addEventListener('click', resetScanner);
function resetScanner() {
document.getElementById('qr-reader').style.display = 'block';
document.getElementById('scan-result').classList.add('hidden');
document.getElementById('message-input').value = '';
scanning = true;
pendingScannedId = null;
}
function startScanner() { function startScanner() {
html5QrCode = new Html5Qrcode("qr-reader"); html5QrCode = new Html5Qrcode("qr-reader");
@@ -108,23 +155,13 @@ function startScanner() {
qrbox: { width: 250, height: 250 } qrbox: { width: 250, height: 250 }
}, },
onScanSuccess, onScanSuccess,
(errorMessage) => { (errorMessage) => {}
// Ignore scan errors
}
).catch(err => { ).catch(err => {
console.error('Camera error:', err); console.error('Camera error:', err);
showToast('{{ "camera_permission_error"|t }}', 'error'); showToast('{{ "camera_permission_error"|t }}', 'error');
}); });
} }
function resetScanner() {
document.getElementById('qr-reader').style.display = 'block';
document.getElementById('scan-result').classList.add('hidden');
scanning = true;
}
document.getElementById('scan-again').addEventListener('click', resetScanner);
window.addEventListener('DOMContentLoaded', startScanner); window.addEventListener('DOMContentLoaded', startScanner);
</script> </script>
{% endblock %} {% endblock %}
+1
View File
@@ -99,5 +99,6 @@
</script> </script>
<script src="{{ url_for('static', filename='js/main.js') }}"></script> <script src="{{ url_for('static', filename='js/main.js') }}"></script>
{% block extra_styles %}{% endblock %} {% block extra_styles %}{% endblock %}
{% block scripts %}{% endblock %}
</body> </body>
</html> </html>