Add edit RSVP modal, plus-one tracking, and unified signup form
Merge RSVP + slot claim into a single form. Add plus_one field to RSVPs. Add clickable RSVP names that open a modal to edit name/note/plus_one. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+132
-85
@@ -133,9 +133,10 @@ type SlotView struct {
|
||||
}
|
||||
|
||||
type GoingPerson struct {
|
||||
Name string
|
||||
Note string
|
||||
RsvpID int64
|
||||
Name string
|
||||
Note string
|
||||
RsvpID int64
|
||||
PlusOne int64
|
||||
}
|
||||
|
||||
type EventPageData struct {
|
||||
@@ -198,13 +199,15 @@ func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*Eve
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalGoing += int64(len(rsvps))
|
||||
for _, r := range rsvps {
|
||||
totalGoing += 1 + r.PlusOne
|
||||
}
|
||||
|
||||
// Build deduplicated GoingList: RSVPs first, then claim-only people
|
||||
var goingList []GoingPerson
|
||||
seen := make(map[string]bool)
|
||||
for _, r := range rsvps {
|
||||
goingList = append(goingList, GoingPerson{Name: r.Name, Note: r.Note, RsvpID: r.ID})
|
||||
goingList = append(goingList, GoingPerson{Name: r.Name, Note: r.Note, RsvpID: r.ID, PlusOne: r.PlusOne})
|
||||
seen[strings.ToLower(r.Name)] = true
|
||||
}
|
||||
for _, c := range claims {
|
||||
@@ -258,78 +261,7 @@ func (s *Server) handleSlotsPartial(w http.ResponseWriter, r *http.Request) {
|
||||
pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data)
|
||||
}
|
||||
|
||||
// --- Claim / Unclaim ---
|
||||
|
||||
func (s *Server) handleClaim(w http.ResponseWriter, r *http.Request) {
|
||||
slug := chi.URLParam(r, "slug")
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 8*1024)
|
||||
r.ParseForm()
|
||||
|
||||
slotID, err := strconv.ParseInt(r.FormValue("slot_id"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid slot", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := sanitize(r.FormValue("name"), maxFieldLen)
|
||||
if name == "" {
|
||||
http.Error(w, "Name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
note := sanitize(r.FormValue("note"), maxNoteLen)
|
||||
|
||||
// Check slot exists and belongs to this event
|
||||
slot, err := s.q.GetSlot(r.Context(), slotID)
|
||||
if err != nil {
|
||||
http.Error(w, "Slot not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
event, err := s.q.GetEventBySlug(r.Context(), slug)
|
||||
if err != nil || slot.EventID != event.ID {
|
||||
http.Error(w, "Invalid slot", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check not full
|
||||
count, err := s.q.CountClaimsBySlot(r.Context(), slotID)
|
||||
if err != nil {
|
||||
http.Error(w, "error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if count >= slot.MaxClaims {
|
||||
http.Error(w, "Slot is full", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = s.q.CreateClaim(r.Context(), db.CreateClaimParams{
|
||||
SlotID: slotID, Name: name, Note: note,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("create claim: %v", err)
|
||||
http.Error(w, "Failed to claim", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-RSVP if they're not already on the going list
|
||||
_, err = s.q.GetRsvpByName(r.Context(), db.GetRsvpByNameParams{
|
||||
EventID: event.ID, Name: name,
|
||||
})
|
||||
if err == sql.ErrNoRows {
|
||||
s.q.CreateRsvp(r.Context(), db.CreateRsvpParams{
|
||||
EventID: event.ID, Name: name,
|
||||
})
|
||||
}
|
||||
|
||||
s.notify(slug)
|
||||
|
||||
// Return updated slots partial
|
||||
data, err := s.loadEventPage(r, slug, false)
|
||||
if err != nil {
|
||||
http.Error(w, "error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data)
|
||||
}
|
||||
// --- Unclaim ---
|
||||
|
||||
func (s *Server) handleUnclaim(w http.ResponseWriter, r *http.Request) {
|
||||
slug := chi.URLParam(r, "slug")
|
||||
@@ -355,7 +287,7 @@ func (s *Server) handleUnclaim(w http.ResponseWriter, r *http.Request) {
|
||||
pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data)
|
||||
}
|
||||
|
||||
// --- RSVP ---
|
||||
// --- RSVP (+ optional claim) ---
|
||||
|
||||
func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
|
||||
slug := chi.URLParam(r, "slug")
|
||||
@@ -368,6 +300,13 @@ func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
note := sanitize(r.FormValue("note"), maxNoteLen)
|
||||
plusOne := int64(0)
|
||||
if v, err := strconv.ParseInt(r.FormValue("plus_one"), 10, 64); err == nil && v > 0 {
|
||||
plusOne = v
|
||||
}
|
||||
if plusOne > 10 {
|
||||
plusOne = 10
|
||||
}
|
||||
|
||||
event, err := s.q.GetEventBySlug(r.Context(), slug)
|
||||
if err != nil {
|
||||
@@ -375,27 +314,135 @@ func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
count, err := s.q.CountRsvps(r.Context(), event.ID)
|
||||
// Optional slot claim
|
||||
if slotIDStr := r.FormValue("slot_id"); slotIDStr != "" {
|
||||
slotID, err := strconv.ParseInt(slotIDStr, 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid slot", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
slot, err := s.q.GetSlot(r.Context(), slotID)
|
||||
if err != nil {
|
||||
http.Error(w, "Slot not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if slot.EventID != event.ID {
|
||||
http.Error(w, "Invalid slot", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
count, err := s.q.CountClaimsBySlot(r.Context(), slotID)
|
||||
if err != nil {
|
||||
http.Error(w, "error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if count >= slot.MaxClaims {
|
||||
http.Error(w, "Slot is full", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = s.q.CreateClaim(r.Context(), db.CreateClaimParams{
|
||||
SlotID: slotID, Name: name, Note: note,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("create claim: %v", err)
|
||||
http.Error(w, "Failed to claim", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Create RSVP (deduped — skip if already on the list)
|
||||
_, err = s.q.GetRsvpByName(r.Context(), db.GetRsvpByNameParams{
|
||||
EventID: event.ID, Name: name,
|
||||
})
|
||||
if err == sql.ErrNoRows {
|
||||
count, err := s.q.CountRsvps(r.Context(), event.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if count >= maxRsvps {
|
||||
http.Error(w, "RSVP list is full", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = s.q.CreateRsvp(r.Context(), db.CreateRsvpParams{
|
||||
EventID: event.ID, Name: name, Note: note, PlusOne: plusOne,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("create rsvp: %v", err)
|
||||
http.Error(w, "Failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
s.notify(slug)
|
||||
|
||||
data, err := s.loadEventPage(r, slug, false)
|
||||
if err != nil {
|
||||
http.Error(w, "error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if count >= maxRsvps {
|
||||
http.Error(w, "RSVP list is full", http.StatusConflict)
|
||||
pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data)
|
||||
}
|
||||
|
||||
func (s *Server) handleEditRsvpForm(w http.ResponseWriter, r *http.Request) {
|
||||
slug := chi.URLParam(r, "slug")
|
||||
rsvpID, err := strconv.ParseInt(chi.URLParam(r, "rsvpID"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid RSVP", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = s.q.CreateRsvp(r.Context(), db.CreateRsvpParams{
|
||||
EventID: event.ID, Name: name, Note: note,
|
||||
rsvp, err := s.q.GetRsvp(r.Context(), rsvpID)
|
||||
if err != nil {
|
||||
http.Error(w, "RSVP not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
pageTmpl["edit-rsvp"].ExecuteTemplate(w, "edit-rsvp", map[string]any{
|
||||
"Slug": slug,
|
||||
"Rsvp": rsvp,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateRsvp(w http.ResponseWriter, r *http.Request) {
|
||||
slug := chi.URLParam(r, "slug")
|
||||
rsvpID, err := strconv.ParseInt(chi.URLParam(r, "rsvpID"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid RSVP", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 8*1024)
|
||||
r.ParseForm()
|
||||
|
||||
name := sanitize(r.FormValue("name"), maxFieldLen)
|
||||
if name == "" {
|
||||
http.Error(w, "Name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
note := sanitize(r.FormValue("note"), maxNoteLen)
|
||||
plusOne := int64(0)
|
||||
if v, err := strconv.ParseInt(r.FormValue("plus_one"), 10, 64); err == nil && v > 0 {
|
||||
plusOne = v
|
||||
}
|
||||
if plusOne > 10 {
|
||||
plusOne = 10
|
||||
}
|
||||
|
||||
err = s.q.UpdateRsvp(r.Context(), db.UpdateRsvpParams{
|
||||
Name: name, Note: note, PlusOne: plusOne, ID: rsvpID,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("create rsvp: %v", err)
|
||||
http.Error(w, "Failed", http.StatusInternalServerError)
|
||||
http.Error(w, "Failed to update", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
s.notify(slug)
|
||||
|
||||
w.Header().Set("HX-Trigger", "closeModal")
|
||||
data, err := s.loadEventPage(r, slug, false)
|
||||
if err != nil {
|
||||
http.Error(w, "error", http.StatusInternalServerError)
|
||||
|
||||
Reference in New Issue
Block a user