diff --git a/package-lock.json b/package-lock.json index dc1c46e..3435357 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@stellar/stellar-sdk": "^15.1.0", "bignumber.js": "^11.1.3", "idb": "^8.0.3", + "intl-messageformat": "^11.2.8", "next": "16.2.9", "qrcode": "^1.5.4", "react": "19.2.4", @@ -704,6 +705,24 @@ } } }, + "node_modules/@formatjs/fast-memoize": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.6.tgz", + "integrity": "sha512-H5aexk1Le7T9TPmscacZ+1pR6CTa2n1wq+HDVGXhH8TzUlQQpeXzZs91dRtmFHrbeNbjPFPfQujUqm7MHgVoXQ==" + }, + "node_modules/@formatjs/icu-messageformat-parser": { + "version": "3.5.11", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.11.tgz", + "integrity": "sha512-NVsuNsc2dUVG9+4HBJ/srScxtA/18LqGgwtop/tuN/OIBjVl6QA+0KhfZQddDD9sEh2LeVjLFPGVU3ixa3blcA==", + "dependencies": { + "@formatjs/icu-skeleton-parser": "2.1.10" + } + }, + "node_modules/@formatjs/icu-skeleton-parser": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.10.tgz", + "integrity": "sha512-XuSva+8ZGawk8VnD5VD6UeH8KarQ/Z022zgjHDoHmlNiAewstXuuzXc0Hk5pGFSdG+nNw5bfJKXqj1ZXHn9yUA==" + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -4939,6 +4958,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5365,6 +5385,15 @@ "node": ">= 0.4" } }, + "node_modules/intl-messageformat": { + "version": "11.2.8", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.8.tgz", + "integrity": "sha512-l323RCl3qJDVQ8U9j74ut/hVMdg3VPsOHpVMDvFfz9qiq4dPO5ooVYFNVUzzrpgG39a+RLzcXyJb8VFgIU+tUA==", + "dependencies": { + "@formatjs/fast-memoize": "3.1.6", + "@formatjs/icu-messageformat-parser": "3.5.11" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", diff --git a/package.json b/package.json index e67e855..238404f 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@stellar/stellar-sdk": "^15.1.0", "bignumber.js": "^11.1.3", "idb": "^8.0.3", + "intl-messageformat": "^11.2.8", "next": "16.2.9", "qrcode": "^1.5.4", "react": "19.2.4", diff --git a/scripts/extract-i18n-keys.ts b/scripts/extract-i18n-keys.ts new file mode 100644 index 0000000..994c306 --- /dev/null +++ b/scripts/extract-i18n-keys.ts @@ -0,0 +1,25 @@ +import fs from "fs"; +import path from "path"; + +function walk(dir: string, cb: (file: string) => void) { + for (const f of fs.readdirSync(dir)) { + const full = path.join(dir, f); + const stat = fs.statSync(full); + if (stat.isDirectory()) walk(full, cb); + else if (/\.(ts|tsx|js|jsx)$/.test(f)) cb(full); + } +} + +const root = path.resolve(__dirname, "..", "src"); +const keys = new Set(); +const re = /t\(\s*["'`]([\w.\-:\s]+)["'`]/g; +walk(root, (file) => { + const content = fs.readFileSync(file, "utf8"); + let m; + while ((m = re.exec(content))) keys.add(m[1]); +}); + +const out: Record = {}; +for (const k of Array.from(keys).sort()) out[k] = ""; +fs.writeFileSync(path.join(root, "i18n-keys.json"), JSON.stringify(out, null, 2)); +console.warn("Wrote src/i18n-keys.json with", Object.keys(out).length, "keys"); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3ef8779..d5e5421 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import "./globals.css"; import { ThemeProvider } from "@/components/providers/ThemeProvider"; import { WalletProvider } from "@/components/providers/WalletProvider"; import { ServiceWorkerProvider } from "@/components/providers/ServiceWorkerProvider"; +import { I18nProvider } from "@/i18n/I18nProvider"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -45,7 +46,9 @@ export default function RootLayout({ - {children} + + {children} + diff --git a/src/components/settings/LanguageSwitcher.tsx b/src/components/settings/LanguageSwitcher.tsx new file mode 100644 index 0000000..afdab0b --- /dev/null +++ b/src/components/settings/LanguageSwitcher.tsx @@ -0,0 +1,38 @@ +"use client"; + +import React from "react"; +import { useTranslation } from "@/i18n/useTranslation"; +import { SUPPORTED_LOCALES, Locale } from "@/i18n/config"; + +const FLAGS: Record = { + "en-US": "🇺🇸", + "es-MX": "🇲🇽", + "pt-BR": "🇧🇷", + "fr-FR": "🇫🇷", + "de-DE": "🇩🇪", + "ar-SA": "🇸🇦", + "ja-JP": "🇯🇵", + "zh-CN": "🇨🇳", +}; + +export function LanguageSwitcher() { + const { locale, setLocale, t } = useTranslation(); + + return ( + + ); +} diff --git a/src/hooks/useLocale.ts b/src/hooks/useLocale.ts new file mode 100644 index 0000000..b5b232b --- /dev/null +++ b/src/hooks/useLocale.ts @@ -0,0 +1,33 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { DEFAULT_LOCALE, Locale, SUPPORTED_LOCALES } from "@/i18n/config"; + +export function useLocale() { + const [locale, setLocaleState] = useState(() => { + if (typeof window === "undefined") return DEFAULT_LOCALE; + return (window.localStorage.getItem("locale") as Locale) || DEFAULT_LOCALE; + }); + + useEffect(() => { + const onStorage = (e: StorageEvent) => { + if (e.key === "locale") { + setLocaleState((e.newValue as Locale) || DEFAULT_LOCALE); + } + }; + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); + }, []); + + const setLocale = (l: Locale) => { + if (!SUPPORTED_LOCALES.includes(l)) { + console.warn("unsupported locale", l); + return; + } + window.localStorage.setItem("locale", l); + setLocaleState(l); + window.dispatchEvent(new CustomEvent("localeChange", { detail: l })); + }; + + return { locale, setLocale } as const; +} diff --git a/src/i18n/I18nProvider.tsx b/src/i18n/I18nProvider.tsx new file mode 100644 index 0000000..cd3d2ea --- /dev/null +++ b/src/i18n/I18nProvider.tsx @@ -0,0 +1,93 @@ +"use client"; + +import React, { createContext, useContext, useEffect, useState } from "react"; +import { DEFAULT_LOCALE, isRTL, Locale, SUPPORTED_LOCALES } from "./config"; +import { formatMessage } from "./icu"; + +type Translations = Record; + +type I18nContextValue = { + locale: Locale; + t: (key: string, values?: Record) => string; + setLocale: (l: Locale) => void; + translations: Translations | null; +}; + +const I18nContext = createContext(undefined); + +export function useI18nContext() { + const ctx = useContext(I18nContext); + if (!ctx) throw new Error("useI18nContext must be used within I18nProvider"); + return ctx; +} + +export function I18nProvider({ children }: { children: React.ReactNode }) { + const [locale, setLocaleState] = useState(() => { + if (typeof window === "undefined") return DEFAULT_LOCALE; + return (window.localStorage.getItem("locale") as Locale) || (DEFAULT_LOCALE as Locale); + }); + const [translations, setTranslations] = useState(null); + + useEffect(() => { + let mounted = true; + async function load() { + try { + // dynamic import of JSON bundles; ensure they're not in the initial chunk + const mod = await import(/* webpackChunkName: "locale-[request]" */ `./locales/${locale}.json`); + if (!mounted) return; + setTranslations(mod.default || mod); + } catch (err) { + if (process.env.NODE_ENV === "development") console.warn("i18n load error:", err); + if (locale !== DEFAULT_LOCALE) { + // try fallback + const mod = await import(/* webpackChunkName: "locale-en-US" */ `./locales/${DEFAULT_LOCALE}.json`); + setTranslations(mod.default || mod); + } else { + setTranslations({}); + } + } + } + load(); + return () => { + mounted = false; + }; + }, [locale]); + + useEffect(() => { + if (typeof document !== "undefined") { + document.documentElement.lang = locale; + document.documentElement.dir = isRTL(locale) ? "rtl" : "ltr"; + } + }, [locale]); + + const setLocale = (l: Locale) => { + if (!SUPPORTED_LOCALES.includes(l)) { + console.warn("Attempt to set unsupported locale", l); + return; + } + window.localStorage.setItem("locale", l); + setLocaleState(l); + }; + + const t = (key: string, values: Record = {}) => { + const msg = translations?.[key] ?? undefined; + if (!msg) { + if (process.env.NODE_ENV === "development") { + console.warn(`i18n: missing key ${key} for locale ${locale}`); + return `[${key}]`; + } + // fallback to key or en-US + return key; + } + return formatMessage(msg, values, locale); + }; + + const ctx: I18nContextValue = { + locale, + t, + setLocale, + translations, + }; + + return {children}; +} diff --git a/src/i18n/config.ts b/src/i18n/config.ts new file mode 100644 index 0000000..da46675 --- /dev/null +++ b/src/i18n/config.ts @@ -0,0 +1,18 @@ +export const SUPPORTED_LOCALES = [ + "en-US", + "es-MX", + "pt-BR", + "fr-FR", + "de-DE", + "ar-SA", + "ja-JP", + "zh-CN", +] as const; + +export type Locale = (typeof SUPPORTED_LOCALES)[number]; + +export const DEFAULT_LOCALE: Locale = "en-US"; + +export function isRTL(locale: string) { + return ["ar", "he"].some((p) => locale.startsWith(p)); +} diff --git a/src/i18n/icu.ts b/src/i18n/icu.ts new file mode 100644 index 0000000..df9adc6 --- /dev/null +++ b/src/i18n/icu.ts @@ -0,0 +1,19 @@ +import IntlMessageFormat from "intl-messageformat"; + +export function formatMessage( + message: string, + values: Record = {}, + locale = "en-US" +): string { + try { + const mf = new IntlMessageFormat(message, locale); + return mf.format(values) as string; + } catch (err) { + if (process.env.NODE_ENV === "development") { + console.warn("i18n.formatMessage error:", err); + // helpful debug fallback + return `[i18n:err] ${message}`; + } + return message; + } +} diff --git a/src/i18n/locales/ar-SA.json b/src/i18n/locales/ar-SA.json new file mode 100644 index 0000000..933d2ba --- /dev/null +++ b/src/i18n/locales/ar-SA.json @@ -0,0 +1,12 @@ +{ + "app.title": "لوحة Utility Protocol — شبكة DePIN المكانية", + "settings.language": "اللغة", + "tariff.kwh": "{value, plural, zero {0 كيلو واط ساعي} one {# كيلو واط ساعي} two {# كيلو واط ساعي} few {# كيلو واط ساعي} many {# كيلو واط ساعي} other {# كيلو واط ساعي}}", + "meter.status.online": "متصل", + "meter.status.offline": "غير متصل", + "error.network": "خطأ في الشبكة. حاول مرة أخرى.", + "compliance.notice": "هذا الجهاز يتوافق مع اللوائح المحلية.", + "date.short": "{ts, date, short}", + "currency.default": "SAR", + "meter.readings": "{count, plural, zero {لا قراءات} one {# قراءة} two {# قراءتان} few {# قراءات} many {# قراءة} other {# قراءة}}" +} diff --git a/src/i18n/locales/de-DE.json b/src/i18n/locales/de-DE.json new file mode 100644 index 0000000..d3641b6 --- /dev/null +++ b/src/i18n/locales/de-DE.json @@ -0,0 +1,12 @@ +{ + "app.title": "Utility Protocol — Spatial DePIN Dashboard", + "settings.language": "Sprache", + "tariff.kwh": "{value, plural, one {# kWh} other {# kWh}}", + "meter.status.online": "Online", + "meter.status.offline": "Offline", + "error.network": "Netzwerkfehler. Bitte erneut versuchen.", + "compliance.notice": "Dieses Gerät entspricht den lokalen Vorschriften.", + "date.short": "{ts, date, short}", + "currency.default": "EUR", + "meter.readings": "{count, plural, one {# Messung} other {# Messungen}}" +} diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json new file mode 100644 index 0000000..73a23ce --- /dev/null +++ b/src/i18n/locales/en-US.json @@ -0,0 +1,12 @@ +{ + "app.title": "Utility Protocol — Spatial DePIN Dashboard", + "settings.language": "Language", + "tariff.kwh": "{value, plural, one {# kWh} other {# kWh}}", + "meter.status.online": "Online", + "meter.status.offline": "Offline", + "error.network": "Network error. Please retry.", + "compliance.notice": "This device complies with local regulations.", + "date.short": "{ts, date, short}", + "currency.default": "USD", + "meter.readings": "{count, plural, one {# reading} other {# readings}}" +} diff --git a/src/i18n/locales/es-MX.json b/src/i18n/locales/es-MX.json new file mode 100644 index 0000000..24167eb --- /dev/null +++ b/src/i18n/locales/es-MX.json @@ -0,0 +1,12 @@ +{ + "app.title": "Panel Utility Protocol — DePIN Espacial", + "settings.language": "Idioma", + "tariff.kwh": "{value, plural, one {# kWh} other {# kWh}}", + "meter.status.online": "En línea", + "meter.status.offline": "Sin conexión", + "error.network": "Error de red. Intenta de nuevo.", + "compliance.notice": "Este dispositivo cumple con las regulaciones locales.", + "date.short": "{ts, date, short}", + "currency.default": "MXN", + "meter.readings": "{count, plural, one {# lectura} other {# lecturas}}" +} diff --git a/src/i18n/locales/fr-FR.json b/src/i18n/locales/fr-FR.json new file mode 100644 index 0000000..ec22f4a --- /dev/null +++ b/src/i18n/locales/fr-FR.json @@ -0,0 +1,12 @@ +{ + "app.title": "Tableau de bord Utility Protocol — DePIN Spatial", + "settings.language": "Langue", + "tariff.kwh": "{value, plural, one {# kWh} other {# kWh}}", + "meter.status.online": "En ligne", + "meter.status.offline": "Hors ligne", + "error.network": "Erreur réseau. Veuillez réessayer.", + "compliance.notice": "Cet appareil est conforme aux réglementations locales.", + "date.short": "{ts, date, short}", + "currency.default": "EUR", + "meter.readings": "{count, plural, one {# lecture} other {# lectures}}" +} diff --git a/src/i18n/locales/ja-JP.json b/src/i18n/locales/ja-JP.json new file mode 100644 index 0000000..4f25f6a --- /dev/null +++ b/src/i18n/locales/ja-JP.json @@ -0,0 +1,12 @@ +{ + "app.title": "ユーティリティプロトコル — 空間DePINダッシュボード", + "settings.language": "言語", + "tariff.kwh": "{value, plural, one {# kWh} other {# kWh}}", + "meter.status.online": "オンライン", + "meter.status.offline": "オフライン", + "error.network": "ネットワークエラー。再試行してください。", + "compliance.notice": "このデバイスは地域の規制に準拠しています。", + "date.short": "{ts, date, short}", + "currency.default": "JPY", + "meter.readings": "{count, plural, one {# 件の読み取り} other {# 件の読み取り}}" +} diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json new file mode 100644 index 0000000..5c2f273 --- /dev/null +++ b/src/i18n/locales/pt-BR.json @@ -0,0 +1,12 @@ +{ + "app.title": "Painel Utility Protocol — DePIN Espacial", + "settings.language": "Idioma", + "tariff.kwh": "{value, plural, one {# kWh} other {# kWh}}", + "meter.status.online": "Online", + "meter.status.offline": "Offline", + "error.network": "Erro de rede. Tente novamente.", + "compliance.notice": "Este dispositivo está em conformidade com as regulamentações locais.", + "date.short": "{ts, date, short}", + "currency.default": "BRL", + "meter.readings": "{count, plural, one {# leitura} other {# leituras}}" +} diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json new file mode 100644 index 0000000..12e3a9f --- /dev/null +++ b/src/i18n/locales/zh-CN.json @@ -0,0 +1,12 @@ +{ + "app.title": "公用事业协议 — 空间 DePIN 仪表盘", + "settings.language": "语言", + "tariff.kwh": "{value, plural, one {# kWh} other {# kWh}}", + "meter.status.online": "在线", + "meter.status.offline": "离线", + "error.network": "网络错误。请重试。", + "compliance.notice": "此设备符合当地法规。", + "date.short": "{ts, date, short}", + "currency.default": "CNY", + "meter.readings": "{count, plural, one {# 次读数} other {# 次读数}}" +} diff --git a/src/i18n/useTranslation.ts b/src/i18n/useTranslation.ts new file mode 100644 index 0000000..759e0cb --- /dev/null +++ b/src/i18n/useTranslation.ts @@ -0,0 +1,29 @@ +"use client"; + +import { useI18nContext } from "./I18nProvider"; + +export function useTranslation() { + const { t, locale, setLocale } = useI18nContext(); + + const formatNumber = (value: number, opts?: Intl.NumberFormatOptions) => { + return new Intl.NumberFormat(locale, opts).format(value); + }; + + const formatCurrency = (value: number, currency: string, opts?: Intl.NumberFormatOptions) => { + return new Intl.NumberFormat(locale, { style: "currency", currency, ...opts }).format(value); + }; + + const formatDate = (date: Date | string | number, opts?: Intl.DateTimeFormatOptions) => { + const d = typeof date === "string" || typeof date === "number" ? new Date(date) : date; + return new Intl.DateTimeFormat(locale, opts).format(d); + }; + + return { + t, + locale, + setLocale, + formatNumber, + formatCurrency, + formatDate, + }; +} diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts new file mode 100644 index 0000000..5c1052c --- /dev/null +++ b/src/utils/formatting.ts @@ -0,0 +1,14 @@ +import { Locale } from "@/i18n/config"; + +export function formatNumber(value: number, locale: Locale, opts?: Intl.NumberFormatOptions) { + return new Intl.NumberFormat(locale, opts).format(value); +} + +export function formatCurrency(value: number, locale: Locale, currency: string) { + return new Intl.NumberFormat(locale, { style: "currency", currency }).format(value); +} + +export function formatDate(date: Date | string | number, locale: Locale, opts?: Intl.DateTimeFormatOptions) { + const d = typeof date === "string" || typeof date === "number" ? new Date(date) : date; + return new Intl.DateTimeFormat(locale, opts).format(d); +} diff --git a/tests/e2e/locale-switch.spec.ts b/tests/e2e/locale-switch.spec.ts new file mode 100644 index 0000000..0023fd9 --- /dev/null +++ b/tests/e2e/locale-switch.spec.ts @@ -0,0 +1,45 @@ +import { test, expect } from "@playwright/test"; + +test.describe("Locale switching", () => { + test("switches to es-MX and sets correct lang attribute", async ({ page }) => { + await page.goto("/"); + + // set locale in localStorage and reload + await page.evaluate(() => localStorage.setItem("locale", "es-MX")); + await page.reload(); + + // wait for I18nProvider to apply locale + await page.waitForFunction( + () => document.documentElement.lang === "es-MX", + { timeout: 5000 } + ); + + // verify lang attribute is set correctly + const lang = await page.getAttribute("html", "lang"); + expect(lang).toBe("es-MX"); + + // verify LTR direction for es-MX + const dir = await page.getAttribute("html", "dir"); + expect(dir).toBe("ltr"); + + await page.screenshot({ path: "screenshots/locale-es-MX.png", fullPage: true }); + }); + + test("sets RTL direction for ar-SA", async ({ page }) => { + await page.goto("/"); + + await page.evaluate(() => localStorage.setItem("locale", "ar-SA")); + await page.reload(); + + await page.waitForFunction( + () => document.documentElement.dir === "rtl", + { timeout: 5000 } + ); + + const dir = await page.getAttribute("html", "dir"); + expect(dir).toBe("rtl"); + + const lang = await page.getAttribute("html", "lang"); + expect(lang).toBe("ar-SA"); + }); +});