From a9a663cc40e1440f0dcdb8fc2976adc91f262d93 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 28 May 2026 17:20:10 -0300 Subject: [PATCH] refactor(i18n): migrate hardcoded UI strings to next-intl t() in playground + search-tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes Gap 2 from code review #2: ExportCodeModal, BuildTab, PresetPicker, StudioTopBar, SearchToolsTopBar, SearchConceptCard and ProviderCatalog now use useTranslations() from next-intl for all user-facing strings. The F9 i18n PR (566ebb953) added the keys but several legacy hardcoded strings still slipped through. This change wires them up and fixes pt-BR translations that were leaking English ("Cancel" → "Cancelar", "Save" → "Salvar", "Copy" → "Copiar", "Clear All" → "Limpar tudo", etc.). New keys added in both pt-BR.json and en.json under the `playground` namespace: - close, closeExportModal, exportShort - exportRealKeyWarning, placeholderHintPrefix, placeholderHintSuffix - copyLangCode (with {language} placeholder) - loadingPresets, loadPresetPlaceholder Test updates: - 7 UI test files: mocks updated for next-intl (useTranslations) and assertions updated to match the i18n key-as-text returned by the mock. - PlaygroundStudio test: 2 tests previously checked for a "F7 implementation pending" placeholder which no longer exists (F7+F9 fully implemented those tabs) — assertions now verify the tab becomes active instead. - PlaygroundConfigPane test: endpoint select count bumped from 10 to 13 (D4-rev2) with a more robust selector that finds the endpoint select regardless of PresetPicker's load-preset select position. Validation: - npm run typecheck:core: clean - npm run lint: 0 errors (warnings pre-existing) - npx vitest run tests/unit/ui: 245 tests pass (26 files) - node --test tests/unit/playground-*.test.ts tests/unit/search-tools-*.test.ts tests/unit/db-playground-presets.test.ts: 122 tests pass --- .../playground/components/ExportCodeModal.tsx | 21 +++---- .../playground/components/PresetPicker.tsx | 26 ++++---- .../playground/components/StudioTopBar.tsx | 26 ++++---- .../playground/components/tabs/BuildTab.tsx | 6 +- .../components/ProviderCatalog.tsx | 11 ++-- .../components/SearchConceptCard.tsx | 59 +++++++++---------- .../components/SearchToolsTopBar.tsx | 19 +++--- src/i18n/messages/en.json | 9 +++ src/i18n/messages/pt-BR.json | 43 ++++++++------ tests/unit/ui/playground-build-tab.test.tsx | 10 +++- tests/unit/ui/playground-config-pane.test.tsx | 13 ++-- .../unit/ui/playground-export-modal.test.tsx | 21 ++++++- .../unit/ui/playground-preset-picker.test.tsx | 18 +++--- tests/unit/ui/playground-studio.test.tsx | 45 ++++++++------ .../ui/search-tools-concept-card.test.tsx | 7 ++- 15 files changed, 200 insertions(+), 134 deletions(-) diff --git a/src/app/(dashboard)/dashboard/playground/components/ExportCodeModal.tsx b/src/app/(dashboard)/dashboard/playground/components/ExportCodeModal.tsx index db872cb6d6..08c3fee74e 100644 --- a/src/app/(dashboard)/dashboard/playground/components/ExportCodeModal.tsx +++ b/src/app/(dashboard)/dashboard/playground/components/ExportCodeModal.tsx @@ -3,6 +3,7 @@ // src/app/(dashboard)/dashboard/playground/components/ExportCodeModal.tsx import { useState, useEffect, useCallback } from "react"; +import { useTranslations } from "next-intl"; import type { PlaygroundState, ExportLanguage } from "@/lib/playground/codeExport"; import { exportAllLanguages, API_KEY_PLACEHOLDER } from "@/lib/playground/codeExport"; @@ -23,6 +24,7 @@ const LANGUAGE_TABS: Array<{ id: ExportLanguage; label: string }> = [ * Security: always uses API_KEY_PLACEHOLDER ("$OMNIROUTE_API_KEY") — never a real key (D11 / Hard Rule #1). */ export default function ExportCodeModal({ state, onClose }: ExportCodeModalProps) { + const t = useTranslations("playground"); const [activeLanguage, setActiveLanguage] = useState("curl"); const [copied, setCopied] = useState(false); @@ -63,7 +65,7 @@ export default function ExportCodeModal({ state, onClose }: ExportCodeModalProps onClick={onClose} role="dialog" aria-modal="true" - aria-label="Export code" + aria-label={t("exportCode")} >
</> -

