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
6 changes: 6 additions & 0 deletions src/components/layout/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ export function Navbar() {
return () => window.removeEventListener('scroll', handle)
}, [])

useEffect(() => {
setMobileOpen(false)
// Intentionally only depends on pathname — setter is stable
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname])

return (
<nav
className="fixed top-0 w-full z-50 transition-all duration-300"
Expand Down
181 changes: 181 additions & 0 deletions src/pages/History.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useWallet } from '../hooks/useWallet'
import { fetchTransactions, stellarExpertUrl } from '../services/history.service'
import type { Transaction, TransactionType, TransactionStatus } from '../services/history.service'
import { Card } from '../components/ui/Card'
import { Button } from '../components/ui/Button'
import { Download } from 'lucide-react'

function formatDate(d: string) {
return new Date(d).toLocaleString()
}

export function History() {
const { address, isConnected } = useWallet()
const [loading, setLoading] = useState(false)
const [transactions, setTransactions] = useState<Transaction[]>([])
const [filterType, setFilterType] = useState('')
const [filterStatus, setFilterStatus] = useState('')
const [fromDate, setFromDate] = useState('')
const [toDate, setToDate] = useState('')

useEffect(() => {
if (!isConnected || !address) return

let isMounted = true

const load = async () => {
setLoading(true)
try {
const data = await fetchTransactions(address, {
type: (filterType || undefined) as TransactionType | undefined,
status: (filterStatus || undefined) as TransactionStatus | undefined,
fromDate: fromDate || undefined,
toDate: toDate || undefined,
})
if (isMounted) setTransactions(data)
} finally {
if (isMounted) setLoading(false)
}
}

load()

return () => {
isMounted = false
}
}, [isConnected, address, filterType, filterStatus, fromDate, toDate])

const pending = useMemo(
() => transactions.filter((t) => t.status === 'pending'),
[transactions]
)

function exportCsv() {
const rows = transactions.map((t) => ({
id: t.id,
hash: t.hash,
type: t.type,
amount: t.amount,
asset: t.asset || 'XLM',
from: t.from,
to: t.to,
status: t.status,
createdAt: t.createdAt,
}))
const csv = [Object.keys(rows[0] || {}).join(','), ...rows.map(r => Object.values(r).join(','))].join('\n')
const blob = new Blob([csv], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'transactions.csv'
a.click()
URL.revokeObjectURL(url)
}

if (!isConnected) {
return (
<div className="max-w-7xl mx-auto px-6 py-24 text-center">
<h2 className="font-display font-bold text-3xl text-text-primary mb-4">
Connect your wallet to view history
</h2>
<Link to="/dashboard">
<Button>Go to Dashboard</Button>
</Link>
</div>
)
}

return (
<div className="max-w-7xl mx-auto px-6 py-12">
<div className="mb-6 flex items-center justify-between">
<h1 className="font-display font-bold text-2xl">Transaction History</h1>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={exportCsv}>
<Download size={14} /> Export CSV
</Button>
</div>
</div>

<Card className="mb-6">
<div className="flex flex-wrap gap-4 items-end">
<div>
<label className="text-sm text-text-muted block mb-1">Type</label>
<select value={filterType} onChange={e => setFilterType(e.target.value)} className="p-2 rounded-lg bg-bg">
<option value="">All</option>
<option value="payment">Payment</option>
<option value="repayment">Repayment</option>
<option value="loan_disbursement">Disbursement</option>
</select>
</div>

<div>
<label className="text-sm text-text-muted block mb-1">Status</label>
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)} className="p-2 rounded-lg bg-bg">
<option value="">All</option>
<option value="pending">Pending</option>
<option value="confirmed">Confirmed</option>
<option value="failed">Failed</option>
</select>
</div>

<div>
<label className="text-sm text-text-muted block mb-1">From</label>
<input type="date" value={fromDate} onChange={e => setFromDate(e.target.value)} className="p-2 rounded-lg bg-bg" />
</div>

<div>
<label className="text-sm text-text-muted block mb-1">To</label>
<input type="date" value={toDate} onChange={e => setToDate(e.target.value)} className="p-2 rounded-lg bg-bg" />
</div>

</div>
</Card>

{pending.length > 0 && (
<Card className="mb-6">
<h3 className="font-semibold mb-3">Pending Transactions</h3>
<ul className="space-y-3">
{pending.map(t => (
<li key={t.id} className="flex justify-between">
<div>
<div className="font-medium">{t.type} — {t.amount} {t.asset || 'XLM'}</div>
<div className="text-sm text-text-muted">{formatDate(t.createdAt)}</div>
</div>
<div className="text-sm text-text-muted">{t.status}</div>
</li>
))}
</ul>
</Card>
)}

<div className="space-y-4">
{loading ? (
<Card>Loading...</Card>
) : (
transactions.sort((a,b)=>+new Date(b.createdAt)-+new Date(a.createdAt)).map(tx => (
<Card key={tx.id} hover>
<div className="flex justify-between items-start">
<div>
<div className="font-medium text-sm">{tx.type} — {tx.amount} {tx.asset || 'XLM'}</div>
<div className="text-sm text-text-muted">{formatDate(tx.createdAt)}</div>
<div className="text-sm mt-2">
<a href={stellarExpertUrl(tx)} target="_blank" rel="noreferrer" className="text-brand underline">View on Stellar Expert</a>
</div>
</div>
<div className="text-right">
<div className="text-sm text-text-muted mb-2">{tx.status}</div>
<details>
<summary className="cursor-pointer text-sm text-brand">Details</summary>
<pre className="text-xs mt-2 bg-bg p-2 rounded">{JSON.stringify(tx, null, 2)}</pre>
</details>
</div>
</div>
</Card>
))
)}
</div>
</div>
)
}
5 changes: 5 additions & 0 deletions src/router/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { SponsorOnboarding } from '../pages/SponsorOnboarding'
import { Vouch } from '../pages/Vouch'
import { LearnerProfile } from '../pages/LearnerProfile'
import { NotFound } from '../pages/NotFound'
import { History } from '../pages/History'

