328 lines
9.7 KiB
Vue
328 lines
9.7 KiB
Vue
|
|
<script setup lang="ts">
|
||
|
|
import { ref, computed, onMounted } from 'vue'
|
||
|
|
import { useRouter } from 'vue-router'
|
||
|
|
import { toast } from 'vue-sonner'
|
||
|
|
import { Send, ArrowLeft } from 'lucide-vue-next'
|
||
|
|
import { Button } from '@/components/ui/button'
|
||
|
|
import { Label } from '@/components/ui/label'
|
||
|
|
import { Input } from '@/components/ui/input'
|
||
|
|
import { Textarea } from '@/components/ui/textarea'
|
||
|
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||
|
|
import { Badge } from '@/components/ui/badge'
|
||
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||
|
|
import {
|
||
|
|
Dialog,
|
||
|
|
DialogContent,
|
||
|
|
DialogDescription,
|
||
|
|
DialogFooter,
|
||
|
|
DialogHeader,
|
||
|
|
DialogTitle,
|
||
|
|
} from '@/components/ui/dialog'
|
||
|
|
import { useNeptuneStore } from '@/stores/neptuneStore'
|
||
|
|
import { useNeptuneWallet } from '@/composables/useNeptuneWallet'
|
||
|
|
|
||
|
|
const router = useRouter()
|
||
|
|
const neptuneStore = useNeptuneStore()
|
||
|
|
const { getBalance, getUtxos, buildTransaction, broadcastSignedTransaction } = useNeptuneWallet()
|
||
|
|
|
||
|
|
const availableBalance = ref('0')
|
||
|
|
const loading = ref(false)
|
||
|
|
const sendLoading = ref(false)
|
||
|
|
const showConfirmModal = ref(false)
|
||
|
|
|
||
|
|
const outputAddress = ref('')
|
||
|
|
const outputAmount = ref('')
|
||
|
|
const priorityFee = ref('')
|
||
|
|
|
||
|
|
const loadBalance = async () => {
|
||
|
|
try {
|
||
|
|
loading.value = true
|
||
|
|
const result = await getBalance()
|
||
|
|
availableBalance.value = result?.balance ?? '0'
|
||
|
|
} catch (error) {
|
||
|
|
toast.error('Failed to load balance')
|
||
|
|
} finally {
|
||
|
|
loading.value = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const isAddressValid = computed(() => outputAddress.value.trim().length > 0)
|
||
|
|
const isAmountValid = computed(() => {
|
||
|
|
if (!outputAmount.value) return false
|
||
|
|
const num = parseFloat(outputAmount.value)
|
||
|
|
if (isNaN(num) || num <= 0) return false
|
||
|
|
|
||
|
|
const balance = parseFloat(availableBalance.value)
|
||
|
|
if (!isNaN(balance) && num > balance) return false
|
||
|
|
|
||
|
|
return true
|
||
|
|
})
|
||
|
|
const isFeeValid = computed(() => {
|
||
|
|
if (!priorityFee.value) return false
|
||
|
|
const num = parseFloat(priorityFee.value)
|
||
|
|
return !isNaN(num) && num >= 0
|
||
|
|
})
|
||
|
|
|
||
|
|
const amountErrorMessage = computed(() => {
|
||
|
|
if (!outputAmount.value) return ''
|
||
|
|
const num = parseFloat(outputAmount.value)
|
||
|
|
if (isNaN(num) || num <= 0) return 'Invalid amount'
|
||
|
|
|
||
|
|
const balance = parseFloat(availableBalance.value)
|
||
|
|
if (!isNaN(balance) && num > balance) {
|
||
|
|
return `Insufficient balance. Available: ${availableBalance.value} XNT`
|
||
|
|
}
|
||
|
|
|
||
|
|
return ''
|
||
|
|
})
|
||
|
|
|
||
|
|
const isFormValid = computed(
|
||
|
|
() => isAddressValid.value && isAmountValid.value && isFeeValid.value && !sendLoading.value
|
||
|
|
)
|
||
|
|
|
||
|
|
const formatDecimal = (value: string) => {
|
||
|
|
if (!value) return ''
|
||
|
|
|
||
|
|
let cleaned = value.replace(/[^\d.]/g, '')
|
||
|
|
|
||
|
|
const parts = cleaned.split('.')
|
||
|
|
if (parts.length > 2) {
|
||
|
|
cleaned = parts[0] + '.' + parts.slice(1).join('')
|
||
|
|
}
|
||
|
|
|
||
|
|
if (cleaned && !cleaned.includes('.') && /^\d+$/.test(cleaned)) {
|
||
|
|
cleaned = cleaned + '.0'
|
||
|
|
}
|
||
|
|
|
||
|
|
if (cleaned.includes('.')) {
|
||
|
|
const [integer, decimal = ''] = cleaned.split('.')
|
||
|
|
cleaned = integer + '.' + decimal.slice(0, 8)
|
||
|
|
}
|
||
|
|
|
||
|
|
return cleaned
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleAmountBlur = () => {
|
||
|
|
if (outputAmount.value) {
|
||
|
|
outputAmount.value = formatDecimal(outputAmount.value)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleFeeBlur = () => {
|
||
|
|
if (priorityFee.value) {
|
||
|
|
priorityFee.value = formatDecimal(priorityFee.value)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleShowConfirm = () => {
|
||
|
|
if (!isFormValid.value) return
|
||
|
|
showConfirmModal.value = true
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleCancelConfirm = () => {
|
||
|
|
showConfirmModal.value = false
|
||
|
|
}
|
||
|
|
|
||
|
|
const handleConfirmSend = async () => {
|
||
|
|
try {
|
||
|
|
sendLoading.value = true
|
||
|
|
showConfirmModal.value = false
|
||
|
|
|
||
|
|
// Get UTXOs
|
||
|
|
const utxosResult = await getUtxos()
|
||
|
|
const utxos = utxosResult?.utxos || []
|
||
|
|
|
||
|
|
if (!utxos.length) {
|
||
|
|
toast.error('No UTXOs available')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Build transaction
|
||
|
|
const inputAdditionRecords = utxos.map((utxo: any) => utxo.additionRecord || utxo.addition_record)
|
||
|
|
|
||
|
|
const txResult = await buildTransaction({
|
||
|
|
inputAdditionRecords,
|
||
|
|
outputAddresses: [outputAddress.value.trim()],
|
||
|
|
outputAmounts: [outputAmount.value],
|
||
|
|
fee: priorityFee.value,
|
||
|
|
})
|
||
|
|
|
||
|
|
if (!txResult.success) {
|
||
|
|
toast.error('Failed to build transaction')
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
// Broadcast transaction
|
||
|
|
await broadcastSignedTransaction(txResult.transaction)
|
||
|
|
|
||
|
|
toast.success('Transaction sent successfully!')
|
||
|
|
|
||
|
|
// Navigate back to wallet
|
||
|
|
router.push('/')
|
||
|
|
} catch (error) {
|
||
|
|
const message = error instanceof Error ? error.message : 'Failed to send transaction'
|
||
|
|
toast.error('Transaction Failed', { description: message })
|
||
|
|
} finally {
|
||
|
|
sendLoading.value = false
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
onMounted(() => {
|
||
|
|
loadBalance()
|
||
|
|
})
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<template>
|
||
|
|
<div class="p-4">
|
||
|
|
<div class="mx-auto max-w-2xl space-y-6">
|
||
|
|
<!-- Header -->
|
||
|
|
<div class="flex items-center gap-3">
|
||
|
|
<Button variant="ghost" size="icon" @click="router.push('/')">
|
||
|
|
<ArrowLeft class="size-5" />
|
||
|
|
</Button>
|
||
|
|
<div>
|
||
|
|
<h1 class="text-2xl font-bold text-foreground">Send Transaction</h1>
|
||
|
|
<p class="text-sm text-muted-foreground">Send XNT to another address</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Send Form -->
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle class="flex items-center gap-2">
|
||
|
|
<Send class="size-5" />
|
||
|
|
Transaction Details
|
||
|
|
</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent class="space-y-4">
|
||
|
|
<!-- Recipient Address -->
|
||
|
|
<div class="space-y-2">
|
||
|
|
<Label for="recipient">
|
||
|
|
Recipient Address <span class="text-destructive">*</span>
|
||
|
|
</Label>
|
||
|
|
<Textarea
|
||
|
|
id="recipient"
|
||
|
|
v-model="outputAddress"
|
||
|
|
placeholder="Enter recipient address"
|
||
|
|
rows="3"
|
||
|
|
class="font-mono text-sm"
|
||
|
|
:disabled="sendLoading"
|
||
|
|
/>
|
||
|
|
<p v-if="outputAddress && !isAddressValid" class="text-xs text-destructive">
|
||
|
|
Address is required
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Amount and Fee Row -->
|
||
|
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||
|
|
<div class="space-y-2">
|
||
|
|
<div class="flex items-center justify-between">
|
||
|
|
<Label for="amount">
|
||
|
|
Amount <span class="text-destructive">*</span>
|
||
|
|
</Label>
|
||
|
|
<Badge variant="secondary" class="text-xs">
|
||
|
|
Available: {{ availableBalance }} XNT
|
||
|
|
</Badge>
|
||
|
|
</div>
|
||
|
|
<Input
|
||
|
|
id="amount"
|
||
|
|
v-model="outputAmount"
|
||
|
|
type="text"
|
||
|
|
placeholder="0.0"
|
||
|
|
:disabled="sendLoading"
|
||
|
|
@blur="handleAmountBlur"
|
||
|
|
/>
|
||
|
|
<p v-if="amountErrorMessage" class="text-xs text-destructive">
|
||
|
|
{{ amountErrorMessage }}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="space-y-2">
|
||
|
|
<Label for="fee">
|
||
|
|
Priority Fee <span class="text-destructive">*</span>
|
||
|
|
</Label>
|
||
|
|
<Input
|
||
|
|
id="fee"
|
||
|
|
v-model="priorityFee"
|
||
|
|
type="text"
|
||
|
|
placeholder="0.0"
|
||
|
|
:disabled="sendLoading"
|
||
|
|
@blur="handleFeeBlur"
|
||
|
|
/>
|
||
|
|
<p v-if="priorityFee && !isFeeValid" class="text-xs text-destructive">
|
||
|
|
Invalid fee
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Send Button -->
|
||
|
|
<Button
|
||
|
|
class="w-full gap-2"
|
||
|
|
:disabled="!isFormValid || sendLoading"
|
||
|
|
@click="handleShowConfirm"
|
||
|
|
>
|
||
|
|
<Send class="size-4" />
|
||
|
|
{{ sendLoading ? 'Sending...' : 'Review & Send' }}
|
||
|
|
</Button>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Confirm Transaction Modal -->
|
||
|
|
<Dialog v-model:open="showConfirmModal">
|
||
|
|
<DialogContent class="sm:max-w-[500px]">
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>Confirm Transaction</DialogTitle>
|
||
|
|
<DialogDescription>Please review the transaction details before sending</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
|
||
|
|
<div class="space-y-4 py-4">
|
||
|
|
<!-- Recipient -->
|
||
|
|
<div class="space-y-2">
|
||
|
|
<Label class="text-sm text-muted-foreground">Recipient Address</Label>
|
||
|
|
<div class="break-all rounded-lg border border-border bg-muted/50 p-3 font-mono text-sm">
|
||
|
|
{{ outputAddress }}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Amount and Fee -->
|
||
|
|
<div class="grid grid-cols-2 gap-4">
|
||
|
|
<div class="space-y-2">
|
||
|
|
<Label class="text-sm text-muted-foreground">Amount</Label>
|
||
|
|
<div class="rounded-lg border border-border bg-muted/50 p-3 font-semibold">
|
||
|
|
{{ outputAmount }} XNT
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="space-y-2">
|
||
|
|
<Label class="text-sm text-muted-foreground">Priority Fee</Label>
|
||
|
|
<div class="rounded-lg border border-border bg-muted/50 p-3 font-semibold">
|
||
|
|
{{ priorityFee }} XNT
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Alert>
|
||
|
|
<AlertDescription>
|
||
|
|
This action cannot be undone. Make sure all details are correct before proceeding.
|
||
|
|
</AlertDescription>
|
||
|
|
</Alert>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<DialogFooter class="gap-2">
|
||
|
|
<Button variant="outline" @click="handleCancelConfirm" :disabled="sendLoading">
|
||
|
|
Cancel
|
||
|
|
</Button>
|
||
|
|
<Button @click="handleConfirmSend" :disabled="sendLoading">
|
||
|
|
<span
|
||
|
|
v-if="sendLoading"
|
||
|
|
class="mr-2 size-4 animate-spin rounded-full border-2 border-white border-t-transparent"
|
||
|
|
/>
|
||
|
|
{{ sendLoading ? 'Sending...' : 'Confirm & Send' }}
|
||
|
|
</Button>
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
|