Add yes/no RSVP answers with required login
RSVPs now carry a status ('yes'/'no'). When auth is enabled, answering
requires login for all events, not just capped ones. Answers are deduped
one per user and re-answering updates in place. A 'no' zeroes plus_one,
is excluded from Going counts and cap math, frees the person's slot
claims, and shows in a separate "Can't make it" list.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -70,4 +70,4 @@ Dynamic PNG generation at `/e/{slug}/og.png` using Go's `image` package with a c
|
|||||||
|
|
||||||
### Auth Model
|
### Auth Model
|
||||||
|
|
||||||
No user auth. Admin access is via secret `admin_token` in the URL. Claims/RSVPs are open to anyone with the event link.
|
Email-code login behind the `BBQ_FEATURES=auth` flag. When auth is on, RSVP answers (yes or no) require login and are deduped one-per-user; when off, everything is open like a shared Google Sheet. RSVPs have a `status` column (`yes`/`no`) — no's don't count toward Going and free any claimed slots. Admin access is via secret `admin_token` in the URL (plus event ownership when auth is on).
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ type Rsvp struct {
|
|||||||
Name string
|
Name string
|
||||||
Note string
|
Note string
|
||||||
PlusOne int64
|
PlusOne int64
|
||||||
|
Status string
|
||||||
UserID sql.NullInt64
|
UserID sql.NullInt64
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-4
@@ -64,12 +64,16 @@ DELETE FROM claims WHERE id = ?;
|
|||||||
-- name: CountClaimsBySlot :one
|
-- name: CountClaimsBySlot :one
|
||||||
SELECT COUNT(*) FROM claims WHERE slot_id = ?;
|
SELECT COUNT(*) FROM claims WHERE slot_id = ?;
|
||||||
|
|
||||||
|
-- name: DeleteClaimsByEventAndName :exec
|
||||||
|
DELETE FROM claims WHERE slot_id IN (SELECT id FROM slots WHERE event_id = ?)
|
||||||
|
AND claims.name = ? COLLATE NOCASE;
|
||||||
|
|
||||||
-- name: ListRsvps :many
|
-- name: ListRsvps :many
|
||||||
SELECT * FROM rsvps WHERE event_id = ? ORDER BY created_at;
|
SELECT * FROM rsvps WHERE event_id = ? ORDER BY created_at;
|
||||||
|
|
||||||
-- name: CreateRsvp :one
|
-- name: CreateRsvp :one
|
||||||
INSERT INTO rsvps (event_id, name, note, plus_one, user_id)
|
INSERT INTO rsvps (event_id, name, note, plus_one, status, user_id)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: DeleteRsvp :exec
|
-- name: DeleteRsvp :exec
|
||||||
@@ -85,7 +89,7 @@ SELECT * FROM rsvps WHERE event_id = ? AND name = ? COLLATE NOCASE LIMIT 1;
|
|||||||
SELECT * FROM rsvps WHERE event_id = ? AND user_id = ? LIMIT 1;
|
SELECT * FROM rsvps WHERE event_id = ? AND user_id = ? LIMIT 1;
|
||||||
|
|
||||||
-- name: CountGoing :one
|
-- name: CountGoing :one
|
||||||
SELECT CAST(COALESCE(SUM(1 + plus_one), 0) AS INTEGER) FROM rsvps WHERE event_id = ?;
|
SELECT CAST(COALESCE(SUM(1 + plus_one), 0) AS INTEGER) FROM rsvps WHERE event_id = ? AND status = 'yes';
|
||||||
|
|
||||||
-- name: DeleteDuplicateRsvps :exec
|
-- name: DeleteDuplicateRsvps :exec
|
||||||
DELETE FROM rsvps WHERE id NOT IN (
|
DELETE FROM rsvps WHERE id NOT IN (
|
||||||
@@ -140,7 +144,7 @@ UPDATE events SET user_id = ? WHERE id = ?;
|
|||||||
SELECT * FROM rsvps WHERE id = ?;
|
SELECT * FROM rsvps WHERE id = ?;
|
||||||
|
|
||||||
-- name: UpdateRsvp :exec
|
-- name: UpdateRsvp :exec
|
||||||
UPDATE rsvps SET name = ?, note = ?, plus_one = ? WHERE id = ?;
|
UPDATE rsvps SET name = ?, note = ?, plus_one = ?, status = ? WHERE id = ?;
|
||||||
|
|
||||||
-- name: CreateSmsConsent :exec
|
-- name: CreateSmsConsent :exec
|
||||||
INSERT INTO sms_consents (phone, ip_address, user_agent) VALUES (?, ?, ?);
|
INSERT INTO sms_consents (phone, ip_address, user_agent) VALUES (?, ?, ?);
|
||||||
|
|||||||
+33
-9
@@ -23,7 +23,7 @@ func (q *Queries) CountClaimsBySlot(ctx context.Context, slotID int64) (int64, e
|
|||||||
}
|
}
|
||||||
|
|
||||||
const countGoing = `-- name: CountGoing :one
|
const countGoing = `-- name: CountGoing :one
|
||||||
SELECT CAST(COALESCE(SUM(1 + plus_one), 0) AS INTEGER) FROM rsvps WHERE event_id = ?
|
SELECT CAST(COALESCE(SUM(1 + plus_one), 0) AS INTEGER) FROM rsvps WHERE event_id = ? AND status = 'yes'
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) CountGoing(ctx context.Context, eventID int64) (int64, error) {
|
func (q *Queries) CountGoing(ctx context.Context, eventID int64) (int64, error) {
|
||||||
@@ -115,9 +115,9 @@ func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createRsvp = `-- name: CreateRsvp :one
|
const createRsvp = `-- name: CreateRsvp :one
|
||||||
INSERT INTO rsvps (event_id, name, note, plus_one, user_id)
|
INSERT INTO rsvps (event_id, name, note, plus_one, status, user_id)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
RETURNING id, event_id, name, note, plus_one, user_id, created_at
|
RETURNING id, event_id, name, note, plus_one, status, user_id, created_at
|
||||||
`
|
`
|
||||||
|
|
||||||
type CreateRsvpParams struct {
|
type CreateRsvpParams struct {
|
||||||
@@ -125,6 +125,7 @@ type CreateRsvpParams struct {
|
|||||||
Name string
|
Name string
|
||||||
Note string
|
Note string
|
||||||
PlusOne int64
|
PlusOne int64
|
||||||
|
Status string
|
||||||
UserID sql.NullInt64
|
UserID sql.NullInt64
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +135,7 @@ func (q *Queries) CreateRsvp(ctx context.Context, arg CreateRsvpParams) (Rsvp, e
|
|||||||
arg.Name,
|
arg.Name,
|
||||||
arg.Note,
|
arg.Note,
|
||||||
arg.PlusOne,
|
arg.PlusOne,
|
||||||
|
arg.Status,
|
||||||
arg.UserID,
|
arg.UserID,
|
||||||
)
|
)
|
||||||
var i Rsvp
|
var i Rsvp
|
||||||
@@ -143,6 +145,7 @@ func (q *Queries) CreateRsvp(ctx context.Context, arg CreateRsvpParams) (Rsvp, e
|
|||||||
&i.Name,
|
&i.Name,
|
||||||
&i.Note,
|
&i.Note,
|
||||||
&i.PlusOne,
|
&i.PlusOne,
|
||||||
|
&i.Status,
|
||||||
&i.UserID,
|
&i.UserID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
)
|
)
|
||||||
@@ -271,6 +274,21 @@ func (q *Queries) DeleteClaim(ctx context.Context, id int64) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deleteClaimsByEventAndName = `-- name: DeleteClaimsByEventAndName :exec
|
||||||
|
DELETE FROM claims WHERE slot_id IN (SELECT id FROM slots WHERE event_id = ?)
|
||||||
|
AND claims.name = ? COLLATE NOCASE
|
||||||
|
`
|
||||||
|
|
||||||
|
type DeleteClaimsByEventAndNameParams struct {
|
||||||
|
EventID int64
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *Queries) DeleteClaimsByEventAndName(ctx context.Context, arg DeleteClaimsByEventAndNameParams) error {
|
||||||
|
_, err := q.db.ExecContext(ctx, deleteClaimsByEventAndName, arg.EventID, arg.Name)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
const deleteDuplicateRsvps = `-- name: DeleteDuplicateRsvps :exec
|
const deleteDuplicateRsvps = `-- name: DeleteDuplicateRsvps :exec
|
||||||
DELETE FROM rsvps WHERE id NOT IN (
|
DELETE FROM rsvps WHERE id NOT IN (
|
||||||
SELECT MIN(id) FROM rsvps GROUP BY event_id, name COLLATE NOCASE
|
SELECT MIN(id) FROM rsvps GROUP BY event_id, name COLLATE NOCASE
|
||||||
@@ -391,7 +409,7 @@ func (q *Queries) GetEventBySlug(ctx context.Context, slug string) (Event, error
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getRsvp = `-- name: GetRsvp :one
|
const getRsvp = `-- name: GetRsvp :one
|
||||||
SELECT id, event_id, name, note, plus_one, user_id, created_at FROM rsvps WHERE id = ?
|
SELECT id, event_id, name, note, plus_one, status, user_id, created_at FROM rsvps WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) GetRsvp(ctx context.Context, id int64) (Rsvp, error) {
|
func (q *Queries) GetRsvp(ctx context.Context, id int64) (Rsvp, error) {
|
||||||
@@ -403,6 +421,7 @@ func (q *Queries) GetRsvp(ctx context.Context, id int64) (Rsvp, error) {
|
|||||||
&i.Name,
|
&i.Name,
|
||||||
&i.Note,
|
&i.Note,
|
||||||
&i.PlusOne,
|
&i.PlusOne,
|
||||||
|
&i.Status,
|
||||||
&i.UserID,
|
&i.UserID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
)
|
)
|
||||||
@@ -410,7 +429,7 @@ func (q *Queries) GetRsvp(ctx context.Context, id int64) (Rsvp, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getRsvpByName = `-- name: GetRsvpByName :one
|
const getRsvpByName = `-- name: GetRsvpByName :one
|
||||||
SELECT id, event_id, name, note, plus_one, user_id, created_at FROM rsvps WHERE event_id = ? AND name = ? COLLATE NOCASE LIMIT 1
|
SELECT id, event_id, name, note, plus_one, status, user_id, created_at FROM rsvps WHERE event_id = ? AND name = ? COLLATE NOCASE LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetRsvpByNameParams struct {
|
type GetRsvpByNameParams struct {
|
||||||
@@ -427,6 +446,7 @@ func (q *Queries) GetRsvpByName(ctx context.Context, arg GetRsvpByNameParams) (R
|
|||||||
&i.Name,
|
&i.Name,
|
||||||
&i.Note,
|
&i.Note,
|
||||||
&i.PlusOne,
|
&i.PlusOne,
|
||||||
|
&i.Status,
|
||||||
&i.UserID,
|
&i.UserID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
)
|
)
|
||||||
@@ -434,7 +454,7 @@ func (q *Queries) GetRsvpByName(ctx context.Context, arg GetRsvpByNameParams) (R
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getRsvpByUser = `-- name: GetRsvpByUser :one
|
const getRsvpByUser = `-- name: GetRsvpByUser :one
|
||||||
SELECT id, event_id, name, note, plus_one, user_id, created_at FROM rsvps WHERE event_id = ? AND user_id = ? LIMIT 1
|
SELECT id, event_id, name, note, plus_one, status, user_id, created_at FROM rsvps WHERE event_id = ? AND user_id = ? LIMIT 1
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetRsvpByUserParams struct {
|
type GetRsvpByUserParams struct {
|
||||||
@@ -451,6 +471,7 @@ func (q *Queries) GetRsvpByUser(ctx context.Context, arg GetRsvpByUserParams) (R
|
|||||||
&i.Name,
|
&i.Name,
|
||||||
&i.Note,
|
&i.Note,
|
||||||
&i.PlusOne,
|
&i.PlusOne,
|
||||||
|
&i.Status,
|
||||||
&i.UserID,
|
&i.UserID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
)
|
)
|
||||||
@@ -653,7 +674,7 @@ func (q *Queries) ListEventsByUser(ctx context.Context, userID sql.NullInt64) ([
|
|||||||
}
|
}
|
||||||
|
|
||||||
const listRsvps = `-- name: ListRsvps :many
|
const listRsvps = `-- name: ListRsvps :many
|
||||||
SELECT id, event_id, name, note, plus_one, user_id, created_at FROM rsvps WHERE event_id = ? ORDER BY created_at
|
SELECT id, event_id, name, note, plus_one, status, user_id, created_at FROM rsvps WHERE event_id = ? ORDER BY created_at
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) ListRsvps(ctx context.Context, eventID int64) ([]Rsvp, error) {
|
func (q *Queries) ListRsvps(ctx context.Context, eventID int64) ([]Rsvp, error) {
|
||||||
@@ -671,6 +692,7 @@ func (q *Queries) ListRsvps(ctx context.Context, eventID int64) ([]Rsvp, error)
|
|||||||
&i.Name,
|
&i.Name,
|
||||||
&i.Note,
|
&i.Note,
|
||||||
&i.PlusOne,
|
&i.PlusOne,
|
||||||
|
&i.Status,
|
||||||
&i.UserID,
|
&i.UserID,
|
||||||
&i.CreatedAt,
|
&i.CreatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
@@ -811,13 +833,14 @@ func (q *Queries) UpdateEventLocation(ctx context.Context, arg UpdateEventLocati
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateRsvp = `-- name: UpdateRsvp :exec
|
const updateRsvp = `-- name: UpdateRsvp :exec
|
||||||
UPDATE rsvps SET name = ?, note = ?, plus_one = ? WHERE id = ?
|
UPDATE rsvps SET name = ?, note = ?, plus_one = ?, status = ? WHERE id = ?
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpdateRsvpParams struct {
|
type UpdateRsvpParams struct {
|
||||||
Name string
|
Name string
|
||||||
Note string
|
Note string
|
||||||
PlusOne int64
|
PlusOne int64
|
||||||
|
Status string
|
||||||
ID int64
|
ID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -826,6 +849,7 @@ func (q *Queries) UpdateRsvp(ctx context.Context, arg UpdateRsvpParams) error {
|
|||||||
arg.Name,
|
arg.Name,
|
||||||
arg.Note,
|
arg.Note,
|
||||||
arg.PlusOne,
|
arg.PlusOne,
|
||||||
|
arg.Status,
|
||||||
arg.ID,
|
arg.ID,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
|
|||||||
+117
-39
@@ -170,12 +170,14 @@ type EventPageData struct {
|
|||||||
Slots []SlotView
|
Slots []SlotView
|
||||||
Rsvps []db.Rsvp
|
Rsvps []db.Rsvp
|
||||||
GoingList []GoingPerson
|
GoingList []GoingPerson
|
||||||
|
NotGoingList []GoingPerson
|
||||||
TotalGoing int64
|
TotalGoing int64
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
BaseURL string
|
BaseURL string
|
||||||
DescriptionHTML template.HTML
|
DescriptionHTML template.HTML
|
||||||
User *db.User
|
User *db.User
|
||||||
AuthEnabled bool
|
AuthEnabled bool
|
||||||
|
CanEditRsvps bool
|
||||||
Capped bool
|
Capped bool
|
||||||
SpotsLeft int64
|
SpotsLeft int64
|
||||||
CapFull bool
|
CapFull bool
|
||||||
@@ -228,14 +230,15 @@ func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*Eve
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var totalGoing int64
|
var totalGoing int64
|
||||||
|
var goingList, notGoingList []GoingPerson
|
||||||
for _, r := range rsvps {
|
for _, r := range rsvps {
|
||||||
totalGoing += 1 + r.PlusOne
|
p := GoingPerson{Name: r.Name, Note: r.Note, RsvpID: r.ID, PlusOne: r.PlusOne}
|
||||||
|
if r.Status == "no" {
|
||||||
|
notGoingList = append(notGoingList, p)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
totalGoing += 1 + r.PlusOne
|
||||||
// Build GoingList from RSVPs only
|
goingList = append(goingList, p)
|
||||||
var goingList []GoingPerson
|
|
||||||
for _, r := range rsvps {
|
|
||||||
goingList = append(goingList, GoingPerson{Name: r.Name, Note: r.Note, RsvpID: r.ID, PlusOne: r.PlusOne})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var descHTML template.HTML
|
var descHTML template.HTML
|
||||||
@@ -259,17 +262,20 @@ func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*Eve
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
user := s.currentUser(r)
|
||||||
return &EventPageData{
|
return &EventPageData{
|
||||||
Event: event,
|
Event: event,
|
||||||
Slots: slotViews,
|
Slots: slotViews,
|
||||||
Rsvps: rsvps,
|
Rsvps: rsvps,
|
||||||
GoingList: goingList,
|
GoingList: goingList,
|
||||||
|
NotGoingList: notGoingList,
|
||||||
TotalGoing: totalGoing,
|
TotalGoing: totalGoing,
|
||||||
IsAdmin: isAdmin,
|
IsAdmin: isAdmin,
|
||||||
BaseURL: s.baseURL,
|
BaseURL: s.baseURL,
|
||||||
DescriptionHTML: descHTML,
|
DescriptionHTML: descHTML,
|
||||||
User: s.currentUser(r),
|
User: user,
|
||||||
AuthEnabled: s.features.Auth,
|
AuthEnabled: s.features.Auth,
|
||||||
|
CanEditRsvps: isAdmin || !s.features.Auth || user != nil,
|
||||||
Capped: capped,
|
Capped: capped,
|
||||||
SpotsLeft: spotsLeft,
|
SpotsLeft: spotsLeft,
|
||||||
CapFull: capped && totalGoing >= event.AttendeeCap,
|
CapFull: capped && totalGoing >= event.AttendeeCap,
|
||||||
@@ -356,12 +362,19 @@ func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
note := sanitize(r.FormValue("note"), maxNoteLen)
|
note := sanitize(r.FormValue("note"), maxNoteLen)
|
||||||
|
status := "yes"
|
||||||
|
if r.FormValue("status") == "no" {
|
||||||
|
status = "no"
|
||||||
|
}
|
||||||
plusOne := int64(0)
|
plusOne := int64(0)
|
||||||
if v, err := strconv.ParseInt(r.FormValue("plus_one"), 10, 64); err == nil && v > 0 {
|
if v, err := strconv.ParseInt(r.FormValue("plus_one"), 10, 64); err == nil && v > 0 {
|
||||||
plusOne = v
|
plusOne = v
|
||||||
}
|
}
|
||||||
if plusOne > 10 {
|
if plusOne > maxPlusOne {
|
||||||
plusOne = 10
|
plusOne = maxPlusOne
|
||||||
|
}
|
||||||
|
if status == "no" {
|
||||||
|
plusOne = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
event, err := s.q.GetEventBySlug(r.Context(), slug)
|
event, err := s.q.GetEventBySlug(r.Context(), slug)
|
||||||
@@ -370,28 +383,45 @@ func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capped events require login and enforce the cap on total people going.
|
// Answering — yes or no — requires login when auth is enabled.
|
||||||
capped := s.features.Auth && event.AttendeeCap > 0
|
|
||||||
user := s.currentUser(r)
|
user := s.currentUser(r)
|
||||||
alreadyRsvped := false
|
if s.features.Auth && user == nil {
|
||||||
if capped {
|
|
||||||
if user == nil {
|
|
||||||
http.Error(w, "You must be logged in to RSVP", http.StatusUnauthorized)
|
http.Error(w, "You must be logged in to RSVP", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, err := s.q.GetRsvpByUser(r.Context(), db.GetRsvpByUserParams{
|
|
||||||
|
// One answer per person: find an existing RSVP by user, falling back to name.
|
||||||
|
var existing *db.Rsvp
|
||||||
|
if user != nil {
|
||||||
|
if rv, err := s.q.GetRsvpByUser(r.Context(), db.GetRsvpByUserParams{
|
||||||
EventID: event.ID,
|
EventID: event.ID,
|
||||||
UserID: sql.NullInt64{Int64: user.ID, Valid: true},
|
UserID: sql.NullInt64{Int64: user.ID, Valid: true},
|
||||||
})
|
}); err == nil {
|
||||||
alreadyRsvped = err == nil
|
existing = &rv
|
||||||
if !alreadyRsvped {
|
}
|
||||||
|
}
|
||||||
|
if existing == nil {
|
||||||
|
if rv, err := s.q.GetRsvpByName(r.Context(), db.GetRsvpByNameParams{
|
||||||
|
EventID: event.ID, Name: name,
|
||||||
|
}); err == nil {
|
||||||
|
existing = &rv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capped events enforce the cap on total people going.
|
||||||
|
capped := s.features.Auth && event.AttendeeCap > 0
|
||||||
|
if capped && status == "yes" {
|
||||||
going, err := s.q.CountGoing(r.Context(), event.ID)
|
going, err := s.q.CountGoing(r.Context(), event.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "error", http.StatusInternalServerError)
|
http.Error(w, "error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if going+1+plusOne > event.AttendeeCap {
|
var current int64
|
||||||
left := event.AttendeeCap - going
|
if existing != nil && existing.Status == "yes" {
|
||||||
|
current = 1 + existing.PlusOne
|
||||||
|
}
|
||||||
|
if going-current+1+plusOne > event.AttendeeCap {
|
||||||
|
left := event.AttendeeCap - (going - current)
|
||||||
if left <= 0 {
|
if left <= 0 {
|
||||||
http.Error(w, "Event is full", http.StatusConflict)
|
http.Error(w, "Event is full", http.StatusConflict)
|
||||||
} else {
|
} else {
|
||||||
@@ -400,10 +430,9 @@ func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Optional slot claim
|
// Optional slot claim (only when going)
|
||||||
if slotIDStr := r.FormValue("slot_id"); slotIDStr != "" {
|
if slotIDStr := r.FormValue("slot_id"); slotIDStr != "" && status == "yes" {
|
||||||
slotID, err := strconv.ParseInt(slotIDStr, 10, 64)
|
slotID, err := strconv.ParseInt(slotIDStr, 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Invalid slot", http.StatusBadRequest)
|
http.Error(w, "Invalid slot", http.StatusBadRequest)
|
||||||
@@ -440,16 +469,17 @@ func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create RSVP (deduped — skip if already on the list)
|
// Record the answer: update the existing RSVP or create a new one.
|
||||||
if !alreadyRsvped {
|
if existing != nil {
|
||||||
var userID sql.NullInt64
|
err = s.q.UpdateRsvp(r.Context(), db.UpdateRsvpParams{
|
||||||
if user != nil {
|
Name: name, Note: note, PlusOne: plusOne, Status: status, ID: existing.ID,
|
||||||
userID = sql.NullInt64{Int64: user.ID, Valid: true}
|
|
||||||
}
|
|
||||||
_, err = s.q.GetRsvpByName(r.Context(), db.GetRsvpByNameParams{
|
|
||||||
EventID: event.ID, Name: name,
|
|
||||||
})
|
})
|
||||||
if err == sql.ErrNoRows {
|
if err != nil {
|
||||||
|
log.Printf("update rsvp: %v", err)
|
||||||
|
http.Error(w, "Failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
count, err := s.q.CountRsvps(r.Context(), event.ID)
|
count, err := s.q.CountRsvps(r.Context(), event.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "error", http.StatusInternalServerError)
|
http.Error(w, "error", http.StatusInternalServerError)
|
||||||
@@ -460,8 +490,12 @@ func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var userID sql.NullInt64
|
||||||
|
if user != nil {
|
||||||
|
userID = sql.NullInt64{Int64: user.ID, Valid: true}
|
||||||
|
}
|
||||||
_, err = s.q.CreateRsvp(r.Context(), db.CreateRsvpParams{
|
_, err = s.q.CreateRsvp(r.Context(), db.CreateRsvpParams{
|
||||||
EventID: event.ID, Name: name, Note: note, PlusOne: plusOne, UserID: userID,
|
EventID: event.ID, Name: name, Note: note, PlusOne: plusOne, Status: status, UserID: userID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("create rsvp: %v", err)
|
log.Printf("create rsvp: %v", err)
|
||||||
@@ -469,6 +503,17 @@ func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Not coming — free any slots claimed under their name.
|
||||||
|
if status == "no" {
|
||||||
|
s.q.DeleteClaimsByEventAndName(r.Context(), db.DeleteClaimsByEventAndNameParams{
|
||||||
|
EventID: event.ID, Name: name,
|
||||||
|
})
|
||||||
|
if existing != nil && !strings.EqualFold(existing.Name, name) {
|
||||||
|
s.q.DeleteClaimsByEventAndName(r.Context(), db.DeleteClaimsByEventAndNameParams{
|
||||||
|
EventID: event.ID, Name: existing.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
s.notify(slug)
|
s.notify(slug)
|
||||||
@@ -509,6 +554,11 @@ func (s *Server) handleUpdateRsvp(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s.features.Auth && s.currentUser(r) == nil {
|
||||||
|
http.Error(w, "You must be logged in to RSVP", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, 8*1024)
|
r.Body = http.MaxBytesReader(w, r.Body, 8*1024)
|
||||||
r.ParseForm()
|
r.ParseForm()
|
||||||
|
|
||||||
@@ -518,12 +568,19 @@ func (s *Server) handleUpdateRsvp(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
note := sanitize(r.FormValue("note"), maxNoteLen)
|
note := sanitize(r.FormValue("note"), maxNoteLen)
|
||||||
|
status := "yes"
|
||||||
|
if r.FormValue("status") == "no" {
|
||||||
|
status = "no"
|
||||||
|
}
|
||||||
plusOne := int64(0)
|
plusOne := int64(0)
|
||||||
if v, err := strconv.ParseInt(r.FormValue("plus_one"), 10, 64); err == nil && v > 0 {
|
if v, err := strconv.ParseInt(r.FormValue("plus_one"), 10, 64); err == nil && v > 0 {
|
||||||
plusOne = v
|
plusOne = v
|
||||||
}
|
}
|
||||||
if plusOne > 10 {
|
if plusOne > maxPlusOne {
|
||||||
plusOne = 10
|
plusOne = maxPlusOne
|
||||||
|
}
|
||||||
|
if status == "no" {
|
||||||
|
plusOne = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
event, err := s.q.GetEventBySlug(r.Context(), slug)
|
event, err := s.q.GetEventBySlug(r.Context(), slug)
|
||||||
@@ -531,32 +588,48 @@ func (s *Server) handleUpdateRsvp(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "Event not found", http.StatusNotFound)
|
http.Error(w, "Event not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Raising plus_one can't exceed the attendee cap.
|
|
||||||
if s.features.Auth && event.AttendeeCap > 0 {
|
|
||||||
old, err := s.q.GetRsvp(r.Context(), rsvpID)
|
old, err := s.q.GetRsvp(r.Context(), rsvpID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "RSVP not found", http.StatusNotFound)
|
http.Error(w, "RSVP not found", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Raising plus_one (or flipping back to yes) can't exceed the attendee cap.
|
||||||
|
if s.features.Auth && event.AttendeeCap > 0 && status == "yes" {
|
||||||
going, err := s.q.CountGoing(r.Context(), event.ID)
|
going, err := s.q.CountGoing(r.Context(), event.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "error", http.StatusInternalServerError)
|
http.Error(w, "error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if going-old.PlusOne+plusOne > event.AttendeeCap {
|
var current int64
|
||||||
|
if old.Status == "yes" {
|
||||||
|
current = 1 + old.PlusOne
|
||||||
|
}
|
||||||
|
if going-current+1+plusOne > event.AttendeeCap {
|
||||||
http.Error(w, "Not enough spots left", http.StatusConflict)
|
http.Error(w, "Not enough spots left", http.StatusConflict)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err = s.q.UpdateRsvp(r.Context(), db.UpdateRsvpParams{
|
err = s.q.UpdateRsvp(r.Context(), db.UpdateRsvpParams{
|
||||||
Name: name, Note: note, PlusOne: plusOne, ID: rsvpID,
|
Name: name, Note: note, PlusOne: plusOne, Status: status, ID: rsvpID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Failed to update", http.StatusInternalServerError)
|
http.Error(w, "Failed to update", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Not coming — free any slots claimed under their name.
|
||||||
|
if status == "no" {
|
||||||
|
s.q.DeleteClaimsByEventAndName(r.Context(), db.DeleteClaimsByEventAndNameParams{
|
||||||
|
EventID: event.ID, Name: name,
|
||||||
|
})
|
||||||
|
if !strings.EqualFold(old.Name, name) {
|
||||||
|
s.q.DeleteClaimsByEventAndName(r.Context(), db.DeleteClaimsByEventAndNameParams{
|
||||||
|
EventID: event.ID, Name: old.Name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
s.notify(slug)
|
s.notify(slug)
|
||||||
|
|
||||||
w.Header().Set("HX-Trigger", "closeModal")
|
w.Header().Set("HX-Trigger", "closeModal")
|
||||||
@@ -576,6 +649,11 @@ func (s *Server) handleUnrsvp(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s.features.Auth && s.currentUser(r) == nil {
|
||||||
|
http.Error(w, "You must be logged in to RSVP", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
s.q.DeleteRsvp(r.Context(), rsvpID)
|
s.q.DeleteRsvp(r.Context(), rsvpID)
|
||||||
s.notify(slug)
|
s.notify(slug)
|
||||||
|
|
||||||
|
|||||||
+93
-11
@@ -54,7 +54,7 @@ func autoRsvpPlusOne(ctx context.Context, q *db.Queries, event db.Event, name st
|
|||||||
})
|
})
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
_, err = q.CreateRsvp(ctx, db.CreateRsvpParams{
|
_, err = q.CreateRsvp(ctx, db.CreateRsvpParams{
|
||||||
EventID: event.ID, Name: name, Note: note, PlusOne: plusOne,
|
EventID: event.ID, Name: name, Note: note, PlusOne: plusOne, Status: "yes",
|
||||||
})
|
})
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -162,10 +162,10 @@ func TestDeduplicateExistingRsvps(t *testing.T) {
|
|||||||
event := createTestEvent(t, q)
|
event := createTestEvent(t, q)
|
||||||
|
|
||||||
// Insert duplicate RSVPs directly
|
// Insert duplicate RSVPs directly
|
||||||
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Charlie", PlusOne: 0})
|
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Charlie", PlusOne: 0, Status: "yes"})
|
||||||
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Charlie", PlusOne: 0})
|
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Charlie", PlusOne: 0, Status: "yes"})
|
||||||
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "charlie", PlusOne: 0})
|
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "charlie", PlusOne: 0, Status: "yes"})
|
||||||
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Dana", PlusOne: 0})
|
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Dana", PlusOne: 0, Status: "yes"})
|
||||||
|
|
||||||
rsvps, _ := q.ListRsvps(ctx, event.ID)
|
rsvps, _ := q.ListRsvps(ctx, event.ID)
|
||||||
if len(rsvps) != 4 {
|
if len(rsvps) != 4 {
|
||||||
@@ -187,14 +187,14 @@ func TestUpdateRsvp(t *testing.T) {
|
|||||||
event := createTestEvent(t, q)
|
event := createTestEvent(t, q)
|
||||||
|
|
||||||
rsvp, err := q.CreateRsvp(ctx, db.CreateRsvpParams{
|
rsvp, err := q.CreateRsvp(ctx, db.CreateRsvpParams{
|
||||||
EventID: event.ID, Name: "Alice", Note: "hi", PlusOne: 1,
|
EventID: event.ID, Name: "Alice", Note: "hi", PlusOne: 1, Status: "yes",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = q.UpdateRsvp(ctx, db.UpdateRsvpParams{
|
err = q.UpdateRsvp(ctx, db.UpdateRsvpParams{
|
||||||
Name: "Alicia", Note: "updated", PlusOne: 3, ID: rsvp.ID,
|
Name: "Alicia", Note: "updated", PlusOne: 3, Status: "yes", ID: rsvp.ID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -252,8 +252,8 @@ func TestCountGoing(t *testing.T) {
|
|||||||
t.Fatalf("expected 0 going on empty event, got %d", going)
|
t.Fatalf("expected 0 going on empty event, got %d", going)
|
||||||
}
|
}
|
||||||
|
|
||||||
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Alice", PlusOne: 0})
|
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Alice", PlusOne: 0, Status: "yes"})
|
||||||
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Bob", PlusOne: 2})
|
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Bob", PlusOne: 2, Status: "yes"})
|
||||||
|
|
||||||
going, err = q.CountGoing(ctx, event.ID)
|
going, err = q.CountGoing(ctx, event.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -282,7 +282,7 @@ func TestGetRsvpByUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
q.CreateRsvp(ctx, db.CreateRsvpParams{
|
q.CreateRsvp(ctx, db.CreateRsvpParams{
|
||||||
EventID: event.ID, Name: "Alice", PlusOne: 1,
|
EventID: event.ID, Name: "Alice", PlusOne: 1, Status: "yes",
|
||||||
UserID: sql.NullInt64{Int64: user.ID, Valid: true},
|
UserID: sql.NullInt64{Int64: user.ID, Valid: true},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -311,7 +311,7 @@ func TestAttendeeCapArithmetic(t *testing.T) {
|
|||||||
t.Fatalf("expected AttendeeCap=3, got %d", event.AttendeeCap)
|
t.Fatalf("expected AttendeeCap=3, got %d", event.AttendeeCap)
|
||||||
}
|
}
|
||||||
|
|
||||||
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Alice", PlusOne: 1})
|
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Alice", PlusOne: 1, Status: "yes"})
|
||||||
|
|
||||||
going, _ := q.CountGoing(ctx, event.ID)
|
going, _ := q.CountGoing(ctx, event.ID)
|
||||||
// Same check as handleRsvp: a new RSVP with plus_one=1 would exceed the cap.
|
// Same check as handleRsvp: a new RSVP with plus_one=1 would exceed the cap.
|
||||||
@@ -341,3 +341,85 @@ func TestRsvpPlusOne(t *testing.T) {
|
|||||||
t.Fatalf("expected PlusOne=2, got %d", rsvps[0].PlusOne)
|
t.Fatalf("expected PlusOne=2, got %d", rsvps[0].PlusOne)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRsvpNoExcludedFromCountGoing(t *testing.T) {
|
||||||
|
_, q := setupTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
event := createTestEvent(t, q)
|
||||||
|
|
||||||
|
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Alice", PlusOne: 1, Status: "yes"})
|
||||||
|
q.CreateRsvp(ctx, db.CreateRsvpParams{EventID: event.ID, Name: "Bob", PlusOne: 0, Status: "no"})
|
||||||
|
|
||||||
|
going, err := q.CountGoing(ctx, event.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if going != 2 {
|
||||||
|
t.Fatalf("expected 2 going (no's excluded), got %d", going)
|
||||||
|
}
|
||||||
|
|
||||||
|
rsvps, _ := q.ListRsvps(ctx, event.ID)
|
||||||
|
if len(rsvps) != 2 {
|
||||||
|
t.Fatalf("expected 2 RSVPs listed (including no), got %d", len(rsvps))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchAnswerToNo(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", PlusOne: 2, Status: "yes",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same update handleRsvp performs when re-answering with "no".
|
||||||
|
err = q.UpdateRsvp(ctx, db.UpdateRsvpParams{
|
||||||
|
Name: "Alice", Note: "", PlusOne: 0, Status: "no", ID: rsvp.ID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
going, _ := q.CountGoing(ctx, event.ID)
|
||||||
|
if going != 0 {
|
||||||
|
t.Fatalf("expected 0 going after switching to no, got %d", going)
|
||||||
|
}
|
||||||
|
updated, _ := q.GetRsvp(ctx, rsvp.ID)
|
||||||
|
if updated.Status != "no" {
|
||||||
|
t.Fatalf("expected status no, got %s", updated.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNoFreesClaimedSlots(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: 2,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := autoRsvp(ctx, q, event, "Alice", "seltzer", &slot.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same cleanup handleRsvp performs on a "no" answer (case-insensitive).
|
||||||
|
err = q.DeleteClaimsByEventAndName(ctx, db.DeleteClaimsByEventAndNameParams{
|
||||||
|
EventID: event.ID, Name: "alice",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
count, _ := q.CountClaimsBySlot(ctx, slot.ID)
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatalf("expected claim freed after no, got %d claims", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ func runMigrations(database *sql.DB) {
|
|||||||
// Attendee cap (0 = uncapped); capped events require login to RSVP.
|
// Attendee cap (0 = uncapped); capped events require login to RSVP.
|
||||||
`ALTER TABLE events ADD COLUMN attendee_cap INTEGER NOT NULL DEFAULT 0`,
|
`ALTER TABLE events ADD COLUMN attendee_cap INTEGER NOT NULL DEFAULT 0`,
|
||||||
`ALTER TABLE rsvps ADD COLUMN user_id INTEGER REFERENCES users(id)`,
|
`ALTER TABLE rsvps ADD COLUMN user_id INTEGER REFERENCES users(id)`,
|
||||||
|
// RSVPs can now be a yes or a no.
|
||||||
|
`ALTER TABLE rsvps ADD COLUMN status TEXT NOT NULL DEFAULT 'yes'`,
|
||||||
}
|
}
|
||||||
for _, m := range migrations {
|
for _, m := range migrations {
|
||||||
_, err := database.Exec(m)
|
_, err := database.Exec(m)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ CREATE TABLE IF NOT EXISTS rsvps (
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
note TEXT NOT NULL DEFAULT '',
|
note TEXT NOT NULL DEFAULT '',
|
||||||
plus_one INTEGER NOT NULL DEFAULT 0,
|
plus_one INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'yes', -- 'yes' or 'no'
|
||||||
user_id INTEGER REFERENCES users(id),
|
user_id INTEGER REFERENCES users(id),
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,13 @@
|
|||||||
<label>Your name</label>
|
<label>Your name</label>
|
||||||
<input type="text" name="name" value="{{.Rsvp.Name}}" required>
|
<input type="text" name="name" value="{{.Rsvp.Name}}" required>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>Coming?</label>
|
||||||
|
<select name="status">
|
||||||
|
<option value="yes" {{if ne .Rsvp.Status "no"}}selected{{end}}>Yes, I'm going</option>
|
||||||
|
<option value="no" {{if eq .Rsvp.Status "no"}}selected{{end}}>Can't make it</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>Bringing anyone?</label>
|
<label>Bringing anyone?</label>
|
||||||
<input type="number" name="plus_one" value="{{.Rsvp.PlusOne}}" min="0" max="10">
|
<input type="number" name="plus_one" value="{{.Rsvp.PlusOne}}" min="0" max="10">
|
||||||
|
|||||||
+18
-11
@@ -41,25 +41,25 @@
|
|||||||
|
|
||||||
<div class="section-label" style="margin-top:40px">Sign up</div>
|
<div class="section-label" style="margin-top:40px">Sign up</div>
|
||||||
|
|
||||||
{{if and .Capped .CapFull}}
|
{{if and .AuthEnabled (not .User)}}
|
||||||
<div class="claim-form-wrapper">
|
|
||||||
<div class="form-title">Event is full</div>
|
|
||||||
<p style="font-family:'DM Mono',monospace;font-size:0.8rem;color:#555;">
|
|
||||||
All {{.Event.AttendeeCap}} spots are taken.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{{else if and .Capped (not .User)}}
|
|
||||||
<div class="claim-form-wrapper">
|
<div class="claim-form-wrapper">
|
||||||
<div class="form-title">Log in to RSVP</div>
|
<div class="form-title">Log in to RSVP</div>
|
||||||
<p style="font-family:'DM Mono',monospace;font-size:0.8rem;color:#555;margin-bottom:16px;">
|
<p style="font-family:'DM Mono',monospace;font-size:0.8rem;color:#555;margin-bottom:16px;">
|
||||||
This event has limited spots ({{.SpotsLeft}} left), so RSVPs require an account.
|
{{if .Capped}}This event has limited spots ({{.SpotsLeft}} left), so RSVPs{{else}}RSVPs{{end}} require an account.
|
||||||
</p>
|
</p>
|
||||||
<a class="btn-submit" style="display:inline-block;text-decoration:none;text-align:center;"
|
<a class="btn-submit" style="display:inline-block;text-decoration:none;text-align:center;"
|
||||||
href="/login?next=/e/{{.Event.Slug}}">Log in to RSVP ↗</a>
|
href="/login?next=/e/{{.Event.Slug}}">Log in to RSVP ↗</a>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="claim-form-wrapper">
|
<div class="claim-form-wrapper">
|
||||||
<div class="form-title">I'm coming →</div>
|
{{if .CapFull}}
|
||||||
|
<div class="form-title">Event is full</div>
|
||||||
|
<p style="font-family:'DM Mono',monospace;font-size:0.8rem;color:#555;margin-bottom:16px;">
|
||||||
|
All {{.Event.AttendeeCap}} spots are taken. You can still let them know you can't make it.
|
||||||
|
</p>
|
||||||
|
{{else}}
|
||||||
|
<div class="form-title">Are you coming?</div>
|
||||||
|
{{end}}
|
||||||
<form hx-post="/e/{{.Event.Slug}}/rsvp"
|
<form hx-post="/e/{{.Event.Slug}}/rsvp"
|
||||||
hx-target="#slots-container"
|
hx-target="#slots-container"
|
||||||
hx-swap="innerHTML settle:0.1s"
|
hx-swap="innerHTML settle:0.1s"
|
||||||
@@ -68,6 +68,7 @@
|
|||||||
<label>Your name</label>
|
<label>Your name</label>
|
||||||
<input type="text" name="name" placeholder="e.g. Sam" required value="{{if .User}}{{.User.Name}}{{end}}">
|
<input type="text" name="name" placeholder="e.g. Sam" required value="{{if .User}}{{.User.Name}}{{end}}">
|
||||||
</div>
|
</div>
|
||||||
|
{{if not .CapFull}}
|
||||||
{{if .Slots}}
|
{{if .Slots}}
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<label>Bringing something?</label>
|
<label>Bringing something?</label>
|
||||||
@@ -83,11 +84,17 @@
|
|||||||
<label>Bringing anyone?</label>
|
<label>Bringing anyone?</label>
|
||||||
<input type="number" name="plus_one" value="0" min="0" max="{{if .Capped}}{{.PlusOneMax}}{{else}}10{{end}}">
|
<input type="number" name="plus_one" value="0" min="0" max="{{if .Capped}}{{.PlusOneMax}}{{else}}10{{end}}">
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
<div class="form-row" id="claim-note">
|
<div class="form-row" id="claim-note">
|
||||||
<label>Note (optional)</label>
|
<label>Note (optional)</label>
|
||||||
<input type="text" id="claim-note-input" name="note" placeholder="e.g. arriving late, dietary restrictions, etc.">
|
<input type="text" id="claim-note-input" name="note" placeholder="e.g. arriving late, dietary restrictions, etc.">
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-submit" type="submit">Count me in ↗</button>
|
<div style="display:flex;gap:10px">
|
||||||
|
{{if not .CapFull}}
|
||||||
|
<button class="btn-submit" type="submit" name="status" value="yes">Count me in ↗</button>
|
||||||
|
{{end}}
|
||||||
|
<button class="btn-submit btn-no" type="submit" name="status" value="no">Can't make it</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -286,6 +286,13 @@
|
|||||||
box-shadow: 4px 4px 0 var(--sage);
|
box-shadow: 4px 4px 0 var(--sage);
|
||||||
transform: translate(-2px, -2px);
|
transform: translate(-2px, -2px);
|
||||||
}
|
}
|
||||||
|
.btn-no {
|
||||||
|
background: var(--cream);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.btn-no:hover {
|
||||||
|
box-shadow: 4px 4px 0 var(--peach);
|
||||||
|
}
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
height: 4px;
|
height: 4px;
|
||||||
background: #e8e8e8;
|
background: #e8e8e8;
|
||||||
|
|||||||
+23
-2
@@ -37,11 +37,11 @@
|
|||||||
{{if .GoingList}}
|
{{if .GoingList}}
|
||||||
{{range .GoingList}}
|
{{range .GoingList}}
|
||||||
<span class="claim-chip">
|
<span class="claim-chip">
|
||||||
{{if gt .RsvpID 0}}<span class="rsvp-name-link"
|
{{if and (gt .RsvpID 0) $.CanEditRsvps}}<span class="rsvp-name-link"
|
||||||
hx-get="/e/{{$.Event.Slug}}/rsvp/{{.RsvpID}}/edit"
|
hx-get="/e/{{$.Event.Slug}}/rsvp/{{.RsvpID}}/edit"
|
||||||
hx-target="#edit-modal"
|
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}}
|
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}}
|
{{if and (gt .RsvpID 0) $.CanEditRsvps}}
|
||||||
<button hx-delete="/e/{{$.Event.Slug}}/rsvp/{{.RsvpID}}"
|
<button hx-delete="/e/{{$.Event.Slug}}/rsvp/{{.RsvpID}}"
|
||||||
hx-target="#slots-container"
|
hx-target="#slots-container"
|
||||||
hx-swap="innerHTML settle:0.1s"
|
hx-swap="innerHTML settle:0.1s"
|
||||||
@@ -54,6 +54,27 @@
|
|||||||
<span class="nobody">no one yet</span>
|
<span class="nobody">no one yet</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{if .NotGoingList}}
|
||||||
|
<div class="section-label">Can't make it ({{len .NotGoingList}})</div>
|
||||||
|
<div class="rsvp-list">
|
||||||
|
{{range .NotGoingList}}
|
||||||
|
<span class="claim-chip" style="opacity:0.6">
|
||||||
|
{{if $.CanEditRsvps}}<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 .Note}} <small style="color:#888">({{.Note}})</small>{{end}}
|
||||||
|
{{if $.CanEditRsvps}}
|
||||||
|
<button hx-delete="/e/{{$.Event.Slug}}/rsvp/{{.RsvpID}}"
|
||||||
|
hx-target="#slots-container"
|
||||||
|
hx-swap="innerHTML settle:0.1s"
|
||||||
|
hx-confirm="Remove {{.Name}}?"
|
||||||
|
title="Remove">×</button>
|
||||||
|
{{end}}
|
||||||
|
</span>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "slots.html"}}{{template "slots-inner" .}}{{end}}
|
{{define "slots.html"}}{{template "slots-inner" .}}{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user