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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astra-sdk-web",
3
- "version": "1.1.6",
3
+ "version": "1.1.8",
4
4
  "description": "Official Astra SDK for JavaScript/TypeScript",
5
5
  "type": "module",
6
6
  "main": "./dist/astra-sdk.cjs.js",
@@ -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
- await apiService.uploadFaceScan(blob);
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
- <div className="fixed inset-0 bg-black p-5 z-[1000] flex items-center justify-center font-sans overflow-y-auto custom__scrollbar">
151
- <div className="max-w-[400px] w-full mx-auto bg-[#0b0f17] rounded-2xl p-6 shadow-xl mt-48">
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
- const newStage: LivenessStage = "LEFT";
196
- state.stage = newStage;
197
- state.centerHold = 0;
198
- if (this.callbacks.onLivenessUpdate) {
199
- this.callbacks.onLivenessUpdate(newStage, "Turn your face LEFT");
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
- if (!state.snapTriggered) {
239
- state.snapTriggered = true;
240
- const newStage: LivenessStage = "DONE";
241
- state.stage = newStage;
242
- if (this.callbacks.onLivenessUpdate) {
243
- this.callbacks.onLivenessUpdate(newStage, "Capturing...");
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
- throw new Error(message);
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();