smartcomply-web-sdk 1.0.61 → 1.0.63
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/camera/CameraManager.d.ts +8 -0
- package/dist/camera/CameraManager.d.ts.map +1 -1
- package/dist/camera/CameraManager.js +13 -2
- package/dist/camera/CameraManager.js.map +1 -1
- package/dist/camera/Permission.d.ts +11 -0
- package/dist/camera/Permission.d.ts.map +1 -1
- package/dist/camera/Permission.js +23 -0
- package/dist/camera/Permission.js.map +1 -1
- package/dist/esm/camera/CameraManager.d.ts +8 -0
- package/dist/esm/camera/CameraManager.d.ts.map +1 -1
- package/dist/esm/camera/CameraManager.js +11 -1
- package/dist/esm/camera/CameraManager.js.map +1 -1
- package/dist/esm/camera/Permission.d.ts +11 -0
- package/dist/esm/camera/Permission.d.ts.map +1 -1
- package/dist/esm/camera/Permission.js +22 -0
- package/dist/esm/camera/Permission.js.map +1 -1
- package/dist/esm/flow/DocumentCapture.d.ts +20 -5
- package/dist/esm/flow/DocumentCapture.d.ts.map +1 -1
- package/dist/esm/flow/DocumentCapture.js +191 -207
- package/dist/esm/flow/DocumentCapture.js.map +1 -1
- package/dist/esm/flow/SmartComplyFlow.d.ts.map +1 -1
- package/dist/esm/flow/SmartComplyFlow.js +84 -116
- package/dist/esm/flow/SmartComplyFlow.js.map +1 -1
- package/dist/esm/flow/icons.d.ts +14 -0
- package/dist/esm/flow/icons.d.ts.map +1 -0
- package/dist/esm/flow/icons.js +34 -0
- package/dist/esm/flow/icons.js.map +1 -0
- package/dist/esm/flow/spinner.d.ts +15 -0
- package/dist/esm/flow/spinner.d.ts.map +1 -0
- package/dist/esm/flow/spinner.js +55 -0
- package/dist/esm/flow/spinner.js.map +1 -0
- package/dist/esm/flow/theme.d.ts +19 -0
- package/dist/esm/flow/theme.d.ts.map +1 -1
- package/dist/esm/flow/theme.js +19 -0
- package/dist/esm/flow/theme.js.map +1 -1
- package/dist/esm/modules/liveness/LivenessUI.d.ts +10 -0
- package/dist/esm/modules/liveness/LivenessUI.d.ts.map +1 -1
- package/dist/esm/modules/liveness/LivenessUI.js +17 -16
- package/dist/esm/modules/liveness/LivenessUI.js.map +1 -1
- package/dist/esm/modules/liveness/liveness.d.ts +2 -0
- package/dist/esm/modules/liveness/liveness.d.ts.map +1 -1
- package/dist/esm/modules/liveness/liveness.js +1 -1
- package/dist/esm/modules/liveness/liveness.js.map +1 -1
- package/dist/esm/utils/constants.d.ts +11 -3
- package/dist/esm/utils/constants.d.ts.map +1 -1
- package/dist/esm/utils/constants.js +11 -3
- package/dist/esm/utils/constants.js.map +1 -1
- package/dist/esm/utils/documentRules.d.ts +15 -0
- package/dist/esm/utils/documentRules.d.ts.map +1 -0
- package/dist/esm/utils/documentRules.js +27 -0
- package/dist/esm/utils/documentRules.js.map +1 -0
- package/dist/flow/DocumentCapture.d.ts +20 -5
- package/dist/flow/DocumentCapture.d.ts.map +1 -1
- package/dist/flow/DocumentCapture.js +191 -207
- package/dist/flow/DocumentCapture.js.map +1 -1
- package/dist/flow/SmartComplyFlow.d.ts.map +1 -1
- package/dist/flow/SmartComplyFlow.js +86 -118
- package/dist/flow/SmartComplyFlow.js.map +1 -1
- package/dist/flow/icons.d.ts +14 -0
- package/dist/flow/icons.d.ts.map +1 -0
- package/dist/flow/icons.js +37 -0
- package/dist/flow/icons.js.map +1 -0
- package/dist/flow/spinner.d.ts +15 -0
- package/dist/flow/spinner.d.ts.map +1 -0
- package/dist/flow/spinner.js +59 -0
- package/dist/flow/spinner.js.map +1 -0
- package/dist/flow/theme.d.ts +19 -0
- package/dist/flow/theme.d.ts.map +1 -1
- package/dist/flow/theme.js +20 -1
- package/dist/flow/theme.js.map +1 -1
- package/dist/modules/liveness/LivenessUI.d.ts +10 -0
- package/dist/modules/liveness/LivenessUI.d.ts.map +1 -1
- package/dist/modules/liveness/LivenessUI.js +17 -16
- package/dist/modules/liveness/LivenessUI.js.map +1 -1
- package/dist/modules/liveness/liveness.d.ts +2 -0
- package/dist/modules/liveness/liveness.d.ts.map +1 -1
- package/dist/modules/liveness/liveness.js +1 -1
- package/dist/modules/liveness/liveness.js.map +1 -1
- package/dist/smartcomply.browser.js +376 -313
- package/dist/smartcomply.browser.js.map +4 -4
- package/dist/utils/constants.d.ts +11 -3
- package/dist/utils/constants.d.ts.map +1 -1
- package/dist/utils/constants.js +11 -3
- package/dist/utils/constants.js.map +1 -1
- package/dist/utils/documentRules.d.ts +15 -0
- package/dist/utils/documentRules.d.ts.map +1 -0
- package/dist/utils/documentRules.js +31 -0
- package/dist/utils/documentRules.js.map +1 -0
- package/package.json +6 -3
|
@@ -1,9 +1,18 @@
|
|
|
1
|
+
import { RADIUS, TYPE } from "./theme";
|
|
1
2
|
import { assessQuality } from "../utils/ImageQuality";
|
|
3
|
+
import { requiresBackSide, computeScaleRatio } from "../utils/documentRules";
|
|
4
|
+
import { CameraManager, CameraAccessError } from "../camera/CameraManager";
|
|
5
|
+
import { isRetryableCameraError } from "../camera/Permission";
|
|
6
|
+
import { DOC_CAMERA_IDEAL_WIDTH, DOC_CAMERA_IDEAL_HEIGHT } from "../utils/constants";
|
|
7
|
+
import { icon } from "./icons";
|
|
8
|
+
import { renderSpinner } from "./spinner";
|
|
2
9
|
export class DocumentCapture {
|
|
3
10
|
constructor() {
|
|
4
11
|
this.root = null;
|
|
5
12
|
this.videoEl = null;
|
|
6
|
-
this.
|
|
13
|
+
this.videoWrapEl = null;
|
|
14
|
+
this.cameraManager = new CameraManager();
|
|
15
|
+
this.orientationHandler = null;
|
|
7
16
|
this.resolveCapture = null;
|
|
8
17
|
this.rejectCapture = null;
|
|
9
18
|
this.frontBlob = null;
|
|
@@ -63,26 +72,11 @@ export class DocumentCapture {
|
|
|
63
72
|
this.backBlob = null;
|
|
64
73
|
this.currentSide = 'front';
|
|
65
74
|
}
|
|
66
|
-
requiresBackSide(documentType) {
|
|
67
|
-
// Only request back-side capture for documents where the backend OCR
|
|
68
|
-
// actually needs the reverse face. NIN cards are single-sided for OCR
|
|
69
|
-
// purposes — do NOT include "nin" here.
|
|
70
|
-
const type = documentType.toLowerCase();
|
|
71
|
-
return type.includes("voter") ||
|
|
72
|
-
type.includes("driver") ||
|
|
73
|
-
type.includes("license") ||
|
|
74
|
-
type.includes("national id") ||
|
|
75
|
-
type.includes("national id card") ||
|
|
76
|
-
type.includes("ghana");
|
|
77
|
-
// Passport: back side is the machine-readable zone but OCR reads from front only.
|
|
78
|
-
// NIN / NIN with face: front only needed.
|
|
79
|
-
// Voter's card / Driver's license / National ID card: back required.
|
|
80
|
-
}
|
|
81
75
|
shouldCaptureBackSide(documentType) {
|
|
82
|
-
// Prefer the backend-provided flag; fall back to
|
|
76
|
+
// Prefer the backend-provided flag; fall back to the shared heuristic only when absent.
|
|
83
77
|
return this.requiresBackOverride !== undefined
|
|
84
78
|
? this.requiresBackOverride
|
|
85
|
-
:
|
|
79
|
+
: requiresBackSide(documentType);
|
|
86
80
|
}
|
|
87
81
|
renderChoiceScreen(container, theme, documentType) {
|
|
88
82
|
this.root = document.createElement("div");
|
|
@@ -106,14 +100,14 @@ export class DocumentCapture {
|
|
|
106
100
|
iconBox.style.cssText = `
|
|
107
101
|
width:48px;height:48px;border-radius:14px;flex-shrink:0;
|
|
108
102
|
display:flex;align-items:center;justify-content:center;
|
|
109
|
-
|
|
103
|
+
color:${theme.primary};
|
|
110
104
|
background:${theme.primary}15;
|
|
111
105
|
border:1.5px solid ${theme.primary}30;
|
|
112
106
|
`;
|
|
113
|
-
iconBox.
|
|
107
|
+
iconBox.innerHTML = icon(this._docIcon(documentType), 22);
|
|
114
108
|
const titleBlock = document.createElement("div");
|
|
115
109
|
const titleEl = document.createElement("div");
|
|
116
|
-
titleEl.style.cssText = `color:${theme.text};font-size
|
|
110
|
+
titleEl.style.cssText = `color:${theme.text};font-size:${TYPE.headingSm}px;font-weight:700;line-height:1.3;`;
|
|
117
111
|
titleEl.textContent = `Capture your ${docLabel}`;
|
|
118
112
|
const subEl = document.createElement("div");
|
|
119
113
|
subEl.style.cssText = `color:${theme.textSecondary};font-size:12px;margin-top:2px;`;
|
|
@@ -142,7 +136,7 @@ export class DocumentCapture {
|
|
|
142
136
|
transition:all 0.15s ease;min-height:80px;
|
|
143
137
|
box-shadow:0 4px 14px ${theme.primary}30;
|
|
144
138
|
`;
|
|
145
|
-
cameraBtn.innerHTML = `<span style="
|
|
139
|
+
cameraBtn.innerHTML = `<span style="display:inline-flex;">${icon("camera", 24)}</span><span>Take Photo</span><span style="font-size:10px;opacity:0.8;font-weight:500;">Use camera</span>`;
|
|
146
140
|
cameraBtn.addEventListener("mouseenter", () => { cameraBtn.style.opacity = "0.9"; cameraBtn.style.transform = "translateY(-1px)"; });
|
|
147
141
|
cameraBtn.addEventListener("mouseleave", () => { cameraBtn.style.opacity = "1"; cameraBtn.style.transform = "none"; });
|
|
148
142
|
cameraBtn.addEventListener("click", () => {
|
|
@@ -159,7 +153,7 @@ export class DocumentCapture {
|
|
|
159
153
|
font-size:13px;font-weight:600;font-family:inherit;
|
|
160
154
|
transition:all 0.15s ease;min-height:80px;
|
|
161
155
|
`;
|
|
162
|
-
uploadBtn.innerHTML = `<span style="
|
|
156
|
+
uploadBtn.innerHTML = `<span style="display:inline-flex;">${icon("gallery", 24)}</span><span>From Gallery</span><span style="font-size:10px;color:${theme.textMuted || theme.textSecondary};font-weight:500;">Upload file</span>`;
|
|
163
157
|
uploadBtn.addEventListener("mouseenter", () => { uploadBtn.style.borderColor = theme.primary; uploadBtn.style.transform = "translateY(-1px)"; });
|
|
164
158
|
uploadBtn.addEventListener("mouseleave", () => { uploadBtn.style.borderColor = theme.border; uploadBtn.style.transform = "none"; });
|
|
165
159
|
uploadBtn.addEventListener("click", () => { this.handleFileUpload(container, theme); });
|
|
@@ -171,7 +165,7 @@ export class DocumentCapture {
|
|
|
171
165
|
tips.style.cssText = `
|
|
172
166
|
margin:0 20px 16px;padding:12px 14px;
|
|
173
167
|
background:${theme.shimmer || theme.cardBg};
|
|
174
|
-
border:1px solid ${theme.border};border-radius
|
|
168
|
+
border:1px solid ${theme.border};border-radius:${RADIUS.md}px;
|
|
175
169
|
`;
|
|
176
170
|
const tipsGrid = document.createElement("div");
|
|
177
171
|
tipsGrid.style.cssText = `
|
|
@@ -186,7 +180,7 @@ export class DocumentCapture {
|
|
|
186
180
|
tipItems.forEach((t) => {
|
|
187
181
|
const row = document.createElement("div");
|
|
188
182
|
row.style.cssText = `display:flex;align-items:center;gap:5px;font-size:11px;color:${theme.textSecondary};`;
|
|
189
|
-
row.innerHTML = `<span style="color:${theme.primary};
|
|
183
|
+
row.innerHTML = `<span style="color:${theme.primary};flex-shrink:0;display:inline-flex;">${icon("check", 12)}</span><span>${t}</span>`;
|
|
190
184
|
tipsGrid.appendChild(row);
|
|
191
185
|
});
|
|
192
186
|
tips.appendChild(tipsGrid);
|
|
@@ -231,51 +225,99 @@ export class DocumentCapture {
|
|
|
231
225
|
this.root.appendChild(fileInput);
|
|
232
226
|
container.appendChild(this.root);
|
|
233
227
|
}
|
|
234
|
-
/** Returns
|
|
228
|
+
/** Returns the icon name matching the document type. */
|
|
235
229
|
_docIcon(documentType) {
|
|
236
230
|
const t = documentType.toLowerCase();
|
|
237
231
|
if (t.includes("passport"))
|
|
238
|
-
return "
|
|
232
|
+
return "passport";
|
|
239
233
|
if (t.includes("driver") || t.includes("licen"))
|
|
240
|
-
return "
|
|
234
|
+
return "car";
|
|
241
235
|
if (t.includes("voter") || t.includes("pvc"))
|
|
242
|
-
return "
|
|
243
|
-
return "
|
|
236
|
+
return "ballot";
|
|
237
|
+
return "id-card";
|
|
244
238
|
}
|
|
245
239
|
renderCameraView(container, theme, documentType) {
|
|
240
|
+
// Dark cinematic chrome + corner-bracket guide — matches the liveness
|
|
241
|
+
// scan screen's visual language (LivenessUI.ts) instead of the plain
|
|
242
|
+
// rectangle outline this used to be, so the two camera experiences in
|
|
243
|
+
// the flow read as one product.
|
|
246
244
|
const wrapper = document.createElement("div");
|
|
247
|
-
wrapper.style.cssText =
|
|
245
|
+
wrapper.style.cssText = `
|
|
246
|
+
position:relative;width:100%;max-width:420px;margin:0 auto;
|
|
247
|
+
background:#0a0a0a;border-radius:${RADIUS.lg}px;overflow:hidden;
|
|
248
|
+
box-shadow:0 8px 32px rgba(0,0,0,0.4);
|
|
249
|
+
`;
|
|
250
|
+
this.videoWrapEl = wrapper;
|
|
248
251
|
this.videoEl = document.createElement("video");
|
|
249
252
|
this.videoEl.autoplay = true;
|
|
250
253
|
this.videoEl.playsInline = true;
|
|
251
254
|
this.videoEl.muted = true;
|
|
255
|
+
// Starts at 4:3 (typical document-shaped default); _fitToStream() below
|
|
256
|
+
// corrects this to the stream's real aspect ratio once metadata loads,
|
|
257
|
+
// instead of force-cropping every device's native ratio into one fixed box.
|
|
252
258
|
this.videoEl.style.cssText = "width:100%;display:block;aspect-ratio:4/3;object-fit:cover;";
|
|
253
259
|
wrapper.appendChild(this.videoEl);
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
260
|
+
const vignette = document.createElement("div");
|
|
261
|
+
vignette.style.cssText = `
|
|
262
|
+
position:absolute;inset:0;pointer-events:none;
|
|
263
|
+
box-shadow:inset 0 0 0 9999px rgba(0,0,0,0.38);
|
|
264
|
+
-webkit-mask:radial-gradient(ellipse 46% 34% at 50% 50%, transparent 60%, #000 100%);
|
|
265
|
+
mask:radial-gradient(ellipse 46% 34% at 50% 50%, transparent 60%, #000 100%);
|
|
266
|
+
`;
|
|
267
|
+
wrapper.appendChild(vignette);
|
|
268
|
+
// Corner-bracket guide — same bracket-path language as LivenessUI's oval
|
|
269
|
+
// guide, adapted to a rectangle since documents (unlike faces) aren't oval.
|
|
270
|
+
const guideWrap = document.createElement("div");
|
|
271
|
+
guideWrap.style.cssText = `
|
|
272
|
+
position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);
|
|
273
|
+
width:84%;aspect-ratio:16/10;pointer-events:none;
|
|
259
274
|
`;
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
275
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
276
|
+
svg.setAttribute("viewBox", "0 0 160 100");
|
|
277
|
+
svg.style.cssText = "width:100%;height:100%;overflow:visible;";
|
|
278
|
+
const bracketGroup = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
279
|
+
bracketGroup.setAttribute("stroke", theme.primary);
|
|
280
|
+
bracketGroup.setAttribute("stroke-width", "3");
|
|
281
|
+
bracketGroup.setAttribute("stroke-linecap", "round");
|
|
282
|
+
bracketGroup.setAttribute("fill", "none");
|
|
283
|
+
const bLen = 16;
|
|
284
|
+
const bx0 = 4, bx1 = 156, by0 = 4, by1 = 96;
|
|
285
|
+
const brackets = [
|
|
286
|
+
`M${bx0},${by0 + bLen} L${bx0},${by0} L${bx0 + bLen},${by0}`,
|
|
287
|
+
`M${bx1 - bLen},${by0} L${bx1},${by0} L${bx1},${by0 + bLen}`,
|
|
288
|
+
`M${bx0},${by1 - bLen} L${bx0},${by1} L${bx0 + bLen},${by1}`,
|
|
289
|
+
`M${bx1 - bLen},${by1} L${bx1},${by1} L${bx1},${by1 - bLen}`,
|
|
290
|
+
];
|
|
291
|
+
for (const d of brackets) {
|
|
292
|
+
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
293
|
+
path.setAttribute("d", d);
|
|
294
|
+
bracketGroup.appendChild(path);
|
|
295
|
+
}
|
|
296
|
+
svg.appendChild(bracketGroup);
|
|
297
|
+
guideWrap.appendChild(svg);
|
|
298
|
+
wrapper.appendChild(guideWrap);
|
|
299
|
+
// Header — gradient scrim + document label
|
|
300
|
+
const header = document.createElement("div");
|
|
301
|
+
header.style.cssText = `
|
|
302
|
+
position:absolute;top:0;left:0;right:0;
|
|
303
|
+
background:linear-gradient(180deg,rgba(0,0,0,0.8) 0%,transparent 100%);
|
|
304
|
+
padding:14px 16px 32px;text-align:center;
|
|
264
305
|
`;
|
|
265
|
-
|
|
266
|
-
wrapper.appendChild(
|
|
267
|
-
//
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
position:absolute;bottom:
|
|
271
|
-
|
|
272
|
-
|
|
306
|
+
header.innerHTML = `<div style="color:#fff;font-size:${TYPE.sm}px;font-weight:600;">Align your ${this.getDocumentLabel(documentType)} within the frame</div>`;
|
|
307
|
+
wrapper.appendChild(header);
|
|
308
|
+
// Footer — gradient scrim + tips
|
|
309
|
+
const footer = document.createElement("div");
|
|
310
|
+
footer.style.cssText = `
|
|
311
|
+
position:absolute;bottom:0;left:0;right:0;
|
|
312
|
+
background:linear-gradient(0deg,rgba(0,0,0,0.7) 0%,transparent 100%);
|
|
313
|
+
padding:28px 10px 18px;text-align:center;
|
|
273
314
|
`;
|
|
274
|
-
|
|
275
|
-
wrapper.appendChild(
|
|
315
|
+
footer.innerHTML = `<div style="font-size:${TYPE.xs}px;color:rgba(255,255,255,0.55);">Good lighting · No glare · All 4 corners visible</div>`;
|
|
316
|
+
wrapper.appendChild(footer);
|
|
276
317
|
container.appendChild(wrapper);
|
|
277
318
|
// Capture button
|
|
278
319
|
const captureBtn = document.createElement("button");
|
|
320
|
+
captureBtn.setAttribute("aria-label", "Capture photo");
|
|
279
321
|
captureBtn.style.cssText = `
|
|
280
322
|
display:block;margin:16px auto 0;width:64px;height:64px;
|
|
281
323
|
border-radius:50%;border:4px solid ${theme.primary};
|
|
@@ -302,6 +344,41 @@ export class DocumentCapture {
|
|
|
302
344
|
// Open camera
|
|
303
345
|
this.openCamera();
|
|
304
346
|
}
|
|
347
|
+
/**
|
|
348
|
+
* Matches the video box's aspect-ratio CSS to the stream's actual
|
|
349
|
+
* negotiated dimensions instead of a single hardcoded ratio — a stream
|
|
350
|
+
* that's natively 16:9 no longer gets force-cropped into a 4:3 box.
|
|
351
|
+
* Clamped to a sane document-shaped range so a degenerate/unusual stream
|
|
352
|
+
* can't produce a sliver-thin or absurdly tall box.
|
|
353
|
+
*/
|
|
354
|
+
_fitToStream() {
|
|
355
|
+
if (!this.videoEl)
|
|
356
|
+
return;
|
|
357
|
+
const { videoWidth: w, videoHeight: h } = this.videoEl;
|
|
358
|
+
if (!w || !h)
|
|
359
|
+
return;
|
|
360
|
+
const ratio = Math.min(Math.max(w / h, 1), 2); // clamp to [1:1, 2:1]
|
|
361
|
+
this.videoEl.style.aspectRatio = `${ratio}`;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Re-fits the video box when the device rotates mid-capture. Does not
|
|
365
|
+
* re-request getUserMedia (disruptive/slow) — the stream's dimensions
|
|
366
|
+
* don't change on rotation, only the viewport does, so re-reading the
|
|
367
|
+
* already-known videoWidth/videoHeight is enough to keep the box sane.
|
|
368
|
+
*/
|
|
369
|
+
_watchOrientation() {
|
|
370
|
+
this._unwatchOrientation();
|
|
371
|
+
this.orientationHandler = () => this._fitToStream();
|
|
372
|
+
window.addEventListener("orientationchange", this.orientationHandler);
|
|
373
|
+
window.addEventListener("resize", this.orientationHandler);
|
|
374
|
+
}
|
|
375
|
+
_unwatchOrientation() {
|
|
376
|
+
if (this.orientationHandler) {
|
|
377
|
+
window.removeEventListener("orientationchange", this.orientationHandler);
|
|
378
|
+
window.removeEventListener("resize", this.orientationHandler);
|
|
379
|
+
this.orientationHandler = null;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
305
382
|
async openCamera() {
|
|
306
383
|
// Check browser compatibility
|
|
307
384
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
@@ -309,28 +386,32 @@ export class DocumentCapture {
|
|
|
309
386
|
return;
|
|
310
387
|
}
|
|
311
388
|
const maxRetries = 3;
|
|
312
|
-
|
|
313
|
-
|
|
389
|
+
// Retries only transient/negotiable failures (busy camera, unsupported
|
|
390
|
+
// constraints, anything unrecognized) — a hard permission denial or "no
|
|
391
|
+
// camera exists" can never be fixed by retrying, so those short-circuit
|
|
392
|
+
// to an immediate error instead of burning ~6s of backoff first.
|
|
393
|
+
const attemptCameraAccess = async (constraints, retryCount = 0) => {
|
|
314
394
|
try {
|
|
315
|
-
return await
|
|
395
|
+
return await this.cameraManager.open(constraints);
|
|
316
396
|
}
|
|
317
397
|
catch (err) {
|
|
318
398
|
console.warn(`Camera access attempt ${retryCount + 1} failed:`, err);
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
await new Promise(resolve => setTimeout(resolve, 1000 * retryCount)); // Exponential backoff
|
|
322
|
-
return attemptCameraAccess(constraints);
|
|
399
|
+
const status = err instanceof CameraAccessError ? err.status : "unknown";
|
|
400
|
+
if (isRetryableCameraError(status) && retryCount < maxRetries - 1) {
|
|
401
|
+
await new Promise((resolve) => setTimeout(resolve, 1000 * (retryCount + 1))); // Exponential backoff
|
|
402
|
+
return attemptCameraAccess(constraints, retryCount + 1);
|
|
323
403
|
}
|
|
324
404
|
throw err;
|
|
325
405
|
}
|
|
326
406
|
};
|
|
407
|
+
let stream;
|
|
327
408
|
try {
|
|
328
409
|
// Try rear camera first
|
|
329
|
-
|
|
410
|
+
stream = await attemptCameraAccess({
|
|
330
411
|
video: {
|
|
331
412
|
facingMode: { ideal: "environment" },
|
|
332
|
-
width: { ideal:
|
|
333
|
-
height: { ideal:
|
|
413
|
+
width: { ideal: DOC_CAMERA_IDEAL_WIDTH },
|
|
414
|
+
height: { ideal: DOC_CAMERA_IDEAL_HEIGHT },
|
|
334
415
|
},
|
|
335
416
|
});
|
|
336
417
|
}
|
|
@@ -338,48 +419,39 @@ export class DocumentCapture {
|
|
|
338
419
|
console.warn("Rear camera failed, trying any camera:", err);
|
|
339
420
|
// Fallback to any camera
|
|
340
421
|
try {
|
|
341
|
-
|
|
342
|
-
video: { width: { ideal:
|
|
422
|
+
stream = await attemptCameraAccess({
|
|
423
|
+
video: { width: { ideal: DOC_CAMERA_IDEAL_WIDTH } },
|
|
343
424
|
});
|
|
344
425
|
}
|
|
345
426
|
catch (fallbackErr) {
|
|
346
|
-
|
|
347
|
-
this.rejectCapture?.(new Error(errorMessage));
|
|
427
|
+
this.rejectCapture?.(new Error(fallbackErr?.message || "Camera access failed. Please check your camera permissions and try again."));
|
|
348
428
|
return;
|
|
349
429
|
}
|
|
350
430
|
}
|
|
431
|
+
// A capture that's already resolved (photo taken, camera intentionally
|
|
432
|
+
// stopped) must not surface a stale interruption from the old stream.
|
|
433
|
+
this.cameraManager.onStreamInterrupted(() => {
|
|
434
|
+
if (this.resolveCapture || this.rejectCapture) {
|
|
435
|
+
this.rejectCapture?.(new Error("Camera connection was lost. Please check your camera and try again."));
|
|
436
|
+
}
|
|
437
|
+
});
|
|
351
438
|
if (this.videoEl) {
|
|
352
|
-
this.videoEl.srcObject =
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
getCameraErrorMessage(error) {
|
|
356
|
-
if (error.name === 'NotAllowedError') {
|
|
357
|
-
return "Camera access denied. Please allow camera permissions and try again.";
|
|
358
|
-
}
|
|
359
|
-
else if (error.name === 'NotFoundError') {
|
|
360
|
-
return "No camera found. Please ensure your device has a working camera.";
|
|
361
|
-
}
|
|
362
|
-
else if (error.name === 'NotReadableError') {
|
|
363
|
-
return "Camera is already in use by another application. Please close other apps using the camera and try again.";
|
|
364
|
-
}
|
|
365
|
-
else if (error.name === 'OverconstrainedError') {
|
|
366
|
-
return "Camera constraints not supported. Trying with different settings...";
|
|
367
|
-
}
|
|
368
|
-
else {
|
|
369
|
-
return "Camera access failed. Please check your camera permissions and try again.";
|
|
439
|
+
this.videoEl.srcObject = stream;
|
|
440
|
+
this.videoEl.addEventListener("loadedmetadata", () => this._fitToStream(), { once: true });
|
|
441
|
+
this._watchOrientation();
|
|
370
442
|
}
|
|
371
443
|
}
|
|
372
444
|
captureFrame(container, theme, documentType) {
|
|
373
445
|
if (!this.videoEl)
|
|
374
446
|
return;
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
447
|
+
// Draw at the video's native resolution — the downstream compressImage()
|
|
448
|
+
// call (see proceedWithPhoto) is the single place resolution gets capped
|
|
449
|
+
// (via computeScaleRatio, which never upscales). Resizing here too used
|
|
450
|
+
// to cap captures at 1280x960 before compressImage ever ran, silently
|
|
451
|
+
// undermining its higher resolution/quality target.
|
|
380
452
|
const canvas = document.createElement("canvas");
|
|
381
|
-
canvas.width =
|
|
382
|
-
canvas.height =
|
|
453
|
+
canvas.width = this.videoEl.videoWidth;
|
|
454
|
+
canvas.height = this.videoEl.videoHeight;
|
|
383
455
|
const ctx = canvas.getContext("2d");
|
|
384
456
|
if (!ctx)
|
|
385
457
|
return;
|
|
@@ -387,14 +459,15 @@ export class DocumentCapture {
|
|
|
387
459
|
canvas.toBlob(async (blob) => {
|
|
388
460
|
if (blob) {
|
|
389
461
|
this.stopCamera();
|
|
390
|
-
// Quality analysis runs synchronously over
|
|
391
|
-
// show a loading state so the capture->preview
|
|
392
|
-
// looks like a frozen/unresponsive tap on
|
|
462
|
+
// Quality analysis runs synchronously over the captured frame's
|
|
463
|
+
// pixel data — show a loading state so the capture->preview
|
|
464
|
+
// transition never looks like a frozen/unresponsive tap on
|
|
465
|
+
// slower devices.
|
|
393
466
|
this.showLoadingState("Analyzing photo...", theme);
|
|
394
467
|
const quality = await this._assessImageQuality(canvas, ctx);
|
|
395
468
|
this.showPreview(container, theme, blob, documentType, quality);
|
|
396
469
|
}
|
|
397
|
-
}, "image/jpeg", 0.
|
|
470
|
+
}, "image/jpeg", 0.92);
|
|
398
471
|
}
|
|
399
472
|
/**
|
|
400
473
|
* Runs the local sharpness/glare/contrast/brightness check against a
|
|
@@ -442,23 +515,36 @@ export class DocumentCapture {
|
|
|
442
515
|
container.appendChild(this.root);
|
|
443
516
|
}
|
|
444
517
|
this.root.style.cssText = "display:flex;flex-direction:column;align-items:center;gap:16px;";
|
|
518
|
+
// Step badge — same pill treatment as SmartComplyFlow's document-confirm
|
|
519
|
+
// screen (Step 2: check what we read), so this doesn't read as
|
|
520
|
+
// unexplained repetition of that later screen — they check different
|
|
521
|
+
// things (photo clarity here, OCR-extracted data there).
|
|
522
|
+
const stepBadge = document.createElement("div");
|
|
523
|
+
stepBadge.style.cssText = `
|
|
524
|
+
display:inline-flex;align-items:center;gap:5px;
|
|
525
|
+
padding:3px 11px;border-radius:${RADIUS.pill}px;
|
|
526
|
+
font-size:10.5px;font-weight:600;letter-spacing:0.2px;
|
|
527
|
+
color:${theme.primary};background:${theme.primary}15;border:1px solid ${theme.primary}30;
|
|
528
|
+
`;
|
|
529
|
+
stepBadge.textContent = "STEP 1 OF 2 · CHECK THE PHOTO";
|
|
530
|
+
this.root.appendChild(stepBadge);
|
|
445
531
|
// Preview image
|
|
446
532
|
const img = document.createElement("img");
|
|
447
533
|
const imageUrl = URL.createObjectURL(imageData);
|
|
448
534
|
img.src = imageUrl;
|
|
449
535
|
img.style.cssText = `
|
|
450
|
-
width:100%;max-height:300px;object-fit:contain;border-radius
|
|
536
|
+
width:100%;max-height:300px;object-fit:contain;border-radius:${RADIUS.md}px;
|
|
451
537
|
border:2px solid ${theme.border};
|
|
452
538
|
`;
|
|
453
539
|
this.root.appendChild(img);
|
|
454
540
|
// Store URL for cleanup
|
|
455
541
|
img._blobUrl = imageUrl;
|
|
456
542
|
const label = document.createElement("div");
|
|
457
|
-
label.style.cssText = `color:${theme.
|
|
543
|
+
label.style.cssText = `color:${theme.text};font-size:${TYPE.base}px;font-weight:700;text-align:center;`;
|
|
458
544
|
label.textContent = "Does the photo look clear?";
|
|
459
545
|
this.root.appendChild(label);
|
|
460
546
|
const sublabel = document.createElement("div");
|
|
461
|
-
sublabel.style.cssText = `color:${theme.textMuted};font-size
|
|
547
|
+
sublabel.style.cssText = `color:${theme.textMuted};font-size:${TYPE.xs}px;text-align:center;line-height:1.6;`;
|
|
462
548
|
sublabel.textContent = "All text must be readable and all four corners must be visible.";
|
|
463
549
|
this.root.appendChild(sublabel);
|
|
464
550
|
// Quality warning banner — only shown when the local check flags an issue.
|
|
@@ -587,126 +673,24 @@ export class DocumentCapture {
|
|
|
587
673
|
input.click();
|
|
588
674
|
}
|
|
589
675
|
stopCamera() {
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
this.stream = null;
|
|
593
|
-
}
|
|
676
|
+
this._unwatchOrientation();
|
|
677
|
+
this.cameraManager.stop();
|
|
594
678
|
if (this.videoEl) {
|
|
595
679
|
this.videoEl.srcObject = null;
|
|
596
680
|
this.videoEl = null;
|
|
597
681
|
}
|
|
598
|
-
|
|
599
|
-
createEnhancedButton(label, theme, isPrimary, ariaLabel) {
|
|
600
|
-
const btn = document.createElement("button");
|
|
601
|
-
btn.textContent = label;
|
|
602
|
-
btn.setAttribute("aria-label", ariaLabel);
|
|
603
|
-
btn.style.cssText = `
|
|
604
|
-
padding:18px 24px;border-radius:16px;font-size:16px;font-weight:600;
|
|
605
|
-
cursor:pointer;transition:all 0.3s ease;border:none;width:100%;
|
|
606
|
-
min-height:56px;touch-action:manipulation;-webkit-tap-highlight-color:transparent;
|
|
607
|
-
position:relative;overflow:hidden;
|
|
608
|
-
${isPrimary
|
|
609
|
-
? `background:linear-gradient(135deg, ${theme.primary}, ${theme.primary}DD);color:#fff;box-shadow:0 8px 24px ${theme.primary}25, 0 4px 12px ${theme.primary}15;`
|
|
610
|
-
: `background:${theme.inputBg};color:${theme.text};border:2px solid ${theme.border};box-shadow:0 2px 8px ${theme.border}20;`}
|
|
611
|
-
`;
|
|
612
|
-
// Add ripple effect
|
|
613
|
-
btn.addEventListener("click", (e) => {
|
|
614
|
-
const ripple = document.createElement("span");
|
|
615
|
-
const rect = btn.getBoundingClientRect();
|
|
616
|
-
const size = Math.max(rect.width, rect.height);
|
|
617
|
-
const x = e.clientX - rect.left - size / 2;
|
|
618
|
-
const y = e.clientY - rect.top - size / 2;
|
|
619
|
-
ripple.style.cssText = `
|
|
620
|
-
position:absolute;width:${size}px;height:${size}px;
|
|
621
|
-
left:${x}px;top:${y}px;border-radius:50%;
|
|
622
|
-
background:${isPrimary ? 'rgba(255,255,255,0.3)' : theme.primary + '20'};
|
|
623
|
-
transform:scale(0);animation:ripple 0.6s ease-out;
|
|
624
|
-
pointer-events:none;
|
|
625
|
-
`;
|
|
626
|
-
btn.appendChild(ripple);
|
|
627
|
-
setTimeout(() => ripple.remove(), 600);
|
|
628
|
-
});
|
|
629
|
-
btn.addEventListener("mouseenter", () => {
|
|
630
|
-
btn.style.transform = "translateY(-2px)";
|
|
631
|
-
if (isPrimary) {
|
|
632
|
-
btn.style.boxShadow = `0 12px 32px ${theme.primary}30, 0 6px 16px ${theme.primary}20`;
|
|
633
|
-
}
|
|
634
|
-
else {
|
|
635
|
-
btn.style.boxShadow = `0 4px 16px ${theme.border}30, 0 2px 8px ${theme.border}20`;
|
|
636
|
-
btn.style.borderColor = theme.primary;
|
|
637
|
-
}
|
|
638
|
-
});
|
|
639
|
-
btn.addEventListener("mouseleave", () => {
|
|
640
|
-
btn.style.transform = "translateY(0)";
|
|
641
|
-
if (isPrimary) {
|
|
642
|
-
btn.style.boxShadow = `0 8px 24px ${theme.primary}25, 0 4px 12px ${theme.primary}15`;
|
|
643
|
-
}
|
|
644
|
-
else {
|
|
645
|
-
btn.style.boxShadow = `0 2px 8px ${theme.border}20`;
|
|
646
|
-
btn.style.borderColor = theme.border;
|
|
647
|
-
}
|
|
648
|
-
});
|
|
649
|
-
// Add ripple animation
|
|
650
|
-
if (!document.head.querySelector('style[data-ripple-animation]')) {
|
|
651
|
-
const style = document.createElement('style');
|
|
652
|
-
style.setAttribute('data-ripple-animation', 'true');
|
|
653
|
-
style.textContent = `
|
|
654
|
-
@keyframes ripple {
|
|
655
|
-
to { transform: scale(4); opacity: 0; }
|
|
656
|
-
}
|
|
657
|
-
`;
|
|
658
|
-
document.head.appendChild(style);
|
|
659
|
-
}
|
|
660
|
-
return btn;
|
|
682
|
+
this.videoWrapEl = null;
|
|
661
683
|
}
|
|
662
684
|
showLoadingState(message, theme) {
|
|
663
685
|
if (!this.root)
|
|
664
686
|
return;
|
|
665
687
|
this.root.innerHTML = "";
|
|
666
688
|
this.root.style.cssText = "display:flex;flex-direction:column;align-items:center;justify-content:center;gap:20px;padding:40px 20px;min-height:300px;";
|
|
667
|
-
|
|
668
|
-
const spinnerContainer = document.createElement("div");
|
|
669
|
-
spinnerContainer.style.cssText = "position:relative;width:80px;height:80px;";
|
|
670
|
-
const spinner = document.createElement("div");
|
|
671
|
-
spinner.style.cssText = `
|
|
672
|
-
position:absolute;inset:0;border-radius:50%;
|
|
673
|
-
border:4px solid ${theme.border};
|
|
674
|
-
border-top-color:${theme.primary};
|
|
675
|
-
animation:sc-spin 1s linear infinite;
|
|
676
|
-
`;
|
|
677
|
-
spinnerContainer.appendChild(spinner);
|
|
678
|
-
const centerIcon = document.createElement("div");
|
|
679
|
-
centerIcon.style.cssText = `
|
|
680
|
-
position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
|
|
681
|
-
font-size:28px;animation:pulse 2s ease-in-out infinite;
|
|
682
|
-
`;
|
|
683
|
-
centerIcon.textContent = "📄";
|
|
684
|
-
spinnerContainer.appendChild(centerIcon);
|
|
685
|
-
this.root.appendChild(spinnerContainer);
|
|
686
|
-
// Loading message
|
|
689
|
+
this.root.appendChild(renderSpinner(theme, { size: 80, icon: icon("document", 28) }));
|
|
687
690
|
const loadingText = document.createElement("div");
|
|
688
|
-
loadingText.style.cssText = `
|
|
689
|
-
color:${theme.text};font-size:16px;font-weight:600;text-align:center;
|
|
690
|
-
animation:fadeIn 0.5s ease-out;
|
|
691
|
-
`;
|
|
691
|
+
loadingText.style.cssText = `color:${theme.text};font-size:${TYPE.heading}px;font-weight:600;text-align:center;`;
|
|
692
692
|
loadingText.textContent = message;
|
|
693
693
|
this.root.appendChild(loadingText);
|
|
694
|
-
// Add pulse animation
|
|
695
|
-
if (!document.head.querySelector('style[data-pulse-animation]')) {
|
|
696
|
-
const style = document.createElement('style');
|
|
697
|
-
style.setAttribute('data-pulse-animation', 'true');
|
|
698
|
-
style.textContent = `
|
|
699
|
-
@keyframes pulse {
|
|
700
|
-
0%, 100% { transform: scale(1); opacity: 1; }
|
|
701
|
-
50% { transform: scale(1.1); opacity: 0.8; }
|
|
702
|
-
}
|
|
703
|
-
@keyframes fadeIn {
|
|
704
|
-
from { opacity: 0; transform: translateY(10px); }
|
|
705
|
-
to { opacity: 1; transform: translateY(0); }
|
|
706
|
-
}
|
|
707
|
-
`;
|
|
708
|
-
document.head.appendChild(style);
|
|
709
|
-
}
|
|
710
694
|
}
|
|
711
695
|
showError(message) {
|
|
712
696
|
if (!this.root)
|
|
@@ -714,7 +698,7 @@ export class DocumentCapture {
|
|
|
714
698
|
const errorContainer = document.createElement("div");
|
|
715
699
|
errorContainer.style.cssText = `
|
|
716
700
|
display:flex;align-items:center;gap:12px;padding:16px;
|
|
717
|
-
background:#ff444415;border:1px solid #ff444430;border-radius
|
|
701
|
+
background:#ff444415;border:1px solid #ff444430;border-radius:${RADIUS.md}px;
|
|
718
702
|
color:#ff4444;font-size:14px;margin-top:16px;animation:shake 0.5s ease-in-out;
|
|
719
703
|
`;
|
|
720
704
|
errorContainer.innerHTML = `
|
|
@@ -741,7 +725,7 @@ export class DocumentCapture {
|
|
|
741
725
|
const btn = document.createElement("button");
|
|
742
726
|
btn.textContent = label;
|
|
743
727
|
btn.style.cssText = `
|
|
744
|
-
padding:16px 24px;border-radius
|
|
728
|
+
padding:16px 24px;border-radius:${RADIUS.md}px;font-size:16px;font-weight:600;
|
|
745
729
|
cursor:pointer;transition:all 0.2s ease;border:none;width:100%;
|
|
746
730
|
min-height:52px;touch-action:manipulation;-webkit-tap-highlight-color:transparent;
|
|
747
731
|
${isPrimary
|
|
@@ -774,7 +758,7 @@ export class DocumentCapture {
|
|
|
774
758
|
const swapped = orientation >= 5 && orientation <= 8;
|
|
775
759
|
const srcW = swapped ? h : w;
|
|
776
760
|
const srcH = swapped ? w : h;
|
|
777
|
-
const ratio =
|
|
761
|
+
const ratio = computeScaleRatio(srcW, srcH, maxWidth, maxHeight);
|
|
778
762
|
const outW = Math.round(srcW * ratio);
|
|
779
763
|
const outH = Math.round(srcH * ratio);
|
|
780
764
|
const canvas = document.createElement("canvas");
|
|
@@ -821,7 +805,7 @@ export class DocumentCapture {
|
|
|
821
805
|
imgEl.onload = () => {
|
|
822
806
|
URL.revokeObjectURL(url);
|
|
823
807
|
let { naturalWidth: w, naturalHeight: h } = imgEl;
|
|
824
|
-
const ratio =
|
|
808
|
+
const ratio = computeScaleRatio(w, h, maxWidth, maxHeight);
|
|
825
809
|
const canvas = document.createElement("canvas");
|
|
826
810
|
canvas.width = Math.round(w * ratio);
|
|
827
811
|
canvas.height = Math.round(h * ratio);
|