Skip to content

Commit 9946f01

Browse files
AmazingAngclaude
andcommitted
Add i18n support with Chinese/English translations
Lightweight React Context + JSON translation system (no external deps). Language preference persisted in localStorage, switchable from Settings. - Add src/i18n/ with I18nProvider, useI18n hook, en.json (558 keys), zh.json - Add language switcher (English/中文) to Settings > General tab - Replace hardcoded UI strings with t() calls across 35 components - Sync document.documentElement.lang with active locale - Fix MapLibre flyTo crash on rapid market switching (stop in-flight animations) - Memoize I18nProvider context value and filter option arrays to avoid unnecessary re-renders Market data (titles, outcomes, rules) from Polymarket API intentionally not translated. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5a9d1c9 commit 9946f01

38 files changed

Lines changed: 2177 additions & 699 deletions

src/app/page.tsx

Lines changed: 76 additions & 93 deletions
Large diffs are not rendered by default.

src/components/AlertManager.tsx

Lines changed: 74 additions & 72 deletions
Large diffs are not rendered by default.

src/components/ArbitragePanel.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ import { useMemo, useState } from "react";
44
import type { ProcessedMarket } from "@/types";
55
import { detectArbitrage, type ArbitrageOpportunity } from "@/lib/arbitrage";
66
import { CATEGORY_COLORS } from "@/lib/categories";
7+
import { useI18n } from "@/i18n";
78

89
interface ArbitragePanelProps {
910
markets: ProcessedMarket[];
1011
onSelectMarket?: (slug: string) => void;
1112
}
1213

1314
export default function ArbitragePanel({ markets, onSelectMarket }: ArbitragePanelProps) {
15+
const { t } = useI18n();
1416
const [expanded, setExpanded] = useState<string | null>(null);
1517

1618
const opportunities = useMemo(() => detectArbitrage(markets), [markets]);
@@ -20,7 +22,7 @@ export default function ArbitragePanel({ markets, onSelectMarket }: ArbitragePan
2022
if (opportunities.length === 0) {
2123
return (
2224
<div className="text-[12px] text-[var(--text-ghost)] py-4 text-center font-mono">
23-
No arbitrage opportunities detected
25+
{t("arbitrage.noOpportunities")}
2426
</div>
2527
);
2628
}
@@ -29,8 +31,7 @@ export default function ArbitragePanel({ markets, onSelectMarket }: ArbitragePan
2931
<div className="font-mono">
3032
{/* Summary */}
3133
<div className="px-1.5 py-1 mb-1 text-[10px] text-[var(--text-dim)] border-b border-[var(--border-subtle)]">
32-
{opportunities.length} opportunities found, best edge:{" "}
33-
<span className="text-[#22c55e] font-bold">{(bestEdge * 100).toFixed(2)}%</span>
34+
{t("arbitrage.opportunitiesFound", { count: opportunities.length, percent: (bestEdge * 100).toFixed(2) })}
3435
</div>
3536

3637
{/* List */}
@@ -60,6 +61,7 @@ function ArbitrageRow({
6061
onToggle: () => void;
6162
onSelect: () => void;
6263
}) {
64+
const { t } = useI18n();
6365
const catColor = CATEGORY_COLORS[opp.category as keyof typeof CATEGORY_COLORS] || "var(--text-faint)";
6466
const deviationColor = opp.direction === "over" ? "#ff4444" : "#22c55e";
6567

@@ -113,7 +115,7 @@ function ArbitrageRow({
113115
</div>
114116
))}
115117
<div className="flex items-center gap-2 text-[10px] pt-0.5 border-t border-[var(--border-subtle)]">
116-
<span className="text-[var(--text-faint)]">Sum</span>
118+
<span className="text-[var(--text-faint)]">{t("arbitrage.sum")}</span>
117119
<span className="tabular-nums font-bold" style={{ color: deviationColor }}>
118120
{(opp.sumProb * 100).toFixed(1)}%
119121
</span>

src/components/CalendarPanel.tsx

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useEffect, useMemo, useState } from "react";
44
import type { ProcessedMarket, Category } from "@/types";
55
import { CATEGORY_COLORS } from "@/lib/categories";
6+
import { useI18n } from "@/i18n";
67

78
interface CalendarPanelProps {
89
markets: ProcessedMarket[];
@@ -85,7 +86,28 @@ function buildCalendar(markets: ProcessedMarket[], categoryFilter: Category | "A
8586
return grouped;
8687
}
8788

89+
const CATEGORY_LABEL_KEYS: Record<string, string> = {
90+
All: "common.all",
91+
Politics: "calendar.politics",
92+
Crypto: "calendar.crypto",
93+
Sports: "calendar.sports",
94+
Finance: "calendar.finance",
95+
Tech: "calendar.tech",
96+
Culture: "calendar.culture",
97+
Other: "calendar.other",
98+
};
99+
100+
const GROUP_LABEL_KEYS: Record<string, string> = {
101+
Today: "calendar.todayGroup",
102+
Tomorrow: "calendar.tomorrowGroup",
103+
"This Week": "calendar.thisWeek",
104+
"Next Week": "calendar.nextWeek",
105+
"This Month": "calendar.thisMonth",
106+
Later: "calendar.later",
107+
};
108+
88109
export default function CalendarPanel({ markets, onSelectMarket }: CalendarPanelProps) {
110+
const { t } = useI18n();
89111
const [categoryFilter, setCategoryFilter] = useState<Category | "All">("All");
90112
const [collapsed, setCollapsed] = useState<Set<TimeGroup>>(new Set());
91113
const [now, setNow] = useState<Date>(() => new Date());
@@ -129,14 +151,14 @@ export default function CalendarPanel({ markets, onSelectMarket }: CalendarPanel
129151
border: `1px solid ${categoryFilter === cat ? "rgba(34,197,94,0.3)" : "transparent"}`,
130152
}}
131153
>
132-
{cat}
154+
{t(CATEGORY_LABEL_KEYS[cat] || cat)}
133155
</button>
134156
))}
135157
</div>
136158

