@koraidv/react 1.7.6 → 1.7.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -205,6 +205,37 @@ interface LivenessScreenProps {
205
205
  onComplete: () => Promise<any>;
206
206
  onCancel: () => void;
207
207
  }
208
+ /**
209
+ * Web liveness screen with a real front-facing camera.
210
+ *
211
+ * Before v1.7.7 this was a stub: a static oval guide + a "Complete
212
+ * Challenge" button that submitted a blank 100×100 white canvas. The
213
+ * camera was never wired up — Web shipped without a real liveness
214
+ * implementation while iOS + Android had full liveness from day one.
215
+ * Found and called out by Luckycat's first end-to-end integration on
216
+ * 2026-05-29.
217
+ *
218
+ * This implementation pairs a real `getUserMedia` camera feed with the
219
+ * server's existing liveness pipeline (ml-service detects whether the
220
+ * submitted frame shows the requested gesture). Per-challenge flow:
221
+ *
222
+ * 1. Render the front camera in the oval guide (object-fit cover,
223
+ * circular clip — same visual shape as the previous stub).
224
+ * 2. Show the challenge instruction (Smile / Turn left / Blink / ...).
225
+ * 3. Run a 3-second countdown so the user has time to perform the
226
+ * gesture.
227
+ * 4. When the countdown hits zero, capture a frame from the video
228
+ * and post it to /verifications/{id}/liveness/challenge with the
229
+ * challenge type. The hook's submitChallenge advances state on
230
+ * pass; on fail the same challenge stays current and the
231
+ * countdown re-arms for retry.
232
+ *
233
+ * Client-side gesture detection (MediaPipe Face Mesh + auto-capture
234
+ * when the gesture is satisfied) is the planned follow-up — that would
235
+ * close the UX gap with iOS, where the user doesn't have to time
236
+ * themselves against a countdown. Today's ship is "real camera, real
237
+ * frames, server decides," which is the parity floor.
238
+ */
208
239
  declare function LivenessScreen({ session, currentChallenge, completedChallenges, onChallengeComplete, onStart, onComplete, onCancel, }: LivenessScreenProps): react_jsx_runtime.JSX.Element;
209
240
 
210
241
  type ResultPageMode = 'detailed' | 'simplified';
package/dist/index.d.ts CHANGED
@@ -205,6 +205,37 @@ interface LivenessScreenProps {
205
205
  onComplete: () => Promise<any>;
206
206
  onCancel: () => void;
207
207
  }
208
+ /**
209
+ * Web liveness screen with a real front-facing camera.
210
+ *
211
+ * Before v1.7.7 this was a stub: a static oval guide + a "Complete
212
+ * Challenge" button that submitted a blank 100×100 white canvas. The
213
+ * camera was never wired up — Web shipped without a real liveness
214
+ * implementation while iOS + Android had full liveness from day one.
215
+ * Found and called out by Luckycat's first end-to-end integration on
216
+ * 2026-05-29.
217
+ *
218
+ * This implementation pairs a real `getUserMedia` camera feed with the
219
+ * server's existing liveness pipeline (ml-service detects whether the
220
+ * submitted frame shows the requested gesture). Per-challenge flow:
221
+ *
222
+ * 1. Render the front camera in the oval guide (object-fit cover,
223
+ * circular clip — same visual shape as the previous stub).
224
+ * 2. Show the challenge instruction (Smile / Turn left / Blink / ...).
225
+ * 3. Run a 3-second countdown so the user has time to perform the
226
+ * gesture.
227
+ * 4. When the countdown hits zero, capture a frame from the video
228
+ * and post it to /verifications/{id}/liveness/challenge with the
229
+ * challenge type. The hook's submitChallenge advances state on
230
+ * pass; on fail the same challenge stays current and the
231
+ * countdown re-arms for retry.
232
+ *
233
+ * Client-side gesture detection (MediaPipe Face Mesh + auto-capture
234
+ * when the gesture is satisfied) is the planned follow-up — that would
235
+ * close the UX gap with iOS, where the user doesn't have to time
236
+ * themselves against a countdown. Today's ship is "real camera, real
237
+ * frames, server decides," which is the parity floor.
238
+ */
208
239
  declare function LivenessScreen({ session, currentChallenge, completedChallenges, onChallengeComplete, onStart, onComplete, onCancel, }: LivenessScreenProps): react_jsx_runtime.JSX.Element;
209
240
 
210
241
  type ResultPageMode = 'detailed' | 'simplified';
package/dist/index.js CHANGED
@@ -330,6 +330,15 @@ function useKoraIDV() {
330
330
  return null;
331
331
  }
332
332
  }, [sdk]);
