async function init() { const URLParams = new URLSearchParams(window.location.search); const formId = URLParams.get('formId'); const region = URLParams.get('region'); const apiId = URLParams.get('apiId'); window.form = new Form(formId, region, apiId); await window.form.init(); } function initAutoResize() { if (window.parent === window) return; const sendHeight = () => { window.parent.postMessage({ t9cmd: 'setHeight', height: document.body.scrollHeight }, '*'); }; new ResizeObserver(sendHeight).observe(document.body); sendHeight(); } class Form { constructor(formId, region, apiId) { this.formId = formId; this.region = region; this.apiId = apiId; this.url = `https://${apiId}.execute-api.${region}.amazonaws.com/Prod/webForms/${formId}`; this.form = {}; } fail = (message) => { document.getElementById('form').classList.remove('d-none'); document.getElementById('non-loaded').classList.add('d-none'); this.showMessage(message, 'danger'); } init = async () => { let missing = ['formId', 'region', 'apiId'].filter(e => ! this[e]); if (missing.length) { return this.fail(`Missing [${missing}]`); } try { await this.load(); this.render(); this.listen(); } catch (e) { this.fail(e); } } load = async () => { let res = await fetch(this.url); if (! res.ok) { let { errorMessage } = await res.json(); throw errorMessage; } let { form, draft, questions, siteKey, answerId } = await res.json(); this.answerId = answerId; this.form = form; if (draft) this.draft = true; let { resources, recaptchaEnabled, patientValidation } = this.form; // load recaptcha if (recaptchaEnabled) await this.enableRecaptcha(siteKey); this.questions = []; let visible = 'showAlways'; if (patientValidation === 'disabled') visible = 'hideAlways'; this.questions.push(new Question('name', true, 'text', resources.nameLabel, resources.nameDescription, true, undefined, { visible })); this.questions.push(new Question('dateOfBirth', true, 'date', resources.dateOfBirthLabel, resources.dateOfBirthDescription, true, undefined, { visible })); this.questions.push(new Question('email', false, 'email', resources.emailLabel, resources.emailDescription, true, undefined, { visible })); this.questions.push(new Question('phoneNumber', true, 'phone', resources.phoneNumberLabel, resources.phoneNumberDescription, true, undefined, { visible })); // instantiate questions if (questions) this.instanceQuestions(questions); } enableRecaptcha = async (siteKey) => { this.siteKey = siteKey; if (! siteKey) { throw 'No [siteKey] configured.'; } let jsScript = document.createElement('script'); jsScript.src = `https://www.google.com/recaptcha/api.js?render=${siteKey}`; jsScript.addEventListener('load', () => grecaptcha.ready(() => { this.recaptchaEnabled = true; })); document.body.appendChild(jsScript); } getToken = async () => { return new Promise((resolve, reject) => { let getToken = async () => { grecaptcha.execute(this.siteKey, { action: 'webFormSubmission' }) .then(token => resolve(token)) .catch(e => reject(e)); } grecaptcha.ready(getToken); }); } instanceQuestions = (questions) => { questions.sort((a, b) => { if (a.order < b.order) return -1; if (a.order > b.order) return 1; return 0; }); for (let { id, required, type, width, titleSize, question, description, choices, visibility } of questions) { this.questions.push(new Question(id, required, type, question, description, false, choices, visibility, width, titleSize)); } } render = () => { let { title, description, resources, patientValidation } = this.form; for (let question of this.questions) { question.render(); } document.querySelector('title').innerText = title; document.getElementById('title').innerText = title; if (this.draft) { let badge = document.createElement('span'); badge.classList.add('badge', 'text-bg-primary'); badge.innerText = 'Draft'; const title = document.getElementById('title'); title.innerHTML = badge.outerHTML + ' ' + title.innerHTML; let warning = document.createElement('div'); warning.classList.add('alert', 'alert-warning'); warning.innerText = 'The version is a preview, different to the published one'; document.getElementById('title').after(warning); } document.getElementById('description').innerHTML = description; this.submitButton = document.createElement('input'); this.submitButton.classList.add('btn', 'btn-primary'); this.submitButton.setAttribute('type', 'button'); this.submitButton.value = resources.submitButton; if (patientValidation === 'required' && ! this.patientValidated) { this.submitButton.value = 'Validate'; } document.getElementById('main').appendChild(this.submitButton) document.getElementById('form').classList.remove('d-none'); document.getElementById('non-loaded').classList.add('d-none'); } hidePatientValidation = () => { document.getElementById('validation').classList.add('d-none'); } listen = () => { this.submitButton.addEventListener('click', async () => { if (! this.patientValidated && this.form.patientValidation === 'required') { return await this.submitValidation(); } return await this.submit(); }); for (let question of this.questions) { question.listen(this); } } loading = (isLoading, customMessage) => { this.showMessage(customMessage ?? 'Loading...', 'primary') let spinner = document.getElementById('spinner'); if (isLoading) { this.submitButton.value = customMessage ?? 'Loading'; this.submitButton.classList.add('disabled'); spinner.classList.remove('d-block'); for (let { id, type } of this.questions) { if (type !== 'divider') document.getElementById(id).disabled = true; } } else { this.submitButton.value = this.form.resources.submitButton; if (this.patientValidation === 'required' && !this.patientValidated) { this.submitButton.value = 'Validate'; } this.submitButton.classList.remove('disabled'); spinner.classList.add('d-none'); for (let { id, type } of this.questions) { if (type !== 'divider') document.getElementById(id).disabled = false; } this.clearMessage(); } } showMessage = (message, type = 'primary') => { let element = document.getElementById('message'); let alert = document.getElementById('alert'); element.innerText = message; alert.classList.remove('alert-danger'); alert.classList.remove('alert-primary'); alert.classList.remove('alert-warning'); alert.classList.add(`alert-${type}`); alert.classList.remove('d-none'); } clearMessage = () => { let element = document.getElementById('message'); let alert = document.getElementById('alert'); element.innerText = ''; alert.classList.add('d-none'); alert.classList.remove('alert-danger'); alert.classList.remove('alert-primary'); alert.classList.remove('alert-warning'); } validate = () => { let answers = this.getAnswers(false); let valid = true; for (let question of this.questions) { let test = question.validate(answers); if (! test) valid = false; } return valid; } updateQuestionsVisibility = () => { let answers = this.getAnswers(false); for (let question of this.questions) { question.toggleVisibility(answers); } } getAnswers = (isPatientData) => { let answers = {}; let questions = this.questions.filter(q => q.isPatientData === isPatientData); for (let question of questions) { if (question.type === 'divider') continue; answers[question.id] = question.getValue(); } return answers; } getGeo = async () => { return new Promise((resolve) => { let geo = [0, 0]; const getPos = ({ coords }) => { let { latitude, longitude } = coords ?? {}; geo = [latitude ?? 0, longitude ?? 0]; resolve(geo); } const setZero = (err) => { console.warn(err); resolve(geo); } if (navigator.geolocation) navigator.geolocation.getCurrentPosition(getPos, setZero); }); } clearForm = () => document.getElementById('main').classList.add('d-none'); submitValidation = async () => { let valid = this.validate(); if (! valid) return; try { this.loading(true, 'Validating Patient...'); let req = { action: 'validate', patientData: this.getAnswers(true), answerId: this.answerId, } if (this.recaptchaEnabled) { req.token = await this.getToken(); } let res = await fetch(this.url, { method: 'POST', body: JSON.stringify(req), }); let data = await res.json(); this.loading(false); if (! data.ok) { console.error(data); this.showMessage(data?.errorMessage ?? 'Ups! Something went wrong', 'danger'); if (data.errors) { for (let error of data.errors) { let question = this.questions.find(({ id }) => id === error.question); question?.flag(error.message); } } return; } this.instanceQuestions(data.questions); this.patientValidated = true; this.submitButton.value = ! this.patientValidated ? 'Validate' : this.form.resources.submitButton; this.hidePatientValidation(); for (let question of this.questions) { question.render(); question.listen(this); } } catch (e) { console.error(e); this.loading(false); this.showMessage(e.message ?? 'Ups! We had an error', 'danger'); } } submit = async () => { let valid = this.validate(); if (! valid) return; try { this.loading(true, 'Sending...'); let req = { action: 'submit', answers: this.getAnswers(false), patientData: this.getAnswers(true), answerId: this.answerId, location: await this.getGeo(), } if (this.recaptchaEnabled) { req.token = await this.getToken(); } let res = await fetch(this.url, { method: 'POST', body: JSON.stringify(req), }); let data = await res.json(); this.loading(false); if (! data.ok) { console.error(data); this.showMessage(data?.errorMessage ?? 'Ups! Something went wrong', 'danger'); if (data.errors) { for (let error of data.errors) { let question = this.questions.find(({ id }) => id === error.question); question?.flag(error.message); } } return; } this.clearForm(); this.showMessage(data.successMessage ?? 'Thanks for contacting us', 'success'); } catch (e) { console.error(e); this.loading(false); this.showMessage(e.message ?? 'Ups! We had an error', 'danger'); } } } class Question { constructor(id, required, type, question, description, isPatientData, choices, visibility, width, titleSize) { this.id = id; this.required = required; this.type = type; this.question = question; this.description = description ?? ''; this.isPatientData = Boolean(isPatientData); this.choices = choices ?? []; this.visibility = visibility ?? { visible: 'showAlways' }; this.width = width; this.titleSize = titleSize; // Setup HTML this.container = document.createElement('div'); this.container.classList.add('mb-3'); if (this.isPatientData || this.width === 'halfWidth') { this.container.classList.add('col-6'); } this.label = document.createElement('label'); this.label.setAttribute('for', this.id); this.label.classList.add('form-label'); if (this.required) this.label.classList.add('required'); this.label.innerText = this.question; // Divider setup if (this.type === 'divider') { this.divider = document.createElement('div'); if (this.titleSize === 'small') this.label = document.createElement('label'); if (this.titleSize === 'medium') this.label = document.createElement('h5'); if (this.titleSize === 'large') this.label = document.createElement('h4'); this.label.classList.add('divider-title'); this.label.innerText = this.question; this.descriptionElement = document.createElement('p'); this.descriptionElement.classList.add('divider-description'); this.descriptionElement.innerText = this.description; this.divider.appendChild(this.label); this.divider.appendChild(this.descriptionElement); this.container.appendChild(this.divider); return; } // Checkbox setup if (this.type === 'checkbox') { this.label.classList.add('checkbox-label'); this.input = document.createElement('input'); this.input.classList.add('me-2'); this.input.type = 'checkbox'; this.input.id = this.id; // Append checkbox and label this.label.innerText = this.question; this.label.style.fontWeight = 'normal !important'; this.label.prepend(this.input); this.container.appendChild(this.label); // Add helper text for checkbox this.helper = document.createElement('div'); this.helper.id = this.id + '-help'; this.helper.classList.add('form-text'); this.helper.innerText = this.description; this.container.appendChild(this.helper); } else { // Setup for other input types this.label.innerText = this.question; this.container.appendChild(this.label); if (this.type === 'longText') { this.input = document.createElement('textarea'); } else if (['choice', 'relationship'].includes(this.type)) { this.input = document.createElement('select'); let element = document.createElement('option'); let placeholder = 'Select one option'; if (this.required) { element.setAttribute('disabled', 'disabled'); placeholder = 'Please select one option'; } element.setAttribute('selected', 'selected'); element.value = ''; element.innerText = placeholder; this.input.appendChild(element); for (let choice of this.choices) { let element = document.createElement('option'); element.value = this.type === 'choice' ? choice : JSON.stringify(choice); element.innerText = this.type === 'choice' ? choice : choice.label; this.input.appendChild(element); } } else if (this.type === 'file') { throw '[File] type is not implemented yet'; } else { this.input = document.createElement('input'); let inputType = this.type === 'phone' ? 'tel' : this.type; this.input.setAttribute('type', inputType); } this.input.id = this.id; this.input.classList.add('form-control'); this.input.setAttribute('aria-describedby', this.id + '-help'); if (this.required) this.input.setAttribute('required', 'required'); this.helper = document.createElement('div'); this.helper.id = this.id + '-help'; this.helper.classList.add('form-text'); this.helper.innerText = this.description; this.container.appendChild(this.input); this.container.appendChild(this.helper); } this.toggleVisibility({}); } render = () => { let writeTo = document.getElementById('questions'); if (this.isPatientData) { writeTo = document.getElementById('validation'); } writeTo.append(this.container); } listen = (form) => { if (!this.input) return; const events = ['change', 'paste', 'keyup']; const update = () => { form.updateQuestionsVisibility(); if (this.type === 'phone') VMasker(this.input).maskPattern('(999) 999-9999'); if (! this.input.classList.contains('is-invalid')) return; this.validate(form.getAnswers()); } for (let event of events) { this.input.addEventListener(event, update); } } getValue = () => { if (this.type === 'divider') return; if (this.type === 'phone') return this.input.value?.match(/\d+/g)?.join(''); if (this.type === 'checkbox') return this.input.checked; return this.input.value?.trim(); } validate = (answers) => { if (! this.isVisible(answers)) return true; // Required checkbox validation if (this.type === 'checkbox' && this.required && !this.input.checked) { this.flag('This field is required'); return false; } let value = this.getValue(); if (this.required && ! value) { this.flag('Value required'); return false; } if (this.type === 'phone') { let rx = new RegExp(/\d{10}/); let isPhone = rx.test(value); if (! isPhone && value) { this.flag('Phone must have 10 digits'); return false; } } if (this.type === 'date') { let rx = new RegExp(/\d{4}\-\d{2}\-\d{2}/); let isDate = rx.test(value); if (! isDate && value) { this.flag('Invalid Date'); return false; } } if (this.type === 'email') { let rx = new RegExp(/\w{2,}\@\w{2,}\.\w+$/); let isDate = rx.test(value); if (! isDate && value) { this.flag('Invalid email'); return false; } } this.unflag(); return true; } flag = (message) => { if (this.type === 'divider') return; this.input.classList.add('is-invalid'); this.helper.innerText = message; this.helper.classList.add('text-danger'); } unflag = () => { if (this.type === 'divider') return; this.input.classList.remove('is-invalid'); this.helper.innerText = this.description; this.helper.classList.remove('text-danger'); } toggleVisibility = (answers) => { if (! this.isVisible(answers)) { this.container.classList.add('d-none'); return; } this.container.classList.remove('d-none'); } isVisible = (answers) => { let { visible, conditions, matchCriteria } = this.visibility; if (visible === 'showAlways') return true; if (visible === 'hideAlways') return false; let matchConditions = conditions.map(({ question, values, textValues, operator }) => { let answer = answers[question.id]; if (operator === 'contains') { let rx = textValues.map(str => `(${str})`).join('|'); let regExp = new RegExp(rx, 'i'); return regExp.test(answer); } if (operator === 'isEmpty') { return ! answer?.trim(); } let expectedValues = values.map(v => v.selectedValue); let equals = expectedValues.includes(answer); if (operator === 'isEqual') { return equals; } if (operator === 'isNotEqual') { return ! equals; } throw `Unknown operator for question ${this.id}`; }); let isVisible = matchConditions.every(c => c); if (matchCriteria === 'anyMatch') { isVisible = matchConditions.some(c => c); } return visible === 'showIfCondition' ? isVisible : ! isVisible; } } document.addEventListener('DOMContentLoaded', init); document.addEventListener('DOMContentLoaded', initAutoResize);