From 72ae7419b52a6bcec9b4e92d6714ef9572a5ed02 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 23 Jul 2026 22:56:37 +0200 Subject: [PATCH 1/3] 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/3] 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); From 374046eb2d7ee84704bf06aeefc39ae70bcaf1b3 Mon Sep 17 00:00:00 2001 From: Sam Date: Thu, 30 Jul 2026 08:10:17 +0200 Subject: [PATCH 3/3] ECHO-748 Restore backwards-compatible prefill parameters as aliases --- echo/docs/portal_prefilling.md | 2 +- .../components/participant/ParticipantInitiateForm.tsx | 9 +++++++-- .../participant/ParticipantOnboardingCards.tsx | 4 ++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/echo/docs/portal_prefilling.md b/echo/docs/portal_prefilling.md index f1ba78913..03290704c 100644 --- a/echo/docs/portal_prefilling.md +++ b/echo/docs/portal_prefilling.md @@ -13,7 +13,7 @@ you can append the following query parameters to any portal start URL (like `htt | parameter | description | example | |---|---|---| | `skipOnboarding` | when set to `1`, skips the introductory tutorial slides and goes directly to the conversation start screen. | `skipOnboarding=1` | -| `participant_name` or `name` | prefills the participant's name on the initiation page. if onboarding is skipped and the name is prefilled (or name is not required), the conversation will initiate *automatically*. | `participant_name=Alice` | +| `participant_name`, `name` or `title` | prefills the participant's name (or session title, like a table number) on the initiation page. if onboarding is skipped and the name is prefilled (or name is not required), the conversation will initiate *automatically*. | `participant_name=Alice` | | `participant_email` or `email` | prefills the participant's e-mail address. this is automatically passed to the language model and database to associate the conversation with their e-mail. | `participant_email=alice@work.com` | | `tags` or `tag_id_list` | a comma-separated list of tag IDs or tag names to apply to the conversation. names are matched *case-insensitively* against the project's tags. | `tags=Bug,Feature` | | `general_feedback` or `feedback` | prefills the text input box for the conversation. if this parameter is present, the portal starts the participant *directly in text input mode* and presets this feedback. | `general_feedback=the+platform+is+very+easy+to+use` | diff --git a/echo/frontend/src/components/participant/ParticipantInitiateForm.tsx b/echo/frontend/src/components/participant/ParticipantInitiateForm.tsx index 5f12ecff8..21ad59fa8 100644 --- a/echo/frontend/src/components/participant/ParticipantInitiateForm.tsx +++ b/echo/frontend/src/components/participant/ParticipantInitiateForm.tsx @@ -38,11 +38,16 @@ export const ParticipantInitiateForm = ({ project }: { project: Project }) => { const defaultName = searchParams.get("participant_name") || searchParams.get("title") || + searchParams.get("name") || ""; const defaultEmail = - searchParams.get("participant_email") || ""; + searchParams.get("participant_email") || + searchParams.get("email") || + ""; const defaultTagsParam = - searchParams.get("tags") || ""; + searchParams.get("tags") || + 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 223e3ab3b..448465976 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=), resolved + // Tables/tags preselected via the QR URL (?tags= / ?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"); + const raw = searchParams.get("tags") ?? searchParams.get("tag_id_list"); if (!raw) return [] as string[]; const wanted = raw .split(",")