From 5027ff78efd59078e99c9212e0358dec749b835e Mon Sep 17 00:00:00 2001 From: tams sokari Date: Thu, 25 Jan 2024 13:37:28 -0500 Subject: [PATCH] embed: add e-signature product (#150) * assets: add `e-signature.html` file * assets: add `js/e-signature.js` file * components: add `signature_pad` component * redesign: add `e_signature` product, improve error handling * feat(eSignature): add html / js files for eSignature * styles: add styles for eSignature * tests: add tests for eSignature product * deps(components): add `signature_pad` as a dependency for components * redesign(SignaturePad): make upload optional etc. - change reset icon - make uploads optional (off by default, enabled using `allow-upload` attribute - show preview of images uploaded, modify reset to match that - reinstate JPG images * enh(eSignature): embed up to signature review screen * redesign: use single name field, disable button until checkbox checked, etc * format: run prettier through files * enh: work on back buttons * enh: add submitSignature method * format: fix lint error * fix: fix typos and more * fix: add explicit `Content-Type` header, fix others * enh: add failure / progress screens * fix: change `Content-Type` to `Content-type` header * redesign: `image` is a blob object * redesign: pass file and not blob, use toISOString for date * redesign: fix file constructor * enh: allow the browser set the Content-Type header * redesign: add upload progress and upload failed screens * fix: SignaturePad captures full drawn signature * fix: signature-previews with max-width of 10rem * enh(SignaturePad): support jpeg uploads * enh(SignaturePad): restrict upload preview to one file * styles: change icon sizes for back and close buttons * redesign: use top offset, instead of center * fix(BackNavigation): make `setActiveScreen` direction aware * components: create component for navigation * redesign: use navigation component in eSignature * redesign: resize checkbox input size, fix alignment * stories: add storybook page for SignaturePad component * redesign(SignaturePad): reset signature on pad should float * fix(SignaturePad): no more offset for SignaturePad * redesign: calculate canvas width relative to container * deps: export signature-pad component * fix: fix lint errors --- .../fixtures/e_signature_documents.json | 16 + .../e_signature_documents_no_file.json | 3 + .../e_signature_documents_no_ids.json | 4 + packages/embed/cypress/pages/e_signature.html | 57 ++ .../cypress/pages/e_signature_no_file.html | 58 ++ .../cypress/pages/e_signature_no_ids.html | 56 ++ .../embed/cypress/tests/e-signature.cy.cjs | 76 ++ packages/embed/src/css/styles.css | 86 ++- packages/embed/src/e-signature.html | 657 ++++++++++++++++++ packages/embed/src/js/e-signature.js | 478 +++++++++++++ packages/embed/src/js/script.js | 91 ++- 11 files changed, 1543 insertions(+), 39 deletions(-) create mode 100644 packages/embed/cypress/fixtures/e_signature_documents.json create mode 100644 packages/embed/cypress/fixtures/e_signature_documents_no_file.json create mode 100644 packages/embed/cypress/fixtures/e_signature_documents_no_ids.json create mode 100644 packages/embed/cypress/pages/e_signature.html create mode 100644 packages/embed/cypress/pages/e_signature_no_file.html create mode 100644 packages/embed/cypress/pages/e_signature_no_ids.html create mode 100644 packages/embed/cypress/tests/e-signature.cy.cjs create mode 100644 packages/embed/src/e-signature.html create mode 100644 packages/embed/src/js/e-signature.js diff --git a/packages/embed/cypress/fixtures/e_signature_documents.json b/packages/embed/cypress/fixtures/e_signature_documents.json new file mode 100644 index 00000000..54238c31 --- /dev/null +++ b/packages/embed/cypress/fixtures/e_signature_documents.json @@ -0,0 +1,16 @@ +{ + "documents": [ + { + "id": "07ef4595-aaed-4597-b6af-444986ae4565", + "link": "https://smile-signable-document-development.s3.us-west-2.amazonaws.com/development/921/SINGLE_USE/caa31f97-0056-4672-99de-2e1b1c439e82-privacy%20policy.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIA4OAOFXPAHPEZQXW5%2F20231202%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20231202T190423Z&X-Amz-Expires=900&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEMv%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaCXVzLXdlc3QtMiJHMEUCIQDx6xxYPXeC6Nm1O6NkDRWNskY6jpWnTo0rhGsSq%2FhLpQIgP3rVKh1%2B0Mm8tceD%2BXXRPeyRCZHysxZNw6YFxhN0EXQqpwMINBADGgw4NTQ3MjgyMjc3NzYiDBIuWCw54%2BS8bXD4iiqEAygeRz2MdUibORe2HlZ7I53MftfqQdqtUZ6CCzSee%2F53ERKuFuQ59SfeEX6KwEpfDsgKxAJsSE%2B5DWdFz3F4KLXeaVb9Ri9EHl7%2BYfYFbFUe25eCk1r0%2FewDpEgDlYr7KSuV84oNCJDmHxxa%2BsMcG6PvfBJ26UNUk5MhHfYBaqhjLTHSlFODgu%2FwYalDEcEcyK5oiJfr3%2BUx8KcNlpQVx2ep%2BbNidaVX30K2lIGqkQ9BFTjYGGzXlJ2JuNoTQjNdobptZdL8lawO1MuXaBJLdFy3AMSbcB8Bp4v39N1HA5q5aVd3odt0JEBpJ0H9pf%2BBa%2BNsS5kaMXwO0w0xjc7SLDHSClMexzwf4vXqLHMW6oPbGvUMIwmqpkYVSkhPPBBDOpEO%2FezO1Eco5shWXBByJjLrAC8wyQcWxX7TS7M9dZAjKSNSAl6D0aHYTgDhCkyJiWFfMHFF4AAR0TurBZO7LMiPoU97D7CvI7lCT0%2FTSFF1c5%2B%2B5fD7vP0PKlDF%2B4o5bqp7%2Bs8wtP%2BtqwY6nQFJodwV1R2DKbSt%2BkrCRHKB8AsdRa2mw8r59BO7ZE3x7m%2FVl61yKoqq5F0G5VW1vr7rT3OHdrUP9aOQaLHtWpKpUby4H5LGAjSZzYkBEO6MunuQ79L6lZVBe23zE2NMxGdxkWCuuj5SNP12ewlslnC1aJBN38StdO520j%2Fs9ayIvtgsmZWkLC99Wv8Sb4xpAz2paN7a%2FTwFPqAnkZVD&X-Amz-Signature=d157d3439c22a5832038af7a63928b4dec62b4326742184f88323556ebcf2fe7&X-Amz-SignedHeaders=host&x-id=GetObject", + "name": "privacy policy", + "size": 186790 + }, + { + "id": "6df1ceb5-72e5-4e38-9eef-66ee085aec17", + "link": "https://smile-signable-document-development.s3.us-west-2.amazonaws.com/development/921/SINGLE_USE/fbedce72-58f7-45c6-9e47-240e278719a7-terms%20and%20conditions.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIA4OAOFXPAHPEZQXW5%2F20231202%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=20231202T190423Z&X-Amz-Expires=900&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEMv%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaCXVzLXdlc3QtMiJHMEUCIQDx6xxYPXeC6Nm1O6NkDRWNskY6jpWnTo0rhGsSq%2FhLpQIgP3rVKh1%2B0Mm8tceD%2BXXRPeyRCZHysxZNw6YFxhN0EXQqpwMINBADGgw4NTQ3MjgyMjc3NzYiDBIuWCw54%2BS8bXD4iiqEAygeRz2MdUibORe2HlZ7I53MftfqQdqtUZ6CCzSee%2F53ERKuFuQ59SfeEX6KwEpfDsgKxAJsSE%2B5DWdFz3F4KLXeaVb9Ri9EHl7%2BYfYFbFUe25eCk1r0%2FewDpEgDlYr7KSuV84oNCJDmHxxa%2BsMcG6PvfBJ26UNUk5MhHfYBaqhjLTHSlFODgu%2FwYalDEcEcyK5oiJfr3%2BUx8KcNlpQVx2ep%2BbNidaVX30K2lIGqkQ9BFTjYGGzXlJ2JuNoTQjNdobptZdL8lawO1MuXaBJLdFy3AMSbcB8Bp4v39N1HA5q5aVd3odt0JEBpJ0H9pf%2BBa%2BNsS5kaMXwO0w0xjc7SLDHSClMexzwf4vXqLHMW6oPbGvUMIwmqpkYVSkhPPBBDOpEO%2FezO1Eco5shWXBByJjLrAC8wyQcWxX7TS7M9dZAjKSNSAl6D0aHYTgDhCkyJiWFfMHFF4AAR0TurBZO7LMiPoU97D7CvI7lCT0%2FTSFF1c5%2B%2B5fD7vP0PKlDF%2B4o5bqp7%2Bs8wtP%2BtqwY6nQFJodwV1R2DKbSt%2BkrCRHKB8AsdRa2mw8r59BO7ZE3x7m%2FVl61yKoqq5F0G5VW1vr7rT3OHdrUP9aOQaLHtWpKpUby4H5LGAjSZzYkBEO6MunuQ79L6lZVBe23zE2NMxGdxkWCuuj5SNP12ewlslnC1aJBN38StdO520j%2Fs9ayIvtgsmZWkLC99Wv8Sb4xpAz2paN7a%2FTwFPqAnkZVD&X-Amz-Signature=889ccd4c0be2eb3fdc868b534c31c51c2c084542b44a89f37a4738203bff5d52&X-Amz-SignedHeaders=host&x-id=GetObject", + "name": "terms and conditions", + "size": 384244 + } + ] +} \ No newline at end of file diff --git a/packages/embed/cypress/fixtures/e_signature_documents_no_file.json b/packages/embed/cypress/fixtures/e_signature_documents_no_file.json new file mode 100644 index 00000000..9bfc0df4 --- /dev/null +++ b/packages/embed/cypress/fixtures/e_signature_documents_no_file.json @@ -0,0 +1,3 @@ +{ + "error": "File not found for ID(s) 6df1ceb5-72e5-4e38-9eef-66ee085aec19" +} \ No newline at end of file diff --git a/packages/embed/cypress/fixtures/e_signature_documents_no_ids.json b/packages/embed/cypress/fixtures/e_signature_documents_no_ids.json new file mode 100644 index 00000000..45e1f7a1 --- /dev/null +++ b/packages/embed/cypress/fixtures/e_signature_documents_no_ids.json @@ -0,0 +1,4 @@ +{ + "code": "2413", + "error": "\"ids\" does not contain 1 required value(s)" +} \ No newline at end of file diff --git a/packages/embed/cypress/pages/e_signature.html b/packages/embed/cypress/pages/e_signature.html new file mode 100644 index 00000000..80ecb371 --- /dev/null +++ b/packages/embed/cypress/pages/e_signature.html @@ -0,0 +1,57 @@ + + + + + + + + + + + + + diff --git a/packages/embed/cypress/pages/e_signature_no_file.html b/packages/embed/cypress/pages/e_signature_no_file.html new file mode 100644 index 00000000..08bc83a4 --- /dev/null +++ b/packages/embed/cypress/pages/e_signature_no_file.html @@ -0,0 +1,58 @@ + + + + + + + + + + + + + diff --git a/packages/embed/cypress/pages/e_signature_no_ids.html b/packages/embed/cypress/pages/e_signature_no_ids.html new file mode 100644 index 00000000..7a5d1db5 --- /dev/null +++ b/packages/embed/cypress/pages/e_signature_no_ids.html @@ -0,0 +1,56 @@ + + + + + + + + + + + + + diff --git a/packages/embed/cypress/tests/e-signature.cy.cjs b/packages/embed/cypress/tests/e-signature.cy.cjs new file mode 100644 index 00000000..8c6639d2 --- /dev/null +++ b/packages/embed/cypress/tests/e-signature.cy.cjs @@ -0,0 +1,76 @@ +describe("eSignature", () => { + describe("with no document ids passed", () => { + beforeEach(() => { + cy.visit("/e_signature_no_ids"); + }); + + it("should show an error message", () => { + cy.get("iframe").should("not.exist"); + cy.get(".validation-message").should("be.visible"); + cy.get(".validation-message").should( + "contain", + "`document_ids` field is required for `e_signature` ", + ); + }); + }); + + describe("with no file found", () => { + beforeEach(() => { + cy.intercept( + { + method: "OPTIONS", + url: "*v1/documents**", + }, + { + statusCode: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "OPTIONS,GET,POST", + "Access-Control-Allow-Headers": "*", + }, + }, + ); + + cy.intercept( + { + method: "GET", + url: "*v1/documents**", + }, + { + statusCode: 400, + fixture: "e_signature_documents_no_file.json", + }, + ); + + cy.visit("/e_signature_no_file"); + }); + + it("should show an error message", () => { + cy.get("iframe").should("not.exist"); + cy.get(".validation-message").should("be.visible"); + cy.get(".validation-message").should("contain", "File not found"); + }); + }); + + describe.only("with both document ids valid", () => { + beforeEach(() => { + cy.intercept( + { + method: "GET", + url: "*v1/documents**", + }, + { + statusCode: 200, + fixture: "e_signature_documents.json", + }, + ); + cy.visit("/e_signature"); + }); + + it("should proceed to upload a signature", () => { + cy.get("iframe").should("exist"); + + cy.getIFrameBody().find("#entry-screen").should("be.visible"); + }); + }); +}); diff --git a/packages/embed/src/css/styles.css b/packages/embed/src/css/styles.css index 6b6b6fec..20e4fb9e 100644 --- a/packages/embed/src/css/styles.css +++ b/packages/embed/src/css/styles.css @@ -91,21 +91,14 @@ main { padding: 2rem; overflow-y: scroll; position: absolute; - max-height: 100vh; } @media screen and (min-width: 40rem) { main { padding: 2rem 5rem; - top: 50%; + top: 4rem; left: 50%; - transform: translate(-50%, -50%); - } -} - -@media screen and (min-width: 80rem) { - main { - max-height: 80%; + transform: translateX(-50%); } } @@ -304,6 +297,20 @@ button[data-type="icon"] { justify-content: end !important; } +.checkbox-input { + display: flex; +} + +.checkbox-input input { + inline-size: 1.25rem; + block-size: 1.25rem; +} + +.checkbox-input label { + margin-inline-start: .5rem; + text-align: initial; +} + .nav { display: flex; justify-content: space-between; @@ -319,8 +326,7 @@ button[data-type="icon"] { } .back-button-text { - font-size: 11px; - line-height: 11px; + line-height: 1; color: rgb(21, 31, 114); } @@ -558,3 +564,61 @@ smileid-combobox-option[aria-selected] { smileid-combobox-option[hidden] { display: none; } + +.document-tips { + margin-block-start: 1.5rem; + display: flex; + align-items: center; + text-align: initial; +} + +.document-tips svg { + flex-shrink: 0; + margin-inline-end: 1rem; +} + +.document-tips p { + margin-block: 0; +} + +.document-tips p:first-of-type { + font-size; 1.875rem; + font-weight: bold +} + +[type='file'] { + display: none; +} + +.document-tips > * + * { + margin-inline-start; 1em; +} + +.document-list { + padding: 0px; +} + +.document-list li { + list-style: none; + background-color: #F9F0E7; + padding: 1rem; + border-radius: .5rem; +} + +.document-list li + li { + margin-block-start: 1rem; +} + +.document-list a { + color: initial; + text-decoration: none; + margin: 0px; +} + +smileid-signature-pad { + margin-inline: auto; +} + +[id='preview-signature'] { + max-inline-size: 10rem; +} diff --git a/packages/embed/src/e-signature.html b/packages/embed/src/e-signature.html new file mode 100644 index 00000000..bd54a9ef --- /dev/null +++ b/packages/embed/src/e-signature.html @@ -0,0 +1,657 @@ + + + + + + SmileIdentity - eSignature + + + + + + + + + + + + + + +
+
+ +
+

+ +

Setting up

+ +

+ Just a few more seconds +
+ We are setting up +

+ +

+ Powered by SmileID + +

+
+ + + + + + + + + + + + + + + + +
+ + + + diff --git a/packages/embed/src/js/e-signature.js b/packages/embed/src/js/e-signature.js new file mode 100644 index 00000000..cdf176e9 --- /dev/null +++ b/packages/embed/src/js/e-signature.js @@ -0,0 +1,478 @@ +import validate from "validate.js"; +import { version as sdkVersion } from "../../package.json"; +import "@smileid/components/signature-pad"; +import "@smileid/components/navigation"; + +function getHumanSize(numberOfBytes) { + // Approximate to the closest prefixed unit + const units = ["B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; + const exponent = Math.min( + Math.floor(Math.log(numberOfBytes) / Math.log(1024)), + units.length - 1, + ); + const approx = numberOfBytes / 1024 ** exponent; + const output = + exponent === 0 + ? `${numberOfBytes} bytes` + : `${approx.toFixed(0)} ${units[exponent]}`; + + return output; +} + +(function eSignature() { + "use strict"; + + function closeWindow(userTriggered) { + const message = userTriggered + ? "SmileIdentity::Close" + : "SmileIdentity::Close::System"; + referenceWindow.postMessage(message, "*"); + } + + // NOTE: In order to support prior integrations, we have `live` and + // `production` pointing to the same URL + const endpoints = { + development: "https://devapi.smileidentity.com/v1", + sandbox: "https://testapi.smileidentity.com/v1", + live: "https://api.smileidentity.com/v1", + production: "https://api.smileidentity.com/v1", + }; + + const referenceWindow = window.parent; + referenceWindow.postMessage("SmileIdentity::ChildPageReady", "*"); + + function handleSuccess() { + referenceWindow.postMessage("SmileIdentity::Success", "*"); + } + + function handleBadDocuments(error) { + referenceWindow.postMessage( + { + message: "SmileIdentity::Error", + data: { + error, + }, + }, + "*", + ); + } + + const visitedScreens = []; + let activeScreen; + + function setActiveScreen(node, navigatingForward = true) { + activeScreen.hidden = true; + node.hidden = false; + if (navigatingForward) { + visitedScreens.push(activeScreen); + } + activeScreen = node; + } + + const NavigationTargets = document.querySelectorAll("smileid-navigation"); + NavigationTargets.forEach(navigationTarget => { + navigationTarget.addEventListener( + "navigation.back", + () => { + const screen = visitedScreens.pop(); + setActiveScreen(screen, false); + }, + false, + ); + + navigationTarget.addEventListener( + "navigation.close", + () => { + closeWindow(true); + }, + false, + ); + }); + + // NOTE: this exception is for inline back navigations + const BackButtons = document.querySelectorAll(".back-button"); + BackButtons.forEach((button) => { + button.addEventListener( + "click", + (event) => { + event.preventDefault(); + const screen = visitedScreens.pop(); + setActiveScreen(screen, false); + }, + false, + ); + }); + + let config; + let partner_params; + let documents; + let personal_info; + let signature; + + function getPartnerParams() { + function parseJWT(token) { + /** + * A JSON Web Token (JWT) uses a base64 URL encoded string in it"s body. + * + * in order to get a regular JSON string, we would follow these steps: + * + * 1. get the body of a JWT string + * 2. replace the base64 URL delimiters ( - and _ ) with regular URL delimiters ( + and / ) + * 3. convert the regular base64 string to a string + * 4. encode the string from above as a URIComponent, + * ref: just above this - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent#examples + * 5. decode the URI Component to a JSON string + * 6. parse the JSON string to a javascript object + */ + const base64Url = token.split(".")[1]; + const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); + const jsonPayload = decodeURIComponent( + atob(base64) + .split("") + .map(function (c) { + return `%${c.charCodeAt(0).toString(16)}`; + }) + .join(""), + ); + + return JSON.parse(jsonPayload); + } + + const { partner_params: partnerParams } = parseJWT(config.token); + + partner_params = { ...partnerParams, ...(config.partner_params || {}) }; + } + + async function getDocuments() { + try { + const { + callback_url, + token, + partner_details: { partner_id }, + } = config; + + const URL = `${ + endpoints[config.environment] + }/documents?ids=${config.document_ids.join(",")}`; + const fetchConfig = { + mode: "cors", + headers: { + "SmileID-Partner-ID": partner_id, + "SmileID-Token": token, + }, + }; + + const response = await fetch(URL, fetchConfig); + const result = await response.json(); + if (response.ok) { + return result; + } else { + handleBadDocuments(result.error); + closeWindow(); + } + } catch (e) { + handleBadDocuments(e); + closeWindow(); + throw new Error("Failed to retrieve documents", { cause: e }); + } + } + + window.addEventListener( + "message", + async (event) => { + if (event.data && event.data.includes("SmileIdentity::Configuration")) { + config = JSON.parse(event.data); + activeScreen = LoadingScreen; + getPartnerParams(); + documents = (await getDocuments()).documents; + initializeSession(documents); + } + }, + false, + ); + + const LoadingScreen = document.querySelector("#loading-screen"); + activeScreen = LoadingScreen; + const EntryScreen = document.querySelector("#entry-screen"); + const DocumentReviewScreen = document.querySelector( + "#document-review-screen", + ); + const PersonalInfoScreen = document.querySelector("#personal-info-screen"); + const PersonalInfoForm = PersonalInfoScreen.querySelector("form"); + const SignatureScreen = document.querySelector("#signature-screen"); + const ReviewSignatureScreen = document.querySelector( + "#review-signature-screen", + ); + const UploadProgressScreen = document.querySelector( + "#upload-progress-screen", + ); + const UploadFailedScreen = document.querySelector("#upload-failure-screen"); + const CompleteScreen = document.querySelector("#complete-screen"); + + EntryScreen.querySelector("#getStarted").addEventListener("click", () => + setActiveScreen(DocumentReviewScreen), + ); + + DocumentReviewScreen.querySelector("#i_agree").addEventListener( + "change", + (event) => { + const button = DocumentReviewScreen.querySelector("#agreeToTerms"); + if (event.target.checked) { + button.removeAttribute("disabled"); + } else { + button.setAttribute("disabled", true); + } + }, + ); + + DocumentReviewScreen.querySelector("#agreeToTerms").addEventListener( + "click", + agreeToTerms, + ); + + SignatureScreen.querySelector("smileid-signature-pad").addEventListener( + "signature-pad.publish", + (event) => { + const name = ReviewSignatureScreen.querySelector("#name"); + name.textContent = personal_info.name; + const image = ReviewSignatureScreen.querySelector("#preview-signature"); + image.src = event.detail; + signature = dataURLToFile(event.detail); + setActiveScreen(ReviewSignatureScreen); + }, + ); + + ReviewSignatureScreen.querySelector("#uploadSignature").addEventListener( + "click", + () => submitSignature(), + ); + + UploadFailedScreen.querySelector("#retry-upload").addEventListener( + "click", + () => submitSignature(), + ); + + function dataURLToFile(dataURL) { + // Code taken from https://github.com/ebidel/filer.js + const parts = dataURL.split(";base64,"); + const contentType = parts[0].split(":")[1]; + const raw = window.atob(parts[1]); + const rawLength = raw.length; + const uInt8Array = new Uint8Array(rawLength); + + for (let i = 0; i < rawLength; ++i) { + uInt8Array[i] = raw.charCodeAt(i); + } + + const ext = { + "image/jpeg": "jpg", + "image/png": "png", + "image/svg+xml": "svg", + }[contentType]; + + return new File( + [new Blob([uInt8Array], { type: contentType })], + `signature.${ext}`, + { type: contentType }, + ); + } + + function initializeSession(documents) { + loadDocuments(documents, DocumentReviewScreen); + setActiveScreen(EntryScreen); + } + + function validateInputs(payload) { + const validationConstraints = { + name: { + presence: { + allowEmpty: false, + message: "is required", + }, + }, + }; + + const validation = validate(payload, validationConstraints); + + if (validation) { + handleValidationErrors(validation); + const submitButton = PersonalInfoForm.querySelector("[type='button']"); + submitButton.removeAttribute("disabled"); + } + + return validation; + } + + function handleValidationErrors(errors) { + const fields = Object.keys(errors); + + fields.forEach((field) => { + const input = PersonalInfoForm.querySelector(`#${field}`); + input.setAttribute("aria-invalid", "true"); + input.setAttribute("aria-describedby", `${field}-hint`); + + const errorDiv = document.createElement("div"); + errorDiv.setAttribute("id", `${field}-hint`); + errorDiv.setAttribute("class", "validation-message"); + errorDiv.textContent = errors[field][0]; + + input.insertAdjacentElement("afterend", errorDiv); + }); + } + + function handlePersonalInfoSubmit(event) { + if (event) { + event.preventDefault(); + resetForm(); + } + + const formData = new FormData(PersonalInfoForm); + const payload = { + ...Object.fromEntries(formData.entries()), + }; + + const isInvalid = validateInputs(payload); + + if (isInvalid) { + return; + } + + personal_info = { + ...payload, + }; + + setActiveScreen(SignatureScreen); + } + + PersonalInfoForm.querySelector("#submitForm").addEventListener( + "click", + (event) => { + handlePersonalInfoSubmit(event); + }, + false, + ); + + function loadDocuments(documents, containerElement) { + const placeholderElement = containerElement.querySelector(".document-list"); + const list = document.createElement("div"); + list.innerHTML = ` + + `; + placeholderElement.replaceWith(list); + + return list; + } + + function agreeToTerms() { + resetForm(); + const checkbox = DocumentReviewScreen.querySelector("#i_agree"); + + if (checkbox.checked) { + setActiveScreen(PersonalInfoScreen); + } else { + displayErrorMessage("You must tick the checkbox to proceed"); + } + } + + function resetForm() { + const invalidElements = PersonalInfoForm.querySelectorAll("[aria-invalid]"); + invalidElements.forEach((el) => el.removeAttribute("aria-invalid")); + + const validationMessages = document.querySelectorAll(".validation-message"); + validationMessages.forEach((el) => el.remove()); + } + + function displayErrorMessage(message) { + const p = document.createElement("p"); + + p.textContent = message; + p.classList.add("validation-message"); + p.style.fontSize = "1.5rem"; + p.style.textAlign = "center"; + + const main = document.querySelector("main"); + main.prepend(p); + } + + async function submitSignature() { + // ACTION: Build the request headers + const headers = { + "SmileID-Partner-ID": config.partner_details.partner_id, + "SmileID-Token": config.token, + }; + + // ACTION: Build the request body + const formData = new FormData(); + formData.append( + "partner_params", + JSON.stringify({ + ...partner_params, + job_type: 12, + }), + ); + formData.append("callback_url", config.callback_url); + formData.append("source_sdk", config.sdk || "hosted_web"); + formData.append("source_sdk_version", config.sdk_version || sdkVersion); + formData.append("smile_client_id", config.partner_details.partner_id); + + formData.append("ids", config.document_ids.join(",")); + formData.append("name", personal_info.name); + formData.append("document_read_at", new Date().toISOString()); + formData.append("image", signature); + + const URL = `${ + endpoints[config.environment] || config.environment + }/documents/sign`; + + try { + setActiveScreen(UploadProgressScreen); + const response = await fetch(URL, { + method: "POST", + headers, + body: formData, + }); + const json = await response.json(); + + if (json.error) throw new Error(json.error); + + setActiveScreen(CompleteScreen); + return json; + } catch (error) { + setActiveScreen(UploadFailedScreen); + throw new Error("signature submission failed", { cause: error }); + } + } + + function complete() { + setActiveScreen(CompleteScreen); + handleSuccess(); + window.setTimeout(closeWindow, 2000); + } +})(); diff --git a/packages/embed/src/js/script.js b/packages/embed/src/js/script.js index 4b5a08aa..d44da95c 100644 --- a/packages/embed/src/js/script.js +++ b/packages/embed/src/js/script.js @@ -69,6 +69,8 @@ window.SmileIdentity = (function () { return "./../doc-verification.html"; case "enhanced_document_verification": return "./../enhanced-document-verification.html"; + case "e_signature": + return "./../e-signature.html"; case "basic_kyc": case "identity_verification": return "./../basic-kyc.html"; @@ -136,6 +138,12 @@ window.SmileIdentity = (function () { } } + function handleError(config, error) { + if (config.onError) { + config.onError(error); + } + } + const requiredPartnerDetails = [ "name", "logo_url", @@ -191,6 +199,25 @@ window.SmileIdentity = (function () { ); } + if ( + config.product === 'e_signature' && + !config.document_ids + ) { + throw new Error( + "SmileIdentity: `document_ids` field is required for `e_signature` product type", + ); + } + + if ( + config.product === 'e_signature' && + config.document_ids && + !Array.isArray(config.document_ids) + ) { + throw new Error( + "SmileIdentity: `document_ids` must be an array containing ids of documents uploaded for `e_signature`", + ); + } + return true; } @@ -204,34 +231,42 @@ window.SmileIdentity = (function () { } function SmileIdentity(config) { - const configIsValid = isConfigValid(config); - - if (configIsValid) { - createIframe(config.product); - - window.addEventListener( - "message", - (event) => { - const tag = event.data.message || event.data; - - switch (tag) { - case "SmileIdentity::ChildPageReady": - return publishConfigToIFrame(config); - case "SmileIdentity::Close": - return closeIFrame(config, true); - case "SmileIdentity::Close::System": - return closeIFrame(config, false); - case "SmileIdentity::Success": - return handleSuccess(config); - case "SmileIdentity::ConsentDenied": - case "SmileIdentity::ConsentDenied::TOTP::ContactMethodsOutdated": - return handleConsentRejection(config, event.data); - default: - return undefined; - } - }, - false, - ); + try { + const configIsValid = isConfigValid(config); + + if (configIsValid) { + createIframe(config.product); + + window.addEventListener( + "message", + (event) => { + const tag = event.data.message || event.data; + + switch (tag) { + case "SmileIdentity::ChildPageReady": + return publishConfigToIFrame(config); + case "SmileIdentity::Close": + return closeIFrame(config, true); + case "SmileIdentity::Close::System": + return closeIFrame(config, false); + case "SmileIdentity::Success": + return handleSuccess(config); + case "SmileIdentity::ConsentDenied": + case "SmileIdentity::ConsentDenied::TOTP::ContactMethodsOutdated": + return handleConsentRejection(config, event.data); + case "SmileIdentity::Error": + return handleError(config, event.data); + default: + return undefined; + } + }, + false, + ); + } + } catch (error) { + if (config.onError) { + config.onError(error.message); + } } }