import * as React from 'react' import { Camera, Flashlight, FlashlightOff, ScanLine, X } from 'lucide-react' import { Button } from '@asteby/metacore-ui' /** `torch` aún no está en todas las libs de TS — extendemos el contrato mínimo. */ type TorchCapabilities = MediaTrackCapabilities & { torch?: boolean } type TorchConstraintSet = MediaTrackConstraintSet & { torch?: boolean } /** `true` si el track de video expone linterna (ImageCapture / torch constraint). */ export function trackSupportsTorch(track: MediaStreamTrack | null | undefined): boolean { if (!track || typeof track.getCapabilities !== 'function') return false try { return !!(track.getCapabilities() as TorchCapabilities).torch } catch { return false } } async function setTrackTorch(track: MediaStreamTrack, on: boolean): Promise { try { await track.applyConstraints({ advanced: [{ torch: on } as TorchConstraintSet], }) return true } catch { // Algunos WebViews fallan con advanced[] — reintento plano. try { await track.applyConstraints({ torch: on } as MediaTrackConstraints) return true } catch { return false } } } /** * BarcodeScanner — primitivo REUSABLE de escaneo por cámara para todo el * ecosistema (POS, formularios declarativos, cualquier addon). Sin dependencias * externas ni assets: usa la API nativa `BarcodeDetector` sobre el stream de la * cámara trasera (`facingMode: environment`), por lo que respeta la CSP estricta * de los addons federados. * * Vive en el SDK (no en un addon) porque escanear un código para "llenar rápido" * es una capacidad transversal: la usa el POS para agregar productos y cualquier * campo de formulario para completar una referencia sin tipear el UUID/SKU. * * Degradación honesta: si el navegador no expone `BarcodeDetector` o el usuario * niega la cámara, muestra un aviso claro. Los lectores FÍSICOS (USB/Bluetooth) * NO necesitan esto: escriben como un teclado y ya funcionan. * * País/negocio-agnóstico: detecta los simbolismos de retail habituales * (EAN-13/8, UPC-A/E, Code-128/39, ITF, QR). */ // `BarcodeDetector` aún no está en las libs de TS por defecto: declaramos el // contrato mínimo que consumimos (evita `any` y mantiene el tipado del detect). interface DetectedBarcode { rawValue: string format: string } interface BarcodeDetectorLike { detect(source: CanvasImageSource): Promise } interface BarcodeDetectorCtor { new (opts?: { formats?: string[] }): BarcodeDetectorLike getSupportedFormats?: () => Promise } /** Simbolismos de retail que intentamos detectar. */ export const RETAIL_BARCODE_FORMATS = [ 'ean_13', 'ean_8', 'upc_a', 'upc_e', 'code_128', 'code_39', 'itf', 'qr_code', ] function getDetectorCtor(): BarcodeDetectorCtor | null { if (typeof window === 'undefined') return null const w = window as unknown as { BarcodeDetector?: BarcodeDetectorCtor } return typeof w.BarcodeDetector === 'function' ? w.BarcodeDetector : null } /** `true` si este navegador puede escanear con la cámara (detector + getUserMedia). */ export function isCameraScanSupported(): boolean { return ( typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia && getDetectorCtor() !== null ) } /** * Beep corto de confirmación al escanear — WebAudio puro (sin assets ni libs, * CSP-safe). El AudioContext se crea perezosamente en el primer uso (ya hubo * gesto del usuario al abrir el escáner, así que suena). Falla en silencio si el * dispositivo no permite audio. Exportado para reutilizarlo con lectores físicos. */ export function useScanBeep() { const ctxRef = React.useRef(null) return React.useCallback(() => { try { const AC = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }) .webkitAudioContext if (!AC) return const ctx = ctxRef.current ?? (ctxRef.current = new AC()) if (ctx.state === 'suspended') void ctx.resume() const osc = ctx.createOscillator() const gain = ctx.createGain() osc.type = 'square' osc.frequency.value = 880 // beep agudo tipo lector de supermercado gain.gain.setValueAtTime(0.0001, ctx.currentTime) gain.gain.exponentialRampToValueAtTime(0.25, ctx.currentTime + 0.01) gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.12) osc.connect(gain).connect(ctx.destination) osc.start() osc.stop(ctx.currentTime + 0.13) } catch { /* audio no disponible — el escaneo sigue funcionando igual */ } }, []) } /** Chip superpuesto sobre la cámara (éxito / error del padre tras resolver el código). */ export type BarcodeScannerFeedback = { message: string tone?: 'success' | 'error' | 'neutral' } export interface BarcodeScannerProps { open: boolean onClose: () => void /** Se dispara con el código detectado. El padre resuelve qué hacer con él. */ onDetected: (code: string) => void /** * Escaneo continuo (default): no cierra tras el primer código, ignora * repetidos ~1.2s y sigue detectando. `false` cierra al primer código * (ideal para llenar UN campo de formulario). */ continuous?: boolean /** Beep de confirmación al detectar (default true). */ beepOnScan?: boolean /** Texto del encabezado del overlay. */ title?: string /** Ayuda mostrada bajo la retícula mientras la cámara está lista. */ hint?: string /** * Posicionamiento del overlay. `absolute` (default) lo ancla al ancestro * posicionado más cercano — correcto dentro de un panel inmersivo o un * modal, sin taparse con el chrome del host. `fixed` cubre todo el viewport. */ position?: 'absolute' | 'fixed' /** * Feedback del padre (ej. "Harina × 2" / "Sin coincidencia") pintado ENCIMA * del video. Los toasts de Sonner del host quedan detrás de este overlay * opaco en la práctica — este chip es la confirmación visible al escanear. */ feedback?: BarcodeScannerFeedback | null } /** * Overlay de cámara a pantalla del contenedor con retícula de puntería. Montalo * condicionalmente (`open`) dentro de un ancestro `relative` cuando uses el * posicionamiento `absolute` por defecto. */ export function BarcodeScanner({ open, onClose, onDetected, continuous = true, beepOnScan = true, title = 'Escanear código', hint = 'Apuntá al código de barras', position = 'absolute', feedback = null, }: BarcodeScannerProps) { const videoRef = React.useRef(null) const streamRef = React.useRef(null) const rafRef = React.useRef(null) const lastHitRef = React.useRef<{ code: string; at: number }>({ code: '', at: 0 }) const [error, setError] = React.useState(null) const [ready, setReady] = React.useState(false) const [torchAvailable, setTorchAvailable] = React.useState(false) const [torchOn, setTorchOn] = React.useState(false) const beep = useScanBeep() const stop = React.useCallback(() => { if (rafRef.current != null) cancelAnimationFrame(rafRef.current) rafRef.current = null const track = streamRef.current?.getVideoTracks()[0] if (track && trackSupportsTorch(track)) { void setTrackTorch(track, false) } streamRef.current?.getTracks().forEach((t) => t.stop()) streamRef.current = null setReady(false) setTorchAvailable(false) setTorchOn(false) }, []) const toggleTorch = React.useCallback(async () => { const track = streamRef.current?.getVideoTracks()[0] if (!track || !trackSupportsTorch(track)) return const next = !torchOn const ok = await setTrackTorch(track, next) if (ok) setTorchOn(next) }, [torchOn]) React.useEffect(() => { if (!open) return setError(null) setTorchAvailable(false) setTorchOn(false) const Ctor = getDetectorCtor() if (!navigator.mediaDevices?.getUserMedia || !Ctor) { setError( 'Tu navegador no soporta escaneo por cámara. Usá un lector físico (escribe en el campo) o escribí el código a mano.', ) return } let cancelled = false const detector = new Ctor({ formats: RETAIL_BARCODE_FORMATS }) const scanFrame = async () => { const video = videoRef.current if (cancelled || !video || video.readyState < 2) { rafRef.current = requestAnimationFrame(scanFrame) return } try { const codes = await detector.detect(video) const hit = codes.find((c) => c.rawValue)?.rawValue?.trim() if (hit) { const now = Date.now() const dup = hit === lastHitRef.current.code && now - lastHitRef.current.at < 1200 if (!dup) { lastHitRef.current = { code: hit, at: now } if (beepOnScan) beep() onDetected(hit) if (!continuous) { onClose() return } } } } catch { /* frame sin código o detect transitorio — seguimos */ } if (!cancelled) rafRef.current = requestAnimationFrame(scanFrame) } void (async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' } }, audio: false, }) if (cancelled) { stream.getTracks().forEach((t) => t.stop()) return } streamRef.current = stream const videoTrack = stream.getVideoTracks()[0] setTorchAvailable(trackSupportsTorch(videoTrack)) const video = videoRef.current if (video) { video.srcObject = stream await video.play().catch(() => undefined) setReady(true) rafRef.current = requestAnimationFrame(scanFrame) } } catch (e) { const name = (e as { name?: string })?.name setError( name === 'NotAllowedError' ? 'Permiso de cámara denegado. Habilitalo en el navegador para escanear.' : 'No se pudo abrir la cámara. Usá un lector físico o escribí el código a mano.', ) } })() return () => { cancelled = true stop() } }, [open, continuous, beepOnScan, onDetected, onClose, stop, beep]) if (!open) return null const posClass = position === 'fixed' ? 'fixed' : 'absolute' return (
{/* Barra superior */}
{title}
{torchAvailable && ( )}
{/* Área de cámara */}
{error ? (

{error}

) : ( <>
) } export default BarcodeScanner