harb/web-app/src/components/TransactionHistory.vue

306 lines
7 KiB
Vue

<template>
<div class="tx-history">
<h3 class="tx-history__title">
Transaction History
<span class="tx-history__count" v-if="transactions.length">{{ transactions.length }}</span>
</h3>
<div v-if="loading" class="tx-history__loading">
<div class="spinner"></div>
Loading transactions
</div>
<div v-else-if="transactions.length === 0" class="tx-history__empty">No transactions found for this address.</div>
<div v-else class="tx-history__table-wrapper">
<table class="tx-table">
<thead>
<tr>
<th>Date</th>
<th>Type</th>
<th class="text-right">Amount (KRK)</th>
<th class="text-right">Value (ETH)</th>
<th>Tx</th>
</tr>
</thead>
<tbody>
<tr v-for="tx in transactions" :key="tx.id" :class="txRowClass(tx.type)">
<td class="tx-date">{{ formatDate(tx.timestamp) }}</td>
<td>
<span class="tx-type-badge" :class="txTypeClass(tx.type)">
{{ txTypeLabel(tx.type) }}
</span>
</td>
<td class="text-right mono">{{ formatKrk(tx.tokenAmount) }}</td>
<td class="text-right mono">{{ tx.ethAmount !== '0' ? formatEth(tx.ethAmount) : '—' }}</td>
<td>
<a :href="explorerTxUrl(tx.txHash)" target="_blank" rel="noopener noreferrer" class="tx-link" :title="tx.txHash">
{{ shortHash(tx.txHash) }}
</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue';
interface Transaction {
id: string;
holder: string;
type: string;
tokenAmount: string;
ethAmount: string;
timestamp: string;
blockNumber: number;
txHash: string;
}
const props = defineProps<{
address: string;
graphqlUrl?: string;
}>();
const transactions = ref<Transaction[]>([]);
const loading = ref(true);
const GRAPHQL_URL = props.graphqlUrl || '/api/graphql';
async function fetchTransactions(address: string) {
if (!address) {
loading.value = false;
return;
}
loading.value = true;
try {
const query = `{
transactionss(
where: { holder: "${address.toLowerCase()}" }
orderBy: "timestamp"
orderDirection: "desc"
limit: 50
) {
items {
id
holder
type
tokenAmount
ethAmount
timestamp
blockNumber
txHash
}
}
}`;
const res = await fetch(GRAPHQL_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
});
const data = await res.json();
transactions.value = data?.data?.transactionss?.items ?? [];
} catch (e) {
// eslint-disable-next-line no-console
console.error('Failed to fetch transactions:', e);
transactions.value = [];
} finally {
loading.value = false;
}
}
function formatDate(timestamp: string): string {
const ts = Number(timestamp) * 1000;
if (!ts) return '—';
const d = new Date(ts);
return (
d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' }) +
' ' +
d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })
);
}
function formatKrk(raw: string): string {
try {
const val = Number(BigInt(raw || '0')) / 1e18;
return val.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 4 });
} catch {
return '0.00';
}
}
function formatEth(raw: string): string {
try {
const val = Number(BigInt(raw || '0')) / 1e18;
return val.toLocaleString('en-US', { minimumFractionDigits: 4, maximumFractionDigits: 6 });
} catch {
return '0.0000';
}
}
function shortHash(hash: string): string {
if (!hash || hash.length < 12) return hash;
return `${hash.slice(0, 6)}${hash.slice(-4)}`;
}
function explorerTxUrl(hash: string): string {
// Base mainnet explorer; adjust for testnet if needed
return `https://basescan.org/tx/${hash}`;
}
function txTypeLabel(type: string): string {
const labels: Record<string, string> = {
buy: 'Buy',
sell: 'Sell',
stake: 'Stake',
unstake: 'Unstake',
snatch_in: 'Snatched In',
snatch_out: 'Snatched Out',
};
return labels[type] || type;
}
function txTypeClass(type: string): string {
if (['buy', 'unstake', 'snatch_in'].includes(type)) return 'tx-type--positive';
if (['sell', 'snatch_out'].includes(type)) return 'tx-type--negative';
return 'tx-type--neutral';
}
function txRowClass(type: string): string {
if (['buy', 'unstake'].includes(type)) return 'tx-row--positive';
if (['sell', 'snatch_out'].includes(type)) return 'tx-row--negative';
return '';
}
onMounted(() => fetchTransactions(props.address));
watch(
() => props.address,
addr => fetchTransactions(addr)
);
</script>
<style lang="sass" scoped>
.tx-history
display: flex
flex-direction: column
gap: 16px
&__title
display: flex
align-items: center
gap: 10px
font-size: 20px
color: #ffffff
margin: 0
&__count
background: rgba(117, 80, 174, 0.3)
color: #7550AE
border-radius: 99px
padding: 2px 10px
font-size: 14px
font-weight: 700
&__loading
display: flex
align-items: center
gap: 12px
color: #9A9898
padding: 24px
&__empty
color: #9A9898
padding: 24px
text-align: center
background: #07111B
border-radius: 12px
border: 1px solid rgba(255,255,255,0.07)
&__table-wrapper
overflow-x: auto
border-radius: 12px
border: 1px solid rgba(255,255,255,0.08)
.tx-table
width: 100%
border-collapse: collapse
font-size: 14px
th
text-align: left
padding: 12px 16px
color: #9A9898
font-size: 11px
text-transform: uppercase
letter-spacing: 1px
border-bottom: 1px solid rgba(255,255,255,0.08)
white-space: nowrap
td
padding: 12px 16px
border-bottom: 1px solid rgba(255,255,255,0.04)
color: #ffffff
.text-right
text-align: right
.mono
font-family: monospace
.tx-date
color: #9A9898
white-space: nowrap
font-size: 13px
.tx-type-badge
padding: 3px 10px
border-radius: 99px
font-size: 12px
font-weight: 600
text-transform: uppercase
letter-spacing: 0.5px
&.tx-type--positive
background: rgba(74, 222, 128, 0.12)
color: #4ADE80
&.tx-type--negative
background: rgba(248, 113, 113, 0.12)
color: #F87171
&.tx-type--neutral
background: rgba(117, 80, 174, 0.15)
color: #7550AE
.tx-row--positive td
background: rgba(74, 222, 128, 0.03)
.tx-row--negative td
background: rgba(248, 113, 113, 0.03)
.tx-link
color: #7550AE
text-decoration: none
font-family: monospace
font-size: 13px
white-space: nowrap
&:hover
text-decoration: underline
.spinner
width: 20px
height: 20px
border: 2px solid rgba(117, 80, 174, 0.3)
border-top-color: #7550AE
border-radius: 50%
animation: spin 0.8s linear infinite
@keyframes spin
to
transform: rotate(360deg)
</style>