Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@
"@tanstack/react-virtual": "^3.13.23",
"axios": "^1.7.2",
"framer-motion": "^12.38.0",
"i18next": "^23.11.5",
"i18next-browser-languagedetector": "^8.0.0",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^14.1.2",
"recharts": "^3.8.1",
"rollup-plugin-visualizer": "^7.0.1",
"web-vitals": "^5.1.0"
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ function App() {
const { canInstall, install, updateAvailable, applyUpdate } = usePWA();
const { queue: queueOffline, pendingCount } = useOfflineQueue();
const { theme, isDark, toggleTheme } = useTheme();
useRTL();
const prefersReduced = useReducedMotion();
const v = makeVariants(prefersReduced);
const tap = tapScale(prefersReduced);
Expand Down Expand Up @@ -212,6 +213,7 @@ function App() {
>
{isDark ? '☀️ Light' : '🌙 Dark'}
</button>
<LanguageSelector />
{canInstall && (
<button type="button" className="pwa-install-btn" onClick={install} title="Install app">
⬇ Install
Expand Down
31 changes: 31 additions & 0 deletions frontend/src/components/LanguageSelector.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useTranslation } from 'react-i18next';
import { SUPPORTED_LANGUAGES, RTL_LANGUAGES } from '../i18n';

/**
* LanguageSelector — dropdown to switch the active language.
* Automatically updates <html dir> and <html lang> on change.
*/
export function LanguageSelector({ className = '' }) {
const { i18n, t } = useTranslation();

const handleChange = (e) => {
const lang = e.target.value;
i18n.changeLanguage(lang);
document.documentElement.lang = lang;
document.documentElement.dir = RTL_LANGUAGES.has(lang) ? 'rtl' : 'ltr';
};

return (
<select
className={`lang-selector ${className}`}
value={i18n.language?.split('-')[0] ?? 'en'}
onChange={handleChange}
aria-label={t('language.select')}
title={t('language.select')}
>
{SUPPORTED_LANGUAGES.map(({ code, name }) => (
<option key={code} value={code}>{name}</option>
))}
</select>
);
}
17 changes: 17 additions & 0 deletions frontend/src/hooks/useRTL.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { RTL_LANGUAGES } from '../i18n';

/**
* Syncs <html dir> and <html lang> whenever the active language changes.
* Mount once near the app root (inside I18nextProvider).
*/
export function useRTL() {
const { i18n } = useTranslation();

useEffect(() => {
const lang = i18n.language?.split('-')[0] ?? 'en';
document.documentElement.lang = lang;
document.documentElement.dir = RTL_LANGUAGES.has(lang) ? 'rtl' : 'ltr';
}, [i18n.language]);
}
104 changes: 104 additions & 0 deletions frontend/src/i18n/formatters.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Locale-aware formatting utilities.
* All functions accept an optional `locale` parameter (BCP 47 tag).
* When omitted they fall back to the current i18n language or browser default.
*/

import i18n from './index';

function currentLocale() {
return i18n.language || navigator.language || 'en';
}

/**
* Format a number for the current locale.
* @param {number} value
* @param {Intl.NumberFormatOptions} [options]
* @param {string} [locale]
*/
export function formatNumber(value, options = {}, locale = currentLocale()) {
return new Intl.NumberFormat(locale, options).format(value);
}

/**
* Format a currency amount.
* @param {number} amount
* @param {string} currency ISO 4217 code, e.g. 'USD', 'EUR'
* @param {string} [locale]
*/
export function formatCurrency(amount, currency, locale = currentLocale()) {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 7,
}).format(amount);
}

/**
* Format a date/time value.
* @param {Date|number|string} value
* @param {Intl.DateTimeFormatOptions} [options]
* @param {string} [locale]
*/
export function formatDate(value, options = { dateStyle: 'medium' }, locale = currentLocale()) {
return new Intl.DateTimeFormat(locale, options).format(new Date(value));
}

/**
* Format a date+time.
* @param {Date|number|string} value
* @param {string} [locale]
*/
export function formatDateTime(value, locale = currentLocale()) {
return formatDate(value, { dateStyle: 'medium', timeStyle: 'short' }, locale);
}

/**
* Format a relative time (e.g. "3 minutes ago").
* @param {Date|number} value Past or future date
* @param {string} [locale]
*/
export function formatRelativeTime(value, locale = currentLocale()) {
const diffMs = new Date(value) - Date.now();
const diffSec = Math.round(diffMs / 1000);
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });

