From 3eabbc5e530f5d8c795a1af1157d3a6d6c7042a8 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 7 Apr 2026 19:11:59 +0200 Subject: [PATCH] i18n(events): RSVP page in it/fr/es + extract e2e helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- apps/mana/apps/web/e2e/events.spec.ts | 20 +--- apps/mana/apps/web/e2e/helpers.ts | 25 +++++ .../src/routes/rsvp/[token]/+page.server.ts | 25 ++++- .../web/src/routes/rsvp/[token]/strings.ts | 97 ++++++++++++++++++- 4 files changed, 143 insertions(+), 24 deletions(-) create mode 100644 apps/mana/apps/web/e2e/helpers.ts diff --git a/apps/mana/apps/web/e2e/events.spec.ts b/apps/mana/apps/web/e2e/events.spec.ts index ec4feff3e..051c9ce88 100644 --- a/apps/mana/apps/web/e2e/events.spec.ts +++ b/apps/mana/apps/web/e2e/events.spec.ts @@ -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', () => { diff --git a/apps/mana/apps/web/e2e/helpers.ts b/apps/mana/apps/web/e2e/helpers.ts new file mode 100644 index 000000000..0b21df90d --- /dev/null +++ b/apps/mana/apps/web/e2e/helpers.ts @@ -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 { + 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' }); +} diff --git a/apps/mana/apps/web/src/routes/rsvp/[token]/+page.server.ts b/apps/mana/apps/web/src/routes/rsvp/[token]/+page.server.ts index 1cf0474f3..16e6c52b5 100644 --- a/apps/mana/apps/web/src/routes/rsvp/[token]/+page.server.ts +++ b/apps/mana/apps/web/src/routes/rsvp/[token]/+page.server.ts @@ -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 = 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 = { + de: 'Event nicht gefunden', + en: 'Event not found', + it: 'Evento non trovato', + fr: 'Événement introuvable', + es: 'Evento no encontrado', + }; + const ERROR_MESSAGES: Record = { + de: 'Konnte Event nicht laden', + en: 'Could not load event', + it: 'Impossibile caricare l’evento', + 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)}`); diff --git a/apps/mana/apps/web/src/routes/rsvp/[token]/strings.ts b/apps/mana/apps/web/src/routes/rsvp/[token]/strings.ts index b4783d0c3..4b7bf6d13 100644 --- a/apps/mana/apps/web/src/routes/rsvp/[token]/strings.ts +++ b/apps/mana/apps/web/src/routes/rsvp/[token]/strings.ts @@ -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 = { 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 quelqu’un ? (${count})`, + noteLabel: 'Note (facultative)', + notePlaceholder: 'p. ex. « J’arrive vers 20h »', + send: 'Envoyer la réponse', + sending: 'Envoi...', + genericError: 'Impossible d’envoyer', + 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 {