Merge pull request 'fix: Post-purchase holder dashboard on landing page (#150)' (#214) from fix/issue-150 into master
This commit is contained in:
commit
de67b46753
12 changed files with 957 additions and 4 deletions
|
|
@ -184,6 +184,12 @@ services:
|
|||
cp -r "$WS/web-app/src/." /app/web-app/src/
|
||||
echo "webapp/src updated from workspace"
|
||||
fi
|
||||
for f in vite.config.ts vite.config.js; do
|
||||
if [ -f "$WS/web-app/$f" ]; then
|
||||
cp "$WS/web-app/$f" /app/web-app/"$f"
|
||||
echo "webapp/$f updated from workspace"
|
||||
fi
|
||||
done
|
||||
|
||||
# Overlay @harb/web3 shared package from workspace
|
||||
if [ -d "$WS/packages/web3" ]; then
|
||||
|
|
@ -219,6 +225,12 @@ services:
|
|||
cp -r "$WS/landing/src/." /app/landing/src/
|
||||
echo "landing/src updated from workspace"
|
||||
fi
|
||||
for f in vite.config.ts vite.config.js; do
|
||||
if [ -f "$WS/landing/$f" ]; then
|
||||
cp "$WS/landing/$f" /app/landing/"$f"
|
||||
echo "landing/$f updated from workspace"
|
||||
fi
|
||||
done
|
||||
|
||||
# Overlay @harb/web3 shared package
|
||||
if [ -d "$WS/packages/web3" ]; then
|
||||
|
|
@ -237,6 +249,19 @@ services:
|
|||
echo "@harb/web3 linked for landing"
|
||||
fi
|
||||
|
||||
# Overlay @harb/ui-shared shared package from workspace
|
||||
if [ -d "$WS/packages/ui-shared" ]; then
|
||||
mkdir -p /app/packages/ui-shared
|
||||
cp -r "$WS/packages/ui-shared/." /app/packages/ui-shared/
|
||||
# Link @harb/ui-shared into landing node_modules
|
||||
mkdir -p /app/landing/node_modules/@harb
|
||||
ln -sf /app/packages/ui-shared /app/landing/node_modules/@harb/ui-shared
|
||||
# Symlink vue into packages dir so @harb/ui-shared can resolve it
|
||||
mkdir -p /app/packages/ui-shared/node_modules
|
||||
ln -sf /app/landing/node_modules/vue /app/packages/ui-shared/node_modules/vue 2>/dev/null || true
|
||||
echo "@harb/ui-shared linked for landing"
|
||||
fi
|
||||
|
||||
echo "=== Starting landing (pre-built image + source overlay) ==="
|
||||
cd /app/landing
|
||||
exec npm run dev -- --host 0.0.0.0 --port 5174
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
307
landing/src/views/HolderDashboardView.vue
Normal file
307
landing/src/views/HolderDashboardView.vue
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
<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>
|
||||
|
|
@ -13,7 +13,8 @@ export default defineConfig({
|
|||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
'@harb/ui-shared': fileURLToPath(new URL('../packages/ui-shared/src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
|
|
|
|||
15
package-lock.json
generated
15
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
11
packages/ui-shared/package.json
Normal file
11
packages/ui-shared/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
381
packages/ui-shared/src/components/TransactionHistory.vue
Normal file
381
packages/ui-shared/src/components/TransactionHistory.vue
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
<template>
|
||||
<div class="tx-history">
|
||||
<h3 class="tx-history__title">
|
||||
Transaction History
|
||||
<span class="tx-history__count" v-if="transactions.length">{{ transactions.length }}</span>
|
||||
</h3>
|
||||
|
||||
<div v-if="loading" class="tx-history__loading">
|
||||
<div class="spinner"></div>
|
||||
Loading transactions…
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="tx-history__error">⚠ {{ error }}</div>
|
||||
|
||||
<div v-else-if="transactions.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</th>
|
||||
<th>Tx</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="tx in transactions" :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">
|
||||
<template v-if="tx.ethAmount !== '0'">
|
||||
<span :title="formatEthCell(tx.ethAmount)">
|
||||
{{ ethUsdPrice ? formatCellUsd(tx.ethAmount) : formatEthCell(tx.ethAmount) }}
|
||||
</span>
|
||||
<br v-if="ethUsdPrice" />
|
||||
<span v-if="ethUsdPrice" class="tx-eth-sub">{{ formatEthCell(tx.ethAmount) }}</span>
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</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';
|
||||
import { useEthPrice, formatUsd } from '../composables/useEthPrice';
|
||||
|
||||
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 error = ref<string | null>(null);
|
||||
|
||||
const graphqlUrl = computed(() => props.graphqlUrl || '/api/graphql');
|
||||
|
||||
const { ethUsdPrice } = useEthPrice();
|
||||
|
||||
async function fetchTransactions(address: string) {
|
||||
if (!address) {
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const hasTypeFilter = props.typeFilter && props.typeFilter.length > 0;
|
||||
const query = hasTypeFilter
|
||||
? `query TxHistory($holder: String!, $types: [String]) {
|
||||
transactionss(
|
||||
where: { holder: $holder, type_in: $types }
|
||||
orderBy: "timestamp"
|
||||
orderDirection: "desc"
|
||||
limit: 50
|
||||
) {
|
||||
items {
|
||||
id
|
||||
holder
|
||||
type
|
||||
tokenAmount
|
||||
ethAmount
|
||||
timestamp
|
||||
blockNumber
|
||||
txHash
|
||||
}
|
||||
}
|
||||
}`
|
||||
: `query TxHistory($holder: String!) {
|
||||
transactionss(
|
||||
where: { holder: $holder }
|
||||
orderBy: "timestamp"
|
||||
orderDirection: "desc"
|
||||
limit: 50
|
||||
) {
|
||||
items {
|
||||
id
|
||||
holder
|
||||
type
|
||||
tokenAmount
|
||||
ethAmount
|
||||
timestamp
|
||||
blockNumber
|
||||
txHash
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
const variables: Record<string, unknown> = { holder: address.toLowerCase() };
|
||||
if (hasTypeFilter) variables.types = props.typeFilter;
|
||||
|
||||
const res = await fetch(graphqlUrl.value, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (Array.isArray(data?.errors) && data.errors.length > 0) {
|
||||
const msgs = data.errors.map((e: { message?: string }) => e.message ?? 'GraphQL error').join(', ');
|
||||
throw new Error(msgs);
|
||||
}
|
||||
|
||||
transactions.value = data?.data?.transactionss?.items ?? [];
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Failed to fetch transactions:', e);
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load transactions';
|
||||
transactions.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function weiToEth(raw: string): number {
|
||||
try {
|
||||
const big = BigInt(raw || '0');
|
||||
return Number(big * 10000n / (10n ** 18n)) / 10000;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
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 big = BigInt(raw || '0');
|
||||
const val = Number(big * 10000n / (10n ** 18n)) / 10000;
|
||||
return val.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 4 });
|
||||
} catch {
|
||||
return '0.00';
|
||||
}
|
||||
}
|
||||
|
||||
function formatEthCell(raw: string): string {
|
||||
const eth = weiToEth(raw);
|
||||
if (eth === 0) return '0';
|
||||
if (eth >= 1) return `${eth.toFixed(2)} ETH`;
|
||||
if (eth >= 0.01) return `${eth.toFixed(4)} ETH`;
|
||||
if (eth >= 0.0001) return `${eth.toFixed(6)} ETH`;
|
||||
return `${eth.toPrecision(4)} ETH`;
|
||||
}
|
||||
|
||||
function formatCellUsd(raw: string): string {
|
||||
if (!ethUsdPrice.value) return formatEthCell(raw);
|
||||
return formatUsd(weiToEth(raw) * ethUsdPrice.value);
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
&__error
|
||||
background: rgba(248, 113, 113, 0.1)
|
||||
border: 1px solid rgba(248, 113, 113, 0.3)
|
||||
border-radius: 12px
|
||||
padding: 16px
|
||||
color: #F87171
|
||||
|
||||
&__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
|
||||
|
||||
.tx-eth-sub
|
||||
font-size: 11px
|
||||
color: #9A9898
|
||||
font-family: monospace
|
||||
|
||||
.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>
|
||||
65
packages/ui-shared/src/composables/useEthPrice.ts
Normal file
65
packages/ui-shared/src/composables/useEthPrice.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
const ETH_PRICE_CACHE_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// Module-level cache shared across all composable instances on the same page
|
||||
let _cachedPrice: number | null = null;
|
||||
let _cacheTime = 0;
|
||||
|
||||
export function formatUsd(usd: number): string {
|
||||
if (usd >= 1000) return `$${(usd / 1000).toFixed(1)}k`;
|
||||
if (usd >= 1) return `$${usd.toFixed(2)}`;
|
||||
if (usd >= 0.01) return `$${usd.toFixed(3)}`;
|
||||
return `$${usd.toFixed(4)}`;
|
||||
}
|
||||
|
||||
export function formatEthCompact(eth: number): string {
|
||||
if (eth === 0) return '0 ETH';
|
||||
if (eth >= 1) return `${eth.toFixed(2)} ETH`;
|
||||
if (eth >= 0.01) return `${eth.toFixed(4)} ETH`;
|
||||
if (eth >= 0.0001) return `${eth.toFixed(6)} ETH`;
|
||||
return `${eth.toPrecision(4)} ETH`;
|
||||
}
|
||||
|
||||
export function useEthPrice() {
|
||||
const ethUsdPrice = ref<number | null>(_cachedPrice);
|
||||
|
||||
async function fetchEthPrice() {
|
||||
const now = Date.now();
|
||||
if (_cachedPrice !== null && now - _cacheTime < ETH_PRICE_CACHE_MS) {
|
||||
ethUsdPrice.value = _cachedPrice;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
const resp = await fetch(
|
||||
'https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd',
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
clearTimeout(timeout);
|
||||
if (!resp.ok) throw new Error('ETH price fetch failed');
|
||||
const data = await resp.json();
|
||||
if (data.ethereum?.usd) {
|
||||
_cachedPrice = data.ethereum.usd;
|
||||
_cacheTime = now;
|
||||
ethUsdPrice.value = _cachedPrice;
|
||||
}
|
||||
} catch {
|
||||
// Keep existing cached price or null; ETH fallback will be used
|
||||
}
|
||||
}
|
||||
|
||||
let interval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchEthPrice();
|
||||
interval = setInterval(() => void fetchEthPrice(), ETH_PRICE_CACHE_MS);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (interval) clearInterval(interval);
|
||||
});
|
||||
|
||||
return { ethUsdPrice, fetchEthPrice };
|
||||
}
|
||||
140
packages/ui-shared/src/composables/useHolderDashboard.ts
Normal file
140
packages/ui-shared/src/composables/useHolderDashboard.ts
Normal 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);
|
||||
// Use BigInt arithmetic to avoid float64 precision loss at high values
|
||||
return Number(big * 10000n / (10n ** BigInt(decimals))) / 10000;
|
||||
} 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,
|
||||
};
|
||||
}
|
||||
3
packages/ui-shared/src/index.ts
Normal file
3
packages/ui-shared/src/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { useHolderDashboard } from './composables/useHolderDashboard';
|
||||
export { useEthPrice, formatUsd, formatEthCompact } from './composables/useEthPrice';
|
||||
export { default as TransactionHistory } from './components/TransactionHistory.vue';
|
||||
Loading…
Add table
Add a link
Reference in a new issue