astra-sdk-web 1.1.0 → 1.1.2
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/.htaccess +9 -0
- package/dist/astra-sdk.cjs.js +1719 -1
- package/dist/astra-sdk.cjs.js.map +1 -1
- package/dist/astra-sdk.css +934 -0
- package/dist/astra-sdk.css.map +1 -0
- package/dist/astra-sdk.d.cts +155 -0
- package/dist/astra-sdk.es.js +1706 -1
- package/dist/astra-sdk.es.js.map +1 -1
- package/dist/components.cjs.js +1473 -0
- package/dist/components.cjs.js.map +1 -0
- package/dist/components.css +934 -0
- package/dist/components.css.map +1 -0
- package/dist/components.d.cts +13 -0
- package/dist/components.d.ts +13 -0
- package/dist/components.es.js +1467 -0
- package/dist/components.es.js.map +1 -0
- package/dist/index.d.ts +155 -115
- package/package.json +13 -2
- package/src/components/KycFlow.tsx +3 -3
- package/src/pages/DocumentUploadModal.tsx +1 -1
- package/src/sdk/index.ts +13 -0
- package/dist/astra-sdk.umd.js +0 -2
- package/dist/astra-sdk.umd.js.map +0 -1
- package/dist/vite.svg +0 -1
|
@@ -0,0 +1,1467 @@
|
|
|
1
|
+
import React, { createContext, useState, useEffect, useRef, useContext, useCallback } from 'react';
|
|
2
|
+
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
3
|
+
import { MemoryRouter, Routes, Route, Navigate, useSearchParams, useNavigate } from 'react-router-dom';
|
|
4
|
+
import { FACEMESH_TESSELATION, FACEMESH_FACE_OVAL, FACEMESH_LEFT_EYE, FACEMESH_RIGHT_EYE, FACEMESH_LIPS, FaceMesh } from '@mediapipe/face_mesh';
|
|
5
|
+
import { drawConnectors, drawLandmarks } from '@mediapipe/drawing_utils';
|
|
6
|
+
import { QRCodeSVG } from 'qrcode.react';
|
|
7
|
+
|
|
8
|
+
// src/components/KycFlow.tsx
|
|
9
|
+
|
|
10
|
+
// src/services/kycApiService.ts
|
|
11
|
+
var KycApiService = class {
|
|
12
|
+
config;
|
|
13
|
+
constructor(config) {
|
|
14
|
+
this.config = config;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Detect device type
|
|
18
|
+
*/
|
|
19
|
+
detectDeviceType() {
|
|
20
|
+
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
|
|
21
|
+
if (/android/i.test(userAgent)) return "android";
|
|
22
|
+
if (/iPad|iPhone|iPod/.test(userAgent) && !window.MSStream) return "ios";
|
|
23
|
+
if (/Mac|Windows|Linux/.test(userAgent)) return "desktop";
|
|
24
|
+
return "unknown";
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Get session status
|
|
28
|
+
*/
|
|
29
|
+
async getSessionStatus() {
|
|
30
|
+
const deviceType = this.config.deviceType || this.detectDeviceType();
|
|
31
|
+
try {
|
|
32
|
+
const response = await fetch(
|
|
33
|
+
`${this.config.apiBaseUrl}/api/v2/dashboard/merchant/onsite/session/${this.config.sessionId}/status`,
|
|
34
|
+
{
|
|
35
|
+
method: "GET",
|
|
36
|
+
headers: {
|
|
37
|
+
"x-server-key": this.config.serverKey,
|
|
38
|
+
"device-type": deviceType,
|
|
39
|
+
"Content-Type": "application/json"
|
|
40
|
+
},
|
|
41
|
+
credentials: "include"
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
const errorData = await response.json().catch(() => ({}));
|
|
46
|
+
const message = errorData?.message || `Status fetch failed with status ${response.status}`;
|
|
47
|
+
throw new Error(message);
|
|
48
|
+
}
|
|
49
|
+
const data = await response.json();
|
|
50
|
+
return data;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
const message = error?.message || "Status fetch failed";
|
|
53
|
+
throw new Error(`Status fetch failed: ${message}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Upload face scan image
|
|
58
|
+
*/
|
|
59
|
+
async uploadFaceScan(faceBlob) {
|
|
60
|
+
await this.checkSessionActive();
|
|
61
|
+
const deviceType = this.config.deviceType || this.detectDeviceType();
|
|
62
|
+
const formData = new FormData();
|
|
63
|
+
const faceFileName = faceBlob?.name || `face-${Date.now()}.jpg`;
|
|
64
|
+
formData.append("face_scan_img", faceBlob, faceFileName);
|
|
65
|
+
try {
|
|
66
|
+
const response = await fetch(
|
|
67
|
+
`${this.config.apiBaseUrl}/api/v2/dashboard/merchant/onsite/session/${this.config.sessionId}/face`,
|
|
68
|
+
{
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: {
|
|
71
|
+
"x-server-key": this.config.serverKey,
|
|
72
|
+
"device-type": deviceType
|
|
73
|
+
},
|
|
74
|
+
credentials: "include",
|
|
75
|
+
body: formData
|
|
76
|
+
}
|
|
77
|
+
);
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
const errorData = await response.json().catch(() => ({}));
|
|
80
|
+
const message = errorData?.message || `Face upload failed with status ${response.status}`;
|
|
81
|
+
throw new Error(message);
|
|
82
|
+
}
|
|
83
|
+
const data = await response.json();
|
|
84
|
+
return data;
|
|
85
|
+
} catch (error) {
|
|
86
|
+
const message = error?.message || "Face upload failed";
|
|
87
|
+
throw new Error(`Face upload failed: ${message}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Upload document scan image
|
|
92
|
+
*/
|
|
93
|
+
async uploadDocument(docBlob, docType) {
|
|
94
|
+
await this.checkSessionActive();
|
|
95
|
+
const deviceType = this.config.deviceType || this.detectDeviceType();
|
|
96
|
+
const formData = new FormData();
|
|
97
|
+
const docFileName = docBlob?.name || `document-${Date.now()}.jpg`;
|
|
98
|
+
formData.append("docs_scan_img", docBlob, docFileName);
|
|
99
|
+
formData.append("docType", docType);
|
|
100
|
+
try {
|
|
101
|
+
const response = await fetch(
|
|
102
|
+
`${this.config.apiBaseUrl}/api/v2/dashboard/merchant/onsite/session/${this.config.sessionId}/docs`,
|
|
103
|
+
{
|
|
104
|
+
method: "POST",
|
|
105
|
+
headers: {
|
|
106
|
+
"x-server-key": this.config.serverKey,
|
|
107
|
+
"device-type": deviceType
|
|
108
|
+
},
|
|
109
|
+
credentials: "include",
|
|
110
|
+
body: formData
|
|
111
|
+
}
|
|
112
|
+
);
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
const errorData = await response.json().catch(() => ({}));
|
|
115
|
+
const message = errorData?.message || `Document upload failed with status ${response.status}`;
|
|
116
|
+
throw new Error(message);
|
|
117
|
+
}
|
|
118
|
+
const data = await response.json();
|
|
119
|
+
return data;
|
|
120
|
+
} catch (error) {
|
|
121
|
+
const message = error?.message || "Document upload failed";
|
|
122
|
+
throw new Error(`Document upload failed: ${message}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Check if session is active, throw error if not
|
|
127
|
+
*/
|
|
128
|
+
async checkSessionActive() {
|
|
129
|
+
const status = await this.getSessionStatus();
|
|
130
|
+
if (status.data.status !== "ACTIVE") {
|
|
131
|
+
throw new Error("Session expired or inactive. Please start a new session.");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Update configuration
|
|
136
|
+
*/
|
|
137
|
+
updateConfig(config) {
|
|
138
|
+
this.config = { ...this.config, ...config };
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Get current configuration
|
|
142
|
+
*/
|
|
143
|
+
getConfig() {
|
|
144
|
+
return { ...this.config };
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
var KycContext = createContext({
|
|
148
|
+
apiService: null,
|
|
149
|
+
setApiConfig: () => {
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
var useKycContext = () => {
|
|
153
|
+
const context = useContext(KycContext);
|
|
154
|
+
if (!context) {
|
|
155
|
+
throw new Error("useKycContext must be used within KycProvider");
|
|
156
|
+
}
|
|
157
|
+
return context;
|
|
158
|
+
};
|
|
159
|
+
var KycProvider = ({
|
|
160
|
+
children,
|
|
161
|
+
apiBaseUrl,
|
|
162
|
+
sessionId,
|
|
163
|
+
serverKey,
|
|
164
|
+
deviceType
|
|
165
|
+
}) => {
|
|
166
|
+
const [apiService, setApiService] = React.useState(null);
|
|
167
|
+
React.useEffect(() => {
|
|
168
|
+
if (apiBaseUrl && sessionId && serverKey) {
|
|
169
|
+
const service = new KycApiService({
|
|
170
|
+
apiBaseUrl,
|
|
171
|
+
sessionId,
|
|
172
|
+
serverKey,
|
|
173
|
+
deviceType
|
|
174
|
+
});
|
|
175
|
+
setApiService(service);
|
|
176
|
+
}
|
|
177
|
+
}, [apiBaseUrl, sessionId, serverKey, deviceType]);
|
|
178
|
+
const setApiConfig = React.useCallback((config) => {
|
|
179
|
+
if (apiService) {
|
|
180
|
+
apiService.updateConfig(config);
|
|
181
|
+
} else {
|
|
182
|
+
const service = new KycApiService(config);
|
|
183
|
+
setApiService(service);
|
|
184
|
+
}
|
|
185
|
+
}, [apiService]);
|
|
186
|
+
return /* @__PURE__ */ jsx(KycContext.Provider, { value: { apiService, setApiConfig }, children });
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// src/utils/deviceDetection.ts
|
|
190
|
+
function isMobileDevice() {
|
|
191
|
+
if (typeof window === "undefined") {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
|
|
195
|
+
const mobileRegex = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i;
|
|
196
|
+
const isSmallScreen = window.innerWidth <= 768;
|
|
197
|
+
const hasTouchScreen = "ontouchstart" in window || navigator.maxTouchPoints > 0;
|
|
198
|
+
return mobileRegex.test(userAgent) || isSmallScreen && hasTouchScreen;
|
|
199
|
+
}
|
|
200
|
+
function useDocumentUpload(callbacks) {
|
|
201
|
+
const [state, setState] = useState({
|
|
202
|
+
docType: "CNIC",
|
|
203
|
+
isDocScanMode: false,
|
|
204
|
+
docPreviewUrl: null,
|
|
205
|
+
docFileName: "",
|
|
206
|
+
loading: false
|
|
207
|
+
});
|
|
208
|
+
const fileInputRef = useRef(null);
|
|
209
|
+
const docVideoRef = useRef(null);
|
|
210
|
+
const docStreamRef = useRef(null);
|
|
211
|
+
const docPendingBlobRef = useRef(null);
|
|
212
|
+
const getRearStream = async () => {
|
|
213
|
+
const attempts = [
|
|
214
|
+
{ video: { facingMode: { exact: "environment" }, width: { ideal: 1280 }, height: { ideal: 1920 } }, audio: false },
|
|
215
|
+
{ video: { facingMode: "environment", width: { ideal: 1280 }, height: { ideal: 1920 } }, audio: false },
|
|
216
|
+
{ video: { facingMode: { exact: "environment" }, width: { ideal: 720 }, height: { ideal: 1280 } }, audio: false },
|
|
217
|
+
{ video: { facingMode: "environment", width: { ideal: 720 }, height: { ideal: 1280 } }, audio: false }
|
|
218
|
+
];
|
|
219
|
+
for (const c of attempts) {
|
|
220
|
+
try {
|
|
221
|
+
return await navigator.mediaDevices.getUserMedia(c);
|
|
222
|
+
} catch {
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
let temp = null;
|
|
226
|
+
try {
|
|
227
|
+
temp = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
|
|
228
|
+
} catch (e) {
|
|
229
|
+
throw e;
|
|
230
|
+
} finally {
|
|
231
|
+
try {
|
|
232
|
+
temp?.getTracks().forEach((t) => t.stop());
|
|
233
|
+
} catch {
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
237
|
+
const videoInputs = devices.filter((d) => d.kind === "videoinput");
|
|
238
|
+
const rear = videoInputs.find((d) => /back|rear|environment/i.test(d.label));
|
|
239
|
+
const chosen = rear || videoInputs.find((d) => !/front|user|face/i.test(d.label)) || videoInputs[0];
|
|
240
|
+
if (!chosen) throw new Error("No video input devices found");
|
|
241
|
+
const byDeviceAttempts = [
|
|
242
|
+
{ video: { deviceId: { exact: chosen.deviceId }, width: { ideal: 1280 }, height: { ideal: 1920 } }, audio: false },
|
|
243
|
+
{ video: { deviceId: { exact: chosen.deviceId }, width: { ideal: 720 }, height: { ideal: 1280 } }, audio: false },
|
|
244
|
+
{ video: { deviceId: { exact: chosen.deviceId } }, audio: false }
|
|
245
|
+
];
|
|
246
|
+
for (const c of byDeviceAttempts) {
|
|
247
|
+
try {
|
|
248
|
+
return await navigator.mediaDevices.getUserMedia(c);
|
|
249
|
+
} catch {
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
|
|
253
|
+
};
|
|
254
|
+
const startDocCamera = async () => {
|
|
255
|
+
try {
|
|
256
|
+
if (docStreamRef.current) {
|
|
257
|
+
try {
|
|
258
|
+
docStreamRef.current.getTracks().forEach((t) => t.stop());
|
|
259
|
+
} catch {
|
|
260
|
+
}
|
|
261
|
+
docStreamRef.current = null;
|
|
262
|
+
}
|
|
263
|
+
const stream = await getRearStream();
|
|
264
|
+
if (docVideoRef.current) {
|
|
265
|
+
docVideoRef.current.srcObject = stream;
|
|
266
|
+
await docVideoRef.current.play().catch(() => {
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
docStreamRef.current = stream;
|
|
270
|
+
} catch (e) {
|
|
271
|
+
console.error("Could not start document camera:", e);
|
|
272
|
+
setState((prev) => ({ ...prev, isDocScanMode: false }));
|
|
273
|
+
if (callbacks?.onError) {
|
|
274
|
+
callbacks.onError(e);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
const handleDocumentUpload = (event) => {
|
|
279
|
+
if (state.loading) return;
|
|
280
|
+
const file = event.target.files?.[0];
|
|
281
|
+
if (!file) return;
|
|
282
|
+
setState((prev) => ({ ...prev, docFileName: file.name || "" }));
|
|
283
|
+
try {
|
|
284
|
+
const objectUrl = URL.createObjectURL(file);
|
|
285
|
+
setState((prev) => ({ ...prev, docPreviewUrl: objectUrl }));
|
|
286
|
+
docPendingBlobRef.current = file;
|
|
287
|
+
} catch (err) {
|
|
288
|
+
console.error("Could not preview document:", err);
|
|
289
|
+
if (callbacks?.onError) {
|
|
290
|
+
callbacks.onError(err);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
const handleConfirmDocumentUpload = async () => {
|
|
295
|
+
if (state.loading || !docPendingBlobRef.current) return;
|
|
296
|
+
setState((prev) => ({ ...prev, loading: true }));
|
|
297
|
+
try {
|
|
298
|
+
const file = docPendingBlobRef.current;
|
|
299
|
+
if (callbacks?.onDocumentUpload) {
|
|
300
|
+
try {
|
|
301
|
+
await callbacks.onDocumentUpload(file, state.docType);
|
|
302
|
+
} catch (uploadError) {
|
|
303
|
+
throw new Error(uploadError.message || "Failed to upload document");
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (callbacks?.onUpload) {
|
|
307
|
+
callbacks.onUpload(file, state.docType);
|
|
308
|
+
}
|
|
309
|
+
setState((prev) => ({
|
|
310
|
+
...prev,
|
|
311
|
+
docPreviewUrl: null,
|
|
312
|
+
docFileName: "",
|
|
313
|
+
loading: false
|
|
314
|
+
}));
|
|
315
|
+
docPendingBlobRef.current = null;
|
|
316
|
+
} catch (err) {
|
|
317
|
+
console.error("Document upload failed:", err);
|
|
318
|
+
setState((prev) => ({ ...prev, loading: false }));
|
|
319
|
+
if (callbacks?.onError) {
|
|
320
|
+
callbacks.onError(err);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
const handleManualCapture = async () => {
|
|
325
|
+
if (!docVideoRef.current || state.loading) return;
|
|
326
|
+
setState((prev) => ({ ...prev, loading: true }));
|
|
327
|
+
try {
|
|
328
|
+
const video = docVideoRef.current;
|
|
329
|
+
const canvas = document.createElement("canvas");
|
|
330
|
+
const width = video.videoWidth || 1280;
|
|
331
|
+
const height = video.videoHeight || 720;
|
|
332
|
+
canvas.width = width;
|
|
333
|
+
canvas.height = height;
|
|
334
|
+
const ctx = canvas.getContext("2d");
|
|
335
|
+
if (!ctx) throw new Error("Canvas not supported");
|
|
336
|
+
ctx.drawImage(video, 0, 0, width, height);
|
|
337
|
+
const blob = await new Promise((resolve) => {
|
|
338
|
+
canvas.toBlob((blob2) => {
|
|
339
|
+
resolve(blob2 || new Blob());
|
|
340
|
+
}, "image/jpeg", 0.92);
|
|
341
|
+
});
|
|
342
|
+
const file = new File([blob], "document.jpg", { type: "image/jpeg" });
|
|
343
|
+
if (callbacks?.onDocumentUpload) {
|
|
344
|
+
try {
|
|
345
|
+
await callbacks.onDocumentUpload(file, state.docType);
|
|
346
|
+
} catch (uploadError) {
|
|
347
|
+
throw new Error(uploadError.message || "Failed to upload document");
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const objectUrl = URL.createObjectURL(file);
|
|
351
|
+
setState((prev) => ({
|
|
352
|
+
...prev,
|
|
353
|
+
docPreviewUrl: objectUrl,
|
|
354
|
+
isDocScanMode: false,
|
|
355
|
+
loading: false
|
|
356
|
+
}));
|
|
357
|
+
docPendingBlobRef.current = file;
|
|
358
|
+
if (docStreamRef.current) {
|
|
359
|
+
docStreamRef.current.getTracks().forEach((t) => t.stop());
|
|
360
|
+
docStreamRef.current = null;
|
|
361
|
+
}
|
|
362
|
+
if (callbacks?.onScan) {
|
|
363
|
+
callbacks.onScan(file, state.docType);
|
|
364
|
+
}
|
|
365
|
+
} catch (err) {
|
|
366
|
+
console.error("Capture failed:", err);
|
|
367
|
+
setState((prev) => ({ ...prev, loading: false }));
|
|
368
|
+
if (callbacks?.onError) {
|
|
369
|
+
callbacks.onError(err);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
useEffect(() => {
|
|
374
|
+
if (state.isDocScanMode) {
|
|
375
|
+
startDocCamera();
|
|
376
|
+
}
|
|
377
|
+
return () => {
|
|
378
|
+
if (docStreamRef.current) {
|
|
379
|
+
docStreamRef.current.getTracks().forEach((t) => t.stop());
|
|
380
|
+
docStreamRef.current = null;
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
}, [state.isDocScanMode]);
|
|
384
|
+
return {
|
|
385
|
+
state,
|
|
386
|
+
setState,
|
|
387
|
+
fileInputRef,
|
|
388
|
+
docVideoRef,
|
|
389
|
+
handleDocumentUpload,
|
|
390
|
+
handleConfirmDocumentUpload,
|
|
391
|
+
handleManualCapture,
|
|
392
|
+
startDocCamera
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
function DocumentUploadModal({ onComplete }) {
|
|
396
|
+
const navigate = useNavigate();
|
|
397
|
+
const { apiService } = useKycContext();
|
|
398
|
+
const [sessionError, setSessionError] = useState(null);
|
|
399
|
+
const {
|
|
400
|
+
state,
|
|
401
|
+
setState,
|
|
402
|
+
fileInputRef,
|
|
403
|
+
docVideoRef,
|
|
404
|
+
handleDocumentUpload,
|
|
405
|
+
handleConfirmDocumentUpload,
|
|
406
|
+
handleManualCapture,
|
|
407
|
+
startDocCamera
|
|
408
|
+
} = useDocumentUpload({
|
|
409
|
+
onDocumentUpload: async (blob, docType) => {
|
|
410
|
+
if (!apiService) {
|
|
411
|
+
throw new Error("API service not initialized");
|
|
412
|
+
}
|
|
413
|
+
await apiService.uploadDocument(blob, docType);
|
|
414
|
+
},
|
|
415
|
+
onUpload: (file, docType) => {
|
|
416
|
+
if (onComplete) {
|
|
417
|
+
onComplete(file, docType);
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
onScan: (file, docType) => {
|
|
421
|
+
if (onComplete) {
|
|
422
|
+
onComplete(file, docType);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
useEffect(() => {
|
|
427
|
+
const checkSession = async () => {
|
|
428
|
+
if (!apiService) return;
|
|
429
|
+
try {
|
|
430
|
+
await apiService.checkSessionActive();
|
|
431
|
+
setSessionError(null);
|
|
432
|
+
} catch (error) {
|
|
433
|
+
const message = error.message || "Session expired or inactive";
|
|
434
|
+
setSessionError(message);
|
|
435
|
+
setTimeout(() => {
|
|
436
|
+
navigate("/qr", { replace: true });
|
|
437
|
+
}, 2e3);
|
|
438
|
+
}
|
|
439
|
+
};
|
|
440
|
+
checkSession();
|
|
441
|
+
}, [apiService, navigate]);
|
|
442
|
+
if (sessionError) {
|
|
443
|
+
return /* @__PURE__ */ jsx("div", { className: "fixed inset-0 bg-black p-4 z-[1000] flex items-center justify-center font-sans overflow-y-auto custom__scrollbar", children: /* @__PURE__ */ jsx("div", { className: "max-w-[400px] w-full mx-auto bg-[#0b0f17] rounded-2xl p-6 shadow-xl", children: /* @__PURE__ */ jsxs("div", { className: "text-center", children: [
|
|
444
|
+
/* @__PURE__ */ jsx("h2", { className: "m-0 mb-4 text-[26px] font-bold text-red-500", children: "Session Expired" }),
|
|
445
|
+
/* @__PURE__ */ jsx("p", { className: "text-[#e5e7eb] mb-4", children: sessionError }),
|
|
446
|
+
/* @__PURE__ */ jsx("p", { className: "text-[#9ca3af] text-sm", children: "Redirecting to QR code page..." })
|
|
447
|
+
] }) }) });
|
|
448
|
+
}
|
|
449
|
+
return /* @__PURE__ */ jsx("div", { className: "fixed inset-0 bg-black p-5 z-[1000] flex items-center justify-center font-sans overflow-y-auto custom__scrollbar", children: /* @__PURE__ */ jsxs(
|
|
450
|
+
"div",
|
|
451
|
+
{
|
|
452
|
+
className: "max-w-[400px] w-full mx-auto bg-[#0b0f17] rounded-2xl p-6 shadow-xl",
|
|
453
|
+
style: {
|
|
454
|
+
marginTop: state.docPreviewUrl && !state.isDocScanMode ? "192px" : "0"
|
|
455
|
+
},
|
|
456
|
+
children: [
|
|
457
|
+
/* @__PURE__ */ jsx("div", { className: "relative mb-4", children: /* @__PURE__ */ jsx("h2", { className: "m-0 mb-4 text-xl font-bold text-white", children: "Document" }) }),
|
|
458
|
+
!state.isDocScanMode && /* @__PURE__ */ jsxs("div", { className: "grid gap-2 mb-3", children: [
|
|
459
|
+
/* @__PURE__ */ jsx(
|
|
460
|
+
"button",
|
|
461
|
+
{
|
|
462
|
+
type: "button",
|
|
463
|
+
onClick: () => fileInputRef.current?.click(),
|
|
464
|
+
className: "w-full py-3 px-4 rounded-lg border-2 border-dashed border-[#374151] bg-[#0f172a] text-[#e5e7eb] text-base cursor-pointer hover:border-[#4b5563] transition-colors",
|
|
465
|
+
children: "Upload Document"
|
|
466
|
+
}
|
|
467
|
+
),
|
|
468
|
+
/* @__PURE__ */ jsx(
|
|
469
|
+
"button",
|
|
470
|
+
{
|
|
471
|
+
type: "button",
|
|
472
|
+
onClick: () => {
|
|
473
|
+
setState((prev) => ({ ...prev, isDocScanMode: true }));
|
|
474
|
+
startDocCamera();
|
|
475
|
+
},
|
|
476
|
+
className: "w-full py-3 px-4 rounded-lg text-white border-none text-base cursor-pointer transition-opacity hover:opacity-90",
|
|
477
|
+
style: {
|
|
478
|
+
background: "linear-gradient(90deg, #FF8A00 0%, #FF3D77 100%)"
|
|
479
|
+
},
|
|
480
|
+
children: "Scan Document (Back Camera)"
|
|
481
|
+
}
|
|
482
|
+
)
|
|
483
|
+
] }),
|
|
484
|
+
/* @__PURE__ */ jsxs("div", { className: "flex gap-3 mb-4", children: [
|
|
485
|
+
/* @__PURE__ */ jsxs("label", { className: "flex items-center gap-1.5 text-[#e5e7eb] cursor-pointer", children: [
|
|
486
|
+
/* @__PURE__ */ jsx(
|
|
487
|
+
"input",
|
|
488
|
+
{
|
|
489
|
+
type: "radio",
|
|
490
|
+
name: "doc-type",
|
|
491
|
+
value: "CNIC",
|
|
492
|
+
checked: state.docType === "CNIC",
|
|
493
|
+
defaultChecked: true,
|
|
494
|
+
onChange: () => setState((prev) => ({ ...prev, docType: "CNIC" })),
|
|
495
|
+
className: "cursor-pointer w-4 h-4",
|
|
496
|
+
style: {
|
|
497
|
+
accentColor: state.docType === "CNIC" ? "#ef4444" : "#6b7280"
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
),
|
|
501
|
+
"NIC"
|
|
502
|
+
] }),
|
|
503
|
+
/* @__PURE__ */ jsxs("label", { className: "flex items-center gap-1.5 text-[#e5e7eb] cursor-pointer", children: [
|
|
504
|
+
/* @__PURE__ */ jsx(
|
|
505
|
+
"input",
|
|
506
|
+
{
|
|
507
|
+
type: "radio",
|
|
508
|
+
name: "doc-type",
|
|
509
|
+
value: "Passport",
|
|
510
|
+
checked: state.docType === "Passport",
|
|
511
|
+
onChange: () => setState((prev) => ({ ...prev, docType: "Passport" })),
|
|
512
|
+
className: "cursor-pointer w-4 h-4",
|
|
513
|
+
style: {
|
|
514
|
+
accentColor: state.docType === "Passport" ? "#ef4444" : "#6b7280"
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
),
|
|
518
|
+
"Passport"
|
|
519
|
+
] })
|
|
520
|
+
] }),
|
|
521
|
+
/* @__PURE__ */ jsx(
|
|
522
|
+
"input",
|
|
523
|
+
{
|
|
524
|
+
ref: fileInputRef,
|
|
525
|
+
type: "file",
|
|
526
|
+
accept: "image/*,.pdf",
|
|
527
|
+
onChange: handleDocumentUpload,
|
|
528
|
+
className: "hidden"
|
|
529
|
+
}
|
|
530
|
+
),
|
|
531
|
+
state.isDocScanMode && /* @__PURE__ */ jsxs("div", { className: "grid gap-3 mt-2", children: [
|
|
532
|
+
/* @__PURE__ */ jsx("div", { className: "relative w-full aspect-[3/4] rounded-lg overflow-hidden bg-black", children: /* @__PURE__ */ jsx(
|
|
533
|
+
"video",
|
|
534
|
+
{
|
|
535
|
+
ref: docVideoRef,
|
|
536
|
+
playsInline: true,
|
|
537
|
+
muted: true,
|
|
538
|
+
autoPlay: true,
|
|
539
|
+
className: "w-full h-full object-cover"
|
|
540
|
+
}
|
|
541
|
+
) }),
|
|
542
|
+
/* @__PURE__ */ jsxs("div", { className: "flex gap-2", children: [
|
|
543
|
+
/* @__PURE__ */ jsx(
|
|
544
|
+
"button",
|
|
545
|
+
{
|
|
546
|
+
type: "button",
|
|
547
|
+
onClick: handleManualCapture,
|
|
548
|
+
disabled: state.loading,
|
|
549
|
+
className: "flex-1 py-3 px-4 rounded-lg text-white border-none text-base cursor-pointer transition-opacity disabled:opacity-50 disabled:cursor-not-allowed hover:opacity-90",
|
|
550
|
+
style: {
|
|
551
|
+
background: "linear-gradient(90deg, #FF8A00 0%, #FF3D77 100%)"
|
|
552
|
+
},
|
|
553
|
+
children: state.loading ? "Capturing..." : "Capture Document"
|
|
554
|
+
}
|
|
555
|
+
),
|
|
556
|
+
/* @__PURE__ */ jsx(
|
|
557
|
+
"button",
|
|
558
|
+
{
|
|
559
|
+
type: "button",
|
|
560
|
+
onClick: () => {
|
|
561
|
+
setState((prev) => ({ ...prev, isDocScanMode: false }));
|
|
562
|
+
},
|
|
563
|
+
disabled: state.loading,
|
|
564
|
+
className: "py-3 px-4 rounded-lg bg-[#111827] text-[#e5e7eb] border-none text-base cursor-pointer hover:bg-[#1f2937] transition-colors disabled:opacity-50",
|
|
565
|
+
children: "Cancel"
|
|
566
|
+
}
|
|
567
|
+
)
|
|
568
|
+
] }),
|
|
569
|
+
/* @__PURE__ */ jsx("p", { className: "m-0 text-[#9ca3af] text-xs text-center", children: state.loading ? "Processing document..." : "Position your document in the frame and tap 'Capture Document' when ready." })
|
|
570
|
+
] }),
|
|
571
|
+
state.docFileName && !state.isDocScanMode && /* @__PURE__ */ jsxs("div", { className: "inline-flex items-center gap-2 py-2 px-3 rounded-full bg-[#1f2937] text-[#e5e7eb] text-sm mb-3", children: [
|
|
572
|
+
/* @__PURE__ */ jsx("span", { children: "\u2714" }),
|
|
573
|
+
/* @__PURE__ */ jsx("span", { children: "File selected" })
|
|
574
|
+
] }),
|
|
575
|
+
state.loading && !state.isDocScanMode && /* @__PURE__ */ jsx("p", { className: "m-3 mt-0 text-[#60a5fa] text-center", children: "Processing..." }),
|
|
576
|
+
!state.isDocScanMode && state.docPreviewUrl && /* @__PURE__ */ jsxs("div", { className: "mt-3", children: [
|
|
577
|
+
/* @__PURE__ */ jsx("div", { className: "font-semibold mb-1.5 text-[#e5e7eb]", children: "Preview" }),
|
|
578
|
+
/* @__PURE__ */ jsx("img", { src: state.docPreviewUrl, alt: "Document preview", className: "w-full rounded-lg border border-[#374151]" }),
|
|
579
|
+
/* @__PURE__ */ jsxs("div", { className: "grid gap-2 grid-cols-2 mt-2", children: [
|
|
580
|
+
/* @__PURE__ */ jsx(
|
|
581
|
+
"button",
|
|
582
|
+
{
|
|
583
|
+
type: "button",
|
|
584
|
+
onClick: handleConfirmDocumentUpload,
|
|
585
|
+
disabled: state.loading,
|
|
586
|
+
className: "py-3 px-4 rounded-lg text-white border-none text-base cursor-pointer w-full transition-opacity disabled:opacity-50 disabled:cursor-not-allowed hover:opacity-90",
|
|
587
|
+
style: {
|
|
588
|
+
background: "linear-gradient(90deg, #10b981 0%, #059669 100%)"
|
|
589
|
+
},
|
|
590
|
+
children: state.loading ? "Uploading..." : "Looks good, continue"
|
|
591
|
+
}
|
|
592
|
+
),
|
|
593
|
+
/* @__PURE__ */ jsx(
|
|
594
|
+
"button",
|
|
595
|
+
{
|
|
596
|
+
type: "button",
|
|
597
|
+
onClick: () => {
|
|
598
|
+
setState((prev) => ({
|
|
599
|
+
...prev,
|
|
600
|
+
docPreviewUrl: null,
|
|
601
|
+
docFileName: ""
|
|
602
|
+
}));
|
|
603
|
+
},
|
|
604
|
+
disabled: state.loading,
|
|
605
|
+
className: "py-3 px-4 rounded-lg bg-[#111827] text-[#e5e7eb] border-none text-base cursor-pointer w-full hover:bg-[#1f2937] transition-colors disabled:opacity-50",
|
|
606
|
+
children: "Retake / Choose again"
|
|
607
|
+
}
|
|
608
|
+
)
|
|
609
|
+
] })
|
|
610
|
+
] }),
|
|
611
|
+
!state.isDocScanMode && !state.docFileName && !state.docPreviewUrl && /* @__PURE__ */ jsx("p", { className: "m-0 text-[#9ca3af] text-xs text-center", children: "After uploading or scanning, return to your desktop to check status." })
|
|
612
|
+
]
|
|
613
|
+
}
|
|
614
|
+
) });
|
|
615
|
+
}
|
|
616
|
+
var DocumentUploadModal_default = DocumentUploadModal;
|
|
617
|
+
function useCamera() {
|
|
618
|
+
const videoRef = useRef(null);
|
|
619
|
+
const streamRef = useRef(null);
|
|
620
|
+
const [cameraReady, setCameraReady] = useState(false);
|
|
621
|
+
const startCamera = async () => {
|
|
622
|
+
try {
|
|
623
|
+
const stream = await navigator.mediaDevices.getUserMedia({
|
|
624
|
+
video: {
|
|
625
|
+
facingMode: "user",
|
|
626
|
+
width: { ideal: 1280 },
|
|
627
|
+
height: { ideal: 720 }
|
|
628
|
+
},
|
|
629
|
+
audio: false
|
|
630
|
+
});
|
|
631
|
+
if (videoRef.current) {
|
|
632
|
+
videoRef.current.srcObject = stream;
|
|
633
|
+
await videoRef.current.play().catch(() => {
|
|
634
|
+
});
|
|
635
|
+
requestAnimationFrame(() => {
|
|
636
|
+
try {
|
|
637
|
+
const v = videoRef.current;
|
|
638
|
+
} catch {
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
streamRef.current = stream;
|
|
643
|
+
setCameraReady(true);
|
|
644
|
+
} catch (e) {
|
|
645
|
+
console.error("Camera access error:", e);
|
|
646
|
+
setCameraReady(false);
|
|
647
|
+
throw e;
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
const stopCamera = () => {
|
|
651
|
+
if (streamRef.current) {
|
|
652
|
+
streamRef.current.getTracks().forEach((t) => t.stop());
|
|
653
|
+
streamRef.current = null;
|
|
654
|
+
}
|
|
655
|
+
if (videoRef.current) {
|
|
656
|
+
videoRef.current.srcObject = null;
|
|
657
|
+
}
|
|
658
|
+
setCameraReady(false);
|
|
659
|
+
};
|
|
660
|
+
useEffect(() => {
|
|
661
|
+
startCamera();
|
|
662
|
+
return () => {
|
|
663
|
+
stopCamera();
|
|
664
|
+
};
|
|
665
|
+
}, []);
|
|
666
|
+
return {
|
|
667
|
+
videoRef,
|
|
668
|
+
cameraReady,
|
|
669
|
+
startCamera,
|
|
670
|
+
stopCamera
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
var FaceMeshService = class {
|
|
674
|
+
faceMesh = null;
|
|
675
|
+
videoRef;
|
|
676
|
+
canvasRef;
|
|
677
|
+
callbacks;
|
|
678
|
+
cameraDriverRef;
|
|
679
|
+
livenessStateRef;
|
|
680
|
+
cancelled = false;
|
|
681
|
+
constructor(videoRef, canvasRef, cameraDriverRef, livenessStateRef, callbacks) {
|
|
682
|
+
this.videoRef = videoRef;
|
|
683
|
+
this.canvasRef = canvasRef;
|
|
684
|
+
this.cameraDriverRef = cameraDriverRef;
|
|
685
|
+
this.livenessStateRef = livenessStateRef;
|
|
686
|
+
this.callbacks = callbacks;
|
|
687
|
+
}
|
|
688
|
+
drawOverlays(ctx, normalized) {
|
|
689
|
+
drawConnectors(ctx, normalized, FACEMESH_TESSELATION, { color: "#60a5fa", lineWidth: 0.5 });
|
|
690
|
+
drawConnectors(ctx, normalized, FACEMESH_FACE_OVAL, { color: "#f59e0b", lineWidth: 2 });
|
|
691
|
+
drawConnectors(ctx, normalized, FACEMESH_LEFT_EYE, { color: "#10b981", lineWidth: 1.5 });
|
|
692
|
+
drawConnectors(ctx, normalized, FACEMESH_RIGHT_EYE, { color: "#ef4444", lineWidth: 1.5 });
|
|
693
|
+
drawConnectors(ctx, normalized, FACEMESH_LIPS, { color: "#a855f7", lineWidth: 1.5 });
|
|
694
|
+
drawLandmarks(ctx, normalized, { color: "#2563eb", lineWidth: 0, radius: 1.5 });
|
|
695
|
+
}
|
|
696
|
+
processResults(results) {
|
|
697
|
+
const canvas = this.canvasRef.current;
|
|
698
|
+
if (!canvas) return;
|
|
699
|
+
const ctx = canvas.getContext("2d");
|
|
700
|
+
if (!ctx) return;
|
|
701
|
+
const dpr = Math.max(1, Math.min(3, window.devicePixelRatio || 1));
|
|
702
|
+
const displayW = canvas.parentElement?.clientWidth || canvas.width;
|
|
703
|
+
const displayH = canvas.parentElement?.clientHeight || canvas.height;
|
|
704
|
+
if (canvas.width !== Math.round(displayW * dpr) || canvas.height !== Math.round(displayH * dpr)) {
|
|
705
|
+
canvas.width = Math.round(displayW * dpr);
|
|
706
|
+
canvas.height = Math.round(displayH * dpr);
|
|
707
|
+
}
|
|
708
|
+
const w = canvas.width, h = canvas.height;
|
|
709
|
+
ctx.fillStyle = "rgb(0, 0, 0)";
|
|
710
|
+
ctx.fillRect(0, 0, w, h);
|
|
711
|
+
const faces = results.multiFaceLandmarks;
|
|
712
|
+
const face = faces && faces[0];
|
|
713
|
+
if (face) {
|
|
714
|
+
const vid = this.videoRef.current;
|
|
715
|
+
const vidW = Math.max(1, vid?.videoWidth || displayW);
|
|
716
|
+
const vidH = Math.max(1, vid?.videoHeight || displayH);
|
|
717
|
+
const scale = Math.max(w / vidW, h / vidH);
|
|
718
|
+
const offsetX = (w - vidW * scale) / 2;
|
|
719
|
+
const offsetY = (h - vidH * scale) / 2;
|
|
720
|
+
const faceOnCanvas = face.map((p) => {
|
|
721
|
+
const mappedX = (p.x * vidW * scale + offsetX) / w;
|
|
722
|
+
return {
|
|
723
|
+
x: 1 - mappedX,
|
|
724
|
+
y: (p.y * vidH * scale + offsetY) / h
|
|
725
|
+
};
|
|
726
|
+
});
|
|
727
|
+
const guideCX = w / 2;
|
|
728
|
+
const guideCY = h / 2;
|
|
729
|
+
const guideR = Math.min(w, h) * 0.45;
|
|
730
|
+
ctx.save();
|
|
731
|
+
ctx.beginPath();
|
|
732
|
+
ctx.arc(guideCX, guideCY, guideR, 0, Math.PI * 2);
|
|
733
|
+
ctx.clip();
|
|
734
|
+
ctx.drawImage(vid, offsetX, offsetY, vidW * scale, vidH * scale);
|
|
735
|
+
ctx.restore();
|
|
736
|
+
this.drawOverlays(ctx, faceOnCanvas);
|
|
737
|
+
this.livenessStateRef.current.lastResultsAt = Date.now();
|
|
738
|
+
if (this.callbacks.onFaceDetected) {
|
|
739
|
+
this.callbacks.onFaceDetected(faceOnCanvas);
|
|
740
|
+
}
|
|
741
|
+
this.processLiveness(faceOnCanvas, w, h);
|
|
742
|
+
} else {
|
|
743
|
+
const vid = this.videoRef.current;
|
|
744
|
+
if (vid) {
|
|
745
|
+
const vidW = Math.max(1, vid?.videoWidth || displayW);
|
|
746
|
+
const vidH = Math.max(1, vid?.videoHeight || displayH);
|
|
747
|
+
const scale = Math.max(w / vidW, h / vidH);
|
|
748
|
+
const offsetX = (w - vidW * scale) / 2;
|
|
749
|
+
const offsetY = (h - vidH * scale) / 2;
|
|
750
|
+
const guideCX = w / 2;
|
|
751
|
+
const guideCY = h / 2;
|
|
752
|
+
const guideR = Math.min(w, h) * 0.45;
|
|
753
|
+
ctx.save();
|
|
754
|
+
ctx.beginPath();
|
|
755
|
+
ctx.arc(guideCX, guideCY, guideR, 0, Math.PI * 2);
|
|
756
|
+
ctx.clip();
|
|
757
|
+
ctx.drawImage(vid, offsetX, offsetY, vidW * scale, vidH * scale);
|
|
758
|
+
ctx.restore();
|
|
759
|
+
}
|
|
760
|
+
if (Date.now() - this.livenessStateRef.current.lastResultsAt > 2e3) {
|
|
761
|
+
if (this.callbacks.onLivenessUpdate) {
|
|
762
|
+
this.callbacks.onLivenessUpdate(
|
|
763
|
+
this.livenessStateRef.current.stage,
|
|
764
|
+
"No face detected. Center your face in frame with good lighting."
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
processLiveness(faceOnCanvas, w, h) {
|
|
771
|
+
const eyeA = faceOnCanvas[33];
|
|
772
|
+
const eyeB = faceOnCanvas[263];
|
|
773
|
+
const flipX = (p) => ({ x: 1 - p.x, y: p.y });
|
|
774
|
+
const eA = flipX(eyeA);
|
|
775
|
+
const eB = flipX(eyeB);
|
|
776
|
+
const n1 = faceOnCanvas[1];
|
|
777
|
+
const n4 = faceOnCanvas[4];
|
|
778
|
+
const nT = flipX(n1 && n4 ? { x: (n1.x + n4.x) / 2, y: (n1.y + n4.y) / 2 } : n1 || n4 || faceOnCanvas[197]);
|
|
779
|
+
const leftEyeOuter = eA.x < eB.x ? eA : eB;
|
|
780
|
+
const rightEyeOuter = eA.x < eB.x ? eB : eA;
|
|
781
|
+
if (leftEyeOuter && rightEyeOuter && nT) {
|
|
782
|
+
const faceWidth = Math.abs(rightEyeOuter.x - leftEyeOuter.x);
|
|
783
|
+
const midX = (leftEyeOuter.x + rightEyeOuter.x) / 2;
|
|
784
|
+
const yaw = (nT.x - midX) / Math.max(1e-6, faceWidth);
|
|
785
|
+
const absYaw = Math.abs(yaw);
|
|
786
|
+
const xs = faceOnCanvas.map((p) => p.x), ys = faceOnCanvas.map((p) => p.y);
|
|
787
|
+
const minX = Math.min(...xs) * w, maxX = Math.max(...xs) * w;
|
|
788
|
+
const minY = Math.min(...ys) * h, maxY = Math.max(...ys) * h;
|
|
789
|
+
const boxCX = (minX + maxX) / 2, boxCY = (minY + maxY) / 2;
|
|
790
|
+
const guideCX = w / 2;
|
|
791
|
+
const guideCY = h / 2;
|
|
792
|
+
const guideR = Math.min(w, h) * 0.45;
|
|
793
|
+
const dx = boxCX - guideCX;
|
|
794
|
+
const dy = boxCY - guideCY;
|
|
795
|
+
const insideGuide = dx * dx + dy * dy <= guideR * guideR && maxX - minX <= guideR * 2 * 1.05 && maxY - minY <= guideR * 2 * 1.05;
|
|
796
|
+
if (!this.livenessStateRef.current.livenessReady) {
|
|
797
|
+
this.livenessStateRef.current.livenessReady = true;
|
|
798
|
+
}
|
|
799
|
+
const centerThreshold = 0.05;
|
|
800
|
+
const rightThreshold = 0.08;
|
|
801
|
+
const holdFramesCenter = 12;
|
|
802
|
+
const holdFramesTurn = 12;
|
|
803
|
+
const state = this.livenessStateRef.current;
|
|
804
|
+
if (state.stage === "CENTER") {
|
|
805
|
+
if (!insideGuide) {
|
|
806
|
+
if (this.callbacks.onLivenessUpdate) {
|
|
807
|
+
this.callbacks.onLivenessUpdate(state.stage, "Center your face inside the circle");
|
|
808
|
+
}
|
|
809
|
+
} else if (absYaw < centerThreshold) {
|
|
810
|
+
state.centerHold += 1;
|
|
811
|
+
if (state.centerHold >= holdFramesCenter) {
|
|
812
|
+
const newStage = "LEFT";
|
|
813
|
+
state.stage = newStage;
|
|
814
|
+
state.centerHold = 0;
|
|
815
|
+
if (this.callbacks.onLivenessUpdate) {
|
|
816
|
+
this.callbacks.onLivenessUpdate(newStage, "Turn your face LEFT");
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
} else {
|
|
820
|
+
state.centerHold = 0;
|
|
821
|
+
if (this.callbacks.onLivenessUpdate) {
|
|
822
|
+
this.callbacks.onLivenessUpdate(state.stage, yaw > 0 ? "Move your face slightly LEFT" : "Move your face slightly RIGHT");
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
} else if (state.stage === "LEFT") {
|
|
826
|
+
if (faceWidth < 0.08) {
|
|
827
|
+
if (this.callbacks.onLivenessUpdate) {
|
|
828
|
+
this.callbacks.onLivenessUpdate(state.stage, "Move closer to the camera");
|
|
829
|
+
}
|
|
830
|
+
} else if (yaw < -0.08) {
|
|
831
|
+
state.leftHold += 1;
|
|
832
|
+
if (state.leftHold >= holdFramesTurn) {
|
|
833
|
+
const newStage = "RIGHT";
|
|
834
|
+
state.stage = newStage;
|
|
835
|
+
state.leftHold = 0;
|
|
836
|
+
if (this.callbacks.onLivenessUpdate) {
|
|
837
|
+
this.callbacks.onLivenessUpdate(newStage, "Great! Now turn your face RIGHT");
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
} else {
|
|
841
|
+
state.leftHold = 0;
|
|
842
|
+
if (this.callbacks.onLivenessUpdate) {
|
|
843
|
+
this.callbacks.onLivenessUpdate(state.stage, yaw > rightThreshold ? "You're facing right. Turn LEFT" : "Turn a bit more LEFT");
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
} else if (state.stage === "RIGHT") {
|
|
847
|
+
if (faceWidth < 0.08) {
|
|
848
|
+
if (this.callbacks.onLivenessUpdate) {
|
|
849
|
+
this.callbacks.onLivenessUpdate(state.stage, "Move closer to the camera");
|
|
850
|
+
}
|
|
851
|
+
} else if (yaw > rightThreshold) {
|
|
852
|
+
state.rightHold += 1;
|
|
853
|
+
if (state.rightHold >= holdFramesTurn) {
|
|
854
|
+
state.rightHold = 0;
|
|
855
|
+
if (!state.snapTriggered) {
|
|
856
|
+
state.snapTriggered = true;
|
|
857
|
+
const newStage = "DONE";
|
|
858
|
+
state.stage = newStage;
|
|
859
|
+
if (this.callbacks.onLivenessUpdate) {
|
|
860
|
+
this.callbacks.onLivenessUpdate(newStage, "Capturing...");
|
|
861
|
+
}
|
|
862
|
+
if (this.callbacks.onCaptureTrigger) {
|
|
863
|
+
this.callbacks.onCaptureTrigger();
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
} else {
|
|
868
|
+
state.rightHold = 0;
|
|
869
|
+
if (this.callbacks.onLivenessUpdate) {
|
|
870
|
+
this.callbacks.onLivenessUpdate(state.stage, yaw < -0.08 ? "You're facing left. Turn RIGHT" : "Turn a bit more RIGHT");
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
waitForVideoReady() {
|
|
877
|
+
return new Promise((resolve, reject) => {
|
|
878
|
+
let attempts = 0;
|
|
879
|
+
const maxAttempts = 100;
|
|
880
|
+
const checkReady = () => {
|
|
881
|
+
if (this.cancelled) {
|
|
882
|
+
reject(new Error("Cancelled"));
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
const video = this.videoRef.current;
|
|
886
|
+
if (video && video.readyState >= 2 && video.videoWidth > 0 && video.videoHeight > 0 && !isNaN(video.videoWidth) && !isNaN(video.videoHeight)) {
|
|
887
|
+
resolve();
|
|
888
|
+
} else if (attempts >= maxAttempts) {
|
|
889
|
+
if (video) {
|
|
890
|
+
resolve();
|
|
891
|
+
} else {
|
|
892
|
+
reject(new Error("Video not ready"));
|
|
893
|
+
}
|
|
894
|
+
} else {
|
|
895
|
+
attempts++;
|
|
896
|
+
requestAnimationFrame(checkReady);
|
|
897
|
+
}
|
|
898
|
+
};
|
|
899
|
+
checkReady();
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
async initialize() {
|
|
903
|
+
try {
|
|
904
|
+
const fm = new FaceMesh({
|
|
905
|
+
locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh@0.4.1633559619/${file}`
|
|
906
|
+
});
|
|
907
|
+
fm.setOptions({
|
|
908
|
+
selfieMode: true,
|
|
909
|
+
maxNumFaces: 1,
|
|
910
|
+
refineLandmarks: true,
|
|
911
|
+
minDetectionConfidence: 0.5,
|
|
912
|
+
minTrackingConfidence: 0.5
|
|
913
|
+
});
|
|
914
|
+
this.faceMesh = fm;
|
|
915
|
+
if (this.cancelled) return;
|
|
916
|
+
if (this.callbacks.onModelLoaded) {
|
|
917
|
+
this.callbacks.onModelLoaded();
|
|
918
|
+
}
|
|
919
|
+
fm.onResults((results) => {
|
|
920
|
+
if (!this.cancelled) {
|
|
921
|
+
this.processResults(results);
|
|
922
|
+
}
|
|
923
|
+
});
|
|
924
|
+
if (this.videoRef.current) {
|
|
925
|
+
try {
|
|
926
|
+
await this.waitForVideoReady();
|
|
927
|
+
} catch (error) {
|
|
928
|
+
if (!this.cancelled) {
|
|
929
|
+
console.debug("Video ready check failed, continuing anyway:", error);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
const tick = async () => {
|
|
933
|
+
if (this.cancelled) return;
|
|
934
|
+
if (!this.videoRef.current || !this.faceMesh) return;
|
|
935
|
+
const video = this.videoRef.current;
|
|
936
|
+
if (video.readyState >= 2 && video.videoWidth > 0 && video.videoHeight > 0 && !isNaN(video.videoWidth) && !isNaN(video.videoHeight)) {
|
|
937
|
+
try {
|
|
938
|
+
await this.faceMesh.send({ image: video });
|
|
939
|
+
} catch (error) {
|
|
940
|
+
if (!this.cancelled) {
|
|
941
|
+
console.debug("MediaPipe send error (non-critical):", error);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
if (!this.cancelled) {
|
|
946
|
+
this.cameraDriverRef.current = requestAnimationFrame(tick);
|
|
947
|
+
}
|
|
948
|
+
};
|
|
949
|
+
this.cameraDriverRef.current = requestAnimationFrame(tick);
|
|
950
|
+
}
|
|
951
|
+
} catch (e) {
|
|
952
|
+
if (!this.cancelled && this.callbacks.onModelFailed) {
|
|
953
|
+
this.callbacks.onModelFailed(e);
|
|
954
|
+
}
|
|
955
|
+
throw e;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
cleanup() {
|
|
959
|
+
this.cancelled = true;
|
|
960
|
+
if (this.cameraDriverRef.current) {
|
|
961
|
+
cancelAnimationFrame(this.cameraDriverRef.current);
|
|
962
|
+
this.cameraDriverRef.current = null;
|
|
963
|
+
}
|
|
964
|
+
if (this.faceMesh) {
|
|
965
|
+
try {
|
|
966
|
+
this.faceMesh.close?.();
|
|
967
|
+
} catch {
|
|
968
|
+
}
|
|
969
|
+
this.faceMesh = null;
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
// src/features/faceScan/hooks/useFaceScan.ts
|
|
975
|
+
function useFaceScan(videoRef, canvasRef, callbacks) {
|
|
976
|
+
const [state, setState] = useState({
|
|
977
|
+
cameraReady: false,
|
|
978
|
+
livenessStage: "CENTER",
|
|
979
|
+
livenessReady: false,
|
|
980
|
+
livenessFailed: false,
|
|
981
|
+
modelLoading: true,
|
|
982
|
+
modelLoaded: false,
|
|
983
|
+
livenessInstruction: "Look straight at the camera",
|
|
984
|
+
loading: false,
|
|
985
|
+
allStepsCompleted: false,
|
|
986
|
+
capturedImage: null,
|
|
987
|
+
showDocumentUpload: false
|
|
988
|
+
});
|
|
989
|
+
const refs = {
|
|
990
|
+
centerHold: useRef(0),
|
|
991
|
+
leftHold: useRef(0),
|
|
992
|
+
rightHold: useRef(0),
|
|
993
|
+
snapTriggered: useRef(false),
|
|
994
|
+
lastResultsAt: useRef(0),
|
|
995
|
+
livenessStage: useRef("CENTER"),
|
|
996
|
+
cameraDriver: useRef(null),
|
|
997
|
+
modelLoaded: useRef(false),
|
|
998
|
+
livenessFailed: useRef(false),
|
|
999
|
+
handleFaceCapture: useRef(null)
|
|
1000
|
+
};
|
|
1001
|
+
const livenessStateRef = useRef({
|
|
1002
|
+
centerHold: 0,
|
|
1003
|
+
leftHold: 0,
|
|
1004
|
+
rightHold: 0,
|
|
1005
|
+
snapTriggered: false,
|
|
1006
|
+
lastResultsAt: 0,
|
|
1007
|
+
stage: "CENTER",
|
|
1008
|
+
livenessReady: false
|
|
1009
|
+
});
|
|
1010
|
+
useEffect(() => {
|
|
1011
|
+
livenessStateRef.current.centerHold = refs.centerHold.current;
|
|
1012
|
+
livenessStateRef.current.leftHold = refs.leftHold.current;
|
|
1013
|
+
livenessStateRef.current.rightHold = refs.rightHold.current;
|
|
1014
|
+
livenessStateRef.current.snapTriggered = refs.snapTriggered.current;
|
|
1015
|
+
livenessStateRef.current.lastResultsAt = refs.lastResultsAt.current;
|
|
1016
|
+
livenessStateRef.current.stage = state.livenessStage;
|
|
1017
|
+
livenessStateRef.current.livenessReady = state.livenessReady;
|
|
1018
|
+
}, [state.livenessStage, state.livenessReady]);
|
|
1019
|
+
useEffect(() => {
|
|
1020
|
+
refs.livenessStage.current = state.livenessStage;
|
|
1021
|
+
}, [state.livenessStage]);
|
|
1022
|
+
const setStage = useCallback((next) => {
|
|
1023
|
+
refs.livenessStage.current = next;
|
|
1024
|
+
livenessStateRef.current.stage = next;
|
|
1025
|
+
setState((prev) => ({ ...prev, livenessStage: next }));
|
|
1026
|
+
}, []);
|
|
1027
|
+
const handleFaceCapture = useCallback(async () => {
|
|
1028
|
+
if (!videoRef.current) return;
|
|
1029
|
+
setState((prev) => ({ ...prev, loading: true }));
|
|
1030
|
+
try {
|
|
1031
|
+
const video = videoRef.current;
|
|
1032
|
+
const canvas = document.createElement("canvas");
|
|
1033
|
+
const width = video.videoWidth || 640;
|
|
1034
|
+
const height = video.videoHeight || 480;
|
|
1035
|
+
canvas.width = width;
|
|
1036
|
+
canvas.height = height;
|
|
1037
|
+
const ctx = canvas.getContext("2d");
|
|
1038
|
+
if (!ctx) throw new Error("Canvas not supported");
|
|
1039
|
+
ctx.drawImage(video, 0, 0, width, height);
|
|
1040
|
+
const dataUrl = canvas.toDataURL("image/jpeg", 0.92);
|
|
1041
|
+
const blob = await (await fetch(dataUrl)).blob();
|
|
1042
|
+
if (callbacks?.onFaceUpload) {
|
|
1043
|
+
try {
|
|
1044
|
+
await callbacks.onFaceUpload(blob);
|
|
1045
|
+
setState((prev) => ({
|
|
1046
|
+
...prev,
|
|
1047
|
+
capturedImage: dataUrl,
|
|
1048
|
+
allStepsCompleted: true,
|
|
1049
|
+
livenessInstruction: "Face captured and uploaded successfully!",
|
|
1050
|
+
loading: false
|
|
1051
|
+
}));
|
|
1052
|
+
} catch (uploadError) {
|
|
1053
|
+
throw new Error(uploadError.message || "Failed to upload face scan");
|
|
1054
|
+
}
|
|
1055
|
+
} else {
|
|
1056
|
+
setState((prev) => ({
|
|
1057
|
+
...prev,
|
|
1058
|
+
capturedImage: dataUrl,
|
|
1059
|
+
allStepsCompleted: true,
|
|
1060
|
+
livenessInstruction: "Face captured successfully!",
|
|
1061
|
+
loading: false
|
|
1062
|
+
}));
|
|
1063
|
+
}
|
|
1064
|
+
if (callbacks?.onFaceCaptureComplete) {
|
|
1065
|
+
callbacks.onFaceCaptureComplete(dataUrl);
|
|
1066
|
+
}
|
|
1067
|
+
setTimeout(() => {
|
|
1068
|
+
setState((prev) => ({ ...prev, showDocumentUpload: true }));
|
|
1069
|
+
}, 500);
|
|
1070
|
+
} catch (err) {
|
|
1071
|
+
console.error("Error capturing image:", err);
|
|
1072
|
+
setState((prev) => ({
|
|
1073
|
+
...prev,
|
|
1074
|
+
livenessInstruction: err.message || "Error capturing image. Please try again.",
|
|
1075
|
+
loading: false
|
|
1076
|
+
}));
|
|
1077
|
+
}
|
|
1078
|
+
}, [callbacks]);
|
|
1079
|
+
useEffect(() => {
|
|
1080
|
+
refs.handleFaceCapture.current = handleFaceCapture;
|
|
1081
|
+
}, [handleFaceCapture]);
|
|
1082
|
+
useEffect(() => {
|
|
1083
|
+
if (!state.cameraReady) return;
|
|
1084
|
+
let service = null;
|
|
1085
|
+
let initTimeoutId = null;
|
|
1086
|
+
let cancelled = false;
|
|
1087
|
+
const start = async () => {
|
|
1088
|
+
try {
|
|
1089
|
+
service = new FaceMeshService(
|
|
1090
|
+
videoRef,
|
|
1091
|
+
canvasRef,
|
|
1092
|
+
refs.cameraDriver,
|
|
1093
|
+
livenessStateRef,
|
|
1094
|
+
{
|
|
1095
|
+
onModelLoaded: () => {
|
|
1096
|
+
if (cancelled) return;
|
|
1097
|
+
setState((prev) => ({
|
|
1098
|
+
...prev,
|
|
1099
|
+
modelLoaded: true,
|
|
1100
|
+
modelLoading: false
|
|
1101
|
+
}));
|
|
1102
|
+
refs.modelLoaded.current = true;
|
|
1103
|
+
},
|
|
1104
|
+
onModelFailed: (error) => {
|
|
1105
|
+
if (cancelled) return;
|
|
1106
|
+
setState((prev) => ({
|
|
1107
|
+
...prev,
|
|
1108
|
+
livenessFailed: true,
|
|
1109
|
+
modelLoading: false,
|
|
1110
|
+
modelLoaded: false
|
|
1111
|
+
}));
|
|
1112
|
+
refs.livenessFailed.current = true;
|
|
1113
|
+
console.error("mediapipe facemesh init error", error);
|
|
1114
|
+
},
|
|
1115
|
+
onLivenessUpdate: (stage, instruction) => {
|
|
1116
|
+
if (cancelled) return;
|
|
1117
|
+
setState((prev) => ({
|
|
1118
|
+
...prev,
|
|
1119
|
+
livenessStage: stage,
|
|
1120
|
+
livenessInstruction: instruction,
|
|
1121
|
+
livenessReady: true
|
|
1122
|
+
}));
|
|
1123
|
+
livenessStateRef.current.stage = stage;
|
|
1124
|
+
refs.livenessStage.current = stage;
|
|
1125
|
+
refs.centerHold.current = livenessStateRef.current.centerHold;
|
|
1126
|
+
refs.leftHold.current = livenessStateRef.current.leftHold;
|
|
1127
|
+
refs.rightHold.current = livenessStateRef.current.rightHold;
|
|
1128
|
+
refs.snapTriggered.current = livenessStateRef.current.snapTriggered;
|
|
1129
|
+
},
|
|
1130
|
+
onCaptureTrigger: () => {
|
|
1131
|
+
if (cancelled) return;
|
|
1132
|
+
if (refs.handleFaceCapture.current) {
|
|
1133
|
+
refs.handleFaceCapture.current();
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
);
|
|
1138
|
+
await service.initialize();
|
|
1139
|
+
} catch (e) {
|
|
1140
|
+
if (!cancelled && refs.livenessFailed.current === false) {
|
|
1141
|
+
setState((prev) => ({
|
|
1142
|
+
...prev,
|
|
1143
|
+
livenessFailed: true,
|
|
1144
|
+
modelLoading: false,
|
|
1145
|
+
modelLoaded: false
|
|
1146
|
+
}));
|
|
1147
|
+
refs.livenessFailed.current = true;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
};
|
|
1151
|
+
initTimeoutId = window.setTimeout(() => {
|
|
1152
|
+
if (!cancelled && !refs.modelLoaded.current && !refs.livenessFailed.current) {
|
|
1153
|
+
setState((prev) => ({
|
|
1154
|
+
...prev,
|
|
1155
|
+
livenessFailed: true,
|
|
1156
|
+
modelLoading: false
|
|
1157
|
+
}));
|
|
1158
|
+
refs.livenessFailed.current = true;
|
|
1159
|
+
}
|
|
1160
|
+
}, 8e3);
|
|
1161
|
+
start();
|
|
1162
|
+
return () => {
|
|
1163
|
+
cancelled = true;
|
|
1164
|
+
if (initTimeoutId) {
|
|
1165
|
+
window.clearTimeout(initTimeoutId);
|
|
1166
|
+
initTimeoutId = null;
|
|
1167
|
+
}
|
|
1168
|
+
if (service) {
|
|
1169
|
+
service.cleanup();
|
|
1170
|
+
service = null;
|
|
1171
|
+
}
|
|
1172
|
+
};
|
|
1173
|
+
}, [state.cameraReady]);
|
|
1174
|
+
return {
|
|
1175
|
+
state,
|
|
1176
|
+
setState,
|
|
1177
|
+
refs,
|
|
1178
|
+
setStage,
|
|
1179
|
+
handleFaceCapture
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
function FaceScanModal({ onComplete }) {
|
|
1183
|
+
const faceCanvasRef = useRef(null);
|
|
1184
|
+
const navigate = useNavigate();
|
|
1185
|
+
const { apiService } = useKycContext();
|
|
1186
|
+
const [sessionError, setSessionError] = useState(null);
|
|
1187
|
+
const { videoRef, cameraReady, stopCamera } = useCamera();
|
|
1188
|
+
const { state, setState, refs, handleFaceCapture } = useFaceScan(videoRef, faceCanvasRef, {
|
|
1189
|
+
onFaceUpload: async (blob) => {
|
|
1190
|
+
if (!apiService) {
|
|
1191
|
+
throw new Error("API service not initialized");
|
|
1192
|
+
}
|
|
1193
|
+
await apiService.uploadFaceScan(blob);
|
|
1194
|
+
},
|
|
1195
|
+
onFaceCaptureComplete: (imageData) => {
|
|
1196
|
+
if (onComplete) {
|
|
1197
|
+
onComplete(imageData);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
});
|
|
1201
|
+
useEffect(() => {
|
|
1202
|
+
const checkSession = async () => {
|
|
1203
|
+
if (!apiService) return;
|
|
1204
|
+
try {
|
|
1205
|
+
await apiService.checkSessionActive();
|
|
1206
|
+
setSessionError(null);
|
|
1207
|
+
} catch (error) {
|
|
1208
|
+
const message = error.message || "Session expired or inactive";
|
|
1209
|
+
setSessionError(message);
|
|
1210
|
+
setTimeout(() => {
|
|
1211
|
+
navigate("/qr", { replace: true });
|
|
1212
|
+
}, 2e3);
|
|
1213
|
+
}
|
|
1214
|
+
};
|
|
1215
|
+
checkSession();
|
|
1216
|
+
}, [apiService, navigate]);
|
|
1217
|
+
useEffect(() => {
|
|
1218
|
+
setState((prev) => ({ ...prev, cameraReady }));
|
|
1219
|
+
}, [cameraReady, setState]);
|
|
1220
|
+
const handleRetry = () => {
|
|
1221
|
+
stopCamera();
|
|
1222
|
+
setState({
|
|
1223
|
+
cameraReady: false,
|
|
1224
|
+
livenessStage: "CENTER",
|
|
1225
|
+
livenessReady: false,
|
|
1226
|
+
livenessFailed: false,
|
|
1227
|
+
modelLoading: true,
|
|
1228
|
+
modelLoaded: false,
|
|
1229
|
+
livenessInstruction: "Look straight at the camera",
|
|
1230
|
+
loading: false,
|
|
1231
|
+
allStepsCompleted: false,
|
|
1232
|
+
capturedImage: null,
|
|
1233
|
+
showDocumentUpload: false
|
|
1234
|
+
});
|
|
1235
|
+
refs.centerHold.current = 0;
|
|
1236
|
+
refs.leftHold.current = 0;
|
|
1237
|
+
refs.rightHold.current = 0;
|
|
1238
|
+
refs.snapTriggered.current = false;
|
|
1239
|
+
refs.lastResultsAt.current = 0;
|
|
1240
|
+
refs.modelLoaded.current = false;
|
|
1241
|
+
refs.livenessFailed.current = false;
|
|
1242
|
+
setTimeout(() => {
|
|
1243
|
+
window.location.reload();
|
|
1244
|
+
}, 100);
|
|
1245
|
+
};
|
|
1246
|
+
if (state.showDocumentUpload) {
|
|
1247
|
+
return /* @__PURE__ */ jsx(
|
|
1248
|
+
DocumentUploadModal_default,
|
|
1249
|
+
{
|
|
1250
|
+
onComplete: (_file, _docType) => {
|
|
1251
|
+
if (onComplete && state.capturedImage) {
|
|
1252
|
+
onComplete(state.capturedImage);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
1258
|
+
if (sessionError) {
|
|
1259
|
+
return /* @__PURE__ */ jsx("div", { className: "fixed inset-0 bg-black p-5 z-[1000] flex items-center justify-center font-sans overflow-y-auto custom__scrollbar", children: /* @__PURE__ */ jsx("div", { className: "max-w-[400px] w-full mx-auto bg-[#0b0f17] rounded-2xl p-6 shadow-xl", children: /* @__PURE__ */ jsxs("div", { className: "text-center", children: [
|
|
1260
|
+
/* @__PURE__ */ jsx("h2", { className: "m-0 mb-4 text-[26px] font-bold text-red-500", children: "Session Expired" }),
|
|
1261
|
+
/* @__PURE__ */ jsx("p", { className: "text-[#e5e7eb] mb-4", children: sessionError }),
|
|
1262
|
+
/* @__PURE__ */ jsx("p", { className: "text-[#9ca3af] text-sm", children: "Redirecting to QR code page..." })
|
|
1263
|
+
] }) }) });
|
|
1264
|
+
}
|
|
1265
|
+
return /* @__PURE__ */ jsx("div", { className: "fixed inset-0 bg-black p-5 z-[1000] flex items-center justify-center font-sans overflow-y-auto custom__scrollbar", children: /* @__PURE__ */ jsxs("div", { className: "max-w-[400px] w-full mx-auto bg-[#0b0f17] rounded-2xl p-6 shadow-xl mt-48", children: [
|
|
1266
|
+
/* @__PURE__ */ jsx("div", { className: "relative mb-4", children: /* @__PURE__ */ jsx("h2", { className: "m-0 mb-4 text-[26px] font-bold text-white text-center", children: "Capture Face" }) }),
|
|
1267
|
+
/* @__PURE__ */ jsxs("div", { className: "grid gap-4", children: [
|
|
1268
|
+
!state.modelLoading && state.modelLoaded && !state.livenessFailed && /* @__PURE__ */ jsx("div", { className: "bg-[#0a2315] text-[#34d399] py-3.5 px-4 rounded-xl text-sm border border-[#155e3b] text-left", children: "Face detection model loaded." }),
|
|
1269
|
+
state.modelLoading && !state.livenessFailed && /* @__PURE__ */ jsx("div", { className: "bg-[#1f2937] text-[#e5e7eb] py-3.5 px-4 rounded-xl text-sm border border-[#374151] text-left", children: "Loading face detection model..." }),
|
|
1270
|
+
/* @__PURE__ */ jsxs("div", { className: "relative w-full aspect-square rounded-full overflow-hidden bg-black", children: [
|
|
1271
|
+
/* @__PURE__ */ jsx(
|
|
1272
|
+
"video",
|
|
1273
|
+
{
|
|
1274
|
+
ref: videoRef,
|
|
1275
|
+
playsInline: true,
|
|
1276
|
+
muted: true,
|
|
1277
|
+
className: "w-full h-full block bg-black object-cover -scale-x-100 origin-center"
|
|
1278
|
+
}
|
|
1279
|
+
),
|
|
1280
|
+
/* @__PURE__ */ jsx(
|
|
1281
|
+
"canvas",
|
|
1282
|
+
{
|
|
1283
|
+
ref: faceCanvasRef,
|
|
1284
|
+
className: "absolute top-0 left-0 w-full h-full pointer-events-none z-[2]"
|
|
1285
|
+
}
|
|
1286
|
+
),
|
|
1287
|
+
/* @__PURE__ */ jsx("svg", { viewBox: "0 0 100 100", preserveAspectRatio: "none", className: "absolute inset-0 pointer-events-none z-[3]", children: /* @__PURE__ */ jsx("circle", { cx: "50", cy: "50", r: "44", fill: "none", stroke: "#22c55e", strokeWidth: "2", strokeDasharray: "1 3" }) })
|
|
1288
|
+
] }),
|
|
1289
|
+
!state.livenessFailed && /* @__PURE__ */ jsxs("div", { className: "bg-gradient-to-b from-[rgba(17,24,39,0.9)] to-[rgba(17,24,39,0.6)] text-[#e5e7eb] p-4 rounded-2xl text-base border border-[#30363d]", children: [
|
|
1290
|
+
/* @__PURE__ */ jsx("div", { className: "font-bold mb-2.5 text-[22px] text-white", children: "Liveness Check" }),
|
|
1291
|
+
/* @__PURE__ */ jsx("div", { className: "mb-2.5 text-base", children: state.livenessInstruction }),
|
|
1292
|
+
/* @__PURE__ */ jsxs("div", { className: "grid gap-2.5 text-lg", children: [
|
|
1293
|
+
/* @__PURE__ */ jsx("div", { className: state.livenessStage === "CENTER" || state.livenessStage === "LEFT" || state.livenessStage === "RIGHT" || state.livenessStage === "DONE" ? "opacity-100" : "opacity-40", children: "1. Look Straight" }),
|
|
1294
|
+
/* @__PURE__ */ jsx("div", { className: state.livenessStage === "RIGHT" || state.livenessStage === "DONE" ? "opacity-100" : "opacity-40", children: "2. Turn your face right" }),
|
|
1295
|
+
/* @__PURE__ */ jsx("div", { className: state.livenessStage === "DONE" ? "opacity-100" : "opacity-30", children: "3. Turn your face left" })
|
|
1296
|
+
] })
|
|
1297
|
+
] }),
|
|
1298
|
+
/* @__PURE__ */ jsx(
|
|
1299
|
+
"button",
|
|
1300
|
+
{
|
|
1301
|
+
type: "button",
|
|
1302
|
+
disabled: !state.cameraReady || state.loading || !state.livenessFailed && state.livenessStage !== "DONE",
|
|
1303
|
+
onClick: handleFaceCapture,
|
|
1304
|
+
className: `py-3.5 px-4 rounded-xl text-base font-bold border-none transition-colors ${state.cameraReady && !state.loading && (state.livenessFailed || state.livenessStage === "DONE") ? "bg-[#22c55e] text-[#0b0f17] cursor-pointer hover:bg-[#16a34a]" : "bg-[#374151] text-[#e5e7eb] cursor-not-allowed"}`,
|
|
1305
|
+
children: state.loading ? "Capturing..." : state.livenessFailed || state.livenessStage === "DONE" ? "Capture & Continue" : "Complete steps to continue"
|
|
1306
|
+
}
|
|
1307
|
+
),
|
|
1308
|
+
/* @__PURE__ */ jsx(
|
|
1309
|
+
"button",
|
|
1310
|
+
{
|
|
1311
|
+
type: "button",
|
|
1312
|
+
onClick: handleRetry,
|
|
1313
|
+
disabled: state.loading,
|
|
1314
|
+
className: `py-3 px-4 rounded-[10px] text-[15px] font-semibold border-none w-full transition-colors ${state.loading ? "bg-[#374151] text-[#e5e7eb] cursor-not-allowed opacity-50" : "bg-[#374151] text-[#e5e7eb] cursor-pointer hover:bg-[#4b5563]"}`,
|
|
1315
|
+
children: "Restart"
|
|
1316
|
+
}
|
|
1317
|
+
)
|
|
1318
|
+
] })
|
|
1319
|
+
] }) });
|
|
1320
|
+
}
|
|
1321
|
+
var FaceScanModal_default = FaceScanModal;
|
|
1322
|
+
function MobileRoute({ onClose } = {}) {
|
|
1323
|
+
const navigate = useNavigate();
|
|
1324
|
+
useEffect(() => {
|
|
1325
|
+
if (!isMobileDevice()) {
|
|
1326
|
+
navigate("/qr", { replace: true });
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
}, [navigate]);
|
|
1330
|
+
const handleClose = () => {
|
|
1331
|
+
if (onClose) {
|
|
1332
|
+
onClose();
|
|
1333
|
+
} else {
|
|
1334
|
+
navigate(-1);
|
|
1335
|
+
}
|
|
1336
|
+
};
|
|
1337
|
+
const handleComplete = (capturedImage) => {
|
|
1338
|
+
console.log("Face capture completed with image:", capturedImage.substring(0, 50) + "...");
|
|
1339
|
+
};
|
|
1340
|
+
if (!isMobileDevice()) {
|
|
1341
|
+
return null;
|
|
1342
|
+
}
|
|
1343
|
+
return /* @__PURE__ */ jsx(FaceScanModal_default, { onClose: handleClose, onComplete: handleComplete });
|
|
1344
|
+
}
|
|
1345
|
+
var MobileRoute_default = MobileRoute;
|
|
1346
|
+
function QRCodePage({ onClose } = {}) {
|
|
1347
|
+
const [searchParams] = useSearchParams();
|
|
1348
|
+
const navigate = useNavigate();
|
|
1349
|
+
const [qrUrl, setQrUrl] = useState("");
|
|
1350
|
+
const [copied, setCopied] = useState(false);
|
|
1351
|
+
useEffect(() => {
|
|
1352
|
+
const currentUrl = window.location.origin;
|
|
1353
|
+
const params = {};
|
|
1354
|
+
searchParams.forEach((value, key) => {
|
|
1355
|
+
params[key] = value;
|
|
1356
|
+
});
|
|
1357
|
+
const mobileRoute = "/mobileroute";
|
|
1358
|
+
const queryString = new URLSearchParams(params).toString();
|
|
1359
|
+
const fullUrl = `${currentUrl}${mobileRoute}${queryString ? `?${queryString}` : ""}`;
|
|
1360
|
+
setQrUrl(fullUrl);
|
|
1361
|
+
}, [searchParams]);
|
|
1362
|
+
const handleCopyUrl = async () => {
|
|
1363
|
+
if (qrUrl) {
|
|
1364
|
+
try {
|
|
1365
|
+
await navigator.clipboard.writeText(qrUrl);
|
|
1366
|
+
setCopied(true);
|
|
1367
|
+
setTimeout(() => setCopied(false), 2e3);
|
|
1368
|
+
} catch (err) {
|
|
1369
|
+
console.error("Failed to copy:", err);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
};
|
|
1373
|
+
const handleClose = () => {
|
|
1374
|
+
if (onClose) {
|
|
1375
|
+
onClose();
|
|
1376
|
+
} else {
|
|
1377
|
+
navigate(-1);
|
|
1378
|
+
}
|
|
1379
|
+
};
|
|
1380
|
+
const handleRefresh = () => {
|
|
1381
|
+
window.location.reload();
|
|
1382
|
+
};
|
|
1383
|
+
return /* @__PURE__ */ jsx("div", { className: "fixed inset-0 flex items-center justify-center bg-black p-4 sm:p-6 md:p-8 z-[1000]", children: /* @__PURE__ */ jsxs("div", { className: "relative bg-[rgba(20,20,20,0.95)] rounded-2xl p-5 sm:p-6 md:p-7 max-w-[450px] w-full h-[80vh] flex flex-col text-center shadow-[0_8px_32px_rgba(0,0,0,0.5)]", children: [
|
|
1384
|
+
/* @__PURE__ */ jsx(
|
|
1385
|
+
"button",
|
|
1386
|
+
{
|
|
1387
|
+
className: "absolute top-5 right-5 bg-transparent border-none text-white cursor-pointer p-2 flex items-center justify-center rounded transition-colors hover:bg-white/10 active:bg-white/20",
|
|
1388
|
+
onClick: handleClose,
|
|
1389
|
+
"aria-label": "Close",
|
|
1390
|
+
children: /* @__PURE__ */ jsxs("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [
|
|
1391
|
+
/* @__PURE__ */ jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" }),
|
|
1392
|
+
/* @__PURE__ */ jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" })
|
|
1393
|
+
] })
|
|
1394
|
+
}
|
|
1395
|
+
),
|
|
1396
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center gap-3 sm:gap-4 flex-1 overflow-y-auto custom__scrollbar", children: [
|
|
1397
|
+
/* @__PURE__ */ jsx("h1", { className: "m-0 text-white text-xl sm:text-2xl font-semibold leading-tight", children: "Continue on Mobile" }),
|
|
1398
|
+
/* @__PURE__ */ jsx("p", { className: "m-0 text-white text-sm sm:text-base opacity-90 leading-relaxed", children: "Scan this QR on your phone to capture your face and document" }),
|
|
1399
|
+
qrUrl && /* @__PURE__ */ jsx("div", { className: "flex justify-center items-center p-3 sm:p-4 bg-white rounded-xl border-2 border-white", children: /* @__PURE__ */ jsx(
|
|
1400
|
+
QRCodeSVG,
|
|
1401
|
+
{
|
|
1402
|
+
value: qrUrl,
|
|
1403
|
+
size: 180,
|
|
1404
|
+
level: "H",
|
|
1405
|
+
includeMargin: true,
|
|
1406
|
+
bgColor: "transparent",
|
|
1407
|
+
fgColor: "#000000"
|
|
1408
|
+
}
|
|
1409
|
+
) }),
|
|
1410
|
+
/* @__PURE__ */ jsxs("div", { className: "w-full text-left mt-auto", children: [
|
|
1411
|
+
/* @__PURE__ */ jsx("p", { className: "m-0 mb-2 text-white text-xs sm:text-sm opacity-80", children: "Or open:" }),
|
|
1412
|
+
/* @__PURE__ */ jsxs("div", { className: "flex flex-col sm:flex-row items-start sm:items-center gap-2 p-2 sm:p-3 bg-white/10 rounded-lg border border-white/20", children: [
|
|
1413
|
+
/* @__PURE__ */ jsx("code", { className: "flex-1 text-white text-xs sm:text-sm break-all text-left m-0 font-mono", children: qrUrl }),
|
|
1414
|
+
/* @__PURE__ */ jsx(
|
|
1415
|
+
"button",
|
|
1416
|
+
{
|
|
1417
|
+
className: "bg-transparent border-none text-white cursor-pointer p-1.5 flex items-center justify-center rounded transition-colors flex-shrink-0 hover:bg-white/10 active:bg-white/20 self-end sm:self-auto",
|
|
1418
|
+
onClick: handleCopyUrl,
|
|
1419
|
+
"aria-label": "Copy URL",
|
|
1420
|
+
title: copied ? "Copied!" : "Copy URL",
|
|
1421
|
+
children: copied ? /* @__PURE__ */ jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: /* @__PURE__ */ jsx("polyline", { points: "20 6 9 17 4 12" }) }) : /* @__PURE__ */ jsxs("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [
|
|
1422
|
+
/* @__PURE__ */ jsx("rect", { x: "9", y: "9", width: "13", height: "13", rx: "2", ry: "2" }),
|
|
1423
|
+
/* @__PURE__ */ jsx("path", { d: "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" })
|
|
1424
|
+
] })
|
|
1425
|
+
}
|
|
1426
|
+
)
|
|
1427
|
+
] })
|
|
1428
|
+
] }),
|
|
1429
|
+
/* @__PURE__ */ jsx(
|
|
1430
|
+
"button",
|
|
1431
|
+
{
|
|
1432
|
+
className: "w-full py-3 sm:py-4 px-6 bg-gradient-to-r from-[#FF842D] to-[#FF2D55] border-none rounded-lg text-white text-sm sm:text-base font-semibold cursor-pointer transition-all hover:-translate-y-0.5 hover:shadow-[0_4px_12px_rgba(255,107,53,0.4)] active:translate-y-0",
|
|
1433
|
+
onClick: handleRefresh,
|
|
1434
|
+
children: "I've completed on mobile - Refresh"
|
|
1435
|
+
}
|
|
1436
|
+
)
|
|
1437
|
+
] })
|
|
1438
|
+
] }) });
|
|
1439
|
+
}
|
|
1440
|
+
var QRCodePage_default = QRCodePage;
|
|
1441
|
+
var KycFlow = ({
|
|
1442
|
+
apiBaseUrl,
|
|
1443
|
+
sessionId,
|
|
1444
|
+
serverKey,
|
|
1445
|
+
deviceType,
|
|
1446
|
+
startAtQr = true,
|
|
1447
|
+
onClose
|
|
1448
|
+
}) => {
|
|
1449
|
+
return /* @__PURE__ */ jsx(
|
|
1450
|
+
KycProvider,
|
|
1451
|
+
{
|
|
1452
|
+
apiBaseUrl,
|
|
1453
|
+
sessionId,
|
|
1454
|
+
serverKey,
|
|
1455
|
+
deviceType,
|
|
1456
|
+
children: /* @__PURE__ */ jsx(MemoryRouter, { initialEntries: [startAtQr ? "/qr" : "/mobileroute"], children: /* @__PURE__ */ jsxs(Routes, { children: [
|
|
1457
|
+
/* @__PURE__ */ jsx(Route, { path: "/", element: /* @__PURE__ */ jsx(Navigate, { to: startAtQr ? "/qr" : "/mobileroute", replace: true }) }),
|
|
1458
|
+
/* @__PURE__ */ jsx(Route, { path: "/qr", element: /* @__PURE__ */ jsx(QRCodePage_default, { onClose }) }),
|
|
1459
|
+
/* @__PURE__ */ jsx(Route, { path: "/mobileroute", element: /* @__PURE__ */ jsx(MobileRoute_default, { onClose }) })
|
|
1460
|
+
] }) })
|
|
1461
|
+
}
|
|
1462
|
+
);
|
|
1463
|
+
};
|
|
1464
|
+
|
|
1465
|
+
export { KycFlow };
|
|
1466
|
+
//# sourceMappingURL=components.es.js.map
|
|
1467
|
+
//# sourceMappingURL=components.es.js.map
|