@switchlabs/verify-ai-react-native 2.4.10 → 2.4.14
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/README.md +17 -0
- package/lib/components/VerifyAIScanner.d.ts +7 -1
- package/lib/components/VerifyAIScanner.js +204 -30
- package/lib/types/index.d.ts +6 -0
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +1 -1
- package/src/components/VerifyAIScanner.tsx +272 -42
- package/src/types/index.ts +6 -0
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -56,15 +56,32 @@ function ScannerScreen() {
|
|
|
56
56
|
const { verify } = useVerifyAI({ apiKey: 'vai_your_api_key' });
|
|
57
57
|
return (
|
|
58
58
|
<VerifyAIScanner
|
|
59
|
+
policy="scooter_parking"
|
|
59
60
|
onCapture={(base64) =>
|
|
60
61
|
verify({ image: base64, policy: 'scooter_parking' })
|
|
61
62
|
}
|
|
62
63
|
onResult={(result) => console.log(result.is_compliant ? 'PASS' : 'FAIL')}
|
|
64
|
+
onClose={(result) => console.log('closed', result?.id)}
|
|
65
|
+
showCloseButton
|
|
66
|
+
overlay={{
|
|
67
|
+
instructions: 'Center the scooter in the frame',
|
|
68
|
+
processingMessage: 'Checking parking compliance...',
|
|
69
|
+
failureMessage: 'Parking issue detected',
|
|
70
|
+
retryMessage: 'Try again. {remaining} attempts left.',
|
|
71
|
+
terminalResultDisplayMs: 3000,
|
|
72
|
+
terminalActionLabel: 'Continue',
|
|
73
|
+
}}
|
|
63
74
|
/>
|
|
64
75
|
);
|
|
65
76
|
}
|
|
66
77
|
```
|
|
67
78
|
|
|
79
|
+
Passing `policy` enables policy-aware default copy and guide overlays for
|
|
80
|
+
`scooter`, `bike`, and `forest` parking policies. Override copy through
|
|
81
|
+
`overlay.instructions`, `processingMessage`, `successMessage`, `failureMessage`,
|
|
82
|
+
`retryMessage`, `exhaustedMessage`, `terminalActionLabel`, and
|
|
83
|
+
`terminalResultDisplayMs`.
|
|
84
|
+
|
|
68
85
|
## Offline Mode
|
|
69
86
|
|
|
70
87
|
Requires `@react-native-async-storage/async-storage` to be installed.
|
|
@@ -5,16 +5,22 @@ import type { TelemetryReporter } from '../telemetry/TelemetryReporter';
|
|
|
5
5
|
export interface VerifyAIScannerProps {
|
|
6
6
|
/** Called with base64 image data when the user captures a photo. */
|
|
7
7
|
onCapture: (base64: string) => Promise<VerificationResult | null>;
|
|
8
|
+
/** Optional policy ID used for policy-aware default scanner copy. */
|
|
9
|
+
policy?: string;
|
|
8
10
|
/** Called when a terminal verification result is reached. */
|
|
9
11
|
onResult?: (result: VerificationResult) => void;
|
|
10
12
|
/** Called when an error occurs. */
|
|
11
13
|
onError?: (error: Error) => void;
|
|
14
|
+
/** Called when the user presses the persistent close button. */
|
|
15
|
+
onClose?: (result: VerificationResult | null) => void;
|
|
12
16
|
/** Overlay configuration for the camera view. */
|
|
13
17
|
overlay?: ScannerOverlayConfig;
|
|
14
18
|
/** Custom style for the container. */
|
|
15
19
|
style?: ViewStyle;
|
|
16
20
|
/** Whether to show the default capture button. Set false to use your own. */
|
|
17
21
|
showCaptureButton?: boolean;
|
|
22
|
+
/** Whether to show the persistent close button. */
|
|
23
|
+
showCloseButton?: boolean;
|
|
18
24
|
/** Ref to imperatively trigger capture from parent. */
|
|
19
25
|
captureRef?: React.MutableRefObject<(() => void) | null>;
|
|
20
26
|
/** Whether to enable the camera torch/flashlight. */
|
|
@@ -43,4 +49,4 @@ export interface VerifyAIScannerProps {
|
|
|
43
49
|
* />
|
|
44
50
|
* ```
|
|
45
51
|
*/
|
|
46
|
-
export declare function VerifyAIScanner({ onCapture, onResult, onError, overlay, style, showCaptureButton, captureRef, enableTorch, telemetry: telemetryProp, }: VerifyAIScannerProps): import("react/jsx-runtime").JSX.Element;
|
|
52
|
+
export declare function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose, overlay: overlayProp, style, showCaptureButton, showCloseButton, captureRef, enableTorch, telemetry: telemetryProp, }: VerifyAIScannerProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -1,14 +1,72 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { useRef, useState, useCallback, useEffect } from 'react';
|
|
2
|
+
import { useRef, useState, useCallback, useEffect, useMemo } from 'react';
|
|
3
3
|
import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, AppState, Platform, useWindowDimensions, } from 'react-native';
|
|
4
4
|
import { CameraView, useCameraPermissions, } from 'expo-camera';
|
|
5
|
+
import { VerifyAIRequestError } from '../client';
|
|
5
6
|
import { useTelemetry } from '../telemetry/TelemetryContext';
|
|
7
|
+
import { BikeOverlay } from './BikeOverlay';
|
|
8
|
+
import { ScooterOverlay } from './ScooterOverlay';
|
|
6
9
|
/** Quality used when expo-image-manipulator is not available (lower = smaller). */
|
|
7
10
|
const FALLBACK_QUALITY = 0.65;
|
|
8
11
|
/** Quality used when expo-image-manipulator IS available (resize handles size). */
|
|
9
12
|
const MANIPULATOR_QUALITY = 0.8;
|
|
10
13
|
/** Max dimension (px) on longest side when resize is available. */
|
|
11
14
|
const MAX_DIMENSION = 1600;
|
|
15
|
+
const CAMERA_CAPTURE_ERROR_CODE = 'ERR_CAMERA_IMAGE_CAPTURE';
|
|
16
|
+
const CAMERA_NOT_READY_ERROR_CODE = 'ERR_CAMERA_NOT_READY';
|
|
17
|
+
const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
|
|
18
|
+
const TRANSIENT_ERROR_DISPLAY_MS = 3000;
|
|
19
|
+
function getPolicyScannerDefaults(policy) {
|
|
20
|
+
const id = policy?.toLowerCase() ?? '';
|
|
21
|
+
const isForest = id.includes('forest') || id.includes('humanforest');
|
|
22
|
+
const isBike = isForest || id.includes('bike') || id.includes('ebike') || id.includes('e-bike');
|
|
23
|
+
const isScooter = id.includes('scooter');
|
|
24
|
+
if (!isBike && !isScooter)
|
|
25
|
+
return null;
|
|
26
|
+
const vehicle = isForest ? 'Forest bike' : isBike ? 'bike' : 'scooter';
|
|
27
|
+
return {
|
|
28
|
+
title: 'End Ride Photo',
|
|
29
|
+
instructions: `Step back and take a photo showing your entire ${vehicle} and its parking location`,
|
|
30
|
+
showGuideFrame: true,
|
|
31
|
+
guideFrameAspectRatio: 16 / 9,
|
|
32
|
+
guideOverlayContent: isScooter ? _jsx(ScooterOverlay, {}) : _jsx(BikeOverlay, {}),
|
|
33
|
+
guideCaption: `Please ensure the entire ${vehicle} with both wheels is in the image`,
|
|
34
|
+
processingMessage: 'Checking parking compliance...',
|
|
35
|
+
failureMessage: 'Parking issue detected',
|
|
36
|
+
retryMessage: `Please reposition your ${vehicle} or retake the photo. {remaining} attempts remaining.`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function applyPolicyDefaults(overlay, policy) {
|
|
40
|
+
const defaults = getPolicyScannerDefaults(policy);
|
|
41
|
+
if (!defaults)
|
|
42
|
+
return overlay;
|
|
43
|
+
return {
|
|
44
|
+
...overlay,
|
|
45
|
+
title: overlay?.title ?? defaults.title,
|
|
46
|
+
instructions: overlay?.instructions ?? defaults.instructions,
|
|
47
|
+
showGuideFrame: overlay?.showGuideFrame ?? defaults.showGuideFrame,
|
|
48
|
+
guideFrameAspectRatio: overlay?.guideFrameAspectRatio ?? defaults.guideFrameAspectRatio,
|
|
49
|
+
guideOverlayContent: overlay?.guideOverlayContent ?? defaults.guideOverlayContent,
|
|
50
|
+
guideCaption: overlay?.guideCaption ?? defaults.guideCaption,
|
|
51
|
+
processingMessage: overlay?.processingMessage ?? defaults.processingMessage,
|
|
52
|
+
failureMessage: overlay?.failureMessage ?? defaults.failureMessage,
|
|
53
|
+
retryMessage: overlay?.retryMessage ?? defaults.retryMessage,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function createScannerError(message, code, name) {
|
|
57
|
+
const error = new Error(message);
|
|
58
|
+
error.name = name;
|
|
59
|
+
error.code = code;
|
|
60
|
+
return error;
|
|
61
|
+
}
|
|
62
|
+
function normalizeScannerError(err) {
|
|
63
|
+
const error = (err instanceof Error ? err : new Error(String(err)));
|
|
64
|
+
if (!error.code && error.message === 'Failed to capture photo') {
|
|
65
|
+
error.name = 'CameraCaptureError';
|
|
66
|
+
error.code = CAMERA_CAPTURE_ERROR_CODE;
|
|
67
|
+
}
|
|
68
|
+
return error;
|
|
69
|
+
}
|
|
12
70
|
function getErrorDisplay(error, showTechnicalDetails) {
|
|
13
71
|
if (!error) {
|
|
14
72
|
return {
|
|
@@ -26,9 +84,18 @@ function getErrorDisplay(error, showTechnicalDetails) {
|
|
|
26
84
|
else if (code === 'network_error' || status === 0) {
|
|
27
85
|
message = 'Network request failed. Check your connection and try again.';
|
|
28
86
|
}
|
|
87
|
+
else if (code === CAMERA_CAPTURE_ERROR_CODE || code === 'ERR_IMAGE_CAPTURE_FAILED') {
|
|
88
|
+
message = 'The camera had trouble taking a photo. Please try again.';
|
|
89
|
+
}
|
|
90
|
+
else if (code === CAMERA_NOT_READY_ERROR_CODE) {
|
|
91
|
+
message = 'Camera is not ready yet. Please wait a moment and try again.';
|
|
92
|
+
}
|
|
29
93
|
else if (status === 401) {
|
|
30
94
|
message = 'Verification is not configured correctly.';
|
|
31
95
|
}
|
|
96
|
+
else if (status === 403) {
|
|
97
|
+
message = "This verification couldn't be processed. Please wait a moment and try again, or contact support if it continues.";
|
|
98
|
+
}
|
|
32
99
|
else if (status === 413) {
|
|
33
100
|
message = 'Image is too large. Please try again — the photo will be resized automatically.';
|
|
34
101
|
}
|
|
@@ -52,6 +119,14 @@ function getErrorDisplay(error, showTechnicalDetails) {
|
|
|
52
119
|
message,
|
|
53
120
|
};
|
|
54
121
|
}
|
|
122
|
+
function isTerminalRequestError(error) {
|
|
123
|
+
const status = error.status ?? error.body?.status;
|
|
124
|
+
return (status === 400 ||
|
|
125
|
+
status === 401 ||
|
|
126
|
+
status === 403 ||
|
|
127
|
+
status === 422 ||
|
|
128
|
+
(status !== undefined && status >= 500));
|
|
129
|
+
}
|
|
55
130
|
/**
|
|
56
131
|
* Camera scanner component for capturing verification photos.
|
|
57
132
|
* Uses expo-camera for the camera view and provides a simple capture UI.
|
|
@@ -73,9 +148,10 @@ function getErrorDisplay(error, showTechnicalDetails) {
|
|
|
73
148
|
* />
|
|
74
149
|
* ```
|
|
75
150
|
*/
|
|
76
|
-
export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style, showCaptureButton = true, captureRef, enableTorch, telemetry: telemetryProp, }) {
|
|
151
|
+
export function VerifyAIScanner({ onCapture, policy, onResult, onError, onClose, overlay: overlayProp, style, showCaptureButton = true, showCloseButton = false, captureRef, enableTorch, telemetry: telemetryProp, }) {
|
|
77
152
|
const contextTelemetry = useTelemetry();
|
|
78
153
|
const telemetry = telemetryProp ?? contextTelemetry;
|
|
154
|
+
const overlay = useMemo(() => applyPolicyDefaults(overlayProp, policy), [overlayProp, policy]);
|
|
79
155
|
const cameraRef = useRef(null);
|
|
80
156
|
const [status, setStatus] = useState('idle');
|
|
81
157
|
const [result, setResult] = useState(null);
|
|
@@ -86,7 +162,11 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
86
162
|
const [terminated, setTerminated] = useState(false);
|
|
87
163
|
const [cameraReady, setCameraReady] = useState(false);
|
|
88
164
|
const [cameraKey, setCameraKey] = useState(0);
|
|
165
|
+
const terminalResultRef = useRef(null);
|
|
166
|
+
const terminalResultTimerRef = useRef(null);
|
|
167
|
+
const terminalResultDeliveredRef = useRef(false);
|
|
89
168
|
const cameraReadyRef = useRef(false);
|
|
169
|
+
const cameraEverReadyRef = useRef(false);
|
|
90
170
|
const cameraInitFailedRef = useRef(false);
|
|
91
171
|
const permissionDeniedTrackedRef = useRef(false);
|
|
92
172
|
// Track dimensions for orientation detection and responsive layout
|
|
@@ -206,12 +286,53 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
206
286
|
pausePreview();
|
|
207
287
|
}, [pausePreview]);
|
|
208
288
|
useEffect(() => {
|
|
209
|
-
return
|
|
289
|
+
return () => {
|
|
290
|
+
pausePreview();
|
|
291
|
+
if (terminalResultTimerRef.current) {
|
|
292
|
+
clearTimeout(terminalResultTimerRef.current);
|
|
293
|
+
terminalResultTimerRef.current = null;
|
|
294
|
+
}
|
|
295
|
+
};
|
|
210
296
|
}, [pausePreview]);
|
|
297
|
+
const deliverTerminalResult = useCallback((nextResult) => {
|
|
298
|
+
if (terminalResultDeliveredRef.current)
|
|
299
|
+
return;
|
|
300
|
+
terminalResultDeliveredRef.current = true;
|
|
301
|
+
if (terminalResultTimerRef.current) {
|
|
302
|
+
clearTimeout(terminalResultTimerRef.current);
|
|
303
|
+
terminalResultTimerRef.current = null;
|
|
304
|
+
}
|
|
305
|
+
onResult?.(nextResult);
|
|
306
|
+
}, [onResult]);
|
|
307
|
+
const scheduleTerminalResult = useCallback((nextResult) => {
|
|
308
|
+
terminalResultRef.current = nextResult;
|
|
309
|
+
terminalResultDeliveredRef.current = false;
|
|
310
|
+
if (!onResult)
|
|
311
|
+
return;
|
|
312
|
+
if (terminalResultTimerRef.current) {
|
|
313
|
+
clearTimeout(terminalResultTimerRef.current);
|
|
314
|
+
}
|
|
315
|
+
terminalResultTimerRef.current = setTimeout(() => {
|
|
316
|
+
deliverTerminalResult(nextResult);
|
|
317
|
+
}, overlay?.terminalResultDisplayMs ?? DEFAULT_TERMINAL_RESULT_DISPLAY_MS);
|
|
318
|
+
}, [deliverTerminalResult, onResult, overlay?.terminalResultDisplayMs]);
|
|
319
|
+
const deliverVisibleTerminalResult = useCallback(() => {
|
|
320
|
+
const current = terminalResultRef.current ?? result;
|
|
321
|
+
if (current)
|
|
322
|
+
deliverTerminalResult(current);
|
|
323
|
+
}, [deliverTerminalResult, result]);
|
|
324
|
+
const handleClose = useCallback(() => {
|
|
325
|
+
if (terminalResultTimerRef.current) {
|
|
326
|
+
clearTimeout(terminalResultTimerRef.current);
|
|
327
|
+
terminalResultTimerRef.current = null;
|
|
328
|
+
}
|
|
329
|
+
onClose?.(terminalResultRef.current ?? result);
|
|
330
|
+
}, [onClose, result]);
|
|
211
331
|
// Camera init callbacks
|
|
212
332
|
const onCameraReady = useCallback(() => {
|
|
213
333
|
setCameraReady(true);
|
|
214
334
|
cameraReadyRef.current = true;
|
|
335
|
+
cameraEverReadyRef.current = true;
|
|
215
336
|
cameraInitFailedRef.current = false;
|
|
216
337
|
}, []);
|
|
217
338
|
const onMountError = useCallback((event) => {
|
|
@@ -237,14 +358,20 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
237
358
|
return;
|
|
238
359
|
const timer = setTimeout(() => {
|
|
239
360
|
if (!cameraReadyRef.current && !cameraInitFailedRef.current) {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
361
|
+
// Only track + remount on first-ever mount. A rotation/app-resume
|
|
362
|
+
// triggered remount can legitimately take >3s without indicating a
|
|
363
|
+
// real failure, and firing the alert each time is noisy without
|
|
364
|
+
// surfacing new information. If the camera truly stays broken the
|
|
365
|
+
// user will see capture failures, which is a more meaningful signal.
|
|
366
|
+
if (!cameraEverReadyRef.current) {
|
|
367
|
+
telemetry?.track('camera_preview_timeout', {
|
|
368
|
+
component: 'scanner',
|
|
369
|
+
error: 'Camera did not initialize within 3 seconds — remounting',
|
|
370
|
+
});
|
|
371
|
+
setCameraReady(false);
|
|
372
|
+
cameraReadyRef.current = false;
|
|
373
|
+
setCameraKey((k) => k + 1);
|
|
374
|
+
}
|
|
248
375
|
}
|
|
249
376
|
}, 3000);
|
|
250
377
|
return () => clearTimeout(timer);
|
|
@@ -263,7 +390,7 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
263
390
|
if (!cameraRef.current || status === 'capturing' || status === 'processing' || exhausted)
|
|
264
391
|
return;
|
|
265
392
|
if (!cameraReadyRef.current) {
|
|
266
|
-
const error =
|
|
393
|
+
const error = createScannerError('Camera is not ready yet. Please wait a moment and try again.', CAMERA_NOT_READY_ERROR_CODE, 'CameraNotReadyError');
|
|
267
394
|
setResult(null);
|
|
268
395
|
setLastError(error);
|
|
269
396
|
setStatus('error');
|
|
@@ -278,6 +405,12 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
278
405
|
setStatus('capturing');
|
|
279
406
|
setResult(null);
|
|
280
407
|
setLastError(null);
|
|
408
|
+
terminalResultRef.current = null;
|
|
409
|
+
terminalResultDeliveredRef.current = false;
|
|
410
|
+
if (terminalResultTimerRef.current) {
|
|
411
|
+
clearTimeout(terminalResultTimerRef.current);
|
|
412
|
+
terminalResultTimerRef.current = null;
|
|
413
|
+
}
|
|
281
414
|
try {
|
|
282
415
|
// --- Capture + best-effort resize ---
|
|
283
416
|
// Strategy: try to dynamically import expo-image-manipulator.
|
|
@@ -304,7 +437,7 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
304
437
|
exif: false,
|
|
305
438
|
});
|
|
306
439
|
if (!photo?.uri) {
|
|
307
|
-
throw
|
|
440
|
+
throw createScannerError('Failed to capture photo', CAMERA_CAPTURE_ERROR_CODE, 'CameraCaptureError');
|
|
308
441
|
}
|
|
309
442
|
origWidth = photo.width ?? 0;
|
|
310
443
|
origHeight = photo.height ?? 0;
|
|
@@ -340,7 +473,7 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
340
473
|
exif: false,
|
|
341
474
|
});
|
|
342
475
|
if (!photo?.base64) {
|
|
343
|
-
throw
|
|
476
|
+
throw createScannerError('Failed to capture photo', CAMERA_CAPTURE_ERROR_CODE, 'CameraCaptureError');
|
|
344
477
|
}
|
|
345
478
|
origWidth = photo.width ?? 0;
|
|
346
479
|
origHeight = photo.height ?? 0;
|
|
@@ -376,12 +509,12 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
376
509
|
const approvedResult = { ...verificationResult, is_compliant: true };
|
|
377
510
|
setResult(approvedResult);
|
|
378
511
|
setStatus('success');
|
|
379
|
-
|
|
512
|
+
scheduleTerminalResult(approvedResult);
|
|
380
513
|
}
|
|
381
514
|
else {
|
|
382
515
|
setResult(verificationResult);
|
|
383
516
|
setStatus('error');
|
|
384
|
-
|
|
517
|
+
scheduleTerminalResult(verificationResult);
|
|
385
518
|
}
|
|
386
519
|
return;
|
|
387
520
|
}
|
|
@@ -396,7 +529,7 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
396
529
|
releaseCamera();
|
|
397
530
|
setResult(verificationResult);
|
|
398
531
|
setStatus('success');
|
|
399
|
-
|
|
532
|
+
scheduleTerminalResult(verificationResult);
|
|
400
533
|
}
|
|
401
534
|
else {
|
|
402
535
|
// null result means queued for offline
|
|
@@ -404,20 +537,29 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
404
537
|
}
|
|
405
538
|
}
|
|
406
539
|
catch (err) {
|
|
407
|
-
const error = (err
|
|
540
|
+
const error = normalizeScannerError(err);
|
|
541
|
+
const terminalRequestError = isTerminalRequestError(error);
|
|
542
|
+
if (terminalRequestError) {
|
|
543
|
+
releaseCamera();
|
|
544
|
+
}
|
|
545
|
+
setResult(null);
|
|
408
546
|
setLastError(error);
|
|
409
547
|
setStatus('error');
|
|
410
548
|
onError?.(error);
|
|
411
|
-
//
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
549
|
+
// VerifyAIRequestError is already tracked by the client (auth_error,
|
|
550
|
+
// network_error, rate_limited, etc.). Skip here to avoid double-firing.
|
|
551
|
+
if (!(err instanceof VerifyAIRequestError)) {
|
|
552
|
+
const isCaptureFail = error.message?.includes('capture') || error.message?.includes('photo');
|
|
553
|
+
const isImageFail = error.message?.includes('manipulate') || error.message?.includes('image');
|
|
554
|
+
telemetry?.track(isCaptureFail ? 'capture_failure'
|
|
555
|
+
: isImageFail ? 'image_manipulation_failure'
|
|
556
|
+
: 'unknown_error', { component: 'scanner', error });
|
|
557
|
+
}
|
|
558
|
+
if (!terminalRequestError) {
|
|
559
|
+
setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
|
|
560
|
+
}
|
|
419
561
|
}
|
|
420
|
-
}, [status, exhausted, onCapture,
|
|
562
|
+
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, telemetry]);
|
|
421
563
|
// Expose capture to parent via ref
|
|
422
564
|
if (captureRef) {
|
|
423
565
|
captureRef.current = handleCapture;
|
|
@@ -461,9 +603,10 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
461
603
|
? (overlay?.exhaustedMessage || 'Submitted for review')
|
|
462
604
|
: result.is_compliant
|
|
463
605
|
? (overlay?.successMessage || 'Verified')
|
|
464
|
-
: (overlay?.failureMessage || 'Not Verified') })] }), _jsx(Text, { style: styles.feedbackText, children: result.feedback })] })), status === 'error' && (() => {
|
|
606
|
+
: (overlay?.failureMessage || 'Not Verified') })] }), _jsx(Text, { style: styles.feedbackText, children: result.feedback }), overlay?.showTerminalActionButton !== false && onResult && (_jsx(TouchableOpacity, { style: styles.cardButton, onPress: deliverVisibleTerminalResult, children: _jsx(Text, { style: styles.cardButtonText, children: overlay?.terminalActionLabel || 'Continue' }) }))] })), status === 'error' && (() => {
|
|
465
607
|
let errorTitle;
|
|
466
608
|
let errorMessage;
|
|
609
|
+
let showCloseAction = false;
|
|
467
610
|
if (exhausted) {
|
|
468
611
|
errorTitle = 'Attempts Exhausted';
|
|
469
612
|
errorMessage = overlay?.exhaustedMessage || 'Maximum attempts reached.';
|
|
@@ -485,8 +628,9 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
485
628
|
const display = getErrorDisplay(lastError, overlay?.showTechnicalErrorDetails);
|
|
486
629
|
errorTitle = display.title;
|
|
487
630
|
errorMessage = display.message;
|
|
631
|
+
showCloseAction = !!lastError && isTerminalRequestError(lastError) && !!onClose;
|
|
488
632
|
}
|
|
489
|
-
return (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [styles.resultIconCircle, styles.resultIconError, overlay?.theme?.failureColor ? { backgroundColor: overlay.theme.failureColor } : undefined], children: _jsx(Text, { style: styles.resultIcon, children: "!" }) }), _jsx(Text, { style: [styles.resultLabel, styles.resultLabelError, overlay?.theme?.failureColor ? { color: overlay.theme.failureColor } : undefined], children: errorTitle })] }), _jsx(Text, { style: styles.feedbackText, children: errorMessage })] }));
|
|
633
|
+
return (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [styles.resultIconCircle, styles.resultIconError, overlay?.theme?.failureColor ? { backgroundColor: overlay.theme.failureColor } : undefined], children: _jsx(Text, { style: styles.resultIcon, children: "!" }) }), _jsx(Text, { style: [styles.resultLabel, styles.resultLabelError, overlay?.theme?.failureColor ? { color: overlay.theme.failureColor } : undefined], children: errorTitle })] }), _jsx(Text, { style: styles.feedbackText, children: errorMessage }), showCloseAction && (_jsx(TouchableOpacity, { style: styles.cardButton, onPress: handleClose, children: _jsx(Text, { style: styles.cardButtonText, children: "Close" }) }))] }));
|
|
490
634
|
})(), !showBottomCard && (_jsxs(_Fragment, { children: [overlay?.instructions && status === 'idle' && (_jsx(Text, { style: [
|
|
491
635
|
styles.instructionsText,
|
|
492
636
|
{ transform: [{ rotate: `${overlayRotationDeg}deg` }] },
|
|
@@ -495,7 +639,7 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
495
639
|
overlay?.theme?.captureButtonColor ? { borderColor: overlay.theme.captureButtonColor } : undefined,
|
|
496
640
|
(!cameraReady || status === 'capturing' || status === 'processing') &&
|
|
497
641
|
styles.captureButtonDisabled,
|
|
498
|
-
], onPress: handleCapture, disabled: !cameraReady || status === 'capturing' || status === 'processing', activeOpacity: 0.7, children: _jsx(View, { style: [styles.captureButtonInner, overlay?.theme?.captureButtonColor ? { backgroundColor: overlay.theme.captureButtonColor } : undefined] }) }) }))] }))] })] }) }, cameraKey) }));
|
|
642
|
+
], onPress: handleCapture, disabled: !cameraReady || status === 'capturing' || status === 'processing', activeOpacity: 0.7, children: _jsx(View, { style: [styles.captureButtonInner, overlay?.theme?.captureButtonColor ? { backgroundColor: overlay.theme.captureButtonColor } : undefined] }) }) }))] }))] }), showCloseButton && onClose && (_jsx(TouchableOpacity, { accessibilityRole: "button", accessibilityLabel: "Close scanner", style: styles.closeButton, onPress: handleClose, children: _jsx(Text, { style: styles.closeButtonText, children: "X" }) }))] }) }, cameraKey) }));
|
|
499
643
|
}
|
|
500
644
|
const CORNER_SIZE = 30;
|
|
501
645
|
const CORNER_THICKNESS = 3;
|
|
@@ -703,6 +847,36 @@ const styles = StyleSheet.create({
|
|
|
703
847
|
textAlign: 'center',
|
|
704
848
|
lineHeight: 22,
|
|
705
849
|
},
|
|
850
|
+
cardButton: {
|
|
851
|
+
alignSelf: 'stretch',
|
|
852
|
+
backgroundColor: '#111827',
|
|
853
|
+
borderRadius: 8,
|
|
854
|
+
paddingVertical: 12,
|
|
855
|
+
paddingHorizontal: 16,
|
|
856
|
+
alignItems: 'center',
|
|
857
|
+
marginTop: 8,
|
|
858
|
+
},
|
|
859
|
+
cardButtonText: {
|
|
860
|
+
color: '#fff',
|
|
861
|
+
fontSize: 16,
|
|
862
|
+
fontWeight: '700',
|
|
863
|
+
},
|
|
864
|
+
closeButton: {
|
|
865
|
+
position: 'absolute',
|
|
866
|
+
top: 52,
|
|
867
|
+
right: 16,
|
|
868
|
+
width: 44,
|
|
869
|
+
height: 44,
|
|
870
|
+
borderRadius: 22,
|
|
871
|
+
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
|
872
|
+
justifyContent: 'center',
|
|
873
|
+
alignItems: 'center',
|
|
874
|
+
},
|
|
875
|
+
closeButtonText: {
|
|
876
|
+
color: '#fff',
|
|
877
|
+
fontSize: 18,
|
|
878
|
+
fontWeight: '700',
|
|
879
|
+
},
|
|
706
880
|
// Permission screen
|
|
707
881
|
permissionContainer: {
|
|
708
882
|
justifyContent: 'center',
|
package/lib/types/index.d.ts
CHANGED
|
@@ -122,6 +122,12 @@ export interface ScannerOverlayConfig {
|
|
|
122
122
|
maxAttempts?: number;
|
|
123
123
|
autoApproveOnExhaust?: boolean;
|
|
124
124
|
showTechnicalErrorDetails?: boolean;
|
|
125
|
+
/** Delay before terminal results are reported through onResult. Default: 3000. */
|
|
126
|
+
terminalResultDisplayMs?: number;
|
|
127
|
+
/** Label for the terminal result action button. Default: "Continue". */
|
|
128
|
+
terminalActionLabel?: string;
|
|
129
|
+
/** Whether to show an action button on terminal result cards. Default: true. */
|
|
130
|
+
showTerminalActionButton?: boolean;
|
|
125
131
|
/** Custom theme for scanner colors. */
|
|
126
132
|
theme?: ScannerTheme;
|
|
127
133
|
}
|
package/lib/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const SDK_VERSION = "2.4.
|
|
1
|
+
export declare const SDK_VERSION = "2.4.14";
|
package/lib/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '2.4.
|
|
1
|
+
export const SDK_VERSION = '2.4.14';
|
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useRef, useState, useCallback, useEffect } from 'react';
|
|
1
|
+
import React, { useRef, useState, useCallback, useEffect, useMemo } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
View,
|
|
4
4
|
Text,
|
|
@@ -19,8 +19,11 @@ import type {
|
|
|
19
19
|
ScannerStatus,
|
|
20
20
|
ScannerOverlayConfig,
|
|
21
21
|
} from '../types';
|
|
22
|
+
import { VerifyAIRequestError } from '../client';
|
|
22
23
|
import { useTelemetry } from '../telemetry/TelemetryContext';
|
|
23
24
|
import type { TelemetryReporter } from '../telemetry/TelemetryReporter';
|
|
25
|
+
import { BikeOverlay } from './BikeOverlay';
|
|
26
|
+
import { ScooterOverlay } from './ScooterOverlay';
|
|
24
27
|
|
|
25
28
|
/** Quality used when expo-image-manipulator is not available (lower = smaller). */
|
|
26
29
|
const FALLBACK_QUALITY = 0.65;
|
|
@@ -28,20 +31,30 @@ const FALLBACK_QUALITY = 0.65;
|
|
|
28
31
|
const MANIPULATOR_QUALITY = 0.8;
|
|
29
32
|
/** Max dimension (px) on longest side when resize is available. */
|
|
30
33
|
const MAX_DIMENSION = 1600;
|
|
34
|
+
const CAMERA_CAPTURE_ERROR_CODE = 'ERR_CAMERA_IMAGE_CAPTURE';
|
|
35
|
+
const CAMERA_NOT_READY_ERROR_CODE = 'ERR_CAMERA_NOT_READY';
|
|
36
|
+
const DEFAULT_TERMINAL_RESULT_DISPLAY_MS = 3000;
|
|
37
|
+
const TRANSIENT_ERROR_DISPLAY_MS = 3000;
|
|
31
38
|
|
|
32
39
|
export interface VerifyAIScannerProps {
|
|
33
40
|
/** Called with base64 image data when the user captures a photo. */
|
|
34
41
|
onCapture: (base64: string) => Promise<VerificationResult | null>;
|
|
42
|
+
/** Optional policy ID used for policy-aware default scanner copy. */
|
|
43
|
+
policy?: string;
|
|
35
44
|
/** Called when a terminal verification result is reached. */
|
|
36
45
|
onResult?: (result: VerificationResult) => void;
|
|
37
46
|
/** Called when an error occurs. */
|
|
38
47
|
onError?: (error: Error) => void;
|
|
48
|
+
/** Called when the user presses the persistent close button. */
|
|
49
|
+
onClose?: (result: VerificationResult | null) => void;
|
|
39
50
|
/** Overlay configuration for the camera view. */
|
|
40
51
|
overlay?: ScannerOverlayConfig;
|
|
41
52
|
/** Custom style for the container. */
|
|
42
53
|
style?: ViewStyle;
|
|
43
54
|
/** Whether to show the default capture button. Set false to use your own. */
|
|
44
55
|
showCaptureButton?: boolean;
|
|
56
|
+
/** Whether to show the persistent close button. */
|
|
57
|
+
showCloseButton?: boolean;
|
|
45
58
|
/** Ref to imperatively trigger capture from parent. */
|
|
46
59
|
captureRef?: React.MutableRefObject<(() => void) | null>;
|
|
47
60
|
/** Whether to enable the camera torch/flashlight. */
|
|
@@ -61,6 +74,66 @@ type ErrorWithDetails = Error & {
|
|
|
61
74
|
};
|
|
62
75
|
};
|
|
63
76
|
|
|
77
|
+
function getPolicyScannerDefaults(policy?: string): ScannerOverlayConfig | null {
|
|
78
|
+
const id = policy?.toLowerCase() ?? '';
|
|
79
|
+
const isForest = id.includes('forest') || id.includes('humanforest');
|
|
80
|
+
const isBike =
|
|
81
|
+
isForest || id.includes('bike') || id.includes('ebike') || id.includes('e-bike');
|
|
82
|
+
const isScooter = id.includes('scooter');
|
|
83
|
+
|
|
84
|
+
if (!isBike && !isScooter) return null;
|
|
85
|
+
|
|
86
|
+
const vehicle = isForest ? 'Forest bike' : isBike ? 'bike' : 'scooter';
|
|
87
|
+
return {
|
|
88
|
+
title: 'End Ride Photo',
|
|
89
|
+
instructions: `Step back and take a photo showing your entire ${vehicle} and its parking location`,
|
|
90
|
+
showGuideFrame: true,
|
|
91
|
+
guideFrameAspectRatio: 16 / 9,
|
|
92
|
+
guideOverlayContent: isScooter ? <ScooterOverlay /> : <BikeOverlay />,
|
|
93
|
+
guideCaption: `Please ensure the entire ${vehicle} with both wheels is in the image`,
|
|
94
|
+
processingMessage: 'Checking parking compliance...',
|
|
95
|
+
failureMessage: 'Parking issue detected',
|
|
96
|
+
retryMessage: `Please reposition your ${vehicle} or retake the photo. {remaining} attempts remaining.`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function applyPolicyDefaults(
|
|
101
|
+
overlay: ScannerOverlayConfig | undefined,
|
|
102
|
+
policy: string | undefined,
|
|
103
|
+
): ScannerOverlayConfig | undefined {
|
|
104
|
+
const defaults = getPolicyScannerDefaults(policy);
|
|
105
|
+
if (!defaults) return overlay;
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
...overlay,
|
|
109
|
+
title: overlay?.title ?? defaults.title,
|
|
110
|
+
instructions: overlay?.instructions ?? defaults.instructions,
|
|
111
|
+
showGuideFrame: overlay?.showGuideFrame ?? defaults.showGuideFrame,
|
|
112
|
+
guideFrameAspectRatio: overlay?.guideFrameAspectRatio ?? defaults.guideFrameAspectRatio,
|
|
113
|
+
guideOverlayContent: overlay?.guideOverlayContent ?? defaults.guideOverlayContent,
|
|
114
|
+
guideCaption: overlay?.guideCaption ?? defaults.guideCaption,
|
|
115
|
+
processingMessage: overlay?.processingMessage ?? defaults.processingMessage,
|
|
116
|
+
failureMessage: overlay?.failureMessage ?? defaults.failureMessage,
|
|
117
|
+
retryMessage: overlay?.retryMessage ?? defaults.retryMessage,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function createScannerError(message: string, code: string, name: string): ErrorWithDetails {
|
|
122
|
+
const error = new Error(message) as ErrorWithDetails;
|
|
123
|
+
error.name = name;
|
|
124
|
+
error.code = code;
|
|
125
|
+
return error;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function normalizeScannerError(err: unknown): ErrorWithDetails {
|
|
129
|
+
const error = (err instanceof Error ? err : new Error(String(err))) as ErrorWithDetails;
|
|
130
|
+
if (!error.code && error.message === 'Failed to capture photo') {
|
|
131
|
+
error.name = 'CameraCaptureError';
|
|
132
|
+
error.code = CAMERA_CAPTURE_ERROR_CODE;
|
|
133
|
+
}
|
|
134
|
+
return error;
|
|
135
|
+
}
|
|
136
|
+
|
|
64
137
|
function getErrorDisplay(error: ErrorWithDetails | null, showTechnicalDetails?: boolean): { title: string; message: string } {
|
|
65
138
|
if (!error) {
|
|
66
139
|
return {
|
|
@@ -79,8 +152,14 @@ function getErrorDisplay(error: ErrorWithDetails | null, showTechnicalDetails?:
|
|
|
79
152
|
message = 'Verification timed out. Please try again.';
|
|
80
153
|
} else if (code === 'network_error' || status === 0) {
|
|
81
154
|
message = 'Network request failed. Check your connection and try again.';
|
|
155
|
+
} else if (code === CAMERA_CAPTURE_ERROR_CODE || code === 'ERR_IMAGE_CAPTURE_FAILED') {
|
|
156
|
+
message = 'The camera had trouble taking a photo. Please try again.';
|
|
157
|
+
} else if (code === CAMERA_NOT_READY_ERROR_CODE) {
|
|
158
|
+
message = 'Camera is not ready yet. Please wait a moment and try again.';
|
|
82
159
|
} else if (status === 401) {
|
|
83
160
|
message = 'Verification is not configured correctly.';
|
|
161
|
+
} else if (status === 403) {
|
|
162
|
+
message = "This verification couldn't be processed. Please wait a moment and try again, or contact support if it continues.";
|
|
84
163
|
} else if (status === 413) {
|
|
85
164
|
message = 'Image is too large. Please try again — the photo will be resized automatically.';
|
|
86
165
|
} else if (status === 429) {
|
|
@@ -106,6 +185,17 @@ function getErrorDisplay(error: ErrorWithDetails | null, showTechnicalDetails?:
|
|
|
106
185
|
};
|
|
107
186
|
}
|
|
108
187
|
|
|
188
|
+
function isTerminalRequestError(error: ErrorWithDetails): boolean {
|
|
189
|
+
const status = error.status ?? error.body?.status;
|
|
190
|
+
return (
|
|
191
|
+
status === 400 ||
|
|
192
|
+
status === 401 ||
|
|
193
|
+
status === 403 ||
|
|
194
|
+
status === 422 ||
|
|
195
|
+
(status !== undefined && status >= 500)
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
109
199
|
/**
|
|
110
200
|
* Camera scanner component for capturing verification photos.
|
|
111
201
|
* Uses expo-camera for the camera view and provides a simple capture UI.
|
|
@@ -129,17 +219,24 @@ function getErrorDisplay(error: ErrorWithDetails | null, showTechnicalDetails?:
|
|
|
129
219
|
*/
|
|
130
220
|
export function VerifyAIScanner({
|
|
131
221
|
onCapture,
|
|
222
|
+
policy,
|
|
132
223
|
onResult,
|
|
133
224
|
onError,
|
|
134
|
-
|
|
225
|
+
onClose,
|
|
226
|
+
overlay: overlayProp,
|
|
135
227
|
style,
|
|
136
228
|
showCaptureButton = true,
|
|
229
|
+
showCloseButton = false,
|
|
137
230
|
captureRef,
|
|
138
231
|
enableTorch,
|
|
139
232
|
telemetry: telemetryProp,
|
|
140
233
|
}: VerifyAIScannerProps) {
|
|
141
234
|
const contextTelemetry = useTelemetry();
|
|
142
235
|
const telemetry = telemetryProp ?? contextTelemetry;
|
|
236
|
+
const overlay = useMemo(
|
|
237
|
+
() => applyPolicyDefaults(overlayProp, policy),
|
|
238
|
+
[overlayProp, policy],
|
|
239
|
+
);
|
|
143
240
|
|
|
144
241
|
const cameraRef = useRef<CameraView>(null);
|
|
145
242
|
const [status, setStatus] = useState<ScannerStatus>('idle');
|
|
@@ -151,7 +248,11 @@ export function VerifyAIScanner({
|
|
|
151
248
|
const [terminated, setTerminated] = useState(false);
|
|
152
249
|
const [cameraReady, setCameraReady] = useState(false);
|
|
153
250
|
const [cameraKey, setCameraKey] = useState(0);
|
|
251
|
+
const terminalResultRef = useRef<VerificationResult | null>(null);
|
|
252
|
+
const terminalResultTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
253
|
+
const terminalResultDeliveredRef = useRef(false);
|
|
154
254
|
const cameraReadyRef = useRef(false);
|
|
255
|
+
const cameraEverReadyRef = useRef(false);
|
|
155
256
|
const cameraInitFailedRef = useRef(false);
|
|
156
257
|
const permissionDeniedTrackedRef = useRef(false);
|
|
157
258
|
|
|
@@ -289,13 +390,57 @@ export function VerifyAIScanner({
|
|
|
289
390
|
}, [pausePreview]);
|
|
290
391
|
|
|
291
392
|
useEffect(() => {
|
|
292
|
-
return
|
|
393
|
+
return () => {
|
|
394
|
+
pausePreview();
|
|
395
|
+
if (terminalResultTimerRef.current) {
|
|
396
|
+
clearTimeout(terminalResultTimerRef.current);
|
|
397
|
+
terminalResultTimerRef.current = null;
|
|
398
|
+
}
|
|
399
|
+
};
|
|
293
400
|
}, [pausePreview]);
|
|
294
401
|
|
|
402
|
+
const deliverTerminalResult = useCallback((nextResult: VerificationResult) => {
|
|
403
|
+
if (terminalResultDeliveredRef.current) return;
|
|
404
|
+
terminalResultDeliveredRef.current = true;
|
|
405
|
+
if (terminalResultTimerRef.current) {
|
|
406
|
+
clearTimeout(terminalResultTimerRef.current);
|
|
407
|
+
terminalResultTimerRef.current = null;
|
|
408
|
+
}
|
|
409
|
+
onResult?.(nextResult);
|
|
410
|
+
}, [onResult]);
|
|
411
|
+
|
|
412
|
+
const scheduleTerminalResult = useCallback((nextResult: VerificationResult) => {
|
|
413
|
+
terminalResultRef.current = nextResult;
|
|
414
|
+
terminalResultDeliveredRef.current = false;
|
|
415
|
+
if (!onResult) return;
|
|
416
|
+
|
|
417
|
+
if (terminalResultTimerRef.current) {
|
|
418
|
+
clearTimeout(terminalResultTimerRef.current);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
terminalResultTimerRef.current = setTimeout(() => {
|
|
422
|
+
deliverTerminalResult(nextResult);
|
|
423
|
+
}, overlay?.terminalResultDisplayMs ?? DEFAULT_TERMINAL_RESULT_DISPLAY_MS);
|
|
424
|
+
}, [deliverTerminalResult, onResult, overlay?.terminalResultDisplayMs]);
|
|
425
|
+
|
|
426
|
+
const deliverVisibleTerminalResult = useCallback(() => {
|
|
427
|
+
const current = terminalResultRef.current ?? result;
|
|
428
|
+
if (current) deliverTerminalResult(current);
|
|
429
|
+
}, [deliverTerminalResult, result]);
|
|
430
|
+
|
|
431
|
+
const handleClose = useCallback(() => {
|
|
432
|
+
if (terminalResultTimerRef.current) {
|
|
433
|
+
clearTimeout(terminalResultTimerRef.current);
|
|
434
|
+
terminalResultTimerRef.current = null;
|
|
435
|
+
}
|
|
436
|
+
onClose?.(terminalResultRef.current ?? result);
|
|
437
|
+
}, [onClose, result]);
|
|
438
|
+
|
|
295
439
|
// Camera init callbacks
|
|
296
440
|
const onCameraReady = useCallback(() => {
|
|
297
441
|
setCameraReady(true);
|
|
298
442
|
cameraReadyRef.current = true;
|
|
443
|
+
cameraEverReadyRef.current = true;
|
|
299
444
|
cameraInitFailedRef.current = false;
|
|
300
445
|
}, []);
|
|
301
446
|
|
|
@@ -323,14 +468,20 @@ export function VerifyAIScanner({
|
|
|
323
468
|
|
|
324
469
|
const timer = setTimeout(() => {
|
|
325
470
|
if (!cameraReadyRef.current && !cameraInitFailedRef.current) {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
//
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
471
|
+
// Only track + remount on first-ever mount. A rotation/app-resume
|
|
472
|
+
// triggered remount can legitimately take >3s without indicating a
|
|
473
|
+
// real failure, and firing the alert each time is noisy without
|
|
474
|
+
// surfacing new information. If the camera truly stays broken the
|
|
475
|
+
// user will see capture failures, which is a more meaningful signal.
|
|
476
|
+
if (!cameraEverReadyRef.current) {
|
|
477
|
+
telemetry?.track('camera_preview_timeout', {
|
|
478
|
+
component: 'scanner',
|
|
479
|
+
error: 'Camera did not initialize within 3 seconds — remounting',
|
|
480
|
+
});
|
|
481
|
+
setCameraReady(false);
|
|
482
|
+
cameraReadyRef.current = false;
|
|
483
|
+
setCameraKey((k) => k + 1);
|
|
484
|
+
}
|
|
334
485
|
}
|
|
335
486
|
}, 3000);
|
|
336
487
|
|
|
@@ -353,7 +504,11 @@ export function VerifyAIScanner({
|
|
|
353
504
|
const handleCapture = useCallback(async () => {
|
|
354
505
|
if (!cameraRef.current || status === 'capturing' || status === 'processing' || exhausted) return;
|
|
355
506
|
if (!cameraReadyRef.current) {
|
|
356
|
-
const error =
|
|
507
|
+
const error = createScannerError(
|
|
508
|
+
'Camera is not ready yet. Please wait a moment and try again.',
|
|
509
|
+
CAMERA_NOT_READY_ERROR_CODE,
|
|
510
|
+
'CameraNotReadyError',
|
|
511
|
+
);
|
|
357
512
|
setResult(null);
|
|
358
513
|
setLastError(error);
|
|
359
514
|
setStatus('error');
|
|
@@ -369,6 +524,12 @@ export function VerifyAIScanner({
|
|
|
369
524
|
setStatus('capturing');
|
|
370
525
|
setResult(null);
|
|
371
526
|
setLastError(null);
|
|
527
|
+
terminalResultRef.current = null;
|
|
528
|
+
terminalResultDeliveredRef.current = false;
|
|
529
|
+
if (terminalResultTimerRef.current) {
|
|
530
|
+
clearTimeout(terminalResultTimerRef.current);
|
|
531
|
+
terminalResultTimerRef.current = null;
|
|
532
|
+
}
|
|
372
533
|
|
|
373
534
|
try {
|
|
374
535
|
// --- Capture + best-effort resize ---
|
|
@@ -398,7 +559,11 @@ export function VerifyAIScanner({
|
|
|
398
559
|
});
|
|
399
560
|
|
|
400
561
|
if (!photo?.uri) {
|
|
401
|
-
throw
|
|
562
|
+
throw createScannerError(
|
|
563
|
+
'Failed to capture photo',
|
|
564
|
+
CAMERA_CAPTURE_ERROR_CODE,
|
|
565
|
+
'CameraCaptureError',
|
|
566
|
+
);
|
|
402
567
|
}
|
|
403
568
|
|
|
404
569
|
origWidth = photo.width ?? 0;
|
|
@@ -442,7 +607,11 @@ export function VerifyAIScanner({
|
|
|
442
607
|
});
|
|
443
608
|
|
|
444
609
|
if (!photo?.base64) {
|
|
445
|
-
throw
|
|
610
|
+
throw createScannerError(
|
|
611
|
+
'Failed to capture photo',
|
|
612
|
+
CAMERA_CAPTURE_ERROR_CODE,
|
|
613
|
+
'CameraCaptureError',
|
|
614
|
+
);
|
|
446
615
|
}
|
|
447
616
|
|
|
448
617
|
origWidth = photo.width ?? 0;
|
|
@@ -484,11 +653,11 @@ export function VerifyAIScanner({
|
|
|
484
653
|
const approvedResult: VerificationResult = { ...verificationResult, is_compliant: true };
|
|
485
654
|
setResult(approvedResult);
|
|
486
655
|
setStatus('success');
|
|
487
|
-
|
|
656
|
+
scheduleTerminalResult(approvedResult);
|
|
488
657
|
} else {
|
|
489
658
|
setResult(verificationResult);
|
|
490
659
|
setStatus('error');
|
|
491
|
-
|
|
660
|
+
scheduleTerminalResult(verificationResult);
|
|
492
661
|
}
|
|
493
662
|
return;
|
|
494
663
|
}
|
|
@@ -505,31 +674,40 @@ export function VerifyAIScanner({
|
|
|
505
674
|
releaseCamera();
|
|
506
675
|
setResult(verificationResult);
|
|
507
676
|
setStatus('success');
|
|
508
|
-
|
|
677
|
+
scheduleTerminalResult(verificationResult);
|
|
509
678
|
} else {
|
|
510
679
|
// null result means queued for offline
|
|
511
680
|
setStatus('idle');
|
|
512
681
|
}
|
|
513
682
|
} catch (err) {
|
|
514
|
-
const error = (err
|
|
683
|
+
const error = normalizeScannerError(err);
|
|
684
|
+
const terminalRequestError = isTerminalRequestError(error);
|
|
685
|
+
if (terminalRequestError) {
|
|
686
|
+
releaseCamera();
|
|
687
|
+
}
|
|
688
|
+
setResult(null);
|
|
515
689
|
setLastError(error);
|
|
516
690
|
setStatus('error');
|
|
517
691
|
onError?.(error);
|
|
518
692
|
|
|
519
|
-
//
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
693
|
+
// VerifyAIRequestError is already tracked by the client (auth_error,
|
|
694
|
+
// network_error, rate_limited, etc.). Skip here to avoid double-firing.
|
|
695
|
+
if (!(err instanceof VerifyAIRequestError)) {
|
|
696
|
+
const isCaptureFail = error.message?.includes('capture') || error.message?.includes('photo');
|
|
697
|
+
const isImageFail = error.message?.includes('manipulate') || error.message?.includes('image');
|
|
698
|
+
telemetry?.track(
|
|
699
|
+
isCaptureFail ? 'capture_failure'
|
|
700
|
+
: isImageFail ? 'image_manipulation_failure'
|
|
701
|
+
: 'unknown_error',
|
|
702
|
+
{ component: 'scanner', error },
|
|
703
|
+
);
|
|
704
|
+
}
|
|
528
705
|
|
|
529
|
-
|
|
530
|
-
|
|
706
|
+
if (!terminalRequestError) {
|
|
707
|
+
setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
|
|
708
|
+
}
|
|
531
709
|
}
|
|
532
|
-
}, [status, exhausted, onCapture,
|
|
710
|
+
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, telemetry]);
|
|
533
711
|
|
|
534
712
|
// Expose capture to parent via ref
|
|
535
713
|
if (captureRef) {
|
|
@@ -669,12 +847,20 @@ export function VerifyAIScanner({
|
|
|
669
847
|
</Text>
|
|
670
848
|
</View>
|
|
671
849
|
<Text style={styles.feedbackText}>{result.feedback}</Text>
|
|
850
|
+
{overlay?.showTerminalActionButton !== false && onResult && (
|
|
851
|
+
<TouchableOpacity style={styles.cardButton} onPress={deliverVisibleTerminalResult}>
|
|
852
|
+
<Text style={styles.cardButtonText}>
|
|
853
|
+
{overlay?.terminalActionLabel || 'Continue'}
|
|
854
|
+
</Text>
|
|
855
|
+
</TouchableOpacity>
|
|
856
|
+
)}
|
|
672
857
|
</View>
|
|
673
858
|
)}
|
|
674
859
|
|
|
675
860
|
{status === 'error' && (() => {
|
|
676
861
|
let errorTitle: string;
|
|
677
862
|
let errorMessage: string;
|
|
863
|
+
let showCloseAction = false;
|
|
678
864
|
|
|
679
865
|
if (exhausted) {
|
|
680
866
|
errorTitle = 'Attempts Exhausted';
|
|
@@ -694,6 +880,7 @@ export function VerifyAIScanner({
|
|
|
694
880
|
const display = getErrorDisplay(lastError, overlay?.showTechnicalErrorDetails);
|
|
695
881
|
errorTitle = display.title;
|
|
696
882
|
errorMessage = display.message;
|
|
883
|
+
showCloseAction = !!lastError && isTerminalRequestError(lastError) && !!onClose;
|
|
697
884
|
}
|
|
698
885
|
|
|
699
886
|
return (
|
|
@@ -706,9 +893,12 @@ export function VerifyAIScanner({
|
|
|
706
893
|
{errorTitle}
|
|
707
894
|
</Text>
|
|
708
895
|
</View>
|
|
709
|
-
<Text style={styles.feedbackText}>
|
|
710
|
-
|
|
711
|
-
|
|
896
|
+
<Text style={styles.feedbackText}>{errorMessage}</Text>
|
|
897
|
+
{showCloseAction && (
|
|
898
|
+
<TouchableOpacity style={styles.cardButton} onPress={handleClose}>
|
|
899
|
+
<Text style={styles.cardButtonText}>Close</Text>
|
|
900
|
+
</TouchableOpacity>
|
|
901
|
+
)}
|
|
712
902
|
</View>
|
|
713
903
|
);
|
|
714
904
|
})()}
|
|
@@ -745,6 +935,16 @@ export function VerifyAIScanner({
|
|
|
745
935
|
</>
|
|
746
936
|
)}
|
|
747
937
|
</View>
|
|
938
|
+
{showCloseButton && onClose && (
|
|
939
|
+
<TouchableOpacity
|
|
940
|
+
accessibilityRole="button"
|
|
941
|
+
accessibilityLabel="Close scanner"
|
|
942
|
+
style={styles.closeButton}
|
|
943
|
+
onPress={handleClose}
|
|
944
|
+
>
|
|
945
|
+
<Text style={styles.closeButtonText}>X</Text>
|
|
946
|
+
</TouchableOpacity>
|
|
947
|
+
)}
|
|
748
948
|
</View>
|
|
749
949
|
</CameraView>
|
|
750
950
|
</View>
|
|
@@ -956,15 +1156,45 @@ const styles = StyleSheet.create({
|
|
|
956
1156
|
resultLabelExhausted: {
|
|
957
1157
|
color: '#92400e',
|
|
958
1158
|
},
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
1159
|
+
feedbackText: {
|
|
1160
|
+
color: '#4b5563',
|
|
1161
|
+
fontSize: 15,
|
|
1162
|
+
textAlign: 'center',
|
|
1163
|
+
lineHeight: 22,
|
|
1164
|
+
},
|
|
1165
|
+
cardButton: {
|
|
1166
|
+
alignSelf: 'stretch',
|
|
1167
|
+
backgroundColor: '#111827',
|
|
1168
|
+
borderRadius: 8,
|
|
1169
|
+
paddingVertical: 12,
|
|
1170
|
+
paddingHorizontal: 16,
|
|
1171
|
+
alignItems: 'center',
|
|
1172
|
+
marginTop: 8,
|
|
1173
|
+
},
|
|
1174
|
+
cardButtonText: {
|
|
1175
|
+
color: '#fff',
|
|
1176
|
+
fontSize: 16,
|
|
1177
|
+
fontWeight: '700',
|
|
1178
|
+
},
|
|
1179
|
+
closeButton: {
|
|
1180
|
+
position: 'absolute',
|
|
1181
|
+
top: 52,
|
|
1182
|
+
right: 16,
|
|
1183
|
+
width: 44,
|
|
1184
|
+
height: 44,
|
|
1185
|
+
borderRadius: 22,
|
|
1186
|
+
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
|
1187
|
+
justifyContent: 'center',
|
|
1188
|
+
alignItems: 'center',
|
|
1189
|
+
},
|
|
1190
|
+
closeButtonText: {
|
|
1191
|
+
color: '#fff',
|
|
1192
|
+
fontSize: 18,
|
|
1193
|
+
fontWeight: '700',
|
|
1194
|
+
},
|
|
1195
|
+
|
|
1196
|
+
// Permission screen
|
|
1197
|
+
permissionContainer: {
|
|
968
1198
|
justifyContent: 'center',
|
|
969
1199
|
alignItems: 'center',
|
|
970
1200
|
paddingHorizontal: 40,
|
package/src/types/index.ts
CHANGED
|
@@ -134,6 +134,12 @@ export interface ScannerOverlayConfig {
|
|
|
134
134
|
maxAttempts?: number;
|
|
135
135
|
autoApproveOnExhaust?: boolean;
|
|
136
136
|
showTechnicalErrorDetails?: boolean;
|
|
137
|
+
/** Delay before terminal results are reported through onResult. Default: 3000. */
|
|
138
|
+
terminalResultDisplayMs?: number;
|
|
139
|
+
/** Label for the terminal result action button. Default: "Continue". */
|
|
140
|
+
terminalActionLabel?: string;
|
|
141
|
+
/** Whether to show an action button on terminal result cards. Default: true. */
|
|
142
|
+
showTerminalActionButton?: boolean;
|
|
137
143
|
/** Custom theme for scanner colors. */
|
|
138
144
|
theme?: ScannerTheme;
|
|
139
145
|
}
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SDK_VERSION = '2.4.
|
|
1
|
+
export const SDK_VERSION = '2.4.14';
|