@switchlabs/verify-ai-react-native 0.1.5 → 1.0.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
  }
@@ -28,8 +28,10 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
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;
@@ -79,13 +101,33 @@ export function VerifyAIScanner({ onCapture, onResult, onError, overlay, style,
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.0.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
  }
@@ -69,9 +69,11 @@ export function VerifyAIScanner({
69
69
  const [status, setStatus] = useState<ScannerStatus>('idle');
70
70
  const [result, setResult] = useState<VerificationResult | null>(null);
71
71
  const [permission, requestPermission] = useCameraPermissions();
72
+ const attemptCountRef = useRef(0);
73
+ const [exhausted, setExhausted] = useState(false);
72
74
 
73
75
  const handleCapture = useCallback(async () => {
74
- if (!cameraRef.current || status === 'capturing' || status === 'processing') return;
76
+ if (!cameraRef.current || status === 'capturing' || status === 'processing' || exhausted) return;
75
77
 
76
78
  setStatus('capturing');
77
79
  setResult(null);
@@ -90,7 +92,29 @@ export function VerifyAIScanner({
90
92
  setStatus('processing');
91
93
  const verificationResult = await onCapture(photo.base64);
92
94
 
95
+ attemptCountRef.current++;
96
+
93
97
  if (verificationResult) {
98
+ const maxAttempts = overlay?.maxAttempts;
99
+ const autoApprove = overlay?.autoApproveOnExhaust ?? false;
100
+
101
+ // Check attempt exhaustion for non-compliant results
102
+ if (!verificationResult.is_compliant &&
103
+ maxAttempts != null &&
104
+ attemptCountRef.current >= maxAttempts) {
105
+ setExhausted(true);
106
+ if (autoApprove) {
107
+ const approvedResult: VerificationResult = { ...verificationResult, is_compliant: true };
108
+ setResult(approvedResult);
109
+ setStatus('success');
110
+ onResult?.(approvedResult);
111
+ } else {
112
+ setResult(verificationResult);
113
+ setStatus('error');
114
+ }
115
+ return;
116
+ }
117
+
94
118
  setResult(verificationResult);
95
119
  setStatus('success');
96
120
  onResult?.(verificationResult);
@@ -106,7 +130,7 @@ export function VerifyAIScanner({
106
130
  // Reset after a brief pause
107
131
  setTimeout(() => setStatus('idle'), 2000);
108
132
  }
109
- }, [status, onCapture, onResult, onError]);
133
+ }, [status, exhausted, onCapture, onResult, onError, overlay?.maxAttempts, overlay?.autoApproveOnExhaust]);
110
134
 
111
135
  // Expose capture to parent via ref
112
136
  if (captureRef) {
@@ -151,6 +175,12 @@ export function VerifyAIScanner({
151
175
  : undefined,
152
176
  ]}
153
177
  >
178
+ {/* Guide overlay (e.g. bike silhouette) — rendered behind corners */}
179
+ {overlay.guideOverlayContent && (
180
+ <View style={[StyleSheet.absoluteFill, { opacity: overlay.guideOverlayOpacity ?? 0.3 }]}>
181
+ {overlay.guideOverlayContent}
182
+ </View>
183
+ )}
154
184
  {/* Corner brackets */}
155
185
  <View style={[styles.corner, styles.cornerTopLeft]} />
156
186
  <View style={[styles.corner, styles.cornerTopRight]} />
@@ -164,7 +194,9 @@ export function VerifyAIScanner({
164
194
  {status === 'processing' && (
165
195
  <View style={styles.processingOverlay}>
166
196
  <ActivityIndicator size="large" color="#fff" />
167
- <Text style={styles.statusText}>Analyzing photo...</Text>
197
+ <Text style={styles.statusText}>
198
+ {overlay?.processingMessage || 'Analyzing photo...'}
199
+ </Text>
168
200
  </View>
169
201
  )}
170
202
 
@@ -192,28 +224,48 @@ export function VerifyAIScanner({
192
224
  result.is_compliant ? styles.resultLabelPass : styles.resultLabelFail,
193
225
  ]}
194
226
  >
195
- {result.is_compliant ? 'Verified' : 'Not Verified'}
227
+ {result.is_compliant
228
+ ? (overlay?.successMessage || 'Verified')
229
+ : (overlay?.failureMessage || 'Not Verified')}
196
230
  </Text>
197
231
  </View>
198
232
  <Text style={styles.feedbackText}>{result.feedback}</Text>
199
233
  </View>
200
234
  )}
201
235
 
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>
236
+ {status === 'error' && (() => {
237
+ let errorTitle: string;
238
+ let errorMessage: string;
239
+
240
+ if (exhausted) {
241
+ errorTitle = 'Attempts Exhausted';
242
+ errorMessage = overlay?.exhaustedMessage || 'Maximum attempts reached.';
243
+ } else if (overlay?.maxAttempts != null && attemptCountRef.current < overlay.maxAttempts && result && !result.is_compliant) {
244
+ const remaining = overlay.maxAttempts - attemptCountRef.current;
245
+ errorTitle = 'Not Verified';
246
+ const template = overlay?.retryMessage || 'Please try again. {remaining} attempts left.';
247
+ errorMessage = template.replace('{remaining}', String(remaining));
248
+ } else {
249
+ errorTitle = 'Something went wrong';
250
+ errorMessage = "We couldn't process your photo. Please try again.";
251
+ }
252
+
253
+ return (
254
+ <View style={styles.resultCard}>
255
+ <View style={styles.resultCardHeader}>
256
+ <View style={[styles.resultIconCircle, styles.resultIconError]}>
257
+ <Text style={styles.resultIcon}>!</Text>
258
+ </View>
259
+ <Text style={[styles.resultLabel, styles.resultLabelError]}>
260
+ {errorTitle}
261
+ </Text>
207
262
  </View>
208
- <Text style={[styles.resultLabel, styles.resultLabelError]}>
209
- Something went wrong
263
+ <Text style={styles.feedbackText}>
264
+ {errorMessage}
210
265
  </Text>
211
266
  </View>
212
- <Text style={styles.feedbackText}>
213
- We couldn't process your photo. Please try again.
214
- </Text>
215
- </View>
216
- )}
267
+ );
268
+ })()}
217
269
 
218
270
  {!showBottomCard && (
219
271
  <>
@@ -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
  }