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:
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</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}
|
||||||
— {guestList.adults} adults, {guestList.children} children
|
— {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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user