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.
@@ -126,6 +126,55 @@ function resolveIdTypeInfo(typeName, isDocumentFlow) {
126
126
  };
127
127
  }
128
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
+ }
129
178
  // SmartComplyFlow
130
179
  // ─────────────────────────────────────────────────────────────────────
131
180
  // Add CSS animation keyframes for spin animations
@@ -174,6 +223,7 @@ class SmartComplyFlow {
174
223
  this._pollStatusTimer = null;
175
224
  // Components
176
225
  this.docCapture = null;
226
+ this._livenessSubmitResult = null;
177
227
  this.options = options;
178
228
  this.sdk = new Smartcomply_1.SmartComply({
179
229
  apiKey: options.apiKey,
@@ -249,6 +299,9 @@ class SmartComplyFlow {
249
299
  // Modal
250
300
  this.modal = document.createElement("div");
251
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");
252
305
  this.modal.style.cssText = `
253
306
  width:100%;max-width:580px;max-height:95vh;
254
307
  background:${this.theme.bg};
@@ -274,6 +327,9 @@ class SmartComplyFlow {
274
327
  // Content area (scrollable)
275
328
  this.contentArea = document.createElement("div");
276
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");
277
333
  this.contentArea.style.cssText = `
278
334
  flex:1;overflow-y:auto;padding:16px 20px;
279
335
  -webkit-overflow-scrolling:touch;
@@ -375,7 +431,7 @@ class SmartComplyFlow {
375
431
  this.renderDocumentCapture(wrapper);
376
432
  break;
377
433
  case "processing":
378
- this.renderProcessing(wrapper);
434
+ this.renderProcessing(wrapper, this.livenessEntryId || undefined);
379
435
  break;
380
436
  case "liveness":
381
437
  this.renderLiveness(wrapper);
@@ -664,6 +720,41 @@ class SmartComplyFlow {
664
720
  container.appendChild(autoBtn);
665
721
  return;
666
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
+ }
667
758
  // Track input elements for validation
668
759
  const inputEls = [];
669
760
  for (const field of dataFields) {
@@ -676,7 +767,10 @@ class SmartComplyFlow {
676
767
  const fieldKey = field.label;
677
768
  const fieldWrap = document.createElement("div");
678
769
  fieldWrap.style.cssText = "display:flex;flex-direction:column;gap:6px;";
770
+ const labelId = `sc-field-label-${fieldKey.replace(/\s+/g, '-').toLowerCase()}`;
679
771
  const label = document.createElement("label");
772
+ label.id = labelId;
773
+ label.htmlFor = `sc-field-input-${fieldKey.replace(/\s+/g, '-').toLowerCase()}`;
680
774
  label.style.cssText = `color:${this.theme.textMuted};font-size:10px;font-weight:700;letter-spacing:0.6px;text-transform:uppercase;`;
681
775
  label.textContent = field.label;
682
776
  fieldWrap.appendChild(label);
@@ -750,10 +844,18 @@ class SmartComplyFlow {
750
844
  }
751
845
  else {
752
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);
753
850
  const input = document.createElement("input");
754
851
  input.type = "text";
852
+ input.id = inputId;
853
+ input.setAttribute("aria-labelledby", labelId);
854
+ input.setAttribute("aria-describedby", hintId);
755
855
  input.placeholder = `Enter ${field.label}`;
756
- input.maxLength = 50;
856
+ input.maxLength = rule?.maxLen ? rule.maxLen + 2 : 50;
857
+ if (rule?.numeric)
858
+ input.inputMode = "numeric";
757
859
  input.style.cssText = `
758
860
  width:100%;padding:14px 16px;border-radius:12px;
759
861
  font-size:16px;font-weight:500;
@@ -764,17 +866,50 @@ class SmartComplyFlow {
764
866
  box-sizing:border-box;
765
867
  letter-spacing:0.5px;
766
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
+ }
767
878
  input.addEventListener("focus", () => {
768
879
  input.style.borderColor = this.theme.primary;
769
880
  });
770
881
  input.addEventListener("blur", () => {
771
- 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
+ }
772
901
  });
773
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");
774
908
  this.collectedFields[fieldKey] = input.value.trim();
775
909
  updateSubmitState();
776
910
  });
777
911
  fieldWrap.appendChild(input);
912
+ fieldWrap.appendChild(hintEl);
778
913
  inputEls.push({ field, el: input });
779
914
  }
780
915
  container.appendChild(fieldWrap);
@@ -792,9 +927,16 @@ class SmartComplyFlow {
792
927
  submitBtn.disabled = true;
793
928
  submitBtn.style.opacity = "0.5";
794
929
  const updateSubmitState = () => {
930
+ // All inputs must have a value AND pass format validation
795
931
  const allFilled = inputEls.every(({ el }) => el.value.trim().length > 0);
796
- submitBtn.disabled = !allFilled;
797
- 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";
798
940
  };
799
941
  submitBtn.addEventListener("click", async () => {
800
942
  submitBtn.disabled = true;
@@ -814,7 +956,7 @@ class SmartComplyFlow {
814
956
  this.showStep("liveness");
815
957
  }
816
958
  else {
817
- 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.";
818
960
  errorEl.style.display = "block";
819
961
  submitBtn.textContent = "Verify & Continue";
820
962
  submitBtn.disabled = false;
@@ -822,14 +964,26 @@ class SmartComplyFlow {
822
964
  }
823
965
  }
824
966
  catch (err) {
825
- // C3: Show field-level errors if available
967
+ // Show a single plain-language error — never raw field:message format
826
968
  const fieldErrors = err.data?.errors;
827
969
  if (Array.isArray(fieldErrors) && fieldErrors.length > 0) {
828
- const messages = fieldErrors.map((e) => `${e.field}: ${e.message}`).join("\n");
829
- 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.";
830
983
  }
831
984
  else {
832
- errorEl.textContent = err.message || "Verification failed";
985
+ errorEl.textContent = _sanitizeUserMessage(err.message)
986
+ || "Something went wrong. Please try again.";
833
987
  }
834
988
  errorEl.style.display = "block";
835
989
  submitBtn.textContent = "Verify & Continue";
@@ -885,130 +1039,140 @@ class SmartComplyFlow {
885
1039
  this.showErrorStep(err.message || "Document capture failed. Please try again.");
886
1040
  });
887
1041
  }
888
- // ── Processing ────────────────────────────────────────────────────
889
- renderProcessing(container) {
890
- // Compact layout — fits modal without scroll
891
- container.style.cssText += "display:flex;flex-direction:column;align-items:center;gap:14px;padding:16px;";
892
- // Small spinner
893
- const animationWrap = document.createElement("div");
894
- animationWrap.style.cssText = "position:relative;width:52px;height:52px;flex-shrink:0;";
895
- const spinner = document.createElement("div");
896
- spinner.style.cssText = `
897
- position:absolute;inset:0;border-radius:50%;
898
- border:3px solid ${this.theme.border};
899
- border-top-color:${this.theme.primary};
900
- animation:sc-spin 0.9s linear infinite;
901
- `;
902
- animationWrap.appendChild(spinner);
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;`;
903
1051
  const centerIcon = document.createElement("div");
904
- centerIcon.style.cssText = `
905
- position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
906
- font-size:20px;
907
- `;
1052
+ centerIcon.style.cssText = "position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:22px;";
908
1053
  centerIcon.textContent = "🔍";
909
- animationWrap.appendChild(centerIcon);
910
- container.appendChild(animationWrap);
1054
+ spinnerWrap.appendChild(ring);
1055
+ spinnerWrap.appendChild(centerIcon);
1056
+ container.appendChild(spinnerWrap);
911
1057
  const title = document.createElement("div");
912
1058
  title.style.cssText = `color:${this.theme.text};font-size:15px;font-weight:700;text-align:center;`;
913
- title.textContent = "Processing your document";
1059
+ title.textContent = "Verifying your document";
914
1060
  container.appendChild(title);
915
- const subtitle = document.createElement("div");
916
- subtitle.style.cssText = `color:${this.theme.textSecondary};font-size:12px;text-align:center;max-width:260px;line-height:1.5;`;
917
- subtitle.textContent = "Scanning and verifying your ID. This usually takes a few seconds.";
918
- container.appendChild(subtitle);
919
- // Compact steps — 2-col grid so they fit without scroll
920
- const stepsContainer = document.createElement("div");
921
- stepsContainer.style.cssText = `
922
- width:100%;display:grid;grid-template-columns:1fr 1fr;gap:8px;
923
- `;
924
- const steps = [
925
- { id: 'uploading', text: 'Uploading', icon: '📤' },
926
- { id: 'scanning', text: 'Scanning', icon: '🔍' },
927
- { id: 'verifying', text: 'Verifying', icon: '✓' },
928
- { id: 'analyzing', text: 'Checking quality', icon: '🔬' },
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: "✓" },
929
1075
  ];
930
- steps.forEach((step) => {
931
- const stepEl = document.createElement("div");
932
- stepEl.style.cssText = `
933
- display:flex;align-items:center;gap:8px;padding:9px 10px;border-radius:10px;
934
- background:${this.theme.cardBg};border:1px solid ${this.theme.border};
935
- transition:all 0.3s ease;
936
- `;
937
- stepEl.id = `processing-step-${step.id}`;
938
- const iconEl = document.createElement("div");
939
- iconEl.style.cssText = `
940
- width:26px;height:26px;border-radius:7px;flex-shrink:0;
941
- display:flex;align-items:center;justify-content:center;
942
- font-size:12px;background:${this.theme.shimmer};
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>
943
1086
  `;
944
- iconEl.textContent = step.icon;
945
- iconEl.id = `step-icon-${step.id}`;
946
- const textEl = document.createElement("div");
947
- textEl.style.cssText = `flex:1;color:${this.theme.textSecondary};font-size:11px;font-weight:500;`;
948
- textEl.textContent = step.text;
949
- textEl.id = `step-text-${step.id}`;
950
- const statusEl = document.createElement("div");
951
- statusEl.style.cssText = `width:16px;height:16px;border-radius:50%;border:2px solid ${this.theme.border};position:relative;flex-shrink:0;`;
952
- statusEl.id = `step-status-${step.id}`;
953
- stepEl.appendChild(iconEl);
954
- stepEl.appendChild(textEl);
955
- stepEl.appendChild(statusEl);
956
- stepsContainer.appendChild(stepEl);
1087
+ grid.appendChild(el);
957
1088
  });
958
- container.appendChild(stepsContainer);
959
- 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
+ }
960
1094
  }
961
- startProcessingAnimation() {
962
- const steps = ['uploading', 'scanning', 'verifying', 'analyzing'];
963
- let currentStepIndex = 0;
964
- const animateStep = (stepId) => {
965
- // Update step appearance
966
- const stepEl = document.getElementById(`processing-step-${stepId}`);
967
- const iconEl = document.getElementById(`step-icon-${stepId}`);
968
- const textEl = document.getElementById(`step-text-${stepId}`);
969
- const statusEl = document.getElementById(`step-status-${stepId}`);
970
- if (stepEl && iconEl && textEl && statusEl) {
971
- // Active state
972
- stepEl.style.borderColor = this.theme.primary;
973
- stepEl.style.background = `${this.theme.primary}10`;
974
- iconEl.style.background = this.theme.primary;
975
- iconEl.style.color = 'white';
976
- textEl.style.color = this.theme.text;
977
- textEl.style.fontWeight = '600';
978
- // Add spinner to status
979
- statusEl.innerHTML = `
980
- <div style="
981
- position:absolute;inset:2px;border-radius:50%;
982
- border:2px solid ${this.theme.border};
983
- border-top-color:${this.theme.primary};
984
- animation:sc-spin 0.8s linear infinite;
985
- "></div>
986
- `;
987
- }
988
- // 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) => {
989
1124
  setTimeout(() => {
990
- // Complete current step
991
- if (stepEl && iconEl && textEl && statusEl) {
992
- stepEl.style.borderColor = this.theme.success;
993
- stepEl.style.background = `${this.theme.success}10`;
994
- iconEl.style.background = this.theme.success;
995
- statusEl.innerHTML = `
996
- <div style="
997
- position:absolute;inset:3px;border-radius:50%;
998
- background:${this.theme.success};
999
- display:flex;align-items:center;justify-content:center;
1000
- color:white;font-size:10px;font-weight:bold;
1001
- ">✓</div>
1002
- `;
1003
- }
1004
- currentStepIndex++;
1005
- if (currentStepIndex < steps.length) {
1006
- 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;
1007
1162
  }
1008
- }, 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
+ }
1009
1173
  };
1010
- // Start animation
1011
- setTimeout(() => animateStep(steps[0]), 500);
1174
+ // First poll after 5s — give Celery task time to start
1175
+ this._pollStatusTimer = setTimeout(poll, 5000);
1012
1176
  }
1013
1177
  /**
1014
1178
  * Process document capture result with front/back support
@@ -1050,8 +1214,15 @@ class SmartComplyFlow {
1050
1214
  <div style="color:${this.theme.text};font-size:14px;font-weight:600;">Scanning document...</div>
1051
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>
1052
1216
  </div>`;
1053
- // Create liveness entry with document
1054
- 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()}`;
1055
1226
  const COUNTRY_CODES = {
1056
1227
  nigeria: "NG", ghana: "GH", kenya: "KE",
1057
1228
  south_africa: "ZA", united_states: "US", united_kingdom: "GB", global: "US",
@@ -1130,52 +1301,62 @@ class SmartComplyFlow {
1130
1301
  await attemptProcessing();
1131
1302
  }
1132
1303
  handleDocumentProcessingError(error, captureContainer) {
1133
- 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.";
1134
1306
  let canRetry = true;
1135
- if (error.message) {
1136
- if (error.message.includes("expired")) {
1137
- errorMessage = "Document has expired. Please use a valid document.";
1138
- canRetry = false;
1139
- }
1140
- else if (error.message.includes("quality too low")) {
1141
- errorMessage = "Image quality too low. Please ensure the document is well-lit and clearly visible.";
1142
- }
1143
- else if (error.message.includes("Network") || error.message.includes("fetch")) {
1144
- errorMessage = "Network connection failed. Please check your internet connection and try again.";
1145
- }
1146
- else if (error.message.includes("413") || error.message.includes("too large")) {
1147
- errorMessage = "Document image is too large. Please use a smaller image or retake the photo.";
1148
- }
1149
- else if (error.message.includes("415") || error.message.includes("format")) {
1150
- errorMessage = "Unsupported image format. Please use JPEG or PNG images.";
1151
- }
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.";
1152
1329
  }
1153
1330
  if (captureContainer) {
1154
- captureContainer.innerHTML = `
1155
- <div style="display:flex;flex-direction:column;align-items:center;gap:16px;padding:28px 20px;text-align:center;">
1156
- <div style="width:56px;height:56px;border-radius:50%;background:${this.theme.errorBg || '#FEE'};border:3px solid ${this.theme.error || '#DC3545'};display:flex;align-items:center;justify-content:center;font-size:26px;">❌</div>
1157
- <div style="color:${this.theme.text};font-size:14px;font-weight:700;">Verification Failed</div>
1158
- <div style="color:${this.theme.textSecondary};font-size:13px;max-width:240px;line-height:1.6;">${errorMessage}</div>
1159
- ${canRetry ? `
1160
- <button id="retry-btn" style="
1161
- margin-top:8px;padding:12px 24px;border-radius:8px;
1162
- background:${this.theme.primary};color:white;
1163
- border:none;font-size:14px;font-weight:600;cursor:pointer;
1164
- transition:all 0.2s ease;
1165
- ">Try Again</button>
1166
- ` : ''}
1167
- </div>`;
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);
1168
1347
  if (canRetry) {
1169
- const retryBtn = captureContainer.querySelector("#retry-btn");
1170
- if (retryBtn) {
1171
- retryBtn.addEventListener("click", () => {
1172
- this.showStep("document_capture");
1173
- });
1174
- }
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);
1175
1354
  }
1355
+ captureContainer.innerHTML = "";
1356
+ captureContainer.appendChild(wrap);
1176
1357
  }
1177
1358
  else {
1178
- this.showErrorStep(errorMessage);
1359
+ this.showErrorStep(userMessage);
1179
1360
  }
1180
1361
  }
1181
1362
  // ── Liveness ────────────────────────────────────────────────────
@@ -1191,7 +1372,10 @@ class SmartComplyFlow {
1191
1372
  const livenessContainer = document.createElement("div");
1192
1373
  livenessContainer.style.cssText = "margin:8px 0;width:100%;";
1193
1374
  container.appendChild(livenessContainer);
1194
- 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()}`;
1195
1379
  const COUNTRY_CODES = {
1196
1380
  nigeria: "NG",
1197
1381
  ghana: "GH",
@@ -1212,9 +1396,25 @@ class SmartComplyFlow {
1212
1396
  document: this.documentBlob || undefined,
1213
1397
  document_back: this.documentBackBlob || undefined,
1214
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",
1215
1405
  }, ["BLINK", "TURN_HEAD", "OPEN_MOUTH"], this.livenessEntryId || undefined)
1216
1406
  .then((result) => {
1217
- 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
+ }
1218
1418
  })
1219
1419
  .catch((err) => {
1220
1420
  this.showErrorStep(err.message || "Liveness verification failed. Please try again.");
@@ -1228,12 +1428,14 @@ class SmartComplyFlow {
1228
1428
  return;
1229
1429
  this.contentArea.innerHTML = "";
1230
1430
  const wrapper = document.createElement("div");
1431
+ wrapper.setAttribute("role", "region");
1432
+ wrapper.setAttribute("aria-label", "Verification result");
1231
1433
  wrapper.style.cssText = `
1232
1434
  display:flex;flex-direction:column;align-items:center;
1233
1435
  text-align:center;gap:16px;padding:20px 0;
1234
1436
  opacity:0;transform:scale(0.95);transition:all 0.4s ease;
1235
1437
  `;
1236
- // Icon — starts as a spinner, updates when status resolves
1438
+ // ── Icon — spinner until status resolves ────────────────────────
1237
1439
  const iconWrap = document.createElement("div");
1238
1440
  iconWrap.style.cssText = `
1239
1441
  width:80px;height:80px;border-radius:50%;
@@ -1241,59 +1443,68 @@ class SmartComplyFlow {
1241
1443
  font-size:36px;
1242
1444
  background:${this.theme.cardBg};
1243
1445
  border:3px solid ${this.theme.border};
1446
+ transition:all 0.4s ease;
1244
1447
  `;
1245
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>`;
1246
1449
  wrapper.appendChild(iconWrap);
1450
+ // ── Title and description — updated by polling ───────────────────
1247
1451
  const title = document.createElement("h2");
1452
+ title.setAttribute("aria-live", "polite");
1248
1453
  title.style.cssText = `color:${this.theme.text};font-size:20px;font-weight:700;margin:0;`;
1249
- title.textContent = "Verification Submitted";
1454
+ title.textContent = "Checking your identity…";
1250
1455
  wrapper.appendChild(title);
1251
1456
  const desc = document.createElement("p");
1457
+ desc.setAttribute("aria-live", "polite");
1252
1458
  desc.style.cssText = `color:${this.theme.textSecondary};font-size:14px;line-height:1.6;margin:0;max-width:300px;`;
1253
- desc.textContent = "Processing your identity check. This takes around 30 seconds.";
1459
+ desc.textContent = "This usually takes a few seconds. Please wait.";
1254
1460
  wrapper.appendChild(desc);
1255
- // Details card
1256
- const details = document.createElement("div");
1257
- 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 = `
1258
1465
  width:100%;padding:16px;border-radius:12px;
1259
1466
  background:${this.theme.cardBg};border:1px solid ${this.theme.border};
1260
- text-align:left;
1467
+ text-align:left;display:flex;flex-direction:column;gap:10px;
1468
+ transition:border-color 0.3s ease;
1261
1469
  `;
1262
- // Status badge — mutable DOM node so polling can update it
1470
+ // Status row — always shown
1263
1471
  const statusBadge = document.createElement("span");
1472
+ statusBadge.setAttribute("role", "status");
1473
+ statusBadge.setAttribute("aria-live", "polite");
1474
+ statusBadge.setAttribute("aria-atomic", "true");
1264
1475
  statusBadge.style.cssText = `font-size:12px;font-weight:600;color:${this.theme.warning};`;
1265
1476
  statusBadge.textContent = "Processing…";
1266
1477
  const statusRow = document.createElement("div");
1267
- statusRow.style.cssText = "display:flex;justify-content:space-between;margin-bottom:8px;";
1268
- statusRow.innerHTML = `<span style="color:${this.theme.textMuted};font-size:12px;">Status</span>`;
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>`;
1269
1480
  statusRow.appendChild(statusBadge);
1270
- // Build rows via DOM (not innerHTML+=) to keep statusBadge live in the document
1271
- const entryRow = document.createElement("div");
1272
- entryRow.style.cssText = "display:flex;justify-content:space-between;margin-bottom:8px;";
1273
- entryRow.innerHTML = `
1274
- <span style="color:${this.theme.textMuted};font-size:12px;">Entry ID</span>
1275
- <span style="color:${this.theme.text};font-size:12px;font-weight:600;">${submitResult.id}</span>
1276
- `;
1277
- details.appendChild(entryRow);
1278
- details.appendChild(statusRow);
1279
- const submittedRow = document.createElement("div");
1280
- submittedRow.style.cssText = "display:flex;justify-content:space-between;margin-top:8px;";
1281
- submittedRow.innerHTML = `
1282
- <span style="color:${this.theme.textMuted};font-size:12px;">Submitted</span>
1283
- <span style="color:${this.theme.text};font-size:12px;">${submitResult.submitted_at ? new Date(submitResult.submitted_at).toLocaleString() : "Just now"}</span>
1284
- `;
1285
- details.appendChild(submittedRow);
1286
- wrapper.appendChild(details);
1287
- // Failure reason — hidden until status = failed
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 ──────────────────────
1288
1495
  const failureMsg = document.createElement("div");
1496
+ failureMsg.setAttribute("role", "alert");
1289
1497
  failureMsg.style.cssText = `
1290
1498
  display:none;width:100%;padding:12px 14px;border-radius:10px;
1291
1499
  background:${this.theme.errorBg};border:1px solid ${this.theme.error};
1292
- color:${this.theme.error};font-size:12px;text-align:left;line-height:1.5;
1500
+ color:${this.theme.error};font-size:13px;text-align:left;line-height:1.6;
1293
1501
  `;
1294
1502
  wrapper.appendChild(failureMsg);
1295
- // Done button
1503
+ // ── Action buttons ───────────────────────────────────────────────
1504
+ const btnRow = document.createElement("div");
1505
+ btnRow.style.cssText = "display:flex;flex-direction:column;gap:8px;width:100%;";
1296
1506
  const doneBtn = this.createPrimaryButton("Done");
1507
+ doneBtn.setAttribute("aria-label", "Close verification and continue");
1297
1508
  let finalStatus = submitResult.status;
1298
1509
  doneBtn.addEventListener("click", () => {
1299
1510
  if (this._pollStatusTimer) {
@@ -1309,74 +1520,107 @@ class SmartComplyFlow {
1309
1520
  });
1310
1521
  this.close();
1311
1522
  });
1312
- 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);
1313
1537
  this.contentArea.appendChild(wrapper);
1314
1538
  requestAnimationFrame(() => {
1315
1539
  wrapper.style.opacity = "1";
1316
1540
  wrapper.style.transform = "scale(1)";
1317
1541
  });
1318
- // Start polling for the async result
1319
- 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; });
1320
1544
  }
1321
- _startStatusPolling(entryId, iconWrap, statusBadge, title, desc, failureMsg, onResolved) {
1322
- const MAX_POLLS = 25; // 25 × 3 s = 75 s + 4 s initial = ~79 s max
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
1323
1547
  let polls = 0;
1324
- // 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
1325
1549
  let uiTimeoutId = setTimeout(() => {
1326
1550
  if (!this.isDestroyed)
1327
1551
  applyTimeout();
1328
1552
  }, 30000);
1329
- const clearUiTimeout = () => { if (uiTimeoutId) {
1330
- clearTimeout(uiTimeoutId);
1331
- uiTimeoutId = null;
1332
- } };
1553
+ const clearUiTimeout = () => {
1554
+ if (uiTimeoutId) {
1555
+ clearTimeout(uiTimeoutId);
1556
+ uiTimeoutId = null;
1557
+ }
1558
+ };
1333
1559
  const applyTimeout = () => {
1334
- // Stop polling for real — the message below tells the user it's safe
1335
- // to close and that the result will arrive via webhook/dashboard.
1336
- // Silently continuing to poll in the background (up to another ~49s)
1337
- // used to mean the UI could suddenly flip to Verified/Failed after
1338
- // already telling the user to walk away, with no warning either way.
1339
1560
  if (this._pollStatusTimer) {
1340
1561
  clearTimeout(this._pollStatusTimer);
1341
1562
  this._pollStatusTimer = null;
1342
1563
  }
1343
- iconWrap.innerHTML = "⏱";
1344
- iconWrap.style.background = this.theme.cardBg;
1564
+ iconWrap.innerHTML = "⏳";
1345
1565
  iconWrap.style.border = `3px solid ${this.theme.warning}`;
1346
- iconWrap.style.color = this.theme.warning;
1347
- iconWrap.style.fontSize = "30px";
1348
1566
  statusBadge.style.color = this.theme.textMuted;
1349
1567
  statusBadge.textContent = "Still processing…";
1350
- title.textContent = "Verification In Progress";
1351
- desc.textContent = "Your verification is being processed. Your result will be delivered via your dashboard or webhook. You can safely close this.";
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.";
1352
1570
  };
1353
- const applyPassed = () => {
1571
+ const applyPassed = (result) => {
1354
1572
  clearUiTimeout();
1355
1573
  iconWrap.innerHTML = "✓";
1356
- iconWrap.style.background = this.theme.successBg;
1357
- iconWrap.style.border = `3px solid ${this.theme.success}`;
1358
- iconWrap.style.color = this.theme.success;
1359
- iconWrap.style.fontSize = "36px";
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;
1360
1576
  statusBadge.style.color = this.theme.success;
1361
1577
  statusBadge.textContent = "Verified ✓";
1362
1578
  title.textContent = "Identity Verified";
1363
- 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
+ }
1364
1606
  };
1365
1607
  const applyFailed = (reason) => {
1366
1608
  clearUiTimeout();
1367
1609
  iconWrap.innerHTML = "✕";
1368
- iconWrap.style.background = this.theme.errorBg;
1369
- iconWrap.style.border = `3px solid ${this.theme.error}`;
1370
- iconWrap.style.color = this.theme.error;
1371
- iconWrap.style.fontSize = "32px";
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;
1372
1612
  statusBadge.style.color = this.theme.error;
1373
- statusBadge.textContent = "Failed";
1374
- title.textContent = "Verification Failed";
1375
- desc.textContent = "Your identity check could not be completed.";
1376
- if (reason) {
1377
- failureMsg.textContent = reason;
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;
1378
1620
  failureMsg.style.display = "block";
1379
1621
  }
1622
+ // Show retry button on failure
1623
+ retryBtn.style.display = "block";
1380
1624
  };
1381
1625
  const poll = async () => {
1382
1626
  if (this.isDestroyed)
@@ -1386,34 +1630,32 @@ class SmartComplyFlow {
1386
1630
  const result = await this.sdk.liveness.pollResult(entryId);
1387
1631
  if (result.status === "passed") {
1388
1632
  onResolved("passed");
1389
- applyPassed();
1633
+ applyPassed(result);
1390
1634
  return;
1391
1635
  }
1392
1636
  if (result.status === "failed") {
1393
- onResolved(result.status);
1394
- const reason = result.failure_reason || "Verification could not be completed. Please try again.";
1395
- applyFailed(reason);
1637
+ onResolved("failed");
1638
+ applyFailed(result.failure_reason || null);
1396
1639
  return;
1397
1640
  }
1398
- // Still pending/processing — schedule next poll if budget remains
1641
+ // Still processing — schedule next poll
1399
1642
  if (polls < MAX_POLLS && !this.isDestroyed) {
1400
1643
  this._pollStatusTimer = setTimeout(poll, 3000);
1401
1644
  }
1402
1645
  else if (!this.isDestroyed) {
1403
- applyTimeout(); // budget exhausted — stop spinner, show timeout state
1646
+ applyTimeout();
1404
1647
  }
1405
1648
  }
1406
1649
  catch {
1407
- // Network hiccup — retry with a slightly longer delay
1408
1650
  if (polls < MAX_POLLS && !this.isDestroyed) {
1409
1651
  this._pollStatusTimer = setTimeout(poll, 5000);
1410
1652
  }
1411
1653
  else if (!this.isDestroyed) {
1412
- applyTimeout(); // budget exhausted — stop spinner
1654
+ applyTimeout();
1413
1655
  }
1414
1656
  }
1415
1657
  };
1416
- // First poll after 4 s — gives Celery task time to complete
1658
+ // First poll after 4s — gives Celery task time to start
1417
1659
  this._pollStatusTimer = setTimeout(poll, 4000);
1418
1660
  }
1419
1661
  renderResult(_container) {
@@ -1426,23 +1668,51 @@ class SmartComplyFlow {
1426
1668
  return;
1427
1669
  this.contentArea.innerHTML = "";
1428
1670
  const wrapper = document.createElement("div");
1671
+ wrapper.setAttribute("role", "alert");
1429
1672
  wrapper.style.cssText = `
1430
1673
  display:flex;flex-direction:column;align-items:center;
1431
1674
  text-align:center;gap:16px;padding:20px 0;
1432
1675
  `;
1676
+ // Classify the error into a user-friendly category
1433
1677
  const isCameraErr = /camera|permission|notallowed|notfound/i.test(message);
1434
1678
  const isNetworkErr = /network|timeout|connection|fetch/i.test(message);
1435
- const isAuthErr = /session|token|expired|authentication/i.test(message);
1436
- const errorIcon = isCameraErr ? "\uD83D\uDCF7" : isNetworkErr ? "\uD83D\uDCE1" : isAuthErr ? "\u23F1" : "\u2717";
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
+ : "✕";
1437
1690
  const errorTitle = isCameraErr ? "Camera Access Required"
1438
1691
  : isNetworkErr ? "Connection Problem"
1439
1692
  : isAuthErr ? "Session Expired"
1440
- : "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.";
1441
1711
  const iconWrap = document.createElement("div");
1442
1712
  iconWrap.style.cssText = `
1443
1713
  width:72px;height:72px;border-radius:50%;
1444
1714
  display:flex;align-items:center;justify-content:center;
1445
- font-size:${errorIcon === "\u2717" ? "32px" : "28px"};
1715
+ font-size:28px;
1446
1716
  background:${this.theme.errorBg};
1447
1717
  border:3px solid ${this.theme.error};
1448
1718
  `;
@@ -1454,22 +1724,16 @@ class SmartComplyFlow {
1454
1724
  wrapper.appendChild(title);
1455
1725
  const desc = document.createElement("p");
1456
1726
  desc.style.cssText = `color:${this.theme.textSecondary};font-size:14px;line-height:1.6;margin:0;max-width:300px;`;
1457
- desc.textContent = message;
1727
+ desc.textContent = userMessage;
1458
1728
  wrapper.appendChild(desc);
1459
1729
  const retryBtn = this.createPrimaryButton("Try Again");
1730
+ retryBtn.setAttribute("aria-label", "Try verification again");
1460
1731
  retryBtn.addEventListener("click", () => {
1461
- // If session already exists, go back to welcome without re-initializing
1462
- // (re-initializing would fail with CLIENT_ID_ALREADY_USED)
1463
- if (this.sdk.sessionToken && this.sdkConfig) {
1464
- this.showStep("welcome");
1465
- }
1466
- else {
1467
- // Only re-initialize if session was never created
1468
- this.initialize();
1469
- }
1732
+ this.initialize();
1470
1733
  });
1471
1734
  wrapper.appendChild(retryBtn);
1472
1735
  const closeBtn = this.createSecondaryButton("Close");
1736
+ closeBtn.setAttribute("aria-label", "Close verification widget");
1473
1737
  closeBtn.addEventListener("click", () => {
1474
1738
  this.options.onError?.(new Error(message));
1475
1739
  this.close();
@@ -1482,6 +1746,7 @@ class SmartComplyFlow {
1482
1746
  // ─────────────────────────────────────────────────────────────────
1483
1747
  createHeader() {
1484
1748
  const header = document.createElement("div");
1749
+ header.setAttribute("role", "banner");
1485
1750
  header.style.cssText = `
1486
1751
  display:flex;align-items:center;justify-content:space-between;
1487
1752
  padding:11px 20px;border-bottom:1px solid ${this.theme.border};
@@ -1491,6 +1756,7 @@ class SmartComplyFlow {
1491
1756
  leftArea.style.cssText = "display:flex;align-items:center;gap:8px;min-width:0;";
1492
1757
  this.headerBackBtn = document.createElement("button");
1493
1758
  this.headerBackBtn.innerHTML = "&#8592;";
1759
+ this.headerBackBtn.setAttribute("aria-label", "Go back to previous step");
1494
1760
  this.headerBackBtn.style.cssText = `
1495
1761
  width:30px;height:30px;border-radius:8px;flex-shrink:0;
1496
1762
  display:none;align-items:center;justify-content:center;
@@ -1499,6 +1765,7 @@ class SmartComplyFlow {
1499
1765
  background:${this.theme.cardBg};color:${this.theme.text};
1500
1766
  transition:all 0.2s ease;
1501
1767
  `;
1768
+ this.headerBackBtn.style.cssText += `:focus-visible{outline:2px solid ${this.theme.primary};outline-offset:2px;}`;
1502
1769
  this.headerBackBtn.addEventListener("mouseenter", () => {
1503
1770
  if (this.headerBackBtn) {
1504
1771
  this.headerBackBtn.style.background = this.theme.shimmer;
@@ -1511,6 +1778,7 @@ class SmartComplyFlow {
1511
1778
  });
1512
1779
  leftArea.appendChild(this.headerBackBtn);
1513
1780
  this.brandLabel = document.createElement("div");
1781
+ this.brandLabel.setAttribute("aria-label", "Verification provider");
1514
1782
  this.brandLabel.style.cssText = `
1515
1783
  font-size:15px;font-weight:700;color:${this.theme.text};
1516
1784
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
@@ -1520,6 +1788,8 @@ class SmartComplyFlow {
1520
1788
  header.appendChild(leftArea);
1521
1789
  // Step indicator
1522
1790
  this.stepLabel = document.createElement("div");
1791
+ this.stepLabel.setAttribute("aria-live", "polite");
1792
+ this.stepLabel.setAttribute("aria-atomic", "true");
1523
1793
  this.stepLabel.style.cssText = `
1524
1794
  font-size:12px;color:${this.theme.textMuted};
1525
1795
  font-variant-numeric:tabular-nums;flex-shrink:0;
@@ -1528,6 +1798,7 @@ class SmartComplyFlow {
1528
1798
  // Close button
1529
1799
  const closeBtn = document.createElement("button");
1530
1800
  closeBtn.innerHTML = "✕";
1801
+ closeBtn.setAttribute("aria-label", "Close identity verification");
1531
1802
  closeBtn.style.cssText = `
1532
1803
  width:32px;height:32px;border-radius:8px;flex-shrink:0;
1533
1804
  display:flex;align-items:center;justify-content:center;
@@ -1574,6 +1845,7 @@ class SmartComplyFlow {
1574
1845
  }
1575
1846
  createOptionCard(title, subtitle, onClick) {
1576
1847
  const card = document.createElement("button");
1848
+ card.setAttribute("aria-label", subtitle ? `${title}: ${subtitle}` : title);
1577
1849
  card.style.cssText = `
1578
1850
  width:100%;padding:13px 16px;border-radius:12px;
1579
1851
  background:${this.theme.cardBg};
@@ -1752,6 +2024,12 @@ class SmartComplyFlow {
1752
2024
  #sc-flow-modal * {
1753
2025
  box-sizing: border-box;
1754
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
+ }
1755
2033
  #sc-flow-modal::-webkit-scrollbar {
1756
2034
  width: 4px;
1757
2035
  }