From c8f536e860f5a11f337a5a264ba3e2483c6cebbf Mon Sep 17 00:00:00 2001 From: fauztech05 Date: Fri, 23 Oct 2026 15:52:22 +0100 Subject: [PATCH] feat: [frontend] Internationalization scaffolding (#170) --- frontend/package-lock.json | 144 +++++++++++++++---------- frontend/package.json | 2 + frontend/src/App.tsx | 6 +- frontend/src/components/BountyCard.tsx | 8 +- frontend/src/components/Layout.tsx | 12 ++- frontend/src/main.tsx | 1 + frontend/src/pages/About.tsx | 63 +++++------ frontend/src/pages/BountyDetail.tsx | 38 ++++--- frontend/src/pages/BountyList.tsx | 38 ++++--- 9 files changed, 173 insertions(+), 139 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4c79986..657f267 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,8 +13,10 @@ "@fontsource-variable/jetbrains-mono": "^5.3.0", "@fontsource-variable/space-grotesk": "^5.3.0", "@stellar/stellar-sdk": "^17.2.1", + "i18next": "^25.0.0", "react": "^19.2.8", "react-dom": "^19.2.8", + "react-i18next": "^16.0.0", "react-router-dom": "^7.18.4" }, "devDependencies": { @@ -120,7 +122,6 @@ "version": "7.29.10", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -509,9 +510,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -529,9 +527,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -549,9 +544,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -569,9 +561,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -589,9 +578,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -609,9 +595,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -629,9 +612,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -649,9 +629,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -839,9 +816,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -859,9 +833,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -879,9 +850,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -899,9 +867,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -919,9 +884,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -939,9 +901,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1828,6 +1787,15 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-parse-stringify": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.1.0.tgz", + "integrity": "sha512-E0oAXcELOtsXe+BmpJ2EZyedbldPpriV5vICzEuo6xjC/D1lDukOI7KrpfQGF2Qc4wWEy0nk3bFORS2K5ZAhFQ==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -1841,6 +1809,37 @@ "node": ">= 6" } }, + "node_modules/i18next": { + "version": "25.10.10", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.10.10.tgz", + "integrity": "sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -2062,9 +2061,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2086,9 +2082,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2110,9 +2103,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2134,9 +2124,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2476,6 +2463,33 @@ "react": "^19.3.0" } }, + "node_modules/react-i18next": { + "version": "16.6.6", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.6.6.tgz", + "integrity": "sha512-ZgL2HUoW34UKUkOV7uSQFE1CDnRPD+tCR3ywSuWH7u2iapnz86U8Bi3Vrs620qNDzCf1F47NxglCEkchCTDOHw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 25.10.9", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -2741,7 +2755,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -2780,6 +2794,15 @@ "dev": true, "license": "MIT" }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "8.3.4", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", @@ -2941,6 +2964,15 @@ } } }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7d5121a..c267934 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,8 +17,10 @@ "@fontsource-variable/jetbrains-mono": "^5.3.0", "@fontsource-variable/space-grotesk": "^5.3.0", "@stellar/stellar-sdk": "^17.2.1", + "i18next": "^25.0.0", "react": "^19.2.8", "react-dom": "^19.2.8", + "react-i18next": "^16.0.0", "react-router-dom": "^7.18.4" }, "devDependencies": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d5500cb..464352e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { HashRouter, Route, Routes } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { ErrorBoundary } from "./components/ErrorBoundary"; import { Layout } from "./components/Layout"; import { Empty } from "./components/ui"; @@ -8,6 +9,7 @@ import { BountyList } from "./pages/BountyList"; /** Hash routing so the build works on any static host with no rewrite rules. */ export default function App() { + const { t } = useTranslation(); return ( @@ -16,10 +18,10 @@ export default function App() { } /> } /> } /> - Page not found.} /> + {t("app.notFound")}} /> ); -} \ No newline at end of file +} diff --git a/frontend/src/components/BountyCard.tsx b/frontend/src/components/BountyCard.tsx index f5af418..caa52ae 100644 --- a/frontend/src/components/BountyCard.tsx +++ b/frontend/src/components/BountyCard.tsx @@ -1,9 +1,11 @@ +import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { formatAmount, relativeTime, splitIssueRef, tokenSymbol } from "../lib/format"; import type { Bounty } from "../lib/types"; import { Identicon, Skeleton, StatusBadge } from "./ui"; export function BountyCard({ bounty }: { bounty: Bounty }) { + const { t } = useTranslation(); const { ecosystem, text } = splitIssueRef(bounty.issueRef); const open = bounty.status === "Open"; return ( @@ -24,12 +26,10 @@ export function BountyCard({ bounty }: { bounty: Bounty }) {
- - {bounty.claimCount} {bounty.claimCount === 1 ? "claim" : "claims"} - + {t("bounty.claim", { count: bounty.claimCount })} - {open ? `Closes ${relativeTime(bounty.deadline)}` : "Closed"} + {open ? t("bounty.closesIn", { when: relativeTime(bounty.deadline) }) : t("bounty.closed")}
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 9c57ffa..c1a6a2f 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,10 +1,13 @@ import { useEffect } from "react"; +import { useTranslation, Trans } from "react-i18next"; import { Link, Outlet, useLocation } from "react-router-dom"; import { CONTRACT_ID, NETWORK, explorer } from "../config"; import { shortAddress } from "../lib/format"; +import { LanguageSelector } from "./LanguageSelector"; export function Layout() { const { pathname } = useLocation(); + const { t } = useTranslation(); useEffect(() => { window.scrollTo(0, 0); }, [pathname]); @@ -22,8 +25,9 @@ export function Layout() { @@ -42,9 +46,9 @@ export function Layout() { ); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index d25080c..1a44a0a 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,6 +4,7 @@ import "@fontsource-variable/space-grotesk"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import App from "./App"; +import "./i18n"; import "./index.css"; createRoot(document.getElementById("root")!).render( diff --git a/frontend/src/pages/About.tsx b/frontend/src/pages/About.tsx index c74cbad..abeaf3e 100644 --- a/frontend/src/pages/About.tsx +++ b/frontend/src/pages/About.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from 'react-i18next'; import { useAsync } from '../lib/useAsync'; import { getAdmin, @@ -7,6 +8,7 @@ import { } from '../lib/contract'; export default function About() { + const { t } = useTranslation(); const admin = useAsync(() => getAdmin(), []); const pendingAdmin = useAsync(() => getPendingAdmin(), []); @@ -15,70 +17,59 @@ export default function About() { return (
-

About

-

- DepRadar is a transparent claims and refunds contract. Below you can see - who reviews claims and the rules that govern claims and refunds. -

+

{t('about.title')}

+

{t('about.intro')}

-

Contract

+

{t('about.contract')}

-
Contract ID
-
{contractId ?? 'Unknown'}
-
Network
-
{network ?? 'Unknown'}
+
{t('about.contractId')}
+
{contractId ?? t('about.unknown')}
+
{t('about.network')}
+
{network ?? t('about.unknown')}
-

Administration

- {admin.loading &&

Loading admin…

} +

{t('about.administration')}

+ {admin.loading &&

{t('about.loadingAdmin')}

} {admin.error && (

- Failed to load admin: {admin.error.message} + {t('about.failedAdmin', { message: admin.error.message })}

)} {!admin.loading && !admin.error && (
-
Current admin
-
- {admin.data ?? 'Not set'} -
-
Pending admin
+
{t('about.currentAdmin')}
+
{admin.data ?? t('about.notSet')}
+
{t('about.pendingAdmin')}
{pendingAdmin.loading - ? 'Loading…' + ? t('about.loading') : pendingAdmin.error - ? 'Unavailable' - : (pendingAdmin.data ?? 'None')} + ? t('about.unavailable') + : (pendingAdmin.data ?? t('about.none'))}
)}
-

Rules

+

{t('about.rules')}

-

Claims

+

{t('about.claims')}

    -
  • Claims are reviewed by the admin.
  • -
  • Accepted claims are paid out.
  • -
  • Rejected claims are not paid.
  • +
  • {t('about.claimsRule1')}
  • +
  • {t('about.claimsRule2')}
  • +
  • {t('about.claimsRule3')}
-

Refunds

+

{t('about.refunds')}

    -
  • - The creator can cancel the contract after the deadline if there are - no pending claims. -
  • -
  • - The creator can also cancel after the 7-day review window has - passed. -
  • +
  • {t('about.refundsRule1')}
  • +
  • {t('about.refundsRule2')}
); -} \ No newline at end of file +} diff --git a/frontend/src/pages/BountyDetail.tsx b/frontend/src/pages/BountyDetail.tsx index 89caad5..4ecc0f0 100644 --- a/frontend/src/pages/BountyDetail.tsx +++ b/frontend/src/pages/BountyDetail.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { Link, useParams } from "react-router-dom"; import { ScannerDetection } from "../components/ScannerDetection"; import { AddressLink, CopyButton, Empty, ErrorState, Identicon, Skeleton, StatusBadge } from "../components/ui"; @@ -10,6 +11,7 @@ import type { Bounty, Claim } from "../lib/types"; import { useAsync } from "../lib/useAsync"; export function BountyDetail() { + const { t } = useTranslation(); const idParam = useParams().id; const id = Number(idParam); const state = useAsync(async () => { @@ -21,18 +23,19 @@ export function BountyDetail() { return (
- ← Bounty board + {t("app.backToBoard")} {state.status === "loading" && } {state.status === "error" && } - {state.status === "success" && state.data === null && Bounty #{idParam} doesn't exist.} + {state.status === "success" && state.data === null && {t("app.bountyMissing", { id: idParam })}} {state.status === "success" && state.data && }
); } function Detail({ bounty, claims }: { bounty: Bounty; claims: Claim[] }) { + const { t } = useTranslation(); const { ecosystem, text } = splitIssueRef(bounty.issueRef); const timeline = bountyTimeline(bounty, claims); const packageName = bountyPackage(bounty); @@ -42,17 +45,18 @@ function Detail({ bounty, claims }: { bounty: Bounty; claims: Claim[] }) {
- Bounty #{bounty.id} + {t("bounty.id", { id: bounty.id })} {ecosystem && {ecosystem}}

{text}

- Funded by on {formatDate(bounty.createdAt)} + {t("bounty.fundedBy", { address: "", date: "" })} + {formatDate(bounty.createdAt)}

- Reward + {t("bounty.reward")} {formatAmount(bounty.amount)} {tokenSymbol(bounty.token)}
@@ -60,7 +64,7 @@ function Detail({ bounty, claims }: { bounty: Bounty; claims: Claim[] }) {
-

Timeline

+

{t("bounty.timeline")}

    {timeline.map((e, i) => ( @@ -69,37 +73,35 @@ function Detail({ bounty, claims }: { bounty: Bounty; claims: Claim[] }) {