@switchlabs/verify-ai-react-native 2.4.11 → 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 +178 -16
- 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 +243 -25
- 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,15 +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
5
|
import { VerifyAIRequestError } from '../client';
|
|
6
6
|
import { useTelemetry } from '../telemetry/TelemetryContext';
|
|
7
|
+
import { BikeOverlay } from './BikeOverlay';
|
|
8
|
+
import { ScooterOverlay } from './ScooterOverlay';
|
|
7
9
|
/** Quality used when expo-image-manipulator is not available (lower = smaller). */
|
|
8
10
|
const FALLBACK_QUALITY = 0.65;
|
|
9
11
|
/** Quality used when expo-image-manipulator IS available (resize handles size). */
|
|
10
12
|
const MANIPULATOR_QUALITY = 0.8;
|
|
11
13
|
/** Max dimension (px) on longest side when resize is available. */
|
|
12
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
|
+
}
|
|
13
70
|
function getErrorDisplay(error, showTechnicalDetails) {
|
|
14
71
|
if (!error) {
|
|
15
72
|
return {
|
|
@@ -27,9 +84,18 @@ function getErrorDisplay(error, showTechnicalDetails) {
|
|
|
27
84
|
else if (code === 'network_error' || status === 0) {
|
|
28
85
|
message = 'Network request failed. Check your connection and try again.';
|
|
29
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
|
+
}
|
|
30
93
|
else if (status === 401) {
|
|
31
94
|
message = 'Verification is not configured correctly.';
|
|
32
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
|
+
}
|
|
33
99
|
else if (status === 413) {
|
|
34
100
|
message = 'Image is too large. Please try again — the photo will be resized automatically.';
|
|
35
101
|
}
|
|
@@ -53,6 +119,14 @@ function getErrorDisplay(error, showTechnicalDetails) {
|
|
|
53
119
|
message,
|
|
54
120
|
};
|
|
55
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
|
+
}
|
|
56
130
|
/**
|
|
57
131
|
* Camera scanner component for capturing verification photos.
|
|
58
132
|
* Uses expo-camera for the camera view and provides a simple capture UI.
|
|
@@ -74,9 +148,10 @@ function getErrorDisplay(error, showTechnicalDetails) {
|
|
|
74
148
|
* />
|
|
75
149
|
* ```
|
|
76
150
|
*/
|
|
77
|
-
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, }) {
|
|
78
152
|
const contextTelemetry = useTelemetry();
|
|
79
153
|
const telemetry = telemetryProp ?? contextTelemetry;
|
|
154
|
+
const overlay = useMemo(() => applyPolicyDefaults(overlayProp, policy), [overlayProp, policy]);
|
|
80
155
|
const cameraRef = useRef(null);
|
|
81
156
|
const [status, setStatus] = useState('idle');
|
|
82
157
|
const [result, setResult] = useState(null);
|
|
@@ -87,6 +162,9 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
87
162
|
const [terminated, setTerminated] = useState(false);
|
|
88
163
|
const [cameraReady, setCameraReady] = useState(false);
|
|
89
164
|
const [cameraKey, setCameraKey] = useState(0);
|
|
165
|
+
const terminalResultRef = useRef(null);
|
|
166
|
+
const terminalResultTimerRef = useRef(null);
|
|
167
|
+
const terminalResultDeliveredRef = useRef(false);
|
|
90
168
|
const cameraReadyRef = useRef(false);
|
|
91
169
|
const cameraEverReadyRef = useRef(false);
|
|
92
170
|
const cameraInitFailedRef = useRef(false);
|
|
@@ -208,8 +286,48 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
208
286
|
pausePreview();
|
|
209
287
|
}, [pausePreview]);
|
|
210
288
|
useEffect(() => {
|
|
211
|
-
return
|
|
289
|
+
return () => {
|
|
290
|
+
pausePreview();
|
|
291
|
+
if (terminalResultTimerRef.current) {
|
|
292
|
+
clearTimeout(terminalResultTimerRef.current);
|
|
293
|
+
terminalResultTimerRef.current = null;
|
|
294
|
+
}
|
|
295
|
+
};
|
|
212
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]);
|
|
213
331
|
// Camera init callbacks
|
|
214
332
|
const onCameraReady = useCallback(() => {
|
|
215
333
|
setCameraReady(true);
|
|
@@ -272,7 +390,7 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
272
390
|
if (!cameraRef.current || status === 'capturing' || status === 'processing' || exhausted)
|
|
273
391
|
return;
|
|
274
392
|
if (!cameraReadyRef.current) {
|
|
275
|
-
const error =
|
|
393
|
+
const error = createScannerError('Camera is not ready yet. Please wait a moment and try again.', CAMERA_NOT_READY_ERROR_CODE, 'CameraNotReadyError');
|
|
276
394
|
setResult(null);
|
|
277
395
|
setLastError(error);
|
|
278
396
|
setStatus('error');
|
|
@@ -287,6 +405,12 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
287
405
|
setStatus('capturing');
|
|
288
406
|
setResult(null);
|
|
289
407
|
setLastError(null);
|
|
408
|
+
terminalResultRef.current = null;
|
|
409
|
+
terminalResultDeliveredRef.current = false;
|
|
410
|
+
if (terminalResultTimerRef.current) {
|
|
411
|
+
clearTimeout(terminalResultTimerRef.current);
|
|
412
|
+
terminalResultTimerRef.current = null;
|
|
413
|
+
}
|
|
290
414
|
try {
|
|
291
415
|
// --- Capture + best-effort resize ---
|
|
292
416
|
// Strategy: try to dynamically import expo-image-manipulator.
|
|
@@ -313,7 +437,7 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
313
437
|
exif: false,
|
|
314
438
|
});
|
|
315
439
|
if (!photo?.uri) {
|
|
316
|
-
throw
|
|
440
|
+
throw createScannerError('Failed to capture photo', CAMERA_CAPTURE_ERROR_CODE, 'CameraCaptureError');
|
|
317
441
|
}
|
|
318
442
|
origWidth = photo.width ?? 0;
|
|
319
443
|
origHeight = photo.height ?? 0;
|
|
@@ -349,7 +473,7 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
349
473
|
exif: false,
|
|
350
474
|
});
|
|
351
475
|
if (!photo?.base64) {
|
|
352
|
-
throw
|
|
476
|
+
throw createScannerError('Failed to capture photo', CAMERA_CAPTURE_ERROR_CODE, 'CameraCaptureError');
|
|
353
477
|
}
|
|
354
478
|
origWidth = photo.width ?? 0;
|
|
355
479
|
origHeight = photo.height ?? 0;
|
|
@@ -385,12 +509,12 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
385
509
|
const approvedResult = { ...verificationResult, is_compliant: true };
|
|
386
510
|
setResult(approvedResult);
|
|
387
511
|
setStatus('success');
|
|
388
|
-
|
|
512
|
+
scheduleTerminalResult(approvedResult);
|
|
389
513
|
}
|
|
390
514
|
else {
|
|
391
515
|
setResult(verificationResult);
|
|
392
516
|
setStatus('error');
|
|
393
|
-
|
|
517
|
+
scheduleTerminalResult(verificationResult);
|
|
394
518
|
}
|
|
395
519
|
return;
|
|
396
520
|
}
|
|
@@ -405,7 +529,7 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
405
529
|
releaseCamera();
|
|
406
530
|
setResult(verificationResult);
|
|
407
531
|
setStatus('success');
|
|
408
|
-
|
|
532
|
+
scheduleTerminalResult(verificationResult);
|
|
409
533
|
}
|
|
410
534
|
else {
|
|
411
535
|
// null result means queued for offline
|
|
@@ -413,7 +537,12 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
413
537
|
}
|
|
414
538
|
}
|
|
415
539
|
catch (err) {
|
|
416
|
-
const error = (err
|
|
540
|
+
const error = normalizeScannerError(err);
|
|
541
|
+
const terminalRequestError = isTerminalRequestError(error);
|
|
542
|
+
if (terminalRequestError) {
|
|
543
|
+
releaseCamera();
|
|
544
|
+
}
|
|
545
|
+
setResult(null);
|
|
417
546
|
setLastError(error);
|
|
418
547
|
setStatus('error');
|
|
419
548
|
onError?.(error);
|
|
@@ -426,10 +555,11 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
426
555
|
: isImageFail ? 'image_manipulation_failure'
|
|
427
556
|
: 'unknown_error', { component: 'scanner', error });
|
|
428
557
|
}
|
|
429
|
-
|
|
430
|
-
|
|
558
|
+
if (!terminalRequestError) {
|
|
559
|
+
setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
|
|
560
|
+
}
|
|
431
561
|
}
|
|
432
|
-
}, [status, exhausted, onCapture,
|
|
562
|
+
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, telemetry]);
|
|
433
563
|
// Expose capture to parent via ref
|
|
434
564
|
if (captureRef) {
|
|
435
565
|
captureRef.current = handleCapture;
|
|
@@ -473,9 +603,10 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
473
603
|
? (overlay?.exhaustedMessage || 'Submitted for review')
|
|
474
604
|
: result.is_compliant
|
|
475
605
|
? (overlay?.successMessage || 'Verified')
|
|
476
|
-
: (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' && (() => {
|
|
477
607
|
let errorTitle;
|
|
478
608
|
let errorMessage;
|
|
609
|
+
let showCloseAction = false;
|
|
479
610
|
if (exhausted) {
|
|
480
611
|
errorTitle = 'Attempts Exhausted';
|
|
481
612
|
errorMessage = overlay?.exhaustedMessage || 'Maximum attempts reached.';
|
|
@@ -497,8 +628,9 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
497
628
|
const display = getErrorDisplay(lastError, overlay?.showTechnicalErrorDetails);
|
|
498
629
|
errorTitle = display.title;
|
|
499
630
|
errorMessage = display.message;
|
|
631
|
+
showCloseAction = !!lastError && isTerminalRequestError(lastError) && !!onClose;
|
|
500
632
|
}
|
|
501
|
-
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" }) }))] }));
|
|
502
634
|
})(), !showBottomCard && (_jsxs(_Fragment, { children: [overlay?.instructions && status === 'idle' && (_jsx(Text, { style: [
|
|
503
635
|
styles.instructionsText,
|
|
504
636
|
{ transform: [{ rotate: `${overlayRotationDeg}deg` }] },
|
|
@@ -507,7 +639,7 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
|
|
|
507
639
|
overlay?.theme?.captureButtonColor ? { borderColor: overlay.theme.captureButtonColor } : undefined,
|
|
508
640
|
(!cameraReady || status === 'capturing' || status === 'processing') &&
|
|
509
641
|
styles.captureButtonDisabled,
|
|
510
|
-
], 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) }));
|
|
511
643
|
}
|
|
512
644
|
const CORNER_SIZE = 30;
|
|
513
645
|
const CORNER_THICKNESS = 3;
|
|
@@ -715,6 +847,36 @@ const styles = StyleSheet.create({
|
|
|
715
847
|
textAlign: 'center',
|
|
716
848
|
lineHeight: 22,
|
|
717
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
|
+
},
|
|
718
880
|
// Permission screen
|
|
719
881
|
permissionContainer: {
|
|
720
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,
|
|
@@ -22,6 +22,8 @@ import type {
|
|
|
22
22
|
import { VerifyAIRequestError } from '../client';
|
|
23
23
|
import { useTelemetry } from '../telemetry/TelemetryContext';
|
|
24
24
|
import type { TelemetryReporter } from '../telemetry/TelemetryReporter';
|
|
25
|
+
import { BikeOverlay } from './BikeOverlay';
|
|
26
|
+
import { ScooterOverlay } from './ScooterOverlay';
|
|
25
27
|
|
|
26
28
|
/** Quality used when expo-image-manipulator is not available (lower = smaller). */
|
|
27
29
|
const FALLBACK_QUALITY = 0.65;
|
|
@@ -29,20 +31,30 @@ const FALLBACK_QUALITY = 0.65;
|
|
|
29
31
|
const MANIPULATOR_QUALITY = 0.8;
|
|
30
32
|
/** Max dimension (px) on longest side when resize is available. */
|
|
31
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;
|
|
32
38
|
|
|
33
39
|
export interface VerifyAIScannerProps {
|
|
34
40
|
/** Called with base64 image data when the user captures a photo. */
|
|
35
41
|
onCapture: (base64: string) => Promise<VerificationResult | null>;
|
|
42
|
+
/** Optional policy ID used for policy-aware default scanner copy. */
|
|
43
|
+
policy?: string;
|
|
36
44
|
/** Called when a terminal verification result is reached. */
|
|
37
45
|
onResult?: (result: VerificationResult) => void;
|
|
38
46
|
/** Called when an error occurs. */
|
|
39
47
|
onError?: (error: Error) => void;
|
|
48
|
+
/** Called when the user presses the persistent close button. */
|
|
49
|
+
onClose?: (result: VerificationResult | null) => void;
|
|
40
50
|
/** Overlay configuration for the camera view. */
|
|
41
51
|
overlay?: ScannerOverlayConfig;
|
|
42
52
|
/** Custom style for the container. */
|
|
43
53
|
style?: ViewStyle;
|
|
44
54
|
/** Whether to show the default capture button. Set false to use your own. */
|
|
45
55
|
showCaptureButton?: boolean;
|
|
56
|
+
/** Whether to show the persistent close button. */
|
|
57
|
+
showCloseButton?: boolean;
|
|
46
58
|
/** Ref to imperatively trigger capture from parent. */
|
|
47
59
|
captureRef?: React.MutableRefObject<(() => void) | null>;
|
|
48
60
|
/** Whether to enable the camera torch/flashlight. */
|
|
@@ -62,6 +74,66 @@ type ErrorWithDetails = Error & {
|
|
|
62
74
|
};
|
|
63
75
|
};
|
|
64
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
|
+
|
|
65
137
|
function getErrorDisplay(error: ErrorWithDetails | null, showTechnicalDetails?: boolean): { title: string; message: string } {
|
|
66
138
|
if (!error) {
|
|
67
139
|
return {
|
|
@@ -80,8 +152,14 @@ function getErrorDisplay(error: ErrorWithDetails | null, showTechnicalDetails?:
|
|
|
80
152
|
message = 'Verification timed out. Please try again.';
|
|
81
153
|
} else if (code === 'network_error' || status === 0) {
|
|
82
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.';
|
|
83
159
|
} else if (status === 401) {
|
|
84
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.";
|
|
85
163
|
} else if (status === 413) {
|
|
86
164
|
message = 'Image is too large. Please try again — the photo will be resized automatically.';
|
|
87
165
|
} else if (status === 429) {
|
|
@@ -107,6 +185,17 @@ function getErrorDisplay(error: ErrorWithDetails | null, showTechnicalDetails?:
|
|
|
107
185
|
};
|
|
108
186
|
}
|
|
109
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
|
+
|
|
110
199
|
/**
|
|
111
200
|
* Camera scanner component for capturing verification photos.
|
|
112
201
|
* Uses expo-camera for the camera view and provides a simple capture UI.
|
|
@@ -130,17 +219,24 @@ function getErrorDisplay(error: ErrorWithDetails | null, showTechnicalDetails?:
|
|
|
130
219
|
*/
|
|
131
220
|
export function VerifyAIScanner({
|
|
132
221
|
onCapture,
|
|
222
|
+
policy,
|
|
133
223
|
onResult,
|
|
134
224
|
onError,
|
|
135
|
-
|
|
225
|
+
onClose,
|
|
226
|
+
overlay: overlayProp,
|
|
136
227
|
style,
|
|
137
228
|
showCaptureButton = true,
|
|
229
|
+
showCloseButton = false,
|
|
138
230
|
captureRef,
|
|
139
231
|
enableTorch,
|
|
140
232
|
telemetry: telemetryProp,
|
|
141
233
|
}: VerifyAIScannerProps) {
|
|
142
234
|
const contextTelemetry = useTelemetry();
|
|
143
235
|
const telemetry = telemetryProp ?? contextTelemetry;
|
|
236
|
+
const overlay = useMemo(
|
|
237
|
+
() => applyPolicyDefaults(overlayProp, policy),
|
|
238
|
+
[overlayProp, policy],
|
|
239
|
+
);
|
|
144
240
|
|
|
145
241
|
const cameraRef = useRef<CameraView>(null);
|
|
146
242
|
const [status, setStatus] = useState<ScannerStatus>('idle');
|
|
@@ -152,6 +248,9 @@ export function VerifyAIScanner({
|
|
|
152
248
|
const [terminated, setTerminated] = useState(false);
|
|
153
249
|
const [cameraReady, setCameraReady] = useState(false);
|
|
154
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);
|
|
155
254
|
const cameraReadyRef = useRef(false);
|
|
156
255
|
const cameraEverReadyRef = useRef(false);
|
|
157
256
|
const cameraInitFailedRef = useRef(false);
|
|
@@ -291,9 +390,52 @@ export function VerifyAIScanner({
|
|
|
291
390
|
}, [pausePreview]);
|
|
292
391
|
|
|
293
392
|
useEffect(() => {
|
|
294
|
-
return
|
|
393
|
+
return () => {
|
|
394
|
+
pausePreview();
|
|
395
|
+
if (terminalResultTimerRef.current) {
|
|
396
|
+
clearTimeout(terminalResultTimerRef.current);
|
|
397
|
+
terminalResultTimerRef.current = null;
|
|
398
|
+
}
|
|
399
|
+
};
|
|
295
400
|
}, [pausePreview]);
|
|
296
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
|
+
|
|
297
439
|
// Camera init callbacks
|
|
298
440
|
const onCameraReady = useCallback(() => {
|
|
299
441
|
setCameraReady(true);
|
|
@@ -362,7 +504,11 @@ export function VerifyAIScanner({
|
|
|
362
504
|
const handleCapture = useCallback(async () => {
|
|
363
505
|
if (!cameraRef.current || status === 'capturing' || status === 'processing' || exhausted) return;
|
|
364
506
|
if (!cameraReadyRef.current) {
|
|
365
|
-
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
|
+
);
|
|
366
512
|
setResult(null);
|
|
367
513
|
setLastError(error);
|
|
368
514
|
setStatus('error');
|
|
@@ -378,6 +524,12 @@ export function VerifyAIScanner({
|
|
|
378
524
|
setStatus('capturing');
|
|
379
525
|
setResult(null);
|
|
380
526
|
setLastError(null);
|
|
527
|
+
terminalResultRef.current = null;
|
|
528
|
+
terminalResultDeliveredRef.current = false;
|
|
529
|
+
if (terminalResultTimerRef.current) {
|
|
530
|
+
clearTimeout(terminalResultTimerRef.current);
|
|
531
|
+
terminalResultTimerRef.current = null;
|
|
532
|
+
}
|
|
381
533
|
|
|
382
534
|
try {
|
|
383
535
|
// --- Capture + best-effort resize ---
|
|
@@ -407,7 +559,11 @@ export function VerifyAIScanner({
|
|
|
407
559
|
});
|
|
408
560
|
|
|
409
561
|
if (!photo?.uri) {
|
|
410
|
-
throw
|
|
562
|
+
throw createScannerError(
|
|
563
|
+
'Failed to capture photo',
|
|
564
|
+
CAMERA_CAPTURE_ERROR_CODE,
|
|
565
|
+
'CameraCaptureError',
|
|
566
|
+
);
|
|
411
567
|
}
|
|
412
568
|
|
|
413
569
|
origWidth = photo.width ?? 0;
|
|
@@ -451,7 +607,11 @@ export function VerifyAIScanner({
|
|
|
451
607
|
});
|
|
452
608
|
|
|
453
609
|
if (!photo?.base64) {
|
|
454
|
-
throw
|
|
610
|
+
throw createScannerError(
|
|
611
|
+
'Failed to capture photo',
|
|
612
|
+
CAMERA_CAPTURE_ERROR_CODE,
|
|
613
|
+
'CameraCaptureError',
|
|
614
|
+
);
|
|
455
615
|
}
|
|
456
616
|
|
|
457
617
|
origWidth = photo.width ?? 0;
|
|
@@ -493,11 +653,11 @@ export function VerifyAIScanner({
|
|
|
493
653
|
const approvedResult: VerificationResult = { ...verificationResult, is_compliant: true };
|
|
494
654
|
setResult(approvedResult);
|
|
495
655
|
setStatus('success');
|
|
496
|
-
|
|
656
|
+
scheduleTerminalResult(approvedResult);
|
|
497
657
|
} else {
|
|
498
658
|
setResult(verificationResult);
|
|
499
659
|
setStatus('error');
|
|
500
|
-
|
|
660
|
+
scheduleTerminalResult(verificationResult);
|
|
501
661
|
}
|
|
502
662
|
return;
|
|
503
663
|
}
|
|
@@ -514,13 +674,18 @@ export function VerifyAIScanner({
|
|
|
514
674
|
releaseCamera();
|
|
515
675
|
setResult(verificationResult);
|
|
516
676
|
setStatus('success');
|
|
517
|
-
|
|
677
|
+
scheduleTerminalResult(verificationResult);
|
|
518
678
|
} else {
|
|
519
679
|
// null result means queued for offline
|
|
520
680
|
setStatus('idle');
|
|
521
681
|
}
|
|
522
682
|
} catch (err) {
|
|
523
|
-
const error = (err
|
|
683
|
+
const error = normalizeScannerError(err);
|
|
684
|
+
const terminalRequestError = isTerminalRequestError(error);
|
|
685
|
+
if (terminalRequestError) {
|
|
686
|
+
releaseCamera();
|
|
687
|
+
}
|
|
688
|
+
setResult(null);
|
|
524
689
|
setLastError(error);
|
|
525
690
|
setStatus('error');
|
|
526
691
|
onError?.(error);
|
|
@@ -538,10 +703,11 @@ export function VerifyAIScanner({
|
|
|
538
703
|
);
|
|
539
704
|
}
|
|
540
705
|
|
|
541
|
-
|
|
542
|
-
|
|
706
|
+
if (!terminalRequestError) {
|
|
707
|
+
setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
|
|
708
|
+
}
|
|
543
709
|
}
|
|
544
|
-
}, [status, exhausted, onCapture,
|
|
710
|
+
}, [status, exhausted, onCapture, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, scheduleTerminalResult, telemetry]);
|
|
545
711
|
|
|
546
712
|
// Expose capture to parent via ref
|
|
547
713
|
if (captureRef) {
|
|
@@ -681,12 +847,20 @@ export function VerifyAIScanner({
|
|
|
681
847
|
</Text>
|
|
682
848
|
</View>
|
|
683
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
|
+
)}
|
|
684
857
|
</View>
|
|
685
858
|
)}
|
|
686
859
|
|
|
687
860
|
{status === 'error' && (() => {
|
|
688
861
|
let errorTitle: string;
|
|
689
862
|
let errorMessage: string;
|
|
863
|
+
let showCloseAction = false;
|
|
690
864
|
|
|
691
865
|
if (exhausted) {
|
|
692
866
|
errorTitle = 'Attempts Exhausted';
|
|
@@ -706,6 +880,7 @@ export function VerifyAIScanner({
|
|
|
706
880
|
const display = getErrorDisplay(lastError, overlay?.showTechnicalErrorDetails);
|
|
707
881
|
errorTitle = display.title;
|
|
708
882
|
errorMessage = display.message;
|
|
883
|
+
showCloseAction = !!lastError && isTerminalRequestError(lastError) && !!onClose;
|
|
709
884
|
}
|
|
710
885
|
|
|
711
886
|
return (
|
|
@@ -718,9 +893,12 @@ export function VerifyAIScanner({
|
|
|
718
893
|
{errorTitle}
|
|
719
894
|
</Text>
|
|
720
895
|
</View>
|
|
721
|
-
<Text style={styles.feedbackText}>
|
|
722
|
-
|
|
723
|
-
|
|
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
|
+
)}
|
|
724
902
|
</View>
|
|
725
903
|
);
|
|
726
904
|
})()}
|
|
@@ -757,6 +935,16 @@ export function VerifyAIScanner({
|
|
|
757
935
|
</>
|
|
758
936
|
)}
|
|
759
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
|
+
)}
|
|
760
948
|
</View>
|
|
761
949
|
</CameraView>
|
|
762
950
|
</View>
|
|
@@ -968,15 +1156,45 @@ const styles = StyleSheet.create({
|
|
|
968
1156
|
resultLabelExhausted: {
|
|
969
1157
|
color: '#92400e',
|
|
970
1158
|
},
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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: {
|
|
980
1198
|
justifyContent: 'center',
|
|
981
1199
|
alignItems: 'center',
|
|
982
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';
|