640 lines
20 KiB
Vue
640 lines
20 KiB
Vue
<template>
|
|
<div v-if="stats" class="live-stats">
|
|
<div class="live-header">
|
|
<span class="live-dot" :class="{ 'live-dot-error': error }"></span>
|
|
<span class="live-text">Live</span>
|
|
</div>
|
|
<div class="stats-grid">
|
|
<div class="stat-item" :class="{ 'stat-changed': changedStats.has('ethReserve') }">
|
|
<div class="stat-label">ETH Reserve</div>
|
|
<div class="stat-value">{{ ethReserveDisplay }}</div>
|
|
<div v-if="ethReserveSecondary" class="stat-secondary">{{ ethReserveSecondary }}</div>
|
|
<div v-if="growthIndicator !== null" class="growth-badge" :class="growthClass">{{ growthIndicator }}</div>
|
|
<svg v-if="ethReserveSpark.length > 1" class="sparkline" viewBox="0 0 80 24" preserveAspectRatio="none">
|
|
<polyline :points="toSvgPoints(ethReserveSpark)" fill="none" stroke="rgba(96,165,250,0.5)" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" />
|
|
</svg>
|
|
<span v-else-if="stats" class="spark-placeholder">Gathering data...</span>
|
|
</div>
|
|
<div class="stat-item" :class="{ 'stat-changed': changedStats.has('ethPerToken') }">
|
|
<div class="stat-label">ETH / Token</div>
|
|
<div class="stat-value">{{ ethPerTokenDisplay }}</div>
|
|
<div v-if="ethPerTokenSecondary" class="stat-secondary">{{ ethPerTokenSecondary }}</div>
|
|
</div>
|
|
<div class="stat-item" :class="{ 'stat-changed': changedStats.has('supply') }">
|
|
<div class="stat-label">Supply (7d)</div>
|
|
<div class="stat-value">{{ totalSupply }}</div>
|
|
<div v-if="netSupplyIndicator !== null" class="growth-badge" :class="netSupplyClass">{{ netSupplyIndicator }}</div>
|
|
<svg v-if="supplySpark.length > 1" class="sparkline" viewBox="0 0 80 24" preserveAspectRatio="none">
|
|
<polyline :points="toSvgPoints(supplySpark)" fill="none" stroke="rgba(74,222,128,0.5)" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" />
|
|
</svg>
|
|
<span v-else-if="stats" class="spark-placeholder">Gathering data...</span>
|
|
</div>
|
|
<div class="stat-item" :class="{ 'stat-changed': changedStats.has('holders') }">
|
|
<div class="stat-label">Holders</div>
|
|
<div class="stat-value">{{ holders }}</div>
|
|
<div v-if="holderGrowthIndicator !== null" class="growth-badge" :class="holderGrowthClass">{{ holderGrowthIndicator }}</div>
|
|
<svg v-if="holdersSpark.length > 1" class="sparkline" viewBox="0 0 80 24" preserveAspectRatio="none">
|
|
<polyline :points="toSvgPoints(holdersSpark)" fill="none" stroke="rgba(251,191,36,0.5)" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" />
|
|
</svg>
|
|
<span v-else-if="stats" class="spark-placeholder">Gathering data...</span>
|
|
</div>
|
|
<div class="stat-item" :class="{ 'pulse': isRecentRebalance, 'stat-changed': changedStats.has('rebalances') }">
|
|
<div class="stat-label">Rebalances</div>
|
|
<div class="stat-value">{{ rebalanceCount }}</div>
|
|
<div class="growth-badge growth-flat">{{ lastRebalance }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="freshness">{{ error ? 'Connection lost' : `Updated ${secondsSinceUpdate}s ago` }}</div>
|
|
</div>
|
|
<div v-else-if="!error" class="live-stats">
|
|
<div class="stats-grid">
|
|
<div class="stat-item skeleton" v-for="i in 6" :key="i">
|
|
<div class="stat-label skeleton-text"></div>
|
|
<div class="stat-value skeleton-text"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else class="live-stats live-stats-error" data-testid="livestats-error">
|
|
<p class="error-message">Protocol data unavailable</p>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
|
|
|
// Must match RING_BUFFER_SEGMENTS and HOURS_IN_RING_BUFFER in services/ponder/src/helpers/stats.ts
|
|
const RING_SEGMENTS = 4; // ethReserve, minted, burned, holderCount
|
|
const RING_HOURS = 168; // 7 days * 24 hours
|
|
|
|
interface Stats {
|
|
kraikenTotalSupply: string;
|
|
holderCount: number;
|
|
lastRecenterTimestamp: number;
|
|
recentersLastWeek: number;
|
|
lastEthReserve: string;
|
|
mintedLastWeek: string;
|
|
burnedLastWeek: string;
|
|
netSupplyChangeWeek: string;
|
|
ethReserveGrowthBps: number | null;
|
|
ringBuffer: string[] | null;
|
|
ringBufferPointer: number | null;
|
|
}
|
|
|
|
const stats = ref<Stats | null>(null);
|
|
const error = ref(false);
|
|
const ethUsdPrice = ref<number | null>(null);
|
|
let refreshInterval: number | null = null;
|
|
let ethPriceInterval: number | null = null;
|
|
let ethPriceCacheTime = 0;
|
|
const ETH_PRICE_CACHE_MS = 5 * 60 * 1000; // 5 minutes
|
|
const changedStats = ref<Set<string>>(new Set());
|
|
const secondsSinceUpdate = ref(0);
|
|
let freshnessTicker: number | null = null;
|
|
let changeTimeout: number | null = null;
|
|
let loadingTimer: number | null = null;
|
|
const LOADING_TIMEOUT_MS = 10_000; // escalate to error after 10s with no data
|
|
|
|
// Helper: safely convert a Wei BigInt string to ETH float
|
|
function weiToEth(wei: string | null | undefined): number {
|
|
if (!wei || wei === '0') return 0;
|
|
try {
|
|
return Number(BigInt(wei)) / 1e18;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract a time-ordered series from the ring buffer for a given slot offset.
|
|
* Skips leading zeros (pre-launch padding).
|
|
*/
|
|
function extractSeries(ringBuffer: string[], pointer: number, slotOffset: number): number[] {
|
|
if (ringBuffer.length !== RING_HOURS * RING_SEGMENTS) {
|
|
return [];
|
|
}
|
|
const raw: number[] = [];
|
|
for (let i = 0; i < RING_HOURS; i++) {
|
|
// Walk from oldest to newest
|
|
const idx = ((pointer + 1 + i) % RING_HOURS) * RING_SEGMENTS + slotOffset;
|
|
raw.push(Number(ringBuffer[idx] || '0'));
|
|
}
|
|
// Skip leading zeros (pre-launch padding) — use findIndex on any non-zero
|
|
// Note: legitimate zero values mid-series are kept, only leading zeros trimmed
|
|
const firstNonZero = raw.findIndex(v => v > 0);
|
|
return firstNonZero === -1 ? [] : raw.slice(firstNonZero);
|
|
}
|
|
|
|
/**
|
|
* Build cumulative net supply series from minted (slot 1) and burned (slot 2).
|
|
*/
|
|
function extractSupplySeries(ringBuffer: string[], pointer: number): number[] {
|
|
if (ringBuffer.length !== RING_HOURS * RING_SEGMENTS) return [];
|
|
const minted: number[] = [];
|
|
const burned: number[] = [];
|
|
for (let i = 0; i < RING_HOURS; i++) {
|
|
const idx = ((pointer + 1 + i) % RING_HOURS) * RING_SEGMENTS;
|
|
minted.push(Number(ringBuffer[idx + 1] || '0'));
|
|
burned.push(Number(ringBuffer[idx + 2] || '0'));
|
|
}
|
|
// Find first hour with any activity (align with extractSeries)
|
|
const firstActive = minted.findIndex((m, i) => m > 0 || burned[i] > 0);
|
|
if (firstActive === -1) return [];
|
|
// Build cumulative net supply change from first active hour
|
|
const cumulative: number[] = [];
|
|
let sum = 0;
|
|
for (let i = firstActive; i < RING_HOURS; i++) {
|
|
sum += minted[i] - burned[i];
|
|
cumulative.push(sum);
|
|
}
|
|
return cumulative;
|
|
}
|
|
|
|
/**
|
|
* Convert a number[] series to SVG polyline points string, scaled to 80x24.
|
|
*/
|
|
function toSvgPoints(series: number[]): string {
|
|
if (series.length < 2) return '';
|
|
const min = Math.min(...series);
|
|
const max = Math.max(...series);
|
|
const range = max - min || 1;
|
|
const isFlat = max === min;
|
|
return series
|
|
.map((v, i) => {
|
|
const x = (i / (series.length - 1)) * 80;
|
|
const y = isFlat ? 12 : 24 - ((v - min) / range) * 22 - 1; // center flat lines
|
|
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
|
})
|
|
.join(' ');
|
|
}
|
|
|
|
// Sparkline series extracted from ring buffer
|
|
const ethReserveSpark = computed(() => {
|
|
if (!stats.value?.ringBuffer || stats.value.ringBufferPointer == null) return [];
|
|
return extractSeries(stats.value.ringBuffer, stats.value.ringBufferPointer, 0);
|
|
});
|
|
|
|
const supplySpark = computed(() => {
|
|
if (!stats.value?.ringBuffer || stats.value.ringBufferPointer == null) return [];
|
|
return extractSupplySeries(stats.value.ringBuffer, stats.value.ringBufferPointer);
|
|
});
|
|
|
|
const holdersSpark = computed(() => {
|
|
if (!stats.value?.ringBuffer || stats.value.ringBufferPointer == null) return [];
|
|
return extractSeries(stats.value.ringBuffer, stats.value.ringBufferPointer, 3);
|
|
});
|
|
|
|
// Holder growth indicator from ring buffer
|
|
const holderGrowthIndicator = computed((): string | null => {
|
|
const series = holdersSpark.value;
|
|
if (series.length < 2) return null;
|
|
const oldest = series[0];
|
|
const newest = series[series.length - 1];
|
|
if (oldest === 0) return newest > 0 ? `${newest} holders` : null;
|
|
const pct = ((newest - oldest) / oldest) * 100;
|
|
if (Math.abs(pct) < 0.1) return '~ flat';
|
|
return pct > 0 ? `↑ ${pct.toFixed(1)}% this week` : `↓ ${Math.abs(pct).toFixed(1)}% this week`;
|
|
});
|
|
|
|
const holderGrowthClass = computed(() => {
|
|
const series = holdersSpark.value;
|
|
if (series.length < 2) return '';
|
|
const oldest = series[0];
|
|
const newest = series[series.length - 1];
|
|
if (newest > oldest) return 'growth-up';
|
|
if (newest < oldest) return 'growth-down';
|
|
return 'growth-flat';
|
|
});
|
|
|
|
// Format tiny ETH values without truncating to zero
|
|
function formatSmallEth(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`;
|
|
const s = eth.toPrecision(4);
|
|
return `${s} ETH`;
|
|
}
|
|
|
|
// Format USD with tiered precision
|
|
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)}`;
|
|
}
|
|
|
|
// Fetch ETH/USD price from CoinGecko with 5-min cache
|
|
async function fetchEthPrice() {
|
|
const now = Date.now();
|
|
if (ethUsdPrice.value !== null && (now - ethPriceCacheTime) < ETH_PRICE_CACHE_MS) 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) {
|
|
ethUsdPrice.value = data.ethereum.usd;
|
|
ethPriceCacheTime = now;
|
|
}
|
|
} catch {
|
|
// Keep existing cached price or null; ETH fallback will be used
|
|
}
|
|
}
|
|
|
|
// Primary/secondary display helpers
|
|
const ethReserveDisplay = computed(() => {
|
|
if (!stats.value) return '$0.00';
|
|
const eth = weiToEth(stats.value.lastEthReserve);
|
|
if (ethUsdPrice.value) return formatUsd(eth * ethUsdPrice.value);
|
|
return formatSmallEth(eth);
|
|
});
|
|
|
|
const ethReserveSecondary = computed((): string | null => {
|
|
if (!stats.value || !ethUsdPrice.value) return null;
|
|
const eth = weiToEth(stats.value.lastEthReserve);
|
|
return `(${eth.toFixed(2)} ETH)`;
|
|
});
|
|
|
|
// Growth indicator: null = hide (< 7 days history or missing)
|
|
const growthIndicator = computed((): string | null => {
|
|
if (!stats.value || stats.value.ethReserveGrowthBps == null) return null;
|
|
const bps = Number(stats.value.ethReserveGrowthBps);
|
|
const pct = Math.abs(bps / 100);
|
|
if (bps > 10) return `↑ ${pct.toFixed(1)}%`;
|
|
if (bps < -10) return `↓ ${pct.toFixed(1)}%`;
|
|
return `~ ${pct.toFixed(1)}%`;
|
|
});
|
|
|
|
const growthClass = computed(() => {
|
|
if (!stats.value || stats.value.ethReserveGrowthBps == null) return '';
|
|
const bps = Number(stats.value.ethReserveGrowthBps);
|
|
if (bps > 10) return 'growth-up';
|
|
if (bps < -10) return 'growth-down';
|
|
return 'growth-flat';
|
|
});
|
|
|
|
// ETH backing per token
|
|
const ethPerToken = computed(() => {
|
|
if (!stats.value) return '—';
|
|
const reserve = weiToEth(stats.value.lastEthReserve);
|
|
const supply = Number(stats.value.kraikenTotalSupply) / 1e18;
|
|
if (supply === 0) return '—';
|
|
const ratio = reserve / supply;
|
|
return ratio;
|
|
});
|
|
|
|
const ethPerTokenDisplay = computed(() => {
|
|
if (!stats.value) return '—';
|
|
const ratio = ethPerToken.value;
|
|
if (typeof ratio === 'string') return ratio;
|
|
if (ethUsdPrice.value) return formatUsd(ratio * ethUsdPrice.value);
|
|
return formatSmallEth(ratio);
|
|
});
|
|
|
|
const ethPerTokenSecondary = computed((): string | null => {
|
|
if (!stats.value || !ethUsdPrice.value) return null;
|
|
const ratio = ethPerToken.value;
|
|
if (typeof ratio === 'string') return null;
|
|
return `(${formatSmallEth(ratio).replace(' ETH', '')} ETH)`;
|
|
});
|
|
|
|
// Net supply change indicator (7d)
|
|
const netSupplyIndicator = computed((): string | null => {
|
|
if (!stats.value) return null;
|
|
const minted = Number(BigInt(stats.value.mintedLastWeek || '0'));
|
|
const burned = Number(BigInt(stats.value.burnedLastWeek || '0'));
|
|
const supply = Number(BigInt(stats.value.kraikenTotalSupply || '1'));
|
|
if (supply === 0) return null;
|
|
const netPct = ((minted - burned) / supply) * 100;
|
|
if (Math.abs(netPct) < 0.01) return '~ flat';
|
|
return netPct > 0 ? `↑ ${netPct.toFixed(1)}%` : `↓ ${Math.abs(netPct).toFixed(1)}%`;
|
|
});
|
|
|
|
const netSupplyClass = computed(() => {
|
|
if (!stats.value) return '';
|
|
const minted = Number(BigInt(stats.value.mintedLastWeek || '0'));
|
|
const burned = Number(BigInt(stats.value.burnedLastWeek || '0'));
|
|
if (minted > burned * 1.01) return 'growth-up';
|
|
if (burned > minted * 1.01) return 'growth-down';
|
|
return 'growth-flat';
|
|
});
|
|
|
|
// Rebalance count (weekly)
|
|
const rebalanceCount = computed(() => {
|
|
if (!stats.value) return '0';
|
|
return `${stats.value.recentersLastWeek} this week`;
|
|
});
|
|
|
|
const holders = computed(() => {
|
|
if (!stats.value) return '0 holders';
|
|
return `${stats.value.holderCount} holders`;
|
|
});
|
|
|
|
const lastRebalance = computed(() => {
|
|
if (!stats.value) return 'Never';
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const diff = now - stats.value.lastRecenterTimestamp;
|
|
|
|
if (diff < 60) return 'Just now';
|
|
if (diff < 3600) return `${Math.floor(diff / 60)} min ago`;
|
|
if (diff < 86400) return `${Math.floor(diff / 3600)} hours ago`;
|
|
return `${Math.floor(diff / 86400)} days ago`;
|
|
});
|
|
|
|
const isRecentRebalance = computed(() => {
|
|
if (!stats.value) return false;
|
|
const now = Math.floor(Date.now() / 1000);
|
|
return (now - stats.value.lastRecenterTimestamp) < 3600;
|
|
});
|
|
|
|
const totalSupply = computed(() => {
|
|
if (!stats.value) return '0K KRK';
|
|
const supply = Number(stats.value.kraikenTotalSupply) / 1e18;
|
|
if (supply >= 1000000) {
|
|
return `${(supply / 1000000).toFixed(1)}M KRK`;
|
|
}
|
|
return `${(supply / 1000).toFixed(1)}K KRK`;
|
|
});
|
|
|
|
async function fetchStats() {
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 10000);
|
|
const endpoint = `${window.location.origin}/api/graphql`;
|
|
const response = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
signal: controller.signal,
|
|
body: JSON.stringify({
|
|
query: `
|
|
query ProtocolStats {
|
|
statss(where: { id: "0x01" }) {
|
|
items {
|
|
kraikenTotalSupply
|
|
holderCount
|
|
lastRecenterTimestamp
|
|
recentersLastWeek
|
|
lastEthReserve
|
|
mintedLastWeek
|
|
burnedLastWeek
|
|
netSupplyChangeWeek
|
|
ethReserveGrowthBps
|
|
ringBuffer
|
|
ringBufferPointer
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
}),
|
|
});
|
|
|
|
clearTimeout(timeout);
|
|
if (!response.ok) {
|
|
throw new Error('Failed to fetch stats');
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.data?.statss?.items?.[0]) {
|
|
const s = data.data.statss.items[0];
|
|
// Detect changed fields for flash animation
|
|
const prev = stats.value;
|
|
if (prev) {
|
|
const changed = new Set<string>();
|
|
if (s.lastEthReserve !== prev.lastEthReserve) {
|
|
changed.add('ethReserve');
|
|
changed.add('ethPerToken');
|
|
}
|
|
if (s.kraikenTotalSupply !== prev.kraikenTotalSupply) {
|
|
changed.add('supply');
|
|
changed.add('ethPerToken');
|
|
}
|
|
if (s.holderCount !== prev.holderCount) changed.add('holders');
|
|
if (s.recentersLastWeek !== prev.recentersLastWeek) changed.add('rebalances');
|
|
if (changed.size > 0) {
|
|
changedStats.value = changed;
|
|
if (changeTimeout) clearTimeout(changeTimeout);
|
|
changeTimeout = window.setTimeout(() => { changedStats.value = new Set(); }, 700);
|
|
}
|
|
}
|
|
stats.value = s;
|
|
secondsSinceUpdate.value = 0;
|
|
error.value = false;
|
|
if (loadingTimer) { clearTimeout(loadingTimer); loadingTimer = null; }
|
|
} else {
|
|
throw new Error('No stats data');
|
|
}
|
|
} catch (err) {
|
|
// eslint-disable-next-line no-console
|
|
console.error('Failed to fetch protocol stats:', err);
|
|
error.value = true;
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
// Escalate to error state if no data arrives within the timeout window
|
|
loadingTimer = window.setTimeout(() => {
|
|
if (!stats.value) error.value = true;
|
|
loadingTimer = null;
|
|
}, LOADING_TIMEOUT_MS);
|
|
await fetchStats();
|
|
fetchEthPrice();
|
|
refreshInterval = window.setInterval(fetchStats, 30000); // Refresh every 30 seconds
|
|
ethPriceInterval = window.setInterval(fetchEthPrice, ETH_PRICE_CACHE_MS);
|
|
freshnessTicker = window.setInterval(() => { secondsSinceUpdate.value++; }, 1000);
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
if (refreshInterval) clearInterval(refreshInterval);
|
|
if (ethPriceInterval) clearInterval(ethPriceInterval);
|
|
if (freshnessTicker) clearInterval(freshnessTicker);
|
|
if (changeTimeout) clearTimeout(changeTimeout);
|
|
if (loadingTimer) clearTimeout(loadingTimer);
|
|
});
|
|
</script>
|
|
|
|
<style scoped lang="sass">
|
|
.live-stats
|
|
width: 100%
|
|
padding: 40px 0
|
|
margin-top: 60px
|
|
|
|
.stats-grid
|
|
display: grid
|
|
grid-template-columns: 1fr
|
|
gap: 24px
|
|
max-width: 840px
|
|
margin: 0 auto
|
|
padding: 0 32px
|
|
|
|
@media (min-width: 640px)
|
|
grid-template-columns: repeat(2, 1fr)
|
|
|
|
@media (min-width: 992px)
|
|
grid-template-columns: repeat(3, 1fr)
|
|
gap: 32px
|
|
|
|
.stat-item
|
|
display: flex
|
|
flex-direction: column
|
|
align-items: center
|
|
gap: 6px
|
|
padding: 20px
|
|
background: rgba(255, 255, 255, 0.03)
|
|
border: 1px solid rgba(255, 255, 255, 0.08)
|
|
border-radius: 12px
|
|
transition: all 0.3s ease
|
|
|
|
&:hover
|
|
background: rgba(255, 255, 255, 0.05)
|
|
border-color: rgba(255, 255, 255, 0.12)
|
|
|
|
.sparkline
|
|
width: 80px
|
|
height: 24px
|
|
margin-top: 4px
|
|
opacity: 0.8
|
|
|
|
.stat-label
|
|
font-size: 12px
|
|
color: rgba(240, 240, 240, 0.6)
|
|
text-transform: uppercase
|
|
letter-spacing: 0.5px
|
|
font-weight: 500
|
|
|
|
.stat-value
|
|
font-size: 20px
|
|
color: #F0F0F0
|
|
font-weight: 600
|
|
font-family: 'orbitron', sans-serif
|
|
|
|
@media (min-width: 992px)
|
|
font-size: 24px
|
|
|
|
.growth-badge
|
|
font-size: 11px
|
|
font-weight: 600
|
|
letter-spacing: 0.5px
|
|
|
|
&.growth-up
|
|
color: #4ade80
|
|
|
|
&.growth-down
|
|
color: #f87171
|
|
|
|
&.growth-flat
|
|
color: rgba(240, 240, 240, 0.45)
|
|
|
|
.stat-secondary
|
|
font-size: 12px
|
|
color: rgba(240, 240, 240, 0.45)
|
|
letter-spacing: 0.3px
|
|
|
|
.spark-placeholder
|
|
font-size: 11px
|
|
color: rgba(240, 240, 240, 0.3)
|
|
letter-spacing: 0.3px
|
|
margin-top: 4px
|
|
|
|
.pulse
|
|
animation: pulse-glow 2s ease-in-out infinite
|
|
|
|
@keyframes pulse-glow
|
|
0%, 100%
|
|
background: rgba(117, 80, 174, 0.1)
|
|
border-color: rgba(117, 80, 174, 0.3)
|
|
50%
|
|
background: rgba(117, 80, 174, 0.2)
|
|
border-color: rgba(117, 80, 174, 0.5)
|
|
|
|
.live-header
|
|
display: flex
|
|
align-items: center
|
|
justify-content: center
|
|
gap: 6px
|
|
margin-bottom: 16px
|
|
|
|
.live-dot
|
|
width: 8px
|
|
height: 8px
|
|
border-radius: 50%
|
|
background: #4ade80
|
|
animation: dot-pulse 2s ease-in-out infinite
|
|
|
|
.live-dot-error
|
|
background: #ef4444
|
|
animation: none
|
|
|
|
.live-text
|
|
font-size: 11px
|
|
color: rgba(240, 240, 240, 0.5)
|
|
text-transform: uppercase
|
|
letter-spacing: 1px
|
|
font-weight: 500
|
|
|
|
@keyframes dot-pulse
|
|
0%, 100%
|
|
opacity: 1
|
|
box-shadow: 0 0 0 0 rgba(74, 222, 128, 0.4)
|
|
50%
|
|
opacity: 0.7
|
|
box-shadow: 0 0 0 4px rgba(74, 222, 128, 0)
|
|
|
|
.freshness
|
|
text-align: center
|
|
font-size: 11px
|
|
color: rgba(240, 240, 240, 0.35)
|
|
margin-top: 16px
|
|
letter-spacing: 0.3px
|
|
|
|
.stat-changed
|
|
animation: stat-flash 0.7s ease-out
|
|
|
|
@keyframes stat-flash
|
|
0%
|
|
background: rgba(74, 222, 128, 0.15)
|
|
border-color: rgba(74, 222, 128, 0.3)
|
|
100%
|
|
background: rgba(255, 255, 255, 0.03)
|
|
border-color: rgba(255, 255, 255, 0.08)
|
|
|
|
.skeleton
|
|
pointer-events: none
|
|
|
|
.skeleton-text
|
|
background: linear-gradient(90deg, rgba(255, 255, 255, 0.05) 25%, rgba(255, 255, 255, 0.1) 50%, rgba(255, 255, 255, 0.05) 75%)
|
|
background-size: 200% 100%
|
|
animation: shimmer 1.5s infinite
|
|
border-radius: 4px
|
|
|
|
&.stat-label
|
|
height: 12px
|
|
width: 80px
|
|
|
|
&.stat-value
|
|
height: 24px
|
|
width: 100px
|
|
|
|
@keyframes shimmer
|
|
0%
|
|
background-position: 200% 0
|
|
100%
|
|
background-position: -200% 0
|
|
|
|
.live-stats-error
|
|
display: flex
|
|
align-items: center
|
|
justify-content: center
|
|
|
|
.error-message
|
|
font-size: 14px
|
|
color: #f87171
|
|
text-align: center
|
|
letter-spacing: 0.3px
|
|
</style>
|