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:
@@ -0,0 +1,28 @@
|
||||
@import 'tailwindcss';
|
||||
@import '@skeletonlabs/skeleton';
|
||||
@import './lib/wedding-theme.css';
|
||||
|
||||
@font-face {
|
||||
font-family: 'Malibu';
|
||||
src:
|
||||
url('../assets/fonts/Malibu.woff') format('woff'),
|
||||
url('../assets/fonts/Malibu.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Bilbo';
|
||||
src: url('../assets/fonts/Bilbo-Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@theme {
|
||||
/* Script face reserved for "Zoé & Shaldon" — nav title, hero, RSVP, QR page. */
|
||||
--font-malibu: 'Malibu', cursive;
|
||||
/* Formal face for everything else: headings, body, buttons, inputs. */
|
||||
--font-serif: 'Bilbo', Georgia, 'Iowan Old Style', 'Palatino Linotype', 'Book Antiqua', Palatino, serif;
|
||||
}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="wedding">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="text-scale" content="scale" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,75 @@
|
||||
export interface WeddingConfig {
|
||||
siteUrl: string;
|
||||
weddingDate: string;
|
||||
ceremonyTime: string;
|
||||
rsvpByDate: string;
|
||||
venueName: string;
|
||||
venueAddress: string;
|
||||
rsvpPhone: string;
|
||||
}
|
||||
|
||||
export type GuestType = 'adult' | 'child';
|
||||
|
||||
export interface Guest {
|
||||
name: string;
|
||||
type: GuestType;
|
||||
}
|
||||
|
||||
export interface RsvpPayload {
|
||||
guests: Guest[];
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PhotoUploadPayload {
|
||||
name: string;
|
||||
message?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
files: FileList;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function parseError(response: Response): Promise<string> {
|
||||
try {
|
||||
const body = await response.json();
|
||||
if (typeof body?.error === 'string') return body.error;
|
||||
} catch {
|
||||
// fall through to status text
|
||||
}
|
||||
return response.statusText || `Request failed (${response.status})`;
|
||||
}
|
||||
|
||||
export async function fetchConfig(fetchImpl: typeof fetch = fetch): Promise<WeddingConfig> {
|
||||
const response = await fetchImpl('/api/config');
|
||||
if (!response.ok) throw new ApiError(await parseError(response), response.status);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function submitRsvp(payload: RsvpPayload): Promise<void> {
|
||||
const response = await fetch('/api/rsvp', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!response.ok) throw new ApiError(await parseError(response), response.status);
|
||||
}
|
||||
|
||||
export async function uploadPhotos(payload: PhotoUploadPayload): Promise<void> {
|
||||
const form = new FormData();
|
||||
form.set('name', payload.name);
|
||||
if (payload.message) form.set('message', payload.message);
|
||||
if (payload.email) form.set('email', payload.email);
|
||||
if (payload.phone) form.set('phone', payload.phone);
|
||||
for (const file of payload.files) form.append('files', file);
|
||||
|
||||
const response = await fetch('/api/photos/upload', { method: 'POST', body: form });
|
||||
if (!response.ok) throw new ApiError(await parseError(response), response.status);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
target: Date;
|
||||
compact?: boolean;
|
||||
pastMessage?: string;
|
||||
}
|
||||
|
||||
let { target, compact = false, pastMessage = 'The big day is here!' }: Props = $props();
|
||||
|
||||
let now = $state(Date.now());
|
||||
|
||||
$effect(() => {
|
||||
const id = setInterval(() => (now = Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
const diffMs = $derived(Math.max(0, target.getTime() - now));
|
||||
const isPast = $derived(diffMs <= 0);
|
||||
|
||||
const days = $derived(Math.floor(diffMs / 86_400_000));
|
||||
const hours = $derived(Math.floor((diffMs % 86_400_000) / 3_600_000));
|
||||
const minutes = $derived(Math.floor((diffMs % 3_600_000) / 60_000));
|
||||
const seconds = $derived(Math.floor((diffMs % 60_000) / 1000));
|
||||
|
||||
const units = $derived([
|
||||
{ label: 'days', value: days },
|
||||
{ label: 'hours', value: hours },
|
||||
{ label: 'minutes', value: minutes },
|
||||
{ label: 'seconds', value: seconds }
|
||||
]);
|
||||
</script>
|
||||
|
||||
{#if isPast}
|
||||
<p class={compact ? 'text-lg font-medium sm:text-xl' : 'text-surface-50 text-xl font-medium'}>{pastMessage}</p>
|
||||
{:else if compact}
|
||||
<div class="flex items-baseline gap-2 text-lg sm:text-xl" role="timer" aria-live="polite">
|
||||
{#each units as unit, i (unit.label)}
|
||||
{#if i > 0}<span class="text-secondary-300/60">·</span>{/if}
|
||||
<span class="font-semibold tabular-nums">{unit.value}</span>
|
||||
<span class="text-secondary-100/80 text-base">{unit.label}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex gap-4 sm:gap-8" role="timer" aria-live="polite">
|
||||
{#each units as unit (unit.label)}
|
||||
<div class="flex flex-col items-center">
|
||||
<span class="text-secondary-400 text-4xl font-bold tabular-nums sm:text-6xl">
|
||||
{String(unit.value).padStart(2, '0')}
|
||||
</span>
|
||||
<span class="text-surface-200 text-base tracking-wide uppercase sm:text-lg">{unit.label}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import { backOut, cubicIn } from 'svelte/easing';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
const modules = import.meta.glob('../../../assets/photos/*.{jpg,jpeg,png,JPG,JPEG,PNG}', {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
query: '?url'
|
||||
}) as Record<string, string>;
|
||||
|
||||
function shuffle<T>(items: T[]): T[] {
|
||||
const result = [...items];
|
||||
for (let i = result.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[result[i], result[j]] = [result[j], result[i]];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Randomized client-side only: shuffling during SSR/prerender would bake one
|
||||
// order into the static HTML while the client picks another, causing a
|
||||
// hydration mismatch on the <img src>.
|
||||
let photos = $state<string[]>([]);
|
||||
const intervalMs = 6000;
|
||||
|
||||
let index = $state(0);
|
||||
let reducedMotion = $state(false);
|
||||
|
||||
// Each photo flies in/out from a random angle with its own spin and pop, so
|
||||
// no two transitions look alike.
|
||||
function funky(node: HTMLElement, { duration, variant }: { duration: number; variant: 'in' | 'out' }) {
|
||||
if (reducedMotion) {
|
||||
return { duration: 400, css: (t: number) => `opacity: ${t}` };
|
||||
}
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const dist = 55 + Math.random() * 55;
|
||||
const rot = Math.random() * 80 - 40;
|
||||
const scaleFrom = 0.5 + Math.random() * 0.3;
|
||||
const x = Math.cos(angle) * dist;
|
||||
const y = Math.sin(angle) * dist;
|
||||
return {
|
||||
duration: duration + Math.random() * 300,
|
||||
easing: variant === 'in' ? backOut : cubicIn,
|
||||
css: (t: number, u: number) => `
|
||||
transform: translate(${u * x}%, ${u * y}%) rotate(${u * rot}deg) scale(${1 - u * (1 - scaleFrom)});
|
||||
opacity: ${t};
|
||||
`
|
||||
};
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
photos = shuffle(Object.values(modules));
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
reducedMotion = query.matches;
|
||||
const onChange = (event: MediaQueryListEvent) => (reducedMotion = event.matches);
|
||||
query.addEventListener('change', onChange);
|
||||
return () => query.removeEventListener('change', onChange);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (photos.length < 2) return;
|
||||
const id = setInterval(() => {
|
||||
index = (index + 1) % photos.length;
|
||||
}, intervalMs);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if photos.length > 0}
|
||||
<div class="pointer-events-none bg-surface-950 absolute inset-0 overflow-hidden" aria-hidden="true">
|
||||
{#key index}
|
||||
<img
|
||||
src={photos[index]}
|
||||
alt=""
|
||||
class="absolute inset-0 h-full w-full object-contain"
|
||||
in:funky={{ duration: 900, variant: 'in' }}
|
||||
out:funky={{ duration: 700, variant: 'out' }}
|
||||
/>
|
||||
{/key}
|
||||
<div class="absolute inset-0 bg-surface-950/65 dark:bg-surface-950/75"></div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Matches wedding.* / site.* in config.example.yaml. Used as an immediate
|
||||
// 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_WEDDING_DATE = "2026-11-07T16:00:00+02:00";
|
||||
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_VENUE_NAME = "Rivier Plaas";
|
||||
export const FALLBACK_VENUE_ADDRESS =
|
||||
"Langenhoven Rd, Sherman Park AH, Meyerton, 1961";
|
||||
export const FALLBACK_RSVP_PHONE = "076 925 1718";
|
||||
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -0,0 +1,24 @@
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
const STORAGE_KEY = 'zoe-shaldon-rsvp-submitted';
|
||||
|
||||
export function createRsvpStatus() {
|
||||
let submitted = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
submitted = localStorage.getItem(STORAGE_KEY) === 'true';
|
||||
});
|
||||
|
||||
function markSubmitted() {
|
||||
submitted = true;
|
||||
if (browser) localStorage.setItem(STORAGE_KEY, 'true');
|
||||
}
|
||||
|
||||
return {
|
||||
get submitted() {
|
||||
return submitted;
|
||||
},
|
||||
markSubmitted
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { fetchConfig } from './api';
|
||||
import {
|
||||
FALLBACK_SITE_URL,
|
||||
FALLBACK_WEDDING_DATE,
|
||||
FALLBACK_CEREMONY_TIME,
|
||||
FALLBACK_RSVP_BY_DATE,
|
||||
FALLBACK_VENUE_NAME,
|
||||
FALLBACK_VENUE_ADDRESS,
|
||||
FALLBACK_RSVP_PHONE
|
||||
} from './config';
|
||||
|
||||
export function createWeddingInfo() {
|
||||
let siteUrl = $state(FALLBACK_SITE_URL);
|
||||
let date = $state(new Date(FALLBACK_WEDDING_DATE));
|
||||
let ceremonyTime = $state(FALLBACK_CEREMONY_TIME);
|
||||
let rsvpByDate = $state(new Date(FALLBACK_RSVP_BY_DATE));
|
||||
let venueName = $state(FALLBACK_VENUE_NAME);
|
||||
let venueAddress = $state(FALLBACK_VENUE_ADDRESS);
|
||||
let rsvpPhone = $state(FALLBACK_RSVP_PHONE);
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
fetchConfig()
|
||||
.then((config) => {
|
||||
siteUrl = config.siteUrl;
|
||||
date = new Date(config.weddingDate);
|
||||
ceremonyTime = config.ceremonyTime;
|
||||
rsvpByDate = new Date(config.rsvpByDate);
|
||||
venueName = config.venueName;
|
||||
venueAddress = config.venueAddress;
|
||||
rsvpPhone = config.rsvpPhone;
|
||||
})
|
||||
.catch(() => {
|
||||
// keep the fallback values; the API may not be running yet in dev
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
get siteUrl() {
|
||||
return siteUrl;
|
||||
},
|
||||
get date() {
|
||||
return date;
|
||||
},
|
||||
get ceremonyTime() {
|
||||
return ceremonyTime;
|
||||
},
|
||||
get rsvpByDate() {
|
||||
return rsvpByDate;
|
||||
},
|
||||
get venueName() {
|
||||
return venueName;
|
||||
},
|
||||
get venueAddress() {
|
||||
return venueAddress;
|
||||
},
|
||||
get rsvpPhone() {
|
||||
return rsvpPhone;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const dayDateFormatter = new Intl.DateTimeFormat('en-GB', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
|
||||
export function formatFullDate(date: Date): string {
|
||||
return dayDateFormatter.format(date);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
[data-theme='wedding'] {
|
||||
--spacing: 0.25rem;
|
||||
--text-scaling: 1;
|
||||
--typo-base--font-family:
|
||||
'Bilbo', Georgia, 'Iowan Old Style', 'Palatino Linotype', 'Book Antiqua', Palatino, serif;
|
||||
--typo-base--font-size: inherit;
|
||||
--typo-base--color-light: var(--color-surface-950);
|
||||
--typo-base--color-dark: var(--color-surface-50);
|
||||
--typo-base--line-height: inherit;
|
||||
--typo-base--font-weight: bold;
|
||||
--typo-base--font-style: normal;
|
||||
--typo-base--letter-spacing: 0em;
|
||||
--typo-base--font-stretch: inherit;
|
||||
--typo-base--font-kerning: inherit;
|
||||
--typo-base--text-shadow: inherit;
|
||||
--typo-base--word-spacing: inherit;
|
||||
--typo-base--hyphens: inherit;
|
||||
--typo-base--text-transform: inherit;
|
||||
--typo-heading--font-family: inherit;
|
||||
--typo-heading--color-light: inherit;
|
||||
--typo-heading--color-dark: inherit;
|
||||
--typo-heading--font-weight: bold;
|
||||
--typo-heading--font-style: normal;
|
||||
--typo-heading--letter-spacing: inherit;
|
||||
--typo-heading--font-stretch: inherit;
|
||||
--typo-heading--font-kerning: inherit;
|
||||
--typo-heading--text-shadow: inherit;
|
||||
--typo-heading--word-spacing: inherit;
|
||||
--typo-heading--hyphens: inherit;
|
||||
--typo-heading--text-transform: inherit;
|
||||
--typo-anchor--font-family: inherit;
|
||||
--typo-anchor--font-size: inherit;
|
||||
--typo-anchor--color-light: var(--color-primary-500);
|
||||
--typo-anchor--color-dark: var(--color-primary-400);
|
||||
--typo-anchor--line-height: inherit;
|
||||
--typo-anchor--font-weight: inherit;
|
||||
--typo-anchor--font-style: inherit;
|
||||
--typo-anchor--letter-spacing: inherit;
|
||||
--typo-anchor--font-stretch: inherit;
|
||||
--typo-anchor--font-kerning: inherit;
|
||||
--typo-anchor--text-shadow: inherit;
|
||||
--typo-anchor--word-spacing: inherit;
|
||||
--typo-anchor--hyphens: inherit;
|
||||
--typo-anchor--text-transform: inherit;
|
||||
--typo-anchor--text-decoration-line: none;
|
||||
--typo-anchor--text-decoration-color: inherit;
|
||||
--typo-anchor--text-decoration-style: inherit;
|
||||
--typo-anchor--text-decoration-thickness: inherit;
|
||||
--typo-anchor--text-underline-offset: inherit;
|
||||
--typo-anchor--text-underline-position: inherit;
|
||||
--typo-anchor--hover--text-decoration-line: underline;
|
||||
--typo-anchor--hover--text-decoration-color: inherit;
|
||||
--typo-anchor--hover--text-decoration-style: inherit;
|
||||
--typo-anchor--hover--text-decoration-thickness: inherit;
|
||||
--typo-anchor--hover--text-underline-offset: inherit;
|
||||
--typo-anchor--hover--text-underline-position: inherit;
|
||||
--typo-anchor--active--text-decoration-line: none;
|
||||
--typo-anchor--active--text-decoration-color: inherit;
|
||||
--typo-anchor--active--text-decoration-style: inherit;
|
||||
--typo-anchor--active--text-decoration-thickness: inherit;
|
||||
--typo-anchor--active--text-underline-offset: inherit;
|
||||
--typo-anchor--active--text-underline-position: inherit;
|
||||
--typo-anchor--focus--text-decoration-line: none;
|
||||
--typo-anchor--focus--text-decoration-color: inherit;
|
||||
--typo-anchor--focus--text-decoration-style: inherit;
|
||||
--typo-anchor--focus--text-decoration-thickness: inherit;
|
||||
--typo-anchor--focus--text-underline-offset: inherit;
|
||||
--typo-anchor--focus--text-underline-position: inherit;
|
||||
--radius-base: 0.5rem;
|
||||
--radius-container: 0.5rem;
|
||||
--default-border-width: 1px;
|
||||
--default-outline-width: 1px;
|
||||
--default-ring-width: 1px;
|
||||
--corner-shape-base: squircle;
|
||||
--corner-shape-container: squircle;
|
||||
--color-root-bg-light: var(--color-surface-50);
|
||||
--color-root-bg-dark: var(--color-surface-950);
|
||||
--color-brand-light: var(--color-primary-500);
|
||||
--color-brand-contrast-light: var(--color-primary-contrast-500);
|
||||
--color-brand-dark: var(--color-primary-500);
|
||||
--color-brand-contrast-dark: var(--color-primary-contrast-500);
|
||||
|
||||
/* primary (blue) — seed #1E4E79, from the groom's shirt / dusk sky in the save-the-date photo */
|
||||
--color-primary-50: oklch(0.97 0.0133 248.73);
|
||||
--color-primary-100: oklch(0.93 0.0266 248.73);
|
||||
--color-primary-200: oklch(0.86 0.0488 248.73);
|
||||
--color-primary-300: oklch(0.78 0.0666 248.73);
|
||||
--color-primary-400: oklch(0.7 0.0799 248.73);
|
||||
--color-primary-500: oklch(0.62 0.0888 248.73);
|
||||
--color-primary-600: oklch(0.54 0.0817 248.73);
|
||||
--color-primary-700: oklch(0.46 0.071 248.73);
|
||||
--color-primary-800: oklch(0.38 0.0577 248.73);
|
||||
--color-primary-900: oklch(0.3 0.0444 248.73);
|
||||
--color-primary-950: oklch(0.22 0.0311 248.73);
|
||||
--color-primary-contrast-dark: var(--color-primary-950);
|
||||
--color-primary-contrast-light: var(--color-primary-50);
|
||||
--color-primary-contrast-50: var(--color-primary-contrast-dark);
|
||||
--color-primary-contrast-100: var(--color-primary-contrast-dark);
|
||||
--color-primary-contrast-200: var(--color-primary-contrast-dark);
|
||||
--color-primary-contrast-300: var(--color-primary-contrast-dark);
|
||||
--color-primary-contrast-400: var(--color-primary-contrast-dark);
|
||||
--color-primary-contrast-500: var(--color-primary-contrast-light);
|
||||
--color-primary-contrast-600: var(--color-primary-contrast-light);
|
||||
--color-primary-contrast-700: var(--color-primary-contrast-light);
|
||||
--color-primary-contrast-800: var(--color-primary-contrast-light);
|
||||
--color-primary-contrast-900: var(--color-primary-contrast-light);
|
||||
--color-primary-contrast-950: var(--color-primary-contrast-light);
|
||||
|
||||
/* secondary (yellow) — seed #D9A441, from the warm gold title text in the same photo */
|
||||
--color-secondary-50: oklch(0.97 0.0194 79.85);
|
||||
--color-secondary-100: oklch(0.93 0.0389 79.85);
|
||||
--color-secondary-200: oklch(0.86 0.0712 79.85);
|
||||
--color-secondary-300: oklch(0.78 0.0971 79.85);
|
||||
--color-secondary-400: oklch(0.7 0.1166 79.85);
|
||||
--color-secondary-500: oklch(0.62 0.1295 79.85);
|
||||
--color-secondary-600: oklch(0.54 0.1191 79.85);
|
||||
--color-secondary-700: oklch(0.46 0.1036 79.85);
|
||||
--color-secondary-800: oklch(0.38 0.0842 79.85);
|
||||
--color-secondary-900: oklch(0.3 0.0648 79.85);
|
||||
--color-secondary-950: oklch(0.22 0.0453 79.85);
|
||||
--color-secondary-contrast-dark: var(--color-secondary-950);
|
||||
--color-secondary-contrast-light: var(--color-secondary-50);
|
||||
--color-secondary-contrast-50: var(--color-secondary-contrast-light);
|
||||
--color-secondary-contrast-100: var(--color-secondary-contrast-light);
|
||||
--color-secondary-contrast-200: var(--color-secondary-contrast-dark);
|
||||
--color-secondary-contrast-300: var(--color-secondary-contrast-dark);
|
||||
--color-secondary-contrast-400: var(--color-secondary-contrast-dark);
|
||||
--color-secondary-contrast-500: var(--color-secondary-contrast-dark);
|
||||
--color-secondary-contrast-600: var(--color-secondary-contrast-light);
|
||||
--color-secondary-contrast-700: var(--color-secondary-contrast-light);
|
||||
--color-secondary-contrast-800: var(--color-secondary-contrast-light);
|
||||
--color-secondary-contrast-900: var(--color-secondary-contrast-light);
|
||||
--color-secondary-contrast-950: var(--color-secondary-contrast-light);
|
||||
|
||||
/* tertiary/success/warning/error/surface: unmodified neutrals, not part of the brand ask */
|
||||
--color-tertiary-50: oklch(0.91 0.08 328.89);
|
||||
--color-tertiary-100: oklch(0.83 0.13 339.66);
|
||||
--color-tertiary-200: oklch(0.76 0.18 345.54);
|
||||
--color-tertiary-300: oklch(0.7 0.23 350.67);
|
||||
--color-tertiary-400: oklch(0.66 0.25 355.84);
|
||||
--color-tertiary-500: oklch(0.65 0.26 2.47);
|
||||
--color-tertiary-600: oklch(0.59 0.24 1.69);
|
||||
--color-tertiary-700: oklch(0.54 0.22 0.5);
|
||||
--color-tertiary-800: oklch(0.48 0.2 359.65);
|
||||
--color-tertiary-900: oklch(0.43 0.17 357.7);
|
||||
--color-tertiary-950: oklch(0.37 0.15 355.33);
|
||||
--color-tertiary-contrast-dark: var(--color-tertiary-950);
|
||||
--color-tertiary-contrast-light: var(--color-tertiary-50);
|
||||
--color-tertiary-contrast-50: var(--color-tertiary-contrast-dark);
|
||||
--color-tertiary-contrast-100: var(--color-tertiary-contrast-dark);
|
||||
--color-tertiary-contrast-200: var(--color-tertiary-contrast-dark);
|
||||
--color-tertiary-contrast-300: var(--color-tertiary-contrast-dark);
|
||||
--color-tertiary-contrast-400: var(--color-tertiary-contrast-light);
|
||||
--color-tertiary-contrast-500: var(--color-tertiary-contrast-light);
|
||||
--color-tertiary-contrast-600: var(--color-tertiary-contrast-light);
|
||||
--color-tertiary-contrast-700: var(--color-tertiary-contrast-light);
|
||||
--color-tertiary-contrast-800: var(--color-tertiary-contrast-light);
|
||||
--color-tertiary-contrast-900: var(--color-tertiary-contrast-light);
|
||||
--color-tertiary-contrast-950: var(--color-tertiary-contrast-light);
|
||||
--color-success-50: oklch(0.94 0.09 178.68);
|
||||
--color-success-100: oklch(0.92 0.1 178.62);
|
||||
--color-success-200: oklch(0.89 0.11 177.17);
|
||||
--color-success-300: oklch(0.87 0.12 176.91);
|
||||
--color-success-400: oklch(0.85 0.13 175.46);
|
||||
--color-success-500: oklch(0.83 0.13 174.96);
|
||||
--color-success-600: oklch(0.73 0.12 175.71);
|
||||
--color-success-700: oklch(0.62 0.1 176);
|
||||
--color-success-800: oklch(0.51 0.08 178.29);
|
||||
--color-success-900: oklch(0.4 0.06 179.75);
|
||||
--color-success-950: oklch(0.27 0.04 185.3);
|
||||
--color-success-contrast-dark: var(--color-success-950);
|
||||
--color-success-contrast-light: var(--color-success-50);
|
||||
--color-success-contrast-50: var(--color-success-contrast-dark);
|
||||
--color-success-contrast-100: var(--color-success-contrast-dark);
|
||||
--color-success-contrast-200: var(--color-success-contrast-dark);
|
||||
--color-success-contrast-300: var(--color-success-contrast-dark);
|
||||
--color-success-contrast-400: var(--color-success-contrast-dark);
|
||||
--color-success-contrast-500: var(--color-success-contrast-dark);
|
||||
--color-success-contrast-600: var(--color-success-contrast-dark);
|
||||
--color-success-contrast-700: var(--color-success-contrast-light);
|
||||
--color-success-contrast-800: var(--color-success-contrast-light);
|
||||
--color-success-contrast-900: var(--color-success-contrast-light);
|
||||
--color-success-contrast-950: var(--color-success-contrast-light);
|
||||
--color-warning-50: oklch(0.96 0.05 84.57);
|
||||
--color-warning-100: oklch(0.93 0.06 82.17);
|
||||
--color-warning-200: oklch(0.9 0.08 80.34);
|
||||
--color-warning-300: oklch(0.88 0.1 80.02);
|
||||
--color-warning-400: oklch(0.85 0.12 78.36);
|
||||
--color-warning-500: oklch(0.82 0.14 76.72);
|
||||
--color-warning-600: oklch(0.76 0.13 72.26);
|
||||
--color-warning-700: oklch(0.7 0.13 68.1);
|
||||
--color-warning-800: oklch(0.64 0.13 63.18);
|
||||
--color-warning-900: oklch(0.58 0.13 57.97);
|
||||
--color-warning-950: oklch(0.52 0.13 51.44);
|
||||
--color-warning-contrast-dark: var(--color-warning-950);
|
||||
--color-warning-contrast-light: var(--color-warning-50);
|
||||
--color-warning-contrast-50: var(--color-warning-contrast-dark);
|
||||
--color-warning-contrast-100: var(--color-warning-contrast-dark);
|
||||
--color-warning-contrast-200: var(--color-warning-contrast-dark);
|
||||
--color-warning-contrast-300: var(--color-warning-contrast-dark);
|
||||
--color-warning-contrast-400: var(--color-warning-contrast-dark);
|
||||
--color-warning-contrast-500: var(--color-warning-contrast-dark);
|
||||
--color-warning-contrast-600: var(--color-warning-contrast-light);
|
||||
--color-warning-contrast-700: var(--color-warning-contrast-light);
|
||||
--color-warning-contrast-800: var(--color-warning-contrast-light);
|
||||
--color-warning-contrast-900: var(--color-warning-contrast-light);
|
||||
--color-warning-contrast-950: var(--color-warning-contrast-light);
|
||||
--color-error-50: oklch(0.9 0.04 14);
|
||||
--color-error-100: oklch(0.83 0.07 19.8);
|
||||
--color-error-200: oklch(0.77 0.11 21.97);
|
||||
--color-error-300: oklch(0.72 0.15 24.89);
|
||||
--color-error-400: oklch(0.67 0.19 26.71);
|
||||
--color-error-500: oklch(0.64 0.22 28.71);
|
||||
--color-error-600: oklch(0.59 0.21 28.53);
|
||||
--color-error-700: oklch(0.55 0.2 28.58);
|
||||
--color-error-800: oklch(0.51 0.19 28.72);
|
||||
--color-error-900: oklch(0.46 0.18 28.88);
|
||||
--color-error-950: oklch(0.42 0.17 29.23);
|
||||
--color-error-contrast-dark: var(--color-error-950);
|
||||
--color-error-contrast-light: var(--color-error-50);
|
||||
--color-error-contrast-50: var(--color-error-contrast-dark);
|
||||
--color-error-contrast-100: var(--color-error-contrast-dark);
|
||||
--color-error-contrast-200: var(--color-error-contrast-dark);
|
||||
--color-error-contrast-300: var(--color-error-contrast-dark);
|
||||
--color-error-contrast-400: var(--color-error-contrast-light);
|
||||
--color-error-contrast-500: var(--color-error-contrast-light);
|
||||
--color-error-contrast-600: var(--color-error-contrast-light);
|
||||
--color-error-contrast-700: var(--color-error-contrast-light);
|
||||
--color-error-contrast-800: var(--color-error-contrast-light);
|
||||
--color-error-contrast-900: var(--color-error-contrast-light);
|
||||
--color-error-contrast-950: var(--color-error-contrast-light);
|
||||
--color-surface-50: oklch(0.99 0 0);
|
||||
--color-surface-100: oklch(0.91 0 0);
|
||||
--color-surface-200: oklch(0.81 0 0);
|
||||
--color-surface-300: oklch(0.72 0 0);
|
||||
--color-surface-400: oklch(0.62 0 0);
|
||||
--color-surface-500: oklch(0.51 0 0);
|
||||
--color-surface-600: oklch(0.45 0 0);
|
||||
--color-surface-700: oklch(0.39 0 0);
|
||||
--color-surface-800: oklch(0.32 0 0);
|
||||
--color-surface-900: oklch(0.25 0 0);
|
||||
--color-surface-950: oklch(0.18 0 0);
|
||||
--color-surface-contrast-dark: var(--color-surface-950);
|
||||
--color-surface-contrast-light: var(--color-surface-50);
|
||||
--color-surface-contrast-50: var(--color-surface-contrast-dark);
|
||||
--color-surface-contrast-100: var(--color-surface-contrast-dark);
|
||||
--color-surface-contrast-200: var(--color-surface-contrast-dark);
|
||||
--color-surface-contrast-300: var(--color-surface-contrast-dark);
|
||||
--color-surface-contrast-400: var(--color-surface-contrast-light);
|
||||
--color-surface-contrast-500: var(--color-surface-contrast-light);
|
||||
--color-surface-contrast-600: var(--color-surface-contrast-light);
|
||||
--color-surface-contrast-700: var(--color-surface-contrast-light);
|
||||
--color-surface-contrast-800: var(--color-surface-contrast-light);
|
||||
--color-surface-contrast-900: var(--color-surface-contrast-light);
|
||||
--color-surface-contrast-950: var(--color-surface-contrast-light);
|
||||
}
|
||||
@@ -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é & 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>
|
||||
@@ -0,0 +1 @@
|
||||
export const prerender = true;
|
||||
@@ -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é & 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>
|
||||
@@ -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é & 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>
|
||||
@@ -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é & 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>
|
||||
@@ -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é & 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"}
|
||||
>
|
||||
−
|
||||
</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>
|
||||
Reference in New Issue
Block a user