137159
{totalEvents === 0 ? (
138160
<div className="text-[12px] text-[var(--text-ghost)] py-4 text-center">
139-
No upcoming events
161+
{t("calendar.noUpcoming")}
140162
</div>
141163
) : (
142164
<div>
@@ -153,7 +175,7 @@ export default function CalendarPanel({ markets, onSelectMarket }: CalendarPanel
153175
className="w-full flex items-center gap-1 px-1.5 py-1 text-[10px] text-[var(--text-faint)] hover:text-[var(--text-muted)] transition-colors"
154176
>
155177
<span className="text-[9px]">{isCollapsed ? "\u25B6" : "\u25BC"}</span>
156-
<span className="font-bold uppercase tracking-wide">{group}</span>
178+
<span className="font-bold uppercase tracking-wide">{t(GROUP_LABEL_KEYS[group] || group)}</span>
157179
<span className="text-[var(--text-ghost)]">({events.length})</span>
158180
</button>
159181

@@ -207,7 +229,7 @@ export default function CalendarPanel({ markets, onSelectMarket }: CalendarPanel
207229
color: ev.market.impactLevel === "critical" ? "#ff4444" : "#f59e0b",
208230
}}
209231
>
210-
{ev.market.impactLevel}
232+
{t(ev.market.impactLevel === "critical" ? "calendar.critical" : "calendar.high")}
211233
</span>
212234
)}
213235
</div>

src/components/ChartPanel.tsx

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { useState, useEffect, useRef, useCallback, useMemo, memo } from "react";
44
import type { ProcessedMarket } from "@/types";
55
import { SERIES_COLORS, MA_COLORS } from "@/lib/chartConstants";
6+
import { useI18n } from "@/i18n";
67

78
type TimeRange = "1h" | "24h" | "7d" | "30d";
89
type ChartMode = "candle" | "line";
@@ -134,6 +135,7 @@ function getLightweightCharts() {
134135
}
135136

