719 lines
30 KiB
Vue
719 lines
30 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
|
|
import {
|
|
Shield, X, Lock, Unlock, ArrowDownToLine, ArrowUpFromLine, Delete, ChevronRight, Check,
|
|
Wallet as WalletIcon, Bitcoin, Coins, Gem, Layers, Zap, CircleDollarSign, BadgeCent,
|
|
CircleDot, Triangle, Hexagon, Bone, PawPrint, Waypoints,
|
|
} from 'lucide-vue-next';
|
|
|
|
const COIN_ICONS: Record<string, any> = {
|
|
'bitcoin': Bitcoin,
|
|
'coins': Coins,
|
|
'gem': Gem,
|
|
'layers': Layers,
|
|
'zap': Zap,
|
|
'circle-dollar-sign': CircleDollarSign,
|
|
'circle-dot': CircleDot,
|
|
'triangle': Triangle,
|
|
'hexagon': Hexagon,
|
|
'badge-cent': BadgeCent,
|
|
'bone': Bone,
|
|
'paw-print': PawPrint,
|
|
'waypoints': Waypoints,
|
|
};
|
|
|
|
function coinIcon(iconName: string) {
|
|
return COIN_ICONS[iconName] ?? Gem;
|
|
}
|
|
import { useNotifications } from '@/composables/useNotifications';
|
|
import { useVault } from '@/composables/useVault';
|
|
|
|
const props = defineProps<{
|
|
open: boolean;
|
|
coins: { currency: string; name: string; amount: number; icon: string; color: string }[]
|
|
}>();
|
|
|
|
const emit = defineEmits<{ (e: 'close'): void }>();
|
|
|
|
const { notify } = useNotifications();
|
|
const {
|
|
loading,
|
|
error,
|
|
balances,
|
|
pinRequired,
|
|
lockedUntil,
|
|
load,
|
|
deposit,
|
|
withdraw,
|
|
verifyPin,
|
|
setPin,
|
|
clearSessionPin
|
|
} = useVault();
|
|
|
|
// ── Currency ────────────────────────────────────────────────
|
|
const selectedCurrency = ref('BTX');
|
|
|
|
const allCoins = computed(() => {
|
|
if (!props.coins?.length) return [{ currency: 'BTX', name: 'BetiX', amount: 0, icon: 'gem', color: 'var(--primary)' }];
|
|
return props.coins.filter(c => {
|
|
if (c.currency === 'BTX') return true;
|
|
if (c.amount > 0) return true;
|
|
const vaultAmt = parseFloat((balances.value?.vault_balances || {})[c.currency] || '0');
|
|
return vaultAmt > 0;
|
|
});
|
|
});
|
|
|
|
const activeCoin = computed(() => allCoins.value.find(c => c.currency === selectedCurrency.value) || allCoins.value[0]);
|
|
|
|
function selectCurrency(c: string) {
|
|
selectedCurrency.value = c;
|
|
amount.value = '';
|
|
}
|
|
|
|
// ── Balances ────────────────────────────────────────────────
|
|
const walletBal = computed(() => {
|
|
if (selectedCurrency.value === 'BTX') return parseFloat(balances.value?.balance || '0');
|
|
return activeCoin.value?.amount ?? 0;
|
|
});
|
|
|
|
const vaultBal = computed(() => {
|
|
if (!balances.value) return 0;
|
|
const map = balances.value.vault_balances || {};
|
|
return parseFloat(map[selectedCurrency.value] || '0');
|
|
});
|
|
|
|
// ── State ────────────────────────────────────────────────────
|
|
const direction = ref<'to' | 'from'>('to');
|
|
const amount = ref('');
|
|
const pin = ref('');
|
|
const hasPin = ref(true);
|
|
const gateOpen = ref(false);
|
|
const unlocking = ref(false);
|
|
const isTransferring = ref(false);
|
|
const successState = ref(false);
|
|
|
|
// ── Watchers ────────────────────────────────────────────────
|
|
watch(() => props.open, async (v) => {
|
|
if (v) {
|
|
gateOpen.value = true;
|
|
unlocking.value = false;
|
|
pin.value = '';
|
|
amount.value = '';
|
|
successState.value = false;
|
|
selectedCurrency.value = 'BTX';
|
|
await load();
|
|
} else {
|
|
try { clearSessionPin(); } catch {}
|
|
}
|
|
});
|
|
|
|
// ── Keyboard ─────────────────────────────────────────────────
|
|
function onKeydown(e: KeyboardEvent) {
|
|
if (!props.open) return;
|
|
if (gateOpen.value) {
|
|
if (e.key === 'Enter') { e.preventDefault(); doVerifyPin(); return; }
|
|
if (e.key === 'Escape') { e.preventDefault(); close(); return; }
|
|
if (e.key === 'Backspace') { e.preventDefault(); backspacePin(); return; }
|
|
if (/^[0-9]$/.test(e.key)){ e.preventDefault(); appendDigit(e.key); }
|
|
return;
|
|
}
|
|
if (e.key === 'Escape') close();
|
|
}
|
|
|
|
function close() { emit('close'); }
|
|
|
|
// ── Amount Logic ─────────────────────────────────────────────
|
|
function setPercentage(pct: number) {
|
|
const bal = direction.value === 'to' ? walletBal.value : vaultBal.value;
|
|
amount.value = (bal * pct).toFixed(4);
|
|
}
|
|
|
|
function onAmountInput(e: Event) {
|
|
let s = ((e.target as HTMLInputElement).value || '').replace(/,/g, '.').replace(/[^0-9.]/g, '');
|
|
const parts = s.split('.');
|
|
if (parts.length > 2) s = parts.shift()! + '.' + parts.join('');
|
|
const [intPart, fracRaw] = s.split('.') as [string, string?];
|
|
const intSan = (intPart || '').replace(/^0+(?=\d)/, '');
|
|
const frac = (fracRaw ?? '').slice(0, 4);
|
|
amount.value = fracRaw !== undefined ? `${intSan || '0'}.${frac}` : (intSan || '');
|
|
}
|
|
|
|
// ── Transfer ─────────────────────────────────────────────────
|
|
async function submit() {
|
|
try {
|
|
if (!/^\d+(?:\.\d{1,4})?$/.test(amount.value) || parseFloat(amount.value) <= 0) {
|
|
notify({ type: 'red', title: 'Ungültiger Betrag', desc: 'Bitte gib einen gültigen Betrag ein.', icon: 'alert-circle' });
|
|
return;
|
|
}
|
|
const a = amount.value;
|
|
const isTo = direction.value === 'to';
|
|
isTransferring.value = true;
|
|
await new Promise(r => setTimeout(r, 300));
|
|
await (isTo ? deposit(a, selectedCurrency.value) : withdraw(a, selectedCurrency.value));
|
|
successState.value = true;
|
|
animateCoin();
|
|
notify({
|
|
type: 'green',
|
|
title: isTo ? 'Eingezahlt' : 'Ausgezahlt',
|
|
desc: `${a} ${selectedCurrency.value} ${isTo ? '→ Vault' : '← Wallet'}`,
|
|
icon: 'check-circle'
|
|
});
|
|
amount.value = '';
|
|
setTimeout(() => { successState.value = false; isTransferring.value = false; }, 1500);
|
|
} catch (e: any) {
|
|
isTransferring.value = false;
|
|
const msg = String(e?.message || 'Aktion fehlgeschlagen');
|
|
if (msg.includes('No PIN set')) hasPin.value = false;
|
|
if (pinRequired.value || msg.includes('PIN') || msg.includes('locked')) {
|
|
gateOpen.value = true;
|
|
notify({ type: 'magenta', title: 'PIN benötigt', desc: 'Bitte PIN erneut eingeben.', icon: 'lock' });
|
|
} else {
|
|
notify({ type: 'red', title: 'Fehler', desc: msg, icon: 'alert-triangle' });
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── PIN Logic ────────────────────────────────────────────────
|
|
function appendDigit(d: string) { if (pin.value.length < 8) pin.value += d; }
|
|
function backspacePin() { pin.value = pin.value.slice(0, -1); }
|
|
|
|
async function doVerifyPin() {
|
|
try {
|
|
if (!/^\d{4,8}$/.test(pin.value)) { shakeGate(); return; }
|
|
await verifyPin(pin.value);
|
|
unlocking.value = true;
|
|
setTimeout(() => { gateOpen.value = false; pin.value = ''; unlocking.value = false; }, 600);
|
|
} catch (e: any) {
|
|
shakeGate();
|
|
notify({ type: 'red', title: 'Falscher PIN', desc: e.message, icon: 'lock' });
|
|
}
|
|
}
|
|
|
|
async function doSetPin() {
|
|
try {
|
|
if (!/^\d{4,8}$/.test(pin.value)) { shakeGate(); return; }
|
|
await setPin(pin.value);
|
|
notify({ type: 'green', title: 'PIN erstellt', desc: 'Vault ist gesichert.', icon: 'check' });
|
|
hasPin.value = true;
|
|
unlocking.value = true;
|
|
setTimeout(() => { gateOpen.value = false; pin.value = ''; unlocking.value = false; }, 600);
|
|
} catch (e: any) {
|
|
shakeGate();
|
|
notify({ type: 'red', title: 'Fehler', desc: e.message, icon: 'alert-triangle' });
|
|
}
|
|
}
|
|
|
|
function shakeGate() {
|
|
const el = document.querySelector('.vm-gate-pad');
|
|
if (el) { el.classList.remove('shake'); void (el as HTMLElement).offsetWidth; el.classList.add('shake'); }
|
|
}
|
|
|
|
// ── Coin animation ────────────────────────────────────────────
|
|
function animateCoin() {
|
|
const startEl = document.querySelector('.vm-bal-card.source .vm-bal-icon');
|
|
const endEl = document.querySelector('.vm-bal-card.dest .vm-bal-icon');
|
|
if (!startEl || !endEl) return;
|
|
const sr = startEl.getBoundingClientRect();
|
|
const er = endEl.getBoundingClientRect();
|
|
for (let i = 0; i < 6; i++) {
|
|
setTimeout(() => {
|
|
const g = document.createElement('div');
|
|
g.className = 'vm-particle';
|
|
g.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="${activeCoin.value.color}" stroke="none"><circle cx="12" cy="12" r="10"/></svg>`;
|
|
document.body.appendChild(g);
|
|
const ox = (Math.random() - 0.5) * 24, oy = (Math.random() - 0.5) * 24;
|
|
g.style.cssText = `position:fixed;left:${sr.left + sr.width/2 - 7 + ox}px;top:${sr.top + sr.height/2 - 7 + oy}px;opacity:0;transform:scale(.4);pointer-events:none;z-index:10000;`;
|
|
requestAnimationFrame(() => {
|
|
g.style.transition = 'all .7s cubic-bezier(.2,0,.2,1)';
|
|
g.style.opacity = '1';
|
|
g.style.transform = `translate(${er.left - sr.left - ox}px,${er.top - sr.top - oy}px) scale(1)`;
|
|
setTimeout(() => { g.style.opacity = '0'; g.style.transform += ' scale(1.8)'; }, 500);
|
|
});
|
|
setTimeout(() => g.remove(), 800);
|
|
}, i * 70);
|
|
}
|
|
}
|
|
|
|
onMounted(() => { window.addEventListener('keydown', onKeydown); });
|
|
onBeforeUnmount(() => { window.removeEventListener('keydown', onKeydown); });
|
|
</script>
|
|
|
|
<template>
|
|
<Teleport to="body">
|
|
<Transition name="vm-overlay-fade">
|
|
<div v-if="open" class="vm-overlay" @click.self="close">
|
|
<Transition name="vm-pop" appear>
|
|
<div class="vm-card" :class="{ 'is-unlocking': unlocking }">
|
|
|
|
<!-- ── HEADER ── -->
|
|
<div class="vm-header">
|
|
<div class="vm-title">
|
|
<div class="vm-title-icon">
|
|
<Shield :size="16" />
|
|
</div>
|
|
<span>Vault</span>
|
|
</div>
|
|
<button class="vm-close" @click="close" type="button">
|
|
<X :size="16" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- ── CURRENCY TABS ── -->
|
|
<div class="vm-currency-bar">
|
|
<button
|
|
v-for="coin in allCoins"
|
|
:key="coin.currency"
|
|
class="vm-cur-tab"
|
|
:class="{ active: selectedCurrency === coin.currency }"
|
|
:style="selectedCurrency === coin.currency ? { '--tab-color': coin.color } : {}"
|
|
@click="selectCurrency(coin.currency)"
|
|
type="button"
|
|
>
|
|
<component :is="coinIcon(coin.icon)" :size="13" :style="{ color: coin.color }" />
|
|
<span class="vm-cur-code">{{ coin.currency }}</span>
|
|
<span class="vm-cur-amt">{{ coin.amount.toFixed(coin.currency === 'BTC' || coin.currency === 'ETH' ? 6 : 4) }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- ── CONTENT ── -->
|
|
<div class="vm-body">
|
|
<Transition name="vm-slide" mode="out-in">
|
|
|
|
<!-- GATE -->
|
|
<div v-if="gateOpen" class="vm-gate" key="gate">
|
|
<div class="vm-gate-icon" :class="{ open: unlocking }">
|
|
<Lock v-if="!unlocking" :size="28" />
|
|
<Unlock v-else :size="28" />
|
|
</div>
|
|
<h2 class="vm-gate-title">Sicherheitssperre</h2>
|
|
<p class="vm-gate-sub">{{ hasPin ? 'PIN eingeben um den Tresor zu öffnen.' : 'Erstelle einen neuen PIN.' }}</p>
|
|
|
|
<div class="vm-gate-pad">
|
|
<div class="vm-pin-dots">
|
|
<div v-for="i in 8" :key="i" class="vm-dot" :class="{ active: i <= pin.length }"></div>
|
|
</div>
|
|
|
|
<div class="vm-numpad">
|
|
<button v-for="n in 9" :key="n" @click="appendDigit(n.toString())" class="vm-num" type="button">{{ n }}</button>
|
|
<button class="vm-num action" @click="pin = ''" type="button">C</button>
|
|
<button class="vm-num" @click="appendDigit('0')" type="button">0</button>
|
|
<button class="vm-num action" @click="backspacePin" type="button"><Delete :size="16" /></button>
|
|
</div>
|
|
|
|
<p v-if="lockedUntil" class="vm-lock-msg">Gesperrt bis: {{ lockedUntil }}</p>
|
|
|
|
<button
|
|
class="vm-gate-submit"
|
|
@click="hasPin ? doVerifyPin() : doSetPin()"
|
|
:disabled="loading || pin.length < 4"
|
|
type="button"
|
|
>
|
|
<span v-if="loading" class="vm-spin"></span>
|
|
<span v-else>{{ hasPin ? 'Entsperren' : 'PIN erstellen' }}</span>
|
|
</button>
|
|
|
|
<div class="vm-pin-toggle">
|
|
<button v-if="hasPin" type="button" @click="hasPin = false">Noch keinen PIN?</button>
|
|
<button v-else type="button" @click="hasPin = true">Ich habe bereits einen PIN</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- TRANSFER -->
|
|
<div v-else class="vm-transfer" key="transfer">
|
|
|
|
<!-- Direction Toggle -->
|
|
<div class="vm-direction">
|
|
<button
|
|
class="vm-dir-btn"
|
|
:class="{ active: direction === 'to' }"
|
|
@click="direction = 'to'; amount = ''"
|
|
type="button"
|
|
>
|
|
<ArrowDownToLine :size="14" />
|
|
Einzahlen
|
|
</button>
|
|
<button
|
|
class="vm-dir-btn"
|
|
:class="{ active: direction === 'from' }"
|
|
@click="direction = 'from'; amount = ''"
|
|
type="button"
|
|
>
|
|
<ArrowUpFromLine :size="14" />
|
|
Auszahlen
|
|
</button>
|
|
<div class="vm-dir-glider" :style="{ transform: direction === 'to' ? 'translateX(0)' : 'translateX(100%)' }"></div>
|
|
</div>
|
|
|
|
<!-- Balance Cards -->
|
|
<div class="vm-bal-row">
|
|
<div class="vm-bal-card source">
|
|
<div class="vm-bal-icon" :style="{ '--coin-color': activeCoin.color }">
|
|
<WalletIcon v-if="direction === 'to'" :size="17" />
|
|
<Lock v-else :size="17" />
|
|
</div>
|
|
<div class="vm-bal-info">
|
|
<div class="vm-bal-label">{{ direction === 'to' ? 'Wallet' : 'Vault' }}</div>
|
|
<div class="vm-bal-value">
|
|
{{ (direction === 'to' ? walletBal : vaultBal).toFixed(4) }}
|
|
<small>{{ selectedCurrency }}</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="vm-bal-arrow">
|
|
<ChevronRight :size="18" />
|
|
</div>
|
|
|
|
<div class="vm-bal-card dest">
|
|
<div class="vm-bal-info right">
|
|
<div class="vm-bal-label">{{ direction === 'to' ? 'Vault' : 'Wallet' }}</div>
|
|
<div class="vm-bal-value">
|
|
{{ (direction === 'to' ? vaultBal : walletBal).toFixed(4) }}
|
|
<small>{{ selectedCurrency }}</small>
|
|
</div>
|
|
</div>
|
|
<div class="vm-bal-icon" :style="{ '--coin-color': activeCoin.color }">
|
|
<Lock v-if="direction === 'to'" :size="17" />
|
|
<WalletIcon v-else :size="17" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Amount Input -->
|
|
<div class="vm-input-section">
|
|
<div class="vm-input-header">
|
|
<span class="vm-input-label">Betrag</span>
|
|
<button class="vm-avail" type="button" @click="setPercentage(1)">
|
|
Verfügbar: {{ (direction === 'to' ? walletBal : vaultBal).toFixed(4) }} {{ selectedCurrency }}
|
|
</button>
|
|
</div>
|
|
<div class="vm-input-wrap" :class="{ focused: !!amount }">
|
|
<input
|
|
class="vm-input"
|
|
type="text"
|
|
v-model="amount"
|
|
@input="onAmountInput"
|
|
placeholder="0.0000"
|
|
inputmode="decimal"
|
|
autocomplete="off"
|
|
/>
|
|
<span class="vm-input-suffix" :style="{ color: activeCoin.color }">{{ selectedCurrency }}</span>
|
|
</div>
|
|
<div class="vm-pct-row">
|
|
<button type="button" @click="setPercentage(.25)">25%</button>
|
|
<button type="button" @click="setPercentage(.5)">50%</button>
|
|
<button type="button" @click="setPercentage(.75)">75%</button>
|
|
<button type="button" @click="setPercentage(1)">MAX</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Submit -->
|
|
<button
|
|
class="vm-submit"
|
|
:class="{ success: successState }"
|
|
:disabled="loading || isTransferring || !amount"
|
|
@click="submit"
|
|
type="button"
|
|
>
|
|
<span v-if="successState" class="vm-submit-inner">
|
|
<Check :size="18" /> Transferiert
|
|
</span>
|
|
<span v-else-if="loading || isTransferring" class="vm-submit-inner">
|
|
<span class="vm-spin"></span>
|
|
</span>
|
|
<span v-else class="vm-submit-inner">
|
|
{{ direction === 'to' ? '↓ In Vault einzahlen' : '↑ Aus Vault auszahlen' }}
|
|
</span>
|
|
</button>
|
|
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
|
|
</div>
|
|
</Transition>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* ── Reset ── */
|
|
*, *::before, *::after { box-sizing: border-box; }
|
|
|
|
/* ── Overlay ── */
|
|
.vm-overlay {
|
|
position: fixed; inset: 0; z-index: 9999;
|
|
background: rgba(0,0,0,.75);
|
|
backdrop-filter: blur(14px);
|
|
display: flex; align-items: center; justify-content: center;
|
|
padding: 16px;
|
|
}
|
|
|
|
/* ── Card ── */
|
|
.vm-card {
|
|
width: 100%; max-width: 500px;
|
|
background: #0c0c0e;
|
|
border: 1px solid #222;
|
|
border-radius: 24px;
|
|
box-shadow: 0 40px 80px -20px rgba(0,0,0,.9), 0 0 0 1px rgba(255,255,255,.04);
|
|
display: flex; flex-direction: column;
|
|
overflow: hidden;
|
|
position: relative;
|
|
transition: transform .5s cubic-bezier(.16,1,.3,1), border-color .3s;
|
|
}
|
|
.vm-card::before {
|
|
content: ''; position: absolute; top: 0; left: 0; right: 0; height: 1px;
|
|
background: linear-gradient(90deg, transparent, rgba(223,0,106,.4), transparent);
|
|
}
|
|
.vm-card.is-unlocking { transform: scale(1.02); border-color: rgba(223,0,106,.4); }
|
|
|
|
/* ── Header ── */
|
|
.vm-header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 20px 24px;
|
|
border-bottom: 1px solid #1a1a1e;
|
|
background: linear-gradient(to bottom, #101012, #0c0c0e);
|
|
}
|
|
.vm-title { display: flex; align-items: center; gap: 10px; font-size: 13px; font-weight: 900; letter-spacing: 2px; text-transform: uppercase; color: #fff; }
|
|
.vm-title-icon {
|
|
width: 30px; height: 30px; background: rgba(223,0,106,.12); border-radius: 9px;
|
|
display: flex; align-items: center; justify-content: center; color: #df006a;
|
|
box-shadow: 0 0 12px rgba(223,0,106,.1);
|
|
}
|
|
.vm-close {
|
|
width: 30px; height: 30px; background: #1a1a1e; border: 1px solid #2a2a2e; color: #666;
|
|
border-radius: 9px; display: flex; align-items: center; justify-content: center;
|
|
cursor: pointer; transition: .2s;
|
|
}
|
|
.vm-close:hover { background: #2a2a2e; color: #fff; border-color: #444; }
|
|
|
|
/* ── Currency Bar ── */
|
|
.vm-currency-bar {
|
|
display: flex; gap: 6px; padding: 12px 20px;
|
|
border-bottom: 1px solid #1a1a1e;
|
|
background: #0a0a0c;
|
|
overflow-x: auto;
|
|
scrollbar-width: none;
|
|
}
|
|
.vm-currency-bar::-webkit-scrollbar { display: none; }
|
|
|
|
.vm-cur-tab {
|
|
display: flex; align-items: center; gap: 6px;
|
|
padding: 7px 12px;
|
|
background: #141418; border: 1px solid #222; border-radius: 10px;
|
|
cursor: pointer; transition: .2s; white-space: nowrap; flex-shrink: 0;
|
|
color: #666;
|
|
}
|
|
.vm-cur-tab.active {
|
|
border-color: var(--tab-color, #df006a);
|
|
background: rgba(255,255,255,.04);
|
|
color: #fff;
|
|
box-shadow: 0 0 14px -4px var(--tab-color, #df006a);
|
|
}
|
|
.vm-cur-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
|
.vm-cur-code { font-size: 11px; font-weight: 900; letter-spacing: .5px; }
|
|
.vm-cur-amt { font-size: 10px; color: #555; font-weight: 600; }
|
|
.vm-cur-tab.active .vm-cur-amt { color: #888; }
|
|
|
|
/* ── Body ── */
|
|
.vm-body { position: relative; min-height: 380px; display: flex; flex-direction: column; }
|
|
|
|
/* ── Gate ── */
|
|
.vm-gate {
|
|
flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
|
padding: 28px 24px;
|
|
}
|
|
.vm-gate-icon {
|
|
width: 64px; height: 64px; border-radius: 20px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
border: 1px solid #2a2a2e; color: #555;
|
|
transition: all .5s cubic-bezier(.34,1.56,.64,1);
|
|
margin-bottom: 16px;
|
|
background: #141418;
|
|
}
|
|
.vm-gate-icon.open { background: #df006a; color: #fff; border-color: #df006a; transform: scale(1.1) rotate(-8deg); box-shadow: 0 0 30px rgba(223,0,106,.4); }
|
|
.vm-gate-title { font-size: 18px; font-weight: 800; color: #fff; margin: 0 0 6px; text-align: center; }
|
|
.vm-gate-sub { font-size: 12px; color: #666; margin: 0 0 24px; text-align: center; max-width: 240px; }
|
|
|
|
.vm-gate-pad { width: 100%; max-width: 280px; }
|
|
.vm-gate-pad.shake { animation: shake .4s cubic-bezier(.36,.07,.19,.97) both; }
|
|
|
|
.vm-pin-dots { display: flex; justify-content: center; gap: 10px; margin-bottom: 24px; }
|
|
.vm-dot { width: 10px; height: 10px; border-radius: 50%; background: #222; transition: all .25s cubic-bezier(.16,1,.3,1); }
|
|
.vm-dot.active { background: #df006a; box-shadow: 0 0 10px rgba(223,0,106,.6); transform: scale(1.2); }
|
|
|
|
.vm-numpad { display: grid; grid-template-columns: repeat(3,1fr); gap: 10px; margin-bottom: 16px; }
|
|
.vm-num {
|
|
background: #141418; border: 1px solid #222; color: #fff;
|
|
font-size: 18px; font-weight: 600; padding: 16px 0; border-radius: 12px;
|
|
cursor: pointer; transition: .15s; box-shadow: 0 3px 0 rgba(0,0,0,.3);
|
|
}
|
|
.vm-num:hover { background: #1e1e22; transform: translateY(-1px); }
|
|
.vm-num:active { transform: translateY(2px); box-shadow: none; }
|
|
.vm-num.action { color: #555; background: transparent; border-color: transparent; box-shadow: none; font-size: 14px; }
|
|
.vm-num.action:hover { color: #fff; background: #1e1e22; }
|
|
|
|
.vm-lock-msg { font-size: 11px; color: #ff6b6b; text-align: center; margin: 0 0 12px; }
|
|
|
|
.vm-gate-submit {
|
|
width: 100%; background: #df006a; color: #fff; border: none;
|
|
padding: 15px; border-radius: 12px; font-weight: 800; font-size: 14px;
|
|
cursor: pointer; transition: .2s; display: flex; align-items: center; justify-content: center;
|
|
box-shadow: 0 0 20px rgba(223,0,106,.2);
|
|
}
|
|
.vm-gate-submit:hover:not(:disabled) { filter: brightness(1.1); transform: translateY(-2px); box-shadow: 0 8px 24px rgba(223,0,106,.35); }
|
|
.vm-gate-submit:disabled { opacity: .45; cursor: not-allowed; background: #222; color: #555; box-shadow: none; }
|
|
|
|
.vm-pin-toggle { text-align: center; margin-top: 14px; }
|
|
.vm-pin-toggle button { background: none; border: none; color: #555; font-size: 11px; cursor: pointer; transition: .2s; text-decoration: underline; }
|
|
.vm-pin-toggle button:hover { color: #aaa; }
|
|
|
|
/* ── Transfer ── */
|
|
.vm-transfer { padding: 20px; display: flex; flex-direction: column; gap: 18px; flex: 1; }
|
|
|
|
.vm-direction {
|
|
display: flex; background: #101012; padding: 4px; border-radius: 12px;
|
|
border: 1px solid #1e1e22; position: relative;
|
|
}
|
|
.vm-dir-btn {
|
|
flex: 1; background: transparent; border: none; color: #555;
|
|
padding: 11px 12px; font-weight: 700; font-size: 12px; cursor: pointer;
|
|
z-index: 2; display: flex; align-items: center; justify-content: center; gap: 6px;
|
|
transition: color .3s; border-radius: 9px;
|
|
}
|
|
.vm-dir-btn.active { color: #fff; }
|
|
.vm-dir-glider {
|
|
position: absolute; top: 4px; left: 4px; width: calc(50% - 4px); height: calc(100% - 8px);
|
|
background: #df006a; border-radius: 9px; z-index: 1;
|
|
transition: transform .35s cubic-bezier(.16,1,.3,1);
|
|
box-shadow: 0 2px 12px rgba(223,0,106,.3);
|
|
}
|
|
|
|
.vm-bal-row {
|
|
display: flex; align-items: center; gap: 12px;
|
|
background: #101012; border: 1px solid #1e1e22; border-radius: 16px; padding: 16px;
|
|
}
|
|
.vm-bal-card { flex: 1; display: flex; align-items: center; gap: 10px; }
|
|
.vm-bal-card.dest { flex-direction: row-reverse; }
|
|
.vm-bal-icon {
|
|
width: 40px; height: 40px; background: #1a1a1e; border-radius: 12px;
|
|
border: 1px solid #2a2a2e; display: flex; align-items: center; justify-content: center;
|
|
color: #666; flex-shrink: 0;
|
|
}
|
|
.vm-bal-info { display: flex; flex-direction: column; gap: 2px; }
|
|
.vm-bal-info.right { text-align: right; }
|
|
.vm-bal-label { font-size: 10px; font-weight: 700; text-transform: uppercase; color: #555; letter-spacing: .5px; }
|
|
.vm-bal-value { font-size: 14px; color: #fff; font-weight: 700; }
|
|
.vm-bal-value small { font-size: 10px; color: #444; margin-left: 3px; }
|
|
.vm-bal-arrow {
|
|
width: 28px; height: 28px; background: rgba(223,0,106,.1); border: 1px solid rgba(223,0,106,.2);
|
|
border-radius: 50%; display: flex; align-items: center; justify-content: center;
|
|
color: #df006a; flex-shrink: 0;
|
|
}
|
|
|
|
.vm-input-section { display: flex; flex-direction: column; gap: 10px; }
|
|
.vm-input-header { display: flex; justify-content: space-between; align-items: center; }
|
|
.vm-input-label { font-size: 11px; font-weight: 700; text-transform: uppercase; color: #555; letter-spacing: .5px; }
|
|
.vm-avail { background: none; border: none; font-size: 11px; color: #df006a; cursor: pointer; font-weight: 600; }
|
|
.vm-avail:hover { text-decoration: underline; }
|
|
|
|
.vm-input-wrap {
|
|
display: flex; align-items: center;
|
|
background: #101012; border: 1px solid #222; border-radius: 14px; padding: 0 16px;
|
|
transition: all .3s;
|
|
}
|
|
.vm-input-wrap.focused { border-color: #df006a; box-shadow: 0 0 0 3px rgba(223,0,106,.1); }
|
|
.vm-input {
|
|
flex: 1; background: transparent; border: none; color: #fff;
|
|
font-size: 26px; font-weight: 700; padding: 16px 0; outline: none; font-family: monospace;
|
|
}
|
|
.vm-input-suffix { font-size: 13px; font-weight: 800; letter-spacing: .5px; }
|
|
|
|
.vm-pct-row { display: flex; gap: 8px; }
|
|
.vm-pct-row button {
|
|
flex: 1; background: #141418; border: 1px solid #222; color: #888;
|
|
padding: 8px 0; border-radius: 9px; font-size: 11px; font-weight: 700; cursor: pointer; transition: .2s;
|
|
}
|
|
.vm-pct-row button:hover { background: #1e1e22; color: #fff; border-color: #444; }
|
|
|
|
.vm-submit {
|
|
width: 100%; background: linear-gradient(135deg, #df006a, #b8005a);
|
|
color: #fff; border: none; padding: 16px; border-radius: 14px;
|
|
font-weight: 800; font-size: 14px; cursor: pointer;
|
|
display: flex; align-items: center; justify-content: center;
|
|
transition: all .3s cubic-bezier(.16,1,.3,1);
|
|
box-shadow: 0 8px 24px -4px rgba(223,0,106,.3);
|
|
}
|
|
.vm-submit.success { background: #10b981; box-shadow: 0 8px 24px -4px rgba(16,185,129,.4); }
|
|
.vm-submit:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 12px 32px -4px rgba(223,0,106,.4); }
|
|
.vm-submit:disabled { opacity: .45; cursor: not-allowed; background: #1e1e22; color: #444; box-shadow: none; }
|
|
.vm-submit-inner { display: flex; align-items: center; gap: 8px; }
|
|
|
|
/* ── Animations ── */
|
|
@keyframes shake { 10%,90% { transform: translateX(-2px); } 20%,80% { transform: translateX(3px); } 30%,50%,70% { transform: translateX(-5px); } 40%,60% { transform: translateX(5px); } }
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
@keyframes pop-in { from { transform: scale(.85); opacity: 0; } to { transform: scale(1); opacity: 1; } }
|
|
|
|
.vm-spin { width: 18px; height: 18px; border: 2px solid rgba(255,255,255,.2); border-top-color: #fff; border-radius: 50%; animation: spin .8s linear infinite; }
|
|
|
|
.vm-overlay-fade-enter-active, .vm-overlay-fade-leave-active { transition: opacity .25s ease; }
|
|
.vm-overlay-fade-enter-from, .vm-overlay-fade-leave-to { opacity: 0; }
|
|
|
|
.vm-pop-enter-active { transition: all .4s cubic-bezier(.16,1,.3,1); }
|
|
.vm-pop-leave-active { transition: all .25s ease-in; }
|
|
.vm-pop-enter-from { opacity: 0; transform: scale(.94) translateY(16px); }
|
|
.vm-pop-leave-to { opacity: 0; transform: scale(.94) translateY(16px); }
|
|
|
|
.vm-slide-enter-active, .vm-slide-leave-active { transition: all .3s cubic-bezier(.16,1,.3,1); }
|
|
.vm-slide-enter-from { opacity: 0; transform: translateX(16px); }
|
|
.vm-slide-leave-to { opacity: 0; transform: translateX(-16px); }
|
|
|
|
/* ── Mobile ── */
|
|
@media (max-width: 560px) {
|
|
.vm-overlay { align-items: flex-end; padding: 0; }
|
|
.vm-card { border-radius: 24px 24px 0 0; max-width: 100%; border-bottom: none; }
|
|
.vm-body { min-height: auto; }
|
|
.vm-header { padding: 16px 20px; }
|
|
|
|
/* currency bar: slightly smaller tabs so more fit */
|
|
.vm-currency-bar { padding: 10px 16px; gap: 5px; }
|
|
.vm-cur-tab { padding: 6px 10px; gap: 5px; }
|
|
.vm-cur-code { font-size: 10px; }
|
|
.vm-cur-amt { font-size: 9px; }
|
|
|
|
/* transfer panel */
|
|
.vm-transfer { padding: 14px 16px; gap: 12px; }
|
|
.vm-input { font-size: 22px; padding: 13px 0; }
|
|
.vm-input-wrap { padding: 0 14px; }
|
|
|
|
/* balance row: stack vertically if very tight */
|
|
.vm-bal-row { padding: 12px; gap: 8px; }
|
|
.vm-bal-icon { width: 34px; height: 34px; border-radius: 10px; }
|
|
.vm-bal-value { font-size: 13px; }
|
|
|
|
/* numpad: smaller for more breathing room */
|
|
.vm-num { font-size: 16px; padding: 13px 0; border-radius: 10px; }
|
|
.vm-gate { padding: 20px 16px; }
|
|
.vm-gate-pad { max-width: 100%; }
|
|
|
|
/* pct row */
|
|
.vm-pct-row button { padding: 7px 0; font-size: 10px; }
|
|
.vm-submit { padding: 14px; font-size: 13px; }
|
|
}
|
|
|
|
@media (max-width: 380px) {
|
|
.vm-cur-tab { padding: 5px 8px; gap: 4px; }
|
|
.vm-cur-amt { display: none; }
|
|
.vm-num { font-size: 15px; padding: 12px 0; }
|
|
.vm-bal-row { flex-wrap: wrap; }
|
|
.vm-bal-card { min-width: 0; }
|
|
.vm-bal-arrow { display: none; }
|
|
}
|
|
</style>
|
|
|
|
<style>
|
|
.vm-particle { pointer-events: none; filter: drop-shadow(0 0 6px currentColor); }
|
|
</style>
|