Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,31 @@ on:
branches: [main, develop]

jobs:
i18n-check:
name: i18n – missing key gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 22

- name: Enable corepack (installs pnpm from packageManager field)
run: |
corepack enable
corepack prepare pnpm@10.28.2 --activate
pnpm --version

- run: pnpm install --frozen-lockfile

- name: Check i18n keys (EN vs ES, PT, FR)
run: pnpm i18n:check

build:
name: Build & type-check
runs-on: ubuntu-latest
needs: i18n-check

steps:
- uses: actions/checkout@v4
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"test": "playwright test",
"test:ui": "playwright test --ui",
"test:unit": "vitest run",
"i18n:check": "node scripts/check-i18n.js",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"test-storybook": "test-storybook"
Expand Down
91 changes: 91 additions & 0 deletions scripts/check-i18n.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
#!/usr/bin/env node
/**
* scripts/check-i18n.js
*
* CI gate: verifies that every key present in the reference locale (EN)
* exists in all other supported locales (ES, PT, FR).
*
* Exit code 0 → all locales are complete.
* Exit code 1 → one or more locales have missing keys.
*/

import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const I18N_DIR = join(__dirname, '..', 'src', 'i18n');

const REFERENCE_LOCALE = 'en';
const LOCALES_TO_CHECK = ['es', 'pt', 'fr'];

