@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 +73 -0
- package/lib/client/index.d.ts +26 -1
- package/lib/client/index.js +53 -3
- package/lib/components/VerifyAIScanner.js +46 -4
- package/lib/hooks/useVerifyAI.js +16 -3
- package/lib/index.d.ts +1 -1
- package/lib/storage/offlineQueue.js +5 -2
- package/lib/types/index.d.ts +35 -1
- package/package.json +1 -1
- package/src/client/index.ts +59 -3
- package/src/components/VerifyAIScanner.tsx +68 -16
- package/src/hooks/useVerifyAI.ts +22 -4
- package/src/index.ts +1 -0
- package/src/storage/offlineQueue.ts +6 -3
- package/src/types/index.ts +37 -1
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
|
package/lib/client/index.d.ts
CHANGED
|
@@ -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
|
}
|
package/lib/client/index.js
CHANGED
|
@@ -21,10 +21,29 @@ export class VerifyAIClient {
|
|
|
21
21
|
...options.headers,
|
|
22
22
|
},
|
|
23
23
|
});
|
|
24
|
-
const
|
|
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
|
|
27
|
-
|
|
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:
|
|
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
|
|
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,
|
package/lib/hooks/useVerifyAI.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
}
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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
|
|
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
package/src/client/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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}>
|
|
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
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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={
|
|
209
|
-
|
|
263
|
+
<Text style={styles.feedbackText}>
|
|
264
|
+
{errorMessage}
|
|
210
265
|
</Text>
|
|
211
266
|
</View>
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
</Text>
|
|
215
|
-
</View>
|
|
216
|
-
)}
|
|
267
|
+
);
|
|
268
|
+
})()}
|
|
217
269
|
|
|
218
270
|
{!showBottomCard && (
|
|
219
271
|
<>
|
package/src/hooks/useVerifyAI.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
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
|
-
//
|
|
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
|
@@ -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 {
|
package/src/types/index.ts
CHANGED
|
@@ -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
|
|
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
|
}
|