566 lines
16 KiB
Vue
566 lines
16 KiB
Vue
<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&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>
|