const thresholds = [
[60, 'second', 1],
[3600, 'minute', 60],
[86400, 'hour', 3600],
[Infinity, 'day', 86400],
];

for (const [limit, unit, divisor] of thresholds) {
if (Math.abs(diffSec) < limit) {
return rtf.format(Math.round(diffSec / divisor), unit);
}
}
}

/**
* Map a locale code to its preferred currency code.
* Falls back to USD for unknown locales.
*/
const LOCALE_CURRENCY_MAP = {
en: 'USD', 'en-GB': 'GBP', 'en-AU': 'AUD', 'en-CA': 'CAD',
ar: 'SAR', 'ar-AE': 'AED', 'ar-EG': 'EGP',
he: 'ILS',
fr: 'EUR', 'fr-CH': 'CHF',
es: 'EUR', 'es-MX': 'MXN', 'es-AR': 'ARS', 'es-CO': 'COP',
zh: 'CNY', 'zh-TW': 'TWD', 'zh-HK': 'HKD',
pt: 'EUR', 'pt-BR': 'BRL',
};

/**
* Get the preferred currency code for a locale.
* @param {string} [locale]
* @returns {string} ISO 4217 currency code
*/
export function getLocaleCurrency(locale = currentLocale()) {
return LOCALE_CURRENCY_MAP[locale]
?? LOCALE_CURRENCY_MAP[locale.split('-')[0]]
?? 'USD';
}
41 changes: 41 additions & 0 deletions frontend/src/i18n/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';

import en from './locales/en.json';
import ar from './locales/ar.json';
import he from './locales/he.json';
import fr from './locales/fr.json';
import es from './locales/es.json';
import zh from './locales/zh.json';
import pt from './locales/pt.json';

/** Languages that use right-to-left text direction */
export const RTL_LANGUAGES = new Set(['ar', 'he']);

/** All supported locales with their display metadata */
export const SUPPORTED_LANGUAGES = [
{ code: 'en', name: 'English', dir: 'ltr' },
{ code: 'ar', name: 'العربية', dir: 'rtl' },
{ code: 'he', name: 'עברית', dir: 'rtl' },
{ code: 'fr', name: 'Français', dir: 'ltr' },
{ code: 'es', name: 'Español', dir: 'ltr' },
{ code: 'zh', name: '中文', dir: 'ltr' },
{ code: 'pt', name: 'Português', dir: 'ltr' },
];

i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: { en, ar, he, fr, es, zh, pt },
fallbackLng: 'en',
interpolation: { escapeValue: false },
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
lookupLocalStorage: 'i18n_language',
},
});

