astra-sdk-web 1.1.12 → 1.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +179 -179
- package/dist/astra-sdk.cjs.js +2 -2
- package/dist/astra-sdk.cjs.js.map +1 -1
- package/dist/astra-sdk.css +42 -0
- package/dist/astra-sdk.css.map +1 -1
- package/dist/astra-sdk.es.js +2 -2
- package/dist/astra-sdk.es.js.map +1 -1
- package/dist/components.cjs.js +2 -2
- package/dist/components.cjs.js.map +1 -1
- package/dist/components.css +42 -0
- package/dist/components.css.map +1 -1
- package/dist/components.es.js +2 -2
- package/dist/components.es.js.map +1 -1
- package/package.json +74 -74
- package/src/App.tsx +17 -17
- package/src/components/KycFlow.tsx +1 -1
- package/src/components/MobileKycPage.tsx +1122 -0
- package/src/components/kycModal.tsx +551 -0
- package/src/index.css +22 -18
- package/src/main.tsx +10 -10
- package/src/pages/QRCodePage.tsx +1 -1
- package/dist/.htaccess +0 -9
|
@@ -0,0 +1,1122 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { FaceMesh, FACEMESH_TESSELATION, FACEMESH_FACE_OVAL, FACEMESH_LEFT_EYE, FACEMESH_RIGHT_EYE, FACEMESH_LIPS } from "@mediapipe/face_mesh";
|
|
3
|
+
import { drawConnectors, drawLandmarks as drawMPLandmarks } from "@mediapipe/drawing_utils";
|
|
4
|
+
import { toast } from "react-toastify";
|
|
5
|
+
import VerificationSDK from "../VerificationSDK";
|
|
6
|
+
import "./kycModal.css";
|
|
7
|
+
|
|
8
|
+
interface MobileKycPageProps {
|
|
9
|
+
sessionId?: string;
|
|
10
|
+
serverKey?: string;
|
|
11
|
+
sdk?: VerificationSDK;
|
|
12
|
+
showFinalStatus?: boolean; // when true, show STATUS on mobile after document
|
|
13
|
+
apiBaseUrl?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type Step = "FACE" | "DOCUMENT" | "STATUS";
|
|
17
|
+
|
|
18
|
+
export const MobileKycPage: React.FC<MobileKycPageProps> = ({ sessionId, serverKey, sdk: providedSdk, showFinalStatus = false, apiBaseUrl }) => {
|
|
19
|
+
try { console.log('[MobileKycPage] render', { sessionId, hasServerKey: Boolean(serverKey), hasProvidedSdk: Boolean(providedSdk), showFinalStatus }); } catch {}
|
|
20
|
+
const [step, setStep] = useState<Step>("FACE");
|
|
21
|
+
const [loading, setLoading] = useState(false);
|
|
22
|
+
const [status, setStatus] = useState<any>(null);
|
|
23
|
+
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
|
24
|
+
const [docType, setDocType] = useState<string>("CNIC");
|
|
25
|
+
const [docFileName, setDocFileName] = useState<string>("");
|
|
26
|
+
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
27
|
+
const videoRef = useRef<HTMLVideoElement | null>(null);
|
|
28
|
+
const faceCanvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
29
|
+
const streamRef = useRef<MediaStream | null>(null);
|
|
30
|
+
const [cameraReady, setCameraReady] = useState(false);
|
|
31
|
+
const [livenessStage, setLivenessStage] = useState<"CENTER" | "LEFT" | "RIGHT" | "SNAP" | "DONE">("CENTER");
|
|
32
|
+
const [livenessReady, setLivenessReady] = useState(false);
|
|
33
|
+
const [livenessFailed, setLivenessFailed] = useState(false);
|
|
34
|
+
const [modelLoading, setModelLoading] = useState(true);
|
|
35
|
+
const [modelLoaded, setModelLoaded] = useState(false);
|
|
36
|
+
const modelLoadedRef = useRef<boolean>(false);
|
|
37
|
+
const livenessFailedRef = useRef<boolean>(false);
|
|
38
|
+
const livenessTimerRef = useRef<number | null>(null);
|
|
39
|
+
const cameraDriverRef = useRef<number | null>(null);
|
|
40
|
+
const [livenessInstruction, setLivenessInstruction] = useState<string>("Look straight at the camera");
|
|
41
|
+
const centerHoldRef = useRef<number>(0);
|
|
42
|
+
const leftHoldRef = useRef<number>(0);
|
|
43
|
+
const rightHoldRef = useRef<number>(0);
|
|
44
|
+
const centerAfterHoldRef = useRef<number>(0);
|
|
45
|
+
const snapTriggeredRef = useRef<boolean>(false);
|
|
46
|
+
const rafIdRef = useRef<number | null>(null);
|
|
47
|
+
const lastResultsAtRef = useRef<number>(0);
|
|
48
|
+
const [isDocScanMode, setIsDocScanMode] = useState(false);
|
|
49
|
+
const docVideoRef = useRef<HTMLVideoElement | null>(null);
|
|
50
|
+
const docStreamRef = useRef<MediaStream | null>(null);
|
|
51
|
+
const [docPreviewUrl, setDocPreviewUrl] = useState<string | null>(null);
|
|
52
|
+
const docPendingBlobRef = useRef<Blob | null>(null);
|
|
53
|
+
const livenessStageRef = useRef<"CENTER" | "LEFT" | "RIGHT" | "SNAP" | "DONE">("CENTER");
|
|
54
|
+
const [faceAlreadyRegistered, setFaceAlreadyRegistered] = useState(false);
|
|
55
|
+
const [retryingFace, setRetryingFace] = useState(false);
|
|
56
|
+
// (Optional) Offscreen/cv placeholders if needed later
|
|
57
|
+
const offscreenRef = useRef<HTMLCanvasElement | null>(null);
|
|
58
|
+
|
|
59
|
+
// Keep ref in sync with state to avoid stale reads inside callbacks
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
livenessStageRef.current = livenessStage;
|
|
62
|
+
}, [livenessStage]);
|
|
63
|
+
|
|
64
|
+
// Revoke object URL when preview is replaced/removed
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
return () => {
|
|
67
|
+
if (docPreviewUrl && docPreviewUrl.startsWith("blob:")) {
|
|
68
|
+
try { URL.revokeObjectURL(docPreviewUrl); } catch { }
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}, [docPreviewUrl]);
|
|
72
|
+
|
|
73
|
+
// Helper to set stage while keeping ref/state aligned
|
|
74
|
+
const setStage = (next: "CENTER" | "LEFT" | "RIGHT" | "SNAP" | "DONE") => {
|
|
75
|
+
try { console.log('[MobileKycPage] setStage', { from: livenessStageRef.current, to: next }); } catch {}
|
|
76
|
+
livenessStageRef.current = next;
|
|
77
|
+
setLivenessStage(next);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const getDisplayError = (err: unknown): string => {
|
|
81
|
+
const raw = (err && (err as any).message) ? String((err as any).message) : String(err || "");
|
|
82
|
+
const afterColon = raw.includes(":") ? raw.split(":").slice(1).join(":").trim() : raw;
|
|
83
|
+
try {
|
|
84
|
+
const parsed = JSON.parse(afterColon);
|
|
85
|
+
if (parsed && typeof parsed.message === "string") return parsed.message;
|
|
86
|
+
} catch { }
|
|
87
|
+
const match = raw.match(/(Face does not match[^\n]*)/i) || raw.match(/(Document[^\n]*failed[^\n]*)/i);
|
|
88
|
+
if (match && match[1]) return match[1];
|
|
89
|
+
return afterColon.replace(/^document upload failed\s*/i, "").replace(/^face upload failed\s*/i, "").trim() || "Something went wrong";
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const handleRetryDocument = () => {
|
|
93
|
+
setDocFileName("");
|
|
94
|
+
if (fileInputRef.current) {
|
|
95
|
+
fileInputRef.current.value = "";
|
|
96
|
+
fileInputRef.current.click();
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Use provided SDK instance if given, otherwise create one from serverKey and apiBaseUrl (to avoid default backend on mobile)
|
|
101
|
+
const sdk = providedSdk || (serverKey ? new VerificationSDK({ serverKey, apiBaseUrl }) : new VerificationSDK({ serverKey: "", apiBaseUrl }));
|
|
102
|
+
try { console.log('[MobileKycPage] sdk:init', { hasSdk: Boolean(sdk), sessionId }); } catch {}
|
|
103
|
+
// If caller passed a sessionId prop (or init response), assign it to the SDK so uploads/status checks work
|
|
104
|
+
if (sessionId) {
|
|
105
|
+
try {
|
|
106
|
+
sdk.setSessionId(sessionId);
|
|
107
|
+
try { console.log('[MobileKycPage] sdk:setSessionId', { sessionId }); } catch {}
|
|
108
|
+
} catch (e) {
|
|
109
|
+
// noop - defensive in case sdk implementation is missing the method
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Auto-hide toast after 3 seconds
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
if (!toastMessage) return;
|
|
116
|
+
try { console.log('[MobileKycPage] effect:autoHideToast', { toastMessage }); } catch {}
|
|
117
|
+
const id = setTimeout(() => setToastMessage(null), 3000);
|
|
118
|
+
return () => clearTimeout(id);
|
|
119
|
+
}, [toastMessage]);
|
|
120
|
+
|
|
121
|
+
// Ensure document type always has a default
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (!docType) setDocType("CNIC");
|
|
124
|
+
}, [docType]);
|
|
125
|
+
|
|
126
|
+
// Map backend next_step/completed_steps to local Step
|
|
127
|
+
const mapStatusToStep = (response: any): Step | null => {
|
|
128
|
+
try { console.log('[MobileKycPage] mapStatusToStep:input', response); } catch {}
|
|
129
|
+
const data = response?.data ?? response ?? {};
|
|
130
|
+
const nextStepRaw: string | undefined = data?.next_step ?? data?.nextStep;
|
|
131
|
+
const completedSteps: string[] = Array.isArray(data?.completed_steps)
|
|
132
|
+
? data.completed_steps
|
|
133
|
+
: (Array.isArray(data?.completedSteps) ? data.completedSteps : []);
|
|
134
|
+
const norm = (s: string) => String(s || "").toLowerCase().replace(/\s+/g, "_");
|
|
135
|
+
const next = nextStepRaw ? norm(nextStepRaw) : "";
|
|
136
|
+
if (next.includes("face")) return "FACE";
|
|
137
|
+
if (next.includes("doc")) return "DOCUMENT";
|
|
138
|
+
if (next.includes("status") || next.includes("result")) return "STATUS";
|
|
139
|
+
// Fallback using completed steps when next_step is missing
|
|
140
|
+
const cs = completedSteps.map(norm);
|
|
141
|
+
if (cs.includes("face_scan") && !cs.includes("document_upload")) return "DOCUMENT";
|
|
142
|
+
if (cs.includes("face_scan") && cs.includes("document_upload")) return showFinalStatus ? "STATUS" : "DOCUMENT";
|
|
143
|
+
const out = null as Step | null;
|
|
144
|
+
try { console.log('[MobileKycPage] mapStatusToStep:output', out); } catch {}
|
|
145
|
+
return out;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// Start camera when FACE step shows (front camera)
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
const startCamera = async () => {
|
|
151
|
+
try {
|
|
152
|
+
try { console.log('[MobileKycPage] camera:startFace'); } catch {}
|
|
153
|
+
const stream = await navigator.mediaDevices.getUserMedia({
|
|
154
|
+
video: {
|
|
155
|
+
facingMode: "user",
|
|
156
|
+
width: { ideal: 1280 },
|
|
157
|
+
height: { ideal: 720 }
|
|
158
|
+
},
|
|
159
|
+
audio: false
|
|
160
|
+
});
|
|
161
|
+
if (videoRef.current) {
|
|
162
|
+
videoRef.current.srcObject = stream;
|
|
163
|
+
await videoRef.current.play().catch(() => { });
|
|
164
|
+
// Sync overlay canvas size with video
|
|
165
|
+
|
|
166
|
+
requestAnimationFrame(() => {
|
|
167
|
+
try {
|
|
168
|
+
const v = videoRef.current!;
|
|
169
|
+
if (faceCanvasRef.current) {
|
|
170
|
+
faceCanvasRef.current.width = v.videoWidth || v.clientWidth || 640;
|
|
171
|
+
faceCanvasRef.current.height = v.videoHeight || v.clientHeight || 480;
|
|
172
|
+
}
|
|
173
|
+
} catch { }
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
streamRef.current = stream;
|
|
177
|
+
setCameraReady(true);
|
|
178
|
+
} catch (e: any) {
|
|
179
|
+
try { console.log('[MobileKycPage] camera:errorFace', e?.message || e); } catch {}
|
|
180
|
+
toast.error(e?.message || "Could not access camera");
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
if (step === "FACE") {
|
|
184
|
+
startCamera();
|
|
185
|
+
}
|
|
186
|
+
return () => {
|
|
187
|
+
if (streamRef.current) {
|
|
188
|
+
streamRef.current.getTracks().forEach((t) => t.stop());
|
|
189
|
+
streamRef.current = null;
|
|
190
|
+
}
|
|
191
|
+
setCameraReady(false);
|
|
192
|
+
setLivenessStage("CENTER");
|
|
193
|
+
setLivenessReady(false);
|
|
194
|
+
setLivenessFailed(false);
|
|
195
|
+
livenessFailedRef.current = false;
|
|
196
|
+
setLivenessInstruction("Look straight at the camera");
|
|
197
|
+
centerHoldRef.current = 0;
|
|
198
|
+
leftHoldRef.current = 0;
|
|
199
|
+
rightHoldRef.current = 0;
|
|
200
|
+
centerAfterHoldRef.current = 0;
|
|
201
|
+
snapTriggeredRef.current = false;
|
|
202
|
+
setModelLoading(true);
|
|
203
|
+
setModelLoaded(false);
|
|
204
|
+
modelLoadedRef.current = false;
|
|
205
|
+
if (livenessTimerRef.current) {
|
|
206
|
+
window.clearInterval(livenessTimerRef.current);
|
|
207
|
+
livenessTimerRef.current = null;
|
|
208
|
+
}
|
|
209
|
+
if (rafIdRef.current) {
|
|
210
|
+
cancelAnimationFrame(rafIdRef.current);
|
|
211
|
+
rafIdRef.current = null;
|
|
212
|
+
}
|
|
213
|
+
// No mediapipe instance retained here
|
|
214
|
+
};
|
|
215
|
+
}, [step]);
|
|
216
|
+
|
|
217
|
+
// Use native MediaPipe FaceMesh and run liveness using 468 landmarks
|
|
218
|
+
useEffect(() => {
|
|
219
|
+
if (step !== "FACE" || !cameraReady) return;
|
|
220
|
+
let cancelled = false;
|
|
221
|
+
let initTimeoutId: number | null = null;
|
|
222
|
+
let localFaceMesh: any = null;
|
|
223
|
+
const start = async () => {
|
|
224
|
+
try {
|
|
225
|
+
try { console.log('[MobileKycPage] facemesh:init'); } catch {}
|
|
226
|
+
const fm = new FaceMesh({ locateFile: (file: string) => `https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh@0.4.1633559619/${file}` });
|
|
227
|
+
fm.setOptions({
|
|
228
|
+
selfieMode: true,
|
|
229
|
+
maxNumFaces: 1,
|
|
230
|
+
refineLandmarks: true,
|
|
231
|
+
minDetectionConfidence: 0.5,
|
|
232
|
+
minTrackingConfidence: 0.5
|
|
233
|
+
} as any);
|
|
234
|
+
localFaceMesh = fm;
|
|
235
|
+
if (cancelled) return;
|
|
236
|
+
setModelLoaded(true);
|
|
237
|
+
setModelLoading(false);
|
|
238
|
+
modelLoadedRef.current = true;
|
|
239
|
+
const drawOverlays = (ctx: CanvasRenderingContext2D, normalized: Array<{ x: number; y: number }>, w: number, h: number) => {
|
|
240
|
+
// Mesh and contours using MediaPipe helpers (expects normalized coords)
|
|
241
|
+
drawConnectors(ctx, normalized as any, FACEMESH_TESSELATION, { color: "#60a5fa", lineWidth: 0.5 });
|
|
242
|
+
drawConnectors(ctx, normalized as any, FACEMESH_FACE_OVAL, { color: "#f59e0b", lineWidth: 2 });
|
|
243
|
+
drawConnectors(ctx, normalized as any, FACEMESH_LEFT_EYE, { color: "#10b981", lineWidth: 1.5 });
|
|
244
|
+
drawConnectors(ctx, normalized as any, FACEMESH_RIGHT_EYE, { color: "#ef4444", lineWidth: 1.5 });
|
|
245
|
+
drawConnectors(ctx, normalized as any, FACEMESH_LIPS, { color: "#a855f7", lineWidth: 1.5 });
|
|
246
|
+
// Points for extra clarity
|
|
247
|
+
drawMPLandmarks(ctx, normalized as any, { color: "#2563eb", lineWidth: 0, radius: 1.5 });
|
|
248
|
+
};
|
|
249
|
+
const onResults = (results: any) => {
|
|
250
|
+
const canvas = faceCanvasRef.current;
|
|
251
|
+
if (!canvas) return;
|
|
252
|
+
const ctx = canvas.getContext("2d");
|
|
253
|
+
if (!ctx) return;
|
|
254
|
+
// Sync canvas internal size to device pixels for crisp drawing and correct mapping
|
|
255
|
+
const dpr = Math.max(1, Math.min(3, window.devicePixelRatio || 1));
|
|
256
|
+
const displayW = (canvas.parentElement as HTMLElement)?.clientWidth || canvas.width;
|
|
257
|
+
const displayH = (canvas.parentElement as HTMLElement)?.clientHeight || canvas.height;
|
|
258
|
+
if (canvas.width !== Math.round(displayW * dpr) || canvas.height !== Math.round(displayH * dpr)) {
|
|
259
|
+
canvas.width = Math.round(displayW * dpr);
|
|
260
|
+
canvas.height = Math.round(displayH * dpr);
|
|
261
|
+
}
|
|
262
|
+
const w = canvas.width, h = canvas.height;
|
|
263
|
+
ctx.clearRect(0, 0, w, h);
|
|
264
|
+
const faces = results.multiFaceLandmarks as Array<Array<{ x: number; y: number }>> | undefined;
|
|
265
|
+
const face = faces && faces[0];
|
|
266
|
+
if (face) {
|
|
267
|
+
// Map MediaPipe normalized coords into canvas space consistent with video objectFit: cover
|
|
268
|
+
const vid = videoRef.current as HTMLVideoElement | null;
|
|
269
|
+
const vidW = Math.max(1, vid?.videoWidth || displayW);
|
|
270
|
+
const vidH = Math.max(1, vid?.videoHeight || displayH);
|
|
271
|
+
const scale = Math.max(w / vidW, h / vidH);
|
|
272
|
+
const offsetX = (w - vidW * scale) / 2;
|
|
273
|
+
const offsetY = (h - vidH * scale) / 2;
|
|
274
|
+
const faceOnCanvas = face.map(p => ({
|
|
275
|
+
x: (p.x * vidW * scale + offsetX) / w,
|
|
276
|
+
y: (p.y * vidH * scale + offsetY) / h,
|
|
277
|
+
}));
|
|
278
|
+
|
|
279
|
+
drawOverlays(ctx, faceOnCanvas as any, w, h);
|
|
280
|
+
// After drawing using normalized coords, draw a vertical line at the world center for orientation debug (optional)
|
|
281
|
+
// ctx.save(); ctx.strokeStyle = "rgba(0,0,0,0.15)"; ctx.beginPath(); ctx.moveTo(w/2, 0); ctx.lineTo(w/2, h); ctx.stroke(); ctx.restore();
|
|
282
|
+
lastResultsAtRef.current = Date.now();
|
|
283
|
+
const eyeA = faceOnCanvas[33];
|
|
284
|
+
const eyeB = faceOnCanvas[263];
|
|
285
|
+
// Because the video is mirrored for user-friendly preview, landmark x is mirrored too.
|
|
286
|
+
// Flip x to compute yaw in real-world orientation (so turning LEFT yields negative yaw).
|
|
287
|
+
const flipX = (p: any) => ({ x: 1 - p.x, y: p.y });
|
|
288
|
+
const eA = flipX(eyeA);
|
|
289
|
+
const eB = flipX(eyeB);
|
|
290
|
+
const n1 = faceOnCanvas[1];
|
|
291
|
+
const n4 = faceOnCanvas[4];
|
|
292
|
+
const nT = flipX(n1 && n4 ? { x: (n1.x + n4.x) / 2, y: (n1.y + n4.y) / 2 } : (n1 || n4 || faceOnCanvas[197]));
|
|
293
|
+
const leftEyeOuter = eA.x < eB.x ? eA : eB;
|
|
294
|
+
const rightEyeOuter = eA.x < eB.x ? eB : eA;
|
|
295
|
+
const noseTip = faceOnCanvas[1] || faceOnCanvas[4] || faceOnCanvas[197];
|
|
296
|
+
if (leftEyeOuter && rightEyeOuter && nT) {
|
|
297
|
+
const faceWidth = Math.abs(rightEyeOuter.x - leftEyeOuter.x);
|
|
298
|
+
const midX = (leftEyeOuter.x + rightEyeOuter.x) / 2;
|
|
299
|
+
const yaw = (nT.x - midX) / Math.max(1e-6, faceWidth);
|
|
300
|
+
const absYaw = Math.abs(yaw);
|
|
301
|
+
// Centering help: check if face fits inside guide
|
|
302
|
+
const xs = faceOnCanvas.map(p => p.x), ys = faceOnCanvas.map(p => p.y);
|
|
303
|
+
const minX = Math.min(...xs) * w, maxX = Math.max(...xs) * w;
|
|
304
|
+
const minY = Math.min(...ys) * h, maxY = Math.max(...ys) * h;
|
|
305
|
+
const boxCX = (minX + maxX) / 2, boxCY = (minY + maxY) / 2;
|
|
306
|
+
// Define circular guide consistent with UI mask (~45% of min dimension)
|
|
307
|
+
const guideCX = w / 2;
|
|
308
|
+
const guideCY = h / 2;
|
|
309
|
+
const guideR = Math.min(w, h) * 0.45;
|
|
310
|
+
const dx = boxCX - guideCX;
|
|
311
|
+
const dy = boxCY - guideCY;
|
|
312
|
+
const insideGuide = (dx * dx + dy * dy) <= (guideR * guideR)
|
|
313
|
+
&& (maxX - minX) <= guideR * 2 * 1.05 && (maxY - minY) <= guideR * 2 * 1.05;
|
|
314
|
+
if (!livenessReady) setLivenessReady(true);
|
|
315
|
+
const centerThreshold = 0.05; // allow slight yaw for center
|
|
316
|
+
const leftThreshold = 0.08; // require a clearer left turn
|
|
317
|
+
const rightThreshold = 0.08; // require a clearer right turn
|
|
318
|
+
const holdFramesCenter = 12; // require ~200-400ms centered
|
|
319
|
+
const holdFramesTurn = 12; // require ~200-400ms turned
|
|
320
|
+
if (livenessStageRef.current === "CENTER") {
|
|
321
|
+
if (!insideGuide) {
|
|
322
|
+
setLivenessInstruction("Center your face inside the circle");
|
|
323
|
+
} else if (absYaw < centerThreshold) {
|
|
324
|
+
centerHoldRef.current += 1;
|
|
325
|
+
if (centerHoldRef.current >= holdFramesCenter) {
|
|
326
|
+
setStage("LEFT");
|
|
327
|
+
setLivenessInstruction("Turn your face LEFT");
|
|
328
|
+
centerHoldRef.current = 0;
|
|
329
|
+
}
|
|
330
|
+
} else {
|
|
331
|
+
centerHoldRef.current = 0;
|
|
332
|
+
if (yaw > 0) setLivenessInstruction("Move your face slightly LEFT"); else setLivenessInstruction("Move your face slightly RIGHT");
|
|
333
|
+
}
|
|
334
|
+
} else if (livenessStageRef.current === "LEFT") {
|
|
335
|
+
if (faceWidth < 0.08) {
|
|
336
|
+
setLivenessInstruction("Move closer to the camera");
|
|
337
|
+
} else if (yaw < -leftThreshold) {
|
|
338
|
+
leftHoldRef.current += 1;
|
|
339
|
+
if (leftHoldRef.current >= holdFramesTurn) {
|
|
340
|
+
setStage("RIGHT");
|
|
341
|
+
setLivenessInstruction("Great! Now turn your face RIGHT");
|
|
342
|
+
leftHoldRef.current = 0;
|
|
343
|
+
}
|
|
344
|
+
} else {
|
|
345
|
+
leftHoldRef.current = 0;
|
|
346
|
+
if (yaw > rightThreshold) setLivenessInstruction("You're facing right. Turn LEFT"); else setLivenessInstruction("Turn a bit more LEFT");
|
|
347
|
+
}
|
|
348
|
+
} else if (livenessStageRef.current === "RIGHT") {
|
|
349
|
+
if (faceWidth < 0.08) {
|
|
350
|
+
setLivenessInstruction("Move closer to the camera");
|
|
351
|
+
} else if (yaw > rightThreshold) {
|
|
352
|
+
rightHoldRef.current += 1;
|
|
353
|
+
if (rightHoldRef.current >= holdFramesTurn) {
|
|
354
|
+
rightHoldRef.current = 0;
|
|
355
|
+
if (!snapTriggeredRef.current) {
|
|
356
|
+
snapTriggeredRef.current = true;
|
|
357
|
+
setStage("DONE");
|
|
358
|
+
setLivenessInstruction("Capturing...");
|
|
359
|
+
handleFaceCapture();
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
} else {
|
|
363
|
+
rightHoldRef.current = 0;
|
|
364
|
+
if (yaw < -leftThreshold) setLivenessInstruction("You're facing left. Turn RIGHT"); else setLivenessInstruction("Turn a bit more RIGHT");
|
|
365
|
+
}
|
|
366
|
+
} else if (livenessStageRef.current === "SNAP") {
|
|
367
|
+
// legacy path not used
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
} else {
|
|
371
|
+
if (Date.now() - lastResultsAtRef.current > 2000) setLivenessInstruction("No face detected. Center your face in frame with good lighting.");
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
fm.onResults(onResults);
|
|
376
|
+
if (videoRef.current) {
|
|
377
|
+
const tick = async () => {
|
|
378
|
+
if (cancelled) return;
|
|
379
|
+
if (!videoRef.current) return;
|
|
380
|
+
await fm.send({ image: videoRef.current as HTMLVideoElement });
|
|
381
|
+
cameraDriverRef.current = requestAnimationFrame(tick);
|
|
382
|
+
};
|
|
383
|
+
cameraDriverRef.current = requestAnimationFrame(tick);
|
|
384
|
+
}
|
|
385
|
+
} catch (e) {
|
|
386
|
+
if (!cancelled) {
|
|
387
|
+
setLivenessFailed(true);
|
|
388
|
+
setModelLoading(false);
|
|
389
|
+
setModelLoaded(false);
|
|
390
|
+
livenessFailedRef.current = true;
|
|
391
|
+
console.error("mediapipe facemesh init error", e);
|
|
392
|
+
toast.error((e as any)?.message || String(e));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
// Fail-safe timeout to fallback to manual capture
|
|
398
|
+
initTimeoutId = window.setTimeout(() => {
|
|
399
|
+
if (!modelLoadedRef.current && !livenessFailedRef.current && !cancelled) {
|
|
400
|
+
setLivenessFailed(true);
|
|
401
|
+
setModelLoading(false);
|
|
402
|
+
livenessFailedRef.current = true;
|
|
403
|
+
toast.error("Face model initialization timed out. Continuing without liveness.");
|
|
404
|
+
}
|
|
405
|
+
}, 8000);
|
|
406
|
+
|
|
407
|
+
start();
|
|
408
|
+
return () => {
|
|
409
|
+
cancelled = true;
|
|
410
|
+
if (initTimeoutId) {
|
|
411
|
+
window.clearTimeout(initTimeoutId);
|
|
412
|
+
initTimeoutId = null;
|
|
413
|
+
}
|
|
414
|
+
if (cameraDriverRef.current) {
|
|
415
|
+
cancelAnimationFrame(cameraDriverRef.current);
|
|
416
|
+
cameraDriverRef.current = null;
|
|
417
|
+
}
|
|
418
|
+
if (localFaceMesh) {
|
|
419
|
+
try { (localFaceMesh as any).close?.(); } catch { }
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
}, [step, cameraReady]);
|
|
423
|
+
|
|
424
|
+
// Manage back camera for document scan mode
|
|
425
|
+
useEffect(() => {
|
|
426
|
+
const getRearStream = async (): Promise<MediaStream> => {
|
|
427
|
+
const attempts: MediaStreamConstraints[] = [
|
|
428
|
+
{ video: { facingMode: { exact: 'environment' }, width: { ideal: 1280 }, height: { ideal: 1920 } }, audio: false } as MediaStreamConstraints,
|
|
429
|
+
{ video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 1920 } }, audio: false } as unknown as MediaStreamConstraints,
|
|
430
|
+
{ video: { facingMode: { exact: 'environment' }, width: { ideal: 720 }, height: { ideal: 1280 } }, audio: false } as MediaStreamConstraints,
|
|
431
|
+
{ video: { facingMode: 'environment', width: { ideal: 720 }, height: { ideal: 1280 } }, audio: false } as unknown as MediaStreamConstraints,
|
|
432
|
+
];
|
|
433
|
+
|
|
434
|
+
// Try direct environment-facing attempts first
|
|
435
|
+
for (const c of attempts) {
|
|
436
|
+
try { return await navigator.mediaDevices.getUserMedia(c); } catch { /* continue */ }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Request a generic stream to unlock labels, then enumerate and choose rear device
|
|
440
|
+
let temp: MediaStream | null = null;
|
|
441
|
+
try {
|
|
442
|
+
temp = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
|
|
443
|
+
} catch (e) {
|
|
444
|
+
// If generic itself fails, rethrow
|
|
445
|
+
throw e;
|
|
446
|
+
} finally {
|
|
447
|
+
try { temp?.getTracks().forEach(t => t.stop()); } catch {}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
451
|
+
const videoInputs = devices.filter(d => d.kind === 'videoinput');
|
|
452
|
+
// Prefer labels that look like rear; else pick the first that is not obviously front/selfie
|
|
453
|
+
const rear = videoInputs.find(d => /back|rear|environment/i.test(d.label));
|
|
454
|
+
const chosen = rear || videoInputs.find(d => !/front|user|face/i.test(d.label)) || videoInputs[0];
|
|
455
|
+
if (!chosen) throw new Error('No video input devices found');
|
|
456
|
+
|
|
457
|
+
const byDeviceAttempts: MediaStreamConstraints[] = [
|
|
458
|
+
{ video: { deviceId: { exact: chosen.deviceId }, width: { ideal: 1280 }, height: { ideal: 1920 } }, audio: false },
|
|
459
|
+
{ video: { deviceId: { exact: chosen.deviceId }, width: { ideal: 720 }, height: { ideal: 1280 } }, audio: false },
|
|
460
|
+
{ video: { deviceId: { exact: chosen.deviceId } }, audio: false } as MediaStreamConstraints,
|
|
461
|
+
];
|
|
462
|
+
for (const c of byDeviceAttempts) {
|
|
463
|
+
try { return await navigator.mediaDevices.getUserMedia(c); } catch { /* continue */ }
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// Final fallback
|
|
467
|
+
return await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const startDocCamera = async () => {
|
|
471
|
+
try {
|
|
472
|
+
try { console.log('[MobileKycPage] camera:startDoc'); } catch {}
|
|
473
|
+
// Stop any existing document stream first
|
|
474
|
+
if (docStreamRef.current) {
|
|
475
|
+
try { docStreamRef.current.getTracks().forEach(t => t.stop()); } catch {}
|
|
476
|
+
docStreamRef.current = null;
|
|
477
|
+
}
|
|
478
|
+
const stream = await getRearStream();
|
|
479
|
+
if (docVideoRef.current) {
|
|
480
|
+
docVideoRef.current.srcObject = stream;
|
|
481
|
+
await docVideoRef.current.play().catch(() => {});
|
|
482
|
+
}
|
|
483
|
+
docStreamRef.current = stream;
|
|
484
|
+
} catch (e: any) {
|
|
485
|
+
try { console.log('[MobileKycPage] camera:errorDoc', e?.message || e); } catch {}
|
|
486
|
+
toast.error((e && (e.name || e.message)) ? `${e.name || 'Error'}: ${e.message || 'Could not start video source'}` : 'Could not start video source');
|
|
487
|
+
setIsDocScanMode(false);
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
if (step === "DOCUMENT" && isDocScanMode) {
|
|
491
|
+
startDocCamera();
|
|
492
|
+
}
|
|
493
|
+
return () => {
|
|
494
|
+
if (docStreamRef.current) {
|
|
495
|
+
docStreamRef.current.getTracks().forEach((t) => t.stop());
|
|
496
|
+
docStreamRef.current = null;
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
}, [step, isDocScanMode]);
|
|
500
|
+
|
|
501
|
+
// Check session status on each step
|
|
502
|
+
useEffect(() => {
|
|
503
|
+
let cancelled = false;
|
|
504
|
+
const checkStatus = async () => {
|
|
505
|
+
try {
|
|
506
|
+
const res: any = await sdk.getStatus(serverKey);
|
|
507
|
+
try { console.log('[MobileKycPage] status:check', res); } catch {}
|
|
508
|
+
if (cancelled) return;
|
|
509
|
+
const explicitStatus: string | undefined = res?.data?.status ?? res?.status;
|
|
510
|
+
if (explicitStatus && explicitStatus.toUpperCase() === "EXPIRED") {
|
|
511
|
+
setToastMessage("Session expired. Please start a new KYC session.");
|
|
512
|
+
setTimeout(() => {
|
|
513
|
+
if (!cancelled) window.location.href = "/";
|
|
514
|
+
}, 2000);
|
|
515
|
+
}
|
|
516
|
+
// Drive UI using next_step/completed_steps
|
|
517
|
+
const suggested = mapStatusToStep(res);
|
|
518
|
+
if (suggested && suggested !== step) {
|
|
519
|
+
// If STATUS is suggested and we should show final status, also fetch it
|
|
520
|
+
if (suggested === "STATUS" && showFinalStatus) {
|
|
521
|
+
try {
|
|
522
|
+
const result = await sdk.getResult(serverKey);
|
|
523
|
+
setStatus(result);
|
|
524
|
+
} catch {
|
|
525
|
+
setStatus(res);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
setStep(suggested);
|
|
529
|
+
}
|
|
530
|
+
} catch (e: any) {
|
|
531
|
+
// Ignore status check errors to avoid disrupting flow
|
|
532
|
+
try { console.log('[MobileKycPage] status:error', e?.message || e); } catch {}
|
|
533
|
+
}
|
|
534
|
+
};
|
|
535
|
+
checkStatus();
|
|
536
|
+
return () => { cancelled = true; };
|
|
537
|
+
}, [step, sdk]);
|
|
538
|
+
|
|
539
|
+
// On mount, hydrate step from status so refresh returns to the correct place
|
|
540
|
+
useEffect(() => {
|
|
541
|
+
let cancelled = false;
|
|
542
|
+
const hydrate = async () => {
|
|
543
|
+
try {
|
|
544
|
+
const res = await sdk.getStatus(serverKey);
|
|
545
|
+
try { console.log('[MobileKycPage] hydrate', res); } catch {}
|
|
546
|
+
if (cancelled) return;
|
|
547
|
+
const suggested = mapStatusToStep(res);
|
|
548
|
+
if (suggested) {
|
|
549
|
+
if (suggested === "STATUS" && showFinalStatus) {
|
|
550
|
+
try {
|
|
551
|
+
const result = await sdk.getResult(serverKey);
|
|
552
|
+
setStatus(result);
|
|
553
|
+
} catch {
|
|
554
|
+
setStatus(res);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
setStep(suggested);
|
|
558
|
+
}
|
|
559
|
+
} catch {}
|
|
560
|
+
};
|
|
561
|
+
hydrate();
|
|
562
|
+
return () => { cancelled = true; };
|
|
563
|
+
}, []);
|
|
564
|
+
|
|
565
|
+
// Capture face from mobile camera
|
|
566
|
+
const handleFaceCapture = async () => {
|
|
567
|
+
try { console.log('[MobileKycPage] handleFaceCapture:start'); } catch {}
|
|
568
|
+
if (!videoRef.current) return;
|
|
569
|
+
setLoading(true);
|
|
570
|
+
try {
|
|
571
|
+
setFaceAlreadyRegistered(false);
|
|
572
|
+
const video = videoRef.current;
|
|
573
|
+
const canvas = document.createElement("canvas");
|
|
574
|
+
const width = video.videoWidth || 640;
|
|
575
|
+
const height = video.videoHeight || 480;
|
|
576
|
+
canvas.width = width;
|
|
577
|
+
canvas.height = height;
|
|
578
|
+
const ctx = canvas.getContext("2d");
|
|
579
|
+
if (!ctx) throw new Error("Canvas not supported");
|
|
580
|
+
ctx.drawImage(video, 0, 0, width, height);
|
|
581
|
+
const dataUrl = canvas.toDataURL("image/jpeg", 0.92);
|
|
582
|
+
// @ts-ignore
|
|
583
|
+
await sdk.uploadFace(await (await fetch(dataUrl)).blob(), serverKey);
|
|
584
|
+
try { console.log('[MobileKycPage] handleFaceCapture:success'); } catch {}
|
|
585
|
+
setStep("DOCUMENT");
|
|
586
|
+
} catch (err: any) {
|
|
587
|
+
const msg = getDisplayError(err) || "Face capture failed";
|
|
588
|
+
if (typeof msg === "string" && msg.toLowerCase().includes("face already registered")) {
|
|
589
|
+
setFaceAlreadyRegistered(true);
|
|
590
|
+
toast.info("Face already registered. You can request a retry to scan again.");
|
|
591
|
+
} else {
|
|
592
|
+
toast.error(msg);
|
|
593
|
+
}
|
|
594
|
+
} finally {
|
|
595
|
+
setLoading(false);
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
const handleRetryFace = async () => {
|
|
600
|
+
if (!serverKey) {
|
|
601
|
+
toast.error("Missing server key for retry.");
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
setRetryingFace(true);
|
|
605
|
+
try {
|
|
606
|
+
await sdk.retrySession(serverKey);
|
|
607
|
+
setFaceAlreadyRegistered(false);
|
|
608
|
+
setStage("CENTER");
|
|
609
|
+
setToastMessage("Retry processed. Please scan your face again.");
|
|
610
|
+
} catch (err: any) {
|
|
611
|
+
toast.error(getDisplayError(err) || "Unable to process retry");
|
|
612
|
+
} finally {
|
|
613
|
+
setRetryingFace(false);
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
// Handle document upload
|
|
618
|
+
const handleDocumentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
619
|
+
try { console.log('[MobileKycPage] handleDocumentUpload:start'); } catch {}
|
|
620
|
+
if (loading) return; // prevent concurrent actions
|
|
621
|
+
const file = event.target.files?.[0];
|
|
622
|
+
if (!file) return;
|
|
623
|
+
setDocFileName(file.name || "");
|
|
624
|
+
try {
|
|
625
|
+
// Show preview and defer actual upload until user confirms
|
|
626
|
+
const objectUrl = URL.createObjectURL(file);
|
|
627
|
+
setDocPreviewUrl(objectUrl);
|
|
628
|
+
docPendingBlobRef.current = file;
|
|
629
|
+
} catch (err: any) {
|
|
630
|
+
toast.error(getDisplayError(err) || "Could not preview document");
|
|
631
|
+
}
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
const handleConfirmDocumentUpload = async () => {
|
|
635
|
+
try { console.log('[MobileKycPage] handleConfirmDocumentUpload:start', { hasPending: Boolean(docPendingBlobRef.current), docType }); } catch {}
|
|
636
|
+
if (loading || !docPendingBlobRef.current) return;
|
|
637
|
+
setLoading(true);
|
|
638
|
+
try {
|
|
639
|
+
await sdk.uploadDocument(docPendingBlobRef.current, docType,serverKey);
|
|
640
|
+
if (showFinalStatus) {
|
|
641
|
+
setStep("STATUS");
|
|
642
|
+
try {
|
|
643
|
+
const result = await sdk.getResult(serverKey);
|
|
644
|
+
setStatus(result);
|
|
645
|
+
} catch {
|
|
646
|
+
const res = await sdk.getStatus(serverKey);
|
|
647
|
+
setStatus(res);
|
|
648
|
+
}
|
|
649
|
+
} else {
|
|
650
|
+
setToastMessage("Document uploaded. Please return to desktop to check status.");
|
|
651
|
+
}
|
|
652
|
+
setDocPreviewUrl(null);
|
|
653
|
+
docPendingBlobRef.current = null;
|
|
654
|
+
setDocFileName("");
|
|
655
|
+
} catch (err: any) {
|
|
656
|
+
toast.error(getDisplayError(err) || "Document upload failed");
|
|
657
|
+
} finally {
|
|
658
|
+
setLoading(false);
|
|
659
|
+
}
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
// Capture from back camera for document scan and upload
|
|
663
|
+
const handleDocumentScanCapture = async () => {
|
|
664
|
+
try { console.log('[MobileKycPage] handleDocumentScanCapture:start'); } catch {}
|
|
665
|
+
if (!docVideoRef.current) return;
|
|
666
|
+
setLoading(true);
|
|
667
|
+
try {
|
|
668
|
+
const video = docVideoRef.current;
|
|
669
|
+
const canvas = document.createElement("canvas");
|
|
670
|
+
const width = video.videoWidth || 1280;
|
|
671
|
+
const height = video.videoHeight || 720;
|
|
672
|
+
canvas.width = width;
|
|
673
|
+
canvas.height = height;
|
|
674
|
+
const ctx = canvas.getContext("2d");
|
|
675
|
+
if (!ctx) throw new Error("Canvas not supported");
|
|
676
|
+
ctx.drawImage(video, 0, 0, width, height);
|
|
677
|
+
const dataUrl = canvas.toDataURL("image/jpeg", 0.92);
|
|
678
|
+
const blob = await (await fetch(dataUrl)).blob();
|
|
679
|
+
await sdk.uploadDocument(blob, docType,serverKey);
|
|
680
|
+
try { console.log('[MobileKycPage] handleDocumentScanCapture:success'); } catch {}
|
|
681
|
+
setIsDocScanMode(false);
|
|
682
|
+
// Stop camera after capture
|
|
683
|
+
if (docStreamRef.current) {
|
|
684
|
+
docStreamRef.current.getTracks().forEach((t) => t.stop());
|
|
685
|
+
docStreamRef.current = null;
|
|
686
|
+
}
|
|
687
|
+
if (showFinalStatus) {
|
|
688
|
+
setStep("STATUS");
|
|
689
|
+
try {
|
|
690
|
+
const result = await sdk.getResult(serverKey);
|
|
691
|
+
setStatus(result);
|
|
692
|
+
} catch {
|
|
693
|
+
const res = await sdk.getStatus(serverKey);
|
|
694
|
+
setStatus(res);
|
|
695
|
+
}
|
|
696
|
+
} else {
|
|
697
|
+
setToastMessage("Document scanned and uploaded. Please return to desktop to check status.");
|
|
698
|
+
}
|
|
699
|
+
} catch (err: any) {
|
|
700
|
+
toast.error(getDisplayError(err) || "Document scan failed");
|
|
701
|
+
} finally {
|
|
702
|
+
setLoading(false);
|
|
703
|
+
}
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
return (
|
|
707
|
+
<div style={{
|
|
708
|
+
minHeight: "100vh",
|
|
709
|
+
background: "#000000",
|
|
710
|
+
padding: "20px",
|
|
711
|
+
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"
|
|
712
|
+
}}>
|
|
713
|
+
<div style={{
|
|
714
|
+
maxWidth: "400px",
|
|
715
|
+
margin: "0 auto",
|
|
716
|
+
background: "#0b0f17",
|
|
717
|
+
borderRadius: "16px",
|
|
718
|
+
padding: "24px",
|
|
719
|
+
boxShadow: "0 4px 6px -1px rgba(0, 0, 0, 0.6)"
|
|
720
|
+
}}>
|
|
721
|
+
{toastMessage && (
|
|
722
|
+
<div style={{
|
|
723
|
+
background: "#065f46",
|
|
724
|
+
color: "#ffffff",
|
|
725
|
+
padding: "12px",
|
|
726
|
+
borderRadius: "8px",
|
|
727
|
+
marginBottom: "16px",
|
|
728
|
+
textAlign: "center",
|
|
729
|
+
fontSize: "14px"
|
|
730
|
+
}}>
|
|
731
|
+
{toastMessage}
|
|
732
|
+
</div>
|
|
733
|
+
)}
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
{step === "FACE" && (
|
|
737
|
+
<div>
|
|
738
|
+
<h2 style={{ margin: "0 0 16px 0", fontSize: "26px", fontWeight: 700, color: "#ffffff", textAlign: "center" }}>
|
|
739
|
+
Capture Face
|
|
740
|
+
</h2>
|
|
741
|
+
<div style={{ display: "grid", gap: "16px" }}>
|
|
742
|
+
{!modelLoading && modelLoaded && !livenessFailed && (
|
|
743
|
+
<div style={{
|
|
744
|
+
background: "#0a2315",
|
|
745
|
+
color: "#34d399",
|
|
746
|
+
padding: "14px 16px",
|
|
747
|
+
borderRadius: "12px",
|
|
748
|
+
fontSize: "14px",
|
|
749
|
+
border: "1px solid #155e3b",
|
|
750
|
+
textAlign: "left"
|
|
751
|
+
}}>
|
|
752
|
+
Face detection model loaded.
|
|
753
|
+
</div>
|
|
754
|
+
)}
|
|
755
|
+
{modelLoading && !livenessFailed && (
|
|
756
|
+
<div style={{
|
|
757
|
+
background: "#1f2937",
|
|
758
|
+
color: "#e5e7eb",
|
|
759
|
+
padding: "14px 16px",
|
|
760
|
+
borderRadius: "12px",
|
|
761
|
+
fontSize: "14px",
|
|
762
|
+
border: "1px solid #374151",
|
|
763
|
+
textAlign: "left"
|
|
764
|
+
}}>
|
|
765
|
+
Loading face detection model...
|
|
766
|
+
</div>
|
|
767
|
+
)}
|
|
768
|
+
<div style={{ position: "relative", width: "100%", aspectRatio: "1 / 1", borderRadius: "9999px", overflow: "hidden", background: "#000" }}>
|
|
769
|
+
<video
|
|
770
|
+
ref={videoRef}
|
|
771
|
+
playsInline
|
|
772
|
+
muted
|
|
773
|
+
style={{
|
|
774
|
+
width: "100%",
|
|
775
|
+
height: "100%",
|
|
776
|
+
display: "block",
|
|
777
|
+
background: "#000",
|
|
778
|
+
objectFit: "cover",
|
|
779
|
+
transform: "scaleX(-1)",
|
|
780
|
+
transformOrigin: "center"
|
|
781
|
+
}}
|
|
782
|
+
/>
|
|
783
|
+
{/* Canvas for any drawing (kept below ring) */}
|
|
784
|
+
<canvas
|
|
785
|
+
ref={faceCanvasRef}
|
|
786
|
+
style={{
|
|
787
|
+
position: "absolute",
|
|
788
|
+
top: 0,
|
|
789
|
+
left: 0,
|
|
790
|
+
width: "100%",
|
|
791
|
+
height: "100%",
|
|
792
|
+
pointerEvents: "none",
|
|
793
|
+
zIndex: 2
|
|
794
|
+
}}
|
|
795
|
+
/>
|
|
796
|
+
{/* Dotted green ring */}
|
|
797
|
+
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style={{ position: "absolute", inset: 0, pointerEvents: "none", zIndex: 3 }}>
|
|
798
|
+
<circle cx="50" cy="50" r="44" fill="none" stroke="#22c55e" strokeWidth="2" strokeDasharray="1 3" />
|
|
799
|
+
</svg>
|
|
800
|
+
</div>
|
|
801
|
+
{!livenessFailed && (
|
|
802
|
+
<div style={{
|
|
803
|
+
background: "linear-gradient(180deg, rgba(17,24,39,0.9) 0%, rgba(17,24,39,0.6) 100%)",
|
|
804
|
+
color: "#e5e7eb",
|
|
805
|
+
padding: "16px",
|
|
806
|
+
borderRadius: "14px",
|
|
807
|
+
fontSize: "16px",
|
|
808
|
+
border: "1px solid #30363d"
|
|
809
|
+
}}>
|
|
810
|
+
<div style={{ fontWeight: 700, marginBottom: 10, fontSize: 22, color: "#ffffff" }}>Liveness Check</div>
|
|
811
|
+
<div style={{ marginBottom: 10, color: "#e5e7eb", fontSize: 16 }}>{livenessInstruction}</div>
|
|
812
|
+
<div style={{ display: "grid", gap: 10, fontSize: 18 }}>
|
|
813
|
+
<div style={{ opacity: livenessStage === "CENTER" || livenessStage === "LEFT" || livenessStage === "RIGHT" || livenessStage === "DONE" ? 1 : 0.4 }}>1. Look Straight</div>
|
|
814
|
+
<div style={{ opacity: livenessStage === "RIGHT" || livenessStage === "DONE" ? 1 : 0.4 }}>2. Turn your face right</div>
|
|
815
|
+
<div style={{ opacity: livenessStage === "DONE" ? 1 : 0.3 }}>3. Turn your face left</div>
|
|
816
|
+
</div>
|
|
817
|
+
</div>
|
|
818
|
+
)}
|
|
819
|
+
<button
|
|
820
|
+
type="button"
|
|
821
|
+
disabled={!cameraReady || loading || (!livenessFailed && livenessStage !== "DONE")}
|
|
822
|
+
onClick={handleFaceCapture}
|
|
823
|
+
style={{
|
|
824
|
+
padding: "14px 16px",
|
|
825
|
+
borderRadius: "12px",
|
|
826
|
+
background: cameraReady && !loading && (livenessFailed || livenessStage === "DONE") ? "#22c55e" : "#374151",
|
|
827
|
+
color: cameraReady && !loading && (livenessFailed || livenessStage === "DONE") ? "#0b0f17" : "#e5e7eb",
|
|
828
|
+
fontSize: "16px",
|
|
829
|
+
fontWeight: 700,
|
|
830
|
+
cursor: cameraReady && !loading && (livenessFailed || livenessStage === "DONE") ? "pointer" : "not-allowed",
|
|
831
|
+
border: "none"
|
|
832
|
+
}}
|
|
833
|
+
>
|
|
834
|
+
{loading
|
|
835
|
+
? "Capturing..."
|
|
836
|
+
: (livenessFailed || livenessStage === "DONE")
|
|
837
|
+
? "Capture & Continue"
|
|
838
|
+
: "Complete steps to continue"}
|
|
839
|
+
</button>
|
|
840
|
+
{faceAlreadyRegistered && (
|
|
841
|
+
<div style={{ marginTop: 12 }}>
|
|
842
|
+
<p style={{ margin: "0 0 8px 0", color: "#fbbf24", fontSize: "14px" }}>
|
|
843
|
+
Your face is already registered. Tap retry to request a new attempt, then scan your face again.
|
|
844
|
+
</p>
|
|
845
|
+
<button
|
|
846
|
+
type="button"
|
|
847
|
+
onClick={handleRetryFace}
|
|
848
|
+
disabled={retryingFace}
|
|
849
|
+
style={{
|
|
850
|
+
padding: "12px 16px",
|
|
851
|
+
borderRadius: "10px",
|
|
852
|
+
background: "#f97316",
|
|
853
|
+
color: "#0b0f17",
|
|
854
|
+
fontSize: "15px",
|
|
855
|
+
fontWeight: 600,
|
|
856
|
+
border: "none",
|
|
857
|
+
cursor: retryingFace ? "not-allowed" : "pointer",
|
|
858
|
+
width: "100%"
|
|
859
|
+
}}
|
|
860
|
+
>
|
|
861
|
+
{retryingFace ? "Processing retry..." : "Retry face scan"}
|
|
862
|
+
</button>
|
|
863
|
+
</div>
|
|
864
|
+
)}
|
|
865
|
+
</div>
|
|
866
|
+
</div>
|
|
867
|
+
)}
|
|
868
|
+
|
|
869
|
+
{step === "DOCUMENT" && (
|
|
870
|
+
<div>
|
|
871
|
+
<h2 style={{ margin: "0 0 16px 0", fontSize: "20px", fontWeight: "700", color: "#ffffff" }}>
|
|
872
|
+
Document
|
|
873
|
+
</h2>
|
|
874
|
+
{!isDocScanMode && (
|
|
875
|
+
<div style={{ display: "grid", gap: "8px", marginBottom: "12px" }}>
|
|
876
|
+
<button
|
|
877
|
+
type="button"
|
|
878
|
+
onClick={() => fileInputRef.current?.click()}
|
|
879
|
+
style={{
|
|
880
|
+
width: "100%",
|
|
881
|
+
padding: "12px 16px",
|
|
882
|
+
borderRadius: "8px",
|
|
883
|
+
border: "2px dashed #374151",
|
|
884
|
+
background: "#0f172a",
|
|
885
|
+
color: "#e5e7eb",
|
|
886
|
+
fontSize: "16px",
|
|
887
|
+
cursor: "pointer"
|
|
888
|
+
}}
|
|
889
|
+
>
|
|
890
|
+
Upload Document
|
|
891
|
+
</button>
|
|
892
|
+
<button
|
|
893
|
+
type="button"
|
|
894
|
+
onClick={() => { setIsDocScanMode(true); }}
|
|
895
|
+
style={{
|
|
896
|
+
width: "100%",
|
|
897
|
+
padding: "12px 16px",
|
|
898
|
+
borderRadius: "8px",
|
|
899
|
+
background: "linear-gradient(90deg, #FF8A00 0%, #FF3D77 100%)",
|
|
900
|
+
color: "#ffffff",
|
|
901
|
+
border: "none",
|
|
902
|
+
fontSize: "16px",
|
|
903
|
+
cursor: "pointer"
|
|
904
|
+
}}
|
|
905
|
+
>
|
|
906
|
+
Scan Document (Back Camera)
|
|
907
|
+
</button>
|
|
908
|
+
</div>
|
|
909
|
+
)}
|
|
910
|
+
<div style={{ display: "flex", gap: "12px", marginBottom: "16px" }}>
|
|
911
|
+
<label style={{ display: "flex", alignItems: "center", gap: "6px", color: "#e5e7eb" }}>
|
|
912
|
+
<input
|
|
913
|
+
type="radio"
|
|
914
|
+
name="doc-type"
|
|
915
|
+
value="CNIC"
|
|
916
|
+
checked={docType === "CNIC"}
|
|
917
|
+
defaultChecked
|
|
918
|
+
onChange={() => setDocType("CNIC")}
|
|
919
|
+
/>
|
|
920
|
+
NIC
|
|
921
|
+
</label>
|
|
922
|
+
<label style={{ display: "flex", alignItems: "center", gap: "6px", color: "#e5e7eb" }}>
|
|
923
|
+
<input
|
|
924
|
+
type="radio"
|
|
925
|
+
name="doc-type"
|
|
926
|
+
value="Passport"
|
|
927
|
+
checked={docType === "Passport"}
|
|
928
|
+
onChange={() => setDocType("Passport")}
|
|
929
|
+
/>
|
|
930
|
+
Passport
|
|
931
|
+
</label>
|
|
932
|
+
<label style={{ display: "flex", alignItems: "center", gap: "6px", color: "#e5e7eb" }}>
|
|
933
|
+
<input
|
|
934
|
+
type="radio"
|
|
935
|
+
name="doc-type"
|
|
936
|
+
value="DrivingLicense"
|
|
937
|
+
checked={docType === "DrivingLicense"}
|
|
938
|
+
onChange={() => setDocType("DrivingLicense")}
|
|
939
|
+
/>
|
|
940
|
+
DrivingLicense
|
|
941
|
+
</label>
|
|
942
|
+
</div>
|
|
943
|
+
<input
|
|
944
|
+
ref={fileInputRef}
|
|
945
|
+
type="file"
|
|
946
|
+
accept="image/*,.pdf"
|
|
947
|
+
onChange={handleDocumentUpload}
|
|
948
|
+
style={{ display: "none" }}
|
|
949
|
+
/>
|
|
950
|
+
{isDocScanMode && (
|
|
951
|
+
<div style={{ display: "grid", gap: "12px", marginTop: "8px" }}>
|
|
952
|
+
<div style={{ position: "relative", width: "100%", aspectRatio: "3 / 4", borderRadius: "8px", overflow: "hidden", background: "#000" }}>
|
|
953
|
+
<video
|
|
954
|
+
ref={docVideoRef}
|
|
955
|
+
playsInline
|
|
956
|
+
muted
|
|
957
|
+
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
|
958
|
+
/>
|
|
959
|
+
</div>
|
|
960
|
+
<div style={{ display: "flex", gap: "8px" }}>
|
|
961
|
+
<button
|
|
962
|
+
type="button"
|
|
963
|
+
onClick={handleDocumentScanCapture}
|
|
964
|
+
disabled={loading}
|
|
965
|
+
style={{
|
|
966
|
+
flex: 1,
|
|
967
|
+
padding: "12px 16px",
|
|
968
|
+
borderRadius: "8px",
|
|
969
|
+
background: "linear-gradient(90deg, #FF8A00 0%, #FF3D77 100%)",
|
|
970
|
+
color: "#ffffff",
|
|
971
|
+
border: "none",
|
|
972
|
+
fontSize: "16px",
|
|
973
|
+
cursor: "pointer"
|
|
974
|
+
}}
|
|
975
|
+
>
|
|
976
|
+
{loading ? "Uploading..." : "Scan & Upload"}
|
|
977
|
+
</button>
|
|
978
|
+
<button
|
|
979
|
+
type="button"
|
|
980
|
+
onClick={() => { setIsDocScanMode(false); }}
|
|
981
|
+
disabled={loading}
|
|
982
|
+
style={{
|
|
983
|
+
padding: "12px 16px",
|
|
984
|
+
borderRadius: "8px",
|
|
985
|
+
background: "#111827",
|
|
986
|
+
color: "#e5e7eb",
|
|
987
|
+
border: "none",
|
|
988
|
+
fontSize: "16px",
|
|
989
|
+
cursor: "pointer"
|
|
990
|
+
}}
|
|
991
|
+
>
|
|
992
|
+
Cancel
|
|
993
|
+
</button>
|
|
994
|
+
</div>
|
|
995
|
+
<p style={{ margin: 0, color: "#9ca3af", fontSize: "12px", textAlign: "center" }}>
|
|
996
|
+
Align your document in the frame. Use good lighting.
|
|
997
|
+
</p>
|
|
998
|
+
</div>
|
|
999
|
+
)}
|
|
1000
|
+
{docFileName && (
|
|
1001
|
+
<div style={{
|
|
1002
|
+
display: "inline-flex",
|
|
1003
|
+
alignItems: "center",
|
|
1004
|
+
gap: "8px",
|
|
1005
|
+
padding: "8px 12px",
|
|
1006
|
+
borderRadius: "9999px",
|
|
1007
|
+
background: "#1f2937",
|
|
1008
|
+
color: "#e5e7eb",
|
|
1009
|
+
fontSize: "14px"
|
|
1010
|
+
}}>
|
|
1011
|
+
<span>✔</span>
|
|
1012
|
+
<span>File selected</span>
|
|
1013
|
+
</div>
|
|
1014
|
+
)}
|
|
1015
|
+
{loading && (
|
|
1016
|
+
<p style={{ margin: "12px 0 0 0", color: "#60a5fa", textAlign: "center" }}>
|
|
1017
|
+
Processing...
|
|
1018
|
+
</p>
|
|
1019
|
+
)}
|
|
1020
|
+
<div style={{ height: 12 }} />
|
|
1021
|
+
{!isDocScanMode && docPreviewUrl && (
|
|
1022
|
+
<div style={{ marginTop: 12 }}>
|
|
1023
|
+
<div style={{ fontWeight: 600, marginBottom: 6, color: "#e5e7eb" }}>Preview</div>
|
|
1024
|
+
{/* eslint-disable-next-line jsx-a11y/img-redundant-alt */}
|
|
1025
|
+
<img src={docPreviewUrl} alt="Document preview" style={{ width: "100%", borderRadius: 8, border: "1px solid #374151" }} />
|
|
1026
|
+
<div style={{ display: "grid", gap: 8, gridTemplateColumns: "1fr 1fr", marginTop: 8 }}>
|
|
1027
|
+
<button
|
|
1028
|
+
type="button"
|
|
1029
|
+
onClick={handleConfirmDocumentUpload}
|
|
1030
|
+
disabled={loading}
|
|
1031
|
+
style={{
|
|
1032
|
+
padding: "12px 16px",
|
|
1033
|
+
borderRadius: "8px",
|
|
1034
|
+
background: "linear-gradient(90deg, #10b981 0%, #059669 100%)",
|
|
1035
|
+
color: "#ffffff",
|
|
1036
|
+
border: "none",
|
|
1037
|
+
fontSize: "16px",
|
|
1038
|
+
cursor: "pointer",
|
|
1039
|
+
width: "100%"
|
|
1040
|
+
}}
|
|
1041
|
+
>
|
|
1042
|
+
{loading ? "Uploading..." : "Looks good, continue"}
|
|
1043
|
+
</button>
|
|
1044
|
+
<button
|
|
1045
|
+
type="button"
|
|
1046
|
+
onClick={() => { setDocPreviewUrl(null); docPendingBlobRef.current = null; setDocFileName(""); }}
|
|
1047
|
+
disabled={loading}
|
|
1048
|
+
style={{
|
|
1049
|
+
padding: "12px 16px",
|
|
1050
|
+
borderRadius: "8px",
|
|
1051
|
+
background: "#111827",
|
|
1052
|
+
color: "#e5e7eb",
|
|
1053
|
+
border: "none",
|
|
1054
|
+
fontSize: "16px",
|
|
1055
|
+
cursor: "pointer",
|
|
1056
|
+
width: "100%"
|
|
1057
|
+
}}
|
|
1058
|
+
>
|
|
1059
|
+
Retake / Choose again
|
|
1060
|
+
</button>
|
|
1061
|
+
</div>
|
|
1062
|
+
</div>
|
|
1063
|
+
)}
|
|
1064
|
+
{!isDocScanMode && !docFileName && !docPreviewUrl && (
|
|
1065
|
+
<p style={{ margin: 0, color: "#9ca3af", fontSize: "12px", textAlign: "center" }}>
|
|
1066
|
+
After uploading or scanning, return to your desktop to check status.
|
|
1067
|
+
</p>
|
|
1068
|
+
)}
|
|
1069
|
+
</div>
|
|
1070
|
+
)}
|
|
1071
|
+
|
|
1072
|
+
{step === "STATUS" && (
|
|
1073
|
+
<div>
|
|
1074
|
+
<h2 style={{ margin: "0 0 16px 0", fontSize: "20px", fontWeight: "600", color: "#111827" }}>
|
|
1075
|
+
Verification Status
|
|
1076
|
+
</h2>
|
|
1077
|
+
{status ? (
|
|
1078
|
+
<div style={{ background: "#f3f4f6", padding: 12, borderRadius: 8 }}>
|
|
1079
|
+
{(() => {
|
|
1080
|
+
const data: any = status?.data ?? status;
|
|
1081
|
+
const reference: string | undefined = data?.reference ?? data?.ref;
|
|
1082
|
+
const email: string | undefined = data?.email;
|
|
1083
|
+
const faceStatus: string | undefined = data?.face_recognition_status ?? data?.face_status ?? data?.face ?? data?.faceRecognitionStatus;
|
|
1084
|
+
const kycStatus: string | undefined = data?.kyc_status ?? data?.status ?? status?.status;
|
|
1085
|
+
const redirectUrl: string | undefined = data?.redirect_url ?? data?.redirectUrl;
|
|
1086
|
+
return (
|
|
1087
|
+
<div style={{ display: "grid", gap: 8, color: "#000", padding: 0 }}>
|
|
1088
|
+
{reference && (
|
|
1089
|
+
<div><strong>Reference:</strong> {reference}</div>
|
|
1090
|
+
)}
|
|
1091
|
+
{email && (
|
|
1092
|
+
<div><strong>Email:</strong> {email}</div>
|
|
1093
|
+
)}
|
|
1094
|
+
{faceStatus && (
|
|
1095
|
+
<div><strong>Face Status:</strong> {String(faceStatus)}</div>
|
|
1096
|
+
)}
|
|
1097
|
+
{kycStatus && (
|
|
1098
|
+
<div><strong>KYC Status:</strong> {String(kycStatus)}</div>
|
|
1099
|
+
)}
|
|
1100
|
+
{redirectUrl && (
|
|
1101
|
+
<div style={{ overflowWrap: "anywhere" }}>
|
|
1102
|
+
<strong>Redirect URL:</strong> {redirectUrl}
|
|
1103
|
+
</div>
|
|
1104
|
+
)}
|
|
1105
|
+
{!reference && !email && !faceStatus && !kycStatus && !redirectUrl && (
|
|
1106
|
+
<pre style={{ margin: 0 }}>{JSON.stringify(status, null, 2)}</pre>
|
|
1107
|
+
)}
|
|
1108
|
+
</div>
|
|
1109
|
+
);
|
|
1110
|
+
})()}
|
|
1111
|
+
</div>
|
|
1112
|
+
) : (
|
|
1113
|
+
<p style={{ color: "#6b7280" }}>No status available.</p>
|
|
1114
|
+
)}
|
|
1115
|
+
</div>
|
|
1116
|
+
)}
|
|
1117
|
+
</div>
|
|
1118
|
+
</div>
|
|
1119
|
+
);
|
|
1120
|
+
};
|
|
1121
|
+
|
|
1122
|
+
|