import React, { useEffect, useRef, useState } from "react"; import { FaceMesh, FACEMESH_TESSELATION, FACEMESH_FACE_OVAL, FACEMESH_LEFT_EYE, FACEMESH_RIGHT_EYE, FACEMESH_LIPS } from "@mediapipe/face_mesh"; import { drawConnectors, drawLandmarks as drawMPLandmarks } from "@mediapipe/drawing_utils"; import { toast } from "react-toastify"; import { KycApiService } from "../services/kycApiService"; import "./kycModal.css"; interface MobileKycPageProps { sessionId?: string; serverKey?: string; sdk?: KycApiService; showFinalStatus?: boolean; // when true, show STATUS on mobile after document apiBaseUrl?: string; } type Step = "FACE" | "DOCUMENT" | "STATUS"; export const MobileKycPage: React.FC = ({ sessionId, serverKey, sdk: providedSdk, showFinalStatus = false, apiBaseUrl }) => { try { console.log('[MobileKycPage] render', { sessionId, hasServerKey: Boolean(serverKey), hasProvidedSdk: Boolean(providedSdk), showFinalStatus }); } catch {} const [step, setStep] = useState("FACE"); const [loading, setLoading] = useState(false); const [status, setStatus] = useState(null); const [toastMessage, setToastMessage] = useState(null); const [docType, setDocType] = useState("CNIC"); const [docFileName, setDocFileName] = useState(""); const fileInputRef = useRef(null); const videoRef = useRef(null); const faceCanvasRef = useRef(null); const streamRef = useRef(null); const [cameraReady, setCameraReady] = useState(false); const [livenessStage, setLivenessStage] = useState<"CENTER" | "LEFT" | "RIGHT" | "SNAP" | "DONE">("CENTER"); const [livenessReady, setLivenessReady] = useState(false); const [livenessFailed, setLivenessFailed] = useState(false); const [modelLoading, setModelLoading] = useState(true); const [modelLoaded, setModelLoaded] = useState(false); const modelLoadedRef = useRef(false); const livenessFailedRef = useRef(false); const livenessTimerRef = useRef(null); const cameraDriverRef = useRef(null); const [livenessInstruction, setLivenessInstruction] = useState("Look straight at the camera"); const centerHoldRef = useRef(0); const leftHoldRef = useRef(0); const rightHoldRef = useRef(0); const centerAfterHoldRef = useRef(0); const snapTriggeredRef = useRef(false); const rafIdRef = useRef(null); const lastResultsAtRef = useRef(0); const [isDocScanMode, setIsDocScanMode] = useState(false); const docVideoRef = useRef(null); const docStreamRef = useRef(null); const [docPreviewUrl, setDocPreviewUrl] = useState(null); const docPendingBlobRef = useRef(null); const livenessStageRef = useRef<"CENTER" | "LEFT" | "RIGHT" | "SNAP" | "DONE">("CENTER"); const [faceAlreadyRegistered, setFaceAlreadyRegistered] = useState(false); const [retryingFace, setRetryingFace] = useState(false); // (Optional) Offscreen/cv placeholders if needed later const offscreenRef = useRef(null); // Keep ref in sync with state to avoid stale reads inside callbacks useEffect(() => { livenessStageRef.current = livenessStage; }, [livenessStage]); // Revoke object URL when preview is replaced/removed useEffect(() => { return () => { if (docPreviewUrl && docPreviewUrl.startsWith("blob:")) { try { URL.revokeObjectURL(docPreviewUrl); } catch { } } }; }, [docPreviewUrl]); // Helper to set stage while keeping ref/state aligned const setStage = (next: "CENTER" | "LEFT" | "RIGHT" | "SNAP" | "DONE") => { try { console.log('[MobileKycPage] setStage', { from: livenessStageRef.current, to: next }); } catch {} livenessStageRef.current = next; setLivenessStage(next); }; const getDisplayError = (err: unknown): string => { const raw = (err && (err as any).message) ? String((err as any).message) : String(err || ""); const afterColon = raw.includes(":") ? raw.split(":").slice(1).join(":").trim() : raw; try { const parsed = JSON.parse(afterColon); if (parsed && typeof parsed.message === "string") return parsed.message; } catch { } const match = raw.match(/(Face does not match[^\n]*)/i) || raw.match(/(Document[^\n]*failed[^\n]*)/i); if (match && match[1]) return match[1]; return afterColon.replace(/^document upload failed\s*/i, "").replace(/^face upload failed\s*/i, "").trim() || "Something went wrong"; }; const handleRetryDocument = () => { setDocFileName(""); if (fileInputRef.current) { fileInputRef.current.value = ""; fileInputRef.current.click(); } }; // Use provided SDK instance if given, otherwise create one from serverKey and apiBaseUrl const sdk = providedSdk || (sessionId && apiBaseUrl && serverKey ? new KycApiService({ sessionId, apiBaseUrl, serverKey, deviceType: 'mobile' }) : null); try { console.log('[MobileKycPage] sdk:init', { hasSdk: Boolean(sdk), sessionId }); } catch {} // Auto-hide toast after 3 seconds useEffect(() => { if (!toastMessage) return; try { console.log('[MobileKycPage] effect:autoHideToast', { toastMessage }); } catch {} const id = setTimeout(() => setToastMessage(null), 3000); return () => clearTimeout(id); }, [toastMessage]); // Ensure document type always has a default useEffect(() => { if (!docType) setDocType("CNIC"); }, [docType]); // Map backend next_step/completed_steps to local Step const mapStatusToStep = (response: any): Step | null => { try { console.log('[MobileKycPage] mapStatusToStep:input', response); } catch {} const data = response?.data ?? response ?? {}; const nextStepRaw: string | undefined = data?.next_step ?? data?.nextStep; const completedSteps: string[] = Array.isArray(data?.completed_steps) ? data.completed_steps : (Array.isArray(data?.completedSteps) ? data.completedSteps : []); const norm = (s: string) => String(s || "").toLowerCase().replace(/\s+/g, "_"); const next = nextStepRaw ? norm(nextStepRaw) : ""; if (next.includes("face")) return "FACE"; if (next.includes("doc")) return "DOCUMENT"; if (next.includes("status") || next.includes("result")) return "STATUS"; // Fallback using completed steps when next_step is missing const cs = completedSteps.map(norm); if (cs.includes("face_scan") && !cs.includes("document_upload")) return "DOCUMENT"; if (cs.includes("face_scan") && cs.includes("document_upload")) return showFinalStatus ? "STATUS" : "DOCUMENT"; const out = null as Step | null; try { console.log('[MobileKycPage] mapStatusToStep:output', out); } catch {} return out; }; // Start camera when FACE step shows (front camera) useEffect(() => { const startCamera = async () => { try { try { console.log('[MobileKycPage] camera:startFace'); } catch {} const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "user", width: { ideal: 1280 }, height: { ideal: 720 } }, audio: false }); if (videoRef.current) { videoRef.current.srcObject = stream; await videoRef.current.play().catch(() => { }); // Sync overlay canvas size with video requestAnimationFrame(() => { try { const v = videoRef.current!; if (faceCanvasRef.current) { faceCanvasRef.current.width = v.videoWidth || v.clientWidth || 640; faceCanvasRef.current.height = v.videoHeight || v.clientHeight || 480; } } catch { } }); } streamRef.current = stream; setCameraReady(true); } catch (e: any) { try { console.log('[MobileKycPage] camera:errorFace', e?.message || e); } catch {} toast.error(e?.message || "Could not access camera"); } }; if (step === "FACE") { startCamera(); } return () => { if (streamRef.current) { streamRef.current.getTracks().forEach((t) => t.stop()); streamRef.current = null; } setCameraReady(false); setLivenessStage("CENTER"); setLivenessReady(false); setLivenessFailed(false); livenessFailedRef.current = false; setLivenessInstruction("Look straight at the camera"); centerHoldRef.current = 0; leftHoldRef.current = 0; rightHoldRef.current = 0; centerAfterHoldRef.current = 0; snapTriggeredRef.current = false; setModelLoading(true); setModelLoaded(false); modelLoadedRef.current = false; if (livenessTimerRef.current) { window.clearInterval(livenessTimerRef.current); livenessTimerRef.current = null; } if (rafIdRef.current) { cancelAnimationFrame(rafIdRef.current); rafIdRef.current = null; } // No mediapipe instance retained here }; }, [step]); // Use native MediaPipe FaceMesh and run liveness using 468 landmarks useEffect(() => { if (step !== "FACE" || !cameraReady) return; let cancelled = false; let initTimeoutId: number | null = null; let localFaceMesh: any = null; const start = async () => { try { try { console.log('[MobileKycPage] facemesh:init'); } catch {} const fm = new FaceMesh({ locateFile: (file: string) => `https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh@0.4.1633559619/${file}` }); fm.setOptions({ selfieMode: true, maxNumFaces: 1, refineLandmarks: true, minDetectionConfidence: 0.5, minTrackingConfidence: 0.5 } as any); localFaceMesh = fm; if (cancelled) return; setModelLoaded(true); setModelLoading(false); modelLoadedRef.current = true; const drawOverlays = (ctx: CanvasRenderingContext2D, normalized: Array<{ x: number; y: number }>, w: number, h: number) => { // Mesh and contours using MediaPipe helpers (expects normalized coords) drawConnectors(ctx, normalized as any, FACEMESH_TESSELATION, { color: "#60a5fa", lineWidth: 0.5 }); drawConnectors(ctx, normalized as any, FACEMESH_FACE_OVAL, { color: "#f59e0b", lineWidth: 2 }); drawConnectors(ctx, normalized as any, FACEMESH_LEFT_EYE, { color: "#10b981", lineWidth: 1.5 }); drawConnectors(ctx, normalized as any, FACEMESH_RIGHT_EYE, { color: "#ef4444", lineWidth: 1.5 }); drawConnectors(ctx, normalized as any, FACEMESH_LIPS, { color: "#a855f7", lineWidth: 1.5 }); // Points for extra clarity drawMPLandmarks(ctx, normalized as any, { color: "#2563eb", lineWidth: 0, radius: 1.5 }); }; const onResults = (results: any) => { const canvas = faceCanvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; // Sync canvas internal size to device pixels for crisp drawing and correct mapping const dpr = Math.max(1, Math.min(3, window.devicePixelRatio || 1)); const displayW = (canvas.parentElement as HTMLElement)?.clientWidth || canvas.width; const displayH = (canvas.parentElement as HTMLElement)?.clientHeight || canvas.height; if (canvas.width !== Math.round(displayW * dpr) || canvas.height !== Math.round(displayH * dpr)) { canvas.width = Math.round(displayW * dpr); canvas.height = Math.round(displayH * dpr); } const w = canvas.width, h = canvas.height; ctx.clearRect(0, 0, w, h); const faces = results.multiFaceLandmarks as Array> | undefined; const face = faces && faces[0]; if (face) { // Map MediaPipe normalized coords into canvas space consistent with video objectFit: cover const vid = videoRef.current as HTMLVideoElement | null; const vidW = Math.max(1, vid?.videoWidth || displayW); const vidH = Math.max(1, vid?.videoHeight || displayH); const scale = Math.max(w / vidW, h / vidH); const offsetX = (w - vidW * scale) / 2; const offsetY = (h - vidH * scale) / 2; const faceOnCanvas = face.map(p => ({ x: (p.x * vidW * scale + offsetX) / w, y: (p.y * vidH * scale + offsetY) / h, })); drawOverlays(ctx, faceOnCanvas as any, w, h); // After drawing using normalized coords, draw a vertical line at the world center for orientation debug (optional) // 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(); lastResultsAtRef.current = Date.now(); const eyeA = faceOnCanvas[33]; const eyeB = faceOnCanvas[263]; // Because the video is mirrored for user-friendly preview, landmark x is mirrored too. // Flip x to compute yaw in real-world orientation (so turning LEFT yields negative yaw). const flipX = (p: any) => ({ x: 1 - p.x, y: p.y }); const eA = flipX(eyeA); const eB = flipX(eyeB); const n1 = faceOnCanvas[1]; const n4 = faceOnCanvas[4]; const nT = flipX(n1 && n4 ? { x: (n1.x + n4.x) / 2, y: (n1.y + n4.y) / 2 } : (n1 || n4 || faceOnCanvas[197])); const leftEyeOuter = eA.x < eB.x ? eA : eB; const rightEyeOuter = eA.x < eB.x ? eB : eA; const noseTip = faceOnCanvas[1] || faceOnCanvas[4] || faceOnCanvas[197]; if (leftEyeOuter && rightEyeOuter && nT) { const faceWidth = Math.abs(rightEyeOuter.x - leftEyeOuter.x); const midX = (leftEyeOuter.x + rightEyeOuter.x) / 2; const yaw = (nT.x - midX) / Math.max(1e-6, faceWidth); const absYaw = Math.abs(yaw); // Centering help: check if face fits inside guide const xs = faceOnCanvas.map(p => p.x), ys = faceOnCanvas.map(p => p.y); const minX = Math.min(...xs) * w, maxX = Math.max(...xs) * w; const minY = Math.min(...ys) * h, maxY = Math.max(...ys) * h; const boxCX = (minX + maxX) / 2, boxCY = (minY + maxY) / 2; // Define circular guide consistent with UI mask (~45% of min dimension) const guideCX = w / 2; const guideCY = h / 2; const guideR = Math.min(w, h) * 0.45; const dx = boxCX - guideCX; const dy = boxCY - guideCY; const insideGuide = (dx * dx + dy * dy) <= (guideR * guideR) && (maxX - minX) <= guideR * 2 * 1.05 && (maxY - minY) <= guideR * 2 * 1.05; if (!livenessReady) setLivenessReady(true); const centerThreshold = 0.05; // allow slight yaw for center const leftThreshold = 0.08; // require a clearer left turn const rightThreshold = 0.08; // require a clearer right turn const holdFramesCenter = 12; // require ~200-400ms centered const holdFramesTurn = 12; // require ~200-400ms turned if (livenessStageRef.current === "CENTER") { if (!insideGuide) { setLivenessInstruction("Center your face inside the circle"); } else if (absYaw < centerThreshold) { centerHoldRef.current += 1; if (centerHoldRef.current >= holdFramesCenter) { setStage("LEFT"); setLivenessInstruction("Turn your face LEFT"); centerHoldRef.current = 0; } } else { centerHoldRef.current = 0; if (yaw > 0) setLivenessInstruction("Move your face slightly LEFT"); else setLivenessInstruction("Move your face slightly RIGHT"); } } else if (livenessStageRef.current === "LEFT") { if (faceWidth < 0.08) { setLivenessInstruction("Move closer to the camera"); } else if (yaw < -leftThreshold) { leftHoldRef.current += 1; if (leftHoldRef.current >= holdFramesTurn) { setStage("RIGHT"); setLivenessInstruction("Great! Now turn your face RIGHT"); leftHoldRef.current = 0; } } else { leftHoldRef.current = 0; if (yaw > rightThreshold) setLivenessInstruction("You're facing right. Turn LEFT"); else setLivenessInstruction("Turn a bit more LEFT"); } } else if (livenessStageRef.current === "RIGHT") { if (faceWidth < 0.08) { setLivenessInstruction("Move closer to the camera"); } else if (yaw > rightThreshold) { rightHoldRef.current += 1; if (rightHoldRef.current >= holdFramesTurn) { rightHoldRef.current = 0; if (!snapTriggeredRef.current) { snapTriggeredRef.current = true; setStage("DONE"); setLivenessInstruction("Capturing..."); handleFaceCapture(); } } } else { rightHoldRef.current = 0; if (yaw < -leftThreshold) setLivenessInstruction("You're facing left. Turn RIGHT"); else setLivenessInstruction("Turn a bit more RIGHT"); } } else if (livenessStageRef.current === "SNAP") { // legacy path not used } } } else { if (Date.now() - lastResultsAtRef.current > 2000) setLivenessInstruction("No face detected. Center your face in frame with good lighting."); } }; fm.onResults(onResults); if (videoRef.current) { const tick = async () => { if (cancelled) return; if (!videoRef.current) return; await fm.send({ image: videoRef.current as HTMLVideoElement }); cameraDriverRef.current = requestAnimationFrame(tick); }; cameraDriverRef.current = requestAnimationFrame(tick); } } catch (e) { if (!cancelled) { setLivenessFailed(true); setModelLoading(false); setModelLoaded(false); livenessFailedRef.current = true; console.error("mediapipe facemesh init error", e); toast.error((e as any)?.message || String(e)); } } }; // Fail-safe timeout to fallback to manual capture initTimeoutId = window.setTimeout(() => { if (!modelLoadedRef.current && !livenessFailedRef.current && !cancelled) { setLivenessFailed(true); setModelLoading(false); livenessFailedRef.current = true; toast.error("Face model initialization timed out. Continuing without liveness."); } }, 8000); start(); return () => { cancelled = true; if (initTimeoutId) { window.clearTimeout(initTimeoutId); initTimeoutId = null; } if (cameraDriverRef.current) { cancelAnimationFrame(cameraDriverRef.current); cameraDriverRef.current = null; } if (localFaceMesh) { try { (localFaceMesh as any).close?.(); } catch { } } }; }, [step, cameraReady]); // Manage back camera for document scan mode useEffect(() => { const getRearStream = async (): Promise => { const attempts: MediaStreamConstraints[] = [ { video: { facingMode: { exact: 'environment' }, width: { ideal: 1280 }, height: { ideal: 1920 } }, audio: false } as MediaStreamConstraints, { video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 1920 } }, audio: false } as unknown as MediaStreamConstraints, { video: { facingMode: { exact: 'environment' }, width: { ideal: 720 }, height: { ideal: 1280 } }, audio: false } as MediaStreamConstraints, { video: { facingMode: 'environment', width: { ideal: 720 }, height: { ideal: 1280 } }, audio: false } as unknown as MediaStreamConstraints, ]; // Try direct environment-facing attempts first for (const c of attempts) { try { return await navigator.mediaDevices.getUserMedia(c); } catch { /* continue */ } } // Request a generic stream to unlock labels, then enumerate and choose rear device let temp: MediaStream | null = null; try { temp = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }); } catch (e) { // If generic itself fails, rethrow throw e; } finally { try { temp?.getTracks().forEach(t => t.stop()); } catch {} } const devices = await navigator.mediaDevices.enumerateDevices(); const videoInputs = devices.filter(d => d.kind === 'videoinput'); // Prefer labels that look like rear; else pick the first that is not obviously front/selfie const rear = videoInputs.find(d => /back|rear|environment/i.test(d.label)); const chosen = rear || videoInputs.find(d => !/front|user|face/i.test(d.label)) || videoInputs[0]; if (!chosen) throw new Error('No video input devices found'); const byDeviceAttempts: MediaStreamConstraints[] = [ { video: { deviceId: { exact: chosen.deviceId }, width: { ideal: 1280 }, height: { ideal: 1920 } }, audio: false }, { video: { deviceId: { exact: chosen.deviceId }, width: { ideal: 720 }, height: { ideal: 1280 } }, audio: false }, { video: { deviceId: { exact: chosen.deviceId } }, audio: false } as MediaStreamConstraints, ]; for (const c of byDeviceAttempts) { try { return await navigator.mediaDevices.getUserMedia(c); } catch { /* continue */ } } // Final fallback return await navigator.mediaDevices.getUserMedia({ video: true, audio: false }); }; const startDocCamera = async () => { try { try { console.log('[MobileKycPage] camera:startDoc'); } catch {} // Stop any existing document stream first if (docStreamRef.current) { try { docStreamRef.current.getTracks().forEach(t => t.stop()); } catch {} docStreamRef.current = null; } const stream = await getRearStream(); if (docVideoRef.current) { docVideoRef.current.srcObject = stream; await docVideoRef.current.play().catch(() => {}); } docStreamRef.current = stream; } catch (e: any) { try { console.log('[MobileKycPage] camera:errorDoc', e?.message || e); } catch {} toast.error((e && (e.name || e.message)) ? `${e.name || 'Error'}: ${e.message || 'Could not start video source'}` : 'Could not start video source'); setIsDocScanMode(false); } }; if (step === "DOCUMENT" && isDocScanMode) { startDocCamera(); } return () => { if (docStreamRef.current) { docStreamRef.current.getTracks().forEach((t) => t.stop()); docStreamRef.current = null; } }; }, [step, isDocScanMode]); // Check session status on each step useEffect(() => { if (!sdk) return; let cancelled = false; const checkStatus = async () => { try { const res: any = await sdk.getSessionStatus(); try { console.log('[MobileKycPage] status:check', res); } catch {} if (cancelled) return; const explicitStatus: string | undefined = res?.data?.status ?? res?.status; if (explicitStatus && explicitStatus.toUpperCase() === "EXPIRED") { setToastMessage("Session expired. Please start a new KYC session."); setTimeout(() => { if (!cancelled) window.location.href = "/"; }, 2000); } // Drive UI using next_step/completed_steps const suggested = mapStatusToStep(res); if (suggested && suggested !== step) { // If STATUS is suggested and we should show final status, also fetch it if (suggested === "STATUS" && showFinalStatus) { try { const result = await sdk.getResult(); setStatus(result); } catch { setStatus(res); } } setStep(suggested); } } catch (e: any) { // Ignore status check errors to avoid disrupting flow try { console.log('[MobileKycPage] status:error', e?.message || e); } catch {} } }; checkStatus(); return () => { cancelled = true; }; }, [step, sdk]); // On mount, hydrate step from status so refresh returns to the correct place useEffect(() => { if (!sdk) return; let cancelled = false; const hydrate = async () => { try { const res = await sdk.getSessionStatus(); try { console.log('[MobileKycPage] hydrate', res); } catch {} if (cancelled) return; const suggested = mapStatusToStep(res); if (suggested) { if (suggested === "STATUS" && showFinalStatus) { try { const result = await sdk.getResult(); setStatus(result); } catch { setStatus(res); } } setStep(suggested); } } catch {} }; hydrate(); return () => { cancelled = true; }; }, [sdk, showFinalStatus]); // Capture face from mobile camera const handleFaceCapture = async () => { try { console.log('[MobileKycPage] handleFaceCapture:start'); } catch {} if (!videoRef.current) return; setLoading(true); try { setFaceAlreadyRegistered(false); const video = videoRef.current; const canvas = document.createElement("canvas"); const width = video.videoWidth || 640; const height = video.videoHeight || 480; canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Canvas not supported"); ctx.drawImage(video, 0, 0, width, height); const dataUrl = canvas.toDataURL("image/jpeg", 0.92); if (!sdk) throw new Error('SDK not initialized'); await sdk.uploadFaceScan(await (await fetch(dataUrl)).blob()); try { console.log('[MobileKycPage] handleFaceCapture:success'); } catch {} setStep("DOCUMENT"); } catch (err: any) { const msg = getDisplayError(err) || "Face capture failed"; if (typeof msg === "string" && msg.toLowerCase().includes("face already registered")) { setFaceAlreadyRegistered(true); toast.info("Face already registered. You can request a retry to scan again."); } else { toast.error(msg); } } finally { setLoading(false); } }; const handleRetryFace = async () => { if (!sdk) { toast.error("SDK not initialized."); return; } setRetryingFace(true); try { await sdk.retrySession(); setFaceAlreadyRegistered(false); setStage("CENTER"); setToastMessage("Retry processed. Please scan your face again."); } catch (err: any) { toast.error(getDisplayError(err) || "Unable to process retry"); } finally { setRetryingFace(false); } }; // Handle document upload const handleDocumentUpload = async (event: React.ChangeEvent) => { try { console.log('[MobileKycPage] handleDocumentUpload:start'); } catch {} if (loading) return; // prevent concurrent actions const file = event.target.files?.[0]; if (!file) return; setDocFileName(file.name || ""); try { // Show preview and defer actual upload until user confirms const objectUrl = URL.createObjectURL(file); setDocPreviewUrl(objectUrl); docPendingBlobRef.current = file; } catch (err: any) { toast.error(getDisplayError(err) || "Could not preview document"); } }; const handleConfirmDocumentUpload = async () => { try { console.log('[MobileKycPage] handleConfirmDocumentUpload:start', { hasPending: Boolean(docPendingBlobRef.current), docType }); } catch {} if (loading || !docPendingBlobRef.current || !sdk) return; setLoading(true); try { await sdk.uploadDocument(docPendingBlobRef.current, docType); if (showFinalStatus) { setStep("STATUS"); try { const result = await sdk.getResult(); setStatus(result); } catch { const res = await sdk.getSessionStatus(); setStatus(res); } } else { setToastMessage("Document uploaded. Please return to desktop to check status."); } setDocPreviewUrl(null); docPendingBlobRef.current = null; setDocFileName(""); } catch (err: any) { toast.error(getDisplayError(err) || "Document upload failed"); } finally { setLoading(false); } }; // Capture from back camera for document scan and upload const handleDocumentScanCapture = async () => { try { console.log('[MobileKycPage] handleDocumentScanCapture:start'); } catch {} if (!docVideoRef.current) return; setLoading(true); try { const video = docVideoRef.current; const canvas = document.createElement("canvas"); const width = video.videoWidth || 1280; const height = video.videoHeight || 720; canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d"); if (!ctx) throw new Error("Canvas not supported"); ctx.drawImage(video, 0, 0, width, height); const dataUrl = canvas.toDataURL("image/jpeg", 0.92); const blob = await (await fetch(dataUrl)).blob(); if (!sdk) throw new Error('SDK not initialized'); await sdk.uploadDocument(blob, docType); try { console.log('[MobileKycPage] handleDocumentScanCapture:success'); } catch {} setIsDocScanMode(false); // Stop camera after capture if (docStreamRef.current) { docStreamRef.current.getTracks().forEach((t) => t.stop()); docStreamRef.current = null; } if (showFinalStatus) { setStep("STATUS"); try { const result = await sdk.getResult(); setStatus(result); } catch { const res = await sdk.getSessionStatus(); setStatus(res); } } else { setToastMessage("Document scanned and uploaded. Please return to desktop to check status."); } } catch (err: any) { toast.error(getDisplayError(err) || "Document scan failed"); } finally { setLoading(false); } }; return (
{toastMessage && (
{toastMessage}
)} {step === "FACE" && (

Capture Face

{!modelLoading && modelLoaded && !livenessFailed && (
Face detection model loaded.
)} {modelLoading && !livenessFailed && (
Loading face detection model...
)}
{!livenessFailed && (
Liveness Check
{livenessInstruction}
1. Look Straight
2. Turn your face right
3. Turn your face left
)} {faceAlreadyRegistered && (

Your face is already registered. Tap retry to request a new attempt, then scan your face again.

)}
)} {step === "DOCUMENT" && (

Document

{!isDocScanMode && (
)}
{isDocScanMode && (

Align your document in the frame. Use good lighting.

)} {docFileName && (
✔ File selected
)} {loading && (

Processing...

)}
{!isDocScanMode && docPreviewUrl && (
Preview
{/* eslint-disable-next-line jsx-a11y/img-redundant-alt */} Document preview
)} {!isDocScanMode && !docFileName && !docPreviewUrl && (

After uploading or scanning, return to your desktop to check status.

)}
)} {step === "STATUS" && (

Verification Status

{status ? (
{(() => { const data: any = status?.data ?? status; const reference: string | undefined = data?.reference ?? data?.ref; const email: string | undefined = data?.email; const faceStatus: string | undefined = data?.face_recognition_status ?? data?.face_status ?? data?.face ?? data?.faceRecognitionStatus; const kycStatus: string | undefined = data?.kyc_status ?? data?.status ?? status?.status; const redirectUrl: string | undefined = data?.redirect_url ?? data?.redirectUrl; return (
{reference && (
Reference: {reference}
)} {email && (
Email: {email}
)} {faceStatus && (
Face Status: {String(faceStatus)}
)} {kycStatus && (
KYC Status: {String(kycStatus)}
)} {redirectUrl && (
Redirect URL: {redirectUrl}
)} {!reference && !email && !faceStatus && !kycStatus && !redirectUrl && (
{JSON.stringify(status, null, 2)}
)}
); })()}
) : (

No status available.

)}
)}
); };