diff --git a/components/scanner/ScannerCamera.js b/components/scanner/ScannerCamera.js
index ced464c..cb980ec 100644
--- a/components/scanner/ScannerCamera.js
+++ b/components/scanner/ScannerCamera.js
@@ -9,27 +9,44 @@ import { useScannerFlash } from '../../lib/use-scanner-flash.js';
import {
cardGuideToContainerStyle,
computeObjectCoverLayout,
+ layoutGuideToVideoBounds,
videoBoundsToContainerStyle,
} from '../../lib/scanner-video-layout.js';
const TOAST_DURATION_MS = 2500;
-function CardGuideFrame({ layout }) {
+function CardGuideFrame({ layout, onTap, isManualMode, isIdentifying, tapDisabled }) {
if (!layout) return null;
const style = cardGuideToContainerStyle(layout);
+ const className = [
+ 'absolute scan-guide',
+ isManualMode ? 'scan-guide-tappable' : 'pointer-events-none',
+ isIdentifying ? 'scan-guide-active' : '',
+ ]
+ .filter(Boolean)
+ .join(' ');
- return (
-
+ const frame = (
+ <>
-
+ >
+ );
+
+ if (!isManualMode) {
+ return (
+
+ {frame}
+
+ );
+ }
+
+ return (
+
);
}
@@ -116,6 +158,41 @@ function DetectionFrame({ card, layout }) {
);
}
+function ShutterButton({ onClick, busy, label = 'Scan card' }) {
+ return (
+
+ );
+}
+
function ChromeIconButton({
onClick,
label,
@@ -160,6 +237,10 @@ function CameraViewport({
isStreaming,
displayLayout,
foundCards,
+ isManualMode,
+ isIdentifying,
+ onGuideTap,
+ manualScanDisabled,
}) {
return (
- {isStreaming && displayLayout && }
+ {isStreaming && displayLayout && (
+
+ )}
{isStreaming &&
displayLayout &&
@@ -240,8 +329,13 @@ export default function ScannerCamera({
streamRef,
facingMode,
switchFacingMode,
+ triggerManualScan,
} = camera;
+ const isIdentifying = Boolean(identification.isIdentifying);
+ const isManualMode = !autoDetectOn;
+ const manualScanDisabled = isIdentifying || isCheckoutOpen || Boolean(identification.disambiguation);
+
const sound = useScannerSound();
const flash = useScannerFlash(streamRef);
@@ -339,12 +433,35 @@ export default function ScannerCamera({
viewportSize.height
)
: null;
+
+ const handleManualScan = useCallback(() => {
+ if (manualScanDisabled || !triggerManualScan) return;
+
+ const guideBounds = displayLayout ? layoutGuideToVideoBounds(displayLayout) : null;
+ const result = triggerManualScan(guideBounds);
+
+ if (result?.ok) {
+ navigator.vibrate?.(30);
+ return;
+ }
+
+ if (result?.reason === 'no_card') {
+ clearTimeout(toastTimerRef.current);
+ setToast({ message: 'Hold the card in the frame', visible: true, type: 'info' });
+ toastTimerRef.current = setTimeout(() => {
+ setToast((current) => ({ ...current, visible: false }));
+ }, TOAST_DURATION_MS);
+ }
+ }, [displayLayout, manualScanDisabled, triggerManualScan]);
+
const showFlash = flash.flashSupported && facingMode === 'environment';
- const scanStatus = isCheckoutOpen
- ? 'Checkout open'
- : isStreaming
- ? 'Scanning…'
- : 'Starting camera…';
+ const scanStatus = (() => {
+ if (isCheckoutOpen) return 'Checkout open';
+ if (!isStreaming) return 'Starting camera…';
+ if (isIdentifying) return 'Identifying card…';
+ if (isManualMode) return 'Tap frame or scan button';
+ return 'Scanning…';
+ })();
const switchCameraLabel =
facingMode === 'environment' ? 'Switch to front camera' : 'Switch to rear camera';
@@ -371,6 +488,10 @@ export default function ScannerCamera({
isStreaming={isStreaming}
displayLayout={displayLayout}
foundCards={foundCards}
+ isManualMode={isManualMode}
+ isIdentifying={isIdentifying}
+ onGuideTap={handleManualScan}
+ manualScanDisabled={manualScanDisabled}
/>
) : (
@@ -380,6 +501,10 @@ export default function ScannerCamera({
isStreaming={isStreaming}
displayLayout={displayLayout}
foundCards={foundCards}
+ isManualMode={isManualMode}
+ isIdentifying={isIdentifying}
+ onGuideTap={handleManualScan}
+ manualScanDisabled={manualScanDisabled}
/>
)}
@@ -584,18 +709,37 @@ export default function ScannerCamera({
)}
-
- {scanStatus}
-
+ {isManualMode && !isWorkstation ? (
+
+
+
+ {scanStatus}
+
+
+ ) : (
+
+ {scanStatus}
+
+ )}
-
+
+
+
)}
diff --git a/lib/scanner-video-layout.js b/lib/scanner-video-layout.js
index 308d3c9..d56d818 100644
--- a/lib/scanner-video-layout.js
+++ b/lib/scanner-video-layout.js
@@ -96,3 +96,29 @@ export function cardGuideToContainerStyle(layout) {
height: `${(guide.height / layout.containerHeight) * 100}%`,
};
}
+
+/** Map a container-space rectangle to video pixel bounds (inverse of object-cover). */
+export function containerRectToVideoBounds(rect, layout) {
+ const { videoWidth, videoHeight, renderedWidth, renderedHeight, offsetX, offsetY } = layout;
+
+ const toVideoX = (containerX) => ((containerX - offsetX) / renderedWidth) * videoWidth;
+ const toVideoY = (containerY) => ((containerY - offsetY) / renderedHeight) * videoHeight;
+
+ const x1 = toVideoX(rect.left);
+ const y1 = toVideoY(rect.top);
+ const x2 = toVideoX(rect.left + rect.width);
+ const y2 = toVideoY(rect.top + rect.height);
+
+ const x = Math.max(0, Math.min(x1, x2));
+ const y = Math.max(0, Math.min(y1, y2));
+ const width = Math.min(videoWidth - x, Math.abs(x2 - x1));
+ const height = Math.min(videoHeight - y, Math.abs(y2 - y1));
+
+ return { x, y, width, height };
+}
+
+/** Centered card guide converted to video pixel bounds. */
+export function layoutGuideToVideoBounds(layout) {
+ const guide = computeCardGuideBounds(layout.containerWidth, layout.containerHeight);
+ return containerRectToVideoBounds(guide, layout);
+}
diff --git a/lib/use-camera-scanner.js b/lib/use-camera-scanner.js
index 24ae572..7abfcc8 100644
--- a/lib/use-camera-scanner.js
+++ b/lib/use-camera-scanner.js
@@ -3,6 +3,7 @@ import {
detectCardShapesFromFrame,
DETECTION_START_DELAY_MS,
mergeDetectedShapesIntoTrackedCards,
+ MIN_STABLE_COUNT_FOR_VERIFY,
selectCardsReadyForVerification,
SHAPE_DETECTION_INTERVAL_MS,
VERIFICATION_INTERVAL_MS,
@@ -26,6 +27,7 @@ export function useCameraScanner({
onError,
onVerifyCard,
verificationPausedRef,
+ autoDetectPausedRef,
facingMode = 'environment',
}) {
const [isStreaming, setIsStreaming] = useState(false);
@@ -174,6 +176,60 @@ export function useCameraScanner({
setTrackedCards(next);
}, []);
+ const resetScanTrackers = useCallback(() => {
+ trackedCardsRef.current = [];
+ setTrackedCards([]);
+ nextCardIdRef.current = 1;
+ }, []);
+
+ /**
+ * Manual scan: fresh detection pass, then verify the best candidate (or guide bounds).
+ * @returns {{ ok: boolean, reason?: string }}
+ */
+ const triggerManualScan = useCallback(
+ (guideVideoBounds = null) => {
+ if (!isStreamingRef.current) {
+ return { ok: false, reason: 'not_streaming' };
+ }
+
+ if (verificationPausedRef?.current) {
+ return { ok: false, reason: 'paused' };
+ }
+
+ resetScanTrackers();
+
+ const shapes = detectCardShapes();
+ updateTrackedCards(shapes);
+
+ let target = trackedCardsRef.current
+ .filter((card) => card.status === 'detecting')
+ .sort((a, b) => b.stableCount - a.stableCount)[0];
+
+ if (!target && guideVideoBounds?.width > 0 && guideVideoBounds?.height > 0) {
+ target = {
+ id: nextCardIdRef.current++,
+ bounds: guideVideoBounds,
+ corners: null,
+ status: 'detecting',
+ firstSeen: Date.now(),
+ lastSeen: Date.now(),
+ stableCount: MIN_STABLE_COUNT_FOR_VERIFY,
+ scanAttempts: 0,
+ };
+ trackedCardsRef.current = [target];
+ setTrackedCards([target]);
+ }
+
+ if (!target) {
+ return { ok: false, reason: 'no_card' };
+ }
+
+ onVerifyCard?.(target);
+ return { ok: true };
+ },
+ [onVerifyCard, resetScanTrackers, verificationPausedRef]
+ );
+
const startDetection = () => {
if (detectionIntervalRef.current || !isStreamingRef.current) return;
@@ -188,6 +244,7 @@ export function useCameraScanner({
trackingIntervalRef.current = setInterval(() => {
if (document.hidden) return;
if (verificationPausedRef?.current) return;
+ if (autoDetectPausedRef?.current) return;
const cardsToVerify = selectCardsReadyForVerification(trackedCardsRef.current);
cardsToVerify.slice(0, 1).forEach((card) => {
@@ -372,6 +429,8 @@ export function useCameraScanner({
startCamera,
stopCamera,
removeTrackedCard,
+ resetScanTrackers,
+ triggerManualScan,
streamRef,
facingMode: activeFacingMode,
switchFacingMode,
diff --git a/pages/scanner.js b/pages/scanner.js
index dda5490..093164c 100644
--- a/pages/scanner.js
+++ b/pages/scanner.js
@@ -34,13 +34,17 @@ export default function Scanner() {
const [inspectorCommitError, setInspectorCommitError] = useState(null);
const [focusedCardId, setFocusedCardId] = useState(null);
const [stripActiveTab, setStripActiveTab] = useState(TAB_RECENT);
- const [isAutoDetectPaused, setIsAutoDetectPaused] = useState(false);
+ const [isAutoDetectPaused, setIsAutoDetectPaused] = useState(() => {
+ if (typeof window === 'undefined') return false;
+ return window.matchMedia('(max-width: 767px)').matches;
+ });
const [pageToast, setPageToast] = useState({ message: '', visible: false, type: 'success' });
const [galleryBusy, setGalleryBusy] = useState(false);
const [batchBusy, setBatchBusy] = useState(false);
const [batchProgress, setBatchProgress] = useState(null);
const verificationPausedRef = useRef(false);
+ const autoDetectPausedRef = useRef(false);
const prevCardCountRef = useRef(0);
const scannedCardsRef = useRef([]);
const toastTimerRef = useRef(null);
@@ -58,6 +62,7 @@ export default function Scanner() {
onError: (msg) => console.error('Camera error:', msg),
onVerifyCard: (cardTracker) => onVerifyCardRef.current?.(cardTracker),
verificationPausedRef,
+ autoDetectPausedRef,
});
const identification = useScannerIdentification({
@@ -105,8 +110,9 @@ export default function Scanner() {
verificationPausedRef.current =
mobileCheckoutPauses ||
isListPickerOpen ||
- isAutoDetectPaused ||
Boolean(identification.disambiguation);
+
+ autoDetectPausedRef.current = isAutoDetectPaused;
}, [
isCheckoutOpen,
isListPickerOpen,
diff --git a/test/lib/scanner-video-layout.test.js b/test/lib/scanner-video-layout.test.js
index 8423d99..1885772 100644
--- a/test/lib/scanner-video-layout.test.js
+++ b/test/lib/scanner-video-layout.test.js
@@ -3,6 +3,7 @@ import {
cardGuideToContainerStyle,
computeCardGuideBounds,
computeObjectCoverLayout,
+ layoutGuideToVideoBounds,
videoBoundsToContainerStyle,
} from '../../lib/scanner-video-layout.js';
@@ -47,3 +48,14 @@ describe('card guide layout', () => {
expect(style.width).toMatch(/%$/);
});
});
+
+describe('layoutGuideToVideoBounds', () => {
+ it('maps the centered guide back to video pixel space', () => {
+ const layout = computeObjectCoverLayout(1280, 720, 390, 844);
+ const bounds = layoutGuideToVideoBounds(layout);
+ expect(bounds.width).toBeGreaterThan(0);
+ expect(bounds.height).toBeGreaterThan(0);
+ expect(bounds.x).toBeGreaterThanOrEqual(0);
+ expect(bounds.y).toBeGreaterThanOrEqual(0);
+ });
+});