From 832c5e905528bd6befa24f002b8db462ceb2cdc1 Mon Sep 17 00:00:00 2001 From: Gaubee Date: Thu, 25 Dec 2025 17:44:49 +0800 Subject: [PATCH 1/2] feat(i18n): add TypeScript type safety for translations - Add src/i18n/i18next.d.ts type declaration file - Fix all translation key usages to be type-safe - Add missing translation keys detected by type checking - Refactor dynamic key patterns to use useMemo with direct t() calls - Ensure proper namespace declarations in useTranslation hooks This enables compile-time detection of non-existent translation keys. --- src/components/common/theme-demo.stories.tsx | 40 ++++++++-------- .../onboarding/key-type-selector.tsx | 10 ++-- .../security/password-input.test.tsx | 12 ++--- src/components/security/password-input.tsx | 4 +- src/components/security/pattern-lock.tsx | 18 +++---- src/components/token/token-item.tsx | 10 ++-- src/components/transfer/address-input.tsx | 6 +-- src/components/wallet/wallet-card.tsx | 6 +-- src/components/wallet/wallet-selector.tsx | 4 +- src/i18n/i18next.d.ts | 48 +++++++++++++++++++ src/i18n/locales/ar/authorize.json | 3 ++ src/i18n/locales/ar/security.json | 6 ++- src/i18n/locales/en/authorize.json | 3 ++ src/i18n/locales/en/security.json | 2 + src/i18n/locales/zh-CN/authorize.json | 3 ++ src/i18n/locales/zh-CN/common.json | 2 + src/i18n/locales/zh-CN/security.json | 6 +++ src/i18n/locales/zh-TW/authorize.json | 3 ++ src/i18n/locales/zh-TW/security.json | 6 ++- src/pages/guide/WelcomeScreen.tsx | 26 +++++----- src/pages/history/index.tsx | 32 ++++++------- src/pages/home/index.tsx | 30 ++++++------ src/pages/notifications/index.tsx | 5 +- src/pages/settings/chain-config.tsx | 4 +- .../settings/change-wallet-lock.test.tsx | 2 +- src/pages/settings/index.test.tsx | 2 +- src/pages/wallet/create.tsx | 40 ++++++++-------- src/pages/wallet/list.tsx | 2 +- .../sheets/TwoStepSecretConfirmJob.tsx | 2 +- src/stackflow/activities/tabs/WalletTab.tsx | 12 ++--- src/stackflow/components/TabBar.tsx | 23 ++++----- 31 files changed, 224 insertions(+), 148 deletions(-) create mode 100644 src/i18n/i18next.d.ts diff --git a/src/components/common/theme-demo.stories.tsx b/src/components/common/theme-demo.stories.tsx index 732ff808..bbe15bd0 100644 --- a/src/components/common/theme-demo.stories.tsx +++ b/src/components/common/theme-demo.stories.tsx @@ -5,32 +5,32 @@ import { LoadingSpinner } from './loading-spinner'; import { EmptyState } from './empty-state'; function ThemeDemoComponent() { - const { t, i18n } = useTranslation(); + const { t, i18n } = useTranslation(['common', 'wallet', 'transaction']); return (
-

{t('wallet.myWallet')}

-

{t('common.loading')}

+

{t('wallet:myWallet')}

+

{t('loading')}

- {t('common.loading')}... + {t('loading')}...
- {t('wallet.transfer')} + {t('wallet:transfer')} - {t('wallet.receive')} + {t('wallet:receive')}
-

{t('transaction.send')}

-

{t('transaction.pending')}

+

{t('transaction:send')}

+

{t('transaction:pending')}

@@ -72,14 +72,14 @@ export const Default: Story = {}; export const WithEmptyState: Story = { render: () => { - const { t } = useTranslation(); + const { t } = useTranslation(['token', 'empty']); return ( - {t('token.addToken')} + {t('addToken')} } /> @@ -89,13 +89,13 @@ export const WithEmptyState: Story = { export const TransactionStates: Story = { render: () => { - const { t } = useTranslation(); + const { t } = useTranslation('transaction'); return (
{(['send', 'receive', 'swap', 'stake'] as const).map((type) => (
- {t(`transaction.${type}`)} - {t('transaction.confirmed')} + {t(type)} + {t('confirmed')}
))}
@@ -105,11 +105,11 @@ export const TransactionStates: Story = { export const SecurityLabels: Story = { render: () => { - const { t } = useTranslation(); + const { t } = useTranslation('security'); return (
-

{t('security.password')}

+

{t('password')}

{(['weak', 'medium', 'strong'] as const).map((level) => ( - {t(`security.strength.${level}`)} + {t(`strength.${level}`)} ))}
-

{t('security.mnemonic')}

-

{t('security.copyMnemonic')}

+

{t('mnemonic')}

+

{t('copyMnemonic')}

); diff --git a/src/components/onboarding/key-type-selector.tsx b/src/components/onboarding/key-type-selector.tsx index d52f4f62..3e5a85e6 100644 --- a/src/components/onboarding/key-type-selector.tsx +++ b/src/components/onboarding/key-type-selector.tsx @@ -33,13 +33,13 @@ const OPTIONS: Array<{ ]; export function KeyTypeSelector({ value, onChange, disabled = false, className }: KeyTypeSelectorProps) { - const { t } = useTranslation(['onboarding', 'common']); + const { t } = useTranslation('onboarding'); return (
-
{t('onboarding:keyType.title')}
+
{t('keyType.title')}
-
+
{OPTIONS.map((option) => { const isSelected = value === option.value; return ( @@ -60,8 +60,8 @@ export function KeyTypeSelector({ value, onChange, disabled = false, className } >
-
{t(`onboarding:${option.titleKey}`)}
-
{t(`onboarding:${option.descKey}`)}
+
{t(option.titleKey as 'keyType.mnemonic' | 'keyType.arbitrary')}
+
{t(option.descKey as 'keyType.mnemonicDesc' | 'keyType.arbitraryDesc')}
{option.tags.map((tag) => ( {ui}) @@ -31,7 +31,7 @@ describe('calculateStrength', () => { }) describe('PasswordInput', () => { - const placeholder = testI18n.t('security:passwordConfirm.placeholder') + const placeholder = '请输入密码' it('renders password input', () => { renderWithProviders() @@ -41,15 +41,15 @@ describe('PasswordInput', () => { it('toggles password visibility', async () => { renderWithProviders() const input = screen.getByPlaceholderText(placeholder) - const toggleButton = screen.getByRole('button', { name: testI18n.t('a11y.showPassword') }) + const toggleButton = screen.getByRole('button', { name: '显示' }) expect(input).toHaveAttribute('type', 'password') await userEvent.click(toggleButton) expect(input).toHaveAttribute('type', 'text') - expect(screen.getByRole('button', { name: testI18n.t('a11y.hidePassword') })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '隐藏' })).toBeInTheDocument() - await userEvent.click(screen.getByRole('button', { name: testI18n.t('a11y.hidePassword') })) + await userEvent.click(screen.getByRole('button', { name: '隐藏' })) expect(input).toHaveAttribute('type', 'password') }) @@ -58,7 +58,7 @@ describe('PasswordInput', () => { const input = document.querySelector('input')! await userEvent.type(input, 'test') - const strengthLabel = testI18n.t('common:passwordStrength') + const strengthLabel = '强度' await waitFor(() => { const matches = screen.getAllByText( (_content, node) => node?.textContent?.includes(strengthLabel) ?? false, diff --git a/src/components/security/password-input.tsx b/src/components/security/password-input.tsx index 5337eb0c..d91b64cd 100644 --- a/src/components/security/password-input.tsx +++ b/src/components/security/password-input.tsx @@ -37,7 +37,7 @@ const PasswordInput = forwardRef( const [visible, setVisible] = useState(false); const [strength, setStrength] = useState('weak'); const [hasValue, setHasValue] = useState(!!value); - const { t } = useTranslation(); + const { t } = useTranslation('common'); const handleChange = (e: React.ChangeEvent) => { const inputValue = e.target.value; @@ -95,7 +95,7 @@ const PasswordInput = forwardRef(

{t('a11y.passwordStrength', { strength: config.label })}

{error || isErrorAnimating ? (

- {t('security:patternLock.error')} + {t('patternLock.error')}

) : selectedNodes.length === 0 ? (

- {t('security:patternLock.hint', { min: minPoints })} + {t('patternLock.hint', { min: minPoints })}

) : selectedNodes.length < minPoints ? (

- {t('security:patternLock.needMore', { current: selectedNodes.length, min: minPoints })} + {t('patternLock.needMore', { current: selectedNodes.length, min: minPoints })}

) : success ? (

- {t('security:patternLock.success')} + {t('patternLock.success')}

) : (

- {t('security:patternLock.valid', { count: selectedNodes.length })} + {t('patternLock.valid', { count: selectedNodes.length })}

)}
@@ -526,7 +526,7 @@ export function PatternLock({ data-testid={baseTestId ? `${baseTestId}-clear` : undefined} className="mx-auto block text-sm text-muted-foreground hover:text-foreground transition-colors" > - {t('security:patternLock.clear')} + {t('patternLock.clear')} )}
diff --git a/src/components/token/token-item.tsx b/src/components/token/token-item.tsx index 6547d4c2..bd0219db 100644 --- a/src/components/token/token-item.tsx +++ b/src/components/token/token-item.tsx @@ -36,7 +36,7 @@ interface TokenItemProps { export function TokenItem({ token, onClick, showChange = false, loading = false, className }: TokenItemProps) { const isClickable = !!onClick; - const { t } = useTranslation(); + const { t } = useTranslation(['currency', 'common']); const currency = useCurrency(); const shouldFetchRate = token.fiatValue !== undefined && currency !== 'USD'; @@ -59,10 +59,10 @@ export function TokenItem({ token, onClick, showChange = false, loading = false, const exchangeStatusMessage = shouldFetchRate && !canConvert ? exchangeRateError - ? t('currency:exchange.error') + ? t('exchange.error') : exchangeRateLoading - ? t('currency:exchange.loading') - : t('currency:exchange.unavailable') + ? t('exchange.loading') + : t('exchange.unavailable') : null; return ( @@ -71,7 +71,7 @@ export function TokenItem({ token, onClick, showChange = false, loading = false, tabIndex={isClickable ? 0 : undefined} onClick={onClick} onKeyDown={isClickable ? (e) => e.key === 'Enter' && onClick?.() : undefined} - aria-label={isClickable ? t('a11y.tokenDetails', { token: token.symbol }) : undefined} + aria-label={isClickable ? t('common:a11y.tokenDetails', { token: token.symbol }) : undefined} className={cn( '@container flex items-center gap-3 rounded-xl p-3 transition-colors', isClickable && 'hover:bg-muted/50 active:bg-muted cursor-pointer', diff --git a/src/components/transfer/address-input.tsx b/src/components/transfer/address-input.tsx index 7c6a9b24..c79c0ad6 100644 --- a/src/components/transfer/address-input.tsx +++ b/src/components/transfer/address-input.tsx @@ -41,7 +41,7 @@ const AddressInput = forwardRef( const [showDropdown, setShowDropdown] = useState(false); const [selectedIndex, setSelectedIndex] = useState(-1); const containerRef = useRef(null); - const { t } = useTranslation(); + const { t } = useTranslation('common'); const errorId = useId(); const listboxId = useId(); @@ -149,7 +149,7 @@ const AddressInput = forwardRef( onBlur={() => setTimeout(() => setFocused(false), 150)} onKeyDown={handleKeyDown} className="placeholder:text-muted-foreground min-w-0 flex-1 bg-transparent font-mono text-sm outline-none" - placeholder={t('common:addressPlaceholder')} + placeholder={t('addressPlaceholder')} autoComplete="off" autoCapitalize="off" autoCorrect="off" @@ -182,7 +182,7 @@ const AddressInput = forwardRef( aria-label={t('a11y.paste')} > - {t('common:paste')} + {t('paste')}
diff --git a/src/components/wallet/wallet-card.tsx b/src/components/wallet/wallet-card.tsx index 1520d3fb..4fb5f3dc 100644 --- a/src/components/wallet/wallet-card.tsx +++ b/src/components/wallet/wallet-card.tsx @@ -27,7 +27,7 @@ export function WalletCard({ onReceive, className, }: WalletCardProps) { - const { t } = useTranslation('wallet') + const { t } = useTranslation(['wallet', 'common']) return (
- {t('common:transfer')} + {t('transfer')} )} {onReceive && ( @@ -94,7 +94,7 @@ export function WalletCard({ onClick={onReceive} className="flex-1 py-2 px-4 bg-white/20 rounded-full text-sm font-medium hover:bg-white/30 transition-colors @xs:py-2.5 @xs:text-base" > - {t('common:receive')} + {t('receive')} )}
diff --git a/src/components/wallet/wallet-selector.tsx b/src/components/wallet/wallet-selector.tsx index 2875424a..bd19558d 100644 --- a/src/components/wallet/wallet-selector.tsx +++ b/src/components/wallet/wallet-selector.tsx @@ -73,7 +73,7 @@ function WalletItem({ wallet, isSelected, onSelect, notBackedUpLabel }: WalletIt * Wallet selector component for switching between multiple wallets */ export function WalletSelector({ wallets, selectedId, onSelect, onClose, className }: WalletSelectorProps) { - const { t } = useTranslation('common'); + const { t } = useTranslation(['common', 'wallet']); const handleSelect = (wallet: WalletInfo) => { onSelect?.(wallet); @@ -97,7 +97,7 @@ export function WalletSelector({ wallets, selectedId, onSelect, onClose, classNa wallet={wallet} isSelected={wallet.id === selectedId} onSelect={() => handleSelect(wallet)} - notBackedUpLabel={t('wallet:notBackedUp')} + notBackedUpLabel={t('notBackedUp')} /> ))}
diff --git a/src/i18n/i18next.d.ts b/src/i18n/i18next.d.ts new file mode 100644 index 00000000..9af7ffd9 --- /dev/null +++ b/src/i18n/i18next.d.ts @@ -0,0 +1,48 @@ +import 'i18next' + +import type authorize from './locales/zh-CN/authorize.json' +import type common from './locales/zh-CN/common.json' +import type currency from './locales/zh-CN/currency.json' +import type dweb from './locales/zh-CN/dweb.json' +import type empty from './locales/zh-CN/empty.json' +import type error from './locales/zh-CN/error.json' +import type guide from './locales/zh-CN/guide.json' +import type home from './locales/zh-CN/home.json' +import type migration from './locales/zh-CN/migration.json' +import type notification from './locales/zh-CN/notification.json' +import type onboarding from './locales/zh-CN/onboarding.json' +import type scanner from './locales/zh-CN/scanner.json' +import type security from './locales/zh-CN/security.json' +import type settings from './locales/zh-CN/settings.json' +import type staking from './locales/zh-CN/staking.json' +import type time from './locales/zh-CN/time.json' +import type token from './locales/zh-CN/token.json' +import type transaction from './locales/zh-CN/transaction.json' +import type wallet from './locales/zh-CN/wallet.json' + +declare module 'i18next' { + interface CustomTypeOptions { + defaultNS: 'common' + resources: { + authorize: typeof authorize + common: typeof common + currency: typeof currency + dweb: typeof dweb + empty: typeof empty + error: typeof error + guide: typeof guide + home: typeof home + migration: typeof migration + notification: typeof notification + onboarding: typeof onboarding + scanner: typeof scanner + security: typeof security + settings: typeof settings + staking: typeof staking + time: typeof time + token: typeof token + transaction: typeof transaction + wallet: typeof wallet + } + } +} diff --git a/src/i18n/locales/ar/authorize.json b/src/i18n/locales/ar/authorize.json index 4e7ecccb..c578bde2 100644 --- a/src/i18n/locales/ar/authorize.json +++ b/src/i18n/locales/ar/authorize.json @@ -55,5 +55,8 @@ "authFailed": "Authorization failed", "patternIncorrect": "Incorrect pattern", "timeout": "Request timed out" + }, + "passwordConfirm": { + "title": "Confirm Password" } } diff --git a/src/i18n/locales/ar/security.json b/src/i18n/locales/ar/security.json index a5491794..178d8324 100644 --- a/src/i18n/locales/ar/security.json +++ b/src/i18n/locales/ar/security.json @@ -14,7 +14,8 @@ "confirmTitle": "تأكيد قفل المحفظة", "confirmDesc": "ارسم النمط مرة أخرى للتأكيد", "unlockTitle": "فتح المحفظة", - "unlockDesc": "ارسم نمط قفل المحفظة", + "unlockDesc": "Draw your wallet lock pattern", + "verifyTitle": "Verify Wallet", "mismatch": "الأنماط غير متطابقة، حاول مرة أخرى" }, "backupTips": { @@ -39,7 +40,8 @@ }, "walletLock": { "unlockTitle": "فتح المحفظة", - "unlockDesc": "ارسم نمط قفل المحفظة", + "unlockDesc": "Draw your wallet lock pattern", + "verifyTitle": "Verify Wallet", "verifying": "جاري التحقق...", "error": "النمط غير صحيح، حاول مرة أخرى", "biometric": "استخدام البصمة", diff --git a/src/i18n/locales/en/authorize.json b/src/i18n/locales/en/authorize.json index 029e2f5a..3caa8077 100644 --- a/src/i18n/locales/en/authorize.json +++ b/src/i18n/locales/en/authorize.json @@ -55,5 +55,8 @@ "authFailed": "Authorization failed", "patternIncorrect": "Incorrect pattern", "timeout": "Request timed out" + }, + "passwordConfirm": { + "title": "Confirm Password" } } diff --git a/src/i18n/locales/en/security.json b/src/i18n/locales/en/security.json index 61fab2e0..60d41f45 100644 --- a/src/i18n/locales/en/security.json +++ b/src/i18n/locales/en/security.json @@ -15,6 +15,7 @@ "confirmDesc": "Draw the pattern again to confirm", "unlockTitle": "Unlock Wallet", "unlockDesc": "Draw your wallet lock pattern", + "verifyTitle": "Verify Wallet", "mismatch": "Patterns don't match, please try again" }, "backupTips": { @@ -40,6 +41,7 @@ "walletLock": { "unlockTitle": "Unlock Wallet", "unlockDesc": "Draw your wallet lock pattern", + "verifyTitle": "Verify Wallet", "verifying": "Verifying...", "error": "Incorrect pattern, please try again", "biometric": "Use Biometrics", diff --git a/src/i18n/locales/zh-CN/authorize.json b/src/i18n/locales/zh-CN/authorize.json index 4e595eff..d0b3886d 100644 --- a/src/i18n/locales/zh-CN/authorize.json +++ b/src/i18n/locales/zh-CN/authorize.json @@ -55,5 +55,8 @@ "authFailed": "授权失败", "patternIncorrect": "图案错误", "timeout": "请求已超时" + }, + "passwordConfirm": { + "title": "确认密码" } } diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 667383f4..3950ed17 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -1,4 +1,6 @@ { + "verifying": "验证中...", + "confirm": "确认", "transfer": "转账", "receive": "收款", "paste": "粘贴", diff --git a/src/i18n/locales/zh-CN/security.json b/src/i18n/locales/zh-CN/security.json index 6e5e19a4..c2df35db 100644 --- a/src/i18n/locales/zh-CN/security.json +++ b/src/i18n/locales/zh-CN/security.json @@ -1,4 +1,5 @@ { + "password": "密码", "patternLock": { "gridLabel": "{{size}}x{{size}} 图案锁", "nodeLabel": "第{{row}}行第{{col}}列节点{{order, number}}", @@ -40,12 +41,17 @@ "walletLock": { "unlockTitle": "解锁钱包", "unlockDesc": "请绘制钱包锁图案", + "verifyTitle": "验证钱包", "verifying": "验证中...", "error": "图案错误,请重试", "biometric": "使用生物识别", "cancel": "取消" }, + "passwordConfirm": { + "verifying": "验证中..." + }, "twoStepSecret": { + "confirmTitle": "确认安全密码", "setTitle": "设置安全密码", "tooShort": "安全密码至少6个字符", "lengthHint": "建议至少6位", diff --git a/src/i18n/locales/zh-TW/authorize.json b/src/i18n/locales/zh-TW/authorize.json index 0da7278c..4ab51aba 100644 --- a/src/i18n/locales/zh-TW/authorize.json +++ b/src/i18n/locales/zh-TW/authorize.json @@ -55,5 +55,8 @@ "authFailed": "授權失敗", "patternIncorrect": "圖案錯誤", "timeout": "請求已逾時" + }, + "passwordConfirm": { + "title": "Confirm Password" } } diff --git a/src/i18n/locales/zh-TW/security.json b/src/i18n/locales/zh-TW/security.json index d12ff126..a4a0f285 100644 --- a/src/i18n/locales/zh-TW/security.json +++ b/src/i18n/locales/zh-TW/security.json @@ -14,7 +14,8 @@ "confirmTitle": "確認錢包鎖", "confirmDesc": "請再次繪製圖案以確認", "unlockTitle": "解鎖錢包", - "unlockDesc": "請繪製錢包鎖圖案", + "unlockDesc": "Draw your wallet lock pattern", + "verifyTitle": "Verify Wallet", "mismatch": "兩次圖案不一致,請重新設定" }, "backupTips": { @@ -39,7 +40,8 @@ }, "walletLock": { "unlockTitle": "解鎖錢包", - "unlockDesc": "請繪製錢包鎖圖案", + "unlockDesc": "Draw your wallet lock pattern", + "verifyTitle": "Verify Wallet", "verifying": "驗證中...", "error": "圖案錯誤,請重試", "biometric": "使用生物辨識", diff --git a/src/pages/guide/WelcomeScreen.tsx b/src/pages/guide/WelcomeScreen.tsx index 1363fe00..99a08bf4 100644 --- a/src/pages/guide/WelcomeScreen.tsx +++ b/src/pages/guide/WelcomeScreen.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useMemo } from 'react'; import { useNavigation } from '@/stackflow'; import { useTranslation } from 'react-i18next'; import { @@ -28,8 +28,8 @@ export function resetWelcome(): void { interface WelcomeSlide { icon: React.ReactNode; - titleKey: string; - descriptionKey: string; + title: string; + description: string; } export function WelcomeScreen() { @@ -38,23 +38,23 @@ export function WelcomeScreen() { const [currentSlide, setCurrentSlide] = useState(0); const migration = useMigrationOptional(); - const slides: WelcomeSlide[] = [ + const slides: WelcomeSlide[] = useMemo(() => [ { icon: , - titleKey: 'welcome.slides.transfer.title', - descriptionKey: 'welcome.slides.transfer.description', + title: t('welcome.slides.transfer.title'), + description: t('welcome.slides.transfer.description'), }, { icon: , - titleKey: 'welcome.slides.multichain.title', - descriptionKey: 'welcome.slides.multichain.description', + title: t('welcome.slides.multichain.title'), + description: t('welcome.slides.multichain.description'), }, { icon: , - titleKey: 'welcome.slides.security.title', - descriptionKey: 'welcome.slides.security.description', + title: t('welcome.slides.security.title'), + description: t('welcome.slides.security.description'), }, - ]; + ], [t]); const currentSlideData = slides[currentSlide]; if (!currentSlideData) return null; @@ -102,8 +102,8 @@ export function WelcomeScreen() {
{currentSlideData.icon}
-

{t(currentSlideData.titleKey)}

-

{t(currentSlideData.descriptionKey)}

+

{currentSlideData.title}

+

{currentSlideData.description}

{/* Dots indicator */}
diff --git a/src/pages/history/index.tsx b/src/pages/history/index.tsx index 63af2a14..0fc5e4fa 100644 --- a/src/pages/history/index.tsx +++ b/src/pages/history/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { useNavigation } from '@/stackflow'; import { useTranslation } from 'react-i18next'; import { IconRefresh as RefreshCw, IconFilter as Filter } from '@tabler/icons-react'; @@ -11,14 +11,6 @@ import { cn } from '@/lib/utils'; import type { TransactionInfo } from '@/components/transaction/transaction-item'; import type { ChainType } from '@/stores'; -/** Period options - labels will be translated */ -const PERIOD_OPTIONS: { value: TransactionFilter['period']; labelKey: string }[] = [ - { value: 'all', labelKey: 'transaction:history.filter.allTime' }, - { value: '7d', labelKey: 'transaction:history.filter.days7' }, - { value: '30d', labelKey: 'transaction:history.filter.days30' }, - { value: '90d', labelKey: 'transaction:history.filter.days90' }, -]; - export function TransactionHistoryPage() { const { navigate, goBack } = useNavigation(); const currentWallet = useCurrentWallet(); @@ -27,13 +19,21 @@ export function TransactionHistoryPage() { const { t } = useTranslation(['transaction', 'common']); // 使用 TanStack Query 管理交易历史 const { transactions, isLoading, isFetching, filter, setFilter, refresh } = useTransactionHistoryQuery(currentWallet?.id); - const chainOptions: { value: ChainType | 'all'; labelKey?: string; label?: string }[] = [ - { value: 'all', labelKey: 'transaction:history.filter.allChains' }, + + const periodOptions = useMemo(() => [ + { value: 'all' as const, label: t('history.filter.allTime') }, + { value: '7d' as const, label: t('history.filter.days7') }, + { value: '30d' as const, label: t('history.filter.days30') }, + { value: '90d' as const, label: t('history.filter.days90') }, + ], [t]); + + const chainOptions = useMemo(() => [ + { value: 'all' as const, label: t('history.filter.allChains') }, ...enabledChains.map((chain) => ({ value: chain.id, label: chain.name, })), - ]; + ], [t, enabledChains]); // 初始化时设置默认过滤器为当前选中的网络 useEffect(() => { @@ -119,7 +119,7 @@ export function TransactionHistoryPage() { {chainOptions.map((option) => ( - {option.labelKey ? t(option.labelKey) : option.label} + {option.label} ))} @@ -134,9 +134,9 @@ export function TransactionHistoryPage() { - {PERIOD_OPTIONS.map((option) => ( - - {t(option.labelKey)} + {periodOptions.map((option) => ( + + {option.label} ))} diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx index ad01969a..84c3f78b 100644 --- a/src/pages/home/index.tsx +++ b/src/pages/home/index.tsx @@ -56,7 +56,7 @@ export function HomePage() { const clipboard = useClipboard(); const toast = useToast(); const haptics = useHaptics(); - const { t } = useTranslation(); + const { t } = useTranslation(['home', 'common']); const isInitialized = useWalletInitialized(); const hasWallet = useHasWallet(); @@ -79,7 +79,7 @@ export function HomePage() { await clipboard.write({ text: chainAddress.address }); await haptics.impact('light'); setCopied(true); - toast.show(t('home:wallet.addressCopied')); + toast.show(t('wallet.addressCopied')); setTimeout(() => setCopied(false), 2000); } }; @@ -109,7 +109,7 @@ export function HomePage() { data-testid="chain-selector" onClick={handleOpenChainSelector} className="mb-4 flex items-center gap-2 rounded-full bg-white/20 px-3 py-1.5 text-sm text-white" - aria-label={t('a11y.chainSelector')} + aria-label={t('common:a11y.chainSelector')} > {selectedChainName} @@ -126,7 +126,7 @@ export function HomePage() { @@ -138,13 +138,13 @@ export function HomePage() {
navigate({ to: '/send' })}> - {t('home:wallet.send')} + {t('wallet.send')}
navigate({ to: '/receive' })}> - {t('home:wallet.receive')} + {t('wallet.receive')}
@@ -152,7 +152,7 @@ export function HomePage() { {/* 资产列表 */}
-

{t('home:wallet.assets')}

+

{t('wallet.assets')}

({ symbol: token.symbol, @@ -167,8 +167,8 @@ export function HomePage() { // TODO: Implement token detail page route once available console.log('Token clicked:', token.symbol); }} - emptyTitle={t('home:wallet.noAssets')} - emptyDescription={t('home:wallet.noAssetsOnChain', { chain: selectedChainName })} + emptyTitle={t('wallet.noAssets')} + emptyDescription={t('wallet.noAssetsOnChain', { chain: selectedChainName })} />
@@ -176,7 +176,7 @@ export function HomePage() { @@ -186,7 +186,7 @@ export function HomePage() { function NoWalletView() { const { navigate } = useNavigation(); - const { t } = useTranslation(); + const { t } = useTranslation(['home', 'common']); return (
@@ -194,15 +194,15 @@ function NoWalletView() {
-

{t('home:welcome.title')}

-

{t('home:welcome.subtitle')}

+

{t('welcome.title')}

+

{t('welcome.subtitle')}

navigate({ to: '/wallet/create' })}> - {t('home:welcome.createWallet')} + {t('welcome.createWallet')}
diff --git a/src/pages/notifications/index.tsx b/src/pages/notifications/index.tsx index 8768d814..585cd7b8 100644 --- a/src/pages/notifications/index.tsx +++ b/src/pages/notifications/index.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import type { TFunction } from 'i18next'; import { useNavigation } from '@/stackflow'; import { IconBell as Bell, IconCheck as Check, IconTrash as Trash2 } from '@tabler/icons-react'; import { useStore } from '@tanstack/react-store'; @@ -56,7 +57,7 @@ function NotificationItem({ onRead: (id: string) => void; onRemove: (id: string) => void; formatRelativeTime: (timestamp: number) => string; - t: (key: string) => string; + t: TFunction<'notification'>; }) { const style = typeStyles[notification.type]; @@ -117,7 +118,7 @@ function GroupedNotificationList({ onRead: (id: string) => void; onRemove: (id: string) => void; formatRelativeTime: (timestamp: number) => string; - t: (key: string) => string; + t: TFunction<'notification'>; language: string; }) { const grouped = useMemo(() => { diff --git a/src/pages/settings/chain-config.tsx b/src/pages/settings/chain-config.tsx index 166d17fd..75acfbaf 100644 --- a/src/pages/settings/chain-config.tsx +++ b/src/pages/settings/chain-config.tsx @@ -50,7 +50,7 @@ function getManualConfigIds(input: string): string[] { } } -function getSourceLabel(t: (key: string) => string, source: ChainConfigSource): string { +function getSourceLabel(t: (key: string, options?: object) => string, source: ChainConfigSource): string { switch (source) { case 'default': return t('chainConfig.source.default'); @@ -311,7 +311,7 @@ export function ChainConfigPage() { {configs.map((config, index) => { const warning = warningById.get(config.id); const disabledByWarning = warning?.kind === 'incompatible_major'; - const sourceLabel = getSourceLabel(t, config.source); + const sourceLabel = getSourceLabel(t as (key: string, options?: object) => string, config.source); return (
diff --git a/src/pages/settings/change-wallet-lock.test.tsx b/src/pages/settings/change-wallet-lock.test.tsx index 345b4453..456f572f 100644 --- a/src/pages/settings/change-wallet-lock.test.tsx +++ b/src/pages/settings/change-wallet-lock.test.tsx @@ -4,7 +4,7 @@ import userEvent from '@testing-library/user-event' import { ChangeWalletLockPage } from './change-wallet-lock' import { TestI18nProvider, testI18n } from '@/test/i18n-mock' -const t = testI18n.getFixedT('zh-CN') +const t = testI18n.getFixedT('zh-CN', ['settings', 'common', 'security']) // Mock stackflow const mockGoBack = vi.fn() diff --git a/src/pages/settings/index.test.tsx b/src/pages/settings/index.test.tsx index bb756ae3..3d08809f 100644 --- a/src/pages/settings/index.test.tsx +++ b/src/pages/settings/index.test.tsx @@ -7,7 +7,7 @@ import { SettingsPage } from './index'; import { TestI18nProvider, testI18n } from '@/test/i18n-mock'; import { IconWallet as Wallet } from '@tabler/icons-react'; -const t = testI18n.getFixedT('zh-CN'); +const t = testI18n.getFixedT('zh-CN', ['settings', 'common', 'security']); // Mock stackflow const mockNavigate = vi.fn(); diff --git a/src/pages/wallet/create.tsx b/src/pages/wallet/create.tsx index 80a8fee6..f53bbca8 100644 --- a/src/pages/wallet/create.tsx +++ b/src/pages/wallet/create.tsx @@ -30,7 +30,7 @@ const STEPS: Step[] = ['pattern', 'mnemonic', 'verify', 'chains']; export function WalletCreatePage() { const { navigate, goBack } = useNavigation(); - const { t } = useTranslation(); + const { t } = useTranslation('onboarding'); const chainConfigs = useChainConfigs(); const [step, setStep] = useState('pattern'); const [patternKey, setPatternKey] = useState(''); @@ -152,7 +152,7 @@ export function WalletCreatePage() { await walletActions.createWallet( { - name: t('onboarding:create.defaultWalletName'), + name: t('create.defaultWalletName'), keyType: 'mnemonic', address: primaryChain.address, chain: primaryChain.chain, @@ -164,14 +164,14 @@ export function WalletCreatePage() { navigate({ to: '/' }); } catch (error) { - console.error(t('onboarding:create.createFailed'), error); + console.error(t('create.createFailed'), error); setIsCreating(false); } }; return (
- + {/* 进度指示器 */}
@@ -215,7 +215,7 @@ export function WalletCreatePage() { selectionCount={selectedChainIds.length} onSelectionChange={setSelectedChainIds} onComplete={handleComplete} - completeLabel={t('onboarding:create.complete')} + completeLabel={t('create.complete')} isSubmitting={isCreating} />
@@ -235,25 +235,25 @@ interface MnemonicStepProps { } function MnemonicStep({ mnemonic, hidden, copied, onToggleHidden, onCopy, onContinue }: MnemonicStepProps) { - const { t } = useTranslation(); + const { t } = useTranslation('onboarding'); const canContinue = copied || !hidden; return (
-

{t('onboarding:create.backupMnemonic')}

-

{t('onboarding:create.backupHint')}

+

{t('create.backupMnemonic')}

+

{t('create.backupHint')}

- {t('onboarding:create.mnemonicWarning')} + {t('create.mnemonicWarning')}
- {t('onboarding:create.mnemonicTitle')} + {t('create.mnemonicTitle')}
@@ -261,7 +261,7 @@ function MnemonicStep({ mnemonic, hidden, copied, onToggleHidden, onCopy, onCont
- {canContinue ? t('onboarding:create.mnemonicBackedUp') : t('onboarding:create.mnemonicViewFirst')} + {canContinue ? t('create.mnemonicBackedUp') : t('create.mnemonicViewFirst')} {canContinue && }
@@ -274,7 +274,7 @@ interface VerifyStepProps { } function VerifyStep({ mnemonic, onContinue }: VerifyStepProps) { - const { t } = useTranslation(); + const { t } = useTranslation(['onboarding', 'common']); const [selectedIndices] = useState(() => { const indices = Array.from({ length: mnemonic.length }, (_, i) => i); const shuffled = indices.sort(() => Math.random() - 0.5); @@ -297,7 +297,7 @@ function VerifyStep({ mnemonic, onContinue }: VerifyStepProps) { const word = mnemonic[index]; const answer = answers[index]; if (answer && word && answer.toLowerCase() !== word.toLowerCase()) { - return t('onboarding:create.wordIncorrect'); + return t('create.wordIncorrect'); } return undefined; }; @@ -306,20 +306,20 @@ function VerifyStep({ mnemonic, onContinue }: VerifyStepProps) {
-

{t('onboarding:create.verifyTitle')}

-

{t('onboarding:create.verifyDesc')}

+

{t('create.verifyTitle')}

+

{t('create.verifyDesc')}

{selectedIndices.map((index) => ( - + handleInputChange(index, e.target.value)} className={cn(getFieldError(index) && 'border-destructive focus-visible:ring-destructive')} - placeholder={t('onboarding:create.wordNPlaceholder', { n: index + 1 })} + placeholder={t('create.wordNPlaceholder', { n: index + 1 })} autoCapitalize="off" autoCorrect="off" /> @@ -365,8 +365,8 @@ function ChainSelectionStep({
-

{t('onboarding:chainSelector.title')}

-

{t('onboarding:chainSelector.subtitle')}

+

{t('chainSelector.title')}

+

{t('chainSelector.subtitle')}

{chains.length === 0 ? ( diff --git a/src/pages/wallet/list.tsx b/src/pages/wallet/list.tsx index c142c895..82db41fe 100644 --- a/src/pages/wallet/list.tsx +++ b/src/pages/wallet/list.tsx @@ -76,7 +76,7 @@ export function WalletListPage() { isActive={wallet.id === currentWalletId} onSelect={() => handleSelectWallet(wallet.id)} onDetail={() => handleWalletDetail(wallet.id)} - t={t} + t={t as (key: string, options?: unknown) => string} /> ))}
diff --git a/src/stackflow/activities/sheets/TwoStepSecretConfirmJob.tsx b/src/stackflow/activities/sheets/TwoStepSecretConfirmJob.tsx index 18c7e07b..852e382c 100644 --- a/src/stackflow/activities/sheets/TwoStepSecretConfirmJob.tsx +++ b/src/stackflow/activities/sheets/TwoStepSecretConfirmJob.tsx @@ -33,7 +33,7 @@ type TwoStepSecretConfirmJobParams = { }; function TwoStepSecretConfirmJobContent() { - const { t } = useTranslation(["security", "transaction"]); + const { t } = useTranslation(["security", "transaction", "common"]); const { pop } = useFlow(); const { title, description } = useActivityParams(); diff --git a/src/stackflow/activities/tabs/WalletTab.tsx b/src/stackflow/activities/tabs/WalletTab.tsx index 5aab2fa4..f32321f4 100644 --- a/src/stackflow/activities/tabs/WalletTab.tsx +++ b/src/stackflow/activities/tabs/WalletTab.tsx @@ -9,7 +9,7 @@ import { cn } from "@/lib/utils"; export function WalletTab() { const { push } = useFlow(); - const { t } = useTranslation(); + const { t } = useTranslation(['wallet', 'common']); const wallets = useWallets(); const currentWallet = useCurrentWallet(); const currentWalletId = currentWallet?.id; @@ -25,7 +25,7 @@ export function WalletTab() { return (
- +
{/* Wallet List */} @@ -78,7 +78,7 @@ export function WalletTab() { type="button" onClick={(e) => handleWalletSettings(e, wallet.id, wallet.name)} className="shrink-0 rounded-full p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors" - aria-label={t('wallet:detail.title')} + aria-label={t('detail.title')} > @@ -93,8 +93,8 @@ export function WalletTab() {
-

{t('wallet:empty')}

-

{t('wallet:emptyHint')}

+

{t('empty')}

+

{t('emptyHint')}

)} @@ -106,7 +106,7 @@ export function WalletTab() { onClick={() => push("WalletAddJob", {})} > - {t('wallet:add')} + {t('add')}
diff --git a/src/stackflow/components/TabBar.tsx b/src/stackflow/components/TabBar.tsx index 48a4a030..250bd8d2 100644 --- a/src/stackflow/components/TabBar.tsx +++ b/src/stackflow/components/TabBar.tsx @@ -1,4 +1,5 @@ import { cn } from "@/lib/utils"; +import { useMemo } from "react"; import { IconHome, IconWallet, @@ -12,24 +13,24 @@ export type TabId = "home" | "wallet" | "transfer" | "settings"; interface Tab { id: TabId; - labelKey: string; + label: string; icon: Icon; } -const tabConfigs: Tab[] = [ - { id: "home", labelKey: "a11y.tabHome", icon: IconHome }, - { id: "wallet", labelKey: "a11y.tabWallet", icon: IconWallet }, - { id: "transfer", labelKey: "a11y.tabTransfer", icon: IconArrowsExchange }, - { id: "settings", labelKey: "a11y.tabSettings", icon: IconSettings }, -]; - interface TabBarProps { activeTab: TabId; onTabChange: (tab: TabId) => void; } export function TabBar({ activeTab, onTabChange }: TabBarProps) { - const { t } = useTranslation(); + const { t } = useTranslation('common'); + + const tabConfigs: Tab[] = useMemo(() => [ + { id: "home", label: t('a11y.tabHome'), icon: IconHome }, + { id: "wallet", label: t('a11y.tabWallet'), icon: IconWallet }, + { id: "transfer", label: t('a11y.tabTransfer'), icon: IconArrowsExchange }, + { id: "settings", label: t('a11y.tabSettings'), icon: IconSettings }, + ], [t]); return (
@@ -37,7 +38,7 @@ export function TabBar({ activeTab, onTabChange }: TabBarProps) { {tabConfigs.map((tab) => { const Icon = tab.icon; const isActive = activeTab === tab.id; - const label = t(tab.labelKey); + const label = tab.label; return (