feat(api): add endpoint to fetch guest list
* implement ListGuests method in store * create handleGuestList function in API * add GuestListEntry and GuestList interfaces in UI * create guest list page to display RSVPs
This commit is contained in:
@@ -33,6 +33,7 @@ func (s *Server) Routes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /healthz", s.handleHealthz)
|
||||
mux.HandleFunc("GET /api/config", s.handleConfig)
|
||||
mux.HandleFunc("POST /api/rsvp", s.handleRSVP)
|
||||
mux.HandleFunc("GET /api/guests", s.handleGuestList)
|
||||
mux.HandleFunc("POST /api/photos/upload", s.handlePhotoUpload)
|
||||
}
|
||||
|
||||
@@ -108,6 +109,38 @@ func (s *Server) handleRSVP(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
type guestResponse struct {
|
||||
Name string `json:"name"`
|
||||
IsChild bool `json:"isChild"`
|
||||
}
|
||||
|
||||
func (s *Server) handleGuestList(w http.ResponseWriter, r *http.Request) {
|
||||
guests, err := s.store.ListGuests()
|
||||
if err != nil {
|
||||
log.Printf("guests: list failed: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "could not load guests")
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]guestResponse, 0, len(guests))
|
||||
adults, children := 0, 0
|
||||
for _, g := range guests {
|
||||
out = append(out, guestResponse{Name: g.Name, IsChild: g.IsChild})
|
||||
if g.IsChild {
|
||||
children++
|
||||
} else {
|
||||
adults++
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"guests": out,
|
||||
"total": len(out),
|
||||
"adults": adults,
|
||||
"children": children,
|
||||
})
|
||||
}
|
||||
|
||||
var allowedImageTypes = map[string]bool{
|
||||
"image/jpeg": true,
|
||||
"image/png": true,
|
||||
|
||||
@@ -78,6 +78,30 @@ type RSVP struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
// ListGuests returns every RSVP'd guest, ordered by submission then entry order.
|
||||
func (s *Store) ListGuests() ([]Guest, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT g.name, g.is_child
|
||||
FROM rsvp_guests g
|
||||
JOIN rsvps r ON r.id = g.rsvp_id
|
||||
ORDER BY r.created_at ASC, g.id ASC`,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var guests []Guest
|
||||
for rows.Next() {
|
||||
var g Guest
|
||||
if err := rows.Scan(&g.Name, &g.IsChild); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
guests = append(guests, g)
|
||||
}
|
||||
return guests, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) InsertRSVP(r RSVP) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
|
||||
@@ -28,6 +28,18 @@ export interface PhotoUploadPayload {
|
||||
files: FileList;
|
||||
}
|
||||
|
||||
export interface GuestListEntry {
|
||||
name: string;
|
||||
isChild: boolean;
|
||||
}
|
||||
|
||||
export interface GuestList {
|
||||
guests: GuestListEntry[];
|
||||
total: number;
|
||||
adults: number;
|
||||
children: number;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
@@ -62,6 +74,12 @@ export async function submitRsvp(payload: RsvpPayload): Promise<void> {
|
||||
if (!response.ok) throw new ApiError(await parseError(response), response.status);
|
||||
}
|
||||
|
||||
export async function fetchGuests(fetchImpl: typeof fetch = fetch): Promise<GuestList> {
|
||||
const response = await fetchImpl('/api/guests');
|
||||
if (!response.ok) throw new ApiError(await parseError(response), response.status);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function uploadPhotos(payload: PhotoUploadPayload): Promise<void> {
|
||||
const form = new FormData();
|
||||
form.set('name', payload.name);
|
||||
|
||||
@@ -24,12 +24,12 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Zoé & Shaldon are getting married</title>
|
||||
<link rel="icon" href={favicon} />
|
||||
<title>Zoé & Shaldon are getting married</title>
|
||||
<link rel="icon" href={favicon} />
|
||||
</svelte:head>
|
||||
|
||||
<div
|
||||
class="bg-surface-50 dark:bg-surface-950 flex min-h-screen flex-col bg-[url('/patterns/damask.svg')] bg-repeat [background-size:160px_160px]"
|
||||
class="bg-surface-50 dark:bg-surface-950 flex min-h-screen flex-col bg-[url('/patterns/damask.svg')] bg-repeat [background-size:160px_160px]"
|
||||
>
|
||||
<div class="sticky top-0 z-20 flex flex-col">
|
||||
<nav
|
||||
@@ -65,18 +65,32 @@
|
||||
onclick={() => (menuOpen = !menuOpen)}
|
||||
>
|
||||
{#if menuOpen}
|
||||
<svg viewBox="0 0 24 24" class="size-6" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="size-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg viewBox="0 0 24 24" class="size-6" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="size-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" d="M4 7h16M4 12h16M4 17h16" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</nav>
|
||||
{#if menuOpen}
|
||||
<div class="bg-primary-950 border-primary-800 flex flex-col gap-1 border-t px-4 py-3 sm:hidden">
|
||||
<div
|
||||
class="bg-primary-950 border-primary-800 flex flex-col gap-1 border-t px-4 py-3 sm:hidden"
|
||||
>
|
||||
{#each links as link (link.href)}
|
||||
<a
|
||||
href={link.href}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { fetchGuests, ApiError, type GuestList } from '$lib/api';
|
||||
|
||||
let status = $state<'loading' | 'done' | 'error'>('loading');
|
||||
let errorMessage = $state('');
|
||||
let guestList = $state<GuestList>({ guests: [], total: 0, adults: 0, children: 0 });
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
fetchGuests()
|
||||
.then((list) => {
|
||||
guestList = list;
|
||||
status = 'done';
|
||||
})
|
||||
.catch((err) => {
|
||||
status = 'error';
|
||||
errorMessage = err instanceof ApiError ? err.message : 'Something went wrong. Please try again.';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="flex flex-1 items-center justify-center px-4 py-16">
|
||||
<div class="w-full max-w-md">
|
||||
<h1 class="font-serif text-4xl">Who's coming</h1>
|
||||
<p class="text-surface-600-400 mt-2 mb-8 text-2xl">
|
||||
{#if status === 'done'}
|
||||
{guestList.total}
|
||||
{guestList.total === 1 ? 'guest has' : 'guests have'} RSVP'd
|
||||
{#if guestList.children > 0}
|
||||
— {guestList.adults} adults, {guestList.children} children
|
||||
{/if}
|
||||
{:else}
|
||||
Everyone who has RSVP'd so far.
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if status === 'loading'}
|
||||
<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>
|
||||
{:else}
|
||||
<ul class="flex flex-col gap-2">
|
||||
{#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">
|
||||
<span class="text-2xl">{guest.name}</span>
|
||||
{#if guest.isChild}
|
||||
<span class="preset-tonal-secondary rounded-base px-2 text-lg font-medium">Child</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
Reference in New Issue
Block a user