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