const router = createBrowserRouter([
{
Expand Down Expand Up @@ -53,6 +54,10 @@ const router = createBrowserRouter([
path: '/learner/:walletAddress',
element: <Layout><LearnerProfile /></Layout>,
},
{
path: '/history',
element: <Layout><History /></Layout>,
},
{
path: '*',
element: <Layout><NotFound /></Layout>,
Expand Down
54 changes: 54 additions & 0 deletions src/services/history.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { api } from './api'

export type TransactionStatus = 'pending' | 'confirmed' | 'failed'
export type TransactionType =
| 'payment'
| 'payment_request'
| 'loan_disbursement'
| 'repayment'
| 'other'

export interface Transaction {
id: string
hash: string
type: TransactionType
amount: number
asset?: string
from: string
to: string
status: TransactionStatus
createdAt: string
network: 'public' | 'testnet'
meta?: Record<string, unknown>
}

export interface TransactionFilter {
type?: TransactionType | ''
status?: TransactionStatus | ''
fromDate?: string
toDate?: string
}

export async function fetchTransactions(
walletAddress: string,
filters?: TransactionFilter
): Promise<Transaction[]> {
const params: Record<string, string> = {}
if (filters?.type) params.type = filters.type
if (filters?.status) params.status = filters.status
if (filters?.fromDate) params.from = filters.fromDate
if (filters?.toDate) params.to = filters.toDate

const res = await api.get<Transaction[]>(
`/wallets/${encodeURIComponent(walletAddress)}/transactions`,
{ params }
)
return res.data
}

export function stellarExpertUrl(tx: Transaction) {
const base = tx.network === 'testnet'
? 'https://stellar.expert/explorer/testnet/tx'
: 'https://stellar.expert/explorer/public/tx'
return `${base}/${tx.hash}`
}
Loading