smartcomply-web-sdk 1.0.42 β†’ 1.0.45

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.
@@ -25,6 +25,13 @@ const COUNTRY_LABELS = {
25
25
  united_states: "πŸ‡ΊπŸ‡Έ United States",
26
26
  united_kingdom: "πŸ‡¬πŸ‡§ United Kingdom",
27
27
  };
28
+ // Backend/admin data tags document-capture fields inconsistently β€” the field
29
+ // renderer (below) already treats "image" and "file" as file inputs alongside
30
+ // "upload", so flow-routing decisions must recognize the same set or a channel
31
+ // tagged "image"/"file" silently falls back to the number-entry screen.
32
+ function isUploadFieldType(type) {
33
+ return type === "upload" || type === "image" || type === "file";
34
+ }
28
35
  function resolveIdTypeInfo(typeName, isDocumentFlow) {
29
36
  const u = typeName.toUpperCase();
30
37
  const advanced = u.includes("ADVANCED");
@@ -119,6 +126,55 @@ function resolveIdTypeInfo(typeName, isDocumentFlow) {
119
126
  };
120
127
  }
121
128
  // ─────────────────────────────────────────────────────────────────────
129
+ // ── Widget-side message sanitizer ─────────────────────────────────────────────
130
+ // Strips anything technical before showing text to the end user.
131
+ // Keeps messages that are already written plainly in natural language.
132
+ function _sanitizeUserMessage(raw) {
133
+ if (!raw)
134
+ return null;
135
+ const lower = raw.toLowerCase();
136
+ // Block anything that looks internal / technical
137
+ const blocklist = [
138
+ "smartcomply", "face comparison service", "session id", "session_id",
139
+ "provider", "ocr", "textract", "rekognition", "deepface",
140
+ "ivs", "celery", "exception", "traceback", "str(e)",
141
+ "error:", "failed:", "django", "python", "http ",
142
+ "comparison mode", "liveness mode", "callback",
143
+ "face_comparison", "face_match", "confidence_percentage",
144
+ "threshold_percentage", "polling_fallback", "resolved_by",
145
+ ];
146
+ for (const blocked of blocklist) {
147
+ if (lower.includes(blocked))
148
+ return null;
149
+ }
150
+ // Block snake_case (internal field names accidentally leaking through)
151
+ if (/[a-z]+_[a-z]+/.test(raw) && !/\s/.test(raw.trim()))
152
+ return null;
153
+ // Block anything starting with a lowercase letter (likely a code/key)
154
+ if (raw.trim()[0] === raw.trim()[0].toLowerCase() && /^[a-z]/.test(raw.trim()))
155
+ return null;
156
+ return raw.trim();
157
+ }
158
+ // ── Document type code β†’ human label ─────────────────────────────────────────
159
+ function _formatDocType(code) {
160
+ const map = {
161
+ passport: "Passport",
162
+ national_id: "National ID",
163
+ nin_slip: "NIN Slip",
164
+ nin_with_face: "National Identity Number",
165
+ drivers_license: "Driver's Licence",
166
+ voters_card: "Voter's Card",
167
+ kenya_id: "Kenya National ID",
168
+ ghana_id: "Ghana Card",
169
+ };
170
+ if (!code)
171
+ return "";
172
+ const key = code.toLowerCase().replace(/[\s-]/g, "_");
173
+ if (map[key])
174
+ return map[key];
175
+ // Fallback: title-case the code
176
+ return code.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
177
+ }
122
178
  // SmartComplyFlow
123
179
  // ─────────────────────────────────────────────────────────────────────
124
180
  // Add CSS animation keyframes for spin animations
