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:
2026-05-18 23:11:11 -04:00
parent fe5cdd92a1
commit 6e3fc9721a
12 changed files with 415 additions and 186 deletions
+1
View File
@@ -35,6 +35,7 @@ type Rsvp struct {
EventID int64
Name string
Note string
PlusOne int64
CreatedAt time.Time
}
+8 -2
View File
@@ -59,8 +59,8 @@ SELECT COUNT(*) FROM claims WHERE slot_id = ?;
SELECT * FROM rsvps WHERE event_id = ? ORDER BY created_at;
-- name: CreateRsvp :one
INSERT INTO rsvps (event_id, name, note)
VALUES (?, ?, ?)
INSERT INTO rsvps (event_id, name, note, plus_one)
VALUES (?, ?, ?, ?)
RETURNING *;
-- name: DeleteRsvp :exec
@@ -120,3 +120,9 @@ SELECT * FROM events WHERE user_id = ? ORDER BY created_at DESC;
-- name: SetEventUser :exec
UPDATE events SET user_id = ? WHERE id = ?;
-- name: GetRsvp :one
SELECT * FROM rsvps WHERE id = ?;
-- name: UpdateRsvp :exec
UPDATE rsvps SET name = ?, note = ?, plus_one = ? WHERE id = ?;
+54 -6
View File
@@ -101,25 +101,32 @@ func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event
}
const createRsvp = `-- name: CreateRsvp :one
INSERT INTO rsvps (event_id, name, note)
VALUES (?, ?, ?)
RETURNING id, event_id, name, note, created_at
INSERT INTO rsvps (event_id, name, note, plus_one)
VALUES (?, ?, ?, ?)
RETURNING id, event_id, name, note, plus_one, created_at
`
type CreateRsvpParams struct {
EventID int64
Name string
Note string
PlusOne int64
}
func (q *Queries) CreateRsvp(ctx context.Context, arg CreateRsvpParams) (Rsvp, error) {
row := q.db.QueryRowContext(ctx, createRsvp, arg.EventID, arg.Name, arg.Note)
row := q.db.QueryRowContext(ctx, createRsvp,
arg.EventID,
arg.Name,
arg.Note,
arg.PlusOne,
)
var i Rsvp
err := row.Scan(
&i.ID,
&i.EventID,
&i.Name,
&i.Note,
&i.PlusOne,
&i.CreatedAt,
)
return i, err
@@ -332,8 +339,26 @@ func (q *Queries) GetEventBySlug(ctx context.Context, slug string) (Event, error
return i, err
}
const getRsvp = `-- name: GetRsvp :one
SELECT id, event_id, name, note, plus_one, created_at FROM rsvps WHERE id = ?
`
func (q *Queries) GetRsvp(ctx context.Context, id int64) (Rsvp, error) {
row := q.db.QueryRowContext(ctx, getRsvp, id)
var i Rsvp
err := row.Scan(
&i.ID,
&i.EventID,
&i.Name,
&i.Note,
&i.PlusOne,
&i.CreatedAt,
)
return i, err
}
const getRsvpByName = `-- name: GetRsvpByName :one
SELECT id, event_id, name, note, created_at FROM rsvps WHERE event_id = ? AND name = ? COLLATE NOCASE LIMIT 1
SELECT id, event_id, name, note, plus_one, created_at FROM rsvps WHERE event_id = ? AND name = ? COLLATE NOCASE LIMIT 1
`
type GetRsvpByNameParams struct {
@@ -349,6 +374,7 @@ func (q *Queries) GetRsvpByName(ctx context.Context, arg GetRsvpByNameParams) (R
&i.EventID,
&i.Name,
&i.Note,
&i.PlusOne,
&i.CreatedAt,
)
return i, err
@@ -549,7 +575,7 @@ func (q *Queries) ListEventsByUser(ctx context.Context, userID sql.NullInt64) ([
}
const listRsvps = `-- name: ListRsvps :many
SELECT id, event_id, name, note, created_at FROM rsvps WHERE event_id = ? ORDER BY created_at
SELECT id, event_id, name, note, plus_one, created_at FROM rsvps WHERE event_id = ? ORDER BY created_at
`
func (q *Queries) ListRsvps(ctx context.Context, eventID int64) ([]Rsvp, error) {
@@ -566,6 +592,7 @@ func (q *Queries) ListRsvps(ctx context.Context, eventID int64) ([]Rsvp, error)
&i.EventID,
&i.Name,
&i.Note,
&i.PlusOne,
&i.CreatedAt,
); err != nil {
return nil, err
@@ -675,6 +702,27 @@ func (q *Queries) UpdateEventDescription(ctx context.Context, arg UpdateEventDes
return err
}
const updateRsvp = `-- name: UpdateRsvp :exec
UPDATE rsvps SET name = ?, note = ?, plus_one = ? WHERE id = ?
`
type UpdateRsvpParams struct {
Name string
Note string
PlusOne int64
ID int64
}
func (q *Queries) UpdateRsvp(ctx context.Context, arg UpdateRsvpParams) error {
_, err := q.db.ExecContext(ctx, updateRsvp,
arg.Name,
arg.Note,
arg.PlusOne,
arg.ID,
)
return err
}
const updateSlot = `-- name: UpdateSlot :exec
UPDATE slots SET name = ?, emoji = ?, max_claims = ? WHERE id = ?
`
+132 -85
View File
@@ -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)
+129 -63
View File
@@ -34,39 +34,42 @@ func createTestEvent(t *testing.T, q *db.Queries) db.Event {
return event
}
func TestAutoRsvpOnClaim(t *testing.T) {
// autoRsvp simulates the merged handleRsvp logic: create RSVP (deduped) + optional claim
func autoRsvp(ctx context.Context, q *db.Queries, event db.Event, name string, note string, slotID *int64) error {
return autoRsvpPlusOne(ctx, q, event, name, note, 0, slotID)
}
func autoRsvpPlusOne(ctx context.Context, q *db.Queries, event db.Event, name string, note string, plusOne int64, slotID *int64) error {
if slotID != nil {
_, err := q.CreateClaim(ctx, db.CreateClaimParams{
SlotID: *slotID, Name: name, Note: note,
})
if err != nil {
return err
}
}
_, err := q.GetRsvpByName(ctx, db.GetRsvpByNameParams{
EventID: event.ID, Name: name,
})
if err == sql.ErrNoRows {
_, err = q.CreateRsvp(ctx, db.CreateRsvpParams{
EventID: event.ID, Name: name, Note: note, PlusOne: plusOne,
})
return err
}
return nil
}
func TestRsvpOnly(t *testing.T) {
_, q := setupTestDB(t)
ctx := context.Background()
event := createTestEvent(t, q)
slot, err := q.CreateSlot(ctx, db.CreateSlotParams{
EventID: event.ID, Name: "Drinks", MaxClaims: 5,
})
if err != nil {
if err := autoRsvp(ctx, q, event, "Alice", "", nil); err != nil {
t.Fatal(err)
}
// Claim a slot — should also create an RSVP
_, err = q.CreateClaim(ctx, db.CreateClaimParams{
SlotID: slot.ID, Name: "Alice",
})
if err != nil {
t.Fatal(err)
}
// Simulate the auto-RSVP logic from handleClaim
_, err = q.GetRsvpByName(ctx, db.GetRsvpByNameParams{
EventID: event.ID, Name: "Alice",
})
if err == sql.ErrNoRows {
_, err = q.CreateRsvp(ctx, db.CreateRsvpParams{
EventID: event.ID, Name: "Alice",
})
if err != nil {
t.Fatal(err)
}
}
rsvps, _ := q.ListRsvps(ctx, event.ID)
if len(rsvps) != 1 {
t.Fatalf("expected 1 RSVP, got %d", len(rsvps))
@@ -76,7 +79,42 @@ func TestAutoRsvpOnClaim(t *testing.T) {
}
}
func TestAutoRsvpNoDuplicate(t *testing.T) {
func TestRsvpWithClaim(t *testing.T) {
_, q := setupTestDB(t)
ctx := context.Background()
event := createTestEvent(t, q)
slot, err := q.CreateSlot(ctx, db.CreateSlotParams{
EventID: event.ID, Name: "Drinks", MaxClaims: 5,
})
if err != nil {
t.Fatal(err)
}
if err := autoRsvp(ctx, q, event, "Alice", "sparkling water", &slot.ID); err != nil {
t.Fatal(err)
}
// Check RSVP created
rsvps, _ := q.ListRsvps(ctx, event.ID)
if len(rsvps) != 1 {
t.Fatalf("expected 1 RSVP, got %d", len(rsvps))
}
if rsvps[0].Name != "Alice" {
t.Fatalf("expected RSVP name Alice, got %s", rsvps[0].Name)
}
// Check claim created
claims, _ := q.ListClaimsByEvent(ctx, event.ID)
if len(claims) != 1 {
t.Fatalf("expected 1 claim, got %d", len(claims))
}
if claims[0].Name != "Alice" {
t.Fatalf("expected claim name Alice, got %s", claims[0].Name)
}
}
func TestRsvpNoDuplicate(t *testing.T) {
_, q := setupTestDB(t)
ctx := context.Background()
event := createTestEvent(t, q)
@@ -85,23 +123,11 @@ func TestAutoRsvpNoDuplicate(t *testing.T) {
EventID: event.ID, Name: "Drinks", MaxClaims: 5,
})
// RSVP first
q.CreateRsvp(ctx, db.CreateRsvpParams{
EventID: event.ID, Name: "Bob",
})
// RSVP first (no slot)
autoRsvp(ctx, q, event, "Bob", "", nil)
// Then claim — auto-RSVP should skip
q.CreateClaim(ctx, db.CreateClaimParams{
SlotID: slot.ID, Name: "Bob",
})
_, err := q.GetRsvpByName(ctx, db.GetRsvpByNameParams{
EventID: event.ID, Name: "Bob",
})
if err == sql.ErrNoRows {
q.CreateRsvp(ctx, db.CreateRsvpParams{
EventID: event.ID, Name: "Bob",
})
}
// Then RSVP again with a slot claim — should not duplicate RSVP
autoRsvp(ctx, q, event, "Bob", "", &slot.ID)
rsvps, _ := q.ListRsvps(ctx, event.ID)
if len(rsvps) != 1 {
@@ -109,7 +135,7 @@ func TestAutoRsvpNoDuplicate(t *testing.T) {
}
}
func TestAutoRsvpCaseInsensitive(t *testing.T) {
func TestRsvpCaseInsensitiveDedup(t *testing.T) {
_, q := setupTestDB(t)
ctx := context.Background()
event := createTestEvent(t, q)
@@ -119,22 +145,10 @@ func TestAutoRsvpCaseInsensitive(t *testing.T) {
})
// RSVP as "alice"
q.CreateRsvp(ctx, db.CreateRsvpParams{
EventID: event.ID, Name: "alice",
})
autoRsvp(ctx, q, event, "alice", "", nil)
// Claim as "Alice" — should not create duplicate
q.CreateClaim(ctx, db.CreateClaimParams{
SlotID: slot.ID, Name: "Alice",
})
_, err := q.GetRsvpByName(ctx, db.GetRsvpByNameParams{
EventID: event.ID, Name: "Alice",
})
if err == sql.ErrNoRows {
q.CreateRsvp(ctx, db.CreateRsvpParams{
EventID: event.ID, Name: "Alice",
})
}
// Claim as "Alice" — should not create duplicate RSVP
autoRsvp(ctx, q, event, "Alice", "", &slot.ID)
rsvps, _ := q.ListRsvps(ctx, event.ID)
if len(rsvps) != 1 {
@@ -148,10 +162,10 @@ func TestDeduplicateExistingRsvps(t *testing.T) {
event := createTestEvent(t, q)
// Insert duplicate RSVPs directly
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Charlie"})
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Charlie"})
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "charlie"})
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Dana"})
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Charlie", PlusOne: 0})
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Charlie", PlusOne: 0})
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "charlie", PlusOne: 0})
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Dana", PlusOne: 0})
rsvps, _ := q.ListRsvps(ctx, event.ID)
if len(rsvps) != 4 {
@@ -166,3 +180,55 @@ func TestDeduplicateExistingRsvps(t *testing.T) {
t.Fatalf("expected 2 RSVPs after dedup (Charlie + Dana), got %d", len(rsvps))
}
}
func TestUpdateRsvp(t *testing.T) {
_, q := setupTestDB(t)
ctx := context.Background()
event := createTestEvent(t, q)
rsvp, err := q.CreateRsvp(ctx, db.CreateRsvpParams{
EventID: event.ID, Name: "Alice", Note: "hi", PlusOne: 1,
})
if err != nil {
t.Fatal(err)
}
err = q.UpdateRsvp(ctx, db.UpdateRsvpParams{
Name: "Alicia", Note: "updated", PlusOne: 3, ID: rsvp.ID,
})
if err != nil {
t.Fatal(err)
}
updated, err := q.GetRsvp(ctx, rsvp.ID)
if err != nil {
t.Fatal(err)
}
if updated.Name != "Alicia" {
t.Fatalf("expected name Alicia, got %s", updated.Name)
}
if updated.Note != "updated" {
t.Fatalf("expected note 'updated', got %s", updated.Note)
}
if updated.PlusOne != 3 {
t.Fatalf("expected PlusOne=3, got %d", updated.PlusOne)
}
}
func TestRsvpPlusOne(t *testing.T) {
_, q := setupTestDB(t)
ctx := context.Background()
event := createTestEvent(t, q)
if err := autoRsvpPlusOne(ctx, q, event, "Alice", "", 2, nil); err != nil {
t.Fatal(err)
}
rsvps, _ := q.ListRsvps(ctx, event.ID)
if len(rsvps) != 1 {
t.Fatalf("expected 1 RSVP, got %d", len(rsvps))
}
if rsvps[0].PlusOne != 2 {
t.Fatalf("expected PlusOne=2, got %d", rsvps[0].PlusOne)
}
}
+7 -2
View File
@@ -134,6 +134,10 @@ func main() {
pageTmpl["slots"] = template.Must(
template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/slots.html"),
)
// edit-rsvp modal partial
pageTmpl["edit-rsvp"] = template.Must(
template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/edit-rsvp.html"),
)
srv := NewServer(database, baseURL, features)
@@ -175,9 +179,10 @@ func main() {
// Guest event view
r.Get("/e/{slug}", srv.handleEvent)
r.Get("/e/{slug}/slots", srv.handleSlotsPartial)
r.Post("/e/{slug}/claim", srv.handleClaim)
r.Delete("/e/{slug}/claim/{claimID}", srv.handleUnclaim)
r.Post("/e/{slug}/rsvp", srv.handleRsvp)
r.Delete("/e/{slug}/claim/{claimID}", srv.handleUnclaim)
r.Get("/e/{slug}/rsvp/{rsvpID}/edit", srv.handleEditRsvpForm)
r.Put("/e/{slug}/rsvp/{rsvpID}", srv.handleUpdateRsvp)
r.Delete("/e/{slug}/rsvp/{rsvpID}", srv.handleUnrsvp)
// OG image
+2
View File
@@ -20,6 +20,8 @@ func runMigrations(database *sql.DB) {
`CREATE INDEX IF NOT EXISTS idx_verification_codes_identifier ON verification_codes(identifier)`,
// Dedupe RSVPs: keep earliest per (event_id, name).
`DELETE FROM rsvps WHERE id NOT IN (SELECT MIN(id) FROM rsvps GROUP BY event_id, name COLLATE NOCASE)`,
// Plus-one tracking for RSVPs.
`ALTER TABLE rsvps ADD COLUMN plus_one INTEGER NOT NULL DEFAULT 0`,
}
for _, m := range migrations {
_, err := database.Exec(m)
+1
View File
@@ -33,6 +33,7 @@ CREATE TABLE IF NOT EXISTS rsvps (
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
name TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '',
plus_one INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
+27
View File
@@ -0,0 +1,27 @@
{{define "edit-rsvp"}}
<div class="modal-backdrop" onclick="if(event.target===this)document.getElementById('edit-modal').innerHTML=''">
<div class="modal-card">
<div class="form-title">Edit RSVP</div>
<form hx-put="/e/{{.Slug}}/rsvp/{{.Rsvp.ID}}"
hx-target="#slots-container"
hx-swap="innerHTML settle:0.1s">
<div class="form-row">
<label>Your name</label>
<input type="text" name="name" value="{{.Rsvp.Name}}" required>
</div>
<div class="form-row">
<label>Bringing anyone?</label>
<input type="number" name="plus_one" value="{{.Rsvp.PlusOne}}" min="0" max="10">
</div>
<div class="form-row">
<label>Note (optional)</label>
<input type="text" name="note" value="{{.Rsvp.Note}}">
</div>
<button class="btn-submit" type="submit">Save</button>
<div style="text-align:center;margin-top:12px;">
<button type="button" class="btn-cancel" onclick="document.getElementById('edit-modal').innerHTML=''">Cancel</button>
</div>
</form>
</div>
</div>
{{end}}
+12 -27
View File
@@ -51,43 +51,28 @@
<label>Your name</label>
<input type="text" name="name" placeholder="e.g. Sam" required value="{{if .User}}{{.User.Name}}{{end}}">
</div>
{{if .Slots}}
<div class="form-row">
<label>Note (optional)</label>
<input type="text" name="note" placeholder="e.g. +1, arriving late, etc.">
</div>
<button class="btn-submit" type="submit">Count me in &#8599;</button>
</form>
</div>
{{if .Slots}}
<div class="section-label" style="margin-top:40px">I'll bring something</div>
<div class="claim-form-wrapper">
<div class="form-title">Claim a slot &#8594;</div>
<form hx-post="/e/{{.Event.Slug}}/claim"
hx-target="#slots-container"
hx-swap="innerHTML settle:0.1s"
hx-on::after-request="if(event.detail.successful) this.reset()">
<div class="form-row">
<label>Your name</label>
<input type="text" name="name" placeholder="e.g. Sam" required value="{{if .User}}{{.User.Name}}{{end}}">
</div>
<div class="form-row">
<label>Slot</label>
<select name="slot_id">
<label>Bringing something?</label>
<select name="slot_id" onchange="document.getElementById('claim-note').style.display = this.value ? '' : 'none'; document.getElementById('claim-note-input').placeholder = this.value ? 'e.g. bringing sparkling water + lemonade' : 'e.g. +1, arriving late, etc.'">
<option value="">Just myself</option>
{{range .Slots}}{{if not .IsFull}}
<option value="{{.Slot.ID}}">{{.Slot.Emoji}} {{.Slot.Name}} ({{$left := sub .Slot.MaxClaims .ClaimCount}}{{$left}} spot{{if ne $left 1}}s{{end}} left)</option>
{{end}}{{end}}
</select>
</div>
{{end}}
<div class="form-row">
<label>Note (optional)</label>
<input type="text" name="note" placeholder="e.g. bringing sparkling water + lemonade">
<label>Bringing anyone?</label>
<input type="number" name="plus_one" value="0" min="0" max="10">
</div>
<button class="btn-submit" type="submit">Claim &#8599;</button>
<div class="form-row" id="claim-note">
<label>Note (optional)</label>
<input type="text" id="claim-note-input" name="note" placeholder="e.g. arriving late, dietary restrictions, etc.">
</div>
<button class="btn-submit" type="submit">Count me in &#8599;</button>
</form>
</div>
{{end}}
{{if .IsAdmin}}
<div class="section-label" style="margin-top:40px">Admin: Description</div>
+38
View File
@@ -372,6 +372,40 @@
text-decoration: underline;
}
.btn-danger:hover { color: #a00; }
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.45);
z-index: 90;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.modal-card {
background: white;
border: var(--border-w) solid var(--border);
box-shadow: 5px 5px 0 var(--ink);
padding: 24px;
max-width: 480px;
width: 100%;
animation: fadeUp 0.2s ease both;
}
.btn-cancel {
background: none;
border: none;
font-family: 'DM Mono', monospace;
font-size: 0.78rem;
cursor: pointer;
text-decoration: underline;
color: #888;
}
.btn-cancel:hover { color: var(--ink); }
.rsvp-name-link {
cursor: pointer;
text-decoration: none;
}
.rsvp-name-link:hover { text-decoration: underline; }
@keyframes fadeUp {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
@@ -438,6 +472,10 @@ document.addEventListener('click', (e) => {
}
});
document.addEventListener('DOMContentLoaded', initEmojiPickers);
document.body.addEventListener('closeModal', function() {
document.getElementById('edit-modal').innerHTML = '';
});
</script>
<div id="edit-modal"></div>
</body>
</html>{{end}}
+4 -1
View File
@@ -37,7 +37,10 @@
{{if .GoingList}}
{{range .GoingList}}
<span class="claim-chip">
{{.Name}}{{if .Note}} <small style="color:#888">({{.Note}})</small>{{end}}
{{if gt .RsvpID 0}}<span class="rsvp-name-link"
hx-get="/e/{{$.Event.Slug}}/rsvp/{{.RsvpID}}/edit"
hx-target="#edit-modal"
hx-swap="innerHTML">{{.Name}}</span>{{else}}{{.Name}}{{end}}{{if gt .PlusOne 0}} +{{.PlusOne}}{{end}}{{if .Note}} <small style="color:#888">({{.Note}})</small>{{end}}
{{if gt .RsvpID 0}}
<button hx-delete="/e/{{$.Event.Slug}}/rsvp/{{.RsvpID}}"
hx-target="#slots-container"