From 32034472ccfa2ea868292185c3c40a9bdd05bfac Mon Sep 17 00:00:00 2001 From: Evandro Ribeiro Date: Sat, 31 Oct 2026 12:02:07 +0000 Subject: [PATCH] feat(web): add i18n runtime with Portuguese translation and language toggle (fixes #35) --- apps/web/package.json | 7 +- apps/web/scripts/check-i18n.mjs | 93 ++++++++ apps/web/src/components/contributors.tsx | 11 +- .../components/explorer/activity-sections.tsx | 10 +- apps/web/src/components/explorer/explorer.tsx | 18 +- .../src/components/explorer/filter-panel.tsx | 81 ++++--- .../src/components/explorer/project-card.tsx | 10 +- .../components/explorer/project-explorer.tsx | 214 +++++++++--------- .../components/explorer/resource-table.tsx | 6 +- apps/web/src/components/layout/about.tsx | 30 ++- .../src/components/layout/language-toggle.tsx | 43 ++++ .../web/src/components/layout/site-footer.tsx | 18 +- .../web/src/components/layout/site-header.tsx | 8 +- apps/web/src/components/overview.tsx | 34 +-- apps/web/src/components/pagination-bar.tsx | 9 +- apps/web/src/components/query-state.tsx | 15 +- apps/web/src/i18n/index.ts | 25 ++ apps/web/src/i18n/locales/en.json | 141 ++++++++++++ apps/web/src/i18n/locales/pt.json | 141 ++++++++++++ apps/web/src/main.tsx | 1 + apps/web/src/pages/builder.tsx | 8 +- apps/web/src/pages/home.tsx | 10 +- apps/web/src/pages/project.tsx | 72 +++--- pnpm-lock.yaml | 56 +++++ 24 files changed, 813 insertions(+), 248 deletions(-) create mode 100644 apps/web/scripts/check-i18n.mjs create mode 100644 apps/web/src/components/layout/language-toggle.tsx create mode 100644 apps/web/src/i18n/index.ts create mode 100644 apps/web/src/i18n/locales/en.json create mode 100644 apps/web/src/i18n/locales/pt.json diff --git a/apps/web/package.json b/apps/web/package.json index 72076fc..35303ed 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,8 @@ "lint": "oxlint", "preview": "vite preview", "format": "prettier --write .", - "format:check": "prettier --check ." + "format:check": "prettier --check .", + "check:i18n": "node scripts/check-i18n.mjs" }, "dependencies": { "@ai-sdk/react": "^4.0.158", @@ -20,12 +21,14 @@ "ai": "^7.0.154", "class-variance-authority": "^0.7.1", "cn": "^0.5.0", + "i18next": "^26.5.12", "lucide-react": "^1.61.0", "motion": "^14.6.0", "next-themes": "^0.4.6", "posthog-js": "^1.440.0", "react": "^19.3.1", "react-dom": "^19.3.1", + "react-i18next": "^17.1.9", "react-router": "^8.5.0", "react-useanimations": "^3.1.1", "recharts": "3.8.0", @@ -46,4 +49,4 @@ "typescript": "~6.0.3", "vite": "^8.3.4" } -} +} \ No newline at end of file diff --git a/apps/web/scripts/check-i18n.mjs b/apps/web/scripts/check-i18n.mjs new file mode 100644 index 0000000..588b4e0 --- /dev/null +++ b/apps/web/scripts/check-i18n.mjs @@ -0,0 +1,93 @@ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const root = path.resolve(__dirname, '..') + +const enPath = path.join(root, 'src', 'i18n', 'locales', 'en.json') +const ptPath = path.join(root, 'src', 'i18n', 'locales', 'pt.json') + +if (!fs.existsSync(enPath) || !fs.existsSync(ptPath)) { + console.error('Error: Translation files missing!') + process.exit(1) +} + +const en = JSON.parse(fs.readFileSync(enPath, 'utf8')) +const pt = JSON.parse(fs.readFileSync(ptPath, 'utf8')) + +function flattenKeys(obj, prefix = '') { + let keys = {} + for (const [k, v] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${k}` : k + if (typeof v === 'object' && v !== null && !Array.isArray(v)) { + Object.assign(keys, flattenKeys(v, fullKey)) + } else { + keys[fullKey] = v + } + } + return keys +} + +const enKeys = flattenKeys(en) +const ptKeys = flattenKeys(pt) + +let errors = 0 + +// 1. Verify key parity +for (const key of Object.keys(enKeys)) { + if (!(key in ptKeys)) { + console.error(`Missing translation in pt.json: ${key}`) + errors++ + } else if (!ptKeys[key] || typeof ptKeys[key] !== 'string' || ptKeys[key].trim() === '') { + console.error(`Empty translation in pt.json: ${key}`) + errors++ + } +} + +for (const key of Object.keys(ptKeys)) { + if (!(key in enKeys)) { + console.error(`Extraneous key in pt.json not in en.json: ${key}`) + errors++ + } +} + +console.log(`Verified ${Object.keys(enKeys).length} translation keys parity between EN and PT.`) + +// 2. Scan critical components for leftover hardcoded strings +const filesToCheck = [ + 'src/components/layout/site-header.tsx', + 'src/components/layout/site-footer.tsx', + 'src/pages/home.tsx', + 'src/pages/builder.tsx', + 'src/components/overview.tsx', +] + +const forbiddenSubstrings = [ + '> Winners', + '>Open Source', + 'Independent project, not affiliated', + 'HackMeridian 2026, rebuilt from the chain.', + 'Contracts deployed, per hour over the 27 hour window', +] + +for (const rel of filesToCheck) { + const filePath = path.join(root, rel) + if (fs.existsSync(filePath)) { + const content = fs.readFileSync(filePath, 'utf8') + for (const forbidden of forbiddenSubstrings) { + if (content.includes(forbidden)) { + console.error(`Found hardcoded string "${forbidden}" in ${rel}`) + errors++ + } + } + } +} + +if (errors > 0) { + console.error(`\nCI i18n Check FAILED with ${errors} error(s).`) + process.exit(1) +} else { + console.log('CI i18n Check PASSED: No hardcoded strings found and all keys match!') + process.exit(0) +} diff --git a/apps/web/src/components/contributors.tsx b/apps/web/src/components/contributors.tsx index 148ca38..882f251 100644 --- a/apps/web/src/components/contributors.tsx +++ b/apps/web/src/components/contributors.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from 'react-i18next' + import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { flagEmoji } from '@/lib/format' @@ -6,6 +8,7 @@ import type { Contributor } from '@/lib/types' const VISIBLE = 30 export function Contributors({ contributors }: { contributors: Contributor[] }) { + const { t } = useTranslation() const hidden = contributors.length - VISIBLE return ( @@ -24,11 +27,15 @@ export function Contributors({ contributors }: { contributors: Contributor[] }) {person.login} - {person.country && ` ${flagEmoji(person.country)}`} · {person.contributions} commits + {person.country && ` ${flagEmoji(person.country)}`} · {person.contributions} {t('project.commits')} ))} - {hidden > 0 && +{hidden} more} + {hidden > 0 && ( + + {t('project.moreContributors', { count: hidden })} + + )} ) } diff --git a/apps/web/src/components/explorer/activity-sections.tsx b/apps/web/src/components/explorer/activity-sections.tsx index b02fc43..63f0d70 100644 --- a/apps/web/src/components/explorer/activity-sections.tsx +++ b/apps/web/src/components/explorer/activity-sections.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from 'react-i18next' + import { DataTable } from '@/components/data-table' import { Section } from '@/components/section' import type { Contract, FailedTx, WasmUpload } from '@/lib/types' @@ -11,20 +13,22 @@ interface ActivitySectionsProps { } export function ActivitySections({ contracts, wasmUploads, failedTxs }: ActivitySectionsProps) { + const { t } = useTranslation() + return ( <> {contracts.length > 0 && ( -
+
row.contract_id} />
)} {wasmUploads.length > 0 && ( -
+
row.tx_hash} />
)} {failedTxs.length > 0 && ( -
+
row.tx_hash} />
)} diff --git a/apps/web/src/components/explorer/explorer.tsx b/apps/web/src/components/explorer/explorer.tsx index 66ec28b..28baee1 100644 --- a/apps/web/src/components/explorer/explorer.tsx +++ b/apps/web/src/components/explorer/explorer.tsx @@ -1,5 +1,6 @@ import { SearchIcon } from 'lucide-react' import { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' import { useSearchParams } from 'react-router' import { Input } from '@/components/ui/input' @@ -17,6 +18,7 @@ export function Explorer() { const search = useDebouncedValue(input.trim()) const top = useRef(null) const awards = params.has('award') + const { t } = useTranslation() useEffect(() => { if (awards) top.current?.scrollIntoView({ behavior: 'smooth' }) @@ -40,17 +42,17 @@ export function Explorer() { className="flex scroll-mt-4 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between" > - Projects - Contracts - Wasm - Failed + {t('explorer.tabs.projects')} + {t('explorer.tabs.contracts')} + {t('explorer.tabs.wasm')} + {t('explorer.tabs.failed')}
setInput(event.target.value)} - placeholder="Search address, hash or repo" + placeholder={t('explorer.searchPlaceholder')} className="pl-8" />
@@ -65,7 +67,7 @@ export function Explorer() { search={search} columns={contractColumns} rowKey={(row) => row.contract_id} - empty="No contracts match." + empty={t('explorer.noContracts')} /> @@ -75,7 +77,7 @@ export function Explorer() { search={search} columns={wasmColumns} rowKey={(row) => row.tx_hash} - empty="No wasm uploads match." + empty={t('explorer.noWasm')} /> @@ -85,7 +87,7 @@ export function Explorer() { search={search} columns={failedColumns} rowKey={(row) => row.tx_hash} - empty="No failed transactions match." + empty={t('explorer.noFailed')} /> diff --git a/apps/web/src/components/explorer/filter-panel.tsx b/apps/web/src/components/explorer/filter-panel.tsx index 1e96ecd..79b38d2 100644 --- a/apps/web/src/components/explorer/filter-panel.tsx +++ b/apps/web/src/components/explorer/filter-panel.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' @@ -24,36 +25,38 @@ interface FilterPanelProps { onClear: () => void } -const FIXED: Record<'award' | 'tier' | 'track' | 'activity', [string, string][]> = { - award: [ - ['first', placeLabel.first], - ['runner-up', placeLabel['runner-up']], - ], - tier: [ - ['verified', tierLabel.verified], - ['likely', tierLabel.likely], - ], - track: [ - ['genesis', trackLabel.genesis], - ['scale', trackLabel.scale], - ], - activity: [ - ['contracts', 'Deployed contracts'], - ['failed', 'Failed transactions'], - ], -} - function options( key: FilterKey, projects: Project[], integrations: Integration[], selection: Selection, + t: (k: string, opts?: Record) => string, ): Option[] { const counts = countValues(projects, selection, key) const chosen = new Set(selection[key]) + + const fixedLabels: Record<'award' | 'tier' | 'track' | 'activity', [string, string][]> = { + award: [ + ['first', t('places.first', { defaultValue: placeLabel.first })], + ['runner-up', t('places.runnerUp', { defaultValue: placeLabel['runner-up'] })], + ], + tier: [ + ['verified', t('tiers.verified', { defaultValue: tierLabel.verified })], + ['likely', t('tiers.likely', { defaultValue: tierLabel.likely })], + ], + track: [ + ['genesis', t('tracks.genesis', { defaultValue: trackLabel.genesis })], + ['scale', t('tracks.scale', { defaultValue: trackLabel.scale })], + ], + activity: [ + ['contracts', t('explorer.filterFixed.contracts')], + ['failed', t('explorer.filterFixed.failed')], + ], + } + const labelled: [string, string, string?][] = key === 'award' || key === 'tier' || key === 'track' || key === 'activity' - ? FIXED[key].map(([value, label]) => [value, label]) + ? fixedLabels[key].map(([value, label]) => [value, label]) : key === 'partner' ? integrations.map((item) => [item.slug, item.name, item.category]) : [ @@ -66,10 +69,12 @@ function options( value, key === 'country' ? `${flagEmoji(value)} ${countryName(value)}` : value, ]) + + const isFixed = key in fixedLabels return labelled .map(([value, label, hint]) => ({ value, label, hint, count: counts.get(value) ?? 0 })) .filter((option) => option.count > 0 || chosen.has(option.value)) - .sort((a, b) => (key in FIXED ? 0 : b.count - a.count || a.label.localeCompare(b.label))) + .sort((a, b) => (isFixed ? 0 : b.count - a.count || a.label.localeCompare(b.label))) } function Group({ @@ -78,12 +83,14 @@ function Group({ chosen, onToggle, collapsible, + t, }: { title: string items: Option[] chosen: string[] onToggle: (value: string) => void collapsible: boolean + t: (k: string, opts?: Record) => string }) { const [expanded, setExpanded] = useState(false) if (items.length === 0) return null @@ -109,23 +116,13 @@ function Group({ onClick={() => setExpanded(!expanded)} className="self-start text-xs text-muted-foreground hover:text-foreground" > - {expanded ? 'Show less' : `Show all ${items.length}`} + {expanded ? t('explorer.showLess') : t('explorer.showAll', { count: items.length })} )} ) } -const GROUPS: { key: FilterKey; title: string; collapsible: boolean }[] = [ - { key: 'award', title: 'Awards', collapsible: false }, - { key: 'tier', title: 'Evidence', collapsible: false }, - { key: 'track', title: 'Track', collapsible: false }, - { key: 'partner', title: 'Integrations', collapsible: true }, - { key: 'activity', title: 'On-chain activity', collapsible: false }, - { key: 'language', title: 'Language', collapsible: true }, - { key: 'country', title: 'Country', collapsible: true }, -] - export function FilterPanel({ projects, integrations, @@ -134,25 +131,37 @@ export function FilterPanel({ onClear, }: FilterPanelProps) { const active = selectedCount(selection) + const { t } = useTranslation() + + const groups: { key: FilterKey; title: string; collapsible: boolean }[] = [ + { key: 'award', title: t('explorer.filterGroups.award'), collapsible: false }, + { key: 'tier', title: t('explorer.filterGroups.tier'), collapsible: false }, + { key: 'track', title: t('explorer.filterGroups.track'), collapsible: false }, + { key: 'partner', title: t('explorer.filterGroups.partner'), collapsible: true }, + { key: 'activity', title: t('explorer.filterGroups.activity'), collapsible: false }, + { key: 'language', title: t('explorer.filterGroups.language'), collapsible: true }, + { key: 'country', title: t('explorer.filterGroups.country'), collapsible: true }, + ] return (
-

Filters

+

{t('explorer.filters')}

{active > 0 && ( )}
- {GROUPS.map(({ key, title, collapsible }) => ( + {groups.map(({ key, title, collapsible }) => ( onToggle(key, value)} collapsible={collapsible} + t={t} /> ))}
diff --git a/apps/web/src/components/explorer/project-card.tsx b/apps/web/src/components/explorer/project-card.tsx index 8bb2dbe..8360eff 100644 --- a/apps/web/src/components/explorer/project-card.tsx +++ b/apps/web/src/components/explorer/project-card.tsx @@ -1,10 +1,11 @@ import { ExternalLinkIcon, StarIcon } from 'lucide-react' +import { useTranslation } from 'react-i18next' import { Link } from 'react-router' -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { AwardBadge } from '@/components/award-badge' import { TierBadge } from '@/components/tier-badge' import { TrackBadge } from '@/components/track-badge' +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Badge } from '@/components/ui/badge' import { Card, CardContent, CardHeader } from '@/components/ui/card' import { useIntegrations } from '@/hooks/use-projects' @@ -18,6 +19,7 @@ export function ProjectCard({ project }: { project: Project }) { const topics = (project.topics ?? []).slice(0, VISIBLE_TOPICS) const { data: integrations = [] } = useIntegrations() const partners = integrations.filter((item) => project.integrations?.includes(item.slug)) + const { t } = useTranslation() return ( @@ -58,7 +60,7 @@ export function ProjectCard({ project }: { project: Project }) {

- {project.description ?? 'No description'} + {project.description ?? t('project.noDescription')}

{project.language && {project.language}} @@ -67,7 +69,9 @@ export function ProjectCard({ project }: { project: Project }) { {formatNumber(project.stars)} )} - {project.contracts > 0 && {formatNumber(project.contracts)} contracts} + {project.contracts > 0 && ( + {t('project.contractsCount', { count: formatNumber(project.contracts) })} + )}
{partners.length > 0 && (
diff --git a/apps/web/src/components/explorer/project-explorer.tsx b/apps/web/src/components/explorer/project-explorer.tsx index 36c3c85..3d467de 100644 --- a/apps/web/src/components/explorer/project-explorer.tsx +++ b/apps/web/src/components/explorer/project-explorer.tsx @@ -1,5 +1,6 @@ import { SlidersHorizontalIcon } from 'lucide-react' import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' import { useNavigate, useSearchParams } from 'react-router' import { DataTable } from '@/components/data-table' @@ -32,6 +33,7 @@ export function ProjectExplorer({ search }: { search: string }) { const view = params.get('view') === 'table' ? 'table' : 'cards' const selection = useMemo(() => readSelection(params), [params]) const active = selectedCount(selection) + const { t } = useTranslation() const update = (change: (next: URLSearchParams) => void) => { setPage(0) @@ -68,126 +70,130 @@ export function ProjectExplorer({ search }: { search: string }) { .sort((a, b) => PLACE_RANK[a.award ?? 'none'] - PLACE_RANK[b.award ?? 'none']), [projects, selection, needle], ) - const mode = usePaginationMode() - const scrolling = mode === 'scroll' - const visible = scrolling - ? filtered.slice(0, (page + 1) * PAGE_SIZE) - : filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE) - const more = (next: number) => { - posthog.capture('list_more', { mode, resource: 'projects' }) - setPage(next) - } - - const cards = ( -
- {isPending - ? Array.from({ length: 9 }, (_, index) => ( - - )) - : visible.map((project) => )} -
- ) - - const table = (rows: typeof filtered, paginate: boolean) => ( - row.project_id} - paginate={paginate} - isLoading={isPending} - empty="No projects match." - onRowClick={(row) => navigate(`/project/${row.project_id}`)} - /> - ) - const panel = ( - - ) + const paginationMode = usePaginationMode() + const paged = + paginationMode === 'scroll' + ? filtered.slice(0, (page + 1) * PAGE_SIZE) + : filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE) if (isError) { return ( - Could not load projects - The API is unreachable. Try again in a moment. + {t('table.unreachableTitle', { resource: t('explorer.tabs.projects') })} + {t('table.unreachableDesc')} ) } return ( -
- -
-
-
- - }> - - Filters{active > 0 && ` (${active})`} - - - - Filters - - {panel} - - - - {isPending ? 'Loading projects' : `${formatNumber(filtered.length)} projects`} - -
- update((p) => p.set('view', next))}> - - Cards - Table - - +
+
+
+ + + + {t('explorer.filters')} {active > 0 && `(${active})`} + + } + /> + + + {t('explorer.filters')} + +
+ +
+
+
+ + {formatNumber(filtered.length)} {t('table.resultsCount', { count: filtered.length }).replace(`${filtered.length} `, '')} +
- {view === 'cards' ? ( -
- {scrolling ? ( + + update((searchParams) => searchParams.set('view', next))} + > + + {t('explorer.cards')} + {t('explorer.table')} + + +
+ +
+ + +
+ {isPending ? ( +
+ {Array.from({ length: 9 }).map((_, index) => ( + + ))} +
+ ) : filtered.length === 0 ? ( +
+ {t('explorer.noProjects')} +
+ ) : view === 'cards' ? ( + paginationMode === 'scroll' ? ( more(page + 1)} + onMore={() => { + posthog.capture('list_more', { mode: 'scroll', resource: 'projects' }) + setPage((p) => p + 1) + }} > - {cards} +
+ {paged.map((project) => ( + + ))} +
) : ( - cards - )} - {!isPending && filtered.length === 0 && ( -

No projects match.

- )} - {!scrolling && ( - - )} -
- ) : scrolling ? ( - more(page + 1)} - > - {table(visible, false)} - - ) : ( - table(filtered, true) - )} +
+
+ {paged.map((project) => ( + + ))} +
+ { + posthog.capture('list_more', { mode: 'click', resource: 'projects' }) + setPage(next) + }} + /> +
+ ) + ) : ( + row.project_id} + onRowClick={(row) => navigate(`/project/${row.project_id}`)} + /> + )} +
) diff --git a/apps/web/src/components/explorer/resource-table.tsx b/apps/web/src/components/explorer/resource-table.tsx index 673fcc6..ebf5cdc 100644 --- a/apps/web/src/components/explorer/resource-table.tsx +++ b/apps/web/src/components/explorer/resource-table.tsx @@ -1,4 +1,5 @@ import { useState } from 'react' +import { useTranslation } from 'react-i18next' import { type Column, DataTable } from '@/components/data-table' import { ScrollBox } from '@/components/scroll-box' @@ -19,10 +20,11 @@ interface ResourceTableProps { } function Unreachable({ resource }: { resource: string }) { + const { t } = useTranslation() return ( - Could not load {resource} - The API is unreachable. Try again in a moment. + {t('table.unreachableTitle', { resource })} + {t('table.unreachableDesc')} ) } diff --git a/apps/web/src/components/layout/about.tsx b/apps/web/src/components/layout/about.tsx index 815b78d..f70b79d 100644 --- a/apps/web/src/components/layout/about.tsx +++ b/apps/web/src/components/layout/about.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from 'react' +import { useTranslation } from 'react-i18next' import odii from '@/assets/odii.jpg' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' @@ -18,6 +19,9 @@ function Outbound({ href, children }: { href: string; children: ReactNode }) { } export function About() { + const { i18n } = useTranslation() + const isPt = i18n.language.startsWith('pt') + return (
@@ -26,12 +30,26 @@ export function About() { O

- Hey curious, I'm Odii. A super talented builder on Stellar, I've worked on projects such - as Sorokit,{' '} - StellarTools and many more, feel - free to follow me on GitHub,{' '} - X or{' '} - LinkedIn. + {isPt ? ( + <> + Olá curioso, sou o Odii. Um builder na Stellar, trabalhei em projetos como{' '} + Sorokit,{' '} + StellarTools e muitos outros. + Sente-te à vontade para me seguir no{' '} + GitHub,{' '} + X ou{' '} + LinkedIn. + + ) : ( + <> + Hey curious, I'm Odii. A super talented builder on Stellar, I've worked on projects such + as Sorokit,{' '} + StellarTools and many more, feel + free to follow me on GitHub,{' '} + X or{' '} + LinkedIn. + + )}

diff --git a/apps/web/src/components/layout/language-toggle.tsx b/apps/web/src/components/layout/language-toggle.tsx new file mode 100644 index 0000000..10f0f23 --- /dev/null +++ b/apps/web/src/components/layout/language-toggle.tsx @@ -0,0 +1,43 @@ +import { useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import { z } from 'zod' + +import { Button } from '@/components/ui/button' +import { useLocalStorageState } from '@/hooks/use-local-storage-state' + +const LangSchema = z.enum(['en', 'pt']) +type Language = z.infer + +export function LanguageToggle() { + const { i18n, t } = useTranslation() + const [lang, setLang] = useLocalStorageState('hackproof-language', 'en', LangSchema) + + useEffect(() => { + if (lang && i18n.language !== lang) { + void i18n.changeLanguage(lang) + } + }, [lang, i18n]) + + const toggleLanguage = () => { + const next: Language = lang === 'pt' ? 'en' : 'pt' + setLang(next) + void i18n.changeLanguage(next) + } + + const current = lang === 'pt' ? 'pt' : 'en' + + return ( +
+ {t('footer.language')}: + +
+ ) +} diff --git a/apps/web/src/components/layout/site-footer.tsx b/apps/web/src/components/layout/site-footer.tsx index 728638c..80f1d67 100644 --- a/apps/web/src/components/layout/site-footer.tsx +++ b/apps/web/src/components/layout/site-footer.tsx @@ -1,13 +1,21 @@ +import { useTranslation } from 'react-i18next' + +import { LanguageToggle } from '@/components/layout/language-toggle' import { REPO_URL } from '@/lib/config' export function SiteFooter() { + const { t } = useTranslation() + return ( ) diff --git a/apps/web/src/components/layout/site-header.tsx b/apps/web/src/components/layout/site-header.tsx index bc54eb7..5548606 100644 --- a/apps/web/src/components/layout/site-header.tsx +++ b/apps/web/src/components/layout/site-header.tsx @@ -1,5 +1,6 @@ import { CrownIcon, ExternalLinkIcon, MoonIcon, SunIcon } from 'lucide-react' import { useTheme } from 'next-themes' +import { useTranslation } from 'react-i18next' import { Link } from 'react-router' import { Button } from '@/components/ui/button' @@ -7,6 +8,7 @@ import { REPO_URL } from '@/lib/config' export function SiteHeader() { const { resolvedTheme, setTheme } = useTheme() + const { t } = useTranslation() return (
@@ -19,7 +21,7 @@ export function SiteHeader() { to="/?award=first&award=runner-up" className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground" > - Winners + {t('nav.winners')} - Open Source + {t('nav.openSource')} {page + 1} / {lastPage + 1} @@ -39,7 +42,7 @@ export function PaginationBar({ total, page, onPageChange }: PaginationBarProps) disabled={total === undefined || page >= lastPage} onClick={() => onPageChange(page + 1)} > - Next + {t('table.next')} diff --git a/apps/web/src/components/query-state.tsx b/apps/web/src/components/query-state.tsx index a9f32ff..b8175c3 100644 --- a/apps/web/src/components/query-state.tsx +++ b/apps/web/src/components/query-state.tsx @@ -1,16 +1,23 @@ -import { ApiError } from '@/lib/api' +import { useTranslation } from 'react-i18next' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import { ApiError } from '@/lib/api' export function DetailError({ error, subject }: { error: Error; subject: string }) { + const { t } = useTranslation() const notFound = error instanceof ApiError && error.status === 404 + return ( - {notFound ? `No ${subject} found` : `Could not load ${subject}`} + + {notFound + ? `${t('table.none')}: ${subject}` + : t('table.unreachableTitle', { resource: subject })} + {notFound - ? 'Nothing on testnet matches this inside the hack window.' - : 'The API is unreachable. Try again in a moment.'} + ? t('table.none') + : t('table.unreachableDesc')} ) diff --git a/apps/web/src/i18n/index.ts b/apps/web/src/i18n/index.ts new file mode 100644 index 0000000..547c81a --- /dev/null +++ b/apps/web/src/i18n/index.ts @@ -0,0 +1,25 @@ +import i18n from 'i18next' +import { initReactI18next } from 'react-i18next' + +import en from './locales/en.json' +import pt from './locales/pt.json' + +const savedLanguage = typeof window !== 'undefined' ? localStorage.getItem('hackproof-language') : null +const initialLanguage = savedLanguage ? JSON.parse(savedLanguage) : 'en' + +void i18n + .use(initReactI18next) + .init({ + resources: { + en: { translation: en }, + pt: { translation: pt }, + 'pt-PT': { translation: pt }, + }, + lng: initialLanguage === 'pt' ? 'pt' : 'en', + fallbackLng: 'en', + interpolation: { + escapeValue: false, + }, + }) + +export default i18n diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json new file mode 100644 index 0000000..d82174b --- /dev/null +++ b/apps/web/src/i18n/locales/en.json @@ -0,0 +1,141 @@ +{ + "nav": { + "winners": "Winners", + "openSource": "Open Source", + "toggleTheme": "Toggle theme" + }, + "footer": { + "disclaimer": "Independent project, not affiliated with SDF or Guava.", + "github": "GitHub", + "language": "Language" + }, + "home": { + "heroTitle": "HackMeridian 2026, rebuilt from the chain.", + "heroSubtitle": "The submission list is not public, so we rebuilt it from Stellar testnet. Every project below is matched to on-chain activity you can check yourself.", + "windowTo": "to", + "overviewNote": "A reconstruction: the official submission list is not public." + }, + "overview": { + "projects": "Projects", + "verifiedOnChain": "Verified on-chain", + "contractsPerHour": "Contracts deployed, per hour over the 27 hour window", + "deployAccounts": "Deploy accounts", + "contributors": "Contributors", + "countries": "Countries", + "wasmUploads": "Wasm uploads", + "failedTxs": "Failed txs" + }, + "explorer": { + "tabs": { + "projects": "Projects", + "contracts": "Contracts", + "wasm": "Wasm", + "failed": "Failed" + }, + "searchPlaceholder": "Search address, hash or repo", + "filters": "Filters", + "clear": "Clear", + "cards": "Cards", + "table": "Table", + "loadMore": "Load more", + "noProjects": "No projects match your filter.", + "noContracts": "No contracts match.", + "noWasm": "No wasm uploads match.", + "noFailed": "No failed transactions match.", + "filterGroups": { + "award": "Awards", + "tier": "Evidence", + "track": "Track", + "partner": "Integrations", + "activity": "On-chain activity", + "language": "Language", + "country": "Country" + }, + "filterFixed": { + "contracts": "Deployed contracts", + "failed": "Failed transactions" + }, + "showLess": "Show less", + "showAll": "Show all {{count}}" + }, + "project": { + "noDescription": "No description", + "noRepo": "no repo", + "contractsCount": "{{count}} contracts", + "commitsInWindow": "{{window}} of {{total}} commits inside the window", + "includedBecause": "Included because it {{inclusion}}.", + "createdDuringHack": "was created during the hack", + "addedStellarDuringHack": "added Stellar during the hack", + "defaultInclusion": "has a Stellar dependency and commits during the hack", + "sections": { + "integrations": "Integrations", + "contributors": "Contributors", + "evidence": "Why this is linked", + "activity": "Activity" + }, + "derivedNotice": "{{count}} more items are attributed through the same deploy accounts.", + "onChainMetadata": "on-chain metadata", + "commits": "commits", + "moreContributors": "+{{count}} more" + }, + "builder": { + "title": "Builder", + "projects": "Projects" + }, + "table": { + "resultsCount": "{{count}} results", + "previous": "Previous", + "next": "Next", + "none": "none", + "unreachableTitle": "Could not load {{resource}}", + "unreachableDesc": "The API is unreachable. Try again in a moment.", + "columns": { + "project": "Project", + "evidence": "Evidence", + "footprint": "Footprint", + "contracts": "Contracts", + "classicOps": "Classic ops", + "stars": "Stars", + "commits": "Commits", + "lastActivity": "Last activity", + "tx": "Tx", + "deployer": "Deployer", + "ledger": "Ledger", + "time": "Time", + "wasm": "Wasm", + "uploader": "Uploader", + "repo": "Repo", + "size": "Size", + "uploaded": "Uploaded", + "source": "Source", + "kind": "Kind", + "result": "Result", + "partner": "Partner", + "matched": "Matched", + "method": "Method", + "foundIn": "Found in" + } + }, + "tiers": { + "verified": "Verified on-chain", + "likely": "Code only", + "possible": "Possible", + "unattributed": "No repo" + }, + "tracks": { + "genesis": "Genesis", + "scale": "Scale" + }, + "places": { + "first": "1st place", + "runnerUp": "Runner-up" + }, + "footprint": { + "contracts": "Contracts", + "classic": "Classic ops only", + "github": "GitHub only" + }, + "about": { + "text": "Hey curious, I'm Odii. A super talented builder on Stellar, I've worked on projects such as Sorokit, StellarTools and many more, feel free to follow me on GitHub, X or LinkedIn." + } +} diff --git a/apps/web/src/i18n/locales/pt.json b/apps/web/src/i18n/locales/pt.json new file mode 100644 index 0000000..eed20eb --- /dev/null +++ b/apps/web/src/i18n/locales/pt.json @@ -0,0 +1,141 @@ +{ + "nav": { + "winners": "Vencedores", + "openSource": "Código Aberto", + "toggleTheme": "Alternar tema" + }, + "footer": { + "disclaimer": "Projeto independente, não afiliado à SDF ou à Guava.", + "github": "GitHub", + "language": "Idioma" + }, + "home": { + "heroTitle": "HackMeridian 2026, reconstruído a partir da rede.", + "heroSubtitle": "A lista oficial de submissões não é pública, por isso reconstruímo-la a partir da testnet Stellar. Cada projeto abaixo está associado a atividade on-chain auditável.", + "windowTo": "até", + "overviewNote": "Uma reconstrução: a lista oficial de submissões não é pública." + }, + "overview": { + "projects": "Projetos", + "verifiedOnChain": "Verificado on-chain", + "contractsPerHour": "Contratos implementados, por hora durante a janela de 27 horas", + "deployAccounts": "Contas de deploy", + "contributors": "Contribuidores", + "countries": "Países", + "wasmUploads": "Uploads Wasm", + "failedTxs": "Transações falhadas" + }, + "explorer": { + "tabs": { + "projects": "Projetos", + "contracts": "Contratos", + "wasm": "Wasm", + "failed": "Falhadas" + }, + "searchPlaceholder": "Pesquisar endereço, hash ou repositório", + "filters": "Filtros", + "clear": "Limpar", + "cards": "Cartões", + "table": "Tabela", + "loadMore": "Carregar mais", + "noProjects": "Nenhum projeto corresponde ao seu filtro.", + "noContracts": "Nenhum contrato encontrado.", + "noWasm": "Nenhum upload de wasm encontrado.", + "noFailed": "Nenhuma transação falhada encontrada.", + "filterGroups": { + "award": "Prémios", + "tier": "Evidência", + "track": "Trilha", + "partner": "Integrações", + "activity": "Atividade on-chain", + "language": "Linguagem", + "country": "País" + }, + "filterFixed": { + "contracts": "Contratos implementados", + "failed": "Transações falhadas" + }, + "showLess": "Mostrar menos", + "showAll": "Mostrar todos os {{count}}" + }, + "project": { + "noDescription": "Sem descrição", + "noRepo": "sem repositório", + "contractsCount": "{{count}} contratos", + "commitsInWindow": "{{window}} de {{total}} commits dentro da janela", + "includedBecause": "Incluído porque {{inclusion}}.", + "createdDuringHack": "foi criado durante o hackathon", + "addedStellarDuringHack": "adicionou Stellar durante o hackathon", + "defaultInclusion": "possui dependência Stellar e commits durante o hackathon", + "sections": { + "integrations": "Integrações", + "contributors": "Contribuidores", + "evidence": "Porque está associado", + "activity": "Atividade" + }, + "derivedNotice": "Mais {{count}} itens são atribuídos através das mesmas contas de deploy.", + "onChainMetadata": "metadados on-chain", + "commits": "commits", + "moreContributors": "+{{count}} outros" + }, + "builder": { + "title": "Criador", + "projects": "Projetos" + }, + "table": { + "resultsCount": "{{count}} resultados", + "previous": "Anterior", + "next": "Seguinte", + "none": "nenhum", + "unreachableTitle": "Não foi possível carregar {{resource}}", + "unreachableDesc": "A API está inacessível. Tente novamente dentro de instantes.", + "columns": { + "project": "Projeto", + "evidence": "Evidência", + "footprint": "Pegada", + "contracts": "Contratos", + "classicOps": "Ops clássicas", + "stars": "Estrelas", + "commits": "Commits", + "lastActivity": "Última atividade", + "tx": "Tx", + "deployer": "Criador", + "ledger": "Ledger", + "time": "Hora", + "wasm": "Wasm", + "uploader": "Carregador", + "repo": "Repositório", + "size": "Tamanho", + "uploaded": "Carregado", + "source": "Origem", + "kind": "Tipo", + "result": "Resultado", + "partner": "Parceiro", + "matched": "Correspondido", + "method": "Método", + "foundIn": "Encontrado em" + } + }, + "tiers": { + "verified": "Verificado on-chain", + "likely": "Apenas código", + "possible": "Possível", + "unattributed": "Sem repositório" + }, + "tracks": { + "genesis": "Génesis", + "scale": "Escala" + }, + "places": { + "first": "1º lugar", + "runnerUp": "Finalista" + }, + "footprint": { + "contracts": "Contratos", + "classic": "Apenas ops clássicas", + "github": "Apenas GitHub" + }, + "about": { + "text": "Olá curioso, sou o Odii. Um builder na Stellar, já trabalhei em projetos como Sorokit, StellarTools e muitos outros. Sente-te à vontade para me seguir no GitHub, X ou LinkedIn." + } +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index ef09358..35f7692 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -11,6 +11,7 @@ import { TooltipProvider } from '@/components/ui/tooltip' import { App } from './app' import { posthog } from './lib/analytics' import './index.css' +import './i18n' const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60_000, retry: 1 } }, diff --git a/apps/web/src/pages/builder.tsx b/apps/web/src/pages/builder.tsx index 13982a1..f7f6e40 100644 --- a/apps/web/src/pages/builder.tsx +++ b/apps/web/src/pages/builder.tsx @@ -1,4 +1,5 @@ import { ExternalLinkIcon } from 'lucide-react' +import { useTranslation } from 'react-i18next' import { useNavigate, useParams } from 'react-router' import { DataTable } from '@/components/data-table' @@ -15,14 +16,15 @@ export function BuilderPage() { const address = useParams().address ?? '' const navigate = useNavigate() const { data, isPending, error } = useBuilder(address) + const { t } = useTranslation() if (isPending) return - if (error) return + if (error) return return (
-

Builder

+

{t('builder.title')}

{data.projects.length > 0 && ( -
+

- HackMeridian 2026, rebuilt from the chain. + {t('home.heroTitle')}

- The submission list is not public, so we rebuilt it from Stellar testnet. Every project - below is matched to on-chain activity you can check yourself. + {t('home.heroSubtitle')}

{data ? (

- {formatDateTime(data.start)} to {formatDateTime(data.end)} + {formatDateTime(data.start)} {t('home.windowTo')} {formatDateTime(data.end)}

) : ( diff --git a/apps/web/src/pages/project.tsx b/apps/web/src/pages/project.tsx index 4036891..061f8d0 100644 --- a/apps/web/src/pages/project.tsx +++ b/apps/web/src/pages/project.tsx @@ -1,6 +1,8 @@ -import { ExternalLinkIcon, StarIcon } from 'lucide-react' +import { ExternalLinkIcon } from 'lucide-react' +import { useTranslation } from 'react-i18next' import { useParams } from 'react-router' +import { AwardBadge } from '@/components/award-badge' import { Contributors } from '@/components/contributors' import { DataTable } from '@/components/data-table' import { ActivitySections } from '@/components/explorer/activity-sections' @@ -8,7 +10,6 @@ import { integrationColumns, linkColumns } from '@/components/explorer/columns' import { ProjectSkeleton } from '@/components/page-skeletons' import { DetailError } from '@/components/query-state' import { Section } from '@/components/section' -import { AwardBadge } from '@/components/award-badge' import { TierBadge } from '@/components/tier-badge' import { TrackBadge } from '@/components/track-badge' import { Accordion } from '@/components/ui/accordion' @@ -16,13 +17,14 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Badge } from '@/components/ui/badge' import { useHackWindow } from '@/hooks/use-hack-window' import { useProject } from '@/hooks/use-project' -import { flagEmoji, footprintLabel, formatDateTime, isWithin, projectName } from '@/lib/format' +import { flagEmoji, footprintLabel, isWithin, projectName } from '@/lib/format' import { githubUrl } from '@/lib/links' export function ProjectPage() { const projectId = useParams()['*'] ?? '' const { data, isPending, error } = useProject(projectId) const { data: hackWindow } = useHackWindow() + const { t, i18n } = useTranslation() if (isPending) return if (error) return @@ -34,12 +36,13 @@ export function ProjectPage() { const derived = links.length - direct.length const packages = Object.entries(project.dependencies?.packages ?? {}) const reasons = [ - isWithin(project.created_at, hackWindow) && 'was created during the hack', - project.stellar_added_in_window && 'added Stellar during the hack', + isWithin(project.created_at, hackWindow) && t('project.createdDuringHack'), + project.stellar_added_in_window && t('project.addedStellarDuringHack'), ].filter(Boolean) as string[] + const locale = i18n.language.startsWith('pt') ? 'pt' : 'en' const inclusion = reasons.length - ? new Intl.ListFormat('en', { type: 'conjunction' }).format(reasons) - : 'has a Stellar dependency and commits during the hack' + ? new Intl.ListFormat(locale, { type: 'conjunction' }).format(reasons) + : t('project.defaultInclusion') return (
@@ -56,32 +59,23 @@ export function ProjectPage() { - {footprintLabel[project.footprint]} + {t(`footprint.${project.footprint}`, { defaultValue: footprintLabel[project.footprint] })}
+ {project.description && ( -

{project.description}

+

{project.description}

)} +
- {project.url ? ( + {project.url && ( - GitHub - - ) : ( - no repo linked - )} - {project.owner && ( - - by {project.owner} + {project.full_name ?? name} + )} {project.homepage && ( @@ -91,39 +85,27 @@ export function ProjectPage() { rel="noreferrer" className="inline-flex items-center gap-1 hover:text-foreground" > - Demo + {project.homepage} + )} - {project.language && {project.language}} - {project.license && {project.license}} - {project.forks !== null && project.forks > 0 && {project.forks} forks} - {project.stars !== null && ( - - {project.stars} - - )} - {project.footprint !== 'github' && project.first_activity && project.last_activity && ( - - {formatDateTime(project.first_activity)} to {formatDateTime(project.last_activity)} - - )} {project.full_name && - hackWindow && project.window_commits !== null && - project.total_commits !== null && ( + project.total_commits !== null && + hackWindow && ( - {project.window_commits} of {project.total_commits} commits inside the window + {t('project.commitsInWindow', { window: project.window_commits, total: project.total_commits })} )}
{project.tier !== 'verified' && ( -

Included because it {inclusion}.

+

{t('project.includedBecause', { inclusion })}

)} {packages.length > 0 && (
@@ -138,7 +120,7 @@ export function ProjectPage() {
{integration_uses.length > 0 && ( -
+
)} {contributors.length > 0 && ( -
+
)} {direct.length > 0 && ( -
+
{derived > 0 && (

- {derived} more items are attributed through the same deploy accounts. + {t('project.derivedNotice', { count: derived })}

)}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de60ba8..125be77 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,6 +34,9 @@ importers: cn: specifier: ^0.5.0 version: 0.5.0 + i18next: + specifier: ^26.5.12 + version: 26.5.12(typescript@6.0.3) lucide-react: specifier: ^1.61.0 version: 1.61.0(@types/react@19.3.0)(react@19.3.1) @@ -52,6 +55,9 @@ importers: react-dom: specifier: ^19.3.1 version: 19.3.1(react@19.3.1) + react-i18next: + specifier: ^17.1.9 + version: 17.1.9(i18next@26.5.12(typescript@6.0.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@6.0.3) react-router: specifier: ^8.5.0 version: 8.5.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) @@ -252,6 +258,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.10': + resolution: {integrity: sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==} + engines: {node: '>=6.9.0'} + '@babel/runtime@8.0.8': resolution: {integrity: sha512-dEu8nGlRqjsJsvviUUoSMfcRHpwomCiRlXsc2mPfJ79+ffTpDm+JtfUtTMzUrqCApHQnCsVPD3gT1ZT8RgBRCQ==} @@ -1440,6 +1450,9 @@ packages: resolution: {integrity: sha512-yhRLQ21U6qa9Hsu2N7Qcbh8tE+WKEcoErjIo56LeSGiGXGp9sA7UpE86Ui9uexb61KakiGwuHMn2QdV7w6i6kQ==} engines: {node: '>=16.9.0'} + html-parse-stringify@4.0.1: + resolution: {integrity: sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -1458,6 +1471,14 @@ packages: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} + i18next@26.5.12: + resolution: {integrity: sha512-vTt8tc3y5axoU7wwTYg8vjpILLyP33a5GyGJAVI7FDRx1XueYxZVIkcVeStoftrVnAM/o28hKvK0sslucDI9aQ==} + peerDependencies: + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + typescript: + optional: true + iconv-lite@0.7.3: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} @@ -2235,6 +2256,22 @@ packages: peerDependencies: react: ^19.3.1 + react-i18next@17.1.9: + resolution: {integrity: sha512-OgYqIH13XHPlWTaaI795Dz+k2AdKnXcOxNQVnf750T8Ij0TYWp1nYvXRpoJBulPqlyk2OAbn1PM4tFKiIj/byA==} + peerDependencies: + i18next: '>= 26.2.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + react-is@19.3.1: resolution: {integrity: sha512-RRieF6IyxQBkcK6pP8xOHprLTPW6QjRkbGdImtqNme+/hYVwCZkLQLdlZGOhUqXwkS9IEynaGvPJtv6XB/cz0Q==} @@ -2933,6 +2970,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/runtime@7.29.10': {} + '@babel/runtime@8.0.8': {} '@babel/template@7.29.7': @@ -4000,6 +4039,8 @@ snapshots: hono@4.13.14: {} + html-parse-stringify@4.0.1: {} + html-url-attributes@3.0.1: {} html-void-elements@3.0.0: {} @@ -4016,6 +4057,10 @@ snapshots: human-signals@8.0.1: {} + i18next@26.5.12(typescript@6.0.3): + optionalDependencies: + typescript: 6.0.3 + iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 @@ -4877,6 +4922,17 @@ snapshots: react: 19.3.1 scheduler: 0.28.0 + react-i18next@17.1.9(i18next@26.5.12(typescript@6.0.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@6.0.3): + dependencies: + '@babel/runtime': 7.29.10 + html-parse-stringify: 4.0.1 + i18next: 26.5.12(typescript@6.0.3) + react: 19.3.1 + use-sync-external-store: 1.7.0(react@19.3.1) + optionalDependencies: + react-dom: 19.3.1(react@19.3.1) + typescript: 6.0.3 + react-is@19.3.1: {} react-redux@9.3.0(@types/react@19.3.0)(react@19.3.1)(redux@5.0.1):