Add QR code scanning functionality with new QRScanner component

- Introduced a new QRScanner component to facilitate QR code scanning
within the application.
- Integrated QR code scanning capabilities into the SendDialog.vue,
allowing users to scan QR codes for payment destinations.
- Updated package.json and package-lock.json to include the qr-scanner
library for QR code processing.
- Enhanced user experience by providing visual feedback and error
handling during the scanning process.

These changes improve the payment workflow by enabling users to easily
scan QR codes for transactions.

Enhance QR code interaction in ReceiveDialog.vue

- Updated the QR code display to include a clickable area that allows
users to copy the invoice directly to the clipboard.
- Added a descriptive message below the QR code to inform users about
the copy functionality, improving usability and accessibility.

These changes enhance the user experience by making it easier to
interact with the QR code for invoice management.

Fix QR scanner loading state condition and remove unused video element
reference

- Updated the loading state condition in qr-scanner.vue to check for
camera permission correctly.
- Removed the unused videoElement reference in useQRScanner.ts to clean
up the code.

These changes improve the functionality and clarity of the QR scanner
component.
This commit is contained in:
padreug 2025-09-18 22:09:46 +02:00
parent ef818baed6
commit a9c07f6af3
6 changed files with 311 additions and 10 deletions

16
package-lock.json generated
View file

