@switchlabs/verify-ai-react-native 0.1.5 → 1.1.0

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 ADDED
@@ -0,0 +1,73 @@
1
+ # @switchlabs/verify-ai-react-native
2
+
3
+ React Native SDK for Verify AI photo verification.
4
+
5
+ ## Install
6
+
7
+ Expo-managed apps:
8
+
9
+ ```bash
10
+ npx expo install @switchlabs/verify-ai-react-native expo-camera @react-native-async-storage/async-storage
11
+ ```
12
+
13
+ React Native CLI:
14
+
15
+ ```bash
16
+ npm install @switchlabs/verify-ai-react-native expo-camera @react-native-async-storage/async-storage
17
+ ```
18
+
19
+ ## Quick Start
20
+
21
+ ```tsx
22
+ import { useVerifyAI } from '@switchlabs/verify-ai-react-native';
23
+
24
+ function ParkingScreen() {
25
+ const { verify } = useVerifyAI({ apiKey: 'vai_your_api_key' });
26
+
27
+ const onPhoto = async (base64: string) => {
28
+ const result = await verify({
29
+ image: base64,
30
+ policy: 'scooter_parking',
31
+ });
32
+ if (result?.is_compliant) {
33
+ console.log('PASS');
34
+ }
35
+ };
36
+
37
+ return null;
38
+ }
39
+ ```
40
+
41
+ ## Scanner Component
42
+
43
+ ```tsx
44
+ import { useVerifyAI, VerifyAIScanner } from '@switchlabs/verify-ai-react-native';
45
+
46
+ function ScannerScreen() {
47
+ const { verify } = useVerifyAI({ apiKey: 'vai_your_api_key' });
48
+ return (
49
+ <VerifyAIScanner
50
+ onCapture={(base64) => verify({ image: base64, policy: 'scooter_parking' })}
51
+ onResult={(result) => console.log(result.is_compliant ? 'PASS' : 'FAIL')}
52
+ />
53
+ );
54
+ }
55
+ ```
56
+
57
+ ## Offline Mode
58
+
59
+ Set `offlineMode: true` to queue transient failures (network, timeout, 429, 5xx) and retry later.
60
+
61
+ ```tsx
62
+ const { verify, queueSize, processQueue } = useVerifyAI({
63
+ apiKey: 'vai_your_api_key',
64
+ offlineMode: true,
65
+ });
66
+
67
+ const result = await verify({ image: base64, policy: 'scooter_parking' });
68
+ await processQueue();
69
+ ```
70
+
71
+ ## API Docs
72
+
73
+ Full API docs: https://verify.switchlabs.dev/docs
@@ -1,4 +1,4 @@
1
- import type { VerifyAIConfig, VerificationRequest, VerificationResult, VerificationListResponse, VerificationListParams, VerifyAIError } from '../types';
1
+ import type { VerifyAIConfig, VerificationRequest, VerificationResult, VerificationListResponse, VerificationListParams, VerifyAIError, PolicyConfigResponse } from '../types';
2
2
  export declare class VerifyAIClient {
3
3
  private apiKey;
4
4
  private baseUrl;
@@ -51,6 +51,29 @@ export declare class VerifyAIClient {
51
51
  * @returns The full verification result with a fresh signed image URL
52
52
  */
53
53
  getVerification(id: string): Promise<VerificationResult>;
54
+ /**
55
+ * Fetch the policy configuration (categories, attempt limits, UI copy).
56
+ *
57
+ * Use this to configure the scanner overlay with server-driven settings
58
+ * so you can update behavior without shipping a new app version.
59
+ *
60
+ * @param policyId - The policy ID (e.g., "pol_abc123")
61
+ * @returns Policy config with maxAttempts, autoApproveOnExhaust, uiCopy, categories
62
+ *
63
+ * @example
64
+ * ```typescript
65
+ * const config = await client.fetchPolicyConfig('pol_abc123');
66
+ * // Use config to set scanner overlay props
67
+ * <VerifyAIScanner
68
+ * overlay={{
69
+ * maxAttempts: config.maxAttempts,
70
+ * autoApproveOnExhaust: config.autoApproveOnExhaust,
71
+ * processingMessage: config.uiCopy.processingMessage,
72
+ * }}
73
+ * />
74
+ * ```
75
+ */
76
+ fetchPolicyConfig(policyId: string): Promise<PolicyConfigResponse>;
54
77
  }
55
78
  export declare class VerifyAIRequestError extends Error {
56
79
  status: number;
@@ -58,5 +81,7 @@ export declare class VerifyAIRequestError extends Error {
58
81
  constructor(message: string, status: number, body: VerifyAIError);
59
82
  get isRateLimited(): boolean;
60
83
  get isUnauthorized(): boolean;
84
+ get isServerError(): boolean;
85
+ get isRetryable(): boolean;
61
86
  get upgradeUrl(): string | undefined;
62
87
  }
@@ -21,10 +21,29 @@ export class VerifyAIClient {
21
21
  ...options.headers,
22
22
  },
23
23
  });
