mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 19:26:42 +02:00
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:
parent
980a5e996c
commit
216746721e
27 changed files with 1764 additions and 11 deletions
|
|
@ -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:*'] : []),
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
76
apps/mana/apps/web/src/lib/modules/events/api.ts
Normal file
76
apps/mana/apps/web/src/lib/modules/events/api.ts
Normal 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`);
|
||||
},
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
54
apps/mana/apps/web/src/routes/rsvp/[token]/+page.server.ts
Normal file
54
apps/mana/apps/web/src/routes/rsvp/[token]/+page.server.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
416
apps/mana/apps/web/src/routes/rsvp/[token]/+page.svelte
Normal file
416
apps/mana/apps/web/src/routes/rsvp/[token]/+page.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue