feat(14-05): convert tender-radar results-side components to useTranslations

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 14:15:33 +02:00
parent d73171b052
commit 2ea02a2671
10 changed files with 338 additions and 230 deletions
@@ -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<string, string> = {
'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();
@@ -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<CoverageResponse | null>(null);
const [denylistedPortals, setDenylistedPortals] = useState<DenylistedPortal[]>([]);
@@ -53,17 +55,14 @@ export function CoverageBanner() {
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
{showCoverageNote && (
<p>
<strong className="text-foreground">Datenabdeckung:</strong> 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.
<strong className="text-foreground">{t('coverage.label')}</strong>{' '}
{t('coverage.text')}
</p>
)}
{denylistedPortals.length > 0 && (
<p className={showCoverageNote ? 'mt-2' : undefined}>
<strong className="text-foreground">Manuell beobachten:</strong>{' '}
Diese Portale lassen laut ihren Nutzungsbedingungen keinen
automatisierten Zugriff zu und müssen daher manuell geprüft werden:{' '}
<strong className="text-foreground">{t('coverage.denylistLabel')}</strong>{' '}
{t('coverage.denylistText')}{' '}
{denylistedPortals.map((p, i) => (
<span key={p.portal}>
{i > 0 && ', '}
@@ -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() {
<div className="flex flex-wrap items-end gap-4">
<div className="min-w-[220px] flex-1">
<label htmlFor="tr-filter-q" className={labelCls}>
Suche
{t('filter.searchLabel')}
</label>
<input
id="tr-filter-q"
type="text"
placeholder="Titel oder Auftraggeber…"
placeholder={t('filter.searchPlaceholder')}
value={q}
onChange={(e) => setParam('q', e.target.value)}
className={inputCls}
@@ -196,7 +175,7 @@ export function FilterPanel() {
<div>
<label htmlFor="tr-filter-sort" className={labelCls}>
Sortierung
{t('filter.sortLabel')}
</label>
<select
id="tr-filter-sort"
@@ -206,7 +185,7 @@ export function FilterPanel() {
>
{SORT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
{t(opt.labelKey)}
</option>
))}
</select>
@@ -221,7 +200,7 @@ export function FilterPanel() {
className="h-4 w-4"
/>
<label htmlFor="tr-filter-open-only" className="text-sm text-foreground">
Nur noch offene
{t('filter.openOnlyLabel')}
</label>
</div>
@@ -234,7 +213,7 @@ export function FilterPanel() {
className="h-4 w-4"
/>
<label htmlFor="tr-filter-fav-only" className="text-sm text-foreground">
Nur Favoriten/Merkliste
{t('filter.favOnlyLabel')}
</label>
</div>
</div>
@@ -242,7 +221,7 @@ export function FilterPanel() {
<div className="flex flex-wrap items-end gap-4">
<div>
<label htmlFor="tr-filter-deadline-from" className={labelCls}>
Abgabefrist von
{t('filter.deadlineFromLabel')}
</label>
<input
id="tr-filter-deadline-from"
@@ -254,7 +233,7 @@ export function FilterPanel() {
</div>
<div>
<label htmlFor="tr-filter-deadline-to" className={labelCls}>
Abgabefrist bis
{t('filter.deadlineToLabel')}
</label>
<input
id="tr-filter-deadline-to"
@@ -269,14 +248,14 @@ export function FilterPanel() {
<div className="flex flex-wrap items-end gap-4">
<div>
<label htmlFor="tr-filter-plz" className={labelCls}>
PLZ
{t('filter.plzLabel')}
</label>
<input
id="tr-filter-plz"
type="text"
inputMode="numeric"
maxLength={5}
placeholder="z. B. 10"
placeholder={t('filter.plzPlaceholder')}
value={plz}
onChange={(e) => 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() {
<div className="min-w-[220px]">
<label htmlFor="tr-filter-bundesland" className={labelCls}>
Bundesland
{t('filter.bundeslandLabel')}
</label>
<select
id="tr-filter-bundesland"
@@ -293,10 +272,10 @@ export function FilterPanel() {
onChange={(e) => setParam('bundesland', e.target.value || null)}
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground"
>
<option value="">Alle Bundesländer</option>
{BUNDESLAND_OPTIONS.map((name) => (
<option key={name} value={name}>
{name}
<option value="">{t('filter.bundeslandAll')}</option>
{BUNDESLAND_OPTIONS.map((b) => (
<option key={b.value} value={b.value}>
{t(`filter.bundeslandOptions.${b.code}`)}
</option>
))}
</select>
@@ -306,12 +285,12 @@ export function FilterPanel() {
<div className="flex flex-wrap items-start gap-4">
<div className="relative min-w-[280px] flex-1">
<label htmlFor="tr-filter-cpv" className={labelCls}>
Branche (CPV)
{t('filter.cpvLabel')}
</label>
<input
id="tr-filter-cpv"
type="text"
placeholder="z. B. Bau, IT, Gesundheit…"
placeholder={t('filter.cpvPlaceholder')}
value={cpvSearchText}
onChange={(e) => setCpvSearchText(e.target.value)}
className={inputCls}
@@ -345,7 +324,7 @@ export function FilterPanel() {
{code} – {labelForCpvDivision(code)}
<button
type="button"
aria-label={`Division ${code} entfernen`}
aria-label={t('filter.cpvRemoveAria', { code })}
onClick={() => toggleCpvDivision(code)}
className="text-muted-foreground hover:text-foreground"
>
@@ -359,14 +338,14 @@ export function FilterPanel() {
<div>
<label htmlFor="tr-filter-value-min" className={labelCls}>
Auftragswert von (€)
{t('filter.valueMinLabel')}
</label>
<input
id="tr-filter-value-min"
type="number"
inputMode="decimal"
min={0}
placeholder="min."
placeholder={t('filter.valueMinPlaceholder')}
value={valueMin}
onChange={(e) => setParam('valueMin', e.target.value)}
className="h-9 w-32 rounded border border-border bg-background px-3 text-sm text-foreground"
@@ -374,14 +353,14 @@ export function FilterPanel() {
</div>
<div>
<label htmlFor="tr-filter-value-max" className={labelCls}>
Auftragswert bis (€)
{t('filter.valueMaxLabel')}
</label>
<input
id="tr-filter-value-max"
type="number"
inputMode="decimal"
min={0}
placeholder="max."
placeholder={t('filter.valueMaxPlaceholder')}
value={valueMax}
onChange={(e) => setParam('valueMax', e.target.value)}
className="h-9 w-32 rounded border border-border bg-background px-3 text-sm text-foreground"
@@ -401,7 +380,7 @@ export function FilterPanel() {
htmlFor="tr-filter-include-null-value"
className="text-sm text-foreground"
>
Ohne Wertangabe einschließen
{t('filter.includeNullValueLabel')}
</label>
</div>
</div>
@@ -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<string, string> = {
'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();
@@ -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() {
<table className="w-full text-sm">
<thead className="bg-muted/30">
<tr>
<th className={thStaticCls}>Titel</th>
<th className={thStaticCls}>Auftraggeber</th>
<th className={thStaticCls}>{t('results.colTitle')}</th>
<th className={thStaticCls}>{t('results.colBuyer')}</th>
{SORT_HEADERS.map((h) => (
<th
key={h.key}
@@ -207,11 +211,11 @@ export function ResultsList() {
onClick={() => handleSort(h.key)}
aria-sort={currentSort === h.key ? 'ascending' : 'none'}
>
{h.label}
{t(h.labelKey)}
{currentSort === h.key ? ' ▾' : ''}
</th>
))}
<th className={thStaticCls}>Triage</th>
<th className={thStaticCls}>{t('results.colTriage')}</th>
</tr>
</thead>
<tbody>
@@ -235,11 +239,10 @@ export function ResultsList() {
<tr>
<td colSpan={6} className="px-4 py-10 text-center">
<p className="font-medium text-foreground">
Keine Ausschreibungen gefunden.
{t('results.emptyTitle')}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Passen Sie die Filter an oder deaktivieren Sie „nur noch
offene".
{t('results.emptyBody')}
</p>
</td>
</tr>
@@ -262,7 +265,9 @@ export function ResultsList() {
</td>
<td className="px-4 py-3">{tender.buyerName ?? '–'}</td>
<td className="px-4 py-3">{formatDate(tender.deadlineAt)}</td>
<td className="px-4 py-3">{formatValue(tender.estimatedValue)}</td>
<td className="px-4 py-3">
{formatValue(tender.estimatedValue, t('results.noValue'))}
</td>
<td className="px-4 py-3">{formatDate(tender.publishedAt)}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
@@ -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')}
</button>
<button
type="button"
@@ -290,13 +295,13 @@ export function ResultsList() {
aria-pressed={rowTriage.isFavorite}
aria-label={
rowTriage.isFavorite
? 'Favorit entfernen'
: 'Favorit setzen'
? t('results.removeFavorite')
: t('results.setFavorite')
}
title={
rowTriage.isFavorite
? 'Favorit entfernen'
: 'Favorit setzen'
? t('results.removeFavorite')
: t('results.setFavorite')
}
className={`rounded border px-2 py-1 text-xs transition-colors hover:bg-muted ${
rowTriage.isFavorite
@@ -304,7 +309,7 @@ export function ResultsList() {
: 'border-border text-foreground'
}`}
>
{rowTriage.isFavorite ? '★ Favorit' : '☆ Favorit'}
{rowTriage.isFavorite ? t('results.favoriteOn') : t('results.favoriteOff')}
</button>
</div>
</td>
@@ -318,8 +323,11 @@ export function ResultsList() {
{!isLoading && total > limit && (
<div className="flex items-center justify-between px-4 py-3 border-t border-border">
<span className="text-sm text-muted-foreground">
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),
})}
</span>
<div className="flex gap-2">
<button
@@ -328,7 +336,7 @@ export function ResultsList() {
disabled={page <= 1}
className="rounded border border-border px-3 py-1 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
← Zurück
{t('results.prevPage')}
</button>
<button
type="button"
@@ -336,7 +344,7 @@ export function ResultsList() {
disabled={page >= totalPages}
className="rounded border border-border px-3 py-1 text-sm text-foreground transition-colors hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed"
>
Weiter →
{t('results.nextPage')}
</button>
</div>
</div>
@@ -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<string, string>) => {
const translations: Record<string, string> = {
'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();
@@ -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 (
<div className="rounded-lg border border-border bg-card p-4 shadow-sm space-y-3">
<h2 className="text-sm font-semibold text-foreground">Suchprofile</h2>
<h2 className="text-sm font-semibold text-foreground">{t('savedSearch.title')}</h2>
<div className="flex flex-wrap items-end gap-2">
<div className="min-w-[220px] flex-1">
@@ -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')}
</label>
<input
id="tr-saved-search-name"
type="text"
placeholder="Name des Suchprofils…"
placeholder={t('savedSearch.namePlaceholder')}
value={newName}
onChange={(e) => 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')}
</button>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
{isLoading ? (
<p className="text-sm text-muted-foreground">Lädt…</p>
<p className="text-sm text-muted-foreground">{t('savedSearch.loading')}</p>
) : profiles.length === 0 ? (
<p className="text-sm text-muted-foreground">
Noch keine Suchprofile gespeichert.
{t('savedSearch.empty')}
</p>
) : (
<ul className="flex flex-wrap gap-2">
@@ -276,19 +268,19 @@ export function SavedSearchBar() {
/>
<button
type="button"
aria-label={`${profile.name} umbenennen bestätigen`}
aria-label={t('savedSearch.renameConfirmAria', { name: profile.name })}
onClick={() => confirmRename(profile.id)}
className="text-xs font-medium text-primary hover:underline"
>
Speichern
{t('savedSearch.save')}
</button>
<button
type="button"
aria-label="Umbenennen abbrechen"
aria-label={t('savedSearch.renameCancelAria')}
onClick={cancelRename}
className="text-xs text-muted-foreground hover:text-foreground"
>
Abbrechen
{t('savedSearch.cancel')}
</button>
</>
) : (
@@ -296,41 +288,41 @@ export function SavedSearchBar() {
<button
type="button"
onClick={() => handleLoad(profile)}
title="Suchprofil laden"
title={t('savedSearch.loadTitle')}
className="font-medium hover:underline"
>
{profile.name}
</button>
<label
className="flex items-center gap-1 text-xs text-muted-foreground"
title="Sofort-Alert per E-Mail bei neuen Treffern dieses Profils"
title={t('savedSearch.instantAlertTitle')}
>
<input
type="checkbox"
aria-label={`Sofort-Alert für ${profile.name}`}
aria-label={t('savedSearch.instantAlertAria', { name: profile.name })}
checked={profile.instantAlert}
onChange={() => handleToggleInstantAlert(profile)}
className="h-3.5 w-3.5"
/>
Sofort-Alert
{t('savedSearch.instantAlertLabel')}
</label>
<button
type="button"
aria-label={`${profile.name} umbenennen`}
title="Umbenennen"
aria-label={t('savedSearch.renameAria', { name: profile.name })}
title={t('savedSearch.renameTitle')}
onClick={() => startRename(profile)}
className="text-xs text-muted-foreground hover:text-foreground"
>
Umbenennen
{t('savedSearch.renameTitle')}
</button>
<button
type="button"
aria-label={`${profile.name} löschen`}
title="Löschen"
aria-label={t('savedSearch.deleteAria', { name: profile.name })}
title={t('savedSearch.deleteTitle')}
onClick={() => handleDelete(profile)}
className="text-xs text-muted-foreground hover:text-destructive"
>
Löschen
{t('savedSearch.deleteTitle')}
</button>
</>
)}
@@ -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<string, string>) => {
const translations: Record<string, string> = {
'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();
@@ -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<Tender | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(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 */}
<button
type="button"
aria-label="Schließen"
aria-label={t('detail.close')}
onClick={onClose}
className="absolute inset-0 h-full w-full cursor-default"
tabIndex={-1}
@@ -138,37 +138,37 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
<div className="relative h-full w-full max-w-lg overflow-y-auto border-l border-border bg-background p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-bold tracking-tight">
Ausschreibungsdetails
{t('detail.title')}
</h2>
<button
type="button"
onClick={onClose}
className="rounded border border-border px-3 py-1 text-sm text-foreground transition-colors hover:bg-muted"
>
Schließen
{t('detail.close')}
</button>
</div>
{isLoading ? (
<p className="text-sm text-muted-foreground">Lädt…</p>
<p className="text-sm text-muted-foreground">{t('detail.loading')}</p>
) : error ? (
<p className="text-sm text-destructive">{error}</p>
) : tender ? (
<dl className="space-y-4 text-sm">
<div>
<dt className={fieldLabelCls()}>Titel</dt>
<dt className={fieldLabelCls()}>{t('detail.fieldTitle')}</dt>
<dd className="mt-1 font-medium text-foreground">
{tender.title}
</dd>
</div>
<div>
<dt className={fieldLabelCls()}>Auftraggeber</dt>
<dt className={fieldLabelCls()}>{t('detail.fieldBuyer')}</dt>
<dd className="mt-1">{tender.buyerName ?? '–'}</dd>
</div>
<div>
<dt className={fieldLabelCls()}>Region / PLZ / Bundesland</dt>
<dt className={fieldLabelCls()}>{t('detail.fieldRegion')}</dt>
<dd className="mt-1">
{[tender.region, tender.plz, tender.bundesland]
.filter(Boolean)
@@ -177,44 +177,46 @@ export function TenderDetail({ tenderId, onClose }: TenderDetailProps) {
</div>
<div>
<dt className={fieldLabelCls()}>CPV-Codes</dt>
<dt className={fieldLabelCls()}>{t('detail.fieldCpv')}</dt>
<dd className="mt-1">
{tender.cpvCodes.length > 0
? tender.cpvCodes.join(', ')
: 'keine CPV-Codes angegeben'}
: t('detail.noCpv')}
</dd>
</div>
<div>
<dt className={fieldLabelCls()}>Abgabefrist</dt>
<dd className="mt-1">{formatDeadline(tender.deadlineAt)}</dd>
<dt className={fieldLabelCls()}>{t('detail.fieldDeadline')}</dt>
<dd className="mt-1">
{formatDeadline(tender.deadlineAt, t('detail.noDeadline'))}
</dd>
</div>
<div>
<dt className={fieldLabelCls()}>Geschätzter Auftragswert</dt>
<dd className="mt-1">{formatValue(tender.estimatedValue)}</dd>
<dt className={fieldLabelCls()}>{t('detail.fieldValue')}</dt>
<dd className="mt-1">
{formatValue(tender.estimatedValue, t('detail.noValue'))}
</dd>
</div>
<div>
<dt className={fieldLabelCls()}>Verfahrensart</dt>
<dt className={fieldLabelCls()}>{t('detail.fieldProcedureType')}</dt>
<dd className="mt-1">{tender.procedureType ?? '–'}</dd>
</div>
<div>
<dt className={fieldLabelCls()}>Status</dt>
<dt className={fieldLabelCls()}>{t('detail.fieldStatus')}</dt>
<dd className="mt-1">{tender.status}</dd>
</div>
<div>
<dt className={fieldLabelCls()}>Veröffentlicht</dt>
<dt className={fieldLabelCls()}>{t('detail.fieldPublished')}</dt>
<dd className="mt-1">{formatPublished(tender.publishedAt)}</dd>
</div>
<div className="rounded border border-border bg-muted/30 p-3">
<p className="text-xs text-muted-foreground">
Vergabeunterlagen werden nicht lokal gespiegelt — der
folgende Link führt direkt zum Original auf dem
Quellportal.
{t('detail.mirrorNotice')}
</p>
{tender.sources && tender.sources.length > 0 ? (
<ul className="mt-2 space-y-1">
@@ -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) })}
</a>
) : (
<span className="text-sm text-muted-foreground">
{portalLabel(source.sourcePortal)}: Kein Quell-Link
verfügbar.
{t('detail.noSourceLink', { portal: portalLabel(source.sourcePortal) })}
</span>
)}
</li>
@@ -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')}
</a>
) : (
<p className="mt-2 text-sm text-muted-foreground">
Kein Quell-Link verfügbar.
{t('detail.noSourceLinkFallback')}
</p>
)}
</div>
@@ -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() {
<div className="mx-auto max-w-5xl space-y-6 p-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">
Ausschreibungs-Radar
{t('page.title')}
</h1>
</div>
@@ -67,15 +68,18 @@ function TenderRadarContent() {
);
}
function TenderRadarFallback() {
const t = useTranslations('tenderRadar');
return (
<div className="mx-auto max-w-5xl space-y-6 p-6">
<p className="text-sm text-muted-foreground">{t('page.loading')}</p>
</div>
);
}
export default function TenderRadarPage() {
return (
<Suspense
fallback={
<div className="mx-auto max-w-5xl space-y-6 p-6">
<p className="text-sm text-muted-foreground">Lädt…</p>
</div>
}
>
<Suspense fallback={<TenderRadarFallback />}>
<TenderRadarContent />
</Suspense>
);