smartcomply-web-sdk 1.0.43 → 1.0.47

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 (35) hide show
  1. package/dist/esm/flow/SmartComplyFlow.d.ts +22 -4
  2. package/dist/esm/flow/SmartComplyFlow.d.ts.map +1 -1
  3. package/dist/esm/flow/SmartComplyFlow.js +904 -492
  4. package/dist/esm/flow/SmartComplyFlow.js.map +1 -1
  5. package/dist/esm/flow/theme.d.ts +7 -1
  6. package/dist/esm/flow/theme.d.ts.map +1 -1
  7. package/dist/esm/flow/theme.js +27 -2
  8. package/dist/esm/flow/theme.js.map +1 -1
  9. package/dist/esm/modules/liveness/LivenessUI.d.ts +1 -1
  10. package/dist/esm/modules/liveness/LivenessUI.d.ts.map +1 -1
  11. package/dist/esm/modules/liveness/LivenessUI.js +5 -5
  12. package/dist/esm/modules/liveness/LivenessUI.js.map +1 -1
  13. package/dist/esm/modules/liveness/liveness.d.ts +10 -5
  14. package/dist/esm/modules/liveness/liveness.d.ts.map +1 -1
  15. package/dist/esm/modules/liveness/liveness.js +87 -43
  16. package/dist/esm/modules/liveness/liveness.js.map +1 -1
  17. package/dist/flow/SmartComplyFlow.d.ts +22 -4
  18. package/dist/flow/SmartComplyFlow.d.ts.map +1 -1
  19. package/dist/flow/SmartComplyFlow.js +904 -492
  20. package/dist/flow/SmartComplyFlow.js.map +1 -1
  21. package/dist/flow/theme.d.ts +7 -1
  22. package/dist/flow/theme.d.ts.map +1 -1
  23. package/dist/flow/theme.js +27 -2
  24. package/dist/flow/theme.js.map +1 -1
  25. package/dist/modules/liveness/LivenessUI.d.ts +1 -1
  26. package/dist/modules/liveness/LivenessUI.d.ts.map +1 -1
  27. package/dist/modules/liveness/LivenessUI.js +5 -5
  28. package/dist/modules/liveness/LivenessUI.js.map +1 -1
  29. package/dist/modules/liveness/liveness.d.ts +10 -5
  30. package/dist/modules/liveness/liveness.d.ts.map +1 -1
  31. package/dist/modules/liveness/liveness.js +87 -43
  32. package/dist/modules/liveness/liveness.js.map +1 -1
  33. package/dist/smartcomply.browser.js +828 -461
  34. package/dist/smartcomply.browser.js.map +3 -3
  35. 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,16 +118,114 @@ 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
- // ─────────────────────────────────────────────────────────────────────
128
+ // ─────────────────────────────────────────────────────────────────────
129
+ // ── Widget-side message sanitizer ─────────────────────────────────────────────
130
+ // Strips anything technical before showing text to the end user.
131
+ // Keeps messages that are already written plainly in natural language.
132
+ function _sanitizeUserMessage(raw) {
133
+ if (!raw)
134
+ return null;
135
+ const lower = raw.toLowerCase();
136
+ // Block anything that looks internal / technical
137
+ const blocklist = [
138
+ "smartcomply", "face comparison service", "session id", "session_id",
139
+ "provider", "ocr", "textract", "rekognition", "deepface",
140
+ "ivs", "celery", "exception", "traceback", "str(e)",
141
+ "error:", "failed:", "django", "python", "http ",
142
+ "comparison mode", "liveness mode", "callback",
143
+ "face_comparison", "face_match", "confidence_percentage",
144
+ "threshold_percentage", "polling_fallback", "resolved_by",
145
+ ];
146
+ for (const blocked of blocklist) {
147
+ if (lower.includes(blocked))
148
+ return null;
149
+ }
150
+ // Block snake_case (internal field names accidentally leaking through)
151
+ if (/[a-z]+_[a-z]+/.test(raw) && !/\s/.test(raw.trim()))
152
+ return null;
153
+ // Block anything starting with a lowercase letter (likely a code/key)
154
+ if (raw.trim()[0] === raw.trim()[0].toLowerCase() && /^[a-z]/.test(raw.trim()))
155
+ return null;
156
+ return raw.trim();
157
+ }
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 ─────────────────────────────────────────
208
+ function _formatDocType(code) {
209
+ const map = {
210
+ passport: "Passport",
211
+ national_id: "National ID",
212
+ nin_slip: "NIN Slip",
213
+ nin_with_face: "National Identity Number",
214
+ drivers_license: "Driver's Licence",
215
+ voters_card: "Voter's Card",
216
+ kenya_id: "Kenya National ID",
217
+ ghana_id: "Ghana Card",
218
+ };
219
+ if (!code)
220
+ return "";
221
+ const key = code.toLowerCase().replace(/[\s-]/g, "_");
222
+ if (map[key])
223
+ return map[key];
224
+ // Fallback: title-case the code
225
+ return code.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
226
+ }
126
227
  // SmartComplyFlow
127
- // ─────────────────────────────────────────────────────────────────────
228
+ // ─────────────────────────────────────────────────────────────────────
128
229
  // Add CSS animation keyframes for spin animations
129
230
  const style = document.createElement('style');
130
231
  style.textContent = `
@@ -165,6 +266,25 @@ export class SmartComplyFlow {
165
266
  this.livenessEntryId = null;
166
267
  this.isDestroyed = false;
167
268
  this.headerBackBtn = null;
269
+ // Retry safety — max 3 confirmation rejections or liveness failures per session
270
+ this._confirmRetries = 0;
271
+ this._livenessFailures = 0;
272
+ // IVS result stored for confirm_identity screen
273
+ this._ivsName = "";
274
+ this._ivsGender = "";
275
+ // OCR result stored for document_confirm screen
276
+ this._ocrName = "";
277
+ this._ocrDocNumber = "";
278
+ this._ocrExpiry = "";
279
+ this._ocrFaceImageUrl = "";
280
+ this._ocrIsExpired = false;
281
+ this._ocrExpirySoon = false;
282
+ // Camera pre-warm stream (created during confirmation screen, used in liveness)
283
+ this._prewarmedStream = null;
284
+ // OCR gate poll timer — stored so it can be cancelled on navigation or close
285
+ this._ocrPollTimer = null;
286
+ // UI timeout from _startStatusPolling — stored so close() can cancel it
287
+ this._uiTimeoutId = null;
168
288
  // Session expiry
169
289
  this.sessionExpiryTimer = null;
170
290
  // Status polling (result screen)
@@ -204,6 +324,11 @@ export class SmartComplyFlow {
204
324
  close() {
205
325
  this.isDestroyed = true;
206
326
  this.docCapture?.destroy();
327
+ // Release any pre-warmed camera stream
328
+ if (this._prewarmedStream) {
329
+ this._prewarmedStream.getTracks().forEach(t => t.stop());
330
+ this._prewarmedStream = null;
331
+ }
207
332
  if (this.sessionExpiryTimer) {
208
333
  clearTimeout(this.sessionExpiryTimer);
209
334
  this.sessionExpiryTimer = null;
@@ -212,13 +337,23 @@ export class SmartComplyFlow {
212
337
  clearTimeout(this._pollStatusTimer);
213
338
  this._pollStatusTimer = null;
214
339
  }
340
+ // B6: cancel OCR gate poll timer
341
+ if (this._ocrPollTimer) {
342
+ clearTimeout(this._ocrPollTimer);
343
+ this._ocrPollTimer = null;
344
+ }
345
+ // P4: cancel result-screen UI timeout
346
+ if (this._uiTimeoutId) {
347
+ clearTimeout(this._uiTimeoutId);
348
+ this._uiTimeoutId = null;
349
+ }
215
350
  if (this.overlay) {
216
351
  this.overlay.style.opacity = "0";
217
352
  setTimeout(() => {
218
353
  try {
219
354
  this.overlay?.remove();
220
355
  }
221
- catch (_) { /* extension interference — safe to ignore */ }
356
+ catch (_) { /* extension interference — safe to ignore */ }
222
357
  this.overlay = null;
223
358
  this.modal = null;
224
359
  this.contentArea = null;
@@ -226,9 +361,9 @@ export class SmartComplyFlow {
226
361
  }
227
362
  this.options.onClose?.();
228
363
  }
229
- // ─────────────────────────────────────────────────────────────────
364
+ // ─────────────────────────────────────────────────────────────────
230
365
  // Initialization
231
- // ─────────────────────────────────────────────────────────────────
366
+ // ─────────────────────────────────────────────────────────────────
232
367
  mount() {
233
368
  // Inject keyframes
234
369
  this.injectStyles();
@@ -246,6 +381,9 @@ export class SmartComplyFlow {
246
381
  // Modal
247
382
  this.modal = document.createElement("div");
248
383
  this.modal.id = "sc-flow-modal";
384
+ this.modal.setAttribute("role", "dialog");
385
+ this.modal.setAttribute("aria-modal", "true");
386
+ this.modal.setAttribute("aria-label", "Identity verification");
249
387
  this.modal.style.cssText = `
250
388
  width:100%;max-width:580px;max-height:95vh;
251
389
  background:${this.theme.bg};
