feat(ui): add RSVP attendance option and admin features

* Implement attendance selection in RSVP form
* Add admin endpoints for managing RSVPs and guests
* Update RSVP handling to include attendance status
This commit is contained in:
2026-09-06 16:02:52 +02:00
parent 735601e179
commit b7e88099c8
9 changed files with 615 additions and 56 deletions
+2 -1
View File
@@ -24,7 +24,8 @@ CONFIG_PATH=../config.yaml ./wedding-server # serves on :8080
## Requirements ## Requirements
- Countdown to the wedding date, with day/date/time and venue, on the landing page. - Countdown to the wedding date, with day/date/time and venue, on the landing page.
- Landing page background: photos from `ui/assets/photos` slide in/out continuously behind the hero content. - Landing page background: photos from `ui/assets/photos` slide in/out continuously behind the hero content.
- RSVP form: one or more guest names (add/remove rows, at least one required), message (optional) — no email/phone captured → saved + emailed to couple. Shows the RSVP deadline and its own countdown. - RSVP form: Attending / Not-attending choice, one or more guest names (add/remove rows, at least one required), message (optional) — no email/phone captured → saved + emailed to couple. Shows the RSVP deadline and its own countdown.
- `/guests?admin=xxx`: unauthenticated admin editor — toggle attending, toggle adult/child, rename/remove guests, delete or add an RSVP. Gate is the URL flag only; `/api/admin/*` endpoints are unprotected. Non-attending RSVPs are excluded from the public guest list and counts.
- Photo upload: name (required), message (optional), email/phone (optional) + photo files → saved to `data/photos/`, links emailed to couple. - Photo upload: name (required), message (optional), email/phone (optional) + photo files → saved to `data/photos/`, links emailed to couple.
- Photo upload only opens **7 November 2026** (same date drives the countdown) — enforced server-side, not just hidden in the UI. - Photo upload only opens **7 November 2026** (same date drives the countdown) — enforced server-side, not just hidden in the UI.
- Venue, RSVP deadline, and RSVP phone number sourced from the formal invitation (`concept/IMG-20260811-WA0020.jpg`). - Venue, RSVP deadline, and RSVP phone number sourced from the formal invitation (`concept/IMG-20260811-WA0020.jpg`).
+5 -1
View File
@@ -69,7 +69,11 @@ func runRSVPCLI(st *store.Store, args []string) int {
names[i] += " (child)" names[i] += " (child)"
} }
} }
fmt.Printf("#%d %s %s\n", r.ID, r.CreatedAt.Local().Format("2006-01-02 15:04"), strings.Join(names, ", ")) state := "attending"
if !r.Attending {
state = "NOT attending"
}
fmt.Printf("#%d %s [%s] %s\n", r.ID, r.CreatedAt.Local().Format("2006-01-02 15:04"), state, strings.Join(names, ", "))
if r.Message != "" { if r.Message != "" {
fmt.Printf(" message: %s\n", r.Message) fmt.Printf(" message: %s\n", r.Message)
} }
+187 -4
View File
@@ -38,6 +38,186 @@ func (s *Server) Routes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/guests", s.handleGuestList) mux.HandleFunc("GET /api/guests", s.handleGuestList)
mux.HandleFunc("GET /api/photos", s.handleListPhotos) mux.HandleFunc("GET /api/photos", s.handleListPhotos)
mux.HandleFunc("POST /api/photos/upload", s.handlePhotoUpload) mux.HandleFunc("POST /api/photos/upload", s.handlePhotoUpload)
// Admin RSVP editing. These endpoints are unauthenticated; the UI gates
// them behind ?admin=1 only. Put the server behind auth before exposing it
// on an untrusted network.
mux.HandleFunc("GET /api/admin/rsvps", s.handleAdminListRSVPs)
mux.HandleFunc("POST /api/admin/rsvps", s.handleAdminCreateRSVP)
mux.HandleFunc("PATCH /api/admin/rsvps/{id}", s.handleAdminPatchRSVP)
mux.HandleFunc("DELETE /api/admin/rsvps/{id}", s.handleAdminDeleteRSVP)
mux.HandleFunc("POST /api/admin/rsvps/{id}/guests", s.handleAdminAddGuest)
mux.HandleFunc("PATCH /api/admin/guests/{id}", s.handleAdminPatchGuest)
mux.HandleFunc("DELETE /api/admin/guests/{id}", s.handleAdminDeleteGuest)
}
func pathID(r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
return 0, false
}
return id, true
}
type adminGuest struct {
ID int64 `json:"id"`
Name string `json:"name"`
IsChild bool `json:"isChild"`
}
type adminRSVP struct {
ID int64 `json:"id"`
Attending bool `json:"attending"`
Message string `json:"message"`
CreatedAt string `json:"createdAt"`
Guests []adminGuest `json:"guests"`
}
func (s *Server) handleAdminListRSVPs(w http.ResponseWriter, r *http.Request) {
records, err := s.store.ListRSVPs()
if err != nil {
log.Printf("admin: list rsvps failed: %v", err)
writeError(w, http.StatusInternalServerError, "could not load RSVPs")
return
}
out := make([]adminRSVP, 0, len(records))
for _, rec := range records {
guests := make([]adminGuest, 0, len(rec.Guests))
for _, g := range rec.Guests {
guests = append(guests, adminGuest{ID: g.ID, Name: g.Name, IsChild: g.IsChild})
}
out = append(out, adminRSVP{
ID: rec.ID,
Attending: rec.Attending,
Message: rec.Message,
CreatedAt: rec.CreatedAt.Format(time.RFC3339),
Guests: guests,
})
}
writeJSON(w, http.StatusOK, map[string]any{"rsvps": out})
}
func (s *Server) handleAdminCreateRSVP(w http.ResponseWriter, r *http.Request) {
var req rsvpRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
var guests []store.Guest
for _, g := range req.Guests {
name := strings.TrimSpace(g.Name)
if name != "" {
guests = append(guests, store.Guest{Name: name, IsChild: g.Type == "child"})
}
}
if len(guests) == 0 {
writeError(w, http.StatusBadRequest, "at least one name is required")
return
}
id, err := s.store.CreateRSVP(store.RSVP{
Guests: guests,
Message: strings.TrimSpace(req.Message),
Attending: req.Attending == nil || *req.Attending,
})
if err != nil {
log.Printf("admin: create rsvp failed: %v", err)
writeError(w, http.StatusInternalServerError, "could not save RSVP")
return
}
writeJSON(w, http.StatusCreated, map[string]any{"id": id})
}
func (s *Server) handleAdminPatchRSVP(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var req struct {
Attending *bool `json:"attending"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil || req.Attending == nil {
writeError(w, http.StatusBadRequest, "attending is required")
return
}
if err := s.store.SetRSVPAttending(id, *req.Attending); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handleAdminDeleteRSVP(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := s.store.DeleteRSVP(id); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handleAdminAddGuest(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var req guestRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
guestID, err := s.store.AddGuest(id, store.Guest{Name: name, IsChild: req.Type == "child"})
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusCreated, map[string]any{"id": guestID})
}
func (s *Server) handleAdminPatchGuest(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
var req guestRequest
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
if err := s.store.UpdateGuest(id, name, req.Type == "child"); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handleAdminDeleteGuest(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
if err := s.store.DeleteGuest(id); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
} }
func writeJSON(w http.ResponseWriter, status int, v any) { func writeJSON(w http.ResponseWriter, status int, v any) {
@@ -73,8 +253,9 @@ type guestRequest struct {
} }
type rsvpRequest struct { type rsvpRequest struct {
Guests []guestRequest `json:"guests"` Guests []guestRequest `json:"guests"`
Message string `json:"message"` Message string `json:"message"`
Attending *bool `json:"attending"`
} }
func (s *Server) handleRSVP(w http.ResponseWriter, r *http.Request) { func (s *Server) handleRSVP(w http.ResponseWriter, r *http.Request) {
@@ -96,9 +277,11 @@ func (s *Server) handleRSVP(w http.ResponseWriter, r *http.Request) {
return return
} }
attending := req.Attending == nil || *req.Attending
rsvp := store.RSVP{ rsvp := store.RSVP{
Guests: guests, Guests: guests,
Message: strings.TrimSpace(req.Message), Message: strings.TrimSpace(req.Message),
Attending: attending,
} }
if err := s.store.InsertRSVP(rsvp); err != nil { if err := s.store.InsertRSVP(rsvp); err != nil {
log.Printf("rsvp: insert failed: %v", err) log.Printf("rsvp: insert failed: %v", err)
+10 -2
View File
@@ -93,7 +93,11 @@ func (m *Mailer) SendRSVP(r store.RSVP, allGuests []store.Guest) error {
joined := strings.Join(names, ", ") joined := strings.Join(names, ", ")
var b strings.Builder var b strings.Builder
fmt.Fprintf(&b, "New RSVP for %d guest(s) — %d adult(s), %d child(ren): %s\n", len(r.Guests), adults, children, joined) if r.Attending {
fmt.Fprintf(&b, "New RSVP (ATTENDING) for %d guest(s) — %d adult(s), %d child(ren): %s\n", len(r.Guests), adults, children, joined)
} else {
fmt.Fprintf(&b, "New RSVP (NOT ATTENDING) — %s\n", joined)
}
if r.Message != "" { if r.Message != "" {
fmt.Fprintf(&b, "\nMessage:\n%s\n", r.Message) fmt.Fprintf(&b, "\nMessage:\n%s\n", r.Message)
} }
@@ -113,7 +117,11 @@ func (m *Mailer) SendRSVP(r store.RSVP, allGuests []store.Guest) error {
fmt.Fprintf(&b, "\n%d adult(s), %d child(ren) total\n", totalAdults, totalChildren) fmt.Fprintf(&b, "\n%d adult(s), %d child(ren) total\n", totalAdults, totalChildren)
} }
return m.send(fmt.Sprintf("RSVP from %s", joined), m.header("an RSVP is made")+b.String()) subjectState := "attending"
if !r.Attending {
subjectState = "not attending"
}
return m.send(fmt.Sprintf("RSVP from %s (%s)", joined, subjectState), m.header("an RSVP is made")+b.String())
} }
func (m *Mailer) SendPhotoUpload(u store.PhotoUpload, files []store.PhotoFile) error { func (m *Mailer) SendPhotoUpload(u store.PhotoUpload, files []store.PhotoFile) error {
+102 -12
View File
@@ -17,6 +17,7 @@ const schema = `
CREATE TABLE IF NOT EXISTS rsvps ( CREATE TABLE IF NOT EXISTS rsvps (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
message TEXT, message TEXT,
attending INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL created_at DATETIME NOT NULL
); );
@@ -60,6 +61,9 @@ func Open(dataDir string) (*Store, error) {
db.Close() db.Close()
return nil, fmt.Errorf("migrating schema: %w", err) return nil, fmt.Errorf("migrating schema: %w", err)
} }
// Backfill columns on databases created before they existed; SQLite has no
// "ADD COLUMN IF NOT EXISTS", so a duplicate-column error here is expected.
db.Exec(`ALTER TABLE rsvps ADD COLUMN attending INTEGER NOT NULL DEFAULT 1`)
return &Store{db: db}, nil return &Store{db: db}, nil
} }
@@ -69,13 +73,15 @@ func (s *Store) Close() error {
} }
type Guest struct { type Guest struct {
ID int64
Name string Name string
IsChild bool IsChild bool
} }
type RSVP struct { type RSVP struct {
Guests []Guest Guests []Guest
Message string Message string
Attending bool
} }
// ListGuests returns every RSVP'd guest, ordered by submission then entry order. // ListGuests returns every RSVP'd guest, ordered by submission then entry order.
@@ -84,6 +90,7 @@ func (s *Store) ListGuests() ([]Guest, error) {
`SELECT g.name, g.is_child `SELECT g.name, g.is_child
FROM rsvp_guests g FROM rsvp_guests g
JOIN rsvps r ON r.id = g.rsvp_id JOIN rsvps r ON r.id = g.rsvp_id
WHERE r.attending <> 0
ORDER BY r.created_at ASC, g.id ASC`, ORDER BY r.created_at ASC, g.id ASC`,
) )
if err != nil { if err != nil {
@@ -105,6 +112,7 @@ func (s *Store) ListGuests() ([]Guest, error) {
type RSVPRecord struct { type RSVPRecord struct {
ID int64 ID int64
Message string Message string
Attending bool
CreatedAt time.Time CreatedAt time.Time
Guests []Guest Guests []Guest
} }
@@ -112,7 +120,7 @@ type RSVPRecord struct {
// ListRSVPs returns every RSVP submission (not flattened per-guest), oldest first. // ListRSVPs returns every RSVP submission (not flattened per-guest), oldest first.
func (s *Store) ListRSVPs() ([]RSVPRecord, error) { func (s *Store) ListRSVPs() ([]RSVPRecord, error) {
rows, err := s.db.Query( rows, err := s.db.Query(
`SELECT r.id, r.message, r.created_at, g.name, g.is_child `SELECT r.id, r.message, r.attending, r.created_at, g.id, g.name, g.is_child
FROM rsvps r FROM rsvps r
LEFT JOIN rsvp_guests g ON g.rsvp_id = r.id LEFT JOIN rsvp_guests g ON g.rsvp_id = r.id
ORDER BY r.created_at ASC, r.id ASC, g.id ASC`, ORDER BY r.created_at ASC, r.id ASC, g.id ASC`,
@@ -127,21 +135,23 @@ func (s *Store) ListRSVPs() ([]RSVPRecord, error) {
for rows.Next() { for rows.Next() {
var id int64 var id int64
var message string var message string
var attending bool
var createdAt time.Time var createdAt time.Time
var guestID sql.NullInt64
var name sql.NullString var name sql.NullString
var isChild sql.NullBool var isChild sql.NullBool
if err := rows.Scan(&id, &message, &createdAt, &name, &isChild); err != nil { if err := rows.Scan(&id, &message, &attending, &createdAt, &guestID, &name, &isChild); err != nil {
return nil, err return nil, err
} }
i, ok := index[id] i, ok := index[id]
if !ok { if !ok {
out = append(out, RSVPRecord{ID: id, Message: message, CreatedAt: createdAt}) out = append(out, RSVPRecord{ID: id, Message: message, Attending: attending, CreatedAt: createdAt})
i = len(out) - 1 i = len(out) - 1
index[id] = i index[id] = i
} }
if name.Valid { if name.Valid {
out[i].Guests = append(out[i].Guests, Guest{Name: name.String, IsChild: isChild.Bool}) out[i].Guests = append(out[i].Guests, Guest{ID: guestID.Int64, Name: name.String, IsChild: isChild.Bool})
} }
} }
return out, rows.Err() return out, rows.Err()
@@ -173,22 +183,28 @@ func (s *Store) DeleteRSVP(id int64) error {
} }
func (s *Store) InsertRSVP(r RSVP) error { func (s *Store) InsertRSVP(r RSVP) error {
_, err := s.CreateRSVP(r)
return err
}
// CreateRSVP inserts an RSVP and its guests, returning the new RSVP id.
func (s *Store) CreateRSVP(r RSVP) (int64, error) {
tx, err := s.db.Begin() tx, err := s.db.Begin()
if err != nil { if err != nil {
return err return 0, err
} }
defer tx.Rollback() defer tx.Rollback()
res, err := tx.Exec( res, err := tx.Exec(
`INSERT INTO rsvps (message, created_at) VALUES (?, ?)`, `INSERT INTO rsvps (message, attending, created_at) VALUES (?, ?, ?)`,
r.Message, time.Now().UTC(), r.Message, r.Attending, time.Now().UTC(),
) )
if err != nil { if err != nil {
return err return 0, err
} }
rsvpID, err := res.LastInsertId() rsvpID, err := res.LastInsertId()
if err != nil { if err != nil {
return err return 0, err
} }
for _, guest := range r.Guests { for _, guest := range r.Guests {
@@ -196,13 +212,87 @@ func (s *Store) InsertRSVP(r RSVP) error {
`INSERT INTO rsvp_guests (rsvp_id, name, is_child) VALUES (?, ?, ?)`, `INSERT INTO rsvp_guests (rsvp_id, name, is_child) VALUES (?, ?, ?)`,
rsvpID, guest.Name, guest.IsChild, rsvpID, guest.Name, guest.IsChild,
); err != nil { ); err != nil {
return err return 0, err
} }
} }
return rsvpID, tx.Commit()
}
// SetRSVPAttending flips the attendance flag on one RSVP.
func (s *Store) SetRSVPAttending(id int64, attending bool) error {
return s.mustAffectOne(`UPDATE rsvps SET attending = ? WHERE id = ?`, "rsvp", id, attending, id)
}
// UpdateGuest renames a guest line and sets its child flag.
func (s *Store) UpdateGuest(id int64, name string, isChild bool) error {
return s.mustAffectOne(`UPDATE rsvp_guests SET name = ?, is_child = ? WHERE id = ?`, "guest", id, name, isChild, id)
}
// AddGuest appends a guest to an existing RSVP and returns the new guest id.
func (s *Store) AddGuest(rsvpID int64, g Guest) (int64, error) {
var exists int
if err := s.db.QueryRow(`SELECT 1 FROM rsvps WHERE id = ?`, rsvpID).Scan(&exists); err != nil {
if err == sql.ErrNoRows {
return 0, fmt.Errorf("rsvp %d not found", rsvpID)
}
return 0, err
}
res, err := s.db.Exec(
`INSERT INTO rsvp_guests (rsvp_id, name, is_child) VALUES (?, ?, ?)`,
rsvpID, g.Name, g.IsChild,
)
if err != nil {
return 0, err
}
return res.LastInsertId()
}
// DeleteGuest removes one guest line, and its parent RSVP if it was the last one.
func (s *Store) DeleteGuest(id int64) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var rsvpID int64
if err := tx.QueryRow(`SELECT rsvp_id FROM rsvp_guests WHERE id = ?`, id).Scan(&rsvpID); err != nil {
if err == sql.ErrNoRows {
return fmt.Errorf("guest %d not found", id)
}
return err
}
if _, err := tx.Exec(`DELETE FROM rsvp_guests WHERE id = ?`, id); err != nil {
return err
}
var remaining int
if err := tx.QueryRow(`SELECT COUNT(*) FROM rsvp_guests WHERE rsvp_id = ?`, rsvpID).Scan(&remaining); err != nil {
return err
}
if remaining == 0 {
if _, err := tx.Exec(`DELETE FROM rsvps WHERE id = ?`, rsvpID); err != nil {
return err
}
}
return tx.Commit() return tx.Commit()
} }
func (s *Store) mustAffectOne(query, label string, id int64, args ...any) error {
res, err := s.db.Exec(query, args...)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return fmt.Errorf("%s %d not found", label, id)
}
return nil
}
type PhotoFile struct { type PhotoFile struct {
Filename string Filename string
Path string Path string
+3 -2
View File
@@ -78,8 +78,9 @@ func (c *Client) SendRSVP(r store.RSVP) error {
guests[i] = map[string]any{"name": g.Name, "isChild": g.IsChild} guests[i] = map[string]any{"name": g.Name, "isChild": g.IsChild}
} }
return c.post(c.cfg.RSVPURL, "rsvp.created", map[string]any{ return c.post(c.cfg.RSVPURL, "rsvp.created", map[string]any{
"guests": guests, "guests": guests,
"message": r.Message, "message": r.Message,
"attending": r.Attending,
}) })
} }
+55
View File
@@ -19,6 +19,7 @@ export interface Guest {
export interface RsvpPayload { export interface RsvpPayload {
guests: Guest[]; guests: Guest[];
message?: string; message?: string;
attending: boolean;
} }
export interface GuestPhoto { export interface GuestPhoto {
@@ -80,6 +81,60 @@ export async function submitRsvp(payload: RsvpPayload): Promise<void> {
if (!response.ok) throw new ApiError(await parseError(response), response.status); if (!response.ok) throw new ApiError(await parseError(response), response.status);
} }
export interface AdminGuest {
id: number;
name: string;
isChild: boolean;
}
export interface AdminRsvp {
id: number;
attending: boolean;
message: string;
createdAt: string;
guests: AdminGuest[];
}
async function jsonRequest<T>(url: string, method: string, body?: unknown): Promise<T> {
const response = await fetch(url, {
method,
headers: body === undefined ? undefined : { 'content-type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body)
});
if (!response.ok) throw new ApiError(await parseError(response), response.status);
return response.status === 204 ? (undefined as T) : response.json();
}
export async function fetchAdminRsvps(fetchImpl: typeof fetch = fetch): Promise<AdminRsvp[]> {
const response = await fetchImpl('/api/admin/rsvps');
if (!response.ok) throw new ApiError(await parseError(response), response.status);
return (await response.json()).rsvps ?? [];
}
export function setRsvpAttending(id: number, attending: boolean): Promise<unknown> {
return jsonRequest(`/api/admin/rsvps/${id}`, 'PATCH', { attending });
}
export function deleteRsvp(id: number): Promise<unknown> {
return jsonRequest(`/api/admin/rsvps/${id}`, 'DELETE');
}
export function addAdminGuest(rsvpId: number, guest: Guest): Promise<{ id: number }> {
return jsonRequest(`/api/admin/rsvps/${rsvpId}/guests`, 'POST', guest);
}
export function updateAdminGuest(id: number, guest: Guest): Promise<unknown> {
return jsonRequest(`/api/admin/guests/${id}`, 'PATCH', guest);
}
export function deleteAdminGuest(id: number): Promise<unknown> {
return jsonRequest(`/api/admin/guests/${id}`, 'DELETE');
}
export function createAdminRsvp(payload: RsvpPayload): Promise<{ id: number }> {
return jsonRequest('/api/admin/rsvps', 'POST', payload);
}
export async function fetchGuests(fetchImpl: typeof fetch = fetch): Promise<GuestList> { export async function fetchGuests(fetchImpl: typeof fetch = fetch): Promise<GuestList> {
const response = await fetchImpl('/api/guests'); const response = await fetchImpl('/api/guests');
if (!response.ok) throw new ApiError(await parseError(response), response.status); if (!response.ok) throw new ApiError(await parseError(response), response.status);
+221 -34
View File
@@ -1,57 +1,244 @@
<script lang="ts"> <script lang="ts">
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { fetchGuests, ApiError, type GuestList } from '$lib/api'; import { page } from '$app/state';
import {
fetchGuests,
fetchAdminRsvps,
setRsvpAttending,
deleteRsvp,
addAdminGuest,
updateAdminGuest,
deleteAdminGuest,
createAdminRsvp,
ApiError,
type GuestList,
type AdminRsvp
} from '$lib/api';
const isAdmin = $derived(page.url.searchParams.get('admin') === '1');
let status = $state<'loading' | 'done' | 'error'>('loading'); let status = $state<'loading' | 'done' | 'error'>('loading');
let errorMessage = $state(''); let errorMessage = $state('');
let guestList = $state<GuestList>({ guests: [], total: 0, adults: 0, children: 0 }); let guestList = $state<GuestList>({ guests: [], total: 0, adults: 0, children: 0 });
let rsvps = $state<AdminRsvp[]>([]);
let busy = $state(false);
let newName = $state('');
let newChild = $state(false);
let newAttending = $state(true);
$effect(() => { $effect(() => {
if (!browser) return; if (!browser) return;
fetchGuests() void isAdmin;
.then((list) => { load();
guestList = list;
status = 'done';
})
.catch((err) => {
status = 'error';
errorMessage = err instanceof ApiError ? err.message : 'Something went wrong. Please try again.';
});
}); });
async function load() {
status = 'loading';
try {
if (isAdmin) {
rsvps = await fetchAdminRsvps();
} else {
guestList = await fetchGuests();
}
status = 'done';
} catch (err) {
status = 'error';
errorMessage = err instanceof ApiError ? err.message : 'Something went wrong. Please try again.';
}
}
async function run(fn: () => Promise<unknown>) {
busy = true;
errorMessage = '';
try {
await fn();
rsvps = await fetchAdminRsvps();
} catch (err) {
errorMessage = err instanceof ApiError ? err.message : 'Update failed.';
} finally {
busy = false;
}
}
function toggleAttending(r: AdminRsvp) {
run(() => setRsvpAttending(r.id, !r.attending));
}
function toggleChild(g: { id: number; name: string; isChild: boolean }) {
run(() => updateAdminGuest(g.id, { name: g.name, type: g.isChild ? 'adult' : 'child' }));
}
function renameGuest(g: { id: number; name: string; isChild: boolean }, name: string) {
if (name.trim() === '' || name === g.name) return;
run(() => updateAdminGuest(g.id, { name: name.trim(), type: g.isChild ? 'child' : 'adult' }));
}
function removeGuest(id: number) {
run(() => deleteAdminGuest(id));
}
function removeRsvp(id: number) {
run(() => deleteRsvp(id));
}
function addGuest(rsvpId: number, name: string, child: boolean) {
if (name.trim() === '') return;
run(() => addAdminGuest(rsvpId, { name: name.trim(), type: child ? 'child' : 'adult' }));
}
function addParty() {
if (newName.trim() === '') return;
run(async () => {
await createAdminRsvp({
guests: [{ name: newName.trim(), type: newChild ? 'child' : 'adult' }],
attending: newAttending
});
newName = '';
newChild = false;
newAttending = true;
});
}
</script> </script>
<section class="flex flex-1 items-center justify-center px-4 py-16"> <section class="flex flex-1 items-center justify-center px-4 py-16">
<div class="w-full max-w-md"> <div class="w-full max-w-md">
<h1 class="font-serif text-4xl">Who's coming</h1> <h1 class="font-serif text-4xl">Who's coming{isAdmin ? ' — admin' : ''}</h1>
<p class="text-surface-600-400 mt-2 mb-8 text-2xl">
{#if status === 'done'} {#if status === 'loading'}
<p class="text-surface-600-400 mt-8 text-2xl">Loading…</p>
{:else if status === 'error'}
<p class="text-error-500 mt-8 text-2xl">{errorMessage}</p>
{:else if isAdmin}
{#if errorMessage}
<p class="text-error-500 mt-4 text-xl">{errorMessage}</p>
{/if}
<div class="mt-6 flex flex-col gap-6" class:opacity-60={busy}>
{#each rsvps as r (r.id)}
<div class="border-surface-300 dark:border-surface-700 flex flex-col gap-2 border p-3">
<div class="flex items-center justify-between gap-2">
<button
type="button"
class="btn text-lg {r.attending
? 'preset-filled-secondary-500'
: 'preset-filled-surface-200-800'}"
disabled={busy}
onclick={() => toggleAttending(r)}
>
{r.attending ? 'Attending' : 'Not attending'}
</button>
<button
type="button"
class="btn preset-filled-surface-200-800 text-lg"
disabled={busy}
onclick={() => removeRsvp(r.id)}
>
Delete RSVP
</button>
</div>
{#each r.guests as g (g.id)}
<div class="flex items-center gap-2">
<input
class="input border-surface-300 dark:border-surface-700 border text-xl"
value={g.name}
disabled={busy}
onblur={(e) => renameGuest(g, e.currentTarget.value)}
/>
<button
type="button"
class="btn preset-filled-surface-200-800 shrink-0 text-lg"
disabled={busy}
onclick={() => toggleChild(g)}
>
{g.isChild ? 'Child' : 'Adult'}
</button>
<button
type="button"
class="btn preset-filled-surface-200-800 shrink-0 text-lg"
aria-label="Remove guest"
disabled={busy}
onclick={() => removeGuest(g.id)}
>
&minus;
</button>
</div>
{/each}
<form
class="flex items-center gap-2"
onsubmit={(e) => {
e.preventDefault();
const f = e.currentTarget;
const input = f.elements.namedItem('name') as HTMLInputElement;
const child = f.elements.namedItem('child') as HTMLInputElement;
addGuest(r.id, input.value, child.checked);
input.value = '';
child.checked = false;
}}
>
<input
name="name"
class="input border-surface-300 dark:border-surface-700 border text-xl"
placeholder="Add guest"
disabled={busy}
/>
<label class="flex shrink-0 items-center gap-1 text-lg">
<input name="child" type="checkbox" class="checkbox" disabled={busy} /> child
</label>
<button type="submit" class="btn preset-filled-surface-200-800 shrink-0 text-lg" disabled={busy}>
+
</button>
</form>
</div>
{/each}
<div class="border-primary-500 flex flex-col gap-2 border p-3">
<span class="text-xl font-medium">Add RSVP</span>
<input
class="input border-surface-300 dark:border-surface-700 border text-xl"
placeholder="Guest name"
bind:value={newName}
disabled={busy}
/>
<div class="flex items-center gap-4 text-lg">
<label class="flex items-center gap-1">
<input type="checkbox" class="checkbox" bind:checked={newChild} disabled={busy} /> child
</label>
<label class="flex items-center gap-1">
<input type="checkbox" class="checkbox" bind:checked={newAttending} disabled={busy} /> attending
</label>
</div>
<button
type="button"
class="btn preset-filled-secondary-500 text-xl"
disabled={busy}
onclick={addParty}
>
Add
</button>
</div>
</div>
{:else}
<p class="text-surface-600-400 mt-2 mb-8 text-2xl">
{guestList.total} {guestList.total}
{guestList.total === 1 ? 'guest has' : 'guests have'} RSVP'd {guestList.total === 1 ? 'guest has' : 'guests have'} RSVP'd
{#if guestList.children > 0} {#if guestList.children > 0}
&mdash; {guestList.adults} adults, {guestList.children} children &mdash; {guestList.adults} adults, {guestList.children} children
{/if} {/if}
{:else} </p>
Everyone who has RSVP'd so far.
{/if}
</p>
{#if status === 'loading'} {#if guestList.total === 0}
<p class="text-surface-600-400 text-2xl">Loading…</p> <p class="text-surface-600-400 text-2xl">No RSVPs yet.</p>
{:else if status === 'error'} {:else}
<p class="text-error-500 text-2xl">{errorMessage}</p> <ul class="flex flex-col gap-2">
{:else if guestList.total === 0} {#each guestList.guests as guest, i (i)}
<p class="text-surface-600-400 text-2xl">No RSVPs yet.</p> <li
{:else} class="border-surface-300 dark:border-surface-700 flex items-center justify-between border-b py-2"
<ul class="flex flex-col gap-2"> >
{#each guestList.guests as guest, i (i)} <span class="text-2xl">{guest.name}</span>
<li class="border-surface-300 dark:border-surface-700 flex items-center justify-between border-b py-2"> {#if guest.isChild}
<span class="text-2xl">{guest.name}</span> <span class="preset-tonal-secondary rounded-base px-2 text-lg font-medium">Child</span>
{#if guest.isChild} {/if}
<span class="preset-tonal-secondary rounded-base px-2 text-lg font-medium">Child</span> </li>
{/if} {/each}
</li> </ul>
{/each} {/if}
</ul>
{/if} {/if}
</div> </div>
</section> </section>
+30
View File
@@ -17,6 +17,7 @@
let guests = $state<Guest[]>([{ name: "", type: "adult" }]); let guests = $state<Guest[]>([{ name: "", type: "adult" }]);
let message = $state(""); let message = $state("");
let attending = $state(true);
let status = $state<"idle" | "submitting" | "done" | "error">("idle"); let status = $state<"idle" | "submitting" | "done" | "error">("idle");
let errorMessage = $state(""); let errorMessage = $state("");
@@ -46,6 +47,7 @@
await submitRsvp({ await submitRsvp({
guests: trimmedGuests, guests: trimmedGuests,
message: message || undefined, message: message || undefined,
attending,
}); });
status = "done"; status = "done";
rsvpStatus.markSubmitted(); rsvpStatus.markSubmitted();
@@ -174,6 +176,34 @@
<p class="text-error-500 text-2xl">{errorMessage}</p> <p class="text-error-500 text-2xl">{errorMessage}</p>
{/if} {/if}
<div class="flex flex-col gap-2 w-full">
<span class="label-text text-surface-100 text-2xl">Will you attend? *</span>
<div class="flex gap-2 w-full">
<button
type="button"
class="btn flex-1 text-2xl {attending
? 'preset-filled-secondary-500'
: 'preset-filled-surface-200-800'}"
aria-pressed={attending}
onclick={() => (attending = true)}
disabled={status === "submitting"}
>
Attending
</button>
<button
type="button"
class="btn flex-1 text-2xl {attending
? 'preset-filled-surface-200-800'
: 'preset-filled-secondary-500'}"
aria-pressed={!attending}
onclick={() => (attending = false)}
disabled={status === "submitting"}
>
Not attending
</button>
</div>
</div>
<button <button
class="btn preset-filled-secondary-500 mt-2 text-2xl" class="btn preset-filled-secondary-500 mt-2 text-2xl"
type="submit" type="submit"