mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
feat: send/receive dialogs, qr scanner, history and unlock components
This commit is contained in:
@@ -0,0 +1,77 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Unified activity history: renders the activity store's events newest
|
||||||
|
// first, one row per wallet action. Self-contained - designed to sit inside
|
||||||
|
// a q-expansion-item on the home page.
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useActivityStore } from '@/stores/activity';
|
||||||
|
import type { ActivityKind } from '@/lnurlcash/storage';
|
||||||
|
|
||||||
|
const activity = useActivityStore();
|
||||||
|
|
||||||
|
// the store already prepends new events, so the array is newest-first
|
||||||
|
const events = computed(() => activity.events);
|
||||||
|
|
||||||
|
const KIND_ICONS: Record<ActivityKind, string> = {
|
||||||
|
mint: 'arrow_downward',
|
||||||
|
receive: 'south_west',
|
||||||
|
melt: 'north_east',
|
||||||
|
split: 'shuffle',
|
||||||
|
combine: 'shuffle',
|
||||||
|
spent: 'check',
|
||||||
|
deleted: 'delete',
|
||||||
|
transfer: 'swap_horiz',
|
||||||
|
};
|
||||||
|
|
||||||
|
const KIND_COLORS: Record<ActivityKind, string> = {
|
||||||
|
mint: 'positive',
|
||||||
|
receive: 'positive',
|
||||||
|
melt: 'primary',
|
||||||
|
split: 'info',
|
||||||
|
combine: 'info',
|
||||||
|
spent: 'grey-5',
|
||||||
|
deleted: 'negative',
|
||||||
|
transfer: 'primary',
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconFor = (kind: ActivityKind): string => KIND_ICONS[kind];
|
||||||
|
const colorFor = (kind: ActivityKind): string => KIND_COLORS[kind];
|
||||||
|
|
||||||
|
const relativeTime = (timestamp: number): string => {
|
||||||
|
const elapsed = Date.now() - timestamp;
|
||||||
|
const minutes = Math.floor(elapsed / 60_000);
|
||||||
|
if (minutes < 1) return 'just now';
|
||||||
|
if (minutes < 60) return `${minutes} min ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours} h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days === 1) return 'yesterday';
|
||||||
|
if (days < 7) return `${days} days ago`;
|
||||||
|
return new Date(timestamp).toLocaleDateString();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-list v-if="events.length" class="history-list">
|
||||||
|
<q-item v-for="event in events" :key="event.id" class="q-px-sm">
|
||||||
|
<q-item-section avatar top>
|
||||||
|
<q-icon :name="iconFor(event.kind)" :color="colorFor(event.kind)" size="20px" />
|
||||||
|
</q-item-section>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label class="text-grey-3">{{ event.message }}</q-item-label>
|
||||||
|
<q-item-label caption class="text-grey-6">
|
||||||
|
{{ relativeTime(event.createdAt) }}
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
<div v-else class="column items-center q-pa-lg text-grey-6">
|
||||||
|
<q-icon name="history" size="32px" class="q-mb-sm" />
|
||||||
|
<div>No activity yet</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.history-list {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// QR display for invoices and bearer notes. White frame matches the
|
||||||
|
// lnurl-wallet design language (scanners need the quiet zone).
|
||||||
|
import QrcodeVue from 'qrcode.vue';
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
value: string;
|
||||||
|
size?: number;
|
||||||
|
}>(),
|
||||||
|
{ size: 220 },
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="qr-frame">
|
||||||
|
<qrcode-vue :value="value" :size="size" level="M" render-as="svg" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.qr-frame {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Camera QR scanner (in-browser, works in PWA and Capacitor WebView via
|
||||||
|
// getUserMedia). Emits `decode` with the scanned text; parent decides what
|
||||||
|
// the payload means (invoice, note, address...).
|
||||||
|
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||||
|
import QrScanner from '@agicash/qr-scanner';
|
||||||
|
|
||||||
|
const emit = defineEmits<{ decode: [text: string]; error: [message: string] }>();
|
||||||
|
|
||||||
|
const videoEl = ref<HTMLVideoElement | null>(null);
|
||||||
|
const starting = ref(true);
|
||||||
|
let scanner: QrScanner | null = null;
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
if (!(await QrScanner.hasCamera())) {
|
||||||
|
emit('error', 'No camera available on this device.');
|
||||||
|
starting.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!videoEl.value) return;
|
||||||
|
scanner = new QrScanner(
|
||||||
|
videoEl.value,
|
||||||
|
(result) => emit('decode', result.data),
|
||||||
|
{ highlightScanRegion: true },
|
||||||
|
);
|
||||||
|
await scanner.start();
|
||||||
|
starting.value = false;
|
||||||
|
} catch (err) {
|
||||||
|
starting.value = false;
|
||||||
|
emit(
|
||||||
|
'error',
|
||||||
|
err instanceof Error ? err.message : 'Camera access failed.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
scanner?.destroy();
|
||||||
|
scanner = null;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="scanner-box">
|
||||||
|
<video ref="videoEl" muted playsinline />
|
||||||
|
<div v-if="starting" class="scanner-overlay">Starting camera…</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.scanner-box {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #001616;
|
||||||
|
}
|
||||||
|
video {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.scanner-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #55ffcc;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<template>
|
||||||
|
<q-card class="sattle-card unlock-card q-pa-lg">
|
||||||
|
<div class="column items-center q-mb-md">
|
||||||
|
<q-icon name="lock" color="primary" size="40px" />
|
||||||
|
<div class="text-h6 q-mt-sm">Wallet locked</div>
|
||||||
|
<div class="text-caption text-grey-5">Enter your password to unlock.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
v-model="password"
|
||||||
|
:type="showPassword ? 'text' : 'password'"
|
||||||
|
dark
|
||||||
|
outlined
|
||||||
|
color="primary"
|
||||||
|
label="Password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
:error="error !== ''"
|
||||||
|
:error-message="error"
|
||||||
|
autofocus
|
||||||
|
@keyup.enter="unlock"
|
||||||
|
>
|
||||||
|
<template #append>
|
||||||
|
<q-icon
|
||||||
|
:name="showPassword ? 'visibility_off' : 'visibility'"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="showPassword = !showPassword"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</q-input>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
text-color="dark"
|
||||||
|
label="Unlock"
|
||||||
|
class="full-width q-mt-md"
|
||||||
|
:loading="busy"
|
||||||
|
:disable="password === ''"
|
||||||
|
@click="unlock"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="text-center q-mt-md">
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
no-caps
|
||||||
|
size="sm"
|
||||||
|
color="grey-5"
|
||||||
|
label="Restore a different wallet"
|
||||||
|
@click="router.push('/welcome')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
import { useWalletStore } from '@/stores/wallet';
|
||||||
|
|
||||||
|
const emit = defineEmits<{ unlocked: [] }>();
|
||||||
|
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const password = ref('');
|
||||||
|
const showPassword = ref(false);
|
||||||
|
const busy = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
|
||||||
|
const unlock = async () => {
|
||||||
|
if (busy.value || password.value === '') return;
|
||||||
|
busy.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
await wallet.unlock(password.value);
|
||||||
|
password.value = '';
|
||||||
|
emit('unlocked');
|
||||||
|
} catch (err) {
|
||||||
|
// a wrong password fails WebCrypto's auth-tag check with a generic
|
||||||
|
// DOMException - anything but the store's own "no wallet" signal means
|
||||||
|
// the password simply didn't fit
|
||||||
|
error.value =
|
||||||
|
err instanceof Error && err.message === 'No wallet on this device.'
|
||||||
|
? err.message
|
||||||
|
: 'Wrong password.';
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.unlock-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<template>
|
||||||
|
<q-dialog
|
||||||
|
:model-value="modelValue"
|
||||||
|
position="bottom"
|
||||||
|
transition-show="slide-up"
|
||||||
|
transition-hide="slide-down"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<q-card class="sattle-card receive-chooser q-pb-md">
|
||||||
|
<q-card-section class="row items-center q-pb-sm">
|
||||||
|
<div class="col text-h6">Receive</div>
|
||||||
|
<q-btn v-close-popup flat round dense icon="close" color="primary" />
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section class="q-pt-sm">
|
||||||
|
<div class="q-gutter-y-md">
|
||||||
|
<div
|
||||||
|
class="action-row"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click="open('lightning')"
|
||||||
|
@keydown.enter.prevent="open('lightning')"
|
||||||
|
@keydown.space.prevent="open('lightning')"
|
||||||
|
>
|
||||||
|
<div class="row items-center no-wrap">
|
||||||
|
<div class="icon-circle">
|
||||||
|
<q-icon name="flash_on" color="dark" size="24px" />
|
||||||
|
</div>
|
||||||
|
<div class="col q-ml-md">
|
||||||
|
<div class="text-body1 text-weight-medium">Lightning</div>
|
||||||
|
<div class="text-caption text-grey-5">
|
||||||
|
Create an invoice to receive from any Lightning wallet
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-icon name="chevron_right" color="grey-5" size="24px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="action-row"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click="open('token')"
|
||||||
|
@keydown.enter.prevent="open('token')"
|
||||||
|
@keydown.space.prevent="open('token')"
|
||||||
|
>
|
||||||
|
<div class="row items-center no-wrap">
|
||||||
|
<div class="icon-circle">
|
||||||
|
<q-icon name="receipt_long" color="dark" size="24px" />
|
||||||
|
</div>
|
||||||
|
<div class="col q-ml-md">
|
||||||
|
<div class="text-body1 text-weight-medium">Bearer note</div>
|
||||||
|
<div class="text-caption text-grey-5">
|
||||||
|
Paste or scan a note someone sent you
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-icon name="chevron_right" color="grey-5" size="24px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<receive-lightning-dialog v-model="showLightning" @received="onReceived" />
|
||||||
|
<receive-token-dialog v-model="showToken" @received="onReceived" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import ReceiveLightningDialog from './ReceiveLightningDialog.vue';
|
||||||
|
import ReceiveTokenDialog from './ReceiveTokenDialog.vue';
|
||||||
|
|
||||||
|
defineProps<{ modelValue: boolean }>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean];
|
||||||
|
received: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const showLightning = ref(false);
|
||||||
|
const showToken = ref(false);
|
||||||
|
|
||||||
|
const open = (which: 'lightning' | 'token') => {
|
||||||
|
emit('update:modelValue', false);
|
||||||
|
if (which === 'lightning') {
|
||||||
|
showLightning.value = true;
|
||||||
|
} else {
|
||||||
|
showToken.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onReceived = () => {
|
||||||
|
emit('received');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.receive-chooser {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-row {
|
||||||
|
background: rgba(85, 255, 204, 0.05);
|
||||||
|
border: 1px solid rgba(85, 255, 204, 0.15);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-row:hover,
|
||||||
|
.action-row:focus-visible {
|
||||||
|
background: rgba(85, 255, 204, 0.1);
|
||||||
|
border-color: rgba(85, 255, 204, 0.35);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-circle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #55ffcc;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
<template>
|
||||||
|
<q-dialog
|
||||||
|
:model-value="modelValue"
|
||||||
|
position="bottom"
|
||||||
|
transition-show="slide-up"
|
||||||
|
transition-hide="slide-down"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<q-card class="sattle-card receive-dialog q-pb-md">
|
||||||
|
<q-card-section class="row items-center q-pb-sm">
|
||||||
|
<q-btn
|
||||||
|
v-if="step === 'invoice'"
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
icon="arrow_back"
|
||||||
|
color="primary"
|
||||||
|
aria-label="Back"
|
||||||
|
@click="step = 'form'"
|
||||||
|
/>
|
||||||
|
<div class="col text-h6 q-ml-sm">Receive Lightning</div>
|
||||||
|
<q-btn v-close-popup flat round dense icon="close" color="primary" />
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- step 1: amount + mint -->
|
||||||
|
<q-card-section v-if="step === 'form'" class="q-pt-sm">
|
||||||
|
<q-input
|
||||||
|
v-model.number="amountSats"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
dark
|
||||||
|
outlined
|
||||||
|
color="primary"
|
||||||
|
label="Amount"
|
||||||
|
suffix="sats"
|
||||||
|
class="q-mb-md"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-select
|
||||||
|
v-model="mintChoice"
|
||||||
|
:options="mintOptions"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
dark
|
||||||
|
outlined
|
||||||
|
color="primary"
|
||||||
|
label="Mint"
|
||||||
|
class="q-mb-md"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
v-if="mintChoice === CUSTOM_MINT"
|
||||||
|
v-model="customMint"
|
||||||
|
dark
|
||||||
|
outlined
|
||||||
|
color="primary"
|
||||||
|
label="Mint address"
|
||||||
|
placeholder="mint@example.com or lnurl1…"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
class="q-mb-md"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="formError" class="text-negative q-mb-md">{{ formError }}</div>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
text-color="dark"
|
||||||
|
label="Create invoice"
|
||||||
|
class="full-width"
|
||||||
|
:loading="preparing"
|
||||||
|
:disable="!formValid"
|
||||||
|
@click="createInvoice"
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- step 2: invoice + waiting -->
|
||||||
|
<q-card-section v-else-if="step === 'invoice' && prepared" class="q-pt-sm">
|
||||||
|
<div class="column items-center q-mb-md">
|
||||||
|
<qr-code :value="prepared.invoice" :size="220" />
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
no-caps
|
||||||
|
icon="content_copy"
|
||||||
|
color="primary"
|
||||||
|
label="Copy invoice"
|
||||||
|
class="q-mt-sm"
|
||||||
|
@click="copyInvoice"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center q-mb-md">
|
||||||
|
<div class="text-body1">
|
||||||
|
You receive <strong>{{ netSats.toLocaleString() }} sats</strong>
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-grey-5">
|
||||||
|
Invoice amount: {{ grossSats.toLocaleString() }} sats
|
||||||
|
<span v-if="feeSats > 0">(includes a {{ feeSats.toLocaleString() }} sat mint fee)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="claimError" class="q-mb-md">
|
||||||
|
<q-banner class="sattle-card text-negative" rounded>
|
||||||
|
<template #avatar>
|
||||||
|
<q-icon name="error" color="negative" />
|
||||||
|
</template>
|
||||||
|
{{ claimError }}
|
||||||
|
</q-banner>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
text-color="dark"
|
||||||
|
label="Try again"
|
||||||
|
class="full-width q-mt-sm"
|
||||||
|
@click="retryClaim"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="waiting" class="column items-center q-gutter-sm q-mb-sm">
|
||||||
|
<q-spinner color="primary" size="32px" />
|
||||||
|
<div class="text-body2 text-grey-5">Waiting for payment…</div>
|
||||||
|
<q-btn flat no-caps dense color="grey-5" label="Stop waiting" @click="stopWaiting" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="column items-center q-gutter-sm">
|
||||||
|
<div class="text-caption text-grey-5 text-center">
|
||||||
|
Not watching right now — if the invoice gets paid, the sats are still
|
||||||
|
claimed into your wallet automatically.
|
||||||
|
</div>
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
no-caps
|
||||||
|
color="primary"
|
||||||
|
label="Keep waiting for payment"
|
||||||
|
@click="resumeWaiting"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- step 3: success -->
|
||||||
|
<q-card-section v-else class="q-pt-sm">
|
||||||
|
<div class="column items-center text-center q-gutter-sm q-mb-md">
|
||||||
|
<q-icon name="check_circle" color="positive" size="56px" />
|
||||||
|
<div class="text-h5 text-weight-bold">
|
||||||
|
Received {{ receivedSats.toLocaleString() }} sats
|
||||||
|
</div>
|
||||||
|
<div class="text-body2 text-grey-5">from {{ receivedServer }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-banner v-if="rotationWarning" class="sattle-card text-warning q-mb-md" rounded>
|
||||||
|
<template #avatar>
|
||||||
|
<q-icon name="warning" color="warning" />
|
||||||
|
</template>
|
||||||
|
The note is in your wallet, but it could not be fully secured yet — the
|
||||||
|
sender may still hold a copy. You can secure it later.
|
||||||
|
</q-banner>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
v-close-popup
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
text-color="dark"
|
||||||
|
label="Done"
|
||||||
|
class="full-width"
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<!-- first-contact mint trust prompt -->
|
||||||
|
<q-dialog v-model="showTrust" persistent>
|
||||||
|
<q-card class="sattle-card trust-card q-pa-lg">
|
||||||
|
<div class="text-h6 q-mb-sm">New mint</div>
|
||||||
|
<div class="text-body2 q-mb-md">
|
||||||
|
This payment came from a mint you have not used before:
|
||||||
|
<strong>{{ trustServer }}</strong>
|
||||||
|
<template v-if="trustNodeAlias"> ({{ trustNodeAlias }})</template>
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-grey-5 q-mb-md">
|
||||||
|
Trusting saves the mint so it is offered next time. You can manage trusted
|
||||||
|
mints in Settings.
|
||||||
|
</div>
|
||||||
|
<div class="row q-gutter-sm justify-end">
|
||||||
|
<q-btn flat no-caps color="grey-5" label="Just this once" @click="skipTrust" />
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
no-caps
|
||||||
|
color="primary"
|
||||||
|
text-color="dark"
|
||||||
|
label="Trust this mint"
|
||||||
|
@click="trustMint"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { Notify, copyToClipboard } from 'quasar';
|
||||||
|
|
||||||
|
import QrCode from '../QrCode.vue';
|
||||||
|
import { prepareMint, claimMintedNote } from '@/lnurlcash/ops';
|
||||||
|
import type { ClaimedNote, PreparedMint } from '@/lnurlcash/ops';
|
||||||
|
import type { NewBearer } from '@/lnurlcash/types';
|
||||||
|
import { msatToSats, satsToMsat, floorMsatToSat, MSAT_PER_SAT } from '@/lnurlcash/units';
|
||||||
|
import { mintAddressCacheInfo } from '@/lnurlcash/trustedMints';
|
||||||
|
import { useWalletStore } from '@/stores/wallet';
|
||||||
|
import { useMintsStore } from '@/stores/mints';
|
||||||
|
import { useActivityStore } from '@/stores/activity';
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: boolean }>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean];
|
||||||
|
received: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
const mints = useMintsStore();
|
||||||
|
const activity = useActivityStore();
|
||||||
|
|
||||||
|
const CUSTOM_MINT = '__custom__';
|
||||||
|
|
||||||
|
// whole-sat display for received amounts (msat remainder rounded down, per
|
||||||
|
// units.ts's floorMsatToSat)
|
||||||
|
const displaySats = (msat: number): number => floorMsatToSat(msat) / MSAT_PER_SAT;
|
||||||
|
|
||||||
|
const errorMessage = (err: unknown): string =>
|
||||||
|
err instanceof Error ? err.message : 'Something went wrong.';
|
||||||
|
|
||||||
|
type Step = 'form' | 'invoice' | 'success';
|
||||||
|
const step = ref<Step>('form');
|
||||||
|
|
||||||
|
// ---- form ----
|
||||||
|
const amountSats = ref<number | null>(null);
|
||||||
|
const mintChoice = ref('');
|
||||||
|
const customMint = ref('');
|
||||||
|
const preparing = ref(false);
|
||||||
|
const formError = ref('');
|
||||||
|
|
||||||
|
type MintOption = { label: string; value: string };
|
||||||
|
|
||||||
|
const mintOptions = computed<MintOption[]>(() => {
|
||||||
|
const options: MintOption[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const mint of mints.mints) {
|
||||||
|
const address = mint.username ? `${mint.username}@${mint.server}` : `@${mint.server}`;
|
||||||
|
if (seen.has(address)) continue;
|
||||||
|
seen.add(address);
|
||||||
|
const label = mint.nodeAlias ? `${address} (${mint.nodeAlias})` : address;
|
||||||
|
options.push({ label, value: address });
|
||||||
|
}
|
||||||
|
for (const publicMint of mints.PUBLIC_MINTS) {
|
||||||
|
if (seen.has(publicMint)) continue;
|
||||||
|
seen.add(publicMint);
|
||||||
|
options.push({ label: publicMint, value: publicMint });
|
||||||
|
}
|
||||||
|
options.push({ label: 'Another mint…', value: CUSTOM_MINT });
|
||||||
|
return options;
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultChoice = (): string => {
|
||||||
|
const options = mintOptions.value;
|
||||||
|
if (mints.defaultMint) {
|
||||||
|
const match = options.find((o) => o.value.endsWith(`@${mints.defaultMint}`));
|
||||||
|
if (match) return match.value;
|
||||||
|
}
|
||||||
|
const first = options[0];
|
||||||
|
return first && first.value !== CUSTOM_MINT ? first.value : CUSTOM_MINT;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formValid = computed(() => {
|
||||||
|
if (!Number.isInteger(amountSats.value) || (amountSats.value ?? 0) < 1) return false;
|
||||||
|
return mintChoice.value === CUSTOM_MINT ? customMint.value.trim() !== '' : mintChoice.value !== '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const createInvoice = async () => {
|
||||||
|
const sats = amountSats.value;
|
||||||
|
if (!sats || preparing.value) return;
|
||||||
|
preparing.value = true;
|
||||||
|
formError.value = '';
|
||||||
|
try {
|
||||||
|
const mintInput = mintChoice.value === CUSTOM_MINT ? customMint.value.trim() : mintChoice.value;
|
||||||
|
const preparedMint = await prepareMint(mintInput, satsToMsat(sats));
|
||||||
|
if (!preparedMint.verifyUrl) {
|
||||||
|
// without a verify URL the payment can never be auto-claimed - showing
|
||||||
|
// a payable invoice here would strand the sats at the mint
|
||||||
|
formError.value =
|
||||||
|
'This mint does not support automatic claiming, so sattle cannot receive from it. Choose a different mint.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prepared.value = preparedMint;
|
||||||
|
claimRun = null;
|
||||||
|
claimError.value = '';
|
||||||
|
step.value = 'invoice';
|
||||||
|
beginClaim();
|
||||||
|
} catch (err) {
|
||||||
|
formError.value = errorMessage(err);
|
||||||
|
Notify.create({ type: 'negative', message: formError.value });
|
||||||
|
} finally {
|
||||||
|
preparing.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- invoice ----
|
||||||
|
const prepared = ref<PreparedMint | null>(null);
|
||||||
|
const waiting = ref(false);
|
||||||
|
const claimError = ref('');
|
||||||
|
// single in-flight claim; "stop waiting" only detaches the UI from it - the
|
||||||
|
// claim itself always runs to completion so a settled payment is never
|
||||||
|
// abandoned unclaimed
|
||||||
|
let claimRun: Promise<void> | null = null;
|
||||||
|
|
||||||
|
const grossSats = computed(() => (prepared.value ? msatToSats(prepared.value.grossMsat) : 0));
|
||||||
|
const netSats = computed(() =>
|
||||||
|
prepared.value ? msatToSats(prepared.value.expectedNoteValueMsat) : 0,
|
||||||
|
);
|
||||||
|
const feeSats = computed(() => grossSats.value - netSats.value);
|
||||||
|
|
||||||
|
const copyInvoice = async () => {
|
||||||
|
if (!prepared.value) return;
|
||||||
|
try {
|
||||||
|
await copyToClipboard(prepared.value.invoice);
|
||||||
|
Notify.create({ type: 'positive', message: 'Invoice copied.' });
|
||||||
|
} catch (err) {
|
||||||
|
Notify.create({ type: 'negative', message: errorMessage(err) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const beginClaim = () => {
|
||||||
|
if (!prepared.value || claimRun) return;
|
||||||
|
waiting.value = true;
|
||||||
|
claimError.value = '';
|
||||||
|
const current = prepared.value;
|
||||||
|
claimRun = (async () => {
|
||||||
|
try {
|
||||||
|
const claimed = await claimMintedNote(current);
|
||||||
|
await onClaimed(claimed, current);
|
||||||
|
} catch (err) {
|
||||||
|
claimError.value = `${errorMessage(err)} The invoice stays valid — you can try again.`;
|
||||||
|
Notify.create({ type: 'negative', message: errorMessage(err) });
|
||||||
|
} finally {
|
||||||
|
waiting.value = false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
|
// after a failed claim the run is over - allow a fresh attempt
|
||||||
|
const retryClaim = () => {
|
||||||
|
claimRun = null;
|
||||||
|
beginClaim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopWaiting = () => {
|
||||||
|
waiting.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resumeWaiting = () => {
|
||||||
|
if (claimRun) waiting.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- success ----
|
||||||
|
const receivedSats = ref(0);
|
||||||
|
const receivedServer = ref('');
|
||||||
|
const rotationWarning = ref('');
|
||||||
|
|
||||||
|
const onClaimed = async (claimed: ClaimedNote, from: PreparedMint) => {
|
||||||
|
const server = from.server;
|
||||||
|
const wasTrusted = mints.isTrusted(server);
|
||||||
|
const notes: NewBearer[] = claimed.possibleCopy
|
||||||
|
? [claimed.note, claimed.possibleCopy]
|
||||||
|
: [claimed.note];
|
||||||
|
await wallet.addBearers(notes);
|
||||||
|
receivedSats.value = displaySats(claimed.note.amount);
|
||||||
|
receivedServer.value = server;
|
||||||
|
rotationWarning.value = claimed.rotationError ?? '';
|
||||||
|
activity.log('mint', `Received ${receivedSats.value.toLocaleString()} sats from ${server} over Lightning.`);
|
||||||
|
const nodeInfo = mintAddressCacheInfo(from.nodeInfo, from.username);
|
||||||
|
if (nodeInfo) mints.cacheNodeInfo(server, nodeInfo);
|
||||||
|
Notify.create({
|
||||||
|
type: 'positive',
|
||||||
|
message: `Received ${receivedSats.value.toLocaleString()} sats.`,
|
||||||
|
});
|
||||||
|
emit('received');
|
||||||
|
if (props.modelValue) step.value = 'success';
|
||||||
|
if (!wasTrusted && claimed.note.mintPubkey) {
|
||||||
|
trustServer.value = server;
|
||||||
|
trustPubkey.value = claimed.note.mintPubkey;
|
||||||
|
trustNodeAlias.value = from.nodeInfo?.nodeAlias ?? '';
|
||||||
|
showTrust.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- trust prompt ----
|
||||||
|
const showTrust = ref(false);
|
||||||
|
const trustServer = ref('');
|
||||||
|
const trustPubkey = ref('');
|
||||||
|
const trustNodeAlias = ref('');
|
||||||
|
|
||||||
|
const trustMint = () => {
|
||||||
|
try {
|
||||||
|
mints.trust(trustServer.value, trustPubkey.value, {
|
||||||
|
...(trustNodeAlias.value ? { nodeAlias: trustNodeAlias.value } : {}),
|
||||||
|
});
|
||||||
|
Notify.create({ type: 'positive', message: 'Mint trusted.' });
|
||||||
|
} catch (err) {
|
||||||
|
Notify.create({ type: 'negative', message: errorMessage(err) });
|
||||||
|
} finally {
|
||||||
|
showTrust.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const skipTrust = () => {
|
||||||
|
showTrust.value = false;
|
||||||
|
Notify.create({
|
||||||
|
type: 'warning',
|
||||||
|
message: 'Note added, but this mint is not in your trusted list yet — you can review it in Settings.',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// fresh form every time the dialog opens; a claim already in flight keeps
|
||||||
|
// running in the background regardless
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(open) => {
|
||||||
|
if (!open) return;
|
||||||
|
step.value = 'form';
|
||||||
|
amountSats.value = null;
|
||||||
|
customMint.value = '';
|
||||||
|
mintChoice.value = defaultChoice();
|
||||||
|
preparing.value = false;
|
||||||
|
formError.value = '';
|
||||||
|
prepared.value = null;
|
||||||
|
waiting.value = false;
|
||||||
|
rotationWarning.value = '';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.receive-dialog {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trust-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
<template>
|
||||||
|
<q-dialog
|
||||||
|
:model-value="modelValue"
|
||||||
|
position="bottom"
|
||||||
|
transition-show="slide-up"
|
||||||
|
transition-hide="slide-down"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<q-card class="sattle-card receive-dialog q-pb-md">
|
||||||
|
<q-card-section class="row items-center q-pb-sm">
|
||||||
|
<div class="col text-h6">Receive bearer note</div>
|
||||||
|
<q-btn v-close-popup flat round dense icon="close" color="primary" />
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- input -->
|
||||||
|
<q-card-section v-if="step === 'input'" class="q-pt-sm">
|
||||||
|
<qr-scanner
|
||||||
|
v-if="scanning"
|
||||||
|
class="q-mb-md"
|
||||||
|
@decode="onScan"
|
||||||
|
@error="onScanError"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-input
|
||||||
|
v-model="input"
|
||||||
|
type="textarea"
|
||||||
|
rows="3"
|
||||||
|
dark
|
||||||
|
outlined
|
||||||
|
color="primary"
|
||||||
|
label="Bearer note"
|
||||||
|
placeholder="lnurlw://… or lnurl1…"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
:error="input.trim() !== '' && !inputValid"
|
||||||
|
error-message="Not a valid bearer note."
|
||||||
|
class="q-mb-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<q-banner v-if="errorKind !== ''" class="sattle-card text-negative q-mb-sm" rounded>
|
||||||
|
<template #avatar>
|
||||||
|
<q-icon :name="errorIcon" color="negative" />
|
||||||
|
</template>
|
||||||
|
{{ errorText }}
|
||||||
|
</q-banner>
|
||||||
|
|
||||||
|
<div class="row q-gutter-sm q-mt-sm">
|
||||||
|
<q-btn
|
||||||
|
outline
|
||||||
|
no-caps
|
||||||
|
color="primary"
|
||||||
|
:icon="scanning ? 'keyboard' : 'qr_code_scanner'"
|
||||||
|
:label="scanning ? 'Paste instead' : 'Scan'"
|
||||||
|
@click="scanning = !scanning"
|
||||||
|
/>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
no-caps
|
||||||
|
color="primary"
|
||||||
|
text-color="dark"
|
||||||
|
label="Receive"
|
||||||
|
class="col"
|
||||||
|
:loading="busy"
|
||||||
|
:disable="input.trim() === '' || !inputValid"
|
||||||
|
@click="receive"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- success -->
|
||||||
|
<q-card-section v-else class="q-pt-sm">
|
||||||
|
<div class="column items-center text-center q-gutter-sm q-mb-md">
|
||||||
|
<q-icon name="check_circle" color="positive" size="56px" />
|
||||||
|
<div class="text-h5 text-weight-bold">
|
||||||
|
Received {{ receivedSats.toLocaleString() }} sats
|
||||||
|
</div>
|
||||||
|
<div class="text-body2 text-grey-5">from {{ receivedServer }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-banner v-if="unverifiedNote" class="sattle-card text-info q-mb-md" rounded>
|
||||||
|
<template #avatar>
|
||||||
|
<q-icon name="info" color="info" />
|
||||||
|
</template>
|
||||||
|
The mint could not be reached, so the note is stored unconfirmed at the
|
||||||
|
sender's declared amount. Refresh it later to confirm.
|
||||||
|
</q-banner>
|
||||||
|
|
||||||
|
<q-banner v-if="rotationWarning" class="sattle-card text-warning q-mb-md" rounded>
|
||||||
|
<template #avatar>
|
||||||
|
<q-icon name="warning" color="warning" />
|
||||||
|
</template>
|
||||||
|
The note is in your wallet, but it could not be fully secured yet — the
|
||||||
|
sender may still hold a copy. You can secure it later.
|
||||||
|
</q-banner>
|
||||||
|
|
||||||
|
<q-btn
|
||||||
|
v-close-popup
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
text-color="dark"
|
||||||
|
label="Done"
|
||||||
|
class="full-width"
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
|
||||||
|
<!-- first-contact mint trust prompt -->
|
||||||
|
<q-dialog v-model="showTrust" persistent>
|
||||||
|
<q-card class="sattle-card trust-card q-pa-lg">
|
||||||
|
<div class="text-h6 q-mb-sm">New mint</div>
|
||||||
|
<div class="text-body2 q-mb-md">
|
||||||
|
This note came from a mint you have not used before:
|
||||||
|
<strong>{{ trustServer }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-grey-5 q-mb-md">
|
||||||
|
Trusting saves the mint so it is offered next time. You can manage trusted
|
||||||
|
mints in Settings.
|
||||||
|
</div>
|
||||||
|
<div class="row q-gutter-sm justify-end">
|
||||||
|
<q-btn flat no-caps color="grey-5" label="Just this once" @click="skipTrust" />
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
no-caps
|
||||||
|
color="primary"
|
||||||
|
text-color="dark"
|
||||||
|
label="Trust this mint"
|
||||||
|
@click="trustMint"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { Notify } from 'quasar';
|
||||||
|
import {
|
||||||
|
NoteSpentError,
|
||||||
|
NoteUnknownError,
|
||||||
|
PendingNoteError,
|
||||||
|
isValidNoteInput,
|
||||||
|
} from 'lnurlcash-kit';
|
||||||
|
|
||||||
|
import QrScanner from '../QrScanner.vue';
|
||||||
|
import { receiveBearer } from '@/lnurlcash/ops';
|
||||||
|
import type { NewBearer } from '@/lnurlcash/types';
|
||||||
|
import { floorMsatToSat, MSAT_PER_SAT } from '@/lnurlcash/units';
|
||||||
|
import { useWalletStore } from '@/stores/wallet';
|
||||||
|
import { useMintsStore } from '@/stores/mints';
|
||||||
|
import { useActivityStore } from '@/stores/activity';
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: boolean; initialInput?: string }>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean];
|
||||||
|
received: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
const mints = useMintsStore();
|
||||||
|
const activity = useActivityStore();
|
||||||
|
|
||||||
|
// whole-sat display for received amounts (msat remainder rounded down, per
|
||||||
|
// units.ts's floorMsatToSat)
|
||||||
|
const displaySats = (msat: number): number => floorMsatToSat(msat) / MSAT_PER_SAT;
|
||||||
|
|
||||||
|
const errMsg = (err: unknown): string =>
|
||||||
|
err instanceof Error ? err.message : 'Something went wrong.';
|
||||||
|
|
||||||
|
// distinct, jargon-free error states for the definitive service answers
|
||||||
|
type ReceiveErrorKind =
|
||||||
|
| 'spent'
|
||||||
|
| 'unknown'
|
||||||
|
| 'pending'
|
||||||
|
| 'duplicate'
|
||||||
|
| 'invalid'
|
||||||
|
| 'generic'
|
||||||
|
| '';
|
||||||
|
|
||||||
|
const ERROR_TEXT: Record<Exclude<ReceiveErrorKind, ''>, string> = {
|
||||||
|
spent: 'This note has already been spent.',
|
||||||
|
unknown: "The mint doesn't know this note.",
|
||||||
|
pending: 'This note is locked mid-payment — try again shortly.',
|
||||||
|
duplicate: 'This note is already in your wallet.',
|
||||||
|
invalid: 'Not a valid bearer note.',
|
||||||
|
generic: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ERROR_ICON: Record<Exclude<ReceiveErrorKind, ''>, string> = {
|
||||||
|
spent: 'money_off',
|
||||||
|
unknown: 'help_outline',
|
||||||
|
pending: 'hourglass_top',
|
||||||
|
duplicate: 'content_copy',
|
||||||
|
invalid: 'error_outline',
|
||||||
|
generic: 'error_outline',
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorText = computed(() =>
|
||||||
|
errorKind.value === 'generic'
|
||||||
|
? errorMessageText.value
|
||||||
|
: errorKind.value === ''
|
||||||
|
? ''
|
||||||
|
: ERROR_TEXT[errorKind.value],
|
||||||
|
);
|
||||||
|
const errorIcon = computed(() =>
|
||||||
|
errorKind.value === '' ? 'error_outline' : ERROR_ICON[errorKind.value],
|
||||||
|
);
|
||||||
|
|
||||||
|
type Step = 'input' | 'success';
|
||||||
|
const step = ref<Step>('input');
|
||||||
|
|
||||||
|
const input = ref('');
|
||||||
|
const scanning = ref(false);
|
||||||
|
const busy = ref(false);
|
||||||
|
const errorKind = ref<ReceiveErrorKind>('');
|
||||||
|
const errorMessageText = ref('');
|
||||||
|
|
||||||
|
const inputValid = computed(() => isValidNoteInput(input.value.trim()));
|
||||||
|
|
||||||
|
const clearError = () => {
|
||||||
|
errorKind.value = '';
|
||||||
|
errorMessageText.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const classifyError = (err: unknown): void => {
|
||||||
|
let kind: Exclude<ReceiveErrorKind, ''>;
|
||||||
|
if (err instanceof NoteSpentError) {
|
||||||
|
kind = 'spent';
|
||||||
|
} else if (err instanceof NoteUnknownError) {
|
||||||
|
kind = 'unknown';
|
||||||
|
} else if (err instanceof PendingNoteError) {
|
||||||
|
kind = 'pending';
|
||||||
|
} else if (err instanceof Error && err.message.includes('already in your wallet')) {
|
||||||
|
kind = 'duplicate';
|
||||||
|
} else if (err instanceof Error && err.message.includes('Not an LNURLcash bearer note')) {
|
||||||
|
kind = 'invalid';
|
||||||
|
} else {
|
||||||
|
kind = 'generic';
|
||||||
|
errorMessageText.value = errMsg(err);
|
||||||
|
}
|
||||||
|
errorKind.value = kind;
|
||||||
|
Notify.create({
|
||||||
|
type: 'negative',
|
||||||
|
message: kind === 'generic' ? errorMessageText.value : ERROR_TEXT[kind],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// re-entrancy guard: a scanner double-fire or Enter+click landing together
|
||||||
|
// must not run two receives for the same note - both would pass the
|
||||||
|
// duplicate check before either addBearers landed
|
||||||
|
const receive = async () => {
|
||||||
|
const value = input.value.trim();
|
||||||
|
if (busy.value || value === '') return;
|
||||||
|
busy.value = true;
|
||||||
|
clearError();
|
||||||
|
try {
|
||||||
|
const claimed = await receiveBearer(value, wallet.bearers);
|
||||||
|
const note = claimed.note;
|
||||||
|
const server = new URL(note.url).host;
|
||||||
|
const wasTrusted = mints.isTrusted(server);
|
||||||
|
const notes: NewBearer[] = claimed.possibleCopy
|
||||||
|
? [note, claimed.possibleCopy]
|
||||||
|
: [note];
|
||||||
|
await wallet.addBearers(notes);
|
||||||
|
receivedSats.value = displaySats(note.amount);
|
||||||
|
receivedServer.value = server;
|
||||||
|
unverifiedNote.value = !note.verified;
|
||||||
|
rotationWarning.value = claimed.rotationError ?? '';
|
||||||
|
activity.log(
|
||||||
|
'receive',
|
||||||
|
`Received ${receivedSats.value.toLocaleString()} sats from ${server}.`,
|
||||||
|
);
|
||||||
|
Notify.create({
|
||||||
|
type: 'positive',
|
||||||
|
message: `Received ${receivedSats.value.toLocaleString()} sats.`,
|
||||||
|
});
|
||||||
|
emit('received');
|
||||||
|
scanning.value = false;
|
||||||
|
step.value = 'success';
|
||||||
|
if (!wasTrusted && note.mintPubkey) {
|
||||||
|
trustServer.value = server;
|
||||||
|
trustPubkey.value = note.mintPubkey;
|
||||||
|
showTrust.value = true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
classifyError(err);
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onScan = (text: string) => {
|
||||||
|
input.value = text;
|
||||||
|
scanning.value = false;
|
||||||
|
void receive();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onScanError = (message: string) => {
|
||||||
|
scanning.value = false;
|
||||||
|
Notify.create({ type: 'negative', message });
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- success ----
|
||||||
|
const receivedSats = ref(0);
|
||||||
|
const receivedServer = ref('');
|
||||||
|
const unverifiedNote = ref(false);
|
||||||
|
const rotationWarning = ref('');
|
||||||
|
|
||||||
|
// ---- trust prompt ----
|
||||||
|
const showTrust = ref(false);
|
||||||
|
const trustServer = ref('');
|
||||||
|
const trustPubkey = ref('');
|
||||||
|
|
||||||
|
const trustMint = () => {
|
||||||
|
try {
|
||||||
|
mints.trust(trustServer.value, trustPubkey.value);
|
||||||
|
Notify.create({ type: 'positive', message: 'Mint trusted.' });
|
||||||
|
} catch (err) {
|
||||||
|
Notify.create({ type: 'negative', message: errMsg(err) });
|
||||||
|
} finally {
|
||||||
|
showTrust.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const skipTrust = () => {
|
||||||
|
showTrust.value = false;
|
||||||
|
Notify.create({
|
||||||
|
type: 'warning',
|
||||||
|
message: 'Note added, but this mint is not in your trusted list yet — you can review it in Settings.',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(open) => {
|
||||||
|
if (!open) return;
|
||||||
|
step.value = 'input';
|
||||||
|
input.value = props.initialInput ?? '';
|
||||||
|
scanning.value = false;
|
||||||
|
busy.value = false;
|
||||||
|
clearError();
|
||||||
|
unverifiedNote.value = false;
|
||||||
|
rotationWarning.value = '';
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.receive-dialog {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trust-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Pay over Lightning: paste or scan a bolt11 invoice or a Lightning
|
||||||
|
// Address, confirm the amount, and the ops engine carves an exact-amount
|
||||||
|
// note out of the wallet and pays with it. The four possible outcomes are
|
||||||
|
// surfaced as distinct result screens - including the silent-failure case
|
||||||
|
// where the funds come back.
|
||||||
|
//
|
||||||
|
// Fund-safety order when applying the carve: fresh notes are added to the
|
||||||
|
// wallet BEFORE consumed inputs are marked spent.
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import { decodeBolt11AmountMsat, isBolt11Invoice, resolveLnurlInput } from 'lnurlcash-kit';
|
||||||
|
|
||||||
|
import QrScanner from '@/components/QrScanner.vue';
|
||||||
|
import { payWithBearers, UncertainOutcomeError } from '@/lnurlcash/ops';
|
||||||
|
import type { CarveResult, PayOutcome } from '@/lnurlcash/ops';
|
||||||
|
import type { NewBearer } from '@/lnurlcash/types';
|
||||||
|
import { msatToSats, satsToMsat } from '@/lnurlcash/units';
|
||||||
|
import { useWalletStore } from '@/stores/wallet';
|
||||||
|
import { useActivityStore } from '@/stores/activity';
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: boolean; initialInput?: string }>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean];
|
||||||
|
sent: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const $q = useQuasar();
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
const activity = useActivityStore();
|
||||||
|
|
||||||
|
const toast = (type: 'positive' | 'negative' | 'warning' | 'info', message: string): void => {
|
||||||
|
// guarded: the Notify plugin registration lives in quasar.config, outside
|
||||||
|
// this component's control - a missing registration must not break a flow
|
||||||
|
if (typeof $q.notify === 'function') {
|
||||||
|
$q.notify({ type, message, position: 'top', timeout: 3000 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const show = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value: boolean) => emit('update:modelValue', value),
|
||||||
|
});
|
||||||
|
|
||||||
|
type Step = 'input' | 'confirm' | 'working' | 'result';
|
||||||
|
type TargetKind = 'invoice' | 'address';
|
||||||
|
|
||||||
|
type PendingPayment = {
|
||||||
|
kind: TargetKind;
|
||||||
|
input: string;
|
||||||
|
amountMsat: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Result = {
|
||||||
|
outcome: PayOutcome;
|
||||||
|
amountMsat: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const step = ref<Step>('input');
|
||||||
|
const input = ref('');
|
||||||
|
const addressAmountSats = ref('');
|
||||||
|
const showScanner = ref(false);
|
||||||
|
const inlineError = ref<string | null>(null);
|
||||||
|
const stage = ref('');
|
||||||
|
const pendingPayment = ref<PendingPayment | null>(null);
|
||||||
|
const result = ref<Result | null>(null);
|
||||||
|
|
||||||
|
const formatSats = (sats: number): string =>
|
||||||
|
sats.toLocaleString(undefined, { maximumFractionDigits: 3 });
|
||||||
|
|
||||||
|
const classify = (value: string): TargetKind | null => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
if (isBolt11Invoice(trimmed)) return 'invoice';
|
||||||
|
// resolveLnurlInput also accepts Lightning Addresses, bech32 LNURLs and
|
||||||
|
// lightning= deep links - anything it resolves can be paid
|
||||||
|
if (resolveLnurlInput(trimmed) !== null) return 'address';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetKind = computed<TargetKind | null>(() => classify(input.value));
|
||||||
|
|
||||||
|
const truncatedInput = computed(() => {
|
||||||
|
const p = pendingPayment.value;
|
||||||
|
if (!p) return '';
|
||||||
|
if (p.kind === 'address') return p.input;
|
||||||
|
return p.input.length > 30 ? `${p.input.slice(0, 18)}…${p.input.slice(-8)}` : p.input;
|
||||||
|
});
|
||||||
|
|
||||||
|
const resultAmountSats = computed(() =>
|
||||||
|
result.value ? formatSats(msatToSats(result.value.amountMsat)) : '',
|
||||||
|
);
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
step.value = 'input';
|
||||||
|
input.value = props.initialInput ?? '';
|
||||||
|
addressAmountSats.value = '';
|
||||||
|
showScanner.value = false;
|
||||||
|
inlineError.value = null;
|
||||||
|
stage.value = '';
|
||||||
|
pendingPayment.value = null;
|
||||||
|
result.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(open) => {
|
||||||
|
if (open) reset();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const onScan = (text: string) => {
|
||||||
|
input.value = text.replace(/^lightning:/i, '').trim();
|
||||||
|
showScanner.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onScanError = (message: string) => {
|
||||||
|
showScanner.value = false;
|
||||||
|
toast('negative', message);
|
||||||
|
};
|
||||||
|
|
||||||
|
const paste = async () => {
|
||||||
|
try {
|
||||||
|
const text = await navigator.clipboard.readText();
|
||||||
|
if (text) input.value = text.trim();
|
||||||
|
} catch {
|
||||||
|
toast('negative', "Couldn't read the clipboard - paste manually.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// input -> confirm: classify and validate everything that can be checked
|
||||||
|
// before any network call (amount present, within balance)
|
||||||
|
const proceed = () => {
|
||||||
|
inlineError.value = null;
|
||||||
|
const value = input.value.trim();
|
||||||
|
if (!value) {
|
||||||
|
inlineError.value = 'Paste an invoice or a Lightning Address first.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const kind = classify(value);
|
||||||
|
if (kind === null) {
|
||||||
|
inlineError.value = "That doesn't look like a Lightning invoice or address.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let amountMsat: number;
|
||||||
|
if (kind === 'invoice') {
|
||||||
|
const decoded = decodeBolt11AmountMsat(value);
|
||||||
|
if (decoded === null || decoded <= 0) {
|
||||||
|
inlineError.value = "This invoice doesn't have an amount, which this wallet can't pay yet.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
amountMsat = decoded;
|
||||||
|
} else {
|
||||||
|
const sats = Number(addressAmountSats.value);
|
||||||
|
if (!Number.isInteger(sats) || sats <= 0) {
|
||||||
|
inlineError.value = 'Enter how many sats to send to this address.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
amountMsat = satsToMsat(sats);
|
||||||
|
}
|
||||||
|
if (amountMsat > wallet.balanceMsat) {
|
||||||
|
inlineError.value = `That's more than your spendable balance (${formatSats(wallet.balanceSats)} sats).`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingPayment.value = { kind, input: value, amountMsat };
|
||||||
|
step.value = 'confirm';
|
||||||
|
};
|
||||||
|
|
||||||
|
const friendlyError = (err: unknown): string => {
|
||||||
|
const message = err instanceof Error ? err.message : 'Something went wrong.';
|
||||||
|
if (message.startsWith('No mint holds enough')) {
|
||||||
|
return 'Not enough spendable balance to cover that payment.';
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Applies a carve to the wallet in the only safe order: add the fresh
|
||||||
|
// note(s) first, mark the consumed inputs spent after. Returns the wallet
|
||||||
|
// id of the carved note (for the exact-match path the note already exists
|
||||||
|
// in the wallet, so it is found by url instead of re-added).
|
||||||
|
const applyCarve = async (carve: CarveResult): Promise<string> => {
|
||||||
|
const existing = wallet.bearers.find((b) => b.url === carve.note.url);
|
||||||
|
const toAdd: NewBearer[] = [];
|
||||||
|
if (!existing) toAdd.push(carve.note);
|
||||||
|
if (carve.change) toAdd.push(carve.change);
|
||||||
|
const added = toAdd.length > 0 ? await wallet.addBearers(toAdd) : [];
|
||||||
|
for (const consumed of carve.consumed) {
|
||||||
|
await wallet.markSpent(consumed.id);
|
||||||
|
}
|
||||||
|
return existing ? existing.id : added[0].id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pay = async () => {
|
||||||
|
const p = pendingPayment.value;
|
||||||
|
if (!p) return;
|
||||||
|
step.value = 'working';
|
||||||
|
stage.value = 'Preparing the exact amount and sending the payment…';
|
||||||
|
try {
|
||||||
|
const payResult = await payWithBearers(
|
||||||
|
wallet.bearers,
|
||||||
|
p.input,
|
||||||
|
p.kind === 'address' ? { amountMsat: p.amountMsat } : {},
|
||||||
|
);
|
||||||
|
stage.value = 'Confirming the result…';
|
||||||
|
const noteId = await applyCarve(payResult.carve);
|
||||||
|
if (payResult.rescuedNote) {
|
||||||
|
await wallet.addBearers([payResult.rescuedNote]);
|
||||||
|
}
|
||||||
|
const sats = formatSats(msatToSats(payResult.amountMsat));
|
||||||
|
if (payResult.outcome === 'settled') {
|
||||||
|
await wallet.markSpent(noteId);
|
||||||
|
activity.log('melt', `Paid ${sats} sats over Lightning.`);
|
||||||
|
toast('positive', `Paid ${sats} sats.`);
|
||||||
|
emit('sent');
|
||||||
|
} else if (payResult.outcome === 'failed-funds-returned') {
|
||||||
|
// the payment never happened and the note is spendable again - it
|
||||||
|
// stays in the wallet, deliberately NOT marked spent
|
||||||
|
activity.log('transfer', `A ${sats} sat payment failed - funds are back in your wallet.`);
|
||||||
|
toast('warning', 'Payment failed - funds are back in your wallet.');
|
||||||
|
} else if (payResult.outcome === 'unknown-still-pending') {
|
||||||
|
await wallet.markSpent(noteId);
|
||||||
|
activity.log('melt', `Payment of ${sats} sats is still in flight - the note is locked.`);
|
||||||
|
emit('sent');
|
||||||
|
} else {
|
||||||
|
// note-already-spent: the mint says the note is gone; nothing was
|
||||||
|
// paid. Lock it locally so it can't be tried again.
|
||||||
|
await wallet.markSpent(noteId);
|
||||||
|
activity.log('spent', `A ${sats} sat note was already spent at the mint.`);
|
||||||
|
}
|
||||||
|
result.value = { outcome: payResult.outcome, amountMsat: payResult.amountMsat };
|
||||||
|
step.value = 'result';
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UncertainOutcomeError) {
|
||||||
|
await wallet.addBearers(err.possibleOutputs);
|
||||||
|
activity.log(
|
||||||
|
'transfer',
|
||||||
|
'A payment preparation could not be confirmed - possible notes stored unverified.',
|
||||||
|
);
|
||||||
|
inlineError.value =
|
||||||
|
"Couldn't confirm with the mint. Your original notes are untouched, and the possible new notes are stored unverified - refresh your wallet later to reconcile.";
|
||||||
|
toast('warning', 'Payment preparation uncertain - see the notice in the dialog.');
|
||||||
|
} else {
|
||||||
|
inlineError.value = friendlyError(err);
|
||||||
|
toast('negative', inlineError.value);
|
||||||
|
}
|
||||||
|
step.value = 'input';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeResult = () => {
|
||||||
|
show.value = false;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-dialog
|
||||||
|
v-model="show"
|
||||||
|
position="bottom"
|
||||||
|
transition-show="slide-up"
|
||||||
|
transition-hide="slide-down"
|
||||||
|
:persistent="step === 'working'"
|
||||||
|
>
|
||||||
|
<q-card class="sattle-card drawer-card full-width">
|
||||||
|
<q-card-section class="row items-center q-pb-sm">
|
||||||
|
<q-btn
|
||||||
|
v-close-popup
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
icon="close"
|
||||||
|
color="primary"
|
||||||
|
aria-label="Close"
|
||||||
|
:disable="step === 'working'"
|
||||||
|
/>
|
||||||
|
<div class="col text-center">
|
||||||
|
<span class="text-h6 text-primary">Pay with Lightning</span>
|
||||||
|
</div>
|
||||||
|
<div style="width: 40px" />
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- step 1: universal input -->
|
||||||
|
<q-card-section v-if="step === 'input'" class="q-pa-md q-pt-sm">
|
||||||
|
<q-input
|
||||||
|
v-model="input"
|
||||||
|
type="textarea"
|
||||||
|
autogrow
|
||||||
|
outlined
|
||||||
|
color="primary"
|
||||||
|
label="Invoice or Lightning Address"
|
||||||
|
placeholder="lnbc1… or you@host.com"
|
||||||
|
/>
|
||||||
|
<div class="row q-gutter-sm q-mt-sm">
|
||||||
|
<q-btn flat dense color="primary" icon="content_paste" label="Paste" @click="paste" />
|
||||||
|
<q-btn
|
||||||
|
flat
|
||||||
|
dense
|
||||||
|
color="primary"
|
||||||
|
icon="qr_code_scanner"
|
||||||
|
:label="showScanner ? 'Close scanner' : 'Scan'"
|
||||||
|
@click="showScanner = !showScanner"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<QrScanner v-if="showScanner" class="q-mt-md" @decode="onScan" @error="onScanError" />
|
||||||
|
<q-input
|
||||||
|
v-if="targetKind === 'address'"
|
||||||
|
v-model="addressAmountSats"
|
||||||
|
class="q-mt-md"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
outlined
|
||||||
|
color="primary"
|
||||||
|
label="Amount (sats)"
|
||||||
|
/>
|
||||||
|
<q-banner v-if="inlineError" dense class="bg-negative text-white q-mt-md rounded-borders">
|
||||||
|
{{ inlineError }}
|
||||||
|
</q-banner>
|
||||||
|
<div class="row justify-end q-gutter-sm q-mt-lg">
|
||||||
|
<q-btn v-close-popup flat label="Cancel" color="grey-5" />
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
text-color="dark"
|
||||||
|
label="Continue"
|
||||||
|
:disable="!input.trim()"
|
||||||
|
@click="proceed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- step 2: confirm -->
|
||||||
|
<q-card-section v-else-if="step === 'confirm'" class="q-pa-md q-pt-sm">
|
||||||
|
<q-list dense>
|
||||||
|
<q-item>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label caption class="text-grey-5">Amount</q-item-label>
|
||||||
|
<q-item-label class="text-h6 text-primary">
|
||||||
|
{{ pendingPayment ? formatSats(msatToSats(pendingPayment.amountMsat)) : '' }} sats
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
<q-item>
|
||||||
|
<q-item-section>
|
||||||
|
<q-item-label caption class="text-grey-5">To</q-item-label>
|
||||||
|
<q-item-label class="text-grey-3" style="word-break: break-all">
|
||||||
|
{{ truncatedInput }}
|
||||||
|
</q-item-label>
|
||||||
|
</q-item-section>
|
||||||
|
</q-item>
|
||||||
|
</q-list>
|
||||||
|
<div class="text-caption text-grey-5 q-mt-md">
|
||||||
|
If the mint charges a fee, it comes out of your change - you pay exactly the amount
|
||||||
|
shown.
|
||||||
|
</div>
|
||||||
|
<div class="row justify-end q-gutter-sm q-mt-lg">
|
||||||
|
<q-btn flat label="Back" color="grey-5" @click="step = 'input'" />
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
text-color="dark"
|
||||||
|
label="Pay now"
|
||||||
|
@click="pay"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- step 3: in flight -->
|
||||||
|
<q-card-section v-else-if="step === 'working'" class="q-pa-md q-pt-sm column items-center">
|
||||||
|
<q-spinner-dots size="48px" color="primary" class="q-my-md" />
|
||||||
|
<div class="text-body1 text-primary">{{ stage }}</div>
|
||||||
|
<div class="text-caption text-grey-5 q-mt-sm text-center">
|
||||||
|
Confirming a payment can take up to a couple of minutes - please keep this open.
|
||||||
|
</div>
|
||||||
|
<q-linear-progress indeterminate color="primary" class="q-mt-lg full-width" />
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- step 4: outcome -->
|
||||||
|
<q-card-section v-else class="q-pa-md q-pt-sm column items-center">
|
||||||
|
<template v-if="result?.outcome === 'settled'">
|
||||||
|
<q-icon name="check_circle" color="positive" size="64px" class="q-my-md" />
|
||||||
|
<div class="text-h6 text-primary">Paid {{ resultAmountSats }} sats</div>
|
||||||
|
<div class="text-caption text-grey-5 q-mt-sm">The payment went through.</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="result?.outcome === 'failed-funds-returned'">
|
||||||
|
<q-icon name="warning" color="warning" size="64px" class="q-my-md" />
|
||||||
|
<div class="text-h6 text-warning">Payment failed</div>
|
||||||
|
<div class="text-caption text-grey-5 q-mt-sm text-center">
|
||||||
|
Nothing was paid - the funds are back in your wallet.
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="result?.outcome === 'unknown-still-pending'">
|
||||||
|
<q-icon name="schedule" color="info" size="64px" class="q-my-md" />
|
||||||
|
<div class="text-h6 text-info">Payment still in flight</div>
|
||||||
|
<div class="text-caption text-grey-5 q-mt-sm text-center">
|
||||||
|
The note is locked - check later to see whether the payment completed.
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<q-icon name="error_outline" color="negative" size="64px" class="q-my-md" />
|
||||||
|
<div class="text-h6 text-negative">Note already spent</div>
|
||||||
|
<div class="text-caption text-grey-5 q-mt-sm text-center">
|
||||||
|
The note for this payment was already spent - nothing was paid.
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
text-color="dark"
|
||||||
|
label="Done"
|
||||||
|
class="q-mt-lg full-width"
|
||||||
|
@click="closeResult"
|
||||||
|
/>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.drawer-card {
|
||||||
|
border-top-left-radius: 20px;
|
||||||
|
border-top-right-radius: 20px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Send chooser: a bottom sheet offering the two ways to send - hand over a
|
||||||
|
// bearer note, or pay over Lightning. The actual flows live in the child
|
||||||
|
// dialogs; this component just routes to them and re-emits `sent`.
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import SendTokenDialog from './SendTokenDialog.vue';
|
||||||
|
import PayInvoiceDialog from './PayInvoiceDialog.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: boolean }>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean];
|
||||||
|
sent: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const show = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value: boolean) => emit('update:modelValue', value),
|
||||||
|
});
|
||||||
|
|
||||||
|
const showToken = ref(false);
|
||||||
|
const showInvoice = ref(false);
|
||||||
|
|
||||||
|
const openToken = () => {
|
||||||
|
show.value = false;
|
||||||
|
showToken.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openInvoice = () => {
|
||||||
|
show.value = false;
|
||||||
|
showInvoice.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSent = () => emit('sent');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-dialog v-model="show" position="bottom" transition-show="slide-up" transition-hide="slide-down">
|
||||||
|
<q-card class="sattle-card drawer-card full-width">
|
||||||
|
<q-card-section class="row items-center q-pb-sm">
|
||||||
|
<q-btn v-close-popup flat round dense icon="close" color="primary" aria-label="Close" />
|
||||||
|
<div class="col text-center">
|
||||||
|
<span class="text-h6 text-primary">Send</span>
|
||||||
|
</div>
|
||||||
|
<!-- spacer keeps the title centered against the close button -->
|
||||||
|
<div style="width: 40px" />
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<q-card-section class="q-pa-md q-pt-sm">
|
||||||
|
<div class="q-gutter-y-md">
|
||||||
|
<div
|
||||||
|
class="action-row"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click="openToken"
|
||||||
|
@keydown.enter.prevent="openToken"
|
||||||
|
@keydown.space.prevent="openToken"
|
||||||
|
>
|
||||||
|
<div class="row items-center no-wrap">
|
||||||
|
<div class="icon-circle">
|
||||||
|
<q-icon name="sticky_note_2" size="24px" />
|
||||||
|
</div>
|
||||||
|
<div class="col q-ml-md">
|
||||||
|
<div class="text-body1 text-weight-medium text-primary">Bearer note</div>
|
||||||
|
<div class="text-caption text-grey-5">Hand someone a note</div>
|
||||||
|
</div>
|
||||||
|
<q-icon name="chevron_right" color="grey-6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="action-row"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click="openInvoice"
|
||||||
|
@keydown.enter.prevent="openInvoice"
|
||||||
|
@keydown.space.prevent="openInvoice"
|
||||||
|
>
|
||||||
|
<div class="row items-center no-wrap">
|
||||||
|
<div class="icon-circle">
|
||||||
|
<q-icon name="flash_on" size="24px" />
|
||||||
|
</div>
|
||||||
|
<div class="col q-ml-md">
|
||||||
|
<div class="text-body1 text-weight-medium text-primary">Lightning</div>
|
||||||
|
<div class="text-caption text-grey-5">Pay an invoice or address</div>
|
||||||
|
</div>
|
||||||
|
<q-icon name="chevron_right" color="grey-6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
|
||||||
|
<SendTokenDialog v-model="showToken" @sent="onSent" />
|
||||||
|
<PayInvoiceDialog v-model="showInvoice" @sent="onSent" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.drawer-card {
|
||||||
|
border-top-left-radius: 20px;
|
||||||
|
border-top-right-radius: 20px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-row {
|
||||||
|
background: rgba(85, 255, 204, 0.06);
|
||||||
|
border: 1px solid rgba(85, 255, 204, 0.15);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: rgba(85, 255, 204, 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-circle {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(85, 255, 204, 0.12);
|
||||||
|
color: #55ffcc;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Send a bearer note: pick an amount, carve a fresh note worth exactly that
|
||||||
|
// much out of the wallet (the ops engine does the mint calls), then hand it
|
||||||
|
// over as a QR / link. The QR hides behind a tap-to-reveal cover - anyone
|
||||||
|
// who sees it can take the sats.
|
||||||
|
//
|
||||||
|
// Fund-safety order when applying the carve: the replacement notes are
|
||||||
|
// added to the wallet BEFORE the consumed ones are marked spent, so a crash
|
||||||
|
// mid-way strands a duplicate, never a secret.
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useQuasar } from 'quasar';
|
||||||
|
import { toBech32Lnurl } from 'lnurlcash-kit';
|
||||||
|
|
||||||
|
import QrCode from '@/components/QrCode.vue';
|
||||||
|
import { ensureExactAmount, UncertainOutcomeError } from '@/lnurlcash/ops';
|
||||||
|
import type { CarveResult } from '@/lnurlcash/ops';
|
||||||
|
import type { Bearer, NewBearer } from '@/lnurlcash/types';
|
||||||
|
import { msatToSats, satsToMsat } from '@/lnurlcash/units';
|
||||||
|
import { useWalletStore } from '@/stores/wallet';
|
||||||
|
import { useActivityStore } from '@/stores/activity';
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: boolean }>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean];
|
||||||
|
sent: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const $q = useQuasar();
|
||||||
|
const wallet = useWalletStore();
|
||||||
|
const activity = useActivityStore();
|
||||||
|
|
||||||
|
const toast = (type: 'positive' | 'negative' | 'warning' | 'info', message: string): void => {
|
||||||
|
// guarded: the Notify plugin registration lives in quasar.config, outside
|
||||||
|
// this component's control - a missing registration must not break a flow
|
||||||
|
if (typeof $q.notify === 'function') {
|
||||||
|
$q.notify({ type, message, position: 'top', timeout: 3000 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const show = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value: boolean) => emit('update:modelValue', value),
|
||||||
|
});
|
||||||
|
|
||||||
|
type Step = 'amount' | 'ready';
|
||||||
|
const step = ref<Step>('amount');
|
||||||
|
const amountSats = ref('');
|
||||||
|
const preparing = ref(false);
|
||||||
|
const removing = ref(false);
|
||||||
|
const errorMessage = ref<string | null>(null);
|
||||||
|
const prepared = ref<Bearer | null>(null);
|
||||||
|
const revealed = ref(false);
|
||||||
|
|
||||||
|
const formatSats = (sats: number): string =>
|
||||||
|
sats.toLocaleString(undefined, { maximumFractionDigits: 3 });
|
||||||
|
|
||||||
|
const parsedAmount = computed<number | null>(() => {
|
||||||
|
const n = Number(amountSats.value);
|
||||||
|
return Number.isInteger(n) && n > 0 ? n : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const amountError = computed<string | null>(() => {
|
||||||
|
if (parsedAmount.value === null) return null;
|
||||||
|
if (satsToMsat(parsedAmount.value) > wallet.balanceMsat) {
|
||||||
|
return `That's more than your spendable balance (${formatSats(wallet.balanceSats)} sats).`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const canPrepare = computed(
|
||||||
|
() => parsedAmount.value !== null && amountError.value === null && !preparing.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
const noteDisplayValue = computed(() =>
|
||||||
|
prepared.value ? toBech32Lnurl(prepared.value.url) : '',
|
||||||
|
);
|
||||||
|
|
||||||
|
const canShare =
|
||||||
|
typeof navigator !== 'undefined' && typeof navigator.share === 'function';
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
step.value = 'amount';
|
||||||
|
amountSats.value = '';
|
||||||
|
preparing.value = false;
|
||||||
|
removing.value = false;
|
||||||
|
errorMessage.value = null;
|
||||||
|
prepared.value = null;
|
||||||
|
revealed.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(open) => {
|
||||||
|
if (open) reset();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Applies a carve to the wallet in the only safe order: add the fresh
|
||||||
|
// note(s) first, mark the consumed inputs spent after. Returns the wallet
|
||||||
|
// id of the carved note (for the exact-match path the note already exists
|
||||||
|
// in the wallet, so it is found by url instead of re-added).
|
||||||
|
const applyCarve = async (carve: CarveResult): Promise<Bearer> => {
|
||||||
|
const existing = wallet.bearers.find((b) => b.url === carve.note.url);
|
||||||
|
const toAdd: NewBearer[] = [];
|
||||||
|
if (!existing) toAdd.push(carve.note);
|
||||||
|
if (carve.change) toAdd.push(carve.change);
|
||||||
|
const added = toAdd.length > 0 ? await wallet.addBearers(toAdd) : [];
|
||||||
|
for (const consumed of carve.consumed) {
|
||||||
|
await wallet.markSpent(consumed.id);
|
||||||
|
}
|
||||||
|
return existing ?? added[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const prepare = async () => {
|
||||||
|
const sats = parsedAmount.value;
|
||||||
|
if (sats === null || amountError.value !== null) return;
|
||||||
|
preparing.value = true;
|
||||||
|
errorMessage.value = null;
|
||||||
|
try {
|
||||||
|
const carve = await ensureExactAmount(wallet.bearers, satsToMsat(sats));
|
||||||
|
const note = await applyCarve(carve);
|
||||||
|
if (carve.change) {
|
||||||
|
activity.log('split', `Prepared a ${formatSats(sats)} sat note to hand over.`);
|
||||||
|
} else if (carve.consumed.length > 1) {
|
||||||
|
activity.log('combine', `Combined notes into a ${formatSats(sats)} sat note.`);
|
||||||
|
}
|
||||||
|
prepared.value = note;
|
||||||
|
revealed.value = false;
|
||||||
|
step.value = 'ready';
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UncertainOutcomeError) {
|
||||||
|
// the mutation may have landed - the possible outputs carry fresh
|
||||||
|
// secrets and must be tracked alongside the (kept) originals
|
||||||
|
await wallet.addBearers(err.possibleOutputs);
|
||||||
|
activity.log(
|
||||||
|
'transfer',
|
||||||
|
'A note preparation could not be confirmed - possible notes stored unverified.',
|
||||||
|
);
|
||||||
|
errorMessage.value =
|
||||||
|
"Couldn't confirm with the mint. Your original notes are untouched, and the possible new notes are stored unverified - refresh your wallet later to reconcile.";
|
||||||
|
toast('warning', 'Preparation uncertain - see the notice in the dialog.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = err instanceof Error ? err.message : 'Something went wrong.';
|
||||||
|
errorMessage.value = message.startsWith('No mint holds enough')
|
||||||
|
? 'Not enough spendable balance to cover that amount.'
|
||||||
|
: message;
|
||||||
|
toast('negative', errorMessage.value);
|
||||||
|
} finally {
|
||||||
|
preparing.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyNote = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(noteDisplayValue.value);
|
||||||
|
toast('positive', 'Note copied to clipboard.');
|
||||||
|
} catch {
|
||||||
|
toast('negative', "Couldn't copy - reveal the note and copy it manually.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const shareNote = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.share({ title: 'sattle bearer note', text: noteDisplayValue.value });
|
||||||
|
} catch (err) {
|
||||||
|
// the user dismissing the share sheet is not an error
|
||||||
|
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||||
|
await copyNote();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishRemove = async () => {
|
||||||
|
const note = prepared.value;
|
||||||
|
if (!note) return;
|
||||||
|
removing.value = true;
|
||||||
|
try {
|
||||||
|
await wallet.markSpent(note.id);
|
||||||
|
activity.log('spent', `Handed over a ${formatSats(msatToSats(note.amount))} sat note.`);
|
||||||
|
toast('positive', 'Removed from your balance.');
|
||||||
|
emit('sent');
|
||||||
|
show.value = false;
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Something went wrong.';
|
||||||
|
toast('negative', message);
|
||||||
|
} finally {
|
||||||
|
removing.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishKeep = () => {
|
||||||
|
toast('info', 'Note kept in your wallet.');
|
||||||
|
show.value = false;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<q-dialog
|
||||||
|
v-model="show"
|
||||||
|
position="bottom"
|
||||||
|
transition-show="slide-up"
|
||||||
|
transition-hide="slide-down"
|
||||||
|
:persistent="preparing"
|
||||||
|
>
|
||||||
|
<q-card class="sattle-card drawer-card full-width">
|
||||||
|
<q-card-section class="row items-center q-pb-sm">
|
||||||
|
<q-btn
|
||||||
|
v-close-popup
|
||||||
|
flat
|
||||||
|
round
|
||||||
|
dense
|
||||||
|
icon="close"
|
||||||
|
color="primary"
|
||||||
|
aria-label="Close"
|
||||||
|
:disable="preparing"
|
||||||
|
/>
|
||||||
|
<div class="col text-center">
|
||||||
|
<span class="text-h6 text-primary">Send a note</span>
|
||||||
|
</div>
|
||||||
|
<div style="width: 40px" />
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- step 1: amount -->
|
||||||
|
<q-card-section v-if="step === 'amount'" class="q-pa-md q-pt-sm">
|
||||||
|
<div class="text-caption text-grey-5 q-mb-md">
|
||||||
|
Spendable balance: {{ formatSats(wallet.balanceSats) }} sats
|
||||||
|
</div>
|
||||||
|
<q-input
|
||||||
|
v-model="amountSats"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
label="Amount (sats)"
|
||||||
|
outlined
|
||||||
|
color="primary"
|
||||||
|
:error="amountError !== null"
|
||||||
|
:error-message="amountError ?? undefined"
|
||||||
|
:disable="preparing"
|
||||||
|
/>
|
||||||
|
<q-banner v-if="errorMessage" dense class="bg-negative text-white q-mt-md rounded-borders">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</q-banner>
|
||||||
|
<div class="row justify-end q-gutter-sm q-mt-lg">
|
||||||
|
<q-btn v-close-popup flat label="Cancel" color="grey-5" :disable="preparing" />
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
text-color="dark"
|
||||||
|
label="Prepare note"
|
||||||
|
:loading="preparing"
|
||||||
|
:disable="!canPrepare"
|
||||||
|
@click="prepare"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
|
||||||
|
<!-- step 2: hand over -->
|
||||||
|
<q-card-section v-else class="q-pa-md q-pt-sm column items-center">
|
||||||
|
<div class="text-h6 text-primary q-mb-sm">
|
||||||
|
{{ prepared ? formatSats(msatToSats(prepared.amount)) : '' }} sats
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="qr-box"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click="revealed = true"
|
||||||
|
@keydown.enter.prevent="revealed = true"
|
||||||
|
>
|
||||||
|
<QrCode v-if="revealed" :value="noteDisplayValue" :size="220" />
|
||||||
|
<div v-else class="qr-cover column items-center justify-center">
|
||||||
|
<q-icon name="visibility" size="32px" color="primary" />
|
||||||
|
<div class="text-body2 text-primary q-mt-sm">Tap to reveal</div>
|
||||||
|
<div class="text-caption text-grey-5 q-mt-xs text-center q-px-md">
|
||||||
|
Anyone who sees this code can take the sats
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row q-gutter-sm q-mt-md">
|
||||||
|
<q-btn outline color="primary" icon="content_copy" label="Copy" @click="copyNote" />
|
||||||
|
<q-btn
|
||||||
|
v-if="canShare"
|
||||||
|
outline
|
||||||
|
color="primary"
|
||||||
|
icon="share"
|
||||||
|
label="Share"
|
||||||
|
@click="shareNote"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-caption text-grey-5 text-center q-mt-md">
|
||||||
|
Whoever opens this link gets the sats. Your copy stays in the wallet until you remove it.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column q-gutter-sm q-mt-lg full-width">
|
||||||
|
<q-btn
|
||||||
|
unelevated
|
||||||
|
color="primary"
|
||||||
|
text-color="dark"
|
||||||
|
label="Done — remove from my balance"
|
||||||
|
:loading="removing"
|
||||||
|
@click="finishRemove"
|
||||||
|
/>
|
||||||
|
<q-btn flat color="grey-5" label="Keep in wallet" :disable="removing" @click="finishKeep" />
|
||||||
|
</div>
|
||||||
|
</q-card-section>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.drawer-card {
|
||||||
|
border-top-left-radius: 20px;
|
||||||
|
border-top-right-radius: 20px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-box {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-cover {
|
||||||
|
width: 232px; /* 220 QR + 6px frame padding, matching QrCode's frame */
|
||||||
|
height: 232px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px dashed rgba(85, 255, 204, 0.4);
|
||||||
|
background: rgba(0, 34, 34, 0.6);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user