11"use client" ;
22
3- import { useState } from "react" ;
3+ import { useMemo , useState } from "react" ;
4+ import { useTranslations } from "next-intl" ;
45import { cardParams , type CheckResult } from "@/lib/epoch1000/card-params" ;
56import {
67 isValidSolanaAddress ,
78 SOLANA_ADDRESS_ERROR ,
89} from "@/lib/epoch1000/public-key" ;
910
10- const LOADING_LINES = [
11- "checking... " ,
12- "finding first vote... " ,
13- "scanning validator history... " ,
14- "building card... " ,
15- ] ;
11+ const LOADING_LINE_KEYS = [
12+ "loadingChecking " ,
13+ "loadingFindingVote " ,
14+ "loadingScanning " ,
15+ "loadingBuilding " ,
16+ ] as const ;
1617
1718interface Props {
1819 onResult ?: ( _result : CheckResult | null ) => void ;
1920}
2021
2122export default function ValidatorChecker ( { onResult } : Props ) {
23+ const t = useTranslations ( "epoch1000.validator.checker" ) ;
2224 const [ address , setAddress ] = useState ( "" ) ;
2325 const [ loading , setLoading ] = useState ( false ) ;
24- const [ loadingLine , setLoadingLine ] = useState ( LOADING_LINES [ 0 ] ) ;
26+ const [ loadingLine , setLoadingLine ] = useState <
27+ ( typeof LOADING_LINE_KEYS ) [ number ]
28+ > ( LOADING_LINE_KEYS [ 0 ] ) ;
2529 const [ lookupError , setLookupError ] = useState < string | null > ( null ) ;
2630 const [ result , setResult ] = useState < CheckResult | null > ( null ) ;
2731 const [ showAddress , setShowAddress ] = useState ( true ) ;
2832 const [ copied , setCopied ] = useState ( false ) ;
2933
34+ const numberFormatter = useMemo ( ( ) => new Intl . NumberFormat ( "en-US" ) , [ ] ) ;
35+
3036 async function check ( e : React . FormEvent ) {
3137 e . preventDefault ( ) ;
3238 const trimmed = address . trim ( ) ;
@@ -43,7 +49,9 @@ export default function ValidatorChecker({ onResult }: Props) {
4349 const rotator = setInterval (
4450 ( ) =>
4551 setLoadingLine (
46- LOADING_LINES [ Math . floor ( Math . random ( ) * LOADING_LINES . length ) ] ,
52+ LOADING_LINE_KEYS [
53+ Math . floor ( Math . random ( ) * LOADING_LINE_KEYS . length )
54+ ] ,
4755 ) ,
4856 2500 ,
4957 ) ;
@@ -55,13 +63,13 @@ export default function ValidatorChecker({ onResult }: Props) {
5563 } ) ;
5664 const json = await res . json ( ) ;
5765 if ( ! res . ok ) {
58- setLookupError ( json . error ?? "Lookup failed. Try again." ) ;
66+ setLookupError ( json . error ?? t ( "lookupFailed" ) ) ;
5967 } else {
6068 setResult ( json as CheckResult ) ;
6169 onResult ?.( json as CheckResult ) ;
6270 }
6371 } catch {
64- setLookupError ( "Lookup failed. Try again." ) ;
72+ setLookupError ( t ( "lookupFailed" ) ) ;
6573 } finally {
6674 clearInterval ( rotator ) ;
6775 setLoading ( false ) ;
@@ -74,7 +82,12 @@ export default function ValidatorChecker({ onResult }: Props) {
7482 : "" ;
7583 const tweet = result
7684 ? encodeURIComponent (
77- `I've validated ${ result . epochsSurvived } ${ result . capped ? "+" : "" } Solana epochs - ${ result . tier } .\n\n${ cardUrl } ` ,
85+ t ( "tweet" , {
86+ epochsSurvived : result . epochsSurvived ,
87+ capped : result . capped ? "+" : "" ,
88+ tier : result . tier ,
89+ cardUrl,
90+ } ) ,
7891 )
7992 : "" ;
8093
@@ -99,12 +112,13 @@ export default function ValidatorChecker({ onResult }: Props) {
99112 < section id = "checker" className = "flex flex-col gap-6" >
100113 < div >
101114 < h2 className = "font-bold tracking-tight text-3xl sm:text-4xl" >
102- Check < span className = "text-sol-gradient" > validator</ span >
115+ { t . rich ( "heading" , {
116+ gradient : ( chunks ) => (
117+ < span className = "text-sol-gradient" > { chunks } </ span >
118+ ) ,
119+ } ) }
103120 </ h2 >
104- < p className = "mt-2 text-sm text-ep-dim" >
105- Enter a vote account to find your first validating epoch and share
106- your survivor card. No connect, no signature.
107- </ p >
121+ < p className = "mt-2 text-sm text-ep-dim" > { t ( "description" ) } </ p >
108122 </ div >
109123
110124 < form onSubmit = { check } className = "flex flex-col sm:flex-row gap-3" >
@@ -114,10 +128,10 @@ export default function ValidatorChecker({ onResult }: Props) {
114128 setAddress ( e . target . value ) ;
115129 setLookupError ( null ) ;
116130 } }
117- placeholder = "vote account address"
131+ placeholder = { t ( "placeholder" ) }
118132 spellCheck = { false }
119133 autoComplete = "off"
120- aria-label = "Validator vote account address"
134+ aria-label = { t ( "inputAriaLabel" ) }
121135 aria-invalid = { validationError ? true : undefined }
122136 aria-describedby = {
123137 displayedError ? "epoch1000-validator-error" : undefined
@@ -129,13 +143,13 @@ export default function ValidatorChecker({ onResult }: Props) {
129143 disabled = { loading || ! isAddressValid }
130144 className = "!bg-ep-ink text-ep-void font-semibold rounded-full px-7 py-3 text-sm disabled:opacity-40 disabled:cursor-not-allowed hover:!bg-ep-dim transition"
131145 >
132- { loading ? "Checking..." : "Check" }
146+ { loading ? t ( "submitLoading" ) : t ( "submit" ) }
133147 </ button >
134148 </ form >
135149
136150 { loading && (
137151 < p className = "text-sm text-ep-dust animate-pulse" aria-live = "polite" >
138- { loadingLine }
152+ { t ( loadingLine ) }
139153 </ p >
140154 ) }
141155
@@ -154,7 +168,10 @@ export default function ValidatorChecker({ onResult }: Props) {
154168 < img
155169 key = { params }
156170 src = { `/api/epoch1000/og?${ params } ` }
157- alt = { `Validator card: first seen epoch ${ result . firstEpoch } , validated ${ result . epochsSurvived } epochs` }
171+ alt = { t ( "imageAlt" , {
172+ firstEpoch : result . firstEpoch ,
173+ epochsSurvived : result . epochsSurvived ,
174+ } ) }
158175 className = "w-full rounded-lg border border-ep-edge"
159176 width = { 1200 }
160177 height = { 630 }
@@ -168,41 +185,43 @@ export default function ValidatorChecker({ onResult }: Props) {
168185 onChange = { ( e ) => setShowAddress ( e . target . checked ) }
169186 className = "accent-[#14f195]"
170187 />
171- Show vote account
188+ { t ( "showVoteAccount" ) }
172189 </ label >
173190 < a
174191 href = { `https://twitter.com/intent/tweet?text=${ tweet } ` }
175192 target = "_blank"
176193 rel = "noopener noreferrer"
177194 className = "bg-ep-ink text-ep-void font-semibold rounded-full px-5 py-2 hover:bg-ep-dim transition"
178195 >
179- Share
196+ { t ( "share" ) }
180197 </ a >
181198 < button
182199 onClick = { copyLink }
183200 className = "border border-ep-edge rounded-full px-5 py-2 text-ep-dim hover:text-ep-ink transition"
184201 >
185- { copied ? "Copied" : "Copy link" }
202+ { copied ? t ( "copied" ) : t ( "copyLink" ) }
186203 </ button >
187204 < a
188205 href = { `/api/epoch1000/og?${ params } ` }
189206 download = { `epoch1000-validator-${ result . firstEpoch } .png` }
190207 className = "border border-ep-edge rounded-full px-5 py-2 text-ep-dim hover:text-ep-ink transition"
191208 >
192- PNG
209+ { t ( "png" ) }
193210 </ a >
194211 </ div >
195212
196213 < p className = "text-xs text-ep-dust" >
197- First seen: { " " }
214+ { t ( "firstSeen" ) } { " " }
198215 < a
199216 href = { `https://solscan.io/account/${ result . address } ` }
200217 target = "_blank"
201218 rel = "noopener noreferrer"
202219 className = "underline decoration-ep-edge underline-offset-4 hover:text-ep-dim"
203220 >
204- epoch { result . firstEpoch } · slot{ " " }
205- { result . firstSlot . toLocaleString ( "en-US" ) }
221+ { t ( "firstSeenEpoch" , {
222+ epoch : result . firstEpoch ,
223+ slot : numberFormatter . format ( result . firstSlot ) ,
224+ } ) }
206225 </ a >
207226 </ p >
208227 </ div >
0 commit comments