@casperid/react 1.0.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,348 @@
1
+ import React, { useRef, useState, useEffect } from 'react';
2
+ import { motion } from 'framer-motion';
3
+ import { Camera, HelpCircle, Flashlight, Loader2, AlertCircle, RotateCcw } from 'lucide-react';
4
+ import type { DocumentType, DocumentUploadResponse, ExtractedDocumentData } from '../types';
5
+
6
+ import { t } from '../utils/i18n';
7
+
8
+ interface DocumentScanProps {
9
+ requestId: string;
10
+ documentType: DocumentType;
11
+ onNext: (extractedData: ExtractedDocumentData, docUploadId: string) => void;
12
+ onError?: (error: string) => void;
13
+ apiBaseUrl?: string;
14
+ previewMode?: boolean;
15
+ language?: string;
16
+ }
17
+
18
+ const getDocumentTypeLabels = (language: string) => ({
19
+ passport: t('passport', language),
20
+ drivers_license: t('drivers_license', language),
21
+ national_id: t('national_id', language)
22
+ });
23
+
24
+ const DocumentScan: React.FC<DocumentScanProps> = ({
25
+ requestId,
26
+ documentType,
27
+ onNext,
28
+ onError,
29
+ apiBaseUrl = 'https://apis.casperid.com',
30
+ previewMode = false,
31
+ language = 'EN'
32
+ }) => {
33
+ const videoRef = useRef<HTMLVideoElement>(null);
34
+ const canvasRef = useRef<HTMLCanvasElement>(null);
35
+ const streamRef = useRef<MediaStream | null>(null);
36
+
37
+ const [cameraReady, setCameraReady] = useState(false);
38
+ const [capturedImage, setCapturedImage] = useState<Blob | null>(null);
39
+ const [capturedImageUrl, setCapturedImageUrl] = useState<string | null>(null);
40
+ const [isUploading, setIsUploading] = useState(false);
41
+ const [error, setError] = useState('');
42
+ const [scanSide, setScanSide] = useState<'front' | 'back'>('front');
43
+ const [frontImage, setFrontImage] = useState<Blob | null>(null);
44
+
45
+ // Initialize camera
46
+ useEffect(() => {
47
+ const initCamera = async () => {
48
+ try {
49
+ const stream = await navigator.mediaDevices.getUserMedia({
50
+ video: {
51
+ facingMode: 'environment', // Use back camera for documents
52
+ width: { ideal: 1280 },
53
+ height: { ideal: 720 }
54
+ }
55
+ });
56
+ streamRef.current = stream;
57
+ if (videoRef.current) {
58
+ videoRef.current.srcObject = stream;
59
+ videoRef.current.onloadedmetadata = () => {
60
+ setCameraReady(true);
61
+ };
62
+ }
63
+ } catch (err: any) {
64
+ // Fallback to front camera
65
+ try {
66
+ const stream = await navigator.mediaDevices.getUserMedia({
67
+ video: { facingMode: 'user', width: { ideal: 1280 }, height: { ideal: 720 } }
68
+ });
69
+ streamRef.current = stream;
70
+ if (videoRef.current) {
71
+ videoRef.current.srcObject = stream;
72
+ videoRef.current.onloadedmetadata = () => {
73
+ setCameraReady(true);
74
+ };
75
+ }
76
+ } catch (fallbackErr) {
77
+ setError(t('unable_access_camera', language));
78
+ onError?.('Camera access failed');
79
+ }
80
+ }
81
+ };
82
+
83
+ initCamera();
84
+
85
+ return () => {
86
+ if (streamRef.current) {
87
+ streamRef.current.getTracks().forEach(track => track.stop());
88
+ }
89
+ if (capturedImageUrl) {
90
+ URL.revokeObjectURL(capturedImageUrl);
91
+ }
92
+ };
93
+ }, [onError]);
94
+
95
+ // Capture photo
96
+ const handleCapture = async () => {
97
+ if (!videoRef.current || !canvasRef.current) return;
98
+
99
+ const canvas = canvasRef.current;
100
+ const video = videoRef.current;
101
+ const ctx = canvas.getContext('2d');
102
+
103
+ if (!ctx) return;
104
+
105
+ canvas.width = video.videoWidth;
106
+ canvas.height = video.videoHeight;
107
+ ctx.drawImage(video, 0, 0);
108
+
109
+ canvas.toBlob((blob) => {
110
+ if (blob) {
111
+ setCapturedImage(blob);
112
+ setCapturedImageUrl(URL.createObjectURL(blob));
113
+ }
114
+ }, 'image/jpeg', 0.9);
115
+ };
116
+
117
+ // Retake photo
118
+ const handleRetake = () => {
119
+ if (capturedImageUrl) {
120
+ URL.revokeObjectURL(capturedImageUrl);
121
+ }
122
+ setCapturedImage(null);
123
+ setCapturedImageUrl(null);
124
+ setError('');
125
+ };
126
+
127
+ // Confirm and proceed
128
+ const handleConfirm = async () => {
129
+ setIsUploading(true);
130
+
131
+ if (previewMode) {
132
+ setTimeout(() => {
133
+ setIsUploading(false);
134
+ onNext({
135
+ first_name: 'JOHN',
136
+ last_name: 'DOE',
137
+ date_of_birth: '01 JAN 1990',
138
+ nationality: 'Nigerian'
139
+ }, 'mock-upload-id-789');
140
+ }, 2000);
141
+ return;
142
+ }
143
+
144
+ if (!capturedImage) {
145
+ setIsUploading(false); // Ensure loading state is reset if no image
146
+ return;
147
+ }
148
+
149
+ if (scanSide === 'front' && documentType !== 'passport') {
150
+ // For non-passport documents, need back side too
151
+ setFrontImage(capturedImage);
152
+ setCapturedImage(null);
153
+ setCapturedImageUrl(null);
154
+ setScanSide('back');
155
+ setIsUploading(false); // Reset loading state after setting side
156
+ return;
157
+ }
158
+
159
+ // Upload document(s)
160
+ setIsUploading(true);
161
+ setError('');
162
+
163
+ try {
164
+ const formData = new FormData();
165
+ formData.append('document_type', documentType);
166
+
167
+ if (frontImage) {
168
+ formData.append('front_image', frontImage, 'front.jpg');
169
+ formData.append('back_image', capturedImage, 'back.jpg');
170
+ } else {
171
+ formData.append('front_image', capturedImage, 'document.jpg');
172
+ }
173
+
174
+ const response = await fetch(`${apiBaseUrl}/api/v2/verification/layer3/documents/upload`, {
175
+ method: 'POST',
176
+ body: formData,
177
+ credentials: 'include',
178
+ headers: {
179
+ 'X-Request-Id': requestId
180
+ }
181
+ });
182
+
183
+ if (!response.ok) {
184
+ const errorData = await response.json().catch(() => ({}));
185
+ throw new Error(errorData.error || `Document upload failed with status ${response.status}`);
186
+ }
187
+
188
+ const data: DocumentUploadResponse = await response.json();
189
+
190
+ if (data.success && data.extracted_data) {
191
+ // Stop camera
192
+ if (streamRef.current) {
193
+ streamRef.current.getTracks().forEach(track => track.stop());
194
+ }
195
+ onNext(data.extracted_data, data.upload_id || '');
196
+ } else {
197
+ setError(data.error || 'Failed to process document. Please try again.');
198
+ setIsUploading(false);
199
+ }
200
+ } catch (err) {
201
+ setError('Upload failed. Please try again.');
202
+ setIsUploading(false);
203
+ onError?.('Document upload failed');
204
+ }
205
+ };
206
+
207
+ return (
208
+ <motion.div
209
+ initial={{ opacity: 0 }}
210
+ animate={{ opacity: 1 }}
211
+ exit={{ opacity: 0 }}
212
+ className="flex-1 flex flex-col relative"
213
+ >
214
+ {/* Hidden canvas for capture */}
215
+ <canvas ref={canvasRef} className="hidden" />
216
+
217
+ {/* Simulated dark camera background */}
218
+ <div className="absolute inset-0 z-0">
219
+ <div className="w-full h-full bg-slate-800 flex items-center justify-center">
220
+ <div className="w-full h-full opacity-30 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-[#6DE8EC]/20 via-transparent to-transparent" />
221
+ </div>
222
+ <div className="absolute inset-0 bg-black/30 backdrop-blur-[2px]" />
223
+ </div>
224
+
225
+ {/* Top label */}
226
+ <div className="relative z-10 p-6 flex flex-col items-center">
227
+ <div className="text-center px-4 py-2 dark:bg-black/40 bg-white/40 backdrop-blur-xl rounded-full border dark:border-white/10 border-black/5">
228
+ <h3 className="text-main text-sm font-medium tracking-wide uppercase">
229
+ {t('scan_instruction', language)
230
+ .replace('{side}', t(scanSide, language))
231
+ .replace('{docType}', getDocumentTypeLabels(language)[documentType])}
232
+ </h3>
233
+ </div>
234
+ </div>
235
+
236
+ {/* ID frame with video or captured image */}
237
+ <div className="relative z-10 flex-1 flex items-center justify-center px-6">
238
+ <div className="w-full aspect-[1.58/1] relative">
239
+ {/* Video or captured image */}
240
+ {capturedImageUrl ? (
241
+ <img
242
+ src={capturedImageUrl}
243
+ alt="Captured document"
244
+ className="absolute inset-0 w-full h-full object-cover rounded-2xl"
245
+ />
246
+ ) : (
247
+ <video
248
+ ref={videoRef}
249
+ autoPlay
250
+ playsInline
251
+ muted
252
+ className="absolute inset-0 w-full h-full object-cover rounded-2xl"
253
+ />
254
+ )}
255
+
256
+ {/* Frame overlay */}
257
+ <div className="absolute inset-0 border border-[#6DE8EC] rounded-2xl shadow-[0_0_20px_rgba(109,232,236,0.3),inset_0_0_15px_rgba(109,232,236,0.2)]" />
258
+ <div className="absolute top-0 left-0 w-8 h-8 border-t-4 border-l-4 border-[#6DE8EC] rounded-tl-2xl" />
259
+ <div className="absolute top-0 right-0 w-8 h-8 border-t-4 border-r-4 border-[#6DE8EC] rounded-tr-2xl" />
260
+ <div className="absolute bottom-0 left-0 w-8 h-8 border-b-4 border-l-4 border-[#6DE8EC] rounded-bl-2xl" />
261
+ <div className="absolute bottom-0 right-0 w-8 h-8 border-b-4 border-r-4 border-[#6DE8EC] rounded-br-2xl" />
262
+
263
+ {/* Scanning line (only when video is active) */}
264
+ {!capturedImageUrl && cameraReady && (
265
+ <motion.div
266
+ animate={{ top: ['0%', '100%', '0%'] }}
267
+ transition={{ duration: 4, repeat: Infinity, ease: 'linear' }}
268
+ className="absolute left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-[#6DE8EC] to-transparent opacity-50"
269
+ />
270
+ )}
271
+
272
+ {/* Loading overlay */}
273
+ {!cameraReady && !capturedImageUrl && (
274
+ <div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded-2xl">
275
+ <Loader2 className="w-8 h-8 text-[#6DE8EC] animate-spin" />
276
+ </div>
277
+ )}
278
+ </div>
279
+ </div>
280
+
281
+ {/* Error message */}
282
+ {error && (
283
+ <div className="relative z-10 mx-6 mb-4 p-4 bg-red-500/20 border border-red-500/30 rounded-xl flex items-center gap-3">
284
+ <AlertCircle className="w-5 h-5 text-red-500 shrink-0" />
285
+ <p className="text-red-400 text-sm font-medium">{error}</p>
286
+ </div>
287
+ )}
288
+
289
+ {/* Bottom controls */}
290
+ <div className="relative z-10 p-8 dark:bg-black/60 bg-white/60 backdrop-blur-2xl border-t dark:border-white/10 border-black/5 rounded-t-3xl">
291
+ <p className="text-muted text-xs text-center mb-6 font-medium">
292
+ {capturedImageUrl
293
+ ? t('review_capture', language)
294
+ : t('align_document', language)}
295
+ </p>
296
+
297
+ <div className="flex items-center justify-between gap-4">
298
+ {capturedImageUrl ? (
299
+ <>
300
+ <button
301
+ onClick={handleRetake}
302
+ disabled={isUploading}
303
+ className="w-12 h-12 rounded-xl dark:bg-white/5 bg-black/5 flex items-center justify-center text-muted disabled:opacity-50"
304
+ >
305
+ <RotateCcw className="w-6 h-6" />
306
+ </button>
307
+ <button
308
+ onClick={handleConfirm}
309
+ disabled={isUploading}
310
+ className="flex-1 bg-brand hover:bg-brand/90 text-white h-14 rounded-2xl flex items-center justify-center gap-2 font-bold text-lg transition-all active:scale-95 shadow-[0_0_25px_rgba(130,66,240,0.5)] disabled:opacity-70"
311
+ >
312
+ {isUploading ? (
313
+ <>
314
+ <Loader2 className="w-6 h-6 animate-spin" />
315
+ <span>{t('processing', language)}</span>
316
+ </>
317
+ ) : (
318
+ <span>{scanSide === 'front' && documentType !== 'passport' ? t('next_back_side', language) : t('confirm', language)}</span>
319
+ )}
320
+ </button>
321
+ <div className="w-12 h-12" /> {/* Spacer for alignment */}
322
+ </>
323
+ ) : (
324
+ <>
325
+ <button className="w-12 h-12 rounded-xl dark:bg-white/5 bg-black/5 flex items-center justify-center text-muted">
326
+ <Flashlight className="w-6 h-6" />
327
+ </button>
328
+ <button
329
+ onClick={handleCapture}
330
+ disabled={!cameraReady}
331
+ className="flex-1 bg-brand hover:bg-brand/90 text-white h-14 rounded-2xl flex items-center justify-center gap-2 font-bold text-lg transition-all active:scale-95 shadow-[0_0_25px_rgba(130,66,240,0.5)] disabled:opacity-50"
332
+ >
333
+ <Camera className="w-6 h-6" />
334
+ <span>{t('capture', language)}</span>
335
+ </button>
336
+ <button className="w-12 h-12 rounded-xl dark:bg-white/5 bg-black/5 flex items-center justify-center text-muted">
337
+ <HelpCircle className="w-6 h-6" />
338
+ </button>
339
+ </>
340
+ )}
341
+ </div>
342
+ <div className="w-32 h-1.5 bg-slate-600/30 rounded-full mx-auto mt-8" />
343
+ </div>
344
+ </motion.div>
345
+ );
346
+ };
347
+
348
+ export default DocumentScan;