Export code

+

{t("exportCodeTitle")}

@@ -110,8 +112,7 @@ export default function ExportCodeModal({ state, onClose }: ExportCodeModalProps
{hasRealKey ? (
- Security warning: export blocked — a real API key was detected in the output. - Please reset your API key and try again. + {t("exportRealKeyWarning")}
) : (
@@ -121,9 +122,9 @@ export default function ExportCodeModal({ state, onClose }: ExportCodeModalProps
 
           {/* Placeholder hint */}
           

- Replace{" "} + {t("placeholderHintPrefix")}{" "} {API_KEY_PLACEHOLDER} - {" "}with your actual API key, or set it as an environment variable. + {" "}{t("placeholderHintSuffix")}

@@ -133,7 +134,7 @@ export default function ExportCodeModal({ state, onClose }: ExportCodeModalProps onClick={onClose} className="text-xs px-3 py-1.5 rounded border border-border text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors" > - Close + {t("close")}
diff --git a/src/app/(dashboard)/dashboard/playground/components/PresetPicker.tsx b/src/app/(dashboard)/dashboard/playground/components/PresetPicker.tsx index d2367678a8..5112a2dbb4 100644 --- a/src/app/(dashboard)/dashboard/playground/components/PresetPicker.tsx +++ b/src/app/(dashboard)/dashboard/playground/components/PresetPicker.tsx @@ -3,6 +3,7 @@ // src/app/(dashboard)/dashboard/playground/components/PresetPicker.tsx import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; import { usePresets } from "../hooks/usePresets"; import type { ConfigState } from "./StudioConfigPane"; @@ -18,6 +19,7 @@ interface PresetPickerProps { * Load applies preset values to configState; Save opens a name-input modal. */ export default function PresetPicker({ configState, setConfigState }: PresetPickerProps) { + const t = useTranslations("playground"); const { presets, loading, list, create, remove } = usePresets(); const [saveModalOpen, setSaveModalOpen] = useState(false); const [presetName, setPresetName] = useState(""); @@ -47,7 +49,7 @@ export default function PresetPicker({ configState, setConfigState }: PresetPick async function handleSave() { if (!presetName.trim()) { - setSaveError("Name is required"); + setSaveError(t("nameRequired")); return; } @@ -65,7 +67,7 @@ export default function PresetPicker({ configState, setConfigState }: PresetPick setSaving(false); if (result == null) { - setSaveError("Failed to save preset"); + setSaveError(t("failedToSavePreset")); return; } @@ -89,7 +91,7 @@ export default function PresetPicker({ configState, setConfigState }: PresetPick <>
- Presets + {t("presetsLabel")} {/* Load preset select */} @@ -105,10 +107,10 @@ export default function PresetPicker({ configState, setConfigState }: PresetPick }} defaultValue="" className="flex-1 text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main disabled:opacity-50 disabled:cursor-not-allowed" - aria-label="Load a preset" + aria-label={t("loadPreset")} > {presets.map((preset) => (
@@ -162,13 +164,13 @@ export default function PresetPicker({ configState, setConfigState }: PresetPick onClick={closeSave} role="dialog" aria-modal="true" - aria-label="Save preset" + aria-label={t("savePreset")} >
e.stopPropagation()} > -

Save preset

+

{t("savePreset")}

@@ -193,14 +195,14 @@ export default function PresetPicker({ configState, setConfigState }: PresetPick onClick={closeSave} className="text-xs px-3 py-1.5 rounded border border-border text-text-muted hover:text-text-main transition-colors" > - Cancel + {t("cancel")}
diff --git a/src/app/(dashboard)/dashboard/playground/components/StudioTopBar.tsx b/src/app/(dashboard)/dashboard/playground/components/StudioTopBar.tsx index a477d419f7..78839c9388 100644 --- a/src/app/(dashboard)/dashboard/playground/components/StudioTopBar.tsx +++ b/src/app/(dashboard)/dashboard/playground/components/StudioTopBar.tsx @@ -3,6 +3,7 @@ // src/app/(dashboard)/dashboard/playground/components/StudioTopBar.tsx import { useState } from "react"; +import { useTranslations } from "next-intl"; import TokenCostCounter from "./TokenCostCounter"; import ExportCodeModal from "./ExportCodeModal"; import type { StreamMetrics } from "@/shared/schemas/playground"; @@ -20,15 +21,15 @@ interface StudioTopBarProps { interface TabConfig { id: StudioTab; - label: string; + labelKey: "tabChat" | "tabCompare" | "tabApi" | "tabBuild"; icon: string; } const TABS: TabConfig[] = [ - { id: "chat", label: "Chat", icon: "chat" }, - { id: "compare", label: "Compare", icon: "compare" }, - { id: "api", label: "API", icon: "api" }, - { id: "build", label: "Build", icon: "build" }, + { id: "chat", labelKey: "tabChat", icon: "chat" }, + { id: "compare", labelKey: "tabCompare", icon: "compare" }, + { id: "api", labelKey: "tabApi", icon: "api" }, + { id: "build", labelKey: "tabBuild", icon: "build" }, ]; /** @@ -36,6 +37,7 @@ const TABS: TabConfig[] = [ * Export code modal uses ExportCodeModal (F7) when exportState is provided. */ export default function StudioTopBar({ activeTab, onTabChange, metrics, exportState }: StudioTopBarProps) { + const t = useTranslations("playground"); const [exportOpen, setExportOpen] = useState(false); return ( @@ -56,7 +58,7 @@ export default function StudioTopBar({ activeTab, onTabChange, metrics, exportSt }`} > {tab.icon} - {tab.label} + {t(tab.labelKey)} ))} @@ -72,11 +74,11 @@ export default function StudioTopBar({ activeTab, onTabChange, metrics, exportSt @@ -95,17 +97,17 @@ export default function StudioTopBar({ activeTab, onTabChange, metrics, exportSt onClick={(e) => e.stopPropagation()} >
-

Export code

+

{t("exportCodeTitle")}

- No playground state available to export. + {t("noStateToExport")}

diff --git a/src/app/(dashboard)/dashboard/playground/components/tabs/BuildTab.tsx b/src/app/(dashboard)/dashboard/playground/components/tabs/BuildTab.tsx index bf67889bf5..40bfb78c98 100644 --- a/src/app/(dashboard)/dashboard/playground/components/tabs/BuildTab.tsx +++ b/src/app/(dashboard)/dashboard/playground/components/tabs/BuildTab.tsx @@ -3,6 +3,7 @@ // src/app/(dashboard)/dashboard/playground/components/tabs/BuildTab.tsx import { useRef, useState } from "react"; +import { useTranslations } from "next-intl"; import { useToolsBuilder } from "../../hooks/useToolsBuilder"; import { useStructuredOutput } from "../../hooks/useStructuredOutput"; import ToolsBuilder from "../ToolsBuilder"; @@ -45,6 +46,7 @@ interface ToolResultDraft { * for the tool_result + "Send result" button to continue the conversation. */ export default function BuildTab({ configState }: BuildTabProps) { + const t = useTranslations("playground"); const toolsBuilder = useToolsBuilder(); const structuredOutput = useStructuredOutput(); @@ -243,7 +245,7 @@ export default function BuildTab({ configState }: BuildTabProps) { className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded bg-primary text-white hover:bg-primary/90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed" > play_arrow - {running ? "Running…" : "Run"} + {running ? t("running") : t("runLabel")} {messages.length > 0 && ( @@ -251,7 +253,7 @@ export default function BuildTab({ configState }: BuildTabProps) { onClick={clearConversation} className="text-xs px-2.5 py-1.5 rounded border border-border text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors" > - Clear + {t("clearAll")} )} diff --git a/src/app/(dashboard)/dashboard/search-tools/components/ProviderCatalog.tsx b/src/app/(dashboard)/dashboard/search-tools/components/ProviderCatalog.tsx index 6304232561..bbc0df5f3f 100644 --- a/src/app/(dashboard)/dashboard/search-tools/components/ProviderCatalog.tsx +++ b/src/app/(dashboard)/dashboard/search-tools/components/ProviderCatalog.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import Link from "next/link"; +import { useTranslations } from "next-intl"; import type { SearchProviderCatalogItem } from "@/shared/schemas/searchTools"; interface ProviderCatalogProps { @@ -11,6 +12,7 @@ interface ProviderCatalogProps { } function StatusBadge({ status }: { status: SearchProviderCatalogItem["status"] }) { + const t = useTranslations("search"); if (status === "configured") { return ( ); } @@ -29,7 +31,7 @@ function StatusBadge({ status }: { status: SearchProviderCatalogItem["status"] } data-testid="status-rate-limited" > ); } @@ -39,7 +41,7 @@ function StatusBadge({ status }: { status: SearchProviderCatalogItem["status"] } data-testid="status-missing" > ); } @@ -53,6 +55,7 @@ function ProviderCard({ selected: boolean; onSelect?: () => void; }) { + const t = useTranslations("search"); const isClickable = item.status !== "missing" && onSelect; return ( @@ -80,7 +83,7 @@ function ProviderCard({
{item.name}
- {item.kind === "search" ? "Search" : "Fetch/Scrape"} + {item.kind === "search" ? t("kindSearch") : t("kindFetch")}
diff --git a/src/app/(dashboard)/dashboard/search-tools/components/SearchConceptCard.tsx b/src/app/(dashboard)/dashboard/search-tools/components/SearchConceptCard.tsx index 130b95d060..569253860d 100644 --- a/src/app/(dashboard)/dashboard/search-tools/components/SearchConceptCard.tsx +++ b/src/app/(dashboard)/dashboard/search-tools/components/SearchConceptCard.tsx @@ -1,39 +1,33 @@ "use client"; import { useState } from "react"; +import { useTranslations } from "next-intl"; + +type ConceptKey = "search" | "scrape" | "compare" | "rerank" | "auto"; interface ConceptItem { icon: string; - title: string; - description: string; + key: ConceptKey; + titleKey: + | "searchConceptTitle" + | "scrapeConceptTitle" + | "compareConceptTitle" + | "rerankConceptTitle" + | "autoConceptTitle"; + descKey: + | "searchConceptDesc" + | "scrapeConceptDesc" + | "compareConceptDesc" + | "rerankConceptDesc" + | "autoConceptDesc"; } const CONCEPTS: ConceptItem[] = [ - { - icon: "🔍", - title: "Search", - description: "Search = busca na web (lista de links com título, URL, snippet e score de relevância)", - }, - { - icon: "📄", - title: "Scrape", - description: "Scrape = extrai o conteúdo completo de uma URL (markdown, texto ou HTML)", - }, - { - icon: "⚖", - title: "Compare", - description: "Compare = executa a mesma query em N providers side-by-side para comparar latência, custo e overlap de resultados", - }, - { - icon: "↕", - title: "Rerank", - description: "Rerank = reordena resultados via LLM para melhorar relevância baseada na query", - }, - { - icon: "⚡", - title: "Auto (cheapest)", - description: "Auto (cheapest) = escolhe automaticamente o provider mais barato disponível e com credenciais configuradas", - }, + { icon: "🔍", key: "search", titleKey: "searchConceptTitle", descKey: "searchConceptDesc" }, + { icon: "📄", key: "scrape", titleKey: "scrapeConceptTitle", descKey: "scrapeConceptDesc" }, + { icon: "⚖", key: "compare", titleKey: "compareConceptTitle", descKey: "compareConceptDesc" }, + { icon: "↕", key: "rerank", titleKey: "rerankConceptTitle", descKey: "rerankConceptDesc" }, + { icon: "⚡", key: "auto", titleKey: "autoConceptTitle", descKey: "autoConceptDesc" }, ]; interface SearchConceptCardProps { @@ -42,6 +36,7 @@ interface SearchConceptCardProps { } export default function SearchConceptCard({ defaultCollapsed = false }: SearchConceptCardProps) { + const t = useTranslations("search"); const [collapsed, setCollapsed] = useState(defaultCollapsed); return ( @@ -58,7 +53,7 @@ export default function SearchConceptCard({ defaultCollapsed = false }: SearchCo > - Guia de modalidades + {t("modalitiesGuide")}