PluginProbe
Easy Invoice – Invoice Generator, PDF Quotes & Payments / 2.4.0
Easy Invoice – Invoice Generator, PDF Quotes & Payments v2.4.0
2.4.0 2.4.1 2.3.8 2.3.7 2.3.6 2.3.5 2.3.4 2.3.3 2.3.2 2.3.1 2.2.0 2.1.21 2.1.20 2.1.19 2.1.18 2.1.0 2.1.1 2.1.10 2.1.11 2.1.12 2.1.13 2.1.14 2.1.15 2.1.16 2.1.2 All 57 releases
easy-invoice / assets / js / document.js

document.js in Easy Invoice – Invoice Generator, PDF Quotes & Payments 2.4.0, at assets/js/document.js

475 lines 20.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Public invoice / quote page.
3 *
4 * Everything the action bar does: print, PDF, send by email, the payment
5 * panel, accepting or declining a quote, and the administrator's per-document
6 * CSS editor. Configuration arrives in `easyInvoiceDocument` (localised by
7 * DocumentPage::enqueue).
8 */
9 (function ($) {
10 'use strict';
11
12 var cfg = window.easyInvoiceDocument || {};
13 var i18n = cfg.i18n || {};
14 var isQuote = cfg.type === 'quote';
15
16 /* ------------------------------------------------------------------ */
17 /* Modal */
18 /* ------------------------------------------------------------------ */
19
20 function closeModal(overlay) {
21 if (!overlay) { return; }
22 overlay.classList.remove('show');
23 var modal = overlay.querySelector('.ei-modal');
24 if (modal) { modal.classList.remove('show'); }
25 setTimeout(function () {
26 if (overlay.parentNode) { overlay.parentNode.removeChild(overlay); }
27 }, 200);
28 }
29
30 function closeAnyModal() {
31 var existing = document.querySelector('.ei-modal-overlay');
32 if (existing) { closeModal(existing); }
33 }
34
35 function iconFor(kind) {
36 return { info: '?', danger: '!', success: '', error: '' }[kind] || '?';
37 }
38
39 /**
40 * Build a modal.
41 *
42 * @param {Object} o title, message, kind (info|danger|success|error),
43 * confirmText, cancelText, reasonLabel (adds a textarea), onConfirm(reason, buttons)
44 */
45 function openModal(o) {
46 closeAnyModal();
47
48 var overlay = document.createElement('div');
49 overlay.className = 'ei-modal-overlay';
50 overlay.setAttribute('role', 'dialog');
51 overlay.setAttribute('aria-modal', 'true');
52
53 var reasonField = o.reasonLabel
54 ? '<label class="ei-modal-label" for="ei-modal-reason">' + escapeHtml(o.reasonLabel) + '</label>' +
55 '<textarea id="ei-modal-reason" class="ei-modal-textarea"></textarea>'
56 : '';
57
58 var buttons = o.confirmText
59 ? '<button type="button" class="ei-modal-btn ei-modal-btn-secondary" data-modal="cancel">' + escapeHtml(o.cancelText || i18n.cancel || 'Cancel') + '</button>' +
60 '<button type="button" class="ei-modal-btn ' + (o.kind === 'danger' ? 'ei-modal-btn-danger' : 'ei-modal-btn-primary') + '" data-modal="confirm">' + escapeHtml(o.confirmText) + '</button>'
61 : '<button type="button" class="ei-modal-btn ei-modal-btn-primary" data-modal="cancel">' + escapeHtml(o.cancelText || i18n.close || 'Close') + '</button>';
62
63 overlay.innerHTML =
64 '<div class="ei-modal">' +
65 '<div class="ei-modal-header">' +
66 '<div class="ei-modal-icon ' + escapeHtml(o.kind || 'info') + '">' + iconFor(o.kind) + '</div>' +
67 (o.title ? '<h3 class="ei-modal-title">' + escapeHtml(o.title) + '</h3>' : '') +
68 '</div>' +
69 '<div class="ei-modal-body">' +
70 (o.message ? '<p class="ei-modal-message">' + escapeHtml(o.message) + '</p>' : '') +
71 reasonField +
72 '</div>' +
73 '<div class="ei-modal-actions">' + buttons + '</div>' +
74 '</div>';
75
76 document.body.appendChild(overlay);
77 requestAnimationFrame(function () {
78 overlay.classList.add('show');
79 overlay.querySelector('.ei-modal').classList.add('show');
80 });
81
82 var confirmBtn = overlay.querySelector('[data-modal="confirm"]');
83 var cancelBtn = overlay.querySelector('[data-modal="cancel"]');
84
85 function onKey(e) {
86 if (e.key === 'Escape') { finish(); }
87 }
88 function finish() {
89 document.removeEventListener('keydown', onKey);
90 closeModal(overlay);
91 if (typeof o.onClose === 'function') { o.onClose(); }
92 }
93
94 cancelBtn.addEventListener('click', finish);
95 overlay.addEventListener('click', function (e) { if (e.target === overlay) { finish(); } });
96 document.addEventListener('keydown', onKey);
97
98 if (confirmBtn) {
99 confirmBtn.addEventListener('click', function () {
100 var reason = '';
101 var textarea = overlay.querySelector('#ei-modal-reason');
102 if (textarea) {
103 reason = textarea.value.trim();
104 if (o.reasonRequired && !reason) {
105 textarea.classList.add('error');
106 textarea.focus();
107 return;
108 }
109 }
110 o.onConfirm(reason, {
111 busy: function (label) {
112 confirmBtn.disabled = true;
113 cancelBtn.disabled = true;
114 confirmBtn.innerHTML = '<span class="ei-btn__spinner"></span> ' + escapeHtml(label || '');
115 },
116 close: function () {
117 document.removeEventListener('keydown', onKey);
118 closeModal(overlay);
119 }
120 });
121 });
122 (overlay.querySelector('#ei-modal-reason') || confirmBtn).focus();
123 } else {
124 cancelBtn.focus();
125 }
126
127 return overlay;
128 }
129
130 function showResult(kind, message, onClose) {
131 openModal({ kind: kind, message: message, onClose: onClose });
132 }
133
134 function escapeHtml(s) {
135 return String(s === undefined || s === null ? '' : s)
136 .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
137 .replace(/"/g, '&quot;').replace(/'/g, '&#039;');
138 }
139
140 function toast(kind, message) {
141 if (window.EasyInvoiceToast && typeof window.EasyInvoiceToast[kind] === 'function') {
142 window.EasyInvoiceToast[kind](message);
143 } else {
144 showResult(kind === 'success' ? 'success' : 'error', message);
145 }
146 }
147
148 /* ------------------------------------------------------------------ */
149 /* AJAX */
150 /* ------------------------------------------------------------------ */
151
152 function post(data) {
153 data = $.extend({ access_token: cfg.accessToken || '' }, data);
154 return $.ajax({ url: cfg.ajaxUrl, type: 'POST', dataType: 'json', data: data });
155 }
156
157 function errorMessage(response, fallback) {
158 if (response && response.data) {
159 if (typeof response.data === 'string') { return response.data; }
160 if (response.data.message) { return response.data.message; }
161 }
162 return fallback;
163 }
164
165 /* ------------------------------------------------------------------ */
166 /* Actions */
167 /* ------------------------------------------------------------------ */
168
169 function printDocument() {
170 window.print();
171 }
172
173 /**
174 * In-browser PDF: html2canvas + jsPDF over the page exactly as it is drawn
175 * — the design, a custom template, the watermark layer. The default for
176 * the Download button; also the fallback when the server cannot render.
177 */
178 function browserPdf() {
179 if (typeof window.DocumentPdfGenerator === 'undefined') {
180 toast('error', i18n.pdfUnavailable || 'PDF generator not available.');
181 return;
182 }
183 try {
184 new window.DocumentPdfGenerator(cfg.type).generatePDF();
185 } catch (e) {
186 toast('error', i18n.pdfUnavailable || 'PDF generator not available.');
187 }
188 }
189
190 function sendByEmail() {
191 openModal({
192 kind: 'info',
193 title: i18n.sendTitle,
194 message: i18n.sendMessage,
195 confirmText: i18n.send,
196 onConfirm: function (_reason, modal) {
197 modal.busy(i18n.sending);
198 var data = { action: isQuote ? 'easy_invoice_send_quote_email' : 'easy_invoice_send_invoice_email', nonce: (cfg.nonces || {}).sendEmail };
199 data[isQuote ? 'quote_id' : 'invoice_id'] = cfg.id;
200 post(data).done(function (r) {
201 modal.close();
202 if (r && r.success) {
203 showResult('success', errorMessage(r, i18n.sent));
204 } else {
205 showResult('error', errorMessage(r, i18n.sendFailed));
206 }
207 }).fail(function () {
208 modal.close();
209 showResult('error', i18n.networkError);
210 });
211 }
212 });
213 }
214
215 /* Payment panel (invoices) */
216 function paymentPanel() { return document.getElementById('payment-panel'); }
217 function payToggle() { return document.getElementById('toggle-payment-panel'); }
218
219 function openPayment() {
220 var panel = paymentPanel(), btn = payToggle();
221 if (!panel) { return; }
222 panel.hidden = false;
223 requestAnimationFrame(function () { panel.classList.add('is-open'); });
224 if (btn) { btn.classList.add('is-open'); btn.textContent = i18n.hidePayment || 'Hide payment'; }
225 }
226
227 function closePayment() {
228 var panel = paymentPanel(), btn = payToggle();
229 if (!panel) { return; }
230 panel.classList.remove('is-open');
231 setTimeout(function () { panel.hidden = true; }, 300);
232 if (btn) { btn.classList.remove('is-open'); btn.textContent = btn.getAttribute('data-label') || 'Pay Now'; }
233 }
234
235 function togglePayment() {
236 var panel = paymentPanel();
237 if (!panel) { return; }
238 if (panel.hidden || !panel.classList.contains('is-open')) { openPayment(); } else { closePayment(); }
239 }
240
241 /* Signature pad (only when cfg.signature is set by an addon) */
242 function signaturePad(overlay) {
243 var sig = cfg.signature || null;
244 if (!sig) { return null; }
245 var body = overlay.querySelector('.ei-modal-body');
246 var wrap = document.createElement('div');
247 wrap.className = 'ei-signature';
248 wrap.innerHTML =
249 '<label class="ei-modal-label" for="ei-signer-name">' + escapeHtml(sig.nameLabel || 'Your name') + '</label>' +
250 '<input type="text" id="ei-signer-name" class="ei-modal-input" autocomplete="name">' +
251 '<label class="ei-modal-label">' + escapeHtml(sig.padLabel || 'Sign here') + '</label>' +
252 '<canvas class="ei-signature__pad" width="440" height="140" aria-label="' + escapeHtml(sig.padLabel || 'Sign here') + '"></canvas>' +
253 '<div class="ei-signature__tools"><button type="button" class="ei-modal-btn ei-modal-btn-secondary" data-sig="clear">' + escapeHtml(sig.clearLabel || 'Clear') + '</button>' +
254 '<span class="ei-signature__hint">' + escapeHtml(sig.hint || '') + '</span></div>';
255 body.appendChild(wrap);
256
257 var canvas = wrap.querySelector('canvas');
258 var ctx = canvas.getContext('2d');
259 var drawing = false, drawn = false, last = null;
260 ctx.lineWidth = 2; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.strokeStyle = '#111';
261 function pos(e) {
262 var r = canvas.getBoundingClientRect();
263 var p = e.touches ? e.touches[0] : e;
264 return { x: (p.clientX - r.left) * (canvas.width / r.width), y: (p.clientY - r.top) * (canvas.height / r.height) };
265 }
266 function start(e) { drawing = true; last = pos(e); e.preventDefault(); }
267 function move(e) {
268 if (!drawing) { return; }
269 var p = pos(e);
270 ctx.beginPath(); ctx.moveTo(last.x, last.y); ctx.lineTo(p.x, p.y); ctx.stroke();
271 last = p; drawn = true; e.preventDefault();
272 }
273 function end() { drawing = false; }
274 canvas.addEventListener('mousedown', start); canvas.addEventListener('mousemove', move); window.addEventListener('mouseup', end);
275 canvas.addEventListener('touchstart', start, { passive: false }); canvas.addEventListener('touchmove', move, { passive: false }); canvas.addEventListener('touchend', end);
276 wrap.querySelector('[data-sig=clear]').addEventListener('click', function () { ctx.clearRect(0, 0, canvas.width, canvas.height); drawn = false; });
277
278 return {
279 required: !!sig.required,
280 value: function () { return drawn ? canvas.toDataURL('image/png') : ''; },
281 name: function () { return wrap.querySelector('#ei-signer-name').value.trim(); },
282 invalid: function () {
283 var bad = (sig.required && !drawn) || (sig.required && !this.name());
284 canvas.classList.toggle('error', sig.required && !drawn);
285 wrap.querySelector('#ei-signer-name').classList.toggle('error', sig.required && !this.name());
286 return bad;
287 }
288 };
289 }
290
291 /* Quote accept / decline */
292 function acceptQuote(button) {
293 var description = button.getAttribute('data-description') || '';
294 var pad = null;
295 var overlay = openModal({
296 kind: 'info',
297 title: i18n.acceptTitle,
298 message: description,
299 confirmText: i18n.accept,
300 onConfirm: function (_reason, modal) {
301 if (pad && pad.invalid()) { return; }
302 modal.busy(i18n.accepting);
303 var data = { action: 'easy_invoice_accept_quote', quote_id: cfg.id, nonce: (cfg.nonces || {}).quoteAction };
304 if (pad) { data.signature = pad.value(); data.signer_name = pad.name(); }
305 post(data).done(function (r) {
306 modal.close();
307 if (!(r && r.success)) {
308 showResult('error', errorMessage(r, i18n.actionFailed));
309 return;
310 }
311 var url = r.data && (r.data.secure_url || r.data.invoice_url);
312 if (r.data && r.data.invoice_id && url) {
313 showResult('success', errorMessage(r, i18n.acceptedRedirect), function () { window.location.href = url; });
314 setTimeout(function () { window.location.href = url; }, 1500);
315 } else {
316 showResult('success', errorMessage(r, i18n.accepted), function () { window.location.reload(); });
317 setTimeout(function () { window.location.reload(); }, 1500);
318 }
319 }).fail(function () {
320 modal.close();
321 showResult('error', i18n.networkError);
322 });
323 }
324 });
325 pad = signaturePad(overlay);
326 }
327
328 function declineQuote(button) {
329 var message = button.getAttribute('data-message') || i18n.declineMessage || '';
330 var reasonRequired = button.getAttribute('data-reason-required') === '1';
331 openModal({
332 kind: 'danger',
333 title: i18n.declineTitle,
334 message: message,
335 confirmText: i18n.decline,
336 reasonLabel: i18n.declineReason,
337 reasonRequired: reasonRequired,
338 onConfirm: function (reason, modal) {
339 modal.busy(i18n.declining);
340 var data = { action: 'easy_invoice_decline_quote', quote_id: cfg.id, nonce: (cfg.nonces || {}).quoteAction };
341 if (reason) { data.decline_reason = reason; }
342 post(data).done(function (r) {
343 modal.close();
344 if (r && r.success) {
345 showResult('success', errorMessage(r, i18n.declined), function () { window.location.reload(); });
346 setTimeout(function () { window.location.reload(); }, 1500);
347 } else {
348 showResult('error', errorMessage(r, i18n.actionFailed));
349 }
350 }).fail(function () {
351 modal.close();
352 showResult('error', i18n.networkError);
353 });
354 }
355 });
356 }
357
358 /* Administrator: per-document CSS */
359 function initCssPanel() {
360 var toggle = document.getElementById('additional-css-btn');
361 var panel = document.getElementById('css-panel');
362 var overlay = document.getElementById('css-overlay');
363 if (!toggle || !panel || !overlay) { return; }
364
365 var textarea = document.getElementById('additional-css-textarea');
366 var status = document.getElementById('css-status');
367 var styleId = 'custom-' + cfg.type + '-css';
368
369 function applyCss(css) {
370 var el = document.getElementById(styleId);
371 if (!el) {
372 el = document.createElement('style');
373 el.id = styleId;
374 document.head.appendChild(el);
375 }
376 el.textContent = css;
377 }
378 function say(kind, message) {
379 status.hidden = false;
380 status.className = 'ei-css-panel__status ' + (kind === 'success' ? 'is-success' : 'is-error');
381 status.textContent = message;
382 setTimeout(function () { status.hidden = true; }, 3000);
383 }
384 function open() { panel.hidden = false; overlay.hidden = false; textarea.focus(); }
385 function close() { panel.hidden = true; overlay.hidden = true; }
386 function save(css) {
387 return post({ action: 'save_additional_css', post_id: cfg.id, css: css, nonce: (cfg.nonces || {}).additionalCss });
388 }
389
390 toggle.addEventListener('click', open);
391 document.getElementById('close-css-panel').addEventListener('click', close);
392 overlay.addEventListener('click', close);
393
394 document.getElementById('preview-css-btn').addEventListener('click', function () { applyCss(textarea.value); });
395 document.getElementById('save-css-btn').addEventListener('click', function () {
396 save(textarea.value).done(function (r) {
397 if (r && r.success) {
398 applyCss(textarea.value);
399 toggle.classList.toggle('has-css', textarea.value.trim() !== '');
400 say('success', i18n.cssSaved);
401 } else {
402 say('error', errorMessage(r, i18n.cssFailed));
403 }
404 }).fail(function () { say('error', i18n.networkError); });
405 });
406 document.getElementById('clear-css-btn').addEventListener('click', function () {
407 textarea.value = '';
408 save('').done(function (r) {
409 if (r && r.success) {
410 applyCss('');
411 toggle.classList.remove('has-css');
412 say('success', i18n.cssCleared);
413 } else {
414 say('error', errorMessage(r, i18n.cssFailed));
415 }
416 }).fail(function () { say('error', i18n.networkError); });
417 });
418 }
419
420 /* ------------------------------------------------------------------ */
421 /* Wiring */
422 /* ------------------------------------------------------------------ */
423
424 document.addEventListener('click', function (e) {
425 var el = e.target.closest('[data-ei-action]');
426 if (!el) { return; }
427 switch (el.getAttribute('data-ei-action')) {
428 case 'print': e.preventDefault(); printDocument(); break;
429 case 'email': e.preventDefault(); sendByEmail(); break;
430 case 'pay': e.preventDefault(); togglePayment(); break;
431 case 'close-payment': e.preventDefault(); closePayment(); break;
432 case 'accept': e.preventDefault(); acceptQuote(el); break;
433 case 'decline': e.preventDefault(); declineQuote(el); break;
434 case 'pdf':
435 // Browser method: capture the page as drawn, right here, no
436 // reload. Server method: the link itself serves the file.
437 if (cfg.pdfMethod !== 'server') { e.preventDefault(); browserPdf(); }
438 break;
439 }
440 });
441
442 document.addEventListener('DOMContentLoaded', function () {
443 initCssPanel();
444
445 // The server serves ?auto_download_pdf=1 itself; reaching this page
446 // with the flag means it could not, so fall back to the browser.
447 if (cfg.autoDownload) {
448 setTimeout(browserPdf, 800);
449 }
450 // A link that lands the client straight on the payment panel.
451 if (cfg.openPayment) {
452 setTimeout(openPayment, 300);
453 }
454 });
455
456 // Names the previous templates defined globally, kept for customised
457 // design templates that call them.
458 window.printInvoiceContent = printDocument;
459 window.printQuoteContent = printDocument;
460 window.downloadInvoicePdf = browserPdf;
461 window.downloadQuotePdf = browserPdf;
462 window.handleDownloadPDF = browserPdf;
463 window.togglePaymentPanel = togglePayment;
464 window.closePaymentPanel = closePayment;
465 window.confirmSendEmail = sendByEmail;
466 window.sendInvoiceEmail = sendByEmail;
467 window.EasyInvoiceDocument = {
468 modal: openModal,
469 print: printDocument,
470 sendByEmail: sendByEmail,
471 openPayment: openPayment,
472 closePayment: closePayment
473 };
474 })(jQuery);
475