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
af10dcf4c6
commit
fad6486152
5 changed files with 203 additions and 49 deletions
|
|
@ -32,13 +32,16 @@
|
||||||
>
|
>
|
||||||
<div class="pnl-card__label">Unrealized P&L</div>
|
<div class="pnl-card__label">Unrealized P&L</div>
|
||||||
<div class="pnl-card__value">
|
<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>
|
||||||
<div class="pnl-card__percent">
|
<div class="pnl-card__percent">
|
||||||
{{ unrealizedPnlPercent >= 0 ? '+' : '' }}{{ unrealizedPnlPercent.toFixed(1) }}%
|
{{ unrealizedPnlPercent >= 0 ? '+' : '' }}{{ unrealizedPnlPercent.toFixed(1) }}%
|
||||||
</div>
|
</div>
|
||||||
<div class="pnl-card__detail">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -51,8 +54,9 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="value-card">
|
<div class="value-card">
|
||||||
<div class="value-card__label">ETH Backing</div>
|
<div class="value-card__label">ETH Backing</div>
|
||||||
<div class="value-card__value">{{ formatEth(ethBacking) }}</div>
|
<div class="value-card__value">{{ fmtEthUsd(ethBacking) }}</div>
|
||||||
<div class="value-card__unit">ETH</div>
|
<div class="value-card__unit" v-if="ethUsdPrice">{{ formatEthCompact(ethBacking) }}</div>
|
||||||
|
<div class="value-card__unit" v-else>ETH</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -68,7 +72,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
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 route = useRoute();
|
||||||
const addressParam = computed(() => String(route.params.address ?? ''));
|
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 } =
|
const { loading, error, balanceKrk, avgCostBasis, currentPriceEth, unrealizedPnlEth, unrealizedPnlPercent, ethBacking } =
|
||||||
useHolderDashboard(addressParam, graphqlUrl);
|
useHolderDashboard(addressParam, graphqlUrl);
|
||||||
|
|
||||||
|
const { ethUsdPrice } = useEthPrice();
|
||||||
|
|
||||||
const copied = ref(false);
|
const copied = ref(false);
|
||||||
|
|
||||||
const truncatedAddress = computed(() => {
|
const truncatedAddress = computed(() => {
|
||||||
|
|
@ -98,9 +104,11 @@ function formatKrk(val: number): string {
|
||||||
return val.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 4 });
|
return val.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 4 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatEth(val: number): string {
|
/** USD primary; ETH fallback when price not yet loaded */
|
||||||
if (!Number.isFinite(val)) return '0.0000';
|
function fmtEthUsd(val: number): string {
|
||||||
return val.toLocaleString('en-US', { minimumFractionDigits: 4, maximumFractionDigits: 6 });
|
if (!Number.isFinite(val)) return '—';
|
||||||
|
if (ethUsdPrice.value !== null) return formatUsd(val * ethUsdPrice.value);
|
||||||
|
return formatEthCompact(val);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -236,6 +244,17 @@ function formatEth(val: number): string {
|
||||||
.negative &
|
.negative &
|
||||||
color: #EF4444
|
color: #EF4444
|
||||||
|
|
||||||
|
&__value-secondary
|
||||||
|
font-size: 0.85rem
|
||||||
|
margin-top: 0.15rem
|
||||||
|
opacity: 0.6
|
||||||
|
|
||||||
|
.positive &
|
||||||
|
color: #10B981
|
||||||
|
|
||||||
|
.negative &
|
||||||
|
color: #EF4444
|
||||||
|
|
||||||
&__percent
|
&__percent
|
||||||
font-size: 1.2rem
|
font-size: 1.2rem
|
||||||
font-weight: 600
|
font-weight: 600
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
<div class="tx-history">
|
<div class="tx-history">
|
||||||
<h3 class="tx-history__title">
|
<h3 class="tx-history__title">
|
||||||
Transaction History
|
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>
|
</h3>
|
||||||
|
|
||||||
<div v-if="loading" class="tx-history__loading">
|
<div v-if="loading" class="tx-history__loading">
|
||||||
|
|
@ -10,7 +10,9 @@
|
||||||
Loading transactions…
|
Loading transactions…
|
||||||
</div>
|
</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">
|
<div v-else class="tx-history__table-wrapper">
|
||||||
<table class="tx-table">
|
<table class="tx-table">
|
||||||
|
|
@ -19,12 +21,12 @@
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
<th class="text-right">Amount (KRK)</th>
|
<th class="text-right">Amount (KRK)</th>
|
||||||
<th class="text-right">Value (ETH)</th>
|
<th class="text-right">Value</th>
|
||||||
<th>Tx</th>
|
<th>Tx</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<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 class="tx-date">{{ formatDate(tx.timestamp) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="tx-type-badge" :class="txTypeClass(tx.type)">
|
<span class="tx-type-badge" :class="txTypeClass(tx.type)">
|
||||||
|
|
@ -32,7 +34,16 @@
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right mono">{{ formatKrk(tx.tokenAmount) }}</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>
|
<td>
|
||||||
<a :href="explorerTxUrl(tx.txHash)" target="_blank" rel="noopener noreferrer" class="tx-link" :title="tx.txHash">
|
<a :href="explorerTxUrl(tx.txHash)" target="_blank" rel="noopener noreferrer" class="tx-link" :title="tx.txHash">
|
||||||
{{ shortHash(tx.txHash) }} ↗
|
{{ shortHash(tx.txHash) }} ↗
|
||||||
|
|
@ -47,6 +58,7 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted } from 'vue';
|
import { ref, computed, watch, onMounted } from 'vue';
|
||||||
|
import { useEthPrice, formatUsd } from '../composables/useEthPrice';
|
||||||
|
|
||||||
interface Transaction {
|
interface Transaction {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -67,13 +79,11 @@ const props = defineProps<{
|
||||||
|
|
||||||
const transactions = ref<Transaction[]>([]);
|
const transactions = ref<Transaction[]>([]);
|
||||||
const loading = ref(true);
|
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(() => {
|
const { ethUsdPrice } = useEthPrice();
|
||||||
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) {
|
async function fetchTransactions(address: string) {
|
||||||
if (!address) {
|
if (!address) {
|
||||||
|
|
@ -81,45 +91,86 @@ async function fetchTransactions(address: string) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const query = `{
|
const hasTypeFilter = props.typeFilter && props.typeFilter.length > 0;
|
||||||
transactionss(
|
const query = hasTypeFilter
|
||||||
where: { holder: "${address.toLowerCase()}" }
|
? `query TxHistory($holder: String!, $types: [String]) {
|
||||||
orderBy: "timestamp"
|
transactionss(
|
||||||
orderDirection: "desc"
|
where: { holder: $holder, type_in: $types }
|
||||||
limit: 50
|
orderBy: "timestamp"
|
||||||
) {
|
orderDirection: "desc"
|
||||||
items {
|
limit: 50
|
||||||
id
|
) {
|
||||||
holder
|
items {
|
||||||
type
|
id
|
||||||
tokenAmount
|
holder
|
||||||
ethAmount
|
type
|
||||||
timestamp
|
tokenAmount
|
||||||
blockNumber
|
ethAmount
|
||||||
txHash
|
timestamp
|
||||||
}
|
blockNumber
|
||||||
}
|
txHash
|
||||||
}`;
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
: `query TxHistory($holder: String!) {
|
||||||
|
transactionss(
|
||||||
|
where: { holder: $holder }
|
||||||
|
orderBy: "timestamp"
|
||||||
|
orderDirection: "desc"
|
||||||
|
limit: 50
|
||||||
|
) {
|
||||||
|
items {
|
||||||
|
id
|
||||||
|
holder
|
||||||
|
type
|
||||||
|
tokenAmount
|
||||||
|
ethAmount
|
||||||
|
timestamp
|
||||||
|
blockNumber
|
||||||
|
txHash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
|
||||||
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ query }),
|
body: JSON.stringify({ query, variables }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
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 ?? [];
|
transactions.value = data?.data?.transactionss?.items ?? [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error('Failed to fetch transactions:', e);
|
console.error('Failed to fetch transactions:', e);
|
||||||
|
error.value = e instanceof Error ? e.message : 'Failed to load transactions';
|
||||||
transactions.value = [];
|
transactions.value = [];
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
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 {
|
function formatDate(timestamp: string): string {
|
||||||
const ts = Number(timestamp) * 1000;
|
const ts = Number(timestamp) * 1000;
|
||||||
if (!ts) return '—';
|
if (!ts) return '—';
|
||||||
|
|
@ -133,20 +184,26 @@ function formatDate(timestamp: string): string {
|
||||||
|
|
||||||
function formatKrk(raw: string): string {
|
function formatKrk(raw: string): string {
|
||||||
try {
|
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 });
|
return val.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 4 });
|
||||||
} catch {
|
} catch {
|
||||||
return '0.00';
|
return '0.00';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatEth(raw: string): string {
|
function formatEthCell(raw: string): string {
|
||||||
try {
|
const eth = weiToEth(raw);
|
||||||
const val = Number(BigInt(raw || '0')) / 1e18;
|
if (eth === 0) return '0';
|
||||||
return val.toLocaleString('en-US', { minimumFractionDigits: 4, maximumFractionDigits: 6 });
|
if (eth >= 1) return `${eth.toFixed(2)} ETH`;
|
||||||
} catch {
|
if (eth >= 0.01) return `${eth.toFixed(4)} ETH`;
|
||||||
return '0.0000';
|
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 {
|
function shortHash(hash: string): string {
|
||||||
|
|
@ -219,6 +276,13 @@ watch(
|
||||||
color: #9A9898
|
color: #9A9898
|
||||||
padding: 24px
|
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
|
&__empty
|
||||||
color: #9A9898
|
color: #9A9898
|
||||||
padding: 24px
|
padding: 24px
|
||||||
|
|
@ -298,6 +362,11 @@ watch(
|
||||||
&:hover
|
&:hover
|
||||||
text-decoration: underline
|
text-decoration: underline
|
||||||
|
|
||||||
|
.tx-eth-sub
|
||||||
|
font-size: 11px
|
||||||
|
color: #9A9898
|
||||||
|
font-family: monospace
|
||||||
|
|
||||||
.spinner
|
.spinner
|
||||||
width: 20px
|
width: 20px
|
||||||
height: 20px
|
height: 20px
|
||||||
|
|
|
||||||
65
packages/ui-shared/src/composables/useEthPrice.ts
Normal file
65
packages/ui-shared/src/composables/useEthPrice.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -5,8 +5,8 @@ const POLL_INTERVAL_MS = 30_000;
|
||||||
function formatTokenAmount(rawWei: string, decimals = 18): number {
|
function formatTokenAmount(rawWei: string, decimals = 18): number {
|
||||||
try {
|
try {
|
||||||
const big = BigInt(rawWei);
|
const big = BigInt(rawWei);
|
||||||
const divisor = 10 ** decimals;
|
// Use BigInt arithmetic to avoid float64 precision loss at high values
|
||||||
return Number(big) / divisor;
|
return Number(big * 10000n / (10n ** BigInt(decimals))) / 10000;
|
||||||
} catch {
|
} catch {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
export { useHolderDashboard } from './composables/useHolderDashboard';
|
export { useHolderDashboard } from './composables/useHolderDashboard';
|
||||||
|
export { useEthPrice, formatUsd, formatEthCompact } from './composables/useEthPrice';
|
||||||
export { default as TransactionHistory } from './components/TransactionHistory.vue';
|
export { default as TransactionHistory } from './components/TransactionHistory.vue';
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue