diff --git a/db/models.go b/db/models.go index 4fe1917..63cf420 100644 --- a/db/models.go +++ b/db/models.go @@ -35,6 +35,7 @@ type Rsvp struct { EventID int64 Name string Note string + PlusOne int64 CreatedAt time.Time } diff --git a/db/queries.sql b/db/queries.sql index 5f99723..4c0ae99 100644 --- a/db/queries.sql +++ b/db/queries.sql @@ -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 = ?; diff --git a/db/queries.sql.go b/db/queries.sql.go index 08c7790..e3ed5ff 100644 --- a/db/queries.sql.go +++ b/db/queries.sql.go @@ -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 = ? ` diff --git a/handlers.go b/handlers.go index ab07c22..3e46484 100644 --- a/handlers.go +++ b/handlers.go @@ -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) diff --git a/handlers_test.go b/handlers_test.go index 620b413..19aa0c1 100644 --- a/handlers_test.go +++ b/handlers_test.go @@ -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) + } +} diff --git a/main.go b/main.go index 6f3f9d1..fec5b10 100644 --- a/main.go +++ b/main.go @@ -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 diff --git a/migrate.go b/migrate.go index 6310c86..4607c6a 100644 --- a/migrate.go +++ b/migrate.go @@ -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) diff --git a/schema.sql b/schema.sql index f77ce16..1d62d59 100644 --- a/schema.sql +++ b/schema.sql @@ -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 ); diff --git a/templates/edit-rsvp.html b/templates/edit-rsvp.html new file mode 100644 index 0000000..d78f77a --- /dev/null +++ b/templates/edit-rsvp.html @@ -0,0 +1,27 @@ +{{define "edit-rsvp"}} + +{{end}} diff --git a/templates/event.html b/templates/event.html index 03aa757..ac357f1 100644 --- a/templates/event.html +++ b/templates/event.html @@ -51,43 +51,28 @@ + {{if .Slots}}
- - -
- - - - -{{if .Slots}} -
I'll bring something
- -
-
Claim a slot →
-
-
- - -
-
- - + {{range .Slots}}{{if not .IsFull}} {{end}}{{end}}
+ {{end}}
- - + +
- +
+ + +
+
-{{end}} {{if .IsAdmin}}
Admin: Description
diff --git a/templates/layout.html b/templates/layout.html index fa8950f..56f62d0 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -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 = ''; +}); +
{{end}} diff --git a/templates/slots.html b/templates/slots.html index 3171d5c..6897af2 100644 --- a/templates/slots.html +++ b/templates/slots.html @@ -37,7 +37,10 @@ {{if .GoingList}} {{range .GoingList}} - {{.Name}}{{if .Note}} ({{.Note}}){{end}} + {{if gt .RsvpID 0}}{{.Name}}{{else}}{{.Name}}{{end}}{{if gt .PlusOne 0}} +{{.PlusOne}}{{end}}{{if .Note}} ({{.Note}}){{end}} {{if gt .RsvpID 0}}