Enhance RSVP functionality with live updates and page activation

- Refactored rsvp.js to implement a WebSocket connection for real-time updates on activity attendees and capacity.
- Introduced a page activation mechanism to handle dynamic content loading based on the current URL.
- Updated base.html to include jQuery for easier DOM manipulation.
- Modified event_rsvp.html to pass the RSVP ID to the JavaScript context.
- Enhanced rsvp_page_activities.html to display a live connection status and included guest count in the script.
- Adjusted rsvp_page_activities_details.html to reflect the attending status in the activity card.
This commit is contained in:
2026-03-23 17:16:35 +01:00
parent 8d6f261f3d
commit 4112e0dc0a
10 changed files with 220 additions and 55 deletions
+7 -1
View File
@@ -41,4 +41,10 @@ Het plaatje en de beschrijving van het event moeten misschien tussendoor nog erb
Als in: invite link -> event page (create rsvp & token, store in cookie) -> Knop ik kom!, Knop ik kom niet? -> Redirect naar RSVP flow
## Zeven
De websocket overschrijft de huidige keuze. Dus als iets vol zit, wordt hij grijs, ondanks dat die er zelf ook heen gaan en hij groen had moeten zijn!
De websocket overschrijft de huidige keuze. Dus als iets vol zit, wordt hij grijs, ondanks dat die er zelf ook heen gaan en hij groen had moeten zijn!
## Acht
Mails!
## Negen
Gasten (template): koppelen aan een invite!
+46 -2
View File
@@ -1,15 +1,22 @@
import json
import logging
from channels.generic.websocket import AsyncWebsocketConsumer
logger = logging.getLogger(__name__)
connected_websocket_consumers = 0
class ActivityConsumer(AsyncWebsocketConsumer):
async def connect(self):
global connected_websocket_consumers
if "url_route" not in self.scope:
raise ValueError
self.event_id = self.scope["url_route"]["kwargs"]["event_id"]
logger.debug(f"Connection for Event={self.event_id}")
# Subscribe to database changes for this event
await self.channel_layer.group_add(
f"activity_{self.event_id}",
@@ -17,18 +24,55 @@ class ActivityConsumer(AsyncWebsocketConsumer):
)
await self.accept()
connected_websocket_consumers += 1
logger.debug(f"Websocket for Event={self.event_id} opened ({connected_websocket_consumers} total)")
await self.update_connected()
async def disconnect(self, close_code):
global connected_websocket_consumers
logger.debug(f"Disconnection for Event={self.event_id}")
# Remove from group on disconnect
await self.channel_layer.group_discard(
f"activity_{self.event_id}",
self.channel_name
)
connected_websocket_consumers -= 1
logger.debug(f"Websocket for Event={self.event_id} closed ({connected_websocket_consumers} total)")
await self.update_connected()
async def update_connected(self):
"""Update all connected """
logger.debug("Sending connected update...")
await self.channel_layer.group_send(
f"activity_{self.event_id}",
{
"type": "group.message",
"activity_json": {} # connected will be filled
}
)
async def group_message(self, event):
global connected_websocket_consumers
logger.debug(f"Message {self.event_id}: {event}")
activity_json = event.get("activity_json")
logger.debug("Ready to send: " + str(activity_json))
if activity_json is not None:
activity_json["connected"] = connected_websocket_consumers
activity_str = json.dumps(activity_json)
if activity_json:
await self.send(text_data=activity_json)
logger.debug("Sending: " + str(activity_str))
await self.send(text_data=activity_str)
+12 -1
View File
@@ -506,7 +506,7 @@ class GuestListTemplate(TimestampedModel):
return self.title
class GuestListItem(models.Model):
class GuestListItem(TimestampedModel):
template = models.ForeignKey(GuestListTemplate, related_name='items', on_delete=models.CASCADE)
name = models.CharField(max_length=255)
email = models.EmailField(blank=True)
@@ -514,3 +514,14 @@ class GuestListItem(models.Model):
def __str__(self):
return self.name
class GuestItemInvite(TimestampedModel):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
guest = models.ForeignKey(GuestListItem, related_name="guest_invite", on_delete=models.CASCADE)
invite = models.ForeignKey(Invite, related_name="guest_invite", on_delete=models.CASCADE)
class Meta:
unique_together = (("guest", "invite"),)
def __str__(self):
return f"{self.guest} added to {self.invite}"
+6 -4
View File
@@ -42,13 +42,14 @@ def activity_selection_changed(sender, instance, created, **kwargs):
if capacity is None:
capacity = 100
activity_json = json.dumps({
activity_json = {
"activity": str(activity.id),
"rsvp": str(rsvp.id),
"action": "select",
"attendees": activity.attendee_count(),
"capacity": capacity,
"guests": activity.attendee_list()
})
}
# Send message to the group
async_to_sync(send_channel)(event.id, activity_json)
@@ -66,13 +67,14 @@ def activity_selection_deleted(sender, instance, **kwargs):
if capacity is None:
capacity = 100
activity_json = json.dumps({
activity_json = {
"activity": str(activity.id),
"rsvp": str(rsvp.id),
"action": "remove",
"attendees": activity.attendee_count(),
"capacity": capacity,
"guests": activity.attendee_list()
})
}
# Send message to the group
async_to_sync(send_channel)(event.id, activity_json)
File diff suppressed because one or more lines are too long
+136 -39
View File
@@ -1,53 +1,150 @@
const activitySocket = new WebSocket(
'ws://'
+ window.location.host
+ '/ws/event/'
+ eventId
+ '/'
);
function extractPageFromUrl(url) {
// Match /page/<page> pattern
const match = url.match(/\/page\/([^\/]+)/);
return match ? match[1] : null;
}
activitySocket.onmessage = function(e) {
console.log(e);
data = JSON.parse(e.data);
$(document).ready(() => {
pageActivator();
});
// Activity attendees (when everyone can join)
attendees = document.querySelector('#activity-' + data.activity + ' .numatt');
if(attendees){
attendees.innerHTML = data.attendees;
htmx.onLoad(function(content) {
pageActivator();
});
var deactivator = () => {};
var currentPage = "";
function pageActivator(){
const currentPath = window.location.pathname;
var newPage = extractPageFromUrl(currentPath);
if(newPage == currentPage){
return;
}
// Activity capacity (when there is a guest limit)
capacity = document.querySelector('#activity-' + data.activity + ' .numcap');
if(capacity){
capacity.innerHTML = data.capacity;
}
console.log(`Swapping from ${currentPage} to ${newPage}`);
currentPage = newPage;
// Activity capacity (when there is a guest limit)
activity = document.querySelector('#activity-' + data.activity);
if(activity){
if(data.capacity >= guestCount){
activity.classList.remove("bg-gray");
activity.classList.remove("bd-gray");
// Deactivate the old page
deactivator();
switch (currentPage) {
case 'activities':
deactivator = activitiesPage();
break;
default:
break;
}
}
function activitiesPage() {
function showLive(number, domElement) {
if(!domElement){
domElement = $('#rsvp-activity .live');
}
else{
activity.classList.add("bg-gray");
activity.classList.add("bd-gray");
if(domElement && number){
switch (number) {
case 0:
case 1:
domElement.html("🔴 Only you");
break;
case 2:
domElement.html("🔴 One other is watching");
break;
default:
domElement.html("🔴 " + --number + " others are watching");
break;
}
}
}
// Activity capacity (when there is a guest limit)
attendees_names = document.querySelector('#activity-' + data.activity + ' .stratt');
if(attendees_names){
html = "";
const activitySocket = new WebSocket(
'ws://'
+ window.location.host
+ '/ws/event/'
+ eventId
+ '/'
);
for(index in data.guests){
html += "<span class='badge bg-blue'>" + data.guests[index].join(", ") + "</span>";
var globalLiveConnected = 0;
$("body").on("htmx:load.activities", function(evt) {
console.info("HTMX OnLoad ...");
var domElement = $(evt.detail.elt.querySelector(".live"));
showLive(globalLiveConnected, domElement);
});
activitySocket.onmessage = function(e) {
var data = JSON.parse(e.data);
if(data.connected){
globalLiveConnected = data.connected;
showLive(globalLiveConnected);
}
attendees_names.innerHTML = html;
}
// We caused the change, ignore
if(rsvpId && data.rsvp && rsvpId == data.rsvp){
return;
}
// Activity attendees (when everyone can join)
const attendees = $('#activity-' + data.activity + ' .numatt');
if(attendees !== null && data.attendees !== null){
attendees.html(data.attendees);
}
// Activity capacity (when there is a guest limit)
const capacity = $('#activity-' + data.activity + ' .numcap');
if(capacity !== null && data.capacity !== null){
capacity.html(data.capacity);
}
// Activity capacity (when there is a guest limit)
const activity = $('#activity-' + data.activity);
if(activity !== null && data.capacity !== null){
if(!(activity.hasClass("attending"))){
if(data.capacity >= guestCount){
activity.removeClass("bg-gray bd-gray");
}
else{
activity.addClass("bg-gray bd-gray");
}
}
}
// Activity capacity (when there is a guest limit)
const attendees_names = $('#activity-' + data.activity + ' .stratt');
if(attendees_names !== null){
html = "";
for(index in data.guests){
html += "<span class='badge bg-blue'>" + data.guests[index].join(", ") + "</span>";
}
attendees_names.html(html);
}
};
activitySocket.onopen = function(e) {
console.info('Activity socket opened');
};
activitySocket.onclose = function(e) {
console.error('Activity socket closed unexpectedly');
};
activitySocket.onerror = function(e) {
console.error('Activity socket had unexpected error');
};
return (() => {
$("body").off("htmx:load.activities");
activitySocket.close();
});
};
activitySocket.onclose = function(e) {
console.error('Activity socket closed unexpectedly');
};
+1
View File
@@ -9,6 +9,7 @@
<script src="{% static 'js/htmx.2.0.8.min.js' %}" integrity="sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz"></script>
<script src="{% static 'js/htmx-ext-ws.2.0.4.min.js' %}" integrity="sha384-1RwI/nvUSrMRuNj7hX1+27J8XDdCoSLf0EjEyF69nacuWyiJYoQ/j39RT1mSnd2G"></script>
<script src="{% static 'js/jquery-4.0.0.slim.min.js' %}"></script>
<script src="{% static 'js/alpine.3.15.8.min.js' %}" defer></script>
<link rel="stylesheet" href="{% static 'css/simple.min.css' %}">
<link rel="stylesheet" href="{% static 'css/styles.css' %}">
+1
View File
@@ -45,6 +45,7 @@
{% load static %}
<script>
const eventId = "{{ event.id }}";
const rsvpId = "{{ rsvp.id }}";
</script>
<script src="{% static 'js/rsvp.js' %}"></script>
{% endblock %}
@@ -1,5 +1,6 @@
<div class="card" id="rsvp-activity">
<h3>What do you want to go to?</h3>
<h3>What do you want to go to? <span class="badge bg-red live">Connecting...</span> </h3>
{% for group, activities in groups.items %}
<h4 class="group-title">
@@ -22,4 +23,8 @@
{% endfor %}
{% endif %}
</div>
</div>
<script>
var guestCount = {{ rsvp.guest_count }};
</script>
@@ -1,4 +1,4 @@
<div class="subcard cursor {% if activity.id in selected_activities %}bg-green{% elif activity.remaining_capacity < rsvp.guest_count %}bg-gray bd-gray{% endif %}"
<div class="subcard cursor {% if activity.id in selected_activities %}bg-green attending{% elif activity.remaining_capacity < rsvp.guest_count %}bg-gray bd-gray{% endif %}"
hx-post="{% url 'events:rsvp-post-activity' rsvp.id activity.id %}"
hx-target="#rsvp-activity"
hx-swap="outerHTML"
@@ -34,8 +34,4 @@
</span>
{% endfor %}
</span>
</div>
<script>
guestCount = {{ rsvp.guest_count }};
</script>
</div>