From 7559466c272e023cf849939706a0ae74f4b24486 Mon Sep 17 00:00:00 2001 From: DeathKaioken Date: Sat, 2 May 2026 21:00:08 +0200 Subject: [PATCH] i18 Co-authored-by: Copilot --- .../components/InvoiceDetailModal.tsx | 98 +- src/app/admin/language-management/page.tsx | 849 ++++++++++++++++ src/app/admin/page.tsx | 129 ++- src/app/api/i18n/scan/route.ts | 215 ++++ src/app/components/LanguageSwitcher.tsx | 117 +-- src/app/components/UserDetailModal.tsx | 104 +- src/app/components/nav/Header.tsx | 80 +- src/app/dashboard/page.tsx | 61 +- src/app/i18n/dynamicTranslations.ts | 83 ++ src/app/i18n/translations/de.ts | 929 ++++++++++++++++- src/app/i18n/translations/en.ts | 935 +++++++++++++++++- src/app/i18n/types.ts | 917 ++++++++++++++++- src/app/i18n/useTranslation.tsx | 74 +- src/app/quickaction-dashboard/page.tsx | 82 +- .../company/page.tsx | 96 +- .../personal/page.tsx | 172 ++-- .../register-email-verify/page.tsx | 86 +- .../register-sign-contract/company/page.tsx | 96 +- .../register-sign-contract/personal/page.tsx | 96 +- .../company/hooks/useCompanyUploadId.ts | 22 +- .../register-upload-id/company/page.tsx | 66 +- .../personal/hooks/usePersonalUploadId.ts | 34 +- .../register-upload-id/personal/page.tsx | 76 +- 23 files changed, 4691 insertions(+), 726 deletions(-) create mode 100644 src/app/admin/language-management/page.tsx create mode 100644 src/app/api/i18n/scan/route.ts create mode 100644 src/app/i18n/dynamicTranslations.ts diff --git a/src/app/admin/finance-management/components/InvoiceDetailModal.tsx b/src/app/admin/finance-management/components/InvoiceDetailModal.tsx index f7abfb6..fe01715 100644 --- a/src/app/admin/finance-management/components/InvoiceDetailModal.tsx +++ b/src/app/admin/finance-management/components/InvoiceDetailModal.tsx @@ -18,6 +18,7 @@ import { ShieldCheckIcon, } from '@heroicons/react/24/outline' import useAuthStore from '../../../store/authStore' +import { useTranslation } from '../../../i18n/useTranslation' /* ---------- types ---------- */ export type AdminInvoice = { @@ -85,11 +86,11 @@ const STATUSES = ['draft', 'issued', 'paid', 'overdue', 'canceled'] as const type InvoiceStatus = (typeof STATUSES)[number] const STATUS_CONFIG: Record = { - draft: { label: 'Draft', bg: 'bg-gray-100', text: 'text-gray-700', icon: PencilSquareIcon }, - issued: { label: 'Issued', bg: 'bg-indigo-100', text: 'text-indigo-700', icon: DocumentTextIcon }, - paid: { label: 'Paid', bg: 'bg-green-100', text: 'text-green-700', icon: CheckCircleIcon }, - overdue: { label: 'Overdue', bg: 'bg-red-100', text: 'text-red-700', icon: ExclamationCircleIcon }, - canceled: { label: 'Canceled', bg: 'bg-yellow-100', text: 'text-yellow-700', icon: NoSymbolIcon }, + draft: { label: 'draft', bg: 'bg-gray-100', text: 'text-gray-700', icon: PencilSquareIcon }, + issued: { label: 'issued', bg: 'bg-indigo-100', text: 'text-indigo-700', icon: DocumentTextIcon }, + paid: { label: 'paid', bg: 'bg-green-100', text: 'text-green-700', icon: CheckCircleIcon }, + overdue: { label: 'overdue', bg: 'bg-red-100', text: 'text-red-700', icon: ExclamationCircleIcon }, + canceled: { label: 'canceled', bg: 'bg-yellow-100', text: 'text-yellow-700', icon: NoSymbolIcon }, } function fmtDate(d?: string | null) { @@ -116,6 +117,7 @@ export default function InvoiceDetailModal({ onExport, }: InvoiceDetailModalProps) { const token = useAuthStore((s) => s.accessToken) + const { t } = useTranslation() // detail data const [items, setItems] = useState([]) @@ -274,10 +276,10 @@ export default function InvoiceDetailModal({
- Invoice {invoice.invoice_number ?? `#${invoice.id}`} + {t('invoiceDetailModal.invoiceTitle')} {invoice.invoice_number ?? `#${invoice.id}`}

- Created {fmtDateTime(invoice.created_at)} + {t('invoiceDetailModal.created')} {fmtDateTime(invoice.created_at)}

@@ -297,12 +299,12 @@ export default function InvoiceDetailModal({
- {statusConf.label} + {t(`invoiceDetailModal.status${statusConf.label.charAt(0).toUpperCase() + statusConf.label.slice(1)}` as any)}
- Change status: + {t('invoiceDetailModal.changeStatus')} {STATUSES.map((s) => { const sc = STATUS_CONFIG[s] const active = s === currentStatus @@ -317,7 +319,7 @@ export default function InvoiceDetailModal({ : 'border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-40' }`} > - {sc.label} + {t(`invoiceDetailModal.status${sc.label.charAt(0).toUpperCase() + sc.label.slice(1)}` as any)} ) })} @@ -327,7 +329,7 @@ export default function InvoiceDetailModal({ {/* status feedback */} {changingStatus && (
- Updating status… + {t('invoiceDetailModal.updatingStatus')}
)} {statusMsg && ( @@ -346,46 +348,46 @@ export default function InvoiceDetailModal({ {/* Customer info */}
- Customer + {t('invoiceDetailModal.customer')}
- - - - - - + + + + + +
{/* Financial info */}
- Financials + {t('invoiceDetailModal.financials')}
- - - - - + + + + +
{/* Dates */}
- Dates + {t('invoiceDetailModal.dates')}
- - - - + + + +
{/* Line items */}
- Line Items + {t('invoiceDetailModal.lineItems')}
{detailLoading ? (
@@ -395,17 +397,17 @@ export default function InvoiceDetailModal({ ) : detailError ? (
{detailError}
) : items.length === 0 ? ( -
No line items found.
+
{t('invoiceDetailModal.noLineItems')}
) : (
- - - - - + + + + + @@ -421,7 +423,7 @@ export default function InvoiceDetailModal({ - + @@ -434,17 +436,17 @@ export default function InvoiceDetailModal({ {payments.length > 0 && (
- Payments + {t('invoiceDetailModal.payments')}
DescriptionQtyUnit PriceTaxGross{t('invoiceDetailModal.description')}{t('invoiceDetailModal.qty')}{t('invoiceDetailModal.unitPrice')}{t('invoiceDetailModal.tax')}{t('invoiceDetailModal.gross')}
Total{t('invoiceDetailModal.total')} {fmtMoney(invoice.total_gross)}
- - - - - + + + + + @@ -471,8 +473,8 @@ export default function InvoiceDetailModal({ {invoice.context && (
- Context / Metadata - (click to expand) + {t('invoiceDetailModal.contextMetadata')} + {t('invoiceDetailModal.clickToExpand')}
                         {typeof invoice.context === 'string' ? invoice.context : JSON.stringify(invoice.context, null, 2)}
@@ -488,20 +490,20 @@ export default function InvoiceDetailModal({
                       onClick={() => onExport?.(invoice)}
                       className="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition"
                     >
-                       Export JSON
+                       {t('invoiceDetailModal.exportJson')}
                     
                     
                   
                   
                 
               
diff --git a/src/app/admin/language-management/page.tsx b/src/app/admin/language-management/page.tsx
new file mode 100644
index 0000000..e94c775
--- /dev/null
+++ b/src/app/admin/language-management/page.tsx
@@ -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 {
+  const groups: Record = {};
+  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({ 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>({});
+
+  // ── 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(null);
+
+  // ── active namespace category ('all' = show everything)
+  const [activeCategory, setActiveCategory] = useState('all');
+
+  // ── new language: flag selection
+  const [newFlag, setNewFlag] = useState('🏳️');
+
+  // ── scan results modal
+  const [showScanModal, setShowScanModal] = useState(false);
+  const [lastScanTime, setLastScanTime] = useState(null);
+  const [isScanning, setIsScanning] = useState(false);
+  const [scanError, setScanError] = useState(null);
+  const [workspaceScan, setWorkspaceScan] = useState(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 => {
+    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 = {};
+    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 = {};
+      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 (
+    
+      
+ {/* Header */} +
+
+

Language Management

+

+ Manage UI translations. All {totalKeys} keys scanned from the English source file. +

+
+
+ + + {isDirty && ( + + )} + {saved && !isDirty && ( + + Saved + + )} +
+
+ + {/* Language tabs */} +
+ {allLanguages.map((lang) => ( + + )} + + ))} + +
+ + {/* Progress bar */} + {activeLang !== 'en' && ( +
+
+
+ Translation progress + {translatedCount} / {totalKeys} keys translated +
+
+
+
+
+ + {Math.round((translatedCount / totalKeys) * 100)}% + +
+ )} + + {/* Category tabs */} +
+ + {NAMESPACE_CATEGORIES.map((cat) => ( + + ))} +
+ + {/* Search */} +
+ 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]" + /> +
+ + {/* Translation table grouped by namespace */} +
+ {filteredNs.map((ns) => { + const keys = filteredGroups[ns]; + const isOpen = !!expandedNs[ns]; + return ( +
+ + {isOpen && ( +
MethodTransactionAmountPaid AtStatus{t('invoiceDetailModal.method')}{t('invoiceDetailModal.transaction')}{t('invoiceDetailModal.amount')}{t('invoiceDetailModal.paidAt')}{t('invoiceDetailModal.status')}
+ + + + {activeLang !== 'en' && ( + + )} + + + + + {keys.map((key) => { + const enVal = getEnglishValue(key); + const currentVal = getDisplayValue(key); + const hasOverride = (data.translations[activeLang]?.[key] ?? '') !== ''; + return ( + + + {activeLang !== 'en' && ( + + )} +
KeyEnglish (reference) + {allLanguages.find((l) => l.code === activeLang)?.name ?? activeLang} +
+ {key} + + {enVal} + +
+