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
129 changes: 74 additions & 55 deletions frontend/src/components/Dashboard.tsx
Original file line number Diff line number Diff line change
@@ -1,73 +1,45 @@
import { Input } from './UI';
import { useState, useEffect } from 'react'
import React, { useState, useMemo, useCallback } from 'react'
import { Input, PaginationControls } from './UI'
import { TransactionHistory } from './TransactionHistory'
import { useDebounce } from '../hooks/useDebounce'
import { useStellarContext } from '../context/StellarContext'
import { useTokens } from '../hooks/useTokens'
import { STELLAR_CONFIG } from '../config/stellar'
import type { TokenInfo } from '../types'

const PAGE_SIZE = 10

export const TokenDashboard: React.FC = () => {
const { stellarService } = useStellarContext()
const { wallet } = useWallet()
const [tokens, setTokens] = useState<TokenInfo[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const { tokens, isLoading, error, page, totalCount, totalPages, setPage } =
useTokens(PAGE_SIZE)

const [copiedAddress, setCopiedAddress] = useState<string | null>(null)
const [search, setSearch] = useState('')
const debouncedSearch = useDebounce(search, 300)

const loadTokens = useCallback(async () => {
if (!wallet.address) {
setTokens([])
setIsLoading(false)
return
}

setIsLoading(true)
setError(null)
try {
const tokenList = await stellarService.getTokensByCreator(wallet.address)
setTokens(tokenList)
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch tokens'
setError(message)
setTokens([])
} finally {
setIsLoading(false)
}
}, [wallet.address])

useEffect(() => {
loadTokens()
}, [loadTokens])

const handleCopyAddress = async (address: string) => {
const handleCopyAddress = useCallback(async (address: string) => {
try {
await navigator.clipboard.writeText(address)
setCopiedAddress(address)
setTimeout(() => setCopiedAddress(null), 1800)
} catch {
setError('Unable to copy token address. Check browser clipboard permissions and try again.')
// clipboard not available
}
}
}, [])

const formatCreationDate = useMemo(
() => (createdAt: number | undefined) => {
if (!createdAt) return 'Unknown'
return new Date(createdAt * 1000).toLocaleString()
},
[]
)
const formatCreationDate = useCallback((createdAt: number | undefined) => {
if (!createdAt) return 'Unknown'
return new Date(createdAt * 1000).toLocaleString()
}, [])

const results = useMemo(() => {
if (!search.trim()) return tokens
const query = search.toLowerCase()
const filteredTokens = useMemo(() => {
if (!debouncedSearch.trim()) return tokens
const query = debouncedSearch.toLowerCase()
return tokens.filter(
(t) =>
t.name.toLowerCase().includes(query) ||
t.symbol.toLowerCase().includes(query) ||
t.creator.toLowerCase().includes(query)
t.creator.toLowerCase().includes(query),
)
}, [tokens, search])
}, [tokens, debouncedSearch])

const factoryContractId = STELLAR_CONFIG.factoryContractId

Expand All @@ -78,13 +50,60 @@ export const TokenDashboard: React.FC = () => {
label="Search tokens"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by address or name..."
placeholder="Search by address, name or symbol..."
/>
<ul className="space-y-2">
{results.map((r, i) => (
<li key={i} className="p-2 border rounded text-sm">{JSON.stringify(r)}</li>
))}
</ul>

{isLoading && (
<p className="text-sm text-gray-500">Loading tokens...</p>
)}

{error && (
<p className="text-sm text-red-500">{error}</p>
)}

{!isLoading && !error && (
<>
<ul className="space-y-2">
{filteredTokens.length === 0 ? (
<li className="text-sm text-gray-500">No tokens found.</li>
) : (
filteredTokens.map((token, i) => (
<li
key={token.creator + i}
className="p-3 border rounded text-sm flex items-center justify-between gap-2"
>
<div>
<span className="font-medium">{token.name}</span>
<span className="ml-2 text-gray-500">({token.symbol})</span>
<div className="text-xs text-gray-400 mt-0.5">
Created: {formatCreationDate(token.createdAt)}
</div>
</div>
<button
onClick={() => handleCopyAddress(token.creator)}
className="text-xs text-blue-500 hover:underline shrink-0"
aria-label={`Copy address for ${token.name}`}
>
{copiedAddress === token.creator ? 'Copied!' : 'Copy address'}
</button>
</li>
))
)}
</ul>

{/* Only show pagination when not filtering by search */}
{!debouncedSearch.trim() && (
<PaginationControls
page={page}
totalPages={totalPages}
totalCount={totalCount}
pageSize={PAGE_SIZE}
onPrev={() => setPage(page - 1)}
onNext={() => setPage(page + 1)}
/>
)}
</>
)}
</div>

{factoryContractId && (
Expand Down
53 changes: 53 additions & 0 deletions frontend/src/components/UI/PaginationControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React from 'react'

interface PaginationControlsProps {
page: number
totalPages: number
totalCount: number
pageSize: number
onPrev: () => void
onNext: () => void
}

export const PaginationControls: React.FC<PaginationControlsProps> = ({
page,
totalPages,
totalCount,
pageSize,
onPrev,
onNext,
}) => {
const start = Math.min((page - 1) * pageSize + 1, totalCount)
const end = Math.min(page * pageSize, totalCount)

return (
<div className="flex items-center justify-between mt-4 text-sm text-gray-600">
<span>
{totalCount === 0
? 'No tokens found'
: `Showing ${start}–${end} of ${totalCount} token${totalCount !== 1 ? 's' : ''}`}
</span>
<div className="flex items-center gap-2">
<button
onClick={onPrev}
disabled={page <= 1}
aria-label="Previous page"
className="px-3 py-1 rounded border border-gray-300 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-gray-100 transition-colors"
>
Previous
</button>
<span className="px-2">
Page {page} of {totalPages}
</span>
<button
onClick={onNext}
disabled={page >= totalPages}
aria-label="Next page"
className="px-3 py-1 rounded border border-gray-300 disabled:opacity-40 disabled:cursor-not-allowed hover:bg-gray-100 transition-colors"
>
Next
</button>
</div>
</div>
)
}
1 change: 1 addition & 0 deletions frontend/src/components/UI/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export { Card } from './Card';
export { Spinner } from './Spinner';
export { ToastContainer } from './ToastContainer';
export { MainnetConfirmationModal } from './MainnetConfirmationModal';
export { PaginationControls } from './PaginationControls';
76 changes: 76 additions & 0 deletions frontend/src/hooks/useTokens.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { useState, useEffect, useCallback } from 'react'
import { useStellarContext } from '../context/StellarContext'
import { useWallet } from './useWallet'
import type { TokenInfo } from '../types'

const PAGE_SIZE_DEFAULT = 10

interface UseTokensResult {
tokens: TokenInfo[]
isLoading: boolean
error: string | null
page: number
totalCount: number
totalPages: number
setPage: (page: number) => void
reload: () => void
}

/**
* Fetches tokens created by the connected wallet, with page/pageSize pagination.
* Tokens are fetched all-at-once from the service and sliced client-side so
* we can show accurate totals without multiple round-trips.
*/
export function useTokens(pageSize: number = PAGE_SIZE_DEFAULT): UseTokensResult {
const { stellarService } = useStellarContext()
const { wallet } = useWallet()

const [allTokens, setAllTokens] = useState<TokenInfo[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)

const load = useCallback(async () => {
if (!wallet.address) {
setAllTokens([])
setIsLoading(false)
return
}

setIsLoading(true)
setError(null)
try {
const list = await stellarService.getTokensByCreator(wallet.address)
setAllTokens(list)
setPage(1) // reset to first page on reload
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch tokens')
setAllTokens([])
} finally {
setIsLoading(false)
}
}, [wallet.address, stellarService])

useEffect(() => {
load()
}, [load])

const totalCount = allTokens.length
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize))

// Clamp page in case tokens shrink
const safePage = Math.min(page, totalPages)
const startIndex = (safePage - 1) * pageSize
const tokens = allTokens.slice(startIndex, startIndex + pageSize)

return {
tokens,
isLoading,
error,
page: safePage,
totalCount,
totalPages,
setPage,
reload: load,
}
}
Loading