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.
This commit is contained in:
parent
c849258b5f
commit
bebdc3c24c
5 changed files with 317 additions and 4 deletions
16
package-lock.json
generated
16
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
134
src/components/ui/qr-scanner.vue
Normal file
134
src/components/ui/qr-scanner.vue
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
<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>
|
||||
|
||||
<!-- Overlay with scanning indicator -->
|
||||
<div v-if="isScanning" class="absolute inset-0 pointer-events-none">
|
||||
<!-- Scanning frame -->
|
||||
<div class="absolute inset-4 border-2 border-white rounded-lg">
|
||||
<div class="absolute top-0 left-0 w-6 h-6 border-t-4 border-l-4 border-primary rounded-tl-lg"></div>
|
||||
<div class="absolute top-0 right-0 w-6 h-6 border-t-4 border-r-4 border-primary rounded-tr-lg"></div>
|
||||
<div class="absolute bottom-0 left-0 w-6 h-6 border-b-4 border-l-4 border-primary rounded-bl-lg"></div>
|
||||
<div class="absolute bottom-0 right-0 w-6 h-6 border-b-4 border-r-4 border-primary rounded-br-lg"></div>
|
||||
</div>
|
||||
|
||||
<!-- Scanning line animation -->
|
||||
<div class="absolute inset-x-4 top-4 bottom-4 overflow-hidden">
|
||||
<div class="h-0.5 bg-primary/70 animate-pulse"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="!isScanning && hasPermission !== false" 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>
|
||||
111
src/composables/useQRScanner.ts
Normal file
111
src/composables/useQRScanner.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
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
|
||||
let videoElement: HTMLVideoElement | null = null
|
||||
|
||||
const startScanning = async (videoEl: HTMLVideoElement, onResult: (result: string) => void) => {
|
||||
try {
|
||||
error.value = null
|
||||
videoElement = videoEl
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue