Skip to content

Commit 65d49ec

Browse files
authored
Merge pull request #192 from sonoflawal/feat/i18n-multi-language
feat: add multi-language support (i18n)
2 parents cf5a2c9 + 77f1d03 commit 65d49ec

15 files changed

Lines changed: 670 additions & 1 deletion

File tree

frontend/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@
1616
"@tanstack/react-virtual": "^3.13.23",
1717
"axios": "^1.7.2",
1818
"framer-motion": "^12.38.0",
19+
"i18next": "^23.11.5",
20+
"i18next-browser-languagedetector": "^8.0.0",
1921
"qrcode": "^1.5.4",
2022
"react": "^18.3.1",
2123
"react-dom": "^18.3.1",
24+
"react-i18next": "^14.1.2",
2225
"recharts": "^3.8.1",
2326
"rollup-plugin-visualizer": "^7.0.1",
2427
"web-vitals": "^5.1.0"

frontend/src/App.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ function App() {
5353
const { canInstall, install, updateAvailable, applyUpdate } = usePWA();
5454
const { queue: queueOffline, pendingCount } = useOfflineQueue();
5555
const { theme, isDark, toggleTheme } = useTheme();
56+
useRTL();
5657
const prefersReduced = useReducedMotion();
5758
const v = makeVariants(prefersReduced);
5859
const tap = tapScale(prefersReduced);
@@ -212,6 +213,7 @@ function App() {
212213
>
213214
{isDark ? '☀️ Light' : '🌙 Dark'}
214215
</button>
216+
<LanguageSelector />
215217
{canInstall && (
216218
<button type="button" className="pwa-install-btn" onClick={install} title="Install app">
217219
⬇ Install
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { useTranslation } from 'react-i18next';
2+
import { SUPPORTED_LANGUAGES, RTL_LANGUAGES } from '../i18n';
3+
4+
/**
5+
* LanguageSelector — dropdown to switch the active language.
6+
* Automatically updates <html dir> and <html lang> on change.
7+
*/
8+
export function LanguageSelector({ className = '' }) {
9+
const { i18n, t } = useTranslation();
10+
11+
const handleChange = (e) => {
12+
const lang = e.target.value;
13+
i18n.changeLanguage(lang);
14+
document.documentElement.lang = lang;
15+
document.documentElement.dir = RTL_LANGUAGES.has(lang) ? 'rtl' : 'ltr';
16+
};
17+
18+
return (
19+
<select
20+
className={`lang-selector ${className}`}
21+
value={i18n.language?.split('-')[0] ?? 'en'}
22+
onChange={handleChange}
23+
aria-label={t('language.select')}
24+
title={t('language.select')}
25+
>
26+
{SUPPORTED_LANGUAGES.map(({ code, name }) => (
27+
<option key={code} value={code}>{name}</option>
28+
))}
29+
</select>
30+
);
31+
}

frontend/src/hooks/useRTL.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { useEffect } from 'react';
2+
import { useTranslation } from 'react-i18next';
3+
import { RTL_LANGUAGES } from '../i18n';
4+
5+
/**
6+
* Syncs <html dir> and <html lang> whenever the active language changes.
7+
* Mount once near the app root (inside I18nextProvider).
8+
*/
9+
export function useRTL() {
10+
const { i18n } = useTranslation();
11+
12+
useEffect(() => {
13+
const lang = i18n.language?.split('-')[0] ?? 'en';
14+
document.documentElement.lang = lang;
15+
document.documentElement.dir = RTL_LANGUAGES.has(lang) ? 'rtl' : 'ltr';
16+
}, [i18n.language]);
17+
}

frontend/src/i18n/formatters.js

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* Locale-aware formatting utilities.
3+
* All functions accept an optional `locale` parameter (BCP 47 tag).
4+
* When omitted they fall back to the current i18n language or browser default.
5+
*/
6+
7+
import i18n from './index';
8+
9+
function currentLocale() {
10+
return i18n.language || navigator.language || 'en';
11+
}
12+
13+
/**
14+
* Format a number for the current locale.
15+
* @param {number} value
16+
* @param {Intl.NumberFormatOptions} [options]
17+
* @param {string} [locale]
18+
*/
19+
export function formatNumber(value, options = {}, locale = currentLocale()) {
20+
return new Intl.NumberFormat(locale, options).format(value);
21+
}
22+
23+
/**
24+
* Format a currency amount.
25+
* @param {number} amount
26+
* @param {string} currency ISO 4217 code, e.g. 'USD', 'EUR'
27+
* @param {string} [locale]
28+
*/
29+
export function formatCurrency(amount, currency, locale = currentLocale()) {
30+
return new Intl.NumberFormat(locale, {
31+
style: 'currency',
32+
currency,
33+
minimumFractionDigits: 2,
34+
maximumFractionDigits: 7,
35+
}).format(amount);
36+
}
37+
38+
/**
39+
* Format a date/time value.
40+
* @param {Date|number|string} value
41+
* @param {Intl.DateTimeFormatOptions} [options]
42+
* @param {string} [locale]
43+
*/
44+
export function formatDate(value, options = { dateStyle: 'medium' }, locale = currentLocale()) {
45+
return new Intl.DateTimeFormat(locale, options).format(new Date(value));
46+
}
47+
48+
/**
49+
* Format a date+time.
50+
* @param {Date|number|string} value
51+
* @param {string} [locale]
52+
*/
53+
export function formatDateTime(value, locale = currentLocale()) {
54+
return formatDate(value, { dateStyle: 'medium', timeStyle: 'short' }, locale);
55+
}
56+
57+
/**
58+
* Format a relative time (e.g. "3 minutes ago").
59+
* @param {Date|number} value Past or future date
60+
* @param {string} [locale]
61+
*/
62+
export function formatRelativeTime(value, locale = currentLocale()) {
63+
const diffMs = new Date(value) - Date.now();
64+
const diffSec = Math.round(diffMs / 1000);
65+
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
66+
67+
const thresholds = [
68+
[60, 'second', 1],
69+
[3600, 'minute', 60],
70+
[86400, 'hour', 3600],
71+
[Infinity, 'day', 86400],
72+
];
73+
74+
for (const [limit, unit, divisor] of thresholds) {
75+
if (Math.abs(diffSec) < limit) {
76+
return rtf.format(Math.round(diffSec / divisor), unit);
77+
}
78+
}
79+
}
80+
81+
/**
82+
* Map a locale code to its preferred currency code.
83+
* Falls back to USD for unknown locales.
84+
*/
85+
const LOCALE_CURRENCY_MAP = {
86+
en: 'USD', 'en-GB': 'GBP', 'en-AU': 'AUD', 'en-CA': 'CAD',
87+
ar: 'SAR', 'ar-AE': 'AED', 'ar-EG': 'EGP',
88+
he: 'ILS',
89+
fr: 'EUR', 'fr-CH': 'CHF',
90+
es: 'EUR', 'es-MX': 'MXN', 'es-AR': 'ARS', 'es-CO': 'COP',
91+
zh: 'CNY', 'zh-TW': 'TWD', 'zh-HK': 'HKD',
92+
pt: 'EUR', 'pt-BR': 'BRL',
93+
};
94+
95+
/**
96+
* Get the preferred currency code for a locale.
97+
* @param {string} [locale]
98+
* @returns {string} ISO 4217 currency code
99+
*/
100+
export function getLocaleCurrency(locale = currentLocale()) {
101+
return LOCALE_CURRENCY_MAP[locale]
102+
?? LOCALE_CURRENCY_MAP[locale.split('-')[0]]
103+
?? 'USD';
104+
}

frontend/src/i18n/index.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import i18n from 'i18next';
2+
import { initReactI18next } from 'react-i18next';
3+
import LanguageDetector from 'i18next-browser-languagedetector';
4+
5+
import en from './locales/en.json';
6+
import ar from './locales/ar.json';
7+
import he from './locales/he.json';
8+
import fr from './locales/fr.json';
9+
import es from './locales/es.json';
10+
import zh from './locales/zh.json';
11+
import pt from './locales/pt.json';
12+
13+
/** Languages that use right-to-left text direction */
14+
export const RTL_LANGUAGES = new Set(['ar', 'he']);
15+
16+
/** All supported locales with their display metadata */
17+
export const SUPPORTED_LANGUAGES = [
18+
{ code: 'en', name: 'English', dir: 'ltr' },
19+
{ code: 'ar', name: 'العربية', dir: 'rtl' },
20+
{ code: 'he', name: 'עברית', dir: 'rtl' },
21+
{ code: 'fr', name: 'Français', dir: 'ltr' },
22+
{ code: 'es', name: 'Español', dir: 'ltr' },
23+
{ code: 'zh', name: '中文', dir: 'ltr' },
24+
{ code: 'pt', name: 'Português', dir: 'ltr' },
25+
];
26+
27+
i18n
28+
.use(LanguageDetector)
29+
.use(initReactI18next)
30+
.init({
31+
resources: { en, ar, he, fr, es, zh, pt },
32+
fallbackLng: 'en',
33+
interpolation: { escapeValue: false },
34+
detection: {
35+
order: ['localStorage', 'navigator'],
36+
caches: ['localStorage'],
37+
lookupLocalStorage: 'i18n_language',
38+
},
39+
});
40+
41+
export default i18n;

frontend/src/i18n/locales/ar.json

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
{
2+
"app": {
3+
"title": "منصة التحويلات المالية Stellar",
4+
"loading": "جارٍ التحميل…"
5+
},
6+
"nav": {
7+
"switchToLight": "التبديل إلى الوضع الفاتح",
8+
"switchToDark": "التبديل إلى الوضع الداكن",
9+
"light": "☀️ فاتح",
10+
"dark": "🌙 داكن",
11+
"install": "⬇ تثبيت",
12+
"shortcuts": "اختصارات لوحة المفاتيح (?)"
13+
},
14+
"account": {
15+
"create": "إنشاء حساب",
16+
"creating": "جارٍ إنشاء الحساب...",
17+
"import": "استيراد حساب",
18+
"cancelImport": "إلغاء الاستيراد",
19+
"publicKey": "المفتاح العام:",
20+
"secretKey": "المفتاح السري:",
21+
"copyPublicKey": "نسخ المفتاح العام",
22+
"copySecretKey": "نسخ المفتاح السري",
23+
"showQR": "🔲 عرض رمز QR",
24+
"created": "تم إنشاء الحساب! احفظ مفتاحك السري بأمان.",
25+
"imported": "تم استيراد الحساب بنجاح!"
26+
},
27+
"balance": {
28+
"check": "التحقق من الرصيد",
29+
"checking": "جارٍ التحقق من الرصيد..."
30+
},
31+
"payment": {
32+
"title": "إرسال دفعة",
33+
"recipientPlaceholder": "المفتاح العام للمستلم",
34+
"recipientLabel": "المفتاح العام للمستلم",
35+
"amountPlaceholder": "المبلغ (XLM)",
36+
"amountLabel": "مبلغ الدفع بـ XLM",
37+
"send": "إرسال",
38+
"sending": "جارٍ إرسال الدفعة...",
39+
"clear": "مسح",
40+
"clearConfirm": "مسح نموذج الدفع؟",
41+
"sent": "تم إرسال الدفعة! الرمز: {{hash}}",
42+
"queued": "أنت غير متصل. تم وضع الدفعة في قائمة الانتظار وستتم المزامنة تلقائياً.",
43+
"largeWarning": "⚠️ يرجى مراجعة وتأكيد تحذير المعاملة الكبيرة أدناه."
44+
},
45+
"validation": {
46+
"invalidAddress": "تنسيق عنوان Stellar غير صالح (يجب أن يبدأ بـ G ويكون 56 حرفاً)"
47+
},
48+
"pwa": {
49+
"updateAvailable": "يتوفر إصدار جديد.",
50+
"updateNow": "تحديث الآن",
51+
"queued_one": "{{count}} دفعة في قائمة الانتظار — ستتم المزامنة عند الاتصال.",
52+
"queued_other": "{{count}} دفعات في قائمة الانتظار — ستتم المزامنة عند الاتصال."
53+
},
54+
"shortcuts": {
55+
"title": "اختصارات لوحة المفاتيح",
56+
"close": "إغلاق",
57+
"createAccount": "إنشاء حساب جديد",
58+
"copyKey": "نسخ المفتاح (عند تركيز زر النسخ)",
59+
"escape": "إغلاق النوافذ",
60+
"help": "تبديل هذه المساعدة",
61+
"tab": "التنقل بين الحقول",
62+
"enter": "إرسال النموذج المحدد"
63+
},
64+
"network": {
65+
"connected": "متصل",
66+
"disconnected": "غير متصل",
67+
"reconnecting": "جارٍ إعادة الاتصال"
68+
},
69+
"errors": {
70+
"timeout": "انتهت مهلة الطلب. يرجى المحاولة مرة أخرى."
71+
},
72+
"language": {
73+
"select": "اللغة",
74+
"en": "English",
75+
"ar": "العربية",
76+
"he": "עברית",
77+
"fr": "Français",
78+
"es": "Español",
79+
"zh": "中文",
80+
"pt": "Português"
81+
}
82+
}

frontend/src/i18n/locales/en.json

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
{
2+
"app": {
3+
"title": "Stellar Remittance Platform",
4+
"loading": "Loading…"
5+
},
6+
"nav": {
7+
"switchToLight": "Switch to light mode",
8+
"switchToDark": "Switch to dark mode",
9+
"light": "☀️ Light",
10+
"dark": "🌙 Dark",
11+
"install": "⬇ Install",
12+
"shortcuts": "Keyboard shortcuts (?)"
13+
},
14+
"account": {
15+
"create": "Create Account",
16+
"creating": "Creating account...",
17+
"import": "Import Account",
18+
"cancelImport": "Cancel Import",
19+
"publicKey": "Public Key:",
20+
"secretKey": "Secret Key:",
21+
"copyPublicKey": "Copy public key",
22+
"copySecretKey": "Copy secret key",
23+
"showQR": "🔲 Show QR Code",
24+
"created": "Account created! Save your secret key securely.",
25+
"imported": "Account imported successfully!"
26+
},
27+
"balance": {
28+
"check": "Check Balance",
29+
"checking": "Checking balance..."
30+
},
31+
"payment": {
32+
"title": "Send Payment",
33+
"recipientPlaceholder": "Recipient Public Key",
34+
"recipientLabel": "Recipient public key",
35+
"amountPlaceholder": "Amount (XLM)",
36+
"amountLabel": "Payment amount in XLM",
37+
"send": "Send",
38+
"sending": "Sending payment...",
39+
"clear": "Clear",
40+
"clearConfirm": "Clear the payment form?",
41+
"sent": "Payment sent! Hash: {{hash}}",
42+
"queued": "You are offline. Payment queued and will sync automatically.",
43+
"largeWarning": "⚠️ Please review and confirm the large transaction warning below."
44+
},
45+
"validation": {
46+
"invalidAddress": "Invalid Stellar address format (must start with G and be 56 characters)"
47+
},
48+
"pwa": {
49+
"updateAvailable": "A new version is available.",
50+
"updateNow": "Update now",
51+
"queued_one": "{{count}} payment queued offline — will sync when back online.",
52+
"queued_other": "{{count}} payments queued offline — will sync when back online."
53+
},
54+
"shortcuts": {
55+
"title": "Keyboard Shortcuts",
56+
"close": "Close",
57+
"createAccount": "Create new account",
58+
"copyKey": "Copy key (when copy button focused)",
59+
"escape": "Close modals",
60+
"help": "Toggle this help",
61+
"tab": "Navigate between fields",
62+
"enter": "Submit focused form"
63+
},
64+
"network": {
65+
"connected": "connected",
66+
"disconnected": "disconnected",
67+
"reconnecting": "reconnecting"
68+
},
69+
"errors": {
70+
"timeout": "Request timed out. Please try again."
71+
},
72+
"language": {
73+
"select": "Language",
74+
"en": "English",
75+
"ar": "العربية",
76+
"he": "עברית",
77+
"fr": "Français",
78+
"es": "Español",
79+
"zh": "中文",
80+
"pt": "Português"
81+
}
82+
}

0 commit comments

Comments
 (0)