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