diff options
| author | daniel <noreply@maennchen1.de> | 2026-09-24 11:24:12 +0200 |
|---|---|---|
| committer | daniel <noreply@maennchen1.de> | 2026-09-24 11:24:12 +0200 |
| commit | a95de0fa4cd3c617ef08a9679a6c32ae5f676fda (patch) | |
| tree | b78bd9fa23550e7dfd03cc693032cce5e9048236 /views/js/smartcheckout.js | |
| parent | 19a8938fa3b41a6c6ed1e15c20586f860f4a637b (diff) | |
- Feature: PHP 8.3 Kompatibilität
- Feature: SmartCheckout - Ersatz für EinSeitenCheckout
- Feature: Optik im Frontend an moderne Themes angepasst
Diffstat (limited to 'views/js/smartcheckout.js')
| -rw-r--r-- | views/js/smartcheckout.js | 694 |
1 files changed, 694 insertions, 0 deletions
diff --git a/views/js/smartcheckout.js b/views/js/smartcheckout.js new file mode 100644 index 0000000..df7721a --- /dev/null +++ b/views/js/smartcheckout.js @@ -0,0 +1,694 @@ +/** + * 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) + + /** + * 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); + + 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; + + 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'); + +})(); |
