From 2ea02a26715ee9dc4240afeb5bf53b7545b96fbb Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 23 Jul 2026 14:15:33 +0200 Subject: [PATCH] feat(14-05): convert tender-radar results-side components to useTranslations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converts page.tsx, ResultsList, FilterPanel, SavedSearchBar, TenderDetail and CoverageBanner from hardcoded German strings to useTranslations('tenderRadar'). FilterPanel's Bundesland/CPV division option labels are now looked up by stable code (NUTS-1 prefix / CPV division code) while the underlying filter *value* sent to the backend stays the canonical German string the API already matches against. Portal display slugs (DÖE, DTVP, tender24, ...) in TenderDetail's portalLabel() are left untranslated as proper-noun identifiers, not UI copy. Updates the four affected component tests with a next-intl useTranslations mock mirroring the marketplace test convention. Also fixes an unrelated `t` parameter shadowing the translations function inside ResultsList's triage batch-fetch (Rule 1). Co-Authored-By: Claude Opus 4.8 --- .../components/CoverageBanner.test.tsx | 16 ++ .../components/CoverageBanner.tsx | 13 +- .../tender-radar/components/FilterPanel.tsx | 177 ++++++++---------- .../components/ResultsList.test.tsx | 31 +++ .../tender-radar/components/ResultsList.tsx | 80 ++++---- .../components/SavedSearchBar.test.tsx | 39 ++++ .../components/SavedSearchBar.tsx | 66 +++---- .../components/TenderDetail.test.tsx | 40 ++++ .../tender-radar/components/TenderDetail.tsx | 80 ++++---- .../(portal)/modules/tender-radar/page.tsx | 26 +-- 10 files changed, 338 insertions(+), 230 deletions(-) diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.test.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.test.tsx index 83f890a..428f735 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.test.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.test.tsx @@ -18,6 +18,22 @@ vi.mock('@/lib/tender-radar-api', () => ({ fetchDenylistedPortals: (...args: unknown[]) => mockFetchDenylistedPortals(...args), })); +// Mock next-intl useTranslations — mirrors the marketplace test mock +// convention (tenant-selector.test.tsx). +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => { + const translations: Record = { + 'coverage.label': 'Datenabdeckung:', + 'coverage.text': + 'Aktuell werden nur EU-weite Oberschwellen-Ausschreibungen aus der zentralen DÖE-Quelle erfasst. Unterschwellen-Vergaben einzelner Vergabestellen fehlen noch — eine kurze Trefferliste ist daher kein Fehler.', + 'coverage.denylistLabel': 'Manuell beobachten:', + 'coverage.denylistText': + 'Diese Portale lassen laut ihren Nutzungsbedingungen keinen automatisierten Zugriff zu und müssen daher manuell geprüft werden:', + }; + return translations[key] ?? key; + }, +})); + afterEach(() => { cleanup(); mockFetchCoverage.mockReset(); diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.tsx index d0c4672..b517c57 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/CoverageBanner.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; import { fetchCoverage, fetchDenylistedPortals, @@ -28,6 +29,7 @@ import { * public sources are ingested. */ export function CoverageBanner() { + const t = useTranslations('tenderRadar'); const [coverage, setCoverage] = useState(null); const [denylistedPortals, setDenylistedPortals] = useState([]); @@ -53,17 +55,14 @@ export function CoverageBanner() {
{showCoverageNote && (

- Datenabdeckung: Aktuell - werden nur EU-weite Oberschwellen-Ausschreibungen aus der zentralen - DÖE-Quelle erfasst. Unterschwellen-Vergaben einzelner Vergabestellen - fehlen noch — eine kurze Trefferliste ist daher kein Fehler. + {t('coverage.label')}{' '} + {t('coverage.text')}

)} {denylistedPortals.length > 0 && (

- Manuell beobachten:{' '} - Diese Portale lassen laut ihren Nutzungsbedingungen keinen - automatisierten Zugriff zu und müssen daher manuell geprüft werden:{' '} + {t('coverage.denylistLabel')}{' '} + {t('coverage.denylistText')}{' '} {denylistedPortals.map((p, i) => ( {i > 0 && ', '} diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx index 32f1731..937dec2 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/FilterPanel.tsx @@ -1,6 +1,7 @@ 'use client'; import { useMemo, useState } from 'react'; +import { useTranslations } from 'next-intl'; import { useRouter, useSearchParams } from 'next/navigation'; /** @@ -14,14 +15,18 @@ import { useRouter, useSearchParams } from 'next/navigation'; * `bundesland` (Plan 11-02), `cpv`, `valueMin`/`valueMax`/`includeNullValue` * (Plan 11-03) follow the same convention. * - * Hardcoded German strings — i18n rollout is Phase 14 (Research Open - * Question 3, RESOLVED). + * Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the + * tenderRadar i18n namespace. `bundesland`'s option *value* stays the + * canonical German Bundesland name (apps/api's nutsPrefixFor() matches + * against that exact string server-side, D-10 does not change the API + * contract) — only the rendered *label* is translated via + * `filter.bundeslandOptions.{code}`. */ -const SORT_OPTIONS: Array<{ value: string; label: string }> = [ - { value: 'published', label: 'Veröffentlichungsdatum' }, - { value: 'deadline', label: 'Abgabefrist' }, - { value: 'value', label: 'Auftragswert' }, +const SORT_OPTIONS: Array<{ value: string; labelKey: string }> = [ + { value: 'published', labelKey: 'filter.sortPublished' }, + { value: 'deadline', labelKey: 'filter.sortDeadline' }, + { value: 'value', labelKey: 'filter.sortValue' }, ]; /** @@ -30,24 +35,27 @@ const SORT_OPTIONS: Array<{ value: string; label: string }> = [ * small, stable 16-entry list; RESEARCH's "kleine Konstante spiegeln" * option, FILTER-02 Plan 11-02 Task 3). Keep in sync if the Bundesland set * ever changes (it will not — this is the fixed EU NUTS-1/DE schema). + * `code` is the NUTS-1 prefix (matches the API's own lookup keys) used + * solely as the i18n key for the translated display label; `value` is the + * German Bundesland name sent to the backend, unchanged by i18n. */ -const BUNDESLAND_OPTIONS: string[] = [ - 'Baden-Württemberg', - 'Bayern', - 'Berlin', - 'Brandenburg', - 'Bremen', - 'Hamburg', - 'Hessen', - 'Mecklenburg-Vorpommern', - 'Niedersachsen', - 'Nordrhein-Westfalen', - 'Rheinland-Pfalz', - 'Saarland', - 'Sachsen', - 'Sachsen-Anhalt', - 'Schleswig-Holstein', - 'Thüringen', +const BUNDESLAND_OPTIONS: Array<{ code: string; value: string }> = [ + { code: 'DE1', value: 'Baden-Württemberg' }, + { code: 'DE2', value: 'Bayern' }, + { code: 'DE3', value: 'Berlin' }, + { code: 'DE4', value: 'Brandenburg' }, + { code: 'DE5', value: 'Bremen' }, + { code: 'DE6', value: 'Hamburg' }, + { code: 'DE7', value: 'Hessen' }, + { code: 'DE8', value: 'Mecklenburg-Vorpommern' }, + { code: 'DE9', value: 'Niedersachsen' }, + { code: 'DEA', value: 'Nordrhein-Westfalen' }, + { code: 'DEB', value: 'Rheinland-Pfalz' }, + { code: 'DEC', value: 'Saarland' }, + { code: 'DED', value: 'Sachsen' }, + { code: 'DEE', value: 'Sachsen-Anhalt' }, + { code: 'DEF', value: 'Schleswig-Holstein' }, + { code: 'DEG', value: 'Thüringen' }, ]; /** @@ -55,65 +63,36 @@ const BUNDESLAND_OPTIONS: string[] = [ * (web and api are separate packages — same "kleine Konstante spiegeln" * precedent as BUNDESLAND_OPTIONS above, Plan 11-02 Task 3 decision). Only * the 2-digit CPV Division level ships here (D-03, Open Question 2 - * RESOLVED) — deliberately not the full EU CPV vocabulary. + * RESOLVED) — deliberately not the full EU CPV vocabulary. `code` is both + * the filter value sent to the backend AND the i18n key under + * `filter.cpvDivisions.{code}` for the translated label. */ -const CPV_DIVISION_OPTIONS: Array<{ code: string; label: string }> = [ - { code: '03', label: 'Landwirtschaftliche, fischereiwirtschaftliche, forstwirtschaftliche und verwandte Erzeugnisse' }, - { code: '09', label: 'Mineralölerzeugnisse, Brennstoffe, Elektrizität und andere Energiequellen' }, - { code: '14', label: 'Bergbauerzeugnisse, Grundstoffe und verwandte Erzeugnisse' }, - { code: '15', label: 'Nahrungsmittel, Getränke, Tabak und verwandte Erzeugnisse' }, - { code: '16', label: 'Land- und forstwirtschaftliche Maschinen' }, - { code: '18', label: 'Bekleidung, Schuhe, Reiseartikel und Zubehör' }, - { code: '19', label: 'Leder- und Textilwaren, Kunststoff- und Gummierzeugnisse' }, - { code: '22', label: 'Druckerzeugnisse und verwandte Erzeugnisse' }, - { code: '24', label: 'Chemische Erzeugnisse' }, - { code: '30', label: 'Büromaschinen, Datenverarbeitungsgeräte, -ausrüstung und -material, ausgenommen Möbel und Softwarepakete' }, - { code: '31', label: 'Elektrische Maschinen, Geräte, Ausrüstungen und Verbrauchsartikel; Beleuchtung' }, - { code: '32', label: 'Rundfunk-, Fernseh-, Kommunikations-, Telekommunikations- und verwandte Geräte' }, - { code: '33', label: 'Medizinische Apparate, pharmazeutische Erzeugnisse und Körperpflegeprodukte' }, - { code: '34', label: 'Transportausrüstung und Transportzubehör' }, - { code: '35', label: 'Sicherheits-, Feuerlösch-, Polizei- und Verteidigungsausrüstung' }, - { code: '37', label: 'Musikinstrumente, Sportartikel, Spiele, Spielzeug, Kunsthandwerk, Kunstmaterial und Zubehör' }, - { code: '38', label: 'Laborgeräte, optische und Präzisionsgeräte (ausgenommen Gläser)' }, - { code: '39', label: 'Möbel (einschließlich Büromöbel), Einrichtungsgegenstände, Haushaltsgeräte (ausgenommen Beleuchtung) und Reinigungsmittel' }, - { code: '41', label: 'Gesammeltes und gereinigtes Wasser' }, - { code: '42', label: 'Industriemaschinen' }, - { code: '43', label: 'Maschinen für Bergbau, Steinbruch, Bauwesen' }, - { code: '44', label: 'Baustoffe und zugehörige Artikel' }, - { code: '45', label: 'Bauarbeiten' }, - { code: '48', label: 'Softwarepaket und Informationssysteme' }, - { code: '50', label: 'Reparatur- und Wartungsdienste' }, - { code: '51', label: 'Installationsdienste (ausgenommen Software)' }, - { code: '55', label: 'Beherbergungs-, Gaststätten- und Handelsdienstleistungen' }, - { code: '60', label: 'Transportdienstleistungen (ohne Abfalltransport)' }, - { code: '63', label: 'Neben- und Hilfstätigkeiten im Verkehr; Reisebüros' }, - { code: '64', label: 'Post- und Telekommunikationsdienste' }, - { code: '65', label: 'Versorgungsbetriebe' }, - { code: '66', label: 'Finanz- und Versicherungsdienstleistungen' }, - { code: '70', label: 'Dienstleistungen im Zusammenhang mit Immobilien' }, - { code: '71', label: 'Architektur-, Bau-, Ingenieur- und Prüfdienste' }, - { code: '72', label: 'IT-Dienste: Beratung, Software-Entwicklung, Internet und Hilfestellung' }, - { code: '73', label: 'Forschungs- und Entwicklungsdienste sowie zugehörige Beratung' }, - { code: '75', label: 'Öffentliche Verwaltung, Verteidigung und Sozialversicherung' }, - { code: '76', label: 'Dienstleistungen im Zusammenhang mit der Öl- und Gasindustrie' }, - { code: '77', label: 'Landwirtschaftliche, forstwirtschaftliche, gartenbauliche Dienste, Aquakultur und Imkerei' }, - { code: '79', label: 'Unternehmensbezogene Dienstleistungen: Recht, Marketing, Consulting, Personal, Druck und Sicherheit' }, - { code: '80', label: 'Dienstleistungen des Bildungswesens und der Ausbildung' }, - { code: '85', label: 'Dienste im Gesundheits- und Sozialwesen' }, - { code: '90', label: 'Abwasser- und Abfallbeseitigung, Reinigung und Umweltschutzdienste' }, - { code: '92', label: 'Dienstleistungen der Freizeitgestaltung, der Kultur und des Sports' }, - { code: '98', label: 'Sonstige Gemeinschafts-, Sozial- und Individualdienstleistungen' }, +const CPV_DIVISION_CODES: string[] = [ + '03', '09', '14', '15', '16', '18', '19', '22', '24', '30', + '31', '32', '33', '34', '35', '37', '38', '39', '41', '42', + '43', '44', '45', '48', '50', '51', '55', '60', '63', '64', + '65', '66', '70', '71', '72', '73', '75', '76', '77', '79', + '80', '85', '90', '92', '98', ]; -function labelForCpvDivision(code: string): string { - return CPV_DIVISION_OPTIONS.find((d) => d.code === code)?.label ?? code; -} - export function FilterPanel() { + const t = useTranslations('tenderRadar'); const router = useRouter(); const searchParams = useSearchParams(); const [cpvSearchText, setCpvSearchText] = useState(''); + const cpvOptions = useMemo( + () => + CPV_DIVISION_CODES.map((code) => ({ + code, + label: t(`filter.cpvDivisions.${code}`), + })), + [t], + ); + + const labelForCpvDivision = (code: string): string => + cpvOptions.find((d) => d.code === code)?.label ?? code; + const q = searchParams.get('q') ?? ''; const sort = searchParams.get('sort') ?? 'published'; // openOnly defaults to true (D-04) — only an explicit "false" turns it off. @@ -166,12 +145,12 @@ export function FilterPanel() { const cpvMatches = useMemo(() => { const text = cpvSearchText.trim().toLowerCase(); if (!text) return []; - return CPV_DIVISION_OPTIONS.filter( + return cpvOptions.filter( (d) => !selectedCpv.includes(d.code) && (d.code.includes(text) || d.label.toLowerCase().includes(text)), ).slice(0, 8); - }, [cpvSearchText, selectedCpv]); + }, [cpvSearchText, selectedCpv, cpvOptions]); const inputCls = 'h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground'; @@ -182,12 +161,12 @@ export function FilterPanel() {

setParam('q', e.target.value)} className={inputCls} @@ -196,7 +175,7 @@ export function FilterPanel() {
@@ -221,7 +200,7 @@ export function FilterPanel() { className="h-4 w-4" />
@@ -234,7 +213,7 @@ export function FilterPanel() { className="h-4 w-4" />
@@ -242,7 +221,7 @@ export function FilterPanel() {
setParam('plz', e.target.value)} className="h-9 w-28 rounded border border-border bg-background px-3 text-sm text-foreground" @@ -285,7 +264,7 @@ export function FilterPanel() {
@@ -306,12 +285,12 @@ export function FilterPanel() {
setCpvSearchText(e.target.value)} className={inputCls} @@ -345,7 +324,7 @@ export function FilterPanel() { {code} – {labelForCpvDivision(code)}
diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.test.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.test.tsx index 30d176a..9783de5 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.test.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.test.tsx @@ -20,6 +20,37 @@ vi.mock('@/lib/tender-radar-api', () => ({ setTriage: (...args: unknown[]) => mockSetTriage(...args), })); +// Mock next-intl useTranslations — mirrors the marketplace test mock +// convention (tenant-selector.test.tsx): a flat key -> German-copy lookup +// table so assertions against the actual rendered copy keep working. +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => { + const translations: Record = { + 'results.colTitle': 'Titel', + 'results.colBuyer': 'Auftraggeber', + 'results.colDeadline': 'Frist', + 'results.colValue': 'Wert', + 'results.colPublished': 'Veröffentlicht', + 'results.colTriage': 'Triage', + 'results.noValue': 'keine Wertangabe', + 'results.error': 'Ausschreibungen konnten nicht geladen werden.', + 'results.emptyTitle': 'Keine Ausschreibungen gefunden.', + 'results.emptyBody': 'Passen Sie die Filter an oder deaktivieren Sie „nur noch offene".', + 'results.markUnread': 'Als ungelesen markieren', + 'results.markRead': 'Als gelesen markieren', + 'results.readLabel': 'Gelesen', + 'results.unreadLabel': 'Ungelesen', + 'results.removeFavorite': 'Favorit entfernen', + 'results.setFavorite': 'Favorit setzen', + 'results.favoriteOn': '★ Favorit', + 'results.favoriteOff': '☆ Favorit', + 'results.prevPage': '← Zurück', + 'results.nextPage': 'Weiter →', + }; + return translations[key] ?? key; + }, +})); + const mockReplace = vi.fn(); let mockSearchParams = new URLSearchParams(); diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx index 10d8445..591ff65 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/ResultsList.tsx @@ -1,6 +1,7 @@ 'use client'; import { useCallback, useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; import { useRouter, useSearchParams } from 'next/navigation'; import { fetchTriage, @@ -18,13 +19,16 @@ import { * sie -> listTenders()") and re-fetches whenever the URL changes. * * D-05 (Pflichtkriterium): a row with estimatedValue=null is NEVER hidden — - * it renders the literal "keine Wertangabe" label instead of blank/0. + * it renders the translated "no value stated" label instead of blank/0. + * + * Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the + * tenderRadar i18n namespace. */ -const SORT_HEADERS: Array<{ key: 'deadline' | 'value' | 'published'; label: string }> = [ - { key: 'deadline', label: 'Frist' }, - { key: 'value', label: 'Wert' }, - { key: 'published', label: 'Veröffentlicht' }, +const SORT_HEADERS: Array<{ key: 'deadline' | 'value' | 'published'; labelKey: string }> = [ + { key: 'deadline', labelKey: 'results.colDeadline' }, + { key: 'value', labelKey: 'results.colValue' }, + { key: 'published', labelKey: 'results.colPublished' }, ]; const DEFAULT_LIMIT = 20; @@ -44,13 +48,13 @@ function formatDate(value: string | null): string { /** * D-05: estimatedValue arrives as a string (Prisma Decimal) or null. - * Renders "keine Wertangabe" for null/unparseable values — never blank, - * never 0, and never a thrown error. + * Renders the translated "no value stated" label for null/unparseable + * values — never blank, never 0, and never a thrown error. */ -function formatValue(value: string | null): string { - if (value === null || value === undefined) return 'keine Wertangabe'; +function formatValue(value: string | null, noValueLabel: string): string { + if (value === null || value === undefined) return noValueLabel; const n = Number(value); - if (!Number.isFinite(n)) return 'keine Wertangabe'; + if (!Number.isFinite(n)) return noValueLabel; return n.toLocaleString('de-DE', { style: 'currency', currency: 'EUR', @@ -59,6 +63,7 @@ function formatValue(value: string | null): string { } export function ResultsList() { + const t = useTranslations('tenderRadar'); const router = useRouter(); const searchParams = useSearchParams(); const paramsKey = searchParams.toString(); @@ -90,7 +95,7 @@ export function ResultsList() { // key_link: "GET /triage batch → merge in ResultsList"). Triage is a // supplementary per-user overlay — a failed batch-load must not block // rendering the tender list itself. - const ids = data.items.map((t) => t.id); + const ids = data.items.map((item) => item.id); try { const rows = await fetchTriage(ids); if (Array.isArray(rows)) { @@ -110,13 +115,12 @@ export function ResultsList() { } } catch (err) { setError( - err instanceof Error - ? err.message - : 'Ausschreibungen konnten nicht geladen werden.', + err instanceof Error ? err.message : t('results.error'), ); } finally { setIsLoading(false); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [paramsKey]); useEffect(() => { @@ -198,8 +202,8 @@ export function ResultsList() { - - + + {SORT_HEADERS.map((h) => ( ))} - + @@ -235,11 +239,10 @@ export function ResultsList() { @@ -262,7 +265,9 @@ export function ResultsList() { - + @@ -318,8 +323,11 @@ export function ResultsList() { {!isLoading && total > limit && (
- Zeige {(page - 1) * limit + 1}–{Math.min(page * limit, total)} von{' '} - {total} + {t('results.showingRange', { + from: String((page - 1) * limit + 1), + to: String(Math.min(page * limit, total)), + total: String(total), + })}
diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.test.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.test.tsx index d2cbd71..1d85bb9 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.test.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.test.tsx @@ -27,6 +27,45 @@ vi.mock('@/lib/tender-radar-api', () => ({ deleteSavedSearch: (...args: unknown[]) => mockDeleteSavedSearch(...args), })); +// Mock next-intl useTranslations — mirrors the marketplace test mock +// convention (tenant-selector.test.tsx): a flat key -> German-copy lookup +// table with {param} interpolation support. +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, params?: Record) => { + const translations: Record = { + 'savedSearch.title': 'Suchprofile', + 'savedSearch.saveAsLabel': 'Aktuelle Filter speichern als', + 'savedSearch.namePlaceholder': 'Name des Suchprofils…', + 'savedSearch.save': 'Speichern', + 'savedSearch.cancel': 'Abbrechen', + 'savedSearch.loading': 'Lädt…', + 'savedSearch.empty': 'Noch keine Suchprofile gespeichert.', + 'savedSearch.loadTitle': 'Suchprofil laden', + 'savedSearch.instantAlertTitle': 'Sofort-Alert per E-Mail bei neuen Treffern dieses Profils', + 'savedSearch.instantAlertLabel': 'Sofort-Alert', + 'savedSearch.instantAlertAria': 'Sofort-Alert für {name}', + 'savedSearch.renameTitle': 'Umbenennen', + 'savedSearch.renameAria': '{name} umbenennen', + 'savedSearch.renameConfirmAria': '{name} umbenennen bestätigen', + 'savedSearch.renameCancelAria': 'Umbenennen abbrechen', + 'savedSearch.deleteTitle': 'Löschen', + 'savedSearch.deleteAria': '{name} löschen', + 'savedSearch.errorLoad': 'Suchprofile konnten nicht geladen werden.', + 'savedSearch.errorSave': 'Suchprofil konnte nicht gespeichert werden.', + 'savedSearch.errorRename': 'Suchprofil konnte nicht umbenannt werden.', + 'savedSearch.errorDelete': 'Suchprofil konnte nicht gelöscht werden.', + 'savedSearch.errorToggleAlert': 'Sofort-Alert konnte nicht umgeschaltet werden.', + }; + let result = translations[key] ?? key; + if (params) { + for (const [k, v] of Object.entries(params)) { + result = result.replace(`{${k}}`, v); + } + } + return result; + }, +})); + const mockReplace = vi.fn(); let mockSearchParams = new URLSearchParams(); diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.tsx index e683418..39f635a 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/SavedSearchBar.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; import { useRouter, useSearchParams } from 'next/navigation'; import { createSavedSearch, @@ -36,8 +37,8 @@ import { * a profile field alongside name/filters, not a filter key — it never * touches the URL searchParams filters contract above. * - * Hardcoded German strings — i18n rollout is Phase 14 (Research Open - * Question 3, RESOLVED), same convention as FilterPanel/ResultsList. + * Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the + * tenderRadar i18n namespace. */ const SINGLE_VALUE_FILTER_KEYS = [ @@ -110,6 +111,7 @@ export function filtersToSearchParams( } export function SavedSearchBar() { + const t = useTranslations('tenderRadar'); const router = useRouter(); const searchParams = useSearchParams(); @@ -128,9 +130,7 @@ export function SavedSearchBar() { setProfiles(data); } catch (err) { setError( - err instanceof Error - ? err.message - : 'Suchprofile konnten nicht geladen werden.', + err instanceof Error ? err.message : t('savedSearch.errorLoad'), ); } finally { setIsLoading(false); @@ -153,9 +153,7 @@ export function SavedSearchBar() { await load(); } catch (err) { setError( - err instanceof Error - ? err.message - : 'Suchprofil konnte nicht gespeichert werden.', + err instanceof Error ? err.message : t('savedSearch.errorSave'), ); } }; @@ -185,9 +183,7 @@ export function SavedSearchBar() { await load(); } catch (err) { setError( - err instanceof Error - ? err.message - : 'Suchprofil konnte nicht umbenannt werden.', + err instanceof Error ? err.message : t('savedSearch.errorRename'), ); } }; @@ -199,9 +195,7 @@ export function SavedSearchBar() { await load(); } catch (err) { setError( - err instanceof Error - ? err.message - : 'Suchprofil konnte nicht gelöscht werden.', + err instanceof Error ? err.message : t('savedSearch.errorDelete'), ); } }; @@ -213,16 +207,14 @@ export function SavedSearchBar() { await load(); } catch (err) { setError( - err instanceof Error - ? err.message - : 'Sofort-Alert konnte nicht umgeschaltet werden.', + err instanceof Error ? err.message : t('savedSearch.errorToggleAlert'), ); } }; return (
-

Suchprofile

+

{t('savedSearch.title')}

@@ -230,12 +222,12 @@ export function SavedSearchBar() { htmlFor="tr-saved-search-name" className="mb-1 block text-sm text-foreground" > - Aktuelle Filter speichern als + {t('savedSearch.saveAsLabel')} setNewName(e.target.value)} className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground" @@ -247,17 +239,17 @@ export function SavedSearchBar() { disabled={!newName.trim()} className="h-9 rounded border border-border px-3 text-sm text-foreground transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50" > - Speichern + {t('savedSearch.save')}
{error &&

{error}

} {isLoading ? ( -

Lädt…

+

{t('savedSearch.loading')}

) : profiles.length === 0 ? (

- Noch keine Suchprofile gespeichert. + {t('savedSearch.empty')}

) : (
    @@ -276,19 +268,19 @@ export function SavedSearchBar() { /> ) : ( @@ -296,41 +288,41 @@ export function SavedSearchBar() { )} diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.test.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.test.tsx index 1a1c173..5e579b8 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.test.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.test.tsx @@ -24,6 +24,46 @@ vi.mock('@/lib/tender-radar-api', () => ({ getTender: (...args: unknown[]) => mockGetTender(...args), })); +// Mock next-intl useTranslations — mirrors the marketplace test mock +// convention (tenant-selector.test.tsx): a flat key -> German-copy lookup +// table with {param} interpolation support. +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, params?: Record) => { + const translations: Record = { + 'detail.dialogAria': 'Ausschreibungsdetails', + 'detail.title': 'Ausschreibungsdetails', + 'detail.close': 'Schließen', + 'detail.loading': 'Lädt…', + 'detail.error': 'Ausschreibung konnte nicht geladen werden.', + 'detail.fieldTitle': 'Titel', + 'detail.fieldBuyer': 'Auftraggeber', + 'detail.fieldRegion': 'Region / PLZ / Bundesland', + 'detail.fieldCpv': 'CPV-Codes', + 'detail.noCpv': 'keine CPV-Codes angegeben', + 'detail.fieldDeadline': 'Abgabefrist', + 'detail.noDeadline': 'keine Frist angegeben', + 'detail.fieldValue': 'Geschätzter Auftragswert', + 'detail.noValue': 'keine Wertangabe', + 'detail.fieldProcedureType': 'Verfahrensart', + 'detail.fieldStatus': 'Status', + 'detail.fieldPublished': 'Veröffentlicht', + 'detail.mirrorNotice': + 'Vergabeunterlagen werden nicht lokal gespiegelt — der folgende Link führt direkt zum Original auf dem Quellportal.', + 'detail.sourceLink': '{portal}: Zur Ausschreibung auf dem Quellportal →', + 'detail.noSourceLink': '{portal}: Kein Quell-Link verfügbar.', + 'detail.sourceLinkFallback': 'Zur Ausschreibung auf dem Quellportal →', + 'detail.noSourceLinkFallback': 'Kein Quell-Link verfügbar.', + }; + let result = translations[key] ?? key; + if (params) { + for (const [k, v] of Object.entries(params)) { + result = result.replace(`{${k}}`, v); + } + } + return result; + }, +})); + afterEach(() => { cleanup(); mockGetTender.mockReset(); diff --git a/apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.tsx b/apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.tsx index 5d376ae..2a5fc1a 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/components/TenderDetail.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; import { getTender, type Tender } from '@/lib/tender-radar-api'; /** @@ -27,8 +28,8 @@ import { getTender, type Tender } from '@/lib/tender-radar-api'; * Fallback auf den einzelnen `sourceUrl`-Block, falls `sources` fehlt * oder leer ist (Alt-Tender / API-Antworten ohne include). * - * Hardcoded German strings — i18n rollout is Phase 14 (Research Open - * Question 3, RESOLVED), same convention as ResultsList/FilterPanel. + * Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the + * tenderRadar i18n namespace. */ interface TenderDetailProps { @@ -36,22 +37,22 @@ interface TenderDetailProps { onClose: () => void; } -/** D-05 gilt auch für die Detailansicht: NIE ausblenden, NIE Fehler werfen. */ -function formatDeadline(value: string | null): string { - if (!value) return 'keine Frist angegeben'; +/** D-05 applies to the detail view too: NEVER hide, NEVER throw. */ +function formatDeadline(value: string | null, noDeadlineLabel: string): string { + if (!value) return noDeadlineLabel; const d = new Date(value); - if (isNaN(d.getTime())) return 'keine Frist angegeben'; + if (isNaN(d.getTime())) return noDeadlineLabel; return d.toLocaleDateString('de-DE'); } /** - * `estimatedValue` kommt als String (Prisma Decimal) oder null — niemals - * blind `Number()`-koerzieren (gleiche Regel wie ResultsList.formatValue). + * `estimatedValue` arrives as a string (Prisma Decimal) or null — never + * blindly coerce with `Number()` (same rule as ResultsList.formatValue). */ -function formatValue(value: string | null): string { - if (value === null || value === undefined) return 'keine Wertangabe'; +function formatValue(value: string | null, noValueLabel: string): string { + if (value === null || value === undefined) return noValueLabel; const n = Number(value); - if (!Number.isFinite(n)) return 'keine Wertangabe'; + if (!Number.isFinite(n)) return noValueLabel; return n.toLocaleString('de-DE', { style: 'currency', currency: 'EUR', @@ -87,6 +88,7 @@ function portalLabel(sourcePortal: string): string { } export function TenderDetail({ tenderId, onClose }: TenderDetailProps) { + const t = useTranslations('tenderRadar'); const [tender, setTender] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -104,9 +106,7 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) { .catch((err) => { if (!cancelled) { setError( - err instanceof Error - ? err.message - : 'Ausschreibung konnte nicht geladen werden.', + err instanceof Error ? err.message : t('detail.error'), ); } }) @@ -124,12 +124,12 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) { className="fixed inset-0 z-50 flex justify-end bg-black/40" role="dialog" aria-modal="true" - aria-label="Ausschreibungsdetails" + aria-label={t('detail.dialogAria')} > {/* Backdrop click closes the drawer, mirroring standard overlay UX */}
{isLoading ? ( -

Lädt…

+

{t('detail.loading')}

) : error ? (

{error}

) : tender ? (
-
Titel
+
{t('detail.fieldTitle')}
{tender.title}
-
Auftraggeber
+
{t('detail.fieldBuyer')}
{tender.buyerName ?? '–'}
-
Region / PLZ / Bundesland
+
{t('detail.fieldRegion')}
{[tender.region, tender.plz, tender.bundesland] .filter(Boolean) @@ -177,44 +177,46 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
-
CPV-Codes
+
{t('detail.fieldCpv')}
{tender.cpvCodes.length > 0 ? tender.cpvCodes.join(', ') - : 'keine CPV-Codes angegeben'} + : t('detail.noCpv')}
-
Abgabefrist
-
{formatDeadline(tender.deadlineAt)}
+
{t('detail.fieldDeadline')}
+
+ {formatDeadline(tender.deadlineAt, t('detail.noDeadline'))} +
-
Geschätzter Auftragswert
-
{formatValue(tender.estimatedValue)}
+
{t('detail.fieldValue')}
+
+ {formatValue(tender.estimatedValue, t('detail.noValue'))} +
-
Verfahrensart
+
{t('detail.fieldProcedureType')}
{tender.procedureType ?? '–'}
-
Status
+
{t('detail.fieldStatus')}
{tender.status}
-
Veröffentlicht
+
{t('detail.fieldPublished')}
{formatPublished(tender.publishedAt)}

- Vergabeunterlagen werden nicht lokal gespiegelt — der - folgende Link führt direkt zum Original auf dem - Quellportal. + {t('detail.mirrorNotice')}

{tender.sources && tender.sources.length > 0 ? (
    @@ -227,13 +229,11 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) { rel="noopener noreferrer" className="inline-block text-sm font-medium text-primary underline underline-offset-2 hover:text-primary/80" > - {portalLabel(source.sourcePortal)}: Zur Ausschreibung - auf dem Quellportal → + {t('detail.sourceLink', { portal: portalLabel(source.sourcePortal) })} ) : ( - {portalLabel(source.sourcePortal)}: Kein Quell-Link - verfügbar. + {t('detail.noSourceLink', { portal: portalLabel(source.sourcePortal) })} )} @@ -246,11 +246,11 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) { rel="noopener noreferrer" className="mt-2 inline-block text-sm font-medium text-primary underline underline-offset-2 hover:text-primary/80" > - Zur Ausschreibung auf dem Quellportal → + {t('detail.sourceLinkFallback')} ) : (

    - Kein Quell-Link verfügbar. + {t('detail.noSourceLinkFallback')}

    )}
diff --git a/apps/web/src/app/(portal)/modules/tender-radar/page.tsx b/apps/web/src/app/(portal)/modules/tender-radar/page.tsx index bed0f3d..772a438 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/page.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { Suspense } from 'react'; +import { useTranslations } from 'next-intl'; import { useRouter, useSearchParams } from 'next/navigation'; import { CoverageBanner } from './components/CoverageBanner'; import { FilterPanel } from './components/FilterPanel'; @@ -34,11 +35,11 @@ import { TenderDetail } from './components/TenderDetail'; * uses, so saving/loading a profile is congruent with the filter state the * user currently sees. * - * Intentional hardcoded German strings: full i18n (next-intl message - * keys) is CONFIG-03, planned for Phase 14 (Research Open Question 3, - * RESOLVED). + * Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the + * tenderRadar i18n namespace — no more hardcoded German literals. */ function TenderRadarContent() { + const t = useTranslations('tenderRadar'); const router = useRouter(); const searchParams = useSearchParams(); const tenderId = searchParams.get('tender'); @@ -53,7 +54,7 @@ function TenderRadarContent() {

- Ausschreibungs-Radar + {t('page.title')}

@@ -67,15 +68,18 @@ function TenderRadarContent() { ); } +function TenderRadarFallback() { + const t = useTranslations('tenderRadar'); + return ( +
+

{t('page.loading')}

+
+ ); +} + export default function TenderRadarPage() { return ( - -

Lädt…

-
- } - > + }> );
TitelAuftraggeber{t('results.colTitle')}{t('results.colBuyer')} handleSort(h.key)} aria-sort={currentSort === h.key ? 'ascending' : 'none'} > - {h.label} + {t(h.labelKey)} {currentSort === h.key ? ' ▾' : ''} Triage{t('results.colTriage')}

- Keine Ausschreibungen gefunden. + {t('results.emptyTitle')}

- Passen Sie die Filter an oder deaktivieren Sie „nur noch - offene". + {t('results.emptyBody')}

{tender.buyerName ?? '–'} {formatDate(tender.deadlineAt)}{formatValue(tender.estimatedValue)} + {formatValue(tender.estimatedValue, t('results.noValue'))} + {formatDate(tender.publishedAt)}
@@ -272,17 +277,17 @@ export function ResultsList() { aria-pressed={rowTriage.isRead} aria-label={ rowTriage.isRead - ? 'Als ungelesen markieren' - : 'Als gelesen markieren' + ? t('results.markUnread') + : t('results.markRead') } title={ rowTriage.isRead - ? 'Als ungelesen markieren' - : 'Als gelesen markieren' + ? t('results.markUnread') + : t('results.markRead') } className="rounded border border-border px-2 py-1 text-xs text-foreground transition-colors hover:bg-muted" > - {rowTriage.isRead ? 'Gelesen' : 'Ungelesen'} + {rowTriage.isRead ? t('results.readLabel') : t('results.unreadLabel')}