mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
fix(events): tech debt — self-heal snapshots, tombstones, polling cleanup, RSVP i18n
- PublicRsvpList: collapse onMount/onDestroy/$effect into a single $effect with proper cleanup; eliminates the redundant interval init paths and the dead else-branch - DetailView: re-push the snapshot to mana-events when a published event is opened, so any earlier fire-and-forget that lost a write silently self-heals - New _eventsTombstones queue (db version 8): when unpublish/delete fails to remove the server snapshot, queue (eventId, token) for retry; ListView drains the queue on mount with capped attempts - Public /rsvp/[token]: detect Accept-Language in +page.server.ts, pass lang to the page, and use a small inline DE/EN dict in strings.ts — no svelte-i18n on the public route
This commit is contained in:
parent
fbab96c74b
commit
836c9692c5
10 changed files with 283 additions and 59 deletions
|
|
@ -433,6 +433,15 @@ db.version(7).stores({
|
||||||
cycleSymptoms: 'id, name, category, count, updatedAt',
|
cycleSymptoms: 'id, name, category, count, updatedAt',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Version 8: Events tombstones (orphaned snapshot cleanup) ─
|
||||||
|
// Local-only retry queue. When the events store fails to DELETE a
|
||||||
|
// server snapshot during unpublish/delete, the (eventId, token) is
|
||||||
|
// pushed here so a later drain attempt can clean it up. NOT synced.
|
||||||
|
|
||||||
|
db.version(8).stores({
|
||||||
|
_eventsTombstones: 'id, token, attempts, createdAt',
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Sync App Map ──────────────────────────────────────────
|
// ─── Sync App Map ──────────────────────────────────────────
|
||||||
// Maps each table to its appId for sync routing.
|
// Maps each table to its appId for sync routing.
|
||||||
// The SyncEngine uses this to group pending changes and push to /sync/{appId}.
|
// The SyncEngine uses this to group pending changes and push to /sync/{appId}.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { useUpcomingEvents, usePastEvents, useGuestsByEvent, summarizeRsvps } from './queries';
|
import { useUpcomingEvents, usePastEvents, useGuestsByEvent, summarizeRsvps } from './queries';
|
||||||
import { eventsStore } from './stores/events.svelte';
|
import { eventsStore } from './stores/events.svelte';
|
||||||
|
import { drainTombstones } from './tombstones';
|
||||||
import EventCard from './components/EventCard.svelte';
|
import EventCard from './components/EventCard.svelte';
|
||||||
import type { SocialEvent } from './types';
|
import type { SocialEvent } from './types';
|
||||||
|
|
||||||
|
|
@ -14,6 +16,11 @@
|
||||||
const past = usePastEvents();
|
const past = usePastEvents();
|
||||||
const guestsByEvent = useGuestsByEvent();
|
const guestsByEvent = useGuestsByEvent();
|
||||||
|
|
||||||
|
// Retry any orphaned server snapshots from previous failed deletes.
|
||||||
|
onMount(() => {
|
||||||
|
void drainTombstones();
|
||||||
|
});
|
||||||
|
|
||||||
let showCreate = $state(false);
|
let showCreate = $state(false);
|
||||||
let newTitle = $state('');
|
let newTitle = $state('');
|
||||||
let newDate = $state('');
|
let newDate = $state('');
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
|
||||||
import { eventsApi, type PublicRsvpRecord } from '../api';
|
import { eventsApi, type PublicRsvpRecord } from '../api';
|
||||||
import { eventGuestsStore } from '../stores/guests.svelte';
|
import { eventGuestsStore } from '../stores/guests.svelte';
|
||||||
|
|
||||||
|
|
@ -14,7 +13,6 @@
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let lastError = $state<string | null>(null);
|
let lastError = $state<string | null>(null);
|
||||||
let lastFetchedAt = $state<Date | null>(null);
|
let lastFetchedAt = $state<Date | null>(null);
|
||||||
let pollHandle: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
async function fetchRsvps() {
|
async function fetchRsvps() {
|
||||||
if (!isPublished) return;
|
if (!isPublished) return;
|
||||||
|
|
@ -31,27 +29,16 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
// Single source of truth: poll only while published. Cleanup tears down
|
||||||
if (isPublished) {
|
// the interval automatically on unmount or when isPublished flips false.
|
||||||
void fetchRsvps();
|
|
||||||
pollHandle = setInterval(fetchRsvps, 30_000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
if (pollHandle) clearInterval(pollHandle);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Re-poll when isPublished flips
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (isPublished && !pollHandle) {
|
if (!isPublished) {
|
||||||
void fetchRsvps();
|
|
||||||
pollHandle = setInterval(fetchRsvps, 30_000);
|
|
||||||
} else if (!isPublished && pollHandle) {
|
|
||||||
clearInterval(pollHandle);
|
|
||||||
pollHandle = null;
|
|
||||||
rsvps = [];
|
rsvps = [];
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
void fetchRsvps();
|
||||||
|
const id = setInterval(fetchRsvps, 30_000);
|
||||||
|
return () => clearInterval(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function importToGuestList(r: PublicRsvpRecord) {
|
async function importToGuestList(r: PublicRsvpRecord) {
|
||||||
|
|
|
||||||
|
|
@ -3,3 +3,4 @@ export * from './collections';
|
||||||
export * from './queries';
|
export * from './queries';
|
||||||
export { eventsStore } from './stores/events.svelte';
|
export { eventsStore } from './stores/events.svelte';
|
||||||
export { eventGuestsStore } from './stores/guests.svelte';
|
export { eventGuestsStore } from './stores/guests.svelte';
|
||||||
|
export { drainTombstones, recordTombstone } from './tombstones';
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/ser
|
||||||
import { timeBlockTable } from '$lib/data/time-blocks/collections';
|
import { timeBlockTable } from '$lib/data/time-blocks/collections';
|
||||||
import type { LocalSocialEvent, EventStatus } from '../types';
|
import type { LocalSocialEvent, EventStatus } from '../types';
|
||||||
import { eventsApi } from '../api';
|
import { eventsApi } from '../api';
|
||||||
|
import { recordTombstone } from '../tombstones';
|
||||||
|
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -141,7 +142,11 @@ export const eventsStore = {
|
||||||
try {
|
try {
|
||||||
await eventsApi.unpublish(id);
|
await eventsApi.unpublish(id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to delete server snapshot during deleteEvent:', e);
|
console.warn(
|
||||||
|
'Failed to delete server snapshot during deleteEvent, queuing tombstone:',
|
||||||
|
e
|
||||||
|
);
|
||||||
|
if (event.publicToken) await recordTombstone(id, event.publicToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await db.table('socialEvents').update(id, {
|
await db.table('socialEvents').update(id, {
|
||||||
|
|
@ -197,12 +202,16 @@ export const eventsStore = {
|
||||||
async unpublishEvent(id: string) {
|
async unpublishEvent(id: string) {
|
||||||
error = null;
|
error = null;
|
||||||
try {
|
try {
|
||||||
// Best-effort delete on the server. If the network fails we still
|
// Capture the token before we wipe it locally so the tombstone
|
||||||
// flip the local flag — host clearly intended to unpublish.
|
// queue (if needed) has something to retry against.
|
||||||
|
const event = await db.table<LocalSocialEvent>('socialEvents').get(id);
|
||||||
|
const tokenForRetry = event?.publicToken ?? null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await eventsApi.unpublish(id);
|
await eventsApi.unpublish(id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to delete server snapshot during unpublish:', e);
|
console.warn('Failed to delete server snapshot during unpublish, queuing tombstone:', e);
|
||||||
|
if (tokenForRetry) await recordTombstone(id, tokenForRetry);
|
||||||
}
|
}
|
||||||
await db.table('socialEvents').update(id, {
|
await db.table('socialEvents').update(id, {
|
||||||
isPublished: false,
|
isPublished: false,
|
||||||
|
|
|
||||||
71
apps/mana/apps/web/src/lib/modules/events/tombstones.ts
Normal file
71
apps/mana/apps/web/src/lib/modules/events/tombstones.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
/**
|
||||||
|
* Tombstone queue for orphaned mana-events snapshots.
|
||||||
|
*
|
||||||
|
* When the client fails to DELETE a published snapshot from the server
|
||||||
|
* (network error, server down, etc.), the local event is still removed
|
||||||
|
* but the server keeps a stale copy. We push the (eventId, token) here
|
||||||
|
* so a later drain pass can retry the delete.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { db } from '$lib/data/database';
|
||||||
|
import { eventsApi } from './api';
|
||||||
|
|
||||||
|
interface Tombstone {
|
||||||
|
id: string;
|
||||||
|
token: string;
|
||||||
|
eventId: string;
|
||||||
|
attempts: number;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_ATTEMPTS = 10;
|
||||||
|
|
||||||
|
export async function recordTombstone(eventId: string, token: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await db.table<Tombstone>('_eventsTombstones').put({
|
||||||
|
id: token, // token is unique per snapshot
|
||||||
|
token,
|
||||||
|
eventId,
|
||||||
|
attempts: 0,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to record events tombstone:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let draining = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to delete every queued snapshot. Idempotent — safe to call repeatedly.
|
||||||
|
* Tombstones are removed on success or after MAX_ATTEMPTS gives up.
|
||||||
|
* Skips work if a drain is already in progress.
|
||||||
|
*/
|
||||||
|
export async function drainTombstones(): Promise<{ deleted: number; failed: number }> {
|
||||||
|
if (draining) return { deleted: 0, failed: 0 };
|
||||||
|
draining = true;
|
||||||
|
let deleted = 0;
|
||||||
|
let failed = 0;
|
||||||
|
try {
|
||||||
|
const all = await db.table<Tombstone>('_eventsTombstones').toArray();
|
||||||
|
for (const t of all) {
|
||||||
|
try {
|
||||||
|
await eventsApi.unpublish(t.eventId);
|
||||||
|
await db.table('_eventsTombstones').delete(t.id);
|
||||||
|
deleted++;
|
||||||
|
} catch {
|
||||||
|
const next = t.attempts + 1;
|
||||||
|
if (next >= MAX_ATTEMPTS) {
|
||||||
|
console.warn(`Giving up on events tombstone ${t.token} after ${MAX_ATTEMPTS} attempts`);
|
||||||
|
await db.table('_eventsTombstones').delete(t.id);
|
||||||
|
} else {
|
||||||
|
await db.table('_eventsTombstones').update(t.id, { attempts: next });
|
||||||
|
}
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
draining = false;
|
||||||
|
}
|
||||||
|
return { deleted, failed };
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,17 @@
|
||||||
const summary = $derived(summarizeRsvps(guests.value ?? []));
|
const summary = $derived(summarizeRsvps(guests.value ?? []));
|
||||||
const event = $derived(eventQuery.value);
|
const event = $derived(eventQuery.value);
|
||||||
|
|
||||||
|
// Self-heal: if a previous edit failed to push its snapshot to the
|
||||||
|
// server (fire-and-forget can lose writes), opening the detail view
|
||||||
|
// re-pushes the current state. Idempotent and cheap.
|
||||||
|
let lastHealedId: string | null = null;
|
||||||
|
$effect(() => {
|
||||||
|
if (event?.isPublished && event.id !== lastHealedId) {
|
||||||
|
lastHealedId = event.id;
|
||||||
|
void eventsStore.syncSnapshotIfPublished(event.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let editing = $state(false);
|
let editing = $state(false);
|
||||||
let titleDraft = $state('');
|
let titleDraft = $state('');
|
||||||
let descDraft = $state('');
|
let descDraft = $state('');
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,20 @@ const EVENTS_URL =
|
||||||
process.env.PUBLIC_MANA_EVENTS_URL ||
|
process.env.PUBLIC_MANA_EVENTS_URL ||
|
||||||
'http://localhost:3065';
|
'http://localhost:3065';
|
||||||
|
|
||||||
|
type Lang = 'de' | 'en';
|
||||||
|
|
||||||
|
/** Pick the best supported language from an Accept-Language header. */
|
||||||
|
function pickLang(header: string | null): Lang {
|
||||||
|
if (!header) return 'de';
|
||||||
|
// Header looks like "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7"
|
||||||
|
const parts = header.split(',').map((p) => p.trim().split(';')[0].toLowerCase().slice(0, 2));
|
||||||
|
for (const p of parts) {
|
||||||
|
if (p === 'de') return 'de';
|
||||||
|
if (p === 'en') return 'en';
|
||||||
|
}
|
||||||
|
return 'de';
|
||||||
|
}
|
||||||
|
|
||||||
interface EventSnapshot {
|
interface EventSnapshot {
|
||||||
token: string;
|
token: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -33,22 +47,26 @@ interface RsvpSummary {
|
||||||
totalAttending: number;
|
totalAttending: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, fetch }) => {
|
export const load: PageServerLoad = async ({ params, fetch, request }) => {
|
||||||
const token = params.token;
|
const token = params.token;
|
||||||
if (!token) throw error(404, 'Not found');
|
if (!token) throw error(404, 'Not found');
|
||||||
|
|
||||||
|
const lang = pickLang(request.headers.get('accept-language'));
|
||||||
|
const notFoundMsg = lang === 'de' ? 'Event nicht gefunden' : 'Event not found';
|
||||||
|
const errorMsg = lang === 'de' ? 'Konnte Event nicht laden' : 'Could not load event';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${EVENTS_URL}/api/v1/rsvp/${encodeURIComponent(token)}`);
|
const res = await fetch(`${EVENTS_URL}/api/v1/rsvp/${encodeURIComponent(token)}`);
|
||||||
if (res.status === 404) throw error(404, 'Event nicht gefunden');
|
if (res.status === 404) throw error(404, notFoundMsg);
|
||||||
if (!res.ok) throw error(500, 'Konnte Event nicht laden');
|
if (!res.ok) throw error(500, errorMsg);
|
||||||
const data = (await res.json()) as {
|
const data = (await res.json()) as {
|
||||||
event: EventSnapshot;
|
event: EventSnapshot;
|
||||||
summary: RsvpSummary | null;
|
summary: RsvpSummary | null;
|
||||||
cancelled?: boolean;
|
cancelled?: boolean;
|
||||||
};
|
};
|
||||||
return { token, ...data, eventsUrl: EVENTS_URL };
|
return { token, ...data, eventsUrl: EVENTS_URL, lang };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e && typeof e === 'object' && 'status' in e) throw e;
|
if (e && typeof e === 'object' && 'status' in e) throw e;
|
||||||
throw error(500, 'Konnte Event nicht laden');
|
throw error(500, errorMsg);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import { getManaEventsUrl } from '$lib/api/config';
|
import { getManaEventsUrl } from '$lib/api/config';
|
||||||
|
import { getStrings } from './strings';
|
||||||
|
|
||||||
let { data }: { data: PageData } = $props();
|
let { data }: { data: PageData } = $props();
|
||||||
|
const t = $derived(getStrings(data.lang));
|
||||||
|
|
||||||
let name = $state('');
|
let name = $state('');
|
||||||
let email = $state('');
|
let email = $state('');
|
||||||
|
|
@ -15,7 +17,7 @@
|
||||||
|
|
||||||
const startDate = $derived(new Date(data.event.startAt));
|
const startDate = $derived(new Date(data.event.startAt));
|
||||||
const dateLabel = $derived(
|
const dateLabel = $derived(
|
||||||
startDate.toLocaleDateString('de-DE', {
|
startDate.toLocaleDateString(t.dateLocale, {
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
|
|
@ -24,8 +26,8 @@
|
||||||
);
|
);
|
||||||
const timeLabel = $derived(
|
const timeLabel = $derived(
|
||||||
data.event.allDay
|
data.event.allDay
|
||||||
? 'Ganztägig'
|
? t.allDay
|
||||||
: startDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
: startDate.toLocaleTimeString(t.dateLocale, { hour: '2-digit', minute: '2-digit' })
|
||||||
);
|
);
|
||||||
|
|
||||||
async function handleSubmit(e: SubmitEvent) {
|
async function handleSubmit(e: SubmitEvent) {
|
||||||
|
|
@ -51,7 +53,7 @@
|
||||||
}
|
}
|
||||||
submitted = true;
|
submitted = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorMessage = e instanceof Error ? e.message : 'Konnte nicht senden';
|
errorMessage = e instanceof Error ? e.message : t.genericError;
|
||||||
} finally {
|
} finally {
|
||||||
submitting = false;
|
submitting = false;
|
||||||
}
|
}
|
||||||
|
|
@ -59,7 +61,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{data.event.title} — RSVP</title>
|
<title>{data.event.title} {t.pageTitleSuffix}</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="rsvp-page">
|
<div class="rsvp-page">
|
||||||
|
|
@ -92,7 +94,8 @@
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
<span class="icon">👥</span>
|
<span class="icon">👥</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>{data.summary.totalAttending}</strong> Personen kommen
|
<strong>{data.summary.totalAttending}</strong>
|
||||||
|
{t.peopleAttending}
|
||||||
{#if data.event.capacity}
|
{#if data.event.capacity}
|
||||||
<span class="muted">/ {data.event.capacity}</span>
|
<span class="muted">/ {data.event.capacity}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -106,16 +109,20 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if data.cancelled}
|
{#if data.cancelled}
|
||||||
<div class="cancelled">⚠️ Dieses Event wurde abgesagt.</div>
|
<div class="cancelled">{t.cancelledNotice}</div>
|
||||||
{:else if submitted}
|
{:else if submitted}
|
||||||
<div class="success">
|
<div class="success">
|
||||||
<h2>Danke für deine Antwort!</h2>
|
<h2>{t.successHeading}</h2>
|
||||||
<p>
|
<p>
|
||||||
Du hast mit
|
{t.successYou}
|
||||||
<strong>
|
<strong>
|
||||||
{status === 'yes' ? '„Ja, komme“' : status === 'no' ? '„Nein“' : '„Vielleicht“'}
|
{status === 'yes'
|
||||||
|
? t.successComing
|
||||||
|
: status === 'no'
|
||||||
|
? t.successNotComing
|
||||||
|
: t.successMaybe}
|
||||||
</strong>
|
</strong>
|
||||||
geantwortet. Du kannst diese Seite jederzeit erneut öffnen, um deine Antwort zu ändern.
|
{t.successHint}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
class="action-btn"
|
class="action-btn"
|
||||||
|
|
@ -123,31 +130,31 @@
|
||||||
submitted = false;
|
submitted = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Antwort ändern
|
{t.changeAnswer}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<form class="rsvp-form" onsubmit={handleSubmit}>
|
<form class="rsvp-form" onsubmit={handleSubmit}>
|
||||||
<h2>Sag bitte zu</h2>
|
<h2>{t.formHeading}</h2>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="label">Dein Name</span>
|
<span class="label">{t.yourName}</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={name}
|
bind:value={name}
|
||||||
placeholder="z. B. Anna Schmidt"
|
placeholder={t.yourNamePlaceholder}
|
||||||
required
|
required
|
||||||
maxlength="100"
|
maxlength="100"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="label">E-Mail (optional)</span>
|
<span class="label">{t.emailLabel}</span>
|
||||||
<input type="email" bind:value={email} placeholder="anna@example.com" maxlength="200" />
|
<input type="email" bind:value={email} placeholder={t.emailPlaceholder} maxlength="200" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="label">Kommst du?</span>
|
<span class="label">{t.areYouComing}</span>
|
||||||
<div class="status-pills">
|
<div class="status-pills">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -155,7 +162,7 @@
|
||||||
class:active={status === 'yes'}
|
class:active={status === 'yes'}
|
||||||
onclick={() => (status = 'yes')}
|
onclick={() => (status = 'yes')}
|
||||||
>
|
>
|
||||||
✓ Ja, komme
|
{t.yesComing}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -163,7 +170,7 @@
|
||||||
class:active={status === 'maybe'}
|
class:active={status === 'maybe'}
|
||||||
onclick={() => (status = 'maybe')}
|
onclick={() => (status = 'maybe')}
|
||||||
>
|
>
|
||||||
? Vielleicht
|
{t.maybe}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -171,25 +178,21 @@
|
||||||
class:active={status === 'no'}
|
class:active={status === 'no'}
|
||||||
onclick={() => (status = 'no')}
|
onclick={() => (status = 'no')}
|
||||||
>
|
>
|
||||||
✕ Nein
|
{t.no}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if status === 'yes'}
|
{#if status === 'yes'}
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="label">Bringst du jemanden mit? ({plusOnes})</span>
|
<span class="label">{t.bringingPeople(plusOnes)}</span>
|
||||||
<input type="range" min="0" max="10" bind:value={plusOnes} />
|
<input type="range" min="0" max="10" bind:value={plusOnes} />
|
||||||
</label>
|
</label>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span class="label">Notiz (optional)</span>
|
<span class="label">{t.noteLabel}</span>
|
||||||
<textarea
|
<textarea bind:value={note} placeholder={t.notePlaceholder} rows="2" maxlength="1000"
|
||||||
bind:value={note}
|
|
||||||
placeholder="z. B. „Komme erst um 20 Uhr“"
|
|
||||||
rows="2"
|
|
||||||
maxlength="1000"
|
|
||||||
></textarea>
|
></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|
@ -198,14 +201,14 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button type="submit" class="submit-btn" disabled={submitting || !name.trim()}>
|
<button type="submit" class="submit-btn" disabled={submitting || !name.trim()}>
|
||||||
{submitting ? 'Sende...' : 'Antwort senden'}
|
{submitting ? t.sending : t.send}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
Powered by <a href="https://mana.how" target="_blank" rel="noopener">Mana</a>
|
{t.poweredBy} <a href="https://mana.how" target="_blank" rel="noopener">Mana</a>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
108
apps/mana/apps/web/src/routes/rsvp/[token]/strings.ts
Normal file
108
apps/mana/apps/web/src/routes/rsvp/[token]/strings.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
/**
|
||||||
|
* Inline i18n dictionary for the public RSVP page.
|
||||||
|
* Kept tiny and local — this page is the only public consumer.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Lang = 'de' | 'en';
|
||||||
|
|
||||||
|
interface Strings {
|
||||||
|
rsvpTitle: string;
|
||||||
|
pageTitleSuffix: string;
|
||||||
|
allDay: string;
|
||||||
|
peopleAttending: string;
|
||||||
|
cancelledNotice: string;
|
||||||
|
successHeading: string;
|
||||||
|
successYou: string;
|
||||||
|
successComing: string;
|
||||||
|
successNotComing: string;
|
||||||
|
successMaybe: string;
|
||||||
|
successHint: string;
|
||||||
|
changeAnswer: string;
|
||||||
|
formHeading: string;
|
||||||
|
yourName: string;
|
||||||
|
yourNamePlaceholder: string;
|
||||||
|
emailLabel: string;
|
||||||
|
emailPlaceholder: string;
|
||||||
|
areYouComing: string;
|
||||||
|
yesComing: string;
|
||||||
|
maybe: string;
|
||||||
|
no: string;
|
||||||
|
bringingPeople: (count: number) => string;
|
||||||
|
noteLabel: string;
|
||||||
|
notePlaceholder: string;
|
||||||
|
send: string;
|
||||||
|
sending: string;
|
||||||
|
genericError: string;
|
||||||
|
poweredBy: string;
|
||||||
|
dateLocale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DICTS: Record<Lang, Strings> = {
|
||||||
|
de: {
|
||||||
|
rsvpTitle: 'Sag bitte zu',
|
||||||
|
pageTitleSuffix: '— RSVP',
|
||||||
|
allDay: 'Ganztägig',
|
||||||
|
peopleAttending: 'Personen kommen',
|
||||||
|
cancelledNotice: '⚠️ Dieses Event wurde abgesagt.',
|
||||||
|
successHeading: 'Danke für deine Antwort!',
|
||||||
|
successYou: 'Du hast mit',
|
||||||
|
successComing: '„Ja, komme“',
|
||||||
|
successNotComing: '„Nein“',
|
||||||
|
successMaybe: '„Vielleicht“',
|
||||||
|
successHint:
|
||||||
|
'geantwortet. Du kannst diese Seite jederzeit erneut öffnen, um deine Antwort zu ändern.',
|
||||||
|
changeAnswer: 'Antwort ändern',
|
||||||
|
formHeading: 'Sag bitte zu',
|
||||||
|
yourName: 'Dein Name',
|
||||||
|
yourNamePlaceholder: 'z. B. Anna Schmidt',
|
||||||
|
emailLabel: 'E-Mail (optional)',
|
||||||
|
emailPlaceholder: 'anna@example.com',
|
||||||
|
areYouComing: 'Kommst du?',
|
||||||
|
yesComing: '✓ Ja, komme',
|
||||||
|
maybe: '? Vielleicht',
|
||||||
|
no: '✕ Nein',
|
||||||
|
bringingPeople: (count) => `Bringst du jemanden mit? (${count})`,
|
||||||
|
noteLabel: 'Notiz (optional)',
|
||||||
|
notePlaceholder: 'z. B. „Komme erst um 20 Uhr“',
|
||||||
|
send: 'Antwort senden',
|
||||||
|
sending: 'Sende...',
|
||||||
|
genericError: 'Konnte nicht senden',
|
||||||
|
poweredBy: 'Powered by',
|
||||||
|
dateLocale: 'de-DE',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
rsvpTitle: 'Please RSVP',
|
||||||
|
pageTitleSuffix: '— RSVP',
|
||||||
|
allDay: 'All day',
|
||||||
|
peopleAttending: 'people attending',
|
||||||
|
cancelledNotice: '⚠️ This event has been cancelled.',
|
||||||
|
successHeading: 'Thanks for your reply!',
|
||||||
|
successYou: 'You answered',
|
||||||
|
successComing: '"Yes, coming"',
|
||||||
|
successNotComing: '"No"',
|
||||||
|
successMaybe: '"Maybe"',
|
||||||
|
successHint: '. You can reopen this page anytime to change your answer.',
|
||||||
|
changeAnswer: 'Change answer',
|
||||||
|
formHeading: 'Please RSVP',
|
||||||
|
yourName: 'Your name',
|
||||||
|
yourNamePlaceholder: 'e.g. Anna Smith',
|
||||||
|
emailLabel: 'Email (optional)',
|
||||||
|
emailPlaceholder: 'anna@example.com',
|
||||||
|
areYouComing: 'Are you coming?',
|
||||||
|
yesComing: '✓ Yes, coming',
|
||||||
|
maybe: '? Maybe',
|
||||||
|
no: '✕ No',
|
||||||
|
bringingPeople: (count) => `Bringing anyone? (${count})`,
|
||||||
|
noteLabel: 'Note (optional)',
|
||||||
|
notePlaceholder: 'e.g. "Will arrive at 8pm"',
|
||||||
|
send: 'Send reply',
|
||||||
|
sending: 'Sending...',
|
||||||
|
genericError: 'Could not send',
|
||||||
|
poweredBy: 'Powered by',
|
||||||
|
dateLocale: 'en-US',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getStrings(lang: Lang): Strings {
|
||||||
|
return DICTS[lang] ?? DICTS.de;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue