feat(ui): add wedding theme styles and layout components

* Introduced wedding-themed CSS variables for styling.
* Created layout component for the wedding site with navigation and countdown.
* Added RSVP and photo upload pages with form handling.
* Implemented QR code page for wedding site access.
* Configured TypeScript and Vite for the project.
* Added robots.txt for search engine crawling.
This commit is contained in:
2026-08-11 23:31:35 +02:00
commit 39f5b8d5cc
89 changed files with 6829 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
<script lang="ts">
import '../app.css';
import favicon from '$lib/assets/favicon.svg';
import { page } from '$app/state';
import Countdown from '$lib/components/Countdown.svelte';
import { createWeddingInfo } from '$lib/wedding-info.svelte';
let { children } = $props();
const wedding = createWeddingInfo();
const links = [
{ href: '/', label: 'Home' },
{ href: '/rsvp', label: 'RSVP' },
{ href: '/photos', label: 'Photos' }
];
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
<div class="bg-surface-50 dark:bg-surface-950 flex min-h-screen flex-col">
<div class="sticky top-0 z-20 flex flex-col">
<nav class="bg-primary-950 flex items-center justify-between px-4 py-3 sm:px-8">
<a href="/" class="text-surface-50 font-malibu text-xl tracking-wide">Zoé &amp; Shaldon</a>
<div class="flex gap-2">
{#each links as link (link.href)}
<a
href={link.href}
class="btn field-sm {page.url.pathname === link.href
? 'preset-filled-secondary-500'
: 'text-surface-100 hover:text-secondary-400'}"
>
{link.label}
</a>
{/each}
</div>
</nav>
<div class="text-surface-50 flex justify-center px-4 py-3 drop-shadow-[0_2px_6px_rgba(0,0,0,0.6)]">
<Countdown target={wedding.date} compact pastMessage="Already married! 🎉" />
</div>
</div>
<main class="flex flex-1 flex-col">
{@render children()}
</main>
</div>
+1
View File
@@ -0,0 +1 @@
export const prerender = true;
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
import PhotoBackground from "$lib/components/PhotoBackground.svelte";
import { createWeddingInfo, formatFullDate } from "$lib/wedding-info.svelte";
import { createRsvpStatus } from "$lib/rsvp-status.svelte";
const wedding = createWeddingInfo();
const rsvpStatus = createRsvpStatus();
</script>
<section
class="relative flex flex-1 items-center justify-center overflow-hidden px-4 py-16 text-center"
>
<PhotoBackground />
<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"
>
<h1 class="text-surface-50 font-malibu text-6xl sm:text-8xl">
Zoé &amp; Shaldon
</h1>
<p class="text-surface-100 text-2xl">are getting married</p>
<div class="flex flex-col gap-1">
<p class="text-surface-50 text-2xl font-medium sm:text-3xl">
{formatFullDate(wedding.date)}
</p>
<p class="text-surface-200 text-2xl tracking-wide">
{wedding.ceremonyTime}
</p>
</div>
<div class="flex flex-col gap-0.5">
<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}
<a href="/rsvp" class="btn preset-filled-secondary-500 text-2xl">RSVP</a
>
{:else}
<p class="text-secondary-300 text-2xl">✓ You've RSVP'd — thank you!</p>
{/if}
<a href="/photos" class="btn preset-filled-primary-500 text-2xl"
>Share photos</a
>
</div>
</div>
</section>
+126
View File
@@ -0,0 +1,126 @@
<script lang="ts">
import { createWeddingInfo, formatFullDate } from '$lib/wedding-info.svelte';
import { uploadPhotos, ApiError } from '$lib/api';
const MAX_FILES = 10;
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 message = $state('');
let email = $state('');
let phone = $state('');
let files: FileList | undefined = $state();
let status = $state<'idle' | 'submitting' | 'done' | 'error'>('idle');
let errorMessage = $state('');
async function onSubmit(event: SubmitEvent) {
event.preventDefault();
if (!files || files.length === 0) {
status = 'error';
errorMessage = 'Please choose at least one photo.';
return;
}
if (files.length > MAX_FILES) {
status = 'error';
errorMessage = `Please upload at most ${MAX_FILES} photos at a time.`;
return;
}
for (const file of files) {
if (file.size > MAX_SIZE_MB * 1024 * 1024) {
status = 'error';
errorMessage = `"${file.name}" is larger than ${MAX_SIZE_MB}MB.`;
return;
}
}
status = 'submitting';
errorMessage = '';
try {
await uploadPhotos({
name,
message: message || undefined,
email: email || undefined,
phone: phone || undefined,
files
});
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">
{#if !isOpen}
<div class="flex flex-col items-center gap-6 text-center">
<h1 class="font-serif text-4xl">Photo uploads open on the wedding day</h1>
<p class="text-surface-600-400 text-lg">Come back on {formatFullDate(wedding.date)} to share your photos with Zoé &amp; Shaldon.</p>
</div>
{: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!
</div>
{:else}
<div class="w-full max-w-md">
<h1 class="font-serif text-4xl">Share your photos</h1>
<p class="text-surface-600-400 mt-2 mb-8 text-lg">
Upload photos from the day — up to {MAX_FILES} at a time, {MAX_SIZE_MB}MB each.
</p>
<form class="flex flex-col gap-4" onsubmit={onSubmit}>
<label class="label">
<span class="label-text">Name *</span>
<input class="input border border-surface-300 focus:border-primary-500 dark:border-surface-700" type="text" required bind:value={name} disabled={status === 'submitting'} />
</label>
<label class="label">
<span class="label-text">Message to the couple (optional)</span>
<textarea class="textarea border border-surface-300 focus:border-primary-500 dark:border-surface-700" rows="3" bind:value={message} disabled={status === 'submitting'}
></textarea>
</label>
<label class="label">
<span class="label-text">Email (optional)</span>
<input class="input border border-surface-300 focus:border-primary-500 dark:border-surface-700" type="email" bind:value={email} disabled={status === 'submitting'} />
</label>
<label class="label">
<span class="label-text">Phone (optional)</span>
<input class="input border border-surface-300 focus:border-primary-500 dark:border-surface-700" type="tel" bind:value={phone} disabled={status === 'submitting'} />
</label>
<label class="label">
<span class="label-text">Photos *</span>
<input
class="input border border-surface-300 focus:border-primary-500 dark:border-surface-700"
type="file"
accept="image/*"
multiple
required
bind:files
disabled={status === 'submitting'}
/>
</label>
{#if status === 'error'}
<p class="text-error-500 text-base">{errorMessage}</p>
{/if}
<button class="btn preset-filled-secondary-500 mt-2" type="submit" disabled={status === 'submitting'}>
{status === 'submitting' ? 'Uploading…' : 'Upload photos'}
</button>
</form>
</div>
{/if}
</section>
+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
import { QrCode } from '@skeletonlabs/skeleton-svelte';
import { createWeddingInfo } from '$lib/wedding-info.svelte';
const wedding = createWeddingInfo();
</script>
<section class="flex flex-1 items-center justify-center px-4 py-16">
<div class="border-surface-300 dark:border-surface-700 flex flex-col items-center gap-4 border px-6 py-10 text-center">
<p class="text-primary-600-400 text-base tracking-[0.25em] uppercase">Scan to visit</p>
<h1 class="font-malibu text-4xl">Zoé &amp; Shaldon</h1>
<QrCode value={wedding.siteUrl} class="flex flex-col items-center gap-4">
<QrCode.Frame class="w-56">
<QrCode.Pattern />
</QrCode.Frame>
<p class="text-surface-700 dark:text-surface-300 text-base break-all">{wedding.siteUrl}</p>
<QrCode.DownloadTrigger mimeType="image/png" fileName="zoe-and-shaldon-qr.png">
Download PNG
</QrCode.DownloadTrigger>
</QrCode>
</div>
</section>
+191
View File
@@ -0,0 +1,191 @@
<script lang="ts">
import { submitRsvp, ApiError, type Guest, type GuestType } from "$lib/api";
import { createWeddingInfo, formatFullDate } from "$lib/wedding-info.svelte";
import { createRsvpStatus } from "$lib/rsvp-status.svelte";
import Countdown from "$lib/components/Countdown.svelte";
import PhotoBackground from "$lib/components/PhotoBackground.svelte";
const wedding = createWeddingInfo();
const rsvpStatus = createRsvpStatus();
let now = $state(Date.now());
$effect(() => {
const id = setInterval(() => (now = Date.now()), 30_000);
return () => clearInterval(id);
});
const isRsvpOpen = $derived(now < wedding.rsvpByDate.getTime());
let guests = $state<Guest[]>([{ name: "", type: "adult" }]);
let message = $state("");
let status = $state<"idle" | "submitting" | "done" | "error">("idle");
let errorMessage = $state("");
function addGuest(type: GuestType) {
guests.push({ name: "", type });
}
function removeGuest(index: number) {
guests.splice(index, 1);
}
async function onSubmit(event: SubmitEvent) {
event.preventDefault();
const trimmedGuests = guests
.map((g) => ({ name: g.name.trim(), type: g.type }))
.filter((g) => g.name !== "");
if (trimmedGuests.length === 0) {
status = "error";
errorMessage = "Please add at least one name.";
return;
}
status = "submitting";
errorMessage = "";
try {
await submitRsvp({
guests: trimmedGuests,
message: message || undefined,
});
status = "done";
rsvpStatus.markSubmitted();
} catch (err) {
status = "error";
errorMessage =
err instanceof ApiError
? err.message
: "Something went wrong. Please try again.";
}
}
</script>
<section
class="relative flex flex-1 items-center justify-center overflow-hidden px-4 py-16"
>
<PhotoBackground />
<div
class="border-surface-50/70 relative z-10 w-full max-w-md border px-6 py-12 sm:px-10 sm:py-14"
>
<div class="flex flex-col items-center gap-2 text-center">
<p class="text-surface-200 text-base tracking-[0.25em] uppercase">
Please join us to celebrate the union of
</p>
<h1 class="text-surface-50 font-malibu text-5xl">Zoé &amp; Shaldon</h1>
<p class="text-surface-50 mt-3 text-2xl">
{formatFullDate(wedding.date)}
</p>
<p class="text-surface-50 text-2xl">{wedding.ceremonyTime}</p>
<p class="text-surface-50 mt-3 text-2xl">{wedding.venueName}</p>
<p class="text-surface-50 text-2xl">{wedding.venueAddress}</p>
</div>
{#if isRsvpOpen}
<div class="mt-8 flex flex-col items-center gap-3 text-center">
<p class="text-surface-100 text-2xl">
RSVP by {formatFullDate(wedding.rsvpByDate)}
</p>
<Countdown target={wedding.rsvpByDate} />
</div>
{/if}
{#if !isRsvpOpen}
<div class="border-error-400 rounded-base mt-8 border p-4 text-center">
<p class="text-surface-50 text-lg font-medium">RSVP missed</p>
<p class="text-surface-200 mt-1 text-base">
The RSVP deadline has passed. Please contact us directly on {wedding.rsvpPhone}.
</p>
</div>
{:else if status === "done"}
<div class="preset-filled-primary-500 rounded-base mt-8 p-4 text-center">
Thank you — your RSVP has been received!
</div>
{:else}
<form class="mt-8 flex flex-col gap-4 w-full" onsubmit={onSubmit}>
<div class="flex flex-col gap-2 w-full">
<span class="label-text text-surface-100 text-2xl"
>Who's coming? *</span
>
{#each guests as guest, i (i)}
<div class="flex gap-2">
<span
class="preset-tonal-{guest.type === 'child'
? 'secondary'
: 'primary'} rounded-base flex shrink-0 items-center px-2 text-2xl font-medium"
>
{guest.type === "child" ? "Child" : "Adult"}
</span>
<input
class="input border-surface-300 dark:border-surface-700 focus:border-primary-500 border text-2xl"
type="text"
placeholder={guest.type === "child"
? "Child's name"
: "Guest name"}
required
bind:value={guest.name}
disabled={status === "submitting"}
/>
{#if guests.length > 1}
<button
type="button"
class="btn preset-filled-surface-200-800 text-2xl"
aria-label="Remove guest"
onclick={() => removeGuest(i)}
disabled={status === "submitting"}
>
&minus;
</button>
{/if}
</div>
{/each}
<div class="flex gap-2 w-full">
<button
type="button"
class="btn preset-filled-surface-200-800 text-2xl self-start"
onclick={() => addGuest("adult")}
disabled={status === "submitting"}
>
+ Add guest
</button>
<button
type="button"
class="btn preset-filled-surface-200-800 text-2xl self-start"
onclick={() => addGuest("child")}
disabled={status === "submitting"}
>
+ Add child
</button>
</div>
</div>
<label class="label">
<span class="label-text text-surface-100"
>Message to the couple (optional)</span
>
<textarea
class="textarea border-surface-300 dark:border-surface-700 focus:border-primary-500 border text-2xl"
rows="3"
bind:value={message}
disabled={status === "submitting"}
></textarea>
</label>
{#if status === "error"}
<p class="text-error-500 text-2xl">{errorMessage}</p>
{/if}
<button
class="btn preset-filled-secondary-500 mt-2 text-2xl"
type="submit"
disabled={status === "submitting"}
>
{status === "submitting" ? "Sending…" : "Send RSVP"}
</button>
<p class="text-surface-200 text-center text-2xl">
Or RSVP by phone: {wedding.rsvpPhone}
</p>
</form>
{/if}
</div>
</section>