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
29 changes: 29 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions scripts/extract-i18n-keys.ts
Original file line number Diff line number Diff line change
@@ -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<string>();
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<string, string> = {};
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");
5 changes: 4 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -45,7 +46,9 @@ export default function RootLayout({
<body className="min-h-screen bg-background text-foreground">
<ServiceWorkerProvider>
<ThemeProvider>
<WalletProvider>{children}</WalletProvider>
<I18nProvider>
<WalletProvider>{children}</WalletProvider>
</I18nProvider>
</ThemeProvider>
</ServiceWorkerProvider>
</body>
Expand Down
38 changes: 38 additions & 0 deletions src/components/settings/LanguageSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
"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 (
<label className="flex items-center gap-2">
<span>{FLAGS[locale] ?? "🌐"}</span>
<select
aria-label={t("settings.language")}
value={locale}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setLocale(e.target.value as Locale)}
className="rounded border px-2 py-1"
>
{SUPPORTED_LOCALES.map((l) => (
<option key={l} value={l}>
{FLAGS[l]} {l}
</option>
))}
</select>
</label>
);
}
33 changes: 33 additions & 0 deletions src/hooks/useLocale.ts
Original file line number Diff line number Diff line change
@@ -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<Locale>(() => {
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;
}
93 changes: 93 additions & 0 deletions src/i18n/I18nProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>;

type I18nContextValue = {
locale: Locale;
t: (key: string, values?: Record<string, unknown>) => string;
setLocale: (l: Locale) => void;
translations: Translations | null;
};

const I18nContext = createContext<I18nContextValue | undefined>(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<Locale>(() => {
if (typeof window === "undefined") return DEFAULT_LOCALE;
return (window.localStorage.getItem("locale") as Locale) || (DEFAULT_LOCALE as Locale);
});
const [translations, setTranslations] = useState<Translations | null>(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<string, unknown> = {}) => {
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 <I18nContext.Provider value={ctx}>{children}</I18nContext.Provider>;
}
18 changes: 18 additions & 0 deletions src/i18n/config.ts
Original file line number Diff line number Diff line change
@@ -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));
}
19 changes: 19 additions & 0 deletions src/i18n/icu.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import IntlMessageFormat from "intl-messageformat";

export function formatMessage(
message: string,
values: Record<string, unknown> = {},
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;
}
}
12 changes: 12 additions & 0 deletions src/i18n/locales/ar-SA.json
Original file line number Diff line number Diff line change
@@ -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 {# قراءة}}"
}
12 changes: 12 additions & 0 deletions src/i18n/locales/de-DE.json
Original file line number Diff line number Diff line change
@@ -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}}"
}
12 changes: 12 additions & 0 deletions src/i18n/locales/en-US.json
Original file line number Diff line number Diff line change
@@ -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}}"
}
12 changes: 12 additions & 0 deletions src/i18n/locales/es-MX.json
Original file line number Diff line number Diff line change
@@ -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}}"
}
12 changes: 12 additions & 0 deletions src/i18n/locales/fr-FR.json
Original file line number Diff line number Diff line change
@@ -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}}"
}
12 changes: 12 additions & 0 deletions src/i18n/locales/ja-JP.json
Original file line number Diff line number Diff line change
@@ -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 {# 件の読み取り}}"
}
Loading
Loading