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,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"svelte.svelte-vscode",
|
||||
"bradlc.vscode-tailwindcss"
|
||||
]
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"files.associations": {
|
||||
"*.css": "tailwindcss"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```sh
|
||||
# create a new project
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
To recreate this project with the same configuration:
|
||||
|
||||
```sh
|
||||
# recreate this project
|
||||
npx sv@0.17.0 create --template minimal --types ts --add tailwindcss="plugins:none" sveltekit-adapter="adapter:static" --no-download-check --install npm ui
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 4.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.9 MiB |
BIN
Binary file not shown.
Generated
+2739
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "ui",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@skeletonlabs/skeleton": "^5.0.0",
|
||||
"@skeletonlabs/skeleton-svelte": "^5.0.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.63.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"svelte": "^5.56.1",
|
||||
"svelte-check": "^4.6.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
}
|
||||
Generated
+1751
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import adapter from '@sveltejs/adapter-static';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
fs: { allow: ['assets'] }
|
||||
},
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
sveltekit({
|
||||
compilerOptions: {
|
||||
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
|
||||
runes: ({ filename }) => filename.split(/[/\\]/).includes('node_modules') ? undefined : true
|
||||
},
|
||||
adapter: adapter()
|
||||
})
|
||||
]
|
||||
});
|
||||
Reference in New Issue
Block a user