Replace UBI with ETH reserve in ring buffer, fix Dockerfile HEALTHCHECK, enhance LiveStats (#154)
This commit is contained in:
parent
31063379a8
commit
76b2635e63
16 changed files with 2028 additions and 89 deletions
289
web-app/src/composables/usePositionDashboard.ts
Normal file
289
web-app/src/composables/usePositionDashboard.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
import { ref, computed, onMounted, onUnmounted, type Ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { DEFAULT_CHAIN_ID } from '@/config';
|
||||
import { resolveGraphqlEndpoint, formatGraphqlError } from '@/utils/graphqlRetry';
|
||||
import logger from '@/utils/logger';
|
||||
|
||||
const GRAPHQL_TIMEOUT_MS = 15_000;
|
||||
const POLL_INTERVAL_MS = 30_000;
|
||||
|
||||
export interface PositionRecord {
|
||||
id: string;
|
||||
owner: string;
|
||||
share: number;
|
||||
taxRate: number;
|
||||
taxRateIndex: number;
|
||||
kraikenDeposit: string;
|
||||
stakeDeposit: string;
|
||||
taxPaid: string;
|
||||
snatched: number;
|
||||
status: string;
|
||||
creationTime: string;
|
||||
lastTaxTime: string;
|
||||
closedAt: string | null;
|
||||
totalSupplyInit: string;
|
||||
totalSupplyEnd: string | null;
|
||||
payout: string | null;
|
||||
}
|
||||
|
||||
export interface PositionStats {
|
||||
stakeTotalSupply: string;
|
||||
outstandingStake: string;
|
||||
kraikenTotalSupply: string;
|
||||
lastEthReserve: string;
|
||||
}
|
||||
|
||||
export interface ActivePositionShort {
|
||||
id: string;
|
||||
taxRateIndex: number;
|
||||
kraikenDeposit: string;
|
||||
}
|
||||
|
||||
function formatTokenAmount(rawWei: string, decimals = 18): number {
|
||||
try {
|
||||
const big = BigInt(rawWei);
|
||||
const divisor = 10 ** decimals;
|
||||
return Number(big) / divisor;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(ts: string | null): string {
|
||||
if (!ts) return 'N/A';
|
||||
try {
|
||||
// ts may be seconds (unix) or ms
|
||||
const num = Number(ts);
|
||||
const ms = num > 1e12 ? num : num * 1000;
|
||||
return new Date(ms).toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} catch {
|
||||
return 'N/A';
|
||||
}
|
||||
}
|
||||
|
||||
function durationHuman(fromTs: string | null, toTs: string | null = null): string {
|
||||
if (!fromTs) return 'N/A';
|
||||
try {
|
||||
const from = Number(fromTs) > 1e12 ? Number(fromTs) : Number(fromTs) * 1000;
|
||||
const to = toTs ? (Number(toTs) > 1e12 ? Number(toTs) : Number(toTs) * 1000) : Date.now();
|
||||
const diffMs = to - from;
|
||||
if (diffMs < 0) return 'N/A';
|
||||
const totalSec = Math.floor(diffMs / 1000);
|
||||
const days = Math.floor(totalSec / 86400);
|
||||
const hours = Math.floor((totalSec % 86400) / 3600);
|
||||
const mins = Math.floor((totalSec % 3600) / 60);
|
||||
const parts: string[] = [];
|
||||
if (days > 0) parts.push(`${days}d`);
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (mins > 0 && days === 0) parts.push(`${mins}m`);
|
||||
return parts.length ? parts.join(' ') : '<1m';
|
||||
} catch {
|
||||
return 'N/A';
|
||||
}
|
||||
}
|
||||
|
||||
export function usePositionDashboard(positionId: Ref<string>) {
|
||||
const position = ref<PositionRecord | null>(null);
|
||||
const stats = ref<PositionStats | null>(null);
|
||||
const allActivePositions = ref<ActivePositionShort[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function fetchData() {
|
||||
const id = positionId.value;
|
||||
if (!id) return;
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
let endpoint: string;
|
||||
try {
|
||||
endpoint = resolveGraphqlEndpoint(DEFAULT_CHAIN_ID);
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'GraphQL endpoint not configured';
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axios.post(
|
||||
endpoint,
|
||||
{
|
||||
query: `query PositionDashboard {
|
||||
positions(id: "${id}") {
|
||||
id
|
||||
owner
|
||||
share
|
||||
taxRate
|
||||
taxRateIndex
|
||||
kraikenDeposit
|
||||
stakeDeposit
|
||||
taxPaid
|
||||
snatched
|
||||
status
|
||||
creationTime
|
||||
lastTaxTime
|
||||
closedAt
|
||||
totalSupplyInit
|
||||
totalSupplyEnd
|
||||
payout
|
||||
}
|
||||
statss(where: { id: "0x01" }) {
|
||||
items {
|
||||
stakeTotalSupply
|
||||
outstandingStake
|
||||
kraikenTotalSupply
|
||||
lastEthReserve
|
||||
}
|
||||
}
|
||||
positionss(where: { status: "Active" }, limit: 1000) {
|
||||
items {
|
||||
id
|
||||
taxRateIndex
|
||||
kraikenDeposit
|
||||
}
|
||||
}
|
||||
}`,
|
||||
},
|
||||
{ timeout: GRAPHQL_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
const gqlErrors = res.data?.errors;
|
||||
if (Array.isArray(gqlErrors) && gqlErrors.length > 0) {
|
||||
throw new Error(gqlErrors.map((e: { message?: string }) => e.message ?? 'GraphQL error').join(', '));
|
||||
}
|
||||
|
||||
position.value = res.data?.data?.positions ?? null;
|
||||
|
||||
const statsItems = res.data?.data?.statss?.items;
|
||||
stats.value = Array.isArray(statsItems) && statsItems.length > 0 ? (statsItems[0] as PositionStats) : null;
|
||||
|
||||
const activeItems = res.data?.data?.positionss?.items;
|
||||
allActivePositions.value = Array.isArray(activeItems) ? (activeItems as ActivePositionShort[]) : [];
|
||||
|
||||
logger.info(`PositionDashboard loaded for #${id}`);
|
||||
} catch (err) {
|
||||
error.value = formatGraphqlError(err);
|
||||
logger.info('PositionDashboard fetch error', err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Derived values
|
||||
const depositKrk = computed(() => formatTokenAmount(position.value?.kraikenDeposit ?? '0'));
|
||||
const taxPaidKrk = computed(() => formatTokenAmount(position.value?.taxPaid ?? '0'));
|
||||
|
||||
const currentValueKrk = computed(() => {
|
||||
if (!position.value || !stats.value) return 0;
|
||||
const share = Number(position.value.share);
|
||||
const outstanding = formatTokenAmount(stats.value.outstandingStake);
|
||||
return share * outstanding;
|
||||
});
|
||||
|
||||
const netReturnKrk = computed(() => {
|
||||
return currentValueKrk.value - depositKrk.value - taxPaidKrk.value;
|
||||
});
|
||||
|
||||
const taxRatePercent = computed(() => {
|
||||
if (!position.value) return 0;
|
||||
return Number(position.value.taxRate) * 100;
|
||||
});
|
||||
|
||||
const dailyTaxCost = computed(() => {
|
||||
if (!position.value) return 0;
|
||||
return (depositKrk.value * Number(position.value.taxRate)) / 365;
|
||||
});
|
||||
|
||||
const sharePercent = computed(() => {
|
||||
if (!position.value) return 0;
|
||||
return Number(position.value.share) * 100;
|
||||
});
|
||||
|
||||
const createdFormatted = computed(() => formatDate(position.value?.creationTime ?? null));
|
||||
const lastTaxFormatted = computed(() => formatDate(position.value?.lastTaxTime ?? null));
|
||||
const closedAtFormatted = computed(() => formatDate(position.value?.closedAt ?? null));
|
||||
|
||||
const timeHeld = computed(() => {
|
||||
if (!position.value) return 'N/A';
|
||||
return durationHuman(position.value.creationTime, position.value.closedAt ?? null);
|
||||
});
|
||||
|
||||
// Snatch risk: count active positions with lower taxRateIndex
|
||||
const snatchRisk = computed(() => {
|
||||
if (!position.value) return { count: 0, level: 'UNKNOWN', color: '#9A9898' };
|
||||
const myIndex = Number(position.value.taxRateIndex);
|
||||
const lower = allActivePositions.value.filter(p => Number(p.taxRateIndex) < myIndex).length;
|
||||
const total = allActivePositions.value.length;
|
||||
|
||||
let level: string;
|
||||
let color: string;
|
||||
if (total === 0) {
|
||||
level = 'LOW';
|
||||
color = '#4ADE80';
|
||||
} else {
|
||||
const ratio = lower / total;
|
||||
if (ratio < 0.33) {
|
||||
level = 'LOW';
|
||||
color = '#4ADE80';
|
||||
} else if (ratio < 0.67) {
|
||||
level = 'MEDIUM';
|
||||
color = '#FACC15';
|
||||
} else {
|
||||
level = 'HIGH';
|
||||
color = '#F87171';
|
||||
}
|
||||
}
|
||||
|
||||
return { count: lower, level, color };
|
||||
});
|
||||
|
||||
const payoutKrk = computed(() => formatTokenAmount(position.value?.payout ?? '0'));
|
||||
|
||||
const netPnlKrk = computed(() => {
|
||||
if (!position.value) return 0;
|
||||
return payoutKrk.value - depositKrk.value - taxPaidKrk.value;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
pollTimer = setInterval(() => void fetchData(), POLL_INTERVAL_MS);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
position,
|
||||
stats,
|
||||
allActivePositions,
|
||||
depositKrk,
|
||||
taxPaidKrk,
|
||||
currentValueKrk,
|
||||
netReturnKrk,
|
||||
taxRatePercent,
|
||||
dailyTaxCost,
|
||||
sharePercent,
|
||||
createdFormatted,
|
||||
lastTaxFormatted,
|
||||
closedAtFormatted,
|
||||
timeHeld,
|
||||
snatchRisk,
|
||||
payoutKrk,
|
||||
netPnlKrk,
|
||||
refresh: fetchData,
|
||||
};
|
||||
}
|
||||
189
web-app/src/composables/useWalletDashboard.ts
Normal file
189
web-app/src/composables/useWalletDashboard.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { ref, computed, onMounted, onUnmounted, type Ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { DEFAULT_CHAIN_ID } from '@/config';
|
||||
import { resolveGraphqlEndpoint, formatGraphqlError } from '@/utils/graphqlRetry';
|
||||
import logger from '@/utils/logger';
|
||||
|
||||
const GRAPHQL_TIMEOUT_MS = 15_000;
|
||||
const POLL_INTERVAL_MS = 30_000;
|
||||
|
||||
export interface WalletPosition {
|
||||
id: string;
|
||||
share: number;
|
||||
taxRate: number;
|
||||
taxRateIndex: number;
|
||||
kraikenDeposit: string;
|
||||
taxPaid: string;
|
||||
snatched: number;
|
||||
status: string;
|
||||
creationTime: string;
|
||||
lastTaxTime: string;
|
||||
payout: string | null;
|
||||
totalSupplyInit: string;
|
||||
totalSupplyEnd: string | null;
|
||||
}
|
||||
|
||||
export interface WalletStats {
|
||||
kraikenTotalSupply: string;
|
||||
lastEthReserve: string;
|
||||
floorPriceWei: string;
|
||||
currentPriceWei: string;
|
||||
ethReserveGrowthBps: number;
|
||||
recentersLastWeek: number;
|
||||
mintedLastWeek: string;
|
||||
burnedLastWeek: string;
|
||||
netSupplyChangeWeek: string;
|
||||
holderCount: number;
|
||||
}
|
||||
|
||||
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 useWalletDashboard(address: Ref<string>) {
|
||||
const holderBalance = ref<string>('0');
|
||||
const stats = ref<WalletStats | null>(null);
|
||||
const positions = ref<WalletPosition[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function fetchData() {
|
||||
const addr = address.value?.toLowerCase();
|
||||
if (!addr) return;
|
||||
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
let endpoint: string;
|
||||
try {
|
||||
endpoint = resolveGraphqlEndpoint(DEFAULT_CHAIN_ID);
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'GraphQL endpoint not configured';
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await axios.post(
|
||||
endpoint,
|
||||
{
|
||||
query: `query WalletDashboard {
|
||||
holders(address: "${addr}") {
|
||||
address
|
||||
balance
|
||||
}
|
||||
statss(where: { id: "0x01" }) {
|
||||
items {
|
||||
kraikenTotalSupply
|
||||
lastEthReserve
|
||||
floorPriceWei
|
||||
currentPriceWei
|
||||
ethReserveGrowthBps
|
||||
recentersLastWeek
|
||||
mintedLastWeek
|
||||
burnedLastWeek
|
||||
netSupplyChangeWeek
|
||||
holderCount
|
||||
}
|
||||
}
|
||||
positionss(where: { owner: "${addr}" }, limit: 1000) {
|
||||
items {
|
||||
id
|
||||
share
|
||||
taxRate
|
||||
taxRateIndex
|
||||
kraikenDeposit
|
||||
taxPaid
|
||||
snatched
|
||||
status
|
||||
creationTime
|
||||
lastTaxTime
|
||||
payout
|
||||
totalSupplyInit
|
||||
totalSupplyEnd
|
||||
}
|
||||
}
|
||||
}`,
|
||||
},
|
||||
{ timeout: GRAPHQL_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
const gqlErrors = res.data?.errors;
|
||||
if (Array.isArray(gqlErrors) && gqlErrors.length > 0) {
|
||||
throw new Error(gqlErrors.map((e: { message?: string }) => e.message ?? 'GraphQL error').join(', '));
|
||||
}
|
||||
|
||||
const holder = res.data?.data?.holders;
|
||||
holderBalance.value = holder?.balance ?? '0';
|
||||
|
||||
const statsItems = res.data?.data?.statss?.items;
|
||||
stats.value = Array.isArray(statsItems) && statsItems.length > 0 ? (statsItems[0] as WalletStats) : null;
|
||||
|
||||
const posItems = res.data?.data?.positionss?.items;
|
||||
positions.value = Array.isArray(posItems) ? (posItems as WalletPosition[]) : [];
|
||||
|
||||
logger.info(`WalletDashboard loaded for ${addr}`);
|
||||
} catch (err) {
|
||||
error.value = formatGraphqlError(err);
|
||||
logger.info('WalletDashboard fetch error', err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Derived values
|
||||
const balanceKrk = computed(() => formatTokenAmount(holderBalance.value));
|
||||
|
||||
const ethBacking = computed(() => {
|
||||
if (!stats.value) return 0;
|
||||
const balance = balanceKrk.value;
|
||||
const reserve = formatTokenAmount(stats.value.lastEthReserve);
|
||||
const totalSupply = formatTokenAmount(stats.value.kraikenTotalSupply);
|
||||
if (totalSupply === 0) return 0;
|
||||
return balance * (reserve / totalSupply);
|
||||
});
|
||||
|
||||
const floorValue = computed(() => {
|
||||
if (!stats.value) return 0;
|
||||
const balance = balanceKrk.value;
|
||||
// floorPriceWei is price per token in wei → convert to ETH
|
||||
const floorPriceEth = formatTokenAmount(stats.value.floorPriceWei);
|
||||
return balance * floorPriceEth;
|
||||
});
|
||||
|
||||
const activePositions = computed(() => positions.value.filter(p => p.status === 'Active'));
|
||||
const closedPositions = computed(() => positions.value.filter(p => p.status === 'Closed'));
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchData();
|
||||
pollTimer = setInterval(() => void fetchData(), POLL_INTERVAL_MS);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
holderBalance,
|
||||
balanceKrk,
|
||||
ethBacking,
|
||||
floorValue,
|
||||
stats,
|
||||
positions,
|
||||
activePositions,
|
||||
closedPositions,
|
||||
refresh: fetchData,
|
||||
};
|
||||
}
|
||||
|
|
@ -37,6 +37,33 @@ const router = createRouter({
|
|||
},
|
||||
component: () => import('../views/GetKrkView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/cheats',
|
||||
name: 'cheats',
|
||||
meta: {
|
||||
title: 'Cheat Console',
|
||||
layout: 'NavbarLayout',
|
||||
},
|
||||
component: () => import('../views/CheatsView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/wallet/:address',
|
||||
name: 'wallet',
|
||||
meta: {
|
||||
title: 'Wallet',
|
||||
layout: 'NavbarLayout',
|
||||
},
|
||||
component: () => import('../views/WalletView.vue'),
|
||||
},
|
||||
{
|
||||
path: '/position/:id',
|
||||
name: 'position',
|
||||
meta: {
|
||||
title: 'Position',
|
||||
layout: 'NavbarLayout',
|
||||
},
|
||||
component: () => import('../views/PositionView.vue'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
|
|
|||
474
web-app/src/views/PositionView.vue
Normal file
474
web-app/src/views/PositionView.vue
Normal file
|
|
@ -0,0 +1,474 @@
|
|||
<template>
|
||||
<div class="position-view">
|
||||
<!-- Loading / Error -->
|
||||
<div v-if="loading && !position" class="pos-loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading position…</p>
|
||||
</div>
|
||||
<div v-if="error" class="pos-error">⚠️ {{ error }}</div>
|
||||
|
||||
<template v-if="position">
|
||||
<!-- Header -->
|
||||
<div class="pos-header">
|
||||
<div class="pos-header__left">
|
||||
<div class="pos-header__id">Position #{{ position.id }}</div>
|
||||
<span class="status-badge" :class="position.status === 'Active' ? 'status-badge--active' : 'status-badge--closed'">
|
||||
{{ position.status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="pos-header__owner">
|
||||
<span class="pos-header__owner-label">Owner</span>
|
||||
<RouterLink :to="{ name: 'wallet', params: { address: position.owner } }" class="pos-header__owner-addr">
|
||||
{{ truncateAddr(position.owner) }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overview Cards -->
|
||||
<div class="overview-cards">
|
||||
<div class="ov-card">
|
||||
<div class="ov-card__label">Deposited</div>
|
||||
<div class="ov-card__value">{{ fmtKrk(depositKrk) }}</div>
|
||||
<div class="ov-card__unit">KRK</div>
|
||||
</div>
|
||||
<div class="ov-card">
|
||||
<div class="ov-card__label">Current Value</div>
|
||||
<div class="ov-card__value">{{ fmtKrk(currentValueKrk) }}</div>
|
||||
<div class="ov-card__unit">KRK</div>
|
||||
</div>
|
||||
<div class="ov-card">
|
||||
<div class="ov-card__label">Tax Paid</div>
|
||||
<div class="ov-card__value">{{ fmtKrk(taxPaidKrk) }}</div>
|
||||
<div class="ov-card__unit">KRK</div>
|
||||
</div>
|
||||
<div class="ov-card" :class="netReturnKrk >= 0 ? 'ov-card--positive' : 'ov-card--negative'">
|
||||
<div class="ov-card__label">Net Return</div>
|
||||
<div class="ov-card__value">{{ netReturnKrk >= 0 ? '+' : '' }}{{ fmtKrk(netReturnKrk) }}</div>
|
||||
<div class="ov-card__unit">KRK</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Two-column detail + metrics -->
|
||||
<div class="detail-grid">
|
||||
<!-- Position Details -->
|
||||
<div class="detail-panel">
|
||||
<h3 class="panel-title">Position Details</h3>
|
||||
<div class="detail-rows">
|
||||
<div class="detail-row">
|
||||
<span class="detail-row__label">Tax Rate</span>
|
||||
<span class="detail-row__value"
|
||||
>{{ taxRatePercent.toFixed(2) }}% <span class="muted">(index {{ position.taxRateIndex }})</span></span
|
||||
>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-row__label">Created</span>
|
||||
<span class="detail-row__value">{{ createdFormatted }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-row__label">Time Held</span>
|
||||
<span class="detail-row__value">{{ timeHeld }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-row__label">Last Tax Payment</span>
|
||||
<span class="detail-row__value">{{ lastTaxFormatted }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-row__label">Times Snatched</span>
|
||||
<span class="detail-row__value">{{ position.snatched }}</span>
|
||||
</div>
|
||||
<template v-if="position.status === 'Closed'">
|
||||
<div class="detail-row">
|
||||
<span class="detail-row__label">Closed At</span>
|
||||
<span class="detail-row__value">{{ closedAtFormatted }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-row__label">Payout</span>
|
||||
<span class="detail-row__value">{{ fmtKrk(payoutKrk) }} KRK</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Computed Metrics -->
|
||||
<div class="detail-panel">
|
||||
<h3 class="panel-title">Metrics</h3>
|
||||
<div class="detail-rows">
|
||||
<div class="detail-row">
|
||||
<span class="detail-row__label">Daily Tax Cost</span>
|
||||
<span class="detail-row__value">{{ fmtKrk(dailyTaxCost) }} KRK/day</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-row__label">Share of Pool</span>
|
||||
<span class="detail-row__value">{{ sharePercent.toFixed(4) }}%</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-row__label">Days to Breakeven</span>
|
||||
<span class="detail-row__value">{{ breakevenDays }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Snatch Risk -->
|
||||
<div class="snatch-risk" :style="{ '--risk-color': snatchRisk.color }">
|
||||
<div class="snatch-risk__title">Snatch Risk</div>
|
||||
<div class="snatch-risk__badge">{{ snatchRisk.level }}</div>
|
||||
<div class="snatch-risk__detail">{{ snatchRisk.count }} position{{ snatchRisk.count !== 1 ? 's' : '' }} at lower tax rates</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Closed Summary -->
|
||||
<div v-if="position.status === 'Closed'" class="closed-summary">
|
||||
<h3 class="panel-title">Final Summary</h3>
|
||||
<div class="closed-grid">
|
||||
<div class="closed-item">
|
||||
<span class="closed-item__label">Deposited</span>
|
||||
<span class="closed-item__value">{{ fmtKrk(depositKrk) }} KRK</span>
|
||||
</div>
|
||||
<div class="closed-item">
|
||||
<span class="closed-item__label">Payout</span>
|
||||
<span class="closed-item__value">{{ fmtKrk(payoutKrk) }} KRK</span>
|
||||
</div>
|
||||
<div class="closed-item">
|
||||
<span class="closed-item__label">Tax Paid</span>
|
||||
<span class="closed-item__value">{{ fmtKrk(taxPaidKrk) }} KRK</span>
|
||||
</div>
|
||||
<div class="closed-item" :class="netPnlKrk >= 0 ? 'positive' : 'negative'">
|
||||
<span class="closed-item__label">Net P&L</span>
|
||||
<span class="closed-item__value">{{ netPnlKrk >= 0 ? '+' : '' }}{{ fmtKrk(netPnlKrk) }} KRK</span>
|
||||
</div>
|
||||
<div class="closed-item">
|
||||
<span class="closed-item__label">Duration</span>
|
||||
<span class="closed-item__value">{{ timeHeld }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else-if="!loading && !error" class="pos-not-found">Position not found.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, RouterLink } from 'vue-router';
|
||||
import { usePositionDashboard } from '@/composables/usePositionDashboard';
|
||||
|
||||
const route = useRoute();
|
||||
const positionId = computed(() => String(route.params.id ?? ''));
|
||||
|
||||
const {
|
||||
loading,
|
||||
error,
|
||||
position,
|
||||
depositKrk,
|
||||
taxPaidKrk,
|
||||
currentValueKrk,
|
||||
netReturnKrk,
|
||||
taxRatePercent,
|
||||
dailyTaxCost,
|
||||
sharePercent,
|
||||
createdFormatted,
|
||||
lastTaxFormatted,
|
||||
closedAtFormatted,
|
||||
timeHeld,
|
||||
snatchRisk,
|
||||
payoutKrk,
|
||||
netPnlKrk,
|
||||
} = usePositionDashboard(positionId);
|
||||
|
||||
function fmtKrk(val: number): string {
|
||||
if (!Number.isFinite(val)) return '0.00';
|
||||
return val.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 4 });
|
||||
}
|
||||
|
||||
function truncateAddr(addr: string): string {
|
||||
if (!addr || addr.length < 10) return addr;
|
||||
return `${addr.slice(0, 6)}…${addr.slice(-4)}`;
|
||||
}
|
||||
|
||||
// Days to breakeven: (currentValue - deposit) / dailyTaxCost
|
||||
// Only meaningful if position is active and dailyTaxCost > 0
|
||||
const breakevenDays = computed(() => {
|
||||
if (!position.value || position.value.status !== 'Active') return 'N/A';
|
||||
const dtc = dailyTaxCost.value;
|
||||
if (dtc <= 0) return 'N/A';
|
||||
// daily gain ≈ current_value growth — but we don't have real-time growth rate here
|
||||
// fallback: show just daily cost relative to net return
|
||||
if (netReturnKrk.value >= 0) return 'Already profitable';
|
||||
const daysLeft = Math.abs(netReturnKrk.value) / dtc;
|
||||
return `~${Math.ceil(daysLeft)} days`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="sass" scoped>
|
||||
.position-view
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 28px
|
||||
padding: 16px
|
||||
max-width: 1200px
|
||||
margin: 0 auto
|
||||
@media (min-width: 992px)
|
||||
padding: 48px
|
||||
|
||||
// ─── Loading/Error ────────────────────────────────────────────────────────────
|
||||
.pos-loading
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 12px
|
||||
color: #9A9898
|
||||
padding: 32px
|
||||
justify-content: center
|
||||
|
||||
.pos-error
|
||||
background: rgba(248, 113, 113, 0.1)
|
||||
border: 1px solid rgba(248, 113, 113, 0.3)
|
||||
border-radius: 12px
|
||||
padding: 16px
|
||||
color: #F87171
|
||||
|
||||
.pos-not-found
|
||||
text-align: center
|
||||
color: #9A9898
|
||||
padding: 64px
|
||||
|
||||
.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)
|
||||
|
||||
// ─── Header ───────────────────────────────────────────────────────────────────
|
||||
.pos-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: 24px 32px
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 16px
|
||||
@media (min-width: 768px)
|
||||
flex-direction: row
|
||||
align-items: center
|
||||
justify-content: space-between
|
||||
|
||||
&__left
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 16px
|
||||
|
||||
&__id
|
||||
font-family: "Audiowide", sans-serif
|
||||
font-size: 28px
|
||||
color: #ffffff
|
||||
@media (min-width: 768px)
|
||||
font-size: 36px
|
||||
|
||||
&__owner
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 4px
|
||||
|
||||
&__owner-label
|
||||
font-size: 11px
|
||||
text-transform: uppercase
|
||||
letter-spacing: 1px
|
||||
color: #9A9898
|
||||
|
||||
&__owner-addr
|
||||
font-family: monospace
|
||||
color: #7550AE
|
||||
text-decoration: none
|
||||
font-size: 15px
|
||||
&:hover
|
||||
color: #9A70DE
|
||||
text-decoration: underline
|
||||
|
||||
.status-badge
|
||||
padding: 6px 14px
|
||||
border-radius: 99px
|
||||
font-size: 13px
|
||||
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)
|
||||
|
||||
// ─── Overview Cards ───────────────────────────────────────────────────────────
|
||||
.overview-cards
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 16px
|
||||
@media (min-width: 768px)
|
||||
flex-direction: row
|
||||
|
||||
.ov-card
|
||||
flex: 1
|
||||
background: #07111B
|
||||
border: 1px solid rgba(117, 80, 174, 0.2)
|
||||
border-radius: 16px
|
||||
padding: 20px 24px
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 4px
|
||||
|
||||
&--positive
|
||||
border-color: rgba(74, 222, 128, 0.3)
|
||||
.ov-card__value
|
||||
color: #4ADE80
|
||||
|
||||
&--negative
|
||||
border-color: rgba(248, 113, 113, 0.3)
|
||||
.ov-card__value
|
||||
color: #F87171
|
||||
|
||||
&__label
|
||||
font-size: 11px
|
||||
text-transform: uppercase
|
||||
letter-spacing: 1px
|
||||
color: #9A9898
|
||||
|
||||
&__value
|
||||
font-family: "Audiowide", sans-serif
|
||||
font-size: 24px
|
||||
color: #ffffff
|
||||
line-height: 1.2
|
||||
|
||||
&__unit
|
||||
font-size: 12px
|
||||
color: #7550AE
|
||||
font-weight: 600
|
||||
|
||||
// ─── Detail Grid ──────────────────────────────────────────────────────────────
|
||||
.detail-grid
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 20px
|
||||
@media (min-width: 768px)
|
||||
flex-direction: row
|
||||
|
||||
.detail-panel
|
||||
flex: 1
|
||||
background: #07111B
|
||||
border: 1px solid rgba(117, 80, 174, 0.15)
|
||||
border-radius: 16px
|
||||
padding: 24px
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 16px
|
||||
|
||||
.panel-title
|
||||
font-size: 16px
|
||||
font-weight: 600
|
||||
color: #ffffff
|
||||
margin: 0
|
||||
padding-bottom: 12px
|
||||
border-bottom: 1px solid rgba(117, 80, 174, 0.2)
|
||||
|
||||
.detail-rows
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 12px
|
||||
|
||||
.detail-row
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
align-items: center
|
||||
gap: 8px
|
||||
|
||||
&__label
|
||||
font-size: 13px
|
||||
color: #9A9898
|
||||
|
||||
&__value
|
||||
font-size: 13px
|
||||
color: #ffffff
|
||||
font-weight: 600
|
||||
text-align: right
|
||||
|
||||
.muted
|
||||
color: #9A9898
|
||||
font-weight: 400
|
||||
font-size: 12px
|
||||
|
||||
// ─── Snatch Risk ──────────────────────────────────────────────────────────────
|
||||
.snatch-risk
|
||||
margin-top: 8px
|
||||
padding: 16px
|
||||
border-radius: 12px
|
||||
border: 1px solid var(--risk-color, #9A9898)
|
||||
background: color-mix(in srgb, var(--risk-color, #9A9898) 10%, transparent)
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 6px
|
||||
|
||||
&__title
|
||||
font-size: 11px
|
||||
text-transform: uppercase
|
||||
letter-spacing: 1px
|
||||
color: #9A9898
|
||||
|
||||
&__badge
|
||||
font-size: 20px
|
||||
font-weight: 700
|
||||
color: var(--risk-color, #9A9898)
|
||||
font-family: "Audiowide", sans-serif
|
||||
|
||||
&__detail
|
||||
font-size: 13px
|
||||
color: #9A9898
|
||||
|
||||
// ─── Closed Summary ───────────────────────────────────────────────────────────
|
||||
.closed-summary
|
||||
background: #07111B
|
||||
border: 1px solid rgba(255,255,255,0.1)
|
||||
border-radius: 16px
|
||||
padding: 24px
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 16px
|
||||
|
||||
.closed-grid
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
gap: 24px
|
||||
|
||||
.closed-item
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 4px
|
||||
min-width: 120px
|
||||
|
||||
&.positive
|
||||
.closed-item__value
|
||||
color: #4ADE80
|
||||
|
||||
&.negative
|
||||
.closed-item__value
|
||||
color: #F87171
|
||||
|
||||
&__label
|
||||
font-size: 11px
|
||||
text-transform: uppercase
|
||||
letter-spacing: 0.5px
|
||||
color: #9A9898
|
||||
|
||||
&__value
|
||||
font-family: "Audiowide", sans-serif
|
||||
font-size: 18px
|
||||
color: #ffffff
|
||||
</style>
|
||||
492
web-app/src/views/WalletView.vue
Normal file
492
web-app/src/views/WalletView.vue
Normal file
|
|
@ -0,0 +1,492 @@
|
|||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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';
|
||||
|
||||
const route = useRoute();
|
||||
const addressParam = computed(() => String(route.params.address ?? ''));
|
||||
|
||||
const { loading, error, balanceKrk, ethBacking, floorValue, 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)
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue