Launching an app (or opening a tx in mempool from Home/wallet/channels) used to router.push the app-session page in overlay/fullscreen modes, swapping the page underneath — confusing and lossy. All display modes are now store-driven: panel renders beside the page, overlay/fullscreen render above it (teleported to body), and the route never changes. Deep-link paths (/tx/<hash>) ride along via the launcher's panelPath instead of a route query; closing always returns exactly where the user was. The app-session route stays for direct links. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
141 lines
6.0 KiB
Vue
141 lines
6.0 KiB
Vue
<template>
|
|
<BaseModal :show="show" :title="t('transactions.title')" max-width="max-w-2xl" content-class="max-h-[90vh] flex flex-col" @close="close">
|
|
<div v-if="transactions.length === 0" class="flex-1 flex items-center justify-center py-12">
|
|
<p class="text-white/40 text-sm">{{ t('transactions.noTransactionsYet') }}</p>
|
|
</div>
|
|
|
|
<div v-else class="flex-1 overflow-y-auto -mx-2 px-2 divide-y divide-white/5">
|
|
<div
|
|
v-for="tx in transactions"
|
|
:key="(tx.kind || 'onchain') + tx.tx_hash + tx.time_stamp"
|
|
class="flex items-center justify-between gap-3 py-3 hover:bg-white/5 rounded-lg px-2 transition-colors"
|
|
:class="isOnchain(tx) ? 'cursor-pointer' : 'cursor-default'"
|
|
@click="openInMempool(tx)"
|
|
>
|
|
<div class="flex items-center gap-3 min-w-0 flex-1">
|
|
<div
|
|
class="w-8 h-8 rounded-full flex items-center justify-center shrink-0"
|
|
:class="tx.direction === 'incoming'
|
|
? (tx.num_confirmations === 0 ? 'bg-yellow-500/15' : 'bg-green-500/15')
|
|
: 'bg-red-500/10'"
|
|
>
|
|
<svg
|
|
class="w-4 h-4"
|
|
:class="tx.direction === 'incoming'
|
|
? (tx.num_confirmations === 0 ? 'text-yellow-400' : 'text-green-400')
|
|
: 'text-red-400'"
|
|
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
|
>
|
|
<path v-if="tx.direction === 'incoming'" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3" />
|
|
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18" />
|
|
</svg>
|
|
</div>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex items-center gap-2">
|
|
<span
|
|
class="text-sm font-medium"
|
|
:class="tx.direction === 'incoming' ? 'text-green-400' : 'text-red-400'"
|
|
>
|
|
{{ tx.direction === 'incoming' ? '+' : '-' }}{{ Math.abs(tx.amount_sats).toLocaleString() }} sats
|
|
</span>
|
|
<span
|
|
v-if="isOnchain(tx)"
|
|
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
|
|
:class="tx.num_confirmations === 0
|
|
? 'bg-yellow-500/15 text-yellow-400'
|
|
: tx.num_confirmations < 3
|
|
? 'bg-green-500/15 text-green-400'
|
|
: 'bg-white/10 text-white/50'"
|
|
>
|
|
{{ tx.num_confirmations === 0 ? t('transactions.unconfirmed') : t('transactions.confirmations', { count: tx.num_confirmations }) }}
|
|
</span>
|
|
<span
|
|
v-else
|
|
class="text-[10px] px-1.5 py-0.5 rounded-full font-medium"
|
|
:class="tx.kind === 'lightning' ? 'bg-yellow-500/15 text-yellow-400' : tx.kind === 'cashu' ? 'bg-purple-500/15 text-purple-400' : 'bg-blue-500/15 text-blue-400'"
|
|
>
|
|
{{ kindLabel(tx) }}
|
|
</span>
|
|
</div>
|
|
<div class="flex items-center gap-2 mt-0.5">
|
|
<p class="text-[11px] text-white/40 font-mono truncate">{{ tx.tx_hash }}</p>
|
|
<span v-if="tx.label" class="text-[10px] text-white/30 shrink-0">{{ tx.label }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2 shrink-0">
|
|
<span class="text-[11px] text-white/40">{{ formatTxTime(tx.time_stamp) }}</span>
|
|
<svg v-if="isOnchain(tx)" class="w-3.5 h-3.5 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</BaseModal>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { useI18n } from 'vue-i18n'
|
|
import BaseModal from '@/components/BaseModal.vue'
|
|
import { useAppLauncherStore } from '@/stores/appLauncher'
|
|
|
|
interface WalletTransaction {
|
|
tx_hash: string
|
|
amount_sats: number
|
|
direction: 'incoming' | 'outgoing'
|
|
num_confirmations: number
|
|
time_stamp: number
|
|
total_fees: number
|
|
dest_addresses: string[]
|
|
label: string
|
|
block_height: number
|
|
// Which rail the transaction happened on; absent = onchain (older backends)
|
|
kind?: 'onchain' | 'lightning' | 'cashu' | 'fedimint'
|
|
}
|
|
|
|
defineProps<{
|
|
show: boolean
|
|
transactions: WalletTransaction[]
|
|
}>()
|
|
|
|
const emit = defineEmits<{ close: [] }>()
|
|
const { t } = useI18n()
|
|
|
|
function close() {
|
|
emit('close')
|
|
}
|
|
|
|
// Only on-chain transactions exist in mempool; Lightning/ecash rows don't link
|
|
function isOnchain(tx: WalletTransaction): boolean {
|
|
return !tx.kind || tx.kind === 'onchain'
|
|
}
|
|
|
|
function kindLabel(tx: WalletTransaction): string {
|
|
if (tx.kind === 'lightning') return '⚡ Lightning'
|
|
if (tx.kind === 'cashu') return 'Cashu'
|
|
if (tx.kind === 'fedimint') return 'Fedimint'
|
|
return ''
|
|
}
|
|
|
|
function openInMempool(tx: WalletTransaction) {
|
|
if (!isOnchain(tx)) return
|
|
// Overlay the explorer above the current page — never navigate away.
|
|
useAppLauncherStore().openSession('mempool', { path: `/tx/${tx.tx_hash}` })
|
|
}
|
|
|
|
function formatTxTime(timestamp: number): string {
|
|
if (!timestamp) return ''
|
|
const date = new Date(timestamp * 1000)
|
|
const now = new Date()
|
|
const diffMs = now.getTime() - date.getTime()
|
|
const diffMins = Math.floor(diffMs / 60000)
|
|
if (diffMins < 1) return t('transactions.justNow')
|
|
if (diffMins < 60) return t('transactions.minutesAgo', { count: diffMins })
|
|
const diffHours = Math.floor(diffMins / 60)
|
|
if (diffHours < 24) return t('transactions.hoursAgo', { count: diffHours })
|
|
const diffDays = Math.floor(diffHours / 24)
|
|
if (diffDays < 7) return t('transactions.daysAgo', { count: diffDays })
|
|
return date.toLocaleDateString()
|
|
}
|
|
</script>
|