harb/web-app/src/views/WalletView.vue

566 lines
16 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="wallet-view">
<!-- Header -->
<div class="wallet-header">
<div class="wallet-header__address">
<span class="wallet-header__label">Wallet</span>
<div class="wallet-header__addr-row">
<span class="wallet-header__addr-full">{{ truncatedAddress }}</span>
<button class="copy-btn" :class="{ copied: copied }" @click="copyAddress" title="Copy address">
{{ copied ? '✓' : '⎘' }}
</button>
</div>
</div>
<div class="wallet-header__balance">
<span class="wallet-header__balance-num">{{ formatKrk(balanceKrk) }}</span>
<span class="wallet-header__balance-sym">KRK</span>
</div>
</div>
<!-- Loading / Error -->
<div v-if="loading && !balanceKrk" class="wallet-loading">
<div class="spinner"></div>
<p>Loading wallet data</p>
</div>
<div v-if="error" class="wallet-error"> {{ error }}</div>
<!-- P&L Card (hero) -->
<div class="pnl-card" v-if="avgCostBasis > 0" :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">Your 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 class="value-card">
<div class="value-card__label">Floor Value</div>
<div class="value-card__value">{{ formatEth(floorValue) }}</div>
<div class="value-card__unit">ETH</div>
</div>
</div>
<!-- Protocol Health Strip -->
<div class="health-strip" v-if="stats">
<div class="health-strip__title">Protocol Health</div>
<div class="health-strip__items">
<div class="health-item">
<span class="health-item__label">ETH Reserve</span>
<span class="health-item__value">{{ formatEth(ethReserveNum) }} ETH</span>
</div>
<span class="health-sep">·</span>
<div class="health-item">
<span class="health-item__label">Reserve Growth</span>
<span class="health-item__value" :class="{ positive: ethGrowthBps > 0, negative: ethGrowthBps < 0 }">
{{ ethGrowthBps > 0 ? '+' : '' }}{{ (ethGrowthBps / 100).toFixed(2) }}%
</span>
</div>
<span class="health-sep">·</span>
<div class="health-item">
<span class="health-item__label">Rebalances / 7d</span>
<span class="health-item__value">{{ stats.recentersLastWeek }}</span>
</div>
<span class="health-sep">·</span>
<div class="health-item">
<span class="health-item__label">Holders</span>
<span class="health-item__value">{{ stats.holderCount.toLocaleString() }}</span>
</div>
<span class="health-sep">·</span>
<div class="health-item">
<span class="health-item__label">Floor Price</span>
<span class="health-item__value">{{ formatEth(floorPriceEth) }} ETH</span>
</div>
</div>
</div>
<!-- Transaction History -->
<TransactionHistory :address="addressParam" />
<!-- Staking Positions -->
<div class="positions-section">
<h3 class="positions-section__title">
Staking Positions
<span class="positions-section__count">{{ positions.length }}</span>
</h3>
<div v-if="positions.length === 0 && !loading" class="positions-empty">No staking positions found for this address.</div>
<div v-else class="positions-list">
<!-- Active positions -->
<template v-if="activePositions.length > 0">
<div class="positions-group-label">Active</div>
<RouterLink
v-for="pos in activePositions"
:key="pos.id"
:to="{ name: 'position', params: { id: pos.id } }"
class="position-row active"
>
<div class="position-row__id">#{{ pos.id }}</div>
<div class="position-row__deposit">
<span class="position-row__field-label">Deposit</span>
<span class="position-row__field-val">{{ formatKrk(tokenAmount(pos.kraikenDeposit)) }} KRK</span>
</div>
<div class="position-row__tax">
<span class="position-row__field-label">Tax Rate</span>
<span class="position-row__field-val">{{ (Number(pos.taxRate) * 100).toFixed(2) }}%</span>
</div>
<div class="position-row__paid">
<span class="position-row__field-label">Tax Paid</span>
<span class="position-row__field-val">{{ formatKrk(tokenAmount(pos.taxPaid)) }} KRK</span>
</div>
<div class="position-row__status">
<span class="status-badge status-badge--active">Active</span>
</div>
<div class="position-row__arrow"></div>
</RouterLink>
</template>
<!-- Closed positions -->
<template v-if="closedPositions.length > 0">
<div class="positions-group-label">Closed</div>
<RouterLink
v-for="pos in closedPositions"
:key="pos.id"
:to="{ name: 'position', params: { id: pos.id } }"
class="position-row closed"
>
<div class="position-row__id">#{{ pos.id }}</div>
<div class="position-row__deposit">
<span class="position-row__field-label">Deposit</span>
<span class="position-row__field-val">{{ formatKrk(tokenAmount(pos.kraikenDeposit)) }} KRK</span>
</div>
<div class="position-row__payout">
<span class="position-row__field-label">Payout</span>
<span class="position-row__field-val">{{ pos.payout ? formatKrk(tokenAmount(pos.payout)) + ' KRK' : 'N/A' }}</span>
</div>
<div class="position-row__status">
<span class="status-badge status-badge--closed">Closed</span>
</div>
<div class="position-row__arrow"></div>
</RouterLink>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRoute, RouterLink } from 'vue-router';
import { useWalletDashboard } from '@/composables/useWalletDashboard';
import TransactionHistory from '@/components/TransactionHistory.vue';
const route = useRoute();
const addressParam = computed(() => String(route.params.address ?? ''));
const {
loading,
error,
balanceKrk,
ethBacking,
floorValue,
avgCostBasis,
currentPriceEth,
unrealizedPnlEth,
unrealizedPnlPercent,
stats,
positions,
activePositions,
closedPositions,
} = useWalletDashboard(addressParam);
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 });
}
function tokenAmount(rawWei: string, decimals = 18): number {
try {
const big = BigInt(rawWei ?? '0');
return Number(big) / 10 ** decimals;
} catch {
return 0;
}
}
const ethReserveNum = computed(() => tokenAmount(stats.value?.lastEthReserve ?? '0'));
const floorPriceEth = computed(() => tokenAmount(stats.value?.floorPriceWei ?? '0'));
const ethGrowthBps = computed(() => Number(stats.value?.ethReserveGrowthBps ?? 0));
</script>
<style lang="sass" scoped>
.wallet-view
display: flex
flex-direction: column
gap: 28px
padding: 16px
max-width: 1200px
margin: 0 auto
@media (min-width: 992px)
padding: 48px
// ─── Header ───────────────────────────────────────────────────────────────────
.wallet-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
&__label
font-size: 12px
text-transform: uppercase
letter-spacing: 1.5px
color: #7550AE
margin-bottom: 6px
display: block
&__addr-row
display: flex
align-items: center
gap: 10px
&__addr-full
font-size: 16px
color: #ffffff
font-family: monospace
word-break: break-all
&__balance
display: flex
align-items: baseline
gap: 8px
&__balance-num
font-family: "Audiowide", sans-serif
font-size: 36px
color: #7550AE
@media (min-width: 768px)
font-size: 44px
&__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 ────────────────────────────────────────────────────────────
.wallet-loading
display: flex
align-items: center
gap: 12px
color: #9A9898
padding: 16px
.wallet-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)
// ─── 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-family: "Audiowide", sans-serif
font-size: 28px
color: #ffffff
line-height: 1.2
&__unit
font-size: 13px
color: #7550AE
font-weight: 600
// ─── Health Strip ─────────────────────────────────────────────────────────────
.health-strip
background: linear-gradient(135deg, rgba(117, 80, 174, 0.08) 0%, rgba(117, 80, 174, 0.03) 100%)
border: 1px solid rgba(117, 80, 174, 0.2)
border-radius: 14px
padding: 16px 24px
display: flex
flex-direction: column
gap: 12px
&__title
font-size: 12px
text-transform: uppercase
letter-spacing: 1px
color: #7550AE
margin: 0
&__items
display: flex
flex-wrap: wrap
gap: 8px
align-items: center
.health-item
display: flex
gap: 6px
align-items: center
&__label
color: #9A9898
font-size: 13px
&__value
color: #ffffff
font-size: 13px
font-weight: 600
&.positive
color: #4ADE80
&.negative
color: #F87171
.health-sep
color: #444
padding: 0 2px
// ─── Positions ────────────────────────────────────────────────────────────────
.positions-section
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
.positions-empty
color: #9A9898
padding: 24px
text-align: center
background: #07111B
border-radius: 12px
border: 1px solid rgba(255,255,255,0.07)
.positions-group-label
font-size: 11px
text-transform: uppercase
letter-spacing: 1.5px
color: #7550AE
padding: 4px 0
.positions-list
display: flex
flex-direction: column
gap: 8px
.position-row
display: flex
flex-wrap: wrap
align-items: center
gap: 16px
padding: 16px 20px
border-radius: 12px
border: 1px solid rgba(255,255,255,0.08)
text-decoration: none
transition: border-color 0.2s, background 0.2s
cursor: pointer
&.active
background: rgba(117, 80, 174, 0.06)
&:hover
border-color: rgba(117, 80, 174, 0.4)
background: rgba(117, 80, 174, 0.12)
&.closed
background: rgba(255,255,255,0.03)
&:hover
border-color: rgba(255,255,255,0.2)
background: rgba(255,255,255,0.05)
&__id
font-family: "Audiowide", sans-serif
color: #7550AE
font-size: 14px
min-width: 70px
&__deposit, &__tax, &__paid, &__payout
display: flex
flex-direction: column
gap: 2px
min-width: 100px
&__field-label
font-size: 11px
color: #9A9898
text-transform: uppercase
letter-spacing: 0.5px
&__field-val
font-size: 14px
color: #ffffff
font-weight: 600
&__status
margin-left: auto
&__arrow
color: #7550AE
font-size: 18px
.status-badge
padding: 4px 12px
border-radius: 99px
font-size: 12px
font-weight: 700
text-transform: uppercase
letter-spacing: 0.5px
&--active
background: rgba(74, 222, 128, 0.15)
color: #4ADE80
border: 1px solid rgba(74, 222, 128, 0.3)
&--closed
background: rgba(255,255,255,0.08)
color: #9A9898
border: 1px solid rgba(255,255,255,0.15)
.pnl-card
background: rgba(255,255,255,0.06)
border: 1px solid rgba(255,255,255,0.15)
border-radius: 16px
padding: 1.5rem
margin-bottom: 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
</style>