@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 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 pausePreview;
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
- telemetry?.track('camera_preview_timeout', {
241
- component: 'scanner',
242
- error: 'Camera did not initialize within 3 seconds remounting',
243
- });
244
- // Reset state and bump key to force a fresh CameraView mount
245
- setCameraReady(false);
246
- cameraReadyRef.current = false;
247
- setCameraKey((k) => k + 1);
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 = 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');
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 new Error('Failed to capture photo');
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 new Error('Failed to capture photo');
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
- onResult?.(approvedResult);
512
+ scheduleTerminalResult(approvedResult);
380
513
  }
381
514
  else {
382
515
  setResult(verificationResult);
383
516
  setStatus('error');
384
- onResult?.(verificationResult);
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
- onResult?.(verificationResult);
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 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);
408
546
  setLastError(error);
409
547
  setStatus('error');
410
548
  onError?.(error);
411
- // Track the error
412
- const isCaptureFail = error.message?.includes('capture') || error.message?.includes('photo');
413
- const isImageFail = error.message?.includes('manipulate') || error.message?.includes('image');
414
- telemetry?.track(isCaptureFail ? 'capture_failure'
415
- : isImageFail ? 'image_manipulation_failure'
416
- : 'unknown_error', { component: 'scanner', error });
417
- // Reset after a brief pause
418
- setTimeout(() => setStatus('idle'), 2000);
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, onResult, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, telemetry]);
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',
@@ -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.10";
1
+ export declare const SDK_VERSION = "2.4.14";
package/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const SDK_VERSION = '2.4.10';
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.10",
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,
@@ -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
- overlay,
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 pausePreview;
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
- telemetry?.track('camera_preview_timeout', {
327
- component: 'scanner',
328
- error: 'Camera did not initialize within 3 seconds remounting',
329
- });
330
- // Reset state and bump key to force a fresh CameraView mount
331
- setCameraReady(false);
332
- cameraReadyRef.current = false;
333
- setCameraKey((k) => k + 1);
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 = 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
+ );
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 new Error('Failed to capture photo');
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 new Error('Failed to capture photo');
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
- onResult?.(approvedResult);
656
+ scheduleTerminalResult(approvedResult);
488
657
  } else {
489
658
  setResult(verificationResult);
490
659
  setStatus('error');
491
- onResult?.(verificationResult);
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
- onResult?.(verificationResult);
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 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);
515
689
  setLastError(error);
516
690
  setStatus('error');
517
691
  onError?.(error);
518
692
 
519
- // Track the error
520
- const isCaptureFail = error.message?.includes('capture') || error.message?.includes('photo');
521
- const isImageFail = error.message?.includes('manipulate') || error.message?.includes('image');
522
- telemetry?.track(
523
- isCaptureFail ? 'capture_failure'
524
- : isImageFail ? 'image_manipulation_failure'
525
- : 'unknown_error',
526
- { component: 'scanner', error },
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
- // Reset after a brief pause
530
- setTimeout(() => setStatus('idle'), 2000);
706
+ if (!terminalRequestError) {
707
+ setTimeout(() => setStatus('idle'), TRANSIENT_ERROR_DISPLAY_MS);
708
+ }
531
709
  }
532
- }, [status, exhausted, onCapture, onResult, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust, releaseCamera, telemetry]);
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
- {errorMessage}
711
- </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
+ )}
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
- feedbackText: {
960
- color: '#4b5563',
961
- fontSize: 15,
962
- textAlign: 'center',
963
- lineHeight: 22,
964
- },
965
-
966
- // Permission screen
967
- 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: {
968
1198
  justifyContent: 'center',
969
1199
  alignItems: 'center',
970
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.10';
1
+ export const SDK_VERSION = '2.4.14';