i18n(events): RSVP page in it/fr/es + extract e2e helper

- strings.ts: add Italian, French, Spanish dictionaries (≈25 keys
  each) and widen Lang to the full DE/EN/IT/FR/ES set.
- +page.server.ts: pickLang now matches any of the five supported
  locales from Accept-Language; SSR error messages localised the
  same way.
- e2e/helpers.ts: extract the dismissWelcomeModal helper out of
  events.spec.ts so future module specs can reuse it without
  duplicating the locale-agnostic dialog locator.
This commit is contained in:
Till JS 2026-04-07 19:11:59 +02:00
parent 897256c985
commit 3eabbc5e53
4 changed files with 143 additions and 24 deletions

View file

@ -12,24 +12,8 @@
* events-public-rsvp.spec.ts so we don't need a real auth dance here.
*/
import { test, expect, type Page } from '@playwright/test';
/**
* The unified Mana app shows a guest-welcome modal on first load that
* intercepts every click. Always dismiss it before doing anything else.
*/
async function dismissWelcomeModal(page: Page) {
const dialog = page.locator('[role="dialog"][aria-labelledby="welcome-title"]');
// Wait up to 10s for the modal to appear (it's mounted after AuthGate finishes)
try {
await dialog.waitFor({ state: 'visible', timeout: 10_000 });
} catch {
// No modal — already dismissed in a previous test or guest-mode disabled
return;
}
await dialog.getByRole('button', { name: /Weiter als Gast|Continue as Guest/i }).click();
await dialog.waitFor({ state: 'hidden' });
}
import { test, expect } from '@playwright/test';
import { dismissWelcomeModal } from './helpers';
// Each test gets its own browser context so IndexedDB starts empty.
test.describe('Events module — local flow', () => {

View file

@ -0,0 +1,25 @@
/**
* Shared Playwright helpers used across the unified Mana app's E2E suites.
*/
import type { Page } from '@playwright/test';
/**
* The unified Mana app shows a guest-welcome modal on first load that
* intercepts every click. Always dismiss it before doing anything else
* inside an `(app)` route. Locale-agnostic matches the German and
* English "continue as guest" buttons.
*/
export async function dismissWelcomeModal(page: Page): Promise<void> {
const dialog = page.locator('[role="dialog"][aria-labelledby="welcome-title"]');
try {
// The modal is mounted after AuthGate finishes its check, so we
// give it up to 10s to appear before deciding it isn't there.
await dialog.waitFor({ state: 'visible', timeout: 10_000 });
} catch {
// Already dismissed in a previous test or guest mode disabled
return;
}
await dialog.getByRole('button', { name: /Weiter als Gast|Continue as Guest/i }).click();
await dialog.waitFor({ state: 'hidden' });
}

View file

@ -12,7 +12,9 @@ const EVENTS_URL =
process.env.PUBLIC_MANA_EVENTS_URL ||
'http://localhost:3065';
type Lang = 'de' | 'en';
type Lang = 'de' | 'en' | 'it' | 'fr' | 'es';
const SUPPORTED: ReadonlySet<Lang> = new Set(['de', 'en', 'it', 'fr', 'es']);
/** Pick the best supported language from an Accept-Language header. */
function pickLang(header: string | null): Lang {
@ -20,8 +22,7 @@ function pickLang(header: string | null): Lang {
// 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';
if (SUPPORTED.has(p as Lang)) return p as Lang;
}
return 'de';
}
@ -52,8 +53,22 @@ export const load: PageServerLoad = async ({ params, fetch, request }) => {
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';
const NOT_FOUND_MESSAGES: Record<Lang, string> = {
de: 'Event nicht gefunden',
en: 'Event not found',
it: 'Evento non trovato',
fr: 'Événement introuvable',
es: 'Evento no encontrado',
};
const ERROR_MESSAGES: Record<Lang, string> = {
de: 'Konnte Event nicht laden',
en: 'Could not load event',
it: 'Impossibile caricare levento',
fr: 'Impossible de charger lévénement',
es: 'No se pudo cargar el evento',
};
const notFoundMsg = NOT_FOUND_MESSAGES[lang];
const errorMsg = ERROR_MESSAGES[lang];
try {
const res = await fetch(`${EVENTS_URL}/api/v1/rsvp/${encodeURIComponent(token)}`);

View file

@ -3,7 +3,7 @@
* Kept tiny and local this page is the only public consumer.
*/
export type Lang = 'de' | 'en';
export type Lang = 'de' | 'en' | 'it' | 'fr' | 'es';
interface Strings {
rsvpTitle: string;
@ -101,6 +101,101 @@ const DICTS: Record<Lang, Strings> = {
poweredBy: 'Powered by',
dateLocale: 'en-US',
},
it: {
rsvpTitle: 'Conferma la tua presenza',
pageTitleSuffix: '— RSVP',
allDay: 'Tutto il giorno',
peopleAttending: 'persone parteciperanno',
cancelledNotice: '⚠️ Questo evento è stato annullato.',
successHeading: 'Grazie per la tua risposta!',
successYou: 'Hai risposto',
successComing: '«Sì, vengo»',
successNotComing: '«No»',
successMaybe: '«Forse»',
successHint:
'. Puoi riaprire questa pagina in qualsiasi momento per modificare la tua risposta.',
changeAnswer: 'Modifica risposta',
formHeading: 'Conferma la tua presenza',
yourName: 'Il tuo nome',
yourNamePlaceholder: 'es. Anna Bianchi',
emailLabel: 'Email (facoltativa)',
emailPlaceholder: 'anna@example.com',
areYouComing: 'Vieni?',
yesComing: '✓ Sì, vengo',
maybe: '? Forse',
no: '✕ No',
bringingPeople: (count) => `Porti qualcuno con te? (${count})`,
noteLabel: 'Nota (facoltativa)',
notePlaceholder: 'es. «Arrivo solo verso le 20»',
send: 'Invia risposta',
sending: 'Invio...',
genericError: 'Impossibile inviare',
poweredBy: 'Powered by',
dateLocale: 'it-IT',
},
fr: {
rsvpTitle: 'Confirmez votre présence',
pageTitleSuffix: '— RSVP',
allDay: 'Toute la journée',
peopleAttending: 'personnes participeront',
cancelledNotice: '⚠️ Cet événement a été annulé.',
successHeading: 'Merci pour ta réponse !',
successYou: 'Tu as répondu',
successComing: '« Oui, je viens »',
successNotComing: '« Non »',
successMaybe: '« Peut-être »',
successHint: '. Tu peux rouvrir cette page à tout moment pour modifier ta réponse.',
changeAnswer: 'Modifier la réponse',
formHeading: 'Confirmez votre présence',
yourName: 'Ton nom',
yourNamePlaceholder: 'p. ex. Anna Martin',
emailLabel: 'E-mail (facultatif)',
emailPlaceholder: 'anna@example.com',
areYouComing: 'Tu viens ?',
yesComing: '✓ Oui, je viens',
maybe: '? Peut-être',
no: '✕ Non',
bringingPeople: (count) => `Tu amènes quelquun ? (${count})`,
noteLabel: 'Note (facultative)',
notePlaceholder: 'p. ex. « Jarrive vers 20h »',
send: 'Envoyer la réponse',
sending: 'Envoi...',
genericError: 'Impossible denvoyer',
poweredBy: 'Propulsé par',
dateLocale: 'fr-FR',
},
es: {
rsvpTitle: 'Confirma tu asistencia',
pageTitleSuffix: '— RSVP',
allDay: 'Todo el día',
peopleAttending: 'personas asistirán',
cancelledNotice: '⚠️ Este evento ha sido cancelado.',
successHeading: '¡Gracias por tu respuesta!',
successYou: 'Has respondido',
successComing: '«Sí, voy»',
successNotComing: '«No»',
successMaybe: '«Quizás»',
successHint:
'. Puedes volver a abrir esta página en cualquier momento para cambiar tu respuesta.',
changeAnswer: 'Cambiar respuesta',
formHeading: 'Confirma tu asistencia',
yourName: 'Tu nombre',
yourNamePlaceholder: 'p. ej. Ana García',
emailLabel: 'Correo (opcional)',
emailPlaceholder: 'ana@example.com',
areYouComing: '¿Vienes?',
yesComing: '✓ Sí, voy',
maybe: '? Quizás',
no: '✕ No',
bringingPeople: (count) => `¿Traes a alguien contigo? (${count})`,
noteLabel: 'Nota (opcional)',
notePlaceholder: 'p. ej. «Llego sobre las 20h»',
send: 'Enviar respuesta',
sending: 'Enviando...',
genericError: 'No se pudo enviar',
poweredBy: 'Powered by',
dateLocale: 'es-ES',
},
};
export function getStrings(lang: Lang): Strings {