Files
itflow/js/portal.js

300 lines
13 KiB
JavaScript

/*
* 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() && !/<img/i.test(editor.getContent());
if (empty) {
ev.preventDefault();
ev.stopImmediatePropagation();
var holder = editor.getContainer();
holder.classList.add('is-invalid');
var note = form.querySelector('[data-editor-required-note]');
if (note) {
note.hidden = false;
}
editor.focus();
}
}, true);
editor.on('input change', function () {
editor.getContainer().classList.remove('is-invalid');
var note = form.querySelector('[data-editor-required-note]');
if (note) {
note.hidden = true;
}
});
}
});
}
});
}
// ---------- Attachments ----------
function formatSize(bytes) {
if (bytes >= 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);
});
})();