aboutsummaryrefslogtreecommitdiffstats
path: root/src/i18n.js
blob: 0c38520760ac3884d98e7a989ca1bea6d2609982 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
// Lightweight i18n. t('key', {vars}) interpolates {var} and falls back to the
// English string (then the key). The chosen language persists in localStorage;
// otherwise it's guessed from the browser. Arabic/Urdu render right-to-left.
//
// NOTE: non-English translations are machine-generated and would benefit from
// native-speaker review. The English table (en) is the source of truth.

export const LANGS = [
  ['en', 'English'],
  ['zh', '中文'],
  ['hi', 'हिन्दी'],
  ['es', 'Español'],
  ['fr', 'Français'],
  ['ar', 'العربية'],
  ['bn', 'বাংলা'],
  ['pt', 'Português'],
  ['ru', 'Русский'],
  ['ur', 'اردو'],
  ['id', 'Indonesia'],
  ['de', 'Deutsch'],
  ['ja', '日本語'],
  ['sw', 'Kiswahili'],
  ['mr', 'मराठी'],
  ['te', 'తెలుగు'],
  ['tr', 'Türkçe'],
  ['ta', 'தமிழ்'],
  ['vi', 'Tiếng Việt'],
  ['ko', '한국어'],
];

const RTL = new Set(['ar', 'ur']);
export const isRTL = (l) => RTL.has(l || lang);

