dev #21

Merged
Seazn merged 35 commits from dev into main 2026-05-21 17:34:44 +00:00
23 changed files with 4691 additions and 726 deletions
Showing only changes of commit 7559466c27 - Show all commits

View File

@ -18,6 +18,7 @@ import {
ShieldCheckIcon,
} from '@heroicons/react/24/outline'
import useAuthStore from '../../../store/authStore'
import { useTranslation } from '../../../i18n/useTranslation'
/* ---------- types ---------- */
export type AdminInvoice = {
@ -85,11 +86,11 @@ const STATUSES = ['draft', 'issued', 'paid', 'overdue', 'canceled'] as const
type InvoiceStatus = (typeof STATUSES)[number]
const STATUS_CONFIG: Record<InvoiceStatus, { label: string; bg: string; text: string; icon: React.ElementType }> = {
draft: { label: 'Draft', bg: 'bg-gray-100', text: 'text-gray-700', icon: PencilSquareIcon },
issued: { label: 'Issued', bg: 'bg-indigo-100', text: 'text-indigo-700', icon: DocumentTextIcon },
paid: { label: 'Paid', bg: 'bg-green-100', text: 'text-green-700', icon: CheckCircleIcon },
overdue: { label: 'Overdue', bg: 'bg-red-100', text: 'text-red-700', icon: ExclamationCircleIcon },
canceled: { label: 'Canceled', bg: 'bg-yellow-100', text: 'text-yellow-700', icon: NoSymbolIcon },
draft: { label: 'draft', bg: 'bg-gray-100', text: 'text-gray-700', icon: PencilSquareIcon },
issued: { label: 'issued', bg: 'bg-indigo-100', text: 'text-indigo-700', icon: DocumentTextIcon },
paid: { label: 'paid', bg: 'bg-green-100', text: 'text-green-700', icon: CheckCircleIcon },
overdue: { label: 'overdue', bg: 'bg-red-100', text: 'text-red-700', icon: ExclamationCircleIcon },
canceled: { label: 'canceled', bg: 'bg-yellow-100', text: 'text-yellow-700', icon: NoSymbolIcon },
}
function fmtDate(d?: string | null) {
@ -116,6 +117,7 @@ export default function InvoiceDetailModal({
onExport,
}: InvoiceDetailModalProps) {
const token = useAuthStore((s) => s.accessToken)
const { t } = useTranslation()
// detail data
const [items, setItems] = useState<InvoiceItem[]>([])
@ -274,10 +276,10 @@ export default function InvoiceDetailModal({
</div>
<div>
<Dialog.Title className="text-lg font-bold text-white">
Invoice {invoice.invoice_number ?? `#${invoice.id}`}
{t('invoiceDetailModal.invoiceTitle')} {invoice.invoice_number ?? `#${invoice.id}`}
</Dialog.Title>
<p className="text-sm text-blue-200/80">
Created {fmtDateTime(invoice.created_at)}
{t('invoiceDetailModal.created')} {fmtDateTime(invoice.created_at)}
</p>
</div>
</div>
@ -297,12 +299,12 @@ export default function InvoiceDetailModal({
<div className="flex items-center gap-2">
<span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-semibold ${statusConf.bg} ${statusConf.text}`}>
<StatusIcon className="h-4 w-4" />
{statusConf.label}
{t(`invoiceDetailModal.status${statusConf.label.charAt(0).toUpperCase() + statusConf.label.slice(1)}` as any)}
</span>
</div>
<div className="flex-1" />
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-gray-500 mr-1">Change status:</span>
<span className="text-xs text-gray-500 mr-1">{t('invoiceDetailModal.changeStatus')}</span>
{STATUSES.map((s) => {
const sc = STATUS_CONFIG[s]
const active = s === currentStatus
@ -317,7 +319,7 @@ export default function InvoiceDetailModal({
: 'border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-40'
}`}
>
{sc.label}
{t(`invoiceDetailModal.status${sc.label.charAt(0).toUpperCase() + sc.label.slice(1)}` as any)}
</button>
)
})}
@ -327,7 +329,7 @@ export default function InvoiceDetailModal({
{/* status feedback */}
{changingStatus && (
<div className="rounded-lg bg-blue-50 border border-blue-100 px-3 py-2 text-sm text-blue-700 flex items-center gap-2">
<ArrowPathIcon className="h-4 w-4 animate-spin" /> Updating status
<ArrowPathIcon className="h-4 w-4 animate-spin" /> {t('invoiceDetailModal.updatingStatus')}
</div>
)}
{statusMsg && (
@ -346,46 +348,46 @@ export default function InvoiceDetailModal({
{/* Customer info */}
<div className="rounded-xl border border-gray-100 bg-gray-50/50 p-4 space-y-2">
<div className="flex items-center gap-2 text-sm font-semibold text-[#1C2B4A] mb-1">
<UserIcon className="h-4 w-4" /> Customer
<UserIcon className="h-4 w-4" /> {t('invoiceDetailModal.customer')}
</div>
<InfoRow label="Name" value={invoice.buyer_name} />
<InfoRow label="Email" value={invoice.buyer_email} />
<InfoRow label="Street" value={invoice.buyer_street} />
<InfoRow label="City" value={[invoice.buyer_postal_code, invoice.buyer_city].filter(Boolean).join(' ')} />
<InfoRow label="Country" value={invoice.buyer_country} />
<InfoRow label="User ID" value={invoice.user_id != null ? String(invoice.user_id) : null} />
<InfoRow label={t('invoiceDetailModal.name')} value={invoice.buyer_name} />
<InfoRow label={t('invoiceDetailModal.email')} value={invoice.buyer_email} />
<InfoRow label={t('invoiceDetailModal.street')} value={invoice.buyer_street} />
<InfoRow label={t('invoiceDetailModal.city')} value={[invoice.buyer_postal_code, invoice.buyer_city].filter(Boolean).join(' ')} />
<InfoRow label={t('invoiceDetailModal.country')} value={invoice.buyer_country} />
<InfoRow label={t('invoiceDetailModal.userId')} value={invoice.user_id != null ? String(invoice.user_id) : null} />
</div>
{/* Financial info */}
<div className="rounded-xl border border-gray-100 bg-gray-50/50 p-4 space-y-2">
<div className="flex items-center gap-2 text-sm font-semibold text-[#1C2B4A] mb-1">
<BanknotesIcon className="h-4 w-4" /> Financials
<BanknotesIcon className="h-4 w-4" /> {t('invoiceDetailModal.financials')}
</div>
<InfoRow label="Net" value={fmtMoney(invoice.total_net, invoice.currency ?? 'EUR')} />
<InfoRow label="Tax" value={fmtMoney(invoice.total_tax, invoice.currency ?? 'EUR')} />
<InfoRow label="Gross" value={fmtMoney(invoice.total_gross, invoice.currency ?? 'EUR')} highlight />
<InfoRow label="VAT Rate" value={invoice.vat_rate != null ? `${invoice.vat_rate}%` : '—'} />
<InfoRow label="Currency" value={invoice.currency ?? 'EUR'} />
<InfoRow label={t('invoiceDetailModal.net')} value={fmtMoney(invoice.total_net, invoice.currency ?? 'EUR')} />
<InfoRow label={t('invoiceDetailModal.tax')} value={fmtMoney(invoice.total_tax, invoice.currency ?? 'EUR')} />
<InfoRow label={t('invoiceDetailModal.gross')} value={fmtMoney(invoice.total_gross, invoice.currency ?? 'EUR')} highlight />
<InfoRow label={t('invoiceDetailModal.vatRate')} value={invoice.vat_rate != null ? `${invoice.vat_rate}%` : '—'} />
<InfoRow label={t('invoiceDetailModal.currency')} value={invoice.currency ?? 'EUR'} />
</div>
</div>
{/* Dates */}
<div className="rounded-xl border border-gray-100 bg-gray-50/50 p-4">
<div className="flex items-center gap-2 text-sm font-semibold text-[#1C2B4A] mb-2">
<CalendarDaysIcon className="h-4 w-4" /> Dates
<CalendarDaysIcon className="h-4 w-4" /> {t('invoiceDetailModal.dates')}
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
<DateChip label="Issued" value={invoice.issued_at} />
<DateChip label="Due" value={invoice.due_at} />
<DateChip label="Created" value={invoice.created_at} />
<DateChip label="Updated" value={invoice.updated_at} />
<DateChip label={t('invoiceDetailModal.issued')} value={invoice.issued_at} />
<DateChip label={t('invoiceDetailModal.due')} value={invoice.due_at} />
<DateChip label={t('invoiceDetailModal.created')} value={invoice.created_at} />
<DateChip label={t('invoiceDetailModal.updated')} value={invoice.updated_at} />
</div>
</div>
{/* Line items */}
<div className="rounded-xl border border-gray-100 bg-gray-50/50 p-4">
<div className="flex items-center gap-2 text-sm font-semibold text-[#1C2B4A] mb-3">
<DocumentTextIcon className="h-4 w-4" /> Line Items
<DocumentTextIcon className="h-4 w-4" /> {t('invoiceDetailModal.lineItems')}
</div>
{detailLoading ? (
<div className="space-y-2">
@ -395,17 +397,17 @@ export default function InvoiceDetailModal({
) : detailError ? (
<div className="text-sm text-red-600">{detailError}</div>
) : items.length === 0 ? (
<div className="text-sm text-gray-500">No line items found.</div>
<div className="text-sm text-gray-500">{t('invoiceDetailModal.noLineItems')}</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase text-gray-500 border-b border-gray-200">
<th className="pb-2 pr-4 font-medium">Description</th>
<th className="pb-2 pr-4 font-medium">Qty</th>
<th className="pb-2 pr-4 font-medium">Unit Price</th>
<th className="pb-2 pr-4 font-medium">Tax</th>
<th className="pb-2 pr-4 font-medium text-right">Gross</th>
<th className="pb-2 pr-4 font-medium">{t('invoiceDetailModal.description')}</th>
<th className="pb-2 pr-4 font-medium">{t('invoiceDetailModal.qty')}</th>
<th className="pb-2 pr-4 font-medium">{t('invoiceDetailModal.unitPrice')}</th>
<th className="pb-2 pr-4 font-medium">{t('invoiceDetailModal.tax')}</th>
<th className="pb-2 pr-4 font-medium text-right">{t('invoiceDetailModal.gross')}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
@ -421,7 +423,7 @@ export default function InvoiceDetailModal({
</tbody>
<tfoot>
<tr className="border-t border-gray-200">
<td colSpan={4} className="pt-2 text-right font-semibold text-gray-700">Total</td>
<td colSpan={4} className="pt-2 text-right font-semibold text-gray-700">{t('invoiceDetailModal.total')}</td>
<td className="pt-2 text-right font-bold text-[#1C2B4A]">{fmtMoney(invoice.total_gross)}</td>
</tr>
</tfoot>
@ -434,17 +436,17 @@ export default function InvoiceDetailModal({
{payments.length > 0 && (
<div className="rounded-xl border border-gray-100 bg-gray-50/50 p-4">
<div className="flex items-center gap-2 text-sm font-semibold text-[#1C2B4A] mb-3">
<ShieldCheckIcon className="h-4 w-4" /> Payments
<ShieldCheckIcon className="h-4 w-4" /> {t('invoiceDetailModal.payments')}
</div>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase text-gray-500 border-b border-gray-200">
<th className="pb-2 pr-4 font-medium">Method</th>
<th className="pb-2 pr-4 font-medium">Transaction</th>
<th className="pb-2 pr-4 font-medium">Amount</th>
<th className="pb-2 pr-4 font-medium">Paid At</th>
<th className="pb-2 pr-4 font-medium">Status</th>
<th className="pb-2 pr-4 font-medium">{t('invoiceDetailModal.method')}</th>
<th className="pb-2 pr-4 font-medium">{t('invoiceDetailModal.transaction')}</th>
<th className="pb-2 pr-4 font-medium">{t('invoiceDetailModal.amount')}</th>
<th className="pb-2 pr-4 font-medium">{t('invoiceDetailModal.paidAt')}</th>
<th className="pb-2 pr-4 font-medium">{t('invoiceDetailModal.status')}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
@ -471,8 +473,8 @@ export default function InvoiceDetailModal({
{invoice.context && (
<details className="rounded-xl border border-gray-100 bg-gray-50/50 p-4 group">
<summary className="flex items-center gap-2 text-sm font-semibold text-[#1C2B4A] cursor-pointer select-none">
<ClockIcon className="h-4 w-4" /> Context / Metadata
<span className="text-xs font-normal text-gray-400 ml-1">(click to expand)</span>
<ClockIcon className="h-4 w-4" /> {t('invoiceDetailModal.contextMetadata')}
<span className="text-xs font-normal text-gray-400 ml-1">{t('invoiceDetailModal.clickToExpand')}</span>
</summary>
<pre className="mt-3 text-xs text-gray-600 overflow-x-auto whitespace-pre-wrap break-all max-h-48">
{typeof invoice.context === 'string' ? invoice.context : JSON.stringify(invoice.context, null, 2)}
@ -488,20 +490,20 @@ export default function InvoiceDetailModal({
onClick={() => onExport?.(invoice)}
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition"
>
<ArrowDownTrayIcon className="h-4 w-4" /> Export JSON
<ArrowDownTrayIcon className="h-4 w-4" /> {t('invoiceDetailModal.exportJson')}
</button>
<button
onClick={() => onRunPoolCheck?.(invoice.id)}
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition"
>
<ArrowPathIcon className="h-4 w-4" /> Pool Check
<ArrowPathIcon className="h-4 w-4" /> {t('invoiceDetailModal.poolCheck')}
</button>
</div>
<button
onClick={onClose}
className="inline-flex items-center rounded-lg bg-[#1C2B4A] px-4 py-2 text-sm font-semibold text-white hover:bg-[#1C2B4A]/90 transition"
>
Close
{t('invoiceDetailModal.close')}
</button>
</div>
</Dialog.Panel>

View File

@ -0,0 +1,849 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import PageLayout from '../../components/PageLayout';
import {
getAllTranslationKeys,
getEnglishValue,
getBuiltInFlatTranslations,
} from '../../i18n/useTranslation';
import {
loadCustomI18n,
saveCustomI18n,
type CustomI18nData,
type CustomLanguageEntry,
} from '../../i18n/dynamicTranslations';
// ── built-in languages (always present, cannot be deleted)
const BUILTIN_LANGUAGES: CustomLanguageEntry[] = [
{ code: 'en', name: 'English', flag: '🇬🇧' },
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
];
// ── flag emoji options for the language picker
const FLAG_OPTIONS: { flag: string; label: string }[] = [
{ flag: '🇬🇧', label: 'UK' },
{ flag: '🇺🇸', label: 'US' },
{ flag: '🇩🇪', label: 'Germany' },
{ flag: '🇫🇷', label: 'France' },
{ flag: '🇪🇸', label: 'Spain' },
{ flag: '🇮🇹', label: 'Italy' },
{ flag: '🇵🇹', label: 'Portugal' },
{ flag: '🇧🇷', label: 'Brazil' },
{ flag: '🇳🇱', label: 'Netherlands' },
{ flag: '🇧🇪', label: 'Belgium' },
{ flag: '🇨🇭', label: 'Switzerland' },
{ flag: '🇦🇹', label: 'Austria' },
{ flag: '🇵🇱', label: 'Poland' },
{ flag: '🇨🇿', label: 'Czech' },
{ flag: '🇸🇰', label: 'Slovakia' },
{ flag: '🇭🇺', label: 'Hungary' },
{ flag: '🇷🇴', label: 'Romania' },
{ flag: '🇷🇺', label: 'Russia' },
{ flag: '🇺🇦', label: 'Ukraine' },
{ flag: '🇹🇷', label: 'Turkey' },
{ flag: '🇬🇷', label: 'Greece' },
{ flag: '🇸🇪', label: 'Sweden' },
{ flag: '🇳🇴', label: 'Norway' },
{ flag: '🇩🇰', label: 'Denmark' },
{ flag: '🇫🇮', label: 'Finland' },
{ flag: '🇯🇵', label: 'Japan' },
{ flag: '🇰🇷', label: 'Korea' },
{ flag: '🇨🇳', label: 'China' },
{ flag: '🇮🇳', label: 'India' },
{ flag: '🇸🇦', label: 'Saudi' },
{ flag: '🇦🇪', label: 'UAE' },
{ flag: '🇮🇱', label: 'Israel' },
{ flag: '🇿🇦', label: 'S. Africa' },
{ flag: '🇳🇬', label: 'Nigeria' },
{ flag: '🇲🇽', label: 'Mexico' },
{ flag: '🇦🇷', label: 'Argentina' },
{ flag: '🇨🇦', label: 'Canada' },
{ flag: '🇦🇺', label: 'Australia' },
{ flag: '🏳️', label: 'None' },
];
// ── namespace categories
const NAMESPACE_CATEGORIES: { label: string; namespaces: string[] }[] = [
{ label: 'General', namespaces: ['common', 'nav', 'footer', 'home'] },
{ label: 'Auth', namespaces: ['login', 'register', 'passwordReset'] },
{ label: 'Pages', namespaces: ['dashboard', 'profile', 'community', 'shop', 'memberships', 'affiliateLinks', 'aboutUs', 'news'] },
{ label: 'Coffee ABO', namespaces: ['coffeeSelection', 'coffeeSummary'] },
{ label: 'Account', namespaces: ['personalMatrix', 'referralManagement', 'quickactionDashboard', 'suspended'] },
{ label: 'Admin', namespaces: ['adminDashboard', 'userManagement', 'languageManagement', 'contractManagement'] },
{ label: 'Notifications', namespaces: ['toasts'] },
];
function groupKeys(keys: string[]): Record<string, string[]> {
const groups: Record<string, string[]> = {};
for (const key of keys) {
const ns = key.split('.')[0];
if (!groups[ns]) groups[ns] = [];
groups[ns].push(key);
}
return groups;
}
export default function LanguageManagementPage() {
const router = useRouter();
type WorkspaceScanResult = {
scannedFiles: number;
scannedDirectories: number;
translationCallCount: number;
uniqueKeyCount: number;
missingKeys: Array<{ key: string; files: string[] }>;
untranslatedLiterals: Array<{ text: string; files: string[] }>;
};
// ── all flat keys from the English source-of-truth
const allKeys = useMemo(() => getAllTranslationKeys(), []);
const keyGroups = useMemo(() => groupKeys(allKeys), [allKeys]);
const namespaces = useMemo(() => Object.keys(keyGroups).sort(), [keyGroups]);
// ── custom i18n state (persisted in localStorage)
const [data, setData] = useState<CustomI18nData>({ languages: [], translations: {} });
const [isDirty, setIsDirty] = useState(false);
const [saved, setSaved] = useState(false);
// ── selected language tab
const [activeLang, setActiveLang] = useState('en');
// ── search / filter
const [search, setSearch] = useState('');
const [expandedNs, setExpandedNs] = useState<Record<string, boolean>>({});
// ── add-language modal
const [showAddModal, setShowAddModal] = useState(false);
const [newCode, setNewCode] = useState('');
const [newName, setNewName] = useState('');
const [addError, setAddError] = useState('');
// ── delete confirm
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
// ── active namespace category ('all' = show everything)
const [activeCategory, setActiveCategory] = useState<string>('all');
// ── new language: flag selection
const [newFlag, setNewFlag] = useState<string>('🏳️');
// ── scan results modal
const [showScanModal, setShowScanModal] = useState(false);
const [lastScanTime, setLastScanTime] = useState<Date | null>(null);
const [isScanning, setIsScanning] = useState(false);
const [scanError, setScanError] = useState<string | null>(null);
const [workspaceScan, setWorkspaceScan] = useState<WorkspaceScanResult | null>(null);
// ── load on mount
useEffect(() => {
setData(loadCustomI18n());
}, []);
// ── all languages (built-in + custom), deduplicated
const allLanguages: CustomLanguageEntry[] = useMemo(() => {
const custom = data.languages.filter(
(l) => !BUILTIN_LANGUAGES.some((b) => b.code === l.code)
);
return [...BUILTIN_LANGUAGES, ...custom];
}, [data.languages]);
// ── resolve flat translations for the active language
// Built-in langs: use the compiled translation file as base; custom overrides on top.
// Custom langs: custom overrides only, fall back to English.
const baseFlat = useMemo((): Record<string, string> => {
const builtIn = getBuiltInFlatTranslations(activeLang);
return builtIn; // may be empty object for new langs
}, [activeLang]);
const getDisplayValue = useCallback(
(key: string): string => {
const override = data.translations[activeLang]?.[key];
if (override !== undefined) return override;
return baseFlat[key] ?? '';
},
[activeLang, data.translations, baseFlat]
);
const getPlaceholder = useCallback(
(key: string): string => {
// Show English as placeholder when editing non-English
if (activeLang !== 'en') return getEnglishValue(key);
return '';
},
[activeLang]
);
const handleChange = (key: string, value: string) => {
setData((prev) => {
const langTranslations = { ...(prev.translations[activeLang] ?? {}) };
langTranslations[key] = value;
return {
...prev,
translations: { ...prev.translations, [activeLang]: langTranslations },
};
});
setIsDirty(true);
setSaved(false);
};
const handleSave = () => {
saveCustomI18n(data);
setIsDirty(false);
setSaved(true);
setTimeout(() => setSaved(false), 2500);
};
const handleAddLanguage = () => {
const code = newCode.trim().toLowerCase();
const name = newName.trim();
if (!code) { setAddError('Language code is required.'); return; }
if (!name) { setAddError('Language name is required.'); return; }
if (!/^[a-z]{2,5}(-[a-zA-Z]{2,4})?$/.test(code)) {
setAddError('Use a valid BCP-47 code, e.g. fr, es, zh-TW.');
return;
}
if (allLanguages.some((l) => l.code === code)) {
setAddError(`Language "${code}" already exists.`);
return;
}
setData((prev) => ({
...prev,
languages: [...prev.languages, { code, name, flag: newFlag }],
}));
setIsDirty(true);
setSaved(false);
setShowAddModal(false);
setNewCode('');
setNewName('');
setNewFlag('🏳️');
setAddError('');
setActiveLang(code);
};
const handleDeleteLanguage = (code: string) => {
if (BUILTIN_LANGUAGES.some((b) => b.code === code)) return; // can't delete built-ins
setData((prev) => {
const langs = prev.languages.filter((l) => l.code !== code);
const { [code]: _removed, ...rest } = prev.translations;
return { languages: langs, translations: rest };
});
setIsDirty(true);
setSaved(false);
setDeleteTarget(null);
if (activeLang === code) setActiveLang('en');
};
const toggleNs = (ns: string) =>
setExpandedNs((prev) => ({ ...prev, [ns]: !prev[ns] }));
const filteredGroups = useMemo(() => {
const q = search.toLowerCase();
if (!q) return keyGroups;
const result: Record<string, string[]> = {};
for (const [ns, keys] of Object.entries(keyGroups)) {
const filtered = keys.filter(
(k) =>
k.toLowerCase().includes(q) ||
getEnglishValue(k).toLowerCase().includes(q)
);
if (filtered.length > 0) result[ns] = filtered;
}
return result;
}, [keyGroups, search]);
// ── scan results: coverage per namespace
const scanResults = useMemo(() => {
return namespaces.map((ns) => {
const keys = keyGroups[ns] ?? [];
let translated = 0;
if (activeLang === 'en') {
translated = keys.length; // English is the source
} else {
translated = keys.filter((k) => getDisplayValue(k) !== '').length;
}
return { ns, total: keys.length, translated, missing: keys.length - translated };
});
}, [namespaces, keyGroups, activeLang, getDisplayValue]);
const handleScan = async () => {
setShowScanModal(true);
setIsScanning(true);
setScanError(null);
try {
const response = await fetch('/api/i18n/scan', { method: 'GET' });
const result = await response.json();
if (!response.ok || !result?.ok) {
throw new Error(result?.message || 'Scan failed.');
}
setWorkspaceScan({
scannedFiles: Number(result.scannedFiles ?? 0),
scannedDirectories: Number(result.scannedDirectories ?? 0),
translationCallCount: Number(result.translationCallCount ?? 0),
uniqueKeyCount: Number(result.uniqueKeyCount ?? 0),
missingKeys: Array.isArray(result.missingKeys) ? result.missingKeys : [],
untranslatedLiterals: Array.isArray(result.untranslatedLiterals) ? result.untranslatedLiterals : [],
});
setLastScanTime(new Date());
} catch (error) {
setScanError(error instanceof Error ? error.message : 'Scan failed.');
} finally {
setIsScanning(false);
}
};
const categoryNamespaces = useMemo(() => {
if (activeCategory === 'all') return null; // null = no filter
return NAMESPACE_CATEGORIES.find((c) => c.label === activeCategory)?.namespaces ?? [];
}, [activeCategory]);
const filteredNs = useMemo(() => {
const base = Object.keys(filteredGroups).sort();
if (!categoryNamespaces) return base;
return base.filter((ns) => categoryNamespaces.includes(ns));
}, [filteredGroups, categoryNamespaces]);
// Auto-expand all namespaces when searching
useEffect(() => {
if (search) {
const all: Record<string, boolean> = {};
for (const ns of filteredNs) all[ns] = true;
setExpandedNs(all);
}
}, [search]); // eslint-disable-line react-hooks/exhaustive-deps
const totalKeys = allKeys.length;
const translatedCount = useMemo(() => {
return allKeys.filter((k) => {
const v = getDisplayValue(k);
return v !== '' && v !== getEnglishValue(k);
}).length;
}, [allKeys, getDisplayValue]);
const isBuiltin = (code: string) => BUILTIN_LANGUAGES.some((b) => b.code === code);
return (
<PageLayout contentClassName="flex-1 relative w-full">
<div className="mx-auto max-w-7xl px-4 py-8 space-y-6">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-4">
<div>
<h1 className="text-3xl font-bold text-[#1C2B4A]">Language Management</h1>
<p className="text-sm text-gray-500 mt-1">
Manage UI translations. All {totalKeys} keys scanned from the English source file.
</p>
</div>
<div className="flex items-center gap-3 flex-wrap">
<button
onClick={handleScan}
disabled={isScanning}
className="rounded-md border border-[#1C2B4A] text-[#1C2B4A] px-3 py-2 text-sm font-medium hover:bg-[#1C2B4A] hover:text-white transition-colors flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
{isScanning ? 'Scanning...' : 'Scan for new data'}
</button>
<button
onClick={() => router.push('/admin')}
className="rounded-md border border-gray-300 px-3 py-2 text-sm hover:bg-gray-50"
>
Back to Admin
</button>
{isDirty && (
<button
onClick={handleSave}
className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 text-sm font-semibold hover:bg-[#1C2B4A]/90"
>
Save changes
</button>
)}
{saved && !isDirty && (
<span className="rounded-md bg-green-50 border border-green-200 text-green-700 px-3 py-2 text-sm font-medium">
Saved
</span>
)}
</div>
</div>
{/* Language tabs */}
<div className="flex items-center gap-2 flex-wrap">
{allLanguages.map((lang) => (
<button
key={lang.code}
onClick={() => setActiveLang(lang.code)}
className={`relative rounded-lg px-4 py-2 text-sm font-medium transition flex items-center gap-2 ${
activeLang === lang.code
? 'bg-[#1C2B4A] text-white shadow'
: 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50'
}`}
>
{lang.flag && <span className="text-base leading-none">{lang.flag}</span>}
{lang.name}
<span className="text-xs opacity-60">({lang.code})</span>
{!isBuiltin(lang.code) && (
<button
onClick={(e) => {
e.stopPropagation();
setDeleteTarget(lang.code);
}}
title="Delete language"
className={`ml-1 inline-flex items-center justify-center rounded-full w-4 h-4 text-xs leading-none ${
activeLang === lang.code
? 'bg-white/20 hover:bg-white/40 text-white'
: 'bg-gray-200 hover:bg-red-100 text-gray-500 hover:text-red-600'
}`}
>
×
</button>
)}
</button>
))}
<button
onClick={() => setShowAddModal(true)}
className="rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-sm text-gray-500 hover:border-[#1C2B4A] hover:text-[#1C2B4A] transition"
>
+ Add language
</button>
</div>
{/* Progress bar */}
{activeLang !== 'en' && (
<div className="rounded-xl border border-gray-200 bg-white p-4 flex items-center gap-4">
<div className="flex-1">
<div className="flex justify-between text-xs text-gray-500 mb-1">
<span>Translation progress</span>
<span>{translatedCount} / {totalKeys} keys translated</span>
</div>
<div className="h-2 rounded-full bg-gray-100 overflow-hidden">
<div
className="h-full rounded-full bg-[#1C2B4A] transition-all"
style={{ width: `${Math.round((translatedCount / totalKeys) * 100)}%` }}
/>
</div>
</div>
<span className="text-lg font-bold text-[#1C2B4A]">
{Math.round((translatedCount / totalKeys) * 100)}%
</span>
</div>
)}
{/* Category tabs */}
<div className="flex items-center gap-2 flex-wrap border-b border-gray-200 pb-2">
<button
onClick={() => setActiveCategory('all')}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition ${
activeCategory === 'all'
? 'bg-[#1C2B4A] text-white shadow'
: 'text-gray-500 hover:text-[#1C2B4A] hover:bg-gray-100'
}`}
>
All
</button>
{NAMESPACE_CATEGORIES.map((cat) => (
<button
key={cat.label}
onClick={() => setActiveCategory(cat.label)}
className={`rounded-md px-3 py-1.5 text-sm font-medium transition ${
activeCategory === cat.label
? 'bg-[#1C2B4A] text-white shadow'
: 'text-gray-500 hover:text-[#1C2B4A] hover:bg-gray-100'
}`}
>
{cat.label}
</button>
))}
</div>
{/* Search */}
<div>
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search keys or English text…"
className="w-full max-w-sm rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
/>
</div>
{/* Translation table grouped by namespace */}
<div className="space-y-3">
{filteredNs.map((ns) => {
const keys = filteredGroups[ns];
const isOpen = !!expandedNs[ns];
return (
<div key={ns} className="rounded-xl border border-gray-200 bg-white overflow-hidden shadow-sm">
<button
type="button"
onClick={() => toggleNs(ns)}
className="w-full flex items-center justify-between px-5 py-3 bg-gray-50 hover:bg-gray-100 transition"
>
<span className="font-semibold text-[#1C2B4A] capitalize">{ns}</span>
<span className="text-xs text-gray-500">
{keys.length} keys {isOpen ? '▲' : '▼'}
</span>
</button>
{isOpen && (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 bg-gray-50/50">
<th className="px-5 py-2 text-left font-medium text-gray-500 w-1/3">Key</th>
{activeLang !== 'en' && (
<th className="px-5 py-2 text-left font-medium text-gray-500 w-1/3">English (reference)</th>
)}
<th className="px-5 py-2 text-left font-medium text-gray-500">
{allLanguages.find((l) => l.code === activeLang)?.name ?? activeLang}
</th>
</tr>
</thead>
<tbody>
{keys.map((key) => {
const enVal = getEnglishValue(key);
const currentVal = getDisplayValue(key);
const hasOverride = (data.translations[activeLang]?.[key] ?? '') !== '';
return (
<tr key={key} className="border-b border-gray-50 last:border-0 hover:bg-blue-50/30">
<td className="px-5 py-2 font-mono text-xs text-gray-500 align-top pt-3">
{key}
</td>
{activeLang !== 'en' && (
<td className="px-5 py-2 text-gray-500 align-top pt-3 text-xs">
{enVal}
</td>
)}
<td className="px-5 py-2">
<div className="relative">
<textarea
rows={1}
value={activeLang === 'en' ? currentVal : (data.translations[activeLang]?.[key] ?? '')}
onChange={(e) => handleChange(key, e.target.value)}
placeholder={activeLang === 'en' ? '' : enVal}
className={`w-full rounded border px-2 py-1.5 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-[#1C2B4A] ${
hasOverride && activeLang !== 'en'
? 'border-green-300 bg-green-50'
: 'border-gray-200 bg-white'
}`}
style={{ minHeight: '2.25rem', field_sizing: 'content' } as React.CSSProperties}
onInput={(e) => {
const t = e.currentTarget;
t.style.height = 'auto';
t.style.height = `${t.scrollHeight}px`;
}}
/>
{hasOverride && activeLang !== 'en' && (
<button
type="button"
title="Clear override (revert to built-in)"
onClick={() => handleChange(key, '')}
className="absolute top-1 right-1 text-xs text-gray-400 hover:text-red-500"
>
×
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
);
})}
{filteredNs.length === 0 && (
<div className="rounded-xl border border-gray-200 bg-white p-8 text-center text-sm text-gray-500">
No keys match your search.
</div>
)}
</div>
{/* Sticky save bar */}
{isDirty && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50">
<div className="flex items-center gap-3 rounded-xl bg-[#1C2B4A] text-white px-6 py-3 shadow-2xl">
<span className="text-sm">You have unsaved changes.</span>
<button
onClick={handleSave}
className="rounded-md bg-white text-[#1C2B4A] px-4 py-1.5 text-sm font-semibold hover:bg-gray-100"
>
Save
</button>
</div>
</div>
)}
</div>
{/* Add Language Modal */}
{showAddModal && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl">
<h2 className="text-lg font-bold text-[#1C2B4A] mb-4">Add Language</h2>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium mb-1">Language code</label>
<input
value={newCode}
onChange={(e) => setNewCode(e.target.value)}
placeholder="e.g. fr, es, zh-TW"
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Language name</label>
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="e.g. Français"
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#1C2B4A]"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
Flag icon
{newFlag !== '🏳️' && <span className="ml-2 text-lg">{newFlag}</span>}
</label>
<div className="grid grid-cols-8 gap-1 max-h-40 overflow-y-auto rounded border border-gray-200 p-2">
{FLAG_OPTIONS.map(({ flag, label }) => (
<button
key={flag}
type="button"
title={label}
onClick={() => setNewFlag(flag)}
className={`text-xl rounded p-1 hover:bg-gray-100 transition ${
newFlag === flag ? 'ring-2 ring-[#1C2B4A] bg-blue-50' : ''
}`}
>
{flag}
</button>
))}
</div>
</div>
{addError && <p className="text-xs text-red-600">{addError}</p>}
</div>
<div className="mt-5 flex justify-end gap-3">
<button
onClick={() => { setShowAddModal(false); setAddError(''); setNewCode(''); setNewName(''); setNewFlag('🏳️'); }}
className="rounded-md border border-gray-300 px-4 py-2 text-sm hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={handleAddLanguage}
className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 text-sm font-semibold hover:bg-[#1C2B4A]/90"
>
Add
</button>
</div>
</div>
</div>
)}
{/* Delete confirm */}
{deleteTarget && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-sm rounded-2xl bg-white p-6 shadow-2xl">
<h2 className="text-lg font-bold text-red-600 mb-3">Delete Language</h2>
<p className="text-sm text-gray-600 mb-5">
Delete <strong>{allLanguages.find((l) => l.code === deleteTarget)?.name ?? deleteTarget}</strong>?
All translations for this language will be removed.
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setDeleteTarget(null)}
className="rounded-md border border-gray-300 px-4 py-2 text-sm hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={() => handleDeleteLanguage(deleteTarget)}
className="rounded-md bg-red-600 text-white px-4 py-2 text-sm font-semibold hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
)}
{/* Scan Results Modal */}
{showScanModal && (
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-36 pb-6 bg-black/40 backdrop-blur-sm">
<div className="mx-4 w-full max-w-2xl rounded-2xl bg-white shadow-2xl flex flex-col max-h-[calc(100vh-12rem)]">
<div className="px-6 pt-6 pb-4 border-b border-gray-100">
<div className="flex items-start justify-between">
<div>
<h2 className="text-lg font-bold text-[#1C2B4A]">Translation Coverage Scan</h2>
<p className="text-sm text-gray-500 mt-0.5">
{workspaceScan
? `${workspaceScan.scannedFiles} files across ${workspaceScan.scannedDirectories} directories scanned`
: `${totalKeys} keys across ${namespaces.length} namespaces`}
{lastScanTime && (
<span className="ml-2 text-xs text-gray-400">
· Scanned {lastScanTime.toLocaleTimeString()}
</span>
)}
</p>
</div>
<button
onClick={() => setShowScanModal(false)}
className="text-gray-400 hover:text-gray-600 ml-4"
>
</button>
</div>
{/* Overall progress */}
<div className="mt-3">
<div className="flex justify-between text-xs text-gray-500 mb-1">
<span>Overall coverage ({allLanguages.find(l => l.code === activeLang)?.name ?? activeLang})</span>
<span>
{activeLang === 'en'
? `${totalKeys} / ${totalKeys}`
: `${scanResults.reduce((s, r) => s + r.translated, 0)} / ${totalKeys}`
}
</span>
</div>
<div className="h-2 rounded-full bg-gray-100 overflow-hidden">
<div
className="h-full rounded-full bg-[#1C2B4A] transition-all"
style={{
width: activeLang === 'en'
? '100%'
: `${Math.round((scanResults.reduce((s, r) => s + r.translated, 0) / totalKeys) * 100)}%`
}}
/>
</div>
</div>
{workspaceScan && (
<div className="mt-4 grid grid-cols-1 sm:grid-cols-4 gap-2">
<div className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
<p className="text-[11px] uppercase tracking-wide text-gray-500">Translation calls</p>
<p className="text-sm font-semibold text-[#1C2B4A]">{workspaceScan.translationCallCount}</p>
</div>
<div className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
<p className="text-[11px] uppercase tracking-wide text-gray-500">Unique keys used</p>
<p className="text-sm font-semibold text-[#1C2B4A]">{workspaceScan.uniqueKeyCount}</p>
</div>
<div className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
<p className="text-[11px] uppercase tracking-wide text-gray-500">Missing keys in en.ts</p>
<p className="text-sm font-semibold text-red-600">{workspaceScan.missingKeys.length}</p>
</div>
<div className="rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
<p className="text-[11px] uppercase tracking-wide text-gray-500">Untranslated literals</p>
<p className="text-sm font-semibold text-amber-700">{workspaceScan.untranslatedLiterals.length}</p>
</div>
</div>
)}
</div>
<div className="overflow-y-auto flex-1 px-6 py-4">
{scanError && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{scanError}
</div>
)}
{isScanning && (
<div className="mb-4 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm text-blue-700">
Scanning workspace files and component subdirectories...
</div>
)}
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100">
<th className="pb-2 text-left font-medium text-gray-500">Namespace</th>
<th className="pb-2 text-right font-medium text-gray-500">Keys</th>
<th className="pb-2 text-right font-medium text-gray-500">Translated</th>
<th className="pb-2 text-right font-medium text-gray-500">Missing</th>
<th className="pb-2 text-left pl-4 font-medium text-gray-500">Coverage</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{scanResults.map(({ ns, total, translated, missing }) => {
const pct = total === 0 ? 100 : Math.round((translated / total) * 100);
return (
<tr key={ns} className="hover:bg-gray-50">
<td className="py-2 font-mono text-xs text-[#1C2B4A]">{ns}</td>
<td className="py-2 text-right text-gray-500">{total}</td>
<td className="py-2 text-right text-green-600 font-medium">{translated}</td>
<td className={`py-2 text-right font-medium ${missing > 0 ? 'text-red-500' : 'text-gray-400'}`}>
{missing}
</td>
<td className="py-2 pl-4">
<div className="flex items-center gap-2">
<div className="w-24 h-1.5 rounded-full bg-gray-100 overflow-hidden">
<div
className={`h-full rounded-full ${
pct === 100 ? 'bg-green-500' : pct >= 50 ? 'bg-yellow-400' : 'bg-red-400'
}`}
style={{ width: `${pct}%` }}
/>
</div>
<span className="text-xs text-gray-500">{pct}%</span>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
{workspaceScan && workspaceScan.missingKeys.length > 0 && (
<div className="mt-5 rounded-xl border border-red-200 bg-red-50/50 p-4">
<h3 className="text-sm font-semibold text-red-700 mb-2">Missing translation keys detected in workspace</h3>
<div className="space-y-2 max-h-48 overflow-y-auto pr-1">
{workspaceScan.missingKeys.map((entry) => (
<div key={entry.key} className="rounded-md border border-red-100 bg-white px-3 py-2">
<p className="font-mono text-xs text-red-700">{entry.key}</p>
<p className="text-[11px] text-gray-500 mt-1">
{entry.files.slice(0, 3).join(', ')}
{entry.files.length > 3 ? ` (+${entry.files.length - 3} more)` : ''}
</p>
</div>
))}
</div>
</div>
)}
{workspaceScan && workspaceScan.untranslatedLiterals.length > 0 && (
<div className="mt-5 rounded-xl border border-amber-200 bg-amber-50/50 p-4">
<h3 className="text-sm font-semibold text-amber-700 mb-2">Potential untranslated UI text detected</h3>
<p className="text-xs text-amber-700/80 mb-3">
These literals appear directly in JSX. Replace them with t('...') to make the page translatable.
</p>
<div className="space-y-2 max-h-56 overflow-y-auto pr-1">
{workspaceScan.untranslatedLiterals.slice(0, 80).map((entry) => (
<div key={entry.text} className="rounded-md border border-amber-100 bg-white px-3 py-2">
<p className="text-xs font-medium text-amber-800">{entry.text}</p>
<p className="text-[11px] text-gray-500 mt-1">
{entry.files.slice(0, 3).join(', ')}
{entry.files.length > 3 ? ` (+${entry.files.length - 3} more)` : ''}
</p>
</div>
))}
</div>
</div>
)}
</div>
<div className="px-6 py-4 border-t border-gray-100 flex justify-between items-center">
<p className="text-xs text-gray-400">
Scan now checks workspace files (pages, components, hooks, utils) and compares used keys against en.ts.
</p>
<button
onClick={() => setShowScanModal(false)}
className="rounded-md bg-[#1C2B4A] text-white px-4 py-2 text-sm font-semibold hover:bg-[#1C2B4A]/90"
>
Close
</button>
</div>
</div>
</div>
)}
</PageLayout>
);
}

View File

@ -12,12 +12,14 @@ import {
Squares2X2Icon,
BanknotesIcon,
ClipboardDocumentListIcon,
CommandLineIcon
CommandLineIcon,
LanguageIcon
} from '@heroicons/react/24/outline'
import { useMemo, useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useAdminUsers } from '../hooks/useAdminUsers'
import useAuthStore from '../store/authStore'
import { useTranslation } from '../i18n/useTranslation'
// env-based feature flags
const DISPLAY_MATRIX = process.env.NEXT_PUBLIC_DISPLAY_MATRIX !== 'false'
@ -27,6 +29,7 @@ const DISPLAY_DEV_MANAGEMENT = process.env.NEXT_PUBLIC_DISPLAY_DEV_MANAGEMENT !=
export default function AdminDashboardPage() {
const router = useRouter()
const { t } = useTranslation()
const { userStats, isAdmin } = useAdminUsers()
const user = useAuthStore(s => s.user)
const isAdminOrSuper =
@ -90,7 +93,7 @@ export default function AdminDashboardPage() {
<div className="min-h-screen flex items-center justify-center bg-blue-50">
<div className="text-center">
<div className="h-12 w-12 rounded-full border-2 border-blue-900 border-b-transparent animate-spin mx-auto mb-4" />
<p className="text-blue-900">Loading...</p>
<p className="text-blue-900">{t('adminDashboard.loading')}</p>
</div>
</div>
</PageLayout>
@ -104,8 +107,8 @@ export default function AdminDashboardPage() {
<div className="min-h-screen flex items-center justify-center bg-blue-50">
<div className="mx-auto w-full max-w-xl rounded-2xl bg-white shadow ring-1 ring-red-500/20 p-8">
<div className="text-center">
<h1 className="text-2xl font-bold text-red-600 mb-2">Access Denied</h1>
<p className="text-gray-600">You need admin privileges to access this page.</p>
<h1 className="text-2xl font-bold text-red-600 mb-2">{t('adminDashboard.accessDenied')}</h1>
<p className="text-gray-600">{t('adminDashboard.accessDeniedMessage')}</p>
</div>
</div>
</div>
@ -120,9 +123,9 @@ export default function AdminDashboardPage() {
{/* Header */}
<header className="flex flex-col gap-4 mb-8">
<div>
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Admin Dashboard</h1>
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">{t('adminDashboard.title')}</h1>
<p className="text-lg text-blue-700 mt-2">
Manage all administrative features, user management, permissions, and global settings.
{t('adminDashboard.subtitle')}
</p>
</div>
</header>
@ -132,10 +135,10 @@ export default function AdminDashboardPage() {
<ExclamationTriangleIcon className="h-6 w-6 flex-shrink-0 text-red-500 mt-0.5" />
<div className="leading-relaxed">
<p className="font-semibold mb-0.5">
Warning: Settings and actions below this point can have consequences for the entire system!
{t('adminDashboard.warningTitle')}
</p>
<p className="text-red-600/80 hidden sm:block">
Manage all administrative features, user management, permissions, and global settings.
{t('adminDashboard.warningMessage')}
</p>
</div>
</div>
@ -143,27 +146,27 @@ export default function AdminDashboardPage() {
{/* Stats Card */}
<div className="mb-8 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-6">
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
<div className="text-xs text-gray-500">Total Users</div>
<div className="text-xs text-gray-500">{t('adminDashboard.totalUsers')}</div>
<div className="text-xl font-semibold text-blue-900">{displayStats.totalUsers}</div>
</div>
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
<div className="text-xs text-gray-500">Admins</div>
<div className="text-xs text-gray-500">{t('adminDashboard.admins')}</div>
<div className="text-xl font-semibold text-indigo-700">{displayStats.adminUsers}</div>
</div>
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
<div className="text-xs text-gray-500">Active</div>
<div className="text-xs text-gray-500">{t('adminDashboard.active')}</div>
<div className="text-xl font-semibold text-green-700">{displayStats.activeUsers}</div>
</div>
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
<div className="text-xs text-gray-500">Pending Verification</div>
<div className="text-xs text-gray-500">{t('adminDashboard.pendingVerification')}</div>
<div className="text-xl font-semibold text-amber-700">{displayStats.verificationPending}</div>
</div>
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
<div className="text-xs text-gray-500">Personal</div>
<div className="text-xs text-gray-500">{t('adminDashboard.personal')}</div>
<div className="text-xl font-semibold text-blue-700">{displayStats.personalUsers}</div>
</div>
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
<div className="text-xs text-gray-500">Company</div>
<div className="text-xs text-gray-500">{t('adminDashboard.company')}</div>
<div className="text-xl font-semibold text-purple-700">{displayStats.companyUsers}</div>
</div>
</div>
@ -176,9 +179,9 @@ export default function AdminDashboardPage() {
<Squares2X2Icon className="h-7 w-7 text-blue-600" />
</div>
<div>
<h2 className="text-lg font-semibold text-blue-900">Management Shortcuts</h2>
<h2 className="text-lg font-semibold text-blue-900">{t('adminDashboard.managementShortcuts')}</h2>
<p className="text-sm text-blue-700 mt-0.5">
Quick access to common admin modules.
{t('adminDashboard.managementShortcutsSubtitle')}
</p>
</div>
</div>
@ -205,11 +208,11 @@ export default function AdminDashboardPage() {
<Squares2X2Icon className={`h-6 w-6 ${DISPLAY_MATRIX ? 'text-blue-600' : 'text-gray-400'}`} />
</span>
<div className="text-left">
<div className="text-base font-semibold text-blue-900">Matrix Management</div>
<div className="text-xs text-blue-700">Configure matrices and users</div>
<div className="text-base font-semibold text-blue-900">{t('adminDashboard.matrixManagement')}</div>
<div className="text-xs text-blue-700">{t('adminDashboard.matrixManagementDesc')}</div>
{!DISPLAY_MATRIX && (
<p className="mt-1 text-xs text-gray-500 italic">
This module is currently disabled in the system configuration.
{t('adminDashboard.moduleDisabled')}
</p>
)}
</div>
@ -243,11 +246,11 @@ export default function AdminDashboardPage() {
<BanknotesIcon className={`h-6 w-6 ${DISPLAY_ABONEMENTS ? 'text-amber-600' : 'text-gray-400'}`} />
</span>
<div className="text-left">
<div className="text-base font-semibold text-amber-900">Coffee Subscription Management</div>
<div className="text-xs text-amber-700">Plans, billing and renewals</div>
<div className="text-base font-semibold text-amber-900">{t('adminDashboard.coffeeSubscriptions')}</div>
<div className="text-xs text-amber-700">{t('adminDashboard.coffeeSubscriptionsDesc')}</div>
{!DISPLAY_ABONEMENTS && (
<p className="mt-1 text-xs text-gray-500 italic">
This module is currently disabled in the system configuration.
{t('adminDashboard.moduleDisabled')}
</p>
)}
</div>
@ -272,8 +275,8 @@ export default function AdminDashboardPage() {
<ClipboardDocumentListIcon className="h-6 w-6 text-indigo-600" />
</span>
<div className="text-left">
<div className="text-base font-semibold text-indigo-900">Contract Management</div>
<div className="text-xs text-indigo-700">Templates, approvals, status</div>
<div className="text-base font-semibold text-indigo-900">{t('adminDashboard.contractManagement')}</div>
<div className="text-xs text-indigo-700">{t('adminDashboard.contractManagementDesc')}</div>
</div>
</div>
<ArrowRightIcon className="h-5 w-5 text-indigo-600 opacity-70 group-hover:opacity-100" />
@ -290,8 +293,8 @@ export default function AdminDashboardPage() {
<Squares2X2Icon className="h-6 w-6 text-blue-600" />
</span>
<div className="text-left">
<div className="text-base font-semibold text-blue-900">Dashboard Management</div>
<div className="text-xs text-blue-700">Configure dashboard platforms</div>
<div className="text-base font-semibold text-blue-900">{t('adminDashboard.dashboardManagement')}</div>
<div className="text-xs text-blue-700">{t('adminDashboard.dashboardManagementDesc')}</div>
</div>
</div>
<ArrowRightIcon className="h-5 w-5 text-blue-600 opacity-70 group-hover:opacity-100" />
@ -308,8 +311,8 @@ export default function AdminDashboardPage() {
<UsersIcon className="h-6 w-6 text-blue-600" />
</span>
<div className="text-left">
<div className="text-base font-semibold text-blue-900">User Management</div>
<div className="text-xs text-blue-700">Browse, search, and manage all users</div>
<div className="text-base font-semibold text-blue-900">{t('adminDashboard.userManagement')}</div>
<div className="text-xs text-blue-700">{t('adminDashboard.userManagementDesc')}</div>
</div>
</div>
<ArrowRightIcon className="h-5 w-5 text-blue-600 opacity-70 group-hover:opacity-100" />
@ -326,8 +329,8 @@ export default function AdminDashboardPage() {
<ExclamationTriangleIcon className="h-6 w-6 text-rose-600" />
</span>
<div className="text-left">
<div className="text-base font-semibold text-rose-900">User Verify</div>
<div className="text-xs text-rose-700">Review and verify user onboarding status</div>
<div className="text-base font-semibold text-rose-900">{t('adminDashboard.userVerify')}</div>
<div className="text-xs text-rose-700">{t('adminDashboard.userVerifyDesc')}</div>
</div>
</div>
<ArrowRightIcon className="h-5 w-5 text-rose-600 opacity-70 group-hover:opacity-100" />
@ -344,8 +347,8 @@ export default function AdminDashboardPage() {
<BanknotesIcon className="h-6 w-6 text-emerald-600" />
</span>
<div className="text-left">
<div className="text-base font-semibold text-emerald-900">Finance Management</div>
<div className="text-xs text-emerald-700">Tax rates, billing settings and finance tools</div>
<div className="text-base font-semibold text-emerald-900">{t('adminDashboard.financeManagement')}</div>
<div className="text-xs text-emerald-700">{t('adminDashboard.financeManagementDesc')}</div>
</div>
</div>
<ArrowRightIcon className="h-5 w-5 text-emerald-600 opacity-70 group-hover:opacity-100" />
@ -362,8 +365,8 @@ export default function AdminDashboardPage() {
<ServerStackIcon className="h-6 w-6 text-cyan-600" />
</span>
<div className="text-left">
<div className="text-base font-semibold text-cyan-900">Pool Management</div>
<div className="text-xs text-cyan-700">Manage pool structures and assignments</div>
<div className="text-base font-semibold text-cyan-900">{t('adminDashboard.poolManagement')}</div>
<div className="text-xs text-cyan-700">{t('adminDashboard.poolManagementDesc')}</div>
</div>
</div>
<ArrowRightIcon className="h-5 w-5 text-cyan-600 opacity-70 group-hover:opacity-100" />
@ -380,8 +383,8 @@ export default function AdminDashboardPage() {
<UsersIcon className="h-6 w-6 text-violet-600" />
</span>
<div className="text-left">
<div className="text-base font-semibold text-violet-900">Affiliate Management</div>
<div className="text-xs text-violet-700">Partner content and affiliate controls</div>
<div className="text-base font-semibold text-violet-900">{t('adminDashboard.affiliateManagement')}</div>
<div className="text-xs text-violet-700">{t('adminDashboard.affiliateManagementDesc')}</div>
</div>
</div>
<ArrowRightIcon className="h-5 w-5 text-violet-600 opacity-70 group-hover:opacity-100" />
@ -409,11 +412,11 @@ export default function AdminDashboardPage() {
<ClipboardDocumentListIcon className={`h-6 w-6 ${DISPLAY_NEWS ? 'text-green-600' : 'text-gray-400'}`} />
</span>
<div className="text-left">
<div className="text-base font-semibold text-green-900">News Management</div>
<div className="text-xs text-green-700">Create and manage news articles</div>
<div className="text-base font-semibold text-green-900">{t('adminDashboard.newsManagement')}</div>
<div className="text-xs text-green-700">{t('adminDashboard.newsManagementDesc')}</div>
{!DISPLAY_NEWS && (
<p className="mt-1 text-xs text-gray-500 italic">
This module is currently disabled in the system configuration.
{t('adminDashboard.moduleDisabled')}
</p>
)}
</div>
@ -447,16 +450,16 @@ export default function AdminDashboardPage() {
<CommandLineIcon className={`h-6 w-6 ${DISPLAY_DEV_MANAGEMENT && isAdminOrSuper ? 'text-slate-600' : 'text-gray-400'}`} />
</span>
<div className="text-left">
<div className="text-base font-semibold text-slate-900">Dev Management</div>
<div className="text-xs text-slate-700">Run SQL queries and dev tools</div>
<div className="text-base font-semibold text-slate-900">{t('adminDashboard.devManagement')}</div>
<div className="text-xs text-slate-700">{t('adminDashboard.devManagementDesc')}</div>
{!DISPLAY_DEV_MANAGEMENT && (
<p className="mt-1 text-xs text-gray-500 italic">
This module is currently disabled in the system configuration.
{t('adminDashboard.moduleDisabled')}
</p>
)}
{DISPLAY_DEV_MANAGEMENT && !isAdminOrSuper && (
<p className="mt-1 text-xs text-gray-500 italic">
Admin access required.
{t('adminDashboard.adminAccessRequired')}
</p>
)}
</div>
@ -467,6 +470,24 @@ export default function AdminDashboardPage() {
}`}
/>
</button>
{/* Language Management */}
<button
type="button"
onClick={() => router.push('/admin/language-management')}
className="group w-full flex items-center justify-between rounded-lg border border-teal-200 bg-teal-50 hover:bg-teal-100 px-4 py-4 transform transition-transform duration-200 hover:scale-[1.02] hover:shadow-md"
>
<div className="flex items-center gap-4">
<span className="inline-flex h-10 w-10 items-center justify-center rounded-md bg-teal-100 border border-teal-200 group-hover:animate-pulse">
<LanguageIcon className="h-6 w-6 text-teal-600" />
</span>
<div className="text-left">
<div className="text-base font-semibold text-teal-900">{t('adminDashboard.languageManagement')}</div>
<div className="text-xs text-teal-700">{t('adminDashboard.languageManagementDesc')}</div>
</div>
</div>
<ArrowRightIcon className="h-5 w-5 text-teal-600 opacity-70 group-hover:opacity-100" />
</button>
</div>
</div>
</div>
@ -479,10 +500,10 @@ export default function AdminDashboardPage() {
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900">
Server Status & Logs
{t('adminDashboard.serverStatusLogs')}
</h2>
<p className="text-sm text-gray-500 mt-0.5">
System health, resource usage & recent error insights.
{t('adminDashboard.serverStatusLogsSubtitle')}
</p>
</div>
</div>
@ -493,20 +514,20 @@ export default function AdminDashboardPage() {
<div className="flex items-center gap-3">
<span className={`h-2.5 w-2.5 rounded-full ${serverStats.status === 'Online' ? 'bg-emerald-500' : 'bg-red-500'}`} />
<p className="text-base">
<span className="font-semibold">Server Status:</span>{' '}
<span className="font-semibold">{t('adminDashboard.serverStatusLabel')}</span>{' '}
<span className={serverStats.status === 'Online' ? 'text-emerald-600 font-medium' : 'text-red-600 font-medium'}>
{serverStats.status === 'Online' ? 'Server Online' : 'Offline'}
{serverStats.status === 'Online' ? t('adminDashboard.serverOnline') : t('adminDashboard.serverOffline')}
</span>
</p>
</div>
<div className="text-sm space-y-1 text-gray-600">
<p><span className="font-medium text-gray-700">Uptime:</span> {serverStats.uptime}</p>
<p><span className="font-medium text-gray-700">CPU Usage:</span> {serverStats.cpu}</p>
<p><span className="font-medium text-gray-700">Memory Usage:</span> {serverStats.memory} GB</p>
<p><span className="font-medium text-gray-700">{t('adminDashboard.uptime')}</span> {serverStats.uptime}</p>
<p><span className="font-medium text-gray-700">{t('adminDashboard.cpuUsage')}</span> {serverStats.cpu}</p>
<p><span className="font-medium text-gray-700">{t('adminDashboard.memoryUsage')}</span> {serverStats.memory} GB</p>
</div>
<div className="flex items-center gap-2 text-sm text-gray-500">
<CpuChipIcon className="h-4 w-4" />
<span>Autoscaled environment (mock)</span>
<span>{t('adminDashboard.autoscaledEnvironment')}</span>
</div>
</div>
@ -516,11 +537,11 @@ export default function AdminDashboardPage() {
{/* Logs */}
<div className="lg:col-span-2">
<h3 className="text-base font-semibold text-gray-800 mb-3">
Recent Error Logs
{t('adminDashboard.recentErrorLogs')}
</h3>
{serverStats.recentErrors.length === 0 && (
<p className="text-sm text-gray-500 italic">
No recent logs.
{t('adminDashboard.noRecentLogs')}
</p>
)}
{/* Placeholder for future logs list */}
@ -532,7 +553,7 @@ export default function AdminDashboardPage() {
// TODO: navigate to logs / monitoring page
onClick={() => {}}
>
View Full Logs
{t('adminDashboard.viewFullLogs')}
<ArrowRightIcon className="h-5 w-5" />
</button>
</div>

View File

@ -0,0 +1,215 @@
import { NextResponse } from 'next/server';
import { promises as fs } from 'fs';
import path from 'path';
import { en } from '@/app/i18n/translations/en';
import { flattenObject } from '@/app/i18n/dynamicTranslations';
const EXCLUDED_DIRS = new Set([
'.git',
'.next',
'node_modules',
'dist',
'build',
'coverage',
'out',
'.turbo',
]);
const SCANNED_EXTENSIONS = new Set([
'.ts',
'.tsx',
'.js',
'.jsx',
'.mjs',
'.cjs',
'.json',
'.html',
'.md',
]);
const MAX_FILE_SIZE_BYTES = 1024 * 1024; // 1 MB per file
type MissingKeyMap = Map<string, Set<string>>;
interface ScanResult {
scannedFiles: number;
scannedDirectories: number;
translationCallCount: number;
uniqueKeyCount: number;
missingKeys: Array<{ key: string; files: string[] }>;
untranslatedLiterals: Array<{ text: string; files: string[] }>;
}
async function walk(dir: string, outFiles: string[], counters: { dirs: number }) {
counters.dirs += 1;
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (!EXCLUDED_DIRS.has(entry.name)) {
await walk(fullPath, outFiles, counters);
}
continue;
}
if (!entry.isFile()) continue;
const ext = path.extname(entry.name).toLowerCase();
if (!SCANNED_EXTENSIONS.has(ext)) continue;
const stat = await fs.stat(fullPath);
if (stat.size > MAX_FILE_SIZE_BYTES) continue;
outFiles.push(fullPath);
}
}
function extractTranslationKeys(content: string): string[] {
const keys: string[] = [];
const regexes = [
/\bt\(\s*['"`]([^'"`]+)['"`]\s*[,)\]]/g,
/\bgetEnglishValue\(\s*['"`]([^'"`]+)['"`]\s*\)/g,
];
for (const regex of regexes) {
let match: RegExpExecArray | null = null;
while ((match = regex.exec(content)) !== null) {
if (match[1]) keys.push(match[1]);
}
}
return keys;
}
function isUiCodeFile(filePath: string): boolean {
const ext = path.extname(filePath).toLowerCase();
return ext === '.tsx' || ext === '.jsx';
}
function extractPotentialUiLiterals(content: string): string[] {
const literals: string[] = [];
// Text nodes in JSX, e.g. >Welcome back<
const jsxTextRegex = />\s*([^<>{}\n][^<>{}\n]{1,})\s*</g;
let match: RegExpExecArray | null = null;
while ((match = jsxTextRegex.exec(content)) !== null) {
const text = match[1]?.replace(/\s+/g, ' ').trim();
if (!text) continue;
literals.push(text);
}
return literals;
}
function shouldIgnoreLiteral(text: string): boolean {
const trimmed = text.trim();
if (!trimmed) return true;
if (trimmed.length < 3) return true;
// Ignore non-user-facing technical fragments
if (/^(https?:|\/|\.|#|\{|\}|\[|\]|\(|\)|;|,|\+|-|\*|=|&&|\|\|)/.test(trimmed)) return true;
if (!/[A-Za-zÀ-ÿ]/.test(trimmed)) return true;
if (/^&[a-z]+;$/i.test(trimmed)) return true;
if (/[{}()[\];]|=>|===|!==|&&|\|\||::/.test(trimmed)) return true;
if (/React\.|TouchEvent|MouseEvent|ChangeEvent|KeyboardEvent|SyntheticEvent/.test(trimmed)) return true;
if (/^[0-9]+\s*[)&|]/.test(trimmed)) return true;
if (/^[|><=+\-/*]+/.test(trimmed)) return true;
if (/^[a-z0-9._/-]+$/i.test(trimmed) && !/\s/.test(trimmed)) return true;
if (/^(use client|true|false|null|undefined)$/i.test(trimmed)) return true;
if (/(className|onClick|href|src|aria-|data-)/.test(trimmed)) return true;
if (/^[A-Za-z0-9_.]+\s*:\s*[A-Za-z0-9_.]+$/.test(trimmed)) return true;
return false;
}
function toRelativeWorkspacePath(absPath: string): string {
const rel = path.relative(process.cwd(), absPath);
return rel.split(path.sep).join('/');
}
async function runWorkspaceScan(): Promise<ScanResult> {
const workspaceRoot = process.cwd();
const files: string[] = [];
const counters = { dirs: 0 };
await walk(workspaceRoot, files, counters);
const englishKeys = new Set(Object.keys(flattenObject(en as Record<string, unknown>)));
const uniqueUsedKeys = new Set<string>();
const missingKeyFiles: MissingKeyMap = new Map();
const untranslatedLiteralFiles: Map<string, Set<string>> = new Map();
let translationCallCount = 0;
for (const filePath of files) {
const raw = await fs.readFile(filePath, 'utf8');
const relativePath = toRelativeWorkspacePath(filePath);
const usedKeys = extractTranslationKeys(raw);
if (usedKeys.length > 0) {
translationCallCount += usedKeys.length;
for (const key of usedKeys) {
uniqueUsedKeys.add(key);
if (!englishKeys.has(key)) {
if (!missingKeyFiles.has(key)) {
missingKeyFiles.set(key, new Set<string>());
}
missingKeyFiles.get(key)?.add(relativePath);
}
}
}
if (isUiCodeFile(filePath)) {
const literals = extractPotentialUiLiterals(raw).filter((text) => !shouldIgnoreLiteral(text));
for (const text of literals) {
if (!untranslatedLiteralFiles.has(text)) {
untranslatedLiteralFiles.set(text, new Set<string>());
}
untranslatedLiteralFiles.get(text)?.add(relativePath);
}
}
}
const missingKeys = Array.from(missingKeyFiles.entries())
.map(([key, fileSet]) => ({ key, files: Array.from(fileSet).sort() }))
.sort((a, b) => a.key.localeCompare(b.key));
const untranslatedLiterals = Array.from(untranslatedLiteralFiles.entries())
.map(([text, fileSet]) => ({ text, files: Array.from(fileSet).sort() }))
.sort((a, b) => b.files.length - a.files.length || a.text.localeCompare(b.text))
.slice(0, 300);
return {
scannedFiles: files.length,
scannedDirectories: counters.dirs,
translationCallCount,
uniqueKeyCount: uniqueUsedKeys.size,
missingKeys,
untranslatedLiterals,
};
}
export async function GET() {
try {
const result = await runWorkspaceScan();
return NextResponse.json({
ok: true,
scannedAt: new Date().toISOString(),
...result,
});
} catch (error) {
console.error('Workspace i18n scan failed:', error);
return NextResponse.json(
{
ok: false,
message: 'Workspace scan failed.',
},
{ status: 500 }
);
}
}

View File

@ -3,93 +3,64 @@
import { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/20/solid';
import { useTranslation } from '../i18n/useTranslation';
import { SUPPORTED_LANGUAGES, LANGUAGE_NAMES } from '../i18n/config';
// Built-in language info (code → name + flag emoji)
const BUILTIN_LANG_INFO: Record<string, { name: string; flag: string }> = {
en: { name: 'English', flag: '🇬🇧' },
de: { name: 'Deutsch', flag: '🇩🇪' },
};
interface LangEntry { code: string; name: string; flag: string }
interface LanguageSwitcherProps {
variant?: 'light' | 'dark';
}
// Flag Icons mit Emoji (viel sauberer als selbst gezeichnete CSS-Flaggen)
const FlagIcon = ({ countryCode, className = "size-5" }: { countryCode: string; className?: string }) => {
const flags = {
'de': '🇩🇪',
'en': '🇬🇧'
};
return (
<span className={`${className} flex items-center justify-center text-base`}>
{flags[countryCode as keyof typeof flags] || '🏳️'}
</span>
);
};
export default function LanguageSwitcher({ variant = 'light' }: LanguageSwitcherProps) {
const { language, setLanguage } = useTranslation();
const { language, setLanguage, customI18n } = useTranslation();
const getButtonStyles = () => {
if (variant === 'dark') {
return 'inline-flex w-full justify-center gap-x-1.5 rounded-md bg-white/10 px-3 py-2 text-sm font-semibold text-white inset-ring-1 inset-ring-white/5 hover:bg-white/20';
}
return 'inline-flex w-full justify-center gap-x-1.5 rounded-md bg-gray-100 px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-gray-300 hover:bg-gray-200';
};
// Combine built-in + custom languages (deduplicated by code)
const allLangs: LangEntry[] = [
...Object.entries(BUILTIN_LANG_INFO).map(([code, info]) => ({ code, ...info })),
...customI18n.languages
.filter((l) => !BUILTIN_LANG_INFO[l.code])
.map((l) => ({ code: l.code, name: l.name, flag: l.flag ?? '🏳️' })),
];
const getMenuStyles = () => {
if (variant === 'dark') {
return 'absolute right-0 z-10 mt-2 w-48 origin-top-right divide-y divide-white/10 rounded-md bg-gray-800 outline-1 -outline-offset-1 outline-white/10 transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in';
}
return 'absolute right-0 z-10 mt-2 w-48 origin-top-right divide-y divide-gray-100 rounded-md bg-white outline-1 -outline-offset-1 outline-gray-200 transition data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in';
};
const activeLang: LangEntry =
allLangs.find((l) => l.code === language) ?? { code: language, name: language, flag: '🏳️' };
const getItemStyles = (isActive: boolean) => {
if (variant === 'dark') {
return `group flex items-center px-4 py-2 text-sm ${
isActive
? 'bg-[#8D6B1D] text-white'
: 'text-gray-300 data-focus:bg-white/5 data-focus:text-white data-focus:outline-hidden'
}`;
}
return `group flex items-center px-4 py-2 text-sm ${
isActive
? 'bg-[#8D6B1D] text-white'
: 'text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 data-focus:outline-hidden'
}`;
};
const buttonCls =
variant === 'dark'
? 'inline-flex items-center gap-x-1.5 rounded-md bg-white/10 px-3 py-2 text-sm font-semibold text-white hover:bg-white/20 transition-colors'
: 'inline-flex items-center gap-x-1.5 rounded-md bg-gray-100 px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-gray-300 hover:bg-gray-200 transition-colors';
const menuCls =
variant === 'dark'
? 'absolute right-0 z-50 mt-2 w-52 origin-top-right rounded-xl bg-gray-800/95 backdrop-blur-sm border border-white/10 shadow-2xl py-1 transition data-closed:scale-95 data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in'
: 'absolute right-0 z-50 mt-2 w-52 origin-top-right rounded-xl bg-white border border-gray-200 shadow-xl py-1 transition data-closed:scale-95 data-closed:opacity-0 data-enter:duration-100 data-enter:ease-out data-leave:duration-75 data-leave:ease-in';
const itemCls = (isActive: boolean) =>
variant === 'dark'
? `flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-colors ${isActive ? 'bg-[#8D6B1D] text-white' : 'text-gray-200 hover:bg-white/10 hover:text-white'}`
: `flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-colors ${isActive ? 'bg-[#8D6B1D] text-white' : 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'}`;
return (
<Menu as="div" className="relative inline-block">
<MenuButton className={getButtonStyles()}>
<FlagIcon countryCode={language} className="size-4" />
{LANGUAGE_NAMES[language]}
<ChevronDownIcon aria-hidden="true" className="-mr-1 size-5 text-gray-500" />
<MenuButton className={buttonCls}>
<span>{activeLang.name}</span>
<ChevronDownIcon aria-hidden="true" className="size-4 opacity-60" />
</MenuButton>
<MenuItems
transition
className={getMenuStyles()}
>
<div className="py-1">
{SUPPORTED_LANGUAGES.map((lang) => (
<MenuItem key={lang}>
<button
onClick={() => setLanguage(lang)}
className={getItemStyles(language === lang)}
>
<FlagIcon
countryCode={lang}
className={`mr-3 size-5 ${
variant === 'dark'
? (language === lang ? 'opacity-100' : 'opacity-70 group-data-focus:opacity-100')
: (language === lang ? 'opacity-100' : 'opacity-80 group-data-focus:opacity-100')
}`}
/>
<span className="flex-1 text-left">{LANGUAGE_NAMES[lang]}</span>
{language === lang && (
<span className="ml-2 text-xs font-bold"></span>
)}
</button>
</MenuItem>
))}
</div>
<MenuItems transition className={menuCls}>
{allLangs.map((lang) => (
<MenuItem key={lang.code}>
<button onClick={() => setLanguage(lang.code)} className={itemCls(language === lang.code)}>
<span className="flex-1 text-left">{lang.name}</span>
{language === lang.code && <span className="text-xs font-bold"></span>}
</button>
</MenuItem>
))}
</MenuItems>
</Menu>
);

View File

@ -20,6 +20,7 @@ import {
} from '@heroicons/react/24/outline'
import { AdminAPI, DetailedUserInfo } from '../utils/api'
import useAuthStore from '../store/authStore'
import { useTranslation } from '../i18n/useTranslation'
import ConfirmActionModal from './modals/ConfirmActionModal'
interface UserDetailModalProps {
@ -47,6 +48,7 @@ const STATUS_OPTIONS: { value: UserStatus; label: string; color: string }[] = [
]
export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated }: UserDetailModalProps) {
const { t } = useTranslation()
const [userDetails, setUserDetails] = useState<DetailedUserInfo | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
@ -399,7 +401,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
<div className="flex">
<XCircleIcon className="h-5 w-5 text-red-400" aria-hidden="true" />
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">Error</h3>
<h3 className="text-sm font-medium text-red-800">{t('userDetailModal.error')}</h3>
<div className="mt-2 text-sm text-red-700">{error}</div>
</div>
</div>
@ -430,14 +432,14 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
? 'bg-blue-100 text-blue-800'
: 'bg-purple-100 text-purple-800'
}`}>
{userDetails.user.user_type === 'personal' ? 'Personal' : 'Company'}
{userDetails.user.user_type === 'personal' ? t('userDetailModal.personal') : t('userDetailModal.company')}
</span>
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
userDetails.user.role === 'admin' || userDetails.user.role === 'super_admin'
? 'bg-yellow-100 text-yellow-800'
: 'bg-gray-100 text-gray-800'
}`}>
{userDetails.user.role === 'super_admin' ? 'Super Admin' : userDetails.user.role}
{userDetails.user.role === 'super_admin' ? t('userDetailModal.superAdmin') : userDetails.user.role}
</span>
</div>
</div>
@ -446,7 +448,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
{/* Status Badge */}
{userDetails.userStatus && (
<div className="bg-white rounded-lg px-4 py-3 text-gray-900">
<div className="text-xs text-gray-500 mb-1">Current Status</div>
<div className="text-xs text-gray-500 mb-1">{t('userDetailModal.currentStatus')}</div>
<div className={`inline-flex items-center px-3 py-1.5 rounded-full text-sm font-semibold border ${
getStatusBadgeClass(getStatusColor(userDetails.userStatus.status as UserStatus))
}`}>
@ -461,7 +463,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
<div className="bg-gray-50 rounded-lg p-6 border border-gray-200">
<h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2">
<ShieldCheckIcon className="h-5 w-5 text-indigo-600" />
Admin Controls
{t('userDetailModal.adminControls')}
</h3>
{missingIdOrContract && (
@ -486,7 +488,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
{/* Status Dropdown */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Change Status
{t('userDetailModal.changeStatus')}
</label>
<Listbox value={selectedStatus} onChange={handleStatusChange} disabled={saving}>
<div className="relative">
@ -538,20 +540,20 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
{/* Admin Verification Toggle */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Admin Verification
{t('userDetailModal.adminVerification')}
</label>
{userDetails?.userStatus && (
<p className="text-xs text-gray-500 mb-2">
{canVerify
? 'All steps completed. You can verify this user.'
: 'User has not yet completed all required steps.'}
? t('userDetailModal.allStepsCompleted')
: t('userDetailModal.stepsNotCompleted')}
</p>
)}
<button
type="button"
onClick={handleToggleAdminVerification}
disabled={saving || !canVerify}
title={!canVerify ? 'Complete all steps and ensure files are present in object storage before admin verification' : undefined}
title={!canVerify ? t('userDetailModal.completeStepsTooltip') : undefined}
className={`w-full inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold shadow-sm focus-visible:outline focus-visible:outline-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${
userDetails.userStatus?.is_admin_verified === 1
? 'bg-amber-600 hover:bg-amber-500 text-white focus-visible:outline-amber-600'
@ -561,12 +563,12 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
{saving ? (
<>
<div className="h-4 w-4 border-2 border-white border-b-transparent rounded-full animate-spin" />
Updating...
{t('userDetailModal.updating')}
</>
) : (
<>
<ShieldCheckIcon className="h-4 w-4" />
{userDetails.userStatus?.is_admin_verified === 1 ? 'Unverify User' : 'Verify User'}
{userDetails.userStatus?.is_admin_verified === 1 ? t('userDetailModal.unverifyUser') : t('userDetailModal.verifyUser')}
</>
)}
</button>
@ -580,7 +582,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
<div className="flex items-center gap-3">
<DocumentTextIcon className="h-5 w-5 text-gray-600" />
<div className="flex items-center gap-2">
<span className="text-lg font-semibold text-gray-900">Contract Preview</span>
<span className="text-lg font-semibold text-gray-900">{t('userDetailModal.contractPreview')}</span>
<div className="flex items-center gap-1 rounded-full border border-gray-200 bg-white px-1">
{(['contract','gdpr'] as const).map((tab) => (
<button
@ -589,7 +591,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
onClick={() => setActivePreviewTab(tab)}
className={`px-2.5 py-1 text-xs rounded-full transition ${activePreviewTab === tab ? 'bg-indigo-600 text-white shadow' : 'text-gray-700 hover:bg-gray-100'}`}
>
{tab === 'contract' ? 'Contract' : 'GDPR'}
{tab === 'contract' ? t('userDetailModal.contractTab') : t('userDetailModal.gdprTab')}
</button>
))}
</div>
@ -607,7 +609,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
disabled={previewState[activePreviewTab].loading}
className="inline-flex items-center justify-center rounded-md bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-2 text-sm disabled:opacity-60"
>
{previewState[activePreviewTab].loading ? 'Loading…' : 'Preview'}
{previewState[activePreviewTab].loading ? t('userDetailModal.loadingPreview') : t('userDetailModal.preview')}
</button>
<button
type="button"
@ -621,7 +623,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
disabled={!previewState[activePreviewTab]?.html}
className="inline-flex items-center justify-center rounded-md bg-gray-100 hover:bg-gray-200 text-gray-900 px-3 py-2 text-sm disabled:opacity-60"
>
Open in new tab
{t('userDetailModal.openInNewTab')}
</button>
</div>
</div>
@ -635,23 +637,23 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
return (
<div className="mb-4">
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-semibold text-gray-900">Files in {activePreviewTab.toUpperCase()}</div>
<div className="text-sm font-semibold text-gray-900">{t('userDetailModal.filesIn')} {activePreviewTab.toUpperCase()}</div>
<button
type="button"
onClick={() => loadContractFiles()}
disabled={docsLoading}
className="inline-flex items-center justify-center rounded-md bg-gray-100 hover:bg-gray-200 text-gray-900 px-3 py-1.5 text-xs disabled:opacity-60"
>
{docsLoading ? 'Refreshing…' : 'Refresh'}
{docsLoading ? t('userDetailModal.refreshing') : t('userDetailModal.refresh')}
</button>
</div>
{docsLoading && (
<div className="mt-2 text-xs text-gray-500">Loading files</div>
<div className="mt-2 text-xs text-gray-500">{t('userDetailModal.loadingFiles')}</div>
)}
{!docsLoading && files.length === 0 && (
<div className="mt-2 text-xs text-gray-500">No files found in this folder.</div>
<div className="mt-2 text-xs text-gray-500">{t('userDetailModal.noFilesFound')}</div>
)}
{!docsLoading && files.length > 0 && (
@ -676,7 +678,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
{selectedItem && (
<div className="mt-2 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 text-xs text-gray-600">
<div className="truncate">Selected: {selectedItem.filename}</div>
<div className="truncate">{t('userDetailModal.selected')} {selectedItem.filename}</div>
{files.length >= 1 && (
<button
type="button"
@ -684,7 +686,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
disabled={isMoving}
className="inline-flex items-center justify-center rounded-md bg-gray-100 hover:bg-gray-200 text-gray-900 px-3 py-1.5 text-xs disabled:opacity-60"
>
{isMoving ? 'Moving…' : `Move to ${moveTarget.toUpperCase()}`}
{isMoving ? t('userDetailModal.moving') : `${t('userDetailModal.moveTo')} ${moveTarget.toUpperCase()}`}
</button>
)}
</div>
@ -706,7 +708,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
)}
{previewState[activePreviewTab].loading && (
<div className="flex items-center justify-center h-40 text-sm text-gray-500">
Loading preview
{t('userDetailModal.loadingPreviewText')}
</div>
)}
{!previewState[activePreviewTab].loading && previewState[activePreviewTab].html && (
@ -719,7 +721,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
</div>
)}
{!previewState[activePreviewTab].loading && !previewState[activePreviewTab].html && !previewState[activePreviewTab].error && (
<p className="text-sm text-gray-500">Click Preview to render the latest template for this user.</p>
<p className="text-sm text-gray-500">{t('userDetailModal.clickPreviewHint')}</p>
)}
</div>
</div>
@ -730,37 +732,37 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<UserIcon className="h-5 w-5 text-gray-600" />
Personal Information
{t('userDetailModal.personalInformation')}
</h3>
</div>
<div className="px-6 py-5">
<dl className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-5">
<div>
<dt className="text-sm font-medium text-gray-500 mb-1.5">First Name</dt>
<dt className="text-sm font-medium text-gray-500 mb-1.5">{t('userDetailModal.firstName')}</dt>
<dd className="text-sm text-gray-900 font-medium">{userDetails.personalProfile.first_name || 'N/A'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500 mb-1.5">Last Name</dt>
<dt className="text-sm font-medium text-gray-500 mb-1.5">{t('userDetailModal.lastName')}</dt>
<dd className="text-sm text-gray-900 font-medium">{userDetails.personalProfile.last_name || 'N/A'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500 mb-1.5">
<PhoneIcon className="h-4 w-4 inline mr-1.5" />
Phone
{t('userDetailModal.phone')}
</dt>
<dd className="text-sm text-gray-900 font-medium">{userDetails.personalProfile.phone || 'N/A'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500 mb-1.5">
<CalendarIcon className="h-4 w-4 inline mr-1.5" />
Date of Birth
{t('userDetailModal.dateOfBirth')}
</dt>
<dd className="text-sm text-gray-900 font-medium">{formatDate(userDetails.personalProfile.date_of_birth)}</dd>
</div>
<div className="md:col-span-2">
<dt className="text-sm font-medium text-gray-500 mb-1.5">
<MapPinIcon className="h-4 w-4 inline mr-1.5" />
Address
{t('userDetailModal.address')}
</dt>
<dd className="text-sm text-gray-900 font-medium">
{userDetails.personalProfile.address || 'N/A'}
@ -780,34 +782,34 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<BuildingOfficeIcon className="h-5 w-5 text-gray-600" />
Company Information
{t('userDetailModal.companyInformation')}
</h3>
</div>
<div className="px-6 py-5">
<dl className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-5">
<div>
<dt className="text-sm font-medium text-gray-500 mb-1.5">Company Name</dt>
<dt className="text-sm font-medium text-gray-500 mb-1.5">{t('userDetailModal.companyName')}</dt>
<dd className="text-sm text-gray-900 font-medium">{userDetails.companyProfile.company_name || 'N/A'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500 mb-1.5">Registration Number</dt>
<dt className="text-sm font-medium text-gray-500 mb-1.5">{t('userDetailModal.registrationNumber')}</dt>
<dd className="text-sm text-gray-900 font-medium">{userDetails.companyProfile.registration_number || 'N/A'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500 mb-1.5">Tax ID</dt>
<dt className="text-sm font-medium text-gray-500 mb-1.5">{t('userDetailModal.taxId')}</dt>
<dd className="text-sm text-gray-900 font-medium">{userDetails.companyProfile.tax_id || 'N/A'}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500 mb-1.5">
<PhoneIcon className="h-4 w-4 inline mr-1.5" />
Phone
{t('userDetailModal.phone')}
</dt>
<dd className="text-sm text-gray-900 font-medium">{userDetails.companyProfile.phone || 'N/A'}</dd>
</div>
<div className="md:col-span-2">
<dt className="text-sm font-medium text-gray-500 mb-1.5">
<MapPinIcon className="h-4 w-4 inline mr-1.5" />
Address
{t('userDetailModal.address')}
</dt>
<dd className="text-sm text-gray-900 font-medium">
{userDetails.companyProfile.address || 'N/A'}
@ -827,7 +829,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<CheckCircleIcon className="h-5 w-5 text-gray-600" />
Registration Progress
{t('userDetailModal.registrationProgress')}
</h3>
</div>
<div className="px-6 py-5">
@ -838,7 +840,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
) : (
<XCircleIcon className="h-6 w-6 text-gray-300" />
)}
<span className="text-sm font-medium text-gray-700">Email Verified</span>
<span className="text-sm font-medium text-gray-700">{t('userDetailModal.emailVerified')}</span>
</div>
<div className="flex items-center gap-3">
{userDetails.userStatus.profile_completed === 1 ? (
@ -846,7 +848,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
) : (
<XCircleIcon className="h-6 w-6 text-gray-300" />
)}
<span className="text-sm font-medium text-gray-700">Profile Completed</span>
<span className="text-sm font-medium text-gray-700">{t('userDetailModal.profileCompleted')}</span>
</div>
<div className="flex items-center gap-3">
{userDetails.userStatus.documents_uploaded === 1 ? (
@ -854,7 +856,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
) : (
<XCircleIcon className="h-6 w-6 text-gray-300" />
)}
<span className="text-sm font-medium text-gray-700">Documents Uploaded</span>
<span className="text-sm font-medium text-gray-700">{t('userDetailModal.documentsUploaded')}</span>
</div>
<div className="flex items-center gap-3">
{userDetails.userStatus.contract_signed === 1 ? (
@ -862,7 +864,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
) : (
<XCircleIcon className="h-6 w-6 text-gray-300" />
)}
<span className="text-sm font-medium text-gray-700">Contract Signed</span>
<span className="text-sm font-medium text-gray-700">{t('userDetailModal.contractSigned')}</span>
</div>
</div>
</div>
@ -874,7 +876,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<ShieldCheckIcon className="h-5 w-5 text-gray-600" />
Permissions ({selectedPermissions.length})
{t('userDetailModal.permissions')} ({selectedPermissions.length})
</h3>
<button
type="button"
@ -882,7 +884,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
disabled={permissionsSaving || permissionsLoading}
className="inline-flex items-center justify-center rounded-md bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-2 text-sm disabled:opacity-60"
>
{permissionsSaving ? 'Saving…' : 'Save Permissions'}
{permissionsSaving ? t('userDetailModal.savingPermissions') : t('userDetailModal.savePermissions')}
</button>
</div>
<div className="px-6 py-5">
@ -894,10 +896,10 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
{permissionsLoading ? (
<div className="flex items-center gap-2 text-sm text-gray-500">
<div className="h-4 w-4 border-2 border-gray-400 border-b-transparent rounded-full animate-spin" />
Loading permissions
{t('userDetailModal.loadingPermissions')}
</div>
) : allPermissions.length === 0 ? (
<div className="text-sm text-gray-500">No permissions available.</div>
<div className="text-sm text-gray-500">{t('userDetailModal.noPermissionsAvailable')}</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{allPermissions.map((perm) => {
@ -923,7 +925,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
<div className="text-xs text-gray-500 mt-0.5">{perm.description}</div>
)}
{!perm.is_active && (
<div className="text-xs text-gray-400 mt-0.5">Inactive</div>
<div className="text-xs text-gray-400 mt-0.5">{t('userDetailModal.inactive')}</div>
)}
</div>
</label>
@ -941,7 +943,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
onClick={onClose}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-gray-200 px-4 py-2.5 text-sm font-semibold text-gray-900 shadow-sm hover:bg-gray-300 focus-visible:outline focus-visible:outline-offset-2 focus-visible:outline-gray-500"
>
Close
{t('userDetailModal.close')}
</button>
</div>
</div>
@ -957,14 +959,14 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
<ConfirmActionModal
open={Boolean(moveConfirm)}
pending={Boolean(moveConfirm && moveLoading[(moveConfirm.objectKey || String(moveConfirm.documentId || ''))])}
title={`Move document to ${moveConfirm?.targetType === 'gdpr' ? 'GDPR' : 'Contract'}?`}
description="This will reclassify the selected document under the chosen contract type."
confirmText="Move document"
title={`${t('userDetailModal.moveDocumentTitle')} ${moveConfirm?.targetType === 'gdpr' ? 'GDPR' : 'Contract'}?`}
description={t('userDetailModal.moveDocumentDescription')}
confirmText={t('userDetailModal.moveDocumentConfirm')}
onClose={() => setMoveConfirm(null)}
onConfirm={confirmMoveContractDoc}
extraContent={
moveConfirm?.filename ? (
<div className="text-xs text-gray-600">File: {moveConfirm.filename}</div>
<div className="text-xs text-gray-600">{t('userDetailModal.moveDocumentFile')} {moveConfirm.filename}</div>
) : null
}
/>

View File

@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { useRouter, usePathname } from 'next/navigation'
import Image from 'next/image'
import {
@ -21,6 +21,8 @@ import {
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import useAuthStore from '../../store/authStore'
import { Avatar } from '../avatar'
import LanguageSwitcher from '../LanguageSwitcher'
import { useTranslation } from '../../i18n/useTranslation'
// ENV-BASED FEATURE FLAGS (string envs: treat "false" as off, everything else as on)
const DISPLAY_NEWS = process.env.NEXT_PUBLIC_DISPLAY_NEWS !== 'false'
@ -33,18 +35,18 @@ const DISPLAY_POOLS = process.env.NEXT_PUBLIC_DISPLAY_POOLS !== 'false'
// Information dropdown, controlled by env flags
const informationItems = [
{ name: 'Affiliate-Links', href: '/affiliate-links', description: 'Browse our partner links' },
{ labelKey: 'nav.affiliateLinks', href: '/affiliate-links' },
...(DISPLAY_MEMBERSHIP
? [{ name: 'Memberships', href: '/memberships', description: 'Explore membership options' }]
? [{ labelKey: 'nav.memberships', href: '/memberships' }]
: []),
...(DISPLAY_ABOUT_US
? [{ name: 'About us', href: '/about-us', description: 'Learn more about us' }]
? [{ labelKey: 'nav.aboutUs', href: '/about-us' }]
: []),
]
// Top-level navigation links, controlled by env flags
const navLinks = [
...(DISPLAY_NEWS ? [{ name: 'News', href: '/news' }] : []),
...(DISPLAY_NEWS ? [{ labelKey: 'nav.news', href: '/news' }] : []),
]
// Toggle visibility of Shop navigation across header (desktop + mobile)
@ -55,6 +57,7 @@ interface HeaderProps {
}
export default function Header({ setGlobalLoggingOut }: HeaderProps) {
const { t } = useTranslation()
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [mounted, setMounted] = useState(false)
const [animateIn, setAnimateIn] = useState(false)
@ -84,6 +87,16 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
const [canSeeDashboard, setCanSeeDashboard] = useState(false)
const headerElRef = useRef<HTMLElement | null>(null)
const translatedInformationItems = useMemo(
() => informationItems.map((item) => ({ ...item, name: t(item.labelKey) })),
[t]
)
const translatedNavLinks = useMemo(
() => navLinks.map((link) => ({ ...link, name: t(link.labelKey) })),
[t]
)
const handleLogout = async () => {
try {
// start global logout transition
@ -482,7 +495,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
<PopoverGroup className="hidden lg:flex lg:gap-x-12">
{/* Navigation Links */}
{navLinks.map((link) => (
{translatedNavLinks.map((link) => (
<button
key={link.href}
onClick={() => router.push(link.href)}
@ -526,10 +539,13 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
onClick={() => router.push('/login')}
className="text-sm/6 font-semibold text-gray-900 dark:text-white hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors"
>
Log in <span aria-hidden="true">&rarr;</span>
{t('nav.login')}
</button>
)}
{/* Language switcher (desktop) */}
<LanguageSwitcher variant="dark" />
{/* Desktop hamburger (right side) */}
<button
type="button"
@ -652,7 +668,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
}}
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
>
Startup Dashboard
{t('quickactionDashboard.title')}
</button>
)}
@ -664,7 +680,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
}}
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
>
Dashboard
{t('nav.dashboard')}
</button>
)}
<button
@ -674,7 +690,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
}}
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
>
Profile
{t('nav.profile')}
</button>
<button
onClick={() => {
@ -683,7 +699,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
}}
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
>
My Subscriptions
{t('nav.mySubscriptions')}
</button>
</div>
@ -692,11 +708,11 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
{/* Information disclosure */}
<Disclosure as="div">
<DisclosureButton className="group flex w-full items-center justify-between rounded-lg py-2 px-3 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5">
Information
{t('nav.information')}
<ChevronDownIcon aria-hidden="true" className="size-5 flex-none group-data-open:rotate-180" />
</DisclosureButton>
<DisclosurePanel className="mt-2 space-y-1">
{informationItems.map(item => (
{translatedInformationItems.map(item => (
<DisclosureButton
key={item.name}
as="button"
@ -710,7 +726,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
</Disclosure>
{/* Navigation Links */}
{navLinks.map((link) => (
{translatedNavLinks.map((link) => (
<button
key={link.href}
onClick={() => { router.push(link.href); setMobileMenuOpen(false); }}
@ -727,14 +743,14 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
onClick={() => { console.log('🧭 Header Mobile: navigate to /referral-management'); router.push('/referral-management'); setMobileMenuOpen(false); }}
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
>
Referral Management
{t('referralManagement.title')}
</button>
{DISPLAY_MATRIX && (
<button
onClick={() => { router.push('/personal-matrix'); setMobileMenuOpen(false); }}
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
>
Personal Matrix
{t('personalMatrix.title')}
</button>
)}
</>
@ -744,7 +760,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
onClick={() => { router.push('/coffee-abonnements'); setMobileMenuOpen(false); }}
className="block rounded-lg px-3 py-2 text-base/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
>
Coffee Abonnements
{t('nav.coffeeSubscriptions')}
</button>
)}
@ -753,7 +769,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
<div className="group mt-2 rounded-2xl border border-indigo-100 bg-white shadow-[0_12px_28px_rgba(15,23,42,0.12)] ring-1 ring-indigo-100/70 transition-transform transition-shadow duration-200 ease-out hover:-translate-y-0.5 hover:shadow-[0_16px_32px_rgba(15,23,42,0.18)] dark:border-indigo-500/20 dark:bg-gradient-to-br dark:from-slate-950/85 dark:via-slate-900/90 dark:to-indigo-950/80 dark:ring-white/10 dark:shadow-[0_18px_45px_rgba(0,0,0,0.45)] dark:hover:shadow-[0_22px_55px_rgba(0,0,0,0.6)]">
<div className="px-3 py-2.5 group-hover:animate-pulse">
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-700 dark:text-indigo-100/80">
Admin Navigation
{t('adminDashboard.adminNavigation')}
</p>
<div className="mb-2 h-px w-full bg-gradient-to-r from-transparent via-indigo-200/70 to-transparent opacity-80 transition-opacity group-hover:opacity-100 dark:via-indigo-200/40" />
<div className="grid grid-cols-1 gap-1.5 text-sm">
@ -761,10 +777,10 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
onClick={() => { router.push('/admin'); setMobileMenuOpen(false); }}
className="w-full text-left rounded-lg px-2 py-1.5 text-slate-800 hover:bg-indigo-50 hover:text-slate-900 transition-colors dark:text-indigo-50 dark:hover:bg-white/10 dark:hover:text-white"
>
Admin Dashboard
{t('adminDashboard.title')}
</button>
<p className="px-2 py-1 text-xs text-slate-500 dark:text-indigo-100/70">
Open the dashboard to access all admin modules via icon panels.
{t('adminDashboard.adminNavigationHelp')}
</p>
</div>
</div>
@ -778,11 +794,11 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
{/* Information disclosure */}
<Disclosure as="div">
<DisclosureButton className="group flex w-full items-center justify-between rounded-lg py-2 px-3 textbase/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5">
Information
{t('nav.information')}
<ChevronDownIcon aria-hidden="true" className="size-5 flex-none group-data-open:rotate-180" />
</DisclosureButton>
<DisclosurePanel className="mt-2 space-y-1">
{informationItems.map(item => (
{translatedInformationItems.map(item => (
<DisclosureButton
key={item.name}
as="button"
@ -795,7 +811,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
</DisclosurePanel>
</Disclosure>
{/* Navigation Links */}
{navLinks.map((link) => (
{translatedNavLinks.map((link) => (
<button
key={link.href}
onClick={() => { router.push(link.href); setMobileMenuOpen(false); }}
@ -809,7 +825,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
onClick={() => { router.push('/login'); setMobileMenuOpen(false); }}
className="block rounded-lg px-3 py-2.5 textbase/7 font-semibold text-gray-900 dark:text-white hover:bg-white/5 w-full text-left"
>
Log in
{t('nav.login')}
</button>
</div>
</div>
@ -817,18 +833,22 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
</div>
</div>
{/* Sticky bottom logout button with pulsating hover */}
{user && (
<div className="border-t border-gray-200/60 dark:border-white/10 px-4 py-3">
{/* Sticky bottom: language switcher + logout */}
<div className="border-t border-gray-200/60 dark:border-white/10 px-4 py-3 space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">{t('common.language')}</span>
<LanguageSwitcher variant="dark" />
</div>
{user && (
<button
onClick={() => { handleLogout(); setMobileMenuOpen(false); }}
className="flex w-full items-center justify-center gap-2 rounded-lg bg-red-500/90 hover:bg-red-600 text-white py-2.5 text-sm font-semibold shadow-md shadow-red-900/30 transition-transform transition-colors duration-200 hover:animate-pulse"
>
<ArrowRightOnRectangleIcon className="h-5 w-5" />
<span>Logout</span>
<span>{t('nav.logout')}</span>
</button>
</div>
)}
)}
</div>
</DialogPanel>
</Transition.Child>
</Transition>

View File

@ -8,6 +8,7 @@ import useAuthStore from '../store/authStore'
import PageLayout from '../components/PageLayout'
import Waves from '../components/background/waves'
import BlueBlurryBackground from '../components/background/blueblurry'
import { useTranslation } from '../i18n/useTranslation'
import { useUserStatus } from '../hooks/useUserStatus'
import {
ShoppingBagIcon,
@ -26,6 +27,7 @@ import {
export default function DashboardPage() {
const router = useRouter()
const { t } = useTranslation()
const user = useAuthStore(state => state.user)
const isAuthReady = useAuthStore(state => state.isAuthReady)
const isShopEnabled = process.env.NEXT_PUBLIC_SHOW_SHOP !== 'false'
@ -116,7 +118,7 @@ export default function DashboardPage() {
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#8D6B1D] mx-auto mb-4"></div>
<p className="text-[#4A4A4A]">Loading...</p>
<p className="text-[#4A4A4A]">{t('dashboard.loading')}</p>
</div>
</div>
)
@ -127,8 +129,8 @@ export default function DashboardPage() {
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-white/70 backdrop-blur-sm transition-opacity duration-200 opacity-100">
<div className="rounded-xl bg-white px-5 py-4 shadow ring-1 ring-black/5">
<div className="text-sm font-medium text-gray-900">Redirecting</div>
<div className="mt-1 text-xs text-gray-600">Please wait</div>
<div className="text-sm font-medium text-gray-900">{t('dashboard.redirecting')}</div>
<div className="mt-1 text-xs text-gray-600">{t('dashboard.pleaseWait')}</div>
</div>
</div>
)
@ -162,6 +164,25 @@ export default function DashboardPage() {
UserCircleIcon
}
const getTranslatedOrFallback = (key: string, fallback: string) => {
const translated = t(key)
return translated === key ? fallback : translated
}
const platformTitleKeyById: Record<string, string> = {
'shop': 'dashboard.platformCards.shop.title',
'affiliate-links': 'dashboard.platformCards.affiliateLinks.title',
'referral-management': 'dashboard.platformCards.referralManagement.title',
'profile': 'dashboard.platformCards.profile.title',
}
const platformDescriptionKeyById: Record<string, string> = {
'shop': 'dashboard.platformCards.shop.description',
'affiliate-links': 'dashboard.platformCards.affiliateLinks.description',
'referral-management': 'dashboard.platformCards.referralManagement.description',
'profile': 'dashboard.platformCards.profile.description',
}
const content = (
<div className="relative z-10 flex-1 min-h-0">
<PageLayout className="bg-transparent text-gray-900">
@ -171,22 +192,30 @@ export default function DashboardPage() {
{/* Welcome Section */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">
Welcome back, {getUserName()}! 👋
{t('dashboard.welcomeBack')}, {getUserName()}! 👋
</h1>
<p className="text-gray-600 mt-2">
Here's what's happening with your Profit Planet account
{t('dashboard.welcomeSubtitle')}
</p>
</div>
{/* Quick Actions */}
<div className="mb-8">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Platforms</h2>
<h2 className="text-xl font-semibold text-gray-900 mb-4">{t('dashboard.platforms')}</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
{platforms.filter(p => p.isActive).map((platform) => {
const Icon = icons[platform.icon]
const disabledByEnv = platform.href === '/shop' && !isShopEnabled
const isDisabled = Boolean(platform.disabled) || disabledByEnv
const disabledText = disabledByEnv ? 'This is currently disabled.' : platform.disabledText
const translatedTitle = platformTitleKeyById[platform.id]
? getTranslatedOrFallback(platformTitleKeyById[platform.id], platform.title)
: platform.title
const translatedDescription = platformDescriptionKeyById[platform.id]
? getTranslatedOrFallback(platformDescriptionKeyById[platform.id], platform.description)
: platform.description
const disabledText = disabledByEnv
? t('dashboard.platformDisabled')
: platform.disabledText
return (
<button
@ -221,10 +250,10 @@ export default function DashboardPage() {
: 'text-gray-900 group-hover:text-[#8D6B1D]'
}`}
>
{platform.title}
{translatedTitle}
</h3>
<p className="text-sm text-gray-600 mt-1">
{platform.description}
{translatedDescription}
</p>
{isDisabled && disabledText && (
<p className="mt-3 text-xs font-medium text-amber-700">
@ -244,14 +273,14 @@ export default function DashboardPage() {
<div className="flex items-center">
<StarIcon className="h-12 w-12 text-yellow-300" />
<div className="ml-4">
<h2 className="text-2xl font-bold">Gold Member Status</h2>
<h2 className="text-2xl font-bold">{t('dashboard.goldMemberTitle')}</h2>
<p className="text-yellow-100 mt-1">
Enjoy exclusive benefits and discounts
{t('dashboard.goldMemberDescription')}
</p>
</div>
<div className="ml-auto">
<button className="bg-white/20 hover:bg-white/30 px-4 py-2 rounded-lg text-white font-medium transition-colors">
View Benefits
{t('dashboard.viewBenefits')}
</button>
</div>
</div>
@ -260,9 +289,9 @@ export default function DashboardPage() {
{/* Latest News */}
<div className="rounded-2xl bg-white border border-gray-200 shadow-sm p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Latest News</h2>
<h2 className="text-lg font-semibold text-gray-900">{t('dashboard.latestNews')}</h2>
<Link href="/news" className="text-sm font-medium text-blue-900 hover:text-blue-700">
View all
{t('dashboard.viewAllNews')}
</Link>
</div>
@ -282,7 +311,7 @@ export default function DashboardPage() {
)}
{!newsLoading && !newsError && latestNews.length === 0 && (
<div className="text-sm text-gray-600">No news yet.</div>
<div className="text-sm text-gray-600">{t('dashboard.noNewsYet')}</div>
)}
{!newsLoading && !newsError && latestNews.length > 0 && (
@ -291,7 +320,7 @@ export default function DashboardPage() {
<li key={item.id} className="group">
<Link href={`/news/${item.slug}`} className="block">
<div className="text-xs text-gray-500">
{item.published_at ? new Date(item.published_at).toLocaleDateString('de-DE') : 'Recent'}
{item.published_at ? new Date(item.published_at).toLocaleDateString('de-DE') : t('dashboard.recent')}
</div>
<div className="text-sm font-semibold text-gray-900 group-hover:text-blue-700 line-clamp-2">
{item.title}

View File

@ -0,0 +1,83 @@
// Flatten a nested translations object to dot-separated keys
// e.g. { home: { title: 'Profit Planet' } } -> { 'home.title': 'Profit Planet' }
export function flattenObject(
obj: Record<string, any>,
prefix = ''
): Record<string, string> {
return Object.entries(obj).reduce((acc, [key, value]) => {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
Object.assign(acc, flattenObject(value, fullKey));
} else {
acc[fullKey] = String(value ?? '');
}
return acc;
}, {} as Record<string, string>);
}
// Reverse of flattenObject
export function unflattenObject(flat: Record<string, string>): Record<string, any> {
const result: Record<string, any> = {};
for (const [key, value] of Object.entries(flat)) {
const parts = key.split('.');
let current = result;
for (let i = 0; i < parts.length - 1; i++) {
if (typeof current[parts[i]] !== 'object' || current[parts[i]] === null) {
current[parts[i]] = {};
}
current = current[parts[i]];
}
current[parts[parts.length - 1]] = value;
}
return result;
}
export interface CustomLanguageEntry {
code: string;
name: string;
flag?: string; // emoji flag, e.g. '🇫🇷'
}
export interface CustomI18nData {
/** Extra languages added by admins (does not include built-in en/de) */
languages: CustomLanguageEntry[];
/** Flat translation overrides per language code (includes overrides for built-in langs too) */
translations: Record<string, Record<string, string>>;
}
const STORAGE_KEY = 'pp_i18n_custom';
export function loadCustomI18n(): CustomI18nData {
if (typeof window === 'undefined') return { languages: [], translations: {} };
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { languages: [], translations: {} };
const parsed = JSON.parse(raw);
return {
languages: Array.isArray(parsed.languages) ? parsed.languages : [],
translations:
parsed.translations && typeof parsed.translations === 'object'
? parsed.translations
: {},
};
} catch {
return { languages: [], translations: {} };
}
}
export function saveCustomI18n(data: CustomI18nData): void {
if (typeof window === 'undefined') return;
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}
/** Resolve a flat translation value for a given language, with fallback to English flat map */
export function resolveKey(
key: string,
langCode: string,
customTranslations: Record<string, Record<string, string>>,
enFlat: Record<string, string>
): string {
const langOverride = customTranslations[langCode]?.[key];
if (langOverride !== undefined && langOverride !== '') return langOverride;
return enFlat[key] ?? key;
}

View File

@ -1,6 +1,41 @@
import { Translations } from '../types';
export const de: Translations = {
// ─── General ───────────────────────────────────────────
common: {
loading: 'Laden…',
saving: 'Speichern…',
save: 'Speichern',
saved: 'Gespeichert',
cancel: 'Abbrechen',
close: 'Schließen',
back: 'Zurück',
confirm: 'Bestätigen',
delete: 'Löschen',
edit: 'Bearbeiten',
add: 'Hinzufügen',
search: 'Suchen',
searchPlaceholder: 'Suchen…',
noResults: 'Keine Ergebnisse gefunden.',
error: 'Fehler',
success: 'Erfolg',
required: 'Pflichtfeld',
optional: 'optional',
yes: 'Ja',
no: 'Nein',
copy: 'Kopieren',
copied: 'Kopiert!',
download: 'Herunterladen',
upload: 'Hochladen',
preview: 'Vorschau',
refresh: 'Aktualisieren',
backToHome: 'Zurück zur Startseite',
unsavedChanges: 'Du hast ungespeicherte Änderungen.',
learnMore: 'Mehr erfahren',
getStarted: 'Jetzt starten',
language: 'Sprache',
},
home: {
title: 'Profit Planet',
tagline: 'Nachhaltige Produkte entdecken und handeln',
@ -8,34 +43,36 @@ export const de: Translations = {
features: {
sustainable: {
title: 'Nachhaltige Produkte',
description: 'Entdecke umweltfreundliche Produkte, die einen Unterschied für unseren Planeten machen.'
description: 'Entdecke umweltfreundliche Produkte, die einen Unterschied für unseren Planeten machen.',
},
community: {
title: 'Aktive Community',
description: 'Vernetze dich mit Gleichgesinnten, denen Nachhaltigkeit wichtig ist.'
description: 'Vernetze dich mit Gleichgesinnten, denen Nachhaltigkeit wichtig ist.',
},
rewards: {
title: 'Belohnungen sammeln',
description: 'Erhalte Gold-Punkte für jeden nachhaltigen Kauf und jede Aktion.'
}
description: 'Erhalte Gold-Punkte für jeden nachhaltigen Kauf und jede Aktion.',
},
},
stats: {
members: 'Aktive Mitglieder',
products: 'Öko-Produkte',
communities: 'Communities'
communities: 'Communities',
},
cta: {
getStarted: 'Jetzt starten',
learnMore: 'Mehr erfahren'
}
learnMore: 'Mehr erfahren',
},
},
footer: {
company: 'Profit Planet GmbH',
rights: 'Alle Rechte vorbehalten.',
privacy: 'Datenschutz',
terms: 'AGB',
contact: 'Kontakt'
contact: 'Kontakt',
},
nav: {
home: 'Home',
shop: 'Shop',
@ -43,6 +80,874 @@ export const de: Translations = {
community: 'Community',
profile: 'Profil',
login: 'Anmelden',
logout: 'Abmelden'
}
logout: 'Abmelden',
news: 'Neuigkeiten',
memberships: 'Mitgliedschaften',
aboutUs: 'Über uns',
affiliateLinks: 'Affiliate-Links',
information: 'Informationen',
myAccount: 'Mein Konto',
mySubscriptions: 'Meine Abonnements',
coffeeSubscriptions: 'Kaffee-Abonnements',
},
// ─── Auth ──────────────────────────────────────────────
login: {
title: 'PROFIT PLANET',
subtitle: 'Willkommen zurück! Melde dich an.',
emailLabel: 'E-Mail-Adresse',
emailPlaceholder: 'du@beispiel.com',
passwordLabel: 'Passwort',
passwordPlaceholder: 'Dein Passwort eingeben',
rememberMe: 'Angemeldet bleiben',
submit: 'Anmelden',
submitting: 'Anmelden…',
forgotPassword: 'Passwort vergessen?',
noAccount: 'Noch kein Konto?',
registerLink: 'Registrieren',
errorRequired: 'E-Mail-Adresse ist erforderlich',
errorInvalidEmail: 'Bitte gib eine gültige E-Mail-Adresse ein',
errorPasswordRequired: 'Passwort ist erforderlich',
errorPasswordTooShort: 'Das Passwort muss mindestens 6 Zeichen lang sein',
errorInvalidCredentials: 'E-Mail oder Passwort falsch',
errorAccountNotFound: 'Kein Konto mit dieser E-Mail-Adresse gefunden',
errorAccountLocked: 'Konto wurde gesperrt. Bitte kontaktiere den Support.',
errorConnectionFailed: 'Verbindung zum Server fehlgeschlagen. Bitte versuche es später erneut.',
errorGeneric: 'Anmeldung fehlgeschlagen. Bitte versuche es erneut.',
successTitle: 'Anmeldung erfolgreich',
successMessage: 'Du bist jetzt angemeldet.',
failedTitle: 'Anmeldung fehlgeschlagen',
},
register: {
title: 'Konto erstellen',
subtitle: 'Tritt Profit Planet heute bei.',
tabPersonal: 'Privat',
tabCompany: 'Unternehmen',
tabGuest: 'Gast',
checkingInvitation: 'Einladungslink wird überprüft…',
invitationVerifiedTitle: 'Einladung bestätigt',
invitationVerifiedMessage: 'Dein Einladungslink ist gültig. Du kannst dich jetzt registrieren.',
invalidInvitationTitle: 'Ungültige Einladung',
invalidInvitationMessage: 'Dieser Einladungslink ist ungültig oder nicht mehr aktiv.',
noInvitationToken: 'Kein Einladungstoken im Link gefunden.',
networkError: 'Server nicht erreichbar. Läuft das Backend?',
firstName: 'Vorname',
lastName: 'Nachname',
email: 'E-Mail-Adresse',
confirmEmail: 'E-Mail-Adresse bestätigen',
password: 'Passwort',
confirmPassword: 'Passwort bestätigen',
phone: 'Telefonnummer',
companyName: 'Unternehmensname',
companyEmail: 'Unternehmens-E-Mail',
companyPhone: 'Unternehmenstelefon',
contactPersonName: 'Name der Kontaktperson',
contactPersonPhone: 'Telefon der Kontaktperson',
submit: 'Konto erstellen',
submitting: 'Konto wird erstellt…',
errorAllRequired: 'Alle Felder sind erforderlich',
errorEmailMismatch: 'E-Mail-Adressen stimmen nicht überein',
errorPasswordMismatch: 'Passwörter stimmen nicht überein',
errorPasswordWeak: 'Das Passwort muss mindestens 8 Zeichen lang sein und Groß-, Kleinbuchstaben, Zahlen und Sonderzeichen enthalten',
errorSelectCountryCode: 'Bitte wähle zuerst eine Ländervorwahl aus dem Dropdown.',
errorPhoneRequired: 'Bitte gib deine Telefonnummer ein.',
errorPhoneInvalid: 'Bitte gib eine gültige Mobilnummer ein.',
errorBothPhonesRequired: 'Bitte gib sowohl Unternehmens- als auch Kontakttelefonnummer ein.',
errorBothPhonesInvalid: 'Bitte gib gültige Telefonnummern für Unternehmen und Kontaktperson ein.',
successTitle: 'Registrierung erfolgreich',
successMessage: 'Du kannst dich jetzt mit deinem neuen Konto anmelden.',
alreadyHaveAccount: 'Bereits ein Konto?',
loginLink: 'Anmelden',
sessionDetectedTitle: 'Aktive Sitzung erkannt',
sessionDetectedMessage: 'Du bist bereits angemeldet. Möchtest du dich abmelden und ein neues Konto registrieren?',
sessionContinue: 'Zum Dashboard',
sessionLogout: 'Abmelden und registrieren',
},
passwordReset: {
title: 'Passwort zurücksetzen',
subtitle: 'Gib deine E-Mail-Adresse ein und wir senden dir einen Reset-Link.',
emailLabel: 'E-Mail-Adresse',
emailPlaceholder: 'du@beispiel.com',
submit: 'Reset-Link senden',
submitting: 'Wird gesendet…',
successTitle: 'E-Mail gesendet',
successMessage: 'Prüfe deinen Posteingang für den Passwort-Reset-Link.',
backToLogin: 'Zurück zur Anmeldung',
errorInvalidEmail: 'Bitte gib eine gültige E-Mail-Adresse ein',
},
// ─── Pages ─────────────────────────────────────────────
dashboard: {
title: 'Dashboard',
subtitle: 'Willkommen in deinem Profit Planet Dashboard.',
loading: 'Dashboard wird geladen…',
accessDenied: 'Zugriff verweigert',
accessDeniedMessage: 'Du musst das Onboarding abschließen, um auf das Dashboard zugreifen zu können.',
welcomeBack: 'Willkommen zurück',
welcomeSubtitle: 'Das passiert aktuell mit deinem Profit Planet Konto',
platforms: 'Plattformen',
platformDisabled: 'Dies ist derzeit deaktiviert.',
redirecting: 'Weiterleitung…',
pleaseWait: 'Bitte warten',
goldMemberTitle: 'Gold-Mitgliedsstatus',
goldMemberDescription: 'Genieße exklusive Vorteile und Rabatte',
viewBenefits: 'Vorteile ansehen',
latestNews: 'Neueste News',
viewAllNews: 'Alle ansehen',
noNewsYet: 'Noch keine News.',
recent: 'Neu',
platformCards: {
shop: {
title: 'Shop durchsuchen',
description: 'Nachhaltige Produkte entdecken',
},
affiliateLinks: {
title: 'Affiliate-Links durchsuchen',
description: 'Affiliate-Angebote und Links entdecken',
},
referralManagement: {
title: 'Empfehlungsverwaltung',
description: 'Empfehlungslinks erstellen und verwalten',
},
profile: {
title: 'Profil bearbeiten',
description: 'Deine Informationen aktualisieren',
},
},
noData: 'Keine Daten verfügbar.',
},
profile: {
title: 'Mein Profil',
personalInfo: 'Persönliche Informationen',
bankInfo: 'Bankdaten',
documents: 'Dokumente',
memberStatus: 'Mitgliedsstatus',
profileComplete: 'Profilkompletion',
firstName: 'Vorname',
lastName: 'Nachname',
email: 'E-Mail-Adresse',
phone: 'Telefonnummer',
address: 'Adresse',
joinDate: 'Mitglied seit',
accountHolder: 'Kontoinhaber',
iban: 'IBAN',
contactPersonName: 'Kontaktperson',
editBasicInfo: 'Persönliche Daten bearbeiten',
editBankInfo: 'Bankdaten bearbeiten',
saveChanges: 'Änderungen speichern',
documentName: 'Dokumentname',
documentType: 'Typ',
documentUploaded: 'Hochgeladen',
downloadDocument: 'Herunterladen',
noDocuments: 'Noch keine Dokumente hochgeladen.',
refreshProfile: 'Profil aktualisieren',
loading: 'Profil wird geladen…',
},
community: {
title: 'Community',
subtitle: 'Verbinde dich mit der Profit Planet Community.',
description: 'Nimm an Diskussionen teil und vernetze dich mit anderen Mitgliedern.',
loading: 'Community wird geladen…',
accessDenied: 'Zugriff verweigert',
noAccess: 'Du musst angemeldet sein, um auf die Community zugreifen zu können.',
},
shop: {
title: 'Shop',
subtitle: 'Nachhaltige Produkte entdecken.',
comingSoon: 'Demnächst verfügbar',
addToCart: 'In den Warenkorb',
price: 'Preis',
outOfStock: 'Nicht vorrätig',
viewDetails: 'Details ansehen',
},
memberships: {
title: 'Mitgliedschaften',
subtitle: 'Wähle den richtigen Plan für dich.',
description: 'Werde Mitglied und schalte exklusive Vorteile frei.',
selectPlan: 'Plan auswählen',
perMonth: 'pro Monat',
perYear: 'pro Jahr',
mostPopular: 'Am beliebtesten',
choosePlan: 'Diesen Plan wählen',
},
affiliateLinks: {
title: 'Affiliate-Links',
subtitle: 'Unsere Partner-Links entdecken.',
description: 'Durchsuche und teile unsere Partner-Links, um Prämien zu verdienen.',
visitLink: 'Link besuchen',
partnerLinks: 'Partner-Links',
},
aboutUs: {
title: 'Über uns',
subtitle: 'Erfahre mehr über Profit Planet.',
description: 'Gemeinsam bauen wir eine nachhaltige Zukunft.',
ourTeam: 'Unser Team',
ourMission: 'Unsere Mission',
},
news: {
title: 'Neuigkeiten',
subtitle: 'Bleib mit Profit Planet auf dem Laufenden.',
readMore: 'Mehr lesen',
publishedDate: 'Veröffentlicht',
category: 'Kategorie',
noArticles: 'Keine Artikel verfügbar.',
loadMore: 'Mehr laden',
},
// ─── Coffee ABO ────────────────────────────────────────
coffeeSelection: {
title: 'Kaffee-Abonnement',
subtitle: 'Wähle deine Kaffeesorten für diesen Monat.',
selectYourCoffees: 'Deine Kaffees auswählen',
capsuleTarget: 'Kapsel-Ziel',
planLabel: 'Dein Plan',
yourSelection: 'Deine Auswahl',
totalCapsules: 'Kapseln gesamt',
totalPacks: 'Packungen gesamt',
targetPacks: 'Ziel-Packungen',
selectUpTo: 'Auswahl bis zu',
goToSummary: 'Zur Zusammenfassung',
loading: 'Kaffees werden geladen…',
noProducts: 'Keine Kaffees verfügbar.',
validationExact: 'Du benötigst genau {count} Kapseln ({packs} Packungen).',
packOf10: '10er-Packung',
pricePerPack: 'pro Packung',
},
coffeeSummary: {
title: 'Zusammenfassung & Details',
backToSelection: 'Zurück zur Auswahl',
stepSelection: 'Auswahl',
stepSummary: 'Zusammenfassung',
yourDetails: '1. Deine Daten',
fillFromLoggedIn: 'Felder mit angemeldeten Daten füllen',
firstName: 'Vorname',
lastName: 'Nachname',
email: 'E-Mail',
street: 'Straße & Nr.',
zip: 'PLZ',
city: 'Stadt',
country: 'Land',
phone: 'Telefon',
phoneOptional: 'Telefon (optional)',
paymentMethod: 'Zahlungsmethode',
paymentSepa: 'SEPA',
paymentCard: 'Kreditkarte',
paymentSofort: 'Sofort Banking',
invoiceByEmail: 'Rechnung per E-Mail senden',
invoiceAddress: 'Rechnungsadresse',
sameAsShipping: 'Wie Lieferadresse',
uidNumberLabel: 'UID-Nummer (optional)',
uidNumberPlaceholder: 'z.B. SI12345678',
uidNumberHint: 'Ohne gültige UID wird die Rechnung mit normaler MwSt erstellt.',
reverseChargeHint: 'Unternehmer mit gültiger UID und Rechnungsland außerhalb von AT werden per Reverse Charge ohne ausgewiesene MwSt verrechnet.',
fullName: 'Vollständiger Name',
contractPreview: 'Vertragsvorschau (ABO)',
contractSubtitle: 'Vertragsvariablen werden automatisch aus deinen Formulardaten befüllt.',
openPreview: 'Vorschau öffnen',
contractLoading: 'Vertragsvorschau wird geladen…',
contractError: 'Vertragsvorschau konnte nicht geladen werden:',
contractNotAvailable: 'Vertragsvorlage ist nicht verfügbar.',
pdfPreviewTitle: 'ABO-Vertragsvorschau (PDF)',
pdfGenerating: 'PDF-Vorschau wird erstellt…',
pdfError: 'PDF-Vorschau konnte nicht erstellt werden:',
pdfNotAvailable: 'Keine PDF-Vorschau verfügbar.',
signingCity: 'Ort *',
signingCityPlaceholder: 'z.B. Wien',
signingCityRequired: 'Ort ist erforderlich.',
signatureRequired: 'Unterschrift ist erforderlich.',
completeSubscription: 'Abonnement abschließen',
creating: 'Wird erstellt…',
cannotSubmit: 'Bitte wähle Kaffees aus und fülle alle Pflichtfelder, Ort und Unterschrift aus.',
yourSelection: '2. Deine Auswahl',
shipping: 'Versand',
freeShipping: 'KOSTENLOSER VERSAND',
shippingLoading: 'Laden…',
shippingError: 'Versandkosten konnten nicht geladen werden:',
totalNet: 'Gesamt (netto)',
tax: 'MwSt. ({rate}%)',
taxReverseCharge: 'Steuer (Reverse Charge)',
totalInclTax: 'Gesamt inkl. MwSt.',
reverseChargeActive: 'Reverse Charge aktiv: gültige UID und ausländisches Rechnungsland erkannt.',
capsuleValidation: 'Ausgewählt: {selected} Kapseln ({selectedPacks} Packungen à 10). Ziel: {target} Kapseln ({targetPacks} Packungen).',
exactlyRequired: 'Genau {packs} Packungen ({capsules} Kapseln) sind erforderlich.',
thankYouTitle: 'Danke für dein Abonnement!',
thankYouMessage: 'Abonnement erstellt.',
noSelectionFound: 'Keine Auswahl gefunden.',
noLoggedInData: 'Keine angemeldeten Benutzerdaten zum Befüllen der Felder gefunden.',
},
// ─── Account ───────────────────────────────────────────
personalMatrix: {
title: 'Persönliche Matrix',
subtitle: 'Deine Netzwerkstruktur.',
description: 'Sieh dir deine persönliche Matrix und dein Downline-Netzwerk an.',
loading: 'Matrix wird geladen…',
noData: 'Keine Matrix-Daten verfügbar.',
},
referralManagement: {
title: 'Empfehlungsverwaltung',
subtitle: 'Verwalte deine Empfehlungslinks.',
createLink: 'Empfehlungslink erstellen',
copyLink: 'Link kopieren',
copiedToClipboard: 'In die Zwischenablage kopiert!',
linkExpiry: 'Läuft ab',
noLinks: 'Noch keine Empfehlungslinks.',
generating: 'Wird erstellt…',
usesRemaining: 'verbleibende Nutzungen',
unlimited: 'Unbegrenzt',
createSuccess: 'Empfehlungslink erfolgreich erstellt.',
createError: 'Empfehlungslink konnte nicht erstellt werden.',
},
quickactionDashboard: {
title: 'Schnellaktionen',
subtitle: 'Schließe deine Onboarding-Schritte ab.',
stepLabel: 'Schritt',
completed: 'Abgeschlossen',
pending: 'Ausstehend',
required: 'Erforderlich',
verifyIdentity: 'Identität verifizieren',
completeProfile: 'Profil vervollständigen',
setPayment: 'Zahlung einrichten',
startUsing: 'Profit Planet nutzen',
allDone: 'Alle Schritte abgeschlossen!',
loading: 'Laden…',
guestAccount: 'Gastkonto',
companyAccount: 'Firmenkonto',
personalAccount: 'Privatkonto',
loadingStatus: 'Status wird geladen...',
errorLoadingAccountStatus: 'Fehler beim Laden des Kontostatus',
tryAgain: 'Erneut versuchen',
emailVerificationStatus: 'Status der E-Mail-Verifizierung',
statusOverview: 'Statusübersicht',
actionRequired: 'Aktion erforderlich',
quickActions: 'Schnellaktionen',
tutorial: 'Tutorial',
pleaseVerifyEmailAddress: 'Bitte bestätige deine E-Mail-Adresse, um dein Gastkonto zu aktivieren und auf deine Abonnements zuzugreifen.',
resendAvailableIn: 'Erneut senden möglich in',
requestNewCode: 'Du kannst jetzt einen neuen Code anfordern',
emailVerified: 'E-Mail bestätigt',
verifyEmail: 'E-Mail bestätigen',
idUploaded: 'Ausweis hochgeladen',
uploadIdDocument: 'Ausweisdokument hochladen',
profileCompleted: 'Profil abgeschlossen',
signContract: 'Vertrag unterschreiben',
contractNotReady: 'Vertrag unterschreiben (erfordert alle vorherigen Schritte)',
latestNews: 'Neueste Nachrichten',
viewAll: 'Alle anzeigen',
noNewsYet: 'Noch keine Nachrichten verfügbar.',
recent: 'Neu',
redirecting: 'Weiterleitung…',
takingToDashboard: 'Du wirst zu deinem Dashboard weitergeleitet',
pleaseWait: 'Bitte warten',
goToDashboard: 'Zum Dashboard',
backToDashboard: 'Zurück zum Dashboard',
uploading: 'Wird hochgeladen...',
saved: 'Gespeichert',
uploadContinue: 'Hochladen und fortfahren',
yes: 'Ja',
no: 'Nein',
dragAndDrop: 'Datei hier ablegen oder zum Auswählen klicken',
remove: 'Entfernen',
maxUploadHint: 'Max. 10 MB. JPG, PNG oder PDF.',
statusCards: {
emailVerification: 'E-Mail-Verifizierung',
idDocument: 'Ausweisdokument',
additionalInfo: 'Zusätzliche Angaben',
contract: 'Vertrag',
verified: 'Bestätigt',
missing: 'Fehlt',
uploaded: 'Hochgeladen',
signed: 'Unterschrieben',
},
emailVerify: {
title: 'E-Mail bestätigen',
sentIntro: 'Wir haben einen 6-stelligen Code gesendet an',
sendingIntro: 'Bestätigungs-E-Mail wird gesendet an',
yourEmail: 'deine E-Mail-Adresse',
enterBelow: 'Gib ihn unten ein.',
invalidCode: 'Bitte gib den vollständigen 6-stelligen Code ein.',
authError: 'Nicht authentifiziert. Bitte melde dich erneut an.',
emailVerifiedTitle: 'E-Mail bestätigt',
emailVerifiedMessage: 'Deine E-Mail wurde erfolgreich bestätigt.',
verificationFailedTitle: 'Bestätigung fehlgeschlagen',
networkErrorTitle: 'Netzwerkfehler',
verifying: 'Wird bestätigt...',
verified: 'Bestätigt',
confirmCode: 'Code bestätigen',
resendCode: 'Code erneut senden',
supportHint: 'Keine E-Mail erhalten? Bitte prüfe deinen Spam-Ordner. Probleme bestehen weiter?',
contactSupport: 'Support kontaktieren',
verifiedRedirecting: 'Bestätigt! Weiterleitung in Kürze...',
},
uploadId: {
personalTitle: 'Ausweisdokument hochladen',
personalSubtitle: 'Lade dein Ausweisdokument hoch, um dein Onboarding fortzusetzen.',
companyTitle: 'Firmendokumente hochladen',
companySubtitle: 'Lade die erforderlichen Firmendokumente hoch, um dein Onboarding fortzusetzen.',
idNumber: 'Ausweisnummer *',
idNumberPlaceholder: 'Gib deine Ausweisnummer ein',
idNumberHint: 'Gib die Dokumentnummer genau wie auf dem Dokument angegeben ein.',
contactPersonIdNumber: 'Ausweisnummer der Kontaktperson *',
contactPersonIdNumberPlaceholder: 'Ausweisnummer der Kontaktperson eingeben',
contactPersonIdNumberHint: 'Gib die Ausweisnummer genau wie auf dem Dokument angegeben ein.',
idType: 'Ausweistyp *',
documentType: 'Dokumenttyp *',
selectIdType: 'Ausweistyp wählen',
selectDocumentType: 'Dokumenttyp wählen',
expiryDate: 'Ablaufdatum *',
expiryDateHint: 'Wähle das Ablaufdatum auf dem Dokument.',
backSideQuestion: 'Hat dein Dokument eine Rückseite?',
frontPreviewAlt: 'Vorschau Vorderseite',
backPreviewAlt: 'Vorschau Rückseite',
primaryPreviewAlt: 'Vorschau Hauptdokument',
supportingPreviewAlt: 'Vorschau Zusatzdokument',
clickUploadFront: 'Zum Hochladen der Vorderseite klicken',
clickUploadBack: 'Zum Hochladen der Rückseite klicken',
documentsChecklistTitle: 'Bitte stelle vor dem Hochladen sicher, dass das Dokument:',
clearlyVisible: 'Gut lesbar ist',
showCorners: 'Alle vier Ecken zeigt',
notExpired: 'Nicht abgelaufen ist',
goodLighting: 'Keine Spiegelungen oder dunklen Schatten hat',
bothSidesUploaded: 'Beide Seiten hochgeladen',
frontSideUploaded: 'Vorderseite hochgeladen',
successSavedRedirecting: 'Erfolgreich gespeichert. Weiterleitung...',
personalUploadSuccessTitle: 'Dokumente hochgeladen',
personalUploadSuccessMessage: 'Deine Ausweisdokumente wurden erfolgreich hochgeladen.',
companyUploadSuccessTitle: 'Firmendokumente hochgeladen',
companyUploadSuccessMessage: 'Deine Firmendokumente wurden erfolgreich hochgeladen.',
fileTooLargeTitle: 'Datei zu groß',
fileTooLargeMessage: 'Bitte lade eine Datei hoch, die kleiner als 10 MB ist.',
missingInfoTitle: 'Fehlende Informationen',
fillRequiredFields: 'Bitte fülle alle Pflichtfelder aus.',
frontSideMissingTitle: 'Vorderseite fehlt',
frontSideMissingMessage: 'Bitte lade die Vorderseite hoch.',
backSideMissingTitle: 'Rückseite fehlt',
backSideMissingMessage: 'Bitte lade die Rückseite hoch.',
authErrorTitle: 'Authentifizierungsfehler',
uploadFailedTitle: 'Hochladen fehlgeschlagen',
uploadFailedMessage: 'Deine Dokumente konnten nicht hochgeladen werden.',
networkErrorTitle: 'Netzwerkfehler',
networkErrorMessage: 'Beim Hochladen der Dokumente ist ein Netzwerkfehler aufgetreten.',
},
additionalInfo: {
title: 'Vervollständige dein Profil',
companyTitle: 'Firmenprofil vervollständigen',
personalInformation: 'Persönliche Informationen',
companyDetails: 'Firmendaten',
bankDetails: 'Bankdaten',
additionalInformation: 'Zusätzliche Informationen',
firstName: 'Vorname *',
lastName: 'Nachname *',
email: 'E-Mail *',
phoneNumber: 'Telefonnummer *',
dateOfBirth: 'Geburtsdatum *',
nationality: 'Nationalität',
selectNationality: 'Nationalität auswählen...',
streetHouseNumber: 'Straße & Hausnummer *',
streetNumber: 'Straße & Nummer *',
postalCode: 'Postleitzahl *',
city: 'Stadt *',
country: 'Land',
selectCountry: 'Land auswählen...',
accountHolder: 'Kontoinhaber *',
iban: 'IBAN *',
secondPhoneOptional: 'Zweite Telefonnummer (optional)',
emergencyContactName: 'Notfallkontakt Name',
emergencyContactPhone: 'Notfallkontakt Telefon',
fullNamePlaceholder: 'Vollständiger Name',
postalCodePlaceholder: 'z. B. 12345',
cityPlaceholder: 'z. B. Berlin',
phonePlaceholder: 'z. B. +43 676 1234567',
streetPlaceholder: 'Straße & Hausnummer',
ibanPlaceholder: 'z. B. DE89 3704 0044 0532 0130 00',
companyName: 'Firmenname *',
companyEmail: 'Firmen-E-Mail *',
companyPhone: 'Firmentelefon *',
contactPerson: 'Kontaktperson *',
contactPersonPhone: 'Telefon Kontaktperson *',
registrationNumberOptional: 'Firmenbuchnummer (optional)',
uidNumberOptional: 'UID-Nummer (optional)',
companyHolderPlaceholder: 'Firma / Kontoinhaber',
registrationPlaceholder: 'z. B. FN123456a',
uidPlaceholder: 'z. B. ATU12345678',
bicOptional: 'BIC (optional)',
bicPlaceholder: 'GENODEF1XXX',
contactNamePlaceholder: 'Name des Kontakts',
emergencyContactNamePlaceholder: 'Name des Kontakts',
additionalInfoSuccessTitle: 'Profil gespeichert',
personalSuccessMessage: 'Dein persönliches Profil wurde erfolgreich gespeichert.',
companySuccessMessage: 'Dein Firmenprofil wurde erfolgreich gespeichert.',
dataSavedRedirecting: 'Daten gespeichert. Weiterleitung in Kürze…',
saveContinue: 'Speichern und fortfahren',
saveFailedTitle: 'Speichern fehlgeschlagen',
saveFailedMessage: 'Speichern fehlgeschlagen. Bitte versuche es erneut.',
invalidDateOfBirthTitle: 'Ungültiges Geburtsdatum',
invalidDateOfBirthMessage: 'Ungültiges Geburtsdatum. Du musst mindestens 18 Jahre alt sein.',
invalidIbanTitle: 'Ungültige IBAN',
invalidIbanMessage: 'Ungültige IBAN.',
missingCountryCodeTitle: 'Ländervorwahl fehlt',
missingPhoneNumberTitle: 'Telefonnummer fehlt',
invalidPhoneNumberTitle: 'Ungültige Telefonnummer',
missingCountryCodeMessage: 'Bitte wähle eine Ländervorwahl für deine Telefonnummer aus.',
phoneNumberMissingMessage: 'Bitte gib deine Telefonnummer ein.',
validPhoneNumberMessage: 'Bitte gib eine gültige Telefonnummer ein.',
validSecondPhoneNumberMessage: 'Bitte gib eine gültige zweite Telefonnummer ein.',
validEmergencyPhoneNumberMessage: 'Bitte gib eine gültige Telefonnummer für den Notfallkontakt ein.',
fillRequiredFields: 'Bitte fülle alle Pflichtfelder aus.',
authErrorTitle: 'Authentifizierungsfehler',
authErrorMessage: 'Nicht authentifiziert. Bitte melde dich erneut an.',
searchPlaceholder: 'Suchen…',
noResults: 'Keine Ergebnisse',
countries: {
germany: 'Deutschland', austria: 'Österreich', switzerland: 'Schweiz', italy: 'Italien', france: 'Frankreich', spain: 'Spanien', portugal: 'Portugal', netherlands: 'Niederlande', belgium: 'Belgien', poland: 'Polen', czechRepublic: 'Tschechien', hungary: 'Ungarn', croatia: 'Kroatien', slovenia: 'Slowenien', slovakia: 'Slowakei', unitedKingdom: 'Vereinigtes Königreich', ireland: 'Irland', sweden: 'Schweden', norway: 'Norwegen', denmark: 'Dänemark', finland: 'Finnland', russia: 'Russland', turkey: 'Türkei', greece: 'Griechenland', romania: 'Rumänien', bulgaria: 'Bulgarien', serbia: 'Serbien', albania: 'Albanien', bosniaHerzegovina: 'Bosnien und Herzegowina', unitedStates: 'Vereinigte Staaten', canada: 'Kanada', brazil: 'Brasilien', argentina: 'Argentinien', mexico: 'Mexiko', china: 'China', japan: 'Japan', india: 'Indien', pakistan: 'Pakistan', australia: 'Australien', southAfrica: 'Südafrika', other: 'Andere'
},
nationalities: {
german: 'Deutsch', austrian: 'Österreichisch', swiss: 'Schweizerisch', italian: 'Italienisch', french: 'Französisch', spanish: 'Spanisch', portuguese: 'Portugiesisch', dutch: 'Niederländisch', belgian: 'Belgisch', polish: 'Polnisch', czech: 'Tschechisch', hungarian: 'Ungarisch', croatian: 'Kroatisch', slovenian: 'Slowenisch', slovak: 'Slowakisch', british: 'Britisch', irish: 'Irisch', swedish: 'Schwedisch', norwegian: 'Norwegisch', danish: 'Dänisch', finnish: 'Finnisch', russian: 'Russisch', turkish: 'Türkisch', greek: 'Griechisch', romanian: 'Rumänisch', bulgarian: 'Bulgarisch', serbian: 'Serbisch', albanian: 'Albanisch', bosnian: 'Bosnisch', american: 'Amerikanisch', canadian: 'Kanadisch', brazilian: 'Brasilianisch', argentinian: 'Argentinisch', mexican: 'Mexikanisch', chinese: 'Chinesisch', japanese: 'Japanisch', indian: 'Indisch', pakistani: 'Pakistanisch', australian: 'Australisch', southAfrican: 'Südafrikanisch', other: 'Andere'
},
},
contractSigning: {
personalTitle: 'Persönlichen Teilnahmevertrag unterschreiben',
companyTitle: 'Firmenpartnerschaftsvertrag unterschreiben',
personalSubtitle: 'Bitte prüfe die Vertragsdetails und unterschreibe elektronisch.',
companySubtitle: 'Bitte prüfe die Vertragsdetails und unterschreibe im Namen des Unternehmens.',
documentInformation: 'Dokumentinformationen',
documentPreview: 'Dokumentvorschau',
contractTab: 'Vertrag',
gdprTab: 'DSGVO',
openInNewTab: 'In neuem Tab öffnen',
refresh: 'Aktualisieren',
loadingPreview: 'Vorschau wird geladen…',
noContractAvailable: 'Derzeit ist kein Vertrag verfügbar. Bitte kontaktiere uns.',
noteTitle: 'Hinweis',
noteBody: 'Deine elektronische Signatur ist rechtsverbindlich. Bitte stelle sicher, dass alle Angaben korrekt sind.',
attentionTitle: 'Achtung',
attentionBody: 'Du bestätigst, dass du berechtigt bist, im Namen des Unternehmens zu unterschreiben.',
documentLabel: 'Dokument:',
idLabel: 'ID:',
versionLabel: 'Version / Grundlage:',
jurisdictionLabel: 'Gerichtsstand:',
languageLabel: 'Sprache:',
issuerLabel: 'Aussteller:',
addressLabel: 'Adresse:',
signatureSection: 'Unterschrift',
drawSignature: 'Unterschrift zeichnen *',
clear: 'Leeren',
signatureHelp: 'Zum Unterschreiben Maus oder Touch verwenden. Eine Signatur ist erforderlich.',
captured: 'Erfasst',
confirmations: 'Bestätigungen',
confirmContractPersonal: 'Ich bestätige, dass ich den Vertrag vollständig gelesen und verstanden habe.',
confirmDataPersonal: 'Ich stimme der Verarbeitung meiner personenbezogenen Daten gemäß der Datenschutzerklärung zu.',
confirmSignaturePersonal: 'Ich bestätige, dass diese elektronische Signatur rechtsverbindlich und einer handschriftlichen Unterschrift gleichwertig ist.',
confirmContractCompany: 'Ich bestätige, dass ich den vollständigen Vertrag im Namen des Unternehmens gelesen und akzeptiert habe.',
confirmDataCompany: 'Ich stimme der Verarbeitung von Unternehmens- und personenbezogenen Daten gemäß der Datenschutzerklärung zu.',
confirmSignatureCompany: 'Ich bin berechtigt, rechtsverbindliche Dokumente für dieses Unternehmen zu unterzeichnen.',
noDocumentsAvailableTitle: 'Keine Dokumente verfügbar',
noDocumentsAvailableMessage: 'Verträge können derzeit nicht unterzeichnet werden. Momentan sind keine aktiven Dokumente verfügbar.',
missingInformationTitle: 'Fehlende Informationen',
completePrefix: 'Bitte vervollständige:',
contractReadUnderstood: 'Vertrag gelesen und verstanden',
privacyAccepted: 'Datenschutzerklärung akzeptiert',
electronicSignatureConfirmed: 'Elektronische Signatur bestätigt',
signatureCaptured: 'Signatur im Feld erfasst',
authErrorTitle: 'Authentifizierungsfehler',
authErrorMessage: 'Nicht authentifiziert. Bitte melde dich erneut an.',
contractSignedTitle: 'Vertrag unterschrieben',
personalContractSignedMessage: 'Dein persönlicher Vertrag wurde erfolgreich unterschrieben.',
companyContractSignedMessage: 'Dein Firmenvertrag wurde erfolgreich unterschrieben.',
signingFailedTitle: 'Signatur fehlgeschlagen',
signingFailedMessage: 'Die Signatur ist fehlgeschlagen. Bitte versuche es erneut.',
contractSignedRedirecting: 'Vertrag erfolgreich unterschrieben. Weiterleitung in Kürze…',
signing: 'Wird unterschrieben…',
signed: 'Unterschrieben',
signNow: 'Jetzt unterschreiben',
},
},
suspended: {
title: 'Konto gesperrt',
message: 'Dein Konto wurde gesperrt. Bitte kontaktiere den Support für weitere Hilfe.',
contactSupport: 'Support kontaktieren',
backToLogin: 'Zurück zur Anmeldung',
reason: 'Grund',
},
// ─── Admin ─────────────────────────────────────────────
adminDashboard: {
title: 'Admin-Dashboard',
subtitle: 'Alle administrativen Funktionen, Benutzerverwaltung, Berechtigungen und globale Einstellungen verwalten.',
warningTitle: 'Warnung: Einstellungen und Aktionen unterhalb dieses Punktes können Konsequenzen für das gesamte System haben!',
warningMessage: 'Alle administrativen Funktionen, Benutzerverwaltung, Berechtigungen und globale Einstellungen verwalten.',
accessDenied: 'Zugriff verweigert',
accessDeniedMessage: 'Du benötigst Administratorrechte, um auf diese Seite zuzugreifen.',
loading: 'Laden…',
totalUsers: 'Benutzer gesamt',
admins: 'Admins',
active: 'Aktiv',
pendingVerification: 'Ausstehende Verifizierung',
personal: 'Privat',
company: 'Unternehmen',
managementShortcuts: 'Verwaltungsverknüpfungen',
managementShortcutsSubtitle: 'Schnellzugriff auf häufige Admin-Module.',
matrixManagement: 'Matrix-Verwaltung',
matrixManagementDesc: 'Matrizen und Benutzer konfigurieren',
coffeeSubscriptions: 'Kaffee-Abonnement-Verwaltung',
coffeeSubscriptionsDesc: 'Pläne, Abrechnung und Verlängerungen',
contractManagement: 'Vertragsverwaltung',
contractManagementDesc: 'Vorlagen, Genehmigungen, Status',
dashboardManagement: 'Dashboard-Verwaltung',
dashboardManagementDesc: 'Dashboard-Plattformen konfigurieren',
userManagement: 'Benutzerverwaltung',
userManagementDesc: 'Alle Benutzer durchsuchen und verwalten',
userVerify: 'Benutzer verifizieren',
userVerifyDesc: 'Onboarding-Status der Benutzer prüfen und verifizieren',
financeManagement: 'Finanzverwaltung',
financeManagementDesc: 'Steuersätze, Abrechnungseinstellungen und Finanztools',
poolManagement: 'Pool-Verwaltung',
poolManagementDesc: 'Pool-Strukturen und Zuweisungen verwalten',
affiliateManagement: 'Affiliate-Verwaltung',
affiliateManagementDesc: 'Partner-Inhalte und Affiliate-Steuerung',
newsManagement: 'Neuigkeiten-Verwaltung',
newsManagementDesc: 'Nachrichtenartikel erstellen und verwalten',
devManagement: 'Entwickler-Verwaltung',
devManagementDesc: 'SQL-Abfragen und Entwicklertools ausführen',
languageManagement: 'Sprachverwaltung',
languageManagementDesc: 'Sprachen hinzufügen und UI-Übersetzungen verwalten',
moduleDisabled: 'Dieses Modul ist derzeit in der Systemkonfiguration deaktiviert.',
adminAccessRequired: 'Administratorzugriff erforderlich.',
adminNavigation: 'Admin-Navigation',
adminNavigationHelp: 'Öffne das Dashboard, um über die Modulkarten auf alle Admin-Bereiche zuzugreifen.',
serverStatusLogs: 'Serverstatus & Logs',
serverStatusLogsSubtitle: 'Systemzustand, Ressourcennutzung und aktuelle Fehlerhinweise.',
serverStatusLabel: 'Serverstatus:',
serverOnline: 'Server online',
serverOffline: 'Offline',
uptime: 'Laufzeit:',
cpuUsage: 'CPU-Auslastung:',
memoryUsage: 'Speichernutzung:',
autoscaledEnvironment: 'Autoskalierte Umgebung (Mock)',
recentErrorLogs: 'Aktuelle Fehlerprotokolle',
noRecentLogs: 'Keine aktuellen Logs.',
viewFullLogs: 'Alle Logs anzeigen',
},
userManagement: {
title: 'Benutzerverwaltung',
subtitle: 'Alle Benutzer durchsuchen und verwalten.',
searchPlaceholder: 'Benutzer suchen…',
firstName: 'Vorname',
lastName: 'Nachname',
email: 'E-Mail',
role: 'Rolle',
status: 'Status',
actions: 'Aktionen',
verify: 'Verifizieren',
ban: 'Sperren',
unban: 'Entsperren',
exportCsv: 'CSV exportieren',
noUsers: 'Keine Benutzer gefunden.',
loading: 'Benutzer werden geladen…',
confirmBan: 'Bist du sicher, dass du diesen Benutzer sperren möchtest?',
confirmUnban: 'Bist du sicher, dass du diesen Benutzer entsperren möchtest?',
confirmVerify: 'Bist du sicher, dass du diesen Benutzer verifizieren möchtest?',
createdAt: 'Erstellt am',
lastLogin: 'Letzter Login',
userType: 'Benutzertyp',
},
languageManagement: {
title: 'Sprachverwaltung',
subtitle: 'UI-Übersetzungen verwalten. Alle Schlüssel aus der englischen Quelldatei gescannt.',
addLanguage: 'Sprache hinzufügen',
languageCode: 'Sprachcode',
languageName: 'Sprachname',
languageCodePlaceholder: 'z.B. fr, es, zh-TW',
languageNamePlaceholder: 'z.B. Français',
addBtn: 'Hinzufügen',
deleteLanguage: 'Sprache löschen',
deleteConfirm: 'Löschen',
deleteWarning: 'Alle Übersetzungen für diese Sprache werden entfernt.',
saveChanges: 'Änderungen speichern',
saved: 'Gespeichert',
unsavedChanges: 'Du hast ungespeicherte Änderungen.',
saveNow: 'Speichern',
translationProgress: 'Übersetzungsfortschritt',
keysTranslated: 'Schlüssel übersetzt',
backToAdmin: 'Zurück zum Admin',
searchPlaceholder: 'Schlüssel oder englischen Text suchen…',
noKeysMatch: 'Keine Schlüssel entsprechen deiner Suche.',
englishReference: 'Englisch (Referenz)',
clearOverride: 'Überschreibung löschen (auf Standard zurücksetzen)',
invalidCode: 'Verwende einen gültigen BCP-47-Code, z.B. fr, es, zh-TW.',
codeDuplicate: 'Sprache existiert bereits.',
codeRequired: 'Sprachcode ist erforderlich.',
nameRequired: 'Sprachname ist erforderlich.',
},
contractManagement: {
title: 'Vertragsverwaltung',
subtitle: 'Vertragsvorlagen verwalten.',
uploadTemplate: 'Vorlage hochladen',
currentTemplate: 'Aktuelle Vorlage',
noTemplate: 'Keine Vorlage hochgeladen.',
previewTemplate: 'Vorlage ansehen',
saveTemplate: 'Vorlage speichern',
loading: 'Laden…',
uploadSuccess: 'Vorlage erfolgreich hochgeladen.',
uploadError: 'Vorlage konnte nicht hochgeladen werden.',
},
userDetailModal: {
error: 'Fehler',
personal: 'Privat',
company: 'Unternehmen',
superAdmin: 'Super Admin',
currentStatus: 'Aktueller Status',
adminControls: 'Admin-Steuerung',
missingDocumentsWarning: 'Für diesen Benutzer fehlen Ausweisdokumente oder ein unterzeichneter Vertrag. Der Verifizierungsstatus sollte überprüft werden.',
missingStorageWarning: 'Ausweisdokumente oder ein unterzeichneter Vertrag fehlen im Objektspeicher. Überprüfen Sie den Dateispeicher vor der Verifizierung.',
changeStatus: 'Status ändern',
adminVerification: 'Admin-Verifizierung',
allStepsCompleted: 'Alle Schritte abgeschlossen. Sie können diesen Benutzer verifizieren.',
stepsNotCompleted: 'Der Benutzer hat noch nicht alle erforderlichen Schritte abgeschlossen.',
updating: 'Wird aktualisiert...',
unverifyUser: 'Verifizierung aufheben',
verifyUser: 'Benutzer verifizieren',
contractPreview: 'Vertragsvorschau',
contractTab: 'Vertrag',
gdprTab: 'DSGVO',
loadingPreview: 'Lädt…',
preview: 'Vorschau',
openInNewTab: 'In neuem Tab öffnen',
filesIn: 'Dateien in',
refreshing: 'Aktualisiert…',
refresh: 'Aktualisieren',
loadingFiles: 'Lädt Dateien…',
noFilesFound: 'Keine Dateien in diesem Ordner gefunden.',
selected: 'Ausgewählt:',
moving: 'Wird verschoben…',
moveTo: 'Verschieben nach',
loadingPreviewText: 'Vorschau wird geladen…',
clickPreviewHint: 'Klicken Sie auf „Vorschau", um die neueste Vorlage für diesen Benutzer zu rendern.',
personalInformation: 'Persönliche Informationen',
firstName: 'Vorname',
lastName: 'Nachname',
phone: 'Telefon',
dateOfBirth: 'Geburtsdatum',
address: 'Adresse',
companyInformation: 'Unternehmensinformationen',
companyName: 'Unternehmensname',
registrationNumber: 'Registrierungsnummer',
taxId: 'Steuer-ID',
registrationProgress: 'Registrierungsfortschritt',
emailVerified: 'E-Mail verifiziert',
profileCompleted: 'Profil abgeschlossen',
documentsUploaded: 'Dokumente hochgeladen',
contractSigned: 'Vertrag unterzeichnet',
permissions: 'Berechtigungen',
savingPermissions: 'Wird gespeichert…',
savePermissions: 'Berechtigungen speichern',
loadingPermissions: 'Berechtigungen werden geladen…',
noPermissionsAvailable: 'Keine Berechtigungen verfügbar.',
inactive: 'Inaktiv',
close: 'Schließen',
moveDocumentTitle: 'Dokument verschieben nach',
moveDocumentDescription: 'Das Dokument wird unter dem gewählten Vertragstyp neu klassifiziert.',
moveDocumentConfirm: 'Dokument verschieben',
moveDocumentFile: 'Datei:',
completeStepsTooltip: 'Schließen Sie alle Schritte ab und stellen Sie sicher, dass Dateien im Objektspeicher vorhanden sind, bevor Sie die Admin-Verifizierung durchführen',
},
invoiceDetailModal: {
invoiceTitle: 'Rechnung',
statusDraft: 'Entwurf',
statusIssued: 'Ausgestellt',
statusPaid: 'Bezahlt',
statusOverdue: 'Überfällig',
statusCanceled: 'Storniert',
changeStatus: 'Status ändern:',
updatingStatus: 'Status wird aktualisiert…',
created: 'Erstellt',
customer: 'Kunde',
name: 'Name',
email: 'E-Mail',
street: 'Straße',
city: 'Stadt',
country: 'Land',
userId: 'Benutzer-ID',
financials: 'Finanzen',
net: 'Netto',
tax: 'Steuer',
gross: 'Brutto',
vatRate: 'MwSt-Satz',
currency: 'Währung',
dates: 'Daten',
issued: 'Ausgestellt',
due: 'Fällig',
updated: 'Aktualisiert',
lineItems: 'Positionen',
noLineItems: 'Keine Positionen gefunden.',
description: 'Beschreibung',
qty: 'Menge',
unitPrice: 'Stückpreis',
total: 'Gesamt',
payments: 'Zahlungen',
method: 'Methode',
transaction: 'Transaktion',
amount: 'Betrag',
paidAt: 'Bezahlt am',
status: 'Status',
contextMetadata: 'Kontext / Metadaten',
clickToExpand: '(zum Erweitern klicken)',
exportJson: 'JSON exportieren',
poolCheck: 'Pool-Prüfung',
close: 'Schließen',
poolErrorPrefix: 'Pool-Buchungsfehler:',
poolInflowsBooked: 'Pool-Zufluss/Zuflüsse gebucht',
statusUpdatedTo: 'Status aktualisiert zu',
reasonInvalidInvoiceId: 'Ungültige Rechnungs-ID',
reasonInvoiceNotFound: 'Rechnung für Pool-Buchung nicht gefunden',
reasonInvoiceNotPaid: 'Rechnung nicht als bezahlt markiert',
reasonUnsupportedSourceType: 'Kein Abonnement — keine Pool-Buchung',
reasonMissingAbonementRelation: 'Kein verknüpftes Abonnement — keine Pool-Buchung',
reasonAbonementNotFound: 'Verknüpftes Abonnement nicht gefunden',
reasonNoBreakdownLines: 'Abonnement hat keine Kapselaufschlüsselung — keine Pool-Buchung',
reasonNoActivePools: 'Keine aktiven System-Pools gefunden',
},
// ─── Notifications / Toasts ────────────────────────────
toasts: {
loginSuccess: 'Anmeldung erfolgreich',
loginSuccessMessage: 'Du bist jetzt angemeldet.',
loginFailed: 'Anmeldung fehlgeschlagen',
loginFailedMessage: 'Bitte überprüfe deine Zugangsdaten und versuche es erneut.',
registerSuccess: 'Registrierung erfolgreich',
registerSuccessMessage: 'Du kannst dich jetzt mit deinem neuen Konto anmelden.',
registerFailed: 'Registrierung fehlgeschlagen',
registerFailedMessage: 'Konto konnte nicht erstellt werden. Bitte versuche es erneut.',
invitationVerified: 'Einladung bestätigt',
invitationVerifiedMessage: 'Dein Einladungslink ist gültig. Du kannst dich jetzt registrieren.',
invalidInvitation: 'Ungültige Einladung',
invalidInvitationMessage: 'Dieser Einladungslink ist ungültig oder nicht mehr aktiv.',
networkError: 'Netzwerkfehler',
networkErrorMessage: 'Server nicht erreichbar. Läuft das Backend?',
saveSuccess: 'Erfolgreich gespeichert.',
saveFailed: 'Speichern fehlgeschlagen. Bitte versuche es erneut.',
copySuccess: 'In die Zwischenablage kopiert!',
copyFailed: 'Kopieren fehlgeschlagen.',
deleteSuccess: 'Erfolgreich gelöscht.',
deleteFailed: 'Löschen fehlgeschlagen. Bitte versuche es erneut.',
genericError: 'Etwas ist schief gelaufen. Bitte versuche es erneut.',
},
};

View File

@ -1,6 +1,41 @@
import { Translations } from '../types';
export const en: Translations = {
// ─── General ───────────────────────────────────────────
common: {
loading: 'Loading…',
saving: 'Saving…',
save: 'Save',
saved: 'Saved',
cancel: 'Cancel',
close: 'Close',
back: 'Back',
confirm: 'Confirm',
delete: 'Delete',
edit: 'Edit',
add: 'Add',
search: 'Search',
searchPlaceholder: 'Search…',
noResults: 'No results found.',
error: 'Error',
success: 'Success',
required: 'Required',
optional: 'optional',
yes: 'Yes',
no: 'No',
copy: 'Copy',
copied: 'Copied!',
download: 'Download',
upload: 'Upload',
preview: 'Preview',
refresh: 'Refresh',
backToHome: 'Back to Home',
unsavedChanges: 'You have unsaved changes.',
learnMore: 'Learn More',
getStarted: 'Get Started',
language: 'Language',
},
home: {
title: 'Profit Planet',
tagline: 'Discover and trade sustainable products',
@ -8,34 +43,36 @@ export const en: Translations = {
features: {
sustainable: {
title: 'Sustainable Products',
description: 'Discover eco-friendly products that make a difference for our planet.'
description: 'Discover eco-friendly products that make a difference for our planet.',
},
community: {
title: 'Active Community',
description: 'Connect with like-minded people who care about sustainability.'
description: 'Connect with like-minded people who care about sustainability.',
},
rewards: {
title: 'Earn Rewards',
description: 'Get Gold Points for every sustainable purchase and action.'
}
description: 'Get Gold Points for every sustainable purchase and action.',
},
},
stats: {
members: 'Active Members',
products: 'Eco Products',
communities: 'Communities'
communities: 'Communities',
},
cta: {
getStarted: 'Get Started',
learnMore: 'Learn More'
}
learnMore: 'Learn More',
},
},
footer: {
company: 'Profit Planet GmbH',
rights: 'All rights reserved.',
privacy: 'Privacy Policy',
terms: 'Terms of Service',
contact: 'Contact'
contact: 'Contact',
},
nav: {
home: 'Home',
shop: 'Shop',
@ -43,6 +80,874 @@ export const en: Translations = {
community: 'Community',
profile: 'Profile',
login: 'Login',
logout: 'Logout'
}
logout: 'Logout',
news: 'News',
memberships: 'Memberships',
aboutUs: 'About Us',
affiliateLinks: 'Affiliate Links',
information: 'Information',
myAccount: 'My Account',
mySubscriptions: 'My Subscriptions',
coffeeSubscriptions: 'Coffee Abonnements',
},
// ─── Auth ──────────────────────────────────────────────
login: {
title: 'PROFIT PLANET',
subtitle: 'Welcome back! Log in to continue.',
emailLabel: 'Email address',
emailPlaceholder: 'you@example.com',
passwordLabel: 'Password',
passwordPlaceholder: 'Enter your password',
rememberMe: 'Remember me',
submit: 'Log in',
submitting: 'Logging in…',
forgotPassword: 'Forgot password?',
noAccount: "Don't have an account?",
registerLink: 'Register',
errorRequired: 'Email address is required',
errorInvalidEmail: 'Please enter a valid email address',
errorPasswordRequired: 'Password is required',
errorPasswordTooShort: 'Password must be at least 6 characters long',
errorInvalidCredentials: 'Invalid email or password',
errorAccountNotFound: 'No account found with this email address',
errorAccountLocked: 'Account has been locked. Please contact support.',
errorConnectionFailed: 'Connection to the server failed. Please try again later.',
errorGeneric: 'Login failed. Please try again.',
successTitle: 'Login successful',
successMessage: 'You are now logged in.',
failedTitle: 'Login failed',
},
register: {
title: 'Create Account',
subtitle: 'Join Profit Planet today.',
tabPersonal: 'Personal',
tabCompany: 'Company',
tabGuest: 'Guest',
checkingInvitation: 'Checking invitation link…',
invitationVerifiedTitle: 'Invitation verified',
invitationVerifiedMessage: 'Your invitation link is valid. You can register now.',
invalidInvitationTitle: 'Invalid invitation',
invalidInvitationMessage: 'This invitation link is invalid or no longer active.',
noInvitationToken: 'No invitation token found in the link.',
networkError: 'Could not reach the server. Is the backend running?',
firstName: 'First name',
lastName: 'Last name',
email: 'Email address',
confirmEmail: 'Confirm email address',
password: 'Password',
confirmPassword: 'Confirm password',
phone: 'Phone number',
companyName: 'Company name',
companyEmail: 'Company email',
companyPhone: 'Company phone',
contactPersonName: 'Contact person name',
contactPersonPhone: 'Contact person phone',
submit: 'Create account',
submitting: 'Creating account…',
errorAllRequired: 'All fields are required',
errorEmailMismatch: 'Email addresses do not match',
errorPasswordMismatch: 'Passwords do not match',
errorPasswordWeak: 'Password must be at least 8 characters and contain uppercase, lowercase, numbers and special characters',
errorSelectCountryCode: 'Please select a country code from the dropdown before continuing.',
errorPhoneRequired: 'Please enter your phone number.',
errorPhoneInvalid: 'Please enter a valid mobile phone number.',
errorBothPhonesRequired: 'Please enter both company and contact phone numbers.',
errorBothPhonesInvalid: 'Please enter valid phone numbers for company and contact person.',
successTitle: 'Registration successful',
successMessage: 'You can now log in with your new account.',
alreadyHaveAccount: 'Already have an account?',
loginLink: 'Log in',
sessionDetectedTitle: 'Active session detected',
sessionDetectedMessage: 'You are already logged in. Do you want to log out and register a new account?',
sessionContinue: 'Continue to dashboard',
sessionLogout: 'Log out and register',
},
passwordReset: {
title: 'Reset Password',
subtitle: 'Enter your email address and we will send you a reset link.',
emailLabel: 'Email address',
emailPlaceholder: 'you@example.com',
submit: 'Send reset link',
submitting: 'Sending…',
successTitle: 'Email sent',
successMessage: 'Check your inbox for the password reset link.',
backToLogin: 'Back to login',
errorInvalidEmail: 'Please enter a valid email address',
},
// ─── Pages ─────────────────────────────────────────────
dashboard: {
title: 'Dashboard',
subtitle: 'Welcome to your Profit Planet dashboard.',
loading: 'Loading dashboard…',
accessDenied: 'Access Denied',
accessDeniedMessage: 'You need to complete onboarding to access the dashboard.',
welcomeBack: 'Welcome back',
welcomeSubtitle: "Here's what's happening with your Profit Planet account",
platforms: 'Platforms',
platformDisabled: 'This is currently disabled.',
redirecting: 'Redirecting…',
pleaseWait: 'Please wait',
goldMemberTitle: 'Gold Member Status',
goldMemberDescription: 'Enjoy exclusive benefits and discounts',
viewBenefits: 'View Benefits',
latestNews: 'Latest News',
viewAllNews: 'View all',
noNewsYet: 'No news yet.',
recent: 'Recent',
platformCards: {
shop: {
title: 'Browse Shop',
description: 'Explore sustainable products',
},
affiliateLinks: {
title: 'Browse Affiliate Links',
description: 'Discover affiliate offers and links',
},
referralManagement: {
title: 'Referral Management',
description: 'Create and manage referral links',
},
profile: {
title: 'Edit Profile',
description: 'Update your information',
},
},
noData: 'No data available.',
},
profile: {
title: 'My Profile',
personalInfo: 'Personal Information',
bankInfo: 'Bank Information',
documents: 'Documents',
memberStatus: 'Member Status',
profileComplete: 'Profile completion',
firstName: 'First name',
lastName: 'Last name',
email: 'Email address',
phone: 'Phone number',
address: 'Address',
joinDate: 'Member since',
accountHolder: 'Account holder name',
iban: 'IBAN',
contactPersonName: 'Contact person',
editBasicInfo: 'Edit personal info',
editBankInfo: 'Edit bank info',
saveChanges: 'Save changes',
documentName: 'Document name',
documentType: 'Type',
documentUploaded: 'Uploaded',
downloadDocument: 'Download',
noDocuments: 'No documents uploaded yet.',
refreshProfile: 'Refresh profile',
loading: 'Loading profile…',
},
community: {
title: 'Community',
subtitle: 'Connect with the Profit Planet community.',
description: 'Join discussions and connect with other members.',
loading: 'Loading community…',
accessDenied: 'Access Denied',
noAccess: 'You need to be logged in to access the community.',
},
shop: {
title: 'Shop',
subtitle: 'Browse sustainable products.',
comingSoon: 'Coming soon',
addToCart: 'Add to cart',
price: 'Price',
outOfStock: 'Out of stock',
viewDetails: 'View details',
},
memberships: {
title: 'Memberships',
subtitle: 'Choose the right plan for you.',
description: 'Become a member and unlock exclusive benefits.',
selectPlan: 'Select plan',
perMonth: 'per month',
perYear: 'per year',
mostPopular: 'Most popular',
choosePlan: 'Choose this plan',
},
affiliateLinks: {
title: 'Affiliate Links',
subtitle: 'Explore our partner links.',
description: 'Browse and share our partner links to earn rewards.',
visitLink: 'Visit link',
partnerLinks: 'Partner links',
},
aboutUs: {
title: 'About Us',
subtitle: 'Learn more about Profit Planet.',
description: 'We are building a sustainable future together.',
ourTeam: 'Our Team',
ourMission: 'Our Mission',
},
news: {
title: 'News',
subtitle: 'Stay up to date with Profit Planet.',
readMore: 'Read more',
publishedDate: 'Published',
category: 'Category',
noArticles: 'No articles available.',
loadMore: 'Load more',
},
// ─── Coffee ABO ────────────────────────────────────────
coffeeSelection: {
title: 'Coffee Subscription',
subtitle: 'Select your coffees for this month.',
selectYourCoffees: 'Select your coffees',
capsuleTarget: 'Capsule target',
planLabel: 'Your plan',
yourSelection: 'Your selection',
totalCapsules: 'Total capsules',
totalPacks: 'Total packs',
targetPacks: 'Target packs',
selectUpTo: 'Select up to',
goToSummary: 'Go to summary',
loading: 'Loading coffees…',
noProducts: 'No coffees available.',
validationExact: 'You need exactly {count} capsules ({packs} packs).',
packOf10: 'Pack of 10',
pricePerPack: 'per pack',
},
coffeeSummary: {
title: 'Summary & Details',
backToSelection: 'Back to selection',
stepSelection: 'Selection',
stepSummary: 'Summary',
yourDetails: '1. Your details',
fillFromLoggedIn: 'Fill fields with logged in data',
firstName: 'First name',
lastName: 'Last name',
email: 'Email',
street: 'Street & No.',
zip: 'ZIP',
city: 'City',
country: 'Country',
phone: 'Phone',
phoneOptional: 'Phone (optional)',
paymentMethod: 'Payment method',
paymentSepa: 'SEPA',
paymentCard: 'Credit Card',
paymentSofort: 'Sofort Banking',
invoiceByEmail: 'Send invoice by email',
invoiceAddress: 'Invoice address',
sameAsShipping: 'Same as shipping address',
uidNumberLabel: 'UID Number (optional)',
uidNumberPlaceholder: 'e.g. SI12345678',
uidNumberHint: 'Without a valid UID, the invoice will be created with standard VAT.',
reverseChargeHint: 'Companies with a valid UID and a foreign invoice country outside AT are billed via reverse charge without displayed VAT.',
fullName: 'Full name',
contractPreview: 'Contract preview (ABO)',
contractSubtitle: 'Contract variables are auto-populated from your form data.',
openPreview: 'Open preview',
contractLoading: 'Loading contract preview…',
contractError: 'Contract preview could not be loaded:',
contractNotAvailable: 'Contract template is not available.',
pdfPreviewTitle: 'ABO contract preview (PDF)',
pdfGenerating: 'Generating PDF preview…',
pdfError: 'PDF preview could not be generated:',
pdfNotAvailable: 'No PDF preview available.',
signingCity: 'Ort (Signing City) *',
signingCityPlaceholder: 'e.g. Vienna',
signingCityRequired: 'Signing city is required.',
signatureRequired: 'Signature is required.',
completeSubscription: 'Complete subscription',
creating: 'Creating…',
cannotSubmit: 'Please select coffees and fill all required buyer fields, signing city, and signature.',
yourSelection: '2. Your selection',
shipping: 'Shipping',
freeShipping: 'FREE SHIPPING',
shippingLoading: 'Loading…',
shippingError: 'Shipping fees could not be loaded:',
totalNet: 'Total (net)',
tax: 'Tax ({rate}%)',
taxReverseCharge: 'Tax (Reverse Charge)',
totalInclTax: 'Total incl. tax',
reverseChargeActive: 'Reverse Charge active: valid UID and foreign invoice country detected.',
capsuleValidation: 'Selected: {selected} capsules ({selectedPacks} packs of 10). Target: {target} capsules ({targetPacks} packs).',
exactlyRequired: 'Exactly {packs} packs ({capsules} capsules) are required.',
thankYouTitle: 'Thanks for your subscription!',
thankYouMessage: 'Subscription created.',
noSelectionFound: 'No selection found.',
noLoggedInData: 'No logged-in user data found to fill the fields.',
},
// ─── Account ───────────────────────────────────────────
personalMatrix: {
title: 'Personal Matrix',
subtitle: 'Your network structure.',
description: 'View your personal matrix and downline network.',
loading: 'Loading matrix…',
noData: 'No matrix data available.',
},
referralManagement: {
title: 'Referral Management',
subtitle: 'Manage your referral links.',
createLink: 'Create referral link',
copyLink: 'Copy link',
copiedToClipboard: 'Copied to clipboard!',
linkExpiry: 'Expires',
noLinks: 'No referral links yet.',
generating: 'Generating…',
usesRemaining: 'uses remaining',
unlimited: 'Unlimited',
createSuccess: 'Referral link created successfully.',
createError: 'Could not create referral link.',
},
quickactionDashboard: {
title: 'Quick Actions',
subtitle: 'Complete your onboarding steps.',
stepLabel: 'Step',
completed: 'Completed',
pending: 'Pending',
required: 'Required',
verifyIdentity: 'Verify your identity',
completeProfile: 'Complete your profile',
setPayment: 'Set up payment',
startUsing: 'Start using Profit Planet',
allDone: 'All steps completed!',
loading: 'Loading…',
guestAccount: 'Guest Account',
companyAccount: 'Company Account',
personalAccount: 'Personal Account',
loadingStatus: 'Loading status...',
errorLoadingAccountStatus: 'Error loading account status',
tryAgain: 'Try again',
emailVerificationStatus: 'Email Verification Status',
statusOverview: 'Status Overview',
actionRequired: 'Action Required',
quickActions: 'Quick Actions',
tutorial: 'Tutorial',
pleaseVerifyEmailAddress: 'Please verify your email address to activate your guest account and access your subscriptions.',
resendAvailableIn: 'Resend available in',
requestNewCode: 'You can request a new code now',
emailVerified: 'Email Verified',
verifyEmail: 'Verify Email',
idUploaded: 'ID Uploaded',
uploadIdDocument: 'Upload ID Document',
profileCompleted: 'Profile Completed',
signContract: 'Sign Contract',
contractNotReady: 'Sign Contract (requires all previous steps)',
latestNews: 'Latest News',
viewAll: 'View all',
noNewsYet: 'No news available yet.',
recent: 'Recent',
redirecting: 'Redirecting…',
takingToDashboard: 'Taking you to your dashboard',
pleaseWait: 'Please wait',
goToDashboard: 'Go to Dashboard',
backToDashboard: 'Back to Dashboard',
uploading: 'Uploading...',
saved: 'Saved',
uploadContinue: 'Upload & Continue',
yes: 'Yes',
no: 'No',
dragAndDrop: 'Drag and drop your file here, or click to browse',
remove: 'Remove',
maxUploadHint: 'Max 10MB. JPG, PNG or PDF.',
statusCards: {
emailVerification: 'Email Verification',
idDocument: 'ID Document',
additionalInfo: 'Additional Info',
contract: 'Contract',
verified: 'Verified',
missing: 'Missing',
uploaded: 'Uploaded',
signed: 'Signed',
},
emailVerify: {
title: 'Verify your email',
sentIntro: 'We sent a 6-digit code to',
sendingIntro: 'Sending verification email to',
yourEmail: 'your email',
enterBelow: 'Enter it below.',
invalidCode: 'Please enter the full 6-digit code.',
authError: 'Not authenticated. Please log in again.',
emailVerifiedTitle: 'Email verified',
emailVerifiedMessage: 'Your email has been verified successfully.',
verificationFailedTitle: 'Verification failed',
networkErrorTitle: 'Network error',
verifying: 'Verifying...',
verified: 'Verified',
confirmCode: 'Confirm code',
resendCode: 'Resend code',
supportHint: 'Didnt receive the email? Please check your junk/spam folder. Still having issues?',
contactSupport: 'Contact support',
verifiedRedirecting: 'Verified! Redirecting shortly...',
},
uploadId: {
personalTitle: 'Upload ID Document',
personalSubtitle: 'Upload your identification document to continue your onboarding.',
companyTitle: 'Upload Company Documents',
companySubtitle: 'Upload the required company identification documents to continue your onboarding.',
idNumber: 'ID Number *',
idNumberPlaceholder: 'Enter your ID number',
idNumberHint: 'Enter the document number exactly as shown.',
contactPersonIdNumber: 'Contact Person ID Number *',
contactPersonIdNumberPlaceholder: 'Enter contact person\'s ID number',
contactPersonIdNumberHint: 'Enter the ID number exactly as shown on the document.',
idType: 'ID Type *',
documentType: 'Document Type *',
selectIdType: 'Select ID type',
selectDocumentType: 'Select document type',
expiryDate: 'Expiry Date *',
expiryDateHint: 'Choose the expiry date on the document.',
backSideQuestion: 'Does your document have a back side?',
frontPreviewAlt: 'Front ID preview',
backPreviewAlt: 'Back ID preview',
primaryPreviewAlt: 'Primary document preview',
supportingPreviewAlt: 'Supporting document preview',
clickUploadFront: 'Click to upload the front side',
clickUploadBack: 'Click to upload the back side',
documentsChecklistTitle: 'Before uploading, make sure the document:',
clearlyVisible: 'Is clearly visible',
showCorners: 'Shows all four corners',
notExpired: 'Is not expired',
goodLighting: 'Has no glare or dark shadows',
bothSidesUploaded: 'Both sides uploaded',
frontSideUploaded: 'Front side uploaded',
successSavedRedirecting: 'Saved successfully. Redirecting...',
personalUploadSuccessTitle: 'Documents uploaded',
personalUploadSuccessMessage: 'Your ID documents were uploaded successfully.',
companyUploadSuccessTitle: 'Company documents uploaded',
companyUploadSuccessMessage: 'Your company documents were uploaded successfully.',
fileTooLargeTitle: 'File too large',
fileTooLargeMessage: 'Please upload a file smaller than 10MB.',
missingInfoTitle: 'Missing information',
fillRequiredFields: 'Please fill in all required fields.',
frontSideMissingTitle: 'Front side missing',
frontSideMissingMessage: 'Please upload the front side.',
backSideMissingTitle: 'Back side missing',
backSideMissingMessage: 'Please upload the back side.',
authErrorTitle: 'Authentication error',
uploadFailedTitle: 'Upload failed',
uploadFailedMessage: 'Unable to upload your documents.',
networkErrorTitle: 'Network error',
networkErrorMessage: 'A network error occurred while uploading the documents.',
},
additionalInfo: {
title: 'Complete Your Profile',
companyTitle: 'Complete Company Profile',
personalInformation: 'Personal Information',
companyDetails: 'Company Details',
bankDetails: 'Bank Details',
additionalInformation: 'Additional Information',
firstName: 'First Name *',
lastName: 'Last Name *',
email: 'Email *',
phoneNumber: 'Phone Number *',
dateOfBirth: 'Date of Birth *',
nationality: 'Nationality',
selectNationality: 'Select nationality...',
streetHouseNumber: 'Street & House Number *',
streetNumber: 'Street & Number *',
postalCode: 'Postal Code *',
city: 'City *',
country: 'Country',
selectCountry: 'Select country...',
accountHolder: 'Account Holder *',
iban: 'IBAN *',
secondPhoneOptional: 'Second Phone Number (optional)',
emergencyContactName: 'Emergency Contact Name',
emergencyContactPhone: 'Emergency Contact Phone',
fullNamePlaceholder: 'Full name',
postalCodePlaceholder: 'e.g. 12345',
cityPlaceholder: 'e.g. Berlin',
phonePlaceholder: 'e.g. +43 676 1234567',
streetPlaceholder: 'Street & House Number',
ibanPlaceholder: 'e.g. DE89 3704 0044 0532 0130 00',
companyName: 'Company Name *',
companyEmail: 'Company Email *',
companyPhone: 'Company Phone *',
contactPerson: 'Contact Person *',
contactPersonPhone: 'Contact Person Phone *',
registrationNumberOptional: 'Registration Number (optional)',
uidNumberOptional: 'UID Number (optional)',
companyHolderPlaceholder: 'Company / Holder name',
registrationPlaceholder: 'e.g. FN123456a',
uidPlaceholder: 'e.g. ATU12345678',
bicOptional: 'BIC (optional)',
bicPlaceholder: 'GENODEF1XXX',
contactNamePlaceholder: 'Contact name',
emergencyContactNamePlaceholder: 'Contact name',
additionalInfoSuccessTitle: 'Profile saved',
personalSuccessMessage: 'Your personal profile has been saved successfully.',
companySuccessMessage: 'Your company profile has been saved successfully.',
dataSavedRedirecting: 'Data saved. Redirecting shortly…',
saveContinue: 'Save & Continue',
saveFailedTitle: 'Save failed',
saveFailedMessage: 'Save failed. Please try again.',
invalidDateOfBirthTitle: 'Invalid date of birth',
invalidDateOfBirthMessage: 'Invalid date of birth. You must be at least 18 years old.',
invalidIbanTitle: 'Invalid IBAN',
invalidIbanMessage: 'Invalid IBAN.',
missingCountryCodeTitle: 'Missing country code',
missingPhoneNumberTitle: 'Missing phone number',
invalidPhoneNumberTitle: 'Invalid phone number',
missingCountryCodeMessage: 'Please select a country code for your phone number.',
phoneNumberMissingMessage: 'Please enter your phone number.',
validPhoneNumberMessage: 'Please enter a valid phone number.',
validSecondPhoneNumberMessage: 'Please enter a valid second phone number.',
validEmergencyPhoneNumberMessage: 'Please enter a valid emergency phone number.',
fillRequiredFields: 'Please fill in all required fields.',
authErrorTitle: 'Authentication error',
authErrorMessage: 'Not authenticated. Please log in again.',
searchPlaceholder: 'Search…',
noResults: 'No results',
countries: {
germany: 'Germany', austria: 'Austria', switzerland: 'Switzerland', italy: 'Italy', france: 'France', spain: 'Spain', portugal: 'Portugal', netherlands: 'Netherlands', belgium: 'Belgium', poland: 'Poland', czechRepublic: 'Czech Republic', hungary: 'Hungary', croatia: 'Croatia', slovenia: 'Slovenia', slovakia: 'Slovakia', unitedKingdom: 'United Kingdom', ireland: 'Ireland', sweden: 'Sweden', norway: 'Norway', denmark: 'Denmark', finland: 'Finland', russia: 'Russia', turkey: 'Turkey', greece: 'Greece', romania: 'Romania', bulgaria: 'Bulgaria', serbia: 'Serbia', albania: 'Albania', bosniaHerzegovina: 'Bosnia and Herzegovina', unitedStates: 'United States', canada: 'Canada', brazil: 'Brazil', argentina: 'Argentina', mexico: 'Mexico', china: 'China', japan: 'Japan', india: 'India', pakistan: 'Pakistan', australia: 'Australia', southAfrica: 'South Africa', other: 'Other'
},
nationalities: {
german: 'German', austrian: 'Austrian', swiss: 'Swiss', italian: 'Italian', french: 'French', spanish: 'Spanish', portuguese: 'Portuguese', dutch: 'Dutch', belgian: 'Belgian', polish: 'Polish', czech: 'Czech', hungarian: 'Hungarian', croatian: 'Croatian', slovenian: 'Slovenian', slovak: 'Slovak', british: 'British', irish: 'Irish', swedish: 'Swedish', norwegian: 'Norwegian', danish: 'Danish', finnish: 'Finnish', russian: 'Russian', turkish: 'Turkish', greek: 'Greek', romanian: 'Romanian', bulgarian: 'Bulgarian', serbian: 'Serbian', albanian: 'Albanian', bosnian: 'Bosnian', american: 'American', canadian: 'Canadian', brazilian: 'Brazilian', argentinian: 'Argentinian', mexican: 'Mexican', chinese: 'Chinese', japanese: 'Japanese', indian: 'Indian', pakistani: 'Pakistani', australian: 'Australian', southAfrican: 'South African', other: 'Other'
},
},
contractSigning: {
personalTitle: 'Sign Personal Participation Contract',
companyTitle: 'Sign Company Partnership Contract',
personalSubtitle: 'Please review the contract details and sign electronically.',
companySubtitle: 'Please review the contract details and sign on behalf of the company.',
documentInformation: 'Document Information',
documentPreview: 'Document Preview',
contractTab: 'Contract',
gdprTab: 'GDPR',
openInNewTab: 'Open in new tab',
refresh: 'Refresh',
loadingPreview: 'Loading preview…',
noContractAvailable: 'No contract available at this moment, please contact us.',
noteTitle: 'Note',
noteBody: 'Your electronic signature is legally binding. Please ensure all details are correct.',
attentionTitle: 'Attention',
attentionBody: 'You confirm that you are authorized to sign on behalf of the company.',
documentLabel: 'Document:',
idLabel: 'ID:',
versionLabel: 'Version / Basis:',
jurisdictionLabel: 'Jurisdiction:',
languageLabel: 'Language:',
issuerLabel: 'Issuer:',
addressLabel: 'Address:',
signatureSection: 'Signature',
drawSignature: 'Draw Signature *',
clear: 'Clear',
signatureHelp: 'Use mouse or touch to sign. A signature is required.',
captured: 'Captured',
confirmations: 'Confirmations',
confirmContractPersonal: 'I confirm that I have read and understood the contract in full.',
confirmDataPersonal: 'I consent to the processing of my personal data in accordance with the privacy policy.',
confirmSignaturePersonal: 'I confirm this electronic signature is legally binding and equivalent to a handwritten signature.',
confirmContractCompany: 'I confirm I have read and accepted the full contract on behalf of the company.',
confirmDataCompany: 'I consent to processing of company and personal data in accordance with the privacy policy.',
confirmSignatureCompany: 'I am authorized to sign legally binding documents for this company.',
noDocumentsAvailableTitle: 'No documents available',
noDocumentsAvailableMessage: 'Temporarily unable to sign contracts. No active documents are available at this moment.',
missingInformationTitle: 'Missing information',
completePrefix: 'Please complete:',
contractReadUnderstood: 'Contract read and understood',
privacyAccepted: 'Privacy policy accepted',
electronicSignatureConfirmed: 'Electronic signature confirmed',
signatureCaptured: 'Signature captured on pad',
authErrorTitle: 'Authentication error',
authErrorMessage: 'Not authenticated. Please log in again.',
contractSignedTitle: 'Contract signed',
personalContractSignedMessage: 'Your personal contract has been signed successfully.',
companyContractSignedMessage: 'Your company contract has been signed successfully.',
signingFailedTitle: 'Signature failed',
signingFailedMessage: 'Signature failed. Please try again.',
contractSignedRedirecting: 'Contract signed successfully. Redirecting shortly…',
signing: 'Signing…',
signed: 'Signed',
signNow: 'Sign Now',
},
},
suspended: {
title: 'Account Suspended',
message: 'Your account has been suspended. Please contact support for assistance.',
contactSupport: 'Contact Support',
backToLogin: 'Back to login',
reason: 'Reason',
},
// ─── Admin ─────────────────────────────────────────────
adminDashboard: {
title: 'Admin Dashboard',
subtitle: 'Manage all administrative features, user management, permissions, and global settings.',
warningTitle: 'Warning: Settings and actions below this point can have consequences for the entire system!',
warningMessage: 'Manage all administrative features, user management, permissions, and global settings.',
accessDenied: 'Access Denied',
accessDeniedMessage: 'You need admin privileges to access this page.',
loading: 'Loading…',
totalUsers: 'Total Users',
admins: 'Admins',
active: 'Active',
pendingVerification: 'Pending Verification',
personal: 'Personal',
company: 'Company',
managementShortcuts: 'Management Shortcuts',
managementShortcutsSubtitle: 'Quick access to common admin modules.',
matrixManagement: 'Matrix Management',
matrixManagementDesc: 'Configure matrices and users',
coffeeSubscriptions: 'Coffee Subscription Management',
coffeeSubscriptionsDesc: 'Plans, billing and renewals',
contractManagement: 'Contract Management',
contractManagementDesc: 'Templates, approvals, status',
dashboardManagement: 'Dashboard Management',
dashboardManagementDesc: 'Configure dashboard platforms',
userManagement: 'User Management',
userManagementDesc: 'Browse, search, and manage all users',
userVerify: 'User Verify',
userVerifyDesc: 'Review and verify user onboarding status',
financeManagement: 'Finance Management',
financeManagementDesc: 'Tax rates, billing settings and finance tools',
poolManagement: 'Pool Management',
poolManagementDesc: 'Manage pool structures and assignments',
affiliateManagement: 'Affiliate Management',
affiliateManagementDesc: 'Partner content and affiliate controls',
newsManagement: 'News Management',
newsManagementDesc: 'Create and manage news articles',
devManagement: 'Dev Management',
devManagementDesc: 'Run SQL queries and dev tools',
languageManagement: 'Language Management',
languageManagementDesc: 'Add languages and manage UI translations',
moduleDisabled: 'This module is currently disabled in the system configuration.',
adminAccessRequired: 'Admin access required.',
adminNavigation: 'Admin Navigation',
adminNavigationHelp: 'Open the dashboard to access all admin modules via icon panels.',
serverStatusLogs: 'Server Status & Logs',
serverStatusLogsSubtitle: 'System health, resource usage & recent error insights.',
serverStatusLabel: 'Server Status:',
serverOnline: 'Server Online',
serverOffline: 'Offline',
uptime: 'Uptime:',
cpuUsage: 'CPU Usage:',
memoryUsage: 'Memory Usage:',
autoscaledEnvironment: 'Autoscaled environment (mock)',
recentErrorLogs: 'Recent Error Logs',
noRecentLogs: 'No recent logs.',
viewFullLogs: 'View Full Logs',
},
userManagement: {
title: 'User Management',
subtitle: 'Browse, search, and manage all users.',
searchPlaceholder: 'Search users…',
firstName: 'First Name',
lastName: 'Last Name',
email: 'Email',
role: 'Role',
status: 'Status',
actions: 'Actions',
verify: 'Verify',
ban: 'Ban',
unban: 'Unban',
exportCsv: 'Export CSV',
noUsers: 'No users found.',
loading: 'Loading users…',
confirmBan: 'Are you sure you want to ban this user?',
confirmUnban: 'Are you sure you want to unban this user?',
confirmVerify: 'Are you sure you want to verify this user?',
createdAt: 'Created At',
lastLogin: 'Last Login',
userType: 'User Type',
},
languageManagement: {
title: 'Language Management',
subtitle: 'Manage UI translations. All keys scanned from the English source file.',
addLanguage: 'Add language',
languageCode: 'Language code',
languageName: 'Language name',
languageCodePlaceholder: 'e.g. fr, es, zh-TW',
languageNamePlaceholder: 'e.g. Français',
addBtn: 'Add',
deleteLanguage: 'Delete Language',
deleteConfirm: 'Delete',
deleteWarning: 'All translations for this language will be removed.',
saveChanges: 'Save changes',
saved: 'Saved',
unsavedChanges: 'You have unsaved changes.',
saveNow: 'Save',
translationProgress: 'Translation progress',
keysTranslated: 'keys translated',
backToAdmin: 'Back to Admin',
searchPlaceholder: 'Search keys or English text…',
noKeysMatch: 'No keys match your search.',
englishReference: 'English (reference)',
clearOverride: 'Clear override (revert to built-in)',
invalidCode: 'Use a valid BCP-47 code, e.g. fr, es, zh-TW.',
codeDuplicate: 'Language already exists.',
codeRequired: 'Language code is required.',
nameRequired: 'Language name is required.',
},
contractManagement: {
title: 'Contract Management',
subtitle: 'Manage contract templates.',
uploadTemplate: 'Upload template',
currentTemplate: 'Current template',
noTemplate: 'No template uploaded.',
previewTemplate: 'Preview template',
saveTemplate: 'Save template',
loading: 'Loading…',
uploadSuccess: 'Template uploaded successfully.',
uploadError: 'Could not upload template.',
},
userDetailModal: {
error: 'Error',
personal: 'Personal',
company: 'Company',
superAdmin: 'Super Admin',
currentStatus: 'Current Status',
adminControls: 'Admin Controls',
missingDocumentsWarning: 'ID documents or a signed contract are missing for this user. The user\'s verification status should be checked.',
missingStorageWarning: 'ID documents or a signed contract are missing from object storage. Check the file storage before verifying.',
changeStatus: 'Change Status',
adminVerification: 'Admin Verification',
allStepsCompleted: 'All steps completed. You can verify this user.',
stepsNotCompleted: 'User has not yet completed all required steps.',
updating: 'Updating...',
unverifyUser: 'Unverify User',
verifyUser: 'Verify User',
contractPreview: 'Contract Preview',
contractTab: 'Contract',
gdprTab: 'GDPR',
loadingPreview: 'Loading…',
preview: 'Preview',
openInNewTab: 'Open in new tab',
filesIn: 'Files in',
refreshing: 'Refreshing…',
refresh: 'Refresh',
loadingFiles: 'Loading files…',
noFilesFound: 'No files found in this folder.',
selected: 'Selected:',
moving: 'Moving…',
moveTo: 'Move to',
loadingPreviewText: 'Loading preview…',
clickPreviewHint: 'Click "Preview" to render the latest template for this user.',
personalInformation: 'Personal Information',
firstName: 'First Name',
lastName: 'Last Name',
phone: 'Phone',
dateOfBirth: 'Date of Birth',
address: 'Address',
companyInformation: 'Company Information',
companyName: 'Company Name',
registrationNumber: 'Registration Number',
taxId: 'Tax ID',
registrationProgress: 'Registration Progress',
emailVerified: 'Email Verified',
profileCompleted: 'Profile Completed',
documentsUploaded: 'Documents Uploaded',
contractSigned: 'Contract Signed',
permissions: 'Permissions',
savingPermissions: 'Saving…',
savePermissions: 'Save Permissions',
loadingPermissions: 'Loading permissions…',
noPermissionsAvailable: 'No permissions available.',
inactive: 'Inactive',
close: 'Close',
moveDocumentTitle: 'Move document to',
moveDocumentDescription: 'This will reclassify the selected document under the chosen contract type.',
moveDocumentConfirm: 'Move document',
moveDocumentFile: 'File:',
completeStepsTooltip: 'Complete all steps and ensure files are present in object storage before admin verification',
},
invoiceDetailModal: {
invoiceTitle: 'Invoice',
statusDraft: 'Draft',
statusIssued: 'Issued',
statusPaid: 'Paid',
statusOverdue: 'Overdue',
statusCanceled: 'Canceled',
changeStatus: 'Change status:',
updatingStatus: 'Updating status…',
created: 'Created',
customer: 'Customer',
name: 'Name',
email: 'Email',
street: 'Street',
city: 'City',
country: 'Country',
userId: 'User ID',
financials: 'Financials',
net: 'Net',
tax: 'Tax',
gross: 'Gross',
vatRate: 'VAT Rate',
currency: 'Currency',
dates: 'Dates',
issued: 'Issued',
due: 'Due',
updated: 'Updated',
lineItems: 'Line Items',
noLineItems: 'No line items found.',
description: 'Description',
qty: 'Qty',
unitPrice: 'Unit Price',
total: 'Total',
payments: 'Payments',
method: 'Method',
transaction: 'Transaction',
amount: 'Amount',
paidAt: 'Paid At',
status: 'Status',
contextMetadata: 'Context / Metadata',
clickToExpand: '(click to expand)',
exportJson: 'Export JSON',
poolCheck: 'Pool Check',
close: 'Close',
poolErrorPrefix: 'Pool booking error:',
poolInflowsBooked: 'pool inflow(s) booked',
statusUpdatedTo: 'Status updated to',
reasonInvalidInvoiceId: 'Invalid invoice ID',
reasonInvoiceNotFound: 'Invoice not found for pool booking',
reasonInvoiceNotPaid: 'Invoice not marked as paid',
reasonUnsupportedSourceType: 'Not a subscription invoice — no pool booking',
reasonMissingAbonementRelation: 'No linked subscription — no pool booking',
reasonAbonementNotFound: 'Linked subscription not found',
reasonNoBreakdownLines: 'Subscription has no capsule breakdown — no pool booking',
reasonNoActivePools: 'No active system pools found',
},
// ─── Notifications / Toasts ────────────────────────────
toasts: {
loginSuccess: 'Login successful',
loginSuccessMessage: 'You are now logged in.',
loginFailed: 'Login failed',
loginFailedMessage: 'Please check your credentials and try again.',
registerSuccess: 'Registration successful',
registerSuccessMessage: 'You can now log in with your new account.',
registerFailed: 'Registration failed',
registerFailedMessage: 'Could not create your account. Please try again.',
invitationVerified: 'Invitation verified',
invitationVerifiedMessage: 'Your invitation link is valid. You can register now.',
invalidInvitation: 'Invalid invitation',
invalidInvitationMessage: 'This invitation link is invalid or no longer active.',
networkError: 'Network error',
networkErrorMessage: 'Could not reach the server. Is the backend running?',
saveSuccess: 'Saved successfully.',
saveFailed: 'Could not save. Please try again.',
copySuccess: 'Copied to clipboard!',
copyFailed: 'Could not copy to clipboard.',
deleteSuccess: 'Deleted successfully.',
deleteFailed: 'Could not delete. Please try again.',
genericError: 'Something went wrong. Please try again.',
},
};

View File

@ -1,32 +1,52 @@
export interface Translations {
// ─── General ───────────────────────────────────────────
common: {
loading: string;
saving: string;
save: string;
saved: string;
cancel: string;
close: string;
back: string;
confirm: string;
delete: string;
edit: string;
add: string;
search: string;
searchPlaceholder: string;
noResults: string;
error: string;
success: string;
required: string;
optional: string;
yes: string;
no: string;
copy: string;
copied: string;
download: string;
upload: string;
preview: string;
refresh: string;
backToHome: string;
unsavedChanges: string;
learnMore: string;
getStarted: string;
language: string;
};
home: {
title: string;
tagline: string;
description: string;
features: {
sustainable: {
title: string;
description: string;
};
community: {
title: string;
description: string;
};
rewards: {
title: string;
description: string;
};
};
stats: {
members: string;
products: string;
communities: string;
};
cta: {
getStarted: string;
learnMore: string;
sustainable: { title: string; description: string };
community: { title: string; description: string };
rewards: { title: string; description: string };
};
stats: { members: string; products: string; communities: string };
cta: { getStarted: string; learnMore: string };
};
footer: {
company: string;
rights: string;
@ -34,6 +54,7 @@ export interface Translations {
terms: string;
contact: string;
};
nav: {
home: string;
shop: string;
@ -42,5 +63,857 @@ export interface Translations {
profile: string;
login: string;
logout: string;
news: string;
memberships: string;
aboutUs: string;
affiliateLinks: string;
information: string;
myAccount: string;
mySubscriptions: string;
coffeeSubscriptions: string;
};
// ─── Auth ──────────────────────────────────────────────
login: {
title: string;
subtitle: string;
emailLabel: string;
emailPlaceholder: string;
passwordLabel: string;
passwordPlaceholder: string;
rememberMe: string;
submit: string;
submitting: string;
forgotPassword: string;
noAccount: string;
registerLink: string;
errorRequired: string;
errorInvalidEmail: string;
errorPasswordRequired: string;
errorPasswordTooShort: string;
errorInvalidCredentials: string;
errorAccountNotFound: string;
errorAccountLocked: string;
errorConnectionFailed: string;
errorGeneric: string;
successTitle: string;
successMessage: string;
failedTitle: string;
};
register: {
title: string;
subtitle: string;
tabPersonal: string;
tabCompany: string;
tabGuest: string;
checkingInvitation: string;
invitationVerifiedTitle: string;
invitationVerifiedMessage: string;
invalidInvitationTitle: string;
invalidInvitationMessage: string;
noInvitationToken: string;
networkError: string;
firstName: string;
lastName: string;
email: string;
confirmEmail: string;
password: string;
confirmPassword: string;
phone: string;
companyName: string;
companyEmail: string;
companyPhone: string;
contactPersonName: string;
contactPersonPhone: string;
submit: string;
submitting: string;
errorAllRequired: string;
errorEmailMismatch: string;
errorPasswordMismatch: string;
errorPasswordWeak: string;
errorSelectCountryCode: string;
errorPhoneRequired: string;
errorPhoneInvalid: string;
errorBothPhonesRequired: string;
errorBothPhonesInvalid: string;
successTitle: string;
successMessage: string;
alreadyHaveAccount: string;
loginLink: string;
sessionDetectedTitle: string;
sessionDetectedMessage: string;
sessionContinue: string;
sessionLogout: string;
};
passwordReset: {
title: string;
subtitle: string;
emailLabel: string;
emailPlaceholder: string;
submit: string;
submitting: string;
successTitle: string;
successMessage: string;
backToLogin: string;
errorInvalidEmail: string;
};
// ─── Pages ─────────────────────────────────────────────
dashboard: {
title: string;
subtitle: string;
loading: string;
accessDenied: string;
accessDeniedMessage: string;
welcomeBack: string;
welcomeSubtitle: string;
platforms: string;
platformDisabled: string;
redirecting: string;
pleaseWait: string;
goldMemberTitle: string;
goldMemberDescription: string;
viewBenefits: string;
latestNews: string;
viewAllNews: string;
noNewsYet: string;
recent: string;
platformCards: {
shop: { title: string; description: string };
affiliateLinks: { title: string; description: string };
referralManagement: { title: string; description: string };
profile: { title: string; description: string };
};
noData: string;
};
profile: {
title: string;
personalInfo: string;
bankInfo: string;
documents: string;
memberStatus: string;
profileComplete: string;
firstName: string;
lastName: string;
email: string;
phone: string;
address: string;
joinDate: string;
accountHolder: string;
iban: string;
contactPersonName: string;
editBasicInfo: string;
editBankInfo: string;
saveChanges: string;
documentName: string;
documentType: string;
documentUploaded: string;
downloadDocument: string;
noDocuments: string;
refreshProfile: string;
loading: string;
};
community: {
title: string;
subtitle: string;
description: string;
loading: string;
accessDenied: string;
noAccess: string;
};
shop: {
title: string;
subtitle: string;
comingSoon: string;
addToCart: string;
price: string;
outOfStock: string;
viewDetails: string;
};
memberships: {
title: string;
subtitle: string;
description: string;
selectPlan: string;
perMonth: string;
perYear: string;
mostPopular: string;
choosePlan: string;
};
affiliateLinks: {
title: string;
subtitle: string;
description: string;
visitLink: string;
partnerLinks: string;
};
aboutUs: {
title: string;
subtitle: string;
description: string;
ourTeam: string;
ourMission: string;
};
news: {
title: string;
subtitle: string;
readMore: string;
publishedDate: string;
category: string;
noArticles: string;
loadMore: string;
};
// ─── Coffee ABO ────────────────────────────────────────
coffeeSelection: {
title: string;
subtitle: string;
selectYourCoffees: string;
capsuleTarget: string;
planLabel: string;
yourSelection: string;
totalCapsules: string;
totalPacks: string;
targetPacks: string;
selectUpTo: string;
goToSummary: string;
loading: string;
noProducts: string;
validationExact: string;
packOf10: string;
pricePerPack: string;
};
coffeeSummary: {
title: string;
backToSelection: string;
stepSelection: string;
stepSummary: string;
yourDetails: string;
fillFromLoggedIn: string;
firstName: string;
lastName: string;
email: string;
street: string;
zip: string;
city: string;
country: string;
phone: string;
phoneOptional: string;
paymentMethod: string;
paymentSepa: string;
paymentCard: string;
paymentSofort: string;
invoiceByEmail: string;
invoiceAddress: string;
sameAsShipping: string;
uidNumberLabel: string;
uidNumberPlaceholder: string;
uidNumberHint: string;
reverseChargeHint: string;
fullName: string;
contractPreview: string;
contractSubtitle: string;
openPreview: string;
contractLoading: string;
contractError: string;
contractNotAvailable: string;
pdfPreviewTitle: string;
pdfGenerating: string;
pdfError: string;
pdfNotAvailable: string;
signingCity: string;
signingCityPlaceholder: string;
signingCityRequired: string;
signatureRequired: string;
completeSubscription: string;
creating: string;
cannotSubmit: string;
yourSelection: string;
shipping: string;
freeShipping: string;
shippingLoading: string;
shippingError: string;
totalNet: string;
tax: string;
taxReverseCharge: string;
totalInclTax: string;
reverseChargeActive: string;
capsuleValidation: string;
exactlyRequired: string;
thankYouTitle: string;
thankYouMessage: string;
noSelectionFound: string;
noLoggedInData: string;
};
// ─── Account ───────────────────────────────────────────
personalMatrix: {
title: string;
subtitle: string;
description: string;
loading: string;
noData: string;
};
referralManagement: {
title: string;
subtitle: string;
createLink: string;
copyLink: string;
copiedToClipboard: string;
linkExpiry: string;
noLinks: string;
generating: string;
usesRemaining: string;
unlimited: string;
createSuccess: string;
createError: string;
};
quickactionDashboard: {
title: string;
subtitle: string;
stepLabel: string;
completed: string;
pending: string;
required: string;
verifyIdentity: string;
completeProfile: string;
setPayment: string;
startUsing: string;
allDone: string;
loading: string;
guestAccount: string;
companyAccount: string;
personalAccount: string;
loadingStatus: string;
errorLoadingAccountStatus: string;
tryAgain: string;
emailVerificationStatus: string;
statusOverview: string;
actionRequired: string;
quickActions: string;
tutorial: string;
pleaseVerifyEmailAddress: string;
resendAvailableIn: string;
requestNewCode: string;
emailVerified: string;
verifyEmail: string;
idUploaded: string;
uploadIdDocument: string;
profileCompleted: string;
signContract: string;
contractNotReady: string;
latestNews: string;
viewAll: string;
noNewsYet: string;
recent: string;
redirecting: string;
takingToDashboard: string;
pleaseWait: string;
goToDashboard: string;
backToDashboard: string;
uploading: string;
saved: string;
uploadContinue: string;
yes: string;
no: string;
dragAndDrop: string;
remove: string;
maxUploadHint: string;
statusCards: {
emailVerification: string;
idDocument: string;
additionalInfo: string;
contract: string;
verified: string;
missing: string;
uploaded: string;
signed: string;
};
emailVerify: {
title: string;
sentIntro: string;
sendingIntro: string;
yourEmail: string;
enterBelow: string;
invalidCode: string;
authError: string;
emailVerifiedTitle: string;
emailVerifiedMessage: string;
verificationFailedTitle: string;
networkErrorTitle: string;
verifying: string;
verified: string;
confirmCode: string;
resendCode: string;
supportHint: string;
contactSupport: string;
verifiedRedirecting: string;
};
uploadId: {
personalTitle: string;
personalSubtitle: string;
companyTitle: string;
companySubtitle: string;
idNumber: string;
idNumberPlaceholder: string;
idNumberHint: string;
contactPersonIdNumber: string;
contactPersonIdNumberPlaceholder: string;
contactPersonIdNumberHint: string;
idType: string;
documentType: string;
selectIdType: string;
selectDocumentType: string;
expiryDate: string;
expiryDateHint: string;
backSideQuestion: string;
frontPreviewAlt: string;
backPreviewAlt: string;
primaryPreviewAlt: string;
supportingPreviewAlt: string;
clickUploadFront: string;
clickUploadBack: string;
documentsChecklistTitle: string;
clearlyVisible: string;
showCorners: string;
notExpired: string;
goodLighting: string;
bothSidesUploaded: string;
frontSideUploaded: string;
successSavedRedirecting: string;
personalUploadSuccessTitle: string;
personalUploadSuccessMessage: string;
companyUploadSuccessTitle: string;
companyUploadSuccessMessage: string;
fileTooLargeTitle: string;
fileTooLargeMessage: string;
missingInfoTitle: string;
fillRequiredFields: string;
frontSideMissingTitle: string;
frontSideMissingMessage: string;
backSideMissingTitle: string;
backSideMissingMessage: string;
authErrorTitle: string;
uploadFailedTitle: string;
uploadFailedMessage: string;
networkErrorTitle: string;
networkErrorMessage: string;
};
additionalInfo: {
title: string;
companyTitle: string;
personalInformation: string;
companyDetails: string;
bankDetails: string;
additionalInformation: string;
firstName: string;
lastName: string;
email: string;
phoneNumber: string;
dateOfBirth: string;
nationality: string;
selectNationality: string;
streetHouseNumber: string;
streetNumber: string;
postalCode: string;
city: string;
country: string;
selectCountry: string;
accountHolder: string;
iban: string;
secondPhoneOptional: string;
emergencyContactName: string;
emergencyContactPhone: string;
fullNamePlaceholder: string;
postalCodePlaceholder: string;
cityPlaceholder: string;
phonePlaceholder: string;
streetPlaceholder: string;
ibanPlaceholder: string;
companyName: string;
companyEmail: string;
companyPhone: string;
contactPerson: string;
contactPersonPhone: string;
registrationNumberOptional: string;
uidNumberOptional: string;
companyHolderPlaceholder: string;
registrationPlaceholder: string;
uidPlaceholder: string;
bicOptional: string;
bicPlaceholder: string;
contactNamePlaceholder: string;
emergencyContactNamePlaceholder: string;
additionalInfoSuccessTitle: string;
personalSuccessMessage: string;
companySuccessMessage: string;
dataSavedRedirecting: string;
saveContinue: string;
saveFailedTitle: string;
saveFailedMessage: string;
invalidDateOfBirthTitle: string;
invalidDateOfBirthMessage: string;
invalidIbanTitle: string;
invalidIbanMessage: string;
missingCountryCodeTitle: string;
missingPhoneNumberTitle: string;
invalidPhoneNumberTitle: string;
missingCountryCodeMessage: string;
phoneNumberMissingMessage: string;
validPhoneNumberMessage: string;
validSecondPhoneNumberMessage: string;
validEmergencyPhoneNumberMessage: string;
fillRequiredFields: string;
authErrorTitle: string;
authErrorMessage: string;
searchPlaceholder: string;
noResults: string;
countries: Record<string, string>;
nationalities: Record<string, string>;
};
contractSigning: {
personalTitle: string;
companyTitle: string;
personalSubtitle: string;
companySubtitle: string;
documentInformation: string;
documentPreview: string;
contractTab: string;
gdprTab: string;
openInNewTab: string;
refresh: string;
loadingPreview: string;
noContractAvailable: string;
noteTitle: string;
noteBody: string;
attentionTitle: string;
attentionBody: string;
documentLabel: string;
idLabel: string;
versionLabel: string;
jurisdictionLabel: string;
languageLabel: string;
issuerLabel: string;
addressLabel: string;
signatureSection: string;
drawSignature: string;
clear: string;
signatureHelp: string;
captured: string;
confirmations: string;
confirmContractPersonal: string;
confirmDataPersonal: string;
confirmSignaturePersonal: string;
confirmContractCompany: string;
confirmDataCompany: string;
confirmSignatureCompany: string;
noDocumentsAvailableTitle: string;
noDocumentsAvailableMessage: string;
missingInformationTitle: string;
completePrefix: string;
contractReadUnderstood: string;
privacyAccepted: string;
electronicSignatureConfirmed: string;
signatureCaptured: string;
authErrorTitle: string;
authErrorMessage: string;
contractSignedTitle: string;
personalContractSignedMessage: string;
companyContractSignedMessage: string;
signingFailedTitle: string;
signingFailedMessage: string;
contractSignedRedirecting: string;
signing: string;
signed: string;
signNow: string;
};
};
suspended: {
title: string;
message: string;
contactSupport: string;
backToLogin: string;
reason: string;
};
// ─── Admin ─────────────────────────────────────────────
adminDashboard: {
title: string;
subtitle: string;
warningTitle: string;
warningMessage: string;
accessDenied: string;
accessDeniedMessage: string;
loading: string;
totalUsers: string;
admins: string;
active: string;
pendingVerification: string;
personal: string;
company: string;
managementShortcuts: string;
managementShortcutsSubtitle: string;
matrixManagement: string;
matrixManagementDesc: string;
coffeeSubscriptions: string;
coffeeSubscriptionsDesc: string;
contractManagement: string;
contractManagementDesc: string;
dashboardManagement: string;
dashboardManagementDesc: string;
userManagement: string;
userManagementDesc: string;
userVerify: string;
userVerifyDesc: string;
financeManagement: string;
financeManagementDesc: string;
poolManagement: string;
poolManagementDesc: string;
affiliateManagement: string;
affiliateManagementDesc: string;
newsManagement: string;
newsManagementDesc: string;
devManagement: string;
devManagementDesc: string;
languageManagement: string;
languageManagementDesc: string;
moduleDisabled: string;
adminAccessRequired: string;
adminNavigation: string;
adminNavigationHelp: string;
serverStatusLogs: string;
serverStatusLogsSubtitle: string;
serverStatusLabel: string;
serverOnline: string;
serverOffline: string;
uptime: string;
cpuUsage: string;
memoryUsage: string;
autoscaledEnvironment: string;
recentErrorLogs: string;
noRecentLogs: string;
viewFullLogs: string;
};
userManagement: {
title: string;
subtitle: string;
searchPlaceholder: string;
firstName: string;
lastName: string;
email: string;
role: string;
status: string;
actions: string;
verify: string;
ban: string;
unban: string;
exportCsv: string;
noUsers: string;
loading: string;
confirmBan: string;
confirmUnban: string;
confirmVerify: string;
createdAt: string;
lastLogin: string;
userType: string;
};
languageManagement: {
title: string;
subtitle: string;
addLanguage: string;
languageCode: string;
languageName: string;
languageCodePlaceholder: string;
languageNamePlaceholder: string;
addBtn: string;
deleteLanguage: string;
deleteConfirm: string;
deleteWarning: string;
saveChanges: string;
saved: string;
unsavedChanges: string;
saveNow: string;
translationProgress: string;
keysTranslated: string;
backToAdmin: string;
searchPlaceholder: string;
noKeysMatch: string;
englishReference: string;
clearOverride: string;
invalidCode: string;
codeDuplicate: string;
codeRequired: string;
nameRequired: string;
};
contractManagement: {
title: string;
subtitle: string;
uploadTemplate: string;
currentTemplate: string;
noTemplate: string;
previewTemplate: string;
saveTemplate: string;
loading: string;
uploadSuccess: string;
uploadError: string;
};
userDetailModal: {
error: string;
personal: string;
company: string;
superAdmin: string;
currentStatus: string;
adminControls: string;
missingDocumentsWarning: string;
missingStorageWarning: string;
changeStatus: string;
adminVerification: string;
allStepsCompleted: string;
stepsNotCompleted: string;
updating: string;
unverifyUser: string;
verifyUser: string;
contractPreview: string;
contractTab: string;
gdprTab: string;
loadingPreview: string;
preview: string;
openInNewTab: string;
filesIn: string;
refreshing: string;
refresh: string;
loadingFiles: string;
noFilesFound: string;
selected: string;
moving: string;
moveTo: string;
loadingPreviewText: string;
clickPreviewHint: string;
personalInformation: string;
firstName: string;
lastName: string;
phone: string;
dateOfBirth: string;
address: string;
companyInformation: string;
companyName: string;
registrationNumber: string;
taxId: string;
registrationProgress: string;
emailVerified: string;
profileCompleted: string;
documentsUploaded: string;
contractSigned: string;
permissions: string;
savingPermissions: string;
savePermissions: string;
loadingPermissions: string;
noPermissionsAvailable: string;
inactive: string;
close: string;
moveDocumentTitle: string;
moveDocumentDescription: string;
moveDocumentConfirm: string;
moveDocumentFile: string;
completeStepsTooltip: string;
};
invoiceDetailModal: {
invoiceTitle: string;
statusDraft: string;
statusIssued: string;
statusPaid: string;
statusOverdue: string;
statusCanceled: string;
changeStatus: string;
updatingStatus: string;
created: string;
customer: string;
name: string;
email: string;
street: string;
city: string;
country: string;
userId: string;
financials: string;
net: string;
tax: string;
gross: string;
vatRate: string;
currency: string;
dates: string;
issued: string;
due: string;
updated: string;
lineItems: string;
noLineItems: string;
description: string;
qty: string;
unitPrice: string;
total: string;
payments: string;
method: string;
transaction: string;
amount: string;
paidAt: string;
status: string;
contextMetadata: string;
clickToExpand: string;
exportJson: string;
poolCheck: string;
close: string;
poolErrorPrefix: string;
poolInflowsBooked: string;
statusUpdatedTo: string;
reasonInvalidInvoiceId: string;
reasonInvoiceNotFound: string;
reasonInvoiceNotPaid: string;
reasonUnsupportedSourceType: string;
reasonMissingAbonementRelation: string;
reasonAbonementNotFound: string;
reasonNoBreakdownLines: string;
reasonNoActivePools: string;
};
// ─── Notifications / Toasts ────────────────────────────
toasts: {
loginSuccess: string;
loginSuccessMessage: string;
loginFailed: string;
loginFailedMessage: string;
registerSuccess: string;
registerSuccessMessage: string;
registerFailed: string;
registerFailedMessage: string;
invitationVerified: string;
invitationVerifiedMessage: string;
invalidInvitation: string;
invalidInvitationMessage: string;
networkError: string;
networkErrorMessage: string;
saveSuccess: string;
saveFailed: string;
copySuccess: string;
copyFailed: string;
deleteSuccess: string;
deleteFailed: string;
genericError: string;
};
}

View File

@ -1,19 +1,22 @@
'use client';
import { createContext, useContext, useState, ReactNode } from 'react';
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
import { Language, DEFAULT_LANGUAGE } from './config';
import { en } from './translations/en';
import { de } from './translations/de';
import { flattenObject, loadCustomI18n, type CustomI18nData } from './dynamicTranslations';
const translations = {
en,
de
} as const;
const builtInTranslations: Record<string, Record<string, any>> = { en, de };
// Flat map of English keys used as canonical key list and fallback
const enFlat = flattenObject(en as Record<string, any>);
interface I18nContextType {
language: Language;
setLanguage: (lang: Language) => void;
language: string;
setLanguage: (lang: string) => void;
t: (key: string) => string;
customI18n: CustomI18nData;
reloadCustomI18n: () => void;
}
const I18nContext = createContext<I18nContextType | undefined>(undefined);
@ -23,21 +26,39 @@ interface I18nProviderProps {
}
export function I18nProvider({ children }: I18nProviderProps) {
const [language, setLanguage] = useState<Language>(DEFAULT_LANGUAGE);
const [language, setLanguage] = useState<string>(DEFAULT_LANGUAGE);
const [customI18n, setCustomI18n] = useState<CustomI18nData>({ languages: [], translations: {} });
const t = (key: string): string => {
const keys = key.split('.');
let value: any = translations[language];
const reloadCustomI18n = useCallback(() => {
setCustomI18n(loadCustomI18n());
}, []);
for (const k of keys) {
value = value?.[k];
useEffect(() => {
reloadCustomI18n();
}, [reloadCustomI18n]);
const t = useCallback((key: string): string => {
// 1. Check custom translation overrides for this language
const customOverride = customI18n.translations[language]?.[key];
if (customOverride !== undefined && customOverride !== '') return customOverride;
// 2. Check built-in translations (nested lookup)
const builtIn = builtInTranslations[language];
if (builtIn) {
const keys = key.split('.');
let value: any = builtIn;
for (const k of keys) {
value = value?.[k];
}
if (typeof value === 'string') return value;
}
return typeof value === 'string' ? value : key;
};
// 3. Fallback to English (flat map)
return enFlat[key] ?? key;
}, [language, customI18n]);
return (
<I18nContext.Provider value={{ language, setLanguage, t }}>
<I18nContext.Provider value={{ language, setLanguage, t, customI18n, reloadCustomI18n }}>
{children}
</I18nContext.Provider>
);
@ -50,3 +71,20 @@ export function useTranslation() {
}
return context;
}
/** Returns all known translation keys (from English as source of truth) */
export function getAllTranslationKeys(): string[] {
return Object.keys(enFlat);
}
/** Returns the English value for a key (used as reference in admin UI) */
export function getEnglishValue(key: string): string {
return enFlat[key] ?? key;
}
/** Returns flat translations for a built-in language */
export function getBuiltInFlatTranslations(langCode: string): Record<string, string> {
const builtIn = builtInTranslations[langCode];
if (!builtIn) return {};
return flattenObject(builtIn);
}

View File

@ -7,6 +7,7 @@ import PageLayout from '../components/PageLayout'
import TutorialModal, { createTutorialSteps } from '../components/TutorialModal'
import useAuthStore from '../store/authStore'
import { useUserStatus } from '../hooks/useUserStatus'
import { useTranslation } from '../i18n/useTranslation'
import BlueBlurryBackground from '../components/background/blueblurry' // NEW
import {
CheckCircleIcon,
@ -41,6 +42,7 @@ type LatestNewsItem = {
export default function QuickActionDashboardPage() {
const router = useRouter()
const { t } = useTranslation()
const user = useAuthStore(s => s.user)
const isAuthReady = useAuthStore(s => (s as any).isAuthReady) // NEW
const accessToken = useAuthStore(s => s.accessToken) // NEW
@ -71,18 +73,18 @@ export default function QuickActionDashboardPage() {
try {
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
const res = await fetch(`${BASE_URL}/api/news/active`)
if (!res.ok) throw new Error('Failed to fetch news')
if (!res.ok) throw new Error(t('quickactionDashboard.noNewsYet'))
const json = await res.json()
const data = Array.isArray(json.data) ? json.data : []
if (active) setLatestNews(data.slice(0, 3))
} catch (e: any) {
if (active) setNewsError(e?.message || 'Failed to load news')
if (active) setNewsError(e?.message || t('quickactionDashboard.noNewsYet'))
} finally {
if (active) setNewsLoading(false)
}
})()
return () => { active = false }
}, [])
}, [t])
// Derive status from real backend data
const emailVerified = userStatus?.email_verified || false
@ -177,8 +179,8 @@ export default function QuickActionDashboardPage() {
? [
{
key: 'email',
label: 'Email Verification',
description: emailVerified ? 'Verified' : 'Missing',
label: t('quickactionDashboard.statusCards.emailVerification'),
description: emailVerified ? t('quickactionDashboard.statusCards.verified') : t('quickactionDashboard.statusCards.missing'),
complete: emailVerified,
icon: EnvelopeOpenIcon
}
@ -186,29 +188,29 @@ export default function QuickActionDashboardPage() {
: [
{
key: 'email',
label: 'Email Verification',
description: emailVerified ? 'Verified' : 'Missing',
label: t('quickactionDashboard.statusCards.emailVerification'),
description: emailVerified ? t('quickactionDashboard.statusCards.verified') : t('quickactionDashboard.statusCards.missing'),
complete: emailVerified,
icon: EnvelopeOpenIcon
},
{
key: 'id',
label: 'ID Document',
description: idUploaded ? 'Uploaded' : 'Missing',
label: t('quickactionDashboard.statusCards.idDocument'),
description: idUploaded ? t('quickactionDashboard.statusCards.uploaded') : t('quickactionDashboard.statusCards.missing'),
complete: idUploaded,
icon: IdentificationIcon
},
{
key: 'info',
label: 'Additional Info',
description: additionalInfo ? 'Completed' : 'Missing',
label: t('quickactionDashboard.statusCards.additionalInfo'),
description: additionalInfo ? t('quickactionDashboard.completed') : t('quickactionDashboard.statusCards.missing'),
complete: additionalInfo,
icon: InformationCircleIcon
},
{
key: 'contract',
label: 'Contract',
description: contractSigned ? 'Signed' : 'Missing',
label: t('quickactionDashboard.statusCards.contract'),
description: contractSigned ? t('quickactionDashboard.statusCards.signed') : t('quickactionDashboard.statusCards.missing'),
complete: contractSigned,
icon: DocumentCheckIcon
}
@ -315,8 +317,8 @@ export default function QuickActionDashboardPage() {
{redirectTo && (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-white/70 backdrop-blur-sm transition-opacity duration-200 opacity-100">
<div className="rounded-xl bg-white px-5 py-4 shadow ring-1 ring-black/5">
<div className="text-sm font-medium text-gray-900">Redirecting</div>
<div className="mt-1 text-xs text-gray-600">Taking you to your dashboard</div>
<div className="text-sm font-medium text-gray-900">{t('quickactionDashboard.redirecting')}</div>
<div className="mt-1 text-xs text-gray-600">{t('quickactionDashboard.takingToDashboard')}</div>
</div>
</div>
)}
@ -326,16 +328,16 @@ export default function QuickActionDashboardPage() {
{/* Title */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">
Welcome{isClient && user?.firstName ? `, ${user.firstName}` : ''}!
{t('dashboard.welcomeBack')}{isClient && user?.firstName ? `, ${user.firstName}` : ''}!
</h1>
<p className="text-sm sm:text-base text-gray-600 mt-2">
{isGuest
? 'Guest Account'
? t('quickactionDashboard.guestAccount')
: isClient && user?.userType === 'company'
? 'Company Account'
: 'Personal Account'}
? t('quickactionDashboard.companyAccount')
: t('quickactionDashboard.personalAccount')}
</p>
{loading && <p className="text-xs text-gray-500 mt-1">Loading status...</p>}
{loading && <p className="text-xs text-gray-500 mt-1">{t('quickactionDashboard.loadingStatus')}</p>}
{error && (
<div className="mt-4 max-w-md rounded-md bg-red-50 border border-red-200 px-4 py-3">
<div className="flex">
@ -346,7 +348,7 @@ export default function QuickActionDashboardPage() {
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">
Error loading account status
{t('quickactionDashboard.errorLoadingAccountStatus')}
</h3>
<div className="mt-2 text-sm text-red-700">
<p>{error}</p>
@ -356,7 +358,7 @@ export default function QuickActionDashboardPage() {
onClick={() => refreshStatus()}
className="text-sm bg-red-100 text-red-800 px-3 py-1 rounded-md hover:bg-red-200 transition-colors"
>
Try again
{t('quickactionDashboard.tryAgain')}
</button>
</div>
</div>
@ -370,7 +372,7 @@ export default function QuickActionDashboardPage() {
{/* Status Overview */}
<div className="bg-white rounded-xl shadow-sm ring-1 ring-gray-200 p-5 sm:p-8">
<h2 className="text-sm sm:text-base font-semibold text-gray-900 mb-5">
{isGuest ? 'Email Verification Status' : 'Status Overview'}
{isGuest ? t('quickactionDashboard.emailVerificationStatus') : t('quickactionDashboard.statusOverview')}
</h2>
{/* Guest: single centered card. Regular: 2x2 / 4-col grid */}
@ -420,7 +422,7 @@ export default function QuickActionDashboardPage() {
i
</span>
<h2 className="text-sm sm:text-base font-semibold text-gray-900">
{isGuest ? 'Action Required' : 'Quick Actions'}
{isGuest ? t('quickactionDashboard.actionRequired') : t('quickactionDashboard.quickActions')}
</h2>
</div>
{/* Tutorial button — only for regular users */}
@ -430,7 +432,7 @@ export default function QuickActionDashboardPage() {
className="relative inline-flex items-center gap-2 rounded-md bg-blue-50 px-3 py-2 text-sm font-medium text-blue-700 hover:bg-blue-100 transition-colors"
>
<AcademicCapIcon className="h-4 w-4" />
Tutorial
{t('quickactionDashboard.tutorial')}
{!hasSeenTutorial && (
<span className="absolute -top-1 -right-1 h-3 w-3 bg-red-500 rounded-full animate-pulse" />
)}
@ -443,7 +445,7 @@ export default function QuickActionDashboardPage() {
<div className="flex flex-col items-center text-center">
<div className="max-w-sm w-full">
<p className="text-sm text-gray-600 mb-6">
Please verify your email address to activate your guest account and access your subscriptions.
{t('quickactionDashboard.pleaseVerifyEmailAddress')}
</p>
<button
onClick={handleVerifyEmail}
@ -455,13 +457,13 @@ export default function QuickActionDashboardPage() {
}`}
>
<EnvelopeOpenIcon className="h-6 w-6 sm:h-8 sm:w-8 mb-3" />
{emailVerified ? 'Email Verified ✓' : 'Verify Email'}
{emailVerified ? `${t('quickactionDashboard.emailVerified')}` : t('quickactionDashboard.verifyEmail')}
</button>
{!emailVerified && (
<p className="mt-3 text-xs text-[#112c55]">
{resendRemainingSec > 0
? `Resend available in ${formatMmSs(resendRemainingSec)}`
: 'You can request a new code now'}
? `${t('quickactionDashboard.resendAvailableIn')} ${formatMmSs(resendRemainingSec)}`
: t('quickactionDashboard.requestNewCode')}
</p>
)}
</div>
@ -481,13 +483,13 @@ export default function QuickActionDashboardPage() {
}`}
>
<EnvelopeOpenIcon className="h-5 w-5 sm:h-6 sm:w-6 mb-2" />
{emailVerified ? 'Email Verified' : 'Verify Email'}
{emailVerified ? t('quickactionDashboard.emailVerified') : t('quickactionDashboard.verifyEmail')}
</button>
{!emailVerified && (
<p className="mt-2 text-[11px] text-[#112c55] text-center">
{resendRemainingSec > 0
? `Resend available in ${formatMmSs(resendRemainingSec)}`
: 'You can request a new code now'}
? `${t('quickactionDashboard.resendAvailableIn')} ${formatMmSs(resendRemainingSec)}`
: t('quickactionDashboard.requestNewCode')}
</p>
)}
</div>
@ -503,7 +505,7 @@ export default function QuickActionDashboardPage() {
}`}
>
<ArrowUpOnSquareIcon className="h-5 w-5 sm:h-6 sm:w-6 mb-2" />
{idUploaded ? 'ID Uploaded' : 'Upload ID Document'}
{idUploaded ? t('quickactionDashboard.idUploaded') : t('quickactionDashboard.uploadIdDocument')}
</button>
{/* Additional Info */}
@ -517,7 +519,7 @@ export default function QuickActionDashboardPage() {
}`}
>
<PencilSquareIcon className="h-5 w-5 sm:h-6 sm:w-6 mb-2" />
{additionalInfo ? 'Profile Completed' : 'Complete Profile'}
{additionalInfo ? t('quickactionDashboard.profileCompleted') : t('quickactionDashboard.completeProfile')}
</button>
{/* Sign Contract */}
@ -534,11 +536,11 @@ export default function QuickActionDashboardPage() {
}`}
>
<ClipboardDocumentCheckIcon className="h-5 w-5 sm:h-6 sm:w-6 mb-2" />
{contractSigned ? 'Contract Signed' : 'Sign Contract'}
{contractSigned ? t('quickactionDashboard.statusCards.signed') : t('quickactionDashboard.signContract')}
</button>
{!canSignContract && !contractSigned && (
<p className="mt-2 text-[11px] text-red-600 leading-snug text-center">
Complete previous steps (email, ID, profile) before signing the contract.
{t('quickactionDashboard.contractNotReady')}
</p>
)}
</div>
@ -550,10 +552,10 @@ export default function QuickActionDashboardPage() {
<div className="bg-white rounded-xl shadow-sm ring-1 ring-gray-200 p-5 sm:p-8">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm sm:text-base font-semibold text-gray-900">
Latest News
{t('quickactionDashboard.latestNews')}
</h2>
<Link href="/news" className="text-xs sm:text-sm font-medium text-blue-900 hover:text-blue-700">
View all
{t('quickactionDashboard.viewAll')}
</Link>
</div>
@ -573,7 +575,7 @@ export default function QuickActionDashboardPage() {
)}
{!newsLoading && !newsError && latestNews.length === 0 && (
<div className="text-sm text-gray-600">No news yet.</div>
<div className="text-sm text-gray-600">{t('quickactionDashboard.noNewsYet')}</div>
)}
{!newsLoading && !newsError && latestNews.length > 0 && (
@ -582,7 +584,7 @@ export default function QuickActionDashboardPage() {
<li key={item.id} className="group">
<Link href={`/news/${item.slug}`} className="block">
<div className="text-xs text-gray-500">
{item.published_at ? new Date(item.published_at).toLocaleDateString('de-DE') : 'Recent'}
{item.published_at ? new Date(item.published_at).toLocaleDateString('de-DE') : t('quickactionDashboard.recent')}
</div>
<div className="text-sm font-semibold text-blue-900 group-hover:text-blue-700 line-clamp-2">
{item.title}

View File

@ -6,6 +6,7 @@ import PageLayout from '../../../components/PageLayout'
import useAuthStore from '../../../store/authStore'
import { useUserStatus } from '../../../hooks/useUserStatus'
import { useToast } from '../../../components/toast/toastComponent'
import { useTranslation } from '../../../i18n/useTranslation'
import { ChevronDownIcon } from '@heroicons/react/20/solid' // NEW
import TelephoneInput, { TelephoneInputHandle } from '../../../components/phone/telephoneInput'
@ -186,6 +187,7 @@ export default function CompanyAdditionalInformationPage() {
const { accessToken } = useAuthStore()
const { userStatus, loading: statusLoading, refreshStatus } = useUserStatus()
const { showToast } = useToast()
const { t } = useTranslation()
const companyPhoneRef = useRef<TelephoneInputHandle | null>(null)
const contactPhoneRef = useRef<TelephoneInputHandle | null>(null)
const secondPhoneRef = useRef<TelephoneInputHandle | null>(null)
@ -288,11 +290,11 @@ export default function CompanyAdditionalInformationPage() {
]
for (const k of required) {
if (!form[k].trim()) {
const msg = 'Bitte alle Pflichtfelder ausfüllen.'
const msg = t('quickactionDashboard.additionalInfo.fillRequiredFields')
setError(msg)
showToast({
variant: 'error',
title: 'Missing information',
title: t('quickactionDashboard.additionalInfo.missingCountryCodeTitle'),
message: msg,
})
return false
@ -309,31 +311,31 @@ export default function CompanyAdditionalInformationPage() {
const contactValid = contactApi?.isValid() ?? false
if (!companyDialCode || !contactDialCode) {
const msg = 'Please select country codes for company and contact phone numbers.'
const msg = t('quickactionDashboard.additionalInfo.missingCountryCodeMessage')
setError(msg)
showToast({
variant: 'error',
title: 'Missing country code',
title: t('quickactionDashboard.additionalInfo.missingCountryCodeTitle'),
message: msg,
})
return false
}
if (!companyNumber || !contactNumber) {
const msg = 'Please enter both company and contact phone numbers.'
const msg = t('quickactionDashboard.additionalInfo.phoneNumberMissingMessage')
setError(msg)
showToast({
variant: 'error',
title: 'Missing phone numbers',
title: t('quickactionDashboard.additionalInfo.missingPhoneNumberTitle'),
message: msg,
})
return false
}
if (!companyValid || !contactValid) {
const msg = 'Please enter valid phone numbers for company and contact person.'
const msg = t('quickactionDashboard.additionalInfo.validPhoneNumberMessage')
setError(msg)
showToast({
variant: 'error',
title: 'Invalid phone numbers',
title: t('quickactionDashboard.additionalInfo.invalidPhoneNumberTitle'),
message: msg,
})
return false
@ -344,11 +346,11 @@ export default function CompanyAdditionalInformationPage() {
const secondApi = secondPhoneRef.current
const ok = secondApi?.isValid?.() ?? false
if (!ok) {
const msg = 'Please enter a valid second phone number.'
const msg = t('quickactionDashboard.additionalInfo.validSecondPhoneNumberMessage')
setError(msg)
showToast({
variant: 'error',
title: 'Invalid phone number',
title: t('quickactionDashboard.additionalInfo.invalidPhoneNumberTitle'),
message: msg,
})
return false
@ -359,22 +361,22 @@ export default function CompanyAdditionalInformationPage() {
const emergencyApi = emergencyPhoneRef.current
const ok = emergencyApi?.isValid?.() ?? false
if (!ok) {
const msg = 'Please enter a valid emergency phone number.'
const msg = t('quickactionDashboard.additionalInfo.validEmergencyPhoneNumberMessage')
setError(msg)
showToast({
variant: 'error',
title: 'Invalid phone number',
title: t('quickactionDashboard.additionalInfo.invalidPhoneNumberTitle'),
message: msg,
})
return false
}
}
if (!/^([A-Z]{2}\d{2}[A-Z0-9]{10,30})$/i.test(form.iban.replace(/\s+/g,''))) {
const msg = 'Ungültige IBAN.'
const msg = t('quickactionDashboard.additionalInfo.invalidIbanMessage')
setError(msg)
showToast({
variant: 'error',
title: 'Invalid IBAN',
title: t('quickactionDashboard.additionalInfo.invalidIbanTitle'),
message: msg,
})
return false
@ -389,11 +391,11 @@ export default function CompanyAdditionalInformationPage() {
if (!validate()) return
if (!accessToken) {
const msg = 'Not authenticated. Please log in again.'
const msg = t('quickactionDashboard.additionalInfo.authErrorMessage')
setError(msg)
showToast({
variant: 'error',
title: 'Authentication error',
title: t('quickactionDashboard.additionalInfo.authErrorTitle'),
message: msg,
})
return
@ -447,8 +449,8 @@ export default function CompanyAdditionalInformationPage() {
setSuccess(true)
showToast({
variant: 'success',
title: 'Profile saved',
message: 'Your company profile has been saved successfully.',
title: t('quickactionDashboard.additionalInfo.additionalInfoSuccessTitle'),
message: t('quickactionDashboard.additionalInfo.companySuccessMessage'),
})
// Refresh user status to update profile completion state
@ -462,11 +464,11 @@ export default function CompanyAdditionalInformationPage() {
} catch (error: any) {
console.error('Company profile save error:', error)
const msg = error.message || 'Speichern fehlgeschlagen.'
const msg = error.message || t('quickactionDashboard.additionalInfo.saveFailedMessage')
setError(msg)
showToast({
variant: 'error',
title: 'Save failed',
title: t('quickactionDashboard.additionalInfo.saveFailedTitle'),
message: msg,
})
} finally {
@ -485,8 +487,8 @@ export default function CompanyAdditionalInformationPage() {
{redirectTo && (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-white/70 backdrop-blur-sm transition-opacity duration-200 opacity-100">
<div className="rounded-xl bg-white px-5 py-4 shadow ring-1 ring-black/5">
<div className="text-sm font-medium text-gray-900">Redirecting</div>
<div className="mt-1 text-xs text-gray-600">Please wait</div>
<div className="text-sm font-medium text-gray-900">{t('quickactionDashboard.redirecting')}</div>
<div className="mt-1 text-xs text-gray-600">{t('quickactionDashboard.pleaseWait')}</div>
</div>
</div>
)}
@ -509,18 +511,18 @@ export default function CompanyAdditionalInformationPage() {
>
<div className="px-6 py-8 sm:px-10 lg:px-16">
<h1 className="text-center text-xl sm:text-2xl font-semibold text-[#0F172A] mb-6">
Complete Company Profile
{t('quickactionDashboard.additionalInfo.companyTitle')}
</h1>
{/* Company Details */}
<section>
<h2 className="text-sm font-semibold text-[#0F2460] mb-4">
Company Details
{t('quickactionDashboard.additionalInfo.companyDetails')}
</h2>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
<div className="sm:col-span-2 lg:col-span-3">
<label className="block text-sm font-medium text-gray-700 mb-1">
Company Name *
{t('quickactionDashboard.additionalInfo.companyName')}
</label>
<input
name="companyName"
@ -532,7 +534,7 @@ export default function CompanyAdditionalInformationPage() {
</div>
<div className="sm:col-span-2 lg:col-span-3">
<label className="block text-sm font-medium text-gray-700 mb-1">
Company Email *
{t('quickactionDashboard.additionalInfo.companyEmail')}
</label>
<input
name="companyEmail"
@ -545,7 +547,7 @@ export default function CompanyAdditionalInformationPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Company Phone *
{t('quickactionDashboard.additionalInfo.companyPhone')}
</label>
<TelephoneInput
name="companyPhone"
@ -559,7 +561,7 @@ export default function CompanyAdditionalInformationPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Contact Person *
{t('quickactionDashboard.additionalInfo.contactPerson')}
</label>
<input
name="contactPersonName"
@ -571,7 +573,7 @@ export default function CompanyAdditionalInformationPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Contact Person Phone *
{t('quickactionDashboard.additionalInfo.contactPersonPhone')}
</label>
<TelephoneInput
name="contactPersonPhone"
@ -585,7 +587,7 @@ export default function CompanyAdditionalInformationPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Registration Number (optional)
{t('quickactionDashboard.additionalInfo.registrationNumberOptional')}
</label>
<input
name="registrationNumber"
@ -597,7 +599,7 @@ export default function CompanyAdditionalInformationPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
UID Number (optional)
{t('quickactionDashboard.additionalInfo.uidNumberOptional')}
</label>
<input
name="uidNumber"
@ -609,7 +611,7 @@ export default function CompanyAdditionalInformationPage() {
</div>
<div className="sm:col-span-2 lg:col-span-3">
<label className="block text-sm font-medium text-gray-700 mb-1">
Street & Number *
{t('quickactionDashboard.additionalInfo.streetNumber')}
</label>
<input
name="street"
@ -621,7 +623,7 @@ export default function CompanyAdditionalInformationPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Postal Code *
{t('quickactionDashboard.additionalInfo.postalCode')}
</label>
<input
name="postalCode"
@ -633,7 +635,7 @@ export default function CompanyAdditionalInformationPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
City *
{t('quickactionDashboard.additionalInfo.city')}
</label>
<input
name="city"
@ -645,8 +647,8 @@ export default function CompanyAdditionalInformationPage() {
</div>
<div>
<ModernSelect
label="Country"
placeholder="Select country..."
label={t('quickactionDashboard.additionalInfo.country')}
placeholder={t('quickactionDashboard.additionalInfo.selectCountry')}
value={form.country}
onChange={(v) => setField('country', v)}
options={COUNTRIES.map(c => ({ value: c, label: c }))}
@ -660,12 +662,12 @@ export default function CompanyAdditionalInformationPage() {
{/* Bank Details */}
<section>
<h2 className="text-sm font-semibold text-[#0F2460] mb-4">
Bank Details
{t('quickactionDashboard.additionalInfo.bankDetails')}
</h2>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
<div className="sm:col-span-2 lg:col-span-3">
<label className="block text-sm font-medium text-gray-700 mb-1">
Account Holder *
{t('quickactionDashboard.additionalInfo.accountHolder')}
</label>
<input
name="accountHolder"
@ -678,7 +680,7 @@ export default function CompanyAdditionalInformationPage() {
</div>
<div className="sm:col-span-2 lg:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
IBAN *
{t('quickactionDashboard.additionalInfo.iban')}
</label>
<input
name="iban"
@ -691,7 +693,7 @@ export default function CompanyAdditionalInformationPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
BIC (optional)
{t('quickactionDashboard.additionalInfo.bicOptional')}
</label>
<input
name="bic"
@ -709,12 +711,12 @@ export default function CompanyAdditionalInformationPage() {
{/* Additional Information */}
<section>
<h2 className="text-sm font-semibold text-[#0F2460] mb-4">
Additional Information
{t('quickactionDashboard.additionalInfo.additionalInformation')}
</h2>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
<div className="sm:col-span-2 lg:col-span-3">
<label className="block text-sm font-medium text-gray-700 mb-1">
Second Phone (optional)
{t('quickactionDashboard.additionalInfo.secondPhoneOptional')}
</label>
<TelephoneInput
name="secondPhone"
@ -727,7 +729,7 @@ export default function CompanyAdditionalInformationPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Emergency Contact Name
{t('quickactionDashboard.additionalInfo.emergencyContactName')}
</label>
<input
name="emergencyName"
@ -739,7 +741,7 @@ export default function CompanyAdditionalInformationPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Emergency Contact Phone
{t('quickactionDashboard.additionalInfo.emergencyContactPhone')}
</label>
<TelephoneInput
name="emergencyPhone"
@ -761,7 +763,7 @@ export default function CompanyAdditionalInformationPage() {
)}
{success && (
<div className="mt-6 rounded-md border border-green-300 bg-green-50 px-4 py-3 text-xs text-green-700">
Data saved. Redirecting shortly
{t('quickactionDashboard.additionalInfo.dataSavedRedirecting')}
</div>
)}
@ -771,7 +773,7 @@ export default function CompanyAdditionalInformationPage() {
onClick={() => router.push('/quickaction-dashboard')}
className="inline-flex items-center rounded-md border border-[#8D6B1D]/40 px-4 py-2 text-sm font-semibold text-[#8D6B1D] bg-white hover:bg-[#8D6B1D]/10"
>
Back to Dashboard
{t('quickactionDashboard.backToDashboard')}
</button>
<button
@ -779,7 +781,7 @@ export default function CompanyAdditionalInformationPage() {
disabled={loading || success}
className="inline-flex items-center rounded-md bg-[#8D6B1D] px-6 py-2.5 text-sm font-semibold text-white shadow hover:bg-[#7A5E1A] focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{loading ? 'Speichern…' : success ? 'Gespeichert' : 'Save & Continue'}
{loading ? t('common.saving') : success ? t('common.saved') : t('quickactionDashboard.additionalInfo.saveContinue')}
</button>
</div>
</div>

View File

@ -6,6 +6,7 @@ import PageLayout from '../../../components/PageLayout'
import useAuthStore from '../../../store/authStore'
import { useUserStatus } from '../../../hooks/useUserStatus'
import { useToast } from '../../../components/toast/toastComponent'
import { useTranslation } from '../../../i18n/useTranslation'
import { ChevronDownIcon } from '@heroicons/react/20/solid'
import TelephoneInput, { TelephoneInputHandle } from '../../../components/phone/telephoneInput'
@ -27,25 +28,35 @@ interface PersonalProfileData {
emergencyPhone: string
}
// Common nationalities list
const NATIONALITIES = [
'German', 'Austrian', 'Swiss', 'Italian', 'French', 'Spanish', 'Portuguese', 'Dutch',
'Belgian', 'Polish', 'Czech', 'Hungarian', 'Croatian', 'Slovenian', 'Slovak',
'British', 'Irish', 'Swedish', 'Norwegian', 'Danish', 'Finnish', 'Russian',
'Turkish', 'Greek', 'Romanian', 'Bulgarian', 'Serbian', 'Albanian', 'Bosnian',
'American', 'Canadian', 'Brazilian', 'Argentinian', 'Mexican', 'Chinese',
'Japanese', 'Indian', 'Pakistani', 'Australian', 'South African', 'Other'
]
const NATIONALITY_CODES = [
'german', 'austrian', 'swiss', 'italian', 'french', 'spanish', 'portuguese', 'dutch',
'belgian', 'polish', 'czech', 'hungarian', 'croatian', 'slovenian', 'slovak',
'british', 'irish', 'swedish', 'norwegian', 'danish', 'finnish', 'russian',
'turkish', 'greek', 'romanian', 'bulgarian', 'serbian', 'albanian', 'bosnian',
'american', 'canadian', 'brazilian', 'argentinian', 'mexican', 'chinese',
'japanese', 'indian', 'pakistani', 'australian', 'southAfrican', 'other'
] as const
// Common countries list
const COUNTRIES = [
'Germany', 'Austria', 'Switzerland', 'Italy', 'France', 'Spain', 'Portugal', 'Netherlands',
'Belgium', 'Poland', 'Czech Republic', 'Hungary', 'Croatia', 'Slovenia', 'Slovakia',
'United Kingdom', 'Ireland', 'Sweden', 'Norway', 'Denmark', 'Finland', 'Russia',
'Turkey', 'Greece', 'Romania', 'Bulgaria', 'Serbia', 'Albania', 'Bosnia and Herzegovina',
'United States', 'Canada', 'Brazil', 'Argentina', 'Mexico', 'China', 'Japan',
'India', 'Pakistan', 'Australia', 'South Africa', 'Other'
]
const COUNTRY_CODES = [
'germany', 'austria', 'switzerland', 'italy', 'france', 'spain', 'portugal', 'netherlands',
'belgium', 'poland', 'czechRepublic', 'hungary', 'croatia', 'slovenia', 'slovakia',
'unitedKingdom', 'ireland', 'sweden', 'norway', 'denmark', 'finland', 'russia',
'turkey', 'greece', 'romania', 'bulgaria', 'serbia', 'albania', 'bosniaHerzegovina',
'unitedStates', 'canada', 'brazil', 'argentina', 'mexico', 'china', 'japan',
'india', 'pakistan', 'australia', 'southAfrica', 'other'
] as const
const NATIONALITY_VALUE_BY_CODE: Record<(typeof NATIONALITY_CODES)[number], string> = {
german: 'German', austrian: 'Austrian', swiss: 'Swiss', italian: 'Italian', french: 'French', spanish: 'Spanish', portuguese: 'Portuguese', dutch: 'Dutch', belgian: 'Belgian', polish: 'Polish', czech: 'Czech', hungarian: 'Hungarian', croatian: 'Croatian', slovenian: 'Slovenian', slovak: 'Slovak', british: 'British', irish: 'Irish', swedish: 'Swedish', norwegian: 'Norwegian', danish: 'Danish', finnish: 'Finnish', russian: 'Russian', turkish: 'Turkish', greek: 'Greek', romanian: 'Romanian', bulgarian: 'Bulgarian', serbian: 'Serbian', albanian: 'Albanian', bosnian: 'Bosnian', american: 'American', canadian: 'Canadian', brazilian: 'Brazilian', argentinian: 'Argentinian', mexican: 'Mexican', chinese: 'Chinese', japanese: 'Japanese', indian: 'Indian', pakistani: 'Pakistani', australian: 'Australian', southAfrican: 'South African', other: 'Other'
}
const COUNTRY_VALUE_BY_CODE: Record<(typeof COUNTRY_CODES)[number], string> = {
germany: 'Germany', austria: 'Austria', switzerland: 'Switzerland', italy: 'Italy', france: 'France', spain: 'Spain', portugal: 'Portugal', netherlands: 'Netherlands', belgium: 'Belgium', poland: 'Poland', czechRepublic: 'Czech Republic', hungary: 'Hungary', croatia: 'Croatia', slovenia: 'Slovenia', slovakia: 'Slovakia', unitedKingdom: 'United Kingdom', ireland: 'Ireland', sweden: 'Sweden', norway: 'Norway', denmark: 'Denmark', finland: 'Finland', russia: 'Russia', turkey: 'Turkey', greece: 'Greece', romania: 'Romania', bulgaria: 'Bulgaria', serbia: 'Serbia', albania: 'Albania', bosniaHerzegovina: 'Bosnia and Herzegovina', unitedStates: 'United States', canada: 'Canada', brazil: 'Brazil', argentina: 'Argentina', mexico: 'Mexico', china: 'China', japan: 'Japan', india: 'India', pakistan: 'Pakistan', australia: 'Australia', southAfrica: 'South Africa', other: 'Other'
}
const NATIONALITIES = NATIONALITY_CODES.map(code => NATIONALITY_VALUE_BY_CODE[code])
const COUNTRIES = COUNTRY_CODES.map(code => COUNTRY_VALUE_BY_CODE[code])
const normalizeOptionValue = (value: string) => value.toLowerCase().replace(/[^a-z]/g, '')
const initialData: PersonalProfileData = {
firstName: '',
@ -70,12 +81,16 @@ type SelectOption = { value: string; label: string }
function ModernSelect({
label,
placeholder = 'Select…',
searchPlaceholder = 'Search…',
noResults = 'No results',
value,
onChange,
options,
}: {
label: string
placeholder?: string
searchPlaceholder?: string
noResults?: string
value: string
onChange: (next: string) => void
options: SelectOption[]
@ -166,7 +181,7 @@ function ModernSelect({
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search…"
placeholder={searchPlaceholder}
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
autoFocus
@ -175,7 +190,7 @@ function ModernSelect({
<div className="max-h-[42vh] overflow-auto p-1">
{filtered.length === 0 ? (
<div className="px-3 py-2 text-sm text-gray-500">No results</div>
<div className="px-3 py-2 text-sm text-gray-500">{noResults}</div>
) : (
filtered.map(o => {
const active = o.value === value
@ -208,6 +223,7 @@ function ModernSelect({
export default function PersonalAdditionalInformationPage() {
const router = useRouter()
const { t } = useTranslation()
const user = useAuthStore(s => s.user) // NEW
const isAuthReady = useAuthStore(s => (s as any).isAuthReady) // NEW
const { accessToken } = useAuthStore()
@ -221,6 +237,14 @@ export default function PersonalAdditionalInformationPage() {
const [loading, setLoading] = useState(false)
const [success, setSuccess] = useState(false)
const [error, setError] = useState('')
const nationalityOptions = useMemo(
() => NATIONALITY_CODES.map(code => ({ value: code, label: t(`quickactionDashboard.additionalInfo.nationalities.${code}`) })),
[t]
)
const countryOptions = useMemo(
() => COUNTRY_CODES.map(code => ({ value: code, label: t(`quickactionDashboard.additionalInfo.countries.${code}`) })),
[t]
)
// Prefill form if profile already exists
useEffect(() => {
@ -250,11 +274,11 @@ export default function PersonalAdditionalInformationPage() {
email: user?.email || prev.email,
phone: user?.phone || profile?.phone || prev.phone,
dob: toDateInput(profile?.date_of_birth || profile?.dateOfBirth),
nationality: profile?.nationality || prev.nationality,
nationality: Object.entries(NATIONALITY_VALUE_BY_CODE).find(([, label]) => normalizeOptionValue(label) === normalizeOptionValue(profile?.nationality || ''))?.[0] || prev.nationality,
street: profile?.address || prev.street,
postalCode: profile?.zip_code || profile?.zipCode || prev.postalCode,
city: profile?.city || prev.city,
country: profile?.country || prev.country,
country: Object.entries(COUNTRY_VALUE_BY_CODE).find(([, label]) => normalizeOptionValue(label) === normalizeOptionValue(profile?.country || ''))?.[0] || prev.country,
accountHolder: profile?.account_holder_name || profile?.accountHolderName || prev.accountHolder,
// Prefer IBAN from users table (data.user.iban), fallback to profile if any
iban: (user?.iban ?? profile?.iban ?? prev.iban) as string,
@ -314,11 +338,11 @@ export default function PersonalAdditionalInformationPage() {
]
for (const k of requiredKeys) {
if (!form[k].trim()) {
const msg = 'Please fill in all required fields.'
const msg = t('quickactionDashboard.additionalInfo.fillRequiredFields')
setError(msg)
showToast({
variant: 'error',
title: 'Missing information',
title: t('quickactionDashboard.uploadId.missingInfoTitle'),
message: msg,
})
return false
@ -327,11 +351,11 @@ export default function PersonalAdditionalInformationPage() {
// Date of birth validation
if (!validateDateOfBirth(form.dob)) {
const msg = 'Invalid date of birth. You must be at least 18 years old.'
const msg = t('quickactionDashboard.additionalInfo.invalidDateOfBirthMessage')
setError(msg)
showToast({
variant: 'error',
title: 'Invalid date of birth',
title: t('quickactionDashboard.additionalInfo.invalidDateOfBirthTitle'),
message: msg,
})
return false
@ -339,11 +363,11 @@ export default function PersonalAdditionalInformationPage() {
// very loose IBAN check
if (!/^([A-Z]{2}\d{2}[A-Z0-9]{10,30})$/i.test(form.iban.replace(/\s+/g,''))) {
const msg = 'Invalid IBAN.'
const msg = t('quickactionDashboard.additionalInfo.invalidIbanMessage')
setError(msg)
showToast({
variant: 'error',
title: 'Invalid IBAN',
title: t('quickactionDashboard.additionalInfo.invalidIbanTitle'),
message: msg,
})
return false
@ -354,31 +378,31 @@ export default function PersonalAdditionalInformationPage() {
const intlNumber = phoneApi?.getNumber() || ''
const valid = phoneApi?.isValid() ?? false
if (!dialCode) {
const msg = 'Please select a country code for your phone number.'
const msg = t('quickactionDashboard.additionalInfo.missingCountryCodeMessage')
setError(msg)
showToast({
variant: 'error',
title: 'Missing country code',
title: t('quickactionDashboard.additionalInfo.missingCountryCodeTitle'),
message: msg,
})
return false
}
if (!intlNumber) {
const msg = 'Please enter your phone number.'
const msg = t('quickactionDashboard.additionalInfo.phoneNumberMissingMessage')
setError(msg)
showToast({
variant: 'error',
title: 'Missing phone number',
title: t('quickactionDashboard.additionalInfo.missingPhoneNumberTitle'),
message: msg,
})
return false
}
if (!valid) {
const msg = 'Please enter a valid phone number.'
const msg = t('quickactionDashboard.additionalInfo.validPhoneNumberMessage')
setError(msg)
showToast({
variant: 'error',
title: 'Invalid phone number',
title: t('quickactionDashboard.additionalInfo.invalidPhoneNumberTitle'),
message: msg,
})
return false
@ -389,11 +413,11 @@ export default function PersonalAdditionalInformationPage() {
const secondApi = secondPhoneRef.current
const ok = secondApi?.isValid?.() ?? false
if (!ok) {
const msg = 'Please enter a valid second phone number.'
const msg = t('quickactionDashboard.additionalInfo.validSecondPhoneNumberMessage')
setError(msg)
showToast({
variant: 'error',
title: 'Invalid phone number',
title: t('quickactionDashboard.additionalInfo.invalidPhoneNumberTitle'),
message: msg,
})
return false
@ -404,11 +428,11 @@ export default function PersonalAdditionalInformationPage() {
const emergencyApi = emergencyPhoneRef.current
const ok = emergencyApi?.isValid?.() ?? false
if (!ok) {
const msg = 'Please enter a valid emergency phone number.'
const msg = t('quickactionDashboard.additionalInfo.validEmergencyPhoneNumberMessage')
setError(msg)
showToast({
variant: 'error',
title: 'Invalid phone number',
title: t('quickactionDashboard.additionalInfo.invalidPhoneNumberTitle'),
message: msg,
})
return false
@ -424,11 +448,11 @@ export default function PersonalAdditionalInformationPage() {
if (!validate()) return
if (!accessToken) {
const msg = 'Not authenticated. Please log in again.'
const msg = t('quickactionDashboard.additionalInfo.authErrorMessage')
setError(msg)
showToast({
variant: 'error',
title: 'Authentication error',
title: t('quickactionDashboard.additionalInfo.authErrorTitle'),
message: msg,
})
return
@ -447,11 +471,11 @@ export default function PersonalAdditionalInformationPage() {
lastName: form.lastName,
phone: normalizedPhone,
dateOfBirth: form.dob,
nationality: form.nationality,
nationality: NATIONALITY_VALUE_BY_CODE[form.nationality as keyof typeof NATIONALITY_VALUE_BY_CODE] || form.nationality,
address: form.street, // Backend expects 'address', not nested object
zip_code: form.postalCode, // Backend expects 'zip_code'
city: form.city,
country: form.country,
country: COUNTRY_VALUE_BY_CODE[form.country as keyof typeof COUNTRY_VALUE_BY_CODE] || form.country,
phoneSecondary: normalizedSecondPhone || null, // Backend expects 'phoneSecondary'
emergencyContactName: form.emergencyName || null,
emergencyContactPhone: normalizedEmergencyPhone || null,
@ -476,8 +500,8 @@ export default function PersonalAdditionalInformationPage() {
setSuccess(true)
showToast({
variant: 'success',
title: 'Profile saved',
message: 'Your personal profile has been saved successfully.',
title: t('quickactionDashboard.additionalInfo.additionalInfoSuccessTitle'),
message: t('quickactionDashboard.additionalInfo.personalSuccessMessage'),
})
// Refresh user status to update profile completion state
@ -491,11 +515,11 @@ export default function PersonalAdditionalInformationPage() {
} catch (error: any) {
console.error('Personal profile save error:', error)
const msg = error.message || 'Save failed. Please try again.'
const msg = error.message || t('quickactionDashboard.additionalInfo.saveFailedMessage')
setError(msg)
showToast({
variant: 'error',
title: 'Save failed',
title: t('quickactionDashboard.additionalInfo.saveFailedTitle'),
message: msg,
})
} finally {
@ -548,8 +572,8 @@ export default function PersonalAdditionalInformationPage() {
{redirectTo && (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-white/70 backdrop-blur-sm transition-opacity duration-200 opacity-100">
<div className="rounded-xl bg-white px-5 py-4 shadow ring-1 ring-black/5">
<div className="text-sm font-medium text-gray-900">Redirecting</div>
<div className="mt-1 text-xs text-gray-600">Please wait</div>
<div className="text-sm font-medium text-gray-900">{t('quickactionDashboard.redirecting')}</div>
<div className="mt-1 text-xs text-gray-600">{t('quickactionDashboard.pleaseWait')}</div>
</div>
</div>
)}
@ -572,18 +596,18 @@ export default function PersonalAdditionalInformationPage() {
>
<div className="px-6 py-8 sm:px-10 lg:px-16">
<h1 className="text-center text-xl sm:text-2xl font-semibold text-[#0F172A] mb-6">
Complete Your Profile
{t('quickactionDashboard.additionalInfo.title')}
</h1>
{/* Personal Information */}
<section>
<h2 className="text-sm font-semibold text-[#0F2460] mb-4">
Personal Information
{t('quickactionDashboard.additionalInfo.personalInformation')}
</h2>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
First Name *
{t('quickactionDashboard.additionalInfo.firstName')}
</label>
<input
name="firstName"
@ -595,7 +619,7 @@ export default function PersonalAdditionalInformationPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Last Name *
{t('quickactionDashboard.additionalInfo.lastName')}
</label>
<input
name="lastName"
@ -607,7 +631,7 @@ export default function PersonalAdditionalInformationPage() {
</div>
<div className="sm:col-span-2 lg:col-span-3">
<label className="block text-sm font-medium text-gray-700 mb-1">
Email *
{t('quickactionDashboard.additionalInfo.email')}
</label>
<input
name="email"
@ -620,7 +644,7 @@ export default function PersonalAdditionalInformationPage() {
</div>
<div className="sm:col-span-2 lg:col-span-3">
<label className="block text-sm font-medium text-gray-700 mb-1">
Phone Number *
{t('quickactionDashboard.additionalInfo.phoneNumber')}
</label>
<TelephoneInput
name="phone"
@ -634,7 +658,7 @@ export default function PersonalAdditionalInformationPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Date of Birth *
{t('quickactionDashboard.additionalInfo.dateOfBirth')}
</label>
<input
type="date"
@ -649,8 +673,10 @@ export default function PersonalAdditionalInformationPage() {
</div>
<div>
<ModernSelect
label="Nationality"
placeholder="Select nationality..."
label={t('quickactionDashboard.additionalInfo.nationality')}
placeholder={t('quickactionDashboard.additionalInfo.selectNationality')}
searchPlaceholder={t('quickactionDashboard.additionalInfo.searchPlaceholder')}
noResults={t('quickactionDashboard.additionalInfo.noResults')}
value={form.nationality}
onChange={(v) => setField('nationality', v)}
options={NATIONALITIES.map(n => ({ value: n, label: n }))}
@ -658,7 +684,7 @@ export default function PersonalAdditionalInformationPage() {
</div>
<div className="sm:col-span-2 lg:col-span-3">
<label className="block text-sm font-medium text-gray-700 mb-1">
Street & House Number *
{t('quickactionDashboard.additionalInfo.streetHouseNumber')}
</label>
<input
name="street"
@ -671,7 +697,7 @@ export default function PersonalAdditionalInformationPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Postal Code *
{t('quickactionDashboard.additionalInfo.postalCode')}
</label>
<input
name="postalCode"
@ -684,7 +710,7 @@ export default function PersonalAdditionalInformationPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
City *
{t('quickactionDashboard.additionalInfo.city')}
</label>
<input
name="city"
@ -697,8 +723,10 @@ export default function PersonalAdditionalInformationPage() {
</div>
<div>
<ModernSelect
label="Country"
placeholder="Select country..."
label={t('quickactionDashboard.additionalInfo.country')}
placeholder={t('quickactionDashboard.additionalInfo.selectCountry')}
searchPlaceholder={t('quickactionDashboard.additionalInfo.searchPlaceholder')}
noResults={t('quickactionDashboard.additionalInfo.noResults')}
value={form.country}
onChange={(v) => setField('country', v)}
options={COUNTRIES.map(c => ({ value: c, label: c }))}
@ -712,12 +740,12 @@ export default function PersonalAdditionalInformationPage() {
{/* Bank Details */}
<section>
<h2 className="text-sm font-semibold text-[#0F2460] mb-4">
Bank Details
{t('quickactionDashboard.additionalInfo.bankDetails')}
</h2>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Account Holder *
{t('quickactionDashboard.additionalInfo.accountHolder')}
</label>
<input
name="accountHolder"
@ -730,7 +758,7 @@ export default function PersonalAdditionalInformationPage() {
</div>
<div className="sm:col-span-1 lg:col-span-2">
<label className="block text-sm font-medium text-gray-700 mb-1">
IBAN *
{t('quickactionDashboard.additionalInfo.iban')}
</label>
<input
name="iban"
@ -749,12 +777,12 @@ export default function PersonalAdditionalInformationPage() {
{/* Additional Information */}
<section>
<h2 className="text-sm font-semibold text-[#0F2460] mb-4">
Additional Information
{t('quickactionDashboard.additionalInfo.additionalInformation')}
</h2>
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
<div className="sm:col-span-2 lg:col-span-3">
<label className="block text-sm font-medium text-gray-700 mb-1">
Second Phone Number (optional)
{t('quickactionDashboard.additionalInfo.secondPhoneOptional')}
</label>
<TelephoneInput
name="secondPhone"
@ -767,7 +795,7 @@ export default function PersonalAdditionalInformationPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Emergency Contact Name
{t('quickactionDashboard.additionalInfo.emergencyContactName')}
</label>
<input
name="emergencyName"
@ -779,7 +807,7 @@ export default function PersonalAdditionalInformationPage() {
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Emergency Contact Phone
{t('quickactionDashboard.additionalInfo.emergencyContactPhone')}
</label>
<TelephoneInput
name="emergencyPhone"
@ -801,7 +829,7 @@ export default function PersonalAdditionalInformationPage() {
)}
{success && (
<div className="mt-6 rounded-md border border-green-300 bg-green-50 px-4 py-3 text-xs text-green-700">
Data saved. Redirecting shortly
{t('quickactionDashboard.additionalInfo.dataSavedRedirecting')}
</div>
)}
@ -811,7 +839,7 @@ export default function PersonalAdditionalInformationPage() {
onClick={() => router.push('/quickaction-dashboard')}
className="inline-flex items-center rounded-md border border-[#8D6B1D]/40 px-4 py-2 text-sm font-semibold text-[#8D6B1D] bg-white hover:bg-[#8D6B1D]/10"
>
Back to Dashboard
{t('quickactionDashboard.backToDashboard')}
</button>
<button
@ -819,7 +847,7 @@ export default function PersonalAdditionalInformationPage() {
disabled={loading || success}
className="inline-flex items-center rounded-md bg-[#8D6B1D] px-6 py-2.5 text-sm font-semibold text-white shadow hover:bg-[#7A5E1A] focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{loading ? 'Saving…' : success ? 'Saved' : 'Save & Continue'}
{loading ? t('common.saving') : success ? t('common.saved') : t('quickactionDashboard.additionalInfo.saveContinue')}
</button>
</div>
</div>

View File

@ -7,8 +7,10 @@ import useAuthStore from '../../store/authStore'
import { useUserStatus } from '../../hooks/useUserStatus'
import { useRouter } from 'next/navigation'
import { useToast } from '../../components/toast/toastComponent'
import { useTranslation } from '../../i18n/useTranslation'
export default function EmailVerifyPage() {
const { t } = useTranslation()
const user = useAuthStore(s => s.user)
const isAuthReady = useAuthStore(s => (s as any).isAuthReady) // NEW
const token = useAuthStore(s => s.accessToken)
@ -69,34 +71,34 @@ export default function EmailVerifyPage() {
setResendCooldown(Math.ceil(RESEND_INTERVAL_MS / 1000))
showToast({
variant: 'success',
title: 'Verification email sent',
message: `We sent a verification email to ${user?.email || 'your email'}.`
title: t('quickactionDashboard.emailVerified'),
message: `${t('quickactionDashboard.emailVerify.sentIntro')} ${user?.email || t('quickactionDashboard.emailVerify.yourEmail')}.`
})
} else {
const msg = data?.message || 'Error sending the verification email.'
const msg = data?.message || t('quickactionDashboard.emailVerify.networkErrorTitle')
setError(msg)
emailSentRef.current = false
showToast({
variant: 'error',
title: 'Email not sent',
title: t('quickactionDashboard.emailVerify.verificationFailedTitle'),
message: msg
})
}
} catch (err) {
console.error('Error sending initial verification email:', err)
const msg = 'Network error while sending the verification email.'
const msg = t('quickactionDashboard.emailVerify.networkErrorTitle')
setError(msg)
emailSentRef.current = false
showToast({
variant: 'error',
title: 'Network error',
title: t('quickactionDashboard.emailVerify.networkErrorTitle'),
message: msg
})
}
}
sendInitialEmail()
}, [token, user, showToast])
}, [token, user, showToast, t])
// Cooldown timer
useEffect(() => {
@ -194,21 +196,21 @@ export default function EmailVerifyPage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (fullCode.length !== 6) {
const msg = 'Please enter the 6-digit code.'
const msg = t('quickactionDashboard.emailVerify.invalidCode')
setError(msg)
showToast({
variant: 'error',
title: 'Invalid code',
title: t('quickactionDashboard.emailVerify.invalidCode'),
message: msg
})
return
}
if (!token) {
const msg = 'Not authenticated. Please log in again.'
const msg = t('quickactionDashboard.emailVerify.authError')
setError(msg)
showToast({
variant: 'error',
title: 'Authentication error',
title: t('quickactionDashboard.uploadId.authErrorTitle'),
message: msg
})
return
@ -232,29 +234,29 @@ export default function EmailVerifyPage() {
setSuccess(true)
showToast({
variant: 'success',
title: 'Email verified',
message: 'Your email has been verified successfully.'
title: t('quickactionDashboard.emailVerify.emailVerifiedTitle'),
message: t('quickactionDashboard.emailVerify.emailVerifiedMessage')
})
await refreshStatus()
// Guests go directly to dashboard after email verification
const isGuest = user?.role === 'guest'
window.location.href = isGuest ? '/dashboard' : '/quickaction-dashboard?tutorial=true'
} else {
const msg = data.error || 'Verification failed. Please try again.'
const msg = data.error || t('quickactionDashboard.emailVerify.verificationFailedTitle')
setError(msg)
showToast({
variant: 'error',
title: 'Verification failed',
title: t('quickactionDashboard.emailVerify.verificationFailedTitle'),
message: msg
})
}
} catch (err) {
console.error('Email verification error:', err)
const msg = 'Network error. Please try again.'
const msg = t('quickactionDashboard.uploadId.networkErrorMessage')
setError(msg)
showToast({
variant: 'error',
title: 'Network error',
title: t('quickactionDashboard.emailVerify.networkErrorTitle'),
message: msg
})
} finally {
@ -275,11 +277,11 @@ export default function EmailVerifyPage() {
return
}
if (!token) {
const msg = 'Not authenticated. Please log in again.'
const msg = t('quickactionDashboard.emailVerify.authError')
setError(msg)
showToast({
variant: 'error',
title: 'Authentication error',
title: t('quickactionDashboard.uploadId.authErrorTitle'),
message: msg
})
return
@ -304,29 +306,29 @@ export default function EmailVerifyPage() {
if (!initialEmailSent) setInitialEmailSent(true)
showToast({
variant: 'success',
title: 'Verification email sent',
message: `We sent a new verification email to ${user?.email || 'your email'}.`
title: t('quickactionDashboard.emailVerified'),
message: `${t('quickactionDashboard.emailVerify.sentIntro')} ${user?.email || t('quickactionDashboard.emailVerify.yourEmail')}.`
})
} else {
const msg = data?.message || 'Error sending the email.'
const msg = data?.message || t('quickactionDashboard.emailVerify.networkErrorTitle')
setError(msg)
showToast({
variant: 'error',
title: 'Email not sent',
title: t('quickactionDashboard.emailVerify.verificationFailedTitle'),
message: msg
})
}
} catch (err) {
console.error('Resend email error:', err)
const msg = 'Network error while sending the email.'
const msg = t('quickactionDashboard.emailVerify.networkErrorTitle')
setError(msg)
showToast({
variant: 'error',
title: 'Network error',
title: t('quickactionDashboard.emailVerify.networkErrorTitle'),
message: msg
})
}
}, [token, submitting, success, user, initialEmailSent, showToast])
}, [token, submitting, success, user, initialEmailSent, showToast, t])
// NEW: format seconds to m:ss
const formatMmSs = (total: number) => {
@ -376,8 +378,8 @@ export default function EmailVerifyPage() {
{redirectTo && (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-white/70 backdrop-blur-sm transition-opacity duration-200 opacity-100">
<div className="rounded-xl bg-white px-5 py-4 shadow ring-1 ring-black/5">
<div className="text-sm font-medium text-gray-900">Redirecting</div>
<div className="mt-1 text-xs text-gray-600">Please wait</div>
<div className="text-sm font-medium text-gray-900">{t('quickactionDashboard.redirecting')}</div>
<div className="mt-1 text-xs text-gray-600">{t('quickactionDashboard.pleaseWait')}</div>
</div>
</div>
)}
@ -387,22 +389,22 @@ export default function EmailVerifyPage() {
<div className="max-w-xl mx-auto">
<div className="text-center mb-10">
<h1 className="text-3xl sm:text-4xl font-semibold tracking-tight text-gray-900">
Verify your email
{t('quickactionDashboard.emailVerify.title')}
</h1>
<p className="mt-3 text-gray-700 text-sm sm:text-base">
{initialEmailSent ? (
<>
We sent a 6-digit code to{' '}
{t('quickactionDashboard.emailVerify.sentIntro')}{' '}
<span className="text-[#8D6B1D] font-medium">
{user?.email || 'your email'}
{user?.email || t('quickactionDashboard.emailVerify.yourEmail')}
</span>
. Enter it below.
. {t('quickactionDashboard.emailVerify.enterBelow')}
</>
) : (
<>
Sending verification email to{' '}
{t('quickactionDashboard.emailVerify.sendingIntro')}{' '}
<span className="text-[#8D6B1D] font-medium">
{user?.email || 'your email'}
{user?.email || t('quickactionDashboard.emailVerify.yourEmail')}
</span>
...
</>
@ -445,7 +447,7 @@ export default function EmailVerifyPage() {
{success && (
<div className="rounded-lg border border-green-300 bg-green-50 px-4 py-3 text-sm text-green-700">
Verified! Redirecting shortly...
{t('quickactionDashboard.emailVerify.verifiedRedirecting')}
</div>
)}
@ -460,9 +462,9 @@ export default function EmailVerifyPage() {
{submitting ? (
<>
<span className="size-4 mr-2 rounded-full border-2 border-white border-b-transparent animate-spin" />
Verifying...
{t('quickactionDashboard.emailVerify.verifying')}
</>
) : success ? 'Verified' : 'Confirm code'}
) : success ? t('quickactionDashboard.emailVerify.verified') : t('quickactionDashboard.emailVerify.confirmCode')}
</button>
<button
@ -472,8 +474,8 @@ export default function EmailVerifyPage() {
className="text-sm font-medium text-[#8D6B1D] hover:underline disabled:text-gray-400 disabled:cursor-not-allowed"
>
{resendCooldown
? `Resend in ${formatMmSs(resendCooldown)}`
: 'Resend code'}
? `${t('quickactionDashboard.resendAvailableIn')} ${formatMmSs(resendCooldown)}`
: t('quickactionDashboard.emailVerify.resendCode')}
</button>
</div>
@ -483,15 +485,15 @@ export default function EmailVerifyPage() {
onClick={() => router.push('/quickaction-dashboard')}
className="text-sm font-medium text-[#8D6B1D] hover:underline"
>
Go to Dashboard
{t('quickactionDashboard.goToDashboard')}
</button>
</div>
</fieldset>
<div className="mt-8 text-center text-xs text-gray-500">
Didnt receive the email? Please check your junk/spam folder. Still having issues?{' '}
{t('quickactionDashboard.emailVerify.supportHint')}{' '}
<a href="mailto:test@test.com" className="text-[#8D6B1D] hover:underline">
Contact support
{t('quickactionDashboard.emailVerify.contactSupport')}
</a>
.
</div>

View File

@ -7,6 +7,7 @@ import useAuthStore from '../../../store/authStore'
import { useUserStatus } from '../../../hooks/useUserStatus'
import { API_BASE_URL } from '../../../utils/api'
import { useToast } from '../../../components/toast/toastComponent'
import { useTranslation } from '../../../i18n/useTranslation'
export default function CompanySignContractPage() {
const router = useRouter()
@ -15,6 +16,7 @@ export default function CompanySignContractPage() {
const { accessToken } = useAuthStore()
const { userStatus, loading: statusLoading, refreshStatus } = useUserStatus()
const { showToast } = useToast()
const { t } = useTranslation()
const [date, setDate] = useState('')
const [signatureDataUrl, setSignatureDataUrl] = useState('')
@ -201,37 +203,37 @@ export default function CompanySignContractPage() {
const gdprAvailable = !!previewState.gdpr.html
if (!contractAvailable && !gdprAvailable) {
const msg = 'Temporarily unable to sign contracts. No active documents are available at this moment.'
const msg = t('quickactionDashboard.contractSigning.noDocumentsAvailableMessage')
setError(msg)
showToast({
variant: 'error',
title: 'No documents available',
title: t('quickactionDashboard.contractSigning.noDocumentsAvailableTitle'),
message: msg,
})
return
}
if (contractAvailable && !agreeContract) issues.push('Contract read and understood')
if (gdprAvailable && !agreeData) issues.push('Privacy policy accepted')
if (!confirmSignature) issues.push('Electronic signature confirmed')
if (!signatureDataUrl) issues.push('Signature captured on pad')
if (contractAvailable && !agreeContract) issues.push(t('quickactionDashboard.contractSigning.contractReadUnderstood'))
if (gdprAvailable && !agreeData) issues.push(t('quickactionDashboard.contractSigning.privacyAccepted'))
if (!confirmSignature) issues.push(t('quickactionDashboard.contractSigning.electronicSignatureConfirmed'))
if (!signatureDataUrl) issues.push(t('quickactionDashboard.contractSigning.signatureCaptured'))
const msg = `Please complete: ${issues.join(', ')}`
const msg = `${t('quickactionDashboard.contractSigning.completePrefix')} ${issues.join(', ')}`
setError(msg)
showToast({
variant: 'error',
title: 'Missing information',
title: t('quickactionDashboard.contractSigning.missingInformationTitle'),
message: msg,
})
return
}
if (!accessToken) {
const msg = 'Not authenticated. Please log in again.'
const msg = t('quickactionDashboard.contractSigning.authErrorMessage')
setError(msg)
showToast({
variant: 'error',
title: 'Authentication error',
title: t('quickactionDashboard.contractSigning.authErrorTitle'),
message: msg,
})
return
@ -270,8 +272,8 @@ export default function CompanySignContractPage() {
setSuccess(true)
showToast({
variant: 'success',
title: 'Contract signed',
message: 'Your company contract has been signed successfully.',
title: t('quickactionDashboard.contractSigning.contractSignedTitle'),
message: t('quickactionDashboard.contractSigning.companyContractSignedMessage'),
})
// Refresh user status to update contract signed state
@ -283,11 +285,11 @@ export default function CompanySignContractPage() {
} catch (error: unknown) {
console.error('Contract signing error:', error)
const msg = error instanceof Error ? (error.message || 'Signature failed. Please try again.') : 'Signature failed. Please try again.'
const msg = error instanceof Error ? (error.message || t('quickactionDashboard.contractSigning.signingFailedMessage')) : t('quickactionDashboard.contractSigning.signingFailedMessage')
setError(msg)
showToast({
variant: 'error',
title: 'Signature failed',
title: t('quickactionDashboard.contractSigning.signingFailedTitle'),
message: msg,
})
} finally {
@ -334,8 +336,8 @@ export default function CompanySignContractPage() {
{redirectTo && (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-white/70 backdrop-blur-sm transition-opacity duration-200 opacity-100">
<div className="rounded-xl bg-white px-5 py-4 shadow ring-1 ring-black/5">
<div className="text-sm font-medium text-gray-900">Redirecting</div>
<div className="mt-1 text-xs text-gray-600">Please wait</div>
<div className="text-sm font-medium text-gray-900">{t('quickactionDashboard.redirecting')}</div>
<div className="mt-1 text-xs text-gray-600">{t('quickactionDashboard.pleaseWait')}</div>
</div>
</div>
)}
@ -357,10 +359,10 @@ export default function CompanySignContractPage() {
{/* CHANGED: tighter padding on mobile */}
<div className="px-4 py-6 sm:px-10 sm:py-8 lg:px-14">
<h1 className="text-center text-2xl sm:text-3xl font-semibold text-[#0F172A] mb-2">
Sign Company Partnership Contract
{t('quickactionDashboard.contractSigning.companyTitle')}
</h1>
<p className="text-center text-sm text-gray-600 mb-8">
Please review the contract details and sign on behalf of the company.
{t('quickactionDashboard.contractSigning.companySubtitle')}
</p>
{/* CHANGED: smaller gaps on mobile */}
@ -370,7 +372,7 @@ export default function CompanySignContractPage() {
<div className="rounded-lg border border-gray-200 p-4 sm:p-5 bg-gray-50">
{/* CHANGED: stack header + tabs on mobile */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-3">
<h2 className="text-sm font-semibold text-gray-800">Document Information</h2>
<h2 className="text-sm font-semibold text-gray-800">{t('quickactionDashboard.contractSigning.documentInformation')}</h2>
<div className="flex items-center gap-1 rounded-full border border-gray-200 bg-white px-1 w-fit max-w-full overflow-x-auto">
{(['contract','gdpr'] as const).map((tab) => (
<button
@ -379,7 +381,7 @@ export default function CompanySignContractPage() {
onClick={() => setActiveTab(tab)}
className={`px-2.5 py-1 text-xs rounded-full transition whitespace-nowrap ${activeTab === tab ? 'bg-indigo-600 text-white shadow' : 'text-gray-700 hover:bg-gray-100'}`}
>
{tab === 'contract' ? 'Contract' : 'GDPR'}
{tab === 'contract' ? t('quickactionDashboard.contractSigning.contractTab') : t('quickactionDashboard.contractSigning.gdprTab')}
</button>
))}
</div>
@ -406,22 +408,22 @@ export default function CompanySignContractPage() {
}
return (
<ul className="space-y-2 text-xs sm:text-sm text-gray-600">
<li><span className="font-medium text-gray-700">Document:</span> {meta.title}</li>
<li><span className="font-medium text-gray-700">ID:</span> {meta.id}</li>
<li><span className="font-medium text-gray-700">Version / Basis:</span> {meta.version}</li>
<li><span className="font-medium text-gray-700">Jurisdiction:</span> {meta.jurisdiction}</li>
<li><span className="font-medium text-gray-700">Language:</span> {meta.language}</li>
<li><span className="font-medium text-gray-700">Issuer:</span> {meta.issuer}</li>
<li><span className="font-medium text-gray-700">Address:</span> {meta.address}</li>
<li><span className="font-medium text-gray-700">{t('quickactionDashboard.contractSigning.documentLabel')}</span> {meta.title}</li>
<li><span className="font-medium text-gray-700">{t('quickactionDashboard.contractSigning.idLabel')}</span> {meta.id}</li>
<li><span className="font-medium text-gray-700">{t('quickactionDashboard.contractSigning.versionLabel')}</span> {meta.version}</li>
<li><span className="font-medium text-gray-700">{t('quickactionDashboard.contractSigning.jurisdictionLabel')}</span> {meta.jurisdiction}</li>
<li><span className="font-medium text-gray-700">{t('quickactionDashboard.contractSigning.languageLabel')}</span> {meta.language}</li>
<li><span className="font-medium text-gray-700">{t('quickactionDashboard.contractSigning.issuerLabel')}</span> {meta.issuer}</li>
<li><span className="font-medium text-gray-700">{t('quickactionDashboard.contractSigning.addressLabel')}</span> {meta.address}</li>
</ul>
)
})()}
</div>
{/* CHANGED: tighter padding */}
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 sm:p-5">
<h3 className="text-sm font-semibold text-amber-900 mb-2">Attention</h3>
<h3 className="text-sm font-semibold text-amber-900 mb-2">{t('quickactionDashboard.contractSigning.attentionTitle')}</h3>
<p className="text-xs sm:text-sm text-amber-800 leading-relaxed">
You confirm that you are authorized to sign on behalf of the company.
{t('quickactionDashboard.contractSigning.attentionBody')}
</p>
</div>
</div>
@ -431,7 +433,7 @@ export default function CompanySignContractPage() {
{/* CHANGED: stack toolbar on mobile */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 p-3 border-b border-gray-200 bg-gray-50">
<div className="flex flex-col sm:flex-row sm:items-center gap-2 text-sm font-semibold text-gray-900">
<span>Document Preview</span>
<span>{t('quickactionDashboard.contractSigning.documentPreview')}</span>
<div className="flex items-center gap-1 rounded-full border border-gray-200 bg-white px-1 w-fit max-w-full overflow-x-auto">
{(['contract','gdpr'] as const).map((tab) => (
<button
@ -440,7 +442,7 @@ export default function CompanySignContractPage() {
onClick={() => setActiveTab(tab)}
className={`px-2.5 py-1 text-xs rounded-full transition whitespace-nowrap ${activeTab === tab ? 'bg-indigo-600 text-white shadow' : 'text-gray-700 hover:bg-gray-100'}`}
>
{tab === 'contract' ? 'Contract' : 'GDPR'}
{tab === 'contract' ? t('quickactionDashboard.contractSigning.contractTab') : t('quickactionDashboard.contractSigning.gdprTab')}
</button>
))}
</div>
@ -460,7 +462,7 @@ export default function CompanySignContractPage() {
disabled={!previewState[activeTab]?.html}
className="w-full sm:w-auto inline-flex items-center justify-center rounded-md bg-gray-100 hover:bg-gray-200 text-gray-900 px-2.5 py-1.5 text-xs disabled:opacity-60"
>
Open in new tab
{t('quickactionDashboard.contractSigning.openInNewTab')}
</button>
<button
type="button"
@ -468,20 +470,20 @@ export default function CompanySignContractPage() {
disabled={previewState[activeTab].loading}
className="w-full sm:w-auto inline-flex items-center justify-center rounded-md bg-indigo-600 hover:bg-indigo-500 text-white px-2.5 py-1.5 text-xs disabled:opacity-60"
>
{previewState[activeTab].loading ? 'Loading…' : 'Refresh'}
{previewState[activeTab].loading ? t('quickactionDashboard.contractSigning.loadingPreview') : t('quickactionDashboard.contractSigning.refresh')}
</button>
</div>
</div>
{/* CHANGED: shorter on mobile */}
{previewLoading || previewState[activeTab].loading ? (
<div className="h-64 sm:h-72 flex items-center justify-center text-xs text-gray-500">Loading preview</div>
<div className="h-64 sm:h-72 flex items-center justify-center text-xs text-gray-500">{t('quickactionDashboard.contractSigning.loadingPreview')}</div>
) : previewState[activeTab].error ? (
<div className="h-64 sm:h-72 flex items-center justify-center text-xs text-red-600 px-4 text-center">{previewState[activeTab].error}</div>
) : previewState[activeTab].html ? (
<iframe title={`Company Document Preview ${activeTab}`} className="w-full h-64 sm:h-72" srcDoc={previewState[activeTab].html || ''} />
) : (
<div className="h-64 sm:h-72 flex items-center justify-center text-xs text-gray-500">No contract available at this moment, please contact us.</div>
<div className="h-64 sm:h-72 flex items-center justify-center text-xs text-gray-500">{t('quickactionDashboard.contractSigning.noContractAvailable')}</div>
)}
</div>
</div>
@ -490,11 +492,11 @@ export default function CompanySignContractPage() {
<hr className="my-10 border-gray-200" />
<section>
<h2 className="text-sm font-semibold text-[#0F2460] mb-5">Signature</h2>
<h2 className="text-sm font-semibold text-[#0F2460] mb-5">{t('quickactionDashboard.contractSigning.signatureSection')}</h2>
<div className="">
<label className="block text-sm font-medium text-gray-700 mb-2">
Draw Signature *
{t('quickactionDashboard.contractSigning.drawSignature')}
</label>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<canvas
@ -517,10 +519,10 @@ export default function CompanySignContractPage() {
onClick={clearSignature}
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50"
>
Clear
{t('quickactionDashboard.contractSigning.clear')}
</button>
<span className="text-gray-500">Use mouse or touch to sign. A signature is required.</span>
{signatureDataUrl && <span className="text-green-600 font-medium">Captured</span>}
<span className="text-gray-500">{t('quickactionDashboard.contractSigning.signatureHelp')}</span>
{signatureDataUrl && <span className="text-green-600 font-medium">{t('quickactionDashboard.contractSigning.captured')}</span>}
</div>
</div>
</div>
@ -529,7 +531,7 @@ export default function CompanySignContractPage() {
<hr className="my-10 border-gray-200" />
<section className="space-y-5">
<h2 className="text-sm font-semibold text-[#0F2460]">Confirmations</h2>
<h2 className="text-sm font-semibold text-[#0F2460]">{t('quickactionDashboard.contractSigning.confirmations')}</h2>
<label className="flex items-start gap-3 text-sm text-gray-700">
<input
type="checkbox"
@ -537,7 +539,7 @@ export default function CompanySignContractPage() {
onChange={e => setAgreeContract(e.target.checked)}
className="mt-1 h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span>I confirm I have read and accepted the full contract on behalf of the company.</span>
<span>{t('quickactionDashboard.contractSigning.confirmContractCompany')}</span>
</label>
<label className="flex items-start gap-3 text-sm text-gray-700">
<input
@ -546,7 +548,7 @@ export default function CompanySignContractPage() {
onChange={e => setAgreeData(e.target.checked)}
className="mt-1 h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span>I consent to processing of company and personal data in accordance with the privacy policy.</span>
<span>{t('quickactionDashboard.contractSigning.confirmDataCompany')}</span>
</label>
<label className="flex items-start gap-3 text-sm text-gray-700">
<input
@ -555,7 +557,7 @@ export default function CompanySignContractPage() {
onChange={e => setConfirmSignature(e.target.checked)}
className="mt-1 h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span>I am authorized to sign legally binding documents for this company.</span>
<span>{t('quickactionDashboard.contractSigning.confirmSignatureCompany')}</span>
</label>
</section>
@ -566,7 +568,7 @@ export default function CompanySignContractPage() {
)}
{success && (
<div className="mt-8 rounded-md border border-green-300 bg-green-50 px-4 py-3 text-sm text-green-700">
Contract signed successfully. Redirecting shortly
{t('quickactionDashboard.contractSigning.contractSignedRedirecting')}
</div>
)}
@ -577,14 +579,14 @@ export default function CompanySignContractPage() {
onClick={() => router.push('/quickaction-dashboard')}
className="w-full sm:w-auto inline-flex items-center justify-center rounded-md border border-gray-300 px-4 py-2 text-sm font-semibold text-gray-700 bg-white hover:bg-gray-50"
>
Back to Dashboard
{t('quickactionDashboard.backToDashboard')}
</button>
<button
type="submit"
disabled={submitting || success || (!previewState.contract.html && !previewState.gdpr.html)}
className="w-full sm:w-auto inline-flex items-center justify-center rounded-md bg-indigo-600 px-8 py-3 text-sm font-semibold text-white shadow hover:bg-indigo-500 disabled:bg-gray-400 disabled:cursor-not-allowed transition"
>
{submitting ? 'Signing…' : success ? 'Signed' : 'Sign Now'}
{submitting ? t('quickactionDashboard.contractSigning.signing') : success ? t('quickactionDashboard.contractSigning.signed') : t('quickactionDashboard.contractSigning.signNow')}
</button>
</div>
</div>

View File

@ -7,6 +7,7 @@ import useAuthStore from '../../../store/authStore'
import { useUserStatus } from '../../../hooks/useUserStatus'
import { API_BASE_URL } from '../../../utils/api'
import { useToast } from '../../../components/toast/toastComponent'
import { useTranslation } from '../../../i18n/useTranslation'
export default function PersonalSignContractPage() {
const router = useRouter()
@ -15,6 +16,7 @@ export default function PersonalSignContractPage() {
const { accessToken } = useAuthStore()
const { userStatus, loading: statusLoading, refreshStatus } = useUserStatus() // CHANGED
const { showToast } = useToast()
const { t } = useTranslation()
const [date, setDate] = useState('')
const [signatureDataUrl, setSignatureDataUrl] = useState('')
@ -240,37 +242,37 @@ export default function PersonalSignContractPage() {
const gdprAvailable = !!previewState.gdpr.html
if (!contractAvailable && !gdprAvailable) {
const msg = 'Temporarily unable to sign contracts. No active documents are available at this moment.'
const msg = t('quickactionDashboard.contractSigning.noDocumentsAvailableMessage')
setError(msg)
showToast({
variant: 'error',
title: 'No documents available',
title: t('quickactionDashboard.contractSigning.noDocumentsAvailableTitle'),
message: msg,
})
return
}
if (contractAvailable && !agreeContract) issues.push('Contract read and understood')
if (gdprAvailable && !agreeData) issues.push('Privacy policy accepted')
if (!confirmSignature) issues.push('Electronic signature confirmed')
if (!signatureDataUrl) issues.push('Signature captured on pad')
if (contractAvailable && !agreeContract) issues.push(t('quickactionDashboard.contractSigning.contractReadUnderstood'))
if (gdprAvailable && !agreeData) issues.push(t('quickactionDashboard.contractSigning.privacyAccepted'))
if (!confirmSignature) issues.push(t('quickactionDashboard.contractSigning.electronicSignatureConfirmed'))
if (!signatureDataUrl) issues.push(t('quickactionDashboard.contractSigning.signatureCaptured'))
const msg = `Please complete: ${issues.join(', ')}`
const msg = `${t('quickactionDashboard.contractSigning.completePrefix')} ${issues.join(', ')}`
setError(msg)
showToast({
variant: 'error',
title: 'Missing information',
title: t('quickactionDashboard.contractSigning.missingInformationTitle'),
message: msg,
})
return
}
if (!accessToken) {
const msg = 'Not authenticated. Please log in again.'
const msg = t('quickactionDashboard.contractSigning.authErrorMessage')
setError(msg)
showToast({
variant: 'error',
title: 'Authentication error',
title: t('quickactionDashboard.contractSigning.authErrorTitle'),
message: msg,
})
return
@ -309,8 +311,8 @@ export default function PersonalSignContractPage() {
setSuccess(true)
showToast({
variant: 'success',
title: 'Contract signed',
message: 'Your personal contract has been signed successfully.',
title: t('quickactionDashboard.contractSigning.contractSignedTitle'),
message: t('quickactionDashboard.contractSigning.personalContractSignedMessage'),
})
// Refresh user status to update contract signed state
@ -322,11 +324,11 @@ export default function PersonalSignContractPage() {
} catch (error: unknown) {
console.error('Contract signing error:', error)
const msg = error instanceof Error ? (error.message || 'Signature failed. Please try again.') : 'Signature failed. Please try again.'
const msg = error instanceof Error ? (error.message || t('quickactionDashboard.contractSigning.signingFailedMessage')) : t('quickactionDashboard.contractSigning.signingFailedMessage')
setError(msg)
showToast({
variant: 'error',
title: 'Signature failed',
title: t('quickactionDashboard.contractSigning.signingFailedTitle'),
message: msg,
})
} finally {
@ -340,8 +342,8 @@ export default function PersonalSignContractPage() {
{redirectTo && (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-white/70 backdrop-blur-sm transition-opacity duration-200 opacity-100">
<div className="rounded-xl bg-white px-5 py-4 shadow ring-1 ring-black/5">
<div className="text-sm font-medium text-gray-900">Redirecting</div>
<div className="mt-1 text-xs text-gray-600">Please wait</div>
<div className="text-sm font-medium text-gray-900">{t('quickactionDashboard.redirecting')}</div>
<div className="mt-1 text-xs text-gray-600">{t('quickactionDashboard.pleaseWait')}</div>
</div>
</div>
)}
@ -366,10 +368,10 @@ export default function PersonalSignContractPage() {
{/* CHANGED: tighter padding on mobile */}
<div className="px-4 py-6 sm:px-10 sm:py-8 lg:px-14">
<h1 className="text-center text-2xl sm:text-3xl font-semibold text-[#0F172A] mb-2">
Sign Personal Participation Contract
{t('quickactionDashboard.contractSigning.personalTitle')}
</h1>
<p className="text-center text-sm text-gray-600 mb-8">
Please review the contract details and sign electronically.
{t('quickactionDashboard.contractSigning.personalSubtitle')}
</p>
{/* Contract Meta + Preview */}
@ -379,7 +381,7 @@ export default function PersonalSignContractPage() {
<div className="rounded-lg border border-gray-200 p-4 sm:p-5 bg-gray-50">
{/* CHANGED: stack header + tabs on mobile */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-3">
<h2 className="text-sm font-semibold text-gray-800">Document Information</h2>
<h2 className="text-sm font-semibold text-gray-800">{t('quickactionDashboard.contractSigning.documentInformation')}</h2>
<div className="flex items-center gap-1 rounded-full border border-gray-200 bg-white px-1 w-fit max-w-full overflow-x-auto">
{(['contract','gdpr'] as const).map((tab) => (
<button
@ -388,7 +390,7 @@ export default function PersonalSignContractPage() {
onClick={() => setActiveTab(tab)}
className={`px-2.5 py-1 text-xs rounded-full transition whitespace-nowrap ${activeTab === tab ? 'bg-indigo-600 text-white shadow' : 'text-gray-700 hover:bg-gray-100'}`}
>
{tab === 'contract' ? 'Contract' : 'GDPR'}
{tab === 'contract' ? t('quickactionDashboard.contractSigning.contractTab') : t('quickactionDashboard.contractSigning.gdprTab')}
</button>
))}
</div>
@ -415,22 +417,22 @@ export default function PersonalSignContractPage() {
}
return (
<ul className="space-y-2 text-xs sm:text-sm text-gray-600">
<li><span className="font-medium text-gray-700">Document:</span> {meta.title}</li>
<li><span className="font-medium text-gray-700">ID:</span> {meta.id}</li>
<li><span className="font-medium text-gray-700">Version / Basis:</span> {meta.version}</li>
<li><span className="font-medium text-gray-700">Jurisdiction:</span> {meta.jurisdiction}</li>
<li><span className="font-medium text-gray-700">Language:</span> {meta.language}</li>
<li><span className="font-medium text-gray-700">Issuer:</span> {meta.issuer}</li>
<li><span className="font-medium text-gray-700">Address:</span> {meta.address}</li>
<li><span className="font-medium text-gray-700">{t('quickactionDashboard.contractSigning.documentLabel')}</span> {meta.title}</li>
<li><span className="font-medium text-gray-700">{t('quickactionDashboard.contractSigning.idLabel')}</span> {meta.id}</li>
<li><span className="font-medium text-gray-700">{t('quickactionDashboard.contractSigning.versionLabel')}</span> {meta.version}</li>
<li><span className="font-medium text-gray-700">{t('quickactionDashboard.contractSigning.jurisdictionLabel')}</span> {meta.jurisdiction}</li>
<li><span className="font-medium text-gray-700">{t('quickactionDashboard.contractSigning.languageLabel')}</span> {meta.language}</li>
<li><span className="font-medium text-gray-700">{t('quickactionDashboard.contractSigning.issuerLabel')}</span> {meta.issuer}</li>
<li><span className="font-medium text-gray-700">{t('quickactionDashboard.contractSigning.addressLabel')}</span> {meta.address}</li>
</ul>
)
})()}
</div>
{/* CHANGED: tighter padding */}
<div className="rounded-lg border border-indigo-100 bg-indigo-50/60 p-4 sm:p-5">
<h3 className="text-sm font-semibold text-indigo-900 mb-2">Note</h3>
<h3 className="text-sm font-semibold text-indigo-900 mb-2">{t('quickactionDashboard.contractSigning.noteTitle')}</h3>
<p className="text-xs sm:text-sm text-indigo-800 leading-relaxed">
Your electronic signature is legally binding. Please ensure all details are correct.
{t('quickactionDashboard.contractSigning.noteBody')}
</p>
</div>
</div>
@ -439,7 +441,7 @@ export default function PersonalSignContractPage() {
{/* CHANGED: stack toolbar on mobile */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 p-3 border-b border-gray-200 bg-gray-50">
<div className="flex flex-col sm:flex-row sm:items-center gap-2 text-sm font-semibold text-gray-900">
<span>Contract Preview</span>
<span>{t('quickactionDashboard.contractSigning.documentPreview')}</span>
<div className="flex items-center gap-1 rounded-full border border-gray-200 bg-white px-1 w-fit max-w-full overflow-x-auto">
{(['contract','gdpr'] as const).map((tab) => (
<button
@ -448,7 +450,7 @@ export default function PersonalSignContractPage() {
onClick={() => setActiveTab(tab)}
className={`px-2.5 py-1 text-xs rounded-full transition whitespace-nowrap ${activeTab === tab ? 'bg-indigo-600 text-white shadow' : 'text-gray-700 hover:bg-gray-100'}`}
>
{tab === 'contract' ? 'Contract' : 'GDPR'}
{tab === 'contract' ? t('quickactionDashboard.contractSigning.contractTab') : t('quickactionDashboard.contractSigning.gdprTab')}
</button>
))}
</div>
@ -468,20 +470,20 @@ export default function PersonalSignContractPage() {
disabled={!previewState[activeTab]?.html}
className="w-full sm:w-auto inline-flex items-center justify-center rounded-md bg-gray-100 hover:bg-gray-200 text-gray-900 px-2.5 py-1.5 text-xs disabled:opacity-60"
>
Open in new tab
{t('quickactionDashboard.contractSigning.openInNewTab')}
</button>
</div>
</div>
{/* CHANGED: shorter on mobile */}
{previewLoading || previewState[activeTab].loading ? (
<div className="h-64 sm:h-72 flex items-center justify-center text-xs text-gray-500">Loading preview</div>
<div className="h-64 sm:h-72 flex items-center justify-center text-xs text-gray-500">{t('quickactionDashboard.contractSigning.loadingPreview')}</div>
) : previewState[activeTab].error ? (
<div className="h-64 sm:h-72 flex items-center justify-center text-xs text-red-600 px-4 text-center">{previewState[activeTab].error}</div>
) : previewState[activeTab].html ? (
<iframe title={`Contract Preview ${activeTab}`} className="w-full h-64 sm:h-72" srcDoc={previewState[activeTab].html || ''} />
) : (
<div className="h-64 sm:h-72 flex items-center justify-center text-xs text-gray-500">No contract available at this moment, please contact us.</div>
<div className="h-64 sm:h-72 flex items-center justify-center text-xs text-gray-500">{t('quickactionDashboard.contractSigning.noContractAvailable')}</div>
)}
</div>
</div>
@ -491,11 +493,11 @@ export default function PersonalSignContractPage() {
{/* Signature Pad */}
<section>
<h2 className="text-sm font-semibold text-[#0F2460] mb-5">Signature</h2>
<h2 className="text-sm font-semibold text-[#0F2460] mb-5">{t('quickactionDashboard.contractSigning.signatureSection')}</h2>
<div className="">
<label className="block text-sm font-medium text-gray-700 mb-2">
Draw Signature *
{t('quickactionDashboard.contractSigning.drawSignature')}
</label>
<div className="rounded-lg border border-gray-200 bg-white p-4">
<canvas
@ -518,10 +520,10 @@ export default function PersonalSignContractPage() {
onClick={clearSignature}
className="inline-flex items-center justify-center rounded-md border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50"
>
Clear
{t('quickactionDashboard.contractSigning.clear')}
</button>
<span className="text-gray-500">Use mouse or touch to sign. A signature is required.</span>
{signatureDataUrl && <span className="text-green-600 font-medium">Captured</span>}
<span className="text-gray-500">{t('quickactionDashboard.contractSigning.signatureHelp')}</span>
{signatureDataUrl && <span className="text-green-600 font-medium">{t('quickactionDashboard.contractSigning.captured')}</span>}
</div>
</div>
</div>
@ -531,7 +533,7 @@ export default function PersonalSignContractPage() {
{/* Confirmations */}
<section className="space-y-5">
<h2 className="text-sm font-semibold text-[#0F2460]">Confirmations</h2>
<h2 className="text-sm font-semibold text-[#0F2460]">{t('quickactionDashboard.contractSigning.confirmations')}</h2>
<label className="flex items-start gap-3 text-sm text-gray-700">
<input
type="checkbox"
@ -539,7 +541,7 @@ export default function PersonalSignContractPage() {
onChange={e => setAgreeContract(e.target.checked)}
className="mt-1 h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span>I confirm that I have read and understood the contract in full.</span>
<span>{t('quickactionDashboard.contractSigning.confirmContractPersonal')}</span>
</label>
<label className="flex items-start gap-3 text-sm text-gray-700">
<input
@ -548,7 +550,7 @@ export default function PersonalSignContractPage() {
onChange={e => setAgreeData(e.target.checked)}
className="mt-1 h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span>I consent to the processing of my personal data in accordance with the privacy policy.</span>
<span>{t('quickactionDashboard.contractSigning.confirmDataPersonal')}</span>
</label>
<label className="flex items-start gap-3 text-sm text-gray-700">
<input
@ -557,7 +559,7 @@ export default function PersonalSignContractPage() {
onChange={e => setConfirmSignature(e.target.checked)}
className="mt-1 h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
<span>I confirm this electronic signature is legally binding and equivalent to a handwritten signature.</span>
<span>{t('quickactionDashboard.contractSigning.confirmSignaturePersonal')}</span>
</label>
</section>
@ -568,7 +570,7 @@ export default function PersonalSignContractPage() {
)}
{success && (
<div className="mt-8 rounded-md border border-green-300 bg-green-50 px-4 py-3 text-sm text-green-700">
Contract signed successfully. Redirecting shortly
{t('quickactionDashboard.contractSigning.contractSignedRedirecting')}
</div>
)}
@ -579,14 +581,14 @@ export default function PersonalSignContractPage() {
onClick={() => router.push('/quickaction-dashboard')}
className="w-full sm:w-auto inline-flex items-center justify-center rounded-md border border-gray-300 px-4 py-2 text-sm font-semibold text-gray-700 bg-white hover:bg-gray-50"
>
Back to Dashboard
{t('quickactionDashboard.backToDashboard')}
</button>
<button
type="submit"
disabled={submitting || success || (!previewState.contract.html && !previewState.gdpr.html)}
className="w-full sm:w-auto inline-flex items-center justify-center rounded-md bg-indigo-600 px-8 py-3 text-sm font-semibold text-white shadow hover:bg-indigo-500 disabled:bg-gray-400 disabled:cursor-not-allowed transition"
>
{submitting ? 'Signing…' : success ? 'Signed' : 'Sign Now'}
{submitting ? t('quickactionDashboard.contractSigning.signing') : success ? t('quickactionDashboard.contractSigning.signed') : t('quickactionDashboard.contractSigning.signNow')}
</button>
</div>
</div>

View File

@ -4,12 +4,14 @@ import { useState, useRef, useEffect, useCallback } from 'react'
import useAuthStore from '../../../../store/authStore'
import { useUserStatus } from '../../../../hooks/useUserStatus'
import { useToast } from '../../../../components/toast/toastComponent'
import { useTranslation } from '../../../../i18n/useTranslation'
export function useCompanyUploadId() {
// Auth + status
const { accessToken } = useAuthStore()
const { refreshStatus } = useUserStatus()
const { showToast } = useToast()
const { t } = useTranslation()
// Form state
const [idNumber, setIdNumber] = useState('')
@ -39,11 +41,11 @@ export function useCompanyUploadId() {
// File handlers
const handleFile = (file: File, which: 'front' | 'extra') => {
if (file.size > 10 * 1024 * 1024) {
const msg = 'File size exceeds 10 MB.'
const msg = t('quickactionDashboard.uploadId.fileTooLargeMessage')
setError(msg)
showToast({
variant: 'error',
title: 'File too large',
title: t('quickactionDashboard.uploadId.fileTooLargeTitle'),
message: msg,
})
return
@ -89,11 +91,11 @@ export function useCompanyUploadId() {
// Validation
const validate = () => {
if (!idNumber.trim() || !idType || !expiryDate || !frontFile) {
const msg = 'Please complete all required fields (marked with *).'
const msg = t('quickactionDashboard.uploadId.fillRequiredFields')
setError(msg)
showToast({
variant: 'error',
title: 'Missing information',
title: t('quickactionDashboard.uploadId.missingInfoTitle'),
message: msg,
})
return false
@ -107,11 +109,11 @@ export function useCompanyUploadId() {
e.preventDefault()
if (!validate()) return
if (!accessToken) {
const msg = 'Not authenticated. Please log in again.'
const msg = t('quickactionDashboard.emailVerify.authError')
setError(msg)
showToast({
variant: 'error',
title: 'Authentication error',
title: t('quickactionDashboard.uploadId.authErrorTitle'),
message: msg,
})
return
@ -143,8 +145,8 @@ export function useCompanyUploadId() {
setSuccess(true)
showToast({
variant: 'success',
title: 'Documents uploaded',
message: 'Your company ID documents have been uploaded successfully.',
title: t('quickactionDashboard.uploadId.companyUploadSuccessTitle'),
message: t('quickactionDashboard.uploadId.companyUploadSuccessMessage'),
})
await refreshStatus()
@ -153,11 +155,11 @@ export function useCompanyUploadId() {
}, 1500)
} catch (err: any) {
console.error('Company ID upload error:', err)
const msg = err?.message || 'Upload failed.'
const msg = err?.message || t('quickactionDashboard.uploadId.uploadFailedMessage')
setError(msg)
showToast({
variant: 'error',
title: 'Upload failed',
title: t('quickactionDashboard.uploadId.uploadFailedTitle'),
message: msg,
})
} finally {

View File

@ -8,10 +8,12 @@ import useAuthStore from '../../../store/authStore'
import { useEffect, useState, useCallback, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { useUserStatus } from '../../../hooks/useUserStatus'
import { useTranslation } from '../../../i18n/useTranslation'
const DOC_TYPES = ['Personalausweis', 'Reisepass', 'Führerschein', 'Aufenthaltstitel']
export default function CompanyIdUploadPage() {
const { t } = useTranslation()
const {
// values
idNumber, setIdNumber,
@ -73,8 +75,8 @@ export default function CompanyIdUploadPage() {
<PageLayout>
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-white/70 backdrop-blur-sm transition-opacity duration-200 opacity-100">
<div className="rounded-xl bg-white px-5 py-4 shadow ring-1 ring-black/5">
<div className="text-sm font-medium text-gray-900">Redirecting</div>
<div className="mt-1 text-xs text-gray-600">Please wait</div>
<div className="text-sm font-medium text-gray-900">{t('quickactionDashboard.redirecting')}</div>
<div className="mt-1 text-xs text-gray-600">{t('quickactionDashboard.pleaseWait')}</div>
</div>
</div>
</PageLayout>
@ -96,33 +98,33 @@ export default function CompanyIdUploadPage() {
>
<div className="px-6 py-8 sm:px-12 lg:px-16">
<h1 className="text-xl sm:text-2xl font-semibold text-gray-900 mb-1">
Company Contact Person Identity Verification
{t('quickactionDashboard.uploadId.companyTitle')}
</h1>
<p className="text-sm text-gray-600 mb-8">
Please upload clear photos of both sides of the company contact person&apos;s ID document.
{t('quickactionDashboard.uploadId.companySubtitle')}
</p>
{/* Fields: 3 in one row on md+ with unified inputs */}
<div className="grid gap-6 md:grid-cols-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Contact Person ID Number *
{t('quickactionDashboard.uploadId.contactPersonIdNumber')}
</label>
<input
value={idNumber}
onChange={e => setIdNumber(e.target.value)}
className={`${inputBase} ${idNumber ? 'text-gray-900' : 'text-gray-700'} focus:ring-[#8D6B1D] focus:border-transparent`}
placeholder="Enter contact person's ID number"
placeholder={t('quickactionDashboard.uploadId.contactPersonIdNumberPlaceholder')}
required
/>
<p className="mt-1 text-xs text-gray-600">
Enter the ID number exactly as shown on the document
{t('quickactionDashboard.uploadId.contactPersonIdNumberHint')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Document Type *
{t('quickactionDashboard.uploadId.documentType')}
</label>
<select
value={idType}
@ -130,14 +132,14 @@ export default function CompanyIdUploadPage() {
className={`${inputBase} ${idType ? 'text-gray-900' : 'text-gray-700'} focus:ring-[#8D6B1D] focus:border-transparent`}
required
>
<option value="">Select document type</option>
<option value="">{t('quickactionDashboard.uploadId.selectDocumentType')}</option>
{DOC_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Expiry Date *
{t('quickactionDashboard.uploadId.expiryDate')}
</label>
<input
type="date"
@ -148,7 +150,7 @@ export default function CompanyIdUploadPage() {
required
/>
<p className="mt-1 text-xs text-gray-600">
Enter the expiry date shown on your document
{t('quickactionDashboard.uploadId.expiryDateHint')}
</p>
</div>
</div>
@ -156,7 +158,7 @@ export default function CompanyIdUploadPage() {
{/* Back side toggle */}
<div className="mt-8 flex items-center gap-3">
<span className="text-sm font-medium text-gray-700">
Does ID have a Backside?
{t('quickactionDashboard.uploadId.backSideQuestion')}
</span>
<button
type="button"
@ -168,7 +170,7 @@ export default function CompanyIdUploadPage() {
>
<span className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ${hasBack ? 'translate-x-5' : 'translate-x-0'}`} />
</button>
<span className="text-sm text-gray-700">{hasBack ? 'Yes' : 'No'}</span>
<span className="text-sm text-gray-700">{hasBack ? t('quickactionDashboard.yes') : t('quickactionDashboard.no')}</span>
</div>
{/* Upload Areas */}
@ -193,7 +195,7 @@ export default function CompanyIdUploadPage() {
{frontPreview && (
<img
src={frontPreview}
alt="Primary document preview"
alt={t('quickactionDashboard.uploadId.primaryPreviewAlt')}
className="w-full max-h-56 object-contain rounded-md bg-white border border-gray-200"
/>
)}
@ -203,19 +205,19 @@ export default function CompanyIdUploadPage() {
onClick={e => { e.stopPropagation(); clearFile('front') }}
className="mt-3 inline-flex items-center gap-1 rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-100"
>
<XMarkIcon className="h-4 w-4" /> Remove
<XMarkIcon className="h-4 w-4" /> {t('quickactionDashboard.remove')}
</button>
</div>
) : (
<>
<DocumentArrowUpIcon className="h-10 w-10 text-gray-400 group-hover:text-[#8D6B1D] mb-4 transition" />
<p className="text-sm font-medium text-[#8D6B1D] group-hover:text-[#7A5E1A]">
Click to upload front side
{t('quickactionDashboard.uploadId.clickUploadFront')}
</p>
<p className="mt-2 text-xs text-gray-500">
or drag and drop
{t('quickactionDashboard.dragAndDrop')}
<br />
PNG, JPG, JPEG up to 10MB
{t('quickactionDashboard.maxUploadHint')}
</p>
</>
)}
@ -242,7 +244,7 @@ export default function CompanyIdUploadPage() {
{extraPreview && (
<img
src={extraPreview}
alt="Supporting document preview"
alt={t('quickactionDashboard.uploadId.supportingPreviewAlt')}
className="w-full max-h-56 object-contain rounded-md bg-white border border-gray-200"
/>
)}
@ -252,19 +254,19 @@ export default function CompanyIdUploadPage() {
onClick={e => { e.stopPropagation(); clearFile('extra') }}
className="mt-3 inline-flex items-center gap-1 rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-100"
>
<XMarkIcon className="h-4 w-4" /> Remove
<XMarkIcon className="h-4 w-4" /> {t('quickactionDashboard.remove')}
</button>
</div>
) : (
<>
<DocumentArrowUpIcon className="h-10 w-10 text-gray-400 group-hover:text-[#8D6B1D] mb-4 transition" />
<p className="text-sm font-medium text-[#8D6B1D] group-hover:text-[#7A5E1A]">
Click to upload back side
{t('quickactionDashboard.uploadId.clickUploadBack')}
</p>
<p className="mt-2 text-xs text-gray-500">
or drag and drop
{t('quickactionDashboard.dragAndDrop')}
<br />
PNG, JPG, JPEG up to 10MB
{t('quickactionDashboard.maxUploadHint')}
</p>
</>
)}
@ -275,14 +277,14 @@ export default function CompanyIdUploadPage() {
{/* Info */}
<div className="mt-8 rounded-lg bg-[#8D6B1D]/10 border border-[#8D6B1D]/20 px-5 py-5">
<p className="text-sm font-semibold text-[#3B2C04] mb-3">
Please ensure your ID documents:
{t('quickactionDashboard.uploadId.documentsChecklistTitle')}
</p>
<ul className="text-sm text-[#7A5E1A] space-y-1 list-disc pl-5">
<li>Are clearly visible and readable</li>
<li>Show all four corners</li>
<li>Are not expired</li>
<li>Have good lighting (no shadows or glare)</li>
<li>{hasBack ? 'Both front and back sides are uploaded' : 'Front side is uploaded'}</li>
<li>{t('quickactionDashboard.uploadId.clearlyVisible')}</li>
<li>{t('quickactionDashboard.uploadId.showCorners')}</li>
<li>{t('quickactionDashboard.uploadId.notExpired')}</li>
<li>{t('quickactionDashboard.uploadId.goodLighting')}</li>
<li>{hasBack ? t('quickactionDashboard.uploadId.bothSidesUploaded') : t('quickactionDashboard.uploadId.frontSideUploaded')}</li>
</ul>
</div>
@ -293,7 +295,7 @@ export default function CompanyIdUploadPage() {
)}
{success && (
<div className="mt-6 rounded-md border border-green-300 bg-green-50 px-4 py-3 text-sm text-green-700">
Documents uploaded successfully. Redirecting shortly
{t('quickactionDashboard.uploadId.successSavedRedirecting')}
</div>
)}
@ -304,7 +306,7 @@ export default function CompanyIdUploadPage() {
onClick={goBackToDashboard}
className="inline-flex items-center justify-center rounded-md border border-[#8D6B1D] bg-white/70 px-4 py-3 text-sm font-semibold text-[#8D6B1D] hover:bg-[#8D6B1D]/10 transition"
>
Back to Dashboard
{t('quickactionDashboard.backToDashboard')}
</button>
<button
@ -312,7 +314,7 @@ export default function CompanyIdUploadPage() {
disabled={submitting || success}
className="inline-flex items-center justify-center rounded-md bg-[#8D6B1D] px-6 py-3 text-sm font-semibold text-white shadow hover:bg-[#7A5E1A] focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed transition"
>
{submitting ? 'Uploading…' : success ? 'Saved' : 'Upload & Continue'}
{submitting ? t('quickactionDashboard.uploading') : success ? t('quickactionDashboard.saved') : t('quickactionDashboard.uploadContinue')}
</button>
</div>
</div>

View File

@ -4,12 +4,14 @@ import { useState, useRef, useCallback, useEffect } from 'react'
import useAuthStore from '../../../../store/authStore'
import { useUserStatus } from '../../../../hooks/useUserStatus'
import { useToast } from '../../../../components/toast/toastComponent'
import { useTranslation } from '../../../../i18n/useTranslation'
export function usePersonalUploadId() {
// Auth and status
const token = useAuthStore(s => s.accessToken)
const { refreshStatus } = useUserStatus()
const { showToast } = useToast()
const { t } = useTranslation()
// Form state
const [idNumber, setIdNumber] = useState('')
@ -39,11 +41,11 @@ export function usePersonalUploadId() {
// File handlers
const handleFile = (f: File, side: 'front' | 'back') => {
if (f.size > 10 * 1024 * 1024) {
const msg = 'File size exceeds 10 MB.'
const msg = t('quickactionDashboard.uploadId.fileTooLargeMessage')
setError(msg)
showToast({
variant: 'error',
title: 'File too large',
title: t('quickactionDashboard.uploadId.fileTooLargeTitle'),
message: msg,
})
return
@ -89,31 +91,31 @@ export function usePersonalUploadId() {
// Validation
const validate = () => {
if (!idNumber.trim() || !idType || !expiry) {
const msg = 'Please fill out all required fields.'
const msg = t('quickactionDashboard.uploadId.fillRequiredFields')
setError(msg)
showToast({
variant: 'error',
title: 'Missing information',
title: t('quickactionDashboard.uploadId.missingInfoTitle'),
message: msg,
})
return false
}
if (!frontFile) {
const msg = 'Please upload the front side.'
const msg = t('quickactionDashboard.uploadId.frontSideMissingMessage')
setError(msg)
showToast({
variant: 'error',
title: 'Front side missing',
title: t('quickactionDashboard.uploadId.frontSideMissingTitle'),
message: msg,
})
return false
}
if (hasBack && !backFile) {
const msg = 'Please upload the back side or disable the switch.'
const msg = t('quickactionDashboard.uploadId.backSideMissingMessage')
setError(msg)
showToast({
variant: 'error',
title: 'Back side missing',
title: t('quickactionDashboard.uploadId.backSideMissingTitle'),
message: msg,
})
return false
@ -127,11 +129,11 @@ export function usePersonalUploadId() {
e.preventDefault()
if (!validate()) return
if (!token) {
const msg = 'Not authenticated. Please log in again.'
const msg = t('quickactionDashboard.emailVerify.authError')
setError(msg)
showToast({
variant: 'error',
title: 'Authentication error',
title: t('quickactionDashboard.uploadId.authErrorTitle'),
message: msg,
})
return
@ -160,29 +162,29 @@ export function usePersonalUploadId() {
setSuccess(true)
showToast({
variant: 'success',
title: 'Documents uploaded',
message: 'Your ID documents have been uploaded successfully.',
title: t('quickactionDashboard.uploadId.personalUploadSuccessTitle'),
message: t('quickactionDashboard.uploadId.personalUploadSuccessMessage'),
})
await refreshStatus()
setTimeout(() => {
window.location.href = '/quickaction-dashboard?tutorial=true'
}, 2000)
} else {
const msg = data.message || 'Upload failed. Please try again.'
const msg = data.message || t('quickactionDashboard.uploadId.uploadFailedMessage')
setError(msg)
showToast({
variant: 'error',
title: 'Upload failed',
title: t('quickactionDashboard.uploadId.uploadFailedTitle'),
message: msg,
})
}
} catch (err) {
console.error('Upload error:', err)
const msg = 'Network error. Please try again.'
const msg = t('quickactionDashboard.uploadId.networkErrorMessage')
setError(msg)
showToast({
variant: 'error',
title: 'Network error',
title: t('quickactionDashboard.uploadId.networkErrorTitle'),
message: msg,
})
} finally {

View File

@ -8,6 +8,7 @@ import { useRouter } from 'next/navigation'
import useAuthStore from '../../../store/authStore'
import { DocumentArrowUpIcon, XMarkIcon } from '@heroicons/react/24/outline'
import { useUserStatus } from '../../../hooks/useUserStatus'
import { useTranslation } from '../../../i18n/useTranslation'
// Add back ID types for the dropdown
const ID_TYPES = [
@ -18,10 +19,17 @@ const ID_TYPES = [
]
export default function PersonalIdUploadPage() {
const { t } = useTranslation()
const user = useAuthStore(s => s.user)
const isAuthReady = useAuthStore(s => (s as any).isAuthReady) // NEW
const router = useRouter()
const { userStatus, loading: statusLoading } = useUserStatus()
const idTypes = [
{ value: 'national_id', label: 'National ID Card' },
{ value: 'passport', label: 'Passport' },
{ value: 'driver_license', label: "Driver's License" },
{ value: 'other', label: 'Other' },
]
// NEW: smooth redirect
const [redirectTo, setRedirectTo] = useState<string | null>(null)
@ -64,8 +72,8 @@ export default function PersonalIdUploadPage() {
<PageLayout>
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-white/70 backdrop-blur-sm transition-opacity duration-200 opacity-100">
<div className="rounded-xl bg-white px-5 py-4 shadow ring-1 ring-black/5">
<div className="text-sm font-medium text-gray-900">Redirecting</div>
<div className="mt-1 text-xs text-gray-600">Please wait</div>
<div className="text-sm font-medium text-gray-900">{t('quickactionDashboard.redirecting')}</div>
<div className="mt-1 text-xs text-gray-600">{t('quickactionDashboard.pleaseWait')}</div>
</div>
</div>
</PageLayout>
@ -100,33 +108,33 @@ export default function PersonalIdUploadPage() {
>
<div className="px-6 py-8 sm:px-12 lg:px-16">
<h1 className="text-xl sm:text-2xl font-semibold text-gray-900 mb-1">
Personal Identity Verification
{t('quickactionDashboard.uploadId.personalTitle')}
</h1>
<p className="text-sm text-gray-600 mb-8">
Please upload clear photos of both sides of your governmentissued ID
{t('quickactionDashboard.uploadId.personalSubtitle')}
</p>
{/* Grid Fields: put all three inputs on the same line on md+ */}
<div className="grid gap-6 md:grid-cols-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
ID Number *
{t('quickactionDashboard.uploadId.idNumber')}
</label>
<input
value={idNumber}
onChange={e => setIdNumber(e.target.value)}
placeholder="Enter your ID number"
placeholder={t('quickactionDashboard.uploadId.idNumberPlaceholder')}
className={`${inputBase} ${idNumber ? 'text-gray-900' : 'text-gray-700'} focus:ring-[#8D6B1D] focus:border-transparent`}
required
/>
<p className="mt-1 text-xs text-gray-600">
Enter the number exactly as shown on your ID
{t('quickactionDashboard.uploadId.idNumberHint')}
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
ID Type *
{t('quickactionDashboard.uploadId.idType')}
</label>
<select
value={idType}
@ -134,10 +142,10 @@ export default function PersonalIdUploadPage() {
className={`${inputBase} ${idType ? 'text-gray-900' : 'text-gray-700'} focus:ring-[#8D6B1D] focus:border-transparent`}
required
>
<option value="">Select ID type</option>
{ID_TYPES.map(t => (
<option key={t.value} value={t.value}>
{t.label}
<option value="">{t('quickactionDashboard.uploadId.selectIdType')}</option>
{idTypes.map(idOption => (
<option key={idOption.value} value={idOption.value}>
{idOption.label}
</option>
))}
</select>
@ -145,7 +153,7 @@ export default function PersonalIdUploadPage() {
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Expiry Date *
{t('quickactionDashboard.uploadId.expiryDate')}
</label>
<input
type="date"
@ -161,7 +169,7 @@ export default function PersonalIdUploadPage() {
{/* Back side toggle */}
<div className="mt-8 flex items-center gap-3">
<span className="text-sm font-medium text-gray-700">
Does ID have a Backside?
{t('quickactionDashboard.uploadId.backSideQuestion')}
</span>
<button
type="button"
@ -177,7 +185,7 @@ export default function PersonalIdUploadPage() {
}`}
/>
</button>
<span className="text-sm text-gray-700">{hasBack ? 'Yes' : 'No'}</span>
<span className="text-sm text-gray-700">{hasBack ? t('quickactionDashboard.yes') : t('quickactionDashboard.no')}</span>
</div>
{/* Upload Areas: full width, 1 col if no back, 2 cols if back */}
@ -202,7 +210,7 @@ export default function PersonalIdUploadPage() {
{frontPreview && (
<img
src={frontPreview}
alt="Front ID preview"
alt={t('quickactionDashboard.uploadId.frontPreviewAlt')}
className="w-full max-h-56 object-contain rounded-md bg-white border border-gray-200"
/>
)}
@ -212,19 +220,19 @@ export default function PersonalIdUploadPage() {
onClick={e => { e.stopPropagation(); clearFile('front') }}
className="mt-3 inline-flex items-center gap-1 rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-100"
>
<XMarkIcon className="h-4 w-4" /> Remove
<XMarkIcon className="h-4 w-4" /> {t('quickactionDashboard.remove')}
</button>
</div>
) : (
<>
<DocumentArrowUpIcon className="h-10 w-10 text-gray-400 group-hover:text-[#8D6B1D] mb-3 transition" />
<p className="text-sm font-medium text-[#8D6B1D] group-hover:text-[#7A5E1A]">
Click to upload front side
{t('quickactionDashboard.uploadId.clickUploadFront')}
</p>
<p className="mt-2 text-xs text-gray-500">
or drag and drop
{t('quickactionDashboard.dragAndDrop')}
<br />
PNG, JPG, JPEG up to 10MB
{t('quickactionDashboard.maxUploadHint')}
</p>
</>
)}
@ -251,7 +259,7 @@ export default function PersonalIdUploadPage() {
{backPreview && (
<img
src={backPreview}
alt="Back ID preview"
alt={t('quickactionDashboard.uploadId.backPreviewAlt')}
className="w-full max-h-56 object-contain rounded-md bg-white border border-gray-200"
/>
)}
@ -261,19 +269,19 @@ export default function PersonalIdUploadPage() {
onClick={e => { e.stopPropagation(); clearFile('back') }}
className="mt-3 inline-flex items-center gap-1 rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-600 hover:bg-red-100"
>
<XMarkIcon className="h-4 w-4" /> Remove
<XMarkIcon className="h-4 w-4" /> {t('quickactionDashboard.remove')}
</button>
</div>
) : (
<>
<DocumentArrowUpIcon className="h-10 w-10 text-gray-400 group-hover:text-[#8D6B1D] mb-3 transition" />
<p className="text-sm font-medium text-[#8D6B1D] group-hover:text-[#7A5E1A]">
Click to upload back side
{t('quickactionDashboard.uploadId.clickUploadBack')}
</p>
<p className="mt-2 text-xs text-gray-500">
or drag and drop
{t('quickactionDashboard.dragAndDrop')}
<br />
PNG, JPG, JPEG up to 10MB
{t('quickactionDashboard.maxUploadHint')}
</p>
</>
)}
@ -284,14 +292,14 @@ export default function PersonalIdUploadPage() {
{/* Info Box, errors, success, submit */}
<div className="mt-8 rounded-lg bg-[#8D6B1D]/10 border border-[#8D6B1D]/20 px-5 py-5">
<p className="text-sm font-semibold text-[#3B2C04] mb-3">
Please ensure your ID documents:
{t('quickactionDashboard.uploadId.documentsChecklistTitle')}
</p>
<ul className="text-sm text-[#7A5E1A] space-y-1 list-disc pl-5">
<li>Are clearly visible and readable</li>
<li>Show all four corners</li>
<li>Are not expired</li>
<li>Have good lighting (no shadows or glare)</li>
<li>{hasBack ? 'Both front and back sides are uploaded' : 'Front side is uploaded'}</li>
<li>{t('quickactionDashboard.uploadId.clearlyVisible')}</li>
<li>{t('quickactionDashboard.uploadId.showCorners')}</li>
<li>{t('quickactionDashboard.uploadId.notExpired')}</li>
<li>{t('quickactionDashboard.uploadId.goodLighting')}</li>
<li>{hasBack ? t('quickactionDashboard.uploadId.bothSidesUploaded') : t('quickactionDashboard.uploadId.frontSideUploaded')}</li>
</ul>
</div>
@ -302,7 +310,7 @@ export default function PersonalIdUploadPage() {
)}
{success && (
<div className="mt-6 rounded-md border border-green-300 bg-green-50 px-4 py-3 text-sm text-green-700">
Upload saved successfully. Redirecting shortly
{t('quickactionDashboard.uploadId.successSavedRedirecting')}
</div>
)}
@ -313,7 +321,7 @@ export default function PersonalIdUploadPage() {
onClick={goBackToDashboard}
className="inline-flex items-center justify-center rounded-md border border-[#8D6B1D] bg-white/70 px-4 py-3 text-sm font-semibold text-[#8D6B1D] hover:bg-[#8D6B1D]/10 transition"
>
Back to Dashboard
{t('quickactionDashboard.backToDashboard')}
</button>
<button
@ -321,7 +329,7 @@ export default function PersonalIdUploadPage() {
disabled={submitting || success}
className="inline-flex items-center justify-center rounded-md bg-[#8D6B1D] px-6 py-3 text-sm font-semibold text-white shadow hover:bg-[#7A5E1A] focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed transition"
>
{submitting ? 'Uploading…' : success ? 'Saved' : 'Upload & Continue'}
{submitting ? t('quickactionDashboard.uploading') : success ? t('quickactionDashboard.saved') : t('quickactionDashboard.uploadContinue')}
</button>
</div>
</div>