smartcomply-web-sdk 1.0.45 → 1.0.48

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 (43) hide show
  1. package/dist/camera/CameraManager.d.ts +7 -0
  2. package/dist/camera/CameraManager.d.ts.map +1 -1
  3. package/dist/camera/CameraManager.js +9 -0
  4. package/dist/camera/CameraManager.js.map +1 -1
  5. package/dist/esm/camera/CameraManager.d.ts +7 -0
  6. package/dist/esm/camera/CameraManager.d.ts.map +1 -1
  7. package/dist/esm/camera/CameraManager.js +9 -0
  8. package/dist/esm/camera/CameraManager.js.map +1 -1
  9. package/dist/esm/flow/SmartComplyFlow.d.ts +23 -5
  10. package/dist/esm/flow/SmartComplyFlow.d.ts.map +1 -1
  11. package/dist/esm/flow/SmartComplyFlow.js +698 -507
  12. package/dist/esm/flow/SmartComplyFlow.js.map +1 -1
  13. package/dist/esm/flow/theme.d.ts +7 -1
  14. package/dist/esm/flow/theme.d.ts.map +1 -1
  15. package/dist/esm/flow/theme.js +27 -2
  16. package/dist/esm/flow/theme.js.map +1 -1
  17. package/dist/esm/modules/liveness/LivenessUI.d.ts +1 -1
  18. package/dist/esm/modules/liveness/LivenessUI.d.ts.map +1 -1
  19. package/dist/esm/modules/liveness/LivenessUI.js +5 -5
  20. package/dist/esm/modules/liveness/LivenessUI.js.map +1 -1
  21. package/dist/esm/modules/liveness/liveness.d.ts +1 -0
  22. package/dist/esm/modules/liveness/liveness.d.ts.map +1 -1
  23. package/dist/esm/modules/liveness/liveness.js +55 -33
  24. package/dist/esm/modules/liveness/liveness.js.map +1 -1
  25. package/dist/flow/SmartComplyFlow.d.ts +23 -5
  26. package/dist/flow/SmartComplyFlow.d.ts.map +1 -1
  27. package/dist/flow/SmartComplyFlow.js +698 -507
  28. package/dist/flow/SmartComplyFlow.js.map +1 -1
  29. package/dist/flow/theme.d.ts +7 -1
  30. package/dist/flow/theme.d.ts.map +1 -1
  31. package/dist/flow/theme.js +27 -2
  32. package/dist/flow/theme.js.map +1 -1
  33. package/dist/modules/liveness/LivenessUI.d.ts +1 -1
  34. package/dist/modules/liveness/LivenessUI.d.ts.map +1 -1
  35. package/dist/modules/liveness/LivenessUI.js +5 -5
  36. package/dist/modules/liveness/LivenessUI.js.map +1 -1
  37. package/dist/modules/liveness/liveness.d.ts +1 -0
  38. package/dist/modules/liveness/liveness.d.ts.map +1 -1
  39. package/dist/modules/liveness/liveness.js +55 -33
  40. package/dist/modules/liveness/liveness.js.map +1 -1
  41. package/dist/smartcomply.browser.js +625 -430
  42. package/dist/smartcomply.browser.js.map +3 -3
  43. package/package.json +1 -1
@@ -10,22 +10,25 @@ const STEP_ORDER = [
10
10
  "welcome",
11
11
  "country",
12
12
  "id_type",
13
- "id_input", // or document_capture
13
+ "id_input",
14
+ "confirm_identity",
14
15
  "document_capture",
15
- "processing",
16
+ "ocr_gate",
17
+ "document_confirm",
16
18
  "liveness",
19
+ "done",
17
20
  "result",
18
21
  ];
19
22
  const COUNTRY_LABELS = {
20
- nigeria: "🇳🇬 Nigeria",
21
- global: "🌍 Other Countries",
22
- ghana: "🇬🇭 Ghana",
23
- kenya: "🇰🇪 Kenya",
24
- south_africa: "🇿🇦 South Africa",
25
- united_states: "🇺🇸 United States",
26
- united_kingdom: "🇬🇧 United Kingdom",
23
+ nigeria: "🇳🇬 Nigeria",
24
+ global: "🌍 Other Countries",
25
+ ghana: "🇬🇭 Ghana",
26
+ kenya: "🇰🇪 Kenya",
27
+ south_africa: "🇿🇦 South Africa",
28
+ united_states: "🇺🇸 United States",
29
+ united_kingdom: "🇬🇧 United Kingdom",
27
30
  };
28
- // Backend/admin data tags document-capture fields inconsistently — the field
31
+ // Backend/admin data tags document-capture fields inconsistently — the field
29
32
  // renderer (below) already treats "image" and "file" as file inputs alongside
30
33
  // "upload", so flow-routing decisions must recognize the same set or a channel
31
34
  // tagged "image"/"file" silently falls back to the number-entry screen.