const STR = {
  en: {
    appTitle: 'Hal Wallet',
    home: 'Home',
    logout: 'Logout',
    createNew: 'Create new',
    importExisting: 'Import existing',
    genIntro: 'Generate a new 12-word seed phrase. Write it down. It’s the only way to recover this wallet.',
    generateSeed: 'Generate seed phrase',
    writeDownWarn: '⚠ Write these 12 words down on paper, in order. Anyone with them can spend your coins.',
    copyPhrase: 'Copy phrase',
    regenerate: 'Regenerate',
    verifyBackup: 'Verify backup',
    skipVerification: 'Skip verification',
    confirmBackupIntro: 'Confirm your backup by entering the requested words.',
    wordN: 'Word #{n}',
    reenterPassphrase: 'Re-enter passphrase',
    back: '← Back',
    openWallet: 'Open wallet',
    wordsMismatch: 'Those words don’t match your phrase. Check your backup.',
    passphraseMismatch: 'That passphrase doesn’t match the one you entered.',
    seedPhrase: 'Seed phrase',
    seedPlaceholder: 'enter your seed words separated by spaces',
    passphrase: 'Passphrase',
    show: 'Show',
    hide: 'Hide',
    invalidSeed: 'Invalid seed phrase — check the spelling and word order.',
    keysNote: 'Keys never leave your browser. Works fully offline. Save this page and open it from your filesystem.',
    howItWorks: 'How it works',
    installApp: 'Install app',
    hiwBasicsTitle: 'The basics',
    hiwBasics1: 'When you make a wallet, the page generates twelve random words. Those words are the key to your wallet. Anyone who has them can spend the coins, so write them down on paper and keep them somewhere only you can get to.',
    hiwBasics2: 'If you lose the twelve words and don’t have a backup, the coins are gone for good. There’s no reset link and no one to call, so back them up before you put money in.',
    hiwBasics3: 'There’s no server and no account. The whole app is a single page that runs in your browser, so nobody holds your coins and nothing on a server ever sees your keys. To show your balance and history it reads from a public block explorer (mempool.space by default, or your own node if you set one in Settings).',
    hiwBasics4: 'You can even save this page and open it straight from your computer with no internet at all. It keeps working offline.',
    hiwBasics5: 'It follows the usual Bitcoin standards (a 12-word BIP39 phrase and BIP84 native SegWit keys), so if this site ever disappears you can restore the same phrase in Sparrow, Electrum, or any other standard wallet and your coins will be right there.',
    hiwTribute: 'The name Hal Wallet is a tribute to legendary cypherpunk and Bitcoin pioneer, Hal Finney (1956–2014).',
    balance: 'Balance',
    pending: 'Pending',
    tabReceive: 'Receive',
    tabSend: 'Send',
    tabHistory: 'History',
    tabSettings: 'Settings',
    copyAddress: 'Copy address',
    paymentReceived: 'Payment received!',
    tapToProceed: 'Tap anywhere to proceed',
    recipient: 'Recipient',
    recipients: 'Recipients',
    max: 'Max',
    feeRate: 'Fee rate',
    feeEconomy: 'Economy',
    feeNormal: 'Normal',
    feePriority: 'Priority',
    feeCustom: 'Custom',
    selectedRate: 'Selected rate: {n} sat/vB',
    coinSelection: 'Coin selection',
    automatic: 'Automatic',
    manual: 'Manual',
    noCoins: 'No coins available.',
    nSelected: '{n} selected',
    addRecipient: '+ Add recipient',
    reviewTx: 'Review transaction',
    remove: 'Remove',
    enterRecipientAddr: 'Enter a recipient address.',
    enterAddrForN: 'Enter an address for recipient {n}.',
    enterValidAmtForN: 'Enter a valid amount for recipient {n}.',
    selectCoin: 'Select at least one coin to spend.',
    networkFee: 'Network fee',
    offlineSignNote: 'Offline: sign now, then broadcast the signed transaction from an online device.',
    signBroadcast: 'Sign & broadcast',
    signTx: 'Sign transaction',
    signExport: 'Sign & export (broadcast elsewhere)',
    scanQr: 'Scan QR',
    scanHint: 'Point the camera at a QR code',
    scanNoCamera: 'Could not access the camera.',
    scanNoDecoder: 'QR scanning needs a connection the first time it’s used.',
    scanUnrecognized: 'Couldn’t read a Bitcoin address from that QR.',
    broadcastScanned: 'Broadcast transaction?',
    broadcastNow: 'Broadcast',
    bumpFee: 'Bump fee',
    bumpConfirm: 'Replace with a higher fee?',
    replaceTx: 'Replace',
    bumpInsufficient: 'Not enough funds to bump at this rate. Try a lower rate.',
    scanOffline: 'You’re offline — reconnect to broadcast this transaction.',
    broadcastFailed: 'Broadcast failed: {msg}',
    signingFailed: 'Signing failed: {msg}',
    done: 'Done',
    txSignedNote: 'Signed, but not sent yet. To actually send it, broadcast the code below from any online device (e.g. mempool.space → Broadcast). It will not send on its own.',
    transactionId: 'Transaction ID',
    signedTxRaw: 'Signed transaction (code)',
    copyHex: 'Copy code',
    downloadLabel: 'Download',
    showQrAirgap: 'Show QR (for offline transfer)',
    txBroadcast: '✓ Transaction broadcast!',
    copyTxid: 'Copy transaction ID',
    viewOnMempool: 'View on mempool.space ↗',
    noTxYet: 'No transactions yet.',
    loadingHistory: 'Loading transactions…',
    received: 'Received',
    sent: 'Sent',
    pendingTag: '⏳ pending',
    awaitingConfirmation: 'awaiting confirmation',
    feeShort: 'fee {x}',
    historyOffline: 'Transaction history is unavailable in offline mode.',
    status: 'Status',
    confirmed: 'Confirmed',
    confirmedTag: 'confirmed',
    pendingInMempool: 'Pending (in mempool)',
    block: 'Block',
    date: 'Date',
    copyId: 'Copy ID',
    backToHistory: '← Back to history',
    recoveryPhrase: 'Recovery phrase',
    giftWelcome: 'You’ve been sent Bitcoin',
    giftWelcomeBody: 'This wallet is yours now. Anyone with its recovery phrase controls the coins, so back it up before you do anything else.',
    giftBackup: 'Back up this wallet',
    giftSkip: 'Skip for now',
    giftBackedUp: 'I’ve written it down',
    giftLink: 'Gift this wallet',
    giftLinkDesc: 'Create a link that opens this wallet, with its funds, for onboarding someone fast.',
    giftLinkWarn: '⚠ Anyone who opens this link controls all the funds in this wallet. Only share it to give the wallet away, and keep the amount small.',
    giftLinkReveal: 'Create gift link',
    copyLink: 'Copy link',
    recoveryWarn: '⚠ Anyone who can see these words can steal your funds. Make sure nobody is watching before you reveal them.',
    bip39Passphrase: 'Passphrase',
    copyPassphrase: 'Copy passphrase',
    revealRecovery: 'Reveal recovery phrase',
    offlineTransfer: 'Offline transfer',
    offlineTransferDesc: 'Export your coins on an online device, then import the file on an offline device (with this wallet + seed) to sign without internet.',
    exportSnapshot: '⤓ Export snapshot',
    importSnapshot: 'Import snapshot…',
    rescan: 'Rescan',
    rescanDesc: 'Your current address updates automatically. Rescan only if a payment was sent to an old (reused) address and isn’t showing.',
    rescanWallet: 'Rescan wallet',
    scanning: 'Scanning…',
    explorer: 'Block explorer',
    explorerDesc: 'The server this wallet queries for balances and history. Point it at your own node for maximum privacy.',
    explorerUrl: 'Server URL',
    explorerUrlHint: 'Esplora / electrs REST base, e.g. https://mempool.space/api',
    deviceSync: 'Device sync',
    deviceSyncDesc: 'Keep this wallet in sync across your devices by storing an encrypted copy on a Nostr relay — only a device with your seed can read it. Turn it off to keep everything on this device.',
    syncAcross: 'Sync across devices',
    syncOn: 'On',
    syncOff: 'Off',
    relaysLabel: 'Relays',
    relaysHint: 'One wss:// URL per line.',
    language: 'Language',
    offlineBanner: "Can't reach the network — working offline. Import a snapshot on the Settings tab to load coins.",
    retry: 'Retry',
    copied: 'Copied to clipboard',
    snapshotExported: 'Snapshot exported',
    importedNCoins: 'Imported {n} coin(s)',
    unmatchedSuffix: ' · {n} address(es) not in this wallet',
    importFailed: 'Import failed: {msg}',
    copy: 'Copy',
    switchUnit: 'Switch BTC / sats',
    footerMadeBy: 'made by',
    footerSourceOn: 'source available on',
  },
};

