feat(events): add mana-events service + public RSVP flow (Phase 1b)

New Hono+Bun service at services/mana-events on port 3065 with two
schemas in mana_platform: events_published (snapshots) and public_rsvps
(unauthenticated responses), plus a per-token hourly rate-limit bucket.

- Host endpoints (JWT) for publish/update/unpublish/list-rsvps
- Public endpoints for snapshot fetch + RSVP upsert with rate limiting
- New /rsvp/[token] page outside the auth gate, SSR-loads the snapshot
- Client store wires publishEvent/unpublishEvent to the server, syncs
  snapshot updates after edits, and deletes the snapshot on event delete
- DetailView polls GET /events/:id/rsvps every 30s while open and lets
  hosts import a public response into their local guest list
- generate-env, setup-databases.sh, .env.development, hooks.server.ts,
  package.json wired for local dev
This commit is contained in:
Till JS 2026-04-07 14:27:48 +02:00
parent 980a5e996c
commit 216746721e
27 changed files with 1764 additions and 11 deletions

View file

@ -48,6 +48,8 @@ const PUBLIC_MANA_MEDIA_URL_CLIENT =
process.env.PUBLIC_MANA_MEDIA_URL_CLIENT || process.env.PUBLIC_MANA_MEDIA_URL || '';
const PUBLIC_MANA_LLM_URL_CLIENT =
process.env.PUBLIC_MANA_LLM_URL_CLIENT || process.env.PUBLIC_MANA_LLM_URL || '';
const PUBLIC_MANA_EVENTS_URL_CLIENT =
process.env.PUBLIC_MANA_EVENTS_URL_CLIENT || process.env.PUBLIC_MANA_EVENTS_URL || '';
// Map of app subdomains to internal paths
const APP_SUBDOMAINS = new Set([
@ -106,6 +108,7 @@ window.__PUBLIC_ULOAD_SERVER_URL__ = ${JSON.stringify(PUBLIC_ULOAD_SERVER_URL_CL
window.__PUBLIC_MEMORO_SERVER_URL__ = ${JSON.stringify(PUBLIC_MEMORO_SERVER_URL_CLIENT)};
window.__PUBLIC_MANA_MEDIA_URL__ = ${JSON.stringify(PUBLIC_MANA_MEDIA_URL_CLIENT)};
window.__PUBLIC_MANA_LLM_URL__ = ${JSON.stringify(PUBLIC_MANA_LLM_URL_CLIENT)};
window.__PUBLIC_MANA_EVENTS_URL__ = ${JSON.stringify(PUBLIC_MANA_EVENTS_URL_CLIENT)};
window.__PUBLIC_GLITCHTIP_DSN__ = ${JSON.stringify(PUBLIC_GLITCHTIP_DSN)};
</script>`;
return injectUmamiAnalytics(html.replace('<head>', `<head>${envScript}`));
@ -130,6 +133,7 @@ window.__PUBLIC_GLITCHTIP_DSN__ = ${JSON.stringify(PUBLIC_GLITCHTIP_DSN)};
PUBLIC_MEMORO_SERVER_URL_CLIENT,
PUBLIC_MANA_MEDIA_URL_CLIENT,
PUBLIC_MANA_LLM_URL_CLIENT,
PUBLIC_MANA_EVENTS_URL_CLIENT,
'wss://sync.mana.how',
// Allow all localhost ports in development
...(isDev ? ['http://localhost:*', 'ws://localhost:*'] : []),

View file

@ -21,3 +21,15 @@ export function getManaAuthUrl(): string {
// Server-side (SSR): use environment variable
return process.env.PUBLIC_MANA_AUTH_URL || 'http://localhost:3001';
}
/**
* Get the mana-events service URL (Phase 1b: public RSVP backend).
*/
export function getManaEventsUrl(): string {
if (browser && typeof window !== 'undefined') {
const injected = (window as unknown as { __PUBLIC_MANA_EVENTS_URL__?: string })
.__PUBLIC_MANA_EVENTS_URL__;
return injected || 'http://localhost:3065';
}
return process.env.PUBLIC_MANA_EVENTS_URL || 'http://localhost:3065';
}

View file

@ -0,0 +1,76 @@
/**
* mana-events HTTP client (host-side, JWT-authenticated).
*/
import { authStore } from '$lib/stores/auth.svelte';
import { getManaEventsUrl } from '$lib/api/config';
export interface PublishedSnapshotInput {
eventId: string;
title: string;
description?: string | null;
location?: string | null;
locationUrl?: string | null;
startAt: string;
endAt?: string | null;
allDay?: boolean;
coverImageUrl?: string | null;
color?: string | null;
capacity?: number | null;
}
export interface PublicRsvpRecord {
id: string;
token: string;
name: string;
email: string | null;
status: 'yes' | 'no' | 'maybe';
plusOnes: number;
note: string | null;
createdAt: string;
updatedAt: string;
}
async function fetchWithAuth<T>(path: string, init: RequestInit = {}): Promise<T> {
const token = await authStore.getAccessToken();
const res = await fetch(`${getManaEventsUrl()}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...init.headers,
},
});
if (!res.ok) {
const err = await res.json().catch(() => ({ message: 'Request failed' }));
throw new Error(err.message || `HTTP ${res.status}`);
}
return res.json() as Promise<T>;
}
export const eventsApi = {
async publish(input: PublishedSnapshotInput): Promise<{ token: string; isNew: boolean }> {
return fetchWithAuth('/api/v1/events/publish', {
method: 'POST',
body: JSON.stringify(input),
});
},
async updateSnapshot(
eventId: string,
input: Partial<PublishedSnapshotInput>
): Promise<{ token: string }> {
return fetchWithAuth(`/api/v1/events/${eventId}/snapshot`, {
method: 'PUT',
body: JSON.stringify(input),
});
},
async unpublish(eventId: string): Promise<{ deleted: boolean }> {
return fetchWithAuth(`/api/v1/events/${eventId}`, { method: 'DELETE' });
},
async getRsvps(eventId: string): Promise<{ token: string; rsvps: PublicRsvpRecord[] }> {
return fetchWithAuth(`/api/v1/events/${eventId}/rsvps`);
},
};