136137
function ChartPanelInner({ selectedMarket, lineOnly = false }: ChartPanelProps) {
138+
const { t } = useI18n();
137139
const [timeRange, setTimeRange] = useState<TimeRange>("24h");
138140
const [chartMode, setChartMode] = useState<ChartMode>("candle");
139141
const [showMA, setShowMA] = useState(true);
@@ -471,7 +473,7 @@ function ChartPanelInner({ selectedMarket, lineOnly = false }: ChartPanelProps)
471473
if (!selectedMarket) {
472474
return (
473475
<div className="text-[12px] text-[var(--text-muted)] font-mono p-2">
474-
select a market to view price chart
476+
{t("chart.selectMarket")}
475477
</div>
476478
);
477479
}
@@ -503,24 +505,24 @@ function ChartPanelInner({ selectedMarket, lineOnly = false }: ChartPanelProps)
503505
<div className="w-px h-3 bg-[#2a2a2a]" />
504506
<div className="flex items-center gap-2 text-[9px] tabular-nums">
505507
{selectedMarket.indicators.momentum !== null && (
506-
<span title="Momentum: price change acceleration">
507-
<span className="text-[#555]">Mtm </span>
508+
<span title={t("chart.momentumDesc")}>
509+
<span className="text-[#555]">{t("chart.momentumIndicator")} </span>
508510
<span style={{ color: selectedMarket.indicators.momentum > 0.01 ? "#22c55e" : selectedMarket.indicators.momentum < -0.01 ? "#ff4444" : "#888" }}>
509511
{selectedMarket.indicators.momentum > 0 ? "+" : ""}{(selectedMarket.indicators.momentum * 100).toFixed(2)}%
510512
</span>
511513
</span>
512514
)}
513515
{selectedMarket.indicators.volatility !== null && (
514-
<span title="Volatility: 24h prob standard deviation">
515-
<span className="text-[#555]">Vol{"\u03C3"} </span>
516+
<span title={t("chart.volatilityDesc")}>
517+
<span className="text-[#555]">{t("chart.volatilityIndicator")}{"\u03C3"} </span>
516518
<span style={{ color: selectedMarket.indicators.volatility > 0.05 ? "#f59e0b" : "#888" }}>
517519
{(selectedMarket.indicators.volatility * 100).toFixed(1)}%
518520
</span>
519521
</span>
520522
)}
521523
{selectedMarket.indicators.orderFlowImbalance !== null && (
522-
<span title="Order flow imbalance: (smart buys - sells) / total">
523-
<span className="text-[#555]">Flow </span>
524+
<span title={t("chart.flowDesc")}>
525+
<span className="text-[#555]">{t("chart.flowIndicator")} </span>
524526
<span style={{ color: selectedMarket.indicators.orderFlowImbalance > 0.1 ? "#22c55e" : selectedMarket.indicators.orderFlowImbalance < -0.1 ? "#ff4444" : "#888" }}>
525527
{selectedMarket.indicators.orderFlowImbalance > 0 ? "+" : ""}{(selectedMarket.indicators.orderFlowImbalance * 100).toFixed(0)}%
526528
</span>
@@ -543,7 +545,7 @@ function ChartPanelInner({ selectedMarket, lineOnly = false }: ChartPanelProps)
543545
<button onClick={() => setUseUTC(v => !v)}
544546
className="px-1.5 py-0.5 text-[9px] transition-all"
545547
style={{ color: "#888", background: useUTC ? "#2a2a2a" : "transparent", border: "1px solid #222", borderRadius: 2 }}
546-
title={useUTC ? "Showing UTC time — click for local" : "Showing local time — click for UTC"}>
548+
title={useUTC ? t("chart.showingUtc") : t("chart.showingLocal")}>
547549
{useUTC ? "UTC" : "Local"}
548550
</button>
549551
</div>
@@ -591,7 +593,7 @@ function ChartPanelInner({ selectedMarket, lineOnly = false }: ChartPanelProps)
591593
<span style={{ color: MA_COLORS.ma20 }}>MA20</span>
592594
</>
593595
)}
594-
<span className="text-[#444]">hover chart for details</span>
596+
<span className="text-[#444]">{t("chart.hoverForDetails")}</span>
595597
</>
596598
)}
597599
</div>
@@ -607,7 +609,7 @@ function ChartPanelInner({ selectedMarket, lineOnly = false }: ChartPanelProps)
607609
{error && !loading && (
608610
<div className="absolute inset-0 flex items-center justify-center z-10" style={{ background: "#0d0d0d99" }}>
609611
<div className="text-[11px] text-[var(--red)] font-mono text-center" aria-live="polite">
610-
{error} <button onClick={() => { retryCount.current = 0; setChartData(null); }} className="ml-2 underline">retry</button>
612+
{t("common.loadFailed")} <button onClick={() => { retryCount.current = 0; setChartData(null); }} className="ml-2 underline">{t("common.retry")}</button>
611613
</div>
612614
</div>
613615
)}

