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 12:34:36 +00:00
parent 058451792f
commit e89fd4013d
11 changed files with 779 additions and 5 deletions

View file

@ -18,6 +18,7 @@
},
"dependencies": {
"@harb/web3": "*",
"@harb/ui-shared": "*",
"@tanstack/vue-query": "^5.92.9",
"@wagmi/vue": "^0.2.8",
"sass": "^1.83.4",

View file

@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue';
import { RouterLink } from 'vue-router';
import { useAccount } from '@harb/web3';
const { address, isConnected } = useAccount();
@ -78,7 +79,7 @@ const pnlPercent = computed(() => {
const pnlClass = computed(() => (pnlPercent.value >= 0 ? 'positive' : 'negative'));
const appUrl = computed(() => `/app/wallet/${address.value}`);
const walletRoute = computed(() => ({ name: 'wallet', params: { address: address.value } }));
</script>
<template>
@ -87,7 +88,7 @@ const appUrl = computed(() => `/app/wallet/${address.value}`);
<div v-if="avgCost > 0" class="wallet-card__pnl">
{{ pnlPercent >= 0 ? '+' : '' }}{{ pnlPercent.toFixed(1) }}%
</div>
<a :href="appUrl" class="wallet-card__link">View Dashboard </a>
<RouterLink :to="walletRoute" class="wallet-card__link">View Dashboard </RouterLink>
</div>
<div v-else-if="isConnected && !loading && !hasPosition" class="wallet-card wallet-card--empty">
<span>No KRK yet</span>

View file

@ -20,6 +20,11 @@ const router = createRouter({
name: 'mixed',
component: () => import('../views/HomeViewMixed.vue'),
},
{
path: '/wallet/:address',
name: 'wallet',
component: () => import('../views/HolderDashboardView.vue'),
},
{
path: '/docs',
name: 'Docs',

View file

@ -0,0 +1,288 @@
<template>
<div class="holder-dashboard">
<!-- Header -->
<div class="holder-dashboard__header">
<div class="holder-dashboard__addr-block">
<span class="holder-dashboard__label">Wallet</span>
<div class="holder-dashboard__addr-row">
<span class="holder-dashboard__addr">{{ truncatedAddress }}</span>
<button class="copy-btn" :class="{ copied }" @click="copyAddress" title="Copy address">
{{ copied ? '✓' : '⎘' }}
</button>
</div>
</div>
<div class="holder-dashboard__balance-block">
<span class="holder-dashboard__balance-num">{{ formatKrk(balanceKrk) }}</span>
<span class="holder-dashboard__balance-sym">KRK</span>
</div>
</div>
<!-- Loading / Error -->
<div v-if="loading && balanceKrk === 0" class="holder-dashboard__loading">
<div class="spinner"></div>
<p>Loading wallet data</p>
</div>
<div v-if="error" class="holder-dashboard__error"> {{ error }}</div>
<!-- P&L Card -->
<div
v-if="avgCostBasis > 0"
class="pnl-card"
:class="{ positive: unrealizedPnlEth >= 0, negative: unrealizedPnlEth < 0 }"
>
<div class="pnl-card__label">Unrealized P&amp;L</div>
<div class="pnl-card__value">
{{ unrealizedPnlEth >= 0 ? '+' : '' }}{{ formatEth(unrealizedPnlEth) }} ETH
</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
</div>
</div>
<!-- Value Cards -->
<div class="value-cards">
<div class="value-card">
<div class="value-card__label">KRK Balance</div>
<div class="value-card__value">{{ formatKrk(balanceKrk) }}</div>
<div class="value-card__unit">KRK</div>
</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>
</div>
<!-- Transaction History (buy/sell only) -->
<TransactionHistory
:address="addressParam"
:graphql-url="graphqlUrl"
:type-filter="['buy', 'sell']"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useHolderDashboard, TransactionHistory } from '@harb/ui-shared';
const route = useRoute();
const addressParam = computed(() => String(route.params.address ?? ''));
const graphqlUrl = `${window.location.origin}/api/graphql`;
const { loading, error, balanceKrk, avgCostBasis, currentPriceEth, unrealizedPnlEth, unrealizedPnlPercent, ethBacking } =
useHolderDashboard(addressParam, graphqlUrl);
const copied = ref(false);
const truncatedAddress = computed(() => {
const a = addressParam.value;
if (!a || a.length < 10) return a;
return `${a.slice(0, 6)}${a.slice(-4)}`;
});
function copyAddress() {
navigator.clipboard.writeText(addressParam.value).then(() => {
copied.value = true;
setTimeout(() => (copied.value = false), 1500);
});
}
function formatKrk(val: number): string {
if (!Number.isFinite(val)) return '0.00';
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 });
}
</script>
<style lang="sass" scoped>
.holder-dashboard
display: flex
flex-direction: column
gap: 28px
padding: 16px
max-width: 1200px
margin: 0 auto
@media (min-width: 992px)
padding: 48px
// Header
.holder-dashboard__header
background: linear-gradient(135deg, rgba(117, 80, 174, 0.15) 0%, rgba(117, 80, 174, 0.05) 100%)
border: 1px solid rgba(117, 80, 174, 0.3)
border-radius: 20px
padding: 28px 32px
display: flex
flex-direction: column
gap: 16px
@media (min-width: 768px)
flex-direction: row
align-items: center
justify-content: space-between
.holder-dashboard__label
font-size: 12px
text-transform: uppercase
letter-spacing: 1.5px
color: #7550AE
margin-bottom: 6px
display: block
.holder-dashboard__addr-row
display: flex
align-items: center
gap: 10px
.holder-dashboard__addr
font-size: 16px
color: #ffffff
font-family: monospace
word-break: break-all
.holder-dashboard__balance-block
display: flex
align-items: baseline
gap: 8px
.holder-dashboard__balance-num
font-size: 36px
color: #7550AE
@media (min-width: 768px)
font-size: 44px
.holder-dashboard__balance-sym
font-size: 18px
color: #9A9898
.copy-btn
background: rgba(117, 80, 174, 0.2)
border: 1px solid rgba(117, 80, 174, 0.4)
border-radius: 6px
color: #ffffff
cursor: pointer
font-size: 16px
padding: 4px 10px
transition: background 0.2s
&:hover
background: rgba(117, 80, 174, 0.4)
&.copied
color: #4ADE80
border-color: #4ADE80
// Loading / Error
.holder-dashboard__loading
display: flex
align-items: center
gap: 12px
color: #9A9898
padding: 16px
.holder-dashboard__error
background: rgba(248, 113, 113, 0.1)
border: 1px solid rgba(248, 113, 113, 0.3)
border-radius: 12px
padding: 16px
color: #F87171
.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)
// P&L Card
.pnl-card
background: rgba(255,255,255,0.06)
border: 1px solid rgba(255,255,255,0.15)
border-radius: 16px
padding: 1.5rem
text-align: center
&.positive
border-color: rgba(16, 185, 129, 0.4)
background: rgba(16, 185, 129, 0.08)
&.negative
border-color: rgba(239, 68, 68, 0.4)
background: rgba(239, 68, 68, 0.08)
&__label
font-size: 0.85rem
color: #9A9898
margin-bottom: 0.5rem
&__value
font-size: 1.8rem
font-weight: 700
.positive &
color: #10B981
.negative &
color: #EF4444
&__percent
font-size: 1.2rem
font-weight: 600
margin-top: 0.25rem
.positive &
color: #10B981
.negative &
color: #EF4444
&__detail
font-size: 0.75rem
color: #9A9898
margin-top: 0.75rem
// Value Cards
.value-cards
display: flex
flex-direction: column
gap: 16px
@media (min-width: 768px)
flex-direction: row
.value-card
flex: 1
background: #07111B
border: 1px solid rgba(117, 80, 174, 0.2)
border-radius: 16px
padding: 24px
display: flex
flex-direction: column
gap: 6px
&__label
font-size: 12px
text-transform: uppercase
letter-spacing: 1px
color: #9A9898
&__value
font-size: 28px
color: #ffffff
line-height: 1.2
&__unit
font-size: 13px
color: #7550AE
font-weight: 600
</style>

15
package-lock.json generated
View file

@ -1,5 +1,5 @@
{
"name": "harb-worktree-196",
"name": "harb-worktree-150",
"lockfileVersion": 3,
"requires": true,
"packages": {
@ -63,6 +63,7 @@
"name": "vue-kraiken",
"version": "0.0.0",
"dependencies": {
"@harb/ui-shared": "*",
"@harb/web3": "*",
"@tanstack/vue-query": "^5.92.9",
"@wagmi/vue": "^0.2.8",
@ -3524,6 +3525,10 @@
"graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
}
},
"node_modules/@harb/ui-shared": {
"resolved": "packages/ui-shared",
"link": true
},
"node_modules/@harb/web3": {
"resolved": "packages/web3",
"link": true
@ -21804,6 +21809,13 @@
}
}
},
"packages/ui-shared": {
"name": "@harb/ui-shared",
"version": "0.1.0",
"peerDependencies": {
"vue": "^3.5.0"
}
},
"packages/web3": {
"name": "@harb/web3",
"version": "0.1.0",
@ -21819,6 +21831,7 @@
"name": "harb-staking",
"version": "0.0.0",
"dependencies": {
"@harb/ui-shared": "*",
"@harb/web3": "*",
"@tanstack/vue-query": "^5.64.2",
"@vue/test-utils": "^2.4.6",

View file

@ -0,0 +1,11 @@
{
"name": "@harb/ui-shared",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"peerDependencies": {
"vue": "^3.5.0"
}
}

View 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>

View file

@ -0,0 +1,140 @@
import { ref, computed, onMounted, onUnmounted, type Ref } from 'vue';
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;
} catch {
return 0;
}
}
export function useHolderDashboard(address: Ref<string>, graphqlUrl: string | Ref<string> = '/api/graphql') {
const holderBalance = ref<string>('0');
const holderTotalEthSpent = ref<string>('0');
const holderTotalTokensAcquired = ref<string>('0');
const currentPriceWei = ref<string | null>(null);
const lastEthReserve = ref<string>('0');
const kraikenTotalSupply = ref<string>('0');
const loading = ref(false);
const error = ref<string | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
function resolveUrl(): string {
return typeof graphqlUrl === 'string' ? graphqlUrl : graphqlUrl.value;
}
async function fetchData() {
const addr = address.value?.toLowerCase();
if (!addr) return;
loading.value = true;
error.value = null;
try {
const res = await fetch(resolveUrl(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `query HolderDashboard {
holders(address: "${addr}") {
balance
totalEthSpent
totalTokensAcquired
}
statss(where: { id: "0x01" }) {
items {
kraikenTotalSupply
lastEthReserve
currentPriceWei
}
}
}`,
}),
});
const json = await res.json();
if (Array.isArray(json?.errors) && json.errors.length > 0) {
const msgs = json.errors.map((e: { message?: string }) => e.message ?? 'GraphQL error').join(', ');
throw new Error(msgs);
}
const holder = json?.data?.holders;
holderBalance.value = holder?.balance ?? '0';
holderTotalEthSpent.value = holder?.totalEthSpent ?? '0';
holderTotalTokensAcquired.value = holder?.totalTokensAcquired ?? '0';
const statsItems = json?.data?.statss?.items;
const statsRow = Array.isArray(statsItems) && statsItems.length > 0 ? statsItems[0] : null;
currentPriceWei.value = statsRow?.currentPriceWei ?? null;
lastEthReserve.value = statsRow?.lastEthReserve ?? '0';
kraikenTotalSupply.value = statsRow?.kraikenTotalSupply ?? '0';
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to load holder data';
} finally {
loading.value = false;
}
}
const balanceKrk = computed(() => formatTokenAmount(holderBalance.value));
const ethBacking = computed(() => {
const balance = balanceKrk.value;
const reserve = formatTokenAmount(lastEthReserve.value);
const totalSupply = formatTokenAmount(kraikenTotalSupply.value);
if (totalSupply === 0) return 0;
return balance * (reserve / totalSupply);
});
const avgCostBasis = computed(() => {
const spent = formatTokenAmount(holderTotalEthSpent.value);
const acquired = formatTokenAmount(holderTotalTokensAcquired.value);
if (acquired === 0) return 0;
return spent / acquired;
});
const currentPriceEth = computed(() => {
if (!currentPriceWei.value) return 0;
return formatTokenAmount(currentPriceWei.value);
});
const unrealizedPnlEth = computed(() => {
const basis = avgCostBasis.value;
if (basis === 0) return 0;
return (currentPriceEth.value - basis) * balanceKrk.value;
});
const unrealizedPnlPercent = computed(() => {
const basis = avgCostBasis.value;
if (basis === 0) return 0;
return (currentPriceEth.value / basis - 1) * 100;
});
onMounted(async () => {
await fetchData();
pollTimer = setInterval(() => void fetchData(), POLL_INTERVAL_MS);
});
onUnmounted(() => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
});
return {
loading,
error,
balanceKrk,
avgCostBasis,
currentPriceEth,
unrealizedPnlEth,
unrealizedPnlPercent,
ethBacking,
refresh: fetchData,
};
}

View file

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

View file

@ -33,7 +33,8 @@
"vue-router": "^4.2.5",
"vue-tippy": "^6.6.0",
"vue-toastification": "^2.0.0-rc.5",
"@harb/web3": "*"
"@harb/web3": "*",
"@harb/ui-shared": "*"
},
"devDependencies": {
"@iconify/vue": "^4.3.0",

View file

@ -161,7 +161,7 @@
import { computed, ref } from 'vue';
import { useRoute, RouterLink } from 'vue-router';
import { useWalletDashboard } from '@/composables/useWalletDashboard';
import TransactionHistory from '@/components/TransactionHistory.vue';
import { TransactionHistory } from '@harb/ui-shared';
const route = useRoute();
const addressParam = computed(() => String(route.params.address ?? ''));