# Plan: Zoé & Shaldon Wedding Site ## Goal QR-code-accessible site for save-the-date, RSVP, and guest photo uploads. Wedding date: **7 November 2026**. ## Stack - **Frontend**: SvelteKit, `adapter-static`, prerendered. No SSR needed. - **UI/theming**: Tailwind CSS + [Skeleton](https://skeleton.dev) (Svelte), custom theme for the blue/yellow palette — Skeleton handles light/dark switching itself, no hand-rolled CSS variables needed. - **Backend**: Go, single binary, `embed.FS` serves the built static site + JSON API on one port. - **Storage**: SQLite via `modernc.org/sqlite` (pure Go, no cgo — keeps the Docker build static/`CGO_ENABLED=0`) for RSVP/upload metadata. Photos on disk. - **Email**: Go `net/smtp` (STARTTLS) to notify the couple on new RSVP / photo upload. ## Repo layout ``` zoe-shaldon/ ├── ui/ SvelteKit frontend │ ├── tailwind.config.ts │ ├── assets/photos/ curated couple photos (checked in), source for the background slideshow │ └── src/ │ ├── app.css Tailwind + Skeleton custom theme (wedding-theme) │ ├── lib/components/PhotoBackground.svelte ambient slide-in/out photo background │ └── routes/ │ ├── +page.svelte landing: hero, countdown, photo background, nav │ ├── rsvp/+page.svelte RSVP form │ └── photos/+page.svelte upload form (gated) + status message before open date ├── server/ Go backend │ ├── main.go │ ├── internal/api/ HTTP handlers │ ├── internal/store/ SQLite access │ ├── internal/mail/ SMTP sending │ └── web/ embedded static build (go:embed) ├── data/ runtime data, gitignored │ ├── rsvp.db │ └── photos// ├── concept/ source assets (save-the-date image) ├── config.example.yaml template, checked in ├── config.yaml real config, gitignored ├── Dockerfile └── docker-compose.yml ``` ## Data model (SQLite) ``` rsvps(id, message, created_at) rsvp_guests(id, rsvp_id FK, name) photo_uploads(id, name, email, phone, message, created_at) photo_files(id, upload_id FK, filename, path, url, created_at) ``` RSVP is a party: one or more guest names attached to a single RSVP + optional shared message — no email/phone captured for RSVP (contact for RSVP questions is the phone number on the invite instead). Photo upload keeps its own name/email/phone/message, unchanged. ## API | Method | Path | Body | Notes | |---|---|---|---| | 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 | | 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 | ## Countdowns - Global: a compact countdown to `weddingDate` sits below the nav bar on every page (`+layout.svelte`). Once passed, it reads "Already married! 🎉" instead of digits. - Landing page: hero shows the day/date (e.g. "Saturday, 7 November 2026"), `ceremonyTime` ("16:00 for 16:30"), and venue name/address — all from `GET /api/config`. RSVP button is hidden once the guest has RSVP'd (tracked client-side via `localStorage`, see below); replaced with a thank-you note. - RSVP page: its own live countdown to `rsvpByDate` ("RSVP by "). Once `rsvpByDate` passes, the countdown and form are replaced by an "RSVP missed" notice pointing to `rsvpPhone`. - Photos page: reuses `weddingDate` — before that time shows "uploads open on the wedding day" instead of the upload form (no separate countdown here, the global one covers it). - One date per purpose, no duplicated "opens at" settings to keep in sync. ## RSVP-submitted tracking No accounts/auth — `localStorage` flag (`zoe-shaldon-rsvp-submitted`) set after a successful `POST /api/rsvp`, read by the landing page to decide whether to show the RSVP button or a confirmation. Per-browser only; a guest RSVPing from a different device won't see the flag, which is an acceptable prototype limitation. ## Photo background (landing page) Curated couple photos already checked in at `ui/assets/photos` (33 images) — separate from `data/photos/` (runtime, guest-uploaded, gitignored). These are the source for an ambient background behind the hero content. - `PhotoBackground.svelte`: full-bleed layer behind the hero text, cycling through a shuffled list of the photos on an interval (~6s). Each photo slides in from one edge and slides out the opposite edge (CSS `transform: translateX` + `opacity` transition, one photo at a time, next one queued behind). - Photo list built at compile time via Vite's `import.meta.glob('/assets/photos/*.{jpg,jpeg,png}', { eager: true })` — drop a file in the folder, it's picked up on next build, no code change. - A semi-transparent overlay in the theme's `primary` blue sits between the photos and the text so the hero copy (white/yellow) stays legible over any photo. - Respect `prefers-reduced-motion: reduce` — fall back to a plain crossfade (opacity only, no slide) or a static single image. - **`IMG_E8488.HEIC` in `ui/assets/photos` needs converting to JPG before build** — browsers don't render HEIC natively and Vite won't transform it; either convert now or add a build-step check that fails loudly if a `.heic` file is present. ## Photo upload gating - Gate value is `wedding.date` from the config file (see Config below) — no separate open-date field. - Client: countdown shown before that time; upload form hidden until then. - Server: hard check on every upload request — client-side gating is UX only, not security. ## Upload validation - Allowed types: jpg/png/webp/heic, sniffed via `http.DetectContentType` (not trusted from filename/extension). - Max file size and max files per submission — values TBD, suggest 15MB/file, 10 files/submission. - Stored at `data/photos//_`. ## Email SMTP settings come from the config file (see below). - On RSVP: send guest names + message. - On photo upload: send name/message/contact + direct links to each stored photo. ## Config Single YAML file, path resolved from `CONFIG_PATH` env var (default `./config.yaml`) — this is the *only* env var; everything else lives in the file so it can be edited/mounted without touching Docker env blocks. ```yaml server: port: 8080 wedding: date: "2026-11-07T16:00:00+02:00" # RFC3339, guest arrival time; drives countdown + upload gate 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 venue_name: "Rivier Plaas" venue_address: "Langenhoven Rd, Sherman Park AH, Meyerton, 1961" rsvp_phone: "076 925 1718" storage: data_dir: "./data" # SQLite db + uploaded photos email: smtp_host: "" smtp_port: 587 smtp_user: "" smtp_pass: "" from: "" to: "" # couple's inbox upload: max_size_mb: 15 max_files: 10 ``` `config.example.yaml` is checked in as a template; `config.yaml` is gitignored (holds real SMTP credentials). Venue/RSVP details sourced from `concept/IMG-20260811-WA0020.jpg` (the formal invitation). ## Deployment Host: **`zoeshaldon.warky.info`**, Docker Compose, behind an **existing Apache reverse proxy on the host** (Apache owns TLS/certs — no Caddy/in-container TLS needed). The container publishes only to `127.0.0.1:8080`, and Apache proxies to it. ### Docker - `Dockerfile`: multi-stage — (1) `node:alpine` builds the SvelteKit static output, (2) `golang:alpine` compiles the Go binary with the built frontend copied in for `go:embed`, (3) minimal `alpine` runtime stage with just the binary. - `docker-compose.yml`: one service, `wedding`, builds from the Dockerfile, mounts `./config.yaml:/app/config.yaml:ro` and `./data:/app/data`, published as `127.0.0.1:8080:8080` (loopback only — Apache is the only thing that should reach it). ### Apache config (host side, not part of this repo) ``` ServerName zoeshaldon.warky.info ProxyPreserveHost On ProxyPass / http://127.0.0.1:8080/ ProxyPassReverse / http://127.0.0.1:8080/ # existing SSL cert config for warky.info ``` Needs `mod_proxy` and `mod_proxy_http` enabled. Multipart photo uploads: raise `LimitRequestBody` (or leave unset/0) on this vhost so Apache doesn't reject large uploads ahead of the app's own `upload.max_size_mb` check. ## QR code Generate pointing at `https://zoeshaldon.warky.info` once the container is deployed behind Apache. ## Colour palette Skeleton custom theme (`wedding-theme`), generated with the [Skeleton theme generator](https://skeleton.dev/docs/design/themes) from two seed colours and applied via `data-theme="wedding-theme"` in `app.html`. Light/dark mode is Skeleton's built-in mode switch (`data-mode`) — it derives both from the same theme, so there's no separate light/dark table to hand-maintain. | Slot | Seed colour | Source | |---|---|---| | `primary` (blue) | `#1E4E79` | groom's shirt / dusk sky in `concept/IMG-20260811-WA0019.jpg` | | `secondary` (yellow) | `#D9A441` | warm gold title text in the same image | Skeleton generates the full 50–900 scale and light/dark contrast pairs from these two seeds. Tailwind utility classes (`bg-primary-500`, `text-secondary-600`, etc.) and Skeleton components consume the theme directly — no custom CSS variables needed on top. ## Design — invitation framing Landing and RSVP pages borrow the framed-card look from the formal invitation (`concept/IMG-20260811-WA0020.jpg`): a thin border around the core content block, small-caps tracked micro-copy above the couple's names ("Save the date" / "Please join us to celebrate the union of"), day+date formatted as "Saturday, 7 November 2026" (`Intl.DateTimeFormat('en-GB', {weekday:'long', day:'numeric', month:'long', year:'numeric'})`), and the venue name/address displayed beneath. ## Open decisions (need input before/at implementation) 1. ~~Domain / hosting target~~ — resolved: `zoeshaldon.warky.info`, Docker Compose (see Deployment). 2. SMTP provider/credentials to send from (e.g. existing Gmail/SES/other). 3. Timezone for the 7 Nov 2026 cutoff — assumed SAST (+02:00). 4. Max photo size / count per submission — defaults proposed above, confirm. 5. ~~Colour palette~~ — resolved, see above. Fonts still open (image uses a script face for names/heading + serif for the date — confirm if we should source similar web fonts). ## Prototype status Working end-to-end: `ui/` (landing with countdown + sliding photo background, invitation-framed RSVP form with party-of-guests entry + its own countdown, gated photo upload form) builds and is served by `server/` (Go, SQLite via `modernc.org/sqlite`, SMTP notifications, server-side upload gate). Verified locally: page rendering, multi-guest RSVP submit (add/remove guest rows, blank-name filtering), photo upload with content-type sniffing (rejects non-images, detects HEIC), and both the wedding-date and RSVP-deadline gates/countdowns. Known gaps before this is real: - SMTP not configured (open decision #2) — emails currently log "skipped" instead of sending. - Photos in `ui/assets/photos` are unoptimized (several multi-MB originals) — fine for the background slideshow (one loads at a time), but worth compressing before going live given the site is reached over mobile data via QR code. - No automated tests yet. - `Dockerfile`/`docker-compose.yml` not yet run end-to-end (only `go build`/`npm run build` and the local binary were verified).