Add RSVP support and Open Graph meta tags
People can now RSVP without claiming a slot. OG tags show event title, date/time/location, and headcount in link previews. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,14 @@ type Event struct {
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Rsvp struct {
|
||||
ID int64
|
||||
EventID int64
|
||||
Name string
|
||||
Note string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Slot struct {
|
||||
ID int64
|
||||
EventID int64
|
||||
|
||||
@@ -51,3 +51,17 @@ DELETE FROM claims WHERE id = ?;
|
||||
|
||||
-- name: CountClaimsBySlot :one
|
||||
SELECT COUNT(*) FROM claims WHERE slot_id = ?;
|
||||
|
||||
-- name: ListRsvps :many
|
||||
SELECT * FROM rsvps WHERE event_id = ? ORDER BY created_at;
|
||||
|
||||
-- name: CreateRsvp :one
|
||||
INSERT INTO rsvps (event_id, name, note)
|
||||
VALUES (?, ?, ?)
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeleteRsvp :exec
|
||||
DELETE FROM rsvps WHERE id = ?;
|
||||
|
||||
-- name: CountRsvps :one
|
||||
SELECT COUNT(*) FROM rsvps WHERE event_id = ?;
|
||||
|
||||
@@ -20,6 +20,17 @@ func (q *Queries) CountClaimsBySlot(ctx context.Context, slotID int64) (int64, e
|
||||
return count, err
|
||||
}
|
||||
|
||||
const countRsvps = `-- name: CountRsvps :one
|
||||
SELECT COUNT(*) FROM rsvps WHERE event_id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) CountRsvps(ctx context.Context, eventID int64) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, countRsvps, eventID)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const createClaim = `-- name: CreateClaim :one
|
||||
INSERT INTO claims (slot_id, name, note)
|
||||
VALUES (?, ?, ?)
|
||||
@@ -83,6 +94,31 @@ func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createRsvp = `-- name: CreateRsvp :one
|
||||
INSERT INTO rsvps (event_id, name, note)
|
||||
VALUES (?, ?, ?)
|
||||
RETURNING id, event_id, name, note, created_at
|
||||
`
|
||||
|
||||
type CreateRsvpParams struct {
|
||||
EventID int64
|
||||
Name string
|
||||
Note string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateRsvp(ctx context.Context, arg CreateRsvpParams) (Rsvp, error) {
|
||||
row := q.db.QueryRowContext(ctx, createRsvp, arg.EventID, arg.Name, arg.Note)
|
||||
var i Rsvp
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.EventID,
|
||||
&i.Name,
|
||||
&i.Note,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const createSlot = `-- name: CreateSlot :one
|
||||
INSERT INTO slots (event_id, name, emoji, max_claims, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
@@ -135,6 +171,15 @@ func (q *Queries) DeleteEvent(ctx context.Context, id int64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteRsvp = `-- name: DeleteRsvp :exec
|
||||
DELETE FROM rsvps WHERE id = ?
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteRsvp(ctx context.Context, id int64) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteRsvp, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const deleteSlot = `-- name: DeleteSlot :exec
|
||||
DELETE FROM slots WHERE id = ?
|
||||
`
|
||||
@@ -271,6 +316,39 @@ func (q *Queries) ListClaimsBySlot(ctx context.Context, slotID int64) ([]Claim,
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listRsvps = `-- name: ListRsvps :many
|
||||
SELECT id, event_id, name, note, created_at FROM rsvps WHERE event_id = ? ORDER BY created_at
|
||||
`
|
||||
|
||||
func (q *Queries) ListRsvps(ctx context.Context, eventID int64) ([]Rsvp, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listRsvps, eventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []Rsvp
|
||||
for rows.Next() {
|
||||
var i Rsvp
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.EventID,
|
||||
&i.Name,
|
||||
&i.Note,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listSlots = `-- name: ListSlots :many
|
||||
SELECT id, event_id, name, emoji, max_claims, sort_order FROM slots WHERE event_id = ? ORDER BY sort_order, id
|
||||
`
|
||||
|
||||
+65
@@ -96,6 +96,7 @@ type SlotView struct {
|
||||
type EventPageData struct {
|
||||
Event db.Event
|
||||
Slots []SlotView
|
||||
Rsvps []db.Rsvp
|
||||
TotalGoing int64
|
||||
IsAdmin bool
|
||||
}
|
||||
@@ -143,9 +144,16 @@ func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*Eve
|
||||
})
|
||||
}
|
||||
|
||||
rsvps, err := s.q.ListRsvps(r.Context(), event.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalGoing += int64(len(rsvps))
|
||||
|
||||
return &EventPageData{
|
||||
Event: event,
|
||||
Slots: slotViews,
|
||||
Rsvps: rsvps,
|
||||
TotalGoing: totalGoing,
|
||||
IsAdmin: isAdmin,
|
||||
}, nil
|
||||
@@ -262,6 +270,63 @@ func (s *Server) handleUnclaim(w http.ResponseWriter, r *http.Request) {
|
||||
pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data)
|
||||
}
|
||||
|
||||
// --- RSVP ---
|
||||
|
||||
func (s *Server) handleRsvp(w http.ResponseWriter, r *http.Request) {
|
||||
slug := chi.URLParam(r, "slug")
|
||||
r.ParseForm()
|
||||
|
||||
name := strings.TrimSpace(r.FormValue("name"))
|
||||
if name == "" {
|
||||
http.Error(w, "Name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
note := strings.TrimSpace(r.FormValue("note"))
|
||||
|
||||
event, err := s.q.GetEventBySlug(r.Context(), slug)
|
||||
if err != nil {
|
||||
http.Error(w, "Event not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = s.q.CreateRsvp(r.Context(), db.CreateRsvpParams{
|
||||
EventID: event.ID, Name: name, Note: note,
|
||||
})
|
||||
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
|
||||
}
|
||||
pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data)
|
||||
}
|
||||
|
||||
func (s *Server) handleUnrsvp(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
|
||||
}
|
||||
|
||||
s.q.DeleteRsvp(r.Context(), rsvpID)
|
||||
s.notify(slug)
|
||||
|
||||
data, err := s.loadEventPage(r, slug, false)
|
||||
if err != nil {
|
||||
http.Error(w, "error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data)
|
||||
}
|
||||
|
||||
// --- SSE ---
|
||||
|
||||
func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -146,6 +146,8 @@ func main() {
|
||||
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}/rsvp/{rsvpID}", srv.handleUnrsvp)
|
||||
|
||||
// SSE
|
||||
r.Get("/e/{slug}/sse", srv.handleSSE)
|
||||
|
||||
@@ -26,5 +26,14 @@ CREATE TABLE IF NOT EXISTS claims (
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rsvps (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_slots_event ON slots(event_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_claims_slot ON claims(slot_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_rsvps_event ON rsvps(event_id);
|
||||
|
||||
+34
-5
@@ -2,6 +2,15 @@
|
||||
|
||||
{{define "title"}}{{.Event.Title}} — bbq{{end}}
|
||||
|
||||
{{define "meta"}}
|
||||
<meta property="og:title" content="{{.Event.Title}}">
|
||||
<meta property="og:description" content="{{if .Event.Date}}{{.Event.Date}}{{end}}{{if .Event.Time}} at {{.Event.Time}}{{end}}{{if .Event.Location}} — {{.Event.Location}}{{end}} · {{.TotalGoing}} going">
|
||||
<meta property="og:type" content="website">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="{{.Event.Title}}">
|
||||
<meta name="twitter:description" content="{{if .Event.Date}}{{.Event.Date}}{{end}}{{if .Event.Time}} at {{.Event.Time}}{{end}}{{if .Event.Location}} — {{.Event.Location}}{{end}} · {{.TotalGoing}} going">
|
||||
{{end}}
|
||||
|
||||
{{define "admin-bar"}}{{if .IsAdmin}}<div class="admin-bar">ADMIN VIEW — share the guest link: /e/{{.Event.Slug}}</div>{{end}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
@@ -15,8 +24,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-label">What's needed</div>
|
||||
|
||||
<div id="slots-container"
|
||||
hx-ext="sse"
|
||||
sse-connect="/e/{{.Event.Slug}}/sse"
|
||||
@@ -25,10 +32,31 @@
|
||||
{{template "slots-inner" .}}
|
||||
</div>
|
||||
|
||||
<div class="section-label">Add your name</div>
|
||||
<div class="section-label" style="margin-top:40px">Sign up</div>
|
||||
|
||||
<div class="claim-form-wrapper">
|
||||
<div class="form-title">I'll bring something →</div>
|
||||
<div class="form-title">I'm coming →</div>
|
||||
<form hx-post="/e/{{.Event.Slug}}/rsvp"
|
||||
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>
|
||||
</div>
|
||||
<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 ↗</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 →</div>
|
||||
<form hx-post="/e/{{.Event.Slug}}/claim"
|
||||
hx-target="#slots-container"
|
||||
hx-swap="innerHTML settle:0.1s"
|
||||
@@ -49,9 +77,10 @@
|
||||
<label>Note (optional)</label>
|
||||
<input type="text" name="note" placeholder="e.g. bringing sparkling water + lemonade">
|
||||
</div>
|
||||
<button class="btn-submit" type="submit">Count me in ↗</button>
|
||||
<button class="btn-submit" type="submit">Claim ↗</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .IsAdmin}}
|
||||
<div class="section-label" style="margin-top:40px">Admin: Add slot</div>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{block "title" .}}picnic{{end}}</title>
|
||||
{{block "meta" .}}{{end}}
|
||||
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,700;12..96,800&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script>
|
||||
@@ -319,6 +320,12 @@
|
||||
background: var(--yellow);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.rsvp-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.btn-danger {
|
||||
background: none;
|
||||
border: none;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{{define "slots-inner"}}
|
||||
<div class="section-label">What's needed</div>
|
||||
<div class="slots-grid">
|
||||
{{range .Slots}}
|
||||
<div class="slot-card{{if .IsFull}} full{{end}}">
|
||||
<div class="slot-info">
|
||||
@@ -28,6 +30,25 @@
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="section-label">Going ({{len .Rsvps}})</div>
|
||||
<div class="rsvp-list">
|
||||
{{if .Rsvps}}
|
||||
{{range .Rsvps}}
|
||||
<span class="claim-chip">
|
||||
{{.Name}}{{if .Note}} <small style="color:#888">({{.Note}})</small>{{end}}
|
||||
<button hx-delete="/e/{{$.Event.Slug}}/rsvp/{{.ID}}"
|
||||
hx-target="#slots-container"
|
||||
hx-swap="innerHTML settle:0.1s"
|
||||
hx-confirm="Remove {{.Name}}?"
|
||||
title="Remove">×</button>
|
||||
</span>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<span class="nobody">no one yet</span>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "slots.html"}}{{template "slots-inner" .}}{{end}}
|
||||
|
||||
Reference in New Issue
Block a user