feat(ui): add RSVP attendance option and admin features
* Implement attendance selection in RSVP form * Add admin endpoints for managing RSVPs and guests * Update RSVP handling to include attendance status
This commit is contained in:
@@ -19,6 +19,7 @@ export interface Guest {
|
||||
export interface RsvpPayload {
|
||||
guests: Guest[];
|
||||
message?: string;
|
||||
attending: boolean;
|
||||
}
|
||||
|
||||
export interface GuestPhoto {
|
||||
@@ -80,6 +81,60 @@ export async function submitRsvp(payload: RsvpPayload): Promise<void> {
|
||||
if (!response.ok) throw new ApiError(await parseError(response), response.status);
|
||||
}
|
||||
|
||||
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> {
|
||||
const response = await fetchImpl('/api/guests');
|
||||
if (!response.ok) throw new ApiError(await parseError(response), response.status);
|
||||
|
||||
@@ -1,57 +1,244 @@
|
||||
<script lang="ts">
|
||||
import { browser } from '$app/environment';
|
||||
import { fetchGuests, ApiError, type GuestList } from '$lib/api';
|
||||
import { page } from '$app/state';
|
||||
import {
|
||||
fetchGuests,
|
||||
fetchAdminRsvps,
|
||||
setRsvpAttending,
|
||||
deleteRsvp,
|
||||
addAdminGuest,
|
||||
updateAdminGuest,
|
||||
deleteAdminGuest,
|
||||
createAdminRsvp,
|
||||
ApiError,
|
||||
type GuestList,
|
||||
type AdminRsvp
|
||||
} from '$lib/api';
|
||||
|
||||
const isAdmin = $derived(page.url.searchParams.get('admin') === '1');
|
||||
|
||||
let status = $state<'loading' | 'done' | 'error'>('loading');
|
||||
let errorMessage = $state('');
|
||||
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(() => {
|
||||
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.';
|
||||
});
|
||||
void isAdmin;
|
||||
load();
|
||||
});
|
||||
|
||||
async function load() {
|
||||
status = 'loading';
|
||||
try {
|
||||
if (isAdmin) {
|
||||
rsvps = await fetchAdminRsvps();
|
||||
} else {
|
||||
guestList = await fetchGuests();
|
||||
}
|
||||
status = 'done';
|
||||
} catch (err) {
|
||||
status = 'error';
|
||||
errorMessage = err instanceof ApiError ? err.message : 'Something went wrong. Please try again.';
|
||||
}
|
||||
}
|
||||
|
||||
async function run(fn: () => Promise<unknown>) {
|
||||
busy = true;
|
||||
errorMessage = '';
|
||||
try {
|
||||
await fn();
|
||||
rsvps = await fetchAdminRsvps();
|
||||
} catch (err) {
|
||||
errorMessage = err instanceof ApiError ? err.message : 'Update failed.';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleAttending(r: AdminRsvp) {
|
||||
run(() => setRsvpAttending(r.id, !r.attending));
|
||||
}
|
||||
function toggleChild(g: { id: number; name: string; isChild: boolean }) {
|
||||
run(() => updateAdminGuest(g.id, { name: g.name, type: g.isChild ? 'adult' : 'child' }));
|
||||
}
|
||||
function renameGuest(g: { id: number; name: string; isChild: boolean }, name: string) {
|
||||
if (name.trim() === '' || name === g.name) return;
|
||||
run(() => updateAdminGuest(g.id, { name: name.trim(), type: g.isChild ? 'child' : 'adult' }));
|
||||
}
|
||||
function removeGuest(id: number) {
|
||||
run(() => deleteAdminGuest(id));
|
||||
}
|
||||
function removeRsvp(id: number) {
|
||||
run(() => deleteRsvp(id));
|
||||
}
|
||||
function addGuest(rsvpId: number, name: string, child: boolean) {
|
||||
if (name.trim() === '') return;
|
||||
run(() => addAdminGuest(rsvpId, { name: name.trim(), type: child ? 'child' : 'adult' }));
|
||||
}
|
||||
function addParty() {
|
||||
if (newName.trim() === '') return;
|
||||
run(async () => {
|
||||
await createAdminRsvp({
|
||||
guests: [{ name: newName.trim(), type: newChild ? 'child' : 'adult' }],
|
||||
attending: newAttending
|
||||
});
|
||||
newName = '';
|
||||
newChild = false;
|
||||
newAttending = true;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<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'}
|
||||
<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">
|
||||
{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>
|
||||
</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 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}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
|
||||
let guests = $state<Guest[]>([{ name: "", type: "adult" }]);
|
||||
let message = $state("");
|
||||
let attending = $state(true);
|
||||
|
||||
let status = $state<"idle" | "submitting" | "done" | "error">("idle");
|
||||
let errorMessage = $state("");
|
||||
@@ -46,6 +47,7 @@
|
||||
await submitRsvp({
|
||||
guests: trimmedGuests,
|
||||
message: message || undefined,
|
||||
attending,
|
||||
});
|
||||
status = "done";
|
||||
rsvpStatus.markSubmitted();
|
||||
@@ -174,6 +176,34 @@
|
||||
<p class="text-error-500 text-2xl">{errorMessage}</p>
|
||||
{/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
|
||||
class="btn preset-filled-secondary-500 mt-2 text-2xl"
|
||||
type="submit"
|
||||
|
||||
Reference in New Issue
Block a user