src/components/CountryPanel.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getCountryFlag, marketMatchesCountry } from "@/lib/countries";
66
import { getParentCountry } from "@/lib/geo";
77
import { formatVolume } from "@/lib/format";
88
import MarketCard from "./MarketCard";
9+
import { useI18n } from "@/i18n";
910

1011
interface CountryPanelProps {
1112
countryName: string;
@@ -24,6 +25,7 @@ export default function CountryPanel({
2425
isWatched,
2526
onToggleWatch,
2627
}: CountryPanelProps) {
28+
const { t } = useI18n();
2729
const parentCountry = getParentCountry(countryName);
2830
const displayName = parentCountry ? `${countryName}, ${parentCountry}` : countryName;
2931
const flagSource = parentCountry || countryName;
@@ -90,7 +92,7 @@ export default function CountryPanel({
9092
const data = await res.json();
9193
if (data.summary) setAiSummary(data.summary);
9294
} catch {
93-
setAiSummary("Failed to generate summary");
95+
setAiSummary(t("country.summaryFailed"));
9496
}
9597
setAiLoading(false);
9698
}, [countryName, displayName, countryMarkets, aiLoading]);
@@ -105,7 +107,7 @@ export default function CountryPanel({
105107
onClick={fetchCountrySummary}
106108
disabled={aiLoading || countryMarkets.length === 0}
107109
className="shrink-0 text-[var(--text-faint)] hover:text-[#f59e0b] transition-colors disabled:opacity-50 ml-auto"
108-
title="AI Summary"
110+
title={t("country.aiSummary")}
109111
>
110112
{aiLoading ? (
111113
<span className="inline-block w-3 h-3 border border-[#f59e0b] border-t-transparent rounded-full animate-spin" />
@@ -127,29 +129,29 @@ export default function CountryPanel({
127129
{countryMarkets.length > 0 && (
128130
<div className="grid grid-cols-3 gap-1.5 mb-2 text-[11px]">
129131
<div className="border border-[var(--border-subtle)] rounded-sm px-2 py-1.5">
130-
<div className="text-[9px] uppercase tracking-[0.1em] text-[var(--text-faint)] mb-0.5">total vol</div>
132+
<div className="text-[9px] uppercase tracking-[0.1em] text-[var(--text-faint)] mb-0.5">{t("country.totalVol")}</div>
131133
<div className="text-[var(--text-secondary)]">{formatVolume(totalVol)}</div>
132134
</div>
133135
<div className="border border-[var(--border-subtle)] rounded-sm px-2 py-1.5">
134-
<div className="text-[9px] uppercase tracking-[0.1em] text-[var(--text-faint)] mb-0.5">active</div>
136+
<div className="text-[9px] uppercase tracking-[0.1em] text-[var(--text-faint)] mb-0.5">{t("common.active")}</div>
135137
<div className="text-[var(--text-secondary)]">{activeCount}</div>
136138
</div>
137139
<div className="border border-[var(--border-subtle)] rounded-sm px-2 py-1.5">
138-
<div className="text-[9px] uppercase tracking-[0.1em] text-[var(--text-faint)] mb-0.5">closed</div>
140+
<div className="text-[9px] uppercase tracking-[0.1em] text-[var(--text-faint)] mb-0.5">{t("common.closed")}</div>
139141
<div className="text-[var(--text-secondary)]">{closedCount}</div>
140142
</div>
141143
</div>
142144
)}
143145

144146
{/* Market count */}
145147
<div className="text-[10px] uppercase tracking-[0.15em] text-[var(--text-faint)] mb-1.5">
146-
{countryMarkets.length} market{countryMarkets.length !== 1 ? "s" : ""}
148+
{t("country.marketsCount", { count: countryMarkets.length })}
147149
</div>
148150

149151
{/* Markets list */}
150152
{countryMarkets.length === 0 ? (
151153
<div className="text-[12px] text-[var(--text-ghost)] py-4">
152-
no markets found for this region
154+
{t("country.noMarkets")}
153155
</div>
154156
) : (
155157
<div>

src/components/FilterDropdown.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

33
import { useState, useRef, useEffect } from "react";
4+
import { useI18n } from "@/i18n";
45

56
export interface FilterGroup {
67
label: string;
@@ -22,6 +23,7 @@ function isAllSelected(group: FilterGroup) {
2223
}
2324

2425
export default function FilterDropdown({ groups, label }: FilterDropdownProps) {
26+
const { t } = useI18n();
2527
const [open, setOpen] = useState(false);
2628
const ref = useRef<HTMLDivElement>(null);
2729

@@ -60,7 +62,7 @@ export default function FilterDropdown({ groups, label }: FilterDropdownProps) {
6062
color: hasFilter ? "#22c55e" : "var(--text-faint)",
6163
border: `1px solid ${hasFilter ? "rgba(34,197,94,0.3)" : "var(--border-subtle, #333)"}`,
6264
}}
63-
title="Filter"
65+
title={t("common.filter")}
6466
>
6567
{label && <span className="text-[9px]">{label}</span>}
6668
<svg width="9" height="9" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2">
@@ -94,7 +96,7 @@ export default function FilterDropdown({ groups, label }: FilterDropdownProps) {
9496
style={{ color: isAllSelected(group) ? "#22c55e" : "var(--text-muted)" }}
9597
>
9698
<Checkbox checked={isAllSelected(group)} />
97-
All
99+
{t("filterDropdown.all")}
98100
</button>
99101
)}
100102

src/components/Footer.tsx

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
11
"use client";
22

3-
const LINKS = [
4-
{ label: "About", href: "/about", external: true },
5-
{ label: "Docs", href: "/docs", external: true },
6-
{ label: "GitHub", href: "https://github.com/AmazingAng/polyworld", external: true },
7-
{ label: "X", href: "https://x.com/0xAA_Science", external: true },
3+
import { useI18n } from "@/i18n";
4+
5+
const LINK_KEYS = [
6+
{ key: "about", href: "/about", external: true },
7+
{ key: "docs", href: "/docs", external: true },
8+
{ key: "github", href: "https://github.com/AmazingAng/polyworld", external: true },
9+
{ key: "x", href: "https://x.com/0xAA_Science", external: true },
810
];
911

1012
export default function Footer() {
13+
const { t } = useI18n();
14+
15+
const linkLabels: Record<string, string> = {
16+
about: t("footer.about"),
17+
docs: t("footer.docs"),
18+
github: t("footer.github"),
19+
x: "X",
20+
};
21+
1122
return (
1223
<footer className="flex items-center justify-between px-4 py-2.5 border-t border-[var(--border)] bg-[var(--surface)] font-mono text-[12px] text-[var(--text-dim)] shrink-0">
1324
<div className="flex items-center gap-1.5">
@@ -18,14 +29,14 @@ export default function Footer() {
1829
<span style={{ fontFamily: "'Inter Tight', sans-serif", fontWeight: 800, letterSpacing: '-0.02em' }} className="text-[13px] text-[var(--text-secondary)]">PolyWorld</span>
1930
</div>
2031
<div className="flex items-center gap-5">
21-
{LINKS.map(({ label, href, external }) => (
32+
{LINK_KEYS.map(({ key, href, external }) => (
2233
<a
23-
key={label}
34+
key={key}
2435
href={href}
2536
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
2637
className="hover:text-[var(--accent)] transition-colors"
2738
>
28-
{label}
39+
{linkLabels[key]}
2940
</a>
3041
))}
3142
</div>

0 commit comments

Comments
 (0)