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.
Files changed (89) hide show
  1. package/dist/camera/CameraManager.d.ts +8 -0
  2. package/dist/camera/CameraManager.d.ts.map +1 -1
  3. package/dist/camera/CameraManager.js +13 -2
  4. package/dist/camera/CameraManager.js.map +1 -1
  5. package/dist/camera/Permission.d.ts +11 -0
  6. package/dist/camera/Permission.d.ts.map +1 -1
  7. package/dist/camera/Permission.js +23 -0
  8. package/dist/camera/Permission.js.map +1 -1
  9. package/dist/esm/camera/CameraManager.d.ts +8 -0
  10. package/dist/esm/camera/CameraManager.d.ts.map +1 -1
  11. package/dist/esm/camera/CameraManager.js +11 -1
  12. package/dist/esm/camera/CameraManager.js.map +1 -1
  13. package/dist/esm/camera/Permission.d.ts +11 -0
  14. package/dist/esm/camera/Permission.d.ts.map +1 -1
  15. package/dist/esm/camera/Permission.js +22 -0
  16. package/dist/esm/camera/Permission.js.map +1 -1
  17. package/dist/esm/flow/DocumentCapture.d.ts +20 -5
  18. package/dist/esm/flow/DocumentCapture.d.ts.map +1 -1
  19. package/dist/esm/flow/DocumentCapture.js +191 -207
  20. package/dist/esm/flow/DocumentCapture.js.map +1 -1
  21. package/dist/esm/flow/SmartComplyFlow.d.ts.map +1 -1
  22. package/dist/esm/flow/SmartComplyFlow.js +84 -116
  23. package/dist/esm/flow/SmartComplyFlow.js.map +1 -1
  24. package/dist/esm/flow/icons.d.ts +14 -0
  25. package/dist/esm/flow/icons.d.ts.map +1 -0
  26. package/dist/esm/flow/icons.js +34 -0
  27. package/dist/esm/flow/icons.js.map +1 -0
  28. package/dist/esm/flow/spinner.d.ts +15 -0
  29. package/dist/esm/flow/spinner.d.ts.map +1 -0
  30. package/dist/esm/flow/spinner.js +55 -0
  31. package/dist/esm/flow/spinner.js.map +1 -0
  32. package/dist/esm/flow/theme.d.ts +19 -0
  33. package/dist/esm/flow/theme.d.ts.map +1 -1
  34. package/dist/esm/flow/theme.js +19 -0
  35. package/dist/esm/flow/theme.js.map +1 -1
  36. package/dist/esm/modules/liveness/LivenessUI.d.ts +10 -0
  37. package/dist/esm/modules/liveness/LivenessUI.d.ts.map +1 -1
  38. package/dist/esm/modules/liveness/LivenessUI.js +17 -16
  39. package/dist/esm/modules/liveness/LivenessUI.js.map +1 -1
  40. package/dist/esm/modules/liveness/liveness.d.ts +2 -0
  41. package/dist/esm/modules/liveness/liveness.d.ts.map +1 -1
  42. package/dist/esm/modules/liveness/liveness.js +1 -1
  43. package/dist/esm/modules/liveness/liveness.js.map +1 -1
  44. package/dist/esm/utils/constants.d.ts +11 -3
  45. package/dist/esm/utils/constants.d.ts.map +1 -1
  46. package/dist/esm/utils/constants.js +11 -3
  47. package/dist/esm/utils/constants.js.map +1 -1
  48. package/dist/esm/utils/documentRules.d.ts +15 -0
  49. package/dist/esm/utils/documentRules.d.ts.map +1 -0
  50. package/dist/esm/utils/documentRules.js +27 -0
  51. package/dist/esm/utils/documentRules.js.map +1 -0
  52. package/dist/flow/DocumentCapture.d.ts +20 -5
  53. package/dist/flow/DocumentCapture.d.ts.map +1 -1
  54. package/dist/flow/DocumentCapture.js +191 -207
  55. package/dist/flow/DocumentCapture.js.map +1 -1
  56. package/dist/flow/SmartComplyFlow.d.ts.map +1 -1
  57. package/dist/flow/SmartComplyFlow.js +86 -118
  58. package/dist/flow/SmartComplyFlow.js.map +1 -1
  59. package/dist/flow/icons.d.ts +14 -0
  60. package/dist/flow/icons.d.ts.map +1 -0
  61. package/dist/flow/icons.js +37 -0
  62. package/dist/flow/icons.js.map +1 -0
  63. package/dist/flow/spinner.d.ts +15 -0
  64. package/dist/flow/spinner.d.ts.map +1 -0
  65. package/dist/flow/spinner.js +59 -0
  66. package/dist/flow/spinner.js.map +1 -0
  67. package/dist/flow/theme.d.ts +19 -0
  68. package/dist/flow/theme.d.ts.map +1 -1
  69. package/dist/flow/theme.js +20 -1
  70. package/dist/flow/theme.js.map +1 -1
  71. package/dist/modules/liveness/LivenessUI.d.ts +10 -0
  72. package/dist/modules/liveness/LivenessUI.d.ts.map +1 -1
  73. package/dist/modules/liveness/LivenessUI.js +17 -16
  74. package/dist/modules/liveness/LivenessUI.js.map +1 -1
  75. package/dist/modules/liveness/liveness.d.ts +2 -0
  76. package/dist/modules/liveness/liveness.d.ts.map +1 -1
  77. package/dist/modules/liveness/liveness.js +1 -1
  78. package/dist/modules/liveness/liveness.js.map +1 -1
  79. package/dist/smartcomply.browser.js +376 -313
  80. package/dist/smartcomply.browser.js.map +4 -4
  81. package/dist/utils/constants.d.ts +11 -3
  82. package/dist/utils/constants.d.ts.map +1 -1
  83. package/dist/utils/constants.js +11 -3
  84. package/dist/utils/constants.js.map +1 -1
  85. package/dist/utils/documentRules.d.ts +15 -0
  86. package/dist/utils/documentRules.d.ts.map +1 -0
  87. package/dist/utils/documentRules.js +31 -0
  88. package/dist/utils/documentRules.js.map +1 -0
  89. 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.stream = null;
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 local heuristics only when absent.
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
- : this.requiresBackSide(documentType);
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
- font-size:24px;
103
+ color:${theme.primary};
110
104
  background:${theme.primary}15;
