astra-sdk-web 1.1.6 → 1.1.8
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/dist/astra-sdk.cjs.js +187 -66
- package/dist/astra-sdk.cjs.js.map +1 -1
- package/dist/astra-sdk.css +72 -0
- package/dist/astra-sdk.css.map +1 -1
- package/dist/astra-sdk.es.js +188 -67
- package/dist/astra-sdk.es.js.map +1 -1
- package/dist/components.cjs.js +187 -66
- package/dist/components.cjs.js.map +1 -1
- package/dist/components.css +72 -0
- package/dist/components.css.map +1 -1
- package/dist/components.es.js +188 -67
- package/dist/components.es.js.map +1 -1
- package/package.json +1 -1
- package/src/components/Toast.tsx +82 -0
- package/src/features/faceScan/hooks/useFaceScan.ts +24 -0
- package/src/pages/FaceScanModal.tsx +35 -11
- package/src/services/faceMeshService.ts +49 -15
- package/src/services/kycApiService.ts +5 -1
package/package.json
CHANGED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
interface ToastProps {
|
|
4
|
+
message: string;
|
|
5
|
+
type?: 'success' | 'error' | 'info' | 'warning';
|
|
6
|
+
duration?: number;
|
|
7
|
+
onClose?: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Toast({ message, type = 'info', duration = 5000, onClose }: ToastProps) {
|
|
11
|
+
const [isVisible, setIsVisible] = useState(true);
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const timer = setTimeout(() => {
|
|
15
|
+
setIsVisible(false);
|
|
16
|
+
setTimeout(() => {
|
|
17
|
+
if (onClose) onClose();
|
|
18
|
+
}, 300); // Wait for fade out animation
|
|
19
|
+
}, duration);
|
|
20
|
+
|
|
21
|
+
return () => clearTimeout(timer);
|
|
22
|
+
}, [duration, onClose]);
|
|
23
|
+
|
|
24
|
+
const bgColor = {
|
|
25
|
+
success: 'bg-green-600',
|
|
26
|
+
error: 'bg-red-600',
|
|
27
|
+
info: 'bg-blue-600',
|
|
28
|
+
warning: 'bg-yellow-600',
|
|
29
|
+
}[type];
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
className={`fixed top-4 right-4 z-[10000] transition-all duration-300 ${
|
|
34
|
+
isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-2'
|
|
35
|
+
}`}
|
|
36
|
+
>
|
|
37
|
+
<div
|
|
38
|
+
className={`${bgColor} text-white px-6 py-4 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px] max-w-[500px]`}
|
|
39
|
+
>
|
|
40
|
+
<div className="flex-1">
|
|
41
|
+
<p className="m-0 text-sm font-medium">{message}</p>
|
|
42
|
+
</div>
|
|
43
|
+
<button
|
|
44
|
+
onClick={() => {
|
|
45
|
+
setIsVisible(false);
|
|
46
|
+
setTimeout(() => {
|
|
47
|
+
if (onClose) onClose();
|
|
48
|
+
}, 300);
|
|
49
|
+
}}
|
|
50
|
+
className="text-white hover:text-gray-200 transition-colors"
|
|
51
|
+
aria-label="Close"
|
|
52
|
+
>
|
|
53
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
54
|
+
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
55
|
+
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
56
|
+
</svg>
|
|
57
|
+
</button>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface ToastContainerProps {
|
|
64
|
+
toasts: Array<{ id: string; message: string; type?: 'success' | 'error' | 'info' | 'warning' }>;
|
|
65
|
+
onRemove: (id: string) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function ToastContainer({ toasts, onRemove }: ToastContainerProps) {
|
|
69
|
+
return (
|
|
70
|
+
<div className="fixed top-4 right-4 z-[10000] flex flex-col gap-2">
|
|
71
|
+
{toasts.map((toast) => (
|
|
72
|
+
<Toast
|
|
73
|
+
key={toast.id}
|
|
74
|
+
message={toast.message}
|
|
75
|
+
type={toast.type}
|
|
76
|
+
onClose={() => onRemove(toast.id)}
|
|
77
|
+
/>
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
@@ -49,6 +49,9 @@ export function useFaceScan(
|
|
|
49
49
|
lastResultsAt: 0,
|
|
50
50
|
stage: 'CENTER' as LivenessStage,
|
|
51
51
|
livenessReady: false,
|
|
52
|
+
currentYaw: null as number | null,
|
|
53
|
+
currentAbsYaw: null as number | null,
|
|
54
|
+
livenessCompleted: false,
|
|
52
55
|
});
|
|
53
56
|
|
|
54
57
|
// Sync refs with state
|
|
@@ -74,6 +77,27 @@ export function useFaceScan(
|
|
|
74
77
|
|
|
75
78
|
const handleFaceCapture = useCallback(async () => {
|
|
76
79
|
if (!videoRef.current) return;
|
|
80
|
+
|
|
81
|
+
// Check if face is straight before capturing
|
|
82
|
+
const centerThreshold = 0.05;
|
|
83
|
+
const currentAbsYaw = livenessStateRef.current.currentAbsYaw;
|
|
84
|
+
|
|
85
|
+
if (currentAbsYaw === null || currentAbsYaw === undefined) {
|
|
86
|
+
setState(prev => ({
|
|
87
|
+
...prev,
|
|
88
|
+
livenessInstruction: "Please position your face in front of the camera",
|
|
89
|
+
}));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (currentAbsYaw >= centerThreshold) {
|
|
94
|
+
setState(prev => ({
|
|
95
|
+
...prev,
|
|
96
|
+
livenessInstruction: "Please look straight at the camera before capturing",
|
|
97
|
+
}));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
77
101
|
setState(prev => ({ ...prev, loading: true }));
|
|
78
102
|
try {
|
|
79
103
|
const video = videoRef.current;
|
|
@@ -4,6 +4,7 @@ import DocumentUploadModal from './DocumentUploadModal';
|
|
|
4
4
|
import { useCamera } from '../features/faceScan/hooks/useCamera';
|
|
5
5
|
import { useFaceScan } from '../features/faceScan/hooks/useFaceScan';
|
|
6
6
|
import { useKycContext } from '../contexts/KycContext';
|
|
7
|
+
import { Toast } from '../components/Toast';
|
|
7
8
|
import '../index.css';
|
|
8
9
|
|
|
9
10
|
interface FaceScanModalProps {
|
|
@@ -16,6 +17,7 @@ function FaceScanModal({ onComplete }: FaceScanModalProps) {
|
|
|
16
17
|
const navigate = useNavigate();
|
|
17
18
|
const { apiService } = useKycContext();
|
|
18
19
|
const [sessionError, setSessionError] = useState<string | null>(null);
|
|
20
|
+
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' | 'info' | 'warning' } | null>(null);
|
|
19
21
|
|
|
20
22
|
const { videoRef, cameraReady, stopCamera } = useCamera();
|
|
21
23
|
const { state, setState, refs, handleFaceCapture } = useFaceScan(videoRef, faceCanvasRef, {
|
|
@@ -23,7 +25,26 @@ function FaceScanModal({ onComplete }: FaceScanModalProps) {
|
|
|
23
25
|
if (!apiService) {
|
|
24
26
|
throw new Error('API service not initialized');
|
|
25
27
|
}
|
|
26
|
-
|
|
28
|
+
try {
|
|
29
|
+
await apiService.uploadFaceScan(blob);
|
|
30
|
+
} catch (error: any) {
|
|
31
|
+
const errorMessage = error?.message || '';
|
|
32
|
+
const errorData = (error as any)?.errorData || {};
|
|
33
|
+
|
|
34
|
+
if (
|
|
35
|
+
errorMessage.includes('Face already registered') ||
|
|
36
|
+
errorMessage.includes('already registered') ||
|
|
37
|
+
errorData?.message?.includes('Face already registered') ||
|
|
38
|
+
(error as any)?.statusCode === 500 && errorMessage.includes('Face')
|
|
39
|
+
) {
|
|
40
|
+
setToast({
|
|
41
|
+
message: 'Face already registered',
|
|
42
|
+
type: 'warning',
|
|
43
|
+
});
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
27
48
|
},
|
|
28
49
|
onFaceCaptureComplete: (imageData: string) => {
|
|
29
50
|
if (onComplete) {
|
|
@@ -32,7 +53,6 @@ function FaceScanModal({ onComplete }: FaceScanModalProps) {
|
|
|
32
53
|
},
|
|
33
54
|
});
|
|
34
55
|
|
|
35
|
-
// Check session status on mount
|
|
36
56
|
useEffect(() => {
|
|
37
57
|
const checkSession = async () => {
|
|
38
58
|
if (!apiService) return;
|
|
@@ -43,7 +63,6 @@ function FaceScanModal({ onComplete }: FaceScanModalProps) {
|
|
|
43
63
|
} catch (error: any) {
|
|
44
64
|
const message = error.message || 'Session expired or inactive';
|
|
45
65
|
setSessionError(message);
|
|
46
|
-
// Redirect to QR page after showing error
|
|
47
66
|
setTimeout(() => {
|
|
48
67
|
navigate('/qr', { replace: true });
|
|
49
68
|
}, 2000);
|
|
@@ -53,15 +72,12 @@ function FaceScanModal({ onComplete }: FaceScanModalProps) {
|
|
|
53
72
|
checkSession();
|
|
54
73
|
}, [apiService, navigate]);
|
|
55
74
|
|
|
56
|
-
// Sync camera ready state
|
|
57
75
|
useEffect(() => {
|
|
58
76
|
setState(prev => ({ ...prev, cameraReady }));
|
|
59
77
|
}, [cameraReady, setState]);
|
|
60
78
|
|
|
61
|
-
// Log session info and call status API when camera opens
|
|
62
79
|
useEffect(() => {
|
|
63
80
|
if (cameraReady && apiService) {
|
|
64
|
-
// Get config from apiService to log session info
|
|
65
81
|
const config = apiService.getConfig();
|
|
66
82
|
if (config) {
|
|
67
83
|
console.log('=== Camera Opened ===');
|
|
@@ -70,7 +86,6 @@ function FaceScanModal({ onComplete }: FaceScanModalProps) {
|
|
|
70
86
|
console.log('API Base URL:', config.apiBaseUrl);
|
|
71
87
|
console.log('Device Type:', config.deviceType || 'auto-detected');
|
|
72
88
|
|
|
73
|
-
// Call status API
|
|
74
89
|
apiService.getSessionStatus()
|
|
75
90
|
.then((statusResponse) => {
|
|
76
91
|
console.log('=== Session Status API Response ===');
|
|
@@ -128,8 +143,7 @@ function FaceScanModal({ onComplete }: FaceScanModalProps) {
|
|
|
128
143
|
/>
|
|
129
144
|
);
|
|
130
145
|
}
|
|
131
|
-
|
|
132
|
-
// Show session error if present
|
|
146
|
+
|
|
133
147
|
if (sessionError) {
|
|
134
148
|
return (
|
|
135
149
|
<div className="fixed inset-0 bg-black p-5 z-[1000] flex items-center justify-center font-sans overflow-y-auto custom__scrollbar">
|
|
@@ -147,8 +161,17 @@ function FaceScanModal({ onComplete }: FaceScanModalProps) {
|
|
|
147
161
|
}
|
|
148
162
|
|
|
149
163
|
return (
|
|
150
|
-
|
|
151
|
-
|
|
164
|
+
<>
|
|
165
|
+
{toast && (
|
|
166
|
+
<Toast
|
|
167
|
+
message={toast.message}
|
|
168
|
+
type={toast.type}
|
|
169
|
+
onClose={() => setToast(null)}
|
|
170
|
+
duration={6000}
|
|
171
|
+
/>
|
|
172
|
+
)}
|
|
173
|
+
<div className="fixed inset-0 bg-black p-5 z-[1000] flex items-center justify-center font-sans overflow-y-auto custom__scrollbar">
|
|
174
|
+
<div className="max-w-[400px] w-full mx-auto bg-[#0b0f17] rounded-2xl p-6 shadow-xl mt-48">
|
|
152
175
|
<div className="relative mb-4">
|
|
153
176
|
<h2 className="m-0 mb-4 text-[26px] font-bold text-white text-center">
|
|
154
177
|
Capture Face
|
|
@@ -231,6 +254,7 @@ function FaceScanModal({ onComplete }: FaceScanModalProps) {
|
|
|
231
254
|
</div>
|
|
232
255
|
</div>
|
|
233
256
|
</div>
|
|
257
|
+
</>
|
|
234
258
|
);
|
|
235
259
|
}
|
|
236
260
|
|
|
@@ -19,6 +19,9 @@ export interface LivenessState {
|
|
|
19
19
|
lastResultsAt: number;
|
|
20
20
|
stage: LivenessStage;
|
|
21
21
|
livenessReady: boolean;
|
|
22
|
+
currentYaw: number | null;
|
|
23
|
+
currentAbsYaw: number | null;
|
|
24
|
+
livenessCompleted: boolean;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
export class FaceMeshService {
|
|
@@ -111,6 +114,10 @@ export class FaceMeshService {
|
|
|
111
114
|
|
|
112
115
|
this.processLiveness(faceOnCanvas, w, h);
|
|
113
116
|
} else {
|
|
117
|
+
// Reset face orientation when no face is detected
|
|
118
|
+
this.livenessStateRef.current.currentYaw = null;
|
|
119
|
+
this.livenessStateRef.current.currentAbsYaw = null;
|
|
120
|
+
|
|
114
121
|
const vid = this.videoRef.current as HTMLVideoElement | null;
|
|
115
122
|
if (vid) {
|
|
116
123
|
const vidW = Math.max(1, vid?.videoWidth || displayW);
|
|
@@ -160,6 +167,10 @@ export class FaceMeshService {
|
|
|
160
167
|
const yaw = (nT.x - midX) / Math.max(1e-6, faceWidth);
|
|
161
168
|
const absYaw = Math.abs(yaw);
|
|
162
169
|
|
|
170
|
+
// Store current face orientation for capture validation
|
|
171
|
+
this.livenessStateRef.current.currentYaw = yaw;
|
|
172
|
+
this.livenessStateRef.current.currentAbsYaw = absYaw;
|
|
173
|
+
|
|
163
174
|
const xs = faceOnCanvas.map(p => p.x), ys = faceOnCanvas.map(p => p.y);
|
|
164
175
|
const minX = Math.min(...xs) * w, maxX = Math.max(...xs) * w;
|
|
165
176
|
const minY = Math.min(...ys) * h, maxY = Math.max(...ys) * h;
|
|
@@ -192,11 +203,14 @@ export class FaceMeshService {
|
|
|
192
203
|
} else if (absYaw < centerThreshold) {
|
|
193
204
|
state.centerHold += 1;
|
|
194
205
|
if (state.centerHold >= holdFramesCenter) {
|
|
195
|
-
|
|
196
|
-
state.
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
206
|
+
// Only transition to LEFT if liveness check hasn't been completed yet
|
|
207
|
+
if (!state.livenessCompleted) {
|
|
208
|
+
const newStage: LivenessStage = "LEFT";
|
|
209
|
+
state.stage = newStage;
|
|
210
|
+
state.centerHold = 0;
|
|
211
|
+
if (this.callbacks.onLivenessUpdate) {
|
|
212
|
+
this.callbacks.onLivenessUpdate(newStage, "Turn your face LEFT");
|
|
213
|
+
}
|
|
200
214
|
}
|
|
201
215
|
}
|
|
202
216
|
} else {
|
|
@@ -235,16 +249,13 @@ export class FaceMeshService {
|
|
|
235
249
|
state.rightHold += 1;
|
|
236
250
|
if (state.rightHold >= holdFramesTurn) {
|
|
237
251
|
state.rightHold = 0;
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
if (this.callbacks.onCaptureTrigger) {
|
|
246
|
-
this.callbacks.onCaptureTrigger();
|
|
247
|
-
}
|
|
252
|
+
// Mark liveness as completed and transition to DONE stage
|
|
253
|
+
state.livenessCompleted = true;
|
|
254
|
+
const newStage: LivenessStage = "DONE";
|
|
255
|
+
state.stage = newStage;
|
|
256
|
+
state.centerHold = 0;
|
|
257
|
+
if (this.callbacks.onLivenessUpdate) {
|
|
258
|
+
this.callbacks.onLivenessUpdate(newStage, "Great! Now look straight at the camera");
|
|
248
259
|
}
|
|
249
260
|
}
|
|
250
261
|
} else {
|
|
@@ -253,6 +264,29 @@ export class FaceMeshService {
|
|
|
253
264
|
this.callbacks.onLivenessUpdate(state.stage, yaw < -leftThreshold ? "You're facing left. Turn RIGHT" : "Turn a bit more RIGHT");
|
|
254
265
|
}
|
|
255
266
|
}
|
|
267
|
+
} else if (state.stage === "DONE") {
|
|
268
|
+
// In DONE stage, wait for face to be straight before capturing
|
|
269
|
+
if (absYaw < centerThreshold && insideGuide) {
|
|
270
|
+
state.centerHold += 1;
|
|
271
|
+
if (state.centerHold >= holdFramesCenter && !state.snapTriggered) {
|
|
272
|
+
state.snapTriggered = true;
|
|
273
|
+
if (this.callbacks.onLivenessUpdate) {
|
|
274
|
+
this.callbacks.onLivenessUpdate(state.stage, "Capturing...");
|
|
275
|
+
}
|
|
276
|
+
if (this.callbacks.onCaptureTrigger) {
|
|
277
|
+
this.callbacks.onCaptureTrigger();
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
state.centerHold = 0;
|
|
282
|
+
if (this.callbacks.onLivenessUpdate) {
|
|
283
|
+
if (!insideGuide) {
|
|
284
|
+
this.callbacks.onLivenessUpdate(state.stage, "Center your face inside the circle");
|
|
285
|
+
} else {
|
|
286
|
+
this.callbacks.onLivenessUpdate(state.stage, "Please look straight at the camera");
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
256
290
|
}
|
|
257
291
|
}
|
|
258
292
|
}
|
|
@@ -114,7 +114,11 @@ export class KycApiService {
|
|
|
114
114
|
if (!response.ok) {
|
|
115
115
|
const errorData = await response.json().catch(() => ({}));
|
|
116
116
|
const message = errorData?.message || `Face upload failed with status ${response.status}`;
|
|
117
|
-
|
|
117
|
+
// Preserve the original error message for better error handling
|
|
118
|
+
const error = new Error(message);
|
|
119
|
+
(error as any).statusCode = response.status;
|
|
120
|
+
(error as any).errorData = errorData;
|
|
121
|
+
throw error;
|
|
118
122
|
}
|
|
119
123
|
|
|
120
124
|
const data = await response.json();
|