24
- const body = await response.json();
24
+ const rawBody = await response.text();
25
+ let body = null;
26
+ if (rawBody) {
27
+ try {
28
+ body = JSON.parse(rawBody);
29
+ }
30
+ catch {
31
+ body = { error: rawBody };
32
+ }
33
+ }
25
34
  if (!response.ok) {
26
- const error = body;
27
- throw new VerifyAIRequestError(error.error || `Request failed with status ${response.status}`, response.status, error);
35
+ const errorBody = (body && typeof body === 'object' ? body : null);
36
+ const error = {
37
+ error: errorBody?.error || `Request failed with status ${response.status}`,
38
+ status: response.status,
39
+ current_usage: errorBody?.current_usage,
40
+ limit: errorBody?.limit,
41
+ upgrade_url: errorBody?.upgrade_url,
42
+ };
43
+ throw new VerifyAIRequestError(error.error, response.status, error);
44
+ }
45
+ if (!body || typeof body !== 'object') {
46
+ throw new Error('VerifyAI: Invalid response payload');
28
47
  }
29
48
  return body;
30
49
  }
@@ -104,6 +123,31 @@ export class VerifyAIClient {
104
123
  async getVerification(id) {
105
124
  return this.request(`/verifications/${id}`);
106
125
  }
126
+ /**
127
+ * Fetch the policy configuration (categories, attempt limits, UI copy).
128
+ *
129
+ * Use this to configure the scanner overlay with server-driven settings
130
+ * so you can update behavior without shipping a new app version.
131
+ *
132
+ * @param policyId - The policy ID (e.g., "pol_abc123")
133
+ * @returns Policy config with maxAttempts, autoApproveOnExhaust, uiCopy, categories
134
+ *
135
+ * @example
136
+ * ```typescript
137
+ * const config = await client.fetchPolicyConfig('pol_abc123');
138
+ * // Use config to set scanner overlay props
139
+ * <VerifyAIScanner
140
+ * overlay={{
141
+ * maxAttempts: config.maxAttempts,
142
+ * autoApproveOnExhaust: config.autoApproveOnExhaust,
143
+ * processingMessage: config.uiCopy.processingMessage,
144
+ * }}
145
+ * />
146
+ * ```
147
+ */
148
+ async fetchPolicyConfig(policyId) {
149
+ return this.request(`/policies/${policyId}/config`);
150
+ }
107
151
  }
