/** * Vue-Anwendung des modernen EinSeitenCheckouts * * Die Anwendung rechnet selbst nichts. Jede Änderung wird als Formulardaten an die * Checkout-Seite geschickt, dort serverseitig neu berechnet und als vollständiger * Zustand zurückgeliefert. */ (function() { 'use strict'; var el_root = document.getElementById('wpsg_smartcheckout'); var el_state = document.getElementById('wpsg_smartcheckout_state'); if (el_root === null || el_state === null || typeof Vue === 'undefined') return; var initialState = null; try { initialState = JSON.parse(el_state.textContent); } catch (e) { return; } Vue.createApp({ data: function() { return { state: initialState, form: {}, amounts: {}, voucher: '', voucherError: false, agb: false, garanPopup: false, garanConfirmed: false, garanSubmit: false, garanLightbox: false, busy: false, queued: false, queuedUpload: null, submitting: false, timer: null }; }, created: function() { this.syncForm(true); // Bereits akzeptierte AGB, z.B. nach der Rückkehr von PayPal this.agb = (this.state.accepted && this.state.accepted.agb === true); }, mounted: function() { // Nach einem abgelehnten Bestellversuch steht der Kunde vor dem ersten Fehler if (this.state.errors.length !== 0) this.$nextTick(this.focusError); // Das GARAN-Gewährleistungslabel erscheint wie in der klassischen Zusammenfassung beim Betreten der Seite if (this.state.warranty && this.state.warranty.popup === true && this.state.empty !== true) this.garanPopup = true; // Die Lightbox des Gewährleistungslabels lässt sich mit Esc schließen var self = this; document.addEventListener('keydown', function(event) { if (event.key === 'Escape' && self.garanLightbox === true) self.garanLightbox = false; }); // Zurück aus dem Seiten-Cache des Browsers (z.B. Zurück-Button nach dem Zahlungsanbieter): Layer wieder entfernen window.addEventListener('pageshow', function(event) { if (event.persisted) self.submitting = false; }); // Eingaben in den Blöcken der Module haben kein Vue-Binding, Änderungen werden hier abgefangen el_root.addEventListener('change', this.onModuleChange); // Buttons in den Blöcken der Module, die eine Menge an den Server schicken (z.B. Warenkorbteaser) el_root.addEventListener('click', this.onModuleClick); el_root.addEventListener('keydown', this.onModuleKeydown); }, methods: { /** * Übernimmt den Zustand des Servers in die Formularfelder * * Textfelder werden nur beim ersten Aufbau gesetzt — sonst würde eine Antwort, * die während des Tippens eintrifft, die Eingabe des Kunden überschreiben. */ syncForm: function(full) { var self = this; if (full === true) { Object.keys(this.state.checkout).forEach(function(key) { self.form[key] = self.state.checkout[key]; }); } this.form.shipping = this.state.shipping.selected; this.form.payment = this.state.payment.selected; this.amounts = {}; this.state.products.forEach(function(product) { self.amounts[product.index] = product.amount; }); }, // syncForm(full) /** * Baut den Namen des Formularfeldes, benutzerdefinierte Felder liegen verschachtelt */ fieldName: function(field) { if (field.custom) return 'wpsg[checkout][custom][' + field.custom + ']'; return 'wpsg[checkout][' + field.name + ']'; }, // fieldName(field) /** * Hat der Server dieses Feld als fehlerhaft gemeldet? */ hasError: function(name) { return (this.state.errors.indexOf(name) >= 0); }, // hasError(name) /** * Wählt eine Versand- oder Zahlungsart beim Klick in ihren Block (auch in Hinweis oder Eingabefelder) * * Das label umfasst nur die Kopfzeile, damit Eingabefelder im Hinweis (z.B. Kreditkarte) kein * verschachteltes label bilden. Der Klick auf das Radio selbst läuft über v-model und @change. */ selectChoice: function(type, id, event) { if (event && event.target && typeof event.target.closest === 'function' && event.target.closest('.sc_choice_head') !== null) return; if (this.form[type] === id) return; this.form[type] = id; this.refresh(); }, // selectChoice(type, id, event) /** * Erhöht oder verringert die Menge einer Position */ changeAmount: function(product, diff) { var amount = parseInt(this.amounts[product.index], 10); if (isNaN(amount)) amount = product.amount; amount = amount + diff; if (amount < 1) amount = 1; this.amounts[product.index] = amount; this.refresh(); }, // changeAmount(product, diff) /** * Schickt die Änderungen gesammelt an den Server * * Mehrere schnell aufeinander folgende Änderungen werden zu einer Anfrage * zusammengefasst. */ refresh: function() { var self = this; if (this.timer !== null) window.clearTimeout(this.timer); this.timer = window.setTimeout(function() { self.timer = null; self.send('update'); }, 250); }, // refresh() /** * Entfernt eine Position aus dem Warenkorb */ removeProduct: function(product) { this.send('remove', { wpsg_produkt: product.index }); }, // removeProduct(product) /** * Löst den eingegebenen Gutscheincode ein */ addVoucher: function() { if (this.voucher === '') return; this.send('voucherAdd'); }, // addVoucher() /** * Nimmt einen eingelösten Gutschein wieder aus dem Warenkorb */ removeVoucher: function(item) { this.send('voucherRemove', { wpsg_voucher: item.id }); }, // removeVoucher(item) /** * Schickt das Formular an den Server und übernimmt den neuen Zustand * * arKeepFiles: Namen von Dateifeldern, deren Datei mitgeschickt wird (Upload direkt nach der Auswahl) */ send: function(action, extra, arKeepFiles) { var el_form = document.getElementById('wpsg_smartcheckout_form'); if (el_form === null) return; // Dateien der direkt hochzuladenden Felder sofort sichern: der Block wird mit der nächsten Antwort neu aufgebaut var arUpload = []; if (Array.isArray(arKeepFiles)) { arKeepFiles.forEach(function(name) { el_form.querySelectorAll('input[type="file"]').forEach(function(el) { if (el.name !== name || !el.files) return; for (var i = 0; i < el.files.length; i++) arUpload.push({ name: name, file: el.files[i] }); }); }); } // Es läuft bereits eine Anfrage, die Änderung wird danach nachgereicht (ein Upload samt seiner Dateien) if (this.busy) { if (arUpload.length > 0) this.queuedUpload = { action: action, extra: extra, files: arUpload }; else this.queued = true; return; } this.sendData(el_form, action, extra, arUpload); }, // send(action, extra, arKeepFiles) /** * Baut die Formulardaten, schickt sie ab und übernimmt den neuen Zustand * * arUpload: [{ name, file }] Dateien, die mit dieser Anfrage hochgeladen werden */ sendData: function(el_form, action, extra, arUpload) { var self = this; var modules = this.captureModules(); var data = new FormData(el_form); // Dateien werden erst beim Abschluss übertragen, nicht bei jeder Aktualisierung, außer sie sollen direkt hochgeladen werden el_form.querySelectorAll('input[type="file"][name]').forEach(function(el) { data.delete(el.name); }); (arUpload || []).forEach(function(item) { data.append(item.name, item.file, item.file.name); }); data.append('wpsg_smartcheckout_ajax', action); if (extra) { Object.keys(extra).forEach(function(key) { data.append(key, extra[key]); }); } this.busy = true; window.fetch(el_form.getAttribute('action'), { method: 'POST', body: data, credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } }) .then(function(response) { return response.json(); }) .then(function(result) { self.busy = false; if (result.reload === true) { window.location.reload(); return; } self.voucherError = (result.voucherError === true); // Mini-Warenkorb (Block) aktualisieren document.dispatchEvent(new Event('wpsg_basket_changed')); if (result.ok === true && action === 'voucherAdd') self.voucher = ''; if (result.state) { var accepted = self.state.accepted; self.state = result.state; self.syncForm(false); self.$nextTick(function() { self.restoreModules(modules); self.revokeAccepted(accepted); }); } if (self.queuedUpload !== null) { var upload = self.queuedUpload; self.queuedUpload = null; self.queued = false; self.sendData(el_form, upload.action, upload.extra, upload.files); } else if (self.queued) { self.queued = false; self.refresh(); } }) .catch(function() { self.busy = false; self.queued = false; self.queuedUpload = null; }); }, // sendData(el_form, action, extra, arUpload) /** * Sichert die Eingaben in den servergerenderten Modulblöcken * * Diese Blöcke werden vom Server neu geliefert, sobald sich der Warenkorb * ändert — ein angekreuztes Kästchen wäre sonst nach jedem Versandartwechsel * wieder leer. */ captureModules: function() { var data = {}; document.querySelectorAll('#wpsg_smartcheckout .sc_modul_dynamic [name]').forEach(function(el) { // Dateifelder lassen sich nicht per Script setzen, ein Zuweisen von value würde eine Exception werfen if (el.type === 'file') return; if (el.type === 'checkbox' || el.type === 'radio') data[el.name + '\n' + el.value] = el.checked; else data[el.name] = el.value; }); return data; }, // captureModules() /** * Stellt die gesicherten Eingaben wieder her, soweit die Felder noch existieren */ restoreModules: function(data) { document.querySelectorAll('#wpsg_smartcheckout .sc_modul_dynamic [name]').forEach(function(el) { if (el.type === 'file') return; if (el.type === 'checkbox' || el.type === 'radio') { var key = el.name + '\n' + el.value; if (data.hasOwnProperty(key)) el.checked = data[key]; } else if (data.hasOwnProperty(el.name)) { el.value = data[el.name]; } }); }, // restoreModules(data) /** * Entfernt die Haken bei AGB und Bestellbedingungen, deren Akzeptanz der Server zurückgenommen hat * * Das passiert, wenn sich die Bestellung nach der Rückkehr von einem Zahlungsanbieter ändert. */ revokeAccepted: function(before) { var after = this.state.accepted || { agb: false, ordercondition: [] }; if (!before) return; if (before.agb === true && after.agb !== true) this.agb = false; (before.ordercondition || []).forEach(function(oc_id) { if (after.ordercondition.indexOf(oc_id) >= 0) return; var el = document.querySelector('#wpsg_smartcheckout .sc_modul_dynamic input[name="wpsg_mod_ordercondition[' + oc_id + ']"]'); if (el !== null) el.checked = false; }); }, // revokeAccepted(before) /** * Springt zum ersten beanstandeten Feld */ focusError: function() { var el_first = document.querySelector('#wpsg_smartcheckout .sc_invalid'); if (el_first === null) return; el_first.scrollIntoView({ behavior: 'smooth', block: 'center' }); var el_input = el_first.querySelector('input, select, textarea'); if (el_input !== null) el_input.focus(); }, // focusError() /** * Blockt das Absenden, solange der Server noch rechnet */ /** * Berechnet neu, wenn sich eine Eingabe in einem Block der Module ändert (z.B. Lieferadresse, Produktvariablen) * * Dateifelder laden über eigene Wege hoch, Zustimmungen ändern nichts an der Berechnung. */ onModuleChange: function(event) { var el = event.target; if (!el || typeof el.closest !== 'function') return; // Uploads starten direkt nach der Auswahl über den Button des Moduls (z.B. Upload zum Produkt) if (el.type === 'file') { this.startUpload(el); return; } // Felder ohne Namen werden nicht übertragen, eine Aktualisierung brächte nichts if (!el.name) return; if (el.closest('.sc_modul_dynamic, .sc_modul_extra, .sc_modul_customer') === null) return; if (el.closest('.sc_modul_conditions') !== null) return; this.refresh(); }, // onModuleChange(event) /** * Löst den Upload-Button des Moduls aus, sobald im Dateifeld daneben eine Datei gewählt wurde * * Der Button trägt data-sc-autoupload und wird im SmartCheckout ausgeblendet. Hochgeladen wird über * den bestehenden Weg des Moduls, der auch das Ziel mit der Dateiliste neu befüllt. Trägt das Dateifeld * selbst data-sc-upload, geht die Datei mit einer Aktualisierung des SmartCheckouts an den Server. */ startUpload: function(el_file) { if (!el_file.files || el_file.files.length === 0 || el_file.parentElement === null) return; // Dateifeld, das der SmartCheckout selbst an den Server schickt (z.B. Bestellupload) if (el_file.hasAttribute('data-sc-upload') && el_file.name) { // Rückmeldung wie beim Upload zum Produkt, der Block wird mit der Antwort neu aufgebaut if (el_file.hasAttribute('data-sc-upload-wait') && el_file.parentElement.querySelector('.sc_upload_wait') === null) { var el_wait = document.createElement('div'); el_wait.className = 'sc_upload_wait'; el_wait.textContent = el_file.getAttribute('data-sc-upload-wait') + '…'; el_file.insertAdjacentElement('afterend', el_wait); } if (this.timer !== null) { window.clearTimeout(this.timer); this.timer = null; } this.send('update', null, [el_file.name]); return; } var el_button = el_file.parentElement.querySelector('[data-sc-autoupload]'); if (el_button !== null) el_button.click(); }, // startUpload(el_file) /** * Schickt die Menge eines Buttons mit data-sc-send unter dem dort angegebenen Namen an den Server * * Die Menge steht im Feld mit data-sc-amount innerhalb desselben data-sc-item, ein fester Wert in * data-sc-value. Mit data-sc-confirm fragt der Button vorher nach. */ onModuleClick: function(event) { var el = event.target; if (!el || typeof el.closest !== 'function') return; // Label ohne gültiges for (z.B. Bestellbedingungen und Bestellvariablen mancher Themes): Checkbox daneben umschalten var el_label = el.closest('.sc_modul_dynamic label, .sc_modul_extra label'); if (el_label !== null && el.closest('a') === null && el_label.control === null) { var el_wrap = el_label.parentElement; var el_check = ((el_wrap !== null)?el_wrap.querySelector('input[type="checkbox"]'):null); if (el_check !== null && !el_label.contains(el_check)) { el_check.click(); return; } } var el_button = el.closest('.sc_modul_dynamic [data-sc-send]'); if (el_button === null) return; event.preventDefault(); if (this.busy) return; // Rückfrage des Moduls, z.B. vor dem Entfernen eines Dokuments if (el_button.hasAttribute('data-sc-confirm') && !window.confirm(el_button.getAttribute('data-sc-confirm'))) return; var value; if (el_button.hasAttribute('data-sc-value')) { // Fester Wert des Buttons, z.B. der Index eines Dokuments value = el_button.getAttribute('data-sc-value'); } else { var el_item = el_button.closest('[data-sc-item]'); var el_amount = ((el_item !== null)?el_item.querySelector('[data-sc-amount]'):null); value = ((el_amount !== null)?parseInt(el_amount.value, 10):1); if (isNaN(value) || value < 1) value = 1; } var extra = {}; extra[el_button.getAttribute('data-sc-send')] = value; // Eine noch ausstehende Änderung geht in derselben Anfrage mit if (this.timer !== null) { window.clearTimeout(this.timer); this.timer = null; } this.send('update', extra); }, // onModuleClick(event) /** * Enter im Mengenfeld eines Modulblocks löst dessen Button aus, statt die Bestellung abzuschicken */ onModuleKeydown: function(event) { if (event.key !== 'Enter') return; var el = event.target; if (!el || typeof el.closest !== 'function' || !el.hasAttribute('data-sc-amount')) return; event.preventDefault(); var el_item = el.closest('[data-sc-item]'); var el_button = ((el_item !== null)?el_item.querySelector('[data-sc-send]'):null); if (el_button !== null) el_button.click(); }, // onModuleKeydown(event) /** * Blockt das Absenden, solange der Server noch rechnet oder eine Änderung noch nicht übertragen wurde */ onSubmit: function(event) { // Andere Buttons im Formular (z.B. Login der Kundenverwaltung) werden normal abgeschickt if (event.submitter && event.submitter.name !== 'wpsg_smartcheckout_order') return; // Die Bestellung ist bereits unterwegs, ein weiterer Klick darf sie nicht ein zweites Mal abschicken if (this.submitting) { event.preventDefault(); return; } if (this.timer !== null) { // Ausstehende Änderung sofort übertragen, der Kunde sieht danach die neue Summe event.preventDefault(); window.clearTimeout(this.timer); this.timer = null; this.send('update'); return; } if (this.busy) { event.preventDefault(); return; } // Das GARAN-Gewährleistungslabel muss vor dem Bestellabschluss zur Kenntnis genommen werden if (this.state.warranty && this.state.warranty.popup === true && this.garanConfirmed !== true) { event.preventDefault(); this.garanSubmit = true; this.garanPopup = true; return; } // Ab hier wird tatsächlich bestellt: Layer über die Seite, bis der Browser die nächste Seite lädt this.submitting = true; }, // onSubmit(event) /** * Schließt das GARAN-Popup, kam es über den Bestellbutton, wird die Bestellung danach abgeschickt */ confirmGaran: function() { this.garanConfirmed = true; this.garanPopup = false; if (this.garanSubmit !== true) return; this.garanSubmit = false; var el_form = document.getElementById('wpsg_smartcheckout_form'); var el_button = el_form.querySelector('button[name="wpsg_smartcheckout_order"]'); // requestSubmit() übermittelt den Bestellbutton mit und prüft die Pflichtfelder erneut if (typeof el_form.requestSubmit === 'function' && el_button !== null) el_form.requestSubmit(el_button); else if (el_button !== null) el_button.click(); } // confirmGaran() } }).mount('#wpsg_smartcheckout'); })();