@ -22,6 +22,7 @@
"lucide-vue-next": "^0.474.0",
"nostr-tools": "^2.10.4",
"pinia": "^2.3.1",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"radix-vue": "^1.9.13",
"reka-ui": "^2.5.0",
@ -5091,6 +5092,12 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/offscreencanvas": {
"version": "2019.7.3",
"resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz",
"integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==",
"license": "MIT"
},
"node_modules/@types/qrcode": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz",
@ -11697,6 +11704,15 @@
"node": ">=6"
}
},
"node_modules/qr-scanner": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/qr-scanner/-/qr-scanner-1.4.2.tgz",
"integrity": "sha512-kV1yQUe2FENvn59tMZW6mOVfpq9mGxGf8l6+EGaXUOd4RBOLg7tRC83OrirM5AtDvZRpdjdlXURsHreAOSPOUw==",
"license": "MIT",
"dependencies": {
"@types/offscreencanvas": "^2019.6.4"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",

View file

@ -31,6 +31,7 @@
"lucide-vue-next": "^0.474.0",
"nostr-tools": "^2.10.4",
"pinia": "^2.3.1",
"qr-scanner": "^1.4.2",
"qrcode": "^1.5.4",
"radix-vue": "^1.9.13",
"reka-ui": "^2.5.0",

View file

@ -0,0 +1,119 @@
<template>
<div class="relative">
<!-- Camera Permission Request -->
<div v-if="hasPermission === false" class="text-center p-6 space-y-4">
<div class="text-destructive">
<svg class="w-12 h-12 mx-auto mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
</div>
<div>
<h3 class="font-medium text-lg">Camera Access Required</h3>
<p class="text-sm text-muted-foreground mt-1">{{ error || 'Please allow camera access to scan QR codes' }}</p>
</div>
<Button @click="requestPermission" variant="outline">
Try Again
</Button>
</div>
<!-- Scanner Interface -->
<div v-else class="space-y-4">
<!-- Video Element -->
<div class="relative bg-black rounded-lg overflow-hidden">
<video
ref="videoEl"
class="w-full h-64 sm:h-80 object-cover"
muted
playsinline
></video>
<!-- Loading state -->
<div v-if="!isScanning && hasPermission === true" class="absolute inset-0 flex items-center justify-center bg-black/50">
<div class="text-center text-white">
<Loader2 class="w-8 h-8 animate-spin mx-auto mb-2" />
<p class="text-sm">Starting camera...</p>
</div>
</div>
</div>
<!-- Controls -->
<div class="flex items-center justify-between">
<div class="text-sm text-muted-foreground">
{{ isScanning ? 'Point camera at QR code' : 'Preparing scanner...' }}
</div>
<div class="flex gap-2">
<!-- Flash toggle (if available) -->
<Button
v-if="flashAvailable"
@click="toggleFlash"
variant="outline"
size="sm"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
</Button>
<!-- Close scanner -->
<Button @click="$emit('close')" variant="outline" size="sm">
Close
</Button>
</div>
</div>
<!-- Error display -->
<div v-if="error" class="text-destructive text-sm p-3 bg-destructive/10 rounded-md">
{{ error }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import { Button } from '@/components/ui/button'
import { Loader2 } from 'lucide-vue-next'
import { useQRScanner } from '@/composables/useQRScanner'
interface Emits {
(e: 'result', value: string): void
(e: 'close'): void
}
const emit = defineEmits<Emits>()
const videoEl = ref<HTMLVideoElement>()
const flashAvailable = ref(false)
const {
isScanning,
hasPermission,
error,
startScanning,
stopScanning,
toggleFlash,
hasFlash
} = useQRScanner()
const requestPermission = async () => {
if (videoEl.value) {
await startScanning(videoEl.value, (result) => {
emit('result', result)
})
flashAvailable.value = await hasFlash()
}
}
onMounted(async () => {
await nextTick()
if (videoEl.value) {
await requestPermission()
}
})
onUnmounted(() => {
stopScanning()
})
</script>

View file

@ -0,0 +1,109 @@
import { ref, onUnmounted } from 'vue'
import QrScanner from 'qr-scanner'
export function useQRScanner() {
const isScanning = ref(false)
const hasPermission = ref<boolean | null>(null)
const error = ref<string | null>(null)
const scanResult = ref<string | null>(null)
let qrScanner: QrScanner | null = null
const startScanning = async (videoEl: HTMLVideoElement, onResult: (result: string) => void) => {
try {
error.value = null
// Check if camera is available
const hasCamera = await QrScanner.hasCamera()
if (!hasCamera) {
throw new Error('No camera found')
}
// Request camera permission
await navigator.mediaDevices.getUserMedia({ video: true })
hasPermission.value = true
// Create QR scanner instance
qrScanner = new QrScanner(
videoEl,
(result) => {
scanResult.value = result.data
onResult(result.data)
},
{
highlightScanRegion: true,
highlightCodeOutline: true,
maxScansPerSecond: 5,
}
)
await qrScanner.start()
isScanning.value = true
} catch (err) {
console.error('Failed to start QR scanner:', err)
hasPermission.value = false
if (err instanceof Error) {
if (err.name === 'NotAllowedError') {
error.value = 'Camera permission denied. Please allow camera access and try again.'
} else if (err.name === 'NotFoundError') {
error.value = 'No camera found on this device.'
} else if (err.name === 'NotSupportedError') {
error.value = 'Camera not supported on this device.'
} else {
error.value = err.message
}
} else {
error.value = 'Failed to access camera'
}
}
}
const stopScanning = () => {
if (qrScanner) {
qrScanner.stop()
qrScanner.destroy()
qrScanner = null
}
isScanning.value = false
scanResult.value = null
}
const toggleFlash = async () => {
if (qrScanner) {
try {
await qrScanner.toggleFlash()
} catch (err) {
console.error('Failed to toggle flash:', err)
}
}
}
const hasFlash = async (): Promise<boolean> => {
if (qrScanner) {
try {
return await qrScanner.hasFlash()
} catch (err) {
return false
}
}
return false
}
// Cleanup on unmount
onUnmounted(() => {
stopScanning()
})
return {
isScanning,
hasPermission,
error,
scanResult,
startScanning,
stopScanning,
toggleFlash,
hasFlash
}
}

View file

@ -286,12 +286,17 @@ function formatExpiry(seconds: number): string {
<div v-if="isLoadingQR" class="w-48 h-48 sm:w-64 sm:h-64 flex items-center justify-center bg-muted rounded-lg">
<Loader2 class="h-6 w-6 sm:h-8 sm:w-8 animate-spin text-muted-foreground" />
</div>
<div v-else-if="qrCode" class="bg-white p-2 sm:p-4 rounded-lg">
<img
:src="qrCode"
alt="Lightning Invoice QR Code"
class="w-48 h-48 sm:w-64 sm:h-64"
/>
<div v-else-if="qrCode" class="text-center">
<div class="bg-white p-2 sm:p-4 rounded-lg cursor-pointer hover:bg-gray-50 transition-colors" @click="copyToClipboard(createdInvoice.payment_request || createdInvoice.bolt11, 'qr')">
<img
:src="qrCode"
alt="Lightning Invoice QR Code"
class="w-48 h-48 sm:w-64 sm:h-64"
/>
</div>
<p class="text-xs text-muted-foreground mt-2">
Click QR code to copy invoice
</p>
</div>
<div v-else class="w-48 h-48 sm:w-64 sm:h-64 flex items-center justify-center bg-muted rounded-lg">
<QrCode class="h-8 w-8 sm:h-12 sm:w-12 text-muted-foreground opacity-50" />

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import * as z from 'zod'
@ -8,8 +8,9 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } f
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Send, AlertCircle, Loader2 } from 'lucide-vue-next'
import { Send, AlertCircle, Loader2, ScanLine } from 'lucide-vue-next'
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import QRScanner from '@/components/ui/qr-scanner.vue'
interface Props {
open: boolean
@ -43,11 +44,12 @@ const form = useForm({
}
})
const { resetForm, values, meta } = form
const { resetForm, values, meta, setFieldValue } = form
const isFormValid = computed(() => meta.value.valid)
// State
const isSending = computed(() => walletService?.isSendingPayment?.value || false)
const showScanner = ref(false)
const error = computed(() => walletService?.error?.value)
// Methods
@ -71,6 +73,23 @@ const onSubmit = form.handleSubmit(async (formValues) => {
function closeDialog() {
emit('update:open', false)
resetForm()
showScanner.value = false
}
// QR Scanner functions
function openScanner() {
showScanner.value = true
}
function closeScanner() {
showScanner.value = false
}
function handleScanResult(result: string) {
// Set the scanned result in the destination field
setFieldValue('destination', result)
closeScanner()
toastService?.success('QR code scanned successfully!')
}
// Determine destination type helper text
@ -103,7 +122,19 @@ const destinationType = computed(() => {
<form @submit="onSubmit" class="space-y-4">
<FormField v-slot="{ componentField }" name="destination">
<FormItem>
<FormLabel>Destination *</FormLabel>
<div class="flex items-center justify-between">
<FormLabel>Destination *</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
@click="openScanner"
class="h-7 px-2 text-xs"
>
<ScanLine class="w-3 h-3 mr-1" />
Scan QR
</Button>
</div>
<FormControl>
<Textarea
placeholder="Lightning invoice, LNURL, or Lightning address (user@domain.com)"
@ -170,4 +201,24 @@ const destinationType = computed(() => {
</form>
</DialogContent>
</Dialog>
<!-- QR Scanner Dialog -->
<Dialog :open="showScanner" @update:open="showScanner = $event">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<ScanLine class="h-5 w-5" />
Scan QR Code
</DialogTitle>
<DialogDescription>
Point your camera at a Lightning invoice QR code
</DialogDescription>
</DialogHeader>
<QRScanner
@result="handleScanResult"
@close="closeScanner"
/>
</DialogContent>
</Dialog>
</template>