Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ jobs:

- run: npm install

- name: Type-check TypeScript
working-directory: frontend
run: npm run typecheck

- name: Run tests with coverage
run: npm run test:coverage

Expand Down
77 changes: 77 additions & 0 deletions frontend/docs/typescript-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# TypeScript Migration Plan

## Status

**Phase 1 complete** — `src/utils/` is fully typed (8 files, ~300 lines).

TypeScript is configured in strict mode with `allowJs: true` / `checkJs: false` so the remaining `.jsx`/`.js` files compile without errors while migration continues incrementally.

---

## Completed

| Layer | Files | Status |
|-------|-------|--------|
| `src/utils/` | `validateAmount`, `validateStellarAddress`, `errorMessages`, `errorLogger`, `formatBalance`, `searchHighlighter`, `animations`, `webVitals` | ✅ Converted to `.ts` |

---

## Remaining migration order (lowest risk → highest risk)

### Phase 2 — Design system (standalone, no business logic)
Convert `.jsx` → `.tsx` for files in `src/design-system/`:
`Button`, `Input`, `Modal`, `Badge`, `Card` (plus their `.stories.jsx` files).

Each component has clear props and no external API calls; adding `interface Props` is mechanical.

### Phase 3 — Custom hooks
Convert `src/hooks/*.js` to `.ts`. Hooks have explicit inputs/outputs and no JSX, making them straightforward candidates after the design system.

Priority order: `useMessages`, `useNetworkStatus`, `useExchangeRate`, `useWebSocket`, `useOfflineQueue`, `usePWA`, `useRTL`, `useCopy`, `useFileUpload`, `useFocusTrap`.

### Phase 4 — Store and context
Convert `src/store/` and `src/contexts/`:
- `reducer.js` → `reducer.ts` (type the action union)
- `AppStateContext.jsx` → `AppStateContext.tsx`
- `persistence.js` → `persistence.ts`
- `tabSync.js` → `tabSync.ts`
- `ThemeContext.jsx` → `ThemeContext.tsx`

Define a `State` interface in `reducer.ts` and propagate it through context so all consumers get typed state.

### Phase 5 — Leaf components (no children/complex props)
`Spinner`, `CopyButton`, `StatusMessage`, `NetworkBadge`, `NetworkStatusBanner`, `XLMInfoIcon`, `LanguageSelector`, `FileUpload`, `NotificationBell`.

### Phase 6 — Feature components
`TransactionHistory`, `PaymentConfirmationModal`, `QRCodeModal`, `QRScanner`, `ImportAccountForm`, `ConfirmSendDialog`, `FeeDisplay`, `InlineConfirmation`, `AccountCreatedCelebration`, `TxLookup`, `AddressBook`, `AccountSettings`, `NotificationPreferences`, `BackupSettings`.

### Phase 7 — Complex / data-heavy components
`AMMPoolBrowser`, `StreamPayment`, `PathPayment`, `ConvertWidget`, `AccountRecovery`, `MultiSigTransactions`, `KYCForm`, `ComplianceDashboard`.

### Phase 8 — App entry point
`App.jsx` → `App.tsx` (convert last; it imports everything above and benefits most from having upstream types settled).

---

## Guidelines for each conversion

1. Rename `.jsx` → `.tsx` (or `.js` → `.ts`).
2. Add `interface Props { ... }` for component props; use `React.FC<Props>` or the equivalent function signature.
3. Replace `prop-types` guards with TypeScript types and remove the `prop-types` import.
4. Run `npm run typecheck` after each file to catch issues early.
5. Do not silence errors with `@ts-ignore` — fix them or open a follow-up issue.

## Enabling stricter checks incrementally

Once all files in a phase are converted, enable `checkJs: true` (or remove `allowJs`) and tighten flags:

```json
"noUncheckedIndexedAccess": true, // after Phase 4
"exactOptionalPropertyTypes": true // after Phase 7
```

---

## CI

