From 72ae7419b52a6bcec9b4e92d6714ef9572a5ed02 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 23 Jul 2026 22:56:37 +0200 Subject: [PATCH 1/2] ECHO-748 Accept URL parameters for tags, title, and portal language --- .../participant/ParticipantInitiateForm.tsx | 10 +++- .../ParticipantOnboardingCards.tsx | 4 +- echo/frontend/src/hooks/useLanguage.ts | 50 +++++++++++++++++-- 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/echo/frontend/src/components/participant/ParticipantInitiateForm.tsx b/echo/frontend/src/components/participant/ParticipantInitiateForm.tsx index ba754e964..9409a1b94 100644 --- a/echo/frontend/src/components/participant/ParticipantInitiateForm.tsx +++ b/echo/frontend/src/components/participant/ParticipantInitiateForm.tsx @@ -36,11 +36,17 @@ export const ParticipantInitiateForm = ({ project }: { project: Project }) => { const [readyValues, setReadyValues] = useState(null); const defaultName = - searchParams.get("participant_name") || searchParams.get("name") || ""; + searchParams.get("participant_name") || + searchParams.get("name") || + searchParams.get("title") || + ""; const defaultEmail = searchParams.get("participant_email") || searchParams.get("email") || ""; const defaultTagsParam = - searchParams.get("tags") || searchParams.get("tag_id_list") || ""; + searchParams.get("tags") || + searchParams.get("tag") || + searchParams.get("tag_id_list") || + ""; const defaultTagIdList = useMemo(() => { if (!defaultTagsParam) return []; diff --git a/echo/frontend/src/components/participant/ParticipantOnboardingCards.tsx b/echo/frontend/src/components/participant/ParticipantOnboardingCards.tsx index 448465976..9717a2d4e 100644 --- a/echo/frontend/src/components/participant/ParticipantOnboardingCards.tsx +++ b/echo/frontend/src/components/participant/ParticipantOnboardingCards.tsx @@ -70,10 +70,10 @@ const ParticipantOnboardingCards = ({ const { language } = useLanguage(); - // Tables/tags preselected via the QR URL (?tags= / ?tag_id_list=), resolved + // Tables/tags preselected via the QR URL (?tags= / ?tag= / ?tag_id_list=), resolved // to labels so the funnel can show them and mark them as preselected. const preselectedTags = useMemo(() => { - const raw = searchParams.get("tags") ?? searchParams.get("tag_id_list"); + const raw = searchParams.get("tags") ?? searchParams.get("tag") ?? searchParams.get("tag_id_list"); if (!raw) return [] as string[]; const wanted = raw .split(",") diff --git a/echo/frontend/src/hooks/useLanguage.ts b/echo/frontend/src/hooks/useLanguage.ts index f0dc55f1a..8ef418a0d 100644 --- a/echo/frontend/src/hooks/useLanguage.ts +++ b/echo/frontend/src/hooks/useLanguage.ts @@ -1,8 +1,8 @@ import { i18n } from "@lingui/core"; import { useEffect, useState } from "react"; -import { useParams } from "react-router"; +import { useParams, useSearchParams } from "react-router"; import { SUPPORTED_LANGUAGES } from "@/config"; -import { readStoredLanguage } from "@/lib/language"; +import { readStoredLanguage, storeLanguage } from "@/lib/language"; export const defaultLanguage = "en-US"; @@ -26,19 +26,61 @@ i18n.load({ "cs-CZ": csMessages, }); +const normalizeLanguage = (langParam: string | null): string | null => { + if (!langParam) return null; + const normalized = langParam.trim(); + // Check exact match first (case-insensitive) + const exactMatch = SUPPORTED_LANGUAGES.find( + (l) => l.toLowerCase() === normalized.toLowerCase() + ); + if (exactMatch) return exactMatch; + + // Check 2-letter prefix match (e.g. "de" or "de-CH" -> "de-DE") + const prefix = normalized.split("-")[0].toLowerCase(); + const prefixMatch = SUPPORTED_LANGUAGES.find( + (l) => l.split("-")[0].toLowerCase() === prefix + ); + if (prefixMatch) return prefixMatch; + + return null; +}; + // Seed from the saved preference so a prefix-less entry doesn't flash English. i18n.activate(readStoredLanguage() ?? defaultLanguage); export const useLanguage = () => { const params = useParams(); - // URL prefix wins (shareable/explicit); otherwise restore the saved choice. + let searchParams: URLSearchParams | null = null; + try { + const [sParams] = useSearchParams(); + searchParams = sParams; + } catch (e) { + if (typeof window !== "undefined") { + searchParams = new URLSearchParams(window.location.search); + } + } + + const queryLanguageRaw = searchParams + ? (searchParams.get("portal_language") || + searchParams.get("language") || + searchParams.get("lang") || + searchParams.get("locale")) + : null; + const queryLanguage = normalizeLanguage(queryLanguageRaw); + + // URL prefix wins (shareable/explicit); query param is next; otherwise restore the saved choice. const language = - params.language ?? readStoredLanguage() ?? i18n.locale ?? defaultLanguage; + params.language ?? + queryLanguage ?? + readStoredLanguage() ?? + i18n.locale ?? + defaultLanguage; const [loading, setLoading] = useState(true); useEffect(() => { if ([...SUPPORTED_LANGUAGES.map((l) => l.toString())].includes(language)) { i18n.activate(language); + storeLanguage(language); } else { console.log("Unsupported language", language); i18n.activate(defaultLanguage); From 10c944a7a020070a52e7f2c2bd0a8477255a9bae Mon Sep 17 00:00:00 2001 From: Sam Date: Fri, 24 Jul 2026 02:12:03 +0200 Subject: [PATCH 2/2] ECHO-748 Simplify and limit URL parameters per operator instructions --- .../participant/ParticipantInitiateForm.tsx | 8 +-- .../ParticipantOnboardingCards.tsx | 4 +- echo/frontend/src/hooks/useLanguage.ts | 50 ++----------------- 3 files changed, 8 insertions(+), 54 deletions(-) diff --git a/echo/frontend/src/components/participant/ParticipantInitiateForm.tsx b/echo/frontend/src/components/participant/ParticipantInitiateForm.tsx index 9409a1b94..5f12ecff8 100644 --- a/echo/frontend/src/components/participant/ParticipantInitiateForm.tsx +++ b/echo/frontend/src/components/participant/ParticipantInitiateForm.tsx @@ -37,16 +37,12 @@ export const ParticipantInitiateForm = ({ project }: { project: Project }) => { const defaultName = searchParams.get("participant_name") || - searchParams.get("name") || searchParams.get("title") || ""; const defaultEmail = - searchParams.get("participant_email") || searchParams.get("email") || ""; + searchParams.get("participant_email") || ""; const defaultTagsParam = - searchParams.get("tags") || - searchParams.get("tag") || - searchParams.get("tag_id_list") || - ""; + searchParams.get("tags") || ""; const defaultTagIdList = useMemo(() => { if (!defaultTagsParam) return []; diff --git a/echo/frontend/src/components/participant/ParticipantOnboardingCards.tsx b/echo/frontend/src/components/participant/ParticipantOnboardingCards.tsx index 9717a2d4e..223e3ab3b 100644 --- a/echo/frontend/src/components/participant/ParticipantOnboardingCards.tsx +++ b/echo/frontend/src/components/participant/ParticipantOnboardingCards.tsx @@ -70,10 +70,10 @@ const ParticipantOnboardingCards = ({ const { language } = useLanguage(); - // Tables/tags preselected via the QR URL (?tags= / ?tag= / ?tag_id_list=), resolved + // Tables/tags preselected via the QR URL (?tags=), resolved // to labels so the funnel can show them and mark them as preselected. const preselectedTags = useMemo(() => { - const raw = searchParams.get("tags") ?? searchParams.get("tag") ?? searchParams.get("tag_id_list"); + const raw = searchParams.get("tags"); if (!raw) return [] as string[]; const wanted = raw .split(",") diff --git a/echo/frontend/src/hooks/useLanguage.ts b/echo/frontend/src/hooks/useLanguage.ts index 8ef418a0d..f0dc55f1a 100644 --- a/echo/frontend/src/hooks/useLanguage.ts +++ b/echo/frontend/src/hooks/useLanguage.ts @@ -1,8 +1,8 @@ import { i18n } from "@lingui/core"; import { useEffect, useState } from "react"; -import { useParams, useSearchParams } from "react-router"; +import { useParams } from "react-router"; import { SUPPORTED_LANGUAGES } from "@/config"; -import { readStoredLanguage, storeLanguage } from "@/lib/language"; +import { readStoredLanguage } from "@/lib/language"; export const defaultLanguage = "en-US"; @@ -26,61 +26,19 @@ i18n.load({ "cs-CZ": csMessages, }); -const normalizeLanguage = (langParam: string | null): string | null => { - if (!langParam) return null; - const normalized = langParam.trim(); - // Check exact match first (case-insensitive) - const exactMatch = SUPPORTED_LANGUAGES.find( - (l) => l.toLowerCase() === normalized.toLowerCase() - ); - if (exactMatch) return exactMatch; - - // Check 2-letter prefix match (e.g. "de" or "de-CH" -> "de-DE") - const prefix = normalized.split("-")[0].toLowerCase(); - const prefixMatch = SUPPORTED_LANGUAGES.find( - (l) => l.split("-")[0].toLowerCase() === prefix - ); - if (prefixMatch) return prefixMatch; - - return null; -}; - // Seed from the saved preference so a prefix-less entry doesn't flash English. i18n.activate(readStoredLanguage() ?? defaultLanguage); export const useLanguage = () => { const params = useParams(); - let searchParams: URLSearchParams | null = null; - try { - const [sParams] = useSearchParams(); - searchParams = sParams; - } catch (e) { - if (typeof window !== "undefined") { - searchParams = new URLSearchParams(window.location.search); - } - } - - const queryLanguageRaw = searchParams - ? (searchParams.get("portal_language") || - searchParams.get("language") || - searchParams.get("lang") || - searchParams.get("locale")) - : null; - const queryLanguage = normalizeLanguage(queryLanguageRaw); - - // URL prefix wins (shareable/explicit); query param is next; otherwise restore the saved choice. + // URL prefix wins (shareable/explicit); otherwise restore the saved choice. const language = - params.language ?? - queryLanguage ?? - readStoredLanguage() ?? - i18n.locale ?? - defaultLanguage; + params.language ?? readStoredLanguage() ?? i18n.locale ?? defaultLanguage; const [loading, setLoading] = useState(true); useEffect(() => { if ([...SUPPORTED_LANGUAGES.map((l) => l.toString())].includes(language)) { i18n.activate(language); - storeLanguage(language); } else { console.log("Unsupported language", language); i18n.activate(defaultLanguage);