@@ -271,6 +409,9 @@ export class SmartComplyFlow {
271
409
  // Content area (scrollable)
272
410
  this.contentArea = document.createElement("div");
273
411
  this.contentArea.id = "sc-flow-content";
412
+ this.contentArea.setAttribute("role", "main");
413
+ this.contentArea.setAttribute("aria-live", "polite");
414
+ this.contentArea.setAttribute("aria-atomic", "false");
274
415
  this.contentArea.style.cssText = `
275
416
  flex:1;overflow-y:auto;padding:16px 20px;
276
417
  -webkit-overflow-scrolling:touch;
@@ -307,12 +448,12 @@ export class SmartComplyFlow {
307
448
  }
308
449
  // Load config
309
450
  this.sdkConfig = await this.sdk.initializeConfig();
310
- // Apply theme from config
451
+ // Apply theme from config — use theme_hex to override primary colour if set
311
452
  if (this.sdkConfig.theme) {
312
- this.theme = getTheme(this.sdkConfig.theme);
453
+ this.theme = getTheme(this.sdkConfig.theme, this.sdkConfig.theme_hex || null);
313
454
  this.applyTheme();
314
455
  }
315
- // Update brand name
456
+ // Update brand name in header
316
457
  if (this.brandLabel && this.sdkConfig.brand_name) {
317
458
  this.brandLabel.textContent = this.sdkConfig.brand_name;
318
459
  }
@@ -324,13 +465,18 @@ export class SmartComplyFlow {
324
465
  this.showErrorStep(err.message || "Failed to initialize. Please try again.");
325
466
  }
326
467
  }
327
- // ─────────────────────────────────────────────────────────────────
468
+ // ─────────────────────────────────────────────────────────────────
328
469
  // Step rendering
329
- // ─────────────────────────────────────────────────────────────────
470
+ // ─────────────────────────────────────────────────────────────────
330
471
  showStep(step) {
331
472
  this.currentStep = step;
332
473
  if (!this.contentArea)
333
474
  return;
475
+ // P1/B6: cancel any in-flight OCR gate poll when user navigates away
476
+ if (this._ocrPollTimer) {
477
+ clearTimeout(this._ocrPollTimer);
478
+ this._ocrPollTimer = null;
479
+ }
334
480
  // Update progress
335
481
  this.updateProgress();
336
482
  // Animate out current content
@@ -368,20 +514,32 @@ export class SmartComplyFlow {
368
514
  case "id_input":
369
515
  this.renderIdInput(wrapper);
370
516
  break;
517
+ case "confirm_identity":
518
+ this.renderConfirmIdentity(wrapper);
519
+ break;
371
520
  case "document_capture":
372
521
  this.renderDocumentCapture(wrapper);
373
522
  break;
523
+ case "ocr_gate":
524
+ this.renderOcrGate(wrapper);
525
+ break;
526
+ case "document_confirm":
527
+ this.renderDocumentConfirm(wrapper);
528
+ break;
374
529
  case "processing":
375
- this.renderProcessing(wrapper);
530
+ // C1: processing step is dead code — flow now goes to 'done' after submit
376
531
  break;
377
532
  case "liveness":
378
533
  this.renderLiveness(wrapper);
379
534
  break;
535
+ case "done":
536
+ this.renderDone(wrapper);
537
+ break;
380
538
  case "result":
381
- this.renderResult(wrapper);
539
+ // C2: result step is dead code — showResult/renderResult removed
382
540
  break;
383
541
  case "error":
384
- break; // handled separately
542
+ break;
385
543
  }
386
544
  this.contentArea.appendChild(wrapper);
387
545
  // Animate in
@@ -390,7 +548,7 @@ export class SmartComplyFlow {
390
548
  wrapper.style.transform = "translateX(0)";
391
549
  });
392
550
  }
393
- // ── Loading ─────────────────────────────────────────────────────
551
+ // ── Loading ─────────────────────────────────────────────────────
394
552
  renderLoading(container) {
395
553
  container.style.cssText += "display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:300px;gap:16px;";
396
554
  const spinnerWrap = document.createElement("div");
@@ -421,7 +579,7 @@ export class SmartComplyFlow {
421
579
  sub.textContent = "Connecting to the verification service...";
422
580
  container.appendChild(sub);
423
581
  }
424
- // ── Welcome ────────────────────────────────────────────────────
582
+ // ── Welcome ────────────────────────────────────────────────────
425
583
  renderWelcome(container) {
426
584
  container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:12px;";
427
585
  const isDocFlow = this.isDocumentFlow();
@@ -436,7 +594,7 @@ export class SmartComplyFlow {
436
594
  background:linear-gradient(135deg,${this.theme.primary}22,${this.theme.primaryGlow});
437
595
  border:1.5px solid ${this.theme.primary}33;
438
596
  `;
439
- iconWrap.textContent = isDocFlow ? "🪪" : "🛡️";
597
+ iconWrap.textContent = isDocFlow ? "🪪" : "🛡️";
440
598
  topRow.appendChild(iconWrap);
441
599
  const badge = document.createElement("div");
442
600
  badge.style.cssText = `
@@ -447,7 +605,7 @@ export class SmartComplyFlow {
447
605
  color:${this.theme.primary};
448
606
  border:1px solid ${this.theme.primary}30;
449
607
  `;
450
- badge.textContent = isDocFlow ? "📄 Document Verification" : "🔍 Data Verification";
608
+ badge.textContent = isDocFlow ? "📄 Document Verification" : "🔍 Data Verification";
451
609
  topRow.appendChild(badge);
452
610
  container.appendChild(topRow);
453
611
  // Title + subtitle (compact)
@@ -458,7 +616,11 @@ export class SmartComplyFlow {
458
616
  color:${this.theme.text};font-size:20px;font-weight:700;
459
617
  margin:0;line-height:1.2;
460
618
  `;
461
- title.textContent = "Identity Verification";
619
+ // W2: Use client brand name in title
620
+ const brandName = this.sdkConfig?.brand_name;
621
+ title.textContent = brandName
622
+ ? `Verify your identity for ${brandName}`
623
+ : "Identity Verification";
462
624
  titleWrap.appendChild(title);
463
625
  const desc = document.createElement("p");
464
626
  desc.style.cssText = `
@@ -466,21 +628,21 @@ export class SmartComplyFlow {
466
628
  margin:0;max-width:300px;
467
629
  `;
468
630
  desc.textContent = isDocFlow
469
- ? "Scan your ID document and take a quick selfie to confirm your identity."
470
- : "Enter your ID details and take a quick selfie to confirm your identity.";
631
+ ? "You'll need your document and camera access. This takes about 2 minutes."
632
+ : "You'll need your ID number and camera access. This takes about 2 minutes.";
471
633
  titleWrap.appendChild(desc);
472
634
  container.appendChild(titleWrap);
473
- // Steps preview — compact rows
635
+ // Steps preview — compact rows
474
636
  const steps = isDocFlow
475
637
  ? [
476
- { icon: "🪪", text: "Choose your document type", sub: "Passport, National ID, Driver License" },
477
- { icon: "📷", text: "Scan your document", sub: "Upload or capture a photo of your ID" },
478
- { icon: "🤳", text: "Face check", sub: "Quick selfie to confirm it's you" },
638
+ { icon: "🪪", text: "Choose your document type", sub: "Passport, National ID, Driver License" },
639
+ { icon: "📷", text: "Scan your document", sub: "Upload or capture a photo of your ID" },
640
+ { icon: "🤳", text: "Face check", sub: "Quick selfie to confirm it's you" },
479
641
  ]
480
642
  : [
481
- { icon: "🆔", text: "Choose your ID type", sub: "NIN, BVN, Voter's Card and more" },
482
- { icon: "📝", text: "Enter your ID details", sub: "Exactly as shown on your ID" },
483
- { icon: "🤳", text: "Face check", sub: "Quick selfie to confirm it's you" },
643
+ { icon: "🆔", text: "Choose your ID type", sub: "NIN, BVN, Voter's Card and more" },
644
+ { icon: "📝", text: "Enter your ID details", sub: "Exactly as shown on your ID" },
645
+ { icon: "🤳", text: "Face check", sub: "Quick selfie to confirm it's you" },
484
646
  ];
485
647
  const stepsWrap = document.createElement("div");
486
648
  stepsWrap.style.cssText = `
@@ -521,21 +683,21 @@ export class SmartComplyFlow {
521
683
  });
522
684
  container.appendChild(stepsWrap);
523
685
  // CTA
524
- const btn = this.createPrimaryButton("Get Started →");
686
+ const btn = this.createPrimaryButton("Get Started →");
525
687
  btn.style.cssText += "margin-top:2px;";
526
688
  btn.addEventListener("click", () => this.showStep("country"));
527
689
  container.appendChild(btn);
528
- // Trust badge — "Powered by Adhere" already lives in the persistent
690
+ // Trust badge — "Powered by Adhere" already lives in the persistent
529
691
  // modal footer below; repeating it here just cost vertical space.
530
692
  const trust = document.createElement("div");
531
693
  trust.style.cssText = `
532
694
  display:flex;align-items:center;gap:6px;
533
695
  color:${this.theme.textMuted};font-size:10.5px;
534
696
  `;
535
- trust.textContent = "🔒 End-to-end encrypted";
697
+ trust.textContent = "🔒 End-to-end encrypted";
536
698
  container.appendChild(trust);
537
699
  }
538
- // ── Country Select ──────────────────────────────────────────────
700
+ // ── Country Select ──────────────────────────────────────────────
539
701
  renderCountrySelect(container) {
540
702
  container.style.cssText += "display:flex;flex-direction:column;gap:14px;";
541
703
  const channels = this.sdkConfig?.channels || {};
@@ -560,7 +722,7 @@ export class SmartComplyFlow {
560
722
  container.appendChild(card);
561
723
  }
562
724
  }
563
- // ── ID Type Select ──────────────────────────────────────────────
725
+ // ── ID Type Select ──────────────────────────────────────────────
564
726
  renderIdTypeSelect(container) {
565
727
  container.style.cssText += "display:flex;flex-direction:column;gap:14px;";
566
728
  const globalDocFlow = this.isDocumentFlow();
@@ -588,9 +750,9 @@ export class SmartComplyFlow {
588
750
  // Add back side indicator to subtitle if needed
589
751
  let subtitle = info.desc;
590
752
  if (requiresBack && isThisDocFlow) {
591
- subtitle += " ⚠️ Both sides required";
753
+ subtitle += " ⚠️ Both sides required";
592
754
  }
593
- // Clean display name — strip "with face", "(NIN)", "(BVN)" from user-visible label
755
+ // Clean display name — strip "with face", "(NIN)", "(BVN)" from user-visible label
594
756
  const displayName = typeName
595
757
  .replace(/\s*with\s+face\s*/gi, ' ')
596
758
  .replace(/\s*\(NIN\)\s*/gi, ' ')
@@ -627,7 +789,7 @@ export class SmartComplyFlow {
627
789
  container.appendChild(card);
628
790
  }
629
791
  }
630
- // ── ID Input (data verification — dynamic fields) ─────────────
792
+ // ── ID Input (data verification — dynamic fields) ─────────────
631
793
  renderIdInput(container) {
632
794
  container.style.cssText += "display:flex;flex-direction:column;gap:14px;";
633
795
  const dataFields = this.selectedChannelFields.filter((f) => f.type !== "upload");
@@ -646,7 +808,7 @@ export class SmartComplyFlow {
646
808
  ? `Enter your ${this.selectedIdTypeName || this.selectedIdType} details exactly as they appear on your ID.`
647
809
  : "Fill in the fields below to continue.";
648
810
  container.appendChild(subtitle);
649
- // Edge case: no data fields configured — show fallback and allow submission
811
+ // Edge case: no data fields configured — show fallback and allow submission
650
812
  if (dataFields.length === 0) {
651
813
  const empty = document.createElement("div");
652
814
  empty.style.cssText = `
@@ -661,10 +823,45 @@ export class SmartComplyFlow {
661
823
  container.appendChild(autoBtn);
662
824
  return;
663
825
  }
826
+ const _FIELD_RULES = [
827
+ { match: "BVN", rule: { minLen: 11, maxLen: 11, numeric: true, hint: "BVN must be exactly 11 digits" } },
828
+ { match: "NIN", rule: { minLen: 11, maxLen: 11, numeric: true, hint: "NIN must be exactly 11 digits" } },
829
+ { match: "NATIONAL IDENTITY", rule: { minLen: 11, maxLen: 11, numeric: true, hint: "NIN must be exactly 11 digits" } },
830
+ { match: "PASSPORT", rule: { minLen: 6, maxLen: 20, alphanumeric: true, hint: "Passport number must be 6–20 alphanumeric characters" } },
831
+ { match: "DRIVER", rule: { minLen: 3, maxLen: 30, alphanumeric: true, hint: "Licence number must be 3–30 alphanumeric characters" } },
832
+ { match: "VOTER", rule: { minLen: 6, maxLen: 30, alphanumeric: true, hint: "Voter ID must be 6–30 alphanumeric characters" } },
833
+ { match: "GHANA", rule: { minLen: 8, maxLen: 20, alphanumeric: true, hint: "Ghana Card number must be 8–20 alphanumeric characters" } },
834
+ { match: "SSNIT", rule: { minLen: 7, maxLen: 15, alphanumeric: true, hint: "SSNIT number must be 7–15 alphanumeric characters" } },
835
+ { match: "TIN", rule: { minLen: 8, maxLen: 20, alphanumeric: true, hint: "TIN must be 8–20 characters" } },
836
+ { match: "KRA", rule: { minLen: 10, maxLen: 12, alphanumeric: true, hint: "KRA PIN must be 10–12 characters" } },
837
+ ];
838
+ function getFieldRule(label) {
839
+ const upper = label.toUpperCase();
840
+ for (const entry of _FIELD_RULES) {
841
+ if (upper.includes(entry.match))
842
+ return entry.rule;
843
+ }
844
+ return null;
845
+ }
846
+ function validateFieldValue(label, value) {
847
+ const rule = getFieldRule(label);
848
+ if (!rule)
849
+ return null; // no rule = no validation
850
+ const v = value.trim();
851
+ if (rule.numeric && !/^\d+$/.test(v))
852
+ return `${label} must contain digits only`;
853
+ if (rule.alphanumeric && !/^[A-Za-z0-9]+$/.test(v))
854
+ return `${label} must be alphanumeric (letters and digits only)`;
855
+ if (rule.minLen && v.length < rule.minLen)
856
+ return rule.hint;
857
+ if (rule.maxLen && v.length > rule.maxLen)
858
+ return rule.hint;
859
+ return null;
860
+ }
664
861
  // Track input elements for validation
665
862
  const inputEls = [];
666
863
  for (const field of dataFields) {
667
- // Send the raw label as the payload key — the backend's
864
+ // Send the raw label as the payload key — the backend's
668
865
  // _SDK_FIELD_KEY_MAP looks up the exact label text (e.g. "National ID",
669
866
  // "CAC Number") to remap it to the canonical IVS key. Pre-normalizing
670
867
  // it here (as this used to do) produced keys like "national_id" that
@@ -673,7 +870,10 @@ export class SmartComplyFlow {
673
870
  const fieldKey = field.label;
674
871
  const fieldWrap = document.createElement("div");
675
872
  fieldWrap.style.cssText = "display:flex;flex-direction:column;gap:6px;";
873
+ const labelId = `sc-field-label-${fieldKey.replace(/\s+/g, '-').toLowerCase()}`;
676
874
  const label = document.createElement("label");
875
+ label.id = labelId;
876
+ label.htmlFor = `sc-field-input-${fieldKey.replace(/\s+/g, '-').toLowerCase()}`;
677
877
  label.style.cssText = `color:${this.theme.textMuted};font-size:10px;font-weight:700;letter-spacing:0.6px;text-transform:uppercase;`;
678
878
  label.textContent = field.label;
679
879
  fieldWrap.appendChild(label);
@@ -711,7 +911,7 @@ export class SmartComplyFlow {
711
911
  inputEls.push({ field, el: select });
712
912
  }
713
913
  else if (field.type === "image" || field.type === "file") {
714
- // File upload field — converts to base64 for API submission
914
+ // File upload field — converts to base64 for API submission
715
915
  const fileInput = document.createElement("input");
716
916
  fileInput.type = "file";
717
917
  fileInput.accept = "image/*";
@@ -747,10 +947,18 @@ export class SmartComplyFlow {
747
947
  }
748
948
  else {
749
949
  // Text input field
950
+ const inputId = `sc-field-input-${fieldKey.replace(/\s+/g, '-').toLowerCase()}`;
951
+ const hintId = `sc-field-hint-${fieldKey.replace(/\s+/g, '-').toLowerCase()}`;
952
+ const rule = getFieldRule(field.label);
750
953
  const input = document.createElement("input");
751
954
  input.type = "text";
955
+ input.id = inputId;
956
+ input.setAttribute("aria-labelledby", labelId);
957
+ input.setAttribute("aria-describedby", hintId);
752
958
  input.placeholder = `Enter ${field.label}`;
753
- input.maxLength = 50;
959
+ input.maxLength = rule?.maxLen ? rule.maxLen + 2 : 50;
960
+ if (rule?.numeric)
961
+ input.inputMode = "numeric";
754
962
  input.style.cssText = `
755
963
  width:100%;padding:14px 16px;border-radius:12px;
756
964
  font-size:16px;font-weight:500;
@@ -761,17 +969,50 @@ export class SmartComplyFlow {
761
969
  box-sizing:border-box;
762
970
  letter-spacing:0.5px;
763
971
  `;
972
+ // Inline hint/error element
973
+ const hintEl = document.createElement("div");
974
+ hintEl.id = hintId;
975
+ hintEl.setAttribute("aria-live", "polite");
976
+ hintEl.style.cssText = `font-size:11px;min-height:14px;margin-top:-2px;`;
977
+ if (rule) {
978
+ hintEl.style.color = this.theme.textMuted;
979
+ hintEl.textContent = rule.hint;
980
+ }
764
981
  input.addEventListener("focus", () => {
765
982
  input.style.borderColor = this.theme.primary;
766
983
  });
767
984
  input.addEventListener("blur", () => {
768
- input.style.borderColor = this.theme.border;
985
+ const v = input.value.trim();
986
+ if (v) {
987
+ const err = validateFieldValue(field.label, v);
988
+ if (err) {
989
+ input.style.borderColor = this.theme.error;
990
+ hintEl.style.color = this.theme.error;
991
+ hintEl.textContent = err;
992
+ input.setAttribute("aria-invalid", "true");
993
+ }
994
+ else {
995
+ input.style.borderColor = this.theme.success || this.theme.primary;
996
+ hintEl.style.color = this.theme.textMuted;
997
+ hintEl.textContent = rule?.hint || "";
998
+ input.setAttribute("aria-invalid", "false");
999
+ }
1000
+ }
1001
+ else {
1002
+ input.style.borderColor = this.theme.border;
1003
+ }
769
1004
  });
770
1005
  input.addEventListener("input", () => {
1006
+ // Reset error styling while typing
1007
+ input.style.borderColor = this.theme.primary;
1008
+ hintEl.style.color = this.theme.textMuted;
1009
+ hintEl.textContent = rule?.hint || "";
1010
+ input.removeAttribute("aria-invalid");
771
1011
  this.collectedFields[fieldKey] = input.value.trim();
772
1012
  updateSubmitState();
773
1013
  });
774
1014
  fieldWrap.appendChild(input);
1015
+ fieldWrap.appendChild(hintEl);
775
1016
  inputEls.push({ field, el: input });
776
1017
  }
777
1018
  container.appendChild(fieldWrap);
@@ -789,9 +1030,16 @@ export class SmartComplyFlow {
789
1030
  submitBtn.disabled = true;
790
1031
  submitBtn.style.opacity = "0.5";
791
1032
  const updateSubmitState = () => {
1033
+ // All inputs must have a value AND pass format validation
792
1034
  const allFilled = inputEls.every(({ el }) => el.value.trim().length > 0);
793
- submitBtn.disabled = !allFilled;
794
- submitBtn.style.opacity = allFilled ? "1" : "0.5";
1035
+ const allValid = inputEls.every(({ field, el }) => {
1036
+ if (el.tagName === "SELECT")
1037
+ return el.value.trim().length > 0;
1038
+ return !validateFieldValue(field.label, el.value.trim());
1039
+ });
1040
+ const ok = allFilled && allValid;
1041
+ submitBtn.disabled = !ok;
1042
+ submitBtn.style.opacity = ok ? "1" : "0.5";
795
1043
  };
796
1044
  submitBtn.addEventListener("click", async () => {
797
1045
  submitBtn.disabled = true;
@@ -803,15 +1051,21 @@ export class SmartComplyFlow {
803
1051
  fields: { ...this.collectedFields },
804
1052
  });
805
1053
  if (this.verificationResult.status === "success") {
806
- // Save the first collected field value as identifier for liveness
1054
+ // Save identifier and identity_check_id for liveness step
807
1055
  const fieldValues = Object.values(this.collectedFields);
808
1056
  this.idNumber = fieldValues[0] || "";
809
- // A4: Save identity_check_id from verify response to pass to liveness
810
1057
  this.identityCheckId = this.verificationResult.data?.identity_check_id || null;
811
- this.showStep("liveness");
1058
+ // W3: Store name + gender from IVS result for confirmation screen
1059
+ const d = this.verificationResult.data || {};
1060
+ const nd = d.normalized_data || d.data || d;
1061
+ this._ivsName = [nd.first_name || nd.firstname || "", nd.last_name || nd.lastname || nd.surname || ""]
1062
+ .map(s => String(s || "").trim()).filter(Boolean).join(" ");
1063
+ this._ivsGender = nd.gender || "";
1064
+ // Route to confirmation screen — user must confirm before liveness
1065
+ this.showStep("confirm_identity");
812
1066
  }
813
1067
  else {
814
- errorEl.textContent = this.verificationResult.message || "Verification failed. Please check your details.";
1068
+ errorEl.textContent = this.verificationResult.message || "We could not verify your details. Please check and try again.";
815
1069
  errorEl.style.display = "block";
816
1070
  submitBtn.textContent = "Verify & Continue";
817
1071
  submitBtn.disabled = false;
@@ -819,14 +1073,26 @@ export class SmartComplyFlow {
819
1073
  }
820
1074
  }
821
1075
  catch (err) {
822
- // C3: Show field-level errors if available
1076
+ // Show a single plain-language error — never raw field:message format
823
1077
  const fieldErrors = err.data?.errors;
824
1078
  if (Array.isArray(fieldErrors) && fieldErrors.length > 0) {
825
- const messages = fieldErrors.map((e) => `${e.field}: ${e.message}`).join("\n");
826
- errorEl.textContent = messages;
1079
+ // Pick the first meaningful error message — strip field prefix
1080
+ const firstMsg = fieldErrors[0]?.message || fieldErrors[0]?.detail || "";
1081
+ errorEl.textContent = _sanitizeUserMessage(firstMsg)
1082
+ || "Please check your details and try again.";
1083
+ }
1084
+ else if (err.code === "VERIFICATION_FAILED") {
1085
+ errorEl.textContent = "We could not verify those details. Please check your ID number and try again.";
1086
+ }
1087
+ else if (err.code === "CHANNEL_NOT_CONFIGURED") {
1088
+ errorEl.textContent = "This ID type is not currently available. Please choose a different option.";
1089
+ }
1090
+ else if (err.code === "INSUFFICIENT_BALANCE") {
1091
+ errorEl.textContent = "Verification is temporarily unavailable. Please contact support.";
827
1092
  }
828
1093
  else {
829
- errorEl.textContent = err.message || "Verification failed";
1094
+ errorEl.textContent = _sanitizeUserMessage(err.message)
1095
+ || "Something went wrong. Please try again.";
830
1096
  }
831
1097
  errorEl.style.display = "block";
832
1098
  submitBtn.textContent = "Verify & Continue";
@@ -841,7 +1107,7 @@ export class SmartComplyFlow {
841
1107
  setTimeout(() => firstInput.focus(), 350);
842
1108
  }
843
1109
  }
844
- // ── Document Capture ────────────────────────────────────────────
1110
+ // ── Document Capture ────────────────────────────────────────────
845
1111
  renderDocumentCapture(container) {
846
1112
  container.style.cssText += "display:flex;flex-direction:column;gap:14px;";
847
1113
  // Strip "with face", "(NIN)", "(BVN)" noise from the user-visible title
@@ -882,144 +1148,408 @@ export class SmartComplyFlow {
882
1148
  this.showErrorStep(err.message || "Document capture failed. Please try again.");
883
1149
  });
884
1150
  }
885
- // ── Processing ────────────────────────────────────────────────────
886
- renderProcessing(container) {
887
- // Compact layout — fits modal without scroll
888
- container.style.cssText += "display:flex;flex-direction:column;align-items:center;gap:14px;padding:16px;";
889
- // Small spinner
890
- const animationWrap = document.createElement("div");
891
- animationWrap.style.cssText = "position:relative;width:52px;height:52px;flex-shrink:0;";
892
- const spinner = document.createElement("div");
893
- spinner.style.cssText = `
894
- position:absolute;inset:0;border-radius:50%;
895
- border:3px solid ${this.theme.border};
896
- border-top-color:${this.theme.primary};
897
- animation:sc-spin 0.9s linear infinite;
898
- `;
899
- animationWrap.appendChild(spinner);
900
- const centerIcon = document.createElement("div");
901
- centerIcon.style.cssText = `
902
- position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
903
- font-size:20px;
904
- `;
905
- centerIcon.textContent = "🔍";
906
- animationWrap.appendChild(centerIcon);
907
- container.appendChild(animationWrap);
908
- const title = document.createElement("div");
909
- title.style.cssText = `color:${this.theme.text};font-size:15px;font-weight:700;text-align:center;`;
910
- title.textContent = "Processing your document";
1151
+ // ── W3: Identity Confirmation (data verification) ───────────────
1152
+ renderConfirmIdentity(container) {
1153
+ container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:16px;padding:8px 0;";
1154
+ // W14: Check retry limit before showing screen
1155
+ if (this._confirmRetries >= SmartComplyFlow.MAX_RETRIES) {
1156
+ this._renderRetryLimitReached(container);
1157
+ return;
1158
+ }
1159
+ // W13: Pre-warm camera silently while user reads this screen
1160
+ this._prewarmCamera();
1161
+ const iconWrap = document.createElement("div");
1162
+ 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;`;
1163
+ iconWrap.textContent = "🛡️";
1164
+ container.appendChild(iconWrap);
1165
+ const title = document.createElement("h2");
1166
+ title.style.cssText = `color:${this.theme.text};font-size:18px;font-weight:700;margin:0;`;
1167
+ title.textContent = this._ivsName ? `Is this you?` : "Identity Confirmed";
911
1168
  container.appendChild(title);
912
- const subtitle = document.createElement("div");
913
- subtitle.style.cssText = `color:${this.theme.textSecondary};font-size:12px;text-align:center;max-width:260px;line-height:1.5;`;
914
- subtitle.textContent = "Scanning and verifying your ID. This usually takes a few seconds.";
915
- container.appendChild(subtitle);
916
- // Compact steps — 2-col grid so they fit without scroll
917
- const stepsContainer = document.createElement("div");
918
- stepsContainer.style.cssText = `
919
- width:100%;display:grid;grid-template-columns:1fr 1fr;gap:8px;
920
- `;
921
- const steps = [
922
- { id: 'uploading', text: 'Uploading', icon: '📤' },
923
- { id: 'scanning', text: 'Scanning', icon: '🔍' },
924
- { id: 'verifying', text: 'Verifying', icon: '✓' },
925
- { id: 'analyzing', text: 'Checking quality', icon: '🔬' },
926
- ];
927
- steps.forEach((step) => {
928
- const stepEl = document.createElement("div");
929
- stepEl.style.cssText = `
930
- display:flex;align-items:center;gap:8px;padding:9px 10px;border-radius:10px;
931
- background:${this.theme.cardBg};border:1px solid ${this.theme.border};
932
- transition:all 0.3s ease;
933
- `;
934
- stepEl.id = `processing-step-${step.id}`;
935
- const iconEl = document.createElement("div");
936
- iconEl.style.cssText = `
937
- width:26px;height:26px;border-radius:7px;flex-shrink:0;
938
- display:flex;align-items:center;justify-content:center;
939
- font-size:12px;background:${this.theme.shimmer};
940
- `;
941
- iconEl.textContent = step.icon;
942
- iconEl.id = `step-icon-${step.id}`;
943
- const textEl = document.createElement("div");
944
- textEl.style.cssText = `flex:1;color:${this.theme.textSecondary};font-size:11px;font-weight:500;`;
945
- textEl.textContent = step.text;
946
- textEl.id = `step-text-${step.id}`;
947
- const statusEl = document.createElement("div");
948
- statusEl.style.cssText = `width:16px;height:16px;border-radius:50%;border:2px solid ${this.theme.border};position:relative;flex-shrink:0;`;
949
- statusEl.id = `step-status-${step.id}`;
950
- stepEl.appendChild(iconEl);
951
- stepEl.appendChild(textEl);
952
- stepEl.appendChild(statusEl);
953
- stepsContainer.appendChild(stepEl);
1169
+ if (this._ivsName) {
1170
+ // Profile card
1171
+ const card = document.createElement("div");
1172
+ 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;`;
1173
+ const rows = [
1174
+ { label: "Name", value: this._ivsName },
1175
+ ];
1176
+ if (this._ivsGender) {
1177
+ const gLabel = this._ivsGender.toLowerCase();
1178
+ rows.push({ label: "Gender", value: gLabel === "m" || gLabel === "male" || gLabel === "1" ? "Male" : gLabel === "f" || gLabel === "female" || gLabel === "2" ? "Female" : this._ivsGender });
1179
+ }
1180
+ rows.push({ label: "ID Type", value: _formatDocType(this.selectedIdType) || this.selectedIdTypeName || this.selectedIdType });
1181
+ rows.forEach(({ label, value }) => {
1182
+ const row = document.createElement("div");
1183
+ row.style.cssText = "display:flex;justify-content:space-between;align-items:center;";
1184
+ const lEl = document.createElement("span");
1185
+ lEl.style.cssText = `color:${this.theme.textMuted};font-size:12px;font-weight:500;`;
1186
+ lEl.textContent = label;
1187
+ const vEl = document.createElement("span");
1188
+ vEl.style.cssText = `color:${this.theme.text};font-size:13px;font-weight:600;max-width:60%;text-align:right;`;
1189
+ vEl.textContent = value;
1190
+ row.appendChild(lEl);
1191
+ row.appendChild(vEl);
1192
+ card.appendChild(row);
1193
+ });
1194
+ container.appendChild(card);
1195
+ }
1196
+ else {
1197
+ const sub = document.createElement("p");
1198
+ sub.style.cssText = `color:${this.theme.textSecondary};font-size:13px;margin:0;max-width:280px;line-height:1.6;`;
1199
+ sub.textContent = "Your details have been confirmed. Please continue to the face check.";
1200
+ container.appendChild(sub);
1201
+ }
1202
+ const yesBtn = this.createPrimaryButton("Yes, that's me — continue");
1203
+ yesBtn.addEventListener("click", () => {
1204
+ // B5: Reset retry counter on successful confirmation
1205
+ this._confirmRetries = 0;
1206
+ this.showStep("liveness");
1207
+ });
1208
+ container.appendChild(yesBtn);
1209
+ const noBtn = this.createSecondaryButton("No, use a different ID");
1210
+ noBtn.addEventListener("click", () => {
1211
+ this._confirmRetries++;
1212
+ this._ivsName = "";
1213
+ this._ivsGender = "";
1214
+ this.identityCheckId = null;
1215
+ this.verificationResult = null;
1216
+ this.showStep("id_input");
954
1217
  });
955
- container.appendChild(stepsContainer);
956
- this.startProcessingAnimation();
1218
+ container.appendChild(noBtn);
957
1219
  }
958
- startProcessingAnimation() {
959
- const steps = ['uploading', 'scanning', 'verifying', 'analyzing'];
960
- let currentStepIndex = 0;
961
- const animateStep = (stepId) => {
962
- // Update step appearance
963
- const stepEl = document.getElementById(`processing-step-${stepId}`);
964
- const iconEl = document.getElementById(`step-icon-${stepId}`);
965
- const textEl = document.getElementById(`step-text-${stepId}`);
966
- const statusEl = document.getElementById(`step-status-${stepId}`);
967
- if (stepEl && iconEl && textEl && statusEl) {
968
- // Active state
969
- stepEl.style.borderColor = this.theme.primary;
970
- stepEl.style.background = `${this.theme.primary}10`;
971
- iconEl.style.background = this.theme.primary;
972
- iconEl.style.color = 'white';
973
- textEl.style.color = this.theme.text;
974
- textEl.style.fontWeight = '600';
975
- // Add spinner to status
976
- statusEl.innerHTML = `
977
- <div style="
978
- position:absolute;inset:2px;border-radius:50%;
979
- border:2px solid ${this.theme.border};
980
- border-top-color:${this.theme.primary};
981
- animation:sc-spin 0.8s linear infinite;
982
- "></div>
983
- `;
1220
+ // ── W4: Document Confirmation (document verification) ────────────
1221
+ renderDocumentConfirm(container) {
1222
+ container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:14px;padding:8px 0;";
1223
+ // W14: Check retry limit
1224
+ if (this._confirmRetries >= SmartComplyFlow.MAX_RETRIES) {
1225
+ this._renderRetryLimitReached(container);
1226
+ return;
1227
+ }
1228
+ // W13: Pre-warm camera while user reads
1229
+ this._prewarmCamera();
1230
+ const iconWrap = document.createElement("div");
1231
+ 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;`;
1232
+ iconWrap.textContent = "📄";
1233
+ container.appendChild(iconWrap);
1234
+ const title = document.createElement("h2");
1235
+ title.style.cssText = `color:${this.theme.text};font-size:18px;font-weight:700;margin:0;`;
1236
+ title.textContent = "Are these your details?";
1237
+ container.appendChild(title);
1238
+ const sub = document.createElement("p");
1239
+ sub.style.cssText = `color:${this.theme.textSecondary};font-size:13px;margin:0;max-width:280px;line-height:1.5;`;
1240
+ sub.textContent = "We read the following from your document. Please confirm they are correct before continuing.";
1241
+ container.appendChild(sub);
1242
+ // Document face thumbnail if available
1243
+ if (this._ocrFaceImageUrl) {
1244
+ const faceWrap = document.createElement("div");
1245
+ faceWrap.style.cssText = `width:80px;height:80px;border-radius:50%;overflow:hidden;border:3px solid ${this.theme.primary};flex-shrink:0;margin:0 auto;`;
1246
+ const faceImg = document.createElement("img");
1247
+ faceImg.src = this._ocrFaceImageUrl;
1248
+ faceImg.alt = "Photo from your document";
1249
+ faceImg.style.cssText = "width:100%;height:100%;object-fit:cover;";
1250
+ faceWrap.appendChild(faceImg);
1251
+ container.appendChild(faceWrap);
1252
+ }
1253
+ // Expiry soon warning
1254
+ if (this._ocrExpirySoon && !this._ocrIsExpired) {
1255
+ const warn = document.createElement("div");
1256
+ 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;`;
1257
+ warn.textContent = "⚠️ Your document expires soon. Some services may not accept it, but you can still continue.";
1258
+ container.appendChild(warn);
1259
+ }
1260
+ // Details card — show whatever OCR returned, never block on missing fields
1261
+ const card = document.createElement("div");
1262
+ 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;`;
1263
+ const docRows = [];
1264
+ if (this._ocrName)
1265
+ docRows.push({ label: "Name", value: this._ocrName });
1266
+ if (this._ocrDocNumber)
1267
+ docRows.push({ label: "Document No", value: this._ocrDocNumber });
1268
+ if (this._ocrExpiry)
1269
+ docRows.push({ label: "Expires", value: this._ocrExpiry });
1270
+ docRows.push({ label: "Document Type", value: _formatDocType(this.selectedIdType) || this.selectedIdTypeName });
1271
+ if (docRows.length === 0) {
1272
+ const fallback = document.createElement("div");
1273
+ fallback.style.cssText = `color:${this.theme.textSecondary};font-size:12px;`;
1274
+ fallback.textContent = "Document scanned successfully.";
1275
+ card.appendChild(fallback);
1276
+ }
1277
+ else {
1278
+ docRows.forEach(({ label, value }) => {
1279
+ if (!value)
1280
+ return;
1281
+ const row = document.createElement("div");
1282
+ row.style.cssText = "display:flex;justify-content:space-between;align-items:center;";
1283
+ const lEl = document.createElement("span");
1284
+ lEl.style.cssText = `color:${this.theme.textMuted};font-size:12px;font-weight:500;`;
1285
+ lEl.textContent = label;
1286
+ const vEl = document.createElement("span");
1287
+ vEl.style.cssText = `color:${this.theme.text};font-size:13px;font-weight:600;max-width:60%;text-align:right;`;
1288
+ vEl.textContent = value;
1289
+ row.appendChild(lEl);
1290
+ row.appendChild(vEl);
1291
+ card.appendChild(row);
1292
+ });
1293
+ }
1294
+ container.appendChild(card);
1295
+ const yesBtn = this.createPrimaryButton("Yes, continue");
1296
+ yesBtn.addEventListener("click", () => {
1297
+ // B5: Reset retry counter on successful confirmation
1298
+ this._confirmRetries = 0;
1299
+ this.showStep("liveness");
1300
+ });
1301
+ container.appendChild(yesBtn);
1302
+ const noBtn = this.createSecondaryButton("No, retake document");
1303
+ noBtn.addEventListener("click", () => {
1304
+ this._confirmRetries++;
1305
+ this._ocrName = "";
1306
+ this._ocrDocNumber = "";
1307
+ this._ocrExpiry = "";
1308
+ this._ocrFaceImageUrl = "";
1309
+ this._ocrIsExpired = false;
1310
+ this._ocrExpirySoon = false;
1311
+ this.livenessEntryId = null;
1312
+ this.documentBlob = null;
1313
+ this.documentBackBlob = null;
1314
+ this.showStep("document_capture");
1315
+ });
1316
+ container.appendChild(noBtn);
1317
+ }
1318
+ // ── W6+W7+W8: OCR Gate (document verification) ───────────────────
1319
+ renderOcrGate(container) {
1320
+ container.style.cssText += "display:flex;flex-direction:column;align-items:center;gap:14px;padding:20px 0;text-align:center;";
1321
+ const spinnerWrap = document.createElement("div");
1322
+ spinnerWrap.style.cssText = "position:relative;width:52px;height:52px;flex-shrink:0;";
1323
+ const ring = document.createElement("div");
1324
+ 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;`;
1325
+ const centre = document.createElement("div");
1326
+ centre.style.cssText = "position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:20px;";
1327
+ centre.textContent = "📄";
1328
+ spinnerWrap.appendChild(ring);
1329
+ spinnerWrap.appendChild(centre);
1330
+ container.appendChild(spinnerWrap);
1331
+ const statusEl = document.createElement("div");
1332
+ statusEl.setAttribute("aria-live", "polite");
1333
+ statusEl.style.cssText = `color:${this.theme.text};font-size:15px;font-weight:600;`;
1334
+ statusEl.textContent = "Reading your document…";
1335
+ container.appendChild(statusEl);
1336
+ const subEl = document.createElement("div");
1337
+ subEl.style.cssText = `color:${this.theme.textSecondary};font-size:12px;max-width:260px;line-height:1.5;`;
1338
+ subEl.textContent = "This usually takes 5–15 seconds. Please wait.";
1339
+ container.appendChild(subEl);
1340
+ if (!this.livenessEntryId) {
1341
+ this.showErrorStep("Document upload failed. Please try again.");
1342
+ return;
1343
+ }
1344
+ const entryId = this.livenessEntryId;
1345
+ const MAX_POLLS = 10; // 10 × 2s = 20s timeout
1346
+ let polls = 0;
1347
+ const poll = async () => {
1348
+ if (this.isDestroyed)
1349
+ return;
1350
+ polls++;
1351
+ try {
1352
+ const result = await this.sdk.liveness.pollResult(entryId);
1353
+ // W7: Expired document — stop, do not proceed to liveness
1354
+ if (result.is_expired === true) {
1355
+ container.innerHTML = "";
1356
+ this._renderExpiredDocument(container);
1357
+ return;
1358
+ }
1359
+ // W8: OCR quality fail — offer retake
1360
+ if (result.ocr_quality_failed === true) {
1361
+ container.innerHTML = "";
1362
+ this._renderOcrQualityFail(container, result.ocr_rejection_reason);
1363
+ return;
1364
+ }
1365
+ // OCR complete — store data and show confirmation screen
1366
+ const ocrDone = result.ocr_status === "done" || result.ocr_status === "ocr_complete"
1367
+ || result.ocr_name || result.ocr_document_number || result.ocr_expiry_date;
1368
+ if (ocrDone) {
1369
+ this._ocrName = result.ocr_name || "";
1370
+ this._ocrDocNumber = result.ocr_document_number || "";
1371
+ this._ocrExpiry = result.ocr_expiry_date || "";
1372
+ this._ocrFaceImageUrl = result.ocr_face_image_url || "";
1373
+ this._ocrIsExpired = result.is_expired === true;
1374
+ // B8: Normalise expiry date string before parsing — OCR may return
1375
+ // DD/MM/YYYY, DD-MM-YYYY, YYYY-MM-DD, or "12 May 2029" etc.
1376
+ // new Date() alone is locale-dependent on non-ISO strings.
1377
+ if (this._ocrExpiry) {
1378
+ const expDate = _parseOcrDate(this._ocrExpiry);
1379
+ if (expDate) {
1380
+ const sixMonths = new Date();
1381
+ sixMonths.setMonth(sixMonths.getMonth() + 6);
1382
+ this._ocrExpirySoon = expDate <= sixMonths && expDate > new Date();
1383
+ }
1384
+ }
1385
+ this.showStep("document_confirm");
1386
+ return;
1387
+ }
1388
+ // Still pending — keep polling
1389
+ if (polls < MAX_POLLS && !this.isDestroyed) {
1390
+ this._ocrPollTimer = setTimeout(poll, 2000);
1391
+ }
1392
+ else if (!this.isDestroyed) {
1393
+ // Timeout — proceed anyway with whatever we have
1394
+ statusEl.textContent = "Taking longer than usual…";
1395
+ subEl.textContent = "We'll continue — you can confirm your details on the next screen.";
1396
+ this._ocrPollTimer = setTimeout(() => {
1397
+ if (!this.isDestroyed)
1398
+ this.showStep("document_confirm");
1399
+ }, 2000);
1400
+ }
984
1401
  }
985
- // Move to next step after delay
986
- setTimeout(() => {
987
- // Complete current step
988
- if (stepEl && iconEl && textEl && statusEl) {
989
- stepEl.style.borderColor = this.theme.success;
990
- stepEl.style.background = `${this.theme.success}10`;
991
- iconEl.style.background = this.theme.success;
992
- statusEl.innerHTML = `
993
- <div style="
994
- position:absolute;inset:3px;border-radius:50%;
995
- background:${this.theme.success};
996
- display:flex;align-items:center;justify-content:center;
997
- color:white;font-size:10px;font-weight:bold;
998
- ">✓</div>
999
- `;
1402
+ catch {
1403
+ if (polls < MAX_POLLS && !this.isDestroyed) {
1404
+ this._ocrPollTimer = setTimeout(poll, 3000);
1000
1405
  }
1001
- currentStepIndex++;
1002
- if (currentStepIndex < steps.length) {
1003
- animateStep(steps[currentStepIndex]);
1406
+ else if (!this.isDestroyed) {
1407
+ this.showErrorStep("We could not read your document. Please check your connection and try again.");
1004
1408
  }
1005
- }, 2000); // 2 seconds per step
1409
+ }
1006
1410
  };
1007
- // Start animation
1008
- setTimeout(() => animateStep(steps[0]), 500);
1411
+ // First poll after 3s — give OCR submission time to start
1412
+ this._ocrPollTimer = setTimeout(poll, 3000);
1413
+ }
1414
+ _renderExpiredDocument(container) {
1415
+ container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:14px;padding:20px 0;";
1416
+ const icon = document.createElement("div");
1417
+ 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;`;
1418
+ icon.textContent = "📅";
1419
+ container.appendChild(icon);
1420
+ const t = document.createElement("h2");
1421
+ t.style.cssText = `color:${this.theme.text};font-size:18px;font-weight:700;margin:0;`;
1422
+ t.textContent = "Document Expired";
1423
+ container.appendChild(t);
1424
+ const d = document.createElement("p");
1425
+ d.style.cssText = `color:${this.theme.textSecondary};font-size:13px;margin:0;max-width:280px;line-height:1.6;`;
1426
+ d.textContent = "The document you submitted has expired. Please use a valid, current ID.";
1427
+ container.appendChild(d);
1428
+ const retryBtn = this.createPrimaryButton("Try a different document");
1429
+ retryBtn.addEventListener("click", () => {
1430
+ this.livenessEntryId = null;
1431
+ this.documentBlob = null;
1432
+ this.documentBackBlob = null;
1433
+ this.showStep("document_capture");
1434
+ });
1435
+ container.appendChild(retryBtn);
1436
+ const endBtn = this.createSecondaryButton("End session");
1437
+ endBtn.addEventListener("click", () => { this.options.onError?.(new Error("Document expired")); this.close(); });
1438
+ container.appendChild(endBtn);
1439
+ }
1440
+ _renderOcrQualityFail(container, reason) {
1441
+ container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:14px;padding:20px 0;";
1442
+ const icon = document.createElement("div");
1443
+ 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;`;
1444
+ icon.textContent = "🔍";
1445
+ container.appendChild(icon);
1446
+ const t = document.createElement("h2");
1447
+ t.style.cssText = `color:${this.theme.text};font-size:18px;font-weight:700;margin:0;`;
1448
+ t.textContent = "Document Photo Unclear";
1449
+ container.appendChild(t);
1450
+ const msg = reason === "image_too_blurry" ? "Your document photo is too blurry. Please retake it in good lighting with a steady hand."
1451
+ : reason === "image_too_dark" ? "Your document photo is too dark. Please retake it in better lighting."
1452
+ : reason === "image_too_bright" ? "Your document photo has too much glare. Please reduce reflections and retake."
1453
+ : reason === "document_not_detected" ? "We could not detect a document in the photo. Please ensure the full ID is visible."
1454
+ : "We could not read your document clearly. Please retake the photo in good lighting with no glare.";
1455
+ const d = document.createElement("p");
1456
+ d.style.cssText = `color:${this.theme.textSecondary};font-size:13px;margin:0;max-width:280px;line-height:1.6;`;
1457
+ d.textContent = msg;
1458
+ container.appendChild(d);
1459
+ const retryBtn = this.createPrimaryButton("Retake photo");
1460
+ retryBtn.addEventListener("click", () => {
1461
+ this.livenessEntryId = null;
1462
+ this.documentBlob = null;
1463
+ this.documentBackBlob = null;
1464
+ this.showStep("document_capture");
1465
+ });
1466
+ container.appendChild(retryBtn);
1467
+ }
1468
+ // ── W11: You're all done (end-user final screen) ─────────────────
1469
+ renderDone(container) {
1470
+ container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:16px;padding:24px 0;";
1471
+ container.setAttribute("role", "status");
1472
+ // B7: Fire onComplete immediately when the done screen renders — the end
1473
+ // user has completed their part regardless of whether they click Close.
1474
+ // Firing here means the integrator always gets the event even if the user
1475
+ // closes the modal via the ✕ button instead of the Close button.
1476
+ this.options.onComplete?.({
1477
+ entryId: this.livenessEntryId || 0,
1478
+ sessionId: this.sdk.sessionToken,
1479
+ status: "processing",
1480
+ submittedAt: new Date().toISOString(),
1481
+ verificationResult: this.verificationResult || undefined,
1482
+ });
1483
+ const iconWrap = document.createElement("div");
1484
+ 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;`;
1485
+ iconWrap.textContent = "✓";
1486
+ container.appendChild(iconWrap);
1487
+ const title = document.createElement("h2");
1488
+ title.style.cssText = `color:${this.theme.text};font-size:22px;font-weight:700;margin:0;`;
1489
+ title.textContent = "You're all done!";
1490
+ container.appendChild(title);
1491
+ const brandName = this.sdkConfig?.brand_name || "the service";
1492
+ const desc = document.createElement("p");
1493
+ desc.style.cssText = `color:${this.theme.textSecondary};font-size:14px;line-height:1.6;margin:0;max-width:300px;`;
1494
+ desc.textContent = `Your verification is complete. ${brandName} will review and be in touch shortly.`;
1495
+ container.appendChild(desc);
1496
+ // Subtle info line
1497
+ const info = document.createElement("div");
1498
+ info.style.cssText = `color:${this.theme.textMuted};font-size:11px;display:flex;align-items:center;gap:4px;`;
1499
+ info.textContent = "🔒 Your data is encrypted and secure";
1500
+ container.appendChild(info);
1501
+ const doneBtn = this.createPrimaryButton("Close");
1502
+ doneBtn.addEventListener("click", () => {
1503
+ // onComplete already fired on render — just close the modal
1504
+ this.close();
1505
+ });
1506
+ container.appendChild(doneBtn);
1507
+ }
1508
+ // ── W14: Retry limit reached ─────────────────────────────────────
1509
+ _renderRetryLimitReached(container) {
1510
+ container.style.cssText += "display:flex;flex-direction:column;align-items:center;text-align:center;gap:14px;padding:24px 0;";
1511
+ const icon = document.createElement("div");
1512
+ 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;`;
1513
+ icon.textContent = "⚠️";
1514
+ container.appendChild(icon);
1515
+ const t = document.createElement("h2");
1516
+ t.style.cssText = `color:${this.theme.text};font-size:18px;font-weight:700;margin:0;`;
1517
+ t.textContent = "Unable to Complete Verification";
1518
+ container.appendChild(t);
1519
+ const d = document.createElement("p");
1520
+ d.style.cssText = `color:${this.theme.textSecondary};font-size:13px;margin:0;max-width:280px;line-height:1.6;`;
1521
+ d.textContent = "We were unable to complete your verification at this time. Please contact support or try again later.";
1522
+ container.appendChild(d);
1523
+ const closeBtn = this.createPrimaryButton("Close");
1524
+ closeBtn.addEventListener("click", () => { this.options.onError?.(new Error("Retry limit reached")); this.close(); });
1525
+ container.appendChild(closeBtn);
1526
+ }
1527
+ // ── W13: Camera pre-warm ─────────────────────────────────────────
1528
+ _prewarmCamera() {
1529
+ if (this._prewarmedStream || this.isDestroyed)
1530
+ return;
1531
+ navigator.mediaDevices?.getUserMedia?.({ video: { facingMode: "user" } })
1532
+ .then(stream => {
1533
+ if (this.isDestroyed) {
1534
+ stream.getTracks().forEach(t => t.stop());
1535
+ return;
1536
+ }
1537
+ this._prewarmedStream = stream;
1538
+ })
1539
+ .catch(() => { });
1009
1540
  }
1010
1541
  /**
1011
1542
  * Process document capture result with front/back support
1012
1543
  */
1013
1544
  async processDocumentCaptureResult(result, captureContainer, container) {
1014
- // Store both front and back blobs
1015
- this.documentBlob = result.front;
1016
- this.documentBackBlob = result.back || null;
1017
- // Process the front image through the existing flow
1018
- await this.processDocumentBlob(result.front, captureContainer, container);
1019
- // Log back image capture
1020
- if (result.back) {
1021
- console.log("Back image captured, size:", result.back.size);
1022
- }
1545
+ // B2+P2: Compress both images once here. Do NOT assign documentBlob twice
1546
+ // (old code set it to the raw blob first, then overwrote with compressed).
1547
+ // processDocumentBlob also ran _compressImage — that second pass is removed
1548
+ // below so compression only happens once.
1549
+ this.documentBlob = await _compressImage(result.front, 1600, 0.85);
1550
+ this.documentBackBlob = result.back ? await _compressImage(result.back, 1600, 0.85) : null;
1551
+ // Process the front image — pass the already-compressed blob
1552
+ await this.processDocumentBlob(this.documentBlob, captureContainer, container);
1023
1553
  }
1024
1554
  /**
1025
1555
  * Process document blob - common method for both CameraView and DocumentCapture
@@ -1029,7 +1559,7 @@ export class SmartComplyFlow {
1029
1559
  let retryCount = 0;
1030
1560
  const attemptProcessing = async () => {
1031
1561
  try {
1032
- // Validate blob
1562
+ // Validate blob — compression already done in processDocumentCaptureResult (P2)
1033
1563
  if (!blob || blob.size === 0) {
1034
1564
  throw new Error("Failed to capture document. Please try again.");
1035
1565
  }
@@ -1042,13 +1572,20 @@ export class SmartComplyFlow {
1042
1572
  <div style="display:flex;flex-direction:column;align-items:center;gap:14px;padding:28px 20px;">
1043
1573
  <div style="position:relative;width:52px;height:52px;">
1044
1574
  <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>
1045
- <div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:20px;">🔍</div>
1575
+ <div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:20px;">🔍</div>
1046
1576
  </div>
1047
1577
  <div style="color:${this.theme.text};font-size:14px;font-weight:600;">Scanning document...</div>
1048
1578
  <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>
1049
1579
  </div>`;
1050
- // Create liveness entry with document
1051
- const identifier = `DOC-${Date.now()}`;
1580
+ // Create liveness entry with document.
1581
+ // Use the first collected field value as identifier when available
1582
+ // (document flow has no data-verification step, so collectedFields is
1583
+ // empty — fall back to the selected ID type code so the webhook
1584
+ // subject.identifier is at least human-meaningful, not a raw timestamp).
1585
+ const identifier = Object.values(this.collectedFields)[0]
1586
+ || this.idNumber
1587
+ || this.selectedIdType
1588
+ || `DOC-${Date.now()}`;
1052
1589
  const COUNTRY_CODES = {
1053
1590
  nigeria: "NG", ghana: "GH", kenya: "KE",
1054
1591
  south_africa: "ZA", united_states: "US", united_kingdom: "GB", global: "US",
@@ -1092,10 +1629,9 @@ export class SmartComplyFlow {
1092
1629
  }
1093
1630
  }
1094
1631
  this.livenessEntryId = createResult.id;
1095
- // Document is already uploaded as part of liveness.create() above.
1096
- // Proceed straight to liveness — the backend runs OCR + face comparison
1097
- // after the liveness video is submitted, so there is no need to wait here.
1098
- this.showStep("liveness");
1632
+ // W6: Route to OCR gate — poll until OCR result is ready, validate
1633
+ // expiry/quality, then show document_confirm before liveness starts.
1634
+ this.showStep("ocr_gate");
1099
1635
  }
1100
1636
  catch (err) {
1101
1637
  console.error("Document processing failed:", err);
@@ -1108,7 +1644,7 @@ export class SmartComplyFlow {
1108
1644
  <div style="display:flex;flex-direction:column;align-items:center;gap:16px;padding:28px 20px;text-align:center;">
1109
1645
  <div style="position:relative;width:52px;height:52px;">
1110
1646
  <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>
1111
- <div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:20px;">🔄</div>
1647
+ <div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:20px;">🔄</div>
1112
1648
  </div>
1113
1649
  <div style="color:${this.theme.text};font-size:14px;font-weight:600;">Retrying...</div>
1114
1650
  <div style="color:${this.theme.textSecondary};font-size:12px;max-width:240px;line-height:1.5;">Attempt ${retryCount} of ${maxRetries}</div>
@@ -1127,55 +1663,65 @@ export class SmartComplyFlow {
1127
1663
  await attemptProcessing();
1128
1664
  }
1129
1665
  handleDocumentProcessingError(error, captureContainer) {
1130
- let errorMessage = "Document verification failed. Please try again.";
1666
+ // Map technical errors to user-friendly messages — never pass raw error strings
1667
+ let userMessage = "We could not verify your document. Please try again.";
1131
1668
  let canRetry = true;
1132
- if (error.message) {
1133
- if (error.message.includes("expired")) {
1134
- errorMessage = "Document has expired. Please use a valid document.";
1135
- canRetry = false;
1136
- }
1137
- else if (error.message.includes("quality too low")) {
1138
- errorMessage = "Image quality too low. Please ensure the document is well-lit and clearly visible.";
1139
- }
1140
- else if (error.message.includes("Network") || error.message.includes("fetch")) {
1141
- errorMessage = "Network connection failed. Please check your internet connection and try again.";
1142
- }
1143
- else if (error.message.includes("413") || error.message.includes("too large")) {
1144
- errorMessage = "Document image is too large. Please use a smaller image or retake the photo.";
1145
- }
1146
- else if (error.message.includes("415") || error.message.includes("format")) {
1147
- errorMessage = "Unsupported image format. Please use JPEG or PNG images.";
1148
- }
1669
+ const raw = (error?.message || "").toLowerCase();
1670
+ if (raw.includes("expired")) {
1671
+ userMessage = "The document you submitted has expired. Please use a valid, current ID.";
1672
+ canRetry = false;
1673
+ }
1674
+ else if (raw.includes("quality too low") || raw.includes("blurry") || raw.includes("blur")) {
1675
+ userMessage = "The document photo isn't clear enough. Please retake it in good lighting with no glare.";
1676
+ }
1677
+ else if (raw.includes("not detected") || raw.includes("document_not_detected")) {
1678
+ userMessage = "We could not detect a document in the photo. Please ensure the full ID is visible and try again.";
1679
+ }
1680
+ else if (raw.includes("network") || raw.includes("fetch") || raw.includes("connection")) {
1681
+ userMessage = "A connection error occurred. Please check your internet and try again.";
1682
+ }
1683
+ else if (raw.includes("too large") || raw.includes("413")) {
1684
+ userMessage = "The document image is too large. Please retake the photo.";
1685
+ }
1686
+ else if (raw.includes("format") || raw.includes("415")) {
1687
+ userMessage = "Unsupported image format. Please use JPEG or PNG.";
1688
+ }
1689
+ else if (raw.includes("type mismatch") || raw.includes("document_type_mismatch")) {
1690
+ userMessage = "The document does not match the selected ID type. Please choose the correct ID type.";
1149
1691
  }
1150
1692
  if (captureContainer) {
1151
- captureContainer.innerHTML = `
1152
- <div style="display:flex;flex-direction:column;align-items:center;gap:16px;padding:28px 20px;text-align:center;">
1153
- <div style="width:56px;height:56px;border-radius:50%;background:${this.theme.errorBg || '#FEE'};border:3px solid ${this.theme.error || '#DC3545'};display:flex;align-items:center;justify-content:center;font-size:26px;">❌</div>
1154
- <div style="color:${this.theme.text};font-size:14px;font-weight:700;">Verification Failed</div>
1155
- <div style="color:${this.theme.textSecondary};font-size:13px;max-width:240px;line-height:1.6;">${errorMessage}</div>
1156
- ${canRetry ? `
1157
- <button id="retry-btn" style="
1158
- margin-top:8px;padding:12px 24px;border-radius:8px;
1159
- background:${this.theme.primary};color:white;
1160
- border:none;font-size:14px;font-weight:600;cursor:pointer;
1161
- transition:all 0.2s ease;
1162
- ">Try Again</button>
1163
- ` : ''}
1164
- </div>`;
1693
+ // Build with DOM — never interpolate user-derived content into innerHTML
1694
+ const wrap = document.createElement("div");
1695
+ wrap.style.cssText = "display:flex;flex-direction:column;align-items:center;gap:16px;padding:28px 20px;text-align:center;";
1696
+ wrap.setAttribute("role", "alert");
1697
+ const icon = document.createElement("div");
1698
+ 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;`;
1699
+ icon.textContent = "🔍";
1700
+ wrap.appendChild(icon);
1701
+ const titleEl = document.createElement("div");
1702
+ titleEl.style.cssText = `color:${this.theme.text};font-size:14px;font-weight:700;`;
1703
+ titleEl.textContent = "Document Could Not Be Verified";
1704
+ wrap.appendChild(titleEl);
1705
+ const msgEl = document.createElement("div");
1706
+ msgEl.style.cssText = `color:${this.theme.textSecondary};font-size:13px;max-width:260px;line-height:1.6;`;
1707
+ msgEl.textContent = userMessage;
1708
+ wrap.appendChild(msgEl);
1165
1709
  if (canRetry) {
1166
- const retryBtn = captureContainer.querySelector("#retry-btn");
1167
- if (retryBtn) {
1168
- retryBtn.addEventListener("click", () => {
1169
- this.showStep("document_capture");
1170
- });
1171
- }
1710
+ const retryBtn = document.createElement("button");
1711
+ retryBtn.textContent = "Try Again";
1712
+ retryBtn.setAttribute("aria-label", "Retake document photo");
1713
+ retryBtn.style.cssText = `margin-top:8px;padding:12px 24px;border-radius:8px;background:${this.theme.primary};color:#fff;border:none;font-size:14px;font-weight:600;cursor:pointer;`;
1714
+ retryBtn.addEventListener("click", () => this.showStep("document_capture"));
1715
+ wrap.appendChild(retryBtn);
1172
1716
  }
1717
+ captureContainer.innerHTML = "";
1718
+ captureContainer.appendChild(wrap);
1173
1719
  }
1174
1720
  else {
1175
- this.showErrorStep(errorMessage);
1721
+ this.showErrorStep(userMessage);
1176
1722
  }
1177
1723
  }
1178
- // ── Liveness ────────────────────────────────────────────────────
1724
+ // ── Liveness ────────────────────────────────────────────────────
1179
1725
  renderLiveness(container) {
1180
1726
  container.style.cssText += "display:flex;flex-direction:column;gap:14px;";
1181
1727
  const title = this.createStepTitle("Face verification");
@@ -1188,15 +1734,14 @@ export class SmartComplyFlow {
1188
1734
  const livenessContainer = document.createElement("div");
1189
1735
  livenessContainer.style.cssText = "margin:8px 0;width:100%;";
1190
1736
  container.appendChild(livenessContainer);
1191
- const identifier = this.idNumber || `DOC-${Date.now()}`;
1737
+ // W10: For data verification, identifier is already held by the backend
1738
+ // via identity_check_id — do not re-send it in liveness create.
1739
+ const identifier = this.identityCheckId
1740
+ ? "" // data verification — session holds context via identity_check_id
1741
+ : (this.idNumber || Object.values(this.collectedFields)[0] || this.selectedIdType || `DOC-${Date.now()}`);
1192
1742
  const COUNTRY_CODES = {
1193
- nigeria: "NG",
1194
- ghana: "GH",
1195
- kenya: "KE",
1196
- south_africa: "ZA",
1197
- united_states: "US",
1198
- united_kingdom: "GB",
1199
- global: "US",
1743
+ nigeria: "NG", ghana: "GH", kenya: "KE",
1744
+ south_africa: "ZA", united_states: "US", united_kingdom: "GB", global: "US",
1200
1745
  };
1201
1746
  const country = COUNTRY_CODES[this.selectedCountry] || this.selectedCountry.toUpperCase().slice(0, 2);
1202
1747
  // Start liveness check (this handles camera, detection, recording, submission)
@@ -1209,237 +1754,86 @@ export class SmartComplyFlow {
1209
1754
  document: this.documentBlob || undefined,
1210
1755
  document_back: this.documentBackBlob || undefined,
1211
1756
  identity_check: this.identityCheckId || undefined,
1757
+ verification_type: this.identityCheckId
1758
+ ? "data_verification"
1759
+ : this.documentBlob
1760
+ ? "document_verification"
1761
+ : "liveness_only",
1762
+ // B4: pass pre-warmed stream so camera.open() is skipped
1763
+ prewarmedStream: this._prewarmedStream || undefined,
1212
1764
  }, ["BLINK", "TURN_HEAD", "OPEN_MOUTH"], this.livenessEntryId || undefined)
1213
1765
  .then((result) => {
1214
- this.showResult(result);
1766
+ // W11: After submit, end user is done — show "You're all done" screen.
1767
+ // Backend processing continues in background; client receives webhook.
1768
+ // Track liveness failures for retry limit (W14).
1769
+ if (result.status === "failed") {
1770
+ this._livenessFailures++;
1771
+ }
1772
+ if (result.id) {
1773
+ this.livenessEntryId = result.id;
1774
+ }
1775
+ this.showStep("done");
1215
1776
  })
1216
1777
  .catch((err) => {
1778
+ this._livenessFailures++;
1217
1779
  this.showErrorStep(err.message || "Liveness verification failed. Please try again.");
1218
1780
  });
1219
1781
  }
1220
- // ── Result ──────────────────────────────────────────────────────
1221
- showResult(submitResult) {
1222
- this.currentStep = "result";
1223
- this.updateProgress();
1224
- if (!this.contentArea)
1225
- return;
1226
- this.contentArea.innerHTML = "";
1227
- const wrapper = document.createElement("div");
1228
- wrapper.style.cssText = `
1229
- display:flex;flex-direction:column;align-items:center;
1230
- text-align:center;gap:16px;padding:20px 0;
1231
- opacity:0;transform:scale(0.95);transition:all 0.4s ease;
1232
- `;
1233
- // Icon — starts as a spinner, updates when status resolves
1234
- const iconWrap = document.createElement("div");
1235
- iconWrap.style.cssText = `
1236
- width:80px;height:80px;border-radius:50%;
1237
- display:flex;align-items:center;justify-content:center;
1238
- font-size:36px;
1239
- background:${this.theme.cardBg};
1240
- border:3px solid ${this.theme.border};
1241
- `;
1242
- 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>`;
1243
- wrapper.appendChild(iconWrap);
1244
- const title = document.createElement("h2");
1245
- title.style.cssText = `color:${this.theme.text};font-size:20px;font-weight:700;margin:0;`;
1246
- title.textContent = "Verification Submitted";
1247
- wrapper.appendChild(title);
1248
- const desc = document.createElement("p");
1249
- desc.style.cssText = `color:${this.theme.textSecondary};font-size:14px;line-height:1.6;margin:0;max-width:300px;`;
1250
- desc.textContent = "Processing your identity check. This takes around 30 seconds.";
1251
- wrapper.appendChild(desc);
1252
- // Details card
1253
- const details = document.createElement("div");
1254
- details.style.cssText = `
1255
- width:100%;padding:16px;border-radius:12px;
1256
- background:${this.theme.cardBg};border:1px solid ${this.theme.border};
1257
- text-align:left;
1258
- `;
1259
- // Status badge — mutable DOM node so polling can update it
1260
- const statusBadge = document.createElement("span");
1261
- statusBadge.style.cssText = `font-size:12px;font-weight:600;color:${this.theme.warning};`;
1262
- statusBadge.textContent = "Processing…";
1263
- const statusRow = document.createElement("div");
1264
- statusRow.style.cssText = "display:flex;justify-content:space-between;margin-bottom:8px;";
1265
- statusRow.innerHTML = `<span style="color:${this.theme.textMuted};font-size:12px;">Status</span>`;
1266
- statusRow.appendChild(statusBadge);
1267
- // Build rows via DOM (not innerHTML+=) to keep statusBadge live in the document
1268
- const entryRow = document.createElement("div");
1269
- entryRow.style.cssText = "display:flex;justify-content:space-between;margin-bottom:8px;";
1270
- entryRow.innerHTML = `
1271
- <span style="color:${this.theme.textMuted};font-size:12px;">Entry ID</span>
1272
- <span style="color:${this.theme.text};font-size:12px;font-weight:600;">${submitResult.id}</span>
1273
- `;
1274
- details.appendChild(entryRow);
1275
- details.appendChild(statusRow);
1276
- const submittedRow = document.createElement("div");
1277
- submittedRow.style.cssText = "display:flex;justify-content:space-between;margin-top:8px;";
1278
- submittedRow.innerHTML = `
1279
- <span style="color:${this.theme.textMuted};font-size:12px;">Submitted</span>
1280
- <span style="color:${this.theme.text};font-size:12px;">${submitResult.submitted_at ? new Date(submitResult.submitted_at).toLocaleString() : "Just now"}</span>
1281
- `;
1282
- details.appendChild(submittedRow);
1283
- wrapper.appendChild(details);
1284
- // Failure reason — hidden until status = failed
1285
- const failureMsg = document.createElement("div");
1286
- failureMsg.style.cssText = `
1287
- display:none;width:100%;padding:12px 14px;border-radius:10px;
1288
- background:${this.theme.errorBg};border:1px solid ${this.theme.error};
1289
- color:${this.theme.error};font-size:12px;text-align:left;line-height:1.5;
1290
- `;
1291
- wrapper.appendChild(failureMsg);
1292
- // Done button
1293
- const doneBtn = this.createPrimaryButton("Done");
1294
- let finalStatus = submitResult.status;
1295
- doneBtn.addEventListener("click", () => {
1296
- if (this._pollStatusTimer) {
1297
- clearTimeout(this._pollStatusTimer);
1298
- this._pollStatusTimer = null;
1299
- }
1300
- this.options.onComplete?.({
1301
- entryId: submitResult.id,
1302
- sessionId: this.sdk.sessionToken,
1303
- status: finalStatus,
1304
- submittedAt: submitResult.submitted_at,
1305
- verificationResult: this.verificationResult || undefined,
1306
- });
1307
- this.close();
1308
- });
1309
- wrapper.appendChild(doneBtn);
1310
- this.contentArea.appendChild(wrapper);
1311
- requestAnimationFrame(() => {
1312
- wrapper.style.opacity = "1";
1313
- wrapper.style.transform = "scale(1)";
1314
- });
1315
- // Start polling for the async result
1316
- this._startStatusPolling(submitResult.id, iconWrap, statusBadge, title, desc, failureMsg, (resolved) => { finalStatus = resolved; });
1317
- }
1318
- _startStatusPolling(entryId, iconWrap, statusBadge, title, desc, failureMsg, onResolved) {
1319
- const MAX_POLLS = 25; // 25 × 3 s = 75 s + 4 s initial = ~79 s max
1320
- let polls = 0;
1321
- // Hard 30 s UI timer — stops spinner even if backend is still processing
1322
- let uiTimeoutId = setTimeout(() => {
1323
- if (!this.isDestroyed)
1324
- applyTimeout();
1325
- }, 30000);
1326
- const clearUiTimeout = () => { if (uiTimeoutId) {
1327
- clearTimeout(uiTimeoutId);
1328
- uiTimeoutId = null;
1329
- } };
1330
- const applyTimeout = () => {
1331
- // Stop polling for real — the message below tells the user it's safe
1332
- // to close and that the result will arrive via webhook/dashboard.
1333
- // Silently continuing to poll in the background (up to another ~49s)
1334
- // used to mean the UI could suddenly flip to Verified/Failed after
1335
- // already telling the user to walk away, with no warning either way.
1336
- if (this._pollStatusTimer) {
1337
- clearTimeout(this._pollStatusTimer);
1338
- this._pollStatusTimer = null;
1339
- }
1340
- iconWrap.innerHTML = "⏱";
1341
- iconWrap.style.background = this.theme.cardBg;
1342
- iconWrap.style.border = `3px solid ${this.theme.warning}`;
1343
- iconWrap.style.color = this.theme.warning;
1344
- iconWrap.style.fontSize = "30px";
1345
- statusBadge.style.color = this.theme.textMuted;
1346
- statusBadge.textContent = "Still processing…";
1347
- title.textContent = "Verification In Progress";
1348
- desc.textContent = "Your verification is being processed. Your result will be delivered via your dashboard or webhook. You can safely close this.";
1349
- };
1350
- const applyPassed = () => {
1351
- clearUiTimeout();
1352
- iconWrap.innerHTML = "✓";
1353
- iconWrap.style.background = this.theme.successBg;
1354
- iconWrap.style.border = `3px solid ${this.theme.success}`;
1355
- iconWrap.style.color = this.theme.success;
1356
- iconWrap.style.fontSize = "36px";
1357
- statusBadge.style.color = this.theme.success;
1358
- statusBadge.textContent = "Verified ✓";
1359
- title.textContent = "Identity Verified";
1360
- desc.textContent = "Your identity check passed successfully.";
1361
- };
1362
- const applyFailed = (reason) => {
1363
- clearUiTimeout();
1364
- iconWrap.innerHTML = "✕";
1365
- iconWrap.style.background = this.theme.errorBg;
1366
- iconWrap.style.border = `3px solid ${this.theme.error}`;
1367
- iconWrap.style.color = this.theme.error;
1368
- iconWrap.style.fontSize = "32px";
1369
- statusBadge.style.color = this.theme.error;
1370
- statusBadge.textContent = "Failed";
1371
- title.textContent = "Verification Failed";
1372
- desc.textContent = "Your identity check could not be completed.";
1373
- if (reason) {
1374
- failureMsg.textContent = reason;
1375
- failureMsg.style.display = "block";
1376
- }
1377
- };
1378
- const poll = async () => {
1379
- if (this.isDestroyed)
1380
- return;
1381
- polls++;
1382
- try {
1383
- const result = await this.sdk.liveness.pollResult(entryId);
1384
- if (result.status === "passed") {
1385
- onResolved("passed");
1386
- applyPassed();
1387
- return;
1388
- }
1389
- if (result.status === "failed") {
1390
- onResolved(result.status);
1391
- const reason = result.failure_reason || "Verification could not be completed. Please try again.";
1392
- applyFailed(reason);
1393
- return;
1394
- }
1395
- // Still pending/processing — schedule next poll if budget remains
1396
- if (polls < MAX_POLLS && !this.isDestroyed) {
1397
- this._pollStatusTimer = setTimeout(poll, 3000);
1398
- }
1399
- else if (!this.isDestroyed) {
1400
- applyTimeout(); // budget exhausted — stop spinner, show timeout state
1401
- }
1402
- }
1403
- catch {
1404
- // Network hiccup — retry with a slightly longer delay
1405
- if (polls < MAX_POLLS && !this.isDestroyed) {
1406
- this._pollStatusTimer = setTimeout(poll, 5000);
1407
- }
1408
- else if (!this.isDestroyed) {
1409
- applyTimeout(); // budget exhausted — stop spinner
1410
- }
1411
- }
1412
- };
1413
- // First poll after 4 s — gives Celery task time to complete
1414
- this._pollStatusTimer = setTimeout(poll, 4000);
1415
- }
1416
1782
  renderResult(_container) {
1417
- // Handled by showResult() directly
1783
+ // C2: Dead code — flow routes to 'done' step, not 'result'. Kept as stub.
1418
1784
  }
1419
- // ── Error ───────────────────────────────────────────────────────
1785
+ // ── Error ───────────────────────────────────────────────────────
1420
1786
  showErrorStep(message) {
1421
1787
  this.currentStep = "error";
1422
1788
  if (!this.contentArea)
1423
1789
  return;
1424
1790
  this.contentArea.innerHTML = "";
1425
1791
  const wrapper = document.createElement("div");
1792
+ wrapper.setAttribute("role", "alert");
1426
1793
  wrapper.style.cssText = `
1427
1794
  display:flex;flex-direction:column;align-items:center;
1428
1795
  text-align:center;gap:16px;padding:20px 0;
1429
1796
  `;
1797
+ // Classify the error into a user-friendly category
1430
1798
  const isCameraErr = /camera|permission|notallowed|notfound/i.test(message);
1431
1799
  const isNetworkErr = /network|timeout|connection|fetch/i.test(message);
1432
- const isAuthErr = /session|token|expired|authentication/i.test(message);
1433
- const errorIcon = isCameraErr ? "\uD83D\uDCF7" : isNetworkErr ? "\uD83D\uDCE1" : isAuthErr ? "\u23F1" : "\u2717";
1800
+ const isAuthErr = /session.*expired|token.*expired|authentication/i.test(message);
1801
+ const isDocQuality = /blurry|blur|quality|glare|dark|bright|lighting/i.test(message);
1802
+ const isExpiredDoc = /document.*expired|expired.*document/i.test(message);
1803
+ const isFaceMatch = /face.*match|selfie.*match|does not match/i.test(message);
1804
+ const errorIcon = isCameraErr ? "📷"
1805
+ : isNetworkErr ? "📡"
1806
+ : isAuthErr ? "⏱"
1807
+ : isDocQuality ? "🔍"
1808
+ : isExpiredDoc ? "📅"
1809
+ : isFaceMatch ? "🤳"
1810
+ : "✕";
1434
1811
  const errorTitle = isCameraErr ? "Camera Access Required"
1435
1812
  : isNetworkErr ? "Connection Problem"
1436
1813
  : isAuthErr ? "Session Expired"
1437
- : "Verification Failed";
1814
+ : isDocQuality ? "Document Photo Unclear"
1815
+ : isExpiredDoc ? "Document Expired"
1816
+ : isFaceMatch ? "Face Match Failed"
1817
+ : "Verification Unsuccessful";
1818
+ // Map to user-facing guidance — never show raw internal message
1819
+ const userMessage = isCameraErr
1820
+ ? "Please allow camera access in your browser settings and try again."
1821
+ : isNetworkErr
1822
+ ? "Check your internet connection and try again."
1823
+ : isAuthErr
1824
+ ? "Your session has expired. Please start again."
1825
+ : isDocQuality
1826
+ ? _sanitizeUserMessage(message) || "Please retake your document photo in good lighting with no glare."
1827
+ : isExpiredDoc
1828
+ ? "The document you submitted has expired. Please use a valid, current ID."
1829
+ : isFaceMatch
1830
+ ? "Your selfie did not match the photo on your ID. Please ensure you are using your own valid document and face the camera directly."
1831
+ : _sanitizeUserMessage(message) || "We were unable to complete your verification. Please try again.";
1438
1832
  const iconWrap = document.createElement("div");
1439
1833
  iconWrap.style.cssText = `
1440
1834
  width:72px;height:72px;border-radius:50%;
1441
1835
  display:flex;align-items:center;justify-content:center;
1442
- font-size:${errorIcon === "\u2717" ? "32px" : "28px"};
1836
+ font-size:28px;
1443
1837
  background:${this.theme.errorBg};
1444
1838
  border:3px solid ${this.theme.error};
1445
1839
  `;
@@ -1451,22 +1845,16 @@ export class SmartComplyFlow {
1451
1845
  wrapper.appendChild(title);
1452
1846
  const desc = document.createElement("p");
1453
1847
  desc.style.cssText = `color:${this.theme.textSecondary};font-size:14px;line-height:1.6;margin:0;max-width:300px;`;
1454
- desc.textContent = message;
1848
+ desc.textContent = userMessage;
1455
1849
  wrapper.appendChild(desc);
1456
1850
  const retryBtn = this.createPrimaryButton("Try Again");
1851
+ retryBtn.setAttribute("aria-label", "Try verification again");
1457
1852
  retryBtn.addEventListener("click", () => {
1458
- // If session already exists, go back to welcome without re-initializing
1459
- // (re-initializing would fail with CLIENT_ID_ALREADY_USED)
1460
- if (this.sdk.sessionToken && this.sdkConfig) {
1461
- this.showStep("welcome");
1462
- }
1463
- else {
1464
- // Only re-initialize if session was never created
1465
- this.initialize();
1466
- }
1853
+ this.initialize();
1467
1854
  });
1468
1855
  wrapper.appendChild(retryBtn);
1469
1856
  const closeBtn = this.createSecondaryButton("Close");
1857
+ closeBtn.setAttribute("aria-label", "Close verification widget");
1470
1858
  closeBtn.addEventListener("click", () => {
1471
1859
  this.options.onError?.(new Error(message));
1472
1860
  this.close();
@@ -1474,11 +1862,12 @@ export class SmartComplyFlow {
1474
1862
  wrapper.appendChild(closeBtn);
1475
1863
  this.contentArea.appendChild(wrapper);
1476
1864
  }
1477
- // ─────────────────────────────────────────────────────────────────
1865
+ // ─────────────────────────────────────────────────────────────────
1478
1866
  // UI Helpers
1479
- // ─────────────────────────────────────────────────────────────────
1867
+ // ─────────────────────────────────────────────────────────────────
1480
1868
  createHeader() {
1481
1869
  const header = document.createElement("div");
1870
+ header.setAttribute("role", "banner");
1482
1871
  header.style.cssText = `
1483
1872
  display:flex;align-items:center;justify-content:space-between;
1484
1873
  padding:11px 20px;border-bottom:1px solid ${this.theme.border};
@@ -1488,6 +1877,7 @@ export class SmartComplyFlow {
1488
1877
  leftArea.style.cssText = "display:flex;align-items:center;gap:8px;min-width:0;";
1489
1878
  this.headerBackBtn = document.createElement("button");
1490
1879
  this.headerBackBtn.innerHTML = "&#8592;";
1880
+ this.headerBackBtn.setAttribute("aria-label", "Go back to previous step");
1491
1881
  this.headerBackBtn.style.cssText = `
1492
1882
  width:30px;height:30px;border-radius:8px;flex-shrink:0;
1493
1883
  display:none;align-items:center;justify-content:center;
@@ -1496,6 +1886,7 @@ export class SmartComplyFlow {
1496
1886
  background:${this.theme.cardBg};color:${this.theme.text};
1497
1887
  transition:all 0.2s ease;
1498
1888
  `;
1889
+ this.headerBackBtn.style.cssText += `:focus-visible{outline:2px solid ${this.theme.primary};outline-offset:2px;}`;
1499
1890
  this.headerBackBtn.addEventListener("mouseenter", () => {
1500
1891
  if (this.headerBackBtn) {
1501
1892
  this.headerBackBtn.style.background = this.theme.shimmer;
@@ -1508,6 +1899,7 @@ export class SmartComplyFlow {
1508
1899
  });
1509
1900
  leftArea.appendChild(this.headerBackBtn);
1510
1901
  this.brandLabel = document.createElement("div");
1902
+ this.brandLabel.setAttribute("aria-label", "Verification provider");
1511
1903
  this.brandLabel.style.cssText = `
1512
1904
  font-size:15px;font-weight:700;color:${this.theme.text};
1513
1905
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
@@ -1517,6 +1909,8 @@ export class SmartComplyFlow {
1517
1909
  header.appendChild(leftArea);
1518
1910
  // Step indicator
1519
1911
  this.stepLabel = document.createElement("div");
1912
+ this.stepLabel.setAttribute("aria-live", "polite");
1913
+ this.stepLabel.setAttribute("aria-atomic", "true");
1520
1914
  this.stepLabel.style.cssText = `
1521
1915
  font-size:12px;color:${this.theme.textMuted};
1522
1916
  font-variant-numeric:tabular-nums;flex-shrink:0;
@@ -1524,7 +1918,8 @@ export class SmartComplyFlow {
1524
1918
  header.appendChild(this.stepLabel);
1525
1919
  // Close button
1526
1920
  const closeBtn = document.createElement("button");
1527
- closeBtn.innerHTML = "✕";
1921
+ closeBtn.innerHTML = "✕";
1922
+ closeBtn.setAttribute("aria-label", "Close identity verification");
1528
1923
  closeBtn.style.cssText = `
1529
1924
  width:32px;height:32px;border-radius:8px;flex-shrink:0;
1530
1925
  display:flex;align-items:center;justify-content:center;
@@ -1571,6 +1966,7 @@ export class SmartComplyFlow {
1571
1966
  }
1572
1967
  createOptionCard(title, subtitle, onClick) {
1573
1968
  const card = document.createElement("button");
1969
+ card.setAttribute("aria-label", subtitle ? `${title}: ${subtitle}` : title);
1574
1970
  card.style.cssText = `
1575
1971
  width:100%;padding:13px 16px;border-radius:12px;
1576
1972
  background:${this.theme.cardBg};
@@ -1603,7 +1999,7 @@ export class SmartComplyFlow {
1603
1999
  }
1604
2000
  const arrow = document.createElement("span");
1605
2001
  arrow.style.cssText = `color:${this.theme.textMuted};font-size:18px;flex-shrink:0;`;
1606
- arrow.textContent = "→";
2002
+ arrow.textContent = "→";
1607
2003
  card.appendChild(textWrap);
1608
2004
  card.appendChild(arrow);
1609
2005
  return card;
@@ -1648,7 +2044,7 @@ export class SmartComplyFlow {
1648
2044
  return btn;
1649
2045
  }
1650
2046
  isDocumentFlow() {
1651
- // 1. Check verification_type — handle both string and array from backend
2047
+ // 1. Check verification_type — handle both string and array from backend
1652
2048
  const vType = this.sdkConfig?.verification_type;
1653
2049
  if (vType) {
1654
2050
  const types = Array.isArray(vType) ? vType : [vType];
@@ -1657,7 +2053,7 @@ export class SmartComplyFlow {
1657
2053
  if (types.includes("data_verification"))
1658
2054
  return false;
1659
2055
  }
1660
- // 2. Fallback: inspect channel fields — any upload field = document flow
2056
+ // 2. Fallback: inspect channel fields — any upload field = document flow
1661
2057
  const channels = this.sdkConfig?.channels || {};
1662
2058
  const allFields = Object.values(channels)
1663
2059
  .flat()
@@ -1668,38 +2064,47 @@ export class SmartComplyFlow {
1668
2064
  const isDocFlow = this.isDocumentFlow();
1669
2065
  const countries = Object.keys(this.sdkConfig?.channels || {});
1670
2066
  const singleCountry = countries.length <= 1;
1671
- // User-action steps only (what the user actually does — no loading/welcome/result)
1672
- const actionSteps = ["country", "id_type", isDocFlow ? "document_capture" : "id_input", "liveness"].filter((s) => !(s === "country" && singleCountry));
1673
- const totalSteps = actionSteps.length;
1674
- const actionIndex = actionSteps.indexOf(this.currentStep);
1675
- const pct = this.currentStep === "result" ? 100
2067
+ // Two-stage action steps — Stage 1: validate identity, Stage 2: liveness
2068
+ const stage1Steps = isDocFlow
2069
+ ? ["country", "id_type", "document_capture", "ocr_gate", "document_confirm"].filter(s => !(s === "country" && singleCountry))
2070
+ : ["country", "id_type", "id_input", "confirm_identity"].filter(s => !(s === "country" && singleCountry));
2071
+ const stage2Steps = ["liveness"];
2072
+ const allActionSteps = [...stage1Steps, ...stage2Steps];
2073
+ const totalSteps = allActionSteps.length;
2074
+ const actionIndex = allActionSteps.indexOf(this.currentStep);
2075
+ const pct = this.currentStep === "done" || this.currentStep === "result" ? 100
1676
2076
  : actionIndex >= 0 ? ((actionIndex + 1) / totalSteps) * 100
1677
2077
  : 0;
1678
2078
  if (this.progressBar) {
1679
2079
  this.progressBar.style.width = `${pct}%`;
1680
2080
  }
2081
+ // Stage label: "Stage 1 of 2: Verify Identity" / "Stage 2 of 2: Face Check"
1681
2082
  if (this.stepLabel) {
1682
2083
  if (this.currentStep === "loading" || this.currentStep === "welcome") {
1683
2084
  this.stepLabel.textContent = "";
1684
2085
  }
1685
- else if (this.currentStep === "result") {
1686
- this.stepLabel.textContent = "Complete ✓";
2086
+ else if (this.currentStep === "done" || this.currentStep === "result") {
2087
+ this.stepLabel.textContent = "Complete ✓";
2088
+ }
2089
+ else if (stage2Steps.includes(this.currentStep)) {
2090
+ this.stepLabel.textContent = "Stage 2 of 2: Face Check";
1687
2091
  }
1688
- else if (actionIndex >= 0) {
1689
- this.stepLabel.textContent = `Step ${actionIndex + 1} of ${totalSteps}`;
2092
+ else if (stage1Steps.includes(this.currentStep)) {
2093
+ this.stepLabel.textContent = isDocFlow ? "Stage 1 of 2: Scan Document" : "Stage 1 of 2: Verify Identity";
1690
2094
  }
1691
2095
  else {
1692
2096
  this.stepLabel.textContent = "";
1693
2097
  }
1694
2098
  }
1695
- // Back button — show in header for navigable steps, hidden elsewhere
2099
+ // Back button — show for navigable steps only
1696
2100
  if (this.headerBackBtn) {
1697
- const countries = Object.keys(this.sdkConfig?.channels || {});
1698
2101
  const backTargets = {
1699
2102
  id_type: () => this.showStep(countries.length > 1 ? "country" : "welcome"),
1700
2103
  country: () => this.showStep("welcome"),
1701
2104
  id_input: () => this.showStep("id_type"),
2105
+ confirm_identity: () => this.showStep("id_input"),
1702
2106
  document_capture: () => { this.docCapture?.destroy(); this.showStep("id_type"); },
2107
+ document_confirm: () => { this.docCapture?.destroy(); this.showStep("document_capture"); },
1703
2108
  };
1704
2109
  const backAction = backTargets[this.currentStep];
1705
2110
  if (backAction) {
@@ -1749,6 +2154,12 @@ export class SmartComplyFlow {
1749
2154
  #sc-flow-modal * {
1750
2155
  box-sizing: border-box;
1751
2156
  }
2157
+ #sc-flow-modal button:focus-visible,
2158
+ #sc-flow-modal select:focus-visible,
2159
+ #sc-flow-modal input:focus-visible {
2160
+ outline: 2px solid #3b82f6;
2161
+ outline-offset: 2px;
2162
+ }
1752
2163
  #sc-flow-modal::-webkit-scrollbar {
1753
2164
  width: 4px;
1754
2165
  }
@@ -1763,4 +2174,5 @@ export class SmartComplyFlow {
1763
2174
  document.head.appendChild(style);
1764
2175
  }
1765
2176
  }
2177
+ SmartComplyFlow.MAX_RETRIES = 3;
1766
2178
  //# sourceMappingURL=SmartComplyFlow.js.map