108
152
  export class VerifyAIRequestError extends Error {
109
153
  constructor(message, status, body) {
@@ -118,6 +162,12 @@ export class VerifyAIRequestError extends Error {
118
162
  get isUnauthorized() {
119
163
  return this.status === 401;
120
164
  }
165
+ get isServerError() {
166
+ return this.status >= 500;
167
+ }
168
+ get isRetryable() {
169
+ return this.status === 408 || this.status === 429 || this.status >= 500;
170
+ }
121
171
  get upgradeUrl() {
122
172
  return this.body.upgrade_url;
123
173
  }
@@ -16,6 +16,8 @@ export interface VerifyAIScannerProps {
16
16
  showCaptureButton?: boolean;
17
17
  /** Ref to imperatively trigger capture from parent. */
18
18
  captureRef?: React.MutableRefObject<(() => void) | null>;
19
+ /** Whether to enable the camera torch/flashlight. */
20
+ enableTorch?: boolean;
19
21
  }
20
22
  /**
21
23
  * Camera scanner component for capturing verification photos.
@@ -38,4 +40,4 @@ export interface VerifyAIScannerProps {
38
40
  * />
39
41
  * ```
40
42
  */
41
- export declare function VerifyAIScanner({ onCapture, onResult, onError, overlay, style, showCaptureButton, captureRef, }: VerifyAIScannerProps): import("react/jsx-runtime").JSX.Element;
43
+ export declare function VerifyAIScanner({ onCapture, onResult, onError, overlay, style, showCaptureButton, captureRef, enableTorch, }: VerifyAIScannerProps): import("react/jsx-runtime").JSX.Element;
@@ -23,13 +23,15 @@ import { CameraView, useCameraPermissions, } from 'expo-camera';
23
23
  * />
24
24
  * ```
25
25
  */
26
- export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style, showCaptureButton = true, captureRef, }) {
26
+ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style, showCaptureButton = true, captureRef, enableTorch, }) {
27
27
  const cameraRef = useRef(null);
28
28
  const [status, setStatus] = useState('idle');
29
29
  const [result, setResult] = useState(null);
30
30
  const [permission, requestPermission] = useCameraPermissions();
31
+ const attemptCountRef = useRef(0);
32
+ const [exhausted, setExhausted] = useState(false);
31
33
  const handleCapture = useCallback(async () => {
32
- if (!cameraRef.current || status === 'capturing' || status === 'processing')
34
+ if (!cameraRef.current || status === 'capturing' || status === 'processing' || exhausted)
33
35
  return;
34
36
  setStatus('capturing');
35
37
  setResult(null);
@@ -44,7 +46,27 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
44
46
  }
45
47
  setStatus('processing');
46
48
  const verificationResult = await onCapture(photo.base64);
49
+ attemptCountRef.current++;
47
50
  if (verificationResult) {
51
+ const maxAttempts = overlay?.maxAttempts;
52
+ const autoApprove = overlay?.autoApproveOnExhaust ?? false;
53
+ // Check attempt exhaustion for non-compliant results
54
+ if (!verificationResult.is_compliant &&
55
+ maxAttempts != null &&
56
+ attemptCountRef.current >= maxAttempts) {
57
+ setExhausted(true);
58
+ if (autoApprove) {
59
+ const approvedResult = { ...verificationResult, is_compliant: true };
60
+ setResult(approvedResult);
61
+ setStatus('success');
62
+ onResult?.(approvedResult);
63
+ }
64
+ else {
65
+ setResult(verificationResult);
66
+ setStatus('error');
67
+ }
68
+ return;
69
+ }
48
70
  setResult(verificationResult);
49
71
  setStatus('success');
50
72
  onResult?.(verificationResult);
@@ -62,7 +84,7 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
62
84
  // Reset after a brief pause
63
85
  setTimeout(() => setStatus('idle'), 2000);
64
86
  }
65
- }, [status, onCapture, onResult, onError]);
87
+ }, [status, exhausted, onCapture, onResult, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust]);
66
88
  // Expose capture to parent via ref
67
89
  if (captureRef) {
68
90
  captureRef.current = handleCapture;
@@ -74,18 +96,38 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
74
96
  return (_jsxs(View, { style: [styles.container, styles.permissionContainer, style], children: [_jsx(Text, { style: styles.permissionText, children: "Camera access is required for photo verification" }), _jsx(TouchableOpacity, { style: styles.permissionButton, onPress: requestPermission, children: _jsx(Text, { style: styles.permissionButtonText, children: "Grant Camera Access" }) })] }));
75
97
  }
76
98
  const showBottomCard = status === 'success' || status === 'error';
77
- return (_jsx(View, { style: [styles.container, style], children: _jsx(CameraView, { ref: cameraRef, style: styles.camera, facing: "back", children: _jsxs(View, { style: styles.overlay, children: [overlay?.title && (_jsx(View, { style: styles.topBar, children: _jsx(Text, { style: styles.titleText, children: overlay.title }) })), overlay?.showGuideFrame && (_jsx(View, { style: styles.guideContainer, children: _jsxs(View, { style: [
99
+ return (_jsx(View, { style: [styles.container, style], children: _jsx(CameraView, { ref: cameraRef, style: styles.camera, facing: "back", enableTorch: enableTorch, children: _jsxs(View, { style: styles.overlay, children: [overlay?.title && (_jsx(View, { style: styles.topBar, children: _jsx(Text, { style: styles.titleText, children: overlay.title }) })), overlay?.showGuideFrame && (_jsx(View, { style: styles.guideContainer, children: _jsxs(View, { style: [
78
100
  styles.guideFrame,
79
101
  overlay.guideFrameAspectRatio
80
102
  ? { aspectRatio: overlay.guideFrameAspectRatio }
81
103
  : undefined,
82
- ], children: [_jsx(View, { style: [styles.corner, styles.cornerTopLeft] }), _jsx(View, { style: [styles.corner, styles.cornerTopRight] }), _jsx(View, { style: [styles.corner, styles.cornerBottomLeft] }), _jsx(View, { style: [styles.corner, styles.cornerBottomRight] })] }) })), status === 'processing' && (_jsxs(View, { style: styles.processingOverlay, children: [_jsx(ActivityIndicator, { size: "large", color: "#fff" }), _jsx(Text, { style: styles.statusText, children: "Analyzing photo..." })] })), showBottomCard && _jsx(View, { style: styles.cardBackdrop }), _jsxs(View, { style: styles.bottomArea, children: [status === 'success' && result && (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [
104
+ ], children: [overlay.guideOverlayContent && (_jsx(View, { style: [StyleSheet.absoluteFill, { opacity: overlay.guideOverlayOpacity ?? 0.3 }], children: overlay.guideOverlayContent })), _jsx(View, { style: [styles.corner, styles.cornerTopLeft] }), _jsx(View, { style: [styles.corner, styles.cornerTopRight] }), _jsx(View, { style: [styles.corner, styles.cornerBottomLeft] }), _jsx(View, { style: [styles.corner, styles.cornerBottomRight] })] }) })), status === 'processing' && (_jsxs(View, { style: styles.processingOverlay, children: [_jsx(ActivityIndicator, { size: "large", color: "#fff" }), _jsx(Text, { style: styles.statusText, children: overlay?.processingMessage || 'Analyzing photo...' })] })), showBottomCard && _jsx(View, { style: styles.cardBackdrop }), _jsxs(View, { style: styles.bottomArea, children: [status === 'success' && result && (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [
83
105
  styles.resultIconCircle,
84
106
  result.is_compliant ? styles.resultIconPass : styles.resultIconFail,
85
107
  ], children: _jsx(Text, { style: styles.resultIcon, children: result.is_compliant ? '\u2713' : '\u2717' }) }), _jsx(Text, { style: [
86
108
  styles.resultLabel,
87
109
  result.is_compliant ? styles.resultLabelPass : styles.resultLabelFail,
88
- ], children: result.is_compliant ? 'Verified' : 'Not Verified' })] }), _jsx(Text, { style: styles.feedbackText, children: result.feedback })] })), status === 'error' && (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [styles.resultIconCircle, styles.resultIconError], children: _jsx(Text, { style: styles.resultIcon, children: "!" }) }), _jsx(Text, { style: [styles.resultLabel, styles.resultLabelError], children: "Something went wrong" })] }), _jsx(Text, { style: styles.feedbackText, children: "We couldn't process your photo. Please try again." })] })), !showBottomCard && (_jsxs(_Fragment, { children: [overlay?.instructions && status === 'idle' && (_jsx(Text, { style: styles.instructionsText, children: overlay.instructions })), showCaptureButton && (_jsx(View, { style: styles.captureButtonRow, children: _jsx(TouchableOpacity, { style: [
110
+ ], children: result.is_compliant
111
+ ? (overlay?.successMessage || 'Verified')
112
+ : (overlay?.failureMessage || 'Not Verified') })] }), _jsx(Text, { style: styles.feedbackText, children: result.feedback })] })), status === 'error' && (() => {
113
+ let errorTitle;
114
+ let errorMessage;
115
+ if (exhausted) {
116
+ errorTitle = 'Attempts Exhausted';
117
+ errorMessage = overlay?.exhaustedMessage || 'Maximum attempts reached.';
118
+ }
119
+ else if (overlay?.maxAttempts != null && attemptCountRef.current < overlay.maxAttempts && result && !result.is_compliant) {
120
+ const remaining = overlay.maxAttempts - attemptCountRef.current;
121
+ errorTitle = 'Not Verified';
122
+ const template = overlay?.retryMessage || 'Please try again. {remaining} attempts left.';
123
+ errorMessage = template.replace('{remaining}', String(remaining));
124
+ }
125
+ else {
126
+ errorTitle = 'Something went wrong';
127
+ errorMessage = "We couldn't process your photo. Please try again.";
128
+ }
129
+ return (_jsxs(View, { style: styles.resultCard, children: [_jsxs(View, { style: styles.resultCardHeader, children: [_jsx(View, { style: [styles.resultIconCircle, styles.resultIconError], children: _jsx(Text, { style: styles.resultIcon, children: "!" }) }), _jsx(Text, { style: [styles.resultLabel, styles.resultLabelError], children: errorTitle })] }), _jsx(Text, { style: styles.feedbackText, children: errorMessage })] }));
130
+ })(), !showBottomCard && (_jsxs(_Fragment, { children: [overlay?.instructions && status === 'idle' && (_jsx(Text, { style: styles.instructionsText, children: overlay.instructions })), showCaptureButton && (_jsx(View, { style: styles.captureButtonRow, children: _jsx(TouchableOpacity, { style: [
89
131
  styles.captureButton,
90
132
  (status === 'capturing' || status === 'processing') &&
91
133
  styles.captureButtonDisabled,
@@ -1,7 +1,20 @@
1
1
  import { useMemo, useCallback, useState, useEffect } from 'react';
2
2
  import { AppState } from 'react-native';
3
- import { VerifyAIClient } from '../client';
3
+ import { VerifyAIClient, VerifyAIRequestError } from '../client';
4
4
  import { OfflineQueue } from '../storage/offlineQueue';
5
+ function isQueueableError(error) {
6
+ if (error instanceof VerifyAIRequestError) {
7
+ return error.isRetryable;
8
+ }
9
+ if (error.name === 'AbortError') {
10
+ return true;
11
+ }
12
+ const message = error.message.toLowerCase();
13
+ return (message.includes('network') ||
14
+ message.includes('timeout') ||
15
+ message.includes('timed out') ||
16
+ message.includes('failed to fetch'));
17
+ }
5
18
  /**
6
19
  * React hook for Verify AI. Provides verification methods,
7
20
  * loading/error state, and optional offline queue management.
@@ -62,8 +75,8 @@ export function useVerifyAI(config) {
62
75
  catch (err) {
63
76
  const error = err instanceof Error ? err : new Error(String(err));
64
77
  setError(error);
65
- // If offline mode, queue the request
66
- if (offlineQueue) {
78
+ // Queue only transient failures so invalid requests are surfaced immediately.
79
+ if (offlineQueue && isQueueableError(error)) {
67
80
  await offlineQueue.enqueue(request);
68
81
  await refreshQueueSize();
69
82
  return null;
package/lib/index.d.ts CHANGED
@@ -4,4 +4,4 @@ export type { UseVerifyAIReturn } from './hooks/useVerifyAI';
4
4
  export { VerifyAIScanner } from './components/VerifyAIScanner';
5
5
  export type { VerifyAIScannerProps } from './components/VerifyAIScanner';
6
6
  export { OfflineQueue } from './storage/offlineQueue';
7
- export type { VerifyAIConfig, VerificationRequest, VerificationResult, VerificationListResponse, VerificationListParams, QueueItem, VerifyAIError, ScannerStatus, ScannerOverlayConfig, } from './types';
7
+ export type { VerifyAIConfig, VerificationRequest, VerificationResult, VerificationListResponse, VerificationListParams, QueueItem, VerifyAIError, ScannerStatus, ScannerOverlayConfig, PolicyConfigResponse, } from './types';
@@ -1,4 +1,5 @@
1
1
  import AsyncStorage from '@react-native-async-storage/async-storage';
2
+ import { VerifyAIRequestError } from '../client';
2
3
  const MANIFEST_KEY = '@verifyai/queue_manifest';
3
4
  const ITEM_PREFIX = '@verifyai/queue_item_';
4
5
  const LEGACY_KEY = '@verifyai/offline_queue';
@@ -157,9 +158,11 @@ export class OfflineQueue {
157
158
  await AsyncStorage.removeItem(`${ITEM_PREFIX}${id}`);
158
159
  onResult?.(item.id, result);
159
160
  }
160
- catch {
161
+ catch (err) {
162
+ const requestError = err instanceof VerifyAIRequestError ? err : null;
163
+ const shouldRetry = !requestError || requestError.isRetryable;
161
164
  item.retryCount++;
162
- if (item.retryCount < maxRetries) {
165
+ if (shouldRetry && item.retryCount < maxRetries) {
163
166
  await AsyncStorage.setItem(`${ITEM_PREFIX}${id}`, JSON.stringify(item));
164
167
  remainingIds.push(id);
165
168
  }
@@ -1,3 +1,4 @@
1
+ import type React from 'react';
1
2
  export interface VerifyAIConfig {
2
3
  apiKey: string;
3
4
  baseUrl?: string;
@@ -44,7 +45,7 @@ export interface QueueItem {
44
45
  }
45
46
  export interface VerifyAIError {
46
47
  error: string;
47
- status: number;
48
+ status?: number;
48
49
  current_usage?: number;
49
50
  limit?: number;
50
51
  upgrade_url?: string;
@@ -55,4 +56,37 @@ export interface ScannerOverlayConfig {
55
56
  instructions?: string;
56
57
  showGuideFrame?: boolean;
57
58
  guideFrameAspectRatio?: number;
59
+ /**
60
+ * Optional React element rendered inside the guide frame as a semi-transparent
61
+ * overlay (e.g. a silhouette image to guide photo composition).
62
+ *
63
+ * The element is absolutely positioned to fill the guide frame.
64
+ *
65
+ * @example
66
+ * ```tsx
67
+ * guideOverlayContent: <Image source={require('./bike_silhouette.png')} style={{ width: '100%', height: '100%' }} resizeMode="contain" />
68
+ * ```
69
+ */
70
+ guideOverlayContent?: React.ReactNode;
71
+ /** Opacity of the guideOverlayContent (0–1). Default: 0.3. */
72
+ guideOverlayOpacity?: number;
73
+ processingMessage?: string;
74
+ successMessage?: string;
75
+ failureMessage?: string;
76
+ retryMessage?: string;
77
+ exhaustedMessage?: string;
78
+ maxAttempts?: number;
79
+ autoApproveOnExhaust?: boolean;
80
+ }
81
+ export interface PolicyConfigResponse {
82
+ maxAttempts: number;
83
+ autoApproveOnExhaust: boolean;
84
+ uiCopy: Record<string, string | undefined>;
85
+ categories: Array<{
86
+ id: string;
87
+ label: string;
88
+ color: string;
89
+ isCompliant: boolean;
90
+ description?: string;
91
+ }>;
58
92
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@switchlabs/verify-ai-react-native",
3
- "version": "0.1.5",
3
+ "version": "1.1.0",
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",
@@ -5,6 +5,7 @@ import type {
5
5
  VerificationListResponse,
6
6
  VerificationListParams,
7
7
  VerifyAIError,
8
+ PolicyConfigResponse,
8
9
  } from '../types';
9
10
 
10
11
  const DEFAULT_BASE_URL = 'https://verify.switchlabs.dev/api/v1';
@@ -41,17 +42,38 @@ export class VerifyAIClient {
41
42
  },
42
43
  });
43
44
 
44
- const body = await response.json();
45
+ const rawBody = await response.text();
46
+ let body: unknown = null;
47
+
48
+ if (rawBody) {
49
+ try {
50
+ body = JSON.parse(rawBody);
51
+ } catch {
52
+ body = { error: rawBody };
53
+ }
54
+ }
45
55
 
46
56
  if (!response.ok) {
47
- const error = body as VerifyAIError;
57
+ const errorBody = (body && typeof body === 'object' ? body : null) as VerifyAIError | null;
58
+ const error: VerifyAIError = {
59
+ error: errorBody?.error || `Request failed with status ${response.status}`,
60
+ status: response.status,
61
+ current_usage: errorBody?.current_usage,
62
+ limit: errorBody?.limit,
63
+ upgrade_url: errorBody?.upgrade_url,
64
+ };
65
+
48
66
  throw new VerifyAIRequestError(
49
- error.error || `Request failed with status ${response.status}`,
67
+ error.error,
50
68
  response.status,
51
69
  error
52
70
  );
53
71
  }
54
72
 
73
+ if (!body || typeof body !== 'object') {
74
+ throw new Error('VerifyAI: Invalid response payload');
75
+ }
76
+
55
77
  return body as T;
56
78
  } finally {
57
79
  clearTimeout(timer);
@@ -131,6 +153,32 @@ export class VerifyAIClient {
131
153
  async getVerification(id: string): Promise<VerificationResult> {
132
154
  return this.request<VerificationResult>(`/verifications/${id}`);
133
155
  }
156
+
157
+ /**
158
+ * Fetch the policy configuration (categories, attempt limits, UI copy).
159
+ *
160
+ * Use this to configure the scanner overlay with server-driven settings
161
+ * so you can update behavior without shipping a new app version.
162
+ *
163
+ * @param policyId - The policy ID (e.g., "pol_abc123")
164
+ * @returns Policy config with maxAttempts, autoApproveOnExhaust, uiCopy, categories
165
+ *
166
+ * @example
167
+ * ```typescript
168
+ * const config = await client.fetchPolicyConfig('pol_abc123');
169
+ * // Use config to set scanner overlay props
170
+ * <VerifyAIScanner
171
+ * overlay={{
172
+ * maxAttempts: config.maxAttempts,
173
+ * autoApproveOnExhaust: config.autoApproveOnExhaust,
174
+ * processingMessage: config.uiCopy.processingMessage,
175
+ * }}
176
+ * />
177
+ * ```
178
+ */
179
+ async fetchPolicyConfig(policyId: string): Promise<PolicyConfigResponse> {
180
+ return this.request<PolicyConfigResponse>(`/policies/${policyId}/config`);
181
+ }
134
182
  }
135
183
 
136
184
  export class VerifyAIRequestError extends Error {
@@ -152,6 +200,14 @@ export class VerifyAIRequestError extends Error {
152
200
  return this.status === 401;
153
201
  }
154
202
 
203
+ get isServerError(): boolean {
204
+ return this.status >= 500;
205
+ }
206
+
207
+ get isRetryable(): boolean {
208
+ return this.status === 408 || this.status === 429 || this.status >= 500;
209
+ }
210
+
155
211
  get upgradeUrl(): string | undefined {
156
212
  return this.body.upgrade_url;
157
213
  }
@@ -33,6 +33,8 @@ export interface VerifyAIScannerProps {
33
33
  showCaptureButton?: boolean;
34
34
  /** Ref to imperatively trigger capture from parent. */
35
35
  captureRef?: React.MutableRefObject<(() => void) | null>;
36
+ /** Whether to enable the camera torch/flashlight. */
37
+ enableTorch?: boolean;
36
38
  }
37
39
 
38
40
  /**
@@ -64,14 +66,17 @@ export function VerifyAIScanner({
64
66
  style,
65
67
  showCaptureButton = true,
66
68
  captureRef,
69
+ enableTorch,
67
70
  }: VerifyAIScannerProps) {
68
71
  const cameraRef = useRef<CameraView>(null);
69
72
  const [status, setStatus] = useState<ScannerStatus>('idle');
70
73
  const [result, setResult] = useState<VerificationResult | null>(null);
71
74
  const [permission, requestPermission] = useCameraPermissions();
75
+ const attemptCountRef = useRef(0);
76
+ const [exhausted, setExhausted] = useState(false);
72
77
 
73
78
  const handleCapture = useCallback(async () => {
74
- if (!cameraRef.current || status === 'capturing' || status === 'processing') return;
79
+ if (!cameraRef.current || status === 'capturing' || status === 'processing' || exhausted) return;
75
80
 
76
81
  setStatus('capturing');
77
82
  setResult(null);
@@ -90,7 +95,29 @@ export function VerifyAIScanner({
90
95
  setStatus('processing');
91
96
  const verificationResult = await onCapture(photo.base64);
92
97
 
98
+ attemptCountRef.current++;
99
+
93
100
  if (verificationResult) {
101
+ const maxAttempts = overlay?.maxAttempts;
102
+ const autoApprove = overlay?.autoApproveOnExhaust ?? false;
103
+
104
+ // Check attempt exhaustion for non-compliant results
105
+ if (!verificationResult.is_compliant &&
106
+ maxAttempts != null &&
107
+ attemptCountRef.current >= maxAttempts) {
108
+ setExhausted(true);
109
+ if (autoApprove) {
110
+ const approvedResult: VerificationResult = { ...verificationResult, is_compliant: true };
111
+ setResult(approvedResult);
112
+ setStatus('success');
113
+ onResult?.(approvedResult);
114
+ } else {
115
+ setResult(verificationResult);
116
+ setStatus('error');
117
+ }
118
+ return;
119
+ }
120
+
94
121
  setResult(verificationResult);
95
122
  setStatus('success');
96
123
  onResult?.(verificationResult);
@@ -106,7 +133,7 @@ export function VerifyAIScanner({
106
133
  // Reset after a brief pause
107
134
  setTimeout(() => setStatus('idle'), 2000);
108
135
  }
109
- }, [status, onCapture, onResult, onError]);
136
+ }, [status, exhausted, onCapture, onResult, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust]);
110
137
 
111
138
  // Expose capture to parent via ref
112
139
  if (captureRef) {
@@ -132,7 +159,7 @@ export function VerifyAIScanner({
132
159
 
133
160
  return (
134
161
  <View style={[styles.container, style]}>
135
- <CameraView ref={cameraRef} style={styles.camera} facing="back">
162
+ <CameraView ref={cameraRef} style={styles.camera} facing="back" enableTorch={enableTorch}>
136
163
  {/* Overlay */}
137
164
  <View style={styles.overlay}>
138
165
  {overlay?.title && (
@@ -151,6 +178,12 @@ export function VerifyAIScanner({
151
178
  : undefined,
152
179
  ]}
153
180
  >
181
+ {/* Guide overlay (e.g. bike silhouette) — rendered behind corners */}
182
+ {overlay.guideOverlayContent && (
183
+ <View style={[StyleSheet.absoluteFill, { opacity: overlay.guideOverlayOpacity ?? 0.3 }]}>
184
+ {overlay.guideOverlayContent}
185
+ </View>
186
+ )}
154
187
  {/* Corner brackets */}
155
188
  <View style={[styles.corner, styles.cornerTopLeft]} />
156
189
  <View style={[styles.corner, styles.cornerTopRight]} />
@@ -164,7 +197,9 @@ export function VerifyAIScanner({
164
197
  {status === 'processing' && (
165
198
  <View style={styles.processingOverlay}>
166
199
  <ActivityIndicator size="large" color="#fff" />
167
- <Text style={styles.statusText}>Analyzing photo...</Text>
200
+ <Text style={styles.statusText}>
201
+ {overlay?.processingMessage || 'Analyzing photo...'}
202
+ </Text>
168
203
  </View>
169
204
  )}
170
205
 
@@ -192,28 +227,48 @@ export function VerifyAIScanner({
192
227
  result.is_compliant ? styles.resultLabelPass : styles.resultLabelFail,
193
228
  ]}
194
229
  >
195
- {result.is_compliant ? 'Verified' : 'Not Verified'}
230
+ {result.is_compliant
231
+ ? (overlay?.successMessage || 'Verified')
232
+ : (overlay?.failureMessage || 'Not Verified')}
196
233
  </Text>
197
234
  </View>
198
235
  <Text style={styles.feedbackText}>{result.feedback}</Text>
199
236
  </View>
200
237
  )}
201
238
 
202
- {status === 'error' && (
203
- <View style={styles.resultCard}>
204
- <View style={styles.resultCardHeader}>
205
- <View style={[styles.resultIconCircle, styles.resultIconError]}>
206
- <Text style={styles.resultIcon}>!</Text>
239
+ {status === 'error' && (() => {
240
+ let errorTitle: string;
241
+ let errorMessage: string;
242
+
243
+ if (exhausted) {
244
+ errorTitle = 'Attempts Exhausted';
245
+ errorMessage = overlay?.exhaustedMessage || 'Maximum attempts reached.';
246
+ } else if (overlay?.maxAttempts != null && attemptCountRef.current < overlay.maxAttempts && result && !result.is_compliant) {
247
+ const remaining = overlay.maxAttempts - attemptCountRef.current;
248
+ errorTitle = 'Not Verified';
249
+ const template = overlay?.retryMessage || 'Please try again. {remaining} attempts left.';
250
+ errorMessage = template.replace('{remaining}', String(remaining));
251
+ } else {
252
+ errorTitle = 'Something went wrong';
253
+ errorMessage = "We couldn't process your photo. Please try again.";
254
+ }
255
+
256
+ return (
257
+ <View style={styles.resultCard}>
258
+ <View style={styles.resultCardHeader}>
259
+ <View style={[styles.resultIconCircle, styles.resultIconError]}>
260
+ <Text style={styles.resultIcon}>!</Text>
261
+ </View>
262
+ <Text style={[styles.resultLabel, styles.resultLabelError]}>
263
+ {errorTitle}
264
+ </Text>
207
265
  </View>
208
- <Text style={[styles.resultLabel, styles.resultLabelError]}>
209
- Something went wrong
266
+ <Text style={styles.feedbackText}>
267
+ {errorMessage}
210
268
  </Text>
211
269
  </View>
212
- <Text style={styles.feedbackText}>
213
- We couldn't process your photo. Please try again.
214
- </Text>
215
- </View>
216
- )}
270
+ );
271
+ })()}
217
272
 
218
273
  {!showBottomCard && (
219
274
  <>
@@ -1,6 +1,6 @@
1
- import { useRef, useMemo, useCallback, useState, useEffect } from 'react';
1
+ import { useMemo, useCallback, useState, useEffect } from 'react';
2
2
  import { AppState, type AppStateStatus } from 'react-native';
3
- import { VerifyAIClient } from '../client';
3
+ import { VerifyAIClient, VerifyAIRequestError } from '../client';
4
4
  import { OfflineQueue } from '../storage/offlineQueue';
5
5
  import type {
6
6
  VerifyAIConfig,
@@ -10,6 +10,24 @@ import type {
10
10
  VerificationListResponse,
11
11
  } from '../types';
12
12
 
13
+ function isQueueableError(error: Error): boolean {
14
+ if (error instanceof VerifyAIRequestError) {
15
+ return error.isRetryable;
16
+ }
17
+
18
+ if (error.name === 'AbortError') {
19
+ return true;
20
+ }
21
+
22
+ const message = error.message.toLowerCase();
23
+ return (
24
+ message.includes('network') ||
25
+ message.includes('timeout') ||
26
+ message.includes('timed out') ||
27
+ message.includes('failed to fetch')
28
+ );
29
+ }
30
+
13
31
  export interface UseVerifyAIReturn {
14
32
  /** Submit a verification. Uses offline queue if offlineMode is enabled and request fails. */
15
33
  verify: (request: VerificationRequest) => Promise<VerificationResult | null>;
@@ -104,8 +122,8 @@ export function useVerifyAI(config: VerifyAIConfig): UseVerifyAIReturn {
104
122
  const error = err instanceof Error ? err : new Error(String(err));
105
123
  setError(error);
106
124
 
107
- // If offline mode, queue the request
108
- if (offlineQueue) {
125
+ // Queue only transient failures so invalid requests are surfaced immediately.
126
+ if (offlineQueue && isQueueableError(error)) {
109
127
  await offlineQueue.enqueue(request);
110
128
  await refreshQueueSize();
111
129
  return null;
package/src/index.ts CHANGED
@@ -23,4 +23,5 @@ export type {
23
23
  VerifyAIError,
24
24
  ScannerStatus,
25
25
  ScannerOverlayConfig,
26
+ PolicyConfigResponse,
26
27
  } from './types';
@@ -1,6 +1,6 @@
1
1
  import AsyncStorage from '@react-native-async-storage/async-storage';
2
2
  import type { VerificationRequest, VerificationResult, QueueItem } from '../types';
3
- import { VerifyAIClient } from '../client';
3
+ import { VerifyAIClient, VerifyAIRequestError } from '../client';
4
4
 
5
5
  const MANIFEST_KEY = '@verifyai/queue_manifest';
6
6
  const ITEM_PREFIX = '@verifyai/queue_item_';
@@ -179,9 +179,12 @@ export class OfflineQueue {
179
179
  processed++;
180
180
  await AsyncStorage.removeItem(`${ITEM_PREFIX}${id}`);
181
181
  onResult?.(item.id, result);
182
- } catch {
182
+ } catch (err) {
183
+ const requestError = err instanceof VerifyAIRequestError ? err : null;
184
+ const shouldRetry = !requestError || requestError.isRetryable;
185
+
183
186
  item.retryCount++;
184
- if (item.retryCount < maxRetries) {
187
+ if (shouldRetry && item.retryCount < maxRetries) {
185
188
  await AsyncStorage.setItem(`${ITEM_PREFIX}${id}`, JSON.stringify(item));
186
189
  remainingIds.push(id);
187
190
  } else {
@@ -1,3 +1,5 @@
1
+ import type React from 'react';
2
+
1
3
  export interface VerifyAIConfig {
2
4
  apiKey: string;
3
5
  baseUrl?: string;
@@ -50,7 +52,7 @@ export interface QueueItem {
50
52
 
51
53
  export interface VerifyAIError {
52
54
  error: string;
53
- status: number;
55
+ status?: number;
54
56
  current_usage?: number;
55
57
  limit?: number;
56
58
  upgrade_url?: string;
@@ -63,4 +65,38 @@ export interface ScannerOverlayConfig {
63
65
  instructions?: string;
64
66
  showGuideFrame?: boolean;
65
67
  guideFrameAspectRatio?: number;
68
+ /**
69
+ * Optional React element rendered inside the guide frame as a semi-transparent
70
+ * overlay (e.g. a silhouette image to guide photo composition).
71
+ *
72
+ * The element is absolutely positioned to fill the guide frame.
73
+ *
74
+ * @example
75
+ * ```tsx
76
+ * guideOverlayContent: <Image source={require('./bike_silhouette.png')} style={{ width: '100%', height: '100%' }} resizeMode="contain" />
77
+ * ```
78
+ */
79
+ guideOverlayContent?: React.ReactNode;
80
+ /** Opacity of the guideOverlayContent (0–1). Default: 0.3. */
81
+ guideOverlayOpacity?: number;
82
+ processingMessage?: string;
83
+ successMessage?: string;
84
+ failureMessage?: string;
85
+ retryMessage?: string; // supports {remaining} placeholder
86
+ exhaustedMessage?: string;
87
+ maxAttempts?: number;
88
+ autoApproveOnExhaust?: boolean;
89
+ }
90
+
91
+ export interface PolicyConfigResponse {
92
+ maxAttempts: number;
93
+ autoApproveOnExhaust: boolean;
94
+ uiCopy: Record<string, string | undefined>;
95
+ categories: Array<{
96
+ id: string;
97
+ label: string;
98
+ color: string;
99
+ isCompliant: boolean;
100
+ description?: string;
101
+ }>;
66
102
  }