Jake Baker

One QR scanner, two completely different formats

2 October 2025

Trade fair floor, a few thousand people, and two different kinds of QR code floating around the venue. Exhibitors have QR codes that encode URLs — scan one and you land on a contact form. Guests have QR codes on their badges that are just plain text booking codes — scan one of those and you should land on a conversation notes page with that guest’s details pre-filled.

I needed one scanner component to handle both. The question was how to detect which kind of code was just scanned and route accordingly.

The answer turned out to be a regex check on the raw scanned value. URLs start with http — booking codes don’t. Simple branching:

function handleScan(scannedValue) {
  const isUrl = /^https?:\/\//.test(scannedValue)

  if (isUrl) {
    // Guest scans exhibitor QR → extract path and navigate to contact form
    const match = scannedValue.match(/(?:https?:\/\/[^\/]+\/)(.*)/)
    if (match) {
      navigateTo(`/${match[1]}`)
    }
  } else {
    // Exhibitor scans guest badge → go to conversation notes
    const { exhibitor } = storeToRefs(useAuthStore())

    if (!exhibitor.value?.id) {
      showAlert('No exhibitor profile found')
      return
    }

    navigateTo(`/exhibitor/${exhibitor.value.id}/notes?guest_code=${encodeURIComponent(scannedValue)}`)
  }
}

The URL flow extracts the path from the scanned URL and navigates internally. This means exhibitor QR codes can be full URLs (useful for printing on physical signage — they work if someone scans with their phone camera too) but the app handles them as internal navigation.

The plain text flow is exhibitor-only. It checks that the current user actually has an exhibitor profile before navigating. Without that guard, a guest accidentally scanning another guest’s badge would hit a dead end.

The scanner itself uses vue-qrcode-reader with the camera constrained to the rear-facing lens and only QR codes enabled (no barcodes, no data matrix). When a code is detected, the stream pauses and shows the scanned value with a “go to form” button. The user confirms before navigation happens — no auto-redirect on scan, because scanning the wrong code in a noisy venue is easy and undoing an accidental navigation is annoying.

<QrcodeStream
  :constraints="{ facingMode: 'environment' }"
  :formats="['qr_code']"
  :paused="paused"
  @detect="onDetect"
/>
function onDetect(detectedCodes) {
  result.value = detectedCodes.map(code => code.rawValue)
  paused.value = true
}

The role system also gates who sees the scanner at all. Guests, exhibitors, and admins can access it, but the navigation target differs by role. The scanner component doesn’t know about roles — it just scans and calls handleScan. The role logic lives in the route permissions and the auth store, not in the scanner.

I spent more time on camera error handling than on the actual scanning logic. iOS Safari needs camera permission before you can enumerate devices. The camera-on event from the library signals that permission was granted, and only then do I populate the camera selector dropdown. Without that sequencing, enumerateDevices returns empty arrays on iOS and the user sees no camera options.

The whole thing is maybe 20 lines of actual routing logic. The rest is camera lifecycle and UX — pausing on detect, showing the result, letting the user confirm or re-scan. The dual-mode detection is a regex and an if statement. Sometimes that’s all it takes.