Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d74c6c111 | ||
|
|
b7e88099c8 | ||
|
|
735601e179 | ||
|
|
8e28dec20b | ||
|
|
91c9617255 | ||
|
|
4571c59020 | ||
|
|
59428a27ce | ||
|
|
15bdbdd26f | ||
|
|
23ab28d07e | ||
|
|
9ecfd44248 | ||
|
|
143b2361f6 | ||
|
|
f96465b798 | ||
|
|
79e4c11a16 | ||
|
|
5b3c256a5e | ||
|
|
3dc2e09aeb | ||
|
|
f16d280f9a | ||
|
|
4eddecea8e |
@@ -4,5 +4,4 @@ ui/node_modules/
|
|||||||
ui/build/
|
ui/build/
|
||||||
ui/.svelte-kit/
|
ui/.svelte-kit/
|
||||||
server/wedding-server
|
server/wedding-server
|
||||||
server/web/dist/*
|
server/web/dist/
|
||||||
!server/web/dist/index.html
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ RSVP is a party: one or more guest names attached to a single RSVP + optional sh
|
|||||||
| Method | Path | Body | Notes |
|
| Method | Path | Body | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| POST | `/api/rsvp` | `{names: string[], message?}` | at least one non-blank name required; insert party + guests, email couple |
|
| POST | `/api/rsvp` | `{names: string[], message?}` | at least one non-blank name required; insert party + guests, email couple |
|
||||||
| GET | `/api/config` | — | `{weddingDate, ceremonyTime, rsvpByDate, venueName, venueAddress, rsvpPhone}` (all RFC3339 where dates) — single source of truth for countdowns, gate, and displayed details |
|
| GET | `/api/config` | — | `{weddingDate, eventEnd, ceremonyTime, rsvpByDate, venueName, venueAddress, rsvpPhone}` (all RFC3339 where dates) — single source of truth for countdowns, calendar, gate, and displayed details |
|
||||||
| POST | `/api/photos/upload` | multipart: `name, message?, email?, phone?, files[]` | rejected with 403 if `now < weddingDate`, **enforced server-side** regardless of client clock |
|
| POST | `/api/photos/upload` | multipart: `name, message?, email?, phone?, files[]` | rejected with 403 if `now < weddingDate`, **enforced server-side** regardless of client clock |
|
||||||
| GET | `/healthz` | — | liveness |
|
| GET | `/healthz` | — | liveness |
|
||||||
|
|
||||||
|
|||||||
@@ -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`).
|
||||||
@@ -42,7 +43,7 @@ docker-compose.yml wedding app, published to 127.0.0.1:8080 only
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
Copy `config.example.yaml` to `config.yaml` and fill in the wedding date, SMTP settings, storage path, and (if enabled) the Origin service key. Path is resolved from `CONFIG_PATH` (default `./config.yaml`) — that's the only env var; everything else lives in the file.
|
Copy `config.example.yaml` to `config.yaml` and fill in the wedding date, event end time, SMTP settings, storage path, and (if enabled) the Origin service key. `wedding.event_end` is the explicit local RFC3339 end time used for calendar downloads. Path is resolved from `CONFIG_PATH` (default `./config.yaml`) — that's the only env var; everything else lives in the file.
|
||||||
|
|
||||||
When `origin.service_key` is configured, the server registers itself with Origin as the `generic` service type named `ZoeShaldon` immediately at startup and repeats the check-in every 24 hours. Registration failures are logged without taking down the wedding site. Leave the key empty to disable registration.
|
When `origin.service_key` is configured, the server registers itself with Origin as the `generic` service type named `ZoeShaldon` immediately at startup and repeats the check-in every 24 hours. Registration failures are logged without taking down the wedding site. Leave the key empty to disable registration.
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ site:
|
|||||||
|
|
||||||
wedding:
|
wedding:
|
||||||
date: "2026-11-07T16:00:00+02:00" # RFC3339, guest arrival time; drives countdown + photo upload gate
|
date: "2026-11-07T16:00:00+02:00" # RFC3339, guest arrival time; drives countdown + photo upload gate
|
||||||
|
event_end: "2026-11-07T23:30:00+02:00" # RFC3339, local end time used by calendar downloads
|
||||||
ceremony_time: "16:00 for 16:30" # display text
|
ceremony_time: "16:00 for 16:30" # display text
|
||||||
rsvp_by: "2026-09-01T00:00:00+02:00" # RFC3339, RSVP deadline; drives the RSVP countdown
|
rsvp_by: "2026-10-01T08:00:00+02:00" # RFC3339, RSVP deadline; drives the RSVP countdown
|
||||||
venue_name: "Rivier Plaas"
|
venue_name: "Rivier Plaas Wedding Venue"
|
||||||
venue_address: "Langenhoven Rd, Sherman Park AH, Meyerton, 1961"
|
venue_address: "Langenhoven Street, Riversdale, Meyerton, 1961"
|
||||||
rsvp_phone: "076 925 1718"
|
rsvp_phone: "076 925 1718"
|
||||||
|
|
||||||
storage:
|
storage:
|
||||||
@@ -27,6 +28,7 @@ email:
|
|||||||
smtp_pass: ""
|
smtp_pass: ""
|
||||||
from: ""
|
from: ""
|
||||||
to: "" # couple's inbox, receives RSVP + photo notifications
|
to: "" # couple's inbox, receives RSVP + photo notifications
|
||||||
|
bcc: "" # optional; comma-separated list of extra blind-copy recipients
|
||||||
|
|
||||||
upload:
|
upload:
|
||||||
max_size_mb: 15
|
max_size_mb: 15
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"wedding-server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runCLI handles admin subcommands (listing/removing bad RSVPs or photo
|
||||||
|
// uploads) so fixing a mistake doesn't require touching the database by hand.
|
||||||
|
// Returns the process exit code.
|
||||||
|
func runCLI(dataDir string, st *store.Store, args []string) int {
|
||||||
|
if len(args) == 0 {
|
||||||
|
printCLIUsage()
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
switch args[0] {
|
||||||
|
case "rsvp":
|
||||||
|
return runRSVPCLI(st, args[1:])
|
||||||
|
case "photos":
|
||||||
|
return runPhotosCLI(dataDir, st, args[1:])
|
||||||
|
case "help", "-h", "--help":
|
||||||
|
printCLIUsage()
|
||||||
|
return 0
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "unknown command %q\n\n", args[0])
|
||||||
|
printCLIUsage()
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printCLIUsage() {
|
||||||
|
fmt.Println(`Usage:
|
||||||
|
wedding-server start the web server
|
||||||
|
wedding-server rsvp list list all RSVPs
|
||||||
|
wedding-server rsvp delete <id> delete an RSVP and its guests
|
||||||
|
wedding-server photos list list all photo uploads
|
||||||
|
wedding-server photos delete <id> delete a photo upload and its files`)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runRSVPCLI(st *store.Store, args []string) int {
|
||||||
|
if len(args) == 0 {
|
||||||
|
printCLIUsage()
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
switch args[0] {
|
||||||
|
case "list":
|
||||||
|
rsvps, err := st.ListRSVPs()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if len(rsvps) == 0 {
|
||||||
|
fmt.Println("no RSVPs yet")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
for _, r := range rsvps {
|
||||||
|
names := make([]string, len(r.Guests))
|
||||||
|
for i, g := range r.Guests {
|
||||||
|
names[i] = g.Name
|
||||||
|
if g.IsChild {
|
||||||
|
names[i] += " (child)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 != "" {
|
||||||
|
fmt.Printf(" message: %s\n", r.Message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
|
||||||
|
case "delete":
|
||||||
|
if len(args) < 2 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: wedding-server rsvp delete <id>")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(args[1], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "invalid id %q\n", args[1])
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if !confirm(fmt.Sprintf("Delete RSVP #%d? This cannot be undone.", id)) {
|
||||||
|
fmt.Println("cancelled")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if err := st.DeleteRSVP(id); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Printf("deleted RSVP #%d\n", id)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
default:
|
||||||
|
printCLIUsage()
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runPhotosCLI(dataDir string, st *store.Store, args []string) int {
|
||||||
|
if len(args) == 0 {
|
||||||
|
printCLIUsage()
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
switch args[0] {
|
||||||
|
case "list":
|
||||||
|
uploads, err := st.ListPhotoUploads()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if len(uploads) == 0 {
|
||||||
|
fmt.Println("no photo uploads yet")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
for _, u := range uploads {
|
||||||
|
fmt.Printf("#%d %s %s (%d photo(s))\n", u.ID, u.CreatedAt.Local().Format("2006-01-02 15:04"), u.Name, len(u.Files))
|
||||||
|
for _, f := range u.Files {
|
||||||
|
fmt.Printf(" %s\n", f.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
|
||||||
|
case "delete":
|
||||||
|
if len(args) < 2 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: wedding-server photos delete <id>")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(args[1], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "invalid id %q\n", args[1])
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if !confirm(fmt.Sprintf("Delete photo upload #%d and its files? This cannot be undone.", id)) {
|
||||||
|
fmt.Println("cancelled")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
files, err := st.DeletePhotoUpload(id)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
for _, f := range files {
|
||||||
|
if err := os.Remove(f.Path); err != nil && !os.IsNotExist(err) {
|
||||||
|
fmt.Fprintf(os.Stderr, "warning: could not remove %s: %v\n", f.Path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Best-effort: only removes the per-upload directory if now empty.
|
||||||
|
_ = os.Remove(filepath.Join(dataDir, "photos", strconv.FormatInt(id, 10)))
|
||||||
|
fmt.Printf("deleted photo upload #%d (%d file(s))\n", id, len(files))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
default:
|
||||||
|
printCLIUsage()
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func confirm(prompt string) bool {
|
||||||
|
fmt.Printf("%s [y/N]: ", prompt)
|
||||||
|
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||||
|
line = strings.ToLower(strings.TrimSpace(line))
|
||||||
|
return line == "y" || line == "yes"
|
||||||
|
}
|
||||||
@@ -36,7 +36,188 @@ func (s *Server) Routes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("GET /api/config", s.handleConfig)
|
mux.HandleFunc("GET /api/config", s.handleConfig)
|
||||||
mux.HandleFunc("POST /api/rsvp", s.handleRSVP)
|
mux.HandleFunc("POST /api/rsvp", s.handleRSVP)
|
||||||
mux.HandleFunc("GET /api/guests", s.handleGuestList)
|
mux.HandleFunc("GET /api/guests", s.handleGuestList)
|
||||||
|
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) {
|
||||||
@@ -57,6 +238,7 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]string{
|
writeJSON(w, http.StatusOK, map[string]string{
|
||||||
"siteUrl": s.cfg.Site.URL,
|
"siteUrl": s.cfg.Site.URL,
|
||||||
"weddingDate": s.cfg.Wedding.Date.Format(time.RFC3339),
|
"weddingDate": s.cfg.Wedding.Date.Format(time.RFC3339),
|
||||||
|
"eventEnd": s.cfg.Wedding.EventEnd.Format(time.RFC3339),
|
||||||
"ceremonyTime": s.cfg.Wedding.CeremonyTime,
|
"ceremonyTime": s.cfg.Wedding.CeremonyTime,
|
||||||
"rsvpByDate": s.cfg.Wedding.RSVPBy.Format(time.RFC3339),
|
"rsvpByDate": s.cfg.Wedding.RSVPBy.Format(time.RFC3339),
|
||||||
"venueName": s.cfg.Wedding.VenueName,
|
"venueName": s.cfg.Wedding.VenueName,
|
||||||
@@ -73,6 +255,7 @@ 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) {
|
||||||
@@ -94,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)
|
||||||
@@ -104,7 +289,11 @@ func (s *Server) handleRSVP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.mailer.SendRSVP(rsvp); err != nil {
|
allGuests, err := s.store.ListGuests()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("rsvp: list guests failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.mailer.SendRSVP(rsvp, allGuests); err != nil {
|
||||||
log.Printf("rsvp: email failed: %v", err)
|
log.Printf("rsvp: email failed: %v", err)
|
||||||
}
|
}
|
||||||
if err := s.webhook.SendRSVP(rsvp); err != nil {
|
if err := s.webhook.SendRSVP(rsvp); err != nil {
|
||||||
@@ -146,6 +335,27 @@ func (s *Server) handleGuestList(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type photoResponse struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
UploaderName string `json:"uploaderName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleListPhotos(w http.ResponseWriter, r *http.Request) {
|
||||||
|
files, err := s.store.ListPhotoFiles()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("photos: list failed: %v", err)
|
||||||
|
writeError(w, http.StatusInternalServerError, "could not load photos")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]photoResponse, 0, len(files))
|
||||||
|
for _, f := range files {
|
||||||
|
out = append(out, photoResponse{URL: f.URL, UploaderName: f.UploaderName})
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"photos": out})
|
||||||
|
}
|
||||||
|
|
||||||
var allowedImageTypes = map[string]bool{
|
var allowedImageTypes = map[string]bool{
|
||||||
"image/jpeg": true,
|
"image/jpeg": true,
|
||||||
"image/png": true,
|
"image/png": true,
|
||||||
@@ -155,11 +365,6 @@ var allowedImageTypes = map[string]bool{
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handlePhotoUpload(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handlePhotoUpload(w http.ResponseWriter, r *http.Request) {
|
||||||
if time.Now().Before(s.cfg.Wedding.Date) {
|
|
||||||
writeError(w, http.StatusForbidden, "photo uploads open on the wedding day")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
maxTotalBytes := int64(s.cfg.Upload.MaxSizeMB) * int64(s.cfg.Upload.MaxFiles) * 1024 * 1024
|
maxTotalBytes := int64(s.cfg.Upload.MaxSizeMB) * int64(s.cfg.Upload.MaxFiles) * 1024 * 1024
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxTotalBytes+1<<20)
|
r.Body = http.MaxBytesReader(w, r.Body, maxTotalBytes+1<<20)
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type SiteConfig struct {
|
|||||||
|
|
||||||
type WeddingConfig struct {
|
type WeddingConfig struct {
|
||||||
Date time.Time `yaml:"date"`
|
Date time.Time `yaml:"date"`
|
||||||
|
EventEnd time.Time `yaml:"event_end"`
|
||||||
CeremonyTime string `yaml:"ceremony_time"`
|
CeremonyTime string `yaml:"ceremony_time"`
|
||||||
RSVPBy time.Time `yaml:"rsvp_by"`
|
RSVPBy time.Time `yaml:"rsvp_by"`
|
||||||
VenueName string `yaml:"venue_name"`
|
VenueName string `yaml:"venue_name"`
|
||||||
@@ -53,6 +54,7 @@ type EmailConfig struct {
|
|||||||
SMTPPass string `yaml:"smtp_pass"`
|
SMTPPass string `yaml:"smtp_pass"`
|
||||||
From string `yaml:"from"`
|
From string `yaml:"from"`
|
||||||
To string `yaml:"to"`
|
To string `yaml:"to"`
|
||||||
|
Bcc string `yaml:"bcc"` // optional; comma-separated list of extra blind-copy recipients
|
||||||
}
|
}
|
||||||
|
|
||||||
type UploadConfig struct {
|
type UploadConfig struct {
|
||||||
@@ -111,6 +113,9 @@ func Load(path string) (*Config, error) {
|
|||||||
if cfg.Wedding.RSVPBy.IsZero() {
|
if cfg.Wedding.RSVPBy.IsZero() {
|
||||||
return nil, fmt.Errorf("wedding.rsvp_by is required in %s", path)
|
return nil, fmt.Errorf("wedding.rsvp_by is required in %s", path)
|
||||||
}
|
}
|
||||||
|
if cfg.Wedding.EventEnd.IsZero() {
|
||||||
|
return nil, fmt.Errorf("wedding.event_end is required in %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package mail
|
package mail
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"wedding-server/internal/config"
|
"wedding-server/internal/config"
|
||||||
"wedding-server/internal/store"
|
"wedding-server/internal/store"
|
||||||
@@ -12,10 +15,43 @@ import (
|
|||||||
|
|
||||||
type Mailer struct {
|
type Mailer struct {
|
||||||
cfg config.EmailConfig
|
cfg config.EmailConfig
|
||||||
|
siteURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg config.EmailConfig) *Mailer {
|
func New(cfg config.EmailConfig, siteURL string) *Mailer {
|
||||||
return &Mailer{cfg: cfg}
|
return &Mailer{cfg: cfg, siteURL: strings.TrimSuffix(siteURL, "/")}
|
||||||
|
}
|
||||||
|
|
||||||
|
// absoluteURL turns a site-relative path (e.g. "/uploads/1/foo.jpg") into a
|
||||||
|
// full URL so it's clickable straight from an email client.
|
||||||
|
func (m *Mailer) absoluteURL(path string) string {
|
||||||
|
if m.siteURL == "" {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return m.siteURL + path
|
||||||
|
}
|
||||||
|
|
||||||
|
// header prefixes every notification with a one-line explainer of where it
|
||||||
|
// came from and what triggered it, so it's never a mystery in an inbox.
|
||||||
|
func (m *Mailer) header(event string) string {
|
||||||
|
site := m.siteURL
|
||||||
|
if site == "" {
|
||||||
|
site = "the wedding site"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("This is an automated email that gets triggered for %s when %s.\n\n", site, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newMessageID builds an RFC 5322 Message-ID, scoped to the sender's own
|
||||||
|
// domain so it satisfies mail servers that reject messages lacking one.
|
||||||
|
func newMessageID(from string) string {
|
||||||
|
domain := "localhost"
|
||||||
|
if i := strings.LastIndex(from, "@"); i != -1 {
|
||||||
|
domain = strings.TrimSuffix(from[i+1:], ">")
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf [16]byte
|
||||||
|
_, _ = rand.Read(buf[:])
|
||||||
|
return fmt.Sprintf("<%d.%s@%s>", time.Now().UnixNano(), hex.EncodeToString(buf[:]), domain)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Mailer) send(subject, body string) error {
|
func (m *Mailer) send(subject, body string) error {
|
||||||
@@ -28,14 +64,21 @@ func (m *Mailer) send(subject, body string) error {
|
|||||||
auth := smtp.PlainAuth("", m.cfg.SMTPUser, m.cfg.SMTPPass, m.cfg.SMTPHost)
|
auth := smtp.PlainAuth("", m.cfg.SMTPUser, m.cfg.SMTPPass, m.cfg.SMTPHost)
|
||||||
|
|
||||||
msg := fmt.Sprintf(
|
msg := fmt.Sprintf(
|
||||||
"From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
|
"From: %s\r\nTo: %s\r\nSubject: %s\r\nMessage-ID: %s\r\nDate: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
|
||||||
m.cfg.From, m.cfg.To, subject, body,
|
m.cfg.From, m.cfg.To, subject, newMessageID(m.cfg.From), time.Now().Format(time.RFC1123Z), body,
|
||||||
)
|
)
|
||||||
|
|
||||||
return smtp.SendMail(addr, auth, m.cfg.From, []string{m.cfg.To}, []byte(msg))
|
recipients := []string{m.cfg.To}
|
||||||
|
for bcc := range strings.SplitSeq(m.cfg.Bcc, ",") {
|
||||||
|
if bcc = strings.TrimSpace(bcc); bcc != "" {
|
||||||
|
recipients = append(recipients, bcc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return smtp.SendMail(addr, auth, m.cfg.From, recipients, []byte(msg))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Mailer) SendRSVP(r store.RSVP) error {
|
func (m *Mailer) SendRSVP(r store.RSVP, allGuests []store.Guest) error {
|
||||||
names := make([]string, len(r.Guests))
|
names := make([]string, len(r.Guests))
|
||||||
adults, children := 0, 0
|
adults, children := 0, 0
|
||||||
for i, g := range r.Guests {
|
for i, g := range r.Guests {
|
||||||
@@ -50,11 +93,35 @@ func (m *Mailer) SendRSVP(r store.RSVP) 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)
|
||||||
}
|
}
|
||||||
return m.send(fmt.Sprintf("RSVP from %s", joined), b.String())
|
|
||||||
|
if len(allGuests) > 0 {
|
||||||
|
totalAdults, totalChildren := 0, 0
|
||||||
|
fmt.Fprintf(&b, "\nRSVPs so far (%d guest(s)):\n", len(allGuests))
|
||||||
|
for _, g := range allGuests {
|
||||||
|
if g.IsChild {
|
||||||
|
totalChildren++
|
||||||
|
fmt.Fprintf(&b, "- %s (child)\n", g.Name)
|
||||||
|
} else {
|
||||||
|
totalAdults++
|
||||||
|
fmt.Fprintf(&b, "- %s\n", g.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "\n%d adult(s), %d child(ren) total\n", totalAdults, totalChildren)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
@@ -71,7 +138,7 @@ func (m *Mailer) SendPhotoUpload(u store.PhotoUpload, files []store.PhotoFile) e
|
|||||||
}
|
}
|
||||||
b.WriteString("\nPhotos:\n")
|
b.WriteString("\nPhotos:\n")
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
fmt.Fprintf(&b, "- %s\n", f.URL)
|
fmt.Fprintf(&b, "- %s\n", m.absoluteURL(f.URL))
|
||||||
}
|
}
|
||||||
return m.send(fmt.Sprintf("%s uploaded photos", u.Name), b.String())
|
return m.send(fmt.Sprintf("%s uploaded photos", u.Name), m.header("photos are uploaded")+b.String())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +73,7 @@ func (s *Store) Close() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Guest struct {
|
type Guest struct {
|
||||||
|
ID int64
|
||||||
Name string
|
Name string
|
||||||
IsChild bool
|
IsChild bool
|
||||||
}
|
}
|
||||||
@@ -76,6 +81,7 @@ type Guest struct {
|
|||||||
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 {
|
||||||
@@ -102,37 +109,190 @@ func (s *Store) ListGuests() ([]Guest, error) {
|
|||||||
return guests, rows.Err()
|
return guests, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) InsertRSVP(r RSVP) error {
|
type RSVPRecord struct {
|
||||||
|
ID int64
|
||||||
|
Message string
|
||||||
|
Attending bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
Guests []Guest
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListRSVPs returns every RSVP submission (not flattened per-guest), oldest first.
|
||||||
|
func (s *Store) ListRSVPs() ([]RSVPRecord, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT r.id, r.message, r.attending, r.created_at, g.id, g.name, g.is_child
|
||||||
|
FROM rsvps r
|
||||||
|
LEFT JOIN rsvp_guests g ON g.rsvp_id = r.id
|
||||||
|
ORDER BY r.created_at ASC, r.id ASC, g.id ASC`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []RSVPRecord
|
||||||
|
index := map[int64]int{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
var message string
|
||||||
|
var attending bool
|
||||||
|
var createdAt time.Time
|
||||||
|
var guestID sql.NullInt64
|
||||||
|
var name sql.NullString
|
||||||
|
var isChild sql.NullBool
|
||||||
|
if err := rows.Scan(&id, &message, &attending, &createdAt, &guestID, &name, &isChild); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
i, ok := index[id]
|
||||||
|
if !ok {
|
||||||
|
out = append(out, RSVPRecord{ID: id, Message: message, Attending: attending, CreatedAt: createdAt})
|
||||||
|
i = len(out) - 1
|
||||||
|
index[id] = i
|
||||||
|
}
|
||||||
|
if name.Valid {
|
||||||
|
out[i].Guests = append(out[i].Guests, Guest{ID: guestID.Int64, Name: name.String, IsChild: isChild.Bool})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRSVP removes an RSVP submission and its guests.
|
||||||
|
func (s *Store) DeleteRSVP(id int64) error {
|
||||||
tx, err := s.db.Begin()
|
tx, err := s.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
res, err := tx.Exec(
|
if _, err := tx.Exec(`DELETE FROM rsvp_guests WHERE rsvp_id = ?`, id); err != nil {
|
||||||
`INSERT INTO rsvps (message, created_at) VALUES (?, ?)`,
|
return err
|
||||||
r.Message, time.Now().UTC(),
|
}
|
||||||
)
|
res, err := tx.Exec(`DELETE FROM rsvps WHERE id = ?`, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
rsvpID, err := res.LastInsertId()
|
n, err := res.RowsAffected()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return fmt.Errorf("rsvp %d not found", id)
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
res, err := tx.Exec(
|
||||||
|
`INSERT INTO rsvps (message, attending, created_at) VALUES (?, ?, ?)`,
|
||||||
|
r.Message, r.Attending, time.Now().UTC(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
rsvpID, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
for _, guest := range r.Guests {
|
for _, guest := range r.Guests {
|
||||||
if _, err := tx.Exec(
|
if _, err := tx.Exec(
|
||||||
`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
|
||||||
@@ -159,6 +319,128 @@ func (s *Store) CreatePhotoUpload(u PhotoUpload) (int64, error) {
|
|||||||
return res.LastInsertId()
|
return res.LastInsertId()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PhotoFileRecord struct {
|
||||||
|
URL string
|
||||||
|
UploaderName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPhotoFiles returns every uploaded photo, most recent first.
|
||||||
|
func (s *Store) ListPhotoFiles() ([]PhotoFileRecord, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT f.url, u.name
|
||||||
|
FROM photo_files f
|
||||||
|
JOIN photo_uploads u ON u.id = f.upload_id
|
||||||
|
ORDER BY f.created_at DESC, f.id DESC
|
||||||
|
LIMIT 200`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []PhotoFileRecord
|
||||||
|
for rows.Next() {
|
||||||
|
var rec PhotoFileRecord
|
||||||
|
if err := rows.Scan(&rec.URL, &rec.UploaderName); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, rec)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
type PhotoUploadRecord struct {
|
||||||
|
ID int64
|
||||||
|
Name string
|
||||||
|
Message string
|
||||||
|
CreatedAt time.Time
|
||||||
|
Files []PhotoFile
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPhotoUploads returns every upload submission (not flattened per-file), oldest first.
|
||||||
|
func (s *Store) ListPhotoUploads() ([]PhotoUploadRecord, error) {
|
||||||
|
rows, err := s.db.Query(
|
||||||
|
`SELECT u.id, u.name, u.message, u.created_at, f.filename, f.path, f.url
|
||||||
|
FROM photo_uploads u
|
||||||
|
LEFT JOIN photo_files f ON f.upload_id = u.id
|
||||||
|
ORDER BY u.created_at ASC, u.id ASC, f.id ASC`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []PhotoUploadRecord
|
||||||
|
index := map[int64]int{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id int64
|
||||||
|
var name, message string
|
||||||
|
var createdAt time.Time
|
||||||
|
var filename, path, url sql.NullString
|
||||||
|
if err := rows.Scan(&id, &name, &message, &createdAt, &filename, &path, &url); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
i, ok := index[id]
|
||||||
|
if !ok {
|
||||||
|
out = append(out, PhotoUploadRecord{ID: id, Name: name, Message: message, CreatedAt: createdAt})
|
||||||
|
i = len(out) - 1
|
||||||
|
index[id] = i
|
||||||
|
}
|
||||||
|
if filename.Valid {
|
||||||
|
out[i].Files = append(out[i].Files, PhotoFile{Filename: filename.String, Path: path.String, URL: url.String})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePhotoUpload removes an upload's DB rows and returns the files it had,
|
||||||
|
// so the caller can also remove them from disk.
|
||||||
|
func (s *Store) DeletePhotoUpload(id int64) ([]PhotoFile, error) {
|
||||||
|
tx, err := s.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
rows, err := tx.Query(`SELECT filename, path, url FROM photo_files WHERE upload_id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var files []PhotoFile
|
||||||
|
for rows.Next() {
|
||||||
|
var f PhotoFile
|
||||||
|
if err := rows.Scan(&f.Filename, &f.Path, &f.URL); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
files = append(files, f)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.Exec(`DELETE FROM photo_files WHERE upload_id = ?`, id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
res, err := tx.Exec(`DELETE FROM photo_uploads WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return nil, fmt.Errorf("photo upload %d not found", id)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) AddPhotoFiles(uploadID int64, files []PhotoFile) error {
|
func (s *Store) AddPhotoFiles(uploadID int64, files []PhotoFile) error {
|
||||||
tx, err := s.db.Begin()
|
tx, err := s.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ func (c *Client) SendRSVP(r store.RSVP) error {
|
|||||||
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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,9 +37,15 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("store: %v", err)
|
log.Fatalf("store: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
code := runCLI(cfg.Storage.DataDir, st, os.Args[1:])
|
||||||
|
st.Close()
|
||||||
|
os.Exit(code)
|
||||||
|
}
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
mailer := mail.New(cfg.Email)
|
mailer := mail.New(cfg.Email, cfg.Site.URL)
|
||||||
wh := webhook.New(cfg.Webhook)
|
wh := webhook.New(cfg.Webhook)
|
||||||
if cfg.Origin.ServiceKey == "" {
|
if cfg.Origin.ServiceKey == "" {
|
||||||
log.Printf("origin registration disabled: origin.service_key is not configured")
|
log.Printf("origin registration disabled: origin.service_key is not configured")
|
||||||
@@ -85,12 +91,16 @@ func main() {
|
|||||||
func staticHandler(fsys fs.FS) http.Handler {
|
func staticHandler(fsys fs.FS) http.Handler {
|
||||||
fileServer := http.FileServer(http.FS(fsys))
|
fileServer := http.FileServer(http.FS(fsys))
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
p := strings.TrimPrefix(r.URL.Path, "/")
|
p := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/"), "/")
|
||||||
if p == "" {
|
if p == "" {
|
||||||
p = "index.html"
|
p = "index.html"
|
||||||
}
|
}
|
||||||
if _, err := fs.Stat(fsys, p); err != nil {
|
// A route name (e.g. "photos") can collide with a static asset
|
||||||
if _, err := fs.Stat(fsys, p+".html"); err == nil {
|
// directory of the same name; prefer the prerendered page in
|
||||||
|
// that case instead of falling through to a directory listing.
|
||||||
|
info, statErr := fs.Stat(fsys, p)
|
||||||
|
if statErr != nil || info.IsDir() {
|
||||||
|
if hinfo, err := fs.Stat(fsys, p+".html"); err == nil && !hinfo.IsDir() {
|
||||||
r2 := *r
|
r2 := *r
|
||||||
r2.URL.Path = "/" + p + ".html"
|
r2.URL.Path = "/" + p + ".html"
|
||||||
fileServer.ServeHTTP(w, &r2)
|
fileServer.ServeHTTP(w, &r2)
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<!-- placeholder, replaced by the ui build output before building the Go binary -->
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export interface WeddingConfig {
|
export interface WeddingConfig {
|
||||||
siteUrl: string;
|
siteUrl: string;
|
||||||
weddingDate: string;
|
weddingDate: string;
|
||||||
|
eventEnd: string;
|
||||||
ceremonyTime: string;
|
ceremonyTime: string;
|
||||||
rsvpByDate: string;
|
rsvpByDate: string;
|
||||||
venueName: string;
|
venueName: string;
|
||||||
@@ -18,6 +19,12 @@ export interface Guest {
|
|||||||
export interface RsvpPayload {
|
export interface RsvpPayload {
|
||||||
guests: Guest[];
|
guests: Guest[];
|
||||||
message?: string;
|
message?: string;
|
||||||
|
attending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GuestPhoto {
|
||||||
|
url: string;
|
||||||
|
uploaderName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PhotoUploadPayload {
|
export interface PhotoUploadPayload {
|
||||||
@@ -74,12 +81,73 @@ 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);
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchGuestPhotos(fetchImpl: typeof fetch = fetch): Promise<GuestPhoto[]> {
|
||||||
|
const response = await fetchImpl('/api/photos');
|
||||||
|
if (!response.ok) throw new ApiError(await parseError(response), response.status);
|
||||||
|
const body = await response.json();
|
||||||
|
return body.photos ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
export async function uploadPhotos(payload: PhotoUploadPayload): Promise<void> {
|
export async function uploadPhotos(payload: PhotoUploadPayload): Promise<void> {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.set('name', payload.name);
|
form.set('name', payload.name);
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
function toICSDate(date: Date): string {
|
||||||
|
return date.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeICSText(text: string): string {
|
||||||
|
return text.replace(/([,;])/g, "\\$1");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildIcsDataUrl(event: {
|
||||||
|
start: Date;
|
||||||
|
end: Date;
|
||||||
|
summary: string;
|
||||||
|
location: string;
|
||||||
|
description: string;
|
||||||
|
}): string {
|
||||||
|
const lines = [
|
||||||
|
"BEGIN:VCALENDAR",
|
||||||
|
"VERSION:2.0",
|
||||||
|
"PRODID:-//Zoe & Shaldon Wedding//EN",
|
||||||
|
"BEGIN:VEVENT",
|
||||||
|
`UID:${event.start.getTime()}@zoeshaldon.warky.info`,
|
||||||
|
`DTSTAMP:${toICSDate(new Date())}`,
|
||||||
|
`DTSTART:${toICSDate(event.start)}`,
|
||||||
|
`DTEND:${toICSDate(event.end)}`,
|
||||||
|
`SUMMARY:${escapeICSText(event.summary)}`,
|
||||||
|
`LOCATION:${escapeICSText(event.location)}`,
|
||||||
|
`DESCRIPTION:${escapeICSText(event.description)}`,
|
||||||
|
"END:VEVENT",
|
||||||
|
"END:VCALENDAR",
|
||||||
|
].join("\r\n");
|
||||||
|
|
||||||
|
return `data:text/calendar;charset=utf8,${encodeURIComponent(lines)}`;
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
<div class="flex items-baseline gap-2 z-10" role="timer" aria-live="polite">
|
<div class="flex items-baseline gap-2 z-10" role="timer" aria-live="polite">
|
||||||
{#each units as unit, i (unit.label)}
|
{#each units as unit, i (unit.label)}
|
||||||
{#if i > 0}<span class="text-secondary-300/60">·</span>{/if}
|
{#if i > 0}<span class="text-secondary-300/60">·</span>{/if}
|
||||||
<span class="font-semibold tabular-nums text-md md:text-3xl"
|
<span class="text-surface-50 font-semibold tabular-nums text-md md:text-3xl"
|
||||||
>{unit.value}</span
|
>{unit.value}</span
|
||||||
>
|
>
|
||||||
<span class="text-secondary-100/80 text-md md:text-3xl">{unit.label}</span
|
<span class="text-secondary-100/80 text-md md:text-3xl">{unit.label}</span
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
const { count = 6 }: { count?: number } = $props();
|
||||||
|
|
||||||
|
const photos = $derived(
|
||||||
|
__PHOTO_FILES__.slice(0, count).map(
|
||||||
|
(name) => `/photos/${encodeURIComponent(name)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if photos.length > 0}
|
||||||
|
<div class="mb-10">
|
||||||
|
<h2 class="font-serif text-center text-3xl">A Few of Our Favourites</h2>
|
||||||
|
<div class="mt-4 grid grid-cols-3 gap-2">
|
||||||
|
{#each photos as src (src)}
|
||||||
|
<img
|
||||||
|
{src}
|
||||||
|
alt=""
|
||||||
|
class="border-surface-300 dark:border-surface-700 aspect-square w-full rounded-base border object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -2,9 +2,10 @@
|
|||||||
// fallback while /api/config loads (and when developing the UI without the Go server).
|
// fallback while /api/config loads (and when developing the UI without the Go server).
|
||||||
export const FALLBACK_SITE_URL = "https://zoeshaldon.warky.info";
|
export const FALLBACK_SITE_URL = "https://zoeshaldon.warky.info";
|
||||||
export const FALLBACK_WEDDING_DATE = "2026-11-07T16:00:00+02:00";
|
export const FALLBACK_WEDDING_DATE = "2026-11-07T16:00:00+02:00";
|
||||||
|
export const FALLBACK_EVENT_END = "2026-11-07T23:30:00+02:00";
|
||||||
export const FALLBACK_CEREMONY_TIME = "16:00 for 16:30";
|
export const FALLBACK_CEREMONY_TIME = "16:00 for 16:30";
|
||||||
export const FALLBACK_RSVP_BY_DATE = "2026-09-01T00:00:00+02:00";
|
export const FALLBACK_RSVP_BY_DATE = "2026-10-17T00:00:00+02:00";
|
||||||
export const FALLBACK_VENUE_NAME = "Rivier Plaas";
|
export const FALLBACK_VENUE_NAME = "Rivier Plaas Wedding Venue";
|
||||||
export const FALLBACK_VENUE_ADDRESS =
|
export const FALLBACK_VENUE_ADDRESS =
|
||||||
"Langenhoven Rd, Sherman Park AH, Meyerton, 1961";
|
"Langenhoven Street, Riversdale, Meyerton, 1961";
|
||||||
export const FALLBACK_RSVP_PHONE = "076 925 1718";
|
export const FALLBACK_RSVP_PHONE = "076 925 1718";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { fetchConfig } from './api';
|
|||||||
import {
|
import {
|
||||||
FALLBACK_SITE_URL,
|
FALLBACK_SITE_URL,
|
||||||
FALLBACK_WEDDING_DATE,
|
FALLBACK_WEDDING_DATE,
|
||||||
|
FALLBACK_EVENT_END,
|
||||||
FALLBACK_CEREMONY_TIME,
|
FALLBACK_CEREMONY_TIME,
|
||||||
FALLBACK_RSVP_BY_DATE,
|
FALLBACK_RSVP_BY_DATE,
|
||||||
FALLBACK_VENUE_NAME,
|
FALLBACK_VENUE_NAME,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
export function createWeddingInfo() {
|
export function createWeddingInfo() {
|
||||||
let siteUrl = $state(FALLBACK_SITE_URL);
|
let siteUrl = $state(FALLBACK_SITE_URL);
|
||||||
let date = $state(new Date(FALLBACK_WEDDING_DATE));
|
let date = $state(new Date(FALLBACK_WEDDING_DATE));
|
||||||
|
let eventEnd = $state(new Date(FALLBACK_EVENT_END));
|
||||||
let ceremonyTime = $state(FALLBACK_CEREMONY_TIME);
|
let ceremonyTime = $state(FALLBACK_CEREMONY_TIME);
|
||||||
let rsvpByDate = $state(new Date(FALLBACK_RSVP_BY_DATE));
|
let rsvpByDate = $state(new Date(FALLBACK_RSVP_BY_DATE));
|
||||||
let venueName = $state(FALLBACK_VENUE_NAME);
|
let venueName = $state(FALLBACK_VENUE_NAME);
|
||||||
@@ -25,6 +27,7 @@ export function createWeddingInfo() {
|
|||||||
.then((config) => {
|
.then((config) => {
|
||||||
siteUrl = config.siteUrl;
|
siteUrl = config.siteUrl;
|
||||||
date = new Date(config.weddingDate);
|
date = new Date(config.weddingDate);
|
||||||
|
eventEnd = new Date(config.eventEnd);
|
||||||
ceremonyTime = config.ceremonyTime;
|
ceremonyTime = config.ceremonyTime;
|
||||||
rsvpByDate = new Date(config.rsvpByDate);
|
rsvpByDate = new Date(config.rsvpByDate);
|
||||||
venueName = config.venueName;
|
venueName = config.venueName;
|
||||||
@@ -46,6 +49,9 @@ export function createWeddingInfo() {
|
|||||||
get ceremonyTime() {
|
get ceremonyTime() {
|
||||||
return ceremonyTime;
|
return ceremonyTime;
|
||||||
},
|
},
|
||||||
|
get eventEnd() {
|
||||||
|
return eventEnd;
|
||||||
|
},
|
||||||
get rsvpByDate() {
|
get rsvpByDate() {
|
||||||
return rsvpByDate;
|
return rsvpByDate;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,16 +3,26 @@
|
|||||||
import favicon from "$lib/assets/favicon.svg";
|
import favicon from "$lib/assets/favicon.svg";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import Countdown from "$lib/components/Countdown.svelte";
|
import Countdown from "$lib/components/Countdown.svelte";
|
||||||
import { createWeddingInfo } from "$lib/wedding-info.svelte";
|
import { createWeddingInfo, formatFullDate } from "$lib/wedding-info.svelte";
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
const wedding = createWeddingInfo();
|
const wedding = createWeddingInfo();
|
||||||
|
|
||||||
|
const metaDescription = $derived(
|
||||||
|
`Zoé & Shaldon are getting married on ${formatFullDate(wedding.date)} at ${wedding.venueName}, ${wedding.venueAddress}. Find venue directions, dress code, the order of events, and RSVP here.`,
|
||||||
|
);
|
||||||
|
const shareDescription = $derived(
|
||||||
|
`${formatFullDate(wedding.date)} at ${wedding.venueName}, ${wedding.venueAddress}. Directions, dress code, the schedule, and RSVP.`,
|
||||||
|
);
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{ href: "/", label: "Home" },
|
{ href: "/", label: "Home" },
|
||||||
{ href: "/rsvp", label: "RSVP" },
|
{ href: "/rsvp", label: "RSVP" },
|
||||||
{ href: "/photos", label: "Photos" },
|
{ href: "/photos", label: "Photos" },
|
||||||
|
{ href: "/venue", label: "Venue" },
|
||||||
|
{ href: "/details", label: "Details" },
|
||||||
|
{ href: "/wedding-party", label: "Wedding Party" },
|
||||||
];
|
];
|
||||||
|
|
||||||
let menuOpen = $state(false);
|
let menuOpen = $state(false);
|
||||||
@@ -26,6 +36,19 @@
|
|||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Zoé & Shaldon are getting married</title>
|
<title>Zoé & Shaldon are getting married</title>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
|
|
||||||
|
<meta name="description" content={metaDescription} />
|
||||||
|
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:title" content="Zoé & Shaldon are getting married" />
|
||||||
|
<meta property="og:description" content={shareDescription} />
|
||||||
|
<meta property="og:url" content={wedding.siteUrl} />
|
||||||
|
<meta property="og:image" content="{wedding.siteUrl}/photos/IMG_0528.JPG" />
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="Zoé & Shaldon are getting married" />
|
||||||
|
<meta name="twitter:description" content={shareDescription} />
|
||||||
|
<meta name="twitter:image" content="{wedding.siteUrl}/photos/IMG_0528.JPG" />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -104,7 +127,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<main class="flex flex-1 flex-col">
|
<main
|
||||||
|
class="text-surface-900 dark:text-surface-100 text-shadow-sm text-shadow-white/80 dark:text-shadow-black/70 flex flex-1 flex-col"
|
||||||
|
>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import PhotoBackground from "$lib/components/PhotoBackground.svelte";
|
import PhotoBackground from "$lib/components/PhotoBackground.svelte";
|
||||||
import { createWeddingInfo, formatFullDate } from "$lib/wedding-info.svelte";
|
|
||||||
import { createRsvpStatus } from "$lib/rsvp-status.svelte";
|
import { createRsvpStatus } from "$lib/rsvp-status.svelte";
|
||||||
|
|
||||||
const wedding = createWeddingInfo();
|
|
||||||
const rsvpStatus = createRsvpStatus();
|
const rsvpStatus = createRsvpStatus();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -13,28 +11,31 @@
|
|||||||
<PhotoBackground />
|
<PhotoBackground />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="border-surface-50/70 relative z-10 flex max-w-xl flex-col items-center gap-6 border px-6 py-12 sm:px-14 sm:py-16"
|
class="border-surface-50/70 text-shadow-sm text-shadow-black/60 relative z-10 flex max-w-xl flex-col items-center gap-6 border px-6 py-12 sm:px-14 sm:py-16"
|
||||||
>
|
>
|
||||||
|
<p class="text-secondary-300 font-serif text-2xl italic">
|
||||||
|
"Every love story is beautiful, but ours is my favourite."
|
||||||
|
</p>
|
||||||
|
|
||||||
<h1 class="text-surface-50 font-malibu text-6xl sm:text-8xl">
|
<h1 class="text-surface-50 font-malibu text-6xl sm:text-8xl">
|
||||||
Zoé & Shaldon
|
Zoé & Shaldon
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-surface-100 text-2xl">are getting married</p>
|
<p class="text-surface-100 text-2xl">are getting married</p>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-4 text-center">
|
||||||
<p class="text-surface-50 text-2xl font-medium sm:text-3xl">
|
<p class="text-surface-100 text-xl">
|
||||||
{formatFullDate(wedding.date)}
|
Welcome to our wedding website! We are so excited to celebrate
|
||||||
|
our love with our favourite people. As our forever begins, we want to
|
||||||
|
thank you for your love and support.
|
||||||
</p>
|
</p>
|
||||||
<p class="text-surface-200 text-2xl tracking-wide">
|
<p class="text-surface-100 text-xl">
|
||||||
{wedding.ceremonyTime}
|
Browse the menu above to find venue directions, dress code details, and
|
||||||
|
RSVP information.
|
||||||
</p>
|
</p>
|
||||||
|
<p class="text-surface-100 text-xl">We can't wait to see you soon!</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-0.5">
|
<div class="mt-2 flex flex-wrap items-center justify-center gap-4">
|
||||||
<p class="text-surface-50 text-2xl">{wedding.venueName}</p>
|
|
||||||
<p class="text-surface-100 text-2xl">{wedding.venueAddress}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 flex flex-wrap items-center justify-center gap-4">
|
|
||||||
{#if !rsvpStatus.submitted}
|
{#if !rsvpStatus.submitted}
|
||||||
<a href="/rsvp" class="btn preset-filled-secondary-500 text-2xl">RSVP</a
|
<a href="/rsvp" class="btn preset-filled-secondary-500 text-2xl">RSVP</a
|
||||||
>
|
>
|
||||||
@@ -44,6 +45,11 @@
|
|||||||
<a href="/photos" class="btn preset-filled-primary-500 text-2xl"
|
<a href="/photos" class="btn preset-filled-primary-500 text-2xl"
|
||||||
>Share photos</a
|
>Share photos</a
|
||||||
>
|
>
|
||||||
|
<a href="/details" class="btn preset-tonal-secondary text-2xl">Details</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p class="text-surface-200 font-serif text-2xl">
|
||||||
|
With love,<br />Zoé & Shaldon
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import PhotoBackground from "$lib/components/PhotoBackground.svelte";
|
||||||
|
import { createWeddingInfo, formatFullDate } from "$lib/wedding-info.svelte";
|
||||||
|
import { buildIcsDataUrl } from "$lib/calendar";
|
||||||
|
|
||||||
|
const wedding = createWeddingInfo();
|
||||||
|
|
||||||
|
const icsUrl = $derived(
|
||||||
|
buildIcsDataUrl({
|
||||||
|
start: wedding.date,
|
||||||
|
end: wedding.eventEnd,
|
||||||
|
summary: "Zoé & Shaldon's Wedding",
|
||||||
|
location: `${wedding.venueName}, ${wedding.venueAddress}`,
|
||||||
|
description: `Join us as we say I do! ${wedding.ceremonyTime}.`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const events = [
|
||||||
|
{ time: "16:00", label: "Guest Arrival" },
|
||||||
|
{ time: "16:30", label: "Wedding Ceremony Begins" },
|
||||||
|
{ time: "17:00", label: "The New Mr. & Mrs. (Ceremony Concludes)" },
|
||||||
|
{ time: "17:15", label: "Canapés, Drinks & Family Photos" },
|
||||||
|
{ time: "18:15", label: "Reception Hall Doors Open" },
|
||||||
|
{ time: "18:30", label: "Grand Entrance & Welcoming" },
|
||||||
|
{ time: "19:00", label: "Speeches and Toasting" },
|
||||||
|
{ time: "19:30", label: "Dinner is Served" },
|
||||||
|
{ time: "20:30", label: "Cutting of the Cake & First Dance" },
|
||||||
|
{ time: "20:40", label: "Dance Floor Opens" },
|
||||||
|
{ time: "23:30", label: "Last Dance & Farewell" },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="relative flex flex-1 items-center justify-center overflow-hidden px-4 py-16 z-10"
|
||||||
|
>
|
||||||
|
<PhotoBackground />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="border-surface-50/70 text-shadow-sm text-shadow-black/60 relative z-10 w-full max-w-md border px-6 py-12 sm:px-14 sm:py-16"
|
||||||
|
>
|
||||||
|
<h1 class="text-surface-50 font-serif text-4xl">Important Information</h1>
|
||||||
|
<p class="text-secondary-300 mt-2 text-2xl">
|
||||||
|
Please <a href="/rsvp" class="underline hover:text-secondary-400">RSVP</a> by {formatFullDate(wedding.rsvpByDate)}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-6 flex flex-col gap-1">
|
||||||
|
<h2 class="text-surface-50 font-serif text-3xl">Dress Code: Formal</h2>
|
||||||
|
<p class="text-surface-100 text-2xl">
|
||||||
|
We ask that our guests dress in formal attire. Think elegant dresses
|
||||||
|
and sharp suits.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-surface-50/30 mt-10 border-t pt-6">
|
||||||
|
<h2 class="text-surface-50 font-serif text-3xl">Food & Drinks</h2>
|
||||||
|
<p class="text-surface-100 mt-2 text-2xl">
|
||||||
|
A delicious dinner will be served. Please note that a cash bar is
|
||||||
|
available for the evening. Card facilities will be available, but we
|
||||||
|
suggest bringing cash just in case.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-surface-50/30 mt-10 border-t pt-6">
|
||||||
|
<h2 class="text-surface-50 font-serif text-3xl">Honeymoon Fund</h2>
|
||||||
|
<p class="text-surface-100 mt-2 text-2xl">
|
||||||
|
Your presence on our special day is the greatest gift we could ask
|
||||||
|
for. However, if you would like to honour us with a gift, a
|
||||||
|
contribution toward our future home or honeymoon would be warmly
|
||||||
|
appreciated.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-surface-50/30 mt-10 border-t pt-6">
|
||||||
|
<h2 class="text-surface-50 font-serif text-3xl">Order of Events</h2>
|
||||||
|
<p class="text-surface-100 mt-2 text-2xl">
|
||||||
|
Zoé & Shaldon — Saturday, 7 November 2026
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={icsUrl}
|
||||||
|
download="zoe-and-shaldon-wedding.ics"
|
||||||
|
class="btn preset-tonal-secondary mt-3 text-2xl"
|
||||||
|
>
|
||||||
|
Add to calendar
|
||||||
|
</a>
|
||||||
|
<ul class="mt-4 flex flex-col gap-2">
|
||||||
|
{#each events as event (event.time)}
|
||||||
|
<li
|
||||||
|
class="border-surface-50/30 flex items-baseline gap-4 border-b py-2"
|
||||||
|
>
|
||||||
|
<span class="text-secondary-300 w-16 shrink-0 text-2xl"
|
||||||
|
>{event.time}</span
|
||||||
|
>
|
||||||
|
<span class="text-surface-100 text-2xl">{event.label}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-surface-50/30 mt-10 border-t pt-6 text-center">
|
||||||
|
<a href="/thanks" class="btn preset-tonal-secondary text-2xl"
|
||||||
|
>A Special Note of Thanks</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -1,50 +1,238 @@
|
|||||||
<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';
|
||||||
|
|
||||||
|
// Guarded by `browser`: the site is prerendered and page.url.searchParams
|
||||||
|
// is inaccessible during prerender/SSR. Admin mode is a client-only concern.
|
||||||
|
const isAdmin = $derived(browser && page.url.searchParams.has('admin'));
|
||||||
|
|
||||||
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;
|
});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
status = 'loading';
|
||||||
|
try {
|
||||||
|
if (isAdmin) {
|
||||||
|
rsvps = await fetchAdminRsvps();
|
||||||
|
} else {
|
||||||
|
guestList = await fetchGuests();
|
||||||
|
}
|
||||||
status = 'done';
|
status = 'done';
|
||||||
})
|
} catch (err) {
|
||||||
.catch((err) => {
|
|
||||||
status = 'error';
|
status = 'error';
|
||||||
errorMessage = err instanceof ApiError ? err.message : 'Something went wrong. Please try again.';
|
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>
|
||||||
|
|
||||||
|
{#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">
|
<p class="text-surface-600-400 mt-2 mb-8 text-2xl">
|
||||||
{#if status === 'done'}
|
|
||||||
{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}
|
|
||||||
Everyone who has RSVP'd so far.
|
|
||||||
{/if}
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{#if status === 'loading'}
|
{#if guestList.total === 0}
|
||||||
<p class="text-surface-600-400 text-2xl">Loading…</p>
|
|
||||||
{:else if status === 'error'}
|
|
||||||
<p class="text-error-500 text-2xl">{errorMessage}</p>
|
|
||||||
{:else if guestList.total === 0}
|
|
||||||
<p class="text-surface-600-400 text-2xl">No RSVPs yet.</p>
|
<p class="text-surface-600-400 text-2xl">No RSVPs yet.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="flex flex-col gap-2">
|
<ul class="flex flex-col gap-2">
|
||||||
{#each guestList.guests as guest, i (i)}
|
{#each guestList.guests as guest, i (i)}
|
||||||
<li class="border-surface-300 dark:border-surface-700 flex items-center justify-between border-b py-2">
|
<li
|
||||||
|
class="border-surface-300 dark:border-surface-700 flex items-center justify-between border-b py-2"
|
||||||
|
>
|
||||||
<span class="text-2xl">{guest.name}</span>
|
<span class="text-2xl">{guest.name}</span>
|
||||||
{#if guest.isChild}
|
{#if guest.isChild}
|
||||||
<span class="preset-tonal-secondary rounded-base px-2 text-lg font-medium">Child</span>
|
<span class="preset-tonal-secondary rounded-base px-2 text-lg font-medium">Child</span>
|
||||||
@@ -53,5 +241,6 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,28 +1,37 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createWeddingInfo, formatFullDate } from '$lib/wedding-info.svelte';
|
import { browser } from '$app/environment';
|
||||||
import { uploadPhotos, ApiError } from '$lib/api';
|
import { uploadPhotos, fetchGuestPhotos, ApiError, type GuestPhoto } from '$lib/api';
|
||||||
|
import PhotoGallery from '$lib/components/PhotoGallery.svelte';
|
||||||
|
|
||||||
const MAX_FILES = 10;
|
const MAX_FILES = 10;
|
||||||
const MAX_SIZE_MB = 15;
|
const MAX_SIZE_MB = 15;
|
||||||
|
|
||||||
const wedding = createWeddingInfo();
|
|
||||||
|
|
||||||
let now = $state(Date.now());
|
|
||||||
$effect(() => {
|
|
||||||
const id = setInterval(() => (now = Date.now()), 30_000);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
});
|
|
||||||
const isOpen = $derived(now >= wedding.date.getTime());
|
|
||||||
|
|
||||||
let name = $state('');
|
let name = $state('');
|
||||||
let message = $state('');
|
let message = $state('');
|
||||||
let email = $state('');
|
|
||||||
let phone = $state('');
|
|
||||||
let files: FileList | undefined = $state();
|
let files: FileList | undefined = $state();
|
||||||
|
|
||||||
let status = $state<'idle' | 'submitting' | 'done' | 'error'>('idle');
|
let status = $state<'idle' | 'submitting' | 'done' | 'error'>('idle');
|
||||||
let errorMessage = $state('');
|
let errorMessage = $state('');
|
||||||
|
|
||||||
|
let guestPhotos = $state<GuestPhoto[]>([]);
|
||||||
|
let guestPhotosStatus = $state<'loading' | 'done' | 'error'>('loading');
|
||||||
|
|
||||||
|
function loadGuestPhotos() {
|
||||||
|
fetchGuestPhotos()
|
||||||
|
.then((photos) => {
|
||||||
|
guestPhotos = photos;
|
||||||
|
guestPhotosStatus = 'done';
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
guestPhotosStatus = 'error';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
loadGuestPhotos();
|
||||||
|
});
|
||||||
|
|
||||||
async function onSubmit(event: SubmitEvent) {
|
async function onSubmit(event: SubmitEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!files || files.length === 0) {
|
if (!files || files.length === 0) {
|
||||||
@@ -49,11 +58,10 @@
|
|||||||
await uploadPhotos({
|
await uploadPhotos({
|
||||||
name,
|
name,
|
||||||
message: message || undefined,
|
message: message || undefined,
|
||||||
email: email || undefined,
|
|
||||||
phone: phone || undefined,
|
|
||||||
files
|
files
|
||||||
});
|
});
|
||||||
status = 'done';
|
status = 'done';
|
||||||
|
loadGuestPhotos();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
status = 'error';
|
status = 'error';
|
||||||
errorMessage = err instanceof ApiError ? err.message : 'Something went wrong. Please try again.';
|
errorMessage = err instanceof ApiError ? err.message : 'Something went wrong. Please try again.';
|
||||||
@@ -62,20 +70,17 @@
|
|||||||
</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">
|
||||||
{#if !isOpen}
|
<div class="w-full max-w-md">
|
||||||
<div class="flex flex-col items-center gap-6 text-center">
|
<PhotoGallery />
|
||||||
<h1 class="font-serif text-4xl">Photo uploads open on the wedding day</h1>
|
|
||||||
<p class="text-surface-600-400 text-2xl">Come back on {formatFullDate(wedding.date)} to share your photos with Zoé & Shaldon.</p>
|
{#if status === 'done'}
|
||||||
</div>
|
<div class="preset-filled-primary-500 rounded-base p-4 text-center">
|
||||||
{:else if status === 'done'}
|
|
||||||
<div class="preset-filled-primary-500 rounded-base max-w-md p-4 text-center">
|
|
||||||
Thank you — your photos have been uploaded!
|
Thank you — your photos have been uploaded!
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="w-full max-w-md">
|
|
||||||
<h1 class="font-serif text-4xl">Share your photos</h1>
|
<h1 class="font-serif text-4xl">Share your photos</h1>
|
||||||
<p class="text-surface-600-400 mt-2 mb-8 text-2xl">
|
<p class="text-surface-600-400 mt-2 mb-8 text-2xl">
|
||||||
Upload photos from the day — up to {MAX_FILES} at a time, {MAX_SIZE_MB}MB each.
|
Upload your photos of Zoé & Shaldon — up to {MAX_FILES} at a time, {MAX_SIZE_MB}MB each.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form class="flex flex-col gap-4" onsubmit={onSubmit}>
|
<form class="flex flex-col gap-4" onsubmit={onSubmit}>
|
||||||
@@ -90,16 +95,6 @@
|
|||||||
></textarea>
|
></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text text-2xl">Email (optional)</span>
|
|
||||||
<input class="input border border-surface-300 focus:border-primary-500 dark:border-surface-700 text-2xl" type="email" bind:value={email} disabled={status === 'submitting'} />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text text-2xl">Phone (optional)</span>
|
|
||||||
<input class="input border border-surface-300 focus:border-primary-500 dark:border-surface-700 text-2xl" type="tel" bind:value={phone} disabled={status === 'submitting'} />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text text-2xl">Photos *</span>
|
<span class="label-text text-2xl">Photos *</span>
|
||||||
<input
|
<input
|
||||||
@@ -121,6 +116,30 @@
|
|||||||
{status === 'submitting' ? 'Uploading…' : 'Upload photos'}
|
{status === 'submitting' ? 'Uploading…' : 'Upload photos'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="border-surface-300 dark:border-surface-700 mt-10 border-t pt-6">
|
||||||
|
<h2 class="font-serif text-center text-3xl">Photos From Our Guests</h2>
|
||||||
|
{#if guestPhotosStatus === 'loading'}
|
||||||
|
<p class="text-surface-600-400 mt-4 text-center text-2xl">Loading…</p>
|
||||||
|
{:else if guestPhotosStatus === 'error'}
|
||||||
|
<p class="text-error-500 mt-4 text-center text-2xl">Could not load guest photos.</p>
|
||||||
|
{:else if guestPhotos.length === 0}
|
||||||
|
<p class="text-surface-600-400 mt-4 text-center text-2xl">No photos yet — be the first to share!</p>
|
||||||
|
{:else}
|
||||||
|
<div class="mt-4 grid grid-cols-3 gap-2">
|
||||||
|
{#each guestPhotos as photo (photo.url)}
|
||||||
|
<a href={photo.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
<img
|
||||||
|
src={photo.url}
|
||||||
|
alt="Shared by {photo.uploaderName}"
|
||||||
|
class="border-surface-300 dark:border-surface-700 aspect-square w-full rounded-base border object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/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"
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import PhotoBackground from "$lib/components/PhotoBackground.svelte";
|
||||||
|
|
||||||
|
const thanks = [
|
||||||
|
{ name: "Gayle Muller-Lombard", role: "Mother of the Bride" },
|
||||||
|
{ name: "Jan Lombard", role: "Stepdad of the Bride" },
|
||||||
|
{ name: "Vera Muller", role: "Grandmother of the Bride" },
|
||||||
|
{ name: "Christian Els", role: "Cousin of the Bride" },
|
||||||
|
{ name: "Divan Swart", role: "Father of the Bride" },
|
||||||
|
{ name: "Louis Pietser", role: "Father of the Groom" },
|
||||||
|
{ name: "Dezelle Ras", role: "Friend" },
|
||||||
|
{ name: "Cindy Berriman-Puth", role: "Friend" },
|
||||||
|
{ name: "Hein Puth (Warky Devs)", role: "Website & Friend" },
|
||||||
|
{ name: "Ronel Gous", role: "Ronel Events and Designs & Friend" },
|
||||||
|
{ name: "Monica", role: "Rivier Plaas Wedding Venue" },
|
||||||
|
{ name: "Anton (Music Fanatics)", role: "DJ" },
|
||||||
|
{ name: "Alan Cameron (AA Photography)", role: "Photography" },
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="relative flex flex-1 items-center justify-center overflow-hidden px-4 py-16 z-10"
|
||||||
|
>
|
||||||
|
<PhotoBackground />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="border-surface-50/70 text-shadow-sm text-shadow-black/60 relative z-10 w-full max-w-md border px-6 py-12 sm:px-14 sm:py-16"
|
||||||
|
>
|
||||||
|
<h1 class="text-surface-50 font-serif text-4xl">
|
||||||
|
A Special Note of Thanks
|
||||||
|
</h1>
|
||||||
|
<p class="text-surface-100 mt-2 text-2xl">
|
||||||
|
To some very special people in our lives who have helped us make our day
|
||||||
|
magical:
|
||||||
|
</p>
|
||||||
|
<ul class="mt-4 flex flex-col gap-2">
|
||||||
|
{#each thanks as person (person.name)}
|
||||||
|
<li
|
||||||
|
class="border-surface-50/30 flex items-center justify-between border-b py-2"
|
||||||
|
>
|
||||||
|
<span class="text-surface-50 text-2xl">{person.name}</span>
|
||||||
|
<span class="text-secondary-300 text-lg">{person.role}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
<p class="text-surface-100 mt-6 text-xl">
|
||||||
|
If there is anyone we have forgotten about, please know that you are all
|
||||||
|
special to us!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import PhotoBackground from "$lib/components/PhotoBackground.svelte";
|
||||||
|
import { createWeddingInfo } from "$lib/wedding-info.svelte";
|
||||||
|
|
||||||
|
const wedding = createWeddingInfo();
|
||||||
|
|
||||||
|
const directionsUrl = $derived(
|
||||||
|
`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(
|
||||||
|
`${wedding.venueName}, ${wedding.venueAddress}`,
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="relative flex flex-1 items-center justify-center overflow-hidden px-4 py-16 z-10"
|
||||||
|
>
|
||||||
|
<PhotoBackground />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="border-surface-50/70 text-shadow-sm text-shadow-black/60 relative z-10 w-full max-w-md border px-6 py-12 sm:px-14 sm:py-16"
|
||||||
|
>
|
||||||
|
<h1 class="text-surface-50 font-serif text-4xl">The Venue</h1>
|
||||||
|
|
||||||
|
<div class="mt-6 flex flex-col gap-1">
|
||||||
|
<p class="text-surface-100 text-2xl">{wedding.venueName}</p>
|
||||||
|
<p class="text-surface-200 text-2xl">{wedding.venueAddress}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={directionsUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="btn preset-filled-secondary-500 mt-4 text-2xl"
|
||||||
|
>
|
||||||
|
Get directions
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="border-surface-50/30 mt-10 border-t pt-6">
|
||||||
|
<h2 class="text-surface-50 font-serif text-3xl">Parking</h2>
|
||||||
|
<p class="text-surface-100 mt-2 text-2xl">
|
||||||
|
Secure parking is available on-site at the venue.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-surface-50/30 mt-10 border-t pt-6">
|
||||||
|
<h2 class="text-surface-50 font-serif text-3xl">Accommodation</h2>
|
||||||
|
<p class="text-surface-100 mt-2 text-2xl">Plaaskombuis Meyerton</p>
|
||||||
|
<div class="text-surface-200 mt-1 flex flex-col text-2xl">
|
||||||
|
<a href="tel:0163644144" class="hover:text-secondary-400">016 364 4144</a>
|
||||||
|
<a href="mailto:marisel@plaaskombuis.co.za" class="hover:text-secondary-400"
|
||||||
|
>marisel@plaaskombuis.co.za</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="https://www.plaaskombuis.co.za"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="hover:text-secondary-400"
|
||||||
|
>
|
||||||
|
www.plaaskombuis.co.za
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
function photo(file: string) {
|
||||||
|
return `/people/${encodeURIComponent(file)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initials(name: string) {
|
||||||
|
return name
|
||||||
|
.split(" ")
|
||||||
|
.map((word) => word[0])
|
||||||
|
.slice(0, 2)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
const bride = [
|
||||||
|
{
|
||||||
|
role: "Maid of Honour",
|
||||||
|
name: "Mart-Marie Neethling",
|
||||||
|
note: "Keeper of the bride's sanity and secrets.",
|
||||||
|
photo: photo("Mart-Marie.jpeg"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "Bridesmaid",
|
||||||
|
name: "Bronwin Williams",
|
||||||
|
note: "Childhood friend and the leader of the ultimate hype squad.",
|
||||||
|
photo: photo("Bronwin.jpeg"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "Bridesmaid",
|
||||||
|
name: "Noel Du Toit",
|
||||||
|
note: '"The life of every party" — Cousin.',
|
||||||
|
photo: photo("Noel Du Toit.jpeg"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "Flower Girl",
|
||||||
|
name: "Echo",
|
||||||
|
note: "The cutest little Flower Girl ever seen.",
|
||||||
|
photo: photo("echo.jpg"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const groom = [
|
||||||
|
{
|
||||||
|
role: "Best Man",
|
||||||
|
name: "Justin Pieterse",
|
||||||
|
note: "Tasked with keeping the speech short and the groom sane. (Not that the groom has ever been sane.)",
|
||||||
|
photo: photo("Justin.jpeg"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "Groomsman",
|
||||||
|
name: "Harold Harding",
|
||||||
|
note: "Childhood friend, and always has the groom's back.",
|
||||||
|
photo: photo("Harold.jpeg"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "Groomsman",
|
||||||
|
name: "Dehan Kruger",
|
||||||
|
note: "Chilled, laid back, and the life of every party!",
|
||||||
|
photo: photo("Dehan.jpeg"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "Groomsman",
|
||||||
|
name: "Karabo Makhubo",
|
||||||
|
note: "Sharp dresser, always with a joke to share.",
|
||||||
|
photo: photo("Karabo.jpeg"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="flex flex-1 items-center justify-center px-4 py-16">
|
||||||
|
<div class="w-full max-w-4xl">
|
||||||
|
<h1 class="font-serif text-4xl">The I-Do Crew</h1>
|
||||||
|
<p class="text-surface-600-400 mt-2 text-2xl">
|
||||||
|
Meet the legendary lineup helping us make it down the aisle at Rivier
|
||||||
|
Plaas!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-10 grid grid-cols-1 gap-x-10 gap-y-10 md:grid-cols-2">
|
||||||
|
<div class="border-surface-300 dark:border-surface-700 border-t pt-6">
|
||||||
|
<h2 class="font-serif text-3xl">Behind the Bride</h2>
|
||||||
|
<ul class="mt-4 flex flex-col gap-5">
|
||||||
|
{#each bride as person (person.name)}
|
||||||
|
<li class="flex items-center gap-4">
|
||||||
|
{#if person.photo}
|
||||||
|
<img
|
||||||
|
src={person.photo}
|
||||||
|
alt={person.name}
|
||||||
|
class="border-surface-300 dark:border-surface-700 size-20 shrink-0 rounded-full border object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="border-surface-300 dark:border-surface-700 bg-surface-100 dark:bg-surface-800 text-surface-600-400 flex size-20 shrink-0 items-center justify-center rounded-full border font-serif text-2xl"
|
||||||
|
>
|
||||||
|
{initials(person.name)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div>
|
||||||
|
<p class="text-2xl">
|
||||||
|
<span class="font-medium">{person.role}:</span>
|
||||||
|
{person.name}
|
||||||
|
</p>
|
||||||
|
<p class="text-surface-600-400 text-xl">{person.note}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-surface-300 dark:border-surface-700 border-t pt-6">
|
||||||
|
<h2 class="font-serif text-3xl">Behind the Groom</h2>
|
||||||
|
<ul class="mt-4 flex flex-col gap-5">
|
||||||
|
{#each groom as person (person.name)}
|
||||||
|
<li class="flex items-center gap-4">
|
||||||
|
{#if person.photo}
|
||||||
|
<img
|
||||||
|
src={person.photo}
|
||||||
|
alt={person.name}
|
||||||
|
class="border-surface-300 dark:border-surface-700 size-20 shrink-0 rounded-full border object-cover"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="border-surface-300 dark:border-surface-700 bg-surface-100 dark:bg-surface-800 text-surface-600-400 flex size-20 shrink-0 items-center justify-center rounded-full border font-serif text-2xl"
|
||||||
|
>
|
||||||
|
{initials(person.name)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div>
|
||||||
|
<p class="text-2xl">
|
||||||
|
<span class="font-medium">{person.role}:</span>
|
||||||
|
{person.name}
|
||||||
|
</p>
|
||||||
|
<p class="text-surface-600-400 text-xl">{person.note}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-surface-300 dark:border-surface-700 mt-10 border-t pt-6">
|
||||||
|
<h2 class="font-serif text-3xl">The Dog-bearers</h2>
|
||||||
|
<div class="mt-4 flex items-center gap-4">
|
||||||
|
<img
|
||||||
|
src={photo("ringpups.jpg")}
|
||||||
|
alt="Roxi & Chloe Muller"
|
||||||
|
class="border-surface-300 dark:border-surface-700 size-20 shrink-0 rounded-full border object-cover"
|
||||||
|
/>
|
||||||
|
<p class="text-2xl">Roxi & Chloe Muller</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 108 KiB |