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.
- package/dist/esm/flow/SmartComplyFlow.d.ts +2 -1
- package/dist/esm/flow/SmartComplyFlow.d.ts.map +1 -1
- package/dist/esm/flow/SmartComplyFlow.js +532 -254
- package/dist/esm/flow/SmartComplyFlow.js.map +1 -1
- package/dist/esm/modules/liveness/liveness.d.ts +9 -5
- package/dist/esm/modules/liveness/liveness.d.ts.map +1 -1
- package/dist/esm/modules/liveness/liveness.js +36 -10
- package/dist/esm/modules/liveness/liveness.js.map +1 -1
- package/dist/flow/SmartComplyFlow.d.ts +2 -1
- package/dist/flow/SmartComplyFlow.d.ts.map +1 -1
- package/dist/flow/SmartComplyFlow.js +532 -254
- package/dist/flow/SmartComplyFlow.js.map +1 -1
- package/dist/modules/liveness/liveness.d.ts +9 -5
- package/dist/modules/liveness/liveness.d.ts.map +1 -1
- package/dist/modules/liveness/liveness.js +36 -10
- package/dist/modules/liveness/liveness.js.map +1 -1
- package/dist/smartcomply.browser.js +444 -221
- package/dist/smartcomply.browser.js.map +3 -3
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
794
|
-
|
|
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 || "
|
|
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
|
-
//
|
|
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
|
-
|
|
826
|
-
|
|
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
|
|
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
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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
|
-
|
|
907
|
-
|
|
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 = "
|
|
1056
|
+
title.textContent = "Verifying your document";
|
|
911
1057
|
container.appendChild(title);
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
const
|
|
922
|
-
{ id:
|
|
923
|
-
{ id:
|
|
924
|
-
{ id:
|
|
925
|
-
{ id:
|
|
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
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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
|
-
|
|
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(
|
|
956
|
-
|
|
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
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
const
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
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
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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
|
-
}
|
|
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
|
-
//
|
|
1008
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
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
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
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 =
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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 —
|
|
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 = "
|
|
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 = "
|
|
1456
|
+
desc.textContent = "This usually takes a few seconds. Please wait.";
|
|
1251
1457
|
wrapper.appendChild(desc);
|
|
1252
|
-
//
|
|
1253
|
-
|
|
1254
|
-
|
|
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
|
|
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;
|
|
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
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
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:
|
|
1497
|
+
color:${this.theme.error};font-size:13px;text-align:left;line-height:1.6;
|
|
1290
1498
|
`;
|
|
1291
1499
|
wrapper.appendChild(failureMsg);
|
|
1292
|
-
//
|
|
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
|
-
|
|
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
|
|
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 ×
|
|
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
|
|
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 = () => {
|
|
1327
|
-
|
|
1328
|
-
|
|
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 = "
|
|
1348
|
-
desc.textContent = "
|
|
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.
|
|
1354
|
-
|
|
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
|
|
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.
|
|
1366
|
-
|
|
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 = "
|
|
1371
|
-
title.textContent = "Verification
|
|
1372
|
-
desc.textContent = "
|
|
1373
|
-
|
|
1374
|
-
|
|
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(
|
|
1391
|
-
|
|
1392
|
-
applyFailed(reason);
|
|
1634
|
+
onResolved("failed");
|
|
1635
|
+
applyFailed(result.failure_reason || null);
|
|
1393
1636
|
return;
|
|
1394
1637
|
}
|
|
1395
|
-
// Still
|
|
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();
|
|
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();
|
|
1651
|
+
applyTimeout();
|
|
1410
1652
|
}
|
|
1411
1653
|
}
|
|
1412
1654
|
};
|
|
1413
|
-
// First poll after
|
|
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
|
|
1433
|
-
const
|
|
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
|
-
: "
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 = "←";
|
|
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
|
}
|