From c07eb8fc1d752865f60811618cfe19989f2958a3 Mon Sep 17 00:00:00 2001 From: seaznCode Date: Sun, 7 Jun 2026 21:14:13 +0200 Subject: [PATCH] refactor: enhance language handling and improve translation integration across components + make languages public for normal users --- .../components/contractEditor.tsx | 54 +++++++-- .../components/contractTemplateList.tsx | 60 ++++++---- src/app/api/i18n/translations/route.ts | 5 +- src/app/api/user/settings/route.ts | 97 +++++++++++++++ src/app/i18n/useTranslation.tsx | 113 +++++++++++++++++- .../register-sign-contract/company/page.tsx | 8 +- .../register-sign-contract/personal/page.tsx | 11 +- 7 files changed, 303 insertions(+), 45 deletions(-) create mode 100644 src/app/api/user/settings/route.ts diff --git a/src/app/admin/contract-management/components/contractEditor.tsx b/src/app/admin/contract-management/components/contractEditor.tsx index 86493d7..144e00a 100644 --- a/src/app/admin/contract-management/components/contractEditor.tsx +++ b/src/app/admin/contract-management/components/contractEditor.tsx @@ -13,14 +13,14 @@ type Props = { }; export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdit }: Props) { - const { t } = useTranslation(); + const { t, languages } = useTranslation(); const [name, setName] = useState(''); const [htmlCode, setHtmlCode] = useState(''); const [isPreview, setIsPreview] = useState(false); const [saving, setSaving] = useState(false); const [statusMsg, setStatusMsg] = useState(null); - const [lang, setLang] = useState<'en' | 'de'>('en'); + const [lang, setLang] = useState('en'); const [type, setType] = useState<'contract' | 'invoice' | 'other'>('contract'); const [contractType, setContractType] = useState<'contract' | 'gdpr' | 'abo'>('contract'); const [userType, setUserType] = useState<'personal' | 'company' | 'both'>('personal'); @@ -35,6 +35,37 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi const { uploadTemplate, updateTemplateState, getTemplate, reviseTemplate } = useContractManagement(); + const availableLanguages = React.useMemo(() => { + const byCode = new Map(); + [ + { code: 'en', name: 'English' }, + { code: 'de', name: 'Deutsch' }, + ...languages, + ].forEach((language) => { + const code = String(language?.code || '').trim().toLowerCase(); + if (!code || byCode.has(code)) return; + + byCode.set(code, { + code, + name: String(language?.name || code.toUpperCase()).trim() || code.toUpperCase(), + }); + }); + + const priority = ['en', 'de']; + return Array.from(byCode.values()).sort((left, right) => { + const leftIndex = priority.indexOf(left.code); + const rightIndex = priority.indexOf(right.code); + + if (leftIndex !== -1 || rightIndex !== -1) { + if (leftIndex === -1) return 1; + if (rightIndex === -1) return -1; + if (leftIndex !== rightIndex) return leftIndex - rightIndex; + } + + return left.name.localeCompare(right.name); + }); + }, [languages]); + const resetEditorFields = () => { setName(''); setHtmlCode(''); @@ -44,6 +75,12 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi setEditingMeta(null); }; + useEffect(() => { + if (!availableLanguages.length) return; + if (availableLanguages.some((entry) => entry.code === lang)) return; + setLang(availableLanguages[0].code); + }, [availableLanguages, lang]); + // Load template into editor when editing useEffect(() => { const load = async () => { @@ -58,7 +95,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi setName(tpl.name || ''); setHtmlCode(tpl.html || ''); setDescription(((tpl as any)?.description as string) || ''); // FIX: DocumentTemplate may not declare `description` - setLang((tpl.lang as any) || 'en'); + setLang(String((tpl.lang as any) || 'en')); setType(((tpl.type as any) || 'contract') as 'contract' | 'invoice' | 'other'); setContractType(((tpl.contract_type as any) || 'contract') as 'contract' | 'gdpr' | 'abo'); setUserType(((tpl.user_type as any) || 'both') as 'personal' | 'company' | 'both'); @@ -85,7 +122,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi if (hasDoc) return src; // Minimal A4 skeleton so snippets render and print correctly return ` - + @@ -356,12 +393,13 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi setHtmlCode(e.target.value)} placeholder="Paste your full HTML (or snippet) here…" required - className="min-h-[320px] w-full rounded-lg border border-gray-300 bg-white px-4 py-3 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 font-mono shadow" + className="min-h-80 w-full rounded-lg border border-gray-300 bg-white px-4 py-3 text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 font-mono shadow" /> )} diff --git a/src/app/admin/contract-management/components/contractTemplateList.tsx b/src/app/admin/contract-management/components/contractTemplateList.tsx index 4b11f77..d1c03bf 100644 --- a/src/app/admin/contract-management/components/contractTemplateList.tsx +++ b/src/app/admin/contract-management/components/contractTemplateList.tsx @@ -13,6 +13,11 @@ type Props = { onEdit?: (id: string) => void; }; +type LanguageEntry = { + code: string; + name: string; +}; + type ContractTemplate = { id: string; name: string; @@ -160,7 +165,7 @@ const CONTRACT_TYPE_META: Record = { }, }; -const LANGUAGE_ORDER = ['de', 'en', 'other']; +const LANGUAGE_ORDER = ['de', 'en']; function normalizeFamily(type?: string | null): TemplateFamilyKey { if (type === 'contract') return 'contract'; @@ -185,7 +190,20 @@ function normalizeInvoiceTaxMode(taxMode?: string | null): InvoiceTaxModeKey { function normalizeLanguage(lang?: string | null) { const value = (lang || '').toLowerCase(); - return value === 'de' || value === 'en' ? value : 'other'; + return value || 'other'; +} + +function compareLanguageKeys(left: string, right: string) { + const leftIndex = LANGUAGE_ORDER.indexOf(left); + const rightIndex = LANGUAGE_ORDER.indexOf(right); + + if (leftIndex !== -1 || rightIndex !== -1) { + if (leftIndex === -1) return 1; + if (rightIndex === -1) return -1; + if (leftIndex !== rightIndex) return leftIndex - rightIndex; + } + + return left.localeCompare(right); } function normalizeName(name?: string | null) { @@ -203,26 +221,26 @@ function formatUserType(userType?: string | null) { } } -function formatLanguage(lang?: string | null) { - switch (normalizeLanguage(lang)) { +function formatLanguage(lang?: string | null, languages: LanguageEntry[] = []) { + const normalized = normalizeLanguage(lang); + const languageLabel = languages.find((entry) => normalizeLanguage(entry.code) === normalized)?.name; + if (languageLabel) return languageLabel; + + switch (normalized) { case 'de': return 'German'; case 'en': return 'English'; - default: + case 'other': return 'Other language'; + default: + return normalized.toUpperCase(); } } function formatLanguageCode(lang?: string | null) { - switch (normalizeLanguage(lang)) { - case 'de': - return 'DE'; - case 'en': - return 'EN'; - default: - return 'OT'; - } + const normalized = normalizeLanguage(lang); + return normalized === 'other' ? 'OT' : normalized.toUpperCase(); } function formatInvoiceTaxMode(taxMode?: string | null) { @@ -296,19 +314,19 @@ function buildTrackKey(template: ContractTemplate) { ].join(':'); } -function buildTrack(templates: ContractTemplate[]): TemplateTrack { +function buildTrack(templates: ContractTemplate[], languages: LanguageEntry[] = []): TemplateTrack { const sortedTemplates = [...templates].sort(compareTemplates); const lead = sortedTemplates.find((template) => template.status === 'published') || sortedTemplates[0]; const family = normalizeFamily(lead?.type); const names = Array.from(new Set(sortedTemplates.map((template) => template.name.trim()).filter(Boolean))); const languageKeys = Array.from(new Set(sortedTemplates.map((template) => normalizeLanguage(template.lang)))).sort( - (left, right) => LANGUAGE_ORDER.indexOf(left) - LANGUAGE_ORDER.indexOf(right) + compareLanguageKeys ); const languageColumns = languageKeys.map((langKey) => { const templatesInLanguage = sortedTemplates.filter((template) => normalizeLanguage(template.lang) === langKey); return { key: langKey, - label: formatLanguage(langKey), + label: formatLanguage(langKey, languages), templates: templatesInLanguage, activeTemplate: templatesInLanguage.find((template) => template.status === 'published'), }; @@ -512,7 +530,7 @@ function TemplateVersionHistoryModal({ } export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props) { - const { t } = useTranslation(); + const { t, languages } = useTranslation(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [q, setQ] = useState(''); @@ -547,14 +565,14 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props) formatContractType(item.contract_type), formatUserType(item.user_type), formatInvoiceTaxMode(item.tax_mode), - formatLanguage(item.lang), + formatLanguage(item.lang, languages), ] .join(' ') .toLowerCase(); return haystack.includes(term); }); - }, [items, q]); + }, [items, languages, q]); const familyGroups = useMemo(() => { const grouped = new Map>(); @@ -580,7 +598,7 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props) return FAMILY_ORDER.map((family) => { const trackMap = grouped.get(family) || new Map(); const tracks = Array.from(trackMap.values()) - .map((templates) => buildTrack(templates)) + .map((templates) => buildTrack(templates, languages)) .sort((left, right) => { const activityDelta = right.activeCount - left.activeCount; if (activityDelta !== 0) return activityDelta; @@ -598,7 +616,7 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props) activeTemplates: allTemplates.filter((template) => template.status === 'published').length, }; }).filter((group) => group.totalTemplates > 0); - }, [filtered]); + }, [filtered, languages]); const activeFamily = familyGroups.find((group) => group.key === selectedFamily) || familyGroups[0] || null; const activeContractSections = useMemo(() => { diff --git a/src/app/api/i18n/translations/route.ts b/src/app/api/i18n/translations/route.ts index 00c92fb..996d2f4 100644 --- a/src/app/api/i18n/translations/route.ts +++ b/src/app/api/i18n/translations/route.ts @@ -204,10 +204,7 @@ async function writeLanguageFile( await fs.writeFile(filePath, content, 'utf8'); } -export async function GET(request: Request) { - const access = await requireAdminSession(request); - if (!access.ok) return access.response; - +export async function GET() { try { const data = await readAllTranslations(); return NextResponse.json({ ok: true, ...data }); diff --git a/src/app/api/user/settings/route.ts b/src/app/api/user/settings/route.ts new file mode 100644 index 0000000..a7e1166 --- /dev/null +++ b/src/app/api/user/settings/route.ts @@ -0,0 +1,97 @@ +import { NextResponse } from 'next/server'; +import { fetchBackendAccessToken } from '../../_utils/backendAuth'; + +export const runtime = 'nodejs'; + +function resolveBackendBaseUrl(): string | null { + const value = process.env.NEXT_PUBLIC_API_BASE_URL?.trim(); + return value || null; +} + +function resolveUserSettingsBackendPath(): string { + const configured = process.env.BACKEND_USER_SETTINGS_PATH?.trim(); + if (configured) return configured.startsWith('/') ? configured : `/${configured}`; + return '/api/user/settings'; +} + +function readIncomingBearerToken(request: Request): string | null { + const authorization = request.headers.get('authorization') ?? request.headers.get('Authorization'); + if (!authorization || !authorization.startsWith('Bearer ')) return null; + + const token = authorization.slice('Bearer '.length).trim(); + return token || null; +} + +async function proxyUserSettingsRequest(request: Request, method: 'GET' | 'PUT') { + const incomingAccessToken = readIncomingBearerToken(request); + const tokenResult = incomingAccessToken + ? { ok: true, status: 200, accessToken: incomingAccessToken, setCookies: [] } + : await fetchBackendAccessToken(request); + + if (!tokenResult.ok || !tokenResult.accessToken) { + const denied = NextResponse.json( + { ok: false, message: tokenResult.message ?? 'Unable to obtain backend access token.' }, + { status: tokenResult.status === 401 ? 401 : 403 } + ); + + for (const setCookie of tokenResult.setCookies) { + denied.headers.append('set-cookie', setCookie); + } + + return denied; + } + + const apiBase = resolveBackendBaseUrl(); + if (!apiBase) { + return NextResponse.json({ ok: false, message: 'Missing NEXT_PUBLIC_API_BASE_URL.' }, { status: 500 }); + } + + const headers: Record = { + cookie: request.headers.get('cookie') ?? '', + Authorization: `Bearer ${tokenResult.accessToken}`, + }; + + let body: string | undefined; + if (method === 'PUT') { + const payload = await request.json().catch(() => ({})); + headers['Content-Type'] = 'application/json'; + body = JSON.stringify(payload ?? {}); + } + + const backendResponse = await fetch(`${apiBase}${resolveUserSettingsBackendPath()}`, { + method, + headers, + body, + cache: 'no-store', + }).catch(() => null); + + if (!backendResponse) { + const failed = NextResponse.json( + { ok: false, message: 'User settings backend is unreachable.' }, + { status: 502 } + ); + + for (const setCookie of tokenResult.setCookies) { + failed.headers.append('set-cookie', setCookie); + } + + return failed; + } + + const payload = await backendResponse.json().catch(() => null); + const out = NextResponse.json(payload ?? { ok: backendResponse.ok }, { status: backendResponse.status }); + + for (const setCookie of tokenResult.setCookies) { + out.headers.append('set-cookie', setCookie); + } + + return out; +} + +export async function GET(request: Request) { + return proxyUserSettingsRequest(request, 'GET'); +} + +export async function PUT(request: Request) { + return proxyUserSettingsRequest(request, 'PUT'); +} \ No newline at end of file diff --git a/src/app/i18n/useTranslation.tsx b/src/app/i18n/useTranslation.tsx index 7cb60ec..e7b6e4e 100644 --- a/src/app/i18n/useTranslation.tsx +++ b/src/app/i18n/useTranslation.tsx @@ -1,10 +1,14 @@ 'use client'; -import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; +import { createContext, useContext, useState, useEffect, useCallback, useRef, ReactNode } from 'react'; import { DEFAULT_LANGUAGE } from './config'; import { en } from './translations/en'; import { de } from './translations/de'; +import { sl } from './translations/sl'; import { flattenObject } from './dynamicTranslations'; +import { authFetch } from '../utils/authFetch'; +import useAuthStore from '../store/authStore'; +import { API_ENDPOINTS } from '../utils/api'; type LanguageEntry = { code: string; @@ -19,6 +23,7 @@ type TranslationFilesPayload = { const builtInTranslations: Record> = { en: en as unknown as Record, de: de as unknown as Record, + sl: sl as unknown as Record, }; const APP_LANGUAGE_STORAGE_KEY = 'pp-selected-language'; @@ -26,6 +31,26 @@ const APP_LANGUAGE_STORAGE_KEY = 'pp-selected-language'; // Flat map of English keys used as canonical key list and fallback const enFlat = flattenObject(en as unknown as Record); +function normalizeLanguageValue(value: unknown): string | null { + if (typeof value !== 'string') return null; + const normalized = value.trim().toLowerCase(); + return normalized ? normalized : null; +} + +function extractPreferredLanguage(payload: unknown): string | null { + if (!payload || typeof payload !== 'object') return null; + + const settings = (payload as { settings?: Record }).settings; + if (!settings || typeof settings !== 'object') return null; + + return normalizeLanguageValue( + settings.preferred_language + ?? settings.preferredLanguage + ?? settings.language + ?? settings.lang, + ); +} + function getNestedValue(root: Record, key: string): string | null { const segments = key.split('.'); let current: unknown = root; @@ -53,16 +78,21 @@ interface I18nProviderProps { } export function I18nProvider({ children }: I18nProviderProps) { + const accessToken = useAuthStore((state) => state.accessToken); const [language, setLanguage] = useState(DEFAULT_LANGUAGE); const [hasSyncedStoredLanguage, setHasSyncedStoredLanguage] = useState(false); + const [hasResolvedPersistedLanguage, setHasResolvedPersistedLanguage] = useState(false); + const lastPersistedLanguageRef = useRef(null); const [translationFiles, setTranslationFiles] = useState({ languages: [ { code: 'en', name: 'English' }, { code: 'de', name: 'Deutsch' }, + { code: 'sl', name: 'Slovenian' }, ], translations: { en: enFlat, de: flattenObject(de as unknown as Record), + sl: flattenObject(sl as unknown as Record), }, }); @@ -101,13 +131,17 @@ export function I18nProvider({ children }: I18nProviderProps) { if (typeof window === 'undefined') return; const stored = window.localStorage.getItem(APP_LANGUAGE_STORAGE_KEY); - if (stored && stored.trim() && stored !== language) { - setLanguage(stored); + if (stored && stored.trim()) { + setLanguage((currentLanguage) => currentLanguage === stored ? currentLanguage : stored); } setHasSyncedStoredLanguage(true); }, []); + useEffect(() => { + lastPersistedLanguageRef.current = null; + }, [accessToken]); + useEffect(() => { if (translationFiles.languages.length === 0) return; if (translationFiles.languages.some((entry) => entry.code === language)) return; @@ -118,11 +152,82 @@ export function I18nProvider({ children }: I18nProviderProps) { setLanguage(fallback); }, [translationFiles.languages, language]); + useEffect(() => { + if (!hasSyncedStoredLanguage) return; + + let cancelled = false; + + async function syncPersistedLanguage() { + if (!accessToken) { + setHasResolvedPersistedLanguage(true); + return; + } + + setHasResolvedPersistedLanguage(false); + try { + const response = await authFetch(API_ENDPOINTS.USER_SETTINGS, { + method: 'GET', + cache: 'no-store', + }); + const result = await response.json().catch(() => null); + const preferredLanguage = response.ok ? extractPreferredLanguage(result) : null; + + if (!cancelled && preferredLanguage) { + lastPersistedLanguageRef.current = preferredLanguage; + setLanguage((currentLanguage) => currentLanguage === preferredLanguage ? currentLanguage : preferredLanguage); + } + } catch { + // Ignore preference sync failures and keep local state. + } finally { + if (!cancelled) { + setHasResolvedPersistedLanguage(true); + } + } + } + + void syncPersistedLanguage(); + + return () => { + cancelled = true; + }; + }, [accessToken, hasSyncedStoredLanguage]); + useEffect(() => { if (typeof window === 'undefined' || !hasSyncedStoredLanguage) return; + window.localStorage.setItem(APP_LANGUAGE_STORAGE_KEY, language); document.documentElement.lang = language; - }, [hasSyncedStoredLanguage, language]); + + if (!accessToken || !hasResolvedPersistedLanguage || lastPersistedLanguageRef.current === language) { + return; + } + + let cancelled = false; + + async function persistPreferredLanguage() { + try { + const response = await authFetch(API_ENDPOINTS.USER_SETTINGS, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ preferredLanguage: language }), + }); + + if (!cancelled && response.ok) { + lastPersistedLanguageRef.current = language; + } + } catch { + // Ignore persistence failures; localStorage remains as the client fallback. + } + } + + void persistPreferredLanguage(); + + return () => { + cancelled = true; + }; + }, [accessToken, hasResolvedPersistedLanguage, hasSyncedStoredLanguage, language]); const t = useCallback((key: string): string => { // 1. Check translation loaded from translation files API. diff --git a/src/app/quickaction-dashboard/register-sign-contract/company/page.tsx b/src/app/quickaction-dashboard/register-sign-contract/company/page.tsx index 60fac3b..32ab877 100644 --- a/src/app/quickaction-dashboard/register-sign-contract/company/page.tsx +++ b/src/app/quickaction-dashboard/register-sign-contract/company/page.tsx @@ -10,7 +10,7 @@ import { useToast } from '../../../components/toast/toastComponent' import { useTranslation } from '../../../i18n/useTranslation' export default function CompanySignContractPage() { - const { t } = useTranslation(); + const { t, language } = useTranslation(); const router = useRouter() const user = useAuthStore(s => s.user) // NEW const isAuthReady = useAuthStore(s => (s as any).isAuthReady) // NEW @@ -125,7 +125,8 @@ export default function CompanySignContractPage() { [contractType]: { ...prev[contractType], loading: true, error: null } })) try { - const res = await fetch(`${API_BASE_URL}/api/contracts/preview/latest?contract_type=${contractType}`, { + const qs = new URLSearchParams({ contract_type: contractType, lang: language }).toString() + const res = await fetch(`${API_BASE_URL}/api/contracts/preview/latest?${qs}`, { method: 'GET', headers: { Authorization: `Bearer ${accessToken}` }, credentials: 'include' @@ -156,7 +157,7 @@ export default function CompanySignContractPage() { loadPreview('contract'), loadPreview('gdpr') ]).finally(() => setPreviewLoading(false)) - }, [accessToken]) + }, [accessToken, language]) useEffect(() => { const doneLoading = !previewState.contract.loading && !previewState.gdpr.loading && !previewLoading @@ -246,6 +247,7 @@ export default function CompanySignContractPage() { const contractData = { date, contractType: 'company', + lang: language, } // Create FormData for the existing backend endpoint diff --git a/src/app/quickaction-dashboard/register-sign-contract/personal/page.tsx b/src/app/quickaction-dashboard/register-sign-contract/personal/page.tsx index e46f9a6..b672201 100644 --- a/src/app/quickaction-dashboard/register-sign-contract/personal/page.tsx +++ b/src/app/quickaction-dashboard/register-sign-contract/personal/page.tsx @@ -10,7 +10,7 @@ import { useToast } from '../../../components/toast/toastComponent' import { useTranslation } from '../../../i18n/useTranslation' export default function PersonalSignContractPage() { - const { t } = useTranslation(); + const { t, language } = useTranslation(); const router = useRouter() const user = useAuthStore(s => s.user) // NEW const isAuthReady = useAuthStore(s => (s as any).isAuthReady) // NEW @@ -130,8 +130,8 @@ export default function PersonalSignContractPage() { [contractType]: { ...prev[contractType], loading: true, error: null } })) try { - const qs = contractType ? `?contract_type=${contractType}` : '' - const res = await fetch(`${API_BASE_URL}/api/contracts/preview/latest${qs}`, { + const qs = new URLSearchParams({ contract_type: contractType, lang: language }).toString() + const res = await fetch(`${API_BASE_URL}/api/contracts/preview/latest?${qs}`, { method: 'GET', headers: { Authorization: `Bearer ${accessToken}` }, credentials: 'include' @@ -162,7 +162,7 @@ export default function PersonalSignContractPage() { loadPreview('contract'), loadPreview('gdpr') ]).finally(() => setPreviewLoading(false)) - }, [accessToken]) + }, [accessToken, language]) // NEW: hard block if step already done OR all steps done const [redirectTo, setRedirectTo] = useState(null) @@ -284,7 +284,8 @@ export default function PersonalSignContractPage() { try { const contractData = { date, - contractType: 'personal' + contractType: 'personal', + lang: language, } // Create FormData for the backend endpoint (no dummy PDF needed; server generates from templates)