// --- locale loading -------------------------------------------------------
// Only English is bundled; other languages are fetched on demand from
// locales/<code>.json (emitted by the build) and cached, so each visitor only
// downloads the language they use. t() falls back to English until it loads.
const _inflight = new Map();
export function loadLocale(code) {
  if (code === 'en' || STR[code] || !SUPPORTED.has(code)) return Promise.resolve();
  if (_inflight.has(code)) return _inflight.get(code);
  const p = fetch('locales/' + code + '.json')
    .then((r) => (r.ok ? r.json() : null))
    .then((d) => { if (d) STR[code] = d; })
    .catch(() => {})
    .finally(() => _inflight.delete(code));
  _inflight.set(code, p);
  return p;
}

// --- runtime --------------------------------------------------------------
const SUPPORTED = new Set(LANGS.map(([c]) => c));
const LANG_KEY = 'btc-wallet-lang';

function detect() {
  try {
    const q = new URLSearchParams(location.search).get('lang');
    if (q && SUPPORTED.has(q)) return q;
  } catch {}
  try {
    const saved = localStorage.getItem(LANG_KEY);
    if (saved && SUPPORTED.has(saved)) return saved;
  } catch {}
  try {
    for (const l of navigator.languages || [navigator.language || 'en']) {
      const code = String(l).toLowerCase().split('-')[0];
      if (SUPPORTED.has(code)) return code;
    }
  } catch {}
  return 'en';
}

let lang = detect();

export const getLang = () => lang;

export function setLang(l) {
  if (!SUPPORTED.has(l)) return;
  lang = l;
  try {
    localStorage.setItem(LANG_KEY, l);
  } catch {}
}

export function t(key, vars) {
  let s = (STR[lang] && STR[lang][key]) ?? STR.en[key] ?? key;
  if (vars) for (const k in vars) s = s.replaceAll('{' + k + '}', vars[k]);
  return s;
}