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 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 14:20:52 +02:00
parent 2ea02a2671
commit 849aa9b0a9
7 changed files with 198 additions and 101 deletions
@@ -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<string, string> = {
'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();
@@ -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<FormState>(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() {
<div className="space-y-4">
{!configExists && (
<div className="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300">
Noch nicht gespeichert — Postfach-Konfiguration ausfüllen und
speichern.
{t('emailAlerts.notSaved')}
</div>
)}
{/* Protokoll */}
<div>
<label htmlFor="email-alert-protocol" className={labelCls}>
Protokoll *
{t('emailAlerts.protocolLabel')}
</label>
<select
id="email-alert-protocol"
@@ -230,15 +228,17 @@ export function EmailAlertConfigForm() {
update('protocol', e.target.value as 'imap' | 'exchange')
}
>
<option value="imap">IMAP</option>
<option value="exchange">Exchange</option>
<option value="imap">{t('emailAlerts.protocolImap')}</option>
<option value="exchange">{t('emailAlerts.protocolExchange')}</option>
</select>
</div>
{/* Host / EWS-URL */}
<div>
<label htmlFor="email-alert-host" className={labelCls}>
{form.protocol === 'exchange' ? 'EWS-Endpunkt-URL' : 'IMAP-Server'} *
{(form.protocol === 'exchange'
? t('emailAlerts.hostLabelExchange')
: t('emailAlerts.hostLabelImap')) + ' *'}
</label>
<input
id="email-alert-host"
@@ -246,8 +246,8 @@ export function EmailAlertConfigForm() {
required
placeholder={
form.protocol === 'exchange'
? 'https://mail.firma.de/EWS/Exchange.asmx'
: 'imap.firma.de'
? t('emailAlerts.hostPlaceholderExchange')
: t('emailAlerts.hostPlaceholderImap')
}
className={inputCls}
value={form.host}
@@ -259,7 +259,7 @@ export function EmailAlertConfigForm() {
{form.protocol === 'imap' && (
<div>
<label htmlFor="email-alert-port" className={labelCls}>
Port *
{t('emailAlerts.portLabel')}
</label>
<input
id="email-alert-port"
@@ -276,7 +276,7 @@ export function EmailAlertConfigForm() {
{form.protocol === 'imap' && (
<div>
<label htmlFor="email-alert-encryption" className={labelCls}>
Verschlüsselung *
{t('emailAlerts.encryptionLabel')}
</label>
<select
id="email-alert-encryption"
@@ -289,9 +289,9 @@ export function EmailAlertConfigForm() {
)
}
>
<option value="none">Keine</option>
<option value="starttls">STARTTLS</option>
<option value="ssl-tls">SSL-TLS</option>
<option value="none">{t('emailAlerts.encryptionNone')}</option>
<option value="starttls">{t('emailAlerts.encryptionStarttls')}</option>
<option value="ssl-tls">{t('emailAlerts.encryptionSslTls')}</option>
</select>
</div>
)}
@@ -299,12 +299,16 @@ export function EmailAlertConfigForm() {
{/* Ordner / Postfach */}
<div>
<label htmlFor="email-alert-folder" className={labelCls}>
Ordner
{t('emailAlerts.folderLabel')}
</label>
<input
id="email-alert-folder"
type="text"
placeholder={form.protocol === 'exchange' ? 'Inbox' : 'INBOX'}
placeholder={
form.protocol === 'exchange'
? t('emailAlerts.folderPlaceholderExchange')
: t('emailAlerts.folderPlaceholderImap')
}
className={inputCls}
value={form.folder}
onChange={(e) => update('folder', e.target.value)}
@@ -315,12 +319,12 @@ export function EmailAlertConfigForm() {
{form.protocol === 'exchange' && (
<div>
<label htmlFor="email-alert-domain" className={labelCls}>
Windows-Domäne
{t('emailAlerts.domainLabel')}
</label>
<input
id="email-alert-domain"
type="text"
placeholder="CONTOSO"
placeholder={t('emailAlerts.domainPlaceholder')}
className={inputCls}
value={form.domain}
onChange={(e) => update('domain', e.target.value)}
@@ -331,12 +335,12 @@ export function EmailAlertConfigForm() {
{/* Absenderfilter */}
<div>
<label htmlFor="email-alert-sender-filter" className={labelCls}>
Absenderfilter
{t('emailAlerts.senderFilterLabel')}
</label>
<input
id="email-alert-sender-filter"
type="email"
placeholder="alerts@vergabeportal.de"
placeholder={t('emailAlerts.senderFilterPlaceholder')}
className={inputCls}
value={form.senderFilter}
onChange={(e) => update('senderFilter', e.target.value)}
@@ -346,7 +350,7 @@ export function EmailAlertConfigForm() {
{/* Benutzername */}
<div>
<label htmlFor="email-alert-username" className={labelCls}>
Benutzername
{t('emailAlerts.usernameLabel')}
</label>
<input
id="email-alert-username"
@@ -360,7 +364,7 @@ export function EmailAlertConfigForm() {
{/* Passwort — T-07-12: never pre-filled from server */}
<div>
<label htmlFor="email-alert-password" className={labelCls}>
Passwort
{t('emailAlerts.passwordLabel')}
</label>
<div className="relative max-w-md">
<input
@@ -368,14 +372,14 @@ export function EmailAlertConfigForm() {
type={showPassword ? 'text' : 'password'}
className="h-9 w-full rounded border border-border bg-background px-3 pr-10 text-sm text-foreground"
value={form.password}
placeholder="••••••••"
placeholder={t('emailAlerts.passwordPlaceholder')}
onChange={(e) => update('password', e.target.value)}
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'}
aria-label={showPassword ? t('emailAlerts.hidePassword') : t('emailAlerts.showPassword')}
>
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
</button>
@@ -388,7 +392,7 @@ export function EmailAlertConfigForm() {
htmlFor="email-alert-active"
className="text-sm text-foreground cursor-pointer"
>
Aktiv
{t('emailAlerts.activeLabel')}
</label>
<button
id="email-alert-active"
@@ -423,13 +427,13 @@ export function EmailAlertConfigForm() {
disabled={isSaving}
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"
>
{isSaving ? 'Wird gespeichert...' : 'Speichern'}
{isSaving ? t('emailAlerts.saving') : t('emailAlerts.save')}
</button>
</div>
{saveSuccess && (
<p className="text-sm" style={{ color: 'oklch(0.40 0.15 148)' }}>
Einstellungen gespeichert.
{t('emailAlerts.saveSuccess')}
</p>
)}
{saveError && <p className="text-sm text-destructive">{saveError}</p>}
@@ -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<string, string>) => {
const translations: Record<string, string> = {
'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();
@@ -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<RssFeedSource[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(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() {
<p className="truncate text-sm font-medium text-foreground">
{feed.label}{' '}
<span className="text-xs text-muted-foreground">
({feed.isActive ? 'aktiv' : 'inaktiv'})
({feed.isActive ? t('rssFeeds.activeLabel') : t('rssFeeds.inactiveLabel')})
</span>
</p>
<p className="truncate text-xs text-muted-foreground">
@@ -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')}
</button>
</li>
))}
{feeds.length === 0 && (
<li className="text-sm text-muted-foreground">
Noch keine RSS-Feeds hinterlegt.
{t('rssFeeds.empty')}
</li>
)}
</ul>
@@ -156,13 +153,13 @@ export function RssFeedListForm() {
<div className="space-y-3 border-t border-border pt-4">
<div>
<label htmlFor="rss-feed-url" className={labelCls}>
Feed-URL *
{t('rssFeeds.urlLabel')}
</label>
<input
id="rss-feed-url"
type="url"
required
placeholder="https://www.service.bund.de/.../RSSGenerator_Ausschreibungen.xml"
placeholder={t('rssFeeds.urlPlaceholder')}
className={inputCls}
value={url}
onChange={(e) => {
@@ -173,13 +170,13 @@ export function RssFeedListForm() {
</div>
<div>
<label htmlFor="rss-feed-label" className={labelCls}>
Bezeichnung *
{t('rssFeeds.labelLabel')}
</label>
<input
id="rss-feed-label"
type="text"
required
placeholder="z. B. service-bund"
placeholder={t('rssFeeds.labelPlaceholder')}
className={inputCls}
value={label}
onChange={(e) => {
@@ -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')}
</button>
{addError && <p className="text-sm text-destructive">{addError}</p>}
</div>
@@ -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<string, string>) => {
const translations: Record<string, string> = {
'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
@@ -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<string | null>(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() {
<div className="space-y-4">
{/* Read-only display fields */}
<div>
<span className={labelCls}>Quelle</span>
<span className={labelCls}>{t('sourceConfig.sourceLabel')}</span>
<p className="text-sm text-foreground">{sourceType || 'doe-opendata'}</p>
</div>
<div>
<span className={labelCls}>Zuletzt erfasster Tag</span>
<span className={labelCls}>{t('sourceConfig.lastIngestedLabel')}</span>
<p className="text-sm text-foreground">
{lastIngestedDay
? new Date(lastIngestedDay).toLocaleDateString('de-DE')
: 'Noch keine Erfassung'}
: t('sourceConfig.neverIngested')}
</p>
</div>
{/* Abrufintervall */}
<div>
<label htmlFor="source-config-poll-interval" className={labelCls}>
Abrufintervall (Minuten) *
{t('sourceConfig.pollIntervalLabel')}
</label>
<div className="flex items-center gap-2">
<input
@@ -161,10 +162,13 @@ export function SourceConfigForm() {
value={pollIntervalMin}
onChange={(e) => handleIntervalChange(e.target.value)}
/>
<span className="text-sm text-muted-foreground">Min.</span>
<span className="text-sm text-muted-foreground">{t('sourceConfig.minutesSuffix')}</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
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),
})}
</p>
</div>
@@ -174,7 +178,7 @@ export function SourceConfigForm() {
htmlFor="source-config-active"
className="text-sm text-foreground cursor-pointer"
>
Aktiv
{t('sourceConfig.activeLabel')}
</label>
<button
id="source-config-active"
@@ -209,7 +213,7 @@ export function SourceConfigForm() {
disabled={isSaving}
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"
>
{isSaving ? 'Speichert...' : 'Speichern'}
{isSaving ? t('sourceConfig.saving') : t('sourceConfig.save')}
</button>
</div>
@@ -218,7 +222,7 @@ export function SourceConfigForm() {
)}
{saveSuccess && (
<p className="text-sm" style={{ color: 'oklch(0.40 0.15 148)' }}>
Einstellungen gespeichert.
{t('sourceConfig.saveSuccess')}
</p>
)}
{saveError && <p className="text-sm text-destructive">{saveError}</p>}
@@ -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 (
<div className="mx-auto max-w-2xl p-6">
<h1 className="text-2xl font-semibold tracking-tight mb-6">
Ausschreibungs-Radar — Einstellungen
{t('settings.title')}
</h1>
<SourceConfigForm />
<div className="mt-8 border-t border-border pt-6">
<h2 className="text-lg font-semibold text-foreground mb-4">
RSS-Feeds
{t('settings.rssSectionTitle')}
</h2>
<p className="mb-4 text-xs text-muted-foreground">
Ö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')}
</p>
<RssFeedListForm />
</div>
<div className="mt-8 border-t border-border pt-6">
<h2 className="text-lg font-semibold text-foreground mb-4">
E-Mail-Alerts
{t('settings.emailSectionTitle')}
</h2>
<p className="mb-4 text-xs text-muted-foreground">
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')}
</p>
<EmailAlertConfigForm />
</div>
<div className="mt-8 border-t border-border pt-6">
<h2 className="text-lg font-semibold text-foreground mb-4">
Benachrichtigungen
{t('settings.notificationsSectionTitle')}
</h2>
{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')}
</label>
<select
id="tr-digest-interval"
@@ -141,22 +134,19 @@ export default function TenderRadarSettingsPage() {
disabled={isSaving}
className="h-9 max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground"
>
<option value="daily">Täglich</option>
<option value="weekly">Wöchentlich</option>
<option value="off">Aus</option>
<option value="daily">{t('settings.digestDaily')}</option>
<option value="weekly">{t('settings.digestWeekly')}</option>
<option value="off">{t('settings.digestOff')}</option>
</select>
<p className="mt-1 text-xs text-muted-foreground">
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')}
</p>
</div>
)}
{saveSuccess && (
<p className="mt-2 text-sm" style={{ color: 'oklch(0.40 0.15 148)' }}>
Einstellung gespeichert.
{t('settings.saveSuccess')}
</p>
)}
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}