// src/components/IsbnScanner.jsx
//
// Camera-based barcode scanner that resolves a book's ISBN. Powered by
// @zxing/browser, which wraps getUserMedia + the ZXing format readers.
//
// Behavior:
// - Mounts as a full-screen overlay with a video viewfinder.
// - Prefers the rear-facing camera on mobile (`facingMode: environment`).
// - On scan: validates the value against utils/isbn (EAN-13 barcodes
// ARE ISBN-13s when starting with 978 or 979). Rejects non-book
// barcodes silently so the user can keep scanning.
// - On match: fires `onScan(isbn13)` with the canonical ISBN-13
// digits and stops the camera.
// - On cancel / Escape / unmount: stops the camera cleanly.
//
// Permissions:
// - First open prompts the browser for camera access. If denied, an
// in-overlay help block explains how to enable in browser settings
// and offers a "Type it instead" fallback.
//
// Created by Miguel Brown on 5/13/26.
// Copyright (c) 2026 Luckey Logic LLC. All rights reserved.
import React, { useEffect, useRef, useState } from 'react'
import { BrowserMultiFormatReader } from '@zxing/browser'
import useLockBodyScroll from '../hooks/useLockBodyScroll.js'
import { isValidIsbn, normalizeIsbn } from '../utils/isbn.js'
import styles from './IsbnScanner.module.css'
/**
* IsbnScanner — full-screen camera overlay that resolves an ISBN
* barcode and fires `onScan(isbn13)` once.
*
* @param {Object} props
* @param {Function} props.onScan - Called with a normalized ISBN-13 digit string.
* @param {Function} props.onClose - Called when the user dismisses the scanner.
* @returns {JSX.Element}
*/
export default function IsbnScanner({ onScan, onClose }) {
const videoRef = useRef(null)
const readerRef = useRef(null)
const controlsRef = useRef(null)
const cancelledRef = useRef(false)
const [status, setStatus] = useState('starting') // 'starting' | 'scanning' | 'found' | 'denied' | 'error'
const [statusMsg, setStatusMsg] = useState('Starting camera…')
const [lastSeen, setLastSeen] = useState(null) // shown when a barcode was scanned but rejected as non-ISBN
// Lock background page scroll while the camera overlay is up so a stray
// swipe doesn't scroll the admin page behind the viewfinder.
useLockBodyScroll()
// Close on Escape.
useEffect(() => {
const onKey = (e) => {
if (e.key === 'Escape') onClose && onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [onClose])
// Initialize the scanner once on mount; tear it down on unmount.
useEffect(() => {
cancelledRef.current = false
const reader = new BrowserMultiFormatReader()
readerRef.current = reader
const constraints = {
audio: false,
video: {
facingMode: { ideal: 'environment' },
width : { ideal: 1280 },
height : { ideal: 720 }
}
}
;(async () => {
try {
const controls = await reader.decodeFromConstraints(
constraints,
videoRef.current,
(result, _err, ctrls) => {
if (cancelledRef.current) return
if (!result) return
const text = result.getText()
const isbn13 = normalizeIsbn(text)
if (isbn13 && isValidIsbn(text)) {
// Got an ISBN. Stop and notify.
ctrls.stop()
cancelledRef.current = true
setStatus('found')
setStatusMsg(`Found ISBN ${isbn13}`)
if (onScan) onScan(isbn13)
} else {
// Some other barcode (UPC for a non-book, etc.) — show
// the text briefly and keep scanning.
setLastSeen(text)
}
}
)
controlsRef.current = controls
if (cancelledRef.current) {
controls.stop()
return
}
setStatus('scanning')
setStatusMsg('Point the camera at the barcode on the back of the book.')
} catch (err) {
// NotAllowedError → camera permission was denied
// NotFoundError → no camera available on this device
const denied =
err && (err.name === 'NotAllowedError' || /permission/i.test(err.message || ''))
const noCam =
err && (err.name === 'NotFoundError' || /no devices|no camera/i.test(err.message || ''))
setStatus(denied ? 'denied' : (noCam ? 'no-camera' : 'error'))
setStatusMsg(
denied ? 'Camera access was denied.' :
noCam ? 'No camera found on this device.' :
(err && err.message ? err.message : 'Couldn\'t open the camera.')
)
}
})()
return () => {
cancelledRef.current = true
if (controlsRef.current) {
try { controlsRef.current.stop() } catch (_) { /* noop */ }
controlsRef.current = null
}
readerRef.current = null
}
}, [onScan])
return (
<div
className ={styles.overlay}
role ="dialog"
aria-modal="true"
aria-label="Scan an ISBN barcode"
onClick ={(e) => {
// Clicks on the dark margin (not on the inner panel) close the
// overlay, mirroring the avatar lightbox behavior.
if (e.target === e.currentTarget) onClose && onClose()
}}
>
<button
type ="button"
className ={styles.close}
onClick ={() => onClose && onClose()}
aria-label="Close scanner"
>
×
</button>
<div className={styles.inner}>
<div className={styles.videoWrap}>
{/* video must be muted + playsInline to autoplay on mobile Safari */}
<video
ref ={videoRef}
className ={styles.video}
autoPlay
muted
playsInline
/>
<div className={styles.guide} aria-hidden="true">
<div className={styles.guideCorner + ' ' + styles.tl} />
<div className={styles.guideCorner + ' ' + styles.tr} />
<div className={styles.guideCorner + ' ' + styles.bl} />
<div className={styles.guideCorner + ' ' + styles.br} />
</div>
</div>
<p className={styles.status} aria-live="polite">
{statusMsg}
</p>
{lastSeen && status === 'scanning' ? (
<p className={styles.lastSeen}>
Saw barcode <code>{lastSeen}</code> — not an ISBN. Try the barcode on
the back cover near the publisher info.
</p>
) : null}
{status === 'denied' ? (
<div className={styles.help}>
<p>
<strong>Allow camera access</strong> in your browser settings (click
the camera icon next to the address bar) and reload. Or just type
the ISBN by hand:
</p>
<button
type ="button"
className ="btn btn-primary"
onClick ={() => onClose && onClose()}
>
Type it instead
</button>
</div>
) : null}
{status === 'no-camera' || status === 'error' ? (
<div className={styles.help}>
<p>{statusMsg}</p>
<button
type ="button"
className ="btn btn-primary"
onClick ={() => onClose && onClose()}
>
Type it instead
</button>
</div>
) : null}
</div>
</div>
)
}