fix: Post-purchase holder dashboard on landing page (#150)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
openhands 2026-02-24 15:02:22 +00:00
parent af10dcf4c6
commit fad6486152
5 changed files with 203 additions and 49 deletions

View file

@ -32,13 +32,16 @@
>
<div class="pnl-card__label">Unrealized P&amp;L</div>
<div class="pnl-card__value">
{{ unrealizedPnlEth >= 0 ? '+' : '' }}{{ formatEth(unrealizedPnlEth) }} ETH
{{ unrealizedPnlEth >= 0 ? '+' : '' }}{{ fmtEthUsd(Math.abs(unrealizedPnlEth)) }}
</div>
<div class="pnl-card__value-secondary" v-if="ethUsdPrice">
{{ unrealizedPnlEth >= 0 ? '+' : '' }}{{ formatEthCompact(Math.abs(unrealizedPnlEth)) }}
</div>
<div class="pnl-card__percent">
{{ unrealizedPnlPercent >= 0 ? '+' : '' }}{{ unrealizedPnlPercent.toFixed(1) }}%
</div>
<div class="pnl-card__detail">
Avg cost: {{ formatEth(avgCostBasis) }} ETH/KRK · Current: {{ formatEth(currentPriceEth) }} ETH/KRK
Avg cost: {{ fmtEthUsd(avgCostBasis) }}/KRK · Current: {{ fmtEthUsd(currentPriceEth) }}/KRK
</div>
</div>
@ -51,8 +54,9 @@
</div>
<div class="value-card">
<div class="value-card__label">ETH Backing</div>
<div class="value-card__value">{{ formatEth(ethBacking) }}</div>
<div class="value-card__unit">ETH</div>
<div class="value-card__value">{{ fmtEthUsd(ethBacking) }}</div>
<div class="value-card__unit" v-if="ethUsdPrice">{{ formatEthCompact(ethBacking) }}</div>
<div class="value-card__unit" v-else>ETH</div>
</div>
</div>
@ -68,7 +72,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useHolderDashboard, TransactionHistory } from '@harb/ui-shared';
import { useHolderDashboard, useEthPrice, formatUsd, formatEthCompact, TransactionHistory } from '@harb/ui-shared';
const route = useRoute();
const addressParam = computed(() => String(route.params.address ?? ''));
@ -78,6 +82,8 @@ const graphqlUrl = `${window.location.origin}/api/graphql`;
const { loading, error, balanceKrk, avgCostBasis, currentPriceEth, unrealizedPnlEth, unrealizedPnlPercent, ethBacking } =
useHolderDashboard(addressParam, graphqlUrl);
const { ethUsdPrice } = useEthPrice();
const copied = ref(false);
const truncatedAddress = computed(() => {
@ -98,9 +104,11 @@ function formatKrk(val: number): string {
return val.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 4 });
}
function formatEth(val: number): string {
if (!Number.isFinite(val)) return '0.0000';
return val.toLocaleString('en-US', { minimumFractionDigits: 4, maximumFractionDigits: 6 });
/** USD primary; ETH fallback when price not yet loaded */
function fmtEthUsd(val: number): string {
if (!Number.isFinite(val)) return '—';
if (ethUsdPrice.value !== null) return formatUsd(val * ethUsdPrice.value);
return formatEthCompact(val);
}
</script>
@ -236,6 +244,17 @@ function formatEth(val: number): string {
.negative &
color: #EF4444
&__value-secondary
font-size: 0.85rem
margin-top: 0.15rem
opacity: 0.6
.positive &
color: #10B981
.negative &
color: #EF4444
&__percent
font-size: 1.2rem
font-weight: 600

View file

@ -2,7 +2,7 @@
<div class="tx-history">
<h3 class="tx-history__title">
Transaction History
<span class="tx-history__count" v-if="visibleTransactions.length">{{ visibleTransactions.length }}</span>
<span class="tx-history__count" v-if="transactions.length">{{ transactions.length }}</span>
</h3>
<div v-if="loading" class="tx-history__loading">
@ -10,7 +10,9 @@
Loading transactions
</div>
<div v-else-if="visibleTransactions.length === 0" class="tx-history__empty">No transactions found for this address.</div>
<div v-else-if="error" class="tx-history__error"> {{ error }}</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">
@ -19,12 +21,12 @@
<th>Date</th>
<th>Type</th>
<th class="text-right">Amount (KRK)</th>
<th class="text-right">Value (ETH)</th>
<th class="text-right">Value</th>
<th>Tx</th>
</tr>
</thead>
<tbody>
<tr v-for="tx in visibleTransactions" :key="tx.id" :class="txRowClass(tx.type)">
<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)">
@ -32,7 +34,16 @@
</span>
</td>
<td class="text-right mono">{{ formatKrk(tx.tokenAmount) }}</td>
<td class="text-right mono">{{ tx.ethAmount !== '0' ? formatEth(tx.ethAmount) : '—' }}</td>
<td class="text-right mono">
<template v-if="tx.ethAmount !== '0'">
<span :title="formatEthCell(tx.ethAmount)">
{{ ethUsdPrice ? formatCellUsd(tx.ethAmount) : formatEthCell(tx.ethAmount) }}
</span>
<br v-if="ethUsdPrice" />
<span v-if="ethUsdPrice" class="tx-eth-sub">{{ formatEthCell(tx.ethAmount) }}</span>
</template>
<template v-else></template>
</td>
<td>
<a :href="explorerTxUrl(tx.txHash)" target="_blank" rel="noopener noreferrer" class="tx-link" :title="tx.txHash">
{{ shortHash(tx.txHash) }}
@ -47,6 +58,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import { useEthPrice, formatUsd } from '../composables/useEthPrice';
interface Transaction {
id: string;
@ -67,13 +79,11 @@ const props = defineProps<{
const transactions = ref<Transaction[]>([]);
const loading = ref(true);
const error = ref<string | null>(null);
const GRAPHQL_URL = props.graphqlUrl || '/api/graphql';
const graphqlUrl = computed(() => 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));
});
const { ethUsdPrice } = useEthPrice();
async function fetchTransactions(address: string) {
if (!address) {
@ -81,11 +91,33 @@ async function fetchTransactions(address: string) {
return;
}
loading.value = true;
error.value = null;
try {
const query = `{
const hasTypeFilter = props.typeFilter && props.typeFilter.length > 0;
const query = hasTypeFilter
? `query TxHistory($holder: String!, $types: [String]) {
transactionss(
where: { holder: "${address.toLowerCase()}" }
where: { holder: $holder, type_in: $types }
orderBy: "timestamp"
orderDirection: "desc"
limit: 50
) {
items {
id
holder
type
tokenAmount
ethAmount
timestamp
blockNumber
txHash
}
}
}`
: `query TxHistory($holder: String!) {
transactionss(
where: { holder: $holder }
orderBy: "timestamp"
orderDirection: "desc"
limit: 50
@ -103,23 +135,42 @@ async function fetchTransactions(address: string) {
}
}`;
const res = await fetch(GRAPHQL_URL, {
const variables: Record<string, unknown> = { holder: address.toLowerCase() };
if (hasTypeFilter) variables.types = props.typeFilter;
const res = await fetch(graphqlUrl.value, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
body: JSON.stringify({ query, variables }),
});
const data = await res.json();
if (Array.isArray(data?.errors) && data.errors.length > 0) {
const msgs = data.errors.map((e: { message?: string }) => e.message ?? 'GraphQL error').join(', ');
throw new Error(msgs);
}
transactions.value = data?.data?.transactionss?.items ?? [];
} catch (e) {
// eslint-disable-next-line no-console
console.error('Failed to fetch transactions:', e);
error.value = e instanceof Error ? e.message : 'Failed to load transactions';
transactions.value = [];
} finally {
loading.value = false;
}
}
function weiToEth(raw: string): number {
try {
const big = BigInt(raw || '0');
return Number(big * 10000n / (10n ** 18n)) / 10000;
} catch {
return 0;
}
}
function formatDate(timestamp: string): string {
const ts = Number(timestamp) * 1000;
if (!ts) return '—';
@ -133,20 +184,26 @@ function formatDate(timestamp: string): string {
function formatKrk(raw: string): string {
try {
const val = Number(BigInt(raw || '0')) / 1e18;
const big = BigInt(raw || '0');
const val = Number(big * 10000n / (10n ** 18n)) / 10000;
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 formatEthCell(raw: string): string {
const eth = weiToEth(raw);
if (eth === 0) return '0';
if (eth >= 1) return `${eth.toFixed(2)} ETH`;
if (eth >= 0.01) return `${eth.toFixed(4)} ETH`;
if (eth >= 0.0001) return `${eth.toFixed(6)} ETH`;
return `${eth.toPrecision(4)} ETH`;
}
function formatCellUsd(raw: string): string {
if (!ethUsdPrice.value) return formatEthCell(raw);
return formatUsd(weiToEth(raw) * ethUsdPrice.value);
}
function shortHash(hash: string): string {
@ -219,6 +276,13 @@ watch(
color: #9A9898
padding: 24px
&__error
background: rgba(248, 113, 113, 0.1)
border: 1px solid rgba(248, 113, 113, 0.3)
border-radius: 12px
padding: 16px
color: #F87171
&__empty
color: #9A9898
padding: 24px
@ -298,6 +362,11 @@ watch(
&:hover
text-decoration: underline
.tx-eth-sub
font-size: 11px
color: #9A9898
font-family: monospace
.spinner
width: 20px
height: 20px

View file

@ -0,0 +1,65 @@
import { ref, onMounted, onUnmounted } from 'vue';
const ETH_PRICE_CACHE_MS = 5 * 60 * 1000; // 5 minutes
// Module-level cache shared across all composable instances on the same page
let _cachedPrice: number | null = null;
let _cacheTime = 0;
export function formatUsd(usd: number): string {
if (usd >= 1000) return `$${(usd / 1000).toFixed(1)}k`;
if (usd >= 1) return `$${usd.toFixed(2)}`;
if (usd >= 0.01) return `$${usd.toFixed(3)}`;
return `$${usd.toFixed(4)}`;
}
export function formatEthCompact(eth: number): string {
if (eth === 0) return '0 ETH';
if (eth >= 1) return `${eth.toFixed(2)} ETH`;
if (eth >= 0.01) return `${eth.toFixed(4)} ETH`;
if (eth >= 0.0001) return `${eth.toFixed(6)} ETH`;
return `${eth.toPrecision(4)} ETH`;
}
export function useEthPrice() {
const ethUsdPrice = ref<number | null>(_cachedPrice);
async function fetchEthPrice() {
const now = Date.now();
if (_cachedPrice !== null && now - _cacheTime < ETH_PRICE_CACHE_MS) {
ethUsdPrice.value = _cachedPrice;
return;
}
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const resp = await fetch(
'https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd',
{ signal: controller.signal },
);
clearTimeout(timeout);
if (!resp.ok) throw new Error('ETH price fetch failed');
const data = await resp.json();
if (data.ethereum?.usd) {
_cachedPrice = data.ethereum.usd;
_cacheTime = now;
ethUsdPrice.value = _cachedPrice;
}
} catch {
// Keep existing cached price or null; ETH fallback will be used
}
}
let interval: ReturnType<typeof setInterval> | null = null;
onMounted(async () => {
await fetchEthPrice();
interval = setInterval(() => void fetchEthPrice(), ETH_PRICE_CACHE_MS);
});
onUnmounted(() => {
if (interval) clearInterval(interval);
});
return { ethUsdPrice, fetchEthPrice };
}

View file

@ -5,8 +5,8 @@ const POLL_INTERVAL_MS = 30_000;
function formatTokenAmount(rawWei: string, decimals = 18): number {
try {
const big = BigInt(rawWei);
const divisor = 10 ** decimals;
return Number(big) / divisor;
// Use BigInt arithmetic to avoid float64 precision loss at high values
return Number(big * 10000n / (10n ** BigInt(decimals))) / 10000;
} catch {
return 0;
}

View file

@ -1,2 +1,3 @@
export { useHolderDashboard } from './composables/useHolderDashboard';
export { useEthPrice, formatUsd, formatEthCompact } from './composables/useEthPrice';
export { default as TransactionHistory } from './components/TransactionHistory.vue';