astra-sdk-web 1.0.0 → 1.1.0

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.
@@ -0,0 +1,249 @@
1
+ import { useRef, useState, useEffect, useCallback } from 'react';
2
+ import { FaceMeshService } from '../../../services/faceMeshService';
3
+ import type { LivenessStage } from '../../../services/faceMeshService';
4
+ import type { FaceScanState, LivenessRefs } from '../types';
5
+
6
+ export interface FaceScanCallbacks {
7
+ onFaceCaptureComplete?: (imageData: string) => void;
8
+ onLivenessFailedCallback?: (failed: boolean) => void;
9
+ onFaceUpload?: (blob: Blob) => Promise<void>;
10
+ }
11
+
12
+ export function useFaceScan(
13
+ videoRef: React.RefObject<HTMLVideoElement | null>,
14
+ canvasRef: React.RefObject<HTMLCanvasElement | null>,
15
+ callbacks?: FaceScanCallbacks
16
+ ) {
17
+ const [state, setState] = useState<FaceScanState>({
18
+ cameraReady: false,
19
+ livenessStage: 'CENTER',
20
+ livenessReady: false,
21
+ livenessFailed: false,
22
+ modelLoading: true,
23
+ modelLoaded: false,
24
+ livenessInstruction: 'Look straight at the camera',
25
+ loading: false,
26
+ allStepsCompleted: false,
27
+ capturedImage: null,
28
+ showDocumentUpload: false,
29
+ });
30
+
31
+ const refs: LivenessRefs = {
32
+ centerHold: useRef<number>(0),
33
+ leftHold: useRef<number>(0),
34
+ rightHold: useRef<number>(0),
35
+ snapTriggered: useRef<boolean>(false),
36
+ lastResultsAt: useRef<number>(0),
37
+ livenessStage: useRef<LivenessStage>('CENTER'),
38
+ cameraDriver: useRef<number | null>(null),
39
+ modelLoaded: useRef<boolean>(false),
40
+ livenessFailed: useRef<boolean>(false),
41
+ handleFaceCapture: useRef<(() => void) | null>(null),
42
+ };
43
+
44
+ const livenessStateRef = useRef({
45
+ centerHold: 0,
46
+ leftHold: 0,
47
+ rightHold: 0,
48
+ snapTriggered: false,
49
+ lastResultsAt: 0,
50
+ stage: 'CENTER' as LivenessStage,
51
+ livenessReady: false,
52
+ });
53
+
54
+ // Sync refs with state
55
+ useEffect(() => {
56
+ livenessStateRef.current.centerHold = refs.centerHold.current;
57
+ livenessStateRef.current.leftHold = refs.leftHold.current;
58
+ livenessStateRef.current.rightHold = refs.rightHold.current;
59
+ livenessStateRef.current.snapTriggered = refs.snapTriggered.current;
60
+ livenessStateRef.current.lastResultsAt = refs.lastResultsAt.current;
61
+ livenessStateRef.current.stage = state.livenessStage;
62
+ livenessStateRef.current.livenessReady = state.livenessReady;
63
+ }, [state.livenessStage, state.livenessReady]);
64
+
65
+ useEffect(() => {
66
+ refs.livenessStage.current = state.livenessStage;
67
+ }, [state.livenessStage]);
68
+
69
+ const setStage = useCallback((next: LivenessStage) => {
70
+ refs.livenessStage.current = next;
71
+ livenessStateRef.current.stage = next;
72
+ setState(prev => ({ ...prev, livenessStage: next }));
73
+ }, []);
74
+
75
+ const handleFaceCapture = useCallback(async () => {
76
+ if (!videoRef.current) return;
77
+ setState(prev => ({ ...prev, loading: true }));
78
+ try {
79
+ const video = videoRef.current;
80
+ const canvas = document.createElement("canvas");
81
+ const width = video.videoWidth || 640;
82
+ const height = video.videoHeight || 480;
83
+ canvas.width = width;
84
+ canvas.height = height;
85
+ const ctx = canvas.getContext("2d");
86
+ if (!ctx) throw new Error("Canvas not supported");
87
+ ctx.drawImage(video, 0, 0, width, height);
88
+ const dataUrl = canvas.toDataURL("image/jpeg", 0.92);
89
+
90
+ // Convert data URL to blob
91
+ const blob = await (await fetch(dataUrl)).blob();
92
+
93
+ // Upload face scan if callback provided
94
+ if (callbacks?.onFaceUpload) {
95
+ try {
96
+ await callbacks.onFaceUpload(blob);
97
+ setState(prev => ({
98
+ ...prev,
99
+ capturedImage: dataUrl,
100
+ allStepsCompleted: true,
101
+ livenessInstruction: "Face captured and uploaded successfully!",
102
+ loading: false,
103
+ }));
104
+ } catch (uploadError: any) {
105
+ throw new Error(uploadError.message || 'Failed to upload face scan');
106
+ }
107
+ } else {
108
+ setState(prev => ({
109
+ ...prev,
110
+ capturedImage: dataUrl,
111
+ allStepsCompleted: true,
112
+ livenessInstruction: "Face captured successfully!",
113
+ loading: false,
114
+ }));
115
+ }
116
+
117
+ // Call completion callback
118
+ if (callbacks?.onFaceCaptureComplete) {
119
+ callbacks.onFaceCaptureComplete(dataUrl);
120
+ }
121
+
122
+ setTimeout(() => {
123
+ setState(prev => ({ ...prev, showDocumentUpload: true }));
124
+ }, 500);
125
+ } catch (err: any) {
126
+ console.error('Error capturing image:', err);
127
+ setState(prev => ({
128
+ ...prev,
129
+ livenessInstruction: err.message || 'Error capturing image. Please try again.',
130
+ loading: false,
131
+ }));
132
+ }
133
+ }, [callbacks]);
134
+
135
+ useEffect(() => {
136
+ refs.handleFaceCapture.current = handleFaceCapture;
137
+ }, [handleFaceCapture]);
138
+
139
+ useEffect(() => {
140
+ if (!state.cameraReady) return;
141
+
142
+ let service: FaceMeshService | null = null;
143
+ let initTimeoutId: number | null = null;
144
+ let cancelled = false;
145
+
146
+ const start = async () => {
147
+ try {
148
+ service = new FaceMeshService(
149
+ videoRef,
150
+ canvasRef,
151
+ refs.cameraDriver,
152
+ livenessStateRef,
153
+ {
154
+ onModelLoaded: () => {
155
+ if (cancelled) return;
156
+ setState(prev => ({
157
+ ...prev,
158
+ modelLoaded: true,
159
+ modelLoading: false,
160
+ }));
161
+ refs.modelLoaded.current = true;
162
+ },
163
+ onModelFailed: (error) => {
164
+ if (cancelled) return;
165
+ setState(prev => ({
166
+ ...prev,
167
+ livenessFailed: true,
168
+ modelLoading: false,
169
+ modelLoaded: false,
170
+ }));
171
+ refs.livenessFailed.current = true;
172
+ console.error("mediapipe facemesh init error", error);
173
+ },
174
+ onLivenessUpdate: (stage, instruction) => {
175
+ if (cancelled) return;
176
+ setState(prev => ({
177
+ ...prev,
178
+ livenessStage: stage,
179
+ livenessInstruction: instruction,
180
+ livenessReady: true,
181
+ }));
182
+ livenessStateRef.current.stage = stage;
183
+ refs.livenessStage.current = stage;
184
+ // Sync hold counts from service state
185
+ refs.centerHold.current = livenessStateRef.current.centerHold;
186
+ refs.leftHold.current = livenessStateRef.current.leftHold;
187
+ refs.rightHold.current = livenessStateRef.current.rightHold;
188
+ refs.snapTriggered.current = livenessStateRef.current.snapTriggered;
189
+ },
190
+ onCaptureTrigger: () => {
191
+ if (cancelled) return;
192
+ if (refs.handleFaceCapture.current) {
193
+ refs.handleFaceCapture.current();
194
+ }
195
+ },
196
+ }
197
+ );
198
+
199
+ await service.initialize();
200
+ } catch (e) {
201
+ if (!cancelled && refs.livenessFailed.current === false) {
202
+ setState(prev => ({
203
+ ...prev,
204
+ livenessFailed: true,
205
+ modelLoading: false,
206
+ modelLoaded: false,
207
+ }));
208
+ refs.livenessFailed.current = true;
209
+ }
210
+ }
211
+ };
212
+
213
+ initTimeoutId = window.setTimeout(() => {
214
+ if (!cancelled && !refs.modelLoaded.current && !refs.livenessFailed.current) {
215
+ setState(prev => ({
216
+ ...prev,
217
+ livenessFailed: true,
218
+ modelLoading: false,
219
+ }));
220
+ refs.livenessFailed.current = true;
221
+ }
222
+ }, 8000);
223
+
224
+ start();
225
+
226
+ // Cleanup function - must return a function, not a Promise
227
+ return () => {
228
+ cancelled = true;
229
+ if (initTimeoutId) {
230
+ window.clearTimeout(initTimeoutId);
231
+ initTimeoutId = null;
232
+ }
233
+ if (service) {
234
+ service.cleanup();
235
+ service = null;
236
+ }
237
+ };
238
+ // eslint-disable-next-line react-hooks/exhaustive-deps
239
+ }, [state.cameraReady]);
240
+
241
+ return {
242
+ state,
243
+ setState,
244
+ refs,
245
+ setStage,
246
+ handleFaceCapture,
247
+ };
248
+ }
249
+
@@ -0,0 +1,4 @@
1
+ export * from './types';
2
+ export { useFaceScan } from './hooks/useFaceScan';
3
+ export { useCamera } from './hooks/useCamera';
4
+
@@ -0,0 +1,29 @@
1
+ export type LivenessStage = 'CENTER' | 'LEFT' | 'RIGHT' | 'SNAP' | 'DONE';
2
+
3
+ export interface FaceScanState {
4
+ cameraReady: boolean;
5
+ livenessStage: LivenessStage;
6
+ livenessReady: boolean;
7
+ livenessFailed: boolean;
8
+ modelLoading: boolean;
9
+ modelLoaded: boolean;
10
+ livenessInstruction: string;
11
+ loading: boolean;
12
+ allStepsCompleted: boolean;
13
+ capturedImage: string | null;
14
+ showDocumentUpload: boolean;
15
+ }
16
+
17
+ export interface LivenessRefs {
18
+ centerHold: React.MutableRefObject<number>;
19
+ leftHold: React.MutableRefObject<number>;
20
+ rightHold: React.MutableRefObject<number>;
21
+ snapTriggered: React.MutableRefObject<boolean>;
22
+ lastResultsAt: React.MutableRefObject<number>;
23
+ livenessStage: React.MutableRefObject<LivenessStage>;
24
+ cameraDriver: React.MutableRefObject<number | null>;
25
+ modelLoaded: React.MutableRefObject<boolean>;
26
+ livenessFailed: React.MutableRefObject<boolean>;
27
+ handleFaceCapture: React.MutableRefObject<(() => void) | null>;
28
+ }
29
+
package/src/index.css CHANGED
@@ -1,68 +1,19 @@
1
- :root {
2
- font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
3
- line-height: 1.5;
4
- font-weight: 400;
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
5
4
 
6
- color-scheme: light dark;
7
- color: rgba(255, 255, 255, 0.87);
8
- background-color: #242424;
9
5
 
10
- font-synthesis: none;
11
- text-rendering: optimizeLegibility;
12
- -webkit-font-smoothing: antialiased;
13
- -moz-osx-font-smoothing: grayscale;
14
- }
15
6
 
