mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +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',
|
||||
});
|
||||
|
||||
// ─── 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 ──────────────────────────────────────────
|
||||
// Maps each table to its appId for sync routing.
|
||||
// The SyncEngine uses this to group pending changes and push to /sync/{appId}.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { useUpcomingEvents, usePastEvents, useGuestsByEvent, summarizeRsvps } from './queries';
|
||||
import { eventsStore } from './stores/events.svelte';
|
||||
import { drainTombstones } from './tombstones';
|
||||
import EventCard from './components/EventCard.svelte';
|
||||
import type { SocialEvent } from './types';
|
||||
|
||||
|
|
@ -14,6 +16,11 @@
|
|||
const past = usePastEvents();
|
||||
const guestsByEvent = useGuestsByEvent();
|
||||
|
||||
// Retry any orphaned server snapshots from previous failed deletes.
|
||||
onMount(() => {
|
||||
void drainTombstones();
|
||||
});
|
||||
|
||||
let showCreate = $state(false);
|
||||
let newTitle = $state('');
|
||||
let newDate = $state('');
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { eventsApi, type PublicRsvpRecord } from '../api';
|
||||
import { eventGuestsStore } from '../stores/guests.svelte';
|
||||
|
||||
|
|
@ -14,7 +13,6 @@
|
|||
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;
|
||||
|
|
@ -31,27 +29,16 @@
|
|||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (isPublished) {
|
||||
void fetchRsvps();
|
||||
pollHandle = setInterval(fetchRsvps, 30_000);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (pollHandle) clearInterval(pollHandle);
|
||||
});
|
||||
|
||||
// Re-poll when isPublished flips
|
||||
// Single source of truth: poll only while published. Cleanup tears down
|
||||
// the interval automatically on unmount or when isPublished flips false.
|
||||
$effect(() => {
|
||||
if (isPublished && !pollHandle) {
|
||||
void fetchRsvps();
|
||||
pollHandle = setInterval(fetchRsvps, 30_000);
|
||||
} else if (!isPublished && pollHandle) {
|
||||
clearInterval(pollHandle);
|
||||
pollHandle = null;
|
||||
if (!isPublished) {
|
||||
rsvps = [];
|
||||
return;
|
||||
}
|
||||
void fetchRsvps();
|
||||
const id = setInterval(fetchRsvps, 30_000);
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
async function importToGuestList(r: PublicRsvpRecord) {
|
||||
|
|
|
|||
|
|
@ -3,3 +3,4 @@ export * from './collections';
|
|||
export * from './queries';
|
||||
export { eventsStore } from './stores/events.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 type { LocalSocialEvent, EventStatus } from '../types';
|
||||
import { eventsApi } from '../api';
|
||||
import { recordTombstone } from '../tombstones';
|
||||
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
|
|
@ -141,7 +142,11 @@ export const eventsStore = {
|
|||
try {
|
||||
await eventsApi.unpublish(id);
|
||||
} 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, {
|
||||
|
|
@ -197,12 +202,16 @@ 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.
|
||||
// Capture the token before we wipe it locally so the tombstone
|
||||
// queue (if needed) has something to retry against.
|
||||
const event = await db.table<LocalSocialEvent>('socialEvents').get(id);
|
||||
const tokenForRetry = event?.publicToken ?? null;
|
||||
|
||||
try {
|
||||
await eventsApi.unpublish(id);
|
||||
} 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, {
|
||||
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 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 titleDraft = $state('');
|
||||
let descDraft = $state('');
|
||||
|
|
|
|||
|
|
@ -12,6 +12,20 @@ const EVENTS_URL =
|
|||
process.env.PUBLIC_MANA_EVENTS_URL ||
|
||||
'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 {
|
||||
token: string;
|
||||
title: string;
|
||||
|
|
@ -33,22 +47,26 @@ interface RsvpSummary {
|
|||
totalAttending: number;
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ params, fetch }) => {
|
||||
export const load: PageServerLoad = async ({ params, fetch, request }) => {
|
||||
const token = params.token;
|
||||
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 {
|
||||
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');
|
||||
if (res.status === 404) throw error(404, notFoundMsg);
|
||||
if (!res.ok) throw error(500, errorMsg);
|
||||
const data = (await res.json()) as {
|
||||
event: EventSnapshot;
|
||||
summary: RsvpSummary | null;
|
||||
cancelled?: boolean;
|
||||
};
|
||||
return { token, ...data, eventsUrl: EVENTS_URL };
|
||||
return { token, ...data, eventsUrl: EVENTS_URL, lang };
|
||||
} catch (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">
|
||||
import type { PageData } from './$types';
|
||||
import { getManaEventsUrl } from '$lib/api/config';
|
||||
import { getStrings } from './strings';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
const t = $derived(getStrings(data.lang));
|
||||
|
||||
let name = $state('');
|
||||
let email = $state('');
|
||||
|
|
@ -15,7 +17,7 @@
|
|||
|
||||
const startDate = $derived(new Date(data.event.startAt));
|
||||
const dateLabel = $derived(
|
||||
startDate.toLocaleDateString('de-DE', {
|
||||
startDate.toLocaleDateString(t.dateLocale, {
|
||||
weekday: 'long',
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
|
|
@ -24,8 +26,8 @@
|
|||
);
|
||||
const timeLabel = $derived(
|
||||
data.event.allDay
|
||||
? 'Ganztägig'
|
||||
: startDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||
? t.allDay
|
||||
: startDate.toLocaleTimeString(t.dateLocale, { hour: '2-digit', minute: '2-digit' })
|
||||
);
|
||||
|
||||
async function handleSubmit(e: SubmitEvent) {
|
||||
|
|
@ -51,7 +53,7 @@
|
|||
}
|
||||
submitted = true;
|
||||
} catch (e) {
|
||||
errorMessage = e instanceof Error ? e.message : 'Konnte nicht senden';
|
||||
errorMessage = e instanceof Error ? e.message : t.genericError;
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
|
|
@ -59,7 +61,7 @@
|
|||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.event.title} — RSVP</title>
|
||||
<title>{data.event.title} {t.pageTitleSuffix}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="rsvp-page">
|
||||
|
|
@ -92,7 +94,8 @@
|
|||
<div class="meta-row">
|
||||
<span class="icon">👥</span>
|
||||
<div>
|
||||
<strong>{data.summary.totalAttending}</strong> Personen kommen
|
||||
<strong>{data.summary.totalAttending}</strong>
|
||||
{t.peopleAttending}
|
||||
{#if data.event.capacity}
|
||||
<span class="muted">/ {data.event.capacity}</span>
|
||||
{/if}
|
||||
|
|
@ -106,16 +109,20 @@
|
|||
{/if}
|
||||
|
||||
{#if data.cancelled}
|
||||
<div class="cancelled">⚠️ Dieses Event wurde abgesagt.</div>
|
||||
<div class="cancelled">{t.cancelledNotice}</div>
|
||||
{:else if submitted}
|
||||
<div class="success">
|
||||
<h2>Danke für deine Antwort!</h2>
|
||||
<h2>{t.successHeading}</h2>
|
||||
<p>
|
||||
Du hast mit
|
||||
{t.successYou}
|
||||
<strong>
|
||||
{status === 'yes' ? '„Ja, komme“' : status === 'no' ? '„Nein“' : '„Vielleicht“'}
|
||||
{status === 'yes'
|
||||
? t.successComing
|
||||
: status === 'no'
|
||||
? t.successNotComing
|
||||
: t.successMaybe}
|
||||
</strong>
|
||||
geantwortet. Du kannst diese Seite jederzeit erneut öffnen, um deine Antwort zu ändern.
|
||||
{t.successHint}
|
||||
</p>
|
||||
<button
|
||||
class="action-btn"
|
||||
|
|
@ -123,31 +130,31 @@
|
|||
submitted = false;
|
||||
}}
|
||||
>
|
||||
Antwort ändern
|
||||
{t.changeAnswer}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<form class="rsvp-form" onsubmit={handleSubmit}>
|
||||
<h2>Sag bitte zu</h2>
|
||||
<h2>{t.formHeading}</h2>
|
||||
|
||||
<label class="field">
|
||||
<span class="label">Dein Name</span>
|
||||
<span class="label">{t.yourName}</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={name}
|
||||
placeholder="z. B. Anna Schmidt"
|
||||
placeholder={t.yourNamePlaceholder}
|
||||
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" />
|
||||
<span class="label">{t.emailLabel}</span>
|
||||
<input type="email" bind:value={email} placeholder={t.emailPlaceholder} maxlength="200" />
|
||||
</label>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Kommst du?</span>
|
||||
<span class="label">{t.areYouComing}</span>
|
||||
<div class="status-pills">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -155,7 +162,7 @@
|
|||
class:active={status === 'yes'}
|
||||
onclick={() => (status = 'yes')}
|
||||
>
|
||||
✓ Ja, komme
|
||||
{t.yesComing}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -163,7 +170,7 @@
|
|||
class:active={status === 'maybe'}
|
||||
onclick={() => (status = 'maybe')}
|
||||
>
|
||||
? Vielleicht
|
||||
{t.maybe}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -171,25 +178,21 @@
|
|||
class:active={status === 'no'}
|
||||
onclick={() => (status = 'no')}
|
||||
>
|
||||
✕ Nein
|
||||
{t.no}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if status === 'yes'}
|
||||
<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} />
|
||||
</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"
|
||||
<span class="label">{t.noteLabel}</span>
|
||||
<textarea bind:value={note} placeholder={t.notePlaceholder} rows="2" maxlength="1000"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
|
|
@ -198,14 +201,14 @@
|
|||
{/if}
|
||||
|
||||
<button type="submit" class="submit-btn" disabled={submitting || !name.trim()}>
|
||||
{submitting ? 'Sende...' : 'Antwort senden'}
|
||||
{submitting ? t.sending : t.send}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</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