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
1 change: 1 addition & 0 deletions packages/cli/src/commands/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export function registerBatch(program: Command): void {
url: string;
timeout: number;
verbose: boolean;
retries: number;
json: boolean;
retries: number;
}>();
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function registerWatch(program: Command): void {
url: string;
timeout: number;
verbose: boolean;
retries: number;
json: boolean;
retries: number;
}>();
Expand Down
63 changes: 53 additions & 10 deletions packages/ui/src/app/account/[address]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -17,6 +25,18 @@ function AccountPageInner() {
const [data, setData] = useState<AccountExplanation | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [activeTab, setActiveTab] = useState<AccountTab>('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;
Expand Down Expand Up @@ -79,16 +99,39 @@ function AccountPageInner() {
{error && !loading && <ErrorDisplay error={error} identifier={address} onRetry={load} />}

{data && !loading && (
<AccountResult
data={data}
isSaved={isSaved(address)}
savedLabel={getEntry(address)?.label}
onSave={saveAddress}
onRemoveSaved={() => {
const entry = getEntry(address);
if (entry) removeAddress(entry.id);
}}
/>
<>
{/* Tab switcher — shown after account data loads */}
<div className="flex gap-1 mb-5 p-1 rounded-lg bg-white/4 border border-white/8 w-fit">
{TABS.map((t) => (
<button
key={t.id}
onClick={() => handleTabChange(t.id)}
className={`px-4 py-1.5 rounded-md text-xs font-mono transition-all duration-150 ${
activeTab === t.id
? 'bg-sky-500/20 text-sky-300 border border-sky-500/30'
: 'text-white/35 hover:text-white/60'
}`}
>
{t.label}
</button>
))}
</div>

{activeTab === 'overview' && (
<AccountResult
data={data}
isSaved={isSaved(address)}
savedLabel={getEntry(address)?.label}
onSave={saveAddress}
onRemoveSaved={() => {
const entry = getEntry(address);
if (entry) removeAddress(entry.id);
}}
/>
)}

{activeTab === 'history' && <TransactionHistoryTab address={address} />}
</>
)}
</div>
);
Expand Down
31 changes: 31 additions & 0 deletions packages/ui/src/app/api/account/[address]/history/route.ts
Original file line number Diff line number Diff line change
@@ -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 }
);
}
}
166 changes: 166 additions & 0 deletions packages/ui/src/components/account/TransactionHistoryTab.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center gap-3 px-4 py-3 animate-pulse">
<div className="w-2 h-2 rounded-full bg-white/10 shrink-0" />
<div className="flex-1 space-y-1.5">
<div className="h-3 w-3/4 rounded bg-white/8" />
<div className="h-2.5 w-1/3 rounded bg-white/5" />
</div>
<div className="h-5 w-10 rounded bg-white/6" />
</div>
);
}

function TxRow({ tx }: { tx: AccountHistoryTransaction }) {
const router = useRouter();
return (
<button
onClick={() => router.push(`/tx/${tx.transaction_hash}`)}
className="w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-white/4 transition-colors duration-100 border-b border-white/6 last:border-0"
>
{/* status dot */}
<span
className={`w-2 h-2 rounded-full shrink-0 ${tx.successful ? 'bg-emerald-400' : 'bg-red-400'}`}
aria-label={tx.successful ? 'successful' : 'failed'}
/>

{/* summary */}
<div className="flex-1 min-w-0">
<p className="text-sm text-white/80 truncate leading-snug">{tx.summary}</p>
<p className="text-[11px] font-mono text-white/30 mt-0.5">
{relativeTime(tx.ledger_closed_at)}
</p>
</div>

{/* op count badge */}
<span className="shrink-0 inline-flex items-center px-1.5 py-0.5 rounded text-[11px] font-mono text-white/40 border border-white/10 bg-white/4">
{tx.operation_count} op{tx.operation_count !== 1 ? 's' : ''}
</span>

{/* chevron */}
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" className="shrink-0 text-white/20">
<path d="M4 2l4 4-4 4" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
);
}

export function TransactionHistoryTab({ address }: TransactionHistoryTabProps) {
const [transactions, setTransactions] = useState<AccountHistoryTransaction[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(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 (
<Card className="overflow-hidden p-0">
{Array.from({ length: 5 }).map((_, i) => (
<RowSkeleton key={i} />
))}
</Card>
);
}

if (error) {
return (
<Card>
<p className="text-sm text-red-400">{error}</p>
<button
onClick={load}
className="mt-3 text-xs font-mono text-sky-400 hover:text-sky-300 transition-colors"
>
Retry
</button>
</Card>
);
}

if (transactions.length === 0) {
return (
<Card>
<Label>No transactions</Label>
<p className="text-sm text-white/40 mt-1">This account has no recorded transactions.</p>
</Card>
);
}

return (
<div className="space-y-3">
<Card className="overflow-hidden p-0">
{transactions.map((tx) => (
<TxRow key={tx.transaction_hash} tx={tx} />
))}
</Card>

{hasMore && (
<button
onClick={loadMore}
disabled={loadingMore}
className="w-full py-2 text-xs font-mono text-sky-400 hover:text-sky-300 disabled:text-white/20 transition-colors"
>
{loadingMore ? 'Loading…' : 'Load more'}
</button>
)}
</div>
);
}
16 changes: 16 additions & 0 deletions packages/ui/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@
import type {
TransactionExplanation,
AccountExplanation,
AccountHistoryResponse,
HealthResponse,
ApiError,
} from "@/types";

export type {
TransactionExplanation,
AccountExplanation,
AccountHistoryResponse,
AccountHistoryTransaction,
HealthResponse,
ApiError,
PaymentExplanation,
Expand Down Expand Up @@ -68,4 +71,17 @@ export async function fetchHealth(): Promise<HealthResponse> {
headers: { Accept: "application/json" },
});
return handleResponse<HealthResponse>(res);
}

export async function fetchAccountHistory(
address: string,
limit = 10,
cursor?: string
): Promise<AccountHistoryResponse> {
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<AccountHistoryResponse>(res);
}
17 changes: 17 additions & 0 deletions packages/ui/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Loading