fix: Post-purchase holder dashboard on landing page (#150)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
058451792f
commit
e89fd4013d
11 changed files with 779 additions and 5 deletions
312
packages/ui-shared/src/components/TransactionHistory.vue
Normal file
312
packages/ui-shared/src/components/TransactionHistory.vue
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
<template>
|
||||
<div class="tx-history">
|
||||
<h3 class="tx-history__title">
|
||||
Transaction History
|
||||
<span class="tx-history__count" v-if="visibleTransactions.length">{{ visibleTransactions.length }}</span>
|
||||
</h3>
|
||||
|
||||
<div v-if="loading" class="tx-history__loading">
|
||||
<div class="spinner"></div>
|
||||
Loading transactions…
|
||||
</div>
|
||||
|
||||
<div v-else-if="visibleTransactions.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 visibleTransactions" :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, computed, 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;
|
||||
typeFilter?: string[];
|
||||
}>();
|
||||
|
||||
const transactions = ref<Transaction[]>([]);
|
||||
const loading = ref(true);
|
||||
|
||||
const GRAPHQL_URL = props.graphqlUrl || '/api/graphql';
|
||||
|
||||
const visibleTransactions = computed(() => {
|
||||
if (!props.typeFilter || props.typeFilter.length === 0) return transactions.value;
|
||||
return transactions.value.filter(tx => props.typeFilter!.includes(tx.type));
|
||||
});
|
||||
|
||||
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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue