feat(wallet): paste-send confirmation step + every-time scan/upload chooser
- Pasting an invoice/address now gets the same second-step confirmation the scan flow has: method, destination, live balance -> amount -> balance after (invoice-fixed amounts win over the typed one), for every rail including token minting. Nothing pays until Confirm & Send. - The scan modal opens on a chooser every time: scan with camera or upload/take a photo of the QR — the camera no longer auto-starts, and Back only relights it if camera was the user's choice. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c58f84c6e9
commit
6502f13e29
@@ -1,5 +1,54 @@
|
|||||||
<template>
|
<template>
|
||||||
<BaseModal :show="show" :title="t('web5.sendBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
|
<BaseModal :show="show" :title="t('web5.sendBitcoinTitle')" max-width="max-w-2xl" content-class="max-h-[90vh] overflow-y-auto" @close="close">
|
||||||
|
<!-- ============ CONFIRM PANE (second step, mirrors the scan flow) ============ -->
|
||||||
|
<template v-if="confirming">
|
||||||
|
<div class="mb-3 p-3 bg-white/5 rounded-lg">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="text-xs text-white/50">Method</span>
|
||||||
|
<span class="text-sm font-medium" :class="methodColor">{{ methodLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="dest.trim()" class="mb-1">
|
||||||
|
<p class="text-xs text-white/50 mb-1">{{ effectiveMethod === 'lightning' ? 'Invoice' : effectiveMethod === 'ark' ? 'Destination' : 'Address' }}</p>
|
||||||
|
<p class="text-xs font-mono text-white/80 break-all">{{ destDisplay }}</p>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-xs text-white/60">Creates a {{ methodLabel }} token you can share — sats leave your balance when it's redeemed.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live balance impact -->
|
||||||
|
<div class="mb-3 p-3 bg-white/5 rounded-lg space-y-1.5">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-white/50">{{ methodLabel }} balance</span>
|
||||||
|
<span class="text-sm font-medium text-white/80">{{ confirmBalance === null ? '…' : confirmBalance.toLocaleString() + ' sats' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-white/50 flex items-center gap-2">
|
||||||
|
Amount
|
||||||
|
<span v-if="invoiceAmountSats !== null" class="text-[11px] px-2 py-0.5 rounded-full bg-white/10 text-white/50">set by invoice</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-sm font-medium text-white/80">−{{ confirmAmount.toLocaleString() }} sats</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-white/50">Balance after</span>
|
||||||
|
<span class="text-sm font-medium" :class="insufficient ? 'text-red-400' : 'text-white/80'">
|
||||||
|
{{ confirmBalance === null ? '…' : balanceAfter.toLocaleString() + ' sats' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="insufficient" class="text-xs text-red-400 mb-3">Not enough {{ methodLabel }} balance for this amount.</p>
|
||||||
|
<p v-else-if="isSweep" class="text-xs text-white/50 mb-3">Sweeps the entire on-chain balance minus network fees.</p>
|
||||||
|
<p v-else-if="effectiveMethod === 'onchain'" class="text-xs text-white/50 mb-3">Network fees are deducted on top of the amount.</p>
|
||||||
|
|
||||||
|
<div v-if="error" class="mb-3 alert-error">{{ error }}</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button @click="confirming = false" :disabled="processing" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Back</button>
|
||||||
|
<button @click="send" :disabled="processing || insufficient || (confirmAmount <= 0 && !isSweep)" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
||||||
|
{{ processing ? t('common.sending') : 'Confirm & Send' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
<!-- Method tabs -->
|
<!-- Method tabs -->
|
||||||
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
<div class="flex gap-1 mb-4 p-1 bg-white/5 rounded-lg">
|
||||||
<button
|
<button
|
||||||
@@ -80,10 +129,11 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Scan
|
Scan
|
||||||
</button>
|
</button>
|
||||||
<button @click="send" :disabled="processing || (!amount && !isSweep)" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
<button @click="review" :disabled="processing" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
|
||||||
{{ processing ? t('common.sending') : t('common.send') }}
|
{{ t('common.send') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</BaseModal>
|
</BaseModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -136,12 +186,95 @@ const effectiveMethod = computed(() => {
|
|||||||
return 'lightning'
|
return 'lightning'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// --- Second-step confirmation (parity with the scan flow): review shows the
|
||||||
|
// --- balance reduction before anything is sent or any token is minted.
|
||||||
|
|
||||||
|
const confirming = ref(false)
|
||||||
|
const confirmBalance = ref<number | null>(null)
|
||||||
|
const invoiceAmountSats = ref<number | null>(null)
|
||||||
|
|
||||||
|
const methodLabel = computed(() => ({
|
||||||
|
auto: 'Auto', lightning: 'Lightning', onchain: 'On-chain',
|
||||||
|
ecash: 'Cashu', fedimint: 'Fedimint', ark: 'Ark',
|
||||||
|
}[effectiveMethod.value]))
|
||||||
|
|
||||||
|
const methodColor = computed(() => ({
|
||||||
|
auto: 'text-white/80', lightning: 'text-yellow-400', onchain: 'text-orange-500',
|
||||||
|
ecash: 'text-purple-400', fedimint: 'text-blue-400', ark: 'text-teal-400',
|
||||||
|
}[effectiveMethod.value]))
|
||||||
|
|
||||||
|
const destDisplay = computed(() => {
|
||||||
|
const d = dest.value.trim()
|
||||||
|
return d.length > 64 ? `${d.slice(0, 38)}…${d.slice(-18)}` : d
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Amount encoded in a BOLT11 invoice's human-readable part, in sats (null = zero-amount). */
|
||||||
|
function parseBolt11AmountSats(invoice: string): number | null {
|
||||||
|
const m = /^ln(?:bcrt|bc|tb)(\d+)?([munp])?1/.exec(invoice.toLowerCase())
|
||||||
|
if (!m || !m[1]) return null
|
||||||
|
const value = Number(m[1])
|
||||||
|
const mult = { m: 1e-3, u: 1e-6, n: 1e-9, p: 1e-12 }[m[2] as 'm' | 'u' | 'n' | 'p'] ?? 1
|
||||||
|
return Math.round(value * mult * 1e8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An invoice-fixed amount always wins over the typed one; a sweep is priced
|
||||||
|
// at the whole balance.
|
||||||
|
const confirmAmount = computed(() => {
|
||||||
|
if (isSweep.value) return confirmBalance.value ?? 0
|
||||||
|
if (effectiveMethod.value === 'lightning' && invoiceAmountSats.value !== null) return invoiceAmountSats.value
|
||||||
|
return amount.value > 0 ? Math.floor(amount.value) : 0
|
||||||
|
})
|
||||||
|
const balanceAfter = computed(() => (confirmBalance.value ?? 0) - confirmAmount.value)
|
||||||
|
const insufficient = computed(() =>
|
||||||
|
confirmBalance.value !== null && confirmAmount.value > confirmBalance.value && !isSweep.value
|
||||||
|
)
|
||||||
|
|
||||||
|
async function loadConfirmBalance() {
|
||||||
|
confirmBalance.value = null
|
||||||
|
try {
|
||||||
|
if (effectiveMethod.value === 'ecash') {
|
||||||
|
const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance' })
|
||||||
|
confirmBalance.value = res.balance_sats ?? 0
|
||||||
|
} else if (effectiveMethod.value === 'fedimint') {
|
||||||
|
const res = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.fedimint-balance' })
|
||||||
|
confirmBalance.value = res.balance_sats ?? 0
|
||||||
|
} else {
|
||||||
|
const res = await rpcClient.call<{ balance_sats: number; channel_balance_sats: number }>({ method: 'lnd.getinfo' })
|
||||||
|
confirmBalance.value = effectiveMethod.value === 'onchain'
|
||||||
|
? (res.balance_sats ?? 0)
|
||||||
|
: (res.channel_balance_sats ?? 0)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
confirmBalance.value = null // balance preview is best-effort; send still guarded server-side
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function review() {
|
||||||
|
error.value = ''
|
||||||
|
const method = effectiveMethod.value
|
||||||
|
const d = dest.value.trim()
|
||||||
|
if (method === 'lightning') {
|
||||||
|
if (!d) { error.value = t('web5.pasteInvoice'); return }
|
||||||
|
invoiceAmountSats.value = parseBolt11AmountSats(d)
|
||||||
|
} else {
|
||||||
|
invoiceAmountSats.value = null
|
||||||
|
if (method === 'ark' && !d) { error.value = 'Enter an Ark address, invoice or lightning address'; return }
|
||||||
|
if (method === 'onchain' && !d) { error.value = t('web5.enterBitcoinAddress'); return }
|
||||||
|
}
|
||||||
|
if (!isSweep.value && confirmAmount.value <= 0 && invoiceAmountSats.value === null) {
|
||||||
|
error.value = t('sendBitcoin.amountSats'); return
|
||||||
|
}
|
||||||
|
void loadConfirmBalance()
|
||||||
|
confirming.value = true
|
||||||
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
resultTxid.value = ''
|
resultTxid.value = ''
|
||||||
resultHash.value = ''
|
resultHash.value = ''
|
||||||
resultArk.value = ''
|
resultArk.value = ''
|
||||||
ecashToken.value = ''
|
ecashToken.value = ''
|
||||||
|
confirming.value = false
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,7 +295,8 @@ watch(ecashToken, async (token) => {
|
|||||||
|
|
||||||
async function send() {
|
async function send() {
|
||||||
if (processing.value) return
|
if (processing.value) return
|
||||||
if (!amount.value && !isSweep.value) return
|
// Zero typed amount is fine when the invoice fixes the amount or we sweep.
|
||||||
|
if (!amount.value && !isSweep.value && invoiceAmountSats.value === null) return
|
||||||
processing.value = true
|
processing.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
ecashToken.value = ''
|
ecashToken.value = ''
|
||||||
@@ -212,6 +346,8 @@ async function send() {
|
|||||||
resultTxid.value = res.txid
|
resultTxid.value = res.txid
|
||||||
}
|
}
|
||||||
emit('sent')
|
emit('sent')
|
||||||
|
// Back to the form pane so the success/token panes are visible.
|
||||||
|
confirming.value = false
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
error.value = err instanceof Error ? err.message : t('web5.sendFailed')
|
error.value = err instanceof Error ? err.message : t('web5.sendFailed')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -43,7 +43,22 @@
|
|||||||
<Transition :name="direction === 'forward' ? 'pane-forward' : 'pane-back'" mode="out-in">
|
<Transition :name="direction === 'forward' ? 'pane-forward' : 'pane-back'" mode="out-in">
|
||||||
<!-- ============ SCAN PANE ============ -->
|
<!-- ============ SCAN PANE ============ -->
|
||||||
<div v-if="pane === 'scan'" key="scan">
|
<div v-if="pane === 'scan'" key="scan">
|
||||||
<div class="relative w-full aspect-square rounded-xl overflow-hidden bg-black/40 border border-white/10 mb-4">
|
<!-- Chooser interstitial — every open: the user picks live camera
|
||||||
|
or a photo upload; neither starts until chosen. -->
|
||||||
|
<div v-if="scanChoice === 'unset'" class="w-full rounded-xl bg-black/30 border border-white/10 mb-4 p-6 flex flex-col items-center gap-3">
|
||||||
|
<svg class="w-10 h-10 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 8V6a2 2 0 012-2h2M3 16v2a2 2 0 002 2h2m10-16h2a2 2 0 012 2v2m-4 12h2a2 2 0 002-2v-2M7 12h10" />
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm text-white/60 text-center">How do you want to read the QR?</p>
|
||||||
|
<button v-if="!liveCameraUnavailable" @click="chooseCamera" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
|
||||||
|
Scan with camera
|
||||||
|
</button>
|
||||||
|
<button @click="photoInput?.click()" class="glass-button w-full px-4 py-2.5 rounded-lg text-sm font-medium">
|
||||||
|
Upload / take a photo of the QR
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="relative w-full aspect-square rounded-xl overflow-hidden bg-black/40 border border-white/10 mb-4">
|
||||||
<!-- opacity (not v-if/v-show): the scanner needs the element, and a
|
<!-- opacity (not v-if/v-show): the scanner needs the element, and a
|
||||||
source-less <video> flashes a native play glyph in Android WebViews -->
|
source-less <video> flashes a native play glyph in Android WebViews -->
|
||||||
<video ref="videoElement" class="w-full h-full object-cover transition-opacity duration-200" :class="isScanning ? 'opacity-100' : 'opacity-0'" autoplay muted playsinline></video>
|
<video ref="videoElement" class="w-full h-full object-cover transition-opacity duration-200" :class="isScanning ? 'opacity-100' : 'opacity-0'" autoplay muted playsinline></video>
|
||||||
@@ -73,17 +88,20 @@
|
|||||||
<button @click="photoInput?.click()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium">
|
<button @click="photoInput?.click()" class="glass-button px-4 py-2 rounded-lg text-sm font-medium">
|
||||||
Take photo of QR
|
Take photo of QR
|
||||||
</button>
|
</button>
|
||||||
<input
|
|
||||||
ref="photoInput"
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
capture="environment"
|
|
||||||
class="hidden"
|
|
||||||
@change="onPhotoPicked"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Single always-mounted picker input, shared by the chooser and
|
||||||
|
the in-camera fallback button -->
|
||||||
|
<input
|
||||||
|
ref="photoInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
class="hidden"
|
||||||
|
@change="onPhotoPicked"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="mb-4 p-3 bg-white/5 rounded-lg min-h-[3rem] flex items-center justify-center">
|
<div class="mb-4 p-3 bg-white/5 rounded-lg min-h-[3rem] flex items-center justify-center">
|
||||||
<p class="text-sm text-center" :class="scanStatusIsError ? 'text-red-400' : 'text-white/60'">
|
<p class="text-sm text-center" :class="scanStatusIsError ? 'text-red-400' : 'text-white/60'">
|
||||||
{{ scanStatus || 'Point the camera at a Lightning invoice, Bitcoin address, Cashu or Fedimint code' }}
|
{{ scanStatus || 'Point the camera at a Lightning invoice, Bitcoin address, Cashu or Fedimint code' }}
|
||||||
@@ -282,7 +300,9 @@ function goBack() {
|
|||||||
if (pane.value === 'amount') {
|
if (pane.value === 'amount') {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
goTo('scan', 'back')
|
goTo('scan', 'back')
|
||||||
nextTick(() => { if (!liveCameraUnavailable.value) startScanning() })
|
// Only relight the camera when that's what the user chose; otherwise
|
||||||
|
// they land back on the scan/upload chooser.
|
||||||
|
nextTick(() => { if (scanChoice.value === 'camera' && !liveCameraUnavailable.value) startScanning() })
|
||||||
} else if (pane.value === 'success') {
|
} else if (pane.value === 'success') {
|
||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
@@ -295,6 +315,16 @@ const isScanning = ref(false)
|
|||||||
// open) — the fallback buttons stay hidden until it resolves, so they don't
|
// open) — the fallback buttons stay hidden until it resolves, so they don't
|
||||||
// flash for a second on every open.
|
// flash for a second on every open.
|
||||||
const autoStarting = ref(false)
|
const autoStarting = ref(false)
|
||||||
|
|
||||||
|
// Camera-vs-photo chooser, shown on EVERY open (user request 2026-07-23):
|
||||||
|
// nothing starts until the user picks, and Back from a later pane returns to
|
||||||
|
// the live camera only if that's what they chose.
|
||||||
|
const scanChoice = ref<'unset' | 'camera'>('unset')
|
||||||
|
|
||||||
|
function chooseCamera() {
|
||||||
|
scanChoice.value = 'camera'
|
||||||
|
void nextTick(() => startScanning())
|
||||||
|
}
|
||||||
const scanStatus = ref('')
|
const scanStatus = ref('')
|
||||||
const scanStatusIsError = ref(false)
|
const scanStatusIsError = ref(false)
|
||||||
const cameraError = ref(false)
|
const cameraError = ref(false)
|
||||||
@@ -709,7 +739,8 @@ function close() {
|
|||||||
watch(() => props.show, (open) => {
|
watch(() => props.show, (open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
resetAll()
|
resetAll()
|
||||||
nextTick(() => { if (!liveCameraUnavailable.value) startScanning() })
|
// No auto-start: the chooser interstitial owns the first move every time.
|
||||||
|
scanChoice.value = 'unset'
|
||||||
} else {
|
} else {
|
||||||
stopScanning()
|
stopScanning()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user