View file

@ -0,0 +1,252 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { eventsApi, type PublicRsvpRecord } from '../api';
import { eventGuestsStore } from '../stores/guests.svelte';
interface Props {
eventId: string;
isPublished: boolean;
}
let { eventId, isPublished }: Props = $props();
let rsvps = $state<PublicRsvpRecord[]>([]);
let loading = $state(false);
let lastError = $state<string | null>(null);
let lastFetchedAt = $state<Date | null>(null);
let pollHandle: ReturnType<typeof setInterval> | null = null;
async function fetchRsvps() {
if (!isPublished) return;
loading = true;
try {
const res = await eventsApi.getRsvps(eventId);
rsvps = res.rsvps;
lastError = null;
lastFetchedAt = new Date();
} catch (e) {
lastError = e instanceof Error ? e.message : 'Fehler beim Laden';
} finally {
loading = false;
}
}
onMount(() => {
if (isPublished) {
void fetchRsvps();
pollHandle = setInterval(fetchRsvps, 30_000);
}
});
onDestroy(() => {
if (pollHandle) clearInterval(pollHandle);
});
// Re-poll when isPublished flips
$effect(() => {
if (isPublished && !pollHandle) {
void fetchRsvps();
pollHandle = setInterval(fetchRsvps, 30_000);
} else if (!isPublished && pollHandle) {
clearInterval(pollHandle);
pollHandle = null;
rsvps = [];
}
});
async function importToGuestList(r: PublicRsvpRecord) {
await eventGuestsStore.addGuest({
eventId,
name: r.name,
email: r.email,
rsvpStatus: r.status,
plusOnes: r.plusOnes,
note: r.note,
});
}
</script>
{#if isPublished}
<div class="public-rsvps">
<div class="header-row">
<h3>Antworten via Link</h3>
<button class="refresh" onclick={fetchRsvps} disabled={loading}>
{loading ? '…' : 'Neu laden'}
</button>
</div>
{#if lastError}
<p class="error">{lastError}</p>
{:else if rsvps.length === 0 && !loading}
<p class="empty">Noch keine Antworten via Share-Link.</p>
{:else}
<ul class="rsvp-list">
{#each rsvps as r (r.id)}
<li class="rsvp-row">
<div class="info">
<div class="name-row">
<span class="name">{r.name}</span>
<span class="status status-{r.status}">
{r.status === 'yes' ? 'Ja' : r.status === 'no' ? 'Nein' : 'Vielleicht'}
</span>
{#if r.plusOnes > 0}
<span class="plus">+{r.plusOnes}</span>
{/if}
</div>
{#if r.email}
<div class="email">{r.email}</div>
{/if}
{#if r.note}
<div class="note">{r.note}</div>
{/if}
</div>
<button class="import-btn" onclick={() => importToGuestList(r)} title="Zur Gästeliste">
</button>
</li>
{/each}
</ul>
{/if}
{#if lastFetchedAt}
<div class="meta">
Aktualisiert um {lastFetchedAt.toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})} · Auto-Refresh alle 30s
</div>
{/if}
</div>
{/if}
<style>
.public-rsvps {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.header-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.header-row h3 {
margin: 0;
font-size: 0.8125rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.refresh {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: hsl(var(--color-card));
color: hsl(var(--color-foreground));
cursor: pointer;
}
.refresh:disabled {
opacity: 0.5;
cursor: wait;
}
.error {
margin: 0;
padding: 0.5rem;
border-radius: 0.375rem;
background: rgba(239, 68, 68, 0.1);
color: rgb(220, 38, 38);
font-size: 0.8125rem;
}
.empty {
margin: 0;
padding: 0.75rem;
text-align: center;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
font-style: italic;
}
.rsvp-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.rsvp-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-card));
}
.info {
flex: 1;
min-width: 0;
}
.name-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.name {
font-weight: 500;
font-size: 0.875rem;
color: hsl(var(--color-foreground));
}
.status {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
}
.status-yes {
background: rgba(34, 197, 94, 0.15);
color: rgb(22, 163, 74);
}
.status-no {
background: rgba(239, 68, 68, 0.15);
color: rgb(220, 38, 38);
}
.status-maybe {
background: rgba(245, 158, 11, 0.15);
color: rgb(202, 138, 4);
}
.plus {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.email {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.note {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
font-style: italic;
margin-top: 0.125rem;
}
.import-btn {
padding: 0.25rem 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: transparent;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
font-size: 1rem;
}
.import-btn:hover {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.meta {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
text-align: right;
}
</style>

View file

@ -7,7 +7,9 @@
import { db } from '$lib/data/database';
import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service';
import { timeBlockTable } from '$lib/data/time-blocks/collections';
import type { LocalSocialEvent, EventStatus } from '../types';
import { eventsApi } from '../api';
let error = $state<string | null>(null);
@ -119,6 +121,8 @@ export const eventsStore = {
if (input.coverImage !== undefined) localData.coverImage = input.coverImage;
await db.table('socialEvents').update(id, localData);
// Fire-and-forget snapshot sync if this event is published
void this.syncSnapshotIfPublished(id);
return { success: true as const };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update event';
@ -133,6 +137,13 @@ export const eventsStore = {
if (event?.timeBlockId) {
await deleteBlock(event.timeBlockId);
}
if (event?.isPublished) {
try {
await eventsApi.unpublish(id);
} catch (e) {
console.warn('Failed to delete server snapshot during deleteEvent:', e);
}
}
await db.table('socialEvents').update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@ -145,17 +156,30 @@ export const eventsStore = {
},
/**
* Local-only "publish" stub for Phase 1a.
* Just flips isPublished + assigns a placeholder token. Phase 1b will
* push the snapshot to mana-events and use a real server-issued token.
* Publish event pushes a snapshot to mana-events and stores the
* server-issued token locally. Public RSVP page will read the snapshot.
*/
async publishEvent(id: string) {
error = null;
try {
const token =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID().replace(/-/g, '').slice(0, 24)
: Math.random().toString(36).slice(2, 26);
const event = await db.table<LocalSocialEvent>('socialEvents').get(id);
if (!event) return { success: false as const, error: 'Event not found' };
const block = await timeBlockTable.get(event.timeBlockId);
if (!block) return { success: false as const, error: 'TimeBlock missing for event' };
const { token } = await eventsApi.publish({
eventId: id,
title: event.title,
description: event.description ?? null,
location: event.location ?? null,
locationUrl: event.locationUrl ?? null,
startAt: block.startDate,
endAt: block.endDate ?? null,
allDay: block.allDay ?? false,
coverImageUrl: event.coverImage ?? null,
color: event.color ?? null,
capacity: event.capacity ?? null,
});
await db.table('socialEvents').update(id, {
isPublished: true,
@ -173,6 +197,13 @@ export const eventsStore = {
async unpublishEvent(id: string) {
error = null;
try {
// Best-effort delete on the server. If the network fails we still
// flip the local flag — host clearly intended to unpublish.
try {
await eventsApi.unpublish(id);
} catch (e) {
console.warn('Failed to delete server snapshot during unpublish:', e);
}
await db.table('socialEvents').update(id, {
isPublished: false,
publicToken: null,
@ -185,4 +216,32 @@ export const eventsStore = {
return { success: false as const, error };
}
},
/**
* Push the latest local state of a published event to the server snapshot.
* Called after an updateEvent() if the event is currently published.
*/
async syncSnapshotIfPublished(id: string) {
try {
const event = await db.table<LocalSocialEvent>('socialEvents').get(id);
if (!event || !event.isPublished) return;
const block = await timeBlockTable.get(event.timeBlockId);
if (!block) return;
await eventsApi.updateSnapshot(id, {
eventId: id,
title: event.title,
description: event.description ?? null,
location: event.location ?? null,
locationUrl: event.locationUrl ?? null,
startAt: block.startDate,
endAt: block.endDate ?? null,
allDay: block.allDay ?? false,
coverImageUrl: event.coverImage ?? null,
color: event.color ?? null,
capacity: event.capacity ?? null,
});
} catch (e) {
console.warn('Snapshot sync failed:', e);
}
},
};

View file

@ -3,6 +3,7 @@
import { eventsStore } from '../stores/events.svelte';
import GuestListEditor from '../components/GuestListEditor.svelte';
import RsvpSummaryView from '../components/RsvpSummary.svelte';
import PublicRsvpList from '../components/PublicRsvpList.svelte';
interface Props {
eventId: string;
@ -154,6 +155,12 @@
<GuestListEditor eventId={event.id} />
</section>
{#if event.isPublished}
<section class="section">
<PublicRsvpList eventId={event.id} isPublished={event.isPublished} />
</section>
{/if}
<section class="section">
<h2>Teilen</h2>
{#if event.isPublished && event.publicToken}
@ -163,8 +170,7 @@
<button class="action-btn" onclick={handlePublish}>Privat machen</button>
</div>
<p class="share-hint">
Phase 1a: Link funktioniert noch nicht öffentlich — Gäste-Antworten musst du noch manuell
oben in der Gästeliste eintragen.
Antworten erscheinen automatisch unten in „Antworten via Link“ (Polling alle 30s).
</p>
{:else}
<button class="action-btn primary" onclick={handlePublish}>

View file

@ -0,0 +1,54 @@
/**
* Public RSVP page server-side load.
*
* Fetches the published event snapshot from mana-events. No auth required.
*/
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
const EVENTS_URL =
process.env.PUBLIC_MANA_EVENTS_URL_CLIENT ||
process.env.PUBLIC_MANA_EVENTS_URL ||
'http://localhost:3065';
interface EventSnapshot {
token: string;
title: string;
description: string | null;
location: string | null;
locationUrl: string | null;
startAt: string;
endAt: string | null;
allDay: boolean;
coverImageUrl: string | null;
color: string | null;
capacity: number | null;
}
interface RsvpSummary {
yes: number;
no: number;
maybe: number;
totalAttending: number;
}
export const load: PageServerLoad = async ({ params, fetch }) => {
const token = params.token;
if (!token) throw error(404, 'Not found');
try {
const res = await fetch(`${EVENTS_URL}/api/v1/rsvp/${encodeURIComponent(token)}`);
if (res.status === 404) throw error(404, 'Event nicht gefunden');
if (!res.ok) throw error(500, 'Konnte Event nicht laden');
const data = (await res.json()) as {
event: EventSnapshot;
summary: RsvpSummary | null;
cancelled?: boolean;
};
return { token, ...data, eventsUrl: EVENTS_URL };
} catch (e) {
if (e && typeof e === 'object' && 'status' in e) throw e;
throw error(500, 'Konnte Event nicht laden');
}
};

View file

@ -0,0 +1,416 @@
<script lang="ts">
import type { PageData } from './$types';
import { getManaEventsUrl } from '$lib/api/config';
let { data }: { data: PageData } = $props();
let name = $state('');
let email = $state('');
let status = $state<'yes' | 'no' | 'maybe'>('yes');
let plusOnes = $state(0);
let note = $state('');
let submitting = $state(false);
let submitted = $state(false);
let errorMessage = $state<string | null>(null);
const startDate = $derived(new Date(data.event.startAt));
const dateLabel = $derived(
startDate.toLocaleDateString('de-DE', {
weekday: 'long',
day: '2-digit',
month: 'long',
year: 'numeric',
})
);
const timeLabel = $derived(
data.event.allDay
? 'Ganztägig'
: startDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
);
async function handleSubmit(e: SubmitEvent) {
e.preventDefault();
if (submitting) return;
submitting = true;
errorMessage = null;
try {
const res = await fetch(`${getManaEventsUrl()}/api/v1/rsvp/${data.token}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.trim(),
email: email.trim() || null,
status,
plusOnes,
note: note.trim() || null,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({ message: 'Fehler' }));
throw new Error(err.message || `HTTP ${res.status}`);
}
submitted = true;
} catch (e) {
errorMessage = e instanceof Error ? e.message : 'Konnte nicht senden';
} finally {
submitting = false;
}
}
</script>
<svelte:head>
<title>{data.event.title} — RSVP</title>
</svelte:head>
<div class="rsvp-page">
<div class="card" style:border-top-color={data.event.color ?? '#f43f5e'}>
<h1 class="title">{data.event.title}</h1>
<div class="meta">
<div class="meta-row">
<span class="icon">📅</span>
<div>
<div>{dateLabel}</div>
<div class="muted">{timeLabel}</div>
</div>
</div>
{#if data.event.location}
<div class="meta-row">
<span class="icon">📍</span>
<div>
{#if data.event.locationUrl}
<a href={data.event.locationUrl} target="_blank" rel="noopener noreferrer">
{data.event.location}
</a>
{:else}
{data.event.location}
{/if}
</div>
</div>
{/if}
{#if data.summary}
<div class="meta-row">
<span class="icon">👥</span>
<div>
<strong>{data.summary.totalAttending}</strong> Personen kommen
{#if data.event.capacity}
<span class="muted">/ {data.event.capacity}</span>
{/if}
</div>
</div>
{/if}
</div>
{#if data.event.description}
<p class="description">{data.event.description}</p>
{/if}
{#if data.cancelled}
<div class="cancelled">⚠️ Dieses Event wurde abgesagt.</div>
{:else if submitted}
<div class="success">
<h2>Danke für deine Antwort!</h2>
<p>
Du hast mit
<strong>
{status === 'yes' ? '„Ja, komme“' : status === 'no' ? '„Nein“' : '„Vielleicht“'}
</strong>
geantwortet. Du kannst diese Seite jederzeit erneut öffnen, um deine Antwort zu ändern.
</p>
<button
class="action-btn"
onclick={() => {
submitted = false;
}}
>
Antwort ändern
</button>
</div>
{:else}
<form class="rsvp-form" onsubmit={handleSubmit}>
<h2>Sag bitte zu</h2>
<label class="field">
<span class="label">Dein Name</span>
<input
type="text"
bind:value={name}
placeholder="z. B. Anna Schmidt"
required
maxlength="100"
/>
</label>
<label class="field">
<span class="label">E-Mail (optional)</span>
<input type="email" bind:value={email} placeholder="anna@example.com" maxlength="200" />
</label>
<div class="field">
<span class="label">Kommst du?</span>
<div class="status-pills">
<button
type="button"
class="pill"
class:active={status === 'yes'}
onclick={() => (status = 'yes')}
>
✓ Ja, komme
</button>
<button
type="button"
class="pill"
class:active={status === 'maybe'}
onclick={() => (status = 'maybe')}
>
? Vielleicht
</button>
<button
type="button"
class="pill"
class:active={status === 'no'}
onclick={() => (status = 'no')}
>
✕ Nein
</button>
</div>
</div>
{#if status === 'yes'}
<label class="field">
<span class="label">Bringst du jemanden mit? ({plusOnes})</span>
<input type="range" min="0" max="10" bind:value={plusOnes} />
</label>
{/if}
<label class="field">
<span class="label">Notiz (optional)</span>
<textarea
bind:value={note}
placeholder="z. B. „Komme erst um 20 Uhr“"
rows="2"
maxlength="1000"
></textarea>
</label>
{#if errorMessage}
<div class="error">{errorMessage}</div>
{/if}
<button type="submit" class="submit-btn" disabled={submitting || !name.trim()}>
{submitting ? 'Sende...' : 'Antwort senden'}
</button>
</form>
{/if}
</div>
<footer class="footer">
Powered by <a href="https://mana.how" target="_blank" rel="noopener">Mana</a>
</footer>
</div>
<style>
:global(body) {
background: linear-gradient(135deg, #fef3f6 0%, #fde7ec 100%);
}
.rsvp-page {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem 1rem;
}
.card {
width: 100%;
max-width: 540px;
background: white;
border-radius: 1rem;
border-top: 6px solid #f43f5e;
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.06),
0 1px 4px rgba(0, 0, 0, 0.04);
padding: 2rem;
}
.title {
margin: 0 0 1rem;
font-size: 1.875rem;
font-weight: 700;
color: #111827;
}
.meta {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.25rem;
padding-bottom: 1.25rem;
border-bottom: 1px solid #f3f4f6;
}
.meta-row {
display: flex;
gap: 0.75rem;
font-size: 0.9375rem;
color: #374151;
}
.meta-row .icon {
font-size: 1.125rem;
}
.meta-row a {
color: #f43f5e;
text-decoration: none;
}
.meta-row a:hover {
text-decoration: underline;
}
.muted {
color: #6b7280;
font-size: 0.875rem;
}
.description {
margin: 0 0 1.5rem;
font-size: 0.9375rem;
color: #374151;
white-space: pre-wrap;
line-height: 1.5;
}
.cancelled {
padding: 1rem;
border-radius: 0.5rem;
background: #fef2f2;
color: #dc2626;
font-weight: 500;
text-align: center;
}
.rsvp-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.rsvp-form h2 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: #111827;
}
.field {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.label {
font-size: 0.75rem;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.field input[type='text'],
.field input[type='email'],
.field textarea {
padding: 0.625rem 0.875rem;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
background: white;
font-size: 0.9375rem;
color: #111827;
font-family: inherit;
}
.field input:focus,
.field textarea:focus {
outline: none;
border-color: #f43f5e;
box-shadow: 0 0 0 3px rgba(244, 63, 94, 0.15);
}
.field textarea {
resize: vertical;
}
.status-pills {
display: flex;
gap: 0.5rem;
}
.pill {
flex: 1;
padding: 0.625rem 0.5rem;
border: 2px solid #e5e7eb;
border-radius: 0.625rem;
background: white;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
color: #6b7280;
transition: all 0.15s;
}
.pill:hover {
border-color: #d1d5db;
}
.pill.active {
border-color: #f43f5e;
background: #fff1f3;
color: #be123c;
}
.field input[type='range'] {
width: 100%;
accent-color: #f43f5e;
}
.error {
padding: 0.625rem 0.875rem;
border-radius: 0.5rem;
background: #fef2f2;
color: #dc2626;
font-size: 0.875rem;
}
.submit-btn {
padding: 0.875rem;
border: none;
border-radius: 0.625rem;
background: linear-gradient(135deg, #f43f5e, #be123c);
color: white;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.submit-btn:hover:not(:disabled) {
opacity: 0.95;
}
.submit-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.success {
padding: 1.5rem;
text-align: center;
background: #f0fdf4;
border-radius: 0.625rem;
color: #166534;
}
.success h2 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
color: #15803d;
}
.success p {
margin: 0 0 1rem;
font-size: 0.875rem;
}
.action-btn {
padding: 0.5rem 1rem;
border: 1px solid #15803d;
border-radius: 0.5rem;
background: white;
color: #15803d;
font-size: 0.8125rem;
cursor: pointer;
}
.footer {
margin-top: 1.5rem;
font-size: 0.75rem;
color: #9ca3af;
}
.footer a {
color: #f43f5e;
text-decoration: none;
}
</style>