16
- a {
17
- font-weight: 500;
18
- color: #646cff;
19
- text-decoration: inherit;
20
- }
21
- a:hover {
22
- color: #535bf2;
23
- }
24
-
25
- body {
26
- margin: 0;
27
- display: flex;
28
- place-items: center;
29
- min-width: 320px;
30
- min-height: 100vh;
31
- }
32
-
33
- h1 {
34
- font-size: 3.2em;
35
- line-height: 1.1;
36
- }
37
-
38
- button {
39
- border-radius: 8px;
40
- border: 1px solid transparent;
41
- padding: 0.6em 1.2em;
42
- font-size: 1em;
43
- font-weight: 500;
44
- font-family: inherit;
45
- background-color: #1a1a1a;
46
- cursor: pointer;
47
- transition: border-color 0.25s;
48
- }
49
- button:hover {
50
- border-color: #646cff;
51
- }
52
- button:focus,
53
- button:focus-visible {
54
- outline: 4px auto -webkit-focus-ring-color;
55
- }
56
-
57
- @media (prefers-color-scheme: light) {
58
- :root {
59
- color: #213547;
60
- background-color: #ffffff;
7
+ .custom__scrollbar {
8
+ scrollbar-width: thin;
9
+ scrollbar-color: #474747 transparent;
61
10
  }
62
- a:hover {
63
- color: #747bff;
11
+
12
+ .custom__scrollbar::-webkit-scrollbar {
13
+ width: 6px;
64
14
  }
65
- button {
66
- background-color: #f9f9f9;
15
+
16
+ .custom__scrollbar::-webkit-scrollbar-track {
17
+ background: transparent;
67
18
  }
68
- }
19
+
@@ -0,0 +1,262 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { useNavigate } from 'react-router-dom';
3
+ import { useDocumentUpload } from '../features/documentUpload/hooks/useDocumentUpload';
4
+ import { useKycContext } from '../contexts/KycContext';
5
+ import type { DocumentType } from '../features/documentUpload/types';
6
+
7
+ interface DocumentUploadModalProps {
8
+ onComplete?: (file: File, docType: string) => void;
9
+ }
10
+
11
+ function DocumentUploadModal({ onComplete }: DocumentUploadModalProps) {
12
+ const navigate = useNavigate();
13
+ const { apiService } = useKycContext();
14
+ const [sessionError, setSessionError] = useState<string | null>(null);
15
+
16
+ const {
17
+ state,
18
+ setState,
19
+ fileInputRef,
20
+ docVideoRef,
21
+ handleDocumentUpload,
22
+ handleConfirmDocumentUpload,
23
+ handleManualCapture,
24
+ startDocCamera,
25
+ } = useDocumentUpload({
26
+ onDocumentUpload: async (blob: Blob, docType: string) => {
27
+ if (!apiService) {
28
+ throw new Error('API service not initialized');
29
+ }
30
+ await apiService.uploadDocument(blob, docType);
31
+ },
32
+ onUpload: (file, docType) => {
33
+ if (onComplete) {
34
+ onComplete(file, docType);
35
+ }
36
+ },
37
+ onScan: (file, docType) => {
38
+ if (onComplete) {
39
+ onComplete(file, docType);
40
+ }
41
+ },
42
+ });
43
+
44
+ // Check session status on mount
45
+ useEffect(() => {
46
+ const checkSession = async () => {
47
+ if (!apiService) return;
48
+
49
+ try {
50
+ await apiService.checkSessionActive();
51
+ setSessionError(null);
52
+ } catch (error: any) {
53
+ const message = error.message || 'Session expired or inactive';
54
+ setSessionError(message);
55
+ // Redirect to QR page after showing error
56
+ setTimeout(() => {
57
+ navigate('/qr', { replace: true });
58
+ }, 2000);
59
+ }
60
+ };
61
+
62
+ checkSession();
63
+ }, [apiService, navigate]);
64
+
65
+
66
+ // Show session error if present
67
+ if (sessionError) {
68
+ return (
69
+ <div className="fixed inset-0 bg-black p-5 z-[1000] flex items-center justify-center font-sans overflow-y-auto custom__scrollbar">
70
+ <div className="max-w-[400px] w-full mx-auto bg-[#0b0f17] rounded-2xl p-6 shadow-xl">
71
+ <div className="text-center">
72
+ <h2 className="m-0 mb-4 text-[26px] font-bold text-red-500">
73
+ Session Expired
74
+ </h2>
75
+ <p className="text-[#e5e7eb] mb-4">{sessionError}</p>
76
+ <p className="text-[#9ca3af] text-sm">Redirecting to QR code page...</p>
77
+ </div>
78
+ </div>
79
+ </div>
80
+ );
81
+ }
82
+
83
+ return (
84
+ <div className="fixed inset-0 bg-black p-5 z-[1000] flex items-center justify-center font-sans overflow-y-auto custom__scrollbar">
85
+ <div
86
+ className="max-w-[400px] w-full mx-auto bg-[#0b0f17] rounded-2xl p-6 shadow-xl"
87
+ style={{
88
+ marginTop: state.docPreviewUrl && !state.isDocScanMode ? '192px' : '0'
89
+ }}
90
+ >
91
+ <div className="relative mb-4">
92
+ <h2 className="m-0 mb-4 text-xl font-bold text-white">
93
+ Document
94
+ </h2>
95
+ </div>
96
+
97
+ {!state.isDocScanMode && (
98
+ <div className="grid gap-2 mb-3">
99
+ <button
100
+ type="button"
101
+ onClick={() => fileInputRef.current?.click()}
102
+ className="w-full py-3 px-4 rounded-lg border-2 border-dashed border-[#374151] bg-[#0f172a] text-[#e5e7eb] text-base cursor-pointer hover:border-[#4b5563] transition-colors"
103
+ >
104
+ Upload Document
105
+ </button>
106
+ <button
107
+ type="button"
108
+ onClick={() => {
109
+ setState(prev => ({ ...prev, isDocScanMode: true }));
110
+ startDocCamera();
111
+ }}
112
+ className="w-full py-3 px-4 rounded-lg text-white border-none text-base cursor-pointer transition-opacity hover:opacity-90"
113
+ style={{
114
+ background: "linear-gradient(90deg, #FF8A00 0%, #FF3D77 100%)"
115
+ }}
116
+ >
117
+ Scan Document (Back Camera)
118
+ </button>
119
+ </div>
120
+ )}
121
+
122
+ <div className="flex gap-3 mb-4">
123
+ <label className="flex items-center gap-1.5 text-[#e5e7eb] cursor-pointer">
124
+ <input
125
+ type="radio"
126
+ name="doc-type"
127
+ value="CNIC"
128
+ checked={state.docType === "CNIC"}
129
+ defaultChecked
130
+ onChange={() => setState(prev => ({ ...prev, docType: "CNIC" as DocumentType }))}
131
+ className="cursor-pointer w-4 h-4"
132
+ style={{
133
+ accentColor: state.docType === "CNIC" ? "#ef4444" : "#6b7280"
134
+ }}
135
+ />
136
+ NIC
137
+ </label>
138
+ <label className="flex items-center gap-1.5 text-[#e5e7eb] cursor-pointer">
139
+ <input
140
+ type="radio"
141
+ name="doc-type"
142
+ value="Passport"
143
+ checked={state.docType === "Passport"}
144
+ onChange={() => setState(prev => ({ ...prev, docType: "Passport" as DocumentType }))}
145
+ className="cursor-pointer w-4 h-4"
146
+ style={{
147
+ accentColor: state.docType === "Passport" ? "#ef4444" : "#6b7280"
148
+ }}
149
+ />
150
+ Passport
151
+ </label>
152
+ </div>
153
+
154
+ <input
155
+ ref={fileInputRef}
156
+ type="file"
157
+ accept="image/*,.pdf"
158
+ onChange={handleDocumentUpload}
159
+ className="hidden"
160
+ />
161
+
162
+ {state.isDocScanMode && (
163
+ <div className="grid gap-3 mt-2">
164
+ <div className="relative w-full aspect-[3/4] rounded-lg overflow-hidden bg-black">
165
+ <video
166
+ ref={docVideoRef}
167
+ playsInline
168
+ muted
169
+ autoPlay
170
+ className="w-full h-full object-cover"
171
+ />
172
+ </div>
173
+ <div className="flex gap-2">
174
+ <button
175
+ type="button"
176
+ onClick={handleManualCapture}
177
+ disabled={state.loading}
178
+ className="flex-1 py-3 px-4 rounded-lg text-white border-none text-base cursor-pointer transition-opacity disabled:opacity-50 disabled:cursor-not-allowed hover:opacity-90"
179
+ style={{
180
+ background: "linear-gradient(90deg, #FF8A00 0%, #FF3D77 100%)"
181
+ }}
182
+ >
183
+ {state.loading ? "Capturing..." : "Capture Document"}
184
+ </button>
185
+ <button
186
+ type="button"
187
+ onClick={() => {
188
+ setState(prev => ({ ...prev, isDocScanMode: false }));
189
+ }}
190
+ disabled={state.loading}
191
+ className="py-3 px-4 rounded-lg bg-[#111827] text-[#e5e7eb] border-none text-base cursor-pointer hover:bg-[#1f2937] transition-colors disabled:opacity-50"
192
+ >
193
+ Cancel
194
+ </button>
195
+ </div>
196
+ <p className="m-0 text-[#9ca3af] text-xs text-center">
197
+ {state.loading
198
+ ? "Processing document..."
199
+ : "Position your document in the frame and tap 'Capture Document' when ready."}
200
+ </p>
201
+ </div>
202
+ )}
203
+
204
+ {state.docFileName && !state.isDocScanMode && (
205
+ <div className="inline-flex items-center gap-2 py-2 px-3 rounded-full bg-[#1f2937] text-[#e5e7eb] text-sm mb-3">
206
+ <span>✔</span>
207
+ <span>File selected</span>
208
+ </div>
209
+ )}
210
+
211
+ {state.loading && !state.isDocScanMode && (
212
+ <p className="m-3 mt-0 text-[#60a5fa] text-center">
213
+ Processing...
214
+ </p>
215
+ )}
216
+
217
+ {!state.isDocScanMode && state.docPreviewUrl && (
218
+ <div className="mt-3">
219
+ <div className="font-semibold mb-1.5 text-[#e5e7eb]">Preview</div>
220
+ <img src={state.docPreviewUrl} alt="Document preview" className="w-full rounded-lg border border-[#374151]" />
221
+ <div className="grid gap-2 grid-cols-2 mt-2">
222
+ <button
223
+ type="button"
224
+ onClick={handleConfirmDocumentUpload}
225
+ disabled={state.loading}
226
+ className="py-3 px-4 rounded-lg text-white border-none text-base cursor-pointer w-full transition-opacity disabled:opacity-50 disabled:cursor-not-allowed hover:opacity-90"
227
+ style={{
228
+ background: "linear-gradient(90deg, #10b981 0%, #059669 100%)"
229
+ }}
230
+ >
231
+ {state.loading ? "Uploading..." : "Looks good, continue"}
232
+ </button>
233
+ <button
234
+ type="button"
235
+ onClick={() => {
236
+ setState(prev => ({
237
+ ...prev,
238
+ docPreviewUrl: null,
239
+ docFileName: "",
240
+ }));
241
+ }}
242
+ disabled={state.loading}
243
+ className="py-3 px-4 rounded-lg bg-[#111827] text-[#e5e7eb] border-none text-base cursor-pointer w-full hover:bg-[#1f2937] transition-colors disabled:opacity-50"
244
+ >
245
+ Retake / Choose again
246
+ </button>
247
+ </div>
248
+ </div>
249
+ )}
250
+
251
+ {!state.isDocScanMode && !state.docFileName && !state.docPreviewUrl && (
252
+ <p className="m-0 text-[#9ca3af] text-xs text-center">
253
+ After uploading or scanning, return to your desktop to check status.
254
+ </p>
255
+ )}
256
+ </div>
257
+ </div>
258
+ );
259
+ }
260
+
261
+ export default DocumentUploadModal;
262
+