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.
@@ -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