307 lines
8.3 KiB
Vue
307 lines
8.3 KiB
Vue
<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&L</div>
|
||
<div class="pnl-card__value">
|
||
{{ 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: {{ fmtEthUsd(avgCostBasis) }}/KRK · Current: {{ fmtEthUsd(currentPriceEth) }}/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">{{ 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>
|
||
|
||
<!-- 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, useEthPrice, formatUsd, formatEthCompact, 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 { ethUsdPrice } = useEthPrice();
|
||
|
||
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 });
|
||
}
|
||
|
||
/** 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>
|
||
|
||
<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
|
||
|
||
&__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
|
||
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>
|