From cb76ca877958bb809c9dbfb6ffe9b66cfec16d76 Mon Sep 17 00:00:00 2001 From: EmmyKay0026 Date: Sat, 27 Jun 2026 12:39:17 +0100 Subject: [PATCH] feat: add internationalization locale translations --- frontend/scripts/generate-locales.mjs | 1117 +++++++++++++++++ frontend/src/App.tsx | 9 +- .../src/components/LanguageSwitcher.test.tsx | 81 ++ frontend/src/components/LanguageSwitcher.tsx | 16 +- frontend/src/components/LoadingFallback.tsx | 11 + .../src/components/MobileNav/MobileMenu.tsx | 3 +- .../src/components/MobileNav/navigation.ts | 12 +- frontend/src/components/Navbar.tsx | 4 +- frontend/src/hooks/useTranslatedNav.ts | 34 + frontend/src/i18n/config.ts | 48 +- frontend/src/i18n/documentDirection.ts | 12 + frontend/src/i18n/localeCompleteness.test.ts | 46 + frontend/src/i18n/locales/ar.json | 181 +++ frontend/src/i18n/locales/de.json | 181 +++ frontend/src/i18n/locales/en.json | 35 +- frontend/src/i18n/locales/es.json | 181 +++ frontend/src/i18n/locales/fr.json | 181 +++ frontend/src/i18n/locales/ja.json | 181 +++ frontend/src/i18n/locales/ko.json | 181 +++ frontend/src/i18n/locales/zh.json | 181 +++ frontend/src/i18n/translationKeys.ts | 17 + frontend/src/main.tsx | 37 +- frontend/src/pages/Settings.tsx | 81 +- frontend/src/test/utils.tsx | 7 +- 24 files changed, 2750 insertions(+), 87 deletions(-) create mode 100644 frontend/scripts/generate-locales.mjs create mode 100644 frontend/src/components/LanguageSwitcher.test.tsx create mode 100644 frontend/src/components/LoadingFallback.tsx create mode 100644 frontend/src/hooks/useTranslatedNav.ts create mode 100644 frontend/src/i18n/documentDirection.ts create mode 100644 frontend/src/i18n/localeCompleteness.test.ts create mode 100644 frontend/src/i18n/locales/ar.json create mode 100644 frontend/src/i18n/locales/de.json create mode 100644 frontend/src/i18n/locales/es.json create mode 100644 frontend/src/i18n/locales/fr.json create mode 100644 frontend/src/i18n/locales/ja.json create mode 100644 frontend/src/i18n/locales/ko.json create mode 100644 frontend/src/i18n/locales/zh.json create mode 100644 frontend/src/i18n/translationKeys.ts diff --git a/frontend/scripts/generate-locales.mjs b/frontend/scripts/generate-locales.mjs new file mode 100644 index 00000000..4ffad1a3 --- /dev/null +++ b/frontend/scripts/generate-locales.mjs @@ -0,0 +1,1117 @@ +/** + * Generates non-English locale files from en.json structure. + * Run: node scripts/generate-locales.mjs + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const localesDir = join(__dirname, "../src/i18n/locales"); +const en = JSON.parse(readFileSync(join(localesDir, "en.json"), "utf8")); + +const translations = { + es: { + common: { + loading: "Cargando...", + error: "Error", + success: "Éxito", + cancel: "Cancelar", + save: "Guardar", + delete: "Eliminar", + edit: "Editar", + close: "Cerrar", + search: "Buscar", + filter: "Filtrar", + export: "Exportar", + refresh: "Actualizar", + viewMore: "Ver más", + backToTop: "Volver arriba", + }, + nav: { + dashboard: "Panel", + assets: "Activos", + bridges: "Puentes", + analytics: "Analítica", + alerts: "Alertas", + settings: "Configuración", + docs: "Documentación", + }, + dashboard: { + title: "Panel", + welcome: "Bienvenido a Stellar Bridge Watch", + assetOverview: "Resumen de activos", + bridgeStatus: "Estado de puentes", + recentAlerts: "Alertas recientes", + priceCharts: "Gráficos de precios", + }, + assets: { + title: "Activos", + healthScore: "Puntuación de salud", + price: "Precio", + liquidity: "Liquidez", + volume24h: "Volumen 24h", + change24h: "Cambio 24h", + marketCap: "Capitalización", + viewDetails: "Ver detalles", + }, + bridges: { + title: "Puentes", + status: "Estado", + tvl: "Valor total bloqueado", + supplyOnStellar: "Suministro en Stellar", + supplyOnSource: "Suministro en origen", + lastVerified: "Última verificación", + healthy: "Saludable", + degraded: "Degradado", + down: "Caído", + }, + alerts: { + title: "Alertas", + createAlert: "Crear alerta", + alertType: "Tipo de alerta", + threshold: "Umbral", + priority: "Prioridad", + active: "Activa", + inactive: "Inactiva", + triggered: "Activada", + priceDeviation: "Desviación de precio", + liquidityDrop: "Caída de liquidez", + bridgeMismatch: "Desajuste de suministro del puente", + healthScore: "Umbral de puntuación de salud", + }, + analytics: { + title: "Analítica", + timeRange: "Rango de tiempo", + "1h": "1 hora", + "24h": "24 horas", + "7d": "7 días", + "30d": "30 días", + "90d": "90 días", + custom: "Rango personalizado", + }, + settings: { + title: "Configuración", + language: "Idioma", + theme: "Tema", + notifications: "Notificaciones", + preferences: "Preferencias", + account: "Cuenta", + lightMode: "Modo claro", + darkMode: "Modo oscuro", + autoDetect: "Detección automática", + changeLanguage: "Cambiar idioma", + languageDescription: + "Elige tu idioma de visualización preferido. Tu selección se guarda en este navegador.", + }, + export: { + title: "Exportar datos", + format: "Formato", + dateRange: "Rango de fechas", + selectFields: "Seleccionar campos", + download: "Descargar", + csv: "CSV", + json: "JSON", + excel: "Excel", + exportInProgress: "Exportación en curso...", + exportComplete: "Exportación completada", + exportFailed: "Error en la exportación", + }, + depeg: { + title: "Detección de depeg", + depegged: "Desvinculado", + recovering: "Recuperándose", + resolved: "Resuelto", + severity: "Gravedad", + deviation: "Desviación", + trend: "Tendencia", + timeInDepeg: "Tiempo en depeg", + warning: "Advertencia", + moderate: "Moderado", + severe: "Grave", + critical: "Crítico", + worsening: "Empeorando", + improving: "Mejorando", + stable: "Estable", + }, + errors: { + generic: "Ocurrió un error. Inténtalo de nuevo.", + network: "Error de red. Comprueba tu conexión.", + notFound: "Recurso no encontrado.", + unauthorized: "Acceso no autorizado.", + serverError: "Error del servidor. Inténtalo más tarde.", + }, + validation: { + required: "Este campo es obligatorio", + invalidEmail: "Dirección de correo no válida", + invalidUrl: "URL no válida", + minLength: "La longitud mínima es de {{min}} caracteres", + maxLength: "La longitud máxima es de {{max}} caracteres", + numberRange: "El valor debe estar entre {{min}} y {{max}}", + }, + time: { + seconds: "{{count}} segundo", + seconds_plural: "{{count}} segundos", + minutes: "{{count}} minuto", + minutes_plural: "{{count}} minutos", + hours: "{{count}} hora", + hours_plural: "{{count}} horas", + days: "{{count}} día", + days_plural: "{{count}} días", + ago: "hace {{time}}", + justNow: "Justo ahora", + }, + app: { + loadingPage: "Cargando página...", + }, + }, + fr: { + common: { + loading: "Chargement...", + error: "Erreur", + success: "Succès", + cancel: "Annuler", + save: "Enregistrer", + delete: "Supprimer", + edit: "Modifier", + close: "Fermer", + search: "Rechercher", + filter: "Filtrer", + export: "Exporter", + refresh: "Actualiser", + viewMore: "Voir plus", + backToTop: "Retour en haut", + }, + nav: { + dashboard: "Tableau de bord", + assets: "Actifs", + bridges: "Ponts", + analytics: "Analytique", + alerts: "Alertes", + settings: "Paramètres", + docs: "Documentation", + }, + dashboard: { + title: "Tableau de bord", + welcome: "Bienvenue sur Stellar Bridge Watch", + assetOverview: "Aperçu des actifs", + bridgeStatus: "État des ponts", + recentAlerts: "Alertes récentes", + priceCharts: "Graphiques de prix", + }, + assets: { + title: "Actifs", + healthScore: "Score de santé", + price: "Prix", + liquidity: "Liquidité", + volume24h: "Volume 24h", + change24h: "Variation 24h", + marketCap: "Capitalisation", + viewDetails: "Voir les détails", + }, + bridges: { + title: "Ponts", + status: "État", + tvl: "Valeur totale verrouillée", + supplyOnStellar: "Offre sur Stellar", + supplyOnSource: "Offre sur la source", + lastVerified: "Dernière vérification", + healthy: "Sain", + degraded: "Dégradé", + down: "Hors service", + }, + alerts: { + title: "Alertes", + createAlert: "Créer une alerte", + alertType: "Type d'alerte", + threshold: "Seuil", + priority: "Priorité", + active: "Active", + inactive: "Inactive", + triggered: "Déclenchée", + priceDeviation: "Écart de prix", + liquidityDrop: "Baisse de liquidité", + bridgeMismatch: "Décalage d'offre du pont", + healthScore: "Seuil de score de santé", + }, + analytics: { + title: "Analytique", + timeRange: "Plage de temps", + "1h": "1 heure", + "24h": "24 heures", + "7d": "7 jours", + "30d": "30 jours", + "90d": "90 jours", + custom: "Plage personnalisée", + }, + settings: { + title: "Paramètres", + language: "Langue", + theme: "Thème", + notifications: "Notifications", + preferences: "Préférences", + account: "Compte", + lightMode: "Mode clair", + darkMode: "Mode sombre", + autoDetect: "Détection automatique", + changeLanguage: "Changer de langue", + languageDescription: + "Choisissez votre langue d'affichage préférée. Votre sélection est enregistrée dans ce navigateur.", + }, + export: { + title: "Exporter les données", + format: "Format", + dateRange: "Plage de dates", + selectFields: "Sélectionner les champs", + download: "Télécharger", + csv: "CSV", + json: "JSON", + excel: "Excel", + exportInProgress: "Export en cours...", + exportComplete: "Export terminé", + exportFailed: "Échec de l'export", + }, + depeg: { + title: "Détection de dépeg", + depegged: "Dépegé", + recovering: "En récupération", + resolved: "Résolu", + severity: "Gravité", + deviation: "Écart", + trend: "Tendance", + timeInDepeg: "Durée en dépeg", + warning: "Avertissement", + moderate: "Modéré", + severe: "Sévère", + critical: "Critique", + worsening: "Aggravation", + improving: "Amélioration", + stable: "Stable", + }, + errors: { + generic: "Une erreur s'est produite. Veuillez réessayer.", + network: "Erreur réseau. Vérifiez votre connexion.", + notFound: "Ressource introuvable.", + unauthorized: "Accès non autorisé.", + serverError: "Erreur serveur. Veuillez réessayer plus tard.", + }, + validation: { + required: "Ce champ est obligatoire", + invalidEmail: "Adresse e-mail invalide", + invalidUrl: "URL invalide", + minLength: "La longueur minimale est de {{min}} caractères", + maxLength: "La longueur maximale est de {{max}} caractères", + numberRange: "La valeur doit être entre {{min}} et {{max}}", + }, + time: { + seconds: "{{count}} seconde", + seconds_plural: "{{count}} secondes", + minutes: "{{count}} minute", + minutes_plural: "{{count}} minutes", + hours: "{{count}} heure", + hours_plural: "{{count}} heures", + days: "{{count}} jour", + days_plural: "{{count}} jours", + ago: "il y a {{time}}", + justNow: "À l'instant", + }, + app: { + loadingPage: "Chargement de la page...", + }, + }, + de: { + common: { + loading: "Wird geladen...", + error: "Fehler", + success: "Erfolg", + cancel: "Abbrechen", + save: "Speichern", + delete: "Löschen", + edit: "Bearbeiten", + close: "Schließen", + search: "Suchen", + filter: "Filtern", + export: "Exportieren", + refresh: "Aktualisieren", + viewMore: "Mehr anzeigen", + backToTop: "Nach oben", + }, + nav: { + dashboard: "Dashboard", + assets: "Assets", + bridges: "Bridges", + analytics: "Analytik", + alerts: "Warnungen", + settings: "Einstellungen", + docs: "Dokumentation", + }, + dashboard: { + title: "Dashboard", + welcome: "Willkommen bei Stellar Bridge Watch", + assetOverview: "Asset-Übersicht", + bridgeStatus: "Bridge-Status", + recentAlerts: "Aktuelle Warnungen", + priceCharts: "Preisdiagramme", + }, + assets: { + title: "Assets", + healthScore: "Gesundheitswert", + price: "Preis", + liquidity: "Liquidität", + volume24h: "24h-Volumen", + change24h: "24h-Änderung", + marketCap: "Marktkapitalisierung", + viewDetails: "Details anzeigen", + }, + bridges: { + title: "Bridges", + status: "Status", + tvl: "Gesamtwert gesperrt", + supplyOnStellar: "Angebot auf Stellar", + supplyOnSource: "Angebot auf Quelle", + lastVerified: "Zuletzt verifiziert", + healthy: "Gesund", + degraded: "Beeinträchtigt", + down: "Ausgefallen", + }, + alerts: { + title: "Warnungen", + createAlert: "Warnung erstellen", + alertType: "Warnungstyp", + threshold: "Schwellenwert", + priority: "Priorität", + active: "Aktiv", + inactive: "Inaktiv", + triggered: "Ausgelöst", + priceDeviation: "Preisabweichung", + liquidityDrop: "Liquiditätsrückgang", + bridgeMismatch: "Bridge-Angebotsabweichung", + healthScore: "Gesundheitswert-Schwellenwert", + }, + analytics: { + title: "Analytik", + timeRange: "Zeitraum", + "1h": "1 Stunde", + "24h": "24 Stunden", + "7d": "7 Tage", + "30d": "30 Tage", + "90d": "90 Tage", + custom: "Benutzerdefinierter Bereich", + }, + settings: { + title: "Einstellungen", + language: "Sprache", + theme: "Design", + notifications: "Benachrichtigungen", + preferences: "Einstellungen", + account: "Konto", + lightMode: "Heller Modus", + darkMode: "Dunkler Modus", + autoDetect: "Automatisch erkennen", + changeLanguage: "Sprache ändern", + languageDescription: + "Wählen Sie Ihre bevorzugte Anzeigesprache. Ihre Auswahl wird in diesem Browser gespeichert.", + }, + export: { + title: "Daten exportieren", + format: "Format", + dateRange: "Datumsbereich", + selectFields: "Felder auswählen", + download: "Herunterladen", + csv: "CSV", + json: "JSON", + excel: "Excel", + exportInProgress: "Export läuft...", + exportComplete: "Export abgeschlossen", + exportFailed: "Export fehlgeschlagen", + }, + depeg: { + title: "Depeg-Erkennung", + depegged: "Entkoppelt", + recovering: "Erholung", + resolved: "Behoben", + severity: "Schweregrad", + deviation: "Abweichung", + trend: "Trend", + timeInDepeg: "Zeit im Depeg", + warning: "Warnung", + moderate: "Mäßig", + severe: "Schwer", + critical: "Kritisch", + worsening: "Verschlechternd", + improving: "Verbessernd", + stable: "Stabil", + }, + errors: { + generic: "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.", + network: "Netzwerkfehler. Bitte überprüfen Sie Ihre Verbindung.", + notFound: "Ressource nicht gefunden.", + unauthorized: "Unbefugter Zugriff.", + serverError: "Serverfehler. Bitte versuchen Sie es später erneut.", + }, + validation: { + required: "Dieses Feld ist erforderlich", + invalidEmail: "Ungültige E-Mail-Adresse", + invalidUrl: "Ungültige URL", + minLength: "Mindestlänge ist {{min}} Zeichen", + maxLength: "Maximale Länge ist {{max}} Zeichen", + numberRange: "Wert muss zwischen {{min}} und {{max}} liegen", + }, + time: { + seconds: "{{count}} Sekunde", + seconds_plural: "{{count}} Sekunden", + minutes: "{{count}} Minute", + minutes_plural: "{{count}} Minuten", + hours: "{{count}} Stunde", + hours_plural: "{{count}} Stunden", + days: "{{count}} Tag", + days_plural: "{{count}} Tage", + ago: "vor {{time}}", + justNow: "Gerade eben", + }, + app: { + loadingPage: "Seite wird geladen...", + }, + }, + zh: { + common: { + loading: "加载中...", + error: "错误", + success: "成功", + cancel: "取消", + save: "保存", + delete: "删除", + edit: "编辑", + close: "关闭", + search: "搜索", + filter: "筛选", + export: "导出", + refresh: "刷新", + viewMore: "查看更多", + backToTop: "返回顶部", + }, + nav: { + dashboard: "仪表板", + assets: "资产", + bridges: "跨链桥", + analytics: "分析", + alerts: "警报", + settings: "设置", + docs: "文档", + }, + dashboard: { + title: "仪表板", + welcome: "欢迎使用 Stellar Bridge Watch", + assetOverview: "资产概览", + bridgeStatus: "跨链桥状态", + recentAlerts: "最近警报", + priceCharts: "价格图表", + }, + assets: { + title: "资产", + healthScore: "健康评分", + price: "价格", + liquidity: "流动性", + volume24h: "24小时成交量", + change24h: "24小时变化", + marketCap: "市值", + viewDetails: "查看详情", + }, + bridges: { + title: "跨链桥", + status: "状态", + tvl: "总锁定价值", + supplyOnStellar: "Stellar 供应量", + supplyOnSource: "源链供应量", + lastVerified: "上次验证", + healthy: "健康", + degraded: "降级", + down: "离线", + }, + alerts: { + title: "警报", + createAlert: "创建警报", + alertType: "警报类型", + threshold: "阈值", + priority: "优先级", + active: "活跃", + inactive: "非活跃", + triggered: "已触发", + priceDeviation: "价格偏差", + liquidityDrop: "流动性下降", + bridgeMismatch: "跨链桥供应不匹配", + healthScore: "健康评分阈值", + }, + analytics: { + title: "分析", + timeRange: "时间范围", + "1h": "1 小时", + "24h": "24 小时", + "7d": "7 天", + "30d": "30 天", + "90d": "90 天", + custom: "自定义范围", + }, + settings: { + title: "设置", + language: "语言", + theme: "主题", + notifications: "通知", + preferences: "偏好设置", + account: "账户", + lightMode: "浅色模式", + darkMode: "深色模式", + autoDetect: "自动检测", + changeLanguage: "更改语言", + languageDescription: "选择您偏好的显示语言。您的选择将保存在此浏览器中。", + }, + export: { + title: "导出数据", + format: "格式", + dateRange: "日期范围", + selectFields: "选择字段", + download: "下载", + csv: "CSV", + json: "JSON", + excel: "Excel", + exportInProgress: "正在导出...", + exportComplete: "导出完成", + exportFailed: "导出失败", + }, + depeg: { + title: "脱锚检测", + depegged: "已脱锚", + recovering: "恢复中", + resolved: "已解决", + severity: "严重程度", + deviation: "偏差", + trend: "趋势", + timeInDepeg: "脱锚时长", + warning: "警告", + moderate: "中等", + severe: "严重", + critical: "危急", + worsening: "恶化", + improving: "改善", + stable: "稳定", + }, + errors: { + generic: "发生错误,请重试。", + network: "网络错误,请检查您的连接。", + notFound: "未找到资源。", + unauthorized: "未授权访问。", + serverError: "服务器错误,请稍后重试。", + }, + validation: { + required: "此字段为必填项", + invalidEmail: "无效的电子邮件地址", + invalidUrl: "无效的 URL", + minLength: "最小长度为 {{min}} 个字符", + maxLength: "最大长度为 {{max}} 个字符", + numberRange: "值必须在 {{min}} 和 {{max}} 之间", + }, + time: { + seconds: "{{count}} 秒", + seconds_plural: "{{count}} 秒", + minutes: "{{count}} 分钟", + minutes_plural: "{{count}} 分钟", + hours: "{{count}} 小时", + hours_plural: "{{count}} 小时", + days: "{{count}} 天", + days_plural: "{{count}} 天", + ago: "{{time}}前", + justNow: "刚刚", + }, + app: { + loadingPage: "正在加载页面...", + }, + }, + ja: { + common: { + loading: "読み込み中...", + error: "エラー", + success: "成功", + cancel: "キャンセル", + save: "保存", + delete: "削除", + edit: "編集", + close: "閉じる", + search: "検索", + filter: "フィルター", + export: "エクスポート", + refresh: "更新", + viewMore: "もっと見る", + backToTop: "トップに戻る", + }, + nav: { + dashboard: "ダッシュボード", + assets: "資産", + bridges: "ブリッジ", + analytics: "分析", + alerts: "アラート", + settings: "設定", + docs: "ドキュメント", + }, + dashboard: { + title: "ダッシュボード", + welcome: "Stellar Bridge Watch へようこそ", + assetOverview: "資産概要", + bridgeStatus: "ブリッジステータス", + recentAlerts: "最近のアラート", + priceCharts: "価格チャート", + }, + assets: { + title: "資産", + healthScore: "ヘルススコア", + price: "価格", + liquidity: "流動性", + volume24h: "24時間出来高", + change24h: "24時間変化", + marketCap: "時価総額", + viewDetails: "詳細を見る", + }, + bridges: { + title: "ブリッジ", + status: "ステータス", + tvl: "総ロック額", + supplyOnStellar: "Stellar上の供給量", + supplyOnSource: "ソース上の供給量", + lastVerified: "最終確認", + healthy: "正常", + degraded: "低下", + down: "停止", + }, + alerts: { + title: "アラート", + createAlert: "アラートを作成", + alertType: "アラートタイプ", + threshold: "しきい値", + priority: "優先度", + active: "有効", + inactive: "無効", + triggered: "発動", + priceDeviation: "価格乖離", + liquidityDrop: "流動性低下", + bridgeMismatch: "ブリッジ供給不一致", + healthScore: "ヘルススコアしきい値", + }, + analytics: { + title: "分析", + timeRange: "期間", + "1h": "1時間", + "24h": "24時間", + "7d": "7日間", + "30d": "30日間", + "90d": "90日間", + custom: "カスタム範囲", + }, + settings: { + title: "設定", + language: "言語", + theme: "テーマ", + notifications: "通知", + preferences: "環境設定", + account: "アカウント", + lightMode: "ライトモード", + darkMode: "ダークモード", + autoDetect: "自動検出", + changeLanguage: "言語を変更", + languageDescription: + "表示言語を選択してください。選択内容はこのブラウザに保存されます。", + }, + export: { + title: "データをエクスポート", + format: "形式", + dateRange: "日付範囲", + selectFields: "フィールドを選択", + download: "ダウンロード", + csv: "CSV", + json: "JSON", + excel: "Excel", + exportInProgress: "エクスポート中...", + exportComplete: "エクスポート完了", + exportFailed: "エクスポート失敗", + }, + depeg: { + title: "デペッグ検出", + depegged: "デペッグ", + recovering: "回復中", + resolved: "解決済み", + severity: "重大度", + deviation: "乖離", + trend: "トレンド", + timeInDepeg: "デペッグ時間", + warning: "警告", + moderate: "中程度", + severe: "深刻", + critical: "致命的", + worsening: "悪化", + improving: "改善", + stable: "安定", + }, + errors: { + generic: "エラーが発生しました。もう一度お試しください。", + network: "ネットワークエラー。接続を確認してください。", + notFound: "リソースが見つかりません。", + unauthorized: "権限がありません。", + serverError: "サーバーエラー。後でもう一度お試しください。", + }, + validation: { + required: "この項目は必須です", + invalidEmail: "無効なメールアドレス", + invalidUrl: "無効なURL", + minLength: "最小文字数は {{min}} です", + maxLength: "最大文字数は {{max}} です", + numberRange: "値は {{min}} から {{max}} の間である必要があります", + }, + time: { + seconds: "{{count}} 秒", + seconds_plural: "{{count}} 秒", + minutes: "{{count}} 分", + minutes_plural: "{{count}} 分", + hours: "{{count}} 時間", + hours_plural: "{{count}} 時間", + days: "{{count}} 日", + days_plural: "{{count}} 日", + ago: "{{time}}前", + justNow: "たった今", + }, + app: { + loadingPage: "ページを読み込み中...", + }, + }, + ko: { + common: { + loading: "로딩 중...", + error: "오류", + success: "성공", + cancel: "취소", + save: "저장", + delete: "삭제", + edit: "편집", + close: "닫기", + search: "검색", + filter: "필터", + export: "내보내기", + refresh: "새로고침", + viewMore: "더 보기", + backToTop: "맨 위로", + }, + nav: { + dashboard: "대시보드", + assets: "자산", + bridges: "브릿지", + analytics: "분석", + alerts: "알림", + settings: "설정", + docs: "문서", + }, + dashboard: { + title: "대시보드", + welcome: "Stellar Bridge Watch에 오신 것을 환영합니다", + assetOverview: "자산 개요", + bridgeStatus: "브릿지 상태", + recentAlerts: "최근 알림", + priceCharts: "가격 차트", + }, + assets: { + title: "자산", + healthScore: "건강 점수", + price: "가격", + liquidity: "유동성", + volume24h: "24시간 거래량", + change24h: "24시간 변동", + marketCap: "시가총액", + viewDetails: "상세 보기", + }, + bridges: { + title: "브릿지", + status: "상태", + tvl: "총 예치 가치", + supplyOnStellar: "Stellar 공급량", + supplyOnSource: "소스 공급량", + lastVerified: "마지막 확인", + healthy: "정상", + degraded: "저하", + down: "중단", + }, + alerts: { + title: "알림", + createAlert: "알림 생성", + alertType: "알림 유형", + threshold: "임계값", + priority: "우선순위", + active: "활성", + inactive: "비활성", + triggered: "발동됨", + priceDeviation: "가격 편차", + liquidityDrop: "유동성 감소", + bridgeMismatch: "브릿지 공급 불일치", + healthScore: "건강 점수 임계값", + }, + analytics: { + title: "분석", + timeRange: "기간", + "1h": "1시간", + "24h": "24시간", + "7d": "7일", + "30d": "30일", + "90d": "90일", + custom: "사용자 지정 범위", + }, + settings: { + title: "설정", + language: "언어", + theme: "테마", + notifications: "알림", + preferences: "환경설정", + account: "계정", + lightMode: "라이트 모드", + darkMode: "다크 모드", + autoDetect: "자동 감지", + changeLanguage: "언어 변경", + languageDescription: + "원하는 표시 언어를 선택하세요. 선택 내용은 이 브라우저에 저장됩니다.", + }, + export: { + title: "데이터 내보내기", + format: "형식", + dateRange: "날짜 범위", + selectFields: "필드 선택", + download: "다운로드", + csv: "CSV", + json: "JSON", + excel: "Excel", + exportInProgress: "내보내기 진행 중...", + exportComplete: "내보내기 완료", + exportFailed: "내보내기 실패", + }, + depeg: { + title: "디페그 감지", + depegged: "디페그됨", + recovering: "회복 중", + resolved: "해결됨", + severity: "심각도", + deviation: "편차", + trend: "추세", + timeInDepeg: "디페그 지속 시간", + warning: "경고", + moderate: "보통", + severe: "심각", + critical: "치명적", + worsening: "악화", + improving: "개선", + stable: "안정", + }, + errors: { + generic: "오류가 발생했습니다. 다시 시도해 주세요.", + network: "네트워크 오류. 연결을 확인해 주세요.", + notFound: "리소스를 찾을 수 없습니다.", + unauthorized: "권한이 없습니다.", + serverError: "서버 오류. 나중에 다시 시도해 주세요.", + }, + validation: { + required: "이 필드는 필수입니다", + invalidEmail: "유효하지 않은 이메일 주소", + invalidUrl: "유효하지 않은 URL", + minLength: "최소 길이는 {{min}}자입니다", + maxLength: "최대 길이는 {{max}}자입니다", + numberRange: "값은 {{min}}과 {{max}} 사이여야 합니다", + }, + time: { + seconds: "{{count}}초", + seconds_plural: "{{count}}초", + minutes: "{{count}}분", + minutes_plural: "{{count}}분", + hours: "{{count}}시간", + hours_plural: "{{count}}시간", + days: "{{count}}일", + days_plural: "{{count}}일", + ago: "{{time}} 전", + justNow: "방금", + }, + app: { + loadingPage: "페이지 로딩 중...", + }, + }, + ar: { + common: { + loading: "جارٍ التحميل...", + error: "خطأ", + success: "نجاح", + cancel: "إلغاء", + save: "حفظ", + delete: "حذف", + edit: "تعديل", + close: "إغلاق", + search: "بحث", + filter: "تصفية", + export: "تصدير", + refresh: "تحديث", + viewMore: "عرض المزيد", + backToTop: "العودة للأعلى", + }, + nav: { + dashboard: "لوحة التحكم", + assets: "الأصول", + bridges: "الجسور", + analytics: "التحليلات", + alerts: "التنبيهات", + settings: "الإعدادات", + docs: "التوثيق", + }, + dashboard: { + title: "لوحة التحكم", + welcome: "مرحبًا بك في Stellar Bridge Watch", + assetOverview: "نظرة عامة على الأصول", + bridgeStatus: "حالة الجسور", + recentAlerts: "التنبيهات الأخيرة", + priceCharts: "مخططات الأسعار", + }, + assets: { + title: "الأصول", + healthScore: "درجة الصحة", + price: "السعر", + liquidity: "السيولة", + volume24h: "حجم 24 ساعة", + change24h: "تغيير 24 ساعة", + marketCap: "القيمة السوقية", + viewDetails: "عرض التفاصيل", + }, + bridges: { + title: "الجسور", + status: "الحالة", + tvl: "إجمالي القيمة المقفلة", + supplyOnStellar: "العرض على Stellar", + supplyOnSource: "العرض على المصدر", + lastVerified: "آخر تحقق", + healthy: "سليم", + degraded: "متدهور", + down: "متوقف", + }, + alerts: { + title: "التنبيهات", + createAlert: "إنشاء تنبيه", + alertType: "نوع التنبيه", + threshold: "العتبة", + priority: "الأولوية", + active: "نشط", + inactive: "غير نشط", + triggered: "تم التفعيل", + priceDeviation: "انحراف السعر", + liquidityDrop: "انخفاض السيولة", + bridgeMismatch: "عدم تطابق عرض الجسر", + healthScore: "عتبة درجة الصحة", + }, + analytics: { + title: "التحليلات", + timeRange: "النطاق الزمني", + "1h": "ساعة واحدة", + "24h": "24 ساعة", + "7d": "7 أيام", + "30d": "30 يومًا", + "90d": "90 يومًا", + custom: "نطاق مخصص", + }, + settings: { + title: "الإعدادات", + language: "اللغة", + theme: "السمة", + notifications: "الإشعارات", + preferences: "التفضيلات", + account: "الحساب", + lightMode: "الوضع الفاتح", + darkMode: "الوضع الداكن", + autoDetect: "اكتشاف تلقائي", + changeLanguage: "تغيير اللغة", + languageDescription: + "اختر لغة العرض المفضلة لديك. يتم حفظ اختيارك في هذا المتصفح.", + }, + export: { + title: "تصدير البيانات", + format: "التنسيق", + dateRange: "نطاق التاريخ", + selectFields: "اختر الحقول", + download: "تنزيل", + csv: "CSV", + json: "JSON", + excel: "Excel", + exportInProgress: "جارٍ التصدير...", + exportComplete: "اكتمل التصدير", + exportFailed: "فشل التصدير", + }, + depeg: { + title: "كشف فك الربط", + depegged: "مفكوك الربط", + recovering: "قيد التعافي", + resolved: "تم الحل", + severity: "الشدة", + deviation: "الانحراف", + trend: "الاتجاه", + timeInDepeg: "الوقت في فك الربط", + warning: "تحذير", + moderate: "متوسط", + severe: "شديد", + critical: "حرج", + worsening: "تدهور", + improving: "تحسن", + stable: "مستقر", + }, + errors: { + generic: "حدث خطأ. يرجى المحاولة مرة أخرى.", + network: "خطأ في الشبكة. يرجى التحقق من اتصالك.", + notFound: "المورد غير موجود.", + unauthorized: "وصول غير مصرح به.", + serverError: "خطأ في الخادم. يرجى المحاولة لاحقًا.", + }, + validation: { + required: "هذا الحقل مطلوب", + invalidEmail: "عنوان بريد إلكتروني غير صالح", + invalidUrl: "رابط غير صالح", + minLength: "الحد الأدنى للطول هو {{min}} حرفًا", + maxLength: "الحد الأقصى للطول هو {{max}} حرفًا", + numberRange: "يجب أن تكون القيمة بين {{min}} و {{max}}", + }, + time: { + seconds: "{{count}} ثانية", + seconds_plural: "{{count}} ثوانٍ", + minutes: "{{count}} دقيقة", + minutes_plural: "{{count}} دقائق", + hours: "{{count}} ساعة", + hours_plural: "{{count}} ساعات", + days: "{{count}} يوم", + days_plural: "{{count}} أيام", + ago: "منذ {{time}}", + justNow: "الآن", + }, + app: { + loadingPage: "جارٍ تحميل الصفحة...", + }, + }, +}; + +function assertSameShape(base, candidate, path = "") { + for (const key of Object.keys(base)) { + const nextPath = path ? `${path}.${key}` : key; + if (!(key in candidate)) { + throw new Error(`Missing key ${nextPath}`); + } + if ( + typeof base[key] === "object" && + base[key] !== null && + !Array.isArray(base[key]) + ) { + assertSameShape(base[key], candidate[key], nextPath); + } + } +} + +for (const [code, locale] of Object.entries(translations)) { + assertSameShape(en, locale); + writeFileSync( + join(localesDir, `${code}.json`), + `${JSON.stringify(locale, null, 2)}\n`, + "utf8", + ); + console.log(`Wrote ${code}.json`); +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 79985357..64dcb8d8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { lazy, Suspense } from "react"; import { Routes, Route } from "react-router-dom"; import Layout from "./components/Layout"; +import { LoadingFallback } from "./components/LoadingFallback"; import { GlobalErrorBoundary } from "./components/ErrorBoundary"; import { NotificationProvider } from "./context/NotificationContext"; import { useNotifications } from "./hooks/useNotifications"; @@ -51,13 +52,7 @@ function App() { - - Loading page... - - } - > + }> } /> diff --git a/frontend/src/components/LanguageSwitcher.test.tsx b/frontend/src/components/LanguageSwitcher.test.tsx new file mode 100644 index 00000000..7242a570 --- /dev/null +++ b/frontend/src/components/LanguageSwitcher.test.tsx @@ -0,0 +1,81 @@ +import { Suspense } from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { LanguageSwitcher } from "./LanguageSwitcher"; +import i18n from "../i18n/config"; + +function renderLanguageSwitcher() { + return render( + + + , + ); +} + +function getLanguageSwitcherButton() { + return screen.getByRole("button", { + name: /change language|cambiar idioma|changer de langue|sprache ändern|更改语言|言語を変更|언어 변경|تغيير اللغة/i, + }); +} + +describe("LanguageSwitcher", () => { + beforeEach(async () => { + localStorage.clear(); + document.documentElement.dir = "ltr"; + document.documentElement.lang = "en"; + await i18n.changeLanguage("en"); + }); + + it("lists all supported languages", async () => { + const user = userEvent.setup(); + renderLanguageSwitcher(); + + await user.click(getLanguageSwitcherButton()); + + expect(screen.getByRole("button", { name: /Español/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Français/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Deutsch/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /中文/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /日本語/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /한국어/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /العربية/i })).toBeInTheDocument(); + }); + + it("changes the active locale and persists it to localStorage", async () => { + const user = userEvent.setup(); + renderLanguageSwitcher(); + + await user.click(getLanguageSwitcherButton()); + await user.click(screen.getByRole("button", { name: /Español/i })); + + expect(i18n.language).toMatch(/^es/); + expect(localStorage.getItem("i18nextLng")).toMatch(/^es/); + expect(screen.getByRole("button", { name: /change language|cambiar idioma/i })).toHaveTextContent( + "Español", + ); + }); + + it("applies RTL direction for Arabic", async () => { + const user = userEvent.setup(); + renderLanguageSwitcher(); + + await user.click(getLanguageSwitcherButton()); + await user.click(screen.getByRole("button", { name: /العربية/i })); + + expect(document.documentElement.dir).toBe("rtl"); + expect(document.documentElement.lang).toBe("ar"); + }); + + it("restores LTR direction when switching back to English", async () => { + const user = userEvent.setup(); + renderLanguageSwitcher(); + + await user.click(getLanguageSwitcherButton()); + await user.click(screen.getByRole("button", { name: /العربية/i })); + await user.click(getLanguageSwitcherButton()); + await user.click(screen.getByRole("button", { name: /^English/i })); + + expect(document.documentElement.dir).toBe("ltr"); + expect(document.documentElement.lang).toBe("en"); + }); +}); diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/LanguageSwitcher.tsx index 36ca14f2..d7f8978e 100644 --- a/frontend/src/components/LanguageSwitcher.tsx +++ b/frontend/src/components/LanguageSwitcher.tsx @@ -8,23 +8,17 @@ import { useTranslation } from "react-i18next"; import { SUPPORTED_LANGUAGES, SupportedLanguage } from "../i18n/config"; export function LanguageSwitcher() { - const { i18n } = useTranslation(); + const { i18n, t } = useTranslation(); const [isOpen, setIsOpen] = useState(false); + const activeLanguageCode = i18n.language.split("-")[0]; const currentLanguage = - SUPPORTED_LANGUAGES.find((lang) => lang.code === i18n.language) || + SUPPORTED_LANGUAGES.find((lang) => lang.code === activeLanguageCode) || SUPPORTED_LANGUAGES[0]; const handleLanguageChange = (languageCode: SupportedLanguage) => { - i18n.changeLanguage(languageCode); + void i18n.changeLanguage(languageCode); setIsOpen(false); - - // Update document direction for RTL languages - const language = SUPPORTED_LANGUAGES.find( - (lang) => lang.code === languageCode, - ); - document.documentElement.dir = - language && "rtl" in language && language.rtl === true ? "rtl" : "ltr"; }; return ( @@ -32,7 +26,7 @@ export function LanguageSwitcher() { @@ -232,7 +253,7 @@ export default function Settings() {
-

Profile information

+

{t("settings.profileInfo")}

JS @@ -247,7 +268,7 @@ export default function Settings() { disabled className="w-full py-2 bg-stellar-border text-stellar-text-muted rounded-md text-sm cursor-not-allowed" > - Edit Profile (Locked) + {t("settings.editProfileLocked")}
diff --git a/frontend/src/test/utils.tsx b/frontend/src/test/utils.tsx index 923f6c0b..7065473b 100644 --- a/frontend/src/test/utils.tsx +++ b/frontend/src/test/utils.tsx @@ -1,8 +1,9 @@ /* eslint-disable react-refresh/only-export-components */ -import React, { ReactElement } from "react"; +import React, { ReactElement, Suspense } from "react"; import { render, RenderOptions } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter } from "react-router-dom"; +import "../i18n/config"; const createTestQueryClient = () => new QueryClient({ @@ -21,7 +22,9 @@ const AllTheProviders = ({ children }: AllTheProvidersProps) => { const queryClient = createTestQueryClient(); return ( - {children} + + {children} + ); };