@asteby/metacore-runtime-react 29.2.5 → 29.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/dist/barcode-scanner.d.ts +2 -0
- package/dist/barcode-scanner.d.ts.map +1 -1
- package/dist/barcode-scanner.js +58 -2
- package/dist/dialogs/dynamic-record.js +4 -1
- package/dist/dynamic-form.js +4 -1
- package/package.json +1 -1
- package/src/__tests__/barcode-scanner.test.tsx +19 -1
- package/src/barcode-scanner.tsx +92 -12
- package/src/dialogs/dynamic-record.tsx +3 -1
- package/src/dynamic-form.tsx +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 29.2.7
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 89e79b0: fix(barcode-scanner): cerrar al escanear en formularios (modal crear/editar)
|
|
8
|
+
|
|
9
|
+
`continuous={false}` en dynamic-form y dynamic-record: al llenar SKU/código el
|
|
10
|
+
overlay se cierra. El POS sigue con continuous (default) para varios productos.
|
|
11
|
+
|
|
12
|
+
## 29.2.6
|
|
13
|
+
|
|
14
|
+
### Patch Changes
|
|
15
|
+
|
|
16
|
+
- b1a9703: feat(barcode-scanner): botón de linterna (torch) cuando el dispositivo lo soporta
|
|
17
|
+
|
|
18
|
+
En ambientes oscuros se puede encender/apagar el flash de la cámara trasera
|
|
19
|
+
desde la barra del overlay. Si el hardware no expone `torch`, el botón no
|
|
20
|
+
aparece. Aplica a POS y formularios (mismo primitivo).
|
|
21
|
+
|
|
3
22
|
## 29.2.5
|
|
4
23
|
|
|
5
24
|
### Patch Changes
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
+
/** `true` si el track de video expone linterna (ImageCapture / torch constraint). */
|
|
3
|
+
export declare function trackSupportsTorch(track: MediaStreamTrack | null | undefined): boolean;
|
|
2
4
|
/** Simbolismos de retail que intentamos detectar. */
|
|
3
5
|
export declare const RETAIL_BARCODE_FORMATS: string[];
|
|
4
6
|
/** `true` si este navegador puede escanear con la cámara (detector + getUserMedia). */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"barcode-scanner.d.ts","sourceRoot":"","sources":["../src/barcode-scanner.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"barcode-scanner.d.ts","sourceRoot":"","sources":["../src/barcode-scanner.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAQ9B,qFAAqF;AACrF,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,gBAAgB,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAOtF;AAoDD,qDAAqD;AACrD,eAAO,MAAM,sBAAsB,UASlC,CAAA;AAQD,uFAAuF;AACvF,wBAAgB,qBAAqB,IAAI,OAAO,CAM/C;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,eAyB1B;AAED,MAAM,WAAW,mBAAmB;IAChC,IAAI,EAAE,OAAO,CAAA;IACb,OAAO,EAAE,MAAM,IAAI,CAAA;IACnB,8EAA8E;IAC9E,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IAClC;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,uDAAuD;IACvD,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,wCAAwC;IACxC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,qEAAqE;IACrE,IAAI,CAAC,EAAE,MAAM,CAAA;IACb;;;;OAIG;IACH,QAAQ,CAAC,EAAE,UAAU,GAAG,OAAO,CAAA;CAClC;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,EAC3B,IAAI,EACJ,OAAO,EACP,UAAU,EACV,UAAiB,EACjB,UAAiB,EACjB,KAAyB,EACzB,IAAmC,EACnC,QAAqB,GACxB,EAAE,mBAAmB,4BAgMrB;AAED,eAAe,cAAc,CAAA"}
|
package/dist/barcode-scanner.js
CHANGED
|
@@ -1,7 +1,36 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import * as React from 'react';
|
|
3
|
-
import { Camera, ScanLine, X } from 'lucide-react';
|
|
3
|
+
import { Camera, Flashlight, FlashlightOff, ScanLine, X } from 'lucide-react';
|
|
4
4
|
import { Button } from '@asteby/metacore-ui';
|
|
5
|
+
/** `true` si el track de video expone linterna (ImageCapture / torch constraint). */
|
|
6
|
+
export function trackSupportsTorch(track) {
|
|
7
|
+
if (!track || typeof track.getCapabilities !== 'function')
|
|
8
|
+
return false;
|
|
9
|
+
try {
|
|
10
|
+
return !!track.getCapabilities().torch;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
async function setTrackTorch(track, on) {
|
|
17
|
+
try {
|
|
18
|
+
await track.applyConstraints({
|
|
19
|
+
advanced: [{ torch: on }],
|
|
20
|
+
});
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Algunos WebViews fallan con advanced[] — reintento plano.
|
|
25
|
+
try {
|
|
26
|
+
await track.applyConstraints({ torch: on });
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
5
34
|
/** Simbolismos de retail que intentamos detectar. */
|
|
6
35
|
export const RETAIL_BARCODE_FORMATS = [
|
|
7
36
|
'ean_13',
|
|
@@ -71,19 +100,38 @@ export function BarcodeScanner({ open, onClose, onDetected, continuous = true, b
|
|
|
71
100
|
const lastHitRef = React.useRef({ code: '', at: 0 });
|
|
72
101
|
const [error, setError] = React.useState(null);
|
|
73
102
|
const [ready, setReady] = React.useState(false);
|
|
103
|
+
const [torchAvailable, setTorchAvailable] = React.useState(false);
|
|
104
|
+
const [torchOn, setTorchOn] = React.useState(false);
|
|
74
105
|
const beep = useScanBeep();
|
|
75
106
|
const stop = React.useCallback(() => {
|
|
76
107
|
if (rafRef.current != null)
|
|
77
108
|
cancelAnimationFrame(rafRef.current);
|
|
78
109
|
rafRef.current = null;
|
|
110
|
+
const track = streamRef.current?.getVideoTracks()[0];
|
|
111
|
+
if (track && trackSupportsTorch(track)) {
|
|
112
|
+
void setTrackTorch(track, false);
|
|
113
|
+
}
|
|
79
114
|
streamRef.current?.getTracks().forEach((t) => t.stop());
|
|
80
115
|
streamRef.current = null;
|
|
81
116
|
setReady(false);
|
|
117
|
+
setTorchAvailable(false);
|
|
118
|
+
setTorchOn(false);
|
|
82
119
|
}, []);
|
|
120
|
+
const toggleTorch = React.useCallback(async () => {
|
|
121
|
+
const track = streamRef.current?.getVideoTracks()[0];
|
|
122
|
+
if (!track || !trackSupportsTorch(track))
|
|
123
|
+
return;
|
|
124
|
+
const next = !torchOn;
|
|
125
|
+
const ok = await setTrackTorch(track, next);
|
|
126
|
+
if (ok)
|
|
127
|
+
setTorchOn(next);
|
|
128
|
+
}, [torchOn]);
|
|
83
129
|
React.useEffect(() => {
|
|
84
130
|
if (!open)
|
|
85
131
|
return;
|
|
86
132
|
setError(null);
|
|
133
|
+
setTorchAvailable(false);
|
|
134
|
+
setTorchOn(false);
|
|
87
135
|
const Ctor = getDetectorCtor();
|
|
88
136
|
if (!navigator.mediaDevices?.getUserMedia || !Ctor) {
|
|
89
137
|
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.');
|
|
@@ -132,6 +180,8 @@ export function BarcodeScanner({ open, onClose, onDetected, continuous = true, b
|
|
|
132
180
|
return;
|
|
133
181
|
}
|
|
134
182
|
streamRef.current = stream;
|
|
183
|
+
const videoTrack = stream.getVideoTracks()[0];
|
|
184
|
+
setTorchAvailable(trackSupportsTorch(videoTrack));
|
|
135
185
|
const video = videoRef.current;
|
|
136
186
|
if (video) {
|
|
137
187
|
video.srcObject = stream;
|
|
@@ -155,6 +205,12 @@ export function BarcodeScanner({ open, onClose, onDetected, continuous = true, b
|
|
|
155
205
|
if (!open)
|
|
156
206
|
return null;
|
|
157
207
|
const posClass = position === 'fixed' ? 'fixed' : 'absolute';
|
|
158
|
-
return (_jsxs("div", { className: `${posClass} inset-0 z-[100] flex flex-col bg-black`, children: [_jsxs("div", { className: "flex items-center justify-between px-4 py-3 text-white", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-medium", children: [_jsx(ScanLine, { className: "size-5" }), title] }),
|
|
208
|
+
return (_jsxs("div", { className: `${posClass} inset-0 z-[100] flex flex-col bg-black`, children: [_jsxs("div", { className: "flex items-center justify-between px-4 py-3 text-white", children: [_jsxs("div", { className: "flex items-center gap-2 text-sm font-medium", children: [_jsx(ScanLine, { className: "size-5" }), title] }), _jsxs("div", { className: "flex items-center gap-1", children: [torchAvailable && (_jsx(Button, { type: "button", variant: "ghost", size: "icon", className: torchOn
|
|
209
|
+
? 'bg-amber-400/20 text-amber-300 hover:bg-amber-400/30 hover:text-amber-200'
|
|
210
|
+
: 'text-white hover:bg-white/10 hover:text-white', onClick: () => void toggleTorch(), "aria-label": torchOn ? 'Apagar linterna' : 'Encender linterna', "aria-pressed": torchOn, title: torchOn ? 'Apagar linterna' : 'Encender linterna', children: torchOn ? (_jsx(Flashlight, { className: "size-5" })) : (_jsx(FlashlightOff, { className: "size-5" })) })), _jsx(Button, { type: "button", variant: "ghost", size: "icon", className: "text-white hover:bg-white/10 hover:text-white", onClick: onClose, "aria-label": "Cerrar esc\u00E1ner", children: _jsx(X, { className: "size-5" }) })] })] }), _jsx("div", { className: "relative flex flex-1 items-center justify-center overflow-hidden", children: error ? (_jsxs("div", { className: "flex max-w-sm flex-col items-center gap-3 px-6 text-center text-white", children: [_jsx(Camera, { className: "size-10 opacity-70" }), _jsx("p", { className: "text-sm leading-relaxed", children: error }), _jsx(Button, { variant: "secondary", onClick: onClose, children: "Entendido" })] })) : (_jsxs(_Fragment, { children: [_jsx("video", { ref: videoRef, className: "h-full w-full object-cover", muted: true, playsInline: true }), _jsx("div", { className: "pointer-events-none absolute inset-0 flex items-center justify-center", children: _jsx("div", { className: "relative h-40 w-72 max-w-[80%] rounded-xl border-2 border-white/80 shadow-[0_0_0_9999px_rgba(0,0,0,0.35)]", children: _jsx("span", { className: "bg-primary absolute inset-x-0 top-1/2 h-0.5 -translate-y-1/2 animate-pulse" }) }) }), _jsx("p", { className: "absolute bottom-8 left-0 right-0 px-4 text-center text-sm text-white/90", children: !ready
|
|
211
|
+
? 'Abriendo cámara…'
|
|
212
|
+
: torchAvailable
|
|
213
|
+
? `${hint} · tocá la linterna si está oscuro`
|
|
214
|
+
: hint })] })) })] }));
|
|
159
215
|
}
|
|
160
216
|
export default BarcodeScanner;
|
|
@@ -1034,7 +1034,10 @@ function ScannableRecordInput({ field, value, onChange, inputType, }) {
|
|
|
1034
1034
|
const input = (_jsx(Input, { type: inputType, value: value ?? '', onChange: (e) => onChange(inputType === 'number' ? (e.target.value === '' ? '' : Number(e.target.value)) : e.target.value), placeholder: field.placeholder }));
|
|
1035
1035
|
if (!scanEnabled)
|
|
1036
1036
|
return input;
|
|
1037
|
-
return (_jsxs("div", { className: "flex w-full min-w-0 items-center gap-1.5", children: [_jsx("div", { className: "min-w-0 flex-1", children: input }), _jsx(Button, { type: "button", variant: "outline", size: "icon", className: "size-9 shrink-0", onClick: () => setScanOpen(true), title: "Escanear con la c\u00E1mara", "aria-label": "Escanear c\u00F3digo de barras con la c\u00E1mara", children: _jsx(ScanLine, { className: "size-4" }) }), _jsx(BarcodeScanner, { open: scanOpen, onClose: () => setScanOpen(false), onDetected: (code) => onChange(code),
|
|
1037
|
+
return (_jsxs("div", { className: "flex w-full min-w-0 items-center gap-1.5", children: [_jsx("div", { className: "min-w-0 flex-1", children: input }), _jsx(Button, { type: "button", variant: "outline", size: "icon", className: "size-9 shrink-0", onClick: () => setScanOpen(true), title: "Escanear con la c\u00E1mara", "aria-label": "Escanear c\u00F3digo de barras con la c\u00E1mara", children: _jsx(ScanLine, { className: "size-4" }) }), _jsx(BarcodeScanner, { open: scanOpen, onClose: () => setScanOpen(false), onDetected: (code) => onChange(code),
|
|
1038
|
+
// Un campo = un código: cerrar al detectar. El POS usa continuous
|
|
1039
|
+
// (default) porque suma varios productos seguidos.
|
|
1040
|
+
continuous: false, position: "fixed", title: `Escanear ${field.label ?? ''}`.trim() })] }));
|
|
1038
1041
|
}
|
|
1039
1042
|
function ImageUploadField({ field: _field, value, onChange }) {
|
|
1040
1043
|
const { t } = useTranslation();
|
package/dist/dynamic-form.js
CHANGED
|
@@ -264,7 +264,10 @@ function ScannableInput({ field, value, onChange, type, }) {
|
|
|
264
264
|
const input = (_jsx(Input, { id: field.key, type: type, value: value ?? '', onChange: (e) => onChange(type === 'number' ? e.target.valueAsNumber || '' : e.target.value), placeholder: field.placeholder }));
|
|
265
265
|
if (!scanEnabled)
|
|
266
266
|
return input;
|
|
267
|
-
return (_jsxs("div", { className: "flex w-full min-w-0 items-center gap-1.5", children: [_jsx("div", { className: "min-w-0 flex-1", children: input }), _jsx(Button, { type: "button", variant: "outline", size: "icon", className: "size-9 shrink-0", onClick: () => setScanOpen(true), title: "Escanear con la c\u00E1mara", "aria-label": "Escanear c\u00F3digo de barras con la c\u00E1mara", children: _jsx(ScanLine, { className: "size-4" }) }), _jsx(BarcodeScanner, { open: scanOpen, onClose: () => setScanOpen(false), onDetected: (code) => onChange(code),
|
|
267
|
+
return (_jsxs("div", { className: "flex w-full min-w-0 items-center gap-1.5", children: [_jsx("div", { className: "min-w-0 flex-1", children: input }), _jsx(Button, { type: "button", variant: "outline", size: "icon", className: "size-9 shrink-0", onClick: () => setScanOpen(true), title: "Escanear con la c\u00E1mara", "aria-label": "Escanear c\u00F3digo de barras con la c\u00E1mara", children: _jsx(ScanLine, { className: "size-4" }) }), _jsx(BarcodeScanner, { open: scanOpen, onClose: () => setScanOpen(false), onDetected: (code) => onChange(code),
|
|
268
|
+
// Un campo = un código: cerrar al detectar. El POS usa continuous
|
|
269
|
+
// (default) porque suma varios productos seguidos.
|
|
270
|
+
continuous: false, position: "fixed", title: `Escanear ${field.label ?? ''}`.trim() })] }));
|
|
268
271
|
}
|
|
269
272
|
function RefSelect({ field, value, onChange }) {
|
|
270
273
|
const { options, loading } = useOptionsResolver({
|
package/package.json
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
import { describe, it, expect, afterEach } from 'vitest'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
isCameraScanSupported,
|
|
5
|
+
RETAIL_BARCODE_FORMATS,
|
|
6
|
+
trackSupportsTorch,
|
|
7
|
+
} from '../barcode-scanner'
|
|
4
8
|
|
|
5
9
|
describe('barcode-scanner primitive', () => {
|
|
6
10
|
afterEach(() => {
|
|
@@ -25,4 +29,18 @@ describe('barcode-scanner primitive', () => {
|
|
|
25
29
|
const hasGUM = !!navigator.mediaDevices?.getUserMedia
|
|
26
30
|
expect(isCameraScanSupported()).toBe(hasGUM)
|
|
27
31
|
})
|
|
32
|
+
|
|
33
|
+
it('trackSupportsTorch lee capabilities.torch del track', () => {
|
|
34
|
+
expect(trackSupportsTorch(null)).toBe(false)
|
|
35
|
+
expect(
|
|
36
|
+
trackSupportsTorch({
|
|
37
|
+
getCapabilities: () => ({}),
|
|
38
|
+
} as MediaStreamTrack),
|
|
39
|
+
).toBe(false)
|
|
40
|
+
expect(
|
|
41
|
+
trackSupportsTorch({
|
|
42
|
+
getCapabilities: () => ({ torch: true }),
|
|
43
|
+
} as unknown as MediaStreamTrack),
|
|
44
|
+
).toBe(true)
|
|
45
|
+
})
|
|
28
46
|
})
|
package/src/barcode-scanner.tsx
CHANGED
|
@@ -1,7 +1,38 @@
|
|
|
1
1
|
import * as React from 'react'
|
|
2
|
-
import { Camera, ScanLine, X } from 'lucide-react'
|
|
2
|
+
import { Camera, Flashlight, FlashlightOff, ScanLine, X } from 'lucide-react'
|
|
3
3
|
import { Button } from '@asteby/metacore-ui'
|
|
4
4
|
|
|
5
|
+
/** `torch` aún no está en todas las libs de TS — extendemos el contrato mínimo. */
|
|
6
|
+
type TorchCapabilities = MediaTrackCapabilities & { torch?: boolean }
|
|
7
|
+
type TorchConstraintSet = MediaTrackConstraintSet & { torch?: boolean }
|
|
8
|
+
|
|
9
|
+
/** `true` si el track de video expone linterna (ImageCapture / torch constraint). */
|
|
10
|
+
export function trackSupportsTorch(track: MediaStreamTrack | null | undefined): boolean {
|
|
11
|
+
if (!track || typeof track.getCapabilities !== 'function') return false
|
|
12
|
+
try {
|
|
13
|
+
return !!(track.getCapabilities() as TorchCapabilities).torch
|
|
14
|
+
} catch {
|
|
15
|
+
return false
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function setTrackTorch(track: MediaStreamTrack, on: boolean): Promise<boolean> {
|
|
20
|
+
try {
|
|
21
|
+
await track.applyConstraints({
|
|
22
|
+
advanced: [{ torch: on } as TorchConstraintSet],
|
|
23
|
+
})
|
|
24
|
+
return true
|
|
25
|
+
} catch {
|
|
26
|
+
// Algunos WebViews fallan con advanced[] — reintento plano.
|
|
27
|
+
try {
|
|
28
|
+
await track.applyConstraints({ torch: on } as MediaTrackConstraints)
|
|
29
|
+
return true
|
|
30
|
+
} catch {
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
5
36
|
/**
|
|
6
37
|
* BarcodeScanner — primitivo REUSABLE de escaneo por cámara para todo el
|
|
7
38
|
* ecosistema (POS, formularios declarativos, cualquier addon). Sin dependencias
|
|
@@ -141,19 +172,37 @@ export function BarcodeScanner({
|
|
|
141
172
|
const lastHitRef = React.useRef<{ code: string; at: number }>({ code: '', at: 0 })
|
|
142
173
|
const [error, setError] = React.useState<string | null>(null)
|
|
143
174
|
const [ready, setReady] = React.useState(false)
|
|
175
|
+
const [torchAvailable, setTorchAvailable] = React.useState(false)
|
|
176
|
+
const [torchOn, setTorchOn] = React.useState(false)
|
|
144
177
|
const beep = useScanBeep()
|
|
145
178
|
|
|
146
179
|
const stop = React.useCallback(() => {
|
|
147
180
|
if (rafRef.current != null) cancelAnimationFrame(rafRef.current)
|
|
148
181
|
rafRef.current = null
|
|
182
|
+
const track = streamRef.current?.getVideoTracks()[0]
|
|
183
|
+
if (track && trackSupportsTorch(track)) {
|
|
184
|
+
void setTrackTorch(track, false)
|
|
185
|
+
}
|
|
149
186
|
streamRef.current?.getTracks().forEach((t) => t.stop())
|
|
150
187
|
streamRef.current = null
|
|
151
188
|
setReady(false)
|
|
189
|
+
setTorchAvailable(false)
|
|
190
|
+
setTorchOn(false)
|
|
152
191
|
}, [])
|
|
153
192
|
|
|
193
|
+
const toggleTorch = React.useCallback(async () => {
|
|
194
|
+
const track = streamRef.current?.getVideoTracks()[0]
|
|
195
|
+
if (!track || !trackSupportsTorch(track)) return
|
|
196
|
+
const next = !torchOn
|
|
197
|
+
const ok = await setTrackTorch(track, next)
|
|
198
|
+
if (ok) setTorchOn(next)
|
|
199
|
+
}, [torchOn])
|
|
200
|
+
|
|
154
201
|
React.useEffect(() => {
|
|
155
202
|
if (!open) return
|
|
156
203
|
setError(null)
|
|
204
|
+
setTorchAvailable(false)
|
|
205
|
+
setTorchOn(false)
|
|
157
206
|
|
|
158
207
|
const Ctor = getDetectorCtor()
|
|
159
208
|
if (!navigator.mediaDevices?.getUserMedia || !Ctor) {
|
|
@@ -205,6 +254,8 @@ export function BarcodeScanner({
|
|
|
205
254
|
return
|
|
206
255
|
}
|
|
207
256
|
streamRef.current = stream
|
|
257
|
+
const videoTrack = stream.getVideoTracks()[0]
|
|
258
|
+
setTorchAvailable(trackSupportsTorch(videoTrack))
|
|
208
259
|
const video = videoRef.current
|
|
209
260
|
if (video) {
|
|
210
261
|
video.srcObject = stream
|
|
@@ -239,15 +290,40 @@ export function BarcodeScanner({
|
|
|
239
290
|
<ScanLine className="size-5" />
|
|
240
291
|
{title}
|
|
241
292
|
</div>
|
|
242
|
-
<
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
293
|
+
<div className="flex items-center gap-1">
|
|
294
|
+
{torchAvailable && (
|
|
295
|
+
<Button
|
|
296
|
+
type="button"
|
|
297
|
+
variant="ghost"
|
|
298
|
+
size="icon"
|
|
299
|
+
className={
|
|
300
|
+
torchOn
|
|
301
|
+
? 'bg-amber-400/20 text-amber-300 hover:bg-amber-400/30 hover:text-amber-200'
|
|
302
|
+
: 'text-white hover:bg-white/10 hover:text-white'
|
|
303
|
+
}
|
|
304
|
+
onClick={() => void toggleTorch()}
|
|
305
|
+
aria-label={torchOn ? 'Apagar linterna' : 'Encender linterna'}
|
|
306
|
+
aria-pressed={torchOn}
|
|
307
|
+
title={torchOn ? 'Apagar linterna' : 'Encender linterna'}
|
|
308
|
+
>
|
|
309
|
+
{torchOn ? (
|
|
310
|
+
<Flashlight className="size-5" />
|
|
311
|
+
) : (
|
|
312
|
+
<FlashlightOff className="size-5" />
|
|
313
|
+
)}
|
|
314
|
+
</Button>
|
|
315
|
+
)}
|
|
316
|
+
<Button
|
|
317
|
+
type="button"
|
|
318
|
+
variant="ghost"
|
|
319
|
+
size="icon"
|
|
320
|
+
className="text-white hover:bg-white/10 hover:text-white"
|
|
321
|
+
onClick={onClose}
|
|
322
|
+
aria-label="Cerrar escáner"
|
|
323
|
+
>
|
|
324
|
+
<X className="size-5" />
|
|
325
|
+
</Button>
|
|
326
|
+
</div>
|
|
251
327
|
</div>
|
|
252
328
|
|
|
253
329
|
{/* Área de cámara */}
|
|
@@ -269,8 +345,12 @@ export function BarcodeScanner({
|
|
|
269
345
|
<span className="bg-primary absolute inset-x-0 top-1/2 h-0.5 -translate-y-1/2 animate-pulse" />
|
|
270
346
|
</div>
|
|
271
347
|
</div>
|
|
272
|
-
<p className="absolute bottom-8 left-0 right-0 text-center text-sm text-white/90">
|
|
273
|
-
{ready
|
|
348
|
+
<p className="absolute bottom-8 left-0 right-0 px-4 text-center text-sm text-white/90">
|
|
349
|
+
{!ready
|
|
350
|
+
? 'Abriendo cámara…'
|
|
351
|
+
: torchAvailable
|
|
352
|
+
? `${hint} · tocá la linterna si está oscuro`
|
|
353
|
+
: hint}
|
|
274
354
|
</p>
|
|
275
355
|
</>
|
|
276
356
|
)}
|
|
@@ -1886,7 +1886,9 @@ function ScannableRecordInput({
|
|
|
1886
1886
|
open={scanOpen}
|
|
1887
1887
|
onClose={() => setScanOpen(false)}
|
|
1888
1888
|
onDetected={(code) => onChange(code)}
|
|
1889
|
-
continuous
|
|
1889
|
+
// Un campo = un código: cerrar al detectar. El POS usa continuous
|
|
1890
|
+
// (default) porque suma varios productos seguidos.
|
|
1891
|
+
continuous={false}
|
|
1890
1892
|
position="fixed"
|
|
1891
1893
|
title={`Escanear ${field.label ?? ''}`.trim()}
|
|
1892
1894
|
/>
|
package/src/dynamic-form.tsx
CHANGED
|
@@ -480,7 +480,9 @@ function ScannableInput({
|
|
|
480
480
|
open={scanOpen}
|
|
481
481
|
onClose={() => setScanOpen(false)}
|
|
482
482
|
onDetected={(code) => onChange(code)}
|
|
483
|
-
continuous
|
|
483
|
+
// Un campo = un código: cerrar al detectar. El POS usa continuous
|
|
484
|
+
// (default) porque suma varios productos seguidos.
|
|
485
|
+
continuous={false}
|
|
484
486
|
position="fixed"
|
|
485
487
|
title={`Escanear ${field.label ?? ''}`.trim()}
|
|
486
488
|
/>
|