`npm run typecheck` (`tsc --noEmit`) runs in CI on every push and pull request (see `.github/workflows/test.yml`).
6 changes: 5 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@
"analyze": "vite build && open stats.html",
"preview": "vite preview",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"lint": "eslint src",
"format": "prettier --write \"src/**/*.{js,jsx}\"",
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
},
Expand All @@ -35,11 +36,14 @@
"@storybook/addon-a11y": "^8.0.0",
"@storybook/addon-essentials": "^8.0.0",
"@storybook/react-vite": "^8.0.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.1.0",
"prettier": "^3.3.3",
"storybook": "^8.0.0",
"typescript": "^5.0.0",
"vite": "^5.3.3"
}
}
36 changes: 27 additions & 9 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, lazy, Suspense } from 'react';
import axios from 'axios';
import { motion, AnimatePresence, useReducedMotion } from 'framer-motion';
import { isValidStellarAddress } from './utils/validateStellarAddress';
Expand Down Expand Up @@ -35,8 +35,6 @@ import { FileUpload } from './components/FileUpload';
import { AccountCreatedCelebration } from './components/AccountCreatedCelebration';
import { TxLookup } from './components/TxLookup';
import { AddressBook } from './components/AddressBook';
import { MultiSigTransactions } from './components/MultiSigTransactions';
import { KYCForm } from './components/KYCForm';
import { NotificationPreferences } from './components/NotificationPreferences';
import { NotificationBell } from './components/NotificationBell';
import { useTheme } from './contexts/ThemeContext';
Expand All @@ -45,10 +43,15 @@ import { useExchangeRate } from './hooks/useExchangeRate';
import { useBalance, useSendPayment, useCreateAccount, useImportAccount, useKycStatus, useSaveAccountLabel, useNetworkStatusQuery } from './hooks/useQueryHooks';
import { AMMPoolBrowser } from './components/AMMPoolBrowser';
import { ConvertWidget } from './components/ConvertWidget';
import { AccountRecovery } from './components/AccountRecovery';
import { XLMInfoIcon } from './components/XLMInfoIcon';
import { ComplianceDashboard } from './components/ComplianceDashboard';
import { BackupSettings } from './components/BackupSettings';

// Heavy components loaded on-demand to keep the initial bundle small
const AMMPoolBrowser = lazy(() => import('./components/AMMPoolBrowser').then(m => ({ default: m.AMMPoolBrowser })));
const AccountRecovery = lazy(() => import('./components/AccountRecovery').then(m => ({ default: m.AccountRecovery })));
const MultiSigTransactions = lazy(() => import('./components/MultiSigTransactions').then(m => ({ default: m.MultiSigTransactions })));
const KYCForm = lazy(() => import('./components/KYCForm').then(m => ({ default: m.KYCForm })));
const ComplianceDashboard = lazy(() => import('./components/ComplianceDashboard').then(m => ({ default: m.ComplianceDashboard })));
const BackupSettings = lazy(() => import('./components/BackupSettings').then(m => ({ default: m.BackupSettings })));

