Compare commits

...

3 Commits

7 changed files with 303 additions and 45 deletions

View File

@ -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<string | null>(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<string, { code: string; name: string }>();
[
{ 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 `<!DOCTYPE html>
<html lang="en">
<html lang="${lang}">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
@ -356,12 +393,13 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
</select>
<select
value={lang}
onChange={(e) => setLang(e.target.value as 'en' | 'de')}
onChange={(e) => setLang(e.target.value)}
required
className="w-full sm:w-32 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-900 outline-none focus:ring-2 focus:ring-indigo-500/50 shadow"
>
<option value="en">English (en)</option>
<option value="de">Deutsch (de)</option>
{availableLanguages.map((language) => (
<option key={language.code} value={language.code}>{language.name} ({language.code})</option>
))}
</select>
<input
type="text"
@ -388,7 +426,7 @@ export default function ContractEditor({ onSaved, editingTemplateId, onCancelEdi
onChange={(e) => 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"
/>
</div>
)}

View File

@ -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<ContractTypeKey, ContractTypeMeta> = {
},
};
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<ContractTemplate[]>([]);
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<TemplateFamilyGroup[]>(() => {
const grouped = new Map<TemplateFamilyKey, Map<string, ContractTemplate[]>>();
@ -580,7 +598,7 @@ export default function ContractTemplateList({ refreshKey = 0, onEdit }: Props)
return FAMILY_ORDER.map((family) => {
const trackMap = grouped.get(family) || new Map<string, ContractTemplate[]>();
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<ContractTypeSection[]>(() => {

View File

@ -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 });

View File

@ -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<string, string> = {
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');
}

View File

@ -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<string, Record<string, unknown>> = {
en: en as unknown as Record<string, unknown>,
de: de as unknown as Record<string, unknown>,
sl: sl as unknown as Record<string, unknown>,
};
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<string, unknown>);
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<string, unknown> }).settings;
if (!settings || typeof settings !== 'object') return null;
return normalizeLanguageValue(
settings.preferred_language
?? settings.preferredLanguage
?? settings.language
?? settings.lang,
);
}
function getNestedValue(root: Record<string, unknown>, 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<string>(DEFAULT_LANGUAGE);
const [hasSyncedStoredLanguage, setHasSyncedStoredLanguage] = useState(false);
const [hasResolvedPersistedLanguage, setHasResolvedPersistedLanguage] = useState(false);
const lastPersistedLanguageRef = useRef<string | null>(null);
const [translationFiles, setTranslationFiles] = useState<TranslationFilesPayload>({
languages: [
{ code: 'en', name: 'English' },
{ code: 'de', name: 'Deutsch' },
{ code: 'sl', name: 'Slovenian' },
],
translations: {
en: enFlat,
de: flattenObject(de as unknown as Record<string, unknown>),
sl: flattenObject(sl as unknown as Record<string, unknown>),
},
});
@ -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.

View File

@ -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

View File

@ -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<string | null>(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)