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