@@ -167,6 +223,7 @@ class SmartComplyFlow {
167
223
  this._pollStatusTimer = null;
168
224
  // Components
169
225
  this.docCapture = null;
226
+ this._livenessSubmitResult = null;
170
227
  this.options = options;
171
228
  this.sdk = new Smartcomply_1.SmartComply({
172
229
  apiKey: options.apiKey,
@@ -242,6 +299,9 @@ class SmartComplyFlow {
242
299
  // Modal
243
300
  this.modal = document.createElement("div");
244
301
  this.modal.id = "sc-flow-modal";
302
+ this.modal.setAttribute("role", "dialog");
303
+ this.modal.setAttribute("aria-modal", "true");
304
+ this.modal.setAttribute("aria-label", "Identity verification");
245
305
  this.modal.style.cssText = `
246
306
  width:100%;max-width:580px;max-height:95vh;
247
307
  background:${this.theme.bg};
@@ -267,6 +327,9 @@ class SmartComplyFlow {
267
327
  // Content area (scrollable)
268
328
  this.contentArea = document.createElement("div");
269
329
  this.contentArea.id = "sc-flow-content";
330
+ this.contentArea.setAttribute("role", "main");
331
+ this.contentArea.setAttribute("aria-live", "polite");
332
+ this.contentArea.setAttribute("aria-atomic", "false");
270
333
  this.contentArea.style.cssText = `
271
334
  flex:1;overflow-y:auto;padding:16px 20px;
272
335
  -webkit-overflow-scrolling:touch;
@@ -368,7 +431,7 @@ class SmartComplyFlow {
368
431
  this.renderDocumentCapture(wrapper);
369
432
  break;
370
433
  case "processing":
371
- this.renderProcessing(wrapper);
434
+ this.renderProcessing(wrapper, this.livenessEntryId || undefined);
372
435
  break;
373
436
  case "liveness":
374
437
  this.renderLiveness(wrapper);
@@ -576,7 +639,7 @@ class SmartComplyFlow {
576
639
  const typeCode = typeof idType === "string" ? idType : (idType.code || idType.name);
577
640
  // Detect per-channel: does THIS specific channel have upload fields?
578
641
  const channelFields = typeof idType !== "string" ? (idType.fields || []) : [];
579
- const isThisDocFlow = channelFields.some((f) => f.type === "upload") || globalDocFlow;
642
+ const isThisDocFlow = channelFields.some((f) => isUploadFieldType(f.type)) || globalDocFlow;
580
643
  const info = resolveIdTypeInfo(typeName, isThisDocFlow);
581
644
  // Prefer the backend-provided back-side flag; fall back to local heuristics only when absent.
582
645
  const requiresBackFromBackend = typeof idType !== "string" ? idType.requires_back_side : undefined;
@@ -614,7 +677,7 @@ class SmartComplyFlow {
614
677
  }
615
678
  // Route: use explicit verification_type when set (more reliable than inspecting all channels)
616
679
  // Fallback to per-channel upload field check for mixed / unconfigured setups
617
- const hasUpload = (this.selectedChannelFields).some((f) => f.type === "upload");
680
+ const hasUpload = (this.selectedChannelFields).some((f) => isUploadFieldType(f.type));
618
681
  const vType = this.sdkConfig?.verification_type;
619
682
  const vTypes = vType ? (Array.isArray(vType) ? vType : [vType]) : [];
620
683
  const isDocChannel = hasUpload || (vTypes.length > 0 && vTypes.includes("document_verification"));
@@ -657,6 +720,41 @@ class SmartComplyFlow {
657
720
  container.appendChild(autoBtn);
658
721
  return;
659
722
  }
723
+ const _FIELD_RULES = [
724
+ { match: "BVN", rule: { minLen: 11, maxLen: 11, numeric: true, hint: "BVN must be exactly 11 digits" } },
725
+ { match: "NIN", rule: { minLen: 11, maxLen: 11, numeric: true, hint: "NIN must be exactly 11 digits" } },
726
+ { match: "NATIONAL IDENTITY", rule: { minLen: 11, maxLen: 11, numeric: true, hint: "NIN must be exactly 11 digits" } },
727
+ { match: "PASSPORT", rule: { minLen: 6, maxLen: 20, alphanumeric: true, hint: "Passport number must be 6–20 alphanumeric characters" } },
728
+ { match: "DRIVER", rule: { minLen: 3, maxLen: 30, alphanumeric: true, hint: "Licence number must be 3–30 alphanumeric characters" } },
729
+ { match: "VOTER", rule: { minLen: 6, maxLen: 30, alphanumeric: true, hint: "Voter ID must be 6–30 alphanumeric characters" } },
730
+ { match: "GHANA", rule: { minLen: 8, maxLen: 20, alphanumeric: true, hint: "Ghana Card number must be 8–20 alphanumeric characters" } },
731
+ { match: "SSNIT", rule: { minLen: 7, maxLen: 15, alphanumeric: true, hint: "SSNIT number must be 7–15 alphanumeric characters" } },
732
+ { match: "TIN", rule: { minLen: 8, maxLen: 20, alphanumeric: true, hint: "TIN must be 8–20 characters" } },
733
+ { match: "KRA", rule: { minLen: 10, maxLen: 12, alphanumeric: true, hint: "KRA PIN must be 10–12 characters" } },
734
+ ];
735
+ function getFieldRule(label) {
736
+ const upper = label.toUpperCase();
737
+ for (const entry of _FIELD_RULES) {
738
+ if (upper.includes(entry.match))
739
+ return entry.rule;
740
+ }
741
+ return null;
742
+ }
743
+ function validateFieldValue(label, value) {
744
+ const rule = getFieldRule(label);
745
+ if (!rule)
746
+ return null; // no rule = no validation
747
+ const v = value.trim();
748
+ if (rule.numeric && !/^\d+$/.test(v))
749
+ return `${label} must contain digits only`;
750
+ if (rule.alphanumeric && !/^[A-Za-z0-9]+$/.test(v))
751
+ return `${label} must be alphanumeric (letters and digits only)`;
752
+ if (rule.minLen && v.length < rule.minLen)
753
+ return rule.hint;
754
+ if (rule.maxLen && v.length > rule.maxLen)
755
+ return rule.hint;
756
+ return null;
757
+ }
660
758
  // Track input elements for validation
661
759
  const inputEls = [];
662
760
  for (const field of dataFields) {
@@ -669,7 +767,10 @@ class SmartComplyFlow {
669
767
  const fieldKey = field.label;
670
768
  const fieldWrap = document.createElement("div");
671
769
  fieldWrap.style.cssText = "display:flex;flex-direction:column;gap:6px;";
770
+ const labelId = `sc-field-label-${fieldKey.replace(/\s+/g, '-').toLowerCase()}`;
672
771
  const label = document.createElement("label");
772
+ label.id = labelId;
773
+ label.htmlFor = `sc-field-input-${fieldKey.replace(/\s+/g, '-').toLowerCase()}`;
673
774
  label.style.cssText = `color:${this.theme.textMuted};font-size:10px;font-weight:700;letter-spacing:0.6px;text-transform:uppercase;`;
674
775
  label.textContent = field.label;
675
776
  fieldWrap.appendChild(label);
@@ -743,10 +844,18 @@ class SmartComplyFlow {
743
844
  }
744
845
  else {
745
846
  // Text input field
847
+ const inputId = `sc-field-input-${fieldKey.replace(/\s+/g, '-').toLowerCase()}`;
848
+ const hintId = `sc-field-hint-${fieldKey.replace(/\s+/g, '-').toLowerCase()}`;
849
+ const rule = getFieldRule(field.label);
746
850
  const input = document.createElement("input");
747
851
  input.type = "text";
852
+ input.id = inputId;
853
+ input.setAttribute("aria-labelledby", labelId);
854
+ input.setAttribute("aria-describedby", hintId);
748
855
  input.placeholder = `Enter ${field.label}`;
749
- input.maxLength = 50;
856
+ input.maxLength = rule?.maxLen ? rule.maxLen + 2 : 50;
857
+ if (rule?.numeric)
858
+ input.inputMode = "numeric";
750
859
  input.style.cssText = `
751
860
  width:100%;padding:14px 16px;border-radius:12px;
752
861
  font-size:16px;font-weight:500;
@@ -757,17 +866,50 @@ class SmartComplyFlow {
757
866
  box-sizing:border-box;
758
867
  letter-spacing:0.5px;
759
868
  `;
869
+ // Inline hint/error element
870
+ const hintEl = document.createElement("div");
871
+ hintEl.id = hintId;
872
+ hintEl.setAttribute("aria-live", "polite");
873
+ hintEl.style.cssText = `font-size:11px;min-height:14px;margin-top:-2px;`;
874
+ if (rule) {
875
+ hintEl.style.color = this.theme.textMuted;
876
+ hintEl.textContent = rule.hint;
877
+ }
760
878
  input.addEventListener("focus", () => {
761
879
  input.style.borderColor = this.theme.primary;
762
880
  });
763
881
  input.addEventListener("blur", () => {
764
- input.style.borderColor = this.theme.border;
882
+ const v = input.value.trim();
883
+ if (v) {
884
+ const err = validateFieldValue(field.label, v);
885
+ if (err) {
886
+ input.style.borderColor = this.theme.error;
887
+ hintEl.style.color = this.theme.error;
888
+ hintEl.textContent = err;
889
+ input.setAttribute("aria-invalid", "true");
890
+ }
891
+ else {
892
+ input.style.borderColor = this.theme.success || this.theme.primary;
893
+ hintEl.style.color = this.theme.textMuted;
894
+ hintEl.textContent = rule?.hint || "";
895
+ input.setAttribute("aria-invalid", "false");
896
+ }
897
+ }
898
+ else {
899
+ input.style.borderColor = this.theme.border;
900
+ }
765
901
  });
766
902
  input.addEventListener("input", () => {
903
+ // Reset error styling while typing
904
+ input.style.borderColor = this.theme.primary;
905
+ hintEl.style.color = this.theme.textMuted;
906
+ hintEl.textContent = rule?.hint || "";
907
+ input.removeAttribute("aria-invalid");
767
908
  this.collectedFields[fieldKey] = input.value.trim();
768
909
  updateSubmitState();
769
910
  });
770
911
  fieldWrap.appendChild(input);
912
+ fieldWrap.appendChild(hintEl);
771
913
  inputEls.push({ field, el: input });
772
914
  }
773
915
  container.appendChild(fieldWrap);
@@ -785,9 +927,16 @@ class SmartComplyFlow {
785
927
  submitBtn.disabled = true;
786
928
  submitBtn.style.opacity = "0.5";
787
929
  const updateSubmitState = () => {
930
+ // All inputs must have a value AND pass format validation
788
931
  const allFilled = inputEls.every(({ el }) => el.value.trim().length > 0);
789
- submitBtn.disabled = !allFilled;
790
- submitBtn.style.opacity = allFilled ? "1" : "0.5";
932
+ const allValid = inputEls.every(({ field, el }) => {
933
+ if (el.tagName === "SELECT")
934
+ return el.value.trim().length > 0;
935
+ return !validateFieldValue(field.label, el.value.trim());
936
+ });
937
+ const ok = allFilled && allValid;
938
+ submitBtn.disabled = !ok;
939
+ submitBtn.style.opacity = ok ? "1" : "0.5";
791
940
  };
792
941
  submitBtn.addEventListener("click", async () => {
793
942
  submitBtn.disabled = true;
@@ -807,7 +956,7 @@ class SmartComplyFlow {
807
956
  this.showStep("liveness");
808
957
  }
809
958
  else {
810
- errorEl.textContent = this.verificationResult.message || "Verification failed. Please check your details.";
959
+ errorEl.textContent = this.verificationResult.message || "We could not verify your details. Please check and try again.";
811
960
  errorEl.style.display = "block";
812
961
  submitBtn.textContent = "Verify & Continue";
813
962
  submitBtn.disabled = false;
@@ -815,14 +964,26 @@ class SmartComplyFlow {
815
964
  }
816
965
  }
817
966
  catch (err) {
818
- // C3: Show field-level errors if available
967
+ // Show a single plain-language error β€” never raw field:message format
819
968
  const fieldErrors = err.data?.errors;
820
969
  if (Array.isArray(fieldErrors) && fieldErrors.length > 0) {
821
- const messages = fieldErrors.map((e) => `${e.field}: ${e.message}`).join("\n");
822
- errorEl.textContent = messages;
970
+ // Pick the first meaningful error message β€” strip field prefix
971
+ const firstMsg = fieldErrors[0]?.message || fieldErrors[0]?.detail || "";
972
+ errorEl.textContent = _sanitizeUserMessage(firstMsg)
973
+ || "Please check your details and try again.";
974
+ }
975
+ else if (err.code === "VERIFICATION_FAILED") {
976
+ errorEl.textContent = "We could not verify those details. Please check your ID number and try again.";
977
+ }
978
+ else if (err.code === "CHANNEL_NOT_CONFIGURED") {
979
+ errorEl.textContent = "This ID type is not currently available. Please choose a different option.";
980
+ }
981
+ else if (err.code === "INSUFFICIENT_BALANCE") {
982
+ errorEl.textContent = "Verification is temporarily unavailable. Please contact support.";
823
983
  }
824
984
  else {
825
- errorEl.textContent = err.message || "Verification failed";
985
+ errorEl.textContent = _sanitizeUserMessage(err.message)
986
+ || "Something went wrong. Please try again.";
826
987
  }
827
988
  errorEl.style.display = "block";
828
989
  submitBtn.textContent = "Verify & Continue";
@@ -878,130 +1039,140 @@ class SmartComplyFlow {
878
1039
  this.showErrorStep(err.message || "Document capture failed. Please try again.");
879
1040
  });
880
1041
  }
881
- // ── Processing ────────────────────────────────────────────────────
882
- renderProcessing(container) {
883
- // Compact layout β€” fits modal without scroll
884
- container.style.cssText += "display:flex;flex-direction:column;align-items:center;gap:14px;padding:16px;";
885
- // Small spinner
886
- const animationWrap = document.createElement("div");
887
- animationWrap.style.cssText = "position:relative;width:52px;height:52px;flex-shrink:0;";
888
- const spinner = document.createElement("div");
889
- spinner.style.cssText = `
890
- position:absolute;inset:0;border-radius:50%;
891
- border:3px solid ${this.theme.border};
892
- border-top-color:${this.theme.primary};
893
- animation:sc-spin 0.9s linear infinite;
894
- `;
895
- animationWrap.appendChild(spinner);
1042
+ // ── Processing (real polling-driven, document flow only) ────────────
1043
+ renderProcessing(container, entryId) {
1044
+ container.style.cssText += "display:flex;flex-direction:column;align-items:center;gap:16px;padding:16px;";
1045
+ // Spinner
1046
+ const spinnerWrap = document.createElement("div");
1047
+ spinnerWrap.style.cssText = "position:relative;width:56px;height:56px;flex-shrink:0;";
1048
+ const ring = document.createElement("div");
1049
+ ring.id = "sc-proc-ring";
1050
+ 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;`;
896
1051
  const centerIcon = document.createElement("div");
897
- centerIcon.style.cssText = `
898
- position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
899
- font-size:20px;
900
- `;
1052
+ centerIcon.style.cssText = "position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:22px;";
901
1053
  centerIcon.textContent = "πŸ”";
902
- animationWrap.appendChild(centerIcon);
903
- container.appendChild(animationWrap);
1054
+ spinnerWrap.appendChild(ring);
1055
+ spinnerWrap.appendChild(centerIcon);
1056
+ container.appendChild(spinnerWrap);
904
1057
  const title = document.createElement("div");
905
1058
  title.style.cssText = `color:${this.theme.text};font-size:15px;font-weight:700;text-align:center;`;
906
- title.textContent = "Processing your document";
1059
+ title.textContent = "Verifying your document";
907
1060
  container.appendChild(title);
908
- const subtitle = document.createElement("div");
909
- subtitle.style.cssText = `color:${this.theme.textSecondary};font-size:12px;text-align:center;max-width:260px;line-height:1.5;`;
910
- subtitle.textContent = "Scanning and verifying your ID. This usually takes a few seconds.";
911
- container.appendChild(subtitle);
912
- // Compact steps β€” 2-col grid so they fit without scroll
913
- const stepsContainer = document.createElement("div");
914
- stepsContainer.style.cssText = `
915
- width:100%;display:grid;grid-template-columns:1fr 1fr;gap:8px;
916
- `;
917
- const steps = [
918
- { id: 'uploading', text: 'Uploading', icon: 'πŸ“€' },
919
- { id: 'scanning', text: 'Scanning', icon: 'πŸ”' },
920
- { id: 'verifying', text: 'Verifying', icon: 'βœ“' },
921
- { id: 'analyzing', text: 'Checking quality', icon: 'πŸ”¬' },
1061
+ // Live status line β€” updated by polling
1062
+ const statusLine = document.createElement("div");
1063
+ statusLine.id = "sc-proc-status";
1064
+ statusLine.setAttribute("aria-live", "polite");
1065
+ statusLine.setAttribute("aria-atomic", "true");
1066
+ statusLine.style.cssText = `color:${this.theme.textSecondary};font-size:12px;text-align:center;max-width:260px;line-height:1.5;`;
1067
+ statusLine.textContent = "Scanning document…";
1068
+ container.appendChild(statusLine);
1069
+ // Step checklist β€” reflects real pipeline stages
1070
+ const stages = [
1071
+ { id: "ocr", label: "Reading document", icon: "πŸ“„" },
1072
+ { id: "expiry", label: "Checking expiry", icon: "πŸ“…" },
1073
+ { id: "face", label: "Face match", icon: "🀳" },
1074
+ { id: "result", label: "Finalising result", icon: "βœ“" },
922
1075
  ];
923
- steps.forEach((step) => {
924
- const stepEl = document.createElement("div");
925
- stepEl.style.cssText = `
926
- display:flex;align-items:center;gap:8px;padding:9px 10px;border-radius:10px;
927
- background:${this.theme.cardBg};border:1px solid ${this.theme.border};
928
- transition:all 0.3s ease;
1076
+ const grid = document.createElement("div");
1077
+ grid.style.cssText = "width:100%;display:grid;grid-template-columns:1fr 1fr;gap:8px;";
1078
+ stages.forEach((s) => {
1079
+ const el = document.createElement("div");
1080
+ el.id = `sc-stage-${s.id}`;
1081
+ el.style.cssText = `display:flex;align-items:center;gap:8px;padding:9px 10px;border-radius:10px;background:${this.theme.cardBg};border:1px solid ${this.theme.border};transition:all 0.3s ease;`;
1082
+ el.innerHTML = `
1083
+ <div style="width:26px;height:26px;border-radius:7px;flex-shrink:0;display:flex;align-items:center;justify-content:center;font-size:12px;background:${this.theme.shimmer};">${s.icon}</div>
1084
+ <div style="flex:1;color:${this.theme.textSecondary};font-size:11px;font-weight:500;">${s.label}</div>
1085
+ <div id="sc-stage-status-${s.id}" style="width:14px;height:14px;border-radius:50%;border:2px solid ${this.theme.border};flex-shrink:0;"></div>
929
1086
  `;
930
- stepEl.id = `processing-step-${step.id}`;
931
- const iconEl = document.createElement("div");
932
- iconEl.style.cssText = `
933
- width:26px;height:26px;border-radius:7px;flex-shrink:0;
934
- display:flex;align-items:center;justify-content:center;
935
- font-size:12px;background:${this.theme.shimmer};
936
- `;
937
- iconEl.textContent = step.icon;
938
- iconEl.id = `step-icon-${step.id}`;
939
- const textEl = document.createElement("div");
940
- textEl.style.cssText = `flex:1;color:${this.theme.textSecondary};font-size:11px;font-weight:500;`;
941
- textEl.textContent = step.text;
942
- textEl.id = `step-text-${step.id}`;
943
- const statusEl = document.createElement("div");
944
- statusEl.style.cssText = `width:16px;height:16px;border-radius:50%;border:2px solid ${this.theme.border};position:relative;flex-shrink:0;`;
945
- statusEl.id = `step-status-${step.id}`;
946
- stepEl.appendChild(iconEl);
947
- stepEl.appendChild(textEl);
948
- stepEl.appendChild(statusEl);
949
- stepsContainer.appendChild(stepEl);
1087
+ grid.appendChild(el);
950
1088
  });
951
- container.appendChild(stepsContainer);
952
- this.startProcessingAnimation();
1089
+ container.appendChild(grid);
1090
+ // Start polling immediately β€” update stages as result comes in
1091
+ if (entryId) {
1092
+ this._pollProcessingStages(entryId, statusLine, grid, stages);
1093
+ }
953
1094
  }
954
- startProcessingAnimation() {
955
- const steps = ['uploading', 'scanning', 'verifying', 'analyzing'];
956
- let currentStepIndex = 0;
957
- const animateStep = (stepId) => {
958
- // Update step appearance
959
- const stepEl = document.getElementById(`processing-step-${stepId}`);
960
- const iconEl = document.getElementById(`step-icon-${stepId}`);
961
- const textEl = document.getElementById(`step-text-${stepId}`);
962
- const statusEl = document.getElementById(`step-status-${stepId}`);
963
- if (stepEl && iconEl && textEl && statusEl) {
964
- // Active state
965
- stepEl.style.borderColor = this.theme.primary;
966
- stepEl.style.background = `${this.theme.primary}10`;
967
- iconEl.style.background = this.theme.primary;
968
- iconEl.style.color = 'white';
969
- textEl.style.color = this.theme.text;
970
- textEl.style.fontWeight = '600';
971
- // Add spinner to status
972
- statusEl.innerHTML = `
973
- <div style="
974
- position:absolute;inset:2px;border-radius:50%;
975
- border:2px solid ${this.theme.border};
976
- border-top-color:${this.theme.primary};
977
- animation:sc-spin 0.8s linear infinite;
978
- "></div>
979
- `;
980
- }
981
- // Move to next step after delay
1095
+ _pollProcessingStages(entryId, statusLine, _grid, stages) {
1096
+ // Animate stages sequentially on a timer β€” OCR takes ~5–15s,
1097
+ // face match another ~5–15s. We can't observe sub-steps from the
1098
+ // public sdk-result endpoint, so we advance stages on elapsed time
1099
+ // AND on the final status transition.
1100
+ const stageTimings = [0, 4000, 10000, 18000]; // ms after poll start
1101
+ const startTime = Date.now();
1102
+ const setStageActive = (stageId) => {
1103
+ const el = document.getElementById(`sc-stage-${stageId}`);
1104
+ const statusEl = document.getElementById(`sc-stage-status-${stageId}`);
1105
+ if (!el || !statusEl)
1106
+ return;
1107
+ el.style.borderColor = this.theme.primary;
1108
+ el.style.background = `${this.theme.primary}10`;
1109
+ statusEl.innerHTML = `<div style="position:absolute;inset:1px;border-radius:50%;border:2px solid ${this.theme.border};border-top-color:${this.theme.primary};animation:sc-spin 0.8s linear infinite;"></div>`;
1110
+ statusEl.style.position = "relative";
1111
+ };
1112
+ const setStageComplete = (stageId) => {
1113
+ const el = document.getElementById(`sc-stage-${stageId}`);
1114
+ const statusEl = document.getElementById(`sc-stage-status-${stageId}`);
1115
+ if (!el || !statusEl)
1116
+ return;
1117
+ el.style.borderColor = this.theme.success || "#22c55e";
1118
+ el.style.background = `${this.theme.success || "#22c55e"}10`;
1119
+ statusEl.innerHTML = `<div style="position:absolute;inset:2px;border-radius:50%;background:${this.theme.success || "#22c55e"};display:flex;align-items:center;justify-content:center;color:#fff;font-size:8px;font-weight:bold;">βœ“</div>`;
1120
+ statusEl.style.position = "relative";
1121
+ };
1122
+ // Advance stage indicators on timer
1123
+ stages.forEach((s, i) => {
982
1124
  setTimeout(() => {
983
- // Complete current step
984
- if (stepEl && iconEl && textEl && statusEl) {
985
- stepEl.style.borderColor = this.theme.success;
986
- stepEl.style.background = `${this.theme.success}10`;
987
- iconEl.style.background = this.theme.success;
988
- statusEl.innerHTML = `
989
- <div style="
990
- position:absolute;inset:3px;border-radius:50%;
991
- background:${this.theme.success};
992
- display:flex;align-items:center;justify-content:center;
993
- color:white;font-size:10px;font-weight:bold;
994
- ">βœ“</div>
995
- `;
996
- }
997
- currentStepIndex++;
998
- if (currentStepIndex < steps.length) {
999
- animateStep(steps[currentStepIndex]);
1125
+ if (this.isDestroyed)
1126
+ return;
1127
+ setStageActive(s.id);
1128
+ if (i > 0)
1129
+ setStageComplete(stages[i - 1].id);
1130
+ statusLine.textContent = s.id === "ocr"
1131
+ ? "Scanning document…"
1132
+ : s.id === "expiry"
1133
+ ? "Checking expiry date…"
1134
+ : s.id === "face"
1135
+ ? "Running face match…"
1136
+ : "Finalising result…";
1137
+ }, stageTimings[i]);
1138
+ });
1139
+ // Poll for real result
1140
+ const MAX_POLLS = 30; // 30 Γ— 3s = 90s max
1141
+ let polls = 0;
1142
+ const poll = async () => {
1143
+ if (this.isDestroyed)
1144
+ return;
1145
+ polls++;
1146
+ try {
1147
+ const result = await this.sdk.liveness.pollResult(entryId);
1148
+ if (result.status === "passed" || result.status === "failed") {
1149
+ // Mark all remaining stages complete
1150
+ stages.forEach((s) => setStageComplete(s.id));
1151
+ statusLine.textContent = result.status === "passed"
1152
+ ? "Verification complete βœ“"
1153
+ : "Verification finished";
1154
+ // Short delay so user sees the completed states, then show result
1155
+ setTimeout(() => {
1156
+ if (!this.isDestroyed) {
1157
+ this._livenessSubmitResult = result;
1158
+ this.showResult(result);
1159
+ }
1160
+ }, 800);
1161
+ return;
1000
1162
  }
1001
- }, 2000); // 2 seconds per step
1163
+ }
1164
+ catch { /* network hiccup β€” keep polling */ }
1165
+ if (polls < MAX_POLLS && !this.isDestroyed) {
1166
+ this._pollStatusTimer = setTimeout(poll, 3000);
1167
+ }
1168
+ else if (!this.isDestroyed) {
1169
+ // Timeout β€” transition to result screen with current status
1170
+ stages.forEach((s) => setStageComplete(s.id));
1171
+ statusLine.textContent = "Still processing β€” check back soon";
1172
+ }
1002
1173
  };
1003
- // Start animation
1004
- setTimeout(() => animateStep(steps[0]), 500);
1174
+ // First poll after 5s β€” give Celery task time to start
1175
+ this._pollStatusTimer = setTimeout(poll, 5000);
1005
1176
  }
1006
1177
  /**
1007
1178
  * Process document capture result with front/back support
@@ -1043,8 +1214,15 @@ class SmartComplyFlow {
1043
1214
  <div style="color:${this.theme.text};font-size:14px;font-weight:600;">Scanning document...</div>
1044
1215
  <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>
1045
1216
  </div>`;
1046
- // Create liveness entry with document
1047
- const identifier = `DOC-${Date.now()}`;
1217
+ // Create liveness entry with document.
1218
+ // Use the first collected field value as identifier when available
1219
+ // (document flow has no data-verification step, so collectedFields is
1220
+ // empty β€” fall back to the selected ID type code so the webhook
1221
+ // subject.identifier is at least human-meaningful, not a raw timestamp).
1222
+ const identifier = Object.values(this.collectedFields)[0]
1223
+ || this.idNumber
1224
+ || this.selectedIdType
1225
+ || `DOC-${Date.now()}`;
1048
1226
  const COUNTRY_CODES = {
1049
1227
  nigeria: "NG", ghana: "GH", kenya: "KE",
1050
1228
  south_africa: "ZA", united_states: "US", united_kingdom: "GB", global: "US",
@@ -1123,52 +1301,62 @@ class SmartComplyFlow {
1123
1301
  await attemptProcessing();
1124
1302
  }
1125
1303
  handleDocumentProcessingError(error, captureContainer) {
1126
- let errorMessage = "Document verification failed. Please try again.";
1304
+ // Map technical errors to user-friendly messages β€” never pass raw error strings
1305
+ let userMessage = "We could not verify your document. Please try again.";
1127
1306
  let canRetry = true;
1128
- if (error.message) {
1129
- if (error.message.includes("expired")) {
1130
- errorMessage = "Document has expired. Please use a valid document.";
1131
- canRetry = false;
1132
- }
1133
- else if (error.message.includes("quality too low")) {
1134
- errorMessage = "Image quality too low. Please ensure the document is well-lit and clearly visible.";
1135
- }
1136
- else if (error.message.includes("Network") || error.message.includes("fetch")) {
1137
- errorMessage = "Network connection failed. Please check your internet connection and try again.";
1138
- }
1139
- else if (error.message.includes("413") || error.message.includes("too large")) {
1140
- errorMessage = "Document image is too large. Please use a smaller image or retake the photo.";
1141
- }
1142
- else if (error.message.includes("415") || error.message.includes("format")) {
1143
- errorMessage = "Unsupported image format. Please use JPEG or PNG images.";
1144
- }
1307
+ const raw = (error?.message || "").toLowerCase();
1308
+ if (raw.includes("expired")) {
1309
+ userMessage = "The document you submitted has expired. Please use a valid, current ID.";
1310
+ canRetry = false;
1311
+ }
1312
+ else if (raw.includes("quality too low") || raw.includes("blurry") || raw.includes("blur")) {
1313
+ userMessage = "The document photo isn't clear enough. Please retake it in good lighting with no glare.";
1314
+ }
1315
+ else if (raw.includes("not detected") || raw.includes("document_not_detected")) {
1316
+ userMessage = "We could not detect a document in the photo. Please ensure the full ID is visible and try again.";
1317
+ }
1318
+ else if (raw.includes("network") || raw.includes("fetch") || raw.includes("connection")) {
1319
+ userMessage = "A connection error occurred. Please check your internet and try again.";
1320
+ }
1321
+ else if (raw.includes("too large") || raw.includes("413")) {
1322
+ userMessage = "The document image is too large. Please retake the photo.";
1323
+ }
1324
+ else if (raw.includes("format") || raw.includes("415")) {
1325
+ userMessage = "Unsupported image format. Please use JPEG or PNG.";
1326
+ }
1327
+ else if (raw.includes("type mismatch") || raw.includes("document_type_mismatch")) {
1328
+ userMessage = "The document does not match the selected ID type. Please choose the correct ID type.";
1145
1329
  }
1146
1330
  if (captureContainer) {
1147
- captureContainer.innerHTML = `
1148
- <div style="display:flex;flex-direction:column;align-items:center;gap:16px;padding:28px 20px;text-align:center;">
1149
- <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>
1150
- <div style="color:${this.theme.text};font-size:14px;font-weight:700;">Verification Failed</div>
1151
- <div style="color:${this.theme.textSecondary};font-size:13px;max-width:240px;line-height:1.6;">${errorMessage}</div>
1152
- ${canRetry ? `
1153
- <button id="retry-btn" style="
1154
- margin-top:8px;padding:12px 24px;border-radius:8px;
1155
- background:${this.theme.primary};color:white;
1156
- border:none;font-size:14px;font-weight:600;cursor:pointer;
1157
- transition:all 0.2s ease;
1158
- ">Try Again</button>
1159
- ` : ''}
1160
- </div>`;
1331
+ // Build with DOM β€” never interpolate user-derived content into innerHTML
1332
+ const wrap = document.createElement("div");
1333
+ wrap.style.cssText = "display:flex;flex-direction:column;align-items:center;gap:16px;padding:28px 20px;text-align:center;";
1334
+ wrap.setAttribute("role", "alert");
1335
+ const icon = document.createElement("div");
1336
+ icon.style.cssText = `width:56px;height:56px;border-radius:50%;background:${this.theme.errorBg};border:3px solid ${this.theme.error};display:flex;align-items:center;justify-content:center;font-size:26px;`;
1337
+ icon.textContent = "πŸ”";
1338
+ wrap.appendChild(icon);
1339
+ const titleEl = document.createElement("div");
1340
+ titleEl.style.cssText = `color:${this.theme.text};font-size:14px;font-weight:700;`;
1341
+ titleEl.textContent = "Document Could Not Be Verified";
1342
+ wrap.appendChild(titleEl);
1343
+ const msgEl = document.createElement("div");
1344
+ msgEl.style.cssText = `color:${this.theme.textSecondary};font-size:13px;max-width:260px;line-height:1.6;`;
1345
+ msgEl.textContent = userMessage;
1346
+ wrap.appendChild(msgEl);
1161
1347
  if (canRetry) {
1162
- const retryBtn = captureContainer.querySelector("#retry-btn");
1163
- if (retryBtn) {
1164
- retryBtn.addEventListener("click", () => {
1165
- this.showStep("document_capture");
1166
- });
1167
- }
1348
+ const retryBtn = document.createElement("button");
1349
+ retryBtn.textContent = "Try Again";
1350
+ retryBtn.setAttribute("aria-label", "Retake document photo");
1351
+ 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;`;
1352
+ retryBtn.addEventListener("click", () => this.showStep("document_capture"));
1353
+ wrap.appendChild(retryBtn);
1168
1354
  }
1355
+ captureContainer.innerHTML = "";
1356
+ captureContainer.appendChild(wrap);
1169
1357
  }
1170
1358
  else {
1171
- this.showErrorStep(errorMessage);
1359
+ this.showErrorStep(userMessage);
1172
1360
  }
1173
1361
  }
1174
1362
  // ── Liveness ────────────────────────────────────────────────────
@@ -1184,7 +1372,10 @@ class SmartComplyFlow {
1184
1372
  const livenessContainer = document.createElement("div");
1185
1373
  livenessContainer.style.cssText = "margin:8px 0;width:100%;";
1186
1374
  container.appendChild(livenessContainer);
1187
- const identifier = this.idNumber || `DOC-${Date.now()}`;
1375
+ const identifier = this.idNumber
1376
+ || Object.values(this.collectedFields)[0]
1377
+ || this.selectedIdType
1378
+ || `DOC-${Date.now()}`;
1188
1379
  const COUNTRY_CODES = {
1189
1380
  nigeria: "NG",
1190
1381
  ghana: "GH",
@@ -1205,9 +1396,25 @@ class SmartComplyFlow {
1205
1396
  document: this.documentBlob || undefined,
1206
1397
  document_back: this.documentBackBlob || undefined,
1207
1398
  identity_check: this.identityCheckId || undefined,
1399
+ // Tell the module which flow we're in so it can skip video for data verification
1400
+ verification_type: this.identityCheckId
1401
+ ? "data_verification"
1402
+ : this.documentBlob
1403
+ ? "document_verification"
1404
+ : "liveness_only",
1208
1405
  }, ["BLINK", "TURN_HEAD", "OPEN_MOUTH"], this.livenessEntryId || undefined)
1209
1406
  .then((result) => {
1210
- this.showResult(result);
1407
+ // For document/data verification the backend processes async.
1408
+ // Route through the processing step so the user sees real pipeline
1409
+ // progress instead of jumping straight to result.
1410
+ const isAsync = !!(this.documentBlob || this.identityCheckId);
1411
+ if (isAsync && result.id) {
1412
+ this.livenessEntryId = result.id;
1413
+ this.showStep("processing");
1414
+ }
1415
+ else {
1416
+ this.showResult(result);
1417
+ }
1211
1418
  })
1212
1419
  .catch((err) => {
1213
1420
  this.showErrorStep(err.message || "Liveness verification failed. Please try again.");
@@ -1221,12 +1428,14 @@ class SmartComplyFlow {
1221
1428
  return;
1222
1429
  this.contentArea.innerHTML = "";
1223
1430
  const wrapper = document.createElement("div");
1431
+ wrapper.setAttribute("role", "region");
1432
+ wrapper.setAttribute("aria-label", "Verification result");
1224
1433
  wrapper.style.cssText = `
1225
1434
  display:flex;flex-direction:column;align-items:center;
1226
1435
  text-align:center;gap:16px;padding:20px 0;
1227
1436
  opacity:0;transform:scale(0.95);transition:all 0.4s ease;
1228
1437
  `;
1229
- // Icon β€” starts as a spinner, updates when status resolves
1438
+ // ── Icon β€” spinner until status resolves ────────────────────────
1230
1439
  const iconWrap = document.createElement("div");
1231
1440
  iconWrap.style.cssText = `
1232
1441
  width:80px;height:80px;border-radius:50%;
@@ -1234,59 +1443,68 @@ class SmartComplyFlow {
1234
1443
  font-size:36px;
1235
1444
  background:${this.theme.cardBg};
1236
1445
  border:3px solid ${this.theme.border};
1446
+ transition:all 0.4s ease;
1237
1447
  `;
1238
1448
  iconWrap.innerHTML = `<div style="width:32px;height:32px;border-radius:50%;border:3px solid ${this.theme.border};border-top-color:${this.theme.primary};animation:sc-spin 0.9s linear infinite;"></div>`;
1239
1449
  wrapper.appendChild(iconWrap);
1450
+ // ── Title and description β€” updated by polling ───────────────────
1240
1451
  const title = document.createElement("h2");
1452
+ title.setAttribute("aria-live", "polite");
1241
1453
  title.style.cssText = `color:${this.theme.text};font-size:20px;font-weight:700;margin:0;`;
1242
- title.textContent = "Verification Submitted";
1454
+ title.textContent = "Checking your identity…";
1243
1455
  wrapper.appendChild(title);
1244
1456
  const desc = document.createElement("p");
1457
+ desc.setAttribute("aria-live", "polite");
1245
1458
  desc.style.cssText = `color:${this.theme.textSecondary};font-size:14px;line-height:1.6;margin:0;max-width:300px;`;
1246
- desc.textContent = "Processing your identity check. This takes around 30 seconds.";
1459
+ desc.textContent = "This usually takes a few seconds. Please wait.";
1247
1460
  wrapper.appendChild(desc);
1248
- // Details card
1249
- const details = document.createElement("div");
1250
- details.style.cssText = `
1461
+ // ── Result card β€” initially shows status only ────────────────────
1462
+ // Populated with name/doc type when verification completes.
1463
+ const resultCard = document.createElement("div");
1464
+ resultCard.style.cssText = `
1251
1465
  width:100%;padding:16px;border-radius:12px;
1252
1466
  background:${this.theme.cardBg};border:1px solid ${this.theme.border};
1253
- text-align:left;
1467
+ text-align:left;display:flex;flex-direction:column;gap:10px;
1468
+ transition:border-color 0.3s ease;
1254
1469
  `;
1255
- // Status badge β€” mutable DOM node so polling can update it
1470
+ // Status row β€” always shown
1256
1471
  const statusBadge = document.createElement("span");
1472
+ statusBadge.setAttribute("role", "status");
1473
+ statusBadge.setAttribute("aria-live", "polite");
1474
+ statusBadge.setAttribute("aria-atomic", "true");
1257
1475
  statusBadge.style.cssText = `font-size:12px;font-weight:600;color:${this.theme.warning};`;
1258
1476
  statusBadge.textContent = "Processing…";
1259
1477
  const statusRow = document.createElement("div");
1260
- statusRow.style.cssText = "display:flex;justify-content:space-between;margin-bottom:8px;";
1261
- statusRow.innerHTML = `<span style="color:${this.theme.textMuted};font-size:12px;">Status</span>`;
1478
+ statusRow.style.cssText = "display:flex;justify-content:space-between;align-items:center;";
1479
+ statusRow.innerHTML = `<span style="color:${this.theme.textMuted};font-size:12px;font-weight:500;">Status</span>`;
1262
1480
  statusRow.appendChild(statusBadge);
1263
- // Build rows via DOM (not innerHTML+=) to keep statusBadge live in the document
1264
- const entryRow = document.createElement("div");
1265
- entryRow.style.cssText = "display:flex;justify-content:space-between;margin-bottom:8px;";
1266
- entryRow.innerHTML = `
1267
- <span style="color:${this.theme.textMuted};font-size:12px;">Entry ID</span>
1268
- <span style="color:${this.theme.text};font-size:12px;font-weight:600;">${submitResult.id}</span>
1269
- `;
1270
- details.appendChild(entryRow);
1271
- details.appendChild(statusRow);
1272
- const submittedRow = document.createElement("div");
1273
- submittedRow.style.cssText = "display:flex;justify-content:space-between;margin-top:8px;";
1274
- submittedRow.innerHTML = `
1275
- <span style="color:${this.theme.textMuted};font-size:12px;">Submitted</span>
1276
- <span style="color:${this.theme.text};font-size:12px;">${submitResult.submitted_at ? new Date(submitResult.submitted_at).toLocaleString() : "Just now"}</span>
1277
- `;
1278
- details.appendChild(submittedRow);
1279
- wrapper.appendChild(details);
1280
- // Failure reason β€” hidden until status = failed
1481
+ resultCard.appendChild(statusRow);
1482
+ // Verified details β€” shown only when passed.
1483
+ // nameRow: shows full name from IVS result or OCR
1484
+ // docTypeRow: shows document type verified
1485
+ const nameRow = document.createElement("div");
1486
+ nameRow.style.cssText = "display:none;justify-content:space-between;align-items:center;";
1487
+ nameRow.innerHTML = `<span style="color:${this.theme.textMuted};font-size:12px;font-weight:500;">Name</span><span id="sc-result-name" style="color:${this.theme.text};font-size:12px;font-weight:600;text-align:right;max-width:60%;"></span>`;
1488
+ resultCard.appendChild(nameRow);
1489
+ const docTypeRow = document.createElement("div");
1490
+ docTypeRow.style.cssText = "display:none;justify-content:space-between;align-items:center;";
1491
+ docTypeRow.innerHTML = `<span style="color:${this.theme.textMuted};font-size:12px;font-weight:500;">ID Type</span><span id="sc-result-doctype" style="color:${this.theme.text};font-size:12px;font-weight:600;"></span>`;
1492
+ resultCard.appendChild(docTypeRow);
1493
+ wrapper.appendChild(resultCard);
1494
+ // ── Failure reason β€” shown only when failed ──────────────────────
1281
1495
  const failureMsg = document.createElement("div");
1496
+ failureMsg.setAttribute("role", "alert");
1282
1497
  failureMsg.style.cssText = `
1283
1498
  display:none;width:100%;padding:12px 14px;border-radius:10px;
1284
1499
  background:${this.theme.errorBg};border:1px solid ${this.theme.error};
1285
- color:${this.theme.error};font-size:12px;text-align:left;line-height:1.5;
1500
+ color:${this.theme.error};font-size:13px;text-align:left;line-height:1.6;
1286
1501
  `;
1287
1502
  wrapper.appendChild(failureMsg);
1288
- // Done button
1503
+ // ── Action buttons ───────────────────────────────────────────────
1504
+ const btnRow = document.createElement("div");
1505
+ btnRow.style.cssText = "display:flex;flex-direction:column;gap:8px;width:100%;";
1289
1506
  const doneBtn = this.createPrimaryButton("Done");
1507
+ doneBtn.setAttribute("aria-label", "Close verification and continue");
1290
1508
  let finalStatus = submitResult.status;
1291
1509
  doneBtn.addEventListener("click", () => {
1292
1510
  if (this._pollStatusTimer) {
@@ -1302,74 +1520,107 @@ class SmartComplyFlow {
1302
1520
  });
1303
1521
  this.close();
1304
1522
  });
1305
- wrapper.appendChild(doneBtn);
1523
+ btnRow.appendChild(doneBtn);
1524
+ // "Try again" button β€” shown only on failure, hidden initially
1525
+ const retryBtn = this.createSecondaryButton("Try Again");
1526
+ retryBtn.setAttribute("aria-label", "Start verification again");
1527
+ retryBtn.style.display = "none";
1528
+ retryBtn.addEventListener("click", () => {
1529
+ if (this._pollStatusTimer) {
1530
+ clearTimeout(this._pollStatusTimer);
1531
+ this._pollStatusTimer = null;
1532
+ }
1533
+ this.initialize();
1534
+ });
1535
+ btnRow.appendChild(retryBtn);
1536
+ wrapper.appendChild(btnRow);
1306
1537
  this.contentArea.appendChild(wrapper);
1307
1538
  requestAnimationFrame(() => {
1308
1539
  wrapper.style.opacity = "1";
1309
1540
  wrapper.style.transform = "scale(1)";
1310
1541
  });
1311
- // Start polling for the async result
1312
- this._startStatusPolling(submitResult.id, iconWrap, statusBadge, title, desc, failureMsg, (resolved) => { finalStatus = resolved; });
1542
+ // ── Start polling ────────────────────────────────────────────────
1543
+ this._startStatusPolling(submitResult.id, iconWrap, statusBadge, title, desc, resultCard, nameRow, docTypeRow, failureMsg, retryBtn, (resolved) => { finalStatus = resolved; });
1313
1544
  }
1314
- _startStatusPolling(entryId, iconWrap, statusBadge, title, desc, failureMsg, onResolved) {
1315
- const MAX_POLLS = 25; // 25 Γ— 3 s = 75 s + 4 s initial = ~79 s max
1545
+ _startStatusPolling(entryId, iconWrap, statusBadge, title, desc, resultCard, nameRow, docTypeRow, failureMsg, retryBtn, onResolved) {
1546
+ const MAX_POLLS = 25; // 25 Γ— 3s = 75s + 4s initial β‰ˆ 79s max
1316
1547
  let polls = 0;
1317
- // Hard 30 s UI timer β€” stops spinner even if backend is still processing
1548
+ // Hard 30s UI timer β€” stop spinner and tell user to wait even if backend is slower
1318
1549
  let uiTimeoutId = setTimeout(() => {
1319
1550
  if (!this.isDestroyed)
1320
1551
  applyTimeout();
1321
1552
  }, 30000);
1322
- const clearUiTimeout = () => { if (uiTimeoutId) {
1323
- clearTimeout(uiTimeoutId);
1324
- uiTimeoutId = null;
1325
- } };
1553
+ const clearUiTimeout = () => {
1554
+ if (uiTimeoutId) {
1555
+ clearTimeout(uiTimeoutId);
1556
+ uiTimeoutId = null;
1557
+ }
1558
+ };
1326
1559
  const applyTimeout = () => {
1327
- // Stop polling for real β€” the message below tells the user it's safe
1328
- // to close and that the result will arrive via webhook/dashboard.
1329
- // Silently continuing to poll in the background (up to another ~49s)
1330
- // used to mean the UI could suddenly flip to Verified/Failed after
1331
- // already telling the user to walk away, with no warning either way.
1332
1560
  if (this._pollStatusTimer) {
1333
1561
  clearTimeout(this._pollStatusTimer);
1334
1562
  this._pollStatusTimer = null;
1335
1563
  }
1336
- iconWrap.innerHTML = "⏱";
1337
- iconWrap.style.background = this.theme.cardBg;
1564
+ iconWrap.innerHTML = "⏳";
1338
1565
  iconWrap.style.border = `3px solid ${this.theme.warning}`;
1339
- iconWrap.style.color = this.theme.warning;
1340
- iconWrap.style.fontSize = "30px";
1341
1566
  statusBadge.style.color = this.theme.textMuted;
1342
1567
  statusBadge.textContent = "Still processing…";
1343
- title.textContent = "Verification In Progress";
1344
- desc.textContent = "Your verification is being processed. Your result will be delivered via your dashboard or webhook. You can safely close this.";
1568
+ title.textContent = "Still verifying";
1569
+ desc.textContent = "This is taking a little longer than usual. You can safely close this β€” your result will be available in your account.";
1345
1570
  };
1346
- const applyPassed = () => {
1571
+ const applyPassed = (result) => {
1347
1572
  clearUiTimeout();
1348
1573
  iconWrap.innerHTML = "βœ“";
1349
- iconWrap.style.background = this.theme.successBg;
1350
- iconWrap.style.border = `3px solid ${this.theme.success}`;
1351
- iconWrap.style.color = this.theme.success;
1352
- iconWrap.style.fontSize = "36px";
1574
+ iconWrap.style.cssText += `background:${this.theme.successBg};border:3px solid ${this.theme.success};color:${this.theme.success};font-size:36px;font-weight:700;`;
1575
+ resultCard.style.borderColor = this.theme.success;
1353
1576
  statusBadge.style.color = this.theme.success;
1354
1577
  statusBadge.textContent = "Verified βœ“";
1355
1578
  title.textContent = "Identity Verified";
1356
- desc.textContent = "Your identity check passed successfully.";
1579
+ desc.textContent = "Your identity has been confirmed successfully.";
1580
+ // ── Show name if available ───────────────────────────────────────
1581
+ // Pull from IVS customer_profile (data verification) or
1582
+ // document OCR result (document verification) β€” whichever has data.
1583
+ const profile = result?.customer_profile || result?.data || {};
1584
+ const docResult = result?.document || {};
1585
+ const ocr = docResult?.ocr_result || {};
1586
+ const firstName = (profile?.first_name || ocr?.first_name || docResult?.first_name || "").trim();
1587
+ const lastName = (profile?.last_name || profile?.surname ||
1588
+ ocr?.last_name || ocr?.surname || docResult?.last_name || "").trim();
1589
+ const fullName = [firstName, lastName].filter(Boolean).join(" ");
1590
+ const nameEl = document.getElementById("sc-result-name");
1591
+ if (fullName && nameEl) {
1592
+ nameEl.textContent = fullName;
1593
+ nameRow.style.display = "flex";
1594
+ }
1595
+ // ── Show ID type ─────────────────────────────────────────────────
1596
+ const rawDocType = (docResult?.document_type ||
1597
+ this.selectedIdType ||
1598
+ result?.identifier_type ||
1599
+ "");
1600
+ const docTypeLabel = _formatDocType(rawDocType);
1601
+ const docTypeEl = document.getElementById("sc-result-doctype");
1602
+ if (docTypeLabel && docTypeEl) {
1603
+ docTypeEl.textContent = docTypeLabel;
1604
+ docTypeRow.style.display = "flex";
1605
+ }
1357
1606
  };
1358
1607
  const applyFailed = (reason) => {
1359
1608
  clearUiTimeout();
1360
1609
  iconWrap.innerHTML = "βœ•";
1361
- iconWrap.style.background = this.theme.errorBg;
1362
- iconWrap.style.border = `3px solid ${this.theme.error}`;
1363
- iconWrap.style.color = this.theme.error;
1364
- iconWrap.style.fontSize = "32px";
1610
+ iconWrap.style.cssText += `background:${this.theme.errorBg};border:3px solid ${this.theme.error};color:${this.theme.error};font-size:32px;`;
1611
+ resultCard.style.borderColor = this.theme.error;
1365
1612
  statusBadge.style.color = this.theme.error;
1366
- statusBadge.textContent = "Failed";
1367
- title.textContent = "Verification Failed";
1368
- desc.textContent = "Your identity check could not be completed.";
1369
- if (reason) {
1370
- failureMsg.textContent = reason;
1613
+ statusBadge.textContent = "Not verified";
1614
+ title.textContent = "Verification Unsuccessful";
1615
+ desc.textContent = "We were unable to confirm your identity this time.";
1616
+ // Show a clean, actionable reason β€” never raw technical strings
1617
+ const safeReason = _sanitizeUserMessage(reason);
1618
+ if (safeReason) {
1619
+ failureMsg.textContent = safeReason;
1371
1620
  failureMsg.style.display = "block";
1372
1621
  }
1622
+ // Show retry button on failure
1623
+ retryBtn.style.display = "block";
1373
1624
  };
1374
1625
  const poll = async () => {
1375
1626
  if (this.isDestroyed)
@@ -1379,34 +1630,32 @@ class SmartComplyFlow {
1379
1630
  const result = await this.sdk.liveness.pollResult(entryId);
1380
1631
  if (result.status === "passed") {
1381
1632
  onResolved("passed");
1382
- applyPassed();
1633
+ applyPassed(result);
1383
1634
  return;
1384
1635
  }
1385
1636
  if (result.status === "failed") {
1386
- onResolved(result.status);
1387
- const reason = result.failure_reason || "Verification could not be completed. Please try again.";
1388
- applyFailed(reason);
1637
+ onResolved("failed");
1638
+ applyFailed(result.failure_reason || null);
1389
1639
  return;
1390
1640
  }
1391
- // Still pending/processing β€” schedule next poll if budget remains
1641
+ // Still processing β€” schedule next poll
1392
1642
  if (polls < MAX_POLLS && !this.isDestroyed) {
1393
1643
  this._pollStatusTimer = setTimeout(poll, 3000);
1394
1644
  }
1395
1645
  else if (!this.isDestroyed) {
1396
- applyTimeout(); // budget exhausted β€” stop spinner, show timeout state
1646
+ applyTimeout();
1397
1647
  }
1398
1648
  }
1399
1649
  catch {
1400
- // Network hiccup β€” retry with a slightly longer delay
1401
1650
  if (polls < MAX_POLLS && !this.isDestroyed) {
1402
1651
  this._pollStatusTimer = setTimeout(poll, 5000);
1403
1652
  }
1404
1653
  else if (!this.isDestroyed) {
1405
- applyTimeout(); // budget exhausted β€” stop spinner
1654
+ applyTimeout();
1406
1655
  }
1407
1656
  }
1408
1657
  };
1409
- // First poll after 4 s β€” gives Celery task time to complete
1658
+ // First poll after 4s β€” gives Celery task time to start
1410
1659
  this._pollStatusTimer = setTimeout(poll, 4000);
1411
1660
  }
1412
1661
  renderResult(_container) {
@@ -1419,23 +1668,51 @@ class SmartComplyFlow {
1419
1668
  return;
1420
1669
  this.contentArea.innerHTML = "";
1421
1670
  const wrapper = document.createElement("div");
1671
+ wrapper.setAttribute("role", "alert");
1422
1672
  wrapper.style.cssText = `
1423
1673
  display:flex;flex-direction:column;align-items:center;
1424
1674
  text-align:center;gap:16px;padding:20px 0;
1425
1675
  `;
1676
+ // Classify the error into a user-friendly category
1426
1677
  const isCameraErr = /camera|permission|notallowed|notfound/i.test(message);
1427
1678
  const isNetworkErr = /network|timeout|connection|fetch/i.test(message);
1428
- const isAuthErr = /session|token|expired|authentication/i.test(message);
1429
- const errorIcon = isCameraErr ? "\uD83D\uDCF7" : isNetworkErr ? "\uD83D\uDCE1" : isAuthErr ? "\u23F1" : "\u2717";
1679
+ const isAuthErr = /session.*expired|token.*expired|authentication/i.test(message);
1680
+ const isDocQuality = /blurry|blur|quality|glare|dark|bright|lighting/i.test(message);
1681
+ const isExpiredDoc = /document.*expired|expired.*document/i.test(message);
1682
+ const isFaceMatch = /face.*match|selfie.*match|does not match/i.test(message);
1683
+ const errorIcon = isCameraErr ? "πŸ“·"
1684
+ : isNetworkErr ? "πŸ“‘"
1685
+ : isAuthErr ? "⏱"
1686
+ : isDocQuality ? "πŸ”"
1687
+ : isExpiredDoc ? "πŸ“…"
1688
+ : isFaceMatch ? "🀳"
1689
+ : "βœ•";
1430
1690
  const errorTitle = isCameraErr ? "Camera Access Required"
1431
1691
  : isNetworkErr ? "Connection Problem"
1432
1692
  : isAuthErr ? "Session Expired"
1433
- : "Verification Failed";
1693
+ : isDocQuality ? "Document Photo Unclear"
1694
+ : isExpiredDoc ? "Document Expired"
1695
+ : isFaceMatch ? "Face Match Failed"
1696
+ : "Verification Unsuccessful";
1697
+ // Map to user-facing guidance β€” never show raw internal message
1698
+ const userMessage = isCameraErr
1699
+ ? "Please allow camera access in your browser settings and try again."
1700
+ : isNetworkErr
1701
+ ? "Check your internet connection and try again."
1702
+ : isAuthErr
1703
+ ? "Your session has expired. Please start again."
1704
+ : isDocQuality
1705
+ ? _sanitizeUserMessage(message) || "Please retake your document photo in good lighting with no glare."
1706
+ : isExpiredDoc
1707
+ ? "The document you submitted has expired. Please use a valid, current ID."
1708
+ : isFaceMatch
1709
+ ? "Your selfie did not match the photo on your ID. Please ensure you are using your own valid document and face the camera directly."
1710
+ : _sanitizeUserMessage(message) || "We were unable to complete your verification. Please try again.";
1434
1711
  const iconWrap = document.createElement("div");
1435
1712
  iconWrap.style.cssText = `
1436
1713
  width:72px;height:72px;border-radius:50%;
1437
1714
  display:flex;align-items:center;justify-content:center;
1438
- font-size:${errorIcon === "\u2717" ? "32px" : "28px"};
1715
+ font-size:28px;
1439
1716
  background:${this.theme.errorBg};
1440
1717
  border:3px solid ${this.theme.error};
1441
1718
  `;
@@ -1447,22 +1724,16 @@ class SmartComplyFlow {
1447
1724
  wrapper.appendChild(title);
1448
1725
  const desc = document.createElement("p");
1449
1726
  desc.style.cssText = `color:${this.theme.textSecondary};font-size:14px;line-height:1.6;margin:0;max-width:300px;`;
1450
- desc.textContent = message;
1727
+ desc.textContent = userMessage;
1451
1728
  wrapper.appendChild(desc);
1452
1729
  const retryBtn = this.createPrimaryButton("Try Again");
1730
+ retryBtn.setAttribute("aria-label", "Try verification again");
1453
1731
  retryBtn.addEventListener("click", () => {
1454
- // If session already exists, go back to welcome without re-initializing
1455
- // (re-initializing would fail with CLIENT_ID_ALREADY_USED)
1456
- if (this.sdk.sessionToken && this.sdkConfig) {
1457
- this.showStep("welcome");
1458
- }
1459
- else {
1460
- // Only re-initialize if session was never created
1461
- this.initialize();
1462
- }
1732
+ this.initialize();
1463
1733
  });
1464
1734
  wrapper.appendChild(retryBtn);
1465
1735
  const closeBtn = this.createSecondaryButton("Close");
1736
+ closeBtn.setAttribute("aria-label", "Close verification widget");
1466
1737
  closeBtn.addEventListener("click", () => {
1467
1738
  this.options.onError?.(new Error(message));
1468
1739
  this.close();
@@ -1475,6 +1746,7 @@ class SmartComplyFlow {
1475
1746
  // ─────────────────────────────────────────────────────────────────
1476
1747
  createHeader() {
1477
1748
  const header = document.createElement("div");
1749
+ header.setAttribute("role", "banner");
1478
1750
  header.style.cssText = `
1479
1751
  display:flex;align-items:center;justify-content:space-between;
1480
1752
  padding:11px 20px;border-bottom:1px solid ${this.theme.border};
@@ -1484,6 +1756,7 @@ class SmartComplyFlow {
1484
1756
  leftArea.style.cssText = "display:flex;align-items:center;gap:8px;min-width:0;";
1485
1757
  this.headerBackBtn = document.createElement("button");
1486
1758
  this.headerBackBtn.innerHTML = "&#8592;";
1759
+ this.headerBackBtn.setAttribute("aria-label", "Go back to previous step");
1487
1760
  this.headerBackBtn.style.cssText = `
1488
1761
  width:30px;height:30px;border-radius:8px;flex-shrink:0;
1489
1762
  display:none;align-items:center;justify-content:center;
@@ -1492,6 +1765,7 @@ class SmartComplyFlow {
1492
1765
  background:${this.theme.cardBg};color:${this.theme.text};
1493
1766
  transition:all 0.2s ease;
1494
1767
  `;
1768
+ this.headerBackBtn.style.cssText += `:focus-visible{outline:2px solid ${this.theme.primary};outline-offset:2px;}`;
1495
1769
  this.headerBackBtn.addEventListener("mouseenter", () => {
1496
1770
  if (this.headerBackBtn) {
1497
1771
  this.headerBackBtn.style.background = this.theme.shimmer;
@@ -1504,6 +1778,7 @@ class SmartComplyFlow {
1504
1778
  });
1505
1779
  leftArea.appendChild(this.headerBackBtn);
1506
1780
  this.brandLabel = document.createElement("div");
1781
+ this.brandLabel.setAttribute("aria-label", "Verification provider");
1507
1782
  this.brandLabel.style.cssText = `
1508
1783
  font-size:15px;font-weight:700;color:${this.theme.text};
1509
1784
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
@@ -1513,6 +1788,8 @@ class SmartComplyFlow {
1513
1788
  header.appendChild(leftArea);
1514
1789
  // Step indicator
1515
1790
  this.stepLabel = document.createElement("div");
1791
+ this.stepLabel.setAttribute("aria-live", "polite");
1792
+ this.stepLabel.setAttribute("aria-atomic", "true");
1516
1793
  this.stepLabel.style.cssText = `
1517
1794
  font-size:12px;color:${this.theme.textMuted};
1518
1795
  font-variant-numeric:tabular-nums;flex-shrink:0;
@@ -1521,6 +1798,7 @@ class SmartComplyFlow {
1521
1798
  // Close button
1522
1799
  const closeBtn = document.createElement("button");
1523
1800
  closeBtn.innerHTML = "βœ•";
1801
+ closeBtn.setAttribute("aria-label", "Close identity verification");
1524
1802
  closeBtn.style.cssText = `
1525
1803
  width:32px;height:32px;border-radius:8px;flex-shrink:0;
1526
1804
  display:flex;align-items:center;justify-content:center;
@@ -1567,6 +1845,7 @@ class SmartComplyFlow {
1567
1845
  }
1568
1846
  createOptionCard(title, subtitle, onClick) {
1569
1847
  const card = document.createElement("button");
1848
+ card.setAttribute("aria-label", subtitle ? `${title}: ${subtitle}` : title);
1570
1849
  card.style.cssText = `
1571
1850
  width:100%;padding:13px 16px;border-radius:12px;
1572
1851
  background:${this.theme.cardBg};
@@ -1658,7 +1937,7 @@ class SmartComplyFlow {
1658
1937
  const allFields = Object.values(channels)
1659
1938
  .flat()
1660
1939
  .flatMap((ch) => ch.fields || []);
1661
- return allFields.some((f) => f.type === "upload");
1940
+ return allFields.some((f) => isUploadFieldType(f.type));
1662
1941
  }
1663
1942
  updateProgress() {
1664
1943
  const isDocFlow = this.isDocumentFlow();
@@ -1745,6 +2024,12 @@ class SmartComplyFlow {
1745
2024
  #sc-flow-modal * {
1746
2025
  box-sizing: border-box;
1747
2026
  }
2027
+ #sc-flow-modal button:focus-visible,
2028
+ #sc-flow-modal select:focus-visible,
2029
+ #sc-flow-modal input:focus-visible {
2030
+ outline: 2px solid #3b82f6;
2031
+ outline-offset: 2px;
2032
+ }
1748
2033
  #sc-flow-modal::-webkit-scrollbar {
1749
2034
  width: 4px;
1750
2035
  }