dev #21
@ -18,6 +18,7 @@ import {
|
|||||||
ShieldCheckIcon,
|
ShieldCheckIcon,
|
||||||
} from '@heroicons/react/24/outline'
|
} from '@heroicons/react/24/outline'
|
||||||
import useAuthStore from '../../../store/authStore'
|
import useAuthStore from '../../../store/authStore'
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation'
|
||||||
|
|
||||||
/* ---------- types ---------- */
|
/* ---------- types ---------- */
|
||||||
export type AdminInvoice = {
|
export type AdminInvoice = {
|
||||||
@ -85,11 +86,11 @@ const STATUSES = ['draft', 'issued', 'paid', 'overdue', 'canceled'] as const
|
|||||||
type InvoiceStatus = (typeof STATUSES)[number]
|
type InvoiceStatus = (typeof STATUSES)[number]
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<InvoiceStatus, { label: string; bg: string; text: string; icon: React.ElementType }> = {
|
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 },
|
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 },
|
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 },
|
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 },
|
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 },
|
canceled: { label: 'canceled', bg: 'bg-yellow-100', text: 'text-yellow-700', icon: NoSymbolIcon },
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtDate(d?: string | null) {
|
function fmtDate(d?: string | null) {
|
||||||
@ -116,6 +117,7 @@ export default function InvoiceDetailModal({
|
|||||||
onExport,
|
onExport,
|
||||||
}: InvoiceDetailModalProps) {
|
}: InvoiceDetailModalProps) {
|
||||||
const token = useAuthStore((s) => s.accessToken)
|
const token = useAuthStore((s) => s.accessToken)
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
// detail data
|
// detail data
|
||||||
const [items, setItems] = useState<InvoiceItem[]>([])
|
const [items, setItems] = useState<InvoiceItem[]>([])
|
||||||
@ -274,10 +276,10 @@ export default function InvoiceDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Dialog.Title className="text-lg font-bold text-white">
|
<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>
|
</Dialog.Title>
|
||||||
<p className="text-sm text-blue-200/80">
|
<p className="text-sm text-blue-200/80">
|
||||||
Created {fmtDateTime(invoice.created_at)}
|
{t('invoiceDetailModal.created')} {fmtDateTime(invoice.created_at)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -297,12 +299,12 @@ export default function InvoiceDetailModal({
|
|||||||
<div className="flex items-center gap-2">
|
<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}`}>
|
<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" />
|
<StatusIcon className="h-4 w-4" />
|
||||||
{statusConf.label}
|
{t(`invoiceDetailModal.status${statusConf.label.charAt(0).toUpperCase() + statusConf.label.slice(1)}` as any)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<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) => {
|
{STATUSES.map((s) => {
|
||||||
const sc = STATUS_CONFIG[s]
|
const sc = STATUS_CONFIG[s]
|
||||||
const active = s === currentStatus
|
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'
|
: '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>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@ -327,7 +329,7 @@ export default function InvoiceDetailModal({
|
|||||||
{/* status feedback */}
|
{/* status feedback */}
|
||||||
{changingStatus && (
|
{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">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{statusMsg && (
|
{statusMsg && (
|
||||||
@ -346,46 +348,46 @@ export default function InvoiceDetailModal({
|
|||||||
{/* Customer info */}
|
{/* Customer info */}
|
||||||
<div className="rounded-xl border border-gray-100 bg-gray-50/50 p-4 space-y-2">
|
<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">
|
<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>
|
</div>
|
||||||
<InfoRow label="Name" value={invoice.buyer_name} />
|
<InfoRow label={t('invoiceDetailModal.name')} value={invoice.buyer_name} />
|
||||||
<InfoRow label="Email" value={invoice.buyer_email} />
|
<InfoRow label={t('invoiceDetailModal.email')} value={invoice.buyer_email} />
|
||||||
<InfoRow label="Street" value={invoice.buyer_street} />
|
<InfoRow label={t('invoiceDetailModal.street')} value={invoice.buyer_street} />
|
||||||
<InfoRow label="City" value={[invoice.buyer_postal_code, invoice.buyer_city].filter(Boolean).join(' ')} />
|
<InfoRow label={t('invoiceDetailModal.city')} value={[invoice.buyer_postal_code, invoice.buyer_city].filter(Boolean).join(' ')} />
|
||||||
<InfoRow label="Country" value={invoice.buyer_country} />
|
<InfoRow label={t('invoiceDetailModal.country')} value={invoice.buyer_country} />
|
||||||
<InfoRow label="User ID" value={invoice.user_id != null ? String(invoice.user_id) : null} />
|
<InfoRow label={t('invoiceDetailModal.userId')} value={invoice.user_id != null ? String(invoice.user_id) : null} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Financial info */}
|
{/* Financial info */}
|
||||||
<div className="rounded-xl border border-gray-100 bg-gray-50/50 p-4 space-y-2">
|
<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">
|
<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>
|
</div>
|
||||||
<InfoRow label="Net" value={fmtMoney(invoice.total_net, invoice.currency ?? 'EUR')} />
|
<InfoRow label={t('invoiceDetailModal.net')} value={fmtMoney(invoice.total_net, invoice.currency ?? 'EUR')} />
|
||||||
<InfoRow label="Tax" value={fmtMoney(invoice.total_tax, invoice.currency ?? 'EUR')} />
|
<InfoRow label={t('invoiceDetailModal.tax')} value={fmtMoney(invoice.total_tax, invoice.currency ?? 'EUR')} />
|
||||||
<InfoRow label="Gross" value={fmtMoney(invoice.total_gross, invoice.currency ?? 'EUR')} highlight />
|
<InfoRow label={t('invoiceDetailModal.gross')} value={fmtMoney(invoice.total_gross, invoice.currency ?? 'EUR')} highlight />
|
||||||
<InfoRow label="VAT Rate" value={invoice.vat_rate != null ? `${invoice.vat_rate}%` : '—'} />
|
<InfoRow label={t('invoiceDetailModal.vatRate')} value={invoice.vat_rate != null ? `${invoice.vat_rate}%` : '—'} />
|
||||||
<InfoRow label="Currency" value={invoice.currency ?? 'EUR'} />
|
<InfoRow label={t('invoiceDetailModal.currency')} value={invoice.currency ?? 'EUR'} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dates */}
|
{/* Dates */}
|
||||||
<div className="rounded-xl border border-gray-100 bg-gray-50/50 p-4">
|
<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">
|
<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>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||||||
<DateChip label="Issued" value={invoice.issued_at} />
|
<DateChip label={t('invoiceDetailModal.issued')} value={invoice.issued_at} />
|
||||||
<DateChip label="Due" value={invoice.due_at} />
|
<DateChip label={t('invoiceDetailModal.due')} value={invoice.due_at} />
|
||||||
<DateChip label="Created" value={invoice.created_at} />
|
<DateChip label={t('invoiceDetailModal.created')} value={invoice.created_at} />
|
||||||
<DateChip label="Updated" value={invoice.updated_at} />
|
<DateChip label={t('invoiceDetailModal.updated')} value={invoice.updated_at} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Line items */}
|
{/* Line items */}
|
||||||
<div className="rounded-xl border border-gray-100 bg-gray-50/50 p-4">
|
<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">
|
<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>
|
</div>
|
||||||
{detailLoading ? (
|
{detailLoading ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -395,17 +397,17 @@ export default function InvoiceDetailModal({
|
|||||||
) : detailError ? (
|
) : detailError ? (
|
||||||
<div className="text-sm text-red-600">{detailError}</div>
|
<div className="text-sm text-red-600">{detailError}</div>
|
||||||
) : items.length === 0 ? (
|
) : 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">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full text-sm">
|
<table className="min-w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-xs uppercase text-gray-500 border-b border-gray-200">
|
<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">{t('invoiceDetailModal.description')}</th>
|
||||||
<th className="pb-2 pr-4 font-medium">Qty</th>
|
<th className="pb-2 pr-4 font-medium">{t('invoiceDetailModal.qty')}</th>
|
||||||
<th className="pb-2 pr-4 font-medium">Unit Price</th>
|
<th className="pb-2 pr-4 font-medium">{t('invoiceDetailModal.unitPrice')}</th>
|
||||||
<th className="pb-2 pr-4 font-medium">Tax</th>
|
<th className="pb-2 pr-4 font-medium">{t('invoiceDetailModal.tax')}</th>
|
||||||
<th className="pb-2 pr-4 font-medium text-right">Gross</th>
|
<th className="pb-2 pr-4 font-medium text-right">{t('invoiceDetailModal.gross')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100">
|
<tbody className="divide-y divide-gray-100">
|
||||||
@ -421,7 +423,7 @@ export default function InvoiceDetailModal({
|
|||||||
</tbody>
|
</tbody>
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr className="border-t border-gray-200">
|
<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>
|
<td className="pt-2 text-right font-bold text-[#1C2B4A]">{fmtMoney(invoice.total_gross)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
@ -434,17 +436,17 @@ export default function InvoiceDetailModal({
|
|||||||
{payments.length > 0 && (
|
{payments.length > 0 && (
|
||||||
<div className="rounded-xl border border-gray-100 bg-gray-50/50 p-4">
|
<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">
|
<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>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full text-sm">
|
<table className="min-w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-left text-xs uppercase text-gray-500 border-b border-gray-200">
|
<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">{t('invoiceDetailModal.method')}</th>
|
||||||
<th className="pb-2 pr-4 font-medium">Transaction</th>
|
<th className="pb-2 pr-4 font-medium">{t('invoiceDetailModal.transaction')}</th>
|
||||||
<th className="pb-2 pr-4 font-medium">Amount</th>
|
<th className="pb-2 pr-4 font-medium">{t('invoiceDetailModal.amount')}</th>
|
||||||
<th className="pb-2 pr-4 font-medium">Paid At</th>
|
<th className="pb-2 pr-4 font-medium">{t('invoiceDetailModal.paidAt')}</th>
|
||||||
<th className="pb-2 pr-4 font-medium">Status</th>
|
<th className="pb-2 pr-4 font-medium">{t('invoiceDetailModal.status')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100">
|
<tbody className="divide-y divide-gray-100">
|
||||||
@ -471,8 +473,8 @@ export default function InvoiceDetailModal({
|
|||||||
{invoice.context && (
|
{invoice.context && (
|
||||||
<details className="rounded-xl border border-gray-100 bg-gray-50/50 p-4 group">
|
<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">
|
<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
|
<ClockIcon className="h-4 w-4" /> {t('invoiceDetailModal.contextMetadata')}
|
||||||
<span className="text-xs font-normal text-gray-400 ml-1">(click to expand)</span>
|
<span className="text-xs font-normal text-gray-400 ml-1">{t('invoiceDetailModal.clickToExpand')}</span>
|
||||||
</summary>
|
</summary>
|
||||||
<pre className="mt-3 text-xs text-gray-600 overflow-x-auto whitespace-pre-wrap break-all max-h-48">
|
<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)}
|
{typeof invoice.context === 'string' ? invoice.context : JSON.stringify(invoice.context, null, 2)}
|
||||||
@ -488,20 +490,20 @@ export default function InvoiceDetailModal({
|
|||||||
onClick={() => onExport?.(invoice)}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => onRunPoolCheck?.(invoice.id)}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Dialog.Panel>
|
</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,
|
Squares2X2Icon,
|
||||||
BanknotesIcon,
|
BanknotesIcon,
|
||||||
ClipboardDocumentListIcon,
|
ClipboardDocumentListIcon,
|
||||||
CommandLineIcon
|
CommandLineIcon,
|
||||||
|
LanguageIcon
|
||||||
} from '@heroicons/react/24/outline'
|
} from '@heroicons/react/24/outline'
|
||||||
import { useMemo, useState, useEffect } from 'react'
|
import { useMemo, useState, useEffect } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useAdminUsers } from '../hooks/useAdminUsers'
|
import { useAdminUsers } from '../hooks/useAdminUsers'
|
||||||
import useAuthStore from '../store/authStore'
|
import useAuthStore from '../store/authStore'
|
||||||
|
import { useTranslation } from '../i18n/useTranslation'
|
||||||
|
|
||||||
// env-based feature flags
|
// env-based feature flags
|
||||||
const DISPLAY_MATRIX = process.env.NEXT_PUBLIC_DISPLAY_MATRIX !== 'false'
|
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() {
|
export default function AdminDashboardPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { t } = useTranslation()
|
||||||
const { userStats, isAdmin } = useAdminUsers()
|
const { userStats, isAdmin } = useAdminUsers()
|
||||||
const user = useAuthStore(s => s.user)
|
const user = useAuthStore(s => s.user)
|
||||||
const isAdminOrSuper =
|
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="min-h-screen flex items-center justify-center bg-blue-50">
|
||||||
<div className="text-center">
|
<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" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
@ -104,8 +107,8 @@ export default function AdminDashboardPage() {
|
|||||||
<div className="min-h-screen flex items-center justify-center bg-blue-50">
|
<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="mx-auto w-full max-w-xl rounded-2xl bg-white shadow ring-1 ring-red-500/20 p-8">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="text-2xl font-bold text-red-600 mb-2">Access Denied</h1>
|
<h1 className="text-2xl font-bold text-red-600 mb-2">{t('adminDashboard.accessDenied')}</h1>
|
||||||
<p className="text-gray-600">You need admin privileges to access this page.</p>
|
<p className="text-gray-600">{t('adminDashboard.accessDeniedMessage')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -120,9 +123,9 @@ export default function AdminDashboardPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="flex flex-col gap-4 mb-8">
|
<header className="flex flex-col gap-4 mb-8">
|
||||||
<div>
|
<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">
|
<p className="text-lg text-blue-700 mt-2">
|
||||||
Manage all administrative features, user management, permissions, and global settings.
|
{t('adminDashboard.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@ -132,10 +135,10 @@ export default function AdminDashboardPage() {
|
|||||||
<ExclamationTriangleIcon className="h-6 w-6 flex-shrink-0 text-red-500 mt-0.5" />
|
<ExclamationTriangleIcon className="h-6 w-6 flex-shrink-0 text-red-500 mt-0.5" />
|
||||||
<div className="leading-relaxed">
|
<div className="leading-relaxed">
|
||||||
<p className="font-semibold mb-0.5">
|
<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>
|
||||||
<p className="text-red-600/80 hidden sm:block">
|
<p className="text-red-600/80 hidden sm:block">
|
||||||
Manage all administrative features, user management, permissions, and global settings.
|
{t('adminDashboard.warningMessage')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -143,27 +146,27 @@ export default function AdminDashboardPage() {
|
|||||||
{/* Stats Card */}
|
{/* Stats Card */}
|
||||||
<div className="mb-8 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-6">
|
<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="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 className="text-xl font-semibold text-blue-900">{displayStats.totalUsers}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
<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 className="text-xl font-semibold text-indigo-700">{displayStats.adminUsers}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
<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 className="text-xl font-semibold text-green-700">{displayStats.activeUsers}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
<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 className="text-xl font-semibold text-amber-700">{displayStats.verificationPending}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
<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 className="text-xl font-semibold text-blue-700">{displayStats.personalUsers}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-white border border-gray-100 p-5 text-center shadow">
|
<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 className="text-xl font-semibold text-purple-700">{displayStats.companyUsers}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -176,9 +179,9 @@ export default function AdminDashboardPage() {
|
|||||||
<Squares2X2Icon className="h-7 w-7 text-blue-600" />
|
<Squares2X2Icon className="h-7 w-7 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<p className="text-sm text-blue-700 mt-0.5">
|
||||||
Quick access to common admin modules.
|
{t('adminDashboard.managementShortcutsSubtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -205,11 +208,11 @@ export default function AdminDashboardPage() {
|
|||||||
<Squares2X2Icon className={`h-6 w-6 ${DISPLAY_MATRIX ? 'text-blue-600' : 'text-gray-400'}`} />
|
<Squares2X2Icon className={`h-6 w-6 ${DISPLAY_MATRIX ? 'text-blue-600' : 'text-gray-400'}`} />
|
||||||
</span>
|
</span>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="text-base font-semibold text-blue-900">Matrix Management</div>
|
<div className="text-base font-semibold text-blue-900">{t('adminDashboard.matrixManagement')}</div>
|
||||||
<div className="text-xs text-blue-700">Configure matrices and users</div>
|
<div className="text-xs text-blue-700">{t('adminDashboard.matrixManagementDesc')}</div>
|
||||||
{!DISPLAY_MATRIX && (
|
{!DISPLAY_MATRIX && (
|
||||||
<p className="mt-1 text-xs text-gray-500 italic">
|
<p className="mt-1 text-xs text-gray-500 italic">
|
||||||
This module is currently disabled in the system configuration.
|
{t('adminDashboard.moduleDisabled')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -243,11 +246,11 @@ export default function AdminDashboardPage() {
|
|||||||
<BanknotesIcon className={`h-6 w-6 ${DISPLAY_ABONEMENTS ? 'text-amber-600' : 'text-gray-400'}`} />
|
<BanknotesIcon className={`h-6 w-6 ${DISPLAY_ABONEMENTS ? 'text-amber-600' : 'text-gray-400'}`} />
|
||||||
</span>
|
</span>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="text-base font-semibold text-amber-900">Coffee Subscription Management</div>
|
<div className="text-base font-semibold text-amber-900">{t('adminDashboard.coffeeSubscriptions')}</div>
|
||||||
<div className="text-xs text-amber-700">Plans, billing and renewals</div>
|
<div className="text-xs text-amber-700">{t('adminDashboard.coffeeSubscriptionsDesc')}</div>
|
||||||
{!DISPLAY_ABONEMENTS && (
|
{!DISPLAY_ABONEMENTS && (
|
||||||
<p className="mt-1 text-xs text-gray-500 italic">
|
<p className="mt-1 text-xs text-gray-500 italic">
|
||||||
This module is currently disabled in the system configuration.
|
{t('adminDashboard.moduleDisabled')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -272,8 +275,8 @@ export default function AdminDashboardPage() {
|
|||||||
<ClipboardDocumentListIcon className="h-6 w-6 text-indigo-600" />
|
<ClipboardDocumentListIcon className="h-6 w-6 text-indigo-600" />
|
||||||
</span>
|
</span>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="text-base font-semibold text-indigo-900">Contract Management</div>
|
<div className="text-base font-semibold text-indigo-900">{t('adminDashboard.contractManagement')}</div>
|
||||||
<div className="text-xs text-indigo-700">Templates, approvals, status</div>
|
<div className="text-xs text-indigo-700">{t('adminDashboard.contractManagementDesc')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ArrowRightIcon className="h-5 w-5 text-indigo-600 opacity-70 group-hover:opacity-100" />
|
<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" />
|
<Squares2X2Icon className="h-6 w-6 text-blue-600" />
|
||||||
</span>
|
</span>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="text-base font-semibold text-blue-900">Dashboard Management</div>
|
<div className="text-base font-semibold text-blue-900">{t('adminDashboard.dashboardManagement')}</div>
|
||||||
<div className="text-xs text-blue-700">Configure dashboard platforms</div>
|
<div className="text-xs text-blue-700">{t('adminDashboard.dashboardManagementDesc')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ArrowRightIcon className="h-5 w-5 text-blue-600 opacity-70 group-hover:opacity-100" />
|
<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" />
|
<UsersIcon className="h-6 w-6 text-blue-600" />
|
||||||
</span>
|
</span>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="text-base font-semibold text-blue-900">User Management</div>
|
<div className="text-base font-semibold text-blue-900">{t('adminDashboard.userManagement')}</div>
|
||||||
<div className="text-xs text-blue-700">Browse, search, and manage all users</div>
|
<div className="text-xs text-blue-700">{t('adminDashboard.userManagementDesc')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ArrowRightIcon className="h-5 w-5 text-blue-600 opacity-70 group-hover:opacity-100" />
|
<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" />
|
<ExclamationTriangleIcon className="h-6 w-6 text-rose-600" />
|
||||||
</span>
|
</span>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="text-base font-semibold text-rose-900">User Verify</div>
|
<div className="text-base font-semibold text-rose-900">{t('adminDashboard.userVerify')}</div>
|
||||||
<div className="text-xs text-rose-700">Review and verify user onboarding status</div>
|
<div className="text-xs text-rose-700">{t('adminDashboard.userVerifyDesc')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ArrowRightIcon className="h-5 w-5 text-rose-600 opacity-70 group-hover:opacity-100" />
|
<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" />
|
<BanknotesIcon className="h-6 w-6 text-emerald-600" />
|
||||||
</span>
|
</span>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="text-base font-semibold text-emerald-900">Finance Management</div>
|
<div className="text-base font-semibold text-emerald-900">{t('adminDashboard.financeManagement')}</div>
|
||||||
<div className="text-xs text-emerald-700">Tax rates, billing settings and finance tools</div>
|
<div className="text-xs text-emerald-700">{t('adminDashboard.financeManagementDesc')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ArrowRightIcon className="h-5 w-5 text-emerald-600 opacity-70 group-hover:opacity-100" />
|
<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" />
|
<ServerStackIcon className="h-6 w-6 text-cyan-600" />
|
||||||
</span>
|
</span>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="text-base font-semibold text-cyan-900">Pool Management</div>
|
<div className="text-base font-semibold text-cyan-900">{t('adminDashboard.poolManagement')}</div>
|
||||||
<div className="text-xs text-cyan-700">Manage pool structures and assignments</div>
|
<div className="text-xs text-cyan-700">{t('adminDashboard.poolManagementDesc')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ArrowRightIcon className="h-5 w-5 text-cyan-600 opacity-70 group-hover:opacity-100" />
|
<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" />
|
<UsersIcon className="h-6 w-6 text-violet-600" />
|
||||||
</span>
|
</span>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="text-base font-semibold text-violet-900">Affiliate Management</div>
|
<div className="text-base font-semibold text-violet-900">{t('adminDashboard.affiliateManagement')}</div>
|
||||||
<div className="text-xs text-violet-700">Partner content and affiliate controls</div>
|
<div className="text-xs text-violet-700">{t('adminDashboard.affiliateManagementDesc')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ArrowRightIcon className="h-5 w-5 text-violet-600 opacity-70 group-hover:opacity-100" />
|
<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'}`} />
|
<ClipboardDocumentListIcon className={`h-6 w-6 ${DISPLAY_NEWS ? 'text-green-600' : 'text-gray-400'}`} />
|
||||||
</span>
|
</span>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="text-base font-semibold text-green-900">News Management</div>
|
<div className="text-base font-semibold text-green-900">{t('adminDashboard.newsManagement')}</div>
|
||||||
<div className="text-xs text-green-700">Create and manage news articles</div>
|
<div className="text-xs text-green-700">{t('adminDashboard.newsManagementDesc')}</div>
|
||||||
{!DISPLAY_NEWS && (
|
{!DISPLAY_NEWS && (
|
||||||
<p className="mt-1 text-xs text-gray-500 italic">
|
<p className="mt-1 text-xs text-gray-500 italic">
|
||||||
This module is currently disabled in the system configuration.
|
{t('adminDashboard.moduleDisabled')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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'}`} />
|
<CommandLineIcon className={`h-6 w-6 ${DISPLAY_DEV_MANAGEMENT && isAdminOrSuper ? 'text-slate-600' : 'text-gray-400'}`} />
|
||||||
</span>
|
</span>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="text-base font-semibold text-slate-900">Dev Management</div>
|
<div className="text-base font-semibold text-slate-900">{t('adminDashboard.devManagement')}</div>
|
||||||
<div className="text-xs text-slate-700">Run SQL queries and dev tools</div>
|
<div className="text-xs text-slate-700">{t('adminDashboard.devManagementDesc')}</div>
|
||||||
{!DISPLAY_DEV_MANAGEMENT && (
|
{!DISPLAY_DEV_MANAGEMENT && (
|
||||||
<p className="mt-1 text-xs text-gray-500 italic">
|
<p className="mt-1 text-xs text-gray-500 italic">
|
||||||
This module is currently disabled in the system configuration.
|
{t('adminDashboard.moduleDisabled')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{DISPLAY_DEV_MANAGEMENT && !isAdminOrSuper && (
|
{DISPLAY_DEV_MANAGEMENT && !isAdminOrSuper && (
|
||||||
<p className="mt-1 text-xs text-gray-500 italic">
|
<p className="mt-1 text-xs text-gray-500 italic">
|
||||||
Admin access required.
|
{t('adminDashboard.adminAccessRequired')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -467,6 +470,24 @@ export default function AdminDashboardPage() {
|
|||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -479,10 +500,10 @@ export default function AdminDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900">
|
<h2 className="text-lg font-semibold text-gray-900">
|
||||||
Server Status & Logs
|
{t('adminDashboard.serverStatusLogs')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-500 mt-0.5">
|
<p className="text-sm text-gray-500 mt-0.5">
|
||||||
System health, resource usage & recent error insights.
|
{t('adminDashboard.serverStatusLogsSubtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -493,20 +514,20 @@ export default function AdminDashboardPage() {
|
|||||||
<div className="flex items-center gap-3">
|
<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'}`} />
|
<span className={`h-2.5 w-2.5 rounded-full ${serverStats.status === 'Online' ? 'bg-emerald-500' : 'bg-red-500'}`} />
|
||||||
<p className="text-base">
|
<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'}>
|
<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>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm space-y-1 text-gray-600">
|
<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">{t('adminDashboard.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">{t('adminDashboard.cpuUsage')}</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.memoryUsage')}</span> {serverStats.memory} GB</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||||
<CpuChipIcon className="h-4 w-4" />
|
<CpuChipIcon className="h-4 w-4" />
|
||||||
<span>Autoscaled environment (mock)</span>
|
<span>{t('adminDashboard.autoscaledEnvironment')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -516,11 +537,11 @@ export default function AdminDashboardPage() {
|
|||||||
{/* Logs */}
|
{/* Logs */}
|
||||||
<div className="lg:col-span-2">
|
<div className="lg:col-span-2">
|
||||||
<h3 className="text-base font-semibold text-gray-800 mb-3">
|
<h3 className="text-base font-semibold text-gray-800 mb-3">
|
||||||
Recent Error Logs
|
{t('adminDashboard.recentErrorLogs')}
|
||||||
</h3>
|
</h3>
|
||||||
{serverStats.recentErrors.length === 0 && (
|
{serverStats.recentErrors.length === 0 && (
|
||||||
<p className="text-sm text-gray-500 italic">
|
<p className="text-sm text-gray-500 italic">
|
||||||
No recent logs.
|
{t('adminDashboard.noRecentLogs')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{/* Placeholder for future logs list */}
|
{/* Placeholder for future logs list */}
|
||||||
@ -532,7 +553,7 @@ export default function AdminDashboardPage() {
|
|||||||
// TODO: navigate to logs / monitoring page
|
// TODO: navigate to logs / monitoring page
|
||||||
onClick={() => {}}
|
onClick={() => {}}
|
||||||
>
|
>
|
||||||
View Full Logs
|
{t('adminDashboard.viewFullLogs')}
|
||||||
<ArrowRightIcon className="h-5 w-5" />
|
<ArrowRightIcon className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/react';
|
||||||
import { ChevronDownIcon } from '@heroicons/react/20/solid';
|
import { ChevronDownIcon } from '@heroicons/react/20/solid';
|
||||||
import { useTranslation } from '../i18n/useTranslation';
|
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 {
|
interface LanguageSwitcherProps {
|
||||||
variant?: 'light' | 'dark';
|
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) {
|
export default function LanguageSwitcher({ variant = 'light' }: LanguageSwitcherProps) {
|
||||||
const { language, setLanguage } = useTranslation();
|
const { language, setLanguage, customI18n } = useTranslation();
|
||||||
|
|
||||||
const getButtonStyles = () => {
|
// Combine built-in + custom languages (deduplicated by code)
|
||||||
if (variant === 'dark') {
|
const allLangs: LangEntry[] = [
|
||||||
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';
|
...Object.entries(BUILTIN_LANG_INFO).map(([code, info]) => ({ code, ...info })),
|
||||||
}
|
...customI18n.languages
|
||||||
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';
|
.filter((l) => !BUILTIN_LANG_INFO[l.code])
|
||||||
};
|
.map((l) => ({ code: l.code, name: l.name, flag: l.flag ?? '🏳️' })),
|
||||||
|
];
|
||||||
|
|
||||||
const getMenuStyles = () => {
|
const activeLang: LangEntry =
|
||||||
if (variant === 'dark') {
|
allLangs.find((l) => l.code === language) ?? { code: language, name: language, flag: '🏳️' };
|
||||||
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 getItemStyles = (isActive: boolean) => {
|
const buttonCls =
|
||||||
if (variant === 'dark') {
|
variant === 'dark'
|
||||||
return `group flex items-center px-4 py-2 text-sm ${
|
? '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'
|
||||||
isActive
|
: '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';
|
||||||
? 'bg-[#8D6B1D] text-white'
|
|
||||||
: 'text-gray-300 data-focus:bg-white/5 data-focus:text-white data-focus:outline-hidden'
|
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'
|
||||||
return `group flex items-center px-4 py-2 text-sm ${
|
: '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';
|
||||||
isActive
|
|
||||||
? 'bg-[#8D6B1D] text-white'
|
const itemCls = (isActive: boolean) =>
|
||||||
: 'text-gray-700 data-focus:bg-gray-100 data-focus:text-gray-900 data-focus:outline-hidden'
|
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 (
|
return (
|
||||||
<Menu as="div" className="relative inline-block">
|
<Menu as="div" className="relative inline-block">
|
||||||
<MenuButton className={getButtonStyles()}>
|
<MenuButton className={buttonCls}>
|
||||||
<FlagIcon countryCode={language} className="size-4" />
|
<span>{activeLang.name}</span>
|
||||||
{LANGUAGE_NAMES[language]}
|
<ChevronDownIcon aria-hidden="true" className="size-4 opacity-60" />
|
||||||
<ChevronDownIcon aria-hidden="true" className="-mr-1 size-5 text-gray-500" />
|
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
|
|
||||||
<MenuItems
|
<MenuItems transition className={menuCls}>
|
||||||
transition
|
{allLangs.map((lang) => (
|
||||||
className={getMenuStyles()}
|
<MenuItem key={lang.code}>
|
||||||
>
|
<button onClick={() => setLanguage(lang.code)} className={itemCls(language === lang.code)}>
|
||||||
<div className="py-1">
|
<span className="flex-1 text-left">{lang.name}</span>
|
||||||
{SUPPORTED_LANGUAGES.map((lang) => (
|
{language === lang.code && <span className="text-xs font-bold">✓</span>}
|
||||||
<MenuItem key={lang}>
|
</button>
|
||||||
<button
|
</MenuItem>
|
||||||
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>
|
</MenuItems>
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import {
|
|||||||
} from '@heroicons/react/24/outline'
|
} from '@heroicons/react/24/outline'
|
||||||
import { AdminAPI, DetailedUserInfo } from '../utils/api'
|
import { AdminAPI, DetailedUserInfo } from '../utils/api'
|
||||||
import useAuthStore from '../store/authStore'
|
import useAuthStore from '../store/authStore'
|
||||||
|
import { useTranslation } from '../i18n/useTranslation'
|
||||||
import ConfirmActionModal from './modals/ConfirmActionModal'
|
import ConfirmActionModal from './modals/ConfirmActionModal'
|
||||||
|
|
||||||
interface UserDetailModalProps {
|
interface UserDetailModalProps {
|
||||||
@ -47,6 +48,7 @@ const STATUS_OPTIONS: { value: UserStatus; label: string; color: string }[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated }: UserDetailModalProps) {
|
export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated }: UserDetailModalProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [userDetails, setUserDetails] = useState<DetailedUserInfo | null>(null)
|
const [userDetails, setUserDetails] = useState<DetailedUserInfo | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@ -399,7 +401,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
<div className="flex">
|
<div className="flex">
|
||||||
<XCircleIcon className="h-5 w-5 text-red-400" aria-hidden="true" />
|
<XCircleIcon className="h-5 w-5 text-red-400" aria-hidden="true" />
|
||||||
<div className="ml-3">
|
<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 className="mt-2 text-sm text-red-700">{error}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -430,14 +432,14 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
? 'bg-blue-100 text-blue-800'
|
? 'bg-blue-100 text-blue-800'
|
||||||
: 'bg-purple-100 text-purple-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>
|
||||||
<span className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
|
<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'
|
userDetails.user.role === 'admin' || userDetails.user.role === 'super_admin'
|
||||||
? 'bg-yellow-100 text-yellow-800'
|
? 'bg-yellow-100 text-yellow-800'
|
||||||
: 'bg-gray-100 text-gray-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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -446,7 +448,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
{/* Status Badge */}
|
{/* Status Badge */}
|
||||||
{userDetails.userStatus && (
|
{userDetails.userStatus && (
|
||||||
<div className="bg-white rounded-lg px-4 py-3 text-gray-900">
|
<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 ${
|
<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))
|
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">
|
<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">
|
<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" />
|
<ShieldCheckIcon className="h-5 w-5 text-indigo-600" />
|
||||||
Admin Controls
|
{t('userDetailModal.adminControls')}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{missingIdOrContract && (
|
{missingIdOrContract && (
|
||||||
@ -486,7 +488,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
{/* Status Dropdown */}
|
{/* Status Dropdown */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Change Status
|
{t('userDetailModal.changeStatus')}
|
||||||
</label>
|
</label>
|
||||||
<Listbox value={selectedStatus} onChange={handleStatusChange} disabled={saving}>
|
<Listbox value={selectedStatus} onChange={handleStatusChange} disabled={saving}>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@ -538,20 +540,20 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
{/* Admin Verification Toggle */}
|
{/* Admin Verification Toggle */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Admin Verification
|
{t('userDetailModal.adminVerification')}
|
||||||
</label>
|
</label>
|
||||||
{userDetails?.userStatus && (
|
{userDetails?.userStatus && (
|
||||||
<p className="text-xs text-gray-500 mb-2">
|
<p className="text-xs text-gray-500 mb-2">
|
||||||
{canVerify
|
{canVerify
|
||||||
? 'All steps completed. You can verify this user.'
|
? t('userDetailModal.allStepsCompleted')
|
||||||
: 'User has not yet completed all required steps.'}
|
: t('userDetailModal.stepsNotCompleted')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleToggleAdminVerification}
|
onClick={handleToggleAdminVerification}
|
||||||
disabled={saving || !canVerify}
|
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 ${
|
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
|
userDetails.userStatus?.is_admin_verified === 1
|
||||||
? 'bg-amber-600 hover:bg-amber-500 text-white focus-visible:outline-amber-600'
|
? '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 ? (
|
{saving ? (
|
||||||
<>
|
<>
|
||||||
<div className="h-4 w-4 border-2 border-white border-b-transparent rounded-full animate-spin" />
|
<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" />
|
<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>
|
</button>
|
||||||
@ -580,7 +582,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<DocumentTextIcon className="h-5 w-5 text-gray-600" />
|
<DocumentTextIcon className="h-5 w-5 text-gray-600" />
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<div className="flex items-center gap-1 rounded-full border border-gray-200 bg-white px-1">
|
||||||
{(['contract','gdpr'] as const).map((tab) => (
|
{(['contract','gdpr'] as const).map((tab) => (
|
||||||
<button
|
<button
|
||||||
@ -589,7 +591,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
onClick={() => setActivePreviewTab(tab)}
|
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'}`}
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -607,7 +609,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
disabled={previewState[activePreviewTab].loading}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -621,7 +623,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
disabled={!previewState[activePreviewTab]?.html}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -635,23 +637,23 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
return (
|
return (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => loadContractFiles()}
|
onClick={() => loadContractFiles()}
|
||||||
disabled={docsLoading}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{docsLoading && (
|
{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 && (
|
{!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 && (
|
{!docsLoading && files.length > 0 && (
|
||||||
@ -676,7 +678,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
|
|
||||||
{selectedItem && (
|
{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="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 && (
|
{files.length >= 1 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -684,7 +686,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
disabled={isMoving}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -706,7 +708,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
)}
|
)}
|
||||||
{previewState[activePreviewTab].loading && (
|
{previewState[activePreviewTab].loading && (
|
||||||
<div className="flex items-center justify-center h-40 text-sm text-gray-500">
|
<div className="flex items-center justify-center h-40 text-sm text-gray-500">
|
||||||
Loading preview…
|
{t('userDetailModal.loadingPreviewText')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!previewState[activePreviewTab].loading && previewState[activePreviewTab].html && (
|
{!previewState[activePreviewTab].loading && previewState[activePreviewTab].html && (
|
||||||
@ -719,7 +721,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!previewState[activePreviewTab].loading && !previewState[activePreviewTab].html && !previewState[activePreviewTab].error && (
|
{!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>
|
||||||
</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">
|
<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">
|
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||||
<UserIcon className="h-5 w-5 text-gray-600" />
|
<UserIcon className="h-5 w-5 text-gray-600" />
|
||||||
Personal Information
|
{t('userDetailModal.personalInformation')}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-5">
|
<div className="px-6 py-5">
|
||||||
<dl className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-5">
|
<dl className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-5">
|
||||||
<div>
|
<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>
|
<dd className="text-sm text-gray-900 font-medium">{userDetails.personalProfile.first_name || 'N/A'}</dd>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<dd className="text-sm text-gray-900 font-medium">{userDetails.personalProfile.last_name || 'N/A'}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-gray-500 mb-1.5">
|
<dt className="text-sm font-medium text-gray-500 mb-1.5">
|
||||||
<PhoneIcon className="h-4 w-4 inline mr-1.5" />
|
<PhoneIcon className="h-4 w-4 inline mr-1.5" />
|
||||||
Phone
|
{t('userDetailModal.phone')}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-sm text-gray-900 font-medium">{userDetails.personalProfile.phone || 'N/A'}</dd>
|
<dd className="text-sm text-gray-900 font-medium">{userDetails.personalProfile.phone || 'N/A'}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-gray-500 mb-1.5">
|
<dt className="text-sm font-medium text-gray-500 mb-1.5">
|
||||||
<CalendarIcon className="h-4 w-4 inline mr-1.5" />
|
<CalendarIcon className="h-4 w-4 inline mr-1.5" />
|
||||||
Date of Birth
|
{t('userDetailModal.dateOfBirth')}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-sm text-gray-900 font-medium">{formatDate(userDetails.personalProfile.date_of_birth)}</dd>
|
<dd className="text-sm text-gray-900 font-medium">{formatDate(userDetails.personalProfile.date_of_birth)}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<dt className="text-sm font-medium text-gray-500 mb-1.5">
|
<dt className="text-sm font-medium text-gray-500 mb-1.5">
|
||||||
<MapPinIcon className="h-4 w-4 inline mr-1.5" />
|
<MapPinIcon className="h-4 w-4 inline mr-1.5" />
|
||||||
Address
|
{t('userDetailModal.address')}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-sm text-gray-900 font-medium">
|
<dd className="text-sm text-gray-900 font-medium">
|
||||||
{userDetails.personalProfile.address || 'N/A'}
|
{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">
|
<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">
|
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||||
<BuildingOfficeIcon className="h-5 w-5 text-gray-600" />
|
<BuildingOfficeIcon className="h-5 w-5 text-gray-600" />
|
||||||
Company Information
|
{t('userDetailModal.companyInformation')}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-5">
|
<div className="px-6 py-5">
|
||||||
<dl className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-5">
|
<dl className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-5">
|
||||||
<div>
|
<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>
|
<dd className="text-sm text-gray-900 font-medium">{userDetails.companyProfile.company_name || 'N/A'}</dd>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<dd className="text-sm text-gray-900 font-medium">{userDetails.companyProfile.registration_number || 'N/A'}</dd>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<dd className="text-sm text-gray-900 font-medium">{userDetails.companyProfile.tax_id || 'N/A'}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-gray-500 mb-1.5">
|
<dt className="text-sm font-medium text-gray-500 mb-1.5">
|
||||||
<PhoneIcon className="h-4 w-4 inline mr-1.5" />
|
<PhoneIcon className="h-4 w-4 inline mr-1.5" />
|
||||||
Phone
|
{t('userDetailModal.phone')}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-sm text-gray-900 font-medium">{userDetails.companyProfile.phone || 'N/A'}</dd>
|
<dd className="text-sm text-gray-900 font-medium">{userDetails.companyProfile.phone || 'N/A'}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<dt className="text-sm font-medium text-gray-500 mb-1.5">
|
<dt className="text-sm font-medium text-gray-500 mb-1.5">
|
||||||
<MapPinIcon className="h-4 w-4 inline mr-1.5" />
|
<MapPinIcon className="h-4 w-4 inline mr-1.5" />
|
||||||
Address
|
{t('userDetailModal.address')}
|
||||||
</dt>
|
</dt>
|
||||||
<dd className="text-sm text-gray-900 font-medium">
|
<dd className="text-sm text-gray-900 font-medium">
|
||||||
{userDetails.companyProfile.address || 'N/A'}
|
{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">
|
<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">
|
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||||
<CheckCircleIcon className="h-5 w-5 text-gray-600" />
|
<CheckCircleIcon className="h-5 w-5 text-gray-600" />
|
||||||
Registration Progress
|
{t('userDetailModal.registrationProgress')}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-5">
|
<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" />
|
<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>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{userDetails.userStatus.profile_completed === 1 ? (
|
{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" />
|
<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>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{userDetails.userStatus.documents_uploaded === 1 ? (
|
{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" />
|
<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>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{userDetails.userStatus.contract_signed === 1 ? (
|
{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" />
|
<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>
|
</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">
|
<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">
|
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||||
<ShieldCheckIcon className="h-5 w-5 text-gray-600" />
|
<ShieldCheckIcon className="h-5 w-5 text-gray-600" />
|
||||||
Permissions ({selectedPermissions.length})
|
{t('userDetailModal.permissions')} ({selectedPermissions.length})
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -882,7 +884,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
disabled={permissionsSaving || permissionsLoading}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 py-5">
|
<div className="px-6 py-5">
|
||||||
@ -894,10 +896,10 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
{permissionsLoading ? (
|
{permissionsLoading ? (
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
<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" />
|
<div className="h-4 w-4 border-2 border-gray-400 border-b-transparent rounded-full animate-spin" />
|
||||||
Loading permissions…
|
{t('userDetailModal.loadingPermissions')}
|
||||||
</div>
|
</div>
|
||||||
) : allPermissions.length === 0 ? (
|
) : 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">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
{allPermissions.map((perm) => {
|
{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>
|
<div className="text-xs text-gray-500 mt-0.5">{perm.description}</div>
|
||||||
)}
|
)}
|
||||||
{!perm.is_active && (
|
{!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>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
@ -941,7 +943,7 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
onClick={onClose}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -957,14 +959,14 @@ export default function UserDetailModal({ isOpen, onClose, userId, onUserUpdated
|
|||||||
<ConfirmActionModal
|
<ConfirmActionModal
|
||||||
open={Boolean(moveConfirm)}
|
open={Boolean(moveConfirm)}
|
||||||
pending={Boolean(moveConfirm && moveLoading[(moveConfirm.objectKey || String(moveConfirm.documentId || ''))])}
|
pending={Boolean(moveConfirm && moveLoading[(moveConfirm.objectKey || String(moveConfirm.documentId || ''))])}
|
||||||
title={`Move document to ${moveConfirm?.targetType === 'gdpr' ? 'GDPR' : 'Contract'}?`}
|
title={`${t('userDetailModal.moveDocumentTitle')} ${moveConfirm?.targetType === 'gdpr' ? 'GDPR' : 'Contract'}?`}
|
||||||
description="This will reclassify the selected document under the chosen contract type."
|
description={t('userDetailModal.moveDocumentDescription')}
|
||||||
confirmText="Move document"
|
confirmText={t('userDetailModal.moveDocumentConfirm')}
|
||||||
onClose={() => setMoveConfirm(null)}
|
onClose={() => setMoveConfirm(null)}
|
||||||
onConfirm={confirmMoveContractDoc}
|
onConfirm={confirmMoveContractDoc}
|
||||||
extraContent={
|
extraContent={
|
||||||
moveConfirm?.filename ? (
|
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
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||||
import { useRouter, usePathname } from 'next/navigation'
|
import { useRouter, usePathname } from 'next/navigation'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import {
|
import {
|
||||||
@ -21,6 +21,8 @@ import {
|
|||||||
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||||
import useAuthStore from '../../store/authStore'
|
import useAuthStore from '../../store/authStore'
|
||||||
import { Avatar } from '../avatar'
|
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)
|
// ENV-BASED FEATURE FLAGS (string envs: treat "false" as off, everything else as on)
|
||||||
const DISPLAY_NEWS = process.env.NEXT_PUBLIC_DISPLAY_NEWS !== 'false'
|
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
|
// Information dropdown, controlled by env flags
|
||||||
const informationItems = [
|
const informationItems = [
|
||||||
{ name: 'Affiliate-Links', href: '/affiliate-links', description: 'Browse our partner links' },
|
{ labelKey: 'nav.affiliateLinks', href: '/affiliate-links' },
|
||||||
...(DISPLAY_MEMBERSHIP
|
...(DISPLAY_MEMBERSHIP
|
||||||
? [{ name: 'Memberships', href: '/memberships', description: 'Explore membership options' }]
|
? [{ labelKey: 'nav.memberships', href: '/memberships' }]
|
||||||
: []),
|
: []),
|
||||||
...(DISPLAY_ABOUT_US
|
...(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
|
// Top-level navigation links, controlled by env flags
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
...(DISPLAY_NEWS ? [{ name: 'News', href: '/news' }] : []),
|
...(DISPLAY_NEWS ? [{ labelKey: 'nav.news', href: '/news' }] : []),
|
||||||
]
|
]
|
||||||
|
|
||||||
// Toggle visibility of Shop navigation across header (desktop + mobile)
|
// Toggle visibility of Shop navigation across header (desktop + mobile)
|
||||||
@ -55,6 +57,7 @@ interface HeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||||
const [mounted, setMounted] = useState(false)
|
const [mounted, setMounted] = useState(false)
|
||||||
const [animateIn, setAnimateIn] = useState(false)
|
const [animateIn, setAnimateIn] = useState(false)
|
||||||
@ -84,6 +87,16 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
|||||||
const [canSeeDashboard, setCanSeeDashboard] = useState(false)
|
const [canSeeDashboard, setCanSeeDashboard] = useState(false)
|
||||||
const headerElRef = useRef<HTMLElement | null>(null)
|
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 () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
// start global logout transition
|
// start global logout transition
|
||||||
@ -482,7 +495,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
|||||||
<PopoverGroup className="hidden lg:flex lg:gap-x-12">
|
<PopoverGroup className="hidden lg:flex lg:gap-x-12">
|
||||||
|
|
||||||
{/* Navigation Links */}
|
{/* Navigation Links */}
|
||||||
{navLinks.map((link) => (
|
{translatedNavLinks.map((link) => (
|
||||||
<button
|
<button
|
||||||
key={link.href}
|
key={link.href}
|
||||||
onClick={() => router.push(link.href)}
|
onClick={() => router.push(link.href)}
|
||||||
@ -526,10 +539,13 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
|||||||
onClick={() => router.push('/login')}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Language switcher (desktop) */}
|
||||||
|
<LanguageSwitcher variant="dark" />
|
||||||
|
|
||||||
{/* Desktop hamburger (right side) */}
|
{/* Desktop hamburger (right side) */}
|
||||||
<button
|
<button
|
||||||
type="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"
|
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>
|
</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"
|
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>
|
||||||
)}
|
)}
|
||||||
<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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -692,11 +708,11 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
|||||||
{/* Information disclosure */}
|
{/* Information disclosure */}
|
||||||
<Disclosure as="div">
|
<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">
|
<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" />
|
<ChevronDownIcon aria-hidden="true" className="size-5 flex-none group-data-open:rotate-180" />
|
||||||
</DisclosureButton>
|
</DisclosureButton>
|
||||||
<DisclosurePanel className="mt-2 space-y-1">
|
<DisclosurePanel className="mt-2 space-y-1">
|
||||||
{informationItems.map(item => (
|
{translatedInformationItems.map(item => (
|
||||||
<DisclosureButton
|
<DisclosureButton
|
||||||
key={item.name}
|
key={item.name}
|
||||||
as="button"
|
as="button"
|
||||||
@ -710,7 +726,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
|||||||
</Disclosure>
|
</Disclosure>
|
||||||
|
|
||||||
{/* Navigation Links */}
|
{/* Navigation Links */}
|
||||||
{navLinks.map((link) => (
|
{translatedNavLinks.map((link) => (
|
||||||
<button
|
<button
|
||||||
key={link.href}
|
key={link.href}
|
||||||
onClick={() => { router.push(link.href); setMobileMenuOpen(false); }}
|
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); }}
|
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"
|
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>
|
</button>
|
||||||
{DISPLAY_MATRIX && (
|
{DISPLAY_MATRIX && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { router.push('/personal-matrix'); setMobileMenuOpen(false); }}
|
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"
|
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>
|
</button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@ -744,7 +760,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
|||||||
onClick={() => { router.push('/coffee-abonnements'); setMobileMenuOpen(false); }}
|
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"
|
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>
|
</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="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">
|
<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">
|
<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>
|
</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="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">
|
<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); }}
|
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"
|
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>
|
</button>
|
||||||
<p className="px-2 py-1 text-xs text-slate-500 dark:text-indigo-100/70">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -778,11 +794,11 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
|||||||
{/* Information disclosure */}
|
{/* Information disclosure */}
|
||||||
<Disclosure as="div">
|
<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">
|
<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" />
|
<ChevronDownIcon aria-hidden="true" className="size-5 flex-none group-data-open:rotate-180" />
|
||||||
</DisclosureButton>
|
</DisclosureButton>
|
||||||
<DisclosurePanel className="mt-2 space-y-1">
|
<DisclosurePanel className="mt-2 space-y-1">
|
||||||
{informationItems.map(item => (
|
{translatedInformationItems.map(item => (
|
||||||
<DisclosureButton
|
<DisclosureButton
|
||||||
key={item.name}
|
key={item.name}
|
||||||
as="button"
|
as="button"
|
||||||
@ -795,7 +811,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
|||||||
</DisclosurePanel>
|
</DisclosurePanel>
|
||||||
</Disclosure>
|
</Disclosure>
|
||||||
{/* Navigation Links */}
|
{/* Navigation Links */}
|
||||||
{navLinks.map((link) => (
|
{translatedNavLinks.map((link) => (
|
||||||
<button
|
<button
|
||||||
key={link.href}
|
key={link.href}
|
||||||
onClick={() => { router.push(link.href); setMobileMenuOpen(false); }}
|
onClick={() => { router.push(link.href); setMobileMenuOpen(false); }}
|
||||||
@ -809,7 +825,7 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
|||||||
onClick={() => { router.push('/login'); setMobileMenuOpen(false); }}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -817,18 +833,22 @@ export default function Header({ setGlobalLoggingOut }: HeaderProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sticky bottom logout button with pulsating hover */}
|
{/* Sticky bottom: language switcher + logout */}
|
||||||
{user && (
|
<div className="border-t border-gray-200/60 dark:border-white/10 px-4 py-3 space-y-2">
|
||||||
<div className="border-t border-gray-200/60 dark:border-white/10 px-4 py-3">
|
<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
|
<button
|
||||||
onClick={() => { handleLogout(); setMobileMenuOpen(false); }}
|
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"
|
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" />
|
<ArrowRightOnRectangleIcon className="h-5 w-5" />
|
||||||
<span>Logout</span>
|
<span>{t('nav.logout')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</DialogPanel>
|
</DialogPanel>
|
||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import useAuthStore from '../store/authStore'
|
|||||||
import PageLayout from '../components/PageLayout'
|
import PageLayout from '../components/PageLayout'
|
||||||
import Waves from '../components/background/waves'
|
import Waves from '../components/background/waves'
|
||||||
import BlueBlurryBackground from '../components/background/blueblurry'
|
import BlueBlurryBackground from '../components/background/blueblurry'
|
||||||
|
import { useTranslation } from '../i18n/useTranslation'
|
||||||
import { useUserStatus } from '../hooks/useUserStatus'
|
import { useUserStatus } from '../hooks/useUserStatus'
|
||||||
import {
|
import {
|
||||||
ShoppingBagIcon,
|
ShoppingBagIcon,
|
||||||
@ -26,6 +27,7 @@ import {
|
|||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { t } = useTranslation()
|
||||||
const user = useAuthStore(state => state.user)
|
const user = useAuthStore(state => state.user)
|
||||||
const isAuthReady = useAuthStore(state => state.isAuthReady)
|
const isAuthReady = useAuthStore(state => state.isAuthReady)
|
||||||
const isShopEnabled = process.env.NEXT_PUBLIC_SHOW_SHOP !== 'false'
|
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="min-h-screen flex items-center justify-center">
|
||||||
<div className="text-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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -127,8 +129,8 @@ export default function DashboardPage() {
|
|||||||
return (
|
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="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="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="text-sm font-medium text-gray-900">{t('dashboard.redirecting')}</div>
|
||||||
<div className="mt-1 text-xs text-gray-600">Please wait</div>
|
<div className="mt-1 text-xs text-gray-600">{t('dashboard.pleaseWait')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@ -162,6 +164,25 @@ export default function DashboardPage() {
|
|||||||
UserCircleIcon
|
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 = (
|
const content = (
|
||||||
<div className="relative z-10 flex-1 min-h-0">
|
<div className="relative z-10 flex-1 min-h-0">
|
||||||
<PageLayout className="bg-transparent text-gray-900">
|
<PageLayout className="bg-transparent text-gray-900">
|
||||||
@ -171,22 +192,30 @@ export default function DashboardPage() {
|
|||||||
{/* Welcome Section */}
|
{/* Welcome Section */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">
|
<h1 className="text-3xl font-bold text-gray-900">
|
||||||
Welcome back, {getUserName()}! 👋
|
{t('dashboard.welcomeBack')}, {getUserName()}! 👋
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600 mt-2">
|
<p className="text-gray-600 mt-2">
|
||||||
Here's what's happening with your Profit Planet account
|
{t('dashboard.welcomeSubtitle')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<div className="mb-8">
|
<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">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
{platforms.filter(p => p.isActive).map((platform) => {
|
{platforms.filter(p => p.isActive).map((platform) => {
|
||||||
const Icon = icons[platform.icon]
|
const Icon = icons[platform.icon]
|
||||||
const disabledByEnv = platform.href === '/shop' && !isShopEnabled
|
const disabledByEnv = platform.href === '/shop' && !isShopEnabled
|
||||||
const isDisabled = Boolean(platform.disabled) || disabledByEnv
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
@ -221,10 +250,10 @@ export default function DashboardPage() {
|
|||||||
: 'text-gray-900 group-hover:text-[#8D6B1D]'
|
: 'text-gray-900 group-hover:text-[#8D6B1D]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{platform.title}
|
{translatedTitle}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
{platform.description}
|
{translatedDescription}
|
||||||
</p>
|
</p>
|
||||||
{isDisabled && disabledText && (
|
{isDisabled && disabledText && (
|
||||||
<p className="mt-3 text-xs font-medium text-amber-700">
|
<p className="mt-3 text-xs font-medium text-amber-700">
|
||||||
@ -244,14 +273,14 @@ export default function DashboardPage() {
|
|||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<StarIcon className="h-12 w-12 text-yellow-300" />
|
<StarIcon className="h-12 w-12 text-yellow-300" />
|
||||||
<div className="ml-4">
|
<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">
|
<p className="text-yellow-100 mt-1">
|
||||||
Enjoy exclusive benefits and discounts
|
{t('dashboard.goldMemberDescription')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto">
|
<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">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -260,9 +289,9 @@ export default function DashboardPage() {
|
|||||||
{/* Latest News */}
|
{/* Latest News */}
|
||||||
<div className="rounded-2xl bg-white border border-gray-200 shadow-sm p-6">
|
<div className="rounded-2xl bg-white border border-gray-200 shadow-sm p-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<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">
|
<Link href="/news" className="text-sm font-medium text-blue-900 hover:text-blue-700">
|
||||||
View all
|
{t('dashboard.viewAllNews')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -282,7 +311,7 @@ export default function DashboardPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!newsLoading && !newsError && latestNews.length === 0 && (
|
{!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 && (
|
{!newsLoading && !newsError && latestNews.length > 0 && (
|
||||||
@ -291,7 +320,7 @@ export default function DashboardPage() {
|
|||||||
<li key={item.id} className="group">
|
<li key={item.id} className="group">
|
||||||
<Link href={`/news/${item.slug}`} className="block">
|
<Link href={`/news/${item.slug}`} className="block">
|
||||||
<div className="text-xs text-gray-500">
|
<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>
|
||||||
<div className="text-sm font-semibold text-gray-900 group-hover:text-blue-700 line-clamp-2">
|
<div className="text-sm font-semibold text-gray-900 group-hover:text-blue-700 line-clamp-2">
|
||||||
{item.title}
|
{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';
|
import { Translations } from '../types';
|
||||||
|
|
||||||
export const de: Translations = {
|
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: {
|
home: {
|
||||||
title: 'Profit Planet',
|
title: 'Profit Planet',
|
||||||
tagline: 'Nachhaltige Produkte entdecken und handeln',
|
tagline: 'Nachhaltige Produkte entdecken und handeln',
|
||||||
@ -8,41 +43,911 @@ export const de: Translations = {
|
|||||||
features: {
|
features: {
|
||||||
sustainable: {
|
sustainable: {
|
||||||
title: 'Nachhaltige Produkte',
|
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: {
|
community: {
|
||||||
title: 'Aktive Community',
|
title: 'Aktive Community',
|
||||||
description: 'Vernetze dich mit Gleichgesinnten, denen Nachhaltigkeit wichtig ist.'
|
description: 'Vernetze dich mit Gleichgesinnten, denen Nachhaltigkeit wichtig ist.',
|
||||||
},
|
},
|
||||||
rewards: {
|
rewards: {
|
||||||
title: 'Belohnungen sammeln',
|
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: {
|
stats: {
|
||||||
members: 'Aktive Mitglieder',
|
members: 'Aktive Mitglieder',
|
||||||
products: 'Öko-Produkte',
|
products: 'Öko-Produkte',
|
||||||
communities: 'Communities'
|
communities: 'Communities',
|
||||||
},
|
},
|
||||||
cta: {
|
cta: {
|
||||||
getStarted: 'Jetzt starten',
|
getStarted: 'Jetzt starten',
|
||||||
learnMore: 'Mehr erfahren'
|
learnMore: 'Mehr erfahren',
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
footer: {
|
footer: {
|
||||||
company: 'Profit Planet GmbH',
|
company: 'Profit Planet GmbH',
|
||||||
rights: 'Alle Rechte vorbehalten.',
|
rights: 'Alle Rechte vorbehalten.',
|
||||||
privacy: 'Datenschutz',
|
privacy: 'Datenschutz',
|
||||||
terms: 'AGB',
|
terms: 'AGB',
|
||||||
contact: 'Kontakt'
|
contact: 'Kontakt',
|
||||||
},
|
},
|
||||||
|
|
||||||
nav: {
|
nav: {
|
||||||
home: 'Home',
|
home: 'Home',
|
||||||
shop: 'Shop',
|
shop: 'Shop',
|
||||||
dashboard: 'Dashboard',
|
dashboard: 'Dashboard',
|
||||||
community: 'Community',
|
community: 'Community',
|
||||||
profile: 'Profil',
|
profile: 'Profil',
|
||||||
login: 'Anmelden',
|
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';
|
import { Translations } from '../types';
|
||||||
|
|
||||||
export const en: Translations = {
|
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: {
|
home: {
|
||||||
title: 'Profit Planet',
|
title: 'Profit Planet',
|
||||||
tagline: 'Discover and trade sustainable products',
|
tagline: 'Discover and trade sustainable products',
|
||||||
@ -8,41 +43,911 @@ export const en: Translations = {
|
|||||||
features: {
|
features: {
|
||||||
sustainable: {
|
sustainable: {
|
||||||
title: 'Sustainable Products',
|
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: {
|
community: {
|
||||||
title: 'Active Community',
|
title: 'Active Community',
|
||||||
description: 'Connect with like-minded people who care about sustainability.'
|
description: 'Connect with like-minded people who care about sustainability.',
|
||||||
},
|
},
|
||||||
rewards: {
|
rewards: {
|
||||||
title: 'Earn 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: {
|
stats: {
|
||||||
members: 'Active Members',
|
members: 'Active Members',
|
||||||
products: 'Eco Products',
|
products: 'Eco Products',
|
||||||
communities: 'Communities'
|
communities: 'Communities',
|
||||||
},
|
},
|
||||||
cta: {
|
cta: {
|
||||||
getStarted: 'Get Started',
|
getStarted: 'Get Started',
|
||||||
learnMore: 'Learn More'
|
learnMore: 'Learn More',
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
footer: {
|
footer: {
|
||||||
company: 'Profit Planet GmbH',
|
company: 'Profit Planet GmbH',
|
||||||
rights: 'All rights reserved.',
|
rights: 'All rights reserved.',
|
||||||
privacy: 'Privacy Policy',
|
privacy: 'Privacy Policy',
|
||||||
terms: 'Terms of Service',
|
terms: 'Terms of Service',
|
||||||
contact: 'Contact'
|
contact: 'Contact',
|
||||||
},
|
},
|
||||||
|
|
||||||
nav: {
|
nav: {
|
||||||
home: 'Home',
|
home: 'Home',
|
||||||
shop: 'Shop',
|
shop: 'Shop',
|
||||||
dashboard: 'Dashboard',
|
dashboard: 'Dashboard',
|
||||||
community: 'Community',
|
community: 'Community',
|
||||||
profile: 'Profile',
|
profile: 'Profile',
|
||||||
login: 'Login',
|
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 {
|
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: {
|
home: {
|
||||||
title: string;
|
title: string;
|
||||||
tagline: string;
|
tagline: string;
|
||||||
description: string;
|
description: string;
|
||||||
features: {
|
features: {
|
||||||
sustainable: {
|
sustainable: { title: string; description: string };
|
||||||
title: string;
|
community: { title: string; description: string };
|
||||||
description: string;
|
rewards: { 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;
|
|
||||||
};
|
};
|
||||||
|
stats: { members: string; products: string; communities: string };
|
||||||
|
cta: { getStarted: string; learnMore: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
footer: {
|
footer: {
|
||||||
company: string;
|
company: string;
|
||||||
rights: string;
|
rights: string;
|
||||||
@ -34,6 +54,7 @@ export interface Translations {
|
|||||||
terms: string;
|
terms: string;
|
||||||
contact: string;
|
contact: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
nav: {
|
nav: {
|
||||||
home: string;
|
home: string;
|
||||||
shop: string;
|
shop: string;
|
||||||
@ -42,5 +63,857 @@ export interface Translations {
|
|||||||
profile: string;
|
profile: string;
|
||||||
login: string;
|
login: string;
|
||||||
logout: 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';
|
'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 { Language, DEFAULT_LANGUAGE } from './config';
|
||||||
import { en } from './translations/en';
|
import { en } from './translations/en';
|
||||||
import { de } from './translations/de';
|
import { de } from './translations/de';
|
||||||
|
import { flattenObject, loadCustomI18n, type CustomI18nData } from './dynamicTranslations';
|
||||||
|
|
||||||
const translations = {
|
const builtInTranslations: Record<string, Record<string, any>> = { en, de };
|
||||||
en,
|
|
||||||
de
|
// Flat map of English keys used as canonical key list and fallback
|
||||||
} as const;
|
const enFlat = flattenObject(en as Record<string, any>);
|
||||||
|
|
||||||
interface I18nContextType {
|
interface I18nContextType {
|
||||||
language: Language;
|
language: string;
|
||||||
setLanguage: (lang: Language) => void;
|
setLanguage: (lang: string) => void;
|
||||||
t: (key: string) => string;
|
t: (key: string) => string;
|
||||||
|
customI18n: CustomI18nData;
|
||||||
|
reloadCustomI18n: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const I18nContext = createContext<I18nContextType | undefined>(undefined);
|
const I18nContext = createContext<I18nContextType | undefined>(undefined);
|
||||||
@ -23,21 +26,39 @@ interface I18nProviderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function I18nProvider({ children }: 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 reloadCustomI18n = useCallback(() => {
|
||||||
const keys = key.split('.');
|
setCustomI18n(loadCustomI18n());
|
||||||
let value: any = translations[language];
|
}, []);
|
||||||
|
|
||||||
for (const k of keys) {
|
useEffect(() => {
|
||||||
value = value?.[k];
|
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 (
|
return (
|
||||||
<I18nContext.Provider value={{ language, setLanguage, t }}>
|
<I18nContext.Provider value={{ language, setLanguage, t, customI18n, reloadCustomI18n }}>
|
||||||
{children}
|
{children}
|
||||||
</I18nContext.Provider>
|
</I18nContext.Provider>
|
||||||
);
|
);
|
||||||
@ -49,4 +70,21 @@ export function useTranslation() {
|
|||||||
throw new Error('useTranslation must be used within an I18nProvider');
|
throw new Error('useTranslation must be used within an I18nProvider');
|
||||||
}
|
}
|
||||||
return context;
|
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 TutorialModal, { createTutorialSteps } from '../components/TutorialModal'
|
||||||
import useAuthStore from '../store/authStore'
|
import useAuthStore from '../store/authStore'
|
||||||
import { useUserStatus } from '../hooks/useUserStatus'
|
import { useUserStatus } from '../hooks/useUserStatus'
|
||||||
|
import { useTranslation } from '../i18n/useTranslation'
|
||||||
import BlueBlurryBackground from '../components/background/blueblurry' // NEW
|
import BlueBlurryBackground from '../components/background/blueblurry' // NEW
|
||||||
import {
|
import {
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
@ -41,6 +42,7 @@ type LatestNewsItem = {
|
|||||||
|
|
||||||
export default function QuickActionDashboardPage() {
|
export default function QuickActionDashboardPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { t } = useTranslation()
|
||||||
const user = useAuthStore(s => s.user)
|
const user = useAuthStore(s => s.user)
|
||||||
const isAuthReady = useAuthStore(s => (s as any).isAuthReady) // NEW
|
const isAuthReady = useAuthStore(s => (s as any).isAuthReady) // NEW
|
||||||
const accessToken = useAuthStore(s => s.accessToken) // NEW
|
const accessToken = useAuthStore(s => s.accessToken) // NEW
|
||||||
@ -71,18 +73,18 @@ export default function QuickActionDashboardPage() {
|
|||||||
try {
|
try {
|
||||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||||
const res = await fetch(`${BASE_URL}/api/news/active`)
|
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 json = await res.json()
|
||||||
const data = Array.isArray(json.data) ? json.data : []
|
const data = Array.isArray(json.data) ? json.data : []
|
||||||
if (active) setLatestNews(data.slice(0, 3))
|
if (active) setLatestNews(data.slice(0, 3))
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (active) setNewsError(e?.message || 'Failed to load news')
|
if (active) setNewsError(e?.message || t('quickactionDashboard.noNewsYet'))
|
||||||
} finally {
|
} finally {
|
||||||
if (active) setNewsLoading(false)
|
if (active) setNewsLoading(false)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
return () => { active = false }
|
return () => { active = false }
|
||||||
}, [])
|
}, [t])
|
||||||
|
|
||||||
// Derive status from real backend data
|
// Derive status from real backend data
|
||||||
const emailVerified = userStatus?.email_verified || false
|
const emailVerified = userStatus?.email_verified || false
|
||||||
@ -177,8 +179,8 @@ export default function QuickActionDashboardPage() {
|
|||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
key: 'email',
|
key: 'email',
|
||||||
label: 'Email Verification',
|
label: t('quickactionDashboard.statusCards.emailVerification'),
|
||||||
description: emailVerified ? 'Verified' : 'Missing',
|
description: emailVerified ? t('quickactionDashboard.statusCards.verified') : t('quickactionDashboard.statusCards.missing'),
|
||||||
complete: emailVerified,
|
complete: emailVerified,
|
||||||
icon: EnvelopeOpenIcon
|
icon: EnvelopeOpenIcon
|
||||||
}
|
}
|
||||||
@ -186,29 +188,29 @@ export default function QuickActionDashboardPage() {
|
|||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
key: 'email',
|
key: 'email',
|
||||||
label: 'Email Verification',
|
label: t('quickactionDashboard.statusCards.emailVerification'),
|
||||||
description: emailVerified ? 'Verified' : 'Missing',
|
description: emailVerified ? t('quickactionDashboard.statusCards.verified') : t('quickactionDashboard.statusCards.missing'),
|
||||||
complete: emailVerified,
|
complete: emailVerified,
|
||||||
icon: EnvelopeOpenIcon
|
icon: EnvelopeOpenIcon
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'id',
|
key: 'id',
|
||||||
label: 'ID Document',
|
label: t('quickactionDashboard.statusCards.idDocument'),
|
||||||
description: idUploaded ? 'Uploaded' : 'Missing',
|
description: idUploaded ? t('quickactionDashboard.statusCards.uploaded') : t('quickactionDashboard.statusCards.missing'),
|
||||||
complete: idUploaded,
|
complete: idUploaded,
|
||||||
icon: IdentificationIcon
|
icon: IdentificationIcon
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'info',
|
key: 'info',
|
||||||
label: 'Additional Info',
|
label: t('quickactionDashboard.statusCards.additionalInfo'),
|
||||||
description: additionalInfo ? 'Completed' : 'Missing',
|
description: additionalInfo ? t('quickactionDashboard.completed') : t('quickactionDashboard.statusCards.missing'),
|
||||||
complete: additionalInfo,
|
complete: additionalInfo,
|
||||||
icon: InformationCircleIcon
|
icon: InformationCircleIcon
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'contract',
|
key: 'contract',
|
||||||
label: 'Contract',
|
label: t('quickactionDashboard.statusCards.contract'),
|
||||||
description: contractSigned ? 'Signed' : 'Missing',
|
description: contractSigned ? t('quickactionDashboard.statusCards.signed') : t('quickactionDashboard.statusCards.missing'),
|
||||||
complete: contractSigned,
|
complete: contractSigned,
|
||||||
icon: DocumentCheckIcon
|
icon: DocumentCheckIcon
|
||||||
}
|
}
|
||||||
@ -315,8 +317,8 @@ export default function QuickActionDashboardPage() {
|
|||||||
{redirectTo && (
|
{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="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="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="text-sm font-medium text-gray-900">{t('quickactionDashboard.redirecting')}</div>
|
||||||
<div className="mt-1 text-xs text-gray-600">Taking you to your dashboard</div>
|
<div className="mt-1 text-xs text-gray-600">{t('quickactionDashboard.takingToDashboard')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -326,16 +328,16 @@ export default function QuickActionDashboardPage() {
|
|||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">
|
<h1 className="text-3xl font-bold text-gray-900">
|
||||||
Welcome{isClient && user?.firstName ? `, ${user.firstName}` : ''}!
|
{t('dashboard.welcomeBack')}{isClient && user?.firstName ? `, ${user.firstName}` : ''}!
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm sm:text-base text-gray-600 mt-2">
|
<p className="text-sm sm:text-base text-gray-600 mt-2">
|
||||||
{isGuest
|
{isGuest
|
||||||
? 'Guest Account'
|
? t('quickactionDashboard.guestAccount')
|
||||||
: isClient && user?.userType === 'company'
|
: isClient && user?.userType === 'company'
|
||||||
? 'Company Account'
|
? t('quickactionDashboard.companyAccount')
|
||||||
: 'Personal Account'}
|
: t('quickactionDashboard.personalAccount')}
|
||||||
</p>
|
</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 && (
|
{error && (
|
||||||
<div className="mt-4 max-w-md rounded-md bg-red-50 border border-red-200 px-4 py-3">
|
<div className="mt-4 max-w-md rounded-md bg-red-50 border border-red-200 px-4 py-3">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
@ -346,7 +348,7 @@ export default function QuickActionDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="ml-3">
|
<div className="ml-3">
|
||||||
<h3 className="text-sm font-medium text-red-800">
|
<h3 className="text-sm font-medium text-red-800">
|
||||||
Error loading account status
|
{t('quickactionDashboard.errorLoadingAccountStatus')}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="mt-2 text-sm text-red-700">
|
<div className="mt-2 text-sm text-red-700">
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
@ -356,7 +358,7 @@ export default function QuickActionDashboardPage() {
|
|||||||
onClick={() => refreshStatus()}
|
onClick={() => refreshStatus()}
|
||||||
className="text-sm bg-red-100 text-red-800 px-3 py-1 rounded-md hover:bg-red-200 transition-colors"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -370,7 +372,7 @@ export default function QuickActionDashboardPage() {
|
|||||||
{/* Status Overview */}
|
{/* Status Overview */}
|
||||||
<div className="bg-white rounded-xl shadow-sm ring-1 ring-gray-200 p-5 sm:p-8">
|
<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">
|
<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>
|
</h2>
|
||||||
|
|
||||||
{/* Guest: single centered card. Regular: 2x2 / 4-col grid */}
|
{/* Guest: single centered card. Regular: 2x2 / 4-col grid */}
|
||||||
@ -420,7 +422,7 @@ export default function QuickActionDashboardPage() {
|
|||||||
i
|
i
|
||||||
</span>
|
</span>
|
||||||
<h2 className="text-sm sm:text-base font-semibold text-gray-900">
|
<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>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
{/* Tutorial button — only for regular users */}
|
{/* 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"
|
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" />
|
<AcademicCapIcon className="h-4 w-4" />
|
||||||
Tutorial
|
{t('quickactionDashboard.tutorial')}
|
||||||
{!hasSeenTutorial && (
|
{!hasSeenTutorial && (
|
||||||
<span className="absolute -top-1 -right-1 h-3 w-3 bg-red-500 rounded-full animate-pulse" />
|
<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="flex flex-col items-center text-center">
|
||||||
<div className="max-w-sm w-full">
|
<div className="max-w-sm w-full">
|
||||||
<p className="text-sm text-gray-600 mb-6">
|
<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>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={handleVerifyEmail}
|
onClick={handleVerifyEmail}
|
||||||
@ -455,13 +457,13 @@ export default function QuickActionDashboardPage() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<EnvelopeOpenIcon className="h-6 w-6 sm:h-8 sm:w-8 mb-3" />
|
<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>
|
</button>
|
||||||
{!emailVerified && (
|
{!emailVerified && (
|
||||||
<p className="mt-3 text-xs text-[#112c55]">
|
<p className="mt-3 text-xs text-[#112c55]">
|
||||||
{resendRemainingSec > 0
|
{resendRemainingSec > 0
|
||||||
? `Resend available in ${formatMmSs(resendRemainingSec)}`
|
? `${t('quickactionDashboard.resendAvailableIn')} ${formatMmSs(resendRemainingSec)}`
|
||||||
: 'You can request a new code now'}
|
: t('quickactionDashboard.requestNewCode')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -481,13 +483,13 @@ export default function QuickActionDashboardPage() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<EnvelopeOpenIcon className="h-5 w-5 sm:h-6 sm:w-6 mb-2" />
|
<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>
|
</button>
|
||||||
{!emailVerified && (
|
{!emailVerified && (
|
||||||
<p className="mt-2 text-[11px] text-[#112c55] text-center">
|
<p className="mt-2 text-[11px] text-[#112c55] text-center">
|
||||||
{resendRemainingSec > 0
|
{resendRemainingSec > 0
|
||||||
? `Resend available in ${formatMmSs(resendRemainingSec)}`
|
? `${t('quickactionDashboard.resendAvailableIn')} ${formatMmSs(resendRemainingSec)}`
|
||||||
: 'You can request a new code now'}
|
: t('quickactionDashboard.requestNewCode')}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -503,7 +505,7 @@ export default function QuickActionDashboardPage() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ArrowUpOnSquareIcon className="h-5 w-5 sm:h-6 sm:w-6 mb-2" />
|
<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>
|
</button>
|
||||||
|
|
||||||
{/* Additional Info */}
|
{/* Additional Info */}
|
||||||
@ -517,7 +519,7 @@ export default function QuickActionDashboardPage() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<PencilSquareIcon className="h-5 w-5 sm:h-6 sm:w-6 mb-2" />
|
<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>
|
</button>
|
||||||
|
|
||||||
{/* Sign Contract */}
|
{/* Sign Contract */}
|
||||||
@ -534,11 +536,11 @@ export default function QuickActionDashboardPage() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ClipboardDocumentCheckIcon className="h-5 w-5 sm:h-6 sm:w-6 mb-2" />
|
<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>
|
</button>
|
||||||
{!canSignContract && !contractSigned && (
|
{!canSignContract && !contractSigned && (
|
||||||
<p className="mt-2 text-[11px] text-red-600 leading-snug text-center">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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="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">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h2 className="text-sm sm:text-base font-semibold text-gray-900">
|
<h2 className="text-sm sm:text-base font-semibold text-gray-900">
|
||||||
Latest News
|
{t('quickactionDashboard.latestNews')}
|
||||||
</h2>
|
</h2>
|
||||||
<Link href="/news" className="text-xs sm:text-sm font-medium text-blue-900 hover:text-blue-700">
|
<Link href="/news" className="text-xs sm:text-sm font-medium text-blue-900 hover:text-blue-700">
|
||||||
View all
|
{t('quickactionDashboard.viewAll')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -573,7 +575,7 @@ export default function QuickActionDashboardPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!newsLoading && !newsError && latestNews.length === 0 && (
|
{!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 && (
|
{!newsLoading && !newsError && latestNews.length > 0 && (
|
||||||
@ -582,7 +584,7 @@ export default function QuickActionDashboardPage() {
|
|||||||
<li key={item.id} className="group">
|
<li key={item.id} className="group">
|
||||||
<Link href={`/news/${item.slug}`} className="block">
|
<Link href={`/news/${item.slug}`} className="block">
|
||||||
<div className="text-xs text-gray-500">
|
<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>
|
||||||
<div className="text-sm font-semibold text-blue-900 group-hover:text-blue-700 line-clamp-2">
|
<div className="text-sm font-semibold text-blue-900 group-hover:text-blue-700 line-clamp-2">
|
||||||
{item.title}
|
{item.title}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import PageLayout from '../../../components/PageLayout'
|
|||||||
import useAuthStore from '../../../store/authStore'
|
import useAuthStore from '../../../store/authStore'
|
||||||
import { useUserStatus } from '../../../hooks/useUserStatus'
|
import { useUserStatus } from '../../../hooks/useUserStatus'
|
||||||
import { useToast } from '../../../components/toast/toastComponent'
|
import { useToast } from '../../../components/toast/toastComponent'
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation'
|
||||||
import { ChevronDownIcon } from '@heroicons/react/20/solid' // NEW
|
import { ChevronDownIcon } from '@heroicons/react/20/solid' // NEW
|
||||||
import TelephoneInput, { TelephoneInputHandle } from '../../../components/phone/telephoneInput'
|
import TelephoneInput, { TelephoneInputHandle } from '../../../components/phone/telephoneInput'
|
||||||
|
|
||||||
@ -186,6 +187,7 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
const { accessToken } = useAuthStore()
|
const { accessToken } = useAuthStore()
|
||||||
const { userStatus, loading: statusLoading, refreshStatus } = useUserStatus()
|
const { userStatus, loading: statusLoading, refreshStatus } = useUserStatus()
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
|
const { t } = useTranslation()
|
||||||
const companyPhoneRef = useRef<TelephoneInputHandle | null>(null)
|
const companyPhoneRef = useRef<TelephoneInputHandle | null>(null)
|
||||||
const contactPhoneRef = useRef<TelephoneInputHandle | null>(null)
|
const contactPhoneRef = useRef<TelephoneInputHandle | null>(null)
|
||||||
const secondPhoneRef = useRef<TelephoneInputHandle | null>(null)
|
const secondPhoneRef = useRef<TelephoneInputHandle | null>(null)
|
||||||
@ -288,11 +290,11 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
]
|
]
|
||||||
for (const k of required) {
|
for (const k of required) {
|
||||||
if (!form[k].trim()) {
|
if (!form[k].trim()) {
|
||||||
const msg = 'Bitte alle Pflichtfelder ausfüllen.'
|
const msg = t('quickactionDashboard.additionalInfo.fillRequiredFields')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Missing information',
|
title: t('quickactionDashboard.additionalInfo.missingCountryCodeTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
@ -309,31 +311,31 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
const contactValid = contactApi?.isValid() ?? false
|
const contactValid = contactApi?.isValid() ?? false
|
||||||
|
|
||||||
if (!companyDialCode || !contactDialCode) {
|
if (!companyDialCode || !contactDialCode) {
|
||||||
const msg = 'Please select country codes for company and contact phone numbers.'
|
const msg = t('quickactionDashboard.additionalInfo.missingCountryCodeMessage')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Missing country code',
|
title: t('quickactionDashboard.additionalInfo.missingCountryCodeTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (!companyNumber || !contactNumber) {
|
if (!companyNumber || !contactNumber) {
|
||||||
const msg = 'Please enter both company and contact phone numbers.'
|
const msg = t('quickactionDashboard.additionalInfo.phoneNumberMissingMessage')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Missing phone numbers',
|
title: t('quickactionDashboard.additionalInfo.missingPhoneNumberTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (!companyValid || !contactValid) {
|
if (!companyValid || !contactValid) {
|
||||||
const msg = 'Please enter valid phone numbers for company and contact person.'
|
const msg = t('quickactionDashboard.additionalInfo.validPhoneNumberMessage')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Invalid phone numbers',
|
title: t('quickactionDashboard.additionalInfo.invalidPhoneNumberTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
@ -344,11 +346,11 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
const secondApi = secondPhoneRef.current
|
const secondApi = secondPhoneRef.current
|
||||||
const ok = secondApi?.isValid?.() ?? false
|
const ok = secondApi?.isValid?.() ?? false
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
const msg = 'Please enter a valid second phone number.'
|
const msg = t('quickactionDashboard.additionalInfo.validSecondPhoneNumberMessage')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Invalid phone number',
|
title: t('quickactionDashboard.additionalInfo.invalidPhoneNumberTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
@ -359,22 +361,22 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
const emergencyApi = emergencyPhoneRef.current
|
const emergencyApi = emergencyPhoneRef.current
|
||||||
const ok = emergencyApi?.isValid?.() ?? false
|
const ok = emergencyApi?.isValid?.() ?? false
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
const msg = 'Please enter a valid emergency phone number.'
|
const msg = t('quickactionDashboard.additionalInfo.validEmergencyPhoneNumberMessage')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Invalid phone number',
|
title: t('quickactionDashboard.additionalInfo.invalidPhoneNumberTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!/^([A-Z]{2}\d{2}[A-Z0-9]{10,30})$/i.test(form.iban.replace(/\s+/g,''))) {
|
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)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Invalid IBAN',
|
title: t('quickactionDashboard.additionalInfo.invalidIbanTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
@ -389,11 +391,11 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
if (!validate()) return
|
if (!validate()) return
|
||||||
|
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
const msg = 'Not authenticated. Please log in again.'
|
const msg = t('quickactionDashboard.additionalInfo.authErrorMessage')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Authentication error',
|
title: t('quickactionDashboard.additionalInfo.authErrorTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@ -447,8 +449,8 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
title: 'Profile saved',
|
title: t('quickactionDashboard.additionalInfo.additionalInfoSuccessTitle'),
|
||||||
message: 'Your company profile has been saved successfully.',
|
message: t('quickactionDashboard.additionalInfo.companySuccessMessage'),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Refresh user status to update profile completion state
|
// Refresh user status to update profile completion state
|
||||||
@ -462,11 +464,11 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Company profile save error:', error)
|
console.error('Company profile save error:', error)
|
||||||
const msg = error.message || 'Speichern fehlgeschlagen.'
|
const msg = error.message || t('quickactionDashboard.additionalInfo.saveFailedMessage')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Save failed',
|
title: t('quickactionDashboard.additionalInfo.saveFailedTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
@ -485,8 +487,8 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
{redirectTo && (
|
{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="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="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="text-sm font-medium text-gray-900">{t('quickactionDashboard.redirecting')}</div>
|
||||||
<div className="mt-1 text-xs text-gray-600">Please wait</div>
|
<div className="mt-1 text-xs text-gray-600">{t('quickactionDashboard.pleaseWait')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -509,18 +511,18 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
>
|
>
|
||||||
<div className="px-6 py-8 sm:px-10 lg:px-16">
|
<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">
|
<h1 className="text-center text-xl sm:text-2xl font-semibold text-[#0F172A] mb-6">
|
||||||
Complete Company Profile
|
{t('quickactionDashboard.additionalInfo.companyTitle')}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Company Details */}
|
{/* Company Details */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-[#0F2460] mb-4">
|
<h2 className="text-sm font-semibold text-[#0F2460] mb-4">
|
||||||
Company Details
|
{t('quickactionDashboard.additionalInfo.companyDetails')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<div className="sm:col-span-2 lg:col-span-3">
|
<div className="sm:col-span-2 lg:col-span-3">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Company Name *
|
{t('quickactionDashboard.additionalInfo.companyName')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
name="companyName"
|
name="companyName"
|
||||||
@ -532,7 +534,7 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2 lg:col-span-3">
|
<div className="sm:col-span-2 lg:col-span-3">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Company Email *
|
{t('quickactionDashboard.additionalInfo.companyEmail')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
name="companyEmail"
|
name="companyEmail"
|
||||||
@ -545,7 +547,7 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Company Phone *
|
{t('quickactionDashboard.additionalInfo.companyPhone')}
|
||||||
</label>
|
</label>
|
||||||
<TelephoneInput
|
<TelephoneInput
|
||||||
name="companyPhone"
|
name="companyPhone"
|
||||||
@ -559,7 +561,7 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Contact Person *
|
{t('quickactionDashboard.additionalInfo.contactPerson')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
name="contactPersonName"
|
name="contactPersonName"
|
||||||
@ -571,7 +573,7 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Contact Person Phone *
|
{t('quickactionDashboard.additionalInfo.contactPersonPhone')}
|
||||||
</label>
|
</label>
|
||||||
<TelephoneInput
|
<TelephoneInput
|
||||||
name="contactPersonPhone"
|
name="contactPersonPhone"
|
||||||
@ -585,7 +587,7 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Registration Number (optional)
|
{t('quickactionDashboard.additionalInfo.registrationNumberOptional')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
name="registrationNumber"
|
name="registrationNumber"
|
||||||
@ -597,7 +599,7 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
UID Number (optional)
|
{t('quickactionDashboard.additionalInfo.uidNumberOptional')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
name="uidNumber"
|
name="uidNumber"
|
||||||
@ -609,7 +611,7 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2 lg:col-span-3">
|
<div className="sm:col-span-2 lg:col-span-3">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Street & Number *
|
{t('quickactionDashboard.additionalInfo.streetNumber')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
name="street"
|
name="street"
|
||||||
@ -621,7 +623,7 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Postal Code *
|
{t('quickactionDashboard.additionalInfo.postalCode')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
name="postalCode"
|
name="postalCode"
|
||||||
@ -633,7 +635,7 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
City *
|
{t('quickactionDashboard.additionalInfo.city')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
name="city"
|
name="city"
|
||||||
@ -645,8 +647,8 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<ModernSelect
|
<ModernSelect
|
||||||
label="Country"
|
label={t('quickactionDashboard.additionalInfo.country')}
|
||||||
placeholder="Select country..."
|
placeholder={t('quickactionDashboard.additionalInfo.selectCountry')}
|
||||||
value={form.country}
|
value={form.country}
|
||||||
onChange={(v) => setField('country', v)}
|
onChange={(v) => setField('country', v)}
|
||||||
options={COUNTRIES.map(c => ({ value: c, label: c }))}
|
options={COUNTRIES.map(c => ({ value: c, label: c }))}
|
||||||
@ -660,12 +662,12 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
{/* Bank Details */}
|
{/* Bank Details */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-[#0F2460] mb-4">
|
<h2 className="text-sm font-semibold text-[#0F2460] mb-4">
|
||||||
Bank Details
|
{t('quickactionDashboard.additionalInfo.bankDetails')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<div className="sm:col-span-2 lg:col-span-3">
|
<div className="sm:col-span-2 lg:col-span-3">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Account Holder *
|
{t('quickactionDashboard.additionalInfo.accountHolder')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
name="accountHolder"
|
name="accountHolder"
|
||||||
@ -678,7 +680,7 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2 lg:col-span-2">
|
<div className="sm:col-span-2 lg:col-span-2">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
IBAN *
|
{t('quickactionDashboard.additionalInfo.iban')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
name="iban"
|
name="iban"
|
||||||
@ -691,7 +693,7 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
BIC (optional)
|
{t('quickactionDashboard.additionalInfo.bicOptional')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
name="bic"
|
name="bic"
|
||||||
@ -709,12 +711,12 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
{/* Additional Information */}
|
{/* Additional Information */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-[#0F2460] mb-4">
|
<h2 className="text-sm font-semibold text-[#0F2460] mb-4">
|
||||||
Additional Information
|
{t('quickactionDashboard.additionalInfo.additionalInformation')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<div className="sm:col-span-2 lg:col-span-3">
|
<div className="sm:col-span-2 lg:col-span-3">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Second Phone (optional)
|
{t('quickactionDashboard.additionalInfo.secondPhoneOptional')}
|
||||||
</label>
|
</label>
|
||||||
<TelephoneInput
|
<TelephoneInput
|
||||||
name="secondPhone"
|
name="secondPhone"
|
||||||
@ -727,7 +729,7 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Emergency Contact Name
|
{t('quickactionDashboard.additionalInfo.emergencyContactName')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
name="emergencyName"
|
name="emergencyName"
|
||||||
@ -739,7 +741,7 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Emergency Contact Phone
|
{t('quickactionDashboard.additionalInfo.emergencyContactPhone')}
|
||||||
</label>
|
</label>
|
||||||
<TelephoneInput
|
<TelephoneInput
|
||||||
name="emergencyPhone"
|
name="emergencyPhone"
|
||||||
@ -761,7 +763,7 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
)}
|
)}
|
||||||
{success && (
|
{success && (
|
||||||
<div className="mt-6 rounded-md border border-green-300 bg-green-50 px-4 py-3 text-xs text-green-700">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -771,7 +773,7 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
onClick={() => router.push('/quickaction-dashboard')}
|
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"
|
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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@ -779,7 +781,7 @@ export default function CompanyAdditionalInformationPage() {
|
|||||||
disabled={loading || success}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import PageLayout from '../../../components/PageLayout'
|
|||||||
import useAuthStore from '../../../store/authStore'
|
import useAuthStore from '../../../store/authStore'
|
||||||
import { useUserStatus } from '../../../hooks/useUserStatus'
|
import { useUserStatus } from '../../../hooks/useUserStatus'
|
||||||
import { useToast } from '../../../components/toast/toastComponent'
|
import { useToast } from '../../../components/toast/toastComponent'
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation'
|
||||||
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||||
import TelephoneInput, { TelephoneInputHandle } from '../../../components/phone/telephoneInput'
|
import TelephoneInput, { TelephoneInputHandle } from '../../../components/phone/telephoneInput'
|
||||||
|
|
||||||
@ -27,25 +28,35 @@ interface PersonalProfileData {
|
|||||||
emergencyPhone: string
|
emergencyPhone: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Common nationalities list
|
const NATIONALITY_CODES = [
|
||||||
const NATIONALITIES = [
|
'german', 'austrian', 'swiss', 'italian', 'french', 'spanish', 'portuguese', 'dutch',
|
||||||
'German', 'Austrian', 'Swiss', 'Italian', 'French', 'Spanish', 'Portuguese', 'Dutch',
|
'belgian', 'polish', 'czech', 'hungarian', 'croatian', 'slovenian', 'slovak',
|
||||||
'Belgian', 'Polish', 'Czech', 'Hungarian', 'Croatian', 'Slovenian', 'Slovak',
|
'british', 'irish', 'swedish', 'norwegian', 'danish', 'finnish', 'russian',
|
||||||
'British', 'Irish', 'Swedish', 'Norwegian', 'Danish', 'Finnish', 'Russian',
|
'turkish', 'greek', 'romanian', 'bulgarian', 'serbian', 'albanian', 'bosnian',
|
||||||
'Turkish', 'Greek', 'Romanian', 'Bulgarian', 'Serbian', 'Albanian', 'Bosnian',
|
'american', 'canadian', 'brazilian', 'argentinian', 'mexican', 'chinese',
|
||||||
'American', 'Canadian', 'Brazilian', 'Argentinian', 'Mexican', 'Chinese',
|
'japanese', 'indian', 'pakistani', 'australian', 'southAfrican', 'other'
|
||||||
'Japanese', 'Indian', 'Pakistani', 'Australian', 'South African', 'Other'
|
] as const
|
||||||
]
|
|
||||||
|
|
||||||
// Common countries list
|
const COUNTRY_CODES = [
|
||||||
const COUNTRIES = [
|
'germany', 'austria', 'switzerland', 'italy', 'france', 'spain', 'portugal', 'netherlands',
|
||||||
'Germany', 'Austria', 'Switzerland', 'Italy', 'France', 'Spain', 'Portugal', 'Netherlands',
|
'belgium', 'poland', 'czechRepublic', 'hungary', 'croatia', 'slovenia', 'slovakia',
|
||||||
'Belgium', 'Poland', 'Czech Republic', 'Hungary', 'Croatia', 'Slovenia', 'Slovakia',
|
'unitedKingdom', 'ireland', 'sweden', 'norway', 'denmark', 'finland', 'russia',
|
||||||
'United Kingdom', 'Ireland', 'Sweden', 'Norway', 'Denmark', 'Finland', 'Russia',
|
'turkey', 'greece', 'romania', 'bulgaria', 'serbia', 'albania', 'bosniaHerzegovina',
|
||||||
'Turkey', 'Greece', 'Romania', 'Bulgaria', 'Serbia', 'Albania', 'Bosnia and Herzegovina',
|
'unitedStates', 'canada', 'brazil', 'argentina', 'mexico', 'china', 'japan',
|
||||||
'United States', 'Canada', 'Brazil', 'Argentina', 'Mexico', 'China', 'Japan',
|
'india', 'pakistan', 'australia', 'southAfrica', 'other'
|
||||||
'India', 'Pakistan', 'Australia', 'South Africa', '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 = {
|
const initialData: PersonalProfileData = {
|
||||||
firstName: '',
|
firstName: '',
|
||||||
@ -70,12 +81,16 @@ type SelectOption = { value: string; label: string }
|
|||||||
function ModernSelect({
|
function ModernSelect({
|
||||||
label,
|
label,
|
||||||
placeholder = 'Select…',
|
placeholder = 'Select…',
|
||||||
|
searchPlaceholder = 'Search…',
|
||||||
|
noResults = 'No results',
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
options,
|
options,
|
||||||
}: {
|
}: {
|
||||||
label: string
|
label: string
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
|
searchPlaceholder?: string
|
||||||
|
noResults?: string
|
||||||
value: string
|
value: string
|
||||||
onChange: (next: string) => void
|
onChange: (next: string) => void
|
||||||
options: SelectOption[]
|
options: SelectOption[]
|
||||||
@ -166,7 +181,7 @@ function ModernSelect({
|
|||||||
<input
|
<input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={e => setQuery(e.target.value)}
|
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
|
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"
|
focus:outline-none focus:ring-2 focus:ring-[#8D6B1D] focus:border-transparent"
|
||||||
autoFocus
|
autoFocus
|
||||||
@ -175,7 +190,7 @@ function ModernSelect({
|
|||||||
|
|
||||||
<div className="max-h-[42vh] overflow-auto p-1">
|
<div className="max-h-[42vh] overflow-auto p-1">
|
||||||
{filtered.length === 0 ? (
|
{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 => {
|
filtered.map(o => {
|
||||||
const active = o.value === value
|
const active = o.value === value
|
||||||
@ -208,6 +223,7 @@ function ModernSelect({
|
|||||||
|
|
||||||
export default function PersonalAdditionalInformationPage() {
|
export default function PersonalAdditionalInformationPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { t } = useTranslation()
|
||||||
const user = useAuthStore(s => s.user) // NEW
|
const user = useAuthStore(s => s.user) // NEW
|
||||||
const isAuthReady = useAuthStore(s => (s as any).isAuthReady) // NEW
|
const isAuthReady = useAuthStore(s => (s as any).isAuthReady) // NEW
|
||||||
const { accessToken } = useAuthStore()
|
const { accessToken } = useAuthStore()
|
||||||
@ -221,6 +237,14 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [success, setSuccess] = useState(false)
|
const [success, setSuccess] = useState(false)
|
||||||
const [error, setError] = useState('')
|
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
|
// Prefill form if profile already exists
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -250,11 +274,11 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
email: user?.email || prev.email,
|
email: user?.email || prev.email,
|
||||||
phone: user?.phone || profile?.phone || prev.phone,
|
phone: user?.phone || profile?.phone || prev.phone,
|
||||||
dob: toDateInput(profile?.date_of_birth || profile?.dateOfBirth),
|
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,
|
street: profile?.address || prev.street,
|
||||||
postalCode: profile?.zip_code || profile?.zipCode || prev.postalCode,
|
postalCode: profile?.zip_code || profile?.zipCode || prev.postalCode,
|
||||||
city: profile?.city || prev.city,
|
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,
|
accountHolder: profile?.account_holder_name || profile?.accountHolderName || prev.accountHolder,
|
||||||
// Prefer IBAN from users table (data.user.iban), fallback to profile if any
|
// Prefer IBAN from users table (data.user.iban), fallback to profile if any
|
||||||
iban: (user?.iban ?? profile?.iban ?? prev.iban) as string,
|
iban: (user?.iban ?? profile?.iban ?? prev.iban) as string,
|
||||||
@ -314,11 +338,11 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
]
|
]
|
||||||
for (const k of requiredKeys) {
|
for (const k of requiredKeys) {
|
||||||
if (!form[k].trim()) {
|
if (!form[k].trim()) {
|
||||||
const msg = 'Please fill in all required fields.'
|
const msg = t('quickactionDashboard.additionalInfo.fillRequiredFields')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Missing information',
|
title: t('quickactionDashboard.uploadId.missingInfoTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
@ -327,11 +351,11 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
|
|
||||||
// Date of birth validation
|
// Date of birth validation
|
||||||
if (!validateDateOfBirth(form.dob)) {
|
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)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Invalid date of birth',
|
title: t('quickactionDashboard.additionalInfo.invalidDateOfBirthTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
@ -339,11 +363,11 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
|
|
||||||
// very loose IBAN check
|
// very loose IBAN check
|
||||||
if (!/^([A-Z]{2}\d{2}[A-Z0-9]{10,30})$/i.test(form.iban.replace(/\s+/g,''))) {
|
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)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Invalid IBAN',
|
title: t('quickactionDashboard.additionalInfo.invalidIbanTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
@ -354,31 +378,31 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
const intlNumber = phoneApi?.getNumber() || ''
|
const intlNumber = phoneApi?.getNumber() || ''
|
||||||
const valid = phoneApi?.isValid() ?? false
|
const valid = phoneApi?.isValid() ?? false
|
||||||
if (!dialCode) {
|
if (!dialCode) {
|
||||||
const msg = 'Please select a country code for your phone number.'
|
const msg = t('quickactionDashboard.additionalInfo.missingCountryCodeMessage')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Missing country code',
|
title: t('quickactionDashboard.additionalInfo.missingCountryCodeTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (!intlNumber) {
|
if (!intlNumber) {
|
||||||
const msg = 'Please enter your phone number.'
|
const msg = t('quickactionDashboard.additionalInfo.phoneNumberMissingMessage')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Missing phone number',
|
title: t('quickactionDashboard.additionalInfo.missingPhoneNumberTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (!valid) {
|
if (!valid) {
|
||||||
const msg = 'Please enter a valid phone number.'
|
const msg = t('quickactionDashboard.additionalInfo.validPhoneNumberMessage')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Invalid phone number',
|
title: t('quickactionDashboard.additionalInfo.invalidPhoneNumberTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
@ -389,11 +413,11 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
const secondApi = secondPhoneRef.current
|
const secondApi = secondPhoneRef.current
|
||||||
const ok = secondApi?.isValid?.() ?? false
|
const ok = secondApi?.isValid?.() ?? false
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
const msg = 'Please enter a valid second phone number.'
|
const msg = t('quickactionDashboard.additionalInfo.validSecondPhoneNumberMessage')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Invalid phone number',
|
title: t('quickactionDashboard.additionalInfo.invalidPhoneNumberTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
@ -404,11 +428,11 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
const emergencyApi = emergencyPhoneRef.current
|
const emergencyApi = emergencyPhoneRef.current
|
||||||
const ok = emergencyApi?.isValid?.() ?? false
|
const ok = emergencyApi?.isValid?.() ?? false
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
const msg = 'Please enter a valid emergency phone number.'
|
const msg = t('quickactionDashboard.additionalInfo.validEmergencyPhoneNumberMessage')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Invalid phone number',
|
title: t('quickactionDashboard.additionalInfo.invalidPhoneNumberTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
@ -424,11 +448,11 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
if (!validate()) return
|
if (!validate()) return
|
||||||
|
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
const msg = 'Not authenticated. Please log in again.'
|
const msg = t('quickactionDashboard.additionalInfo.authErrorMessage')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Authentication error',
|
title: t('quickactionDashboard.additionalInfo.authErrorTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@ -447,11 +471,11 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
lastName: form.lastName,
|
lastName: form.lastName,
|
||||||
phone: normalizedPhone,
|
phone: normalizedPhone,
|
||||||
dateOfBirth: form.dob,
|
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
|
address: form.street, // Backend expects 'address', not nested object
|
||||||
zip_code: form.postalCode, // Backend expects 'zip_code'
|
zip_code: form.postalCode, // Backend expects 'zip_code'
|
||||||
city: form.city,
|
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'
|
phoneSecondary: normalizedSecondPhone || null, // Backend expects 'phoneSecondary'
|
||||||
emergencyContactName: form.emergencyName || null,
|
emergencyContactName: form.emergencyName || null,
|
||||||
emergencyContactPhone: normalizedEmergencyPhone || null,
|
emergencyContactPhone: normalizedEmergencyPhone || null,
|
||||||
@ -476,8 +500,8 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
title: 'Profile saved',
|
title: t('quickactionDashboard.additionalInfo.additionalInfoSuccessTitle'),
|
||||||
message: 'Your personal profile has been saved successfully.',
|
message: t('quickactionDashboard.additionalInfo.personalSuccessMessage'),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Refresh user status to update profile completion state
|
// Refresh user status to update profile completion state
|
||||||
@ -491,11 +515,11 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Personal profile save error:', error)
|
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)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Save failed',
|
title: t('quickactionDashboard.additionalInfo.saveFailedTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
@ -548,8 +572,8 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
{redirectTo && (
|
{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="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="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="text-sm font-medium text-gray-900">{t('quickactionDashboard.redirecting')}</div>
|
||||||
<div className="mt-1 text-xs text-gray-600">Please wait</div>
|
<div className="mt-1 text-xs text-gray-600">{t('quickactionDashboard.pleaseWait')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -572,18 +596,18 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
>
|
>
|
||||||
<div className="px-6 py-8 sm:px-10 lg:px-16">
|
<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">
|
<h1 className="text-center text-xl sm:text-2xl font-semibold text-[#0F172A] mb-6">
|
||||||
Complete Your Profile
|
{t('quickactionDashboard.additionalInfo.title')}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Personal Information */}
|
{/* Personal Information */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-[#0F2460] mb-4">
|
<h2 className="text-sm font-semibold text-[#0F2460] mb-4">
|
||||||
Personal Information
|
{t('quickactionDashboard.additionalInfo.personalInformation')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
First Name *
|
{t('quickactionDashboard.additionalInfo.firstName')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
name="firstName"
|
name="firstName"
|
||||||
@ -595,7 +619,7 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Last Name *
|
{t('quickactionDashboard.additionalInfo.lastName')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
name="lastName"
|
name="lastName"
|
||||||
@ -607,7 +631,7 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2 lg:col-span-3">
|
<div className="sm:col-span-2 lg:col-span-3">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Email *
|
{t('quickactionDashboard.additionalInfo.email')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
name="email"
|
name="email"
|
||||||
@ -620,7 +644,7 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2 lg:col-span-3">
|
<div className="sm:col-span-2 lg:col-span-3">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Phone Number *
|
{t('quickactionDashboard.additionalInfo.phoneNumber')}
|
||||||
</label>
|
</label>
|
||||||
<TelephoneInput
|
<TelephoneInput
|
||||||
name="phone"
|
name="phone"
|
||||||
@ -634,7 +658,7 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Date of Birth *
|
{t('quickactionDashboard.additionalInfo.dateOfBirth')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
@ -649,8 +673,10 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<ModernSelect
|
<ModernSelect
|
||||||
label="Nationality"
|
label={t('quickactionDashboard.additionalInfo.nationality')}
|
||||||
placeholder="Select nationality..."
|
placeholder={t('quickactionDashboard.additionalInfo.selectNationality')}
|
||||||
|
searchPlaceholder={t('quickactionDashboard.additionalInfo.searchPlaceholder')}
|
||||||
|
noResults={t('quickactionDashboard.additionalInfo.noResults')}
|
||||||
value={form.nationality}
|
value={form.nationality}
|
||||||
onChange={(v) => setField('nationality', v)}
|
onChange={(v) => setField('nationality', v)}
|
||||||
options={NATIONALITIES.map(n => ({ value: n, label: n }))}
|
options={NATIONALITIES.map(n => ({ value: n, label: n }))}
|
||||||
@ -658,7 +684,7 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2 lg:col-span-3">
|
<div className="sm:col-span-2 lg:col-span-3">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Street & House Number *
|
{t('quickactionDashboard.additionalInfo.streetHouseNumber')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
name="street"
|
name="street"
|
||||||
@ -671,7 +697,7 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Postal Code *
|
{t('quickactionDashboard.additionalInfo.postalCode')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
name="postalCode"
|
name="postalCode"
|
||||||
@ -684,7 +710,7 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
City *
|
{t('quickactionDashboard.additionalInfo.city')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
name="city"
|
name="city"
|
||||||
@ -697,8 +723,10 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<ModernSelect
|
<ModernSelect
|
||||||
label="Country"
|
label={t('quickactionDashboard.additionalInfo.country')}
|
||||||
placeholder="Select country..."
|
placeholder={t('quickactionDashboard.additionalInfo.selectCountry')}
|
||||||
|
searchPlaceholder={t('quickactionDashboard.additionalInfo.searchPlaceholder')}
|
||||||
|
noResults={t('quickactionDashboard.additionalInfo.noResults')}
|
||||||
value={form.country}
|
value={form.country}
|
||||||
onChange={(v) => setField('country', v)}
|
onChange={(v) => setField('country', v)}
|
||||||
options={COUNTRIES.map(c => ({ value: c, label: c }))}
|
options={COUNTRIES.map(c => ({ value: c, label: c }))}
|
||||||
@ -712,12 +740,12 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
{/* Bank Details */}
|
{/* Bank Details */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-[#0F2460] mb-4">
|
<h2 className="text-sm font-semibold text-[#0F2460] mb-4">
|
||||||
Bank Details
|
{t('quickactionDashboard.additionalInfo.bankDetails')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Account Holder *
|
{t('quickactionDashboard.additionalInfo.accountHolder')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
name="accountHolder"
|
name="accountHolder"
|
||||||
@ -730,7 +758,7 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-1 lg:col-span-2">
|
<div className="sm:col-span-1 lg:col-span-2">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
IBAN *
|
{t('quickactionDashboard.additionalInfo.iban')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
name="iban"
|
name="iban"
|
||||||
@ -749,12 +777,12 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
{/* Additional Information */}
|
{/* Additional Information */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-sm font-semibold text-[#0F2460] mb-4">
|
<h2 className="text-sm font-semibold text-[#0F2460] mb-4">
|
||||||
Additional Information
|
{t('quickactionDashboard.additionalInfo.additionalInformation')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<div className="sm:col-span-2 lg:col-span-3">
|
<div className="sm:col-span-2 lg:col-span-3">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Second Phone Number (optional)
|
{t('quickactionDashboard.additionalInfo.secondPhoneOptional')}
|
||||||
</label>
|
</label>
|
||||||
<TelephoneInput
|
<TelephoneInput
|
||||||
name="secondPhone"
|
name="secondPhone"
|
||||||
@ -767,7 +795,7 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Emergency Contact Name
|
{t('quickactionDashboard.additionalInfo.emergencyContactName')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
name="emergencyName"
|
name="emergencyName"
|
||||||
@ -779,7 +807,7 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Emergency Contact Phone
|
{t('quickactionDashboard.additionalInfo.emergencyContactPhone')}
|
||||||
</label>
|
</label>
|
||||||
<TelephoneInput
|
<TelephoneInput
|
||||||
name="emergencyPhone"
|
name="emergencyPhone"
|
||||||
@ -801,7 +829,7 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
)}
|
)}
|
||||||
{success && (
|
{success && (
|
||||||
<div className="mt-6 rounded-md border border-green-300 bg-green-50 px-4 py-3 text-xs text-green-700">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -811,7 +839,7 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
onClick={() => router.push('/quickaction-dashboard')}
|
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"
|
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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@ -819,7 +847,7 @@ export default function PersonalAdditionalInformationPage() {
|
|||||||
disabled={loading || success}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,8 +7,10 @@ import useAuthStore from '../../store/authStore'
|
|||||||
import { useUserStatus } from '../../hooks/useUserStatus'
|
import { useUserStatus } from '../../hooks/useUserStatus'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useToast } from '../../components/toast/toastComponent'
|
import { useToast } from '../../components/toast/toastComponent'
|
||||||
|
import { useTranslation } from '../../i18n/useTranslation'
|
||||||
|
|
||||||
export default function EmailVerifyPage() {
|
export default function EmailVerifyPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const user = useAuthStore(s => s.user)
|
const user = useAuthStore(s => s.user)
|
||||||
const isAuthReady = useAuthStore(s => (s as any).isAuthReady) // NEW
|
const isAuthReady = useAuthStore(s => (s as any).isAuthReady) // NEW
|
||||||
const token = useAuthStore(s => s.accessToken)
|
const token = useAuthStore(s => s.accessToken)
|
||||||
@ -69,34 +71,34 @@ export default function EmailVerifyPage() {
|
|||||||
setResendCooldown(Math.ceil(RESEND_INTERVAL_MS / 1000))
|
setResendCooldown(Math.ceil(RESEND_INTERVAL_MS / 1000))
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
title: 'Verification email sent',
|
title: t('quickactionDashboard.emailVerified'),
|
||||||
message: `We sent a verification email to ${user?.email || 'your email'}.`
|
message: `${t('quickactionDashboard.emailVerify.sentIntro')} ${user?.email || t('quickactionDashboard.emailVerify.yourEmail')}.`
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
const msg = data?.message || 'Error sending the verification email.'
|
const msg = data?.message || t('quickactionDashboard.emailVerify.networkErrorTitle')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
emailSentRef.current = false
|
emailSentRef.current = false
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Email not sent',
|
title: t('quickactionDashboard.emailVerify.verificationFailedTitle'),
|
||||||
message: msg
|
message: msg
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error sending initial verification email:', 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)
|
setError(msg)
|
||||||
emailSentRef.current = false
|
emailSentRef.current = false
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Network error',
|
title: t('quickactionDashboard.emailVerify.networkErrorTitle'),
|
||||||
message: msg
|
message: msg
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sendInitialEmail()
|
sendInitialEmail()
|
||||||
}, [token, user, showToast])
|
}, [token, user, showToast, t])
|
||||||
|
|
||||||
// Cooldown timer
|
// Cooldown timer
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -194,21 +196,21 @@ export default function EmailVerifyPage() {
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (fullCode.length !== 6) {
|
if (fullCode.length !== 6) {
|
||||||
const msg = 'Please enter the 6-digit code.'
|
const msg = t('quickactionDashboard.emailVerify.invalidCode')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Invalid code',
|
title: t('quickactionDashboard.emailVerify.invalidCode'),
|
||||||
message: msg
|
message: msg
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!token) {
|
if (!token) {
|
||||||
const msg = 'Not authenticated. Please log in again.'
|
const msg = t('quickactionDashboard.emailVerify.authError')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Authentication error',
|
title: t('quickactionDashboard.uploadId.authErrorTitle'),
|
||||||
message: msg
|
message: msg
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@ -232,29 +234,29 @@ export default function EmailVerifyPage() {
|
|||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
title: 'Email verified',
|
title: t('quickactionDashboard.emailVerify.emailVerifiedTitle'),
|
||||||
message: 'Your email has been verified successfully.'
|
message: t('quickactionDashboard.emailVerify.emailVerifiedMessage')
|
||||||
})
|
})
|
||||||
await refreshStatus()
|
await refreshStatus()
|
||||||
// Guests go directly to dashboard after email verification
|
// Guests go directly to dashboard after email verification
|
||||||
const isGuest = user?.role === 'guest'
|
const isGuest = user?.role === 'guest'
|
||||||
window.location.href = isGuest ? '/dashboard' : '/quickaction-dashboard?tutorial=true'
|
window.location.href = isGuest ? '/dashboard' : '/quickaction-dashboard?tutorial=true'
|
||||||
} else {
|
} else {
|
||||||
const msg = data.error || 'Verification failed. Please try again.'
|
const msg = data.error || t('quickactionDashboard.emailVerify.verificationFailedTitle')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Verification failed',
|
title: t('quickactionDashboard.emailVerify.verificationFailedTitle'),
|
||||||
message: msg
|
message: msg
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Email verification error:', err)
|
console.error('Email verification error:', err)
|
||||||
const msg = 'Network error. Please try again.'
|
const msg = t('quickactionDashboard.uploadId.networkErrorMessage')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Network error',
|
title: t('quickactionDashboard.emailVerify.networkErrorTitle'),
|
||||||
message: msg
|
message: msg
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
@ -275,11 +277,11 @@ export default function EmailVerifyPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!token) {
|
if (!token) {
|
||||||
const msg = 'Not authenticated. Please log in again.'
|
const msg = t('quickactionDashboard.emailVerify.authError')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Authentication error',
|
title: t('quickactionDashboard.uploadId.authErrorTitle'),
|
||||||
message: msg
|
message: msg
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@ -304,29 +306,29 @@ export default function EmailVerifyPage() {
|
|||||||
if (!initialEmailSent) setInitialEmailSent(true)
|
if (!initialEmailSent) setInitialEmailSent(true)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
title: 'Verification email sent',
|
title: t('quickactionDashboard.emailVerified'),
|
||||||
message: `We sent a new verification email to ${user?.email || 'your email'}.`
|
message: `${t('quickactionDashboard.emailVerify.sentIntro')} ${user?.email || t('quickactionDashboard.emailVerify.yourEmail')}.`
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
const msg = data?.message || 'Error sending the email.'
|
const msg = data?.message || t('quickactionDashboard.emailVerify.networkErrorTitle')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Email not sent',
|
title: t('quickactionDashboard.emailVerify.verificationFailedTitle'),
|
||||||
message: msg
|
message: msg
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Resend email error:', err)
|
console.error('Resend email error:', err)
|
||||||
const msg = 'Network error while sending the email.'
|
const msg = t('quickactionDashboard.emailVerify.networkErrorTitle')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Network error',
|
title: t('quickactionDashboard.emailVerify.networkErrorTitle'),
|
||||||
message: msg
|
message: msg
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [token, submitting, success, user, initialEmailSent, showToast])
|
}, [token, submitting, success, user, initialEmailSent, showToast, t])
|
||||||
|
|
||||||
// NEW: format seconds to m:ss
|
// NEW: format seconds to m:ss
|
||||||
const formatMmSs = (total: number) => {
|
const formatMmSs = (total: number) => {
|
||||||
@ -376,8 +378,8 @@ export default function EmailVerifyPage() {
|
|||||||
{redirectTo && (
|
{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="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="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="text-sm font-medium text-gray-900">{t('quickactionDashboard.redirecting')}</div>
|
||||||
<div className="mt-1 text-xs text-gray-600">Please wait</div>
|
<div className="mt-1 text-xs text-gray-600">{t('quickactionDashboard.pleaseWait')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -387,22 +389,22 @@ export default function EmailVerifyPage() {
|
|||||||
<div className="max-w-xl mx-auto">
|
<div className="max-w-xl mx-auto">
|
||||||
<div className="text-center mb-10">
|
<div className="text-center mb-10">
|
||||||
<h1 className="text-3xl sm:text-4xl font-semibold tracking-tight text-gray-900">
|
<h1 className="text-3xl sm:text-4xl font-semibold tracking-tight text-gray-900">
|
||||||
Verify your email
|
{t('quickactionDashboard.emailVerify.title')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-3 text-gray-700 text-sm sm:text-base">
|
<p className="mt-3 text-gray-700 text-sm sm:text-base">
|
||||||
{initialEmailSent ? (
|
{initialEmailSent ? (
|
||||||
<>
|
<>
|
||||||
We sent a 6-digit code to{' '}
|
{t('quickactionDashboard.emailVerify.sentIntro')}{' '}
|
||||||
<span className="text-[#8D6B1D] font-medium">
|
<span className="text-[#8D6B1D] font-medium">
|
||||||
{user?.email || 'your email'}
|
{user?.email || t('quickactionDashboard.emailVerify.yourEmail')}
|
||||||
</span>
|
</span>
|
||||||
. Enter it below.
|
. {t('quickactionDashboard.emailVerify.enterBelow')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
Sending verification email to{' '}
|
{t('quickactionDashboard.emailVerify.sendingIntro')}{' '}
|
||||||
<span className="text-[#8D6B1D] font-medium">
|
<span className="text-[#8D6B1D] font-medium">
|
||||||
{user?.email || 'your email'}
|
{user?.email || t('quickactionDashboard.emailVerify.yourEmail')}
|
||||||
</span>
|
</span>
|
||||||
...
|
...
|
||||||
</>
|
</>
|
||||||
@ -445,7 +447,7 @@ export default function EmailVerifyPage() {
|
|||||||
|
|
||||||
{success && (
|
{success && (
|
||||||
<div className="rounded-lg border border-green-300 bg-green-50 px-4 py-3 text-sm text-green-700">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -460,9 +462,9 @@ export default function EmailVerifyPage() {
|
|||||||
{submitting ? (
|
{submitting ? (
|
||||||
<>
|
<>
|
||||||
<span className="size-4 mr-2 rounded-full border-2 border-white border-b-transparent animate-spin" />
|
<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>
|
||||||
|
|
||||||
<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"
|
className="text-sm font-medium text-[#8D6B1D] hover:underline disabled:text-gray-400 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{resendCooldown
|
{resendCooldown
|
||||||
? `Resend in ${formatMmSs(resendCooldown)}`
|
? `${t('quickactionDashboard.resendAvailableIn')} ${formatMmSs(resendCooldown)}`
|
||||||
: 'Resend code'}
|
: t('quickactionDashboard.emailVerify.resendCode')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -483,15 +485,15 @@ export default function EmailVerifyPage() {
|
|||||||
onClick={() => router.push('/quickaction-dashboard')}
|
onClick={() => router.push('/quickaction-dashboard')}
|
||||||
className="text-sm font-medium text-[#8D6B1D] hover:underline"
|
className="text-sm font-medium text-[#8D6B1D] hover:underline"
|
||||||
>
|
>
|
||||||
Go to Dashboard
|
{t('quickactionDashboard.goToDashboard')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div className="mt-8 text-center text-xs text-gray-500">
|
<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">
|
<a href="mailto:test@test.com" className="text-[#8D6B1D] hover:underline">
|
||||||
Contact support
|
{t('quickactionDashboard.emailVerify.contactSupport')}
|
||||||
</a>
|
</a>
|
||||||
.
|
.
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import useAuthStore from '../../../store/authStore'
|
|||||||
import { useUserStatus } from '../../../hooks/useUserStatus'
|
import { useUserStatus } from '../../../hooks/useUserStatus'
|
||||||
import { API_BASE_URL } from '../../../utils/api'
|
import { API_BASE_URL } from '../../../utils/api'
|
||||||
import { useToast } from '../../../components/toast/toastComponent'
|
import { useToast } from '../../../components/toast/toastComponent'
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation'
|
||||||
|
|
||||||
export default function CompanySignContractPage() {
|
export default function CompanySignContractPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -15,6 +16,7 @@ export default function CompanySignContractPage() {
|
|||||||
const { accessToken } = useAuthStore()
|
const { accessToken } = useAuthStore()
|
||||||
const { userStatus, loading: statusLoading, refreshStatus } = useUserStatus()
|
const { userStatus, loading: statusLoading, refreshStatus } = useUserStatus()
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const [date, setDate] = useState('')
|
const [date, setDate] = useState('')
|
||||||
const [signatureDataUrl, setSignatureDataUrl] = useState('')
|
const [signatureDataUrl, setSignatureDataUrl] = useState('')
|
||||||
@ -201,37 +203,37 @@ export default function CompanySignContractPage() {
|
|||||||
const gdprAvailable = !!previewState.gdpr.html
|
const gdprAvailable = !!previewState.gdpr.html
|
||||||
|
|
||||||
if (!contractAvailable && !gdprAvailable) {
|
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)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'No documents available',
|
title: t('quickactionDashboard.contractSigning.noDocumentsAvailableTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contractAvailable && !agreeContract) issues.push('Contract read and understood')
|
if (contractAvailable && !agreeContract) issues.push(t('quickactionDashboard.contractSigning.contractReadUnderstood'))
|
||||||
if (gdprAvailable && !agreeData) issues.push('Privacy policy accepted')
|
if (gdprAvailable && !agreeData) issues.push(t('quickactionDashboard.contractSigning.privacyAccepted'))
|
||||||
if (!confirmSignature) issues.push('Electronic signature confirmed')
|
if (!confirmSignature) issues.push(t('quickactionDashboard.contractSigning.electronicSignatureConfirmed'))
|
||||||
if (!signatureDataUrl) issues.push('Signature captured on pad')
|
if (!signatureDataUrl) issues.push(t('quickactionDashboard.contractSigning.signatureCaptured'))
|
||||||
|
|
||||||
const msg = `Please complete: ${issues.join(', ')}`
|
const msg = `${t('quickactionDashboard.contractSigning.completePrefix')} ${issues.join(', ')}`
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Missing information',
|
title: t('quickactionDashboard.contractSigning.missingInformationTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
const msg = 'Not authenticated. Please log in again.'
|
const msg = t('quickactionDashboard.contractSigning.authErrorMessage')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Authentication error',
|
title: t('quickactionDashboard.contractSigning.authErrorTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@ -270,8 +272,8 @@ export default function CompanySignContractPage() {
|
|||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
title: 'Contract signed',
|
title: t('quickactionDashboard.contractSigning.contractSignedTitle'),
|
||||||
message: 'Your company contract has been signed successfully.',
|
message: t('quickactionDashboard.contractSigning.companyContractSignedMessage'),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Refresh user status to update contract signed state
|
// Refresh user status to update contract signed state
|
||||||
@ -283,11 +285,11 @@ export default function CompanySignContractPage() {
|
|||||||
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error('Contract signing error:', error)
|
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)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Signature failed',
|
title: t('quickactionDashboard.contractSigning.signingFailedTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
@ -334,8 +336,8 @@ export default function CompanySignContractPage() {
|
|||||||
{redirectTo && (
|
{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="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="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="text-sm font-medium text-gray-900">{t('quickactionDashboard.redirecting')}</div>
|
||||||
<div className="mt-1 text-xs text-gray-600">Please wait</div>
|
<div className="mt-1 text-xs text-gray-600">{t('quickactionDashboard.pleaseWait')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -357,10 +359,10 @@ export default function CompanySignContractPage() {
|
|||||||
{/* CHANGED: tighter padding on mobile */}
|
{/* CHANGED: tighter padding on mobile */}
|
||||||
<div className="px-4 py-6 sm:px-10 sm:py-8 lg:px-14">
|
<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">
|
<h1 className="text-center text-2xl sm:text-3xl font-semibold text-[#0F172A] mb-2">
|
||||||
Sign Company Partnership Contract
|
{t('quickactionDashboard.contractSigning.companyTitle')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-center text-sm text-gray-600 mb-8">
|
<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>
|
</p>
|
||||||
|
|
||||||
{/* CHANGED: smaller gaps on mobile */}
|
{/* 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">
|
<div className="rounded-lg border border-gray-200 p-4 sm:p-5 bg-gray-50">
|
||||||
{/* CHANGED: stack header + tabs on mobile */}
|
{/* CHANGED: stack header + tabs on mobile */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-3">
|
<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">
|
<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) => (
|
{(['contract','gdpr'] as const).map((tab) => (
|
||||||
<button
|
<button
|
||||||
@ -379,7 +381,7 @@ export default function CompanySignContractPage() {
|
|||||||
onClick={() => setActiveTab(tab)}
|
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'}`}
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -406,22 +408,22 @@ export default function CompanySignContractPage() {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ul className="space-y-2 text-xs sm:text-sm text-gray-600">
|
<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">{t('quickactionDashboard.contractSigning.documentLabel')}</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">{t('quickactionDashboard.contractSigning.idLabel')}</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">{t('quickactionDashboard.contractSigning.versionLabel')}</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">{t('quickactionDashboard.contractSigning.jurisdictionLabel')}</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">{t('quickactionDashboard.contractSigning.languageLabel')}</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">{t('quickactionDashboard.contractSigning.issuerLabel')}</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.addressLabel')}</span> {meta.address}</li>
|
||||||
</ul>
|
</ul>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
{/* CHANGED: tighter padding */}
|
{/* CHANGED: tighter padding */}
|
||||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 sm:p-5">
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -431,7 +433,7 @@ export default function CompanySignContractPage() {
|
|||||||
{/* CHANGED: stack toolbar on mobile */}
|
{/* 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 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">
|
<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">
|
<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) => (
|
{(['contract','gdpr'] as const).map((tab) => (
|
||||||
<button
|
<button
|
||||||
@ -440,7 +442,7 @@ export default function CompanySignContractPage() {
|
|||||||
onClick={() => setActiveTab(tab)}
|
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'}`}
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -460,7 +462,7 @@ export default function CompanySignContractPage() {
|
|||||||
disabled={!previewState[activeTab]?.html}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -468,20 +470,20 @@ export default function CompanySignContractPage() {
|
|||||||
disabled={previewState[activeTab].loading}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CHANGED: shorter on mobile */}
|
{/* CHANGED: shorter on mobile */}
|
||||||
{previewLoading || previewState[activeTab].loading ? (
|
{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 ? (
|
) : 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>
|
<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 ? (
|
) : previewState[activeTab].html ? (
|
||||||
<iframe title={`Company Document Preview ${activeTab}`} className="w-full h-64 sm:h-72" srcDoc={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>
|
||||||
</div>
|
</div>
|
||||||
@ -490,11 +492,11 @@ export default function CompanySignContractPage() {
|
|||||||
<hr className="my-10 border-gray-200" />
|
<hr className="my-10 border-gray-200" />
|
||||||
|
|
||||||
<section>
|
<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="">
|
<div className="">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Draw Signature *
|
{t('quickactionDashboard.contractSigning.drawSignature')}
|
||||||
</label>
|
</label>
|
||||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||||
<canvas
|
<canvas
|
||||||
@ -517,10 +519,10 @@ export default function CompanySignContractPage() {
|
|||||||
onClick={clearSignature}
|
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"
|
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>
|
</button>
|
||||||
<span className="text-gray-500">Use mouse or touch to sign. A signature is required.</span>
|
<span className="text-gray-500">{t('quickactionDashboard.contractSigning.signatureHelp')}</span>
|
||||||
{signatureDataUrl && <span className="text-green-600 font-medium">Captured</span>}
|
{signatureDataUrl && <span className="text-green-600 font-medium">{t('quickactionDashboard.contractSigning.captured')}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -529,7 +531,7 @@ export default function CompanySignContractPage() {
|
|||||||
<hr className="my-10 border-gray-200" />
|
<hr className="my-10 border-gray-200" />
|
||||||
|
|
||||||
<section className="space-y-5">
|
<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">
|
<label className="flex items-start gap-3 text-sm text-gray-700">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@ -537,7 +539,7 @@ export default function CompanySignContractPage() {
|
|||||||
onChange={e => setAgreeContract(e.target.checked)}
|
onChange={e => setAgreeContract(e.target.checked)}
|
||||||
className="mt-1 h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
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>
|
||||||
<label className="flex items-start gap-3 text-sm text-gray-700">
|
<label className="flex items-start gap-3 text-sm text-gray-700">
|
||||||
<input
|
<input
|
||||||
@ -546,7 +548,7 @@ export default function CompanySignContractPage() {
|
|||||||
onChange={e => setAgreeData(e.target.checked)}
|
onChange={e => setAgreeData(e.target.checked)}
|
||||||
className="mt-1 h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
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>
|
||||||
<label className="flex items-start gap-3 text-sm text-gray-700">
|
<label className="flex items-start gap-3 text-sm text-gray-700">
|
||||||
<input
|
<input
|
||||||
@ -555,7 +557,7 @@ export default function CompanySignContractPage() {
|
|||||||
onChange={e => setConfirmSignature(e.target.checked)}
|
onChange={e => setConfirmSignature(e.target.checked)}
|
||||||
className="mt-1 h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
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>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -566,7 +568,7 @@ export default function CompanySignContractPage() {
|
|||||||
)}
|
)}
|
||||||
{success && (
|
{success && (
|
||||||
<div className="mt-8 rounded-md border border-green-300 bg-green-50 px-4 py-3 text-sm text-green-700">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -577,14 +579,14 @@ export default function CompanySignContractPage() {
|
|||||||
onClick={() => router.push('/quickaction-dashboard')}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting || success || (!previewState.contract.html && !previewState.gdpr.html)}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import useAuthStore from '../../../store/authStore'
|
|||||||
import { useUserStatus } from '../../../hooks/useUserStatus'
|
import { useUserStatus } from '../../../hooks/useUserStatus'
|
||||||
import { API_BASE_URL } from '../../../utils/api'
|
import { API_BASE_URL } from '../../../utils/api'
|
||||||
import { useToast } from '../../../components/toast/toastComponent'
|
import { useToast } from '../../../components/toast/toastComponent'
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation'
|
||||||
|
|
||||||
export default function PersonalSignContractPage() {
|
export default function PersonalSignContractPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -15,7 +16,8 @@ export default function PersonalSignContractPage() {
|
|||||||
const { accessToken } = useAuthStore()
|
const { accessToken } = useAuthStore()
|
||||||
const { userStatus, loading: statusLoading, refreshStatus } = useUserStatus() // CHANGED
|
const { userStatus, loading: statusLoading, refreshStatus } = useUserStatus() // CHANGED
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const [date, setDate] = useState('')
|
const [date, setDate] = useState('')
|
||||||
const [signatureDataUrl, setSignatureDataUrl] = useState('')
|
const [signatureDataUrl, setSignatureDataUrl] = useState('')
|
||||||
const [agreeContract, setAgreeContract] = useState(false)
|
const [agreeContract, setAgreeContract] = useState(false)
|
||||||
@ -240,37 +242,37 @@ export default function PersonalSignContractPage() {
|
|||||||
const gdprAvailable = !!previewState.gdpr.html
|
const gdprAvailable = !!previewState.gdpr.html
|
||||||
|
|
||||||
if (!contractAvailable && !gdprAvailable) {
|
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)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'No documents available',
|
title: t('quickactionDashboard.contractSigning.noDocumentsAvailableTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contractAvailable && !agreeContract) issues.push('Contract read and understood')
|
if (contractAvailable && !agreeContract) issues.push(t('quickactionDashboard.contractSigning.contractReadUnderstood'))
|
||||||
if (gdprAvailable && !agreeData) issues.push('Privacy policy accepted')
|
if (gdprAvailable && !agreeData) issues.push(t('quickactionDashboard.contractSigning.privacyAccepted'))
|
||||||
if (!confirmSignature) issues.push('Electronic signature confirmed')
|
if (!confirmSignature) issues.push(t('quickactionDashboard.contractSigning.electronicSignatureConfirmed'))
|
||||||
if (!signatureDataUrl) issues.push('Signature captured on pad')
|
if (!signatureDataUrl) issues.push(t('quickactionDashboard.contractSigning.signatureCaptured'))
|
||||||
|
|
||||||
const msg = `Please complete: ${issues.join(', ')}`
|
const msg = `${t('quickactionDashboard.contractSigning.completePrefix')} ${issues.join(', ')}`
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Missing information',
|
title: t('quickactionDashboard.contractSigning.missingInformationTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
const msg = 'Not authenticated. Please log in again.'
|
const msg = t('quickactionDashboard.contractSigning.authErrorMessage')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Authentication error',
|
title: t('quickactionDashboard.contractSigning.authErrorTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@ -309,8 +311,8 @@ export default function PersonalSignContractPage() {
|
|||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
title: 'Contract signed',
|
title: t('quickactionDashboard.contractSigning.contractSignedTitle'),
|
||||||
message: 'Your personal contract has been signed successfully.',
|
message: t('quickactionDashboard.contractSigning.personalContractSignedMessage'),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Refresh user status to update contract signed state
|
// Refresh user status to update contract signed state
|
||||||
@ -322,11 +324,11 @@ export default function PersonalSignContractPage() {
|
|||||||
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error('Contract signing error:', error)
|
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)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Signature failed',
|
title: t('quickactionDashboard.contractSigning.signingFailedTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
@ -340,8 +342,8 @@ export default function PersonalSignContractPage() {
|
|||||||
{redirectTo && (
|
{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="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="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="text-sm font-medium text-gray-900">{t('quickactionDashboard.redirecting')}</div>
|
||||||
<div className="mt-1 text-xs text-gray-600">Please wait</div>
|
<div className="mt-1 text-xs text-gray-600">{t('quickactionDashboard.pleaseWait')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -366,10 +368,10 @@ export default function PersonalSignContractPage() {
|
|||||||
{/* CHANGED: tighter padding on mobile */}
|
{/* CHANGED: tighter padding on mobile */}
|
||||||
<div className="px-4 py-6 sm:px-10 sm:py-8 lg:px-14">
|
<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">
|
<h1 className="text-center text-2xl sm:text-3xl font-semibold text-[#0F172A] mb-2">
|
||||||
Sign Personal Participation Contract
|
{t('quickactionDashboard.contractSigning.personalTitle')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-center text-sm text-gray-600 mb-8">
|
<p className="text-center text-sm text-gray-600 mb-8">
|
||||||
Please review the contract details and sign electronically.
|
{t('quickactionDashboard.contractSigning.personalSubtitle')}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Contract Meta + Preview */}
|
{/* 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">
|
<div className="rounded-lg border border-gray-200 p-4 sm:p-5 bg-gray-50">
|
||||||
{/* CHANGED: stack header + tabs on mobile */}
|
{/* CHANGED: stack header + tabs on mobile */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-3">
|
<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">
|
<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) => (
|
{(['contract','gdpr'] as const).map((tab) => (
|
||||||
<button
|
<button
|
||||||
@ -388,7 +390,7 @@ export default function PersonalSignContractPage() {
|
|||||||
onClick={() => setActiveTab(tab)}
|
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'}`}
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -415,22 +417,22 @@ export default function PersonalSignContractPage() {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<ul className="space-y-2 text-xs sm:text-sm text-gray-600">
|
<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">{t('quickactionDashboard.contractSigning.documentLabel')}</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">{t('quickactionDashboard.contractSigning.idLabel')}</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">{t('quickactionDashboard.contractSigning.versionLabel')}</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">{t('quickactionDashboard.contractSigning.jurisdictionLabel')}</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">{t('quickactionDashboard.contractSigning.languageLabel')}</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">{t('quickactionDashboard.contractSigning.issuerLabel')}</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.addressLabel')}</span> {meta.address}</li>
|
||||||
</ul>
|
</ul>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
{/* CHANGED: tighter padding */}
|
{/* CHANGED: tighter padding */}
|
||||||
<div className="rounded-lg border border-indigo-100 bg-indigo-50/60 p-4 sm:p-5">
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -439,7 +441,7 @@ export default function PersonalSignContractPage() {
|
|||||||
{/* CHANGED: stack toolbar on mobile */}
|
{/* 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 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">
|
<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">
|
<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) => (
|
{(['contract','gdpr'] as const).map((tab) => (
|
||||||
<button
|
<button
|
||||||
@ -448,7 +450,7 @@ export default function PersonalSignContractPage() {
|
|||||||
onClick={() => setActiveTab(tab)}
|
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'}`}
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -468,20 +470,20 @@ export default function PersonalSignContractPage() {
|
|||||||
disabled={!previewState[activeTab]?.html}
|
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"
|
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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CHANGED: shorter on mobile */}
|
{/* CHANGED: shorter on mobile */}
|
||||||
{previewLoading || previewState[activeTab].loading ? (
|
{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 ? (
|
) : 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>
|
<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 ? (
|
) : previewState[activeTab].html ? (
|
||||||
<iframe title={`Contract Preview ${activeTab}`} className="w-full h-64 sm:h-72" srcDoc={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>
|
||||||
</div>
|
</div>
|
||||||
@ -491,11 +493,11 @@ export default function PersonalSignContractPage() {
|
|||||||
|
|
||||||
{/* Signature Pad */}
|
{/* Signature Pad */}
|
||||||
<section>
|
<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="">
|
<div className="">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Draw Signature *
|
{t('quickactionDashboard.contractSigning.drawSignature')}
|
||||||
</label>
|
</label>
|
||||||
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||||
<canvas
|
<canvas
|
||||||
@ -518,10 +520,10 @@ export default function PersonalSignContractPage() {
|
|||||||
onClick={clearSignature}
|
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"
|
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>
|
</button>
|
||||||
<span className="text-gray-500">Use mouse or touch to sign. A signature is required.</span>
|
<span className="text-gray-500">{t('quickactionDashboard.contractSigning.signatureHelp')}</span>
|
||||||
{signatureDataUrl && <span className="text-green-600 font-medium">Captured</span>}
|
{signatureDataUrl && <span className="text-green-600 font-medium">{t('quickactionDashboard.contractSigning.captured')}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -531,7 +533,7 @@ export default function PersonalSignContractPage() {
|
|||||||
|
|
||||||
{/* Confirmations */}
|
{/* Confirmations */}
|
||||||
<section className="space-y-5">
|
<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">
|
<label className="flex items-start gap-3 text-sm text-gray-700">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@ -539,7 +541,7 @@ export default function PersonalSignContractPage() {
|
|||||||
onChange={e => setAgreeContract(e.target.checked)}
|
onChange={e => setAgreeContract(e.target.checked)}
|
||||||
className="mt-1 h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
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>
|
||||||
<label className="flex items-start gap-3 text-sm text-gray-700">
|
<label className="flex items-start gap-3 text-sm text-gray-700">
|
||||||
<input
|
<input
|
||||||
@ -548,7 +550,7 @@ export default function PersonalSignContractPage() {
|
|||||||
onChange={e => setAgreeData(e.target.checked)}
|
onChange={e => setAgreeData(e.target.checked)}
|
||||||
className="mt-1 h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
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>
|
||||||
<label className="flex items-start gap-3 text-sm text-gray-700">
|
<label className="flex items-start gap-3 text-sm text-gray-700">
|
||||||
<input
|
<input
|
||||||
@ -557,7 +559,7 @@ export default function PersonalSignContractPage() {
|
|||||||
onChange={e => setConfirmSignature(e.target.checked)}
|
onChange={e => setConfirmSignature(e.target.checked)}
|
||||||
className="mt-1 h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
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>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@ -568,7 +570,7 @@ export default function PersonalSignContractPage() {
|
|||||||
)}
|
)}
|
||||||
{success && (
|
{success && (
|
||||||
<div className="mt-8 rounded-md border border-green-300 bg-green-50 px-4 py-3 text-sm text-green-700">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -579,14 +581,14 @@ export default function PersonalSignContractPage() {
|
|||||||
onClick={() => router.push('/quickaction-dashboard')}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting || success || (!previewState.contract.html && !previewState.gdpr.html)}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,12 +4,14 @@ import { useState, useRef, useEffect, useCallback } from 'react'
|
|||||||
import useAuthStore from '../../../../store/authStore'
|
import useAuthStore from '../../../../store/authStore'
|
||||||
import { useUserStatus } from '../../../../hooks/useUserStatus'
|
import { useUserStatus } from '../../../../hooks/useUserStatus'
|
||||||
import { useToast } from '../../../../components/toast/toastComponent'
|
import { useToast } from '../../../../components/toast/toastComponent'
|
||||||
|
import { useTranslation } from '../../../../i18n/useTranslation'
|
||||||
|
|
||||||
export function useCompanyUploadId() {
|
export function useCompanyUploadId() {
|
||||||
// Auth + status
|
// Auth + status
|
||||||
const { accessToken } = useAuthStore()
|
const { accessToken } = useAuthStore()
|
||||||
const { refreshStatus } = useUserStatus()
|
const { refreshStatus } = useUserStatus()
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [idNumber, setIdNumber] = useState('')
|
const [idNumber, setIdNumber] = useState('')
|
||||||
@ -39,11 +41,11 @@ export function useCompanyUploadId() {
|
|||||||
// File handlers
|
// File handlers
|
||||||
const handleFile = (file: File, which: 'front' | 'extra') => {
|
const handleFile = (file: File, which: 'front' | 'extra') => {
|
||||||
if (file.size > 10 * 1024 * 1024) {
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
const msg = 'File size exceeds 10 MB.'
|
const msg = t('quickactionDashboard.uploadId.fileTooLargeMessage')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'File too large',
|
title: t('quickactionDashboard.uploadId.fileTooLargeTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@ -89,11 +91,11 @@ export function useCompanyUploadId() {
|
|||||||
// Validation
|
// Validation
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
if (!idNumber.trim() || !idType || !expiryDate || !frontFile) {
|
if (!idNumber.trim() || !idType || !expiryDate || !frontFile) {
|
||||||
const msg = 'Please complete all required fields (marked with *).'
|
const msg = t('quickactionDashboard.uploadId.fillRequiredFields')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Missing information',
|
title: t('quickactionDashboard.uploadId.missingInfoTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
@ -107,11 +109,11 @@ export function useCompanyUploadId() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!validate()) return
|
if (!validate()) return
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
const msg = 'Not authenticated. Please log in again.'
|
const msg = t('quickactionDashboard.emailVerify.authError')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Authentication error',
|
title: t('quickactionDashboard.uploadId.authErrorTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@ -143,8 +145,8 @@ export function useCompanyUploadId() {
|
|||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
title: 'Documents uploaded',
|
title: t('quickactionDashboard.uploadId.companyUploadSuccessTitle'),
|
||||||
message: 'Your company ID documents have been uploaded successfully.',
|
message: t('quickactionDashboard.uploadId.companyUploadSuccessMessage'),
|
||||||
})
|
})
|
||||||
await refreshStatus()
|
await refreshStatus()
|
||||||
|
|
||||||
@ -153,11 +155,11 @@ export function useCompanyUploadId() {
|
|||||||
}, 1500)
|
}, 1500)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Company ID upload error:', err)
|
console.error('Company ID upload error:', err)
|
||||||
const msg = err?.message || 'Upload failed.'
|
const msg = err?.message || t('quickactionDashboard.uploadId.uploadFailedMessage')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Upload failed',
|
title: t('quickactionDashboard.uploadId.uploadFailedTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -8,10 +8,12 @@ import useAuthStore from '../../../store/authStore'
|
|||||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useUserStatus } from '../../../hooks/useUserStatus'
|
import { useUserStatus } from '../../../hooks/useUserStatus'
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation'
|
||||||
|
|
||||||
const DOC_TYPES = ['Personalausweis', 'Reisepass', 'Führerschein', 'Aufenthaltstitel']
|
const DOC_TYPES = ['Personalausweis', 'Reisepass', 'Führerschein', 'Aufenthaltstitel']
|
||||||
|
|
||||||
export default function CompanyIdUploadPage() {
|
export default function CompanyIdUploadPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const {
|
const {
|
||||||
// values
|
// values
|
||||||
idNumber, setIdNumber,
|
idNumber, setIdNumber,
|
||||||
@ -73,8 +75,8 @@ export default function CompanyIdUploadPage() {
|
|||||||
<PageLayout>
|
<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="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="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="text-sm font-medium text-gray-900">{t('quickactionDashboard.redirecting')}</div>
|
||||||
<div className="mt-1 text-xs text-gray-600">Please wait</div>
|
<div className="mt-1 text-xs text-gray-600">{t('quickactionDashboard.pleaseWait')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
@ -96,33 +98,33 @@ export default function CompanyIdUploadPage() {
|
|||||||
>
|
>
|
||||||
<div className="px-6 py-8 sm:px-12 lg:px-16">
|
<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">
|
<h1 className="text-xl sm:text-2xl font-semibold text-gray-900 mb-1">
|
||||||
Company Contact Person Identity Verification
|
{t('quickactionDashboard.uploadId.companyTitle')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-gray-600 mb-8">
|
<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>
|
</p>
|
||||||
|
|
||||||
{/* Fields: 3 in one row on md+ with unified inputs */}
|
{/* Fields: 3 in one row on md+ with unified inputs */}
|
||||||
<div className="grid gap-6 md:grid-cols-3">
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Contact Person ID Number *
|
{t('quickactionDashboard.uploadId.contactPersonIdNumber')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
value={idNumber}
|
value={idNumber}
|
||||||
onChange={e => setIdNumber(e.target.value)}
|
onChange={e => setIdNumber(e.target.value)}
|
||||||
className={`${inputBase} ${idNumber ? 'text-gray-900' : 'text-gray-700'} focus:ring-[#8D6B1D] focus:border-transparent`}
|
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
|
required
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-gray-600">
|
<p className="mt-1 text-xs text-gray-600">
|
||||||
Enter the ID number exactly as shown on the document
|
{t('quickactionDashboard.uploadId.contactPersonIdNumberHint')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Document Type *
|
{t('quickactionDashboard.uploadId.documentType')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={idType}
|
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`}
|
className={`${inputBase} ${idType ? 'text-gray-900' : 'text-gray-700'} focus:ring-[#8D6B1D] focus:border-transparent`}
|
||||||
required
|
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>)}
|
{DOC_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Expiry Date *
|
{t('quickactionDashboard.uploadId.expiryDate')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
@ -148,7 +150,7 @@ export default function CompanyIdUploadPage() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-gray-600">
|
<p className="mt-1 text-xs text-gray-600">
|
||||||
Enter the expiry date shown on your document
|
{t('quickactionDashboard.uploadId.expiryDateHint')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -156,7 +158,7 @@ export default function CompanyIdUploadPage() {
|
|||||||
{/* Back side toggle */}
|
{/* Back side toggle */}
|
||||||
<div className="mt-8 flex items-center gap-3">
|
<div className="mt-8 flex items-center gap-3">
|
||||||
<span className="text-sm font-medium text-gray-700">
|
<span className="text-sm font-medium text-gray-700">
|
||||||
Does ID have a Backside?
|
{t('quickactionDashboard.uploadId.backSideQuestion')}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="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'}`} />
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Upload Areas */}
|
{/* Upload Areas */}
|
||||||
@ -193,7 +195,7 @@ export default function CompanyIdUploadPage() {
|
|||||||
{frontPreview && (
|
{frontPreview && (
|
||||||
<img
|
<img
|
||||||
src={frontPreview}
|
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"
|
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') }}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<DocumentArrowUpIcon className="h-10 w-10 text-gray-400 group-hover:text-[#8D6B1D] mb-4 transition" />
|
<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]">
|
<p className="text-sm font-medium text-[#8D6B1D] group-hover:text-[#7A5E1A]">
|
||||||
Click to upload front side
|
{t('quickactionDashboard.uploadId.clickUploadFront')}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-xs text-gray-500">
|
<p className="mt-2 text-xs text-gray-500">
|
||||||
or drag and drop
|
{t('quickactionDashboard.dragAndDrop')}
|
||||||
<br />
|
<br />
|
||||||
PNG, JPG, JPEG up to 10MB
|
{t('quickactionDashboard.maxUploadHint')}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -242,7 +244,7 @@ export default function CompanyIdUploadPage() {
|
|||||||
{extraPreview && (
|
{extraPreview && (
|
||||||
<img
|
<img
|
||||||
src={extraPreview}
|
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"
|
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') }}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<DocumentArrowUpIcon className="h-10 w-10 text-gray-400 group-hover:text-[#8D6B1D] mb-4 transition" />
|
<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]">
|
<p className="text-sm font-medium text-[#8D6B1D] group-hover:text-[#7A5E1A]">
|
||||||
Click to upload back side
|
{t('quickactionDashboard.uploadId.clickUploadBack')}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-xs text-gray-500">
|
<p className="mt-2 text-xs text-gray-500">
|
||||||
or drag and drop
|
{t('quickactionDashboard.dragAndDrop')}
|
||||||
<br />
|
<br />
|
||||||
PNG, JPG, JPEG up to 10MB
|
{t('quickactionDashboard.maxUploadHint')}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -275,14 +277,14 @@ export default function CompanyIdUploadPage() {
|
|||||||
{/* Info */}
|
{/* Info */}
|
||||||
<div className="mt-8 rounded-lg bg-[#8D6B1D]/10 border border-[#8D6B1D]/20 px-5 py-5">
|
<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">
|
<p className="text-sm font-semibold text-[#3B2C04] mb-3">
|
||||||
Please ensure your ID documents:
|
{t('quickactionDashboard.uploadId.documentsChecklistTitle')}
|
||||||
</p>
|
</p>
|
||||||
<ul className="text-sm text-[#7A5E1A] space-y-1 list-disc pl-5">
|
<ul className="text-sm text-[#7A5E1A] space-y-1 list-disc pl-5">
|
||||||
<li>Are clearly visible and readable</li>
|
<li>{t('quickactionDashboard.uploadId.clearlyVisible')}</li>
|
||||||
<li>Show all four corners</li>
|
<li>{t('quickactionDashboard.uploadId.showCorners')}</li>
|
||||||
<li>Are not expired</li>
|
<li>{t('quickactionDashboard.uploadId.notExpired')}</li>
|
||||||
<li>Have good lighting (no shadows or glare)</li>
|
<li>{t('quickactionDashboard.uploadId.goodLighting')}</li>
|
||||||
<li>{hasBack ? 'Both front and back sides are uploaded' : 'Front side is uploaded'}</li>
|
<li>{hasBack ? t('quickactionDashboard.uploadId.bothSidesUploaded') : t('quickactionDashboard.uploadId.frontSideUploaded')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -293,7 +295,7 @@ export default function CompanyIdUploadPage() {
|
|||||||
)}
|
)}
|
||||||
{success && (
|
{success && (
|
||||||
<div className="mt-6 rounded-md border border-green-300 bg-green-50 px-4 py-3 text-sm text-green-700">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -304,7 +306,7 @@ export default function CompanyIdUploadPage() {
|
|||||||
onClick={goBackToDashboard}
|
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"
|
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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@ -312,7 +314,7 @@ export default function CompanyIdUploadPage() {
|
|||||||
disabled={submitting || success}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,12 +4,14 @@ import { useState, useRef, useCallback, useEffect } from 'react'
|
|||||||
import useAuthStore from '../../../../store/authStore'
|
import useAuthStore from '../../../../store/authStore'
|
||||||
import { useUserStatus } from '../../../../hooks/useUserStatus'
|
import { useUserStatus } from '../../../../hooks/useUserStatus'
|
||||||
import { useToast } from '../../../../components/toast/toastComponent'
|
import { useToast } from '../../../../components/toast/toastComponent'
|
||||||
|
import { useTranslation } from '../../../../i18n/useTranslation'
|
||||||
|
|
||||||
export function usePersonalUploadId() {
|
export function usePersonalUploadId() {
|
||||||
// Auth and status
|
// Auth and status
|
||||||
const token = useAuthStore(s => s.accessToken)
|
const token = useAuthStore(s => s.accessToken)
|
||||||
const { refreshStatus } = useUserStatus()
|
const { refreshStatus } = useUserStatus()
|
||||||
const { showToast } = useToast()
|
const { showToast } = useToast()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [idNumber, setIdNumber] = useState('')
|
const [idNumber, setIdNumber] = useState('')
|
||||||
@ -39,11 +41,11 @@ export function usePersonalUploadId() {
|
|||||||
// File handlers
|
// File handlers
|
||||||
const handleFile = (f: File, side: 'front' | 'back') => {
|
const handleFile = (f: File, side: 'front' | 'back') => {
|
||||||
if (f.size > 10 * 1024 * 1024) {
|
if (f.size > 10 * 1024 * 1024) {
|
||||||
const msg = 'File size exceeds 10 MB.'
|
const msg = t('quickactionDashboard.uploadId.fileTooLargeMessage')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'File too large',
|
title: t('quickactionDashboard.uploadId.fileTooLargeTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@ -89,31 +91,31 @@ export function usePersonalUploadId() {
|
|||||||
// Validation
|
// Validation
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
if (!idNumber.trim() || !idType || !expiry) {
|
if (!idNumber.trim() || !idType || !expiry) {
|
||||||
const msg = 'Please fill out all required fields.'
|
const msg = t('quickactionDashboard.uploadId.fillRequiredFields')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Missing information',
|
title: t('quickactionDashboard.uploadId.missingInfoTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (!frontFile) {
|
if (!frontFile) {
|
||||||
const msg = 'Please upload the front side.'
|
const msg = t('quickactionDashboard.uploadId.frontSideMissingMessage')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Front side missing',
|
title: t('quickactionDashboard.uploadId.frontSideMissingTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (hasBack && !backFile) {
|
if (hasBack && !backFile) {
|
||||||
const msg = 'Please upload the back side or disable the switch.'
|
const msg = t('quickactionDashboard.uploadId.backSideMissingMessage')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Back side missing',
|
title: t('quickactionDashboard.uploadId.backSideMissingTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return false
|
return false
|
||||||
@ -127,11 +129,11 @@ export function usePersonalUploadId() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!validate()) return
|
if (!validate()) return
|
||||||
if (!token) {
|
if (!token) {
|
||||||
const msg = 'Not authenticated. Please log in again.'
|
const msg = t('quickactionDashboard.emailVerify.authError')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Authentication error',
|
title: t('quickactionDashboard.uploadId.authErrorTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@ -160,29 +162,29 @@ export function usePersonalUploadId() {
|
|||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
title: 'Documents uploaded',
|
title: t('quickactionDashboard.uploadId.personalUploadSuccessTitle'),
|
||||||
message: 'Your ID documents have been uploaded successfully.',
|
message: t('quickactionDashboard.uploadId.personalUploadSuccessMessage'),
|
||||||
})
|
})
|
||||||
await refreshStatus()
|
await refreshStatus()
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = '/quickaction-dashboard?tutorial=true'
|
window.location.href = '/quickaction-dashboard?tutorial=true'
|
||||||
}, 2000)
|
}, 2000)
|
||||||
} else {
|
} else {
|
||||||
const msg = data.message || 'Upload failed. Please try again.'
|
const msg = data.message || t('quickactionDashboard.uploadId.uploadFailedMessage')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Upload failed',
|
title: t('quickactionDashboard.uploadId.uploadFailedTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Upload error:', err)
|
console.error('Upload error:', err)
|
||||||
const msg = 'Network error. Please try again.'
|
const msg = t('quickactionDashboard.uploadId.networkErrorMessage')
|
||||||
setError(msg)
|
setError(msg)
|
||||||
showToast({
|
showToast({
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
title: 'Network error',
|
title: t('quickactionDashboard.uploadId.networkErrorTitle'),
|
||||||
message: msg,
|
message: msg,
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { useRouter } from 'next/navigation'
|
|||||||
import useAuthStore from '../../../store/authStore'
|
import useAuthStore from '../../../store/authStore'
|
||||||
import { DocumentArrowUpIcon, XMarkIcon } from '@heroicons/react/24/outline'
|
import { DocumentArrowUpIcon, XMarkIcon } from '@heroicons/react/24/outline'
|
||||||
import { useUserStatus } from '../../../hooks/useUserStatus'
|
import { useUserStatus } from '../../../hooks/useUserStatus'
|
||||||
|
import { useTranslation } from '../../../i18n/useTranslation'
|
||||||
|
|
||||||
// Add back ID types for the dropdown
|
// Add back ID types for the dropdown
|
||||||
const ID_TYPES = [
|
const ID_TYPES = [
|
||||||
@ -18,10 +19,17 @@ const ID_TYPES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function PersonalIdUploadPage() {
|
export default function PersonalIdUploadPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const user = useAuthStore(s => s.user)
|
const user = useAuthStore(s => s.user)
|
||||||
const isAuthReady = useAuthStore(s => (s as any).isAuthReady) // NEW
|
const isAuthReady = useAuthStore(s => (s as any).isAuthReady) // NEW
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { userStatus, loading: statusLoading } = useUserStatus()
|
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
|
// NEW: smooth redirect
|
||||||
const [redirectTo, setRedirectTo] = useState<string | null>(null)
|
const [redirectTo, setRedirectTo] = useState<string | null>(null)
|
||||||
@ -64,8 +72,8 @@ export default function PersonalIdUploadPage() {
|
|||||||
<PageLayout>
|
<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="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="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="text-sm font-medium text-gray-900">{t('quickactionDashboard.redirecting')}</div>
|
||||||
<div className="mt-1 text-xs text-gray-600">Please wait</div>
|
<div className="mt-1 text-xs text-gray-600">{t('quickactionDashboard.pleaseWait')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
@ -100,33 +108,33 @@ export default function PersonalIdUploadPage() {
|
|||||||
>
|
>
|
||||||
<div className="px-6 py-8 sm:px-12 lg:px-16">
|
<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">
|
<h1 className="text-xl sm:text-2xl font-semibold text-gray-900 mb-1">
|
||||||
Personal Identity Verification
|
{t('quickactionDashboard.uploadId.personalTitle')}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-gray-600 mb-8">
|
<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>
|
</p>
|
||||||
|
|
||||||
{/* Grid Fields: put all three inputs on the same line on md+ */}
|
{/* Grid Fields: put all three inputs on the same line on md+ */}
|
||||||
<div className="grid gap-6 md:grid-cols-3">
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
ID Number *
|
{t('quickactionDashboard.uploadId.idNumber')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
value={idNumber}
|
value={idNumber}
|
||||||
onChange={e => setIdNumber(e.target.value)}
|
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`}
|
className={`${inputBase} ${idNumber ? 'text-gray-900' : 'text-gray-700'} focus:ring-[#8D6B1D] focus:border-transparent`}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-gray-600">
|
<p className="mt-1 text-xs text-gray-600">
|
||||||
Enter the number exactly as shown on your ID
|
{t('quickactionDashboard.uploadId.idNumberHint')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
ID Type *
|
{t('quickactionDashboard.uploadId.idType')}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={idType}
|
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`}
|
className={`${inputBase} ${idType ? 'text-gray-900' : 'text-gray-700'} focus:ring-[#8D6B1D] focus:border-transparent`}
|
||||||
required
|
required
|
||||||
>
|
>
|
||||||
<option value="">Select ID type</option>
|
<option value="">{t('quickactionDashboard.uploadId.selectIdType')}</option>
|
||||||
{ID_TYPES.map(t => (
|
{idTypes.map(idOption => (
|
||||||
<option key={t.value} value={t.value}>
|
<option key={idOption.value} value={idOption.value}>
|
||||||
{t.label}
|
{idOption.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@ -145,7 +153,7 @@ export default function PersonalIdUploadPage() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Expiry Date *
|
{t('quickactionDashboard.uploadId.expiryDate')}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
@ -161,7 +169,7 @@ export default function PersonalIdUploadPage() {
|
|||||||
{/* Back side toggle */}
|
{/* Back side toggle */}
|
||||||
<div className="mt-8 flex items-center gap-3">
|
<div className="mt-8 flex items-center gap-3">
|
||||||
<span className="text-sm font-medium text-gray-700">
|
<span className="text-sm font-medium text-gray-700">
|
||||||
Does ID have a Backside?
|
{t('quickactionDashboard.uploadId.backSideQuestion')}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -177,7 +185,7 @@ export default function PersonalIdUploadPage() {
|
|||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Upload Areas: full width, 1 col if no back, 2 cols if back */}
|
{/* Upload Areas: full width, 1 col if no back, 2 cols if back */}
|
||||||
@ -202,7 +210,7 @@ export default function PersonalIdUploadPage() {
|
|||||||
{frontPreview && (
|
{frontPreview && (
|
||||||
<img
|
<img
|
||||||
src={frontPreview}
|
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"
|
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') }}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<DocumentArrowUpIcon className="h-10 w-10 text-gray-400 group-hover:text-[#8D6B1D] mb-3 transition" />
|
<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]">
|
<p className="text-sm font-medium text-[#8D6B1D] group-hover:text-[#7A5E1A]">
|
||||||
Click to upload front side
|
{t('quickactionDashboard.uploadId.clickUploadFront')}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-xs text-gray-500">
|
<p className="mt-2 text-xs text-gray-500">
|
||||||
or drag and drop
|
{t('quickactionDashboard.dragAndDrop')}
|
||||||
<br />
|
<br />
|
||||||
PNG, JPG, JPEG up to 10MB
|
{t('quickactionDashboard.maxUploadHint')}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -251,7 +259,7 @@ export default function PersonalIdUploadPage() {
|
|||||||
{backPreview && (
|
{backPreview && (
|
||||||
<img
|
<img
|
||||||
src={backPreview}
|
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"
|
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') }}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<DocumentArrowUpIcon className="h-10 w-10 text-gray-400 group-hover:text-[#8D6B1D] mb-3 transition" />
|
<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]">
|
<p className="text-sm font-medium text-[#8D6B1D] group-hover:text-[#7A5E1A]">
|
||||||
Click to upload back side
|
{t('quickactionDashboard.uploadId.clickUploadBack')}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-xs text-gray-500">
|
<p className="mt-2 text-xs text-gray-500">
|
||||||
or drag and drop
|
{t('quickactionDashboard.dragAndDrop')}
|
||||||
<br />
|
<br />
|
||||||
PNG, JPG, JPEG up to 10MB
|
{t('quickactionDashboard.maxUploadHint')}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -284,14 +292,14 @@ export default function PersonalIdUploadPage() {
|
|||||||
{/* Info Box, errors, success, submit */}
|
{/* Info Box, errors, success, submit */}
|
||||||
<div className="mt-8 rounded-lg bg-[#8D6B1D]/10 border border-[#8D6B1D]/20 px-5 py-5">
|
<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">
|
<p className="text-sm font-semibold text-[#3B2C04] mb-3">
|
||||||
Please ensure your ID documents:
|
{t('quickactionDashboard.uploadId.documentsChecklistTitle')}
|
||||||
</p>
|
</p>
|
||||||
<ul className="text-sm text-[#7A5E1A] space-y-1 list-disc pl-5">
|
<ul className="text-sm text-[#7A5E1A] space-y-1 list-disc pl-5">
|
||||||
<li>Are clearly visible and readable</li>
|
<li>{t('quickactionDashboard.uploadId.clearlyVisible')}</li>
|
||||||
<li>Show all four corners</li>
|
<li>{t('quickactionDashboard.uploadId.showCorners')}</li>
|
||||||
<li>Are not expired</li>
|
<li>{t('quickactionDashboard.uploadId.notExpired')}</li>
|
||||||
<li>Have good lighting (no shadows or glare)</li>
|
<li>{t('quickactionDashboard.uploadId.goodLighting')}</li>
|
||||||
<li>{hasBack ? 'Both front and back sides are uploaded' : 'Front side is uploaded'}</li>
|
<li>{hasBack ? t('quickactionDashboard.uploadId.bothSidesUploaded') : t('quickactionDashboard.uploadId.frontSideUploaded')}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -302,7 +310,7 @@ export default function PersonalIdUploadPage() {
|
|||||||
)}
|
)}
|
||||||
{success && (
|
{success && (
|
||||||
<div className="mt-6 rounded-md border border-green-300 bg-green-50 px-4 py-3 text-sm text-green-700">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -313,7 +321,7 @@ export default function PersonalIdUploadPage() {
|
|||||||
onClick={goBackToDashboard}
|
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"
|
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>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@ -321,7 +329,7 @@ export default function PersonalIdUploadPage() {
|
|||||||
disabled={submitting || success}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user