diff --git a/packages/cli/src/commands/batch.ts b/packages/cli/src/commands/batch.ts index 285182e..bcd0804 100644 --- a/packages/cli/src/commands/batch.ts +++ b/packages/cli/src/commands/batch.ts @@ -48,6 +48,7 @@ export function registerBatch(program: Command): void { url: string; timeout: number; verbose: boolean; + retries: number; json: boolean; retries: number; }>(); diff --git a/packages/cli/src/commands/watch.ts b/packages/cli/src/commands/watch.ts index 2496e1a..2ac0286 100644 --- a/packages/cli/src/commands/watch.ts +++ b/packages/cli/src/commands/watch.ts @@ -28,6 +28,7 @@ export function registerWatch(program: Command): void { url: string; timeout: number; verbose: boolean; + retries: number; json: boolean; retries: number; }>(); diff --git a/packages/ui/src/app/account/[address]/page.tsx b/packages/ui/src/app/account/[address]/page.tsx index 5fff880..b0b74f2 100644 --- a/packages/ui/src/app/account/[address]/page.tsx +++ b/packages/ui/src/app/account/[address]/page.tsx @@ -5,10 +5,18 @@ import { useParams, useRouter } from 'next/navigation'; import { fetchAccount } from '@/lib/api'; import type { AccountExplanation } from '@/types'; import { AccountResult } from '@/components/AccountResult'; +import { TransactionHistoryTab } from '@/components/account/TransactionHistoryTab'; import ErrorDisplay from '@/components/ErrorDisplay'; import AppShell from '@/components/AppShell'; import { useAppShell } from '@/components/AppShellContext'; +type AccountTab = 'overview' | 'history'; + +const TABS: { id: AccountTab; label: string }[] = [ + { id: 'overview', label: 'Overview' }, + { id: 'history', label: 'Recent Transactions' }, +]; + function AccountPageInner() { const { address } = useParams<{ address: string }>(); const router = useRouter(); @@ -17,6 +25,18 @@ function AccountPageInner() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState('overview'); + + // Sync tab with URL hash on mount + useEffect(() => { + const hash = window.location.hash.replace('#', ''); + if (hash === 'history') setActiveTab('history'); + }, []); + + const handleTabChange = (tab: AccountTab) => { + setActiveTab(tab); + window.history.replaceState(null, '', `#${tab}`); + }; const load = useCallback(async () => { if (!address) return; @@ -79,16 +99,39 @@ function AccountPageInner() { {error && !loading && } {data && !loading && ( - { - const entry = getEntry(address); - if (entry) removeAddress(entry.id); - }} - /> + <> + {/* Tab switcher — shown after account data loads */} +
+ {TABS.map((t) => ( + + ))} +
+ + {activeTab === 'overview' && ( + { + const entry = getEntry(address); + if (entry) removeAddress(entry.id); + }} + /> + )} + + {activeTab === 'history' && } + )} ); diff --git a/packages/ui/src/app/api/account/[address]/history/route.ts b/packages/ui/src/app/api/account/[address]/history/route.ts new file mode 100644 index 0000000..53565b4 --- /dev/null +++ b/packages/ui/src/app/api/account/[address]/history/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from 'next/server'; + +const API_URL = process.env.API_URL ?? 'http://localhost:4000'; + +export async function GET( + req: Request, + { params }: { params: Promise<{ address: string }> } +) { + const { address } = await params; + const { searchParams } = new URL(req.url); + + const query = new URLSearchParams(); + const limit = searchParams.get('limit'); + const cursor = searchParams.get('cursor'); + if (limit) query.set('limit', limit); + if (cursor) query.set('cursor', cursor); + + const qs = query.toString(); + const url = `${API_URL}/account/${address}/history${qs ? `?${qs}` : ''}`; + + try { + const res = await fetch(url, { headers: { Accept: 'application/json' } }); + const data = await res.json(); + return NextResponse.json(data, { status: res.status }); + } catch { + return NextResponse.json( + { error: { code: 'UPSTREAM_ERROR', message: 'Backend is unavailable' } }, + { status: 502 } + ); + } +} diff --git a/packages/ui/src/components/account/TransactionHistoryTab.tsx b/packages/ui/src/components/account/TransactionHistoryTab.tsx new file mode 100644 index 0000000..2472aaa --- /dev/null +++ b/packages/ui/src/components/account/TransactionHistoryTab.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { fetchAccountHistory } from '@/lib/api'; +import type { AccountHistoryTransaction } from '@/types'; +import { Card } from '@/components/Card'; +import { Label } from '@/components/Label'; + +interface TransactionHistoryTabProps { + address: string; +} + +function relativeTime(iso: string | null): string { + if (!iso) return '—'; + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60_000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.floor(hrs / 24)}d ago`; +} + +function RowSkeleton() { + return ( +
+
+
+
+
+
+
+
+ ); +} + +function TxRow({ tx }: { tx: AccountHistoryTransaction }) { + const router = useRouter(); + return ( + + ); +} + +export function TransactionHistoryTab({ address }: TransactionHistoryTabProps) { + const [transactions, setTransactions] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + const [hasMore, setHasMore] = useState(false); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const result = await fetchAccountHistory(address); + setTransactions(result.transactions); + setNextCursor(result.next_cursor); + setHasMore(result.has_more); + } catch { + setError('Failed to load transaction history. Please try again.'); + } finally { + setLoading(false); + } + }, [address]); + + const loadMore = async () => { + if (!nextCursor || loadingMore) return; + setLoadingMore(true); + try { + const result = await fetchAccountHistory(address, 10, nextCursor); + setTransactions((prev) => [...prev, ...result.transactions]); + setNextCursor(result.next_cursor); + setHasMore(result.has_more); + } catch { + setError('Failed to load more transactions.'); + } finally { + setLoadingMore(false); + } + }; + + useEffect(() => { + load(); + }, [load]); + + if (loading) { + return ( + + {Array.from({ length: 5 }).map((_, i) => ( + + ))} + + ); + } + + if (error) { + return ( + +

{error}

+ +
+ ); + } + + if (transactions.length === 0) { + return ( + + +

This account has no recorded transactions.

+
+ ); + } + + return ( +
+ + {transactions.map((tx) => ( + + ))} + + + {hasMore && ( + + )} +
+ ); +} diff --git a/packages/ui/src/lib/api.ts b/packages/ui/src/lib/api.ts index 2ca8ae9..0769c41 100644 --- a/packages/ui/src/lib/api.ts +++ b/packages/ui/src/lib/api.ts @@ -8,6 +8,7 @@ import type { TransactionExplanation, AccountExplanation, + AccountHistoryResponse, HealthResponse, ApiError, } from "@/types"; @@ -15,6 +16,8 @@ import type { export type { TransactionExplanation, AccountExplanation, + AccountHistoryResponse, + AccountHistoryTransaction, HealthResponse, ApiError, PaymentExplanation, @@ -68,4 +71,17 @@ export async function fetchHealth(): Promise { headers: { Accept: "application/json" }, }); return handleResponse(res); +} + +export async function fetchAccountHistory( + address: string, + limit = 10, + cursor?: string +): Promise { + const params = new URLSearchParams({ limit: String(limit) }); + if (cursor) params.set("cursor", cursor); + const res = await fetch(`/api/account/${address}/history?${params}`, { + headers: { Accept: "application/json" }, + }); + return handleResponse(res); } \ No newline at end of file diff --git a/packages/ui/src/types/index.ts b/packages/ui/src/types/index.ts index a9c919b..796ce67 100644 --- a/packages/ui/src/types/index.ts +++ b/packages/ui/src/types/index.ts @@ -43,6 +43,23 @@ export interface ApiError { }; } +export interface AccountHistoryTransaction { + transaction_hash: string; + successful: boolean; + summary: string; + ledger_closed_at: string | null; + ledger: number | null; + operation_count: number; + fee_explanation: string | null; +} + +export interface AccountHistoryResponse { + address: string; + transactions: AccountHistoryTransaction[]; + next_cursor: string | null; + has_more: boolean; +} + export type Tab = "tx" | "account"; export type PillVariant = "success" | "fail" | "default" | "warning"; \ No newline at end of file