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.
- package/package.json +13 -2
- package/src/App.tsx +12 -8
- package/src/components/KycFlow.tsx +41 -0
- package/src/components/index.ts +3 -0
- package/src/contexts/KycContext.tsx +66 -0
- package/src/features/documentUpload/hooks/useDocumentUpload.ts +226 -0
- package/src/features/documentUpload/index.ts +3 -0
- package/src/features/documentUpload/types.ts +16 -0
- package/src/features/faceScan/hooks/useCamera.ts +62 -0
- package/src/features/faceScan/hooks/useFaceScan.ts +249 -0
- package/src/features/faceScan/index.ts +4 -0
- package/src/features/faceScan/types.ts +29 -0
- package/src/index.css +13 -62
- package/src/pages/DocumentUploadModal.tsx +262 -0
- package/src/pages/FaceScanModal.tsx +207 -0
- package/src/pages/MobileRoute.tsx +42 -0
- package/src/pages/QRCodePage.tsx +125 -0
- package/src/sdk/index.ts +5 -30
- package/src/services/faceMeshService.ts +382 -0
- package/src/services/index.ts +5 -0
- package/src/services/kycApiService.ts +194 -0
- package/src/utils/deviceDetection.ts +28 -0
- package/src/App.css +0 -42
|
@@ -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,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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
63
|
-
|
|
11
|
+
|
|
12
|
+
.custom__scrollbar::-webkit-scrollbar {
|
|
13
|
+
width: 6px;
|
|
64
14
|
}
|
|
65
|
-
|
|
66
|
-
|
|
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
|
+
|