111
105
  border:1.5px solid ${theme.primary}30;
112
106
  `;
113
- iconBox.textContent = this._docIcon(documentType);
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:16px;font-weight:700;line-height:1.3;`;
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="font-size:26px;">📷</span><span>Take Photo</span><span style="font-size:10px;opacity:0.8;font-weight:500;">Use camera</span>`;
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="font-size:26px;">🖼️</span><span>From Gallery</span><span style="font-size:10px;color:${theme.textMuted || theme.textSecondary};font-weight:500;">Upload file</span>`;
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:12px;
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};font-weight:700;flex-shrink:0;">✓</span><span>${t}</span>`;
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 an appropriate emoji icon for the document type. */
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 = "position:relative;width:100%;border-radius:12px;overflow:hidden;background:#000;";
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
- // Guide overlay — rectangular
255
- const guide = document.createElement("div");
256
- guide.style.cssText = `
257
- position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
258
- pointer-events:none;
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 rect = document.createElement("div");
261
- rect.style.cssText = `
262
- width:85%;height:60%;border:2px solid rgba(255,255,255,0.6);
263
- border-radius:12px;box-shadow:0 0 0 9999px rgba(0,0,0,0.4);
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
- guide.appendChild(rect);
266
- wrapper.appendChild(guide);
267
- // Guide label
268
- const guideLbl = document.createElement("div");
269
- guideLbl.style.cssText = `
270
- position:absolute;bottom:60px;left:0;right:0;text-align:center;
271
- color:#fff;font-size:13px;font-weight:600;
272
- text-shadow:0 1px 3px rgba(0,0,0,0.5);
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
- guideLbl.textContent = `Align your ${this.getDocumentLabel(documentType)} within the frame`;
275
- wrapper.appendChild(guideLbl);
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
- let retryCount = 0;
313
- const attemptCameraAccess = async (constraints) => {
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 navigator.mediaDevices.getUserMedia(constraints);
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
- if (retryCount < maxRetries - 1) {
320
- retryCount++;
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
- this.stream = await attemptCameraAccess({
410
+ stream = await attemptCameraAccess({
330
411
  video: {
331
412
  facingMode: { ideal: "environment" },
332
- width: { ideal: 1280 },
333
- height: { ideal: 960 },
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
- this.stream = await attemptCameraAccess({
342
- video: { width: { ideal: 1280 } },
422
+ stream = await attemptCameraAccess({
423
+ video: { width: { ideal: DOC_CAMERA_IDEAL_WIDTH } },
343
424
  });
344
425
  }
345
426
  catch (fallbackErr) {
346
- const errorMessage = this.getCameraErrorMessage(fallbackErr);
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 = this.stream;
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
- const MAX_W = 1280;
376
- const MAX_H = 960;
377
- const vw = this.videoEl.videoWidth || MAX_W;
378
- const vh = this.videoEl.videoHeight || MAX_H;
379
- const ratio = Math.min(MAX_W / vw, MAX_H / vh, 1);
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 = Math.round(vw * ratio);
382
- canvas.height = Math.round(vh * ratio);
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 ~1.2MP of pixel data —
391
- // show a loading state so the capture->preview transition never
392
- // looks like a frozen/unresponsive tap on slower devices.
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.85);
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:12px;
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.textSecondary};font-size:13px;text-align:center;font-weight:500;`;
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:11px;text-align:center;line-height:1.6;`;
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
- if (this.stream) {
591
- this.stream.getTracks().forEach((t) => t.stop());
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
- // Enhanced loading spinner
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:12px;
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:12px;font-size:16px;font-weight:600;
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 = Math.min(maxWidth / srcW, maxHeight / srcH, 1);
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 = Math.min(maxWidth / w, maxHeight / h, 1);
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);