@@ -35,7 +38,7 @@ function isUploadFieldType(type) {
35
38
  function resolveIdTypeInfo(typeName, isDocumentFlow) {
36
39
  const u = typeName.toUpperCase();
37
40
  const advanced = u.includes("ADVANCED");
38
- // Strip UI noise from display labels — users don't need "with face" or "(NIN)" suffixes
41
+ // Strip UI noise from display labels — users don't need "with face" or "(NIN)" suffixes
39
42
  const cleanName = typeName
40
43
  .replace(/\s*with\s+face\s*/gi, ' ')
41
44
  .replace(/\s*\(NIN\)\s*/gi, ' ')
@@ -50,41 +53,41 @@ function resolveIdTypeInfo(typeName, isDocumentFlow) {
50
53
  t.includes("license") ||
51
54
  t.includes("national id") ||
52
55
  t.includes("national id card");
53
- // Note: NIN document (nin_with_face, nin_slip) does NOT require back side —
56
+ // Note: NIN document (nin_with_face, nin_slip) does NOT require back side —
54
57
  // the NIN card is single-sided. Only the physical NIN slip has a back but
55
58
  // the OCR pipeline only needs the front.
56
59
  };
57
- // BVN — always 11 digits
60
+ // BVN — always 11 digits
58
61
  if (u.includes("BVN") || u.includes("BANK VERIFICATION")) {
59
- return { icon: "🏦", desc: "Enter your 11-digit Bank Verification Number (BVN)", requiresBack: false };
62
+ return { icon: "🏦", desc: "Enter your 11-digit Bank Verification Number (BVN)", requiresBack: false };
60
63
  }
61
- // NIN — always 11 digits (covers nin_with_face, NIN slip, etc.)
64
+ // NIN — always 11 digits (covers nin_with_face, NIN slip, etc.)
62
65
  if (u.includes("NIN") || u.includes("NATIONAL IDENTITY NUMBER")) {
63
- return { icon: "🆔", desc: "Enter your 11-digit National Identification Number (NIN)", requiresBack: false };
66
+ return { icon: "🆔", desc: "Enter your 11-digit National Identification Number (NIN)", requiresBack: false };
64
67
  }
65
68
  // Ghana Card / Ghana National ID
66
69
  if (u.includes("GHANA")) {
67
70
  if (isDocumentFlow)
68
- return { icon: "🪪", desc: "Capture or upload your Ghana Card", requiresBack: true };
69
- return { icon: "🪪", desc: "Enter your Ghana Card number", requiresBack: false };
71
+ return { icon: "🪪", desc: "Capture or upload your Ghana Card", requiresBack: true };
72
+ return { icon: "🪪", desc: "Enter your Ghana Card number", requiresBack: false };
70
73
  }
71
74
  // SSNIT (Ghana social security)
72
75
  if (u.includes("SSNIT"))
73
- return { icon: "🏛️", desc: "Enter your SSNIT number", requiresBack: false };
76
+ return { icon: "🏛️", desc: "Enter your SSNIT number", requiresBack: false };
74
77
  // Kenya National ID
75
78
  if (u.includes("KENYA") || (u.includes("NATIONAL") && u.includes("KENYA")))
76
- return { icon: "🆔", desc: "Enter your Kenya National ID number", requiresBack: false };
79
+ return { icon: "🆔", desc: "Enter your Kenya National ID number", requiresBack: false };
77
80
  // TIN
78
81
  if (u.includes("TIN") || u.includes("TAX IDENTIFICATION"))
79
- return { icon: "🧾", desc: "Enter your Tax Identification Number (TIN)", requiresBack: false };
82
+ return { icon: "🧾", desc: "Enter your Tax Identification Number (TIN)", requiresBack: false };
80
83
  // CAC / Company registration
81
84
  if (u.includes("CAC") || u.includes("CORPORATE AFFAIRS") || u.includes("COMPANY"))
82
- return { icon: "🏢", desc: advanced ? "Enter RC number, company name & type" : "Enter your company registration number", requiresBack: false };
85
+ return { icon: "🏢", desc: advanced ? "Enter RC number, company name & type" : "Enter your company registration number", requiresBack: false };
83
86
  // Voter / PVC
84
87
  if (u.includes("VOTER") || u.includes("PVC")) {
85
88
  const requiresBack = requiresBackSide(typeName);
86
89
  return {
87
- icon: "🗳️",
90
+ icon: "🗳️",
88
91
  desc: isDocumentFlow
89
92
  ? `Capture or upload your voter's card${requiresBack ? " (front & back)" : ""}`
90
93
  : "Enter your voter ID number",
@@ -93,12 +96,12 @@ function resolveIdTypeInfo(typeName, isDocumentFlow) {
93
96
  }
94
97
  // Passport
95
98
  if (u.includes("PASSPORT"))
96
- return { icon: "📘", desc: isDocumentFlow ? "Capture or upload your passport" : "Enter your passport number", requiresBack: false };
99
+ return { icon: "📘", desc: isDocumentFlow ? "Capture or upload your passport" : "Enter your passport number", requiresBack: false };
97
100
  // Driver's license
98
101
  if (u.includes("DRIVER") || u.includes("LICENSE") || u.includes("LICENCE")) {
99
102
  const requiresBack = requiresBackSide(typeName);
100
103
  return {
101
- icon: "🚗",
104
+ icon: "🚗",
102
105
  desc: isDocumentFlow
103
106
  ? `Capture or upload your driver's license${requiresBack ? " (front & back)" : ""}`
104
107
  : "Enter your driver's license number",
@@ -109,7 +112,7 @@ function resolveIdTypeInfo(typeName, isDocumentFlow) {
109
112
  if (u.includes("NATIONAL ID") || (u.includes("NATIONAL") && u.includes("CARD"))) {
110
113
  const requiresBack = requiresBackSide(typeName);
111
114
  return {
112
- icon: "🪪",
115
+ icon: "🪪",
113
116
  desc: isDocumentFlow
114
117
  ? `Capture or upload your ID card${requiresBack ? " (front & back)" : ""}`
115
118
  : "Enter your National ID number",
@@ -118,15 +121,15 @@ function resolveIdTypeInfo(typeName, isDocumentFlow) {
118
121
  }
119
122
  // Face liveness / comparison
120
123
  if (u.includes("LIVENESS") || u.includes("FACE COMPARISON") || u.includes("FACE MATCH"))
121
- return { icon: "🤳", desc: "We'll capture a short selfie video to verify you", requiresBack: false };
124
+ return { icon: "🤳", desc: "We'll capture a short selfie video to verify you", requiresBack: false };
122
125
  return {
123
- icon: isDocumentFlow ? "📄" : "🆔",
126
+ icon: isDocumentFlow ? "📄" : "🆔",
124
127
  desc: isDocumentFlow ? "Capture or upload your document" : "Enter your ID number",
125
128
  requiresBack: false,
126
129
  };
127
130
  }
128
- // ─────────────────────────────────────────────────────────────────────
129
- // ── Widget-side message sanitizer ─────────────────────────────────────────────
131
+ // ─────────────────────────────────────────────────────────────────────
132
+ // ── Widget-side message sanitizer ─────────────────────────────────────────────
130
133
  // Strips anything technical before showing text to the end user.
131
134
  // Keeps messages that are already written plainly in natural language.
132
135
  function _sanitizeUserMessage(raw) {
@@ -155,7 +158,56 @@ function _sanitizeUserMessage(raw) {
155
158
  return null;
156
159
  return raw.trim();
157
160
  }
158
- // ── Document type code → human label ─────────────────────────────────────────
161
+ // ── B8: OCR date string normaliser ───────────────────────────────────────────
162
+ // OCR providers return dates in wildly different formats. new Date(str) is
163
+ // locale-dependent for anything other than ISO 8601 (YYYY-MM-DD), so we
164
+ // attempt a set of known patterns before falling back to the native parser.
165
+ function _parseOcrDate(raw) {
166
+ if (!raw)
167
+ return null;
168
+ const s = raw.trim();
169
+ // ISO 8601 or near-ISO — try first, always unambiguous
170
+ if (/^\d{4}-\d{2}-\d{2}/.test(s))
171
+ return new Date(s.slice(0, 10));
172
+ // DD/MM/YYYY or DD-MM-YYYY
173
+ const dmy = s.match(/^(\d{2})[\/\-](\d{2})[\/\-](\d{4})/);
174
+ if (dmy)
175
+ return new Date(`${dmy[3]}-${dmy[2]}-${dmy[1]}`);
176
+ // MM/DD/YYYY (US format — less common from OCR but possible)
177
+ const mdy = s.match(/^(\d{2})\/(\d{2})\/(\d{4})/);
178
+ if (mdy) {
179
+ // Heuristic: if "month" > 12 it must be day-first
180
+ if (parseInt(mdy[1], 10) > 12)
181
+ return new Date(`${mdy[3]}-${mdy[2]}-${mdy[1]}`);
182
+ return new Date(`${mdy[3]}-${mdy[1]}-${mdy[2]}`);
183
+ }
184
+ // DD Mon YYYY e.g. "12 May 2029" or "12 MAY 2029"
185
+ const dMonY = s.match(/^(\d{1,2})\s+([A-Za-z]{3,})\s+(\d{4})/);
186
+ if (dMonY)
187
+ return new Date(`${dMonY[2]} ${dMonY[1]} ${dMonY[3]}`);
188
+ // Last resort: native parser
189
+ const d = new Date(s);
190
+ return isNaN(d.getTime()) ? null : d;
191
+ }
192
+ // ── W12: Image compression before document upload ────────────────────────────
193
+ async function _compressImage(blob, maxWidth = 1600, quality = 0.85) {
194
+ try {
195
+ const bmp = await createImageBitmap(blob);
196
+ const scale = Math.min(1, maxWidth / bmp.width);
197
+ const w = Math.round(bmp.width * scale);
198
+ const h = Math.round(bmp.height * scale);
199
+ const canvas = document.createElement("canvas");
200
+ canvas.width = w;
201
+ canvas.height = h;
202
+ canvas.getContext("2d").drawImage(bmp, 0, 0, w, h);
203
+ bmp.close();
204
+ return await new Promise((res, rej) => canvas.toBlob(b => b ? res(b) : rej(new Error("Compression failed")), "image/jpeg", quality));
205
+ }
206
+ catch {
207
+ return blob; // fallback to original on any error
208
+ }
209
+ }
210
+ // ── Document type code → human label ─────────────────────────────────────────
159
211
  function _formatDocType(code) {
160
212
  const map = {
161
213
  passport: "Passport",
@@ -176,7 +228,7 @@ function _formatDocType(code) {
176
228
  return code.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
177
229
  }
178
230
  // SmartComplyFlow
179
- // ─────────────────────────────────────────────────────────────────────
231
+ // ─────────────────────────────────────────────────────────────────────
180
232
  // Add CSS animation keyframes for spin animations
181
233
  const style = document.createElement('style');
182
234
  style.textContent = `
@@ -198,6 +250,7 @@ class SmartComplyFlow {
198
250
  this.contentArea = null;
199
251
  this.progressBar = null;
200
252
  this.stepLabel = null;
253
+ this.stageDots = null;
201
254
  this.brandLabel = null;
202
255
  // State
203
256
  this.sdkConfig = null;
@@ -217,13 +270,31 @@ class SmartComplyFlow {
217
270
  this.livenessEntryId = null;
218
271
  this.isDestroyed = false;
219
272
  this.headerBackBtn = null;
273
+ // Retry safety — max 3 confirmation rejections or liveness failures per session
274
+ this._confirmRetries = 0;
275
+ this._livenessFailures = 0;
276
+ // IVS result stored for confirm_identity screen
277
+ this._ivsName = "";
278
+ this._ivsGender = "";
279
+ // OCR result stored for document_confirm screen
280
+ this._ocrName = "";
281
+ this._ocrDocNumber = "";
282
+ this._ocrExpiry = "";
283
+ this._ocrFaceImageUrl = "";
284
+ this._ocrIsExpired = false;
285
+ this._ocrExpirySoon = false;
286
+ // Camera pre-warm stream (created during confirmation screen, used in liveness)
287
+ this._prewarmedStream = null;
288
+ // OCR gate poll timer — stored so it can be cancelled on navigation or close
289
+ this._ocrPollTimer = null;
290
+ // UI timeout from _startStatusPolling — stored so close() can cancel it
291
+ this._uiTimeoutId = null;
220
292
  // Session expiry
221
293
  this.sessionExpiryTimer = null;
222
294
  // Status polling (result screen)
223
295
  this._pollStatusTimer = null;
224
296
  // Components
225
297
  this.docCapture = null;
226
- this._livenessSubmitResult = null;
227
298
  this.options = options;
228
299
  this.sdk = new Smartcomply_1.SmartComply({
229
300
  apiKey: options.apiKey,
@@ -257,6 +328,11 @@ class SmartComplyFlow {
257
328
  close() {
258
329
  this.isDestroyed = true;
259
330
  this.docCapture?.destroy();
331
+ // Release any pre-warmed camera stream
332
+ if (this._prewarmedStream) {
333
+ this._prewarmedStream.getTracks().forEach(t => t.stop());
334
+ this._prewarmedStream = null;
335
+ }
260
336
  if (this.sessionExpiryTimer) {
261
337
  clearTimeout(this.sessionExpiryTimer);
262
338
  this.sessionExpiryTimer = null;
@@ -265,13 +341,23 @@ class SmartComplyFlow {
265
341
  clearTimeout(this._pollStatusTimer);
266
342
  this._pollStatusTimer = null;
267
343
  }
344
+ // B6: cancel OCR gate poll timer
345
+ if (this._ocrPollTimer) {
346
+ clearTimeout(this._ocrPollTimer);
347
+ this._ocrPollTimer = null;
348
+ }
349
+ // P4: cancel result-screen UI timeout
350
+ if (this._uiTimeoutId) {
351
+ clearTimeout(this._uiTimeoutId);
352
+ this._uiTimeoutId = null;
353
+ }
268
354
  if (this.overlay) {
269
355
  this.overlay.style.opacity = "0";
270
356
  setTimeout(() => {
271
357
  try {
272
358
  this.overlay?.remove();
273
359
  }
274
- catch (_) { /* extension interference — safe to ignore */ }
360
+ catch (_) { /* extension interference — safe to ignore */ }
275
361
  this.overlay = null;
276
362
  this.modal = null;
277
363
  this.contentArea = null;
@@ -279,9 +365,9 @@ class SmartComplyFlow {
279
365
  }
280
366
  this.options.onClose?.();
281
367
  }
282
- // ─────────────────────────────────────────────────────────────────
368
+ // ─────────────────────────────────────────────────────────────────
283
369
  // Initialization
284
- // ─────────────────────────────────────────────────────────────────
370
+ // ─────────────────────────────────────────────────────────────────
285
371
  mount() {
286
372
  // Inject keyframes
287
373
  this.injectStyles();
@@ -366,12 +452,12 @@ class SmartComplyFlow {
366
452
  }
367
453
  // Load config
368
454
  this.sdkConfig = await this.sdk.initializeConfig();
369
- // Apply theme from config
455
+ // Apply theme from config — use theme_hex to override primary colour if set
370
456
  if (this.sdkConfig.theme) {
371
- this.theme = (0, theme_1.getTheme)(this.sdkConfig.theme);
457
+ this.theme = (0, theme_1.getTheme)(this.sdkConfig.theme, this.sdkConfig.theme_hex || null);
372
458
  this.applyTheme();
373
459
  }
374
- // Update brand name
460
+ // Update brand name in header
375
461
  if (this.brandLabel && this.sdkConfig.brand_name) {
376
462
  this.brandLabel.textContent = this.sdkConfig.brand_name;
377
463
  }
@@ -383,13 +469,26 @@ class SmartComplyFlow {
383
469
  this.showErrorStep(err.message || "Failed to initialize. Please try again.");
384
470
  }
385
471
  }
386
- // ─────────────────────────────────────────────────────────────────
472
+ // ─────────────────────────────────────────────────────────────────
387
473
  // Step rendering
388
- // ─────────────────────────────────────────────────────────────────
474
+ // ─────────────────────────────────────────────────────────────────
389
475
  showStep(step) {
390
476
  this.currentStep = step;
391
477
  if (!this.contentArea)
392
478
  return;
479
+ // P1/B6: cancel any in-flight OCR gate poll when user navigates away
480
+ if (this._ocrPollTimer) {
481
+ clearTimeout(this._ocrPollTimer);
482
+ this._ocrPollTimer = null;
483
+ }
484
+ // Release a pre-warmed camera stream if we're navigating anywhere other
485
+ // than liveness (the only step that consumes it) — otherwise "No, use a
486
+ // different ID" / "No, retake document" / header back-button leave the
487
+ // camera held open indefinitely with no consumer.
488
+ if (step !== "liveness" && this._prewarmedStream) {
489
+ this._prewarmedStream.getTracks().forEach(t => t.stop());
490
+ this._prewarmedStream = null;
491
+ }
393
492
  // Update progress
394
493
  this.updateProgress();
395
494
  // Animate out current content
@@ -427,20 +526,32 @@ class SmartComplyFlow {
427
526
  case "id_input":
428
527
  this.renderIdInput(wrapper);
429
528
  break;
529
+ case "confirm_identity":
530
+ this.renderConfirmIdentity(wrapper);
531
+ break;
430
532
  case "document_capture":
431
533
  this.renderDocumentCapture(wrapper);
432
534
  break;
535
+ case "ocr_gate":
536
+ this.renderOcrGate(wrapper);
537
+ break;
538
+ case "document_confirm":
539
+ this.renderDocumentConfirm(wrapper);
540
+ break;
433
541
  case "processing":
434
- this.renderProcessing(wrapper, this.livenessEntryId || undefined);
542
+ // C1: processing step is dead code — flow now goes to 'done' after submit
435
543
  break;
436
544
  case "liveness":
437
545
  this.renderLiveness(wrapper);
438
546
  break;
547
+ case "done":
548
+ this.renderDone(wrapper);
549
+ break;
439
550
  case "result":
440
- this.renderResult(wrapper);
551
+ // C2: result step is dead code — showResult/renderResult removed
441
552
  break;
442
553
  case "error":
443
- break; // handled separately
554
+ break;
444
555
  }
445
556
  this.contentArea.appendChild(wrapper);
446
557
  // Animate in
@@ -449,7 +560,7 @@ class SmartComplyFlow {
449
560
  wrapper.style.transform = "translateX(0)";
450
561
  });
451
562
  }
452
- // ── Loading ─────────────────────────────────────────────────────
563
+ // ── Loading ─────────────────────────────────────────────────────
453
564
  renderLoading(container) {
454
565
  container.style.cssText += "display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:300px;gap:16px;";
455
566
  const spinnerWrap = document.createElement("div");
@@ -480,7 +591,7 @@ class SmartComplyFlow {
480
591
  sub.textContent = "Connecting to the verification service...";
481
592
  container.appendChild(sub);
482
593
  }
483
- // ── Welcome ────────────────────────────────────────────────────
594
+ // ── Welcome ────────────────────────────────────────────────────
484
595
  renderWelcome(container) {
485
596
  container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:12px;";
486
597
  const isDocFlow = this.isDocumentFlow();
@@ -495,7 +606,7 @@ class SmartComplyFlow {
495
606
  background:linear-gradient(135deg,${this.theme.primary}22,${this.theme.primaryGlow});
496
607
  border:1.5px solid ${this.theme.primary}33;
497
608
  `;
498
- iconWrap.textContent = isDocFlow ? "🪪" : "🛡️";
609
+ iconWrap.textContent = isDocFlow ? "🪪" : "🛡️";
499
610
  topRow.appendChild(iconWrap);
500
611
  const badge = document.createElement("div");
501
612
  badge.style.cssText = `
@@ -506,7 +617,7 @@ class SmartComplyFlow {
506
617
  color:${this.theme.primary};
507
618
  border:1px solid ${this.theme.primary}30;
508
619
  `;
509
- badge.textContent = isDocFlow ? "📄 Document Verification" : "🔍 Data Verification";
620
+ badge.textContent = isDocFlow ? "📄 Document Verification" : "🔍 Data Verification";
510
621
  topRow.appendChild(badge);
511
622
  container.appendChild(topRow);
512
623
  // Title + subtitle (compact)
@@ -517,7 +628,11 @@ class SmartComplyFlow {
517
628
  color:${this.theme.text};font-size:20px;font-weight:700;
518
629
  margin:0;line-height:1.2;
519
630
  `;
520
- title.textContent = "Identity Verification";
631
+ // W2: Use client brand name in title
632
+ const brandName = this.sdkConfig?.brand_name;
633
+ title.textContent = brandName
634
+ ? `${brandName} Identity Verification`
635
+ : "Verify Your Identity";
521
636
  titleWrap.appendChild(title);
522
637
  const desc = document.createElement("p");
523
638
  desc.style.cssText = `
@@ -525,21 +640,21 @@ class SmartComplyFlow {
525
640
  margin:0;max-width:300px;
526
641
  `;
527
642
  desc.textContent = isDocFlow
528
- ? "Scan your ID document and take a quick selfie to confirm your identity."
529
- : "Enter your ID details and take a quick selfie to confirm your identity.";
643
+ ? "You'll need your document and camera access. This takes about 2 minutes."
644
+ : "You'll need your ID number and camera access. This takes about 2 minutes.";
530
645
  titleWrap.appendChild(desc);
531
646
  container.appendChild(titleWrap);
532
- // Steps preview — compact rows
647
+ // Steps preview — compact rows
533
648
  const steps = isDocFlow
534
649
  ? [
535
- { icon: "🪪", text: "Choose your document type", sub: "Passport, National ID, Driver License" },
536
- { icon: "📷", text: "Scan your document", sub: "Upload or capture a photo of your ID" },
537
- { icon: "🤳", text: "Face check", sub: "Quick selfie to confirm it's you" },
650
+ { icon: "🪪", text: "Choose your document type", sub: "Passport, National ID, Driver License" },
651
+ { icon: "📷", text: "Scan your document", sub: "Upload or capture a photo of your ID" },
652
+ { icon: "🤳", text: "Face check", sub: "Quick selfie to confirm it's you" },
538
653
  ]
539
654
  : [
540
- { icon: "🆔", text: "Choose your ID type", sub: "NIN, BVN, Voter's Card and more" },
541
- { icon: "📝", text: "Enter your ID details", sub: "Exactly as shown on your ID" },
542
- { icon: "🤳", text: "Face check", sub: "Quick selfie to confirm it's you" },
655
+ { icon: "🆔", text: "Choose your ID type", sub: "NIN, BVN, Voter's Card and more" },
656
+ { icon: "📝", text: "Enter your ID details", sub: "Exactly as shown on your ID" },
657
+ { icon: "🤳", text: "Face check", sub: "Quick selfie to confirm it's you" },
543
658
  ];
544
659
  const stepsWrap = document.createElement("div");
545
660
  stepsWrap.style.cssText = `
@@ -580,21 +695,21 @@ class SmartComplyFlow {
580
695
  });
581
696
  container.appendChild(stepsWrap);
582
697
  // CTA
583
- const btn = this.createPrimaryButton("Get Started →");
698
+ const btn = this.createPrimaryButton("Get Started →");
584
699
  btn.style.cssText += "margin-top:2px;";
585
700
  btn.addEventListener("click", () => this.showStep("country"));
586
701
  container.appendChild(btn);
587
- // Trust badge — "Powered by Adhere" already lives in the persistent
702
+ // Trust badge — "Powered by Adhere" already lives in the persistent
588
703
  // modal footer below; repeating it here just cost vertical space.
589
704
  const trust = document.createElement("div");
590
705
  trust.style.cssText = `
591
706
  display:flex;align-items:center;gap:6px;
592
707
  color:${this.theme.textMuted};font-size:10.5px;
593
708
  `;
594
- trust.textContent = "🔒 End-to-end encrypted";
709
+ trust.textContent = "🔒 End-to-end encrypted";
595
710
  container.appendChild(trust);
596
711
  }
597
- // ── Country Select ──────────────────────────────────────────────
712
+ // ── Country Select ──────────────────────────────────────────────
598
713
  renderCountrySelect(container) {
599
714
  container.style.cssText += "display:flex;flex-direction:column;gap:14px;";
600
715
  const channels = this.sdkConfig?.channels || {};
@@ -619,7 +734,7 @@ class SmartComplyFlow {
619
734
  container.appendChild(card);
620
735
  }
621
736
  }
622
- // ── ID Type Select ──────────────────────────────────────────────
737
+ // ── ID Type Select ──────────────────────────────────────────────
623
738
  renderIdTypeSelect(container) {
624
739
  container.style.cssText += "display:flex;flex-direction:column;gap:14px;";
625
740
  const globalDocFlow = this.isDocumentFlow();
@@ -647,9 +762,9 @@ class SmartComplyFlow {
647
762
  // Add back side indicator to subtitle if needed
648
763
  let subtitle = info.desc;
649
764
  if (requiresBack && isThisDocFlow) {
650
- subtitle += " ⚠️ Both sides required";
765
+ subtitle += " ⚠️ Both sides required";
651
766
  }
652
- // Clean display name — strip "with face", "(NIN)", "(BVN)" from user-visible label
767
+ // Clean display name — strip "with face", "(NIN)", "(BVN)" from user-visible label
653
768
  const displayName = typeName
654
769
  .replace(/\s*with\s+face\s*/gi, ' ')
655
770
  .replace(/\s*\(NIN\)\s*/gi, ' ')
@@ -686,7 +801,7 @@ class SmartComplyFlow {
686
801
  container.appendChild(card);
687
802
  }
688
803
  }
689
- // ── ID Input (data verification — dynamic fields) ─────────────
804
+ // ── ID Input (data verification — dynamic fields) ─────────────
690
805
  renderIdInput(container) {
691
806
  container.style.cssText += "display:flex;flex-direction:column;gap:14px;";
692
807
  const dataFields = this.selectedChannelFields.filter((f) => f.type !== "upload");
@@ -705,7 +820,7 @@ class SmartComplyFlow {
705
820
  ? `Enter your ${this.selectedIdTypeName || this.selectedIdType} details exactly as they appear on your ID.`
706
821
  : "Fill in the fields below to continue.";
707
822
  container.appendChild(subtitle);
708
- // Edge case: no data fields configured — show fallback and allow submission
823
+ // Edge case: no data fields configured — show fallback and allow submission
709
824
  if (dataFields.length === 0) {
710
825
  const empty = document.createElement("div");
711
826
  empty.style.cssText = `
@@ -724,13 +839,13 @@ class SmartComplyFlow {
724
839
  { match: "BVN", rule: { minLen: 11, maxLen: 11, numeric: true, hint: "BVN must be exactly 11 digits" } },
725
840
  { match: "NIN", rule: { minLen: 11, maxLen: 11, numeric: true, hint: "NIN must be exactly 11 digits" } },
726
841
  { match: "NATIONAL IDENTITY", rule: { minLen: 11, maxLen: 11, numeric: true, hint: "NIN must be exactly 11 digits" } },
727
- { match: "PASSPORT", rule: { minLen: 6, maxLen: 20, alphanumeric: true, hint: "Passport number must be 6–20 alphanumeric characters" } },
728
- { match: "DRIVER", rule: { minLen: 3, maxLen: 30, alphanumeric: true, hint: "Licence number must be 3–30 alphanumeric characters" } },
729
- { match: "VOTER", rule: { minLen: 6, maxLen: 30, alphanumeric: true, hint: "Voter ID must be 6–30 alphanumeric characters" } },
730
- { match: "GHANA", rule: { minLen: 8, maxLen: 20, alphanumeric: true, hint: "Ghana Card number must be 8–20 alphanumeric characters" } },
731
- { match: "SSNIT", rule: { minLen: 7, maxLen: 15, alphanumeric: true, hint: "SSNIT number must be 7–15 alphanumeric characters" } },
732
- { match: "TIN", rule: { minLen: 8, maxLen: 20, alphanumeric: true, hint: "TIN must be 8–20 characters" } },
733
- { match: "KRA", rule: { minLen: 10, maxLen: 12, alphanumeric: true, hint: "KRA PIN must be 10–12 characters" } },
842
+ { match: "PASSPORT", rule: { minLen: 6, maxLen: 20, alphanumeric: true, hint: "Passport number must be 6–20 alphanumeric characters" } },
843
+ { match: "DRIVER", rule: { minLen: 3, maxLen: 30, alphanumeric: true, hint: "Licence number must be 3–30 alphanumeric characters" } },
844
+ { match: "VOTER", rule: { minLen: 6, maxLen: 30, alphanumeric: true, hint: "Voter ID must be 6–30 alphanumeric characters" } },
845
+ { match: "GHANA", rule: { minLen: 8, maxLen: 20, alphanumeric: true, hint: "Ghana Card number must be 8–20 alphanumeric characters" } },
846
+ { match: "SSNIT", rule: { minLen: 7, maxLen: 15, alphanumeric: true, hint: "SSNIT number must be 7–15 alphanumeric characters" } },
847
+ { match: "TIN", rule: { minLen: 8, maxLen: 20, alphanumeric: true, hint: "TIN must be 8–20 characters" } },
848
+ { match: "KRA", rule: { minLen: 10, maxLen: 12, alphanumeric: true, hint: "KRA PIN must be 10–12 characters" } },
734
849
  ];
735
850
  function getFieldRule(label) {
736
851
  const upper = label.toUpperCase();
@@ -758,7 +873,7 @@ class SmartComplyFlow {
758
873
  // Track input elements for validation
759
874
  const inputEls = [];
760
875
  for (const field of dataFields) {
761
- // Send the raw label as the payload key — the backend's
876
+ // Send the raw label as the payload key — the backend's
762
877
  // _SDK_FIELD_KEY_MAP looks up the exact label text (e.g. "National ID",
763
878
  // "CAC Number") to remap it to the canonical IVS key. Pre-normalizing
764
879
  // it here (as this used to do) produced keys like "national_id" that
@@ -808,7 +923,7 @@ class SmartComplyFlow {
808
923
  inputEls.push({ field, el: select });
809
924
  }
810
925
  else if (field.type === "image" || field.type === "file") {
811
- // File upload field — converts to base64 for API submission
926
+ // File upload field — converts to base64 for API submission
812
927
  const fileInput = document.createElement("input");
813
928
  fileInput.type = "file";
814
929
  fileInput.accept = "image/*";
@@ -948,12 +1063,25 @@ class SmartComplyFlow {
948
1063
  fields: { ...this.collectedFields },
949
1064
  });
950
1065
  if (this.verificationResult.status === "success") {
951
- // Save the first collected field value as identifier for liveness
1066
+ // Save identifier and identity_check_id for liveness step
952
1067
  const fieldValues = Object.values(this.collectedFields);
953
1068
  this.idNumber = fieldValues[0] || "";
954
- // A4: Save identity_check_id from verify response to pass to liveness
955
1069
  this.identityCheckId = this.verificationResult.data?.identity_check_id || null;
956
- this.showStep("liveness");
1070
+ // W3: Store name + gender from IVS result for confirmation screen
1071
+ const d = this.verificationResult.data || {};
1072
+ const nd = d.normalized_data || d.data || d;
1073
+ this._ivsName = [nd.first_name || nd.firstname || "", nd.last_name || nd.lastname || nd.surname || ""]
1074
+ .map(s => String(s || "").trim()).filter(Boolean).join(" ");
1075
+ this._ivsGender = nd.gender || "";
1076
+ // W4: If IVS returned no name, there is nothing for the user to confirm —
1077
+ // auto-proceed straight to Stage 2 (liveness) instead of showing a
1078
+ // confirmation screen with no identity details to check.
1079
+ if (!this._ivsName) {
1080
+ this.showStep("liveness");
1081
+ return;
1082
+ }
1083
+ // Route to confirmation screen — user must confirm before liveness
1084
+ this.showStep("confirm_identity");
957
1085
  }
958
1086
  else {
959
1087
  errorEl.textContent = this.verificationResult.message || "We could not verify your details. Please check and try again.";
@@ -964,10 +1092,10 @@ class SmartComplyFlow {
964
1092
  }
965
1093
  }
966
1094
  catch (err) {
967
- // Show a single plain-language error — never raw field:message format
1095
+ // Show a single plain-language error — never raw field:message format
968
1096
  const fieldErrors = err.data?.errors;
969
1097
  if (Array.isArray(fieldErrors) && fieldErrors.length > 0) {
970
- // Pick the first meaningful error message — strip field prefix
1098
+ // Pick the first meaningful error message — strip field prefix
971
1099
  const firstMsg = fieldErrors[0]?.message || fieldErrors[0]?.detail || "";
972
1100
  errorEl.textContent = _sanitizeUserMessage(firstMsg)
973
1101
  || "Please check your details and try again.";
@@ -998,7 +1126,7 @@ class SmartComplyFlow {
998
1126
  setTimeout(() => firstInput.focus(), 350);
999
1127
  }
1000
1128
  }
1001
- // ── Document Capture ────────────────────────────────────────────
1129
+ // ── Document Capture ────────────────────────────────────────────
1002
1130
  renderDocumentCapture(container) {
1003
1131
  container.style.cssText += "display:flex;flex-direction:column;gap:14px;";
1004
1132
  // Strip "with face", "(NIN)", "(BVN)" noise from the user-visible title
@@ -1039,105 +1167,201 @@ class SmartComplyFlow {
1039
1167
  this.showErrorStep(err.message || "Document capture failed. Please try again.");
1040
1168
  });
1041
1169
  }
1042
- // ── Processing (real polling-driven, document flow only) ────────────
1043
- renderProcessing(container, entryId) {
1044
- container.style.cssText += "display:flex;flex-direction:column;align-items:center;gap:16px;padding:16px;";
1045
- // Spinner
1170
+ // ── W3: Identity Confirmation (data verification) ───────────────
1171
+ renderConfirmIdentity(container) {
1172
+ container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:16px;padding:8px 0;";
1173
+ // W14: Check retry limit before showing screen
1174
+ if (this._confirmRetries >= SmartComplyFlow.MAX_RETRIES) {
1175
+ this._renderRetryLimitReached(container);
1176
+ return;
1177
+ }
1178
+ // W13: Pre-warm camera silently while user reads this screen
1179
+ this._prewarmCamera();
1180
+ const iconWrap = document.createElement("div");
1181
+ iconWrap.style.cssText = `width:64px;height:64px;border-radius:50%;background:${this.theme.primary}15;border:2px solid ${this.theme.primary}30;display:flex;align-items:center;justify-content:center;font-size:28px;`;
1182
+ iconWrap.textContent = "🛡️";
1183
+ container.appendChild(iconWrap);
1184
+ const title = document.createElement("h2");
1185
+ title.style.cssText = `color:${this.theme.text};font-size:18px;font-weight:700;margin:0;`;
1186
+ title.textContent = this._ivsName ? `Is this you?` : "Identity Confirmed";
1187
+ container.appendChild(title);
1188
+ if (this._ivsName) {
1189
+ // Profile card
1190
+ const card = document.createElement("div");
1191
+ card.style.cssText = `width:100%;padding:16px;border-radius:14px;background:${this.theme.cardBg};border:1.5px solid ${this.theme.border};text-align:left;display:flex;flex-direction:column;gap:10px;`;
1192
+ const rows = [
1193
+ { label: "Name", value: this._ivsName },
1194
+ ];
1195
+ if (this._ivsGender) {
1196
+ const gLabel = this._ivsGender.toLowerCase();
1197
+ rows.push({ label: "Gender", value: gLabel === "m" || gLabel === "male" || gLabel === "1" ? "Male" : gLabel === "f" || gLabel === "female" || gLabel === "2" ? "Female" : this._ivsGender });
1198
+ }
1199
+ rows.push({ label: "ID Type", value: _formatDocType(this.selectedIdType) || this.selectedIdTypeName || this.selectedIdType });
1200
+ rows.forEach(({ label, value }) => {
1201
+ const row = document.createElement("div");
1202
+ row.style.cssText = "display:flex;justify-content:space-between;align-items:center;";
1203
+ const lEl = document.createElement("span");
1204
+ lEl.style.cssText = `color:${this.theme.textMuted};font-size:12px;font-weight:500;`;
1205
+ lEl.textContent = label;
1206
+ const vEl = document.createElement("span");
1207
+ vEl.style.cssText = `color:${this.theme.text};font-size:13px;font-weight:600;max-width:60%;text-align:right;`;
1208
+ vEl.textContent = value;
1209
+ row.appendChild(lEl);
1210
+ row.appendChild(vEl);
1211
+ card.appendChild(row);
1212
+ });
1213
+ container.appendChild(card);
1214
+ }
1215
+ else {
1216
+ const sub = document.createElement("p");
1217
+ sub.style.cssText = `color:${this.theme.textSecondary};font-size:13px;margin:0;max-width:280px;line-height:1.6;`;
1218
+ sub.textContent = "Your details have been confirmed. Please continue to the face check.";
1219
+ container.appendChild(sub);
1220
+ }
1221
+ const yesBtn = this.createPrimaryButton("Yes, that's me — continue");
1222
+ yesBtn.addEventListener("click", () => {
1223
+ // B5: Reset retry counter on successful confirmation
1224
+ this._confirmRetries = 0;
1225
+ this.showStep("liveness");
1226
+ });
1227
+ container.appendChild(yesBtn);
1228
+ const noBtn = this.createSecondaryButton("No, use a different ID");
1229
+ noBtn.addEventListener("click", () => {
1230
+ this._confirmRetries++;
1231
+ this._ivsName = "";
1232
+ this._ivsGender = "";
1233
+ this.identityCheckId = null;
1234
+ this.verificationResult = null;
1235
+ this.showStep("id_input");
1236
+ });
1237
+ container.appendChild(noBtn);
1238
+ }
1239
+ // ── W4: Document Confirmation (document verification) ────────────
1240
+ renderDocumentConfirm(container) {
1241
+ container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:14px;padding:8px 0;";
1242
+ // W14: Check retry limit
1243
+ if (this._confirmRetries >= SmartComplyFlow.MAX_RETRIES) {
1244
+ this._renderRetryLimitReached(container);
1245
+ return;
1246
+ }
1247
+ // W13: Pre-warm camera while user reads
1248
+ this._prewarmCamera();
1249
+ const iconWrap = document.createElement("div");
1250
+ iconWrap.style.cssText = `width:64px;height:64px;border-radius:50%;background:${this.theme.primary}15;border:2px solid ${this.theme.primary}30;display:flex;align-items:center;justify-content:center;font-size:28px;`;
1251
+ iconWrap.textContent = "📄";
1252
+ container.appendChild(iconWrap);
1253
+ const title = document.createElement("h2");
1254
+ title.style.cssText = `color:${this.theme.text};font-size:18px;font-weight:700;margin:0;`;
1255
+ title.textContent = "Are these your details?";
1256
+ container.appendChild(title);
1257
+ const sub = document.createElement("p");
1258
+ sub.style.cssText = `color:${this.theme.textSecondary};font-size:13px;margin:0;max-width:280px;line-height:1.5;`;
1259
+ sub.textContent = "We read the following from your document. Please confirm they are correct before continuing.";
1260
+ container.appendChild(sub);
1261
+ // Document face thumbnail if available
1262
+ if (this._ocrFaceImageUrl) {
1263
+ const faceWrap = document.createElement("div");
1264
+ faceWrap.style.cssText = `width:80px;height:80px;border-radius:50%;overflow:hidden;border:3px solid ${this.theme.primary};flex-shrink:0;margin:0 auto;`;
1265
+ const faceImg = document.createElement("img");
1266
+ faceImg.src = this._ocrFaceImageUrl;
1267
+ faceImg.alt = "Photo from your document";
1268
+ faceImg.style.cssText = "width:100%;height:100%;object-fit:cover;";
1269
+ faceWrap.appendChild(faceImg);
1270
+ container.appendChild(faceWrap);
1271
+ }
1272
+ // Expiry soon warning
1273
+ if (this._ocrExpirySoon && !this._ocrIsExpired) {
1274
+ const warn = document.createElement("div");
1275
+ warn.style.cssText = `width:100%;padding:10px 14px;border-radius:10px;background:${this.theme.warning}18;border:1px solid ${this.theme.warning}40;color:${this.theme.warning};font-size:12px;text-align:left;line-height:1.5;`;
1276
+ warn.textContent = "⚠️ Your document expires soon. Some services may not accept it, but you can still continue.";
1277
+ container.appendChild(warn);
1278
+ }
1279
+ // Details card — show whatever OCR returned, never block on missing fields
1280
+ const card = document.createElement("div");
1281
+ card.style.cssText = `width:100%;padding:14px 16px;border-radius:14px;background:${this.theme.cardBg};border:1.5px solid ${this.theme.border};text-align:left;display:flex;flex-direction:column;gap:10px;`;
1282
+ const docRows = [];
1283
+ if (this._ocrName)
1284
+ docRows.push({ label: "Name", value: this._ocrName });
1285
+ if (this._ocrDocNumber)
1286
+ docRows.push({ label: "Document No", value: this._ocrDocNumber });
1287
+ if (this._ocrExpiry)
1288
+ docRows.push({ label: "Expires", value: this._ocrExpiry });
1289
+ docRows.push({ label: "Document Type", value: _formatDocType(this.selectedIdType) || this.selectedIdTypeName });
1290
+ if (docRows.length === 0) {
1291
+ const fallback = document.createElement("div");
1292
+ fallback.style.cssText = `color:${this.theme.textSecondary};font-size:12px;`;
1293
+ fallback.textContent = "Document scanned successfully.";
1294
+ card.appendChild(fallback);
1295
+ }
1296
+ else {
1297
+ docRows.forEach(({ label, value }) => {
1298
+ if (!value)
1299
+ return;
1300
+ const row = document.createElement("div");
1301
+ row.style.cssText = "display:flex;justify-content:space-between;align-items:center;";
1302
+ const lEl = document.createElement("span");
1303
+ lEl.style.cssText = `color:${this.theme.textMuted};font-size:12px;font-weight:500;`;
1304
+ lEl.textContent = label;
1305
+ const vEl = document.createElement("span");
1306
+ vEl.style.cssText = `color:${this.theme.text};font-size:13px;font-weight:600;max-width:60%;text-align:right;`;
1307
+ vEl.textContent = value;
1308
+ row.appendChild(lEl);
1309
+ row.appendChild(vEl);
1310
+ card.appendChild(row);
1311
+ });
1312
+ }
1313
+ container.appendChild(card);
1314
+ const yesBtn = this.createPrimaryButton("Yes, continue");
1315
+ yesBtn.addEventListener("click", () => {
1316
+ // B5: Reset retry counter on successful confirmation
1317
+ this._confirmRetries = 0;
1318
+ this.showStep("liveness");
1319
+ });
1320
+ container.appendChild(yesBtn);
1321
+ const noBtn = this.createSecondaryButton("No, retake document");
1322
+ noBtn.addEventListener("click", () => {
1323
+ this._confirmRetries++;
1324
+ this._ocrName = "";
1325
+ this._ocrDocNumber = "";
1326
+ this._ocrExpiry = "";
1327
+ this._ocrFaceImageUrl = "";
1328
+ this._ocrIsExpired = false;
1329
+ this._ocrExpirySoon = false;
1330
+ this.livenessEntryId = null;
1331
+ this.documentBlob = null;
1332
+ this.documentBackBlob = null;
1333
+ this.showStep("document_capture");
1334
+ });
1335
+ container.appendChild(noBtn);
1336
+ }
1337
+ // ── W6+W7+W8: OCR Gate (document verification) ───────────────────
1338
+ renderOcrGate(container) {
1339
+ container.style.cssText += "display:flex;flex-direction:column;align-items:center;gap:14px;padding:20px 0;text-align:center;";
1046
1340
  const spinnerWrap = document.createElement("div");
1047
- spinnerWrap.style.cssText = "position:relative;width:56px;height:56px;flex-shrink:0;";
1341
+ spinnerWrap.style.cssText = "position:relative;width:52px;height:52px;flex-shrink:0;";
1048
1342
  const ring = document.createElement("div");
1049
- ring.id = "sc-proc-ring";
1050
1343
  ring.style.cssText = `position:absolute;inset:0;border-radius:50%;border:3px solid ${this.theme.border};border-top-color:${this.theme.primary};animation:sc-spin 0.9s linear infinite;`;
1051
- const centerIcon = document.createElement("div");
1052
- centerIcon.style.cssText = "position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:22px;";
1053
- centerIcon.textContent = "🔍";
1344
+ const centre = document.createElement("div");
1345
+ centre.style.cssText = "position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:20px;";
1346
+ centre.textContent = "📄";
1054
1347
  spinnerWrap.appendChild(ring);
1055
- spinnerWrap.appendChild(centerIcon);
1348
+ spinnerWrap.appendChild(centre);
1056
1349
  container.appendChild(spinnerWrap);
1057
- const title = document.createElement("div");
1058
- title.style.cssText = `color:${this.theme.text};font-size:15px;font-weight:700;text-align:center;`;
1059
- title.textContent = "Verifying your document";
1060
- container.appendChild(title);
1061
- // Live status line — updated by polling
1062
- const statusLine = document.createElement("div");
1063
- statusLine.id = "sc-proc-status";
1064
- statusLine.setAttribute("aria-live", "polite");
1065
- statusLine.setAttribute("aria-atomic", "true");
1066
- statusLine.style.cssText = `color:${this.theme.textSecondary};font-size:12px;text-align:center;max-width:260px;line-height:1.5;`;
1067
- statusLine.textContent = "Scanning document…";
1068
- container.appendChild(statusLine);
1069
- // Step checklist — reflects real pipeline stages
1070
- const stages = [
1071
- { id: "ocr", label: "Reading document", icon: "📄" },
1072
- { id: "expiry", label: "Checking expiry", icon: "📅" },
1073
- { id: "face", label: "Face match", icon: "🤳" },
1074
- { id: "result", label: "Finalising result", icon: "✓" },
1075
- ];
1076
- const grid = document.createElement("div");
1077
- grid.style.cssText = "width:100%;display:grid;grid-template-columns:1fr 1fr;gap:8px;";
1078
- stages.forEach((s) => {
1079
- const el = document.createElement("div");
1080
- el.id = `sc-stage-${s.id}`;
1081
- el.style.cssText = `display:flex;align-items:center;gap:8px;padding:9px 10px;border-radius:10px;background:${this.theme.cardBg};border:1px solid ${this.theme.border};transition:all 0.3s ease;`;
1082
- el.innerHTML = `
1083
- <div style="width:26px;height:26px;border-radius:7px;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:12px;background:${this.theme.shimmer};">${s.icon}</div>
1084
- <div style="flex:1;color:${this.theme.textSecondary};font-size:11px;font-weight:500;">${s.label}</div>
1085
- <div id="sc-stage-status-${s.id}" style="width:14px;height:14px;border-radius:50%;border:2px solid ${this.theme.border};flex-shrink:0;"></div>
1086
- `;
1087
- grid.appendChild(el);
1088
- });
1089
- container.appendChild(grid);
1090
- // Start polling immediately — update stages as result comes in
1091
- if (entryId) {
1092
- this._pollProcessingStages(entryId, statusLine, grid, stages);
1350
+ const statusEl = document.createElement("div");
1351
+ statusEl.setAttribute("aria-live", "polite");
1352
+ statusEl.style.cssText = `color:${this.theme.text};font-size:15px;font-weight:600;`;
1353
+ statusEl.textContent = "Reading your document…";
1354
+ container.appendChild(statusEl);
1355
+ const subEl = document.createElement("div");
1356
+ subEl.style.cssText = `color:${this.theme.textSecondary};font-size:12px;max-width:260px;line-height:1.5;`;
1357
+ subEl.textContent = "This usually takes 5–15 seconds. Please wait.";
1358
+ container.appendChild(subEl);
1359
+ if (!this.livenessEntryId) {
1360
+ this.showErrorStep("Document upload failed. Please try again.");
1361
+ return;
1093
1362
  }
1094
- }
1095
- _pollProcessingStages(entryId, statusLine, _grid, stages) {
1096
- // Animate stages sequentially on a timer — OCR takes ~5–15s,
1097
- // face match another ~5–15s. We can't observe sub-steps from the
1098
- // public sdk-result endpoint, so we advance stages on elapsed time
1099
- // AND on the final status transition.
1100
- const stageTimings = [0, 4000, 10000, 18000]; // ms after poll start
1101
- const startTime = Date.now();
1102
- const setStageActive = (stageId) => {
1103
- const el = document.getElementById(`sc-stage-${stageId}`);
1104
- const statusEl = document.getElementById(`sc-stage-status-${stageId}`);
1105
- if (!el || !statusEl)
1106
- return;
1107
- el.style.borderColor = this.theme.primary;
1108
- el.style.background = `${this.theme.primary}10`;
1109
- statusEl.innerHTML = `<div style="position:absolute;inset:1px;border-radius:50%;border:2px solid ${this.theme.border};border-top-color:${this.theme.primary};animation:sc-spin 0.8s linear infinite;"></div>`;
1110
- statusEl.style.position = "relative";
1111
- };
1112
- const setStageComplete = (stageId) => {
1113
- const el = document.getElementById(`sc-stage-${stageId}`);
1114
- const statusEl = document.getElementById(`sc-stage-status-${stageId}`);
1115
- if (!el || !statusEl)
1116
- return;
1117
- el.style.borderColor = this.theme.success || "#22c55e";
1118
- el.style.background = `${this.theme.success || "#22c55e"}10`;
1119
- statusEl.innerHTML = `<div style="position:absolute;inset:2px;border-radius:50%;background:${this.theme.success || "#22c55e"};display:flex;align-items:center;justify-content:center;color:#fff;font-size:8px;font-weight:bold;">✓</div>`;
1120
- statusEl.style.position = "relative";
1121
- };
1122
- // Advance stage indicators on timer
1123
- stages.forEach((s, i) => {
1124
- setTimeout(() => {
1125
- if (this.isDestroyed)
1126
- return;
1127
- setStageActive(s.id);
1128
- if (i > 0)
1129
- setStageComplete(stages[i - 1].id);
1130
- statusLine.textContent = s.id === "ocr"
1131
- ? "Scanning document…"
1132
- : s.id === "expiry"
1133
- ? "Checking expiry date…"
1134
- : s.id === "face"
1135
- ? "Running face match…"
1136
- : "Finalising result…";
1137
- }, stageTimings[i]);
1138
- });
1139
- // Poll for real result
1140
- const MAX_POLLS = 30; // 30 × 3s = 90s max
1363
+ const entryId = this.livenessEntryId;
1364
+ const MAX_POLLS = 10; // 10 × 2s = 20s timeout
1141
1365
  let polls = 0;
1142
1366
  const poll = async () => {
1143
1367
  if (this.isDestroyed)
@@ -1145,48 +1369,206 @@ class SmartComplyFlow {
1145
1369
  polls++;
1146
1370
  try {
1147
1371
  const result = await this.sdk.liveness.pollResult(entryId);
1148
- if (result.status === "passed" || result.status === "failed") {
1149
- // Mark all remaining stages complete
1150
- stages.forEach((s) => setStageComplete(s.id));
1151
- statusLine.textContent = result.status === "passed"
1152
- ? "Verification complete ✓"
1153
- : "Verification finished";
1154
- // Short delay so user sees the completed states, then show result
1155
- setTimeout(() => {
1156
- if (!this.isDestroyed) {
1157
- this._livenessSubmitResult = result;
1158
- this.showResult(result);
1372
+ // W7: Expired document — stop, do not proceed to liveness
1373
+ if (result.is_expired === true) {
1374
+ container.innerHTML = "";
1375
+ this._renderExpiredDocument(container);
1376
+ return;
1377
+ }
1378
+ // W8: OCR quality fail — offer retake
1379
+ if (result.ocr_quality_failed === true) {
1380
+ container.innerHTML = "";
1381
+ this._renderOcrQualityFail(container, result.ocr_rejection_reason);
1382
+ return;
1383
+ }
1384
+ // OCR complete — store data and show confirmation screen
1385
+ const ocrDone = result.ocr_status === "done" || result.ocr_status === "ocr_complete"
1386
+ || result.ocr_name || result.ocr_document_number || result.ocr_expiry_date;
1387
+ if (ocrDone) {
1388
+ this._ocrName = result.ocr_name || "";
1389
+ this._ocrDocNumber = result.ocr_document_number || "";
1390
+ this._ocrExpiry = result.ocr_expiry_date || "";
1391
+ this._ocrFaceImageUrl = result.ocr_face_image_url || "";
1392
+ this._ocrIsExpired = result.is_expired === true;
1393
+ // B8: Normalise expiry date string before parsing — OCR may return
1394
+ // DD/MM/YYYY, DD-MM-YYYY, YYYY-MM-DD, or "12 May 2029" etc.
1395
+ // new Date() alone is locale-dependent on non-ISO strings.
1396
+ if (this._ocrExpiry) {
1397
+ const expDate = _parseOcrDate(this._ocrExpiry);
1398
+ if (expDate) {
1399
+ const sixMonths = new Date();
1400
+ sixMonths.setMonth(sixMonths.getMonth() + 6);
1401
+ this._ocrExpirySoon = expDate <= sixMonths && expDate > new Date();
1159
1402
  }
1160
- }, 800);
1403
+ }
1404
+ this.showStep("document_confirm");
1161
1405
  return;
1162
1406
  }
1407
+ // Still pending — keep polling
1408
+ if (polls < MAX_POLLS && !this.isDestroyed) {
1409
+ this._ocrPollTimer = setTimeout(poll, 2000);
1410
+ }
1411
+ else if (!this.isDestroyed) {
1412
+ // Timeout — proceed anyway with whatever we have
1413
+ statusEl.textContent = "Taking longer than usual…";
1414
+ subEl.textContent = "We'll continue — you can confirm your details on the next screen.";
1415
+ this._ocrPollTimer = setTimeout(() => {
1416
+ if (!this.isDestroyed)
1417
+ this.showStep("document_confirm");
1418
+ }, 2000);
1419
+ }
1163
1420
  }
1164
- catch { /* network hiccup — keep polling */ }
1165
- if (polls < MAX_POLLS && !this.isDestroyed) {
1166
- this._pollStatusTimer = setTimeout(poll, 3000);
1167
- }
1168
- else if (!this.isDestroyed) {
1169
- // Timeout — transition to result screen with current status
1170
- stages.forEach((s) => setStageComplete(s.id));
1171
- statusLine.textContent = "Still processing — check back soon";
1421
+ catch {
1422
+ if (polls < MAX_POLLS && !this.isDestroyed) {
1423
+ this._ocrPollTimer = setTimeout(poll, 3000);
1424
+ }
1425
+ else if (!this.isDestroyed) {
1426
+ this.showErrorStep("We could not read your document. Please check your connection and try again.");
1427
+ }
1172
1428
  }
1173
1429
  };
1174
- // First poll after 5s — give Celery task time to start
1175
- this._pollStatusTimer = setTimeout(poll, 5000);
1430
+ // First poll after 3s — give OCR submission time to start
1431
+ this._ocrPollTimer = setTimeout(poll, 3000);
1432
+ }
1433
+ _renderExpiredDocument(container) {
1434
+ container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:14px;padding:20px 0;";
1435
+ const icon = document.createElement("div");
1436
+ icon.style.cssText = `width:72px;height:72px;border-radius:50%;background:${this.theme.errorBg};border:3px solid ${this.theme.error};display:flex;align-items:center;justify-content:center;font-size:28px;`;
1437
+ icon.textContent = "📅";
1438
+ container.appendChild(icon);
1439
+ const t = document.createElement("h2");
1440
+ t.style.cssText = `color:${this.theme.text};font-size:18px;font-weight:700;margin:0;`;
1441
+ t.textContent = "Document Expired";
1442
+ container.appendChild(t);
1443
+ const d = document.createElement("p");
1444
+ d.style.cssText = `color:${this.theme.textSecondary};font-size:13px;margin:0;max-width:280px;line-height:1.6;`;
1445
+ d.textContent = "The document you submitted has expired. Please use a valid, current ID.";
1446
+ container.appendChild(d);
1447
+ const retryBtn = this.createPrimaryButton("Try a different document");
1448
+ retryBtn.addEventListener("click", () => {
1449
+ this.livenessEntryId = null;
1450
+ this.documentBlob = null;
1451
+ this.documentBackBlob = null;
1452
+ this.showStep("document_capture");
1453
+ });
1454
+ container.appendChild(retryBtn);
1455
+ const endBtn = this.createSecondaryButton("End session");
1456
+ endBtn.addEventListener("click", () => { this.options.onError?.(new Error("Document expired")); this.close(); });
1457
+ container.appendChild(endBtn);
1458
+ }
1459
+ _renderOcrQualityFail(container, reason) {
1460
+ container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:14px;padding:20px 0;";
1461
+ const icon = document.createElement("div");
1462
+ icon.style.cssText = `width:72px;height:72px;border-radius:50%;background:${this.theme.errorBg};border:3px solid ${this.theme.error};display:flex;align-items:center;justify-content:center;font-size:28px;`;
1463
+ icon.textContent = "🔍";
1464
+ container.appendChild(icon);
1465
+ const t = document.createElement("h2");
1466
+ t.style.cssText = `color:${this.theme.text};font-size:18px;font-weight:700;margin:0;`;
1467
+ t.textContent = "Document Photo Unclear";
1468
+ container.appendChild(t);
1469
+ const msg = reason === "image_too_blurry" ? "Your document photo is too blurry. Please retake it in good lighting with a steady hand."
1470
+ : reason === "image_too_dark" ? "Your document photo is too dark. Please retake it in better lighting."
1471
+ : reason === "image_too_bright" ? "Your document photo has too much glare. Please reduce reflections and retake."
1472
+ : reason === "document_not_detected" ? "We could not detect a document in the photo. Please ensure the full ID is visible."
1473
+ : "We could not read your document clearly. Please retake the photo in good lighting with no glare.";
1474
+ const d = document.createElement("p");
1475
+ d.style.cssText = `color:${this.theme.textSecondary};font-size:13px;margin:0;max-width:280px;line-height:1.6;`;
1476
+ d.textContent = msg;
1477
+ container.appendChild(d);
1478
+ const retryBtn = this.createPrimaryButton("Retake photo");
1479
+ retryBtn.addEventListener("click", () => {
1480
+ this.livenessEntryId = null;
1481
+ this.documentBlob = null;
1482
+ this.documentBackBlob = null;
1483
+ this.showStep("document_capture");
1484
+ });
1485
+ container.appendChild(retryBtn);
1486
+ }
1487
+ // ── W11: You're all done (end-user final screen) ─────────────────
1488
+ renderDone(container) {
1489
+ container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:16px;padding:24px 0;";
1490
+ container.setAttribute("role", "status");
1491
+ // B7: Fire onComplete immediately when the done screen renders — the end
1492
+ // user has completed their part regardless of whether they click Close.
1493
+ // Firing here means the integrator always gets the event even if the user
1494
+ // closes the modal via the ✕ button instead of the Close button.
1495
+ this.options.onComplete?.({
1496
+ entryId: this.livenessEntryId || 0,
1497
+ sessionId: this.sdk.sessionToken,
1498
+ status: "processing",
1499
+ submittedAt: new Date().toISOString(),
1500
+ verificationResult: this.verificationResult || undefined,
1501
+ });
1502
+ const iconWrap = document.createElement("div");
1503
+ iconWrap.style.cssText = `width:80px;height:80px;border-radius:50%;background:${this.theme.successBg};border:3px solid ${this.theme.success};display:flex;align-items:center;justify-content:center;font-size:36px;font-weight:700;color:${this.theme.success};animation:sc-pop 0.4s ease;`;
1504
+ iconWrap.textContent = "✓";
1505
+ container.appendChild(iconWrap);
1506
+ const title = document.createElement("h2");
1507
+ title.style.cssText = `color:${this.theme.text};font-size:22px;font-weight:700;margin:0;`;
1508
+ title.textContent = "You're all done!";
1509
+ container.appendChild(title);
1510
+ const brandName = this.sdkConfig?.brand_name || "the service";
1511
+ const desc = document.createElement("p");
1512
+ desc.style.cssText = `color:${this.theme.textSecondary};font-size:14px;line-height:1.6;margin:0;max-width:300px;`;
1513
+ desc.textContent = `Your verification is complete. ${brandName} will review and be in touch shortly.`;
1514
+ container.appendChild(desc);
1515
+ // Subtle info line
1516
+ const info = document.createElement("div");
1517
+ info.style.cssText = `color:${this.theme.textMuted};font-size:11px;display:flex;align-items:center;gap:4px;`;
1518
+ info.textContent = "🔒 Your data is encrypted and secure";
1519
+ container.appendChild(info);
1520
+ const doneBtn = this.createPrimaryButton("Close");
1521
+ doneBtn.addEventListener("click", () => {
1522
+ // onComplete already fired on render — just close the modal
1523
+ this.close();
1524
+ });
1525
+ container.appendChild(doneBtn);
1526
+ }
1527
+ // ── W14: Retry limit reached ─────────────────────────────────────
1528
+ _renderRetryLimitReached(container) {
1529
+ container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:14px;padding:24px 0;";
1530
+ const icon = document.createElement("div");
1531
+ icon.style.cssText = `width:72px;height:72px;border-radius:50%;background:${this.theme.errorBg};border:3px solid ${this.theme.error};display:flex;align-items:center;justify-content:center;font-size:28px;`;
1532
+ icon.textContent = "⚠️";
1533
+ container.appendChild(icon);
1534
+ const t = document.createElement("h2");
1535
+ t.style.cssText = `color:${this.theme.text};font-size:18px;font-weight:700;margin:0;`;
1536
+ t.textContent = "Unable to Complete Verification";
1537
+ container.appendChild(t);
1538
+ const d = document.createElement("p");
1539
+ d.style.cssText = `color:${this.theme.textSecondary};font-size:13px;margin:0;max-width:280px;line-height:1.6;`;
1540
+ d.textContent = "We were unable to complete your verification at this time. Please contact support or try again later.";
1541
+ container.appendChild(d);
1542
+ const closeBtn = this.createPrimaryButton("Close");
1543
+ closeBtn.addEventListener("click", () => { this.options.onError?.(new Error("Retry limit reached")); this.close(); });
1544
+ container.appendChild(closeBtn);
1545
+ }
1546
+ // ── W13: Camera pre-warm ─────────────────────────────────────────
1547
+ _prewarmCamera() {
1548
+ if (this._prewarmedStream || this.isDestroyed)
1549
+ return;
1550
+ navigator.mediaDevices?.getUserMedia?.({ video: { facingMode: "user" } })
1551
+ .then(stream => {
1552
+ if (this.isDestroyed) {
1553
+ stream.getTracks().forEach(t => t.stop());
1554
+ return;
1555
+ }
1556
+ this._prewarmedStream = stream;
1557
+ })
1558
+ .catch(() => { });
1176
1559
  }
1177
1560
  /**
1178
1561
  * Process document capture result with front/back support
1179
1562
  */
1180
1563
  async processDocumentCaptureResult(result, captureContainer, container) {
1181
- // Store both front and back blobs
1182
- this.documentBlob = result.front;
1183
- this.documentBackBlob = result.back || null;
1184
- // Process the front image through the existing flow
1185
- await this.processDocumentBlob(result.front, captureContainer, container);
1186
- // Log back image capture
1187
- if (result.back) {
1188
- console.log("Back image captured, size:", result.back.size);
1189
- }
1564
+ // B2+P2: Compress both images once here. Do NOT assign documentBlob twice
1565
+ // (old code set it to the raw blob first, then overwrote with compressed).
1566
+ // processDocumentBlob also ran _compressImage — that second pass is removed
1567
+ // below so compression only happens once.
1568
+ this.documentBlob = await _compressImage(result.front, 1600, 0.85);
1569
+ this.documentBackBlob = result.back ? await _compressImage(result.back, 1600, 0.85) : null;
1570
+ // Process the front image — pass the already-compressed blob
1571
+ await this.processDocumentBlob(this.documentBlob, captureContainer, container);
1190
1572
  }
1191
1573
  /**
1192
1574
  * Process document blob - common method for both CameraView and DocumentCapture
@@ -1196,7 +1578,7 @@ class SmartComplyFlow {
1196
1578
  let retryCount = 0;
1197
1579
  const attemptProcessing = async () => {
1198
1580
  try {
1199
- // Validate blob
1581
+ // Validate blob — compression already done in processDocumentCaptureResult (P2)
1200
1582
  if (!blob || blob.size === 0) {
1201
1583
  throw new Error("Failed to capture document. Please try again.");
1202
1584
  }
@@ -1209,7 +1591,7 @@ class SmartComplyFlow {
1209
1591
  <div style="display:flex;flex-direction:column;align-items:center;gap:14px;padding:28px 20px;">
1210
1592
  <div style="position:relative;width:52px;height:52px;">
1211
1593
  <div style="position:absolute;inset:0;border-radius:50%;border:3px solid ${this.theme.border};border-top-color:${this.theme.primary};animation:sc-spin 0.8s linear infinite;"></div>
1212
- <div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:20px;">🔍</div>
1594
+ <div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:20px;">🔍</div>
1213
1595
  </div>
1214
1596
  <div style="color:${this.theme.text};font-size:14px;font-weight:600;">Scanning document...</div>
1215
1597
  <div style="color:${this.theme.textSecondary};font-size:12px;text-align:center;max-width:200px;line-height:1.5;">Running OCR and verifying your identity</div>
@@ -1217,7 +1599,7 @@ class SmartComplyFlow {
1217
1599
  // Create liveness entry with document.
1218
1600
  // Use the first collected field value as identifier when available
1219
1601
  // (document flow has no data-verification step, so collectedFields is
1220
- // empty — fall back to the selected ID type code so the webhook
1602
+ // empty — fall back to the selected ID type code so the webhook
1221
1603
  // subject.identifier is at least human-meaningful, not a raw timestamp).
1222
1604
  const identifier = Object.values(this.collectedFields)[0]
1223
1605
  || this.idNumber
@@ -1266,10 +1648,9 @@ class SmartComplyFlow {
1266
1648
  }
1267
1649
  }
1268
1650
  this.livenessEntryId = createResult.id;
1269
- // Document is already uploaded as part of liveness.create() above.
1270
- // Proceed straight to liveness — the backend runs OCR + face comparison
1271
- // after the liveness video is submitted, so there is no need to wait here.
1272
- this.showStep("liveness");
1651
+ // W6: Route to OCR gate — poll until OCR result is ready, validate
1652
+ // expiry/quality, then show document_confirm before liveness starts.
1653
+ this.showStep("ocr_gate");
1273
1654
  }
1274
1655
  catch (err) {
1275
1656
  console.error("Document processing failed:", err);
@@ -1282,7 +1663,7 @@ class SmartComplyFlow {
1282
1663
  <div style="display:flex;flex-direction:column;align-items:center;gap:16px;padding:28px 20px;text-align:center;">
1283
1664
  <div style="position:relative;width:52px;height:52px;">
1284
1665
  <div style="position:absolute;inset:0;border-radius:50%;border:3px solid ${this.theme.border};border-top-color:${this.theme.primary};animation:sc-spin 0.8s linear infinite;"></div>
1285
- <div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:20px;">🔄</div>
1666
+ <div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:20px;">🔄</div>
1286
1667
  </div>
1287
1668
  <div style="color:${this.theme.text};font-size:14px;font-weight:600;">Retrying...</div>
1288
1669
  <div style="color:${this.theme.textSecondary};font-size:12px;max-width:240px;line-height:1.5;">Attempt ${retryCount} of ${maxRetries}</div>
@@ -1301,7 +1682,7 @@ class SmartComplyFlow {
1301
1682
  await attemptProcessing();
1302
1683
  }
1303
1684
  handleDocumentProcessingError(error, captureContainer) {
1304
- // Map technical errors to user-friendly messages — never pass raw error strings
1685
+ // Map technical errors to user-friendly messages — never pass raw error strings
1305
1686
  let userMessage = "We could not verify your document. Please try again.";
1306
1687
  let canRetry = true;
1307
1688
  const raw = (error?.message || "").toLowerCase();
@@ -1328,13 +1709,13 @@ class SmartComplyFlow {
1328
1709
  userMessage = "The document does not match the selected ID type. Please choose the correct ID type.";
1329
1710
  }
1330
1711
  if (captureContainer) {
1331
- // Build with DOM — never interpolate user-derived content into innerHTML
1712
+ // Build with DOM — never interpolate user-derived content into innerHTML
1332
1713
  const wrap = document.createElement("div");
1333
1714
  wrap.style.cssText = "display:flex;flex-direction:column;align-items:center;gap:16px;padding:28px 20px;text-align:center;";
1334
1715
  wrap.setAttribute("role", "alert");
1335
1716
  const icon = document.createElement("div");
1336
1717
  icon.style.cssText = `width:56px;height:56px;border-radius:50%;background:${this.theme.errorBg};border:3px solid ${this.theme.error};display:flex;align-items:center;justify-content:center;font-size:26px;`;
1337
- icon.textContent = "🔍";
1718
+ icon.textContent = "🔍";
1338
1719
  wrap.appendChild(icon);
1339
1720
  const titleEl = document.createElement("div");
1340
1721
  titleEl.style.cssText = `color:${this.theme.text};font-size:14px;font-weight:700;`;
@@ -1359,9 +1740,14 @@ class SmartComplyFlow {
1359
1740
  this.showErrorStep(userMessage);
1360
1741
  }
1361
1742
  }
1362
- // ── Liveness ────────────────────────────────────────────────────
1743
+ // ── Liveness ────────────────────────────────────────────────────
1363
1744
  renderLiveness(container) {
1364
1745
  container.style.cssText += "display:flex;flex-direction:column;gap:14px;";
1746
+ // W13: Check retry limit before starting another liveness attempt
1747
+ if (this._livenessFailures >= SmartComplyFlow.MAX_RETRIES) {
1748
+ this._renderRetryLimitReached(container);
1749
+ return;
1750
+ }
1365
1751
  const title = this.createStepTitle("Face verification");
1366
1752
  container.appendChild(title);
1367
1753
  const desc = document.createElement("div");
@@ -1372,18 +1758,21 @@ class SmartComplyFlow {
1372
1758
  const livenessContainer = document.createElement("div");
1373
1759
  livenessContainer.style.cssText = "margin:8px 0;width:100%;";
1374
1760
  container.appendChild(livenessContainer);
1375
- const identifier = this.idNumber
1376
- || Object.values(this.collectedFields)[0]
1377
- || this.selectedIdType
1378
- || `DOC-${Date.now()}`;
1761
+ // W9: For data verification, identity is already held by the backend via
1762
+ // identity_check_id — the real ID number/type must not be re-sent. The
1763
+ // backend's LivenessEntrySerializer still requires non-blank identifier /
1764
+ // identifier_type fields, so a non-identifying stub is sent to satisfy that
1765
+ // constraint without leaking the user's actual ID data a second time.
1766
+ // "SES-" + Date.now() (13 digits) = 17 chars, safely under the backend's
1767
+ // 20-char max on identifier.
1768
+ const isDataVerification = !!this.identityCheckId;
1769
+ const identifier = isDataVerification
1770
+ ? `SES-${Date.now()}`
1771
+ : (this.idNumber || Object.values(this.collectedFields)[0] || this.selectedIdType || `DOC-${Date.now()}`);
1772
+ const identifierType = isDataVerification ? "session" : this.selectedIdType;
1379
1773
  const COUNTRY_CODES = {
1380
- nigeria: "NG",
1381
- ghana: "GH",
1382
- kenya: "KE",
1383
- south_africa: "ZA",
1384
- united_states: "US",
1385
- united_kingdom: "GB",
1386
- global: "US",
1774
+ nigeria: "NG", ghana: "GH", kenya: "KE",
1775
+ south_africa: "ZA", united_states: "US", united_kingdom: "GB", global: "US",
1387
1776
  };
1388
1777
  const country = COUNTRY_CODES[this.selectedCountry] || this.selectedCountry.toUpperCase().slice(0, 2);
1389
1778
  // Start liveness check (this handles camera, detection, recording, submission)
@@ -1391,277 +1780,40 @@ class SmartComplyFlow {
1391
1780
  this.sdk.liveness
1392
1781
  .startCheck(livenessContainer, {
1393
1782
  identifier,
1394
- identifier_type: this.selectedIdType,
1783
+ identifier_type: identifierType,
1395
1784
  country,
1396
1785
  document: this.documentBlob || undefined,
1397
1786
  document_back: this.documentBackBlob || undefined,
1398
1787
  identity_check: this.identityCheckId || undefined,
1399
- // Tell the module which flow we're in so it can skip video for data verification
1400
1788
  verification_type: this.identityCheckId
1401
1789
  ? "data_verification"
1402
1790
  : this.documentBlob
1403
1791
  ? "document_verification"
1404
1792
  : "liveness_only",
1793
+ // B4: pass pre-warmed stream so camera.open() is skipped
1794
+ prewarmedStream: this._prewarmedStream || undefined,
1405
1795
  }, ["BLINK", "TURN_HEAD", "OPEN_MOUTH"], this.livenessEntryId || undefined)
1406
1796
  .then((result) => {
1407
- // For document/data verification the backend processes async.
1408
- // Route through the processing step so the user sees real pipeline
1409
- // progress instead of jumping straight to result.
1410
- const isAsync = !!(this.documentBlob || this.identityCheckId);
1411
- if (isAsync && result.id) {
1412
- this.livenessEntryId = result.id;
1413
- this.showStep("processing");
1797
+ // W11: After submit, end user is done — show "You're all done" screen.
1798
+ // Backend processing continues in background; client receives webhook.
1799
+ // Track liveness failures for retry limit (W14).
1800
+ if (result.status === "failed") {
1801
+ this._livenessFailures++;
1414
1802
  }
1415
- else {
1416
- this.showResult(result);
1803
+ if (result.id) {
1804
+ this.livenessEntryId = result.id;
1417
1805
  }
1806
+ this.showStep("done");
1418
1807
  })
1419
1808
  .catch((err) => {
1809
+ this._livenessFailures++;
1420
1810
  this.showErrorStep(err.message || "Liveness verification failed. Please try again.");
1421
1811
  });
1422
1812
  }
1423
- // ── Result ──────────────────────────────────────────────────────
1424
- showResult(submitResult) {
1425
- this.currentStep = "result";
1426
- this.updateProgress();
1427
- if (!this.contentArea)
1428
- return;
1429
- this.contentArea.innerHTML = "";
1430
- const wrapper = document.createElement("div");
1431
- wrapper.setAttribute("role", "region");
1432
- wrapper.setAttribute("aria-label", "Verification result");
1433
- wrapper.style.cssText = `
1434
- display:flex;flex-direction:column;align-items:center;
1435
- text-align:center;gap:16px;padding:20px 0;
1436
- opacity:0;transform:scale(0.95);transition:all 0.4s ease;
1437
- `;
1438
- // ── Icon — spinner until status resolves ────────────────────────
1439
- const iconWrap = document.createElement("div");
1440
- iconWrap.style.cssText = `
1441
- width:80px;height:80px;border-radius:50%;
1442
- display:flex;align-items:center;justify-content:center;
1443
- font-size:36px;
1444
- background:${this.theme.cardBg};
1445
- border:3px solid ${this.theme.border};
1446
- transition:all 0.4s ease;
1447
- `;
1448
- iconWrap.innerHTML = `<div style="width:32px;height:32px;border-radius:50%;border:3px solid ${this.theme.border};border-top-color:${this.theme.primary};animation:sc-spin 0.9s linear infinite;"></div>`;
1449
- wrapper.appendChild(iconWrap);
1450
- // ── Title and description — updated by polling ───────────────────
1451
- const title = document.createElement("h2");
1452
- title.setAttribute("aria-live", "polite");
1453
- title.style.cssText = `color:${this.theme.text};font-size:20px;font-weight:700;margin:0;`;
1454
- title.textContent = "Checking your identity…";
1455
- wrapper.appendChild(title);
1456
- const desc = document.createElement("p");
1457
- desc.setAttribute("aria-live", "polite");
1458
- desc.style.cssText = `color:${this.theme.textSecondary};font-size:14px;line-height:1.6;margin:0;max-width:300px;`;
1459
- desc.textContent = "This usually takes a few seconds. Please wait.";
1460
- wrapper.appendChild(desc);
1461
- // ── Result card — initially shows status only ────────────────────
1462
- // Populated with name/doc type when verification completes.
1463
- const resultCard = document.createElement("div");
1464
- resultCard.style.cssText = `
1465
- width:100%;padding:16px;border-radius:12px;
1466
- background:${this.theme.cardBg};border:1px solid ${this.theme.border};
1467
- text-align:left;display:flex;flex-direction:column;gap:10px;
1468
- transition:border-color 0.3s ease;
1469
- `;
1470
- // Status row — always shown
1471
- const statusBadge = document.createElement("span");
1472
- statusBadge.setAttribute("role", "status");
1473
- statusBadge.setAttribute("aria-live", "polite");
1474
- statusBadge.setAttribute("aria-atomic", "true");
1475
- statusBadge.style.cssText = `font-size:12px;font-weight:600;color:${this.theme.warning};`;
1476
- statusBadge.textContent = "Processing…";
1477
- const statusRow = document.createElement("div");
1478
- statusRow.style.cssText = "display:flex;justify-content:space-between;align-items:center;";
1479
- statusRow.innerHTML = `<span style="color:${this.theme.textMuted};font-size:12px;font-weight:500;">Status</span>`;
1480
- statusRow.appendChild(statusBadge);
1481
- resultCard.appendChild(statusRow);
1482
- // Verified details — shown only when passed.
1483
- // nameRow: shows full name from IVS result or OCR
1484
- // docTypeRow: shows document type verified
1485
- const nameRow = document.createElement("div");
1486
- nameRow.style.cssText = "display:none;justify-content:space-between;align-items:center;";
1487
- nameRow.innerHTML = `<span style="color:${this.theme.textMuted};font-size:12px;font-weight:500;">Name</span><span id="sc-result-name" style="color:${this.theme.text};font-size:12px;font-weight:600;text-align:right;max-width:60%;"></span>`;
1488
- resultCard.appendChild(nameRow);
1489
- const docTypeRow = document.createElement("div");
1490
- docTypeRow.style.cssText = "display:none;justify-content:space-between;align-items:center;";
1491
- docTypeRow.innerHTML = `<span style="color:${this.theme.textMuted};font-size:12px;font-weight:500;">ID Type</span><span id="sc-result-doctype" style="color:${this.theme.text};font-size:12px;font-weight:600;"></span>`;
1492
- resultCard.appendChild(docTypeRow);
1493
- wrapper.appendChild(resultCard);
1494
- // ── Failure reason — shown only when failed ──────────────────────
1495
- const failureMsg = document.createElement("div");
1496
- failureMsg.setAttribute("role", "alert");
1497
- failureMsg.style.cssText = `
1498
- display:none;width:100%;padding:12px 14px;border-radius:10px;
1499
- background:${this.theme.errorBg};border:1px solid ${this.theme.error};
1500
- color:${this.theme.error};font-size:13px;text-align:left;line-height:1.6;
1501
- `;
1502
- wrapper.appendChild(failureMsg);
1503
- // ── Action buttons ───────────────────────────────────────────────
1504
- const btnRow = document.createElement("div");
1505
- btnRow.style.cssText = "display:flex;flex-direction:column;gap:8px;width:100%;";
1506
- const doneBtn = this.createPrimaryButton("Done");
1507
- doneBtn.setAttribute("aria-label", "Close verification and continue");
1508
- let finalStatus = submitResult.status;
1509
- doneBtn.addEventListener("click", () => {
1510
- if (this._pollStatusTimer) {
1511
- clearTimeout(this._pollStatusTimer);
1512
- this._pollStatusTimer = null;
1513
- }
1514
- this.options.onComplete?.({
1515
- entryId: submitResult.id,
1516
- sessionId: this.sdk.sessionToken,
1517
- status: finalStatus,
1518
- submittedAt: submitResult.submitted_at,
1519
- verificationResult: this.verificationResult || undefined,
1520
- });
1521
- this.close();
1522
- });
1523
- btnRow.appendChild(doneBtn);
1524
- // "Try again" button — shown only on failure, hidden initially
1525
- const retryBtn = this.createSecondaryButton("Try Again");
1526
- retryBtn.setAttribute("aria-label", "Start verification again");
1527
- retryBtn.style.display = "none";
1528
- retryBtn.addEventListener("click", () => {
1529
- if (this._pollStatusTimer) {
1530
- clearTimeout(this._pollStatusTimer);
1531
- this._pollStatusTimer = null;
1532
- }
1533
- this.initialize();
1534
- });
1535
- btnRow.appendChild(retryBtn);
1536
- wrapper.appendChild(btnRow);
1537
- this.contentArea.appendChild(wrapper);
1538
- requestAnimationFrame(() => {
1539
- wrapper.style.opacity = "1";
1540
- wrapper.style.transform = "scale(1)";
1541
- });
1542
- // ── Start polling ────────────────────────────────────────────────
1543
- this._startStatusPolling(submitResult.id, iconWrap, statusBadge, title, desc, resultCard, nameRow, docTypeRow, failureMsg, retryBtn, (resolved) => { finalStatus = resolved; });
1544
- }
1545
- _startStatusPolling(entryId, iconWrap, statusBadge, title, desc, resultCard, nameRow, docTypeRow, failureMsg, retryBtn, onResolved) {
1546
- const MAX_POLLS = 25; // 25 × 3s = 75s + 4s initial ≈ 79s max
1547
- let polls = 0;
1548
- // Hard 30s UI timer — stop spinner and tell user to wait even if backend is slower
1549
- let uiTimeoutId = setTimeout(() => {
1550
- if (!this.isDestroyed)
1551
- applyTimeout();
1552
- }, 30000);
1553
- const clearUiTimeout = () => {
1554
- if (uiTimeoutId) {
1555
- clearTimeout(uiTimeoutId);
1556
- uiTimeoutId = null;
1557
- }
1558
- };
1559
- const applyTimeout = () => {
1560
- if (this._pollStatusTimer) {
1561
- clearTimeout(this._pollStatusTimer);
1562
- this._pollStatusTimer = null;
1563
- }
1564
- iconWrap.innerHTML = "⏳";
1565
- iconWrap.style.border = `3px solid ${this.theme.warning}`;
1566
- statusBadge.style.color = this.theme.textMuted;
1567
- statusBadge.textContent = "Still processing…";
1568
- title.textContent = "Still verifying";
1569
- desc.textContent = "This is taking a little longer than usual. You can safely close this — your result will be available in your account.";
1570
- };
1571
- const applyPassed = (result) => {
1572
- clearUiTimeout();
1573
- iconWrap.innerHTML = "✓";
1574
- iconWrap.style.cssText += `background:${this.theme.successBg};border:3px solid ${this.theme.success};color:${this.theme.success};font-size:36px;font-weight:700;`;
1575
- resultCard.style.borderColor = this.theme.success;
1576
- statusBadge.style.color = this.theme.success;
1577
- statusBadge.textContent = "Verified ✓";
1578
- title.textContent = "Identity Verified";
1579
- desc.textContent = "Your identity has been confirmed successfully.";
1580
- // ── Show name if available ───────────────────────────────────────
1581
- // Pull from IVS customer_profile (data verification) or
1582
- // document OCR result (document verification) — whichever has data.
1583
- const profile = result?.customer_profile || result?.data || {};
1584
- const docResult = result?.document || {};
1585
- const ocr = docResult?.ocr_result || {};
1586
- const firstName = (profile?.first_name || ocr?.first_name || docResult?.first_name || "").trim();
1587
- const lastName = (profile?.last_name || profile?.surname ||
1588
- ocr?.last_name || ocr?.surname || docResult?.last_name || "").trim();
1589
- const fullName = [firstName, lastName].filter(Boolean).join(" ");
1590
- const nameEl = document.getElementById("sc-result-name");
1591
- if (fullName && nameEl) {
1592
- nameEl.textContent = fullName;
1593
- nameRow.style.display = "flex";
1594
- }
1595
- // ── Show ID type ─────────────────────────────────────────────────
1596
- const rawDocType = (docResult?.document_type ||
1597
- this.selectedIdType ||
1598
- result?.identifier_type ||
1599
- "");
1600
- const docTypeLabel = _formatDocType(rawDocType);
1601
- const docTypeEl = document.getElementById("sc-result-doctype");
1602
- if (docTypeLabel && docTypeEl) {
1603
- docTypeEl.textContent = docTypeLabel;
1604
- docTypeRow.style.display = "flex";
1605
- }
1606
- };
1607
- const applyFailed = (reason) => {
1608
- clearUiTimeout();
1609
- iconWrap.innerHTML = "✕";
1610
- iconWrap.style.cssText += `background:${this.theme.errorBg};border:3px solid ${this.theme.error};color:${this.theme.error};font-size:32px;`;
1611
- resultCard.style.borderColor = this.theme.error;
1612
- statusBadge.style.color = this.theme.error;
1613
- statusBadge.textContent = "Not verified";
1614
- title.textContent = "Verification Unsuccessful";
1615
- desc.textContent = "We were unable to confirm your identity this time.";
1616
- // Show a clean, actionable reason — never raw technical strings
1617
- const safeReason = _sanitizeUserMessage(reason);
1618
- if (safeReason) {
1619
- failureMsg.textContent = safeReason;
1620
- failureMsg.style.display = "block";
1621
- }
1622
- // Show retry button on failure
1623
- retryBtn.style.display = "block";
1624
- };
1625
- const poll = async () => {
1626
- if (this.isDestroyed)
1627
- return;
1628
- polls++;
1629
- try {
1630
- const result = await this.sdk.liveness.pollResult(entryId);
1631
- if (result.status === "passed") {
1632
- onResolved("passed");
1633
- applyPassed(result);
1634
- return;
1635
- }
1636
- if (result.status === "failed") {
1637
- onResolved("failed");
1638
- applyFailed(result.failure_reason || null);
1639
- return;
1640
- }
1641
- // Still processing — schedule next poll
1642
- if (polls < MAX_POLLS && !this.isDestroyed) {
1643
- this._pollStatusTimer = setTimeout(poll, 3000);
1644
- }
1645
- else if (!this.isDestroyed) {
1646
- applyTimeout();
1647
- }
1648
- }
1649
- catch {
1650
- if (polls < MAX_POLLS && !this.isDestroyed) {
1651
- this._pollStatusTimer = setTimeout(poll, 5000);
1652
- }
1653
- else if (!this.isDestroyed) {
1654
- applyTimeout();
1655
- }
1656
- }
1657
- };
1658
- // First poll after 4s — gives Celery task time to start
1659
- this._pollStatusTimer = setTimeout(poll, 4000);
1660
- }
1661
1813
  renderResult(_container) {
1662
- // Handled by showResult() directly
1814
+ // C2: Dead code — flow routes to 'done' step, not 'result'. Kept as stub.
1663
1815
  }
1664
- // ── Error ───────────────────────────────────────────────────────
1816
+ // ── Error ───────────────────────────────────────────────────────
1665
1817
  showErrorStep(message) {
1666
1818
  this.currentStep = "error";
1667
1819
  if (!this.contentArea)
@@ -1680,13 +1832,13 @@ class SmartComplyFlow {
1680
1832
  const isDocQuality = /blurry|blur|quality|glare|dark|bright|lighting/i.test(message);
1681
1833
  const isExpiredDoc = /document.*expired|expired.*document/i.test(message);
1682
1834
  const isFaceMatch = /face.*match|selfie.*match|does not match/i.test(message);
1683
- const errorIcon = isCameraErr ? "📷"
1684
- : isNetworkErr ? "📡"
1685
- : isAuthErr ? "⏱"
1686
- : isDocQuality ? "🔍"
1687
- : isExpiredDoc ? "📅"
1688
- : isFaceMatch ? "🤳"
1689
- : "✕";
1835
+ const errorIcon = isCameraErr ? "📷"
1836
+ : isNetworkErr ? "📡"
1837
+ : isAuthErr ? "⏱"
1838
+ : isDocQuality ? "🔍"
1839
+ : isExpiredDoc ? "📅"
1840
+ : isFaceMatch ? "🤳"
1841
+ : "✕";
1690
1842
  const errorTitle = isCameraErr ? "Camera Access Required"
1691
1843
  : isNetworkErr ? "Connection Problem"
1692
1844
  : isAuthErr ? "Session Expired"
@@ -1694,7 +1846,7 @@ class SmartComplyFlow {
1694
1846
  : isExpiredDoc ? "Document Expired"
1695
1847
  : isFaceMatch ? "Face Match Failed"
1696
1848
  : "Verification Unsuccessful";
1697
- // Map to user-facing guidance — never show raw internal message
1849
+ // Map to user-facing guidance — never show raw internal message
1698
1850
  const userMessage = isCameraErr
1699
1851
  ? "Please allow camera access in your browser settings and try again."
1700
1852
  : isNetworkErr
@@ -1741,9 +1893,9 @@ class SmartComplyFlow {
1741
1893
  wrapper.appendChild(closeBtn);
1742
1894
  this.contentArea.appendChild(wrapper);
1743
1895
  }
1744
- // ─────────────────────────────────────────────────────────────────
1896
+ // ─────────────────────────────────────────────────────────────────
1745
1897
  // UI Helpers
1746
- // ─────────────────────────────────────────────────────────────────
1898
+ // ─────────────────────────────────────────────────────────────────
1747
1899
  createHeader() {
1748
1900
  const header = document.createElement("div");
1749
1901
  header.setAttribute("role", "banner");
@@ -1786,7 +1938,22 @@ class SmartComplyFlow {
1786
1938
  this.brandLabel.textContent = this.sdkConfig?.brand_name || "SmartComply";
1787
1939
  leftArea.appendChild(this.brandLabel);
1788
1940
  header.appendChild(leftArea);
1789
- // Step indicator
1941
+ // Step indicator: stage dots + label
1942
+ const stepIndicator = document.createElement("div");
1943
+ stepIndicator.style.cssText = `
1944
+ display:flex;align-items:center;gap:8px;flex-shrink:0;
1945
+ `;
1946
+ this.stageDots = document.createElement("div");
1947
+ this.stageDots.style.cssText = `display:flex;align-items:center;gap:4px;`;
1948
+ for (let i = 0; i < 2; i++) {
1949
+ const dot = document.createElement("span");
1950
+ dot.style.cssText = `
1951
+ width:6px;height:6px;border-radius:50%;
1952
+ background:${this.theme.border};transition:background 0.2s ease;
1953
+ `;
1954
+ this.stageDots.appendChild(dot);
1955
+ }
1956
+ stepIndicator.appendChild(this.stageDots);
1790
1957
  this.stepLabel = document.createElement("div");
1791
1958
  this.stepLabel.setAttribute("aria-live", "polite");
1792
1959
  this.stepLabel.setAttribute("aria-atomic", "true");
@@ -1794,10 +1961,11 @@ class SmartComplyFlow {
1794
1961
  font-size:12px;color:${this.theme.textMuted};
1795
1962
  font-variant-numeric:tabular-nums;flex-shrink:0;
1796
1963
  `;
1797
- header.appendChild(this.stepLabel);
1964
+ stepIndicator.appendChild(this.stepLabel);
1965
+ header.appendChild(stepIndicator);
1798
1966
  // Close button
1799
1967
  const closeBtn = document.createElement("button");
1800
- closeBtn.innerHTML = "✕";
1968
+ closeBtn.innerHTML = "✕";
1801
1969
  closeBtn.setAttribute("aria-label", "Close identity verification");
1802
1970
  closeBtn.style.cssText = `
1803
1971
  width:32px;height:32px;border-radius:8px;flex-shrink:0;
@@ -1878,7 +2046,7 @@ class SmartComplyFlow {
1878
2046
  }
1879
2047
  const arrow = document.createElement("span");
1880
2048
  arrow.style.cssText = `color:${this.theme.textMuted};font-size:18px;flex-shrink:0;`;
1881
- arrow.textContent = "→";
2049
+ arrow.textContent = "→";
1882
2050
  card.appendChild(textWrap);
1883
2051
  card.appendChild(arrow);
1884
2052
  return card;
@@ -1923,7 +2091,7 @@ class SmartComplyFlow {
1923
2091
  return btn;
1924
2092
  }
1925
2093
  isDocumentFlow() {
1926
- // 1. Check verification_type — handle both string and array from backend
2094
+ // 1. Check verification_type — handle both string and array from backend
1927
2095
  const vType = this.sdkConfig?.verification_type;
1928
2096
  if (vType) {
1929
2097
  const types = Array.isArray(vType) ? vType : [vType];
@@ -1932,7 +2100,7 @@ class SmartComplyFlow {
1932
2100
  if (types.includes("data_verification"))
1933
2101
  return false;
1934
2102
  }
1935
- // 2. Fallback: inspect channel fields — any upload field = document flow
2103
+ // 2. Fallback: inspect channel fields — any upload field = document flow
1936
2104
  const channels = this.sdkConfig?.channels || {};
1937
2105
  const allFields = Object.values(channels)
1938
2106
  .flat()
@@ -1943,38 +2111,60 @@ class SmartComplyFlow {
1943
2111
  const isDocFlow = this.isDocumentFlow();
1944
2112
  const countries = Object.keys(this.sdkConfig?.channels || {});
1945
2113
  const singleCountry = countries.length <= 1;
1946
- // User-action steps only (what the user actually does — no loading/welcome/result)
1947
- const actionSteps = ["country", "id_type", isDocFlow ? "document_capture" : "id_input", "liveness"].filter((s) => !(s === "country" && singleCountry));
1948
- const totalSteps = actionSteps.length;
1949
- const actionIndex = actionSteps.indexOf(this.currentStep);
1950
- const pct = this.currentStep === "result" ? 100
2114
+ // Two-stage action steps — Stage 1: validate identity, Stage 2: liveness
2115
+ const stage1Steps = isDocFlow
2116
+ ? ["country", "id_type", "document_capture", "ocr_gate", "document_confirm"].filter(s => !(s === "country" && singleCountry))
2117
+ : ["country", "id_type", "id_input", "confirm_identity"].filter(s => !(s === "country" && singleCountry));
2118
+ const stage2Steps = ["liveness"];
2119
+ const allActionSteps = [...stage1Steps, ...stage2Steps];
2120
+ const totalSteps = allActionSteps.length;
2121
+ const actionIndex = allActionSteps.indexOf(this.currentStep);
2122
+ const pct = this.currentStep === "done" || this.currentStep === "result" ? 100
1951
2123
  : actionIndex >= 0 ? ((actionIndex + 1) / totalSteps) * 100
1952
2124
  : 0;
1953
2125
  if (this.progressBar) {
1954
2126
  this.progressBar.style.width = `${pct}%`;
1955
2127
  }
2128
+ // Stage label: "Stage 1 of 2: Verify Identity" / "Stage 2 of 2: Face Check"
1956
2129
  if (this.stepLabel) {
1957
2130
  if (this.currentStep === "loading" || this.currentStep === "welcome") {
1958
2131
  this.stepLabel.textContent = "";
1959
2132
  }
1960
- else if (this.currentStep === "result") {
1961
- this.stepLabel.textContent = "Complete ✓";
2133
+ else if (this.currentStep === "done" || this.currentStep === "result") {
2134
+ this.stepLabel.textContent = "Complete ✓";
1962
2135
  }
1963
- else if (actionIndex >= 0) {
1964
- this.stepLabel.textContent = `Step ${actionIndex + 1} of ${totalSteps}`;
2136
+ else if (stage2Steps.includes(this.currentStep)) {
2137
+ this.stepLabel.textContent = "Stage 2 of 2: Face Check";
2138
+ }
2139
+ else if (stage1Steps.includes(this.currentStep)) {
2140
+ this.stepLabel.textContent = isDocFlow ? "Stage 1 of 2: Scan Document" : "Stage 1 of 2: Verify Identity";
1965
2141
  }
1966
2142
  else {
1967
2143
  this.stepLabel.textContent = "";
1968
2144
  }
1969
2145
  }
1970
- // Back button — show in header for navigable steps, hidden elsewhere
2146
+ // W3: Stage dots — dot 1 lights once Stage 1 is reached, dot 2 once Stage 2 is reached
2147
+ if (this.stageDots) {
2148
+ const dots = Array.from(this.stageDots.children);
2149
+ const inStage1 = stage1Steps.includes(this.currentStep);
2150
+ const inStage2 = stage2Steps.includes(this.currentStep);
2151
+ const isComplete = this.currentStep === "done" || this.currentStep === "result";
2152
+ const stage1Active = inStage1 || inStage2 || isComplete;
2153
+ const stage2Active = inStage2 || isComplete;
2154
+ if (dots[0])
2155
+ dots[0].style.background = stage1Active ? this.theme.primary : this.theme.border;
2156
+ if (dots[1])
2157
+ dots[1].style.background = stage2Active ? this.theme.primary : this.theme.border;
2158
+ }
2159
+ // Back button — show for navigable steps only
1971
2160
  if (this.headerBackBtn) {
1972
- const countries = Object.keys(this.sdkConfig?.channels || {});
1973
2161
  const backTargets = {
1974
2162
  id_type: () => this.showStep(countries.length > 1 ? "country" : "welcome"),
1975
2163
  country: () => this.showStep("welcome"),
1976
2164
  id_input: () => this.showStep("id_type"),
2165
+ confirm_identity: () => this.showStep("id_input"),
1977
2166
  document_capture: () => { this.docCapture?.destroy(); this.showStep("id_type"); },
2167
+ document_confirm: () => { this.docCapture?.destroy(); this.showStep("document_capture"); },
1978
2168
  };
1979
2169
  const backAction = backTargets[this.currentStep];
1980
2170
  if (backAction) {
@@ -2045,4 +2235,5 @@ class SmartComplyFlow {
2045
2235
  }
2046
2236
  }
2047
2237
  exports.SmartComplyFlow = SmartComplyFlow;
2238
+ SmartComplyFlow.MAX_RETRIES = 3;
2048
2239
  //# sourceMappingURL=SmartComplyFlow.js.map