333
+ const completionFiredRef = (0, import_react2.useRef)(false);
334
+ (0, import_react2.useEffect)(() => {
335
+ if (state.step === "processing" && !completionFiredRef.current) {
336
+ completionFiredRef.current = true;
337
+ complete();
338
+ } else if (state.step !== "processing" && state.step !== "complete") {
339
+ completionFiredRef.current = false;
340
+ }
341
+ }, [state.step, complete]);
333
342
  const cancel = (0, import_react2.useCallback)(() => {
334
343
  sdk.reset();
335
344
  setState({
@@ -2341,7 +2350,12 @@ function LivenessScreen({
2341
2350
  onComplete,
2342
2351
  onCancel
2343
2352
  }) {
2353
+ const videoRef = (0, import_react8.useRef)(null);
2354
+ const canvasRef = (0, import_react8.useRef)(null);
2355
+ const [stream, setStream] = (0, import_react8.useState)(null);
2356
+ const [cameraError, setCameraError] = (0, import_react8.useState)(null);
2344
2357
  const [countdown, setCountdown] = (0, import_react8.useState)(3);
2358
+ const [capturing, setCapturing] = (0, import_react8.useState)(false);
2345
2359
  (0, import_react8.useEffect)(() => {
2346
2360
  injectKeyframes();
2347
2361
  }, []);
@@ -2349,24 +2363,89 @@ function LivenessScreen({
2349
2363
  if (!session) onStart();
2350
2364
  }, [session, onStart]);
2351
2365
  (0, import_react8.useEffect)(() => {
2352
- if (session && !currentChallenge && completedChallenges > 0) {
2353
- onComplete();
2366
+ let mounted = true;
2367
+ async function startCamera() {
2368
+ try {
2369
+ const mediaStream = await navigator.mediaDevices.getUserMedia({
2370
+ video: {
2371
+ facingMode: "user",
2372
+ width: { ideal: 720 },
2373
+ height: { ideal: 720 }
2374
+ }
2375
+ });
2376
+ if (!mounted) {
2377
+ mediaStream.getTracks().forEach((t) => t.stop());
2378
+ return;
2379
+ }
2380
+ setStream(mediaStream);
2381
+ if (videoRef.current) {
2382
+ videoRef.current.srcObject = mediaStream;
2383
+ }
2384
+ } catch {
2385
+ if (mounted) {
2386
+ setCameraError(
2387
+ "Camera access denied. Please enable camera permissions and try again."
2388
+ );
2389
+ }
2390
+ }
2354
2391
  }
2355
- }, [session, currentChallenge, completedChallenges, onComplete]);
2392
+ startCamera();
2393
+ return () => {
2394
+ mounted = false;
2395
+ };
2396
+ }, []);
2397
+ (0, import_react8.useEffect)(() => {
2398
+ return () => {
2399
+ stream?.getTracks().forEach((t) => t.stop());
2400
+ };
2401
+ }, [stream]);
2356
2402
  (0, import_react8.useEffect)(() => {
2357
2403
  if (!currentChallenge) return;
2358
2404
  setCountdown(3);
2359
- const interval = setInterval(() => {
2360
- setCountdown((c) => {
2361
- if (c <= 1) {
2362
- clearInterval(interval);
2363
- return 0;
2364
- }
2365
- return c - 1;
2366
- });
2367
- }, 1e3);
2368
- return () => clearInterval(interval);
2369
2405
  }, [currentChallenge?.id]);
2406
+ const captureFrame = (0, import_react8.useCallback)(async () => {
2407
+ if (!currentChallenge || !videoRef.current || !canvasRef.current || capturing) {
2408
+ return;
2409
+ }
2410
+ const video = videoRef.current;
2411
+ const canvas = canvasRef.current;
2412
+ const ctx = canvas.getContext("2d");
2413
+ if (!ctx || video.videoWidth === 0 || video.videoHeight === 0) return;
2414
+ setCapturing(true);
2415
+ canvas.width = video.videoWidth;
2416
+ canvas.height = video.videoHeight;
2417
+ ctx.drawImage(video, 0, 0);
2418
+ canvas.toBlob(
2419
+ async (blob) => {
2420
+ if (blob) {
2421
+ await onChallengeComplete(blob);
2422
+ }
2423
+ setCapturing(false);
2424
+ },
2425
+ "image/jpeg",
2426
+ 0.85
2427
+ );
2428
+ }, [currentChallenge, capturing, onChallengeComplete]);
2429
+ (0, import_react8.useEffect)(() => {
2430
+ if (!currentChallenge || capturing) return;
2431
+ if (countdown === 0) {
2432
+ captureFrame();
2433
+ return;
2434
+ }
2435
+ const t = setTimeout(() => setCountdown((c) => c - 1), 1e3);
2436
+ return () => clearTimeout(t);
2437
+ }, [countdown, currentChallenge?.id, capturing, captureFrame]);
2438
+ (0, import_react8.useEffect)(() => {
2439
+ if (session && !currentChallenge && completedChallenges > 0) {
2440
+ onComplete();
2441
+ }
2442
+ }, [session, currentChallenge, completedChallenges, onComplete]);
2443
+ if (cameraError) {
2444
+ return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("div", { style: styles.darkContainer, children: /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { style: styles.errorContainer, children: [
2445
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("p", { style: styles.errorText, children: cameraError }),
2446
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("button", { style: styles.primaryButton, onClick: onCancel, children: "Go back" })
2447
+ ] }) });
2448
+ }
2370
2449
  if (!session) {
2371
2450
  return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("div", { style: styles.darkContainer, children: /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { style: styles.loadingContainer, children: [
2372
2451
  /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("div", { style: styles.spinner }),
@@ -2382,33 +2461,91 @@ function LivenessScreen({
2382
2461
  /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("button", { style: styles.glassCloseButton, onClick: onCancel, children: "\u2715" })
2383
2462
  ] }),
2384
2463
  currentChallenge && /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("div", { style: { padding: "16px 0" }, children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("h2", { style: styles.challengeTitle, children: currentChallenge.instruction }) }),
2385
- /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("div", { style: { flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }, children: /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { style: { position: "relative" }, children: [
2386
- /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("div", { style: styles.faceGuide, children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
2387
- "svg",
2388
- {
2389
- style: { position: "absolute", top: "-8px", left: "-8px" },
2390
- width: "256",
2391
- height: "316",
2392
- viewBox: "0 0 256 316",
2393
- children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
2394
- "ellipse",
2395
- {
2396
- cx: "128",
2397
- cy: "158",
2398
- rx: "124",
2399
- ry: "154",
2400
- fill: "none",
2401
- stroke: colors.teal,
2402
- strokeWidth: "5",
2403
- strokeDasharray: `${completedChallenges / totalChallenges * 880} 880`,
2404
- transform: "rotate(-90 128 158)",
2405
- strokeLinecap: "round"
2406
- }
2407
- )
2408
- }
2409
- ) }),
2410
- countdown > 0 && /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("div", { style: styles.countdownBadge, children: countdown })
2411
- ] }) }),
2464
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)(
2465
+ "div",
2466
+ {
2467
+ style: {
2468
+ flex: 1,
2469
+ display: "flex",
2470
+ alignItems: "center",
2471
+ justifyContent: "center"
2472
+ },
2473
+ children: [
2474
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { style: { position: "relative" }, children: [
2475
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
2476
+ "div",
2477
+ {
2478
+ style: {
2479
+ width: "240px",
2480
+ height: "300px",
2481
+ borderRadius: "50%",
2482
+ overflow: "hidden",
2483
+ backgroundColor: "#000",
2484
+ border: "3px solid rgba(255,255,255,0.2)"
2485
+ },
2486
+ children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
2487
+ "video",
2488
+ {
2489
+ ref: videoRef,
2490
+ autoPlay: true,
2491
+ playsInline: true,
2492
+ muted: true,
2493
+ style: {
2494
+ width: "100%",
2495
+ height: "100%",
2496
+ objectFit: "cover",
2497
+ transform: "scaleX(-1)"
2498
+ }
2499
+ }
2500
+ )
2501
+ }
2502
+ ),
2503
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
2504
+ "svg",
2505
+ {
2506
+ style: {
2507
+ position: "absolute",
2508
+ top: "-8px",
2509
+ left: "-8px",
2510
+ pointerEvents: "none"
2511
+ },
2512
+ width: "256",
2513
+ height: "316",
2514
+ viewBox: "0 0 256 316",
2515
+ children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
2516
+ "ellipse",
2517
+ {
2518
+ cx: "128",
2519
+ cy: "158",
2520
+ rx: "124",
2521
+ ry: "154",
2522
+ fill: "none",
2523
+ stroke: colors.teal,
2524
+ strokeWidth: "5",
2525
+ strokeDasharray: `${completedChallenges / totalChallenges * 880} 880`,
2526
+ transform: "rotate(-90 128 158)",
2527
+ strokeLinecap: "round"
2528
+ }
2529
+ )
2530
+ }
2531
+ ),
2532
+ currentChallenge && countdown > 0 && !capturing && /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("div", { style: styles.countdownBadge, children: countdown }),
2533
+ capturing && /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
2534
+ "div",
2535
+ {
2536
+ style: {
2537
+ ...styles.countdownBadge,
2538
+ fontSize: "14px",
2539
+ padding: "8px 14px"
2540
+ },
2541
+ children: "Checking..."
2542
+ }
2543
+ )
2544
+ ] }),
2545
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("canvas", { ref: canvasRef, style: { display: "none" } })
2546
+ ]
2547
+ }
2548
+ ),
2412
2549
  /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("div", { style: { padding: "16px 0" }, children: [
2413
2550
  /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("div", { style: styles.progressDots, children: session.challenges.map((_, index) => /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
2414
2551
  "div",
@@ -2422,24 +2559,21 @@ function LivenessScreen({
2422
2559
  )) }),
2423
2560
  /* @__PURE__ */ (0, import_jsx_runtime9.jsxs)("p", { style: styles.progressText, children: [
2424
2561
  "Challenge ",
2425
- completedChallenges + 1,
2426
- " of ",
2562
+ Math.min(completedChallenges + 1, totalChallenges),
2563
+ " of",
2564
+ " ",
2427
2565
  totalChallenges
2428
2566
  ] })
2429
2567
  ] }),
2430
- /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("div", { style: { padding: "16px 24px 32px" }, children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
2431
- "button",
2568
+ /* @__PURE__ */ (0, import_jsx_runtime9.jsx)("div", { style: { padding: "0 24px 24px", textAlign: "center" }, children: /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
2569
+ "p",
2432
2570
  {
2433
- style: styles.primaryButton,
2434
- onClick: async () => {
2435
- const canvas = document.createElement("canvas");
2436
- canvas.width = 100;
2437
- canvas.height = 100;
2438
- canvas.toBlob(async (blob) => {
2439
- if (blob) await onChallengeComplete(blob);
2440
- });
2571
+ style: {
2572
+ color: "rgba(255,255,255,0.5)",
2573
+ fontSize: "13px",
2574
+ margin: 0
2441
2575
  },
2442
- children: "Complete Challenge"
2576
+ children: "Position your face inside the oval and follow the prompt."
2443
2577
  }
2444
2578
  ) })
2445
2579
  ] });