/**
* Recursively collect all dot-separated key paths from a nested object.
* @param {object} obj
* @param {string} prefix
* @returns {string[]}
*/
function collectKeys(obj, prefix = '') {
const keys = [];
for (const [k, v] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${k}` : k;
if (v !== null && typeof v === 'object' && !Array.isArray(v)) {
keys.push(...collectKeys(v, fullKey));
} else {
keys.push(fullKey);
}
}
return keys;
}

function loadJson(locale) {
const filePath = join(I18N_DIR, `${locale}.json`);
try {
return JSON.parse(readFileSync(filePath, 'utf-8'));
} catch (err) {
console.error(`❌ Could not read ${locale}.json: ${err.message}`);
process.exit(1);
}
}

const referenceData = loadJson(REFERENCE_LOCALE);
const referenceKeys = new Set(collectKeys(referenceData));

let hasErrors = false;

for (const locale of LOCALES_TO_CHECK) {
const data = loadJson(locale);
const localeKeys = new Set(collectKeys(data));

const missing = [...referenceKeys].filter((k) => !localeKeys.has(k));
const extra = [...localeKeys].filter((k) => !referenceKeys.has(k));

if (missing.length === 0 && extra.length === 0) {
console.log(`✅ ${locale}.json — complete (${localeKeys.size} keys)`);
} else {
hasErrors = true;
if (missing.length > 0) {
console.error(`\n❌ ${locale}.json — ${missing.length} missing key(s):`);
for (const key of missing) {
console.error(` - ${key}`);
}
}
if (extra.length > 0) {
const count = extra.length;
console.warn(
`\n⚠️ ${locale}.json — ${count} extra key(s) not in EN (ignored at runtime):`,
);
for (const key of extra) {
console.warn(` + ${key}`);
}
}
}
}

if (hasErrors) {
console.error('\n❌ i18n check failed — add missing keys before merging.\n');
process.exit(1);
} else {
console.log('\n✅ All locales are complete.\n');
process.exit(0);
}
6 changes: 0 additions & 6 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,6 @@ import { WalletConnect } from './WalletConnect';
import { LocaleSwitcher } from './LocaleSwitcher';
import { useTheme } from '@/context/ThemeContext';

const navLinks = [
{ to: '/send', label: 'Send' },
{ to: '/receive', label: 'Receive' },
{ to: '/vault', label: 'Vault' },
];

export function Header() {
const location = useLocation();
const { t } = useTranslation();
Expand Down
2 changes: 2 additions & 0 deletions src/components/LocaleSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next';
const LOCALES: { code: string; label: string }[] = [
{ code: 'en', label: 'EN' },
{ code: 'es', label: 'ES' },
{ code: 'pt', label: 'PT' },
{ code: 'fr', label: 'FR' },
];

export function LocaleSwitcher() {
Expand Down
52 changes: 27 additions & 25 deletions src/components/StellarHistory.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useStellarWallet } from '@/context/StellarWalletContext';
import { stellarTxUrl } from '@/lib/explorer';
import { useActivityStore, ActivityKind, ActivityStatus } from '@/stores/activityStore';

export function StellarHistory() {
const { t } = useTranslation();
const { address, isConnected } = useStellarWallet();
const { entries, clearHistory, pollPending } = useActivityStore();

Expand Down Expand Up @@ -37,10 +39,10 @@ export function StellarHistory() {
return (
<section className="flex flex-col gap-3">
<h1 className="font-heading text-[28px] font-bold uppercase tracking-tight text-on-surface">
History
{t('stellar.historyTitle')}
</h1>
<p className="font-body text-sm leading-relaxed text-on-surface-variant">
Connect your Stellar wallet to view your transaction history.
{t('stellar.connectHistoryPrompt')}
</p>
</section>
);
Expand All @@ -51,61 +53,61 @@ export function StellarHistory() {
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-2">
<span className="font-mono text-[10px] uppercase tracking-widest text-outline">
Stellar Testnet / XLM
{t('stellar.network')}
</span>
<h1 className="font-heading text-[28px] font-bold uppercase tracking-tight text-on-surface">
Activity History
{t('stellar.historyTitle')}
</h1>
</div>
<button
onClick={() => clearHistory('stellar', address ?? '')}
className="rounded-lg bg-surface-container px-4 py-2 font-mono text-xs uppercase tracking-widest text-on-surface transition-colors hover:bg-error/20 hover:text-error"
>
Clear History
{t('stellar.clearHistory')}
</button>
</div>

<div className="flex flex-wrap gap-4">
<div className="flex flex-col gap-1">
<label className="font-mono text-[10px] uppercase tracking-widest text-outline">
Type
{t('stellar.typeLabel')}
</label>
<select
value={filterKind}
onChange={(e) => setFilterKind(e.target.value as any)}
onChange={(e) => setFilterKind(e.target.value as ActivityKind | 'all')}
className="rounded-lg border border-outline bg-surface-container px-3 py-2 font-mono text-sm text-on-surface outline-none focus:border-tertiary"
>
<option value="all">All Types</option>
<option value="stealth-send">Stealth Send</option>
<option value="stealth-receive">Stealth Receive</option>
<option value="withdrawal">Withdrawal</option>
<option value="name-registration">Name Registration</option>
<option value="all">{t('stellar.allTypes')}</option>
<option value="stealth-send">{t('stellar.stealthSend')}</option>
<option value="stealth-receive">{t('stellar.stealthReceive')}</option>
<option value="withdrawal">{t('stellar.withdrawal')}</option>
<option value="name-registration">{t('stellar.nameRegistration')}</option>
</select>
</div>
<div className="flex flex-col gap-1">
<label className="font-mono text-[10px] uppercase tracking-widest text-outline">
Status
{t('stellar.statusLabel')}
</label>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value as any)}
onChange={(e) => setFilterStatus(e.target.value as ActivityStatus | 'all')}
className="rounded-lg border border-outline bg-surface-container px-3 py-2 font-mono text-sm text-on-surface outline-none focus:border-tertiary"
>
<option value="all">All Statuses</option>
<option value="pending">Pending</option>
<option value="confirmed">Confirmed</option>
<option value="failed">Failed</option>
<option value="all">{t('stellar.allStatuses')}</option>
<option value="pending">{t('stellar.pendingStatus')}</option>
<option value="confirmed">{t('stellar.confirmedStatus')}</option>
<option value="failed">{t('stellar.failedStatus')}</option>
</select>
</div>
</div>

{walletEntries.length === 0 && (
<p className="font-body text-sm text-on-surface-variant">No activity recorded yet.</p>
<p className="font-body text-sm text-on-surface-variant">{t('stellar.noActivityYet')}</p>
)}

{walletEntries.length > 0 && filteredEntries.length === 0 && (
<p className="font-body text-sm text-on-surface-variant">
No activity matches the filters.
{t('stellar.noActivityMatchesFilter')}
</p>
)}

Expand Down Expand Up @@ -138,17 +140,17 @@ export function StellarHistory() {
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between gap-4">
<span className="font-mono text-[10px] uppercase tracking-widest text-outline">
Direction
{t('stellar.direction')}
</span>
<span className="font-mono text-[10px] text-on-surface">
{tx.direction === 'in' ? 'Incoming (Received)' : 'Outgoing (Sent)'}
{tx.direction === 'in' ? t('stellar.directionIn') : t('stellar.directionOut')}
</span>
</div>

{tx.amount && (
<div className="flex items-center justify-between gap-4">
<span className="font-mono text-[10px] uppercase tracking-widest text-outline">
Amount
{t('common.amount')}
</span>
<span className="font-mono text-[10px] text-on-surface">{tx.amount} XLM</span>
</div>
Expand All @@ -157,7 +159,7 @@ export function StellarHistory() {
{tx.recipient && (
<div className="flex items-center justify-between gap-4">
<span className="font-mono text-[10px] uppercase tracking-widest text-outline">
Recipient / Address
{t('stellar.recipientAddress')}
</span>
<span
className="max-w-[200px] truncate font-mono text-[10px] text-on-surface"
Expand All @@ -173,7 +175,7 @@ export function StellarHistory() {
{tx.kind !== 'stealth-receive' && (
<div className="flex items-center justify-between gap-4">
<span className="font-mono text-[10px] uppercase tracking-widest text-outline">
Hash
{t('stellar.hash')}
</span>
<a
href={stellarTxUrl(tx.id)}
Expand Down
Loading