import React, { useEffect, useRef, useState } from "react"; import { toast, ToastContainer } from "react-toastify"; import "react-toastify/dist/ReactToastify.css"; import { KycApiService } from "../services/kycApiService"; import "./kycModal.css"; interface KycModalClassNames { backdrop?: string; container?: string; closeButton?: string; title?: string; input?: string; statusBlock?: string; error?: string; loading?: string; } interface KycModalStyleOverrides { backdrop?: React.CSSProperties; container?: React.CSSProperties; closeButton?: React.CSSProperties; title?: React.CSSProperties; input?: React.CSSProperties; statusBlock?: React.CSSProperties; error?: React.CSSProperties; loading?: React.CSSProperties; } interface KycModalProps { sdk: KycApiService; onClose: () => void;// e.g., "PASSPORT", "ID_CARD" classNames?: KycModalClassNames; styles?: KycModalStyleOverrides; defaultDocumentType?: string; // Add this } type Step = "FACE" | "DOCUMENT" | "STATUS"; export const KycModal: React.FC = ({ sdk, onClose, classNames, styles, defaultDocumentType = "CNIC" }: KycModalProps) => { try { console.log('[KycModal] render'); } catch {} // Merge component props with defaults const mergedClassNames = { ...(classNames || {}) } as KycModalClassNames; const mergedStyles = { ...(styles || {}) } as KycModalStyleOverrides; const config = sdk.getConfig(); const sessionId = config?.sessionId || localStorage.getItem("kyc_session_id") || null; const qrFromServer = localStorage.getItem("kyc_qrcode_url") || null; const mobileUrl = sessionId ? (() => { const origin = (typeof window !== 'undefined' && window.location && window.location.origin) ? window.location.origin : ''; return `${origin}/mobile-kyc/${sessionId}?mode=face`; })() : null; const showQr = Boolean(qrFromServer || sessionId); try { console.log('[KycModal] computed', { sessionId, showQr }); } catch {} const computedQrSrc = qrFromServer || (mobileUrl ? `https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=${encodeURIComponent(mobileUrl)}` : ""); const [step, setStep] = useState("FACE"); const [loading, setLoading] = useState(false); const [status, setStatus] = useState(null); const videoRef = useRef(null); const streamRef = useRef(null); const [cameraReady, setCameraReady] = useState(false); const [toastMessage, setToastMessage] = useState(null); const [docType, setDocType] = useState(defaultDocumentType); const [docFileName, setDocFileName] = useState(""); const fileInputRef = useRef(null); // Map backend next_step/completed_steps to local Step const mapStatusToStep = (response: any): Step | null => { 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 "STATUS"; return null; }; // Extract a human-friendly error from SDK/network errors const getDisplayError = (err: unknown): string => { const raw = (err && (err as any).message) ? String((err as any).message) : String(err || ""); // Try to extract JSON payload after a colon 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 {} // Pull common phrases (e.g., "Face does not match") if present in raw const match = raw.match(/(Face does not match[^\n]*)/i) || raw.match(/(Document[^\n]*failed[^\n]*)/i); if (match && match[1]) return match[1]; // Fallback to trimmed raw message without the generic prefix 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(); } }; // Auto-hide toast after 3 seconds useEffect(() => { if (!toastMessage) return; const id = setTimeout(() => setToastMessage(null), 3000); return () => clearTimeout(id); }, [toastMessage]); // Start camera when FACE step shows useEffect(() => { const startCamera = async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "user" }, audio: false }); if (videoRef.current) { videoRef.current.srcObject = stream; await videoRef.current.play().catch(() => {}); } streamRef.current = stream; setCameraReady(true); } catch (e: any) { toast.error(e?.message || "Could not access camera"); } }; if (step === "FACE") { startCamera(); } return () => { // Stop camera when leaving screen/component if (streamRef.current) { streamRef.current.getTracks().forEach((t) => t.stop()); streamRef.current = null; } setCameraReady(false); }; }, [step]); // On each step, check session status; if expired, inform and close useEffect(() => { if (!sessionId) return; // avoid calling status without a session let cancelled = false; const checkStatus = async () => { try { const res: any = await sdk.getSessionStatus(); if (cancelled) return; // Detect expired by explicit status or heuristics const explicitStatus: string | undefined = res?.data?.status ?? res?.status; const msg: string | undefined = res?.message; const expiredFlag: boolean | undefined = res?.data?.expired ?? res?.expired; const expiredAt: string | undefined = res?.data?.expired_at ?? res?.expired_at; const expiredByDate = expiredAt ? new Date(expiredAt).getTime() < Date.now() : false; const expiredByMsg = typeof msg === "string" && msg.toLowerCase().includes("expired"); if ((explicitStatus && explicitStatus.toUpperCase() === "EXPIRED") || expiredFlag || expiredByDate || expiredByMsg) { setToastMessage("Session expired. Returning to previous screen..."); setTimeout(() => { if (!cancelled) onClose(); }, 1200); } // Drive UI using next_step/completed_steps const suggested = mapStatusToStep(res); if (suggested && suggested !== step) { setStep(suggested); if (suggested === "STATUS") { try { const result = await sdk.getResult(); setStatus(result); } catch { setStatus(res); } } } } catch (e: any) { // If the server signals expiry via error message const emsg = e?.message as string | undefined; if (emsg && emsg.toLowerCase().includes("expired")) { setToastMessage("Session expired. Returning to previous screen..."); setTimeout(() => { if (!cancelled) onClose(); }, 1200); } // else ignore to avoid disrupting other flows } }; checkStatus(); return () => { cancelled = true; }; }, [step, sdk, onClose, sessionId]); // On mount, hydrate step from status so refresh returns to the correct place useEffect(() => { if (!sessionId) return; let cancelled = false; const hydrate = async () => { try { const res = await sdk.getSessionStatus(); if (cancelled) return; const suggested = mapStatusToStep(res); if (suggested) { setStep(suggested); if (suggested === "STATUS") { try { const result = await sdk.getResult(); setStatus(result); } catch { setStatus(res); } } } } catch {} }; hydrate(); return () => { cancelled = true; }; }, [sdk, sessionId]); // Capture a frame from the camera and upload as base64 const handleFaceCapture = async () => { if (!videoRef.current) return; setLoading(true); try { 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); console.log("sdk from kycmodal", sdk); await sdk.uploadFaceScan(await (await fetch(dataUrl)).blob()); setStep("DOCUMENT"); } catch (err: any) { const msg = getDisplayError(err) || "Face capture failed"; // If face already exists, do not proceed further if (typeof msg === "string" && msg.toLowerCase().includes("face already registered")) { toast.info("Face already registered. Please proceed later or use a different session."); } else { toast.error(msg); } } finally { setLoading(false); } }; // Handle document upload const handleDocumentUpload = async (event: React.ChangeEvent) => { if (loading) return; // prevent concurrent uploads if (!event.target.files?.[0]) return; const file = event.target.files[0]; setDocFileName(file.name || ""); setLoading(true); try { // Single attempt only; user can click "Upload again" if it fails await sdk.uploadDocument(file, docType); setStep("STATUS"); // Prefer final result if available, fallback to status try { const result = await sdk.getResult(); setStatus(result); } catch { const res = await sdk.getSessionStatus(); setStatus(res); } } catch (err: any) { // Reset file selection on error so user can try again setDocFileName(""); if (fileInputRef.current) { fileInputRef.current.value = ""; } // Extract and display error message const msg = getDisplayError(err); const errorMessage = msg || err?.message || "Document upload failed"; // Show appropriate toast based on error type if (errorMessage.toLowerCase().includes("document") && errorMessage.toLowerCase().includes("already exists")) { toast.info("Document already exists. Please do not upload again."); } else if (errorMessage.toLowerCase().includes("session expired") || errorMessage.toLowerCase().includes("expired")) { toast.error("Session expired. Please start a new KYC session."); } else if (errorMessage.toLowerCase().includes("invalid") || errorMessage.toLowerCase().includes("format")) { toast.error("Invalid document format. Please upload a valid image or PDF file."); } else { toast.error(errorMessage); } // Log error for debugging console.error("Document upload error:", err); } finally { setLoading(false); } }; return ( <>
{toastMessage && (
{toastMessage}
)} {/* Loading indicator will be shown contextually within steps */} {step === "FACE" && (

Capture Face

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

Upload Document

{loading && (

Processing...

)} {docFileName && (
✔ File selected
)}
{!docFileName && ( )}
)} {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.

)}
)}
); }