package/dist/index.mjs CHANGED
@@ -34,7 +34,7 @@ function useKoraIDVContext() {
34
34
  }
35
35
 
36
36
  // src/hooks/useKoraIDV.ts
37
- import { useState, useCallback } from "react";
37
+ import { useState, useCallback, useEffect, useRef } from "react";
38
38
  import {
39
39
  KoraError
40
40
  } from "@koraidv/core";
@@ -281,6 +281,15 @@ function useKoraIDV() {
281
281
  return null;
282
282
  }
283
283
  }, [sdk]);
284
+ const completionFiredRef = useRef(false);
285
+ useEffect(() => {
286
+ if (state.step === "processing" && !completionFiredRef.current) {
287
+ completionFiredRef.current = true;
288
+ complete();
289
+ } else if (state.step !== "processing" && state.step !== "complete") {
290
+ completionFiredRef.current = false;
291
+ }
292
+ }, [state.step, complete]);
284
293
  const cancel = useCallback(() => {
285
294
  sdk.reset();
286
295
  setState({
@@ -319,7 +328,7 @@ function useKoraIDV() {
319
328
  }
320
329
 
321
330
  // src/components/VerificationFlow.tsx
322
- import { useEffect as useEffect7, useState as useState6 } from "react";
331
+ import { useEffect as useEffect8, useState as useState6 } from "react";
323
332
  import { KoraError as KoraError2, KoraErrorCode } from "@koraidv/core";
324
333
 
325
334
  // src/components/styles.ts
@@ -1299,7 +1308,7 @@ var styles = {
1299
1308
  };
1300
1309
 
1301
1310
  // src/components/DesignSystem.tsx
1302
- import { useEffect } from "react";
1311
+ import { useEffect as useEffect2 } from "react";
1303
1312
  import { jsx as jsx2, jsxs } from "react/jsx-runtime";
1304
1313
  function StepProgressBar({ total, current, isDark = false }) {
1305
1314
  return /* @__PURE__ */ jsx2("div", { style: styles.progressBar, children: Array.from({ length: total }).map((_, i) => /* @__PURE__ */ jsx2(
@@ -1373,7 +1382,7 @@ function ScoreMetricRow({ label, score, icon, status, message }) {
1373
1382
  );
1374
1383
  }
1375
1384
  function ProcessingScreen({ steps }) {
1376
- useEffect(() => {
1385
+ useEffect2(() => {
1377
1386
  injectKeyframes();
1378
1387
  }, []);
1379
1388
  return /* @__PURE__ */ jsxs("div", { style: styles.processingContainer, children: [
@@ -1743,7 +1752,7 @@ function getIcon(type) {
1743
1752
  }
1744
1753
 
1745
1754
  // src/components/DocumentCaptureScreen.tsx
1746
- import { useRef, useEffect as useEffect2, useState as useState3, useCallback as useCallback2 } from "react";
1755
+ import { useRef as useRef2, useEffect as useEffect3, useState as useState3, useCallback as useCallback2 } from "react";
1747
1756
  import { Fragment, jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
1748
1757
  var qualityIssueMessages = {
1749
1758
  face_blurred: "Photo on document is blurry. Retake in better lighting.",
@@ -1763,9 +1772,9 @@ function DocumentCaptureScreen({
1763
1772
  onCapture,
1764
1773
  onCancel
1765
1774
  }) {
1766
- const videoRef = useRef(null);
1767
- const canvasRef = useRef(null);
1768
- const guideRef = useRef(null);
1775
+ const videoRef = useRef2(null);
1776
+ const canvasRef = useRef2(null);
1777
+ const guideRef = useRef2(null);
1769
1778
  const [stream, setStream] = useState3(null);
1770
1779
  const [isCapturing, setIsCapturing] = useState3(false);
1771
1780
  const [error, setError] = useState3(null);
@@ -1774,10 +1783,10 @@ function DocumentCaptureScreen({
1774
1783
  const [qualityResult, setQualityResult] = useState3(null);
1775
1784
  const [isCheckingQuality, setIsCheckingQuality] = useState3(false);
1776
1785
  const [retakeCount, setRetakeCount] = useState3(0);
1777
- useEffect2(() => {
1786
+ useEffect3(() => {
1778
1787
  injectKeyframes();
1779
1788
  }, []);
1780
- useEffect2(() => {
1789
+ useEffect3(() => {
1781
1790
  let mounted = true;
1782
1791
  async function startCamera() {
1783
1792
  try {
@@ -1799,7 +1808,7 @@ function DocumentCaptureScreen({
1799
1808
  mounted = false;
1800
1809
  };
1801
1810
  }, [capturedImage]);
1802
- useEffect2(() => {
1811
+ useEffect3(() => {
1803
1812
  return () => {
1804
1813
  stream?.getTracks().forEach((t) => t.stop());
1805
1814
  };
@@ -2042,10 +2051,10 @@ function QualityCheck({ label }) {
2042
2051
  }
2043
2052
 
2044
2053
  // src/components/FlipDocumentScreen.tsx
2045
- import { useEffect as useEffect3 } from "react";
2054
+ import { useEffect as useEffect4 } from "react";
2046
2055
  import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
2047
2056
  function FlipDocumentScreen({ onContinue, onCancel }) {
2048
- useEffect3(() => {
2057
+ useEffect4(() => {
2049
2058
  injectKeyframes();
2050
2059
  }, []);
2051
2060
  return /* @__PURE__ */ jsxs6("div", { style: styles.darkContainer, children: [
@@ -2109,20 +2118,20 @@ function FlipDocumentScreen({ onContinue, onCancel }) {
2109
2118
  }
2110
2119
 
2111
2120
  // src/components/SelfieCaptureScreen.tsx
2112
- import { useRef as useRef2, useEffect as useEffect4, useState as useState4, useCallback as useCallback3 } from "react";
2121
+ import { useRef as useRef3, useEffect as useEffect5, useState as useState4, useCallback as useCallback3 } from "react";
2113
2122
  import { jsx as jsx8, jsxs as jsxs7 } from "react/jsx-runtime";
2114
2123
  function SelfieCaptureScreen({ onCapture, onCancel }) {
2115
- const videoRef = useRef2(null);
2116
- const canvasRef = useRef2(null);
2124
+ const videoRef = useRef3(null);
2125
+ const canvasRef = useRef3(null);
2117
2126
  const [stream, setStream] = useState4(null);
2118
2127
  const [isCapturing, setIsCapturing] = useState4(false);
2119
2128
  const [error, setError] = useState4(null);
2120
2129
  const [capturedImage, setCapturedImage] = useState4(null);
2121
2130
  const [capturedBlob, setCapturedBlob] = useState4(null);
2122
- useEffect4(() => {
2131
+ useEffect5(() => {
2123
2132
  injectKeyframes();
2124
2133
  }, []);
2125
- useEffect4(() => {
2134
+ useEffect5(() => {
2126
2135
  let mounted = true;
2127
2136
  async function startCamera() {
2128
2137
  try {
@@ -2144,7 +2153,7 @@ function SelfieCaptureScreen({ onCapture, onCancel }) {
2144
2153
  mounted = false;
2145
2154
  };
2146
2155
  }, [capturedImage]);
2147
- useEffect4(() => {
2156
+ useEffect5(() => {
2148
2157
  return () => {
2149
2158
  stream?.getTracks().forEach((t) => t.stop());
2150
2159
  };
@@ -2281,7 +2290,7 @@ function QualityCheck2({ label }) {
2281
2290
  }
2282
2291
 
2283
2292
  // src/components/LivenessScreen.tsx
2284
- import { useEffect as useEffect5, useState as useState5 } from "react";
2293
+ import { useEffect as useEffect6, useRef as useRef4, useState as useState5, useCallback as useCallback4 } from "react";
2285
2294
  import { jsx as jsx9, jsxs as jsxs8 } from "react/jsx-runtime";
2286
2295
  function LivenessScreen({
2287
2296
  session,
@@ -2292,32 +2301,102 @@ function LivenessScreen({
2292
2301
  onComplete,
2293
2302
  onCancel
2294
2303
  }) {
2304
+ const videoRef = useRef4(null);
2305
+ const canvasRef = useRef4(null);
2306
+ const [stream, setStream] = useState5(null);
2307
+ const [cameraError, setCameraError] = useState5(null);
2295
2308
  const [countdown, setCountdown] = useState5(3);
2296
- useEffect5(() => {
2309
+ const [capturing, setCapturing] = useState5(false);
2310
+ useEffect6(() => {
2297
2311
  injectKeyframes();
2298
2312
  }, []);
2299
- useEffect5(() => {
2313
+ useEffect6(() => {
2300
2314
  if (!session) onStart();
2301
2315
  }, [session, onStart]);
2302
- useEffect5(() => {
2303
- if (session && !currentChallenge && completedChallenges > 0) {
2304
- onComplete();
2316
+ useEffect6(() => {
2317
+ let mounted = true;
2318
+ async function startCamera() {
2319
+ try {
2320
+ const mediaStream = await navigator.mediaDevices.getUserMedia({
2321
+ video: {
2322
+ facingMode: "user",
2323
+ width: { ideal: 720 },
2324
+ height: { ideal: 720 }
2325
+ }
2326
+ });
2327
+ if (!mounted) {
2328
+ mediaStream.getTracks().forEach((t) => t.stop());
2329
+ return;
2330
+ }
2331
+ setStream(mediaStream);
2332
+ if (videoRef.current) {
2333
+ videoRef.current.srcObject = mediaStream;
2334
+ }
2335
+ } catch {
2336
+ if (mounted) {
2337
+ setCameraError(
2338
+ "Camera access denied. Please enable camera permissions and try again."
2339
+ );
2340
+ }
2341
+ }
2305
2342
  }
2306
- }, [session, currentChallenge, completedChallenges, onComplete]);
2307
- useEffect5(() => {
2343
+ startCamera();
2344
+ return () => {
2345
+ mounted = false;
2346
+ };
2347
+ }, []);
2348
+ useEffect6(() => {
2349
+ return () => {
2350
+ stream?.getTracks().forEach((t) => t.stop());
2351
+ };
2352
+ }, [stream]);
2353
+ useEffect6(() => {
2308
2354
  if (!currentChallenge) return;
2309
2355
  setCountdown(3);
2310
- const interval = setInterval(() => {
2311
- setCountdown((c) => {
2312
- if (c <= 1) {
2313
- clearInterval(interval);
2314
- return 0;
2315
- }
2316
- return c - 1;
2317
- });
2318
- }, 1e3);
2319
- return () => clearInterval(interval);
2320
2356
  }, [currentChallenge?.id]);
2357
+ const captureFrame = useCallback4(async () => {
2358
+ if (!currentChallenge || !videoRef.current || !canvasRef.current || capturing) {
2359
+ return;
2360
+ }
2361
+ const video = videoRef.current;
2362
+ const canvas = canvasRef.current;
2363
+ const ctx = canvas.getContext("2d");
2364
+ if (!ctx || video.videoWidth === 0 || video.videoHeight === 0) return;
2365
+ setCapturing(true);
2366
+ canvas.width = video.videoWidth;
2367
+ canvas.height = video.videoHeight;
2368
+ ctx.drawImage(video, 0, 0);
2369
+ canvas.toBlob(
2370
+ async (blob) => {
2371
+ if (blob) {
2372
+ await onChallengeComplete(blob);
2373
+ }
2374
+ setCapturing(false);
2375
+ },
2376
+ "image/jpeg",
2377
+ 0.85
2378
+ );
2379
+ }, [currentChallenge, capturing, onChallengeComplete]);
2380
+ useEffect6(() => {
2381
+ if (!currentChallenge || capturing) return;
2382
+ if (countdown === 0) {
2383
+ captureFrame();
2384
+ return;
2385
+ }
2386
+ const t = setTimeout(() => setCountdown((c) => c - 1), 1e3);
2387
+ return () => clearTimeout(t);
2388
+ }, [countdown, currentChallenge?.id, capturing, captureFrame]);
2389
+ useEffect6(() => {
2390
+ if (session && !currentChallenge && completedChallenges > 0) {
2391
+ onComplete();
2392
+ }
2393
+ }, [session, currentChallenge, completedChallenges, onComplete]);
2394
+ if (cameraError) {
2395
+ return /* @__PURE__ */ jsx9("div", { style: styles.darkContainer, children: /* @__PURE__ */ jsxs8("div", { style: styles.errorContainer, children: [
2396
+ /* @__PURE__ */ jsx9("p", { style: styles.errorText, children: cameraError }),
2397
+ /* @__PURE__ */ jsx9("button", { style: styles.primaryButton, onClick: onCancel, children: "Go back" })
2398
+ ] }) });
2399
+ }
2321
2400
  if (!session) {
2322
2401
  return /* @__PURE__ */ jsx9("div", { style: styles.darkContainer, children: /* @__PURE__ */ jsxs8("div", { style: styles.loadingContainer, children: [
2323
2402
  /* @__PURE__ */ jsx9("div", { style: styles.spinner }),
@@ -2333,33 +2412,91 @@ function LivenessScreen({
2333
2412
  /* @__PURE__ */ jsx9("button", { style: styles.glassCloseButton, onClick: onCancel, children: "\u2715" })
2334
2413
  ] }),
2335
2414
  currentChallenge && /* @__PURE__ */ jsx9("div", { style: { padding: "16px 0" }, children: /* @__PURE__ */ jsx9("h2", { style: styles.challengeTitle, children: currentChallenge.instruction }) }),
2336
- /* @__PURE__ */ jsx9("div", { style: { flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }, children: /* @__PURE__ */ jsxs8("div", { style: { position: "relative" }, children: [
2337
- /* @__PURE__ */ jsx9("div", { style: styles.faceGuide, children: /* @__PURE__ */ jsx9(
2338
- "svg",
2339
- {
2340
- style: { position: "absolute", top: "-8px", left: "-8px" },
2341
- width: "256",
2342
- height: "316",
2343
- viewBox: "0 0 256 316",
2344
- children: /* @__PURE__ */ jsx9(
2345
- "ellipse",
2346
- {
2347
- cx: "128",
2348
- cy: "158",
2349
- rx: "124",
2350
- ry: "154",
2351
- fill: "none",
2352
- stroke: colors.teal,
2353
- strokeWidth: "5",
2354
- strokeDasharray: `${completedChallenges / totalChallenges * 880} 880`,
2355
- transform: "rotate(-90 128 158)",
2356
- strokeLinecap: "round"
2357
- }
2358
- )
2359
- }
2360
- ) }),
2361
- countdown > 0 && /* @__PURE__ */ jsx9("div", { style: styles.countdownBadge, children: countdown })
2362
- ] }) }),
2415
+ /* @__PURE__ */ jsxs8(
2416
+ "div",
2417
+ {
2418
+ style: {
2419
+ flex: 1,
2420
+ display: "flex",
2421
+ alignItems: "center",
2422
+ justifyContent: "center"
2423
+ },
2424
+ children: [
2425
+ /* @__PURE__ */ jsxs8("div", { style: { position: "relative" }, children: [
2426
+ /* @__PURE__ */ jsx9(
2427
+ "div",
2428
+ {
2429
+ style: {
2430
+ width: "240px",
2431
+ height: "300px",
2432
+ borderRadius: "50%",
2433
+ overflow: "hidden",
2434
+ backgroundColor: "#000",
2435
+ border: "3px solid rgba(255,255,255,0.2)"
2436
+ },
2437
+ children: /* @__PURE__ */ jsx9(
2438
+ "video",
2439
+ {
2440
+ ref: videoRef,
2441
+ autoPlay: true,
2442
+ playsInline: true,
2443
+ muted: true,
2444
+ style: {
2445
+ width: "100%",
2446
+ height: "100%",
2447
+ objectFit: "cover",
2448
+ transform: "scaleX(-1)"
2449
+ }
2450
+ }
2451
+ )
2452
+ }
2453
+ ),
2454
+ /* @__PURE__ */ jsx9(
2455
+ "svg",
2456
+ {
2457
+ style: {
2458
+ position: "absolute",
2459
+ top: "-8px",
2460
+ left: "-8px",
2461
+ pointerEvents: "none"
2462
+ },
2463
+ width: "256",
2464
+ height: "316",
2465
+ viewBox: "0 0 256 316",
2466
+ children: /* @__PURE__ */ jsx9(
2467
+ "ellipse",
2468
+ {
2469
+ cx: "128",
2470
+ cy: "158",
2471
+ rx: "124",
2472
+ ry: "154",
2473
+ fill: "none",
2474
+ stroke: colors.teal,
2475
+ strokeWidth: "5",
2476
+ strokeDasharray: `${completedChallenges / totalChallenges * 880} 880`,
2477
+ transform: "rotate(-90 128 158)",
2478
+ strokeLinecap: "round"
2479
+ }
2480
+ )
2481
+ }
2482
+ ),
2483
+ currentChallenge && countdown > 0 && !capturing && /* @__PURE__ */ jsx9("div", { style: styles.countdownBadge, children: countdown }),
2484
+ capturing && /* @__PURE__ */ jsx9(
2485
+ "div",
2486
+ {
2487
+ style: {
2488
+ ...styles.countdownBadge,
2489
+ fontSize: "14px",
2490
+ padding: "8px 14px"
2491
+ },
2492
+ children: "Checking..."
2493
+ }
2494
+ )
2495
+ ] }),
2496
+ /* @__PURE__ */ jsx9("canvas", { ref: canvasRef, style: { display: "none" } })
2497
+ ]
2498
+ }
2499
+ ),
2363
2500
  /* @__PURE__ */ jsxs8("div", { style: { padding: "16px 0" }, children: [
2364
2501
  /* @__PURE__ */ jsx9("div", { style: styles.progressDots, children: session.challenges.map((_, index) => /* @__PURE__ */ jsx9(
2365
2502
  "div",
@@ -2373,24 +2510,21 @@ function LivenessScreen({
2373
2510
  )) }),
2374
2511
  /* @__PURE__ */ jsxs8("p", { style: styles.progressText, children: [
2375
2512
  "Challenge ",
2376
- completedChallenges + 1,
2377
- " of ",
2513
+ Math.min(completedChallenges + 1, totalChallenges),
2514
+ " of",
2515
+ " ",
2378
2516
  totalChallenges
2379
2517
  ] })
2380
2518
  ] }),
2381
- /* @__PURE__ */ jsx9("div", { style: { padding: "16px 24px 32px" }, children: /* @__PURE__ */ jsx9(
2382
- "button",
2519
+ /* @__PURE__ */ jsx9("div", { style: { padding: "0 24px 24px", textAlign: "center" }, children: /* @__PURE__ */ jsx9(
2520
+ "p",
2383
2521
  {
2384
- style: styles.primaryButton,
2385
- onClick: async () => {
2386
- const canvas = document.createElement("canvas");
2387
- canvas.width = 100;
2388
- canvas.height = 100;
2389
- canvas.toBlob(async (blob) => {
2390
- if (blob) await onChallengeComplete(blob);
2391
- });
2522
+ style: {
2523
+ color: "rgba(255,255,255,0.5)",
2524
+ fontSize: "13px",
2525
+ margin: 0
2392
2526
  },
2393
- children: "Complete Challenge"
2527
+ children: "Position your face inside the oval and follow the prompt."
2394
2528
  }
2395
2529
  ) })
2396
2530
  ] });
@@ -2763,10 +2897,10 @@ function ErrorScreen({ error, onRetry, onCancel }) {
2763
2897
  }
2764
2898
 
2765
2899
  // src/components/LoadingScreen.tsx
2766
- import { useEffect as useEffect6 } from "react";
2900
+ import { useEffect as useEffect7 } from "react";
2767
2901
  import { jsx as jsx12, jsxs as jsxs11 } from "react/jsx-runtime";
2768
2902
  function LoadingScreen({ message = "Loading..." }) {
2769
- useEffect6(() => {
2903
+ useEffect7(() => {
2770
2904
  injectKeyframes();
2771
2905
  }, []);
2772
2906
  return /* @__PURE__ */ jsx12("div", { style: styles.container, children: /* @__PURE__ */ jsxs11("div", { style: styles.loadingContainer, children: [
@@ -2823,20 +2957,20 @@ function VerificationFlow({
2823
2957
  const [showFlipInstruction, setShowFlipInstruction] = useState6(true);
2824
2958
  const [supportedCountries, setSupportedCountries] = useState6([]);
2825
2959
  const [countriesLoading, setCountriesLoading] = useState6(false);
2826
- useEffect7(() => {
2960
+ useEffect8(() => {
2827
2961
  if (state.step === "document_front") {
2828
2962
  setShowFlipInstruction(true);
2829
2963
  }
2830
2964
  }, [state.step]);
2831
- useEffect7(() => {
2965
+ useEffect8(() => {
2832
2966
  startVerification(externalId, tier);
2833
2967
  }, [externalId, tier, startVerification]);
2834
- useEffect7(() => {
2968
+ useEffect8(() => {
2835
2969
  if (state.step === "complete" && state.verification && onComplete) {
2836
2970
  onComplete(state.verification);
2837
2971
  }
2838
2972
  }, [state.step, state.verification, onComplete]);
2839
- useEffect7(() => {
2973
+ useEffect8(() => {
2840
2974
  if (state.error && onError) {
2841
2975
  onError(state.error);
2842
2976
  }
@@ -2972,7 +3106,7 @@ function VerificationFlow({
2972
3106
  }
2973
3107
 
2974
3108
  // src/components/QrHandoffScreen.tsx
2975
- import { useEffect as useEffect8, useState as useState7, useRef as useRef3 } from "react";
3109
+ import { useEffect as useEffect9, useState as useState7, useRef as useRef5 } from "react";
2976
3110
  import { jsx as jsx14, jsxs as jsxs13 } from "react/jsx-runtime";
2977
3111
  function QrHandoffScreen({
2978
3112
  session,
@@ -2985,8 +3119,8 @@ function QrHandoffScreen({
2985
3119
  const [timeLeft, setTimeLeft] = useState7(session.expiresIn);
2986
3120
  const [scanned, setScanned] = useState7(false);
2987
3121
  const [expired, setExpired] = useState7(false);
2988
- const timerRef = useRef3();
2989
- useEffect8(() => {
3122
+ const timerRef = useRef5();
3123
+ useEffect9(() => {
2990
3124
  setTimeLeft(session.expiresIn);
2991
3125
  setExpired(false);
2992
3126
  setScanned(false);
@@ -3003,7 +3137,7 @@ function QrHandoffScreen({
3003
3137
  }, 1e3);
3004
3138
  return () => clearInterval(timerRef.current);
3005
3139
  }, [session.token]);
3006
- useEffect8(() => {
3140
+ useEffect9(() => {
3007
3141
  if (!eventSource) return;
3008
3142
  const handleStatus = (event) => {
3009
3143
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koraidv/react",
3
- "version": "1.7.6",
3
+ "version": "1.7.7",
4
4
  "description": "Kora IDV React Components for Identity Verification",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -21,7 +21,7 @@
21
21
  "test": "vitest run"
22
22
  },
23
23
  "dependencies": {
24
- "@koraidv/core": "^1.7.6"
24
+ "@koraidv/core": "^1.7.7"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/react": "^18.2.0",