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
+28 -3
View File
@@ -61,9 +61,14 @@
{% elif att.my_status == 'rejected' %}
<span class="badge badge-rejected">{{ 'rejected'|t }}</span>
{% else %}
<form method="POST" action="{{ url_for('create_connection') }}" class="connect-form">
<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 }}">
<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>
{% endif %}
</div>
@@ -75,4 +80,24 @@
{% endif %}
{% endif %}
</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 %}
+10 -2
View File
@@ -23,7 +23,7 @@
<div class="request-card">
<div class="request-profile">
{% if req.profile_picture %}
<img src="{{ url_for('uploaded_file', filename=req.profile_picture) }}" alt="{{ req.first_name }}" class="profile-photo">
<img src="{{ url_for('static', filename='uploads/' + req.profile_picture) }}" alt="{{ req.first_name }}" class="profile-photo">
{% 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>
{% endif %}
@@ -32,12 +32,16 @@
<h3><span class="first-name">{{ req.first_name }}</span> <span class="last-name">{{ req.last_name }}</span></h3>
<p class="request-email">{{ req.email }}</p>
<p class="request-org">{{ req.organisation if req.organisation else '' }}{% if req.role %} - {{ req.role }}{% endif %}</p>
{% if req.message %}
<p class="request-message">"{{ req.message[:150] }}{% if req.message|length > 150 %}...{% endif %}"</p>
{% endif %}
{% if req.introduction %}
<p class="request-intro">"{{ req.introduction }}"</p>
{% endif %}
<p class="request-time">{{ 'requested'|t }} {{ req.created_at|localized_date if req.created_at else 'recently' }}</p>
</div>
<div class="request-actions">
<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-danger btn-sm respond-btn" data-connection-id="{{ req.id }}" data-action="reject">{{ 'reject'|t }}</button>
</div>
@@ -68,7 +72,11 @@ document.querySelectorAll('.respond-btn').forEach(btn => {
const data = await response.json();
if (data.success) {
location.reload();
this.closest('.request-card').remove();
// If no requests left, reload to show the empty state
if (!document.querySelector('.request-card')) {
location.reload();
}
} else {
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>
{% 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 %}
<section class="pending-section">
<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>
{% 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>
<p class="connection-org mb-3">{{ conn.organisation if conn.organisation else '' }}</p>
<p class="connection-role">{{ conn.role if conn.role else '' }}</p>
<span class="badge badge-success">{{ 'connected'|t }}</span>
</div>
{% endfor %}
</div>
+58 -21
View File
@@ -16,7 +16,13 @@
<div class="result-icon" id="result-icon"></div>
<h2 id="result-title"></h2>
<p id="result-message"></p>
<button id="scan-again" class="btn btn-primary"><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>
@@ -32,13 +38,14 @@ const myId = {{ session.user_id }};
const myEventId = {{ session.event_id }};
let html5QrCode;
let scanning = true;
let pendingScannedId = null;
async function sendConnectionRequest(scannedId) {
async function sendConnectionRequest(scannedId, message) {
try {
const response = await fetch('/attendee/scan-request', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ scanned_id: scannedId })
body: JSON.stringify({ scanned_id: scannedId, message: message })
});
const data = await response.json();
@@ -46,17 +53,19 @@ async function sendConnectionRequest(scannedId) {
const icon = document.getElementById('result-icon');
const title = document.getElementById('result-title');
const message = document.getElementById('result-message');
const resultActions = document.getElementById('result-actions');
icon.className = 'result-icon';
icon.innerHTML = '<i data-lucide="clock"></i>';
resultActions.classList.add('hidden');
if (data.success) {
document.getElementById('qr-reader').style.display = 'none';
resultDiv.classList.remove('hidden');
icon.classList.add('pending');
icon.innerHTML = '<i data-lucide="clock"></i>';
icon.classList.add('success');
icon.innerHTML = '<i data-lucide="check-circle"></i>';
title.textContent = '{{ "request_sent"|t }}';
message.textContent = '{{ "request_sent_message"|t }}';
document.getElementById('message-input').value = '';
} else {
document.getElementById('qr-reader').style.display = 'none';
resultDiv.classList.remove('hidden');
@@ -68,6 +77,7 @@ async function sendConnectionRequest(scannedId) {
lucide.createIcons();
scanning = false;
pendingScannedId = null;
} catch (error) {
console.error('Connection error:', error);
showToast('Error sending connection request', 'error');
@@ -77,7 +87,6 @@ async function sendConnectionRequest(scannedId) {
function onScanSuccess(decodedText) {
if (!scanning) return;
// Expected format: "NETEVENT:{event_id}:{attendee_id}"
const parts = decodedText.split(':');
if (parts.length === 3 && parts[0] === 'NETEVENT') {
const eventId = parseInt(parts[1]);
@@ -88,16 +97,54 @@ function onScanSuccess(decodedText) {
showToast('{{ "cannot_scan_own_qr"|t }}', 'warning');
return;
}
sendConnectionRequest(attendeeId);
pendingScannedId = attendeeId;
showMessagePrompt();
} else {
showToast('{{ "qr_different_event"|t }}', 'error');
}
} else {
// QR format not recognized
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() {
html5QrCode = new Html5Qrcode("qr-reader");
@@ -108,23 +155,13 @@ function startScanner() {
qrbox: { width: 250, height: 250 }
},
onScanSuccess,
(errorMessage) => {
// Ignore scan errors
}
(errorMessage) => {}
).catch(err => {
console.error('Camera error:', err);
showToast('{{ "camera_permission_error"|t }}', 'error');
});
}
function resetScanner() {
document.getElementById('qr-reader').style.display = 'block';
document.getElementById('scan-result').classList.add('hidden');
scanning = true;
}
document.getElementById('scan-again').addEventListener('click', resetScanner);
window.addEventListener('DOMContentLoaded', startScanner);
</script>
{% endblock %}
{% endblock %}