@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 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 pausePreview;
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 = new Error('Camera is not ready yet. Please wait a moment and try again.');
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 new Error('Failed to capture photo');
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 new Error('Failed to capture photo');
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
- onResult?.(approvedResult);
512
+ scheduleTerminalResult(approvedResult);
389
513
  }
390
514
  else {
391
515
  setResult(verificationResult);
392
516
  setStatus('error');
393
- onResult?.(verificationResult);
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
- onResult?.(verificationResult);
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 instanceof Error ? err : new Error(String(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
- // Reset after a brief pause
430
- setTimeout(() => setStatus('idle'), 2000);
558
+ if (!terminalRequestError) {
559
+ setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
560
+ }
431
561
  }
432
- }, [status, exhausted, onCapture, onResult, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, telemetry]);
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',
@@ -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.11";
1
+ export declare const SDK_VERSION = "2.4.14";
package/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.4.11';
1
+ export const SDK_VERSION = '2.4.14';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@switchlabs/verify-ai-react-native",
3
- "version": "2.4.11",
3
+ "version": "2.4.14",
4
4
  "description": "React Native SDK for Verify AI - photo verification with AI vision processing",
5
5
  "main": "./lib/index.js",
6
6
  "types": "./lib/index.d.ts",
@@ -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
- overlay,
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 pausePreview;
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 = new Error('Camera is not ready yet. Please wait a moment and try again.') as ErrorWithDetails;
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 new Error('Failed to capture photo');
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 new Error('Failed to capture photo');
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
- onResult?.(approvedResult);
656
+ scheduleTerminalResult(approvedResult);
497
657
  } else {
498
658
  setResult(verificationResult);
499
659
  setStatus('error');
500
- onResult?.(verificationResult);
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
- onResult?.(verificationResult);
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 instanceof Error ? err : new Error(String(err))) as ErrorWithDetails;
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
- // Reset after a brief pause
542
- setTimeout(() => setStatus('idle'), 2000);
706
+ if (!terminalRequestError) {
707
+ setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
708
+ }
543
709
  }
544
- }, [status, exhausted, onCapture, onResult, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, telemetry]);
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
- {errorMessage}
723
- </Text>
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
- feedbackText: {
972
- color: '#4b5563',
973
- fontSize: 15,
974
- textAlign: 'center',
975
- lineHeight: 22,
976
- },
977
-
978
- // Permission screen
979
- permissionContainer: {
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,
@@ -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.11';
1
+ export const SDK_VERSION = '2.4.14';