/* * Client portal behaviour - everything the portal pages need from script, in * one file, because the portal's Content-Security-Policy (default-src 'self') * turns inline scripts off: * * - Dark mode, the contact's own choice, kept in a cookie the header reads * - The editor on any .tinymce textarea, in the light or dark skin * - Attachment drop zones: drop files on [data-portal-drop] and they go into * its file input, listed by name and size * - A form marked data-portal-once can't be sent twice * - Print buttons ([data-portal-print]) - an onclick would be blocked * - Pay invoices: the total and the Pay button follow the invoices ticked * - Tables a page left unwrapped scroll sideways on a phone instead of the page * * Taking a card through Stripe is js/portal_stripe.js, on those pages only. */ (function () { 'use strict'; // ---------- Dark mode ---------- document.addEventListener('click', function (e) { var toggle = e.target.closest('[data-portal-theme-toggle]'); if (!toggle) { return; } e.preventDefault(); var dark = document.documentElement.getAttribute('data-bs-theme') !== 'dark'; document.cookie = 'itflow_portal_theme=' + (dark ? 'dark' : 'light') + '; path=/; max-age=31536000; SameSite=Lax'; // The editor draws its own chrome and content, so a reload is what // brings it round with the page window.location.reload(); }); // ---------- The editor ---------- function editorSkin() { var dark = document.documentElement.getAttribute('data-bs-theme') === 'dark'; var root = getComputedStyle(document.documentElement); var body = getComputedStyle(document.body); function v(name, fallback) { return root.getPropertyValue(name).trim() || fallback; } return { skin: dark ? 'oxide-dark' : 'oxide', content_css: dark ? 'dark' : 'default', content_style: 'body{background-color:' + v('--bs-body-bg', '#fff') + ';color:' + v('--bs-body-color', '#212529') + ';' + 'font-family:' + body.fontFamily + ';font-size:' + body.fontSize + ';}' + 'a{color:' + v('--itflow-accent', '#007bff') + ';}' + '.mce-content-body[data-mce-placeholder]:not(.mce-visualblocks)::before{color:' + v('--bs-secondary-color', '#6c757d') + ';}' }; } if (window.tinymce && document.querySelector('textarea.tinymce')) { var skin = editorSkin(); tinymce.init({ selector: 'textarea.tinymce', browser_spellcheck: true, resize: true, min_height: 220, max_height: 600, promotion: false, branding: false, menubar: false, statusbar: false, license_key: 'gpl', skin: skin.skin, content_css: skin.content_css, content_style: skin.content_style, toolbar: 'bold italic forecolor | bullist numlist | link table | removeformat', mobile: { menubar: false, plugins: 'autosave lists autolink', toolbar: 'bold italic bullist numlist' }, plugins: 'link image lists table autoresize', setup: function (editor) { // A required field the editor hides can't take the browser's // own "fill this in" - so the form is checked here instead editor.on('init', function () { var textarea = editor.getElement(); textarea.removeAttribute('required'); var form = textarea.form; if (form && textarea.hasAttribute('data-required') && !form.hasAttribute('data-editor-checked')) { form.setAttribute('data-editor-checked', ''); form.addEventListener('submit', function (ev) { tinymce.triggerSave(); var empty = !editor.getContent({format: 'text'}).trim() && !/= 1048576) { return (bytes / 1048576).toFixed(1) + ' MB'; } return Math.max(1, Math.round(bytes / 1024)) + ' KB'; } function listFiles(zone) { var input = zone.querySelector('input[type=file]'); var list = zone.querySelector('[data-portal-files]'); if (!input || !list) { return; } list.replaceChildren(); Array.prototype.forEach.call(input.files, function (file) { var chip = document.createElement('span'); chip.className = 'itflow-portal-file-chip'; var icon = document.createElement('i'); icon.className = 'fas fa-paperclip'; var name = document.createElement('span'); name.className = 'text-truncate'; name.textContent = file.name; var size = document.createElement('span'); size.className = 'text-body-secondary'; size.textContent = formatSize(file.size); chip.append(icon, name, size); list.append(chip); }); list.hidden = input.files.length === 0; } document.querySelectorAll('[data-portal-drop]').forEach(function (zone) { var input = zone.querySelector('input[type=file]'); if (!input) { return; } input.addEventListener('change', function () { listFiles(zone); }); ['dragenter', 'dragover'].forEach(function (type) { zone.addEventListener(type, function (e) { e.preventDefault(); zone.classList.add('is-dragging'); }); }); ['dragleave', 'drop'].forEach(function (type) { zone.addEventListener(type, function (e) { e.preventDefault(); if (type === 'dragleave' && zone.contains(e.relatedTarget)) { return; } zone.classList.remove('is-dragging'); }); }); zone.addEventListener('drop', function (e) { if (!e.dataTransfer || !e.dataTransfer.files.length) { return; } // Add to what was already picked rather than replace it var merged = new DataTransfer(); Array.prototype.forEach.call(input.files, function (f) { merged.items.add(f); }); Array.prototype.forEach.call(e.dataTransfer.files, function (f) { merged.items.add(f); }); input.files = merged.files; listFiles(zone); }); }); // ---------- Sent once ---------- document.querySelectorAll('form[data-portal-once]').forEach(function (form) { form.addEventListener('submit', function (e) { if (e.defaultPrevented) { return; } form.querySelectorAll('button[type=submit], button:not([type])').forEach(function (button) { // Disabled after this tick, so the button's own name=value still goes with the form setTimeout(function () { button.disabled = true; }, 0); }); }); }); // ---------- Print ---------- document.addEventListener('click', function (e) { if (e.target.closest('[data-portal-print]')) { e.preventDefault(); window.print(); } }); // ---------- Paying invoices ---------- // The ticks on Pay invoices: the total, the card fee, how many, and the // button's words follow them, and a total over the card limit is stopped // here as well as on the server document.querySelectorAll('form[data-portal-pay]').forEach(function (form) { var boxes = Array.prototype.slice.call(form.querySelectorAll('input[data-pay-cents]')); var all = form.querySelector('[data-pay-all]'); var button = form.querySelector('[data-pay-button]'); var limit = parseInt(form.getAttribute('data-pay-limit') || '0', 10); var max = parseInt(form.getAttribute('data-pay-max') || '0', 10); // The card fee in hundredths of a percent and cents - worked as billingCardFeeCents() does var feeOn = form.hasAttribute('data-pay-fee-percent'); var feePercent = parseInt(form.getAttribute('data-pay-fee-percent') || '0', 10); var feeFixed = parseInt(form.getAttribute('data-pay-fee-fixed') || '0', 10); var money; try { money = new Intl.NumberFormat(form.getAttribute('data-pay-locale') || undefined, {style: 'currency', currency: form.getAttribute('data-pay-currency') || 'USD'}); } catch (err) { money = new Intl.NumberFormat(undefined, {style: 'currency', currency: 'USD'}); } function text(selector, value) { var el = form.querySelector(selector); if (el) { el.textContent = value; } } function cardFee(cents) { var keep = 10000 - feePercent; if (!feeOn || cents <= 0 || keep <= 0) { return 0; } return Math.max(0, Math.floor(((cents + feeFixed) * 10000 + keep - 1) / keep) - cents); } function update() { var cents = 0; var count = 0; boxes.forEach(function (box) { if (box.checked) { cents += parseInt(box.getAttribute('data-pay-cents'), 10) || 0; count++; } var row = box.closest('li'); if (row) { row.classList.toggle('is-selected', box.checked); } }); var fee = cardFee(cents); var amount = money.format((cents + fee) / 100); var method = form.querySelector('input[name=method]:checked'); var over = limit > 0 && cents >= limit; var tooMany = max > 0 && count > max; text('[data-pay-subtotal]', money.format(cents / 100)); text('[data-pay-fee]', money.format(fee / 100)); text('[data-pay-total]', amount); text('[data-pay-count]', count + (count === 1 ? ' invoice' : ' invoices')); text('[data-pay-label]', !count ? 'Tick an invoice to pay' : (tooMany ? 'Pay up to ' + max + ' invoices at a time' : (method && method.value === 'new' ? 'Continue to pay ' : 'Pay ') + amount)); var note = form.querySelector('[data-pay-limit-note]'); if (note) { note.hidden = !over; } button.disabled = !count || over || tooMany; if (all) { all.checked = count === boxes.length; all.indeterminate = count > 0 && count < boxes.length; } } if (all) { all.addEventListener('change', function () { boxes.forEach(function (box) { box.checked = all.checked; }); update(); }); } boxes.forEach(function (box) { box.addEventListener('change', update); }); form.querySelectorAll('input[name=method]').forEach(function (radio) { radio.addEventListener('change', update); }); update(); }); // ---------- Tables ---------- document.querySelectorAll('.itflow-portal-main table.table').forEach(function (table) { if (table.closest('.table-responsive')) { return; } var wrap = document.createElement('div'); wrap.className = 'table-responsive'; table.parentNode.insertBefore(wrap, table); wrap.appendChild(table); }); })();