From 849aa9b0a9542feeb85bf1c787f5b2b284a88124 Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 23 Jul 2026 14:20:52 +0200 Subject: [PATCH] feat(14-05): convert tender-radar settings + config forms to useTranslations Converts settings/page.tsx, SourceConfigForm, RssFeedListForm and EmailAlertConfigForm from hardcoded German strings to useTranslations('tenderRadar'). Interval bound and RSS-feed removal validation/error messages use next-intl interpolation ({min}/{max}, {label}). Updates the three affected settings component tests with a next-intl useTranslations mock mirroring the marketplace test convention. The entire tender-radar module UI (results, filters, saved searches, detail, coverage, settings, RSS/email forms) now honors the selected locale (CONFIG-03, D-10) with no language switcher added (D-11). Co-Authored-By: Claude Opus 4.8 --- .../components/EmailAlertConfigForm.test.tsx | 40 +++++++++++ .../components/EmailAlertConfigForm.tsx | 68 ++++++++++--------- .../components/RssFeedListForm.test.tsx | 32 +++++++++ .../settings/components/RssFeedListForm.tsx | 39 +++++------ .../components/SourceConfigForm.test.tsx | 30 ++++++++ .../settings/components/SourceConfigForm.tsx | 42 ++++++------ .../modules/tender-radar/settings/page.tsx | 48 ++++++------- 7 files changed, 198 insertions(+), 101 deletions(-) diff --git a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.test.tsx b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.test.tsx index a500881..051f404 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.test.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.test.tsx @@ -10,6 +10,46 @@ vi.mock('@/lib/tender-radar-api', () => ({ saveEmailConfig: (...args: unknown[]) => mockSaveEmailConfig(...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 = { + 'emailAlerts.notSaved': 'Noch nicht gespeichert — Postfach-Konfiguration ausfüllen und speichern.', + 'emailAlerts.protocolLabel': 'Protokoll *', + 'emailAlerts.protocolImap': 'IMAP', + 'emailAlerts.protocolExchange': 'Exchange', + 'emailAlerts.hostLabelExchange': 'EWS-Endpunkt-URL', + 'emailAlerts.hostLabelImap': 'IMAP-Server', + 'emailAlerts.hostPlaceholderExchange': 'https://mail.firma.de/EWS/Exchange.asmx', + 'emailAlerts.hostPlaceholderImap': 'imap.firma.de', + 'emailAlerts.portLabel': 'Port *', + 'emailAlerts.encryptionLabel': 'Verschlüsselung *', + 'emailAlerts.encryptionNone': 'Keine', + 'emailAlerts.encryptionStarttls': 'STARTTLS', + 'emailAlerts.encryptionSslTls': 'SSL-TLS', + 'emailAlerts.folderLabel': 'Ordner', + 'emailAlerts.folderPlaceholderExchange': 'Inbox', + 'emailAlerts.folderPlaceholderImap': 'INBOX', + 'emailAlerts.domainLabel': 'Windows-Domäne', + 'emailAlerts.domainPlaceholder': 'CONTOSO', + 'emailAlerts.senderFilterLabel': 'Absenderfilter', + 'emailAlerts.senderFilterPlaceholder': 'alerts@vergabeportal.de', + 'emailAlerts.usernameLabel': 'Benutzername', + 'emailAlerts.passwordLabel': 'Passwort', + 'emailAlerts.passwordPlaceholder': '••••••••', + 'emailAlerts.showPassword': 'Passwort anzeigen', + 'emailAlerts.hidePassword': 'Passwort verbergen', + 'emailAlerts.activeLabel': 'Aktiv', + 'emailAlerts.saving': 'Wird gespeichert...', + 'emailAlerts.save': 'Speichern', + 'emailAlerts.saveSuccess': 'Einstellungen gespeichert.', + 'emailAlerts.errorSave': 'Einstellungen konnten nicht gespeichert werden', + }; + return translations[key] ?? key; + }, +})); + afterEach(() => { cleanup(); mockFetchEmailConfig.mockReset(); diff --git a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.tsx b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.tsx index c56afd4..71628b2 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/EmailAlertConfigForm.tsx @@ -1,6 +1,7 @@ 'use client'; import { useCallback, useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; import { type EmailAlertConfig, fetchEmailConfig, @@ -129,11 +130,11 @@ function formToPayload( * test-connection endpoint exists for this config (out of this plan's * scope). * - * Hardcoded German strings — full i18n is CONFIG-03 (Plan 14-05), matching - * the same intentional convention already used by SourceConfigForm/ - * RssFeedListForm. + * Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the + * tenderRadar i18n namespace. */ export function EmailAlertConfigForm() { + const t = useTranslations('tenderRadar'); const [form, setForm] = useState(DEFAULT_FORM); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); @@ -182,9 +183,7 @@ export function EmailAlertConfigForm() { setConfigExists(true); } catch (err) { setSaveError( - err instanceof Error - ? err.message - : 'Einstellungen konnten nicht gespeichert werden', + err instanceof Error ? err.message : t('emailAlerts.errorSave'), ); } finally { setIsSaving(false); @@ -212,15 +211,14 @@ export function EmailAlertConfigForm() {
{!configExists && (
- Noch nicht gespeichert — Postfach-Konfiguration ausfüllen und - speichern. + {t('emailAlerts.notSaved')}
)} {/* Protokoll */}
{/* Host / EWS-URL */}
)} @@ -299,12 +299,16 @@ export function EmailAlertConfigForm() { {/* Ordner / Postfach */}
update('folder', e.target.value)} @@ -315,12 +319,12 @@ export function EmailAlertConfigForm() { {form.protocol === 'exchange' && (
update('domain', e.target.value)} @@ -331,12 +335,12 @@ export function EmailAlertConfigForm() { {/* Absenderfilter */}
update('senderFilter', e.target.value)} @@ -346,7 +350,7 @@ export function EmailAlertConfigForm() { {/* Benutzername */}
update('password', e.target.value)} /> @@ -388,7 +392,7 @@ export function EmailAlertConfigForm() { htmlFor="email-alert-active" className="text-sm text-foreground cursor-pointer" > - Aktiv + {t('emailAlerts.activeLabel')}
{saveSuccess && (

- Einstellungen gespeichert. + {t('emailAlerts.saveSuccess')}

)} {saveError &&

{saveError}

} diff --git a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.test.tsx b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.test.tsx index 768abeb..e42b48e 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.test.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.test.tsx @@ -12,6 +12,38 @@ vi.mock('@/lib/tender-radar-api', () => ({ deleteRssFeed: (...args: unknown[]) => mockDeleteRssFeed(...args), })); +// Mock next-intl useTranslations — mirrors the marketplace test mock +// convention (tenant-selector.test.tsx) with {param} interpolation. +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, params?: Record) => { + const translations: Record = { + 'rssFeeds.errorLoad': 'RSS-Feeds konnten nicht geladen werden', + 'rssFeeds.errorAdd': 'RSS-Feed konnte nicht gespeichert werden', + 'rssFeeds.errorRemove': 'RSS-Feed konnte nicht entfernt werden', + 'rssFeeds.validationRequired': 'URL und Bezeichnung sind erforderlich.', + 'rssFeeds.empty': 'Noch keine RSS-Feeds hinterlegt.', + 'rssFeeds.activeLabel': 'aktiv', + 'rssFeeds.inactiveLabel': 'inaktiv', + 'rssFeeds.removeAria': 'RSS-Feed "{label}" entfernen', + 'rssFeeds.removing': 'Entfernt...', + 'rssFeeds.remove': 'Entfernen', + 'rssFeeds.urlLabel': 'Feed-URL *', + 'rssFeeds.urlPlaceholder': 'https://www.service.bund.de/.../RSSGenerator_Ausschreibungen.xml', + 'rssFeeds.labelLabel': 'Bezeichnung *', + 'rssFeeds.labelPlaceholder': 'z. B. service-bund', + 'rssFeeds.adding': 'Wird hinzugefügt...', + 'rssFeeds.add': 'Feed hinzufügen', + }; + let result = translations[key] ?? key; + if (params) { + for (const [k, v] of Object.entries(params)) { + result = result.replace(`{${k}}`, v); + } + } + return result; + }, +})); + afterEach(() => { cleanup(); mockListRssFeeds.mockReset(); diff --git a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.tsx b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.tsx index ff52068..4e68bbf 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/RssFeedListForm.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; import { type RssFeedSource, createRssFeed, @@ -22,10 +23,11 @@ import { * für automatisierten Zugriff gesperrt...") via `createRssFeed`'s relayed * error message. * - * Hardcoded German strings for now — full i18n is CONFIG-03 (Plan 14-05), - * matching the same intentional MVP-stub convention as SourceConfigForm. + * Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the + * tenderRadar i18n namespace. */ export function RssFeedListForm() { + const t = useTranslations('tenderRadar'); const [feeds, setFeeds] = useState([]); const [isLoading, setIsLoading] = useState(true); const [loadError, setLoadError] = useState(null); @@ -47,9 +49,7 @@ export function RssFeedListForm() { }) .catch((err) => { setLoadError( - err instanceof Error - ? err.message - : 'RSS-Feeds konnten nicht geladen werden', + err instanceof Error ? err.message : t('rssFeeds.errorLoad'), ); }) .finally(() => setIsLoading(false)); @@ -57,13 +57,14 @@ export function RssFeedListForm() { useEffect(() => { loadFeeds(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const handleAdd = async () => { setAddError(null); if (!url.trim() || !label.trim()) { - setAddError('URL und Bezeichnung sind erforderlich.'); + setAddError(t('rssFeeds.validationRequired')); return; } @@ -75,9 +76,7 @@ export function RssFeedListForm() { setLabel(''); } catch (err) { setAddError( - err instanceof Error - ? err.message - : 'RSS-Feed konnte nicht gespeichert werden', + err instanceof Error ? err.message : t('rssFeeds.errorAdd'), ); } finally { setIsAdding(false); @@ -92,9 +91,7 @@ export function RssFeedListForm() { setFeeds((prev) => prev.filter((feed) => feed.id !== id)); } catch (err) { setRemoveError( - err instanceof Error - ? err.message - : 'RSS-Feed konnte nicht entfernt werden', + err instanceof Error ? err.message : t('rssFeeds.errorRemove'), ); } finally { setRemovingId(null); @@ -124,7 +121,7 @@ export function RssFeedListForm() {

{feed.label}{' '} - ({feed.isActive ? 'aktiv' : 'inaktiv'}) + ({feed.isActive ? t('rssFeeds.activeLabel') : t('rssFeeds.inactiveLabel')})

@@ -135,16 +132,16 @@ export function RssFeedListForm() { type="button" onClick={() => handleRemove(feed.id)} disabled={removingId === feed.id} - aria-label={`RSS-Feed "${feed.label}" entfernen`} + aria-label={t('rssFeeds.removeAria', { label: feed.label })} className="shrink-0 rounded px-2 py-1 text-sm text-destructive transition-colors hover:bg-destructive/10 disabled:opacity-50 disabled:cursor-not-allowed" > - {removingId === feed.id ? 'Entfernt...' : 'Entfernen'} + {removingId === feed.id ? t('rssFeeds.removing') : t('rssFeeds.remove')} ))} {feeds.length === 0 && (

  • - Noch keine RSS-Feeds hinterlegt. + {t('rssFeeds.empty')}
  • )} @@ -156,13 +153,13 @@ export function RssFeedListForm() {
    { @@ -173,13 +170,13 @@ export function RssFeedListForm() {
    { @@ -194,7 +191,7 @@ export function RssFeedListForm() { disabled={isAdding} className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed" > - {isAdding ? 'Wird hinzugefügt...' : 'Feed hinzufügen'} + {isAdding ? t('rssFeeds.adding') : t('rssFeeds.add')} {addError &&

    {addError}

    }
    diff --git a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.test.tsx b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.test.tsx index 0849d05..77c261a 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.test.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.test.tsx @@ -10,6 +10,36 @@ vi.mock('@/lib/tender-radar-api', () => ({ saveSourceConfig: (...args: unknown[]) => mockSaveSourceConfig(...args), })); +// Mock next-intl useTranslations — mirrors the marketplace test mock +// convention (tenant-selector.test.tsx) with {param} interpolation. +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, params?: Record) => { + const translations: Record = { + 'sourceConfig.errorLoad': 'Konfiguration konnte nicht geladen werden', + 'sourceConfig.errorSave': 'Einstellungen konnten nicht gespeichert werden', + 'sourceConfig.validationError': 'Intervall muss zwischen {min} und {max} Minuten liegen.', + 'sourceConfig.sourceLabel': 'Quelle', + 'sourceConfig.lastIngestedLabel': 'Zuletzt erfasster Tag', + 'sourceConfig.neverIngested': 'Noch keine Erfassung', + 'sourceConfig.pollIntervalLabel': 'Abrufintervall (Minuten) *', + 'sourceConfig.minutesSuffix': 'Min.', + 'sourceConfig.pollIntervalHelp': + 'Cron-Taktfrequenz ({min}–{max} Min.) — der eigentliche DÖE-Abruf ist zusätzlich über den Tages-Cursor gedrosselt.', + 'sourceConfig.activeLabel': 'Aktiv', + 'sourceConfig.saving': 'Speichert...', + 'sourceConfig.save': 'Speichern', + 'sourceConfig.saveSuccess': 'Einstellungen gespeichert.', + }; + let result = translations[key] ?? key; + if (params) { + for (const [k, v] of Object.entries(params)) { + result = result.replace(`{${k}}`, v); + } + } + return result; + }, +})); + afterEach(() => { cleanup(); // Reset call history AND implementations between tests — restoreAllMocks diff --git a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.tsx b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.tsx index 79c3ff2..7a6b021 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/settings/components/SourceConfigForm.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; import { type SourceConfig, fetchSourceConfig, @@ -22,11 +23,11 @@ const MAX_POLL_INTERVAL_MIN = 1440; * day-cursor) and an isActive toggle. sourceType/lastIngestedDay are shown * read-only so the admin can see the day-cursor state. * - * Hardcoded German strings for now — full i18n is CONFIG-03 (Phase 14), - * matching the intentional MVP-stub convention already used by the - * tender-radar placeholder page (Plan 10-02). + * Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the + * tenderRadar i18n namespace. */ export function SourceConfigForm() { + const t = useTranslations('tenderRadar'); const [sourceType, setSourceType] = useState(''); const [lastIngestedDay, setLastIngestedDay] = useState(null); const [pollIntervalMin, setPollIntervalMin] = useState('60'); @@ -50,14 +51,13 @@ export function SourceConfigForm() { }) .catch((err) => { setSaveError( - err instanceof Error - ? err.message - : 'Konfiguration konnte nicht geladen werden', + err instanceof Error ? err.message : t('sourceConfig.errorLoad'), ); }) .finally(() => { setIsLoading(false); }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const handleIntervalChange = (value: string) => { @@ -85,7 +85,10 @@ export function SourceConfigForm() { parsedInterval > MAX_POLL_INTERVAL_MIN ) { setValidationError( - `Intervall muss zwischen ${MIN_POLL_INTERVAL_MIN} und ${MAX_POLL_INTERVAL_MIN} Minuten liegen.`, + t('sourceConfig.validationError', { + min: String(MIN_POLL_INTERVAL_MIN), + max: String(MAX_POLL_INTERVAL_MIN), + }), ); return; } @@ -103,9 +106,7 @@ export function SourceConfigForm() { setSaveSuccess(true); } catch (err) { setSaveError( - err instanceof Error - ? err.message - : 'Einstellungen konnten nicht gespeichert werden', + err instanceof Error ? err.message : t('sourceConfig.errorSave'), ); } finally { setIsSaving(false); @@ -133,22 +134,22 @@ export function SourceConfigForm() {
    {/* Read-only display fields */}
    - Quelle + {t('sourceConfig.sourceLabel')}

    {sourceType || 'doe-opendata'}

    - Zuletzt erfasster Tag + {t('sourceConfig.lastIngestedLabel')}

    {lastIngestedDay ? new Date(lastIngestedDay).toLocaleDateString('de-DE') - : 'Noch keine Erfassung'} + : t('sourceConfig.neverIngested')}

    {/* Abrufintervall */}
    handleIntervalChange(e.target.value)} /> - Min. + {t('sourceConfig.minutesSuffix')}

    - Cron-Taktfrequenz ({MIN_POLL_INTERVAL_MIN}–{MAX_POLL_INTERVAL_MIN} Min.) — der eigentliche DÖE-Abruf ist zusätzlich über den Tages-Cursor gedrosselt. + {t('sourceConfig.pollIntervalHelp', { + min: String(MIN_POLL_INTERVAL_MIN), + max: String(MAX_POLL_INTERVAL_MIN), + })}

    @@ -174,7 +178,7 @@ export function SourceConfigForm() { htmlFor="source-config-active" className="text-sm text-foreground cursor-pointer" > - Aktiv + {t('sourceConfig.activeLabel')}
    @@ -218,7 +222,7 @@ export function SourceConfigForm() { )} {saveSuccess && (

    - Einstellungen gespeichert. + {t('sourceConfig.saveSuccess')}

    )} {saveError &&

    {saveError}

    } diff --git a/apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx b/apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx index 3b30bb9..7b8a80e 100644 --- a/apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx +++ b/apps/web/src/app/(portal)/modules/tender-radar/settings/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; +import { useTranslations } from 'next-intl'; import { fetchNotificationPref, saveNotificationPref, @@ -40,11 +41,11 @@ import { SourceConfigForm } from './components/SourceConfigForm'; * Next.js App Router route nested under the already-whitelisted * `tender-radar` module page (Plan 10-02). * - * Intentional hardcoded German string: full i18n (next-intl message keys) - * is CONFIG-03, planned for Phase 14 — matching the convention already - * used by the tender-radar placeholder page. + * Plan 14-05 (CONFIG-03/UI-06, D-10): all strings render via the + * tenderRadar i18n namespace. */ export default function TenderRadarSettingsPage() { + const t = useTranslations('tenderRadar'); const [digestInterval, setDigestInterval] = useState< NotificationPref['digestInterval'] >('daily'); @@ -58,12 +59,11 @@ export default function TenderRadarSettingsPage() { .then((pref) => setDigestInterval(pref.digestInterval)) .catch((err) => { setError( - err instanceof Error - ? err.message - : 'Benachrichtigungseinstellung konnte nicht geladen werden', + err instanceof Error ? err.message : t('settings.errorLoad'), ); }) .finally(() => setIsLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const handleChange = async (value: string) => { @@ -78,9 +78,7 @@ export default function TenderRadarSettingsPage() { setSaveSuccess(true); } catch (err) { setError( - err instanceof Error - ? err.message - : 'Benachrichtigungseinstellung konnte nicht gespeichert werden', + err instanceof Error ? err.message : t('settings.errorSave'), ); } finally { setIsSaving(false); @@ -90,38 +88,33 @@ export default function TenderRadarSettingsPage() { return (

    - Ausschreibungs-Radar — Einstellungen + {t('settings.title')}

    - RSS-Feeds + {t('settings.rssSectionTitle')}

    - Öffentliche, plattformweite RSS-Quellen (z. B. service.bund.de oder - eine subreport-elvis-Kommunalfeed) — gilt für alle Mandanten - gleich, nicht pro Mandant konfigurierbar. + {t('settings.rssSectionBody')}

    - E-Mail-Alerts + {t('settings.emailSectionTitle')}

    - Postfach, in das Vergabeportale Ihre Benachrichtigungsmails - schicken — pro Mandant konfigurierbar, getrennt vom - DKV-Rechnungspostfach. Aus diesen Mails erkannte Ausschreibungen - sind nur für Ihren Mandanten sichtbar. + {t('settings.emailSectionBody')}

    - Benachrichtigungen + {t('settings.notificationsSectionTitle')}

    {isLoading ? ( @@ -132,7 +125,7 @@ export default function TenderRadarSettingsPage() { htmlFor="tr-digest-interval" className="mb-1 block text-sm text-foreground" > - Digest-Intervall + {t('settings.digestIntervalLabel')}

    - Fasst neue Treffer aus Ihren Suchprofilen in einer täglichen - oder wöchentlichen Sammel-Mail zusammen. „Aus" deaktiviert den - Digest — Sofort-Alerts pro Suchprofil bleiben unabhängig davon - nutzbar. + {t('settings.digestHelp')}

    )} {saveSuccess && (

    - Einstellung gespeichert. + {t('settings.saveSuccess')}

    )} {error &&

    {error}

    }