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