dev #21
@ -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>
|
||||
|
||||
849
src/app/admin/language-management/page.tsx
Normal file
849
src/app/admin/language-management/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
215
src/app/api/i18n/scan/route.ts
Normal file
215
src/app/api/i18n/scan/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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
|
||||
}
|
||||
/>
|
||||
|
||||
@ -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">→</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>
|
||||
|
||||
@ -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}
|
||||
|
||||
83
src/app/i18n/dynamicTranslations.ts
Normal file
83
src/app/i18n/dynamicTranslations.ts
Normal 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;
|
||||
}
|
||||
@ -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,41 +43,911 @@ 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',
|
||||
shop: 'Shop',
|
||||
dashboard: 'Dashboard',
|
||||
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.',
|
||||
},
|
||||
};
|
||||
|
||||
@ -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,41 +43,911 @@ 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.'
|
||||
title: 'Active Community',
|
||||
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'
|
||||
products: 'Eco Products',
|
||||
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'
|
||||
terms: 'Terms of Service',
|
||||
contact: 'Contact',
|
||||
},
|
||||
|
||||
nav: {
|
||||
home: 'Home',
|
||||
shop: 'Shop',
|
||||
dashboard: 'Dashboard',
|
||||
community: 'Community',
|
||||
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: 'Didn’t 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.',
|
||||
},
|
||||
};
|
||||
|
||||
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@ -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];
|
||||
|
||||
for (const k of keys) {
|
||||
value = value?.[k];
|
||||
const reloadCustomI18n = useCallback(() => {
|
||||
setCustomI18n(loadCustomI18n());
|
||||
}, []);
|
||||
|
||||
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>
|
||||
);
|
||||
@ -49,4 +70,21 @@ export function useTranslation() {
|
||||
throw new Error('useTranslation must be used within an I18nProvider');
|
||||
}
|
||||
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);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
Didn’t 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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,7 +16,8 @@ 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('')
|
||||
const [agreeContract, setAgreeContract] = useState(false)
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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'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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 government‑issued 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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user