summaryrefslogtreecommitdiff
path: root/views/js
diff options
context:
space:
mode:
authordaniel <noreply@maennchen1.de>2026-09-24 11:24:12 +0200
committerdaniel <noreply@maennchen1.de>2026-09-24 11:24:12 +0200
commita95de0fa4cd3c617ef08a9679a6c32ae5f676fda (patch)
treeb78bd9fa23550e7dfd03cc693032cce5e9048236 /views/js
parent19a8938fa3b41a6c6ed1e15c20586f860f4a637b (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')
-rw-r--r--views/js/smartcheckout.js694
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');
+
+})();