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
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Rsvp struct {
|
||||||
|
ID int64
|
||||||
|
EventID int64
|
||||||
|
Name string
|
||||||
|
Note string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type Slot struct {
|
type Slot struct {
|
||||||
ID int64
|
ID int64
|
||||||
EventID int64
|
EventID int64
|
||||||
|
|||||||
@@ -51,3 +51,17 @@ 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: 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
|
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
|
const createClaim = `-- name: CreateClaim :one
|
||||||
INSERT INTO claims (slot_id, name, note)
|
INSERT INTO claims (slot_id, name, note)
|
||||||
VALUES (?, ?, ?)
|
VALUES (?, ?, ?)
|
||||||
@@ -83,6 +94,31 @@ func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event
|
|||||||
return i, err
|
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
|
const createSlot = `-- name: CreateSlot :one
|
||||||
INSERT INTO slots (event_id, name, emoji, max_claims, sort_order)
|
INSERT INTO slots (event_id, name, emoji, max_claims, sort_order)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
@@ -135,6 +171,15 @@ func (q *Queries) DeleteEvent(ctx context.Context, id int64) error {
|
|||||||
return err
|
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
|
const deleteSlot = `-- name: DeleteSlot :exec
|
||||||
DELETE FROM slots WHERE id = ?
|
DELETE FROM slots WHERE id = ?
|
||||||
`
|
`
|
||||||
@@ -271,6 +316,39 @@ func (q *Queries) ListClaimsBySlot(ctx context.Context, slotID int64) ([]Claim,
|
|||||||
return items, nil
|
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
|
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
|
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 {
|
type EventPageData struct {
|
||||||
Event db.Event
|
Event db.Event
|
||||||
Slots []SlotView
|
Slots []SlotView
|
||||||
|
Rsvps []db.Rsvp
|
||||||
TotalGoing int64
|
TotalGoing int64
|
||||||
IsAdmin bool
|
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{
|
return &EventPageData{
|
||||||
Event: event,
|
Event: event,
|
||||||
Slots: slotViews,
|
Slots: slotViews,
|
||||||
|
Rsvps: rsvps,
|
||||||
TotalGoing: totalGoing,
|
TotalGoing: totalGoing,
|
||||||
IsAdmin: isAdmin,
|
IsAdmin: isAdmin,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -262,6 +270,63 @@ func (s *Server) handleUnclaim(w http.ResponseWriter, r *http.Request) {
|
|||||||
pageTmpl["slots"].ExecuteTemplate(w, "slots-inner", data)
|
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 ---
|
// --- SSE ---
|
||||||
|
|
||||||
func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -146,6 +146,8 @@ func main() {
|
|||||||
r.Get("/e/{slug}/slots", srv.handleSlotsPartial)
|
r.Get("/e/{slug}/slots", srv.handleSlotsPartial)
|
||||||
r.Post("/e/{slug}/claim", srv.handleClaim)
|
r.Post("/e/{slug}/claim", srv.handleClaim)
|
||||||
r.Delete("/e/{slug}/claim/{claimID}", srv.handleUnclaim)
|
r.Delete("/e/{slug}/claim/{claimID}", srv.handleUnclaim)
|
||||||
|
r.Post("/e/{slug}/rsvp", srv.handleRsvp)
|
||||||
|
r.Delete("/e/{slug}/rsvp/{rsvpID}", srv.handleUnrsvp)
|
||||||
|
|
||||||
// SSE
|
// SSE
|
||||||
r.Get("/e/{slug}/sse", srv.handleSSE)
|
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
|
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_slots_event ON slots(event_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_claims_slot ON claims(slot_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 "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 "admin-bar"}}{{if .IsAdmin}}<div class="admin-bar">ADMIN VIEW — share the guest link: /e/{{.Event.Slug}}</div>{{end}}{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
@@ -15,8 +24,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section-label">What's needed</div>
|
|
||||||
|
|
||||||
<div id="slots-container"
|
<div id="slots-container"
|
||||||
hx-ext="sse"
|
hx-ext="sse"
|
||||||
sse-connect="/e/{{.Event.Slug}}/sse"
|
sse-connect="/e/{{.Event.Slug}}/sse"
|
||||||
@@ -25,10 +32,31 @@
|
|||||||
{{template "slots-inner" .}}
|
{{template "slots-inner" .}}
|
||||||
</div>
|
</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="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"
|
<form hx-post="/e/{{.Event.Slug}}/claim"
|
||||||
hx-target="#slots-container"
|
hx-target="#slots-container"
|
||||||
hx-swap="innerHTML settle:0.1s"
|
hx-swap="innerHTML settle:0.1s"
|
||||||
@@ -49,9 +77,10 @@
|
|||||||
<label>Note (optional)</label>
|
<label>Note (optional)</label>
|
||||||
<input type="text" name="note" placeholder="e.g. bringing sparkling water + lemonade">
|
<input type="text" name="note" placeholder="e.g. bringing sparkling water + lemonade">
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-submit" type="submit">Count me in ↗</button>
|
<button class="btn-submit" type="submit">Claim ↗</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if .IsAdmin}}
|
{{if .IsAdmin}}
|
||||||
<div class="section-label" style="margin-top:40px">Admin: Add slot</div>
|
<div class="section-label" style="margin-top:40px">Admin: Add slot</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{block "title" .}}picnic{{end}}</title>
|
<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">
|
<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.org@2.0.4"></script>
|
||||||
<script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script>
|
<script src="https://unpkg.com/htmx-ext-sse@2.2.2/sse.js"></script>
|
||||||
@@ -319,6 +320,12 @@
|
|||||||
background: var(--yellow);
|
background: var(--yellow);
|
||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
}
|
}
|
||||||
|
.rsvp-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
.btn-danger {
|
.btn-danger {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
{{define "slots-inner"}}
|
{{define "slots-inner"}}
|
||||||
|
<div class="section-label">What's needed</div>
|
||||||
|
<div class="slots-grid">
|
||||||
{{range .Slots}}
|
{{range .Slots}}
|
||||||
<div class="slot-card{{if .IsFull}} full{{end}}">
|
<div class="slot-card{{if .IsFull}} full{{end}}">
|
||||||
<div class="slot-info">
|
<div class="slot-info">
|
||||||
@@ -28,6 +30,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{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}}
|
{{end}}
|
||||||
|
|
||||||
{{define "slots.html"}}{{template "slots-inner" .}}{{end}}
|
{{define "slots.html"}}{{template "slots-inner" .}}{{end}}
|
||||||
|
|||||||
Reference in New Issue
Block a user