const TIMEOUT_MS = 30000;
const KYC_LARGE_TRANSACTION_LIMIT = 1000;
Expand Down Expand Up @@ -930,12 +933,16 @@ function App() {

{/* AMM Pool Browser */}
<motion.div variants={v.fadeSlide}>
<AMMPoolBrowser />
<Suspense fallback={<Spinner />}>
<AMMPoolBrowser />
</Suspense>
</motion.div>

{/* Account Recovery */}
<motion.div variants={v.fadeSlide}>
<AccountRecovery />
<Suspense fallback={<Spinner />}>
<AccountRecovery />
</Suspense>
{/* Settings Sections Tabs */}
<motion.section className="section" variants={v.fadeSlide}>
<h2 style={{ marginBottom: 16 }}>Advanced Features</h2>
Expand Down Expand Up @@ -976,13 +983,19 @@ function App() {
<AnimatePresence mode="wait">
{activeSettingsSection === 'multisig' && (
<motion.div key="multisig" variants={v.fadeSlide} initial="hidden" animate="visible" exit="exit">
<Suspense fallback={<Spinner />}>
<MultiSigTransactions publicKey={account.publicKey} />
</Suspense>
<ErrorBoundary context="Multi-Sig Transactions">
<MultiSigTransactions publicKey={account.publicKey} />
</ErrorBoundary>
</motion.div>
)}
{activeSettingsSection === 'kyc' && (
<motion.div key="kyc" variants={v.fadeSlide} initial="hidden" animate="visible" exit="exit">
<Suspense fallback={<Spinner />}>
<KYCForm />
</Suspense>
<ErrorBoundary context="KYC Form">
<KYCForm />
</ErrorBoundary>
Expand Down Expand Up @@ -1105,13 +1118,18 @@ function App() {
)}

{showComplianceDashboard && (
<Suspense fallback={<Spinner />}>
<ComplianceDashboard onClose={() => setShowComplianceDashboard(false)} />
</Suspense>
<ErrorBoundary context="Compliance Dashboard">
<ComplianceDashboard onClose={() => setShowComplianceDashboard(false)} />
</ErrorBoundary>
)}

{showBackupSettings && (
<BackupSettings onClose={() => setShowBackupSettings(false)} />
<Suspense fallback={<Spinner />}>
<BackupSettings onClose={() => setShowBackupSettings(false)} />
</Suspense>
)}
</div>
</>
Expand Down
19 changes: 0 additions & 19 deletions frontend/src/utils/animations.js

This file was deleted.

35 changes: 35 additions & 0 deletions frontend/src/utils/animations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { Variants } from 'framer-motion';

interface AnimationVariants {
fadeSlide: Variants;
pop: Variants;
stagger: Variants;
}

interface TapScaleResult {
whileTap?: { scale: number };
whileHover?: { scale: number };
}

// Respects prefers-reduced-motion: pass `reducedMotion` (boolean) to disable transitions
export function makeVariants(reducedMotion: boolean): AnimationVariants {
return {
fadeSlide: {
hidden: { opacity: 0, y: reducedMotion ? 0 : 16 },
visible: { opacity: 1, y: 0, transition: { duration: reducedMotion ? 0 : 0.3, ease: 'easeOut' } },
exit: { opacity: 0, y: reducedMotion ? 0 : -8, transition: { duration: reducedMotion ? 0 : 0.2 } },
},
pop: {
hidden: { opacity: 0, scale: reducedMotion ? 1 : 0.92 },
visible: { opacity: 1, scale: 1, transition: { duration: reducedMotion ? 0 : 0.25, ease: 'easeOut' } },
exit: { opacity: 0, scale: reducedMotion ? 1 : 0.95, transition: { duration: reducedMotion ? 0 : 0.15 } },
},
stagger: {
visible: { transition: { staggerChildren: reducedMotion ? 0 : 0.07 } },
},
};
}

export function tapScale(reducedMotion: boolean): TapScaleResult {
return reducedMotion ? {} : { whileTap: { scale: 0.96 }, whileHover: { scale: 1.02 } };
}
22 changes: 0 additions & 22 deletions frontend/src/utils/errorLogger.js

This file was deleted.

35 changes: 35 additions & 0 deletions frontend/src/utils/errorLogger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
interface ErrorEntry {
message: string;
stack?: string;
timestamp: string;
[key: string]: unknown;
}

declare global {
interface Window {
__reportError?: (entry: ErrorEntry) => void;
}
}

// Lightweight error logger — logs locally and can be wired to an external service
const logs: ErrorEntry[] = [];

export function logError(error: unknown, info: Record<string, unknown> = {}): void {
const entry: ErrorEntry = {
message: (error as Error)?.message || String(error),
stack: (error as Error)?.stack,
timestamp: new Date().toISOString(),
...info,
};
logs.push(entry);
console.error('[ErrorLogger]', entry);

// Hook for external service (e.g. Sentry): window.__reportError?.(entry)
if (typeof window.__reportError === 'function') {
window.__reportError(entry);
}
}

export function getLogs(): ErrorEntry[] {
return [...logs];
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,25 @@
const ERROR_MAP = [
interface ErrorMatch {
match: RegExp;
message: string;
}

interface ApiError {
response?: {
data?: {
extras?: {
result_codes?: {
transaction?: string;
operations?: string[];
};
};
error?: string;
};
};
code?: string;
message?: string;
}

const ERROR_MAP: ErrorMatch[] = [
{ match: /insufficient balance/i, message: 'Insufficient balance to complete this payment.' },
{ match: /no account found|account not found|404/i, message: 'Destination account does not exist on the Stellar network.' },
{ match: /ECONNABORTED|ERR_NETWORK/i, message: 'Connection timed out — please check your internet connection.' },
Expand All @@ -8,7 +29,7 @@ const ERROR_MAP = [
{ match: /tx_failed/i, message: 'Transaction was rejected by the Stellar network.' },
];

const STELLAR_RESULT_CODES = {
const STELLAR_RESULT_CODES: Record<string, string> = {
// Transaction result codes
tx_success: 'Transaction completed successfully.',
tx_failed: 'Transaction failed.',
Expand Down Expand Up @@ -41,9 +62,11 @@ const STELLAR_RESULT_CODES = {
op_not_supported: 'Operation type is not supported.',
};

export function getFriendlyError(error) {
export function getFriendlyError(error: unknown): string {
const err = error as ApiError;

// Check for Stellar SDK result codes first
const resultCodes = error?.response?.data?.extras?.result_codes;
const resultCodes = err?.response?.data?.extras?.result_codes;
if (resultCodes) {
if (resultCodes.transaction) {
const txCode = resultCodes.transaction;
Expand All @@ -60,12 +83,12 @@ export function getFriendlyError(error) {
}

// Handle axios timeout (ECONNABORTED) and network errors (ERR_NETWORK) by error code
if (error?.code === 'ECONNABORTED' || error?.code === 'ERR_NETWORK') {
if (err?.code === 'ECONNABORTED' || err?.code === 'ERR_NETWORK') {
return 'Connection timed out — please check your internet connection.';
}

// Fall back to string matching
const raw = error?.response?.data?.error || error?.message || String(error);
const raw = err?.response?.data?.error || err?.message || String(error);
console.error('[Stellar Error]', raw);
const match = ERROR_MAP.find(e => e.match.test(raw));
return match ? match.message : `Something went wrong: ${raw}`;
Expand Down
Loading
Loading