export default i18n;
82 changes: 82 additions & 0 deletions frontend/src/i18n/locales/ar.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
{
"app": {
"title": "منصة التحويلات المالية Stellar",
"loading": "جارٍ التحميل…"
},
"nav": {
"switchToLight": "التبديل إلى الوضع الفاتح",
"switchToDark": "التبديل إلى الوضع الداكن",
"light": "☀️ فاتح",
"dark": "🌙 داكن",
"install": "⬇ تثبيت",
"shortcuts": "اختصارات لوحة المفاتيح (?)"
},
"account": {
"create": "إنشاء حساب",
"creating": "جارٍ إنشاء الحساب...",
"import": "استيراد حساب",
"cancelImport": "إلغاء الاستيراد",
"publicKey": "المفتاح العام:",
"secretKey": "المفتاح السري:",
"copyPublicKey": "نسخ المفتاح العام",
"copySecretKey": "نسخ المفتاح السري",
"showQR": "🔲 عرض رمز QR",
"created": "تم إنشاء الحساب! احفظ مفتاحك السري بأمان.",
"imported": "تم استيراد الحساب بنجاح!"
},
"balance": {
"check": "التحقق من الرصيد",
"checking": "جارٍ التحقق من الرصيد..."
},
"payment": {
"title": "إرسال دفعة",
"recipientPlaceholder": "المفتاح العام للمستلم",
"recipientLabel": "المفتاح العام للمستلم",
"amountPlaceholder": "المبلغ (XLM)",
"amountLabel": "مبلغ الدفع بـ XLM",
"send": "إرسال",
"sending": "جارٍ إرسال الدفعة...",
"clear": "مسح",
"clearConfirm": "مسح نموذج الدفع؟",
"sent": "تم إرسال الدفعة! الرمز: {{hash}}",
"queued": "أنت غير متصل. تم وضع الدفعة في قائمة الانتظار وستتم المزامنة تلقائياً.",
"largeWarning": "⚠️ يرجى مراجعة وتأكيد تحذير المعاملة الكبيرة أدناه."
},
"validation": {
"invalidAddress": "تنسيق عنوان Stellar غير صالح (يجب أن يبدأ بـ G ويكون 56 حرفاً)"
},
"pwa": {
"updateAvailable": "يتوفر إصدار جديد.",
"updateNow": "تحديث الآن",
"queued_one": "{{count}} دفعة في قائمة الانتظار — ستتم المزامنة عند الاتصال.",
"queued_other": "{{count}} دفعات في قائمة الانتظار — ستتم المزامنة عند الاتصال."
},
"shortcuts": {
"title": "اختصارات لوحة المفاتيح",
"close": "إغلاق",
"createAccount": "إنشاء حساب جديد",
"copyKey": "نسخ المفتاح (عند تركيز زر النسخ)",
"escape": "إغلاق النوافذ",
"help": "تبديل هذه المساعدة",
"tab": "التنقل بين الحقول",
"enter": "إرسال النموذج المحدد"
},
"network": {
"connected": "متصل",
"disconnected": "غير متصل",
"reconnecting": "جارٍ إعادة الاتصال"
},
"errors": {
"timeout": "انتهت مهلة الطلب. يرجى المحاولة مرة أخرى."
},
"language": {
"select": "اللغة",
"en": "English",
"ar": "العربية",
"he": "עברית",
"fr": "Français",
"es": "Español",
"zh": "中文",
"pt": "Português"
}
}
82 changes: 82 additions & 0 deletions frontend/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
{
"app": {
"title": "Stellar Remittance Platform",
"loading": "Loading…"
},
"nav": {
"switchToLight": "Switch to light mode",
"switchToDark": "Switch to dark mode",
"light": "☀️ Light",
"dark": "🌙 Dark",
"install": "⬇ Install",
"shortcuts": "Keyboard shortcuts (?)"
},
"account": {
"create": "Create Account",
"creating": "Creating account...",
"import": "Import Account",
"cancelImport": "Cancel Import",
"publicKey": "Public Key:",
"secretKey": "Secret Key:",
"copyPublicKey": "Copy public key",
"copySecretKey": "Copy secret key",
"showQR": "🔲 Show QR Code",
"created": "Account created! Save your secret key securely.",
"imported": "Account imported successfully!"
},
"balance": {
"check": "Check Balance",
"checking": "Checking balance..."
},
"payment": {
"title": "Send Payment",
"recipientPlaceholder": "Recipient Public Key",
"recipientLabel": "Recipient public key",
"amountPlaceholder": "Amount (XLM)",
"amountLabel": "Payment amount in XLM",
"send": "Send",
"sending": "Sending payment...",
"clear": "Clear",
"clearConfirm": "Clear the payment form?",
"sent": "Payment sent! Hash: {{hash}}",
"queued": "You are offline. Payment queued and will sync automatically.",
"largeWarning": "⚠️ Please review and confirm the large transaction warning below."
},
"validation": {
"invalidAddress": "Invalid Stellar address format (must start with G and be 56 characters)"
},
"pwa": {
"updateAvailable": "A new version is available.",
"updateNow": "Update now",
"queued_one": "{{count}} payment queued offline — will sync when back online.",
"queued_other": "{{count}} payments queued offline — will sync when back online."
},
"shortcuts": {
"title": "Keyboard Shortcuts",
"close": "Close",
"createAccount": "Create new account",
"copyKey": "Copy key (when copy button focused)",
"escape": "Close modals",
"help": "Toggle this help",
"tab": "Navigate between fields",
"enter": "Submit focused form"
},
"network": {
"connected": "connected",
"disconnected": "disconnected",
"reconnecting": "reconnecting"
},
"errors": {
"timeout": "Request timed out. Please try again."
},
"language": {
"select": "Language",
"en": "English",
"ar": "العربية",
"he": "עברית",
"fr": "Français",
"es": "Español",
"zh": "中文",
"pt": "Português"
}
}
Loading
Loading