astra-sdk-web 1.1.6 → 1.1.8

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.
@@ -1,5 +1,5 @@
1
1
  import React, { createContext, useState, useEffect, useRef, useContext, useCallback } from 'react';
2
- import { jsx, jsxs } from 'react/jsx-runtime';
2
+ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
3
3
  import { useNavigate } from 'react-router-dom';
4
4
  import { FACEMESH_TESSELATION, FACEMESH_FACE_OVAL, FACEMESH_LEFT_EYE, FACEMESH_RIGHT_EYE, FACEMESH_LIPS, FaceMesh } from '@mediapipe/face_mesh';
5
5
  import { drawConnectors, drawLandmarks } from '@mediapipe/drawing_utils';
@@ -78,7 +78,10 @@ var KycApiService = class {
78
78
  if (!response.ok) {
79
79
  const errorData = await response.json().catch(() => ({}));
80
80
  const message = errorData?.message || `Face upload failed with status ${response.status}`;
81
- throw new Error(message);
81
+ const error = new Error(message);
82
+ error.statusCode = response.status;
83
+ error.errorData = errorData;
84
+ throw error;
82
85
  }
83
86
  const data = await response.json();
84
87
  return data;
@@ -740,6 +743,8 @@ var FaceMeshService = class {
740
743
  }
741
744
  this.processLiveness(faceOnCanvas, w, h);
742
745
  } else {
746
+ this.livenessStateRef.current.currentYaw = null;
747
+ this.livenessStateRef.current.currentAbsYaw = null;
743
748
  const vid = this.videoRef.current;
744
749
  if (vid) {
745
750
  const vidW = Math.max(1, vid?.videoWidth || displayW);
@@ -783,6 +788,8 @@ var FaceMeshService = class {
783
788
  const midX = (leftEyeOuter.x + rightEyeOuter.x) / 2;
784
789
  const yaw = (nT.x - midX) / Math.max(1e-6, faceWidth);
785
790
  const absYaw = Math.abs(yaw);
791
+ this.livenessStateRef.current.currentYaw = yaw;
792
+ this.livenessStateRef.current.currentAbsYaw = absYaw;
786
793
  const xs = faceOnCanvas.map((p) => p.x), ys = faceOnCanvas.map((p) => p.y);
787
794
  const minX = Math.min(...xs) * w, maxX = Math.max(...xs) * w;
788
795
  const minY = Math.min(...ys) * h, maxY = Math.max(...ys) * h;
@@ -809,11 +816,13 @@ var FaceMeshService = class {
809
816
  } else if (absYaw < centerThreshold) {
810
817
  state.centerHold += 1;
811
818
  if (state.centerHold >= holdFramesCenter) {
812
- const newStage = "LEFT";
813
- state.stage = newStage;
814
- state.centerHold = 0;
815
- if (this.callbacks.onLivenessUpdate) {
816
- this.callbacks.onLivenessUpdate(newStage, "Turn your face LEFT");
819
+ if (!state.livenessCompleted) {
820
+ const newStage = "LEFT";
821
+ state.stage = newStage;
822
+ state.centerHold = 0;
823
+ if (this.callbacks.onLivenessUpdate) {
824
+ this.callbacks.onLivenessUpdate(newStage, "Turn your face LEFT");
825
+ }
817
826
  }
818
827
  }
819
828
  } else {
@@ -852,16 +861,12 @@ var FaceMeshService = class {
852
861
  state.rightHold += 1;
853
862
  if (state.rightHold >= holdFramesTurn) {
854
863
  state.rightHold = 0;
855
- if (!state.snapTriggered) {
856
- state.snapTriggered = true;
857
- const newStage = "DONE";
858
- state.stage = newStage;
859
- if (this.callbacks.onLivenessUpdate) {
860
- this.callbacks.onLivenessUpdate(newStage, "Capturing...");
861
- }
862
- if (this.callbacks.onCaptureTrigger) {
863
- this.callbacks.onCaptureTrigger();
864
- }
864
+ state.livenessCompleted = true;
865
+ const newStage = "DONE";
866
+ state.stage = newStage;
867
+ state.centerHold = 0;
868
+ if (this.callbacks.onLivenessUpdate) {
869
+ this.callbacks.onLivenessUpdate(newStage, "Great! Now look straight at the camera");
865
870
  }
866
871
  }
867
872
  } else {
@@ -870,6 +875,28 @@ var FaceMeshService = class {
870
875
  this.callbacks.onLivenessUpdate(state.stage, yaw < -0.08 ? "You're facing left. Turn RIGHT" : "Turn a bit more RIGHT");
871
876
  }
872
877
  }
878
+ } else if (state.stage === "DONE") {
879
+ if (absYaw < centerThreshold && insideGuide) {
880
+ state.centerHold += 1;
881
+ if (state.centerHold >= holdFramesCenter && !state.snapTriggered) {
882
+ state.snapTriggered = true;
883
+ if (this.callbacks.onLivenessUpdate) {
884
+ this.callbacks.onLivenessUpdate(state.stage, "Capturing...");
885
+ }
886
+ if (this.callbacks.onCaptureTrigger) {
887
+ this.callbacks.onCaptureTrigger();
888
+ }
889
+ }
890
+ } else {
891
+ state.centerHold = 0;
892
+ if (this.callbacks.onLivenessUpdate) {
893
+ if (!insideGuide) {
894
+ this.callbacks.onLivenessUpdate(state.stage, "Center your face inside the circle");
895
+ } else {
896
+ this.callbacks.onLivenessUpdate(state.stage, "Please look straight at the camera");
897
+ }
898
+ }
899
+ }
873
900
  }
874
901
  }
875
902
  }
@@ -1005,7 +1032,10 @@ function useFaceScan(videoRef, canvasRef, callbacks) {
1005
1032
  snapTriggered: false,
1006
1033
  lastResultsAt: 0,
1007
1034
  stage: "CENTER",
1008
- livenessReady: false
1035
+ livenessReady: false,
1036
+ currentYaw: null,
1037
+ currentAbsYaw: null,
1038
+ livenessCompleted: false
1009
1039
  });
1010
1040
  useEffect(() => {
1011
1041
  livenessStateRef.current.centerHold = refs.centerHold.current;
@@ -1026,6 +1056,22 @@ function useFaceScan(videoRef, canvasRef, callbacks) {
1026
1056
  }, []);
1027
1057
  const handleFaceCapture = useCallback(async () => {
1028
1058
  if (!videoRef.current) return;
1059
+ const centerThreshold = 0.05;
1060
+ const currentAbsYaw = livenessStateRef.current.currentAbsYaw;
1061
+ if (currentAbsYaw === null || currentAbsYaw === void 0) {
1062
+ setState((prev) => ({
1063
+ ...prev,
1064
+ livenessInstruction: "Please position your face in front of the camera"
1065
+ }));
1066
+ return;
1067
+ }
1068
+ if (currentAbsYaw >= centerThreshold) {
1069
+ setState((prev) => ({
1070
+ ...prev,
1071
+ livenessInstruction: "Please look straight at the camera before capturing"
1072
+ }));
1073
+ return;
1074
+ }
1029
1075
  setState((prev) => ({ ...prev, loading: true }));
1030
1076
  try {
1031
1077
  const video = videoRef.current;
@@ -1179,18 +1225,82 @@ function useFaceScan(videoRef, canvasRef, callbacks) {
1179
1225
  handleFaceCapture
1180
1226
  };
1181
1227
  }
1228
+ function Toast({ message, type = "info", duration = 5e3, onClose }) {
1229
+ const [isVisible, setIsVisible] = useState(true);
1230
+ useEffect(() => {
1231
+ const timer = setTimeout(() => {
1232
+ setIsVisible(false);
1233
+ setTimeout(() => {
1234
+ if (onClose) onClose();
1235
+ }, 300);
1236
+ }, duration);
1237
+ return () => clearTimeout(timer);
1238
+ }, [duration, onClose]);
1239
+ const bgColor = {
1240
+ success: "bg-green-600",
1241
+ error: "bg-red-600",
1242
+ info: "bg-blue-600",
1243
+ warning: "bg-yellow-600"
1244
+ }[type];
1245
+ return /* @__PURE__ */ jsx(
1246
+ "div",
1247
+ {
1248
+ className: `fixed top-4 right-4 z-[10000] transition-all duration-300 ${isVisible ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-2"}`,
1249
+ children: /* @__PURE__ */ jsxs(
1250
+ "div",
1251
+ {
1252
+ className: `${bgColor} text-white px-6 py-4 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px] max-w-[500px]`,
1253
+ children: [
1254
+ /* @__PURE__ */ jsx("div", { className: "flex-1", children: /* @__PURE__ */ jsx("p", { className: "m-0 text-sm font-medium", children: message }) }),
1255
+ /* @__PURE__ */ jsx(
1256
+ "button",
1257
+ {
1258
+ onClick: () => {
1259
+ setIsVisible(false);
1260
+ setTimeout(() => {
1261
+ if (onClose) onClose();
1262
+ }, 300);
1263
+ },
1264
+ className: "text-white hover:text-gray-200 transition-colors",
1265
+ "aria-label": "Close",
1266
+ children: /* @__PURE__ */ jsxs("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: [
1267
+ /* @__PURE__ */ jsx("line", { x1: "18", y1: "6", x2: "6", y2: "18" }),
1268
+ /* @__PURE__ */ jsx("line", { x1: "6", y1: "6", x2: "18", y2: "18" })
1269
+ ] })
1270
+ }
1271
+ )
1272
+ ]
1273
+ }
1274
+ )
1275
+ }
1276
+ );
1277
+ }
1182
1278
  function FaceScanModal({ onComplete }) {
1183
1279
  const faceCanvasRef = useRef(null);
1184
1280
  const navigate = useNavigate();
1185
1281
  const { apiService } = useKycContext();
1186
1282
  const [sessionError, setSessionError] = useState(null);
1283
+ const [toast, setToast] = useState(null);
1187
1284
  const { videoRef, cameraReady, stopCamera } = useCamera();
1188
1285
  const { state, setState, refs, handleFaceCapture } = useFaceScan(videoRef, faceCanvasRef, {
1189
1286
  onFaceUpload: async (blob) => {
1190
1287
  if (!apiService) {
1191
1288
  throw new Error("API service not initialized");
1192
1289
  }
1193
- await apiService.uploadFaceScan(blob);
1290
+ try {
1291
+ await apiService.uploadFaceScan(blob);
1292
+ } catch (error) {
1293
+ const errorMessage = error?.message || "";
1294
+ const errorData = error?.errorData || {};
1295
+ if (errorMessage.includes("Face already registered") || errorMessage.includes("already registered") || errorData?.message?.includes("Face already registered") || error?.statusCode === 500 && errorMessage.includes("Face")) {
1296
+ setToast({
1297
+ message: "Face already registered",
1298
+ type: "warning"
1299
+ });
1300
+ return;
1301
+ }
1302
+ throw error;
1303
+ }
1194
1304
  },
1195
1305
  onFaceCaptureComplete: (imageData) => {
1196
1306
  if (onComplete) {
@@ -1285,61 +1395,72 @@ function FaceScanModal({ onComplete }) {
1285
1395
  /* @__PURE__ */ jsx("p", { className: "text-[#9ca3af] text-sm", children: "Redirecting to QR code page..." })
1286
1396
  ] }) }) });
1287
1397
  }
1288
- return /* @__PURE__ */ jsx("div", { className: "fixed inset-0 bg-black p-5 z-[1000] flex items-center justify-center font-sans overflow-y-auto custom__scrollbar", children: /* @__PURE__ */ jsxs("div", { className: "max-w-[400px] w-full mx-auto bg-[#0b0f17] rounded-2xl p-6 shadow-xl mt-48", children: [
1289
- /* @__PURE__ */ jsx("div", { className: "relative mb-4", children: /* @__PURE__ */ jsx("h2", { className: "m-0 mb-4 text-[26px] font-bold text-white text-center", children: "Capture Face" }) }),
1290
- /* @__PURE__ */ jsxs("div", { className: "grid gap-4", children: [
1291
- !state.modelLoading && state.modelLoaded && !state.livenessFailed && /* @__PURE__ */ jsx("div", { className: "bg-[#0a2315] text-[#34d399] py-3.5 px-4 rounded-xl text-sm border border-[#155e3b] text-left", children: "Face detection model loaded." }),
1292
- state.modelLoading && !state.livenessFailed && /* @__PURE__ */ jsx("div", { className: "bg-[#1f2937] text-[#e5e7eb] py-3.5 px-4 rounded-xl text-sm border border-[#374151] text-left", children: "Loading face detection model..." }),
1293
- /* @__PURE__ */ jsxs("div", { className: "relative w-full aspect-square rounded-full overflow-hidden bg-black", children: [
1398
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1399
+ toast && /* @__PURE__ */ jsx(
1400
+ Toast,
1401
+ {
1402
+ message: toast.message,
1403
+ type: toast.type,
1404
+ onClose: () => setToast(null),
1405
+ duration: 6e3
1406
+ }
1407
+ ),
1408
+ /* @__PURE__ */ jsx("div", { className: "fixed inset-0 bg-black p-5 z-[1000] flex items-center justify-center font-sans overflow-y-auto custom__scrollbar", children: /* @__PURE__ */ jsxs("div", { className: "max-w-[400px] w-full mx-auto bg-[#0b0f17] rounded-2xl p-6 shadow-xl mt-48", children: [
1409
+ /* @__PURE__ */ jsx("div", { className: "relative mb-4", children: /* @__PURE__ */ jsx("h2", { className: "m-0 mb-4 text-[26px] font-bold text-white text-center", children: "Capture Face" }) }),
1410
+ /* @__PURE__ */ jsxs("div", { className: "grid gap-4", children: [
1411
+ !state.modelLoading && state.modelLoaded && !state.livenessFailed && /* @__PURE__ */ jsx("div", { className: "bg-[#0a2315] text-[#34d399] py-3.5 px-4 rounded-xl text-sm border border-[#155e3b] text-left", children: "Face detection model loaded." }),
1412
+ state.modelLoading && !state.livenessFailed && /* @__PURE__ */ jsx("div", { className: "bg-[#1f2937] text-[#e5e7eb] py-3.5 px-4 rounded-xl text-sm border border-[#374151] text-left", children: "Loading face detection model..." }),
1413
+ /* @__PURE__ */ jsxs("div", { className: "relative w-full aspect-square rounded-full overflow-hidden bg-black", children: [
1414
+ /* @__PURE__ */ jsx(
1415
+ "video",
1416
+ {
1417
+ ref: videoRef,
1418
+ playsInline: true,
1419
+ muted: true,
1420
+ className: "w-full h-full block bg-black object-cover -scale-x-100 origin-center"
1421
+ }
1422
+ ),
1423
+ /* @__PURE__ */ jsx(
1424
+ "canvas",
1425
+ {
1426
+ ref: faceCanvasRef,
1427
+ className: "absolute top-0 left-0 w-full h-full pointer-events-none z-[2]"
1428
+ }
1429
+ ),
1430
+ /* @__PURE__ */ jsx("svg", { viewBox: "0 0 100 100", preserveAspectRatio: "none", className: "absolute inset-0 pointer-events-none z-[3]", children: /* @__PURE__ */ jsx("circle", { cx: "50", cy: "50", r: "44", fill: "none", stroke: "#22c55e", strokeWidth: "2", strokeDasharray: "1 3" }) })
1431
+ ] }),
1432
+ !state.livenessFailed && /* @__PURE__ */ jsxs("div", { className: "bg-gradient-to-b from-[rgba(17,24,39,0.9)] to-[rgba(17,24,39,0.6)] text-[#e5e7eb] p-4 rounded-2xl text-base border border-[#30363d]", children: [
1433
+ /* @__PURE__ */ jsx("div", { className: "font-bold mb-2.5 text-[22px] text-white", children: "Liveness Check" }),
1434
+ /* @__PURE__ */ jsx("div", { className: "mb-2.5 text-base", children: state.livenessInstruction }),
1435
+ /* @__PURE__ */ jsxs("div", { className: "grid gap-2.5 text-lg", children: [
1436
+ /* @__PURE__ */ jsx("div", { className: state.livenessStage === "CENTER" || state.livenessStage === "LEFT" || state.livenessStage === "RIGHT" || state.livenessStage === "DONE" ? "opacity-100" : "opacity-40", children: "1. Look Straight" }),
1437
+ /* @__PURE__ */ jsx("div", { className: state.livenessStage === "RIGHT" || state.livenessStage === "DONE" ? "opacity-100" : "opacity-40", children: "2. Turn your face right" }),
1438
+ /* @__PURE__ */ jsx("div", { className: state.livenessStage === "DONE" ? "opacity-100" : "opacity-30", children: "3. Turn your face left" })
1439
+ ] })
1440
+ ] }),
1294
1441
  /* @__PURE__ */ jsx(
1295
- "video",
1442
+ "button",
1296
1443
  {
1297
- ref: videoRef,
1298
- playsInline: true,
1299
- muted: true,
1300
- className: "w-full h-full block bg-black object-cover -scale-x-100 origin-center"
1444
+ type: "button",
1445
+ disabled: !state.cameraReady || state.loading || !state.livenessFailed && state.livenessStage !== "DONE",
1446
+ onClick: handleFaceCapture,
1447
+ className: `py-3.5 px-4 rounded-xl text-base font-bold border-none transition-colors ${state.cameraReady && !state.loading && (state.livenessFailed || state.livenessStage === "DONE") ? "bg-[#22c55e] text-[#0b0f17] cursor-pointer hover:bg-[#16a34a]" : "bg-[#374151] text-[#e5e7eb] cursor-not-allowed"}`,
1448
+ children: state.loading ? "Capturing..." : state.livenessFailed || state.livenessStage === "DONE" ? "Capture & Continue" : "Complete steps to continue"
1301
1449
  }
1302
1450
  ),
1303
1451
  /* @__PURE__ */ jsx(
1304
- "canvas",
1452
+ "button",
1305
1453
  {
1306
- ref: faceCanvasRef,
1307
- className: "absolute top-0 left-0 w-full h-full pointer-events-none z-[2]"
1454
+ type: "button",
1455
+ onClick: handleRetry,
1456
+ disabled: state.loading,
1457
+ className: `py-3 px-4 rounded-[10px] text-[15px] font-semibold border-none w-full transition-colors ${state.loading ? "bg-[#374151] text-[#e5e7eb] cursor-not-allowed opacity-50" : "bg-[#374151] text-[#e5e7eb] cursor-pointer hover:bg-[#4b5563]"}`,
1458
+ children: "Restart"
1308
1459
  }
1309
- ),
1310
- /* @__PURE__ */ jsx("svg", { viewBox: "0 0 100 100", preserveAspectRatio: "none", className: "absolute inset-0 pointer-events-none z-[3]", children: /* @__PURE__ */ jsx("circle", { cx: "50", cy: "50", r: "44", fill: "none", stroke: "#22c55e", strokeWidth: "2", strokeDasharray: "1 3" }) })
1311
- ] }),
1312
- !state.livenessFailed && /* @__PURE__ */ jsxs("div", { className: "bg-gradient-to-b from-[rgba(17,24,39,0.9)] to-[rgba(17,24,39,0.6)] text-[#e5e7eb] p-4 rounded-2xl text-base border border-[#30363d]", children: [
1313
- /* @__PURE__ */ jsx("div", { className: "font-bold mb-2.5 text-[22px] text-white", children: "Liveness Check" }),
1314
- /* @__PURE__ */ jsx("div", { className: "mb-2.5 text-base", children: state.livenessInstruction }),
1315
- /* @__PURE__ */ jsxs("div", { className: "grid gap-2.5 text-lg", children: [
1316
- /* @__PURE__ */ jsx("div", { className: state.livenessStage === "CENTER" || state.livenessStage === "LEFT" || state.livenessStage === "RIGHT" || state.livenessStage === "DONE" ? "opacity-100" : "opacity-40", children: "1. Look Straight" }),
1317
- /* @__PURE__ */ jsx("div", { className: state.livenessStage === "RIGHT" || state.livenessStage === "DONE" ? "opacity-100" : "opacity-40", children: "2. Turn your face right" }),
1318
- /* @__PURE__ */ jsx("div", { className: state.livenessStage === "DONE" ? "opacity-100" : "opacity-30", children: "3. Turn your face left" })
1319
- ] })
1320
- ] }),
1321
- /* @__PURE__ */ jsx(
1322
- "button",
1323
- {
1324
- type: "button",
1325
- disabled: !state.cameraReady || state.loading || !state.livenessFailed && state.livenessStage !== "DONE",
1326
- onClick: handleFaceCapture,
1327
- className: `py-3.5 px-4 rounded-xl text-base font-bold border-none transition-colors ${state.cameraReady && !state.loading && (state.livenessFailed || state.livenessStage === "DONE") ? "bg-[#22c55e] text-[#0b0f17] cursor-pointer hover:bg-[#16a34a]" : "bg-[#374151] text-[#e5e7eb] cursor-not-allowed"}`,
1328
- children: state.loading ? "Capturing..." : state.livenessFailed || state.livenessStage === "DONE" ? "Capture & Continue" : "Complete steps to continue"
1329
- }
1330
- ),
1331
- /* @__PURE__ */ jsx(
1332
- "button",
1333
- {
1334
- type: "button",
1335
- onClick: handleRetry,
1336
- disabled: state.loading,
1337
- className: `py-3 px-4 rounded-[10px] text-[15px] font-semibold border-none w-full transition-colors ${state.loading ? "bg-[#374151] text-[#e5e7eb] cursor-not-allowed opacity-50" : "bg-[#374151] text-[#e5e7eb] cursor-pointer hover:bg-[#4b5563]"}`,
1338
- children: "Restart"
1339
- }
1340
- )
1341
- ] })
1342
- ] }) });
1460
+ )
1461
+ ] })
1462
+ ] }) })
1463
+ ] });
1343
1464
  }
1344
1465
  var FaceScanModal_default = FaceScanModal;
1345
1466
  function MobileRouteContent({ onClose, onComplete }) {