diff --git a/public/embed/shop-demo.html b/public/embed/shop-demo.html new file mode 100644 index 0000000..cd8caa3 --- /dev/null +++ b/public/embed/shop-demo.html @@ -0,0 +1,33 @@ + + + + + + +Webshop Testseite + + + +
Testseite der Kundenwebsite (intern, nicht verlinkt)
+
+

Unser Online-Shop

+ + +
+

Unser Shop ist gerade nicht erreichbar. Bestellungen telefonisch unter 06301 1234.

+
+
+ + + + diff --git a/public/embed/shop.js b/public/embed/shop.js new file mode 100644 index 0000000..adbbb1d --- /dev/null +++ b/public/embed/shop.js @@ -0,0 +1,1740 @@ +/*! WEBklar Webshop: Einbau-Skript fuer Kundenwebsites. + *
Ausweichtext
+ * data-ansicht="widerruf" oeffnet gleich die Widerrufsfunktion ("Vertrag widerrufen"); die Seitenadresse kennt ?ansicht=widerruf und ?ansicht=agb. + * Keine Abhaengigkeiten, keine Cookies, keine Drittanfragen. Einziger Browser-Speicher: der Warenkorb (nur Artikel-IDs und Mengen) in der + * sessionStorage des Besuchers, solange die Seite im Tab geoeffnet ist (technisch erforderlich, kein Tracking). Alle Texte der Haendler nur ueber + * textContent. Das Widget rechnet nie: Preise, Summen, Versand, Steuerhinweise und Datumstexte kommen als fertige Texte vom Server. */ +(function () { + 'use strict' + var SELF = document.currentScript + var STYLE_ID = 'wks-style' + var API_PFAD = '/api/public/shop/v1/' + var CONFIG_ALTER_MAX_MS = 100 * 60 * 1000 // vor dem Ablauf des Formular-Tokens (2 h) neu laden + var TIMEOUT_MS = 20000 + var FORM_MIN_VORGABE_S = 3 // so lange muss ein Formular-Token mindestens alt sein (wie der Server) + var FORM_PUFFER_MS = 500 // Sicherheitszuschlag zu FORM_MIN_VORGABE_S (Laufzeit der Anfrage) + var MAX_MENGE = 99 + var MAX_POSITIONEN = 50 + var STATUS_VERSUCHE = 5 // Abrufe des Bestellstatus nach der Rueckkehr von Stripe, solange die Bestellung "offen" ist + var STATUS_ABSTAND_MS = 2000 + var PREFIX = 'wks' + Math.random().toString(36).slice(2, 6) + '-' + var zaehler = 0 + var EMAIL_FORM = /^[^\s@,;<>"]+@[^\s@,;<>"]+\.[^\s@,;<>"]+$/ + var ID_FORM = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/ + var TOKEN_FORM = /^[A-Za-z0-9._~-]{8,300}$/ + var LAENDER = { DE: 'Deutschland', AT: 'Österreich' } + var rueckkehr = null // Rueckkehr von Stripe (wk_bestellung/wk_t oder wk_abbruch): der erste Container, der startet, verbraucht sie + var queryAnsicht = '' + + var CSS = [ + '@layer wks{', + '.wks-root{--wks-i-akzent:var(--wks-akzent,var(--wks-vorgabe-akzent,#1d4ed8));--wks-i-akzent-text:var(--wks-akzent-text,var(--wks-vorgabe-akzent-text,#fff));', + '--wks-i-bg:var(--wks-hintergrund,#fff);--wks-i-text:var(--wks-text,#1f2937);--wks-i-rand:var(--wks-rand,#858f9e);--wks-i-radius:var(--wks-radius,10px);', + '--wks-i-weich:#f3f4f6;--wks-i-fehler:#b91c1c;--wks-i-schrift:var(--wks-schrift,inherit);--wks-i-titel:var(--wks-schrift-titel,var(--wks-schrift,inherit))}', + '@supports (color:color-mix(in srgb,red,blue)){.wks-root{--wks-i-weich:color-mix(in srgb,var(--wks-i-text) 6%,var(--wks-i-bg))}}', + '.wks-root{container:wks/inline-size;display:block;position:relative;box-sizing:border-box;padding:20px;border:1px solid #c9ced6;border-radius:var(--wks-i-radius);', + 'background:var(--wks-i-bg);color:var(--wks-i-text);font-family:var(--wks-i-schrift);font-size:16px;line-height:1.5;text-align:left}', + '.wks-root *,.wks-root *::before,.wks-root *::after{box-sizing:border-box}', + '.wks-root [hidden]{display:none!important}', + '.wks-vh{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}', + '.wks-kopfzeile{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:.5rem 1rem;margin:0 0 .25rem}', + '.wks-shopname{font-family:var(--wks-i-titel);font-size:1.5rem;line-height:1.2;font-weight:700;margin:0;color:inherit}', + '.wks-lieferinfo{margin:0 0 .75rem;font-size:.9rem;opacity:.9}', + '.wks-titel{font-family:var(--wks-i-titel);font-size:1.3rem;line-height:1.25;font-weight:700;margin:.5rem 0 .75rem;color:inherit}', + '.wks-titel:focus{outline:none}.wks-titel:focus-visible{outline:3px solid var(--wks-i-text);outline-offset:3px}', + '.wks-untertitel{font-size:1.05rem;font-weight:700;margin:1rem 0 .35rem;color:inherit}', + '.wks-root p{margin:0 0 .75rem}', + '.wks-laden{opacity:.8}', + '.wks-btn{font:inherit;font-weight:600;min-height:44px;padding:.55rem 1.25rem;border-radius:var(--wks-i-radius);border:2px solid var(--wks-i-akzent);background:var(--wks-i-akzent);color:var(--wks-i-akzent-text);cursor:pointer;line-height:1.25}', + '.wks-btn:hover{filter:brightness(1.08)}', + '.wks-btn-sek{background:transparent;color:inherit;border-color:var(--wks-i-rand)}', + '.wks-btn[disabled]{opacity:.6;cursor:default}', + '.wks-link{font:inherit;color:inherit;background:none;border:0;padding:.2rem 0;min-height:24px;text-decoration:underline;cursor:pointer;text-align:left}', + '.wks-nav{display:flex;flex-wrap:wrap;gap:.75rem;justify-content:space-between;align-items:center;margin-top:1.25rem}', + '.wks-nav>:only-child{margin-left:auto}', + '.wks-root :focus-visible{outline:3px solid var(--wks-i-text);outline-offset:2px}', + '.wks-chips{display:flex;flex-wrap:wrap;gap:.5rem;margin:0 0 1rem}', + '.wks-chip{font:inherit;min-height:44px;padding:.4rem 1rem;border-radius:999px;border:1px solid var(--wks-i-rand);background:var(--wks-i-bg);color:inherit;cursor:pointer}', + '.wks-chip[aria-pressed="true"]{background:var(--wks-i-akzent);color:var(--wks-i-akzent-text);border-color:var(--wks-i-akzent);font-weight:600}', + '.wks-raster{list-style:none;margin:0 0 1rem;padding:0;display:grid;grid-template-columns:repeat(auto-fill,minmax(11rem,1fr));gap:1rem}', + '.wks-raster>li{margin:0;padding:0}', + '.wks-karte{position:relative;display:flex;flex-direction:column;gap:.3rem;height:100%;border:1px solid var(--wks-i-rand);border-radius:var(--wks-i-radius);padding:.6rem;background:var(--wks-i-bg)}', + '.wks-karte:hover{border-color:var(--wks-i-akzent)}', + '.wks-karte:focus-within{outline:3px solid var(--wks-i-text);outline-offset:2px}', + '.wks-karte-bild,.wks-karte-leer{display:block;width:100%;aspect-ratio:1/1;object-fit:cover;border-radius:calc(var(--wks-i-radius) * .7);background:var(--wks-i-weich)}', + '.wks-karte-name{margin:0;font-size:1rem;line-height:1.3}', + '.wks-karte-knopf{font:inherit;font-weight:700;text-align:left;background:none;border:0;padding:0;color:inherit;cursor:pointer}', + '.wks-karte-knopf::after{content:"";position:absolute;inset:0}', + '.wks-karte-knopf:focus-visible{outline:none}', + '.wks-karte-text{font-size:.9rem;margin:0}', + '.wks-preis{font-weight:700;margin:0}', + '.wks-grundpreis,.wks-klein{font-size:.85rem;opacity:.85;margin:0}', + '.wks-aus{font-size:.85rem;font-weight:600;color:var(--wks-i-fehler);margin:0}', + '.wks-hinweiszeile{font-size:.85rem;margin:0 0 .75rem;opacity:.9}', + '.wks-detail{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:1.25rem;margin:0 0 1rem}', + '.wks-galerie-gross{display:block;width:100%;aspect-ratio:1/1;object-fit:contain;border:1px solid var(--wks-i-rand);border-radius:var(--wks-i-radius);background:var(--wks-i-weich)}', + '.wks-daumen{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:.5rem}', + '.wks-daumen button{width:4rem;height:4rem;min-width:44px;min-height:44px;padding:0;border:2px solid var(--wks-i-rand);border-radius:calc(var(--wks-i-radius) * .7);background:var(--wks-i-bg);cursor:pointer;overflow:hidden}', + '.wks-daumen button[aria-pressed="true"]{border-color:var(--wks-i-akzent)}', + '.wks-daumen img{display:block;width:100%;height:100%;object-fit:cover}', + '.wks-beschreibung p,.wks-text p{white-space:pre-line}', + '.wks-form{margin:0}', + '.wks-feld{margin:0 0 1rem;min-width:0}', + 'fieldset.wks-feld{border:1px solid var(--wks-i-rand);border-radius:var(--wks-i-radius);padding:.5rem 1rem .75rem}', + '.wks-feld legend{font-weight:600;padding:0 .25rem}', + '.wks-label{display:block;font-weight:600;margin-bottom:.25rem}', + '.wks-stern{color:var(--wks-i-fehler)}', + '.wks-hinweis{font-size:.875rem;margin:0 0 .35rem;opacity:.9}', + '.wks-eingabe{display:block;width:100%;min-height:44px;font:inherit;color:inherit;background:var(--wks-i-bg);border:1px solid var(--wks-i-rand);border-radius:calc(var(--wks-i-radius) * .7);padding:.5rem .7rem}', + '.wks-eingabe-menge{width:6rem}', + 'textarea.wks-eingabe{min-height:6rem;resize:vertical}', + '.wks-eingabe[aria-invalid="true"]{border-color:var(--wks-i-fehler);border-width:2px}', + '.wks-feldfehler,.wks-fehlertext{color:var(--wks-i-fehler);font-weight:600;font-size:.9rem;margin:.3rem 0 0}', + '.wks-fehlertext{margin:0 0 .75rem}', + '.wks-meldung{margin:0 0 .75rem;padding:.6rem .8rem;border-left:4px solid var(--wks-i-akzent);background:var(--wks-i-weich);font-weight:600}', + '.wks-radio{display:flex;gap:.65rem;align-items:flex-start;margin:0 0 .5rem;min-height:44px}', + '.wks-radio input{flex:none;width:1.4rem;height:1.4rem;margin:.35rem 0 0;accent-color:var(--wks-i-akzent)}', + '.wks-radio label{cursor:pointer;padding-top:.2rem}', + '.wks-zeilen{list-style:none;margin:0 0 1rem;padding:0;display:grid;gap:.6rem}', + '.wks-zeilen>li{margin:0;padding:.7rem .9rem;border:1px solid var(--wks-i-rand);border-radius:var(--wks-i-radius);display:flex;flex-wrap:wrap;gap:.5rem 1rem;align-items:center;justify-content:space-between}', + '.wks-zeile-name{font-weight:700;margin:0}', + '.wks-zeile-info{flex:1 1 12rem;min-width:0}', + '.wks-zeile-menge{display:flex;align-items:center;gap:.5rem}', + '.wks-zeile-menge label{font-size:.9rem}', + '.wks-pflicht{margin:1.25rem 0;padding:.9rem 1rem;border:2px solid var(--wks-i-akzent);border-radius:var(--wks-i-radius);background:var(--wks-i-bg)}', + '.wks-pflicht>:last-child{margin-bottom:0}', + '.wks-pflicht h4{font-size:1rem;margin:.9rem 0 .25rem}', + '.wks-pflicht h4:first-child{margin-top:0}', + '.wks-pos{list-style:none;margin:0 0 .75rem;padding:0;display:grid;gap:.5rem}', + '.wks-pos>li{display:flex;flex-wrap:wrap;justify-content:space-between;gap:.25rem 1rem;margin:0}', + '.wks-pos-text{flex:1 1 12rem;min-width:0;overflow-wrap:anywhere}', + '.wks-summen{margin:0 0 .75rem;display:grid;grid-template-columns:1fr max-content;gap:.25rem 1rem}', + '.wks-summen dt{margin:0}.wks-summen dd{margin:0;text-align:right}', + '.wks-summen .wks-gesamt{font-weight:700;font-size:1.1rem;border-top:1px solid var(--wks-i-rand);padding-top:.35rem}', + '.wks-zusammen{margin:0 0 1rem;display:grid;grid-template-columns:max-content 1fr;gap:.35rem 1rem}', + '.wks-zusammen dt{font-weight:600}.wks-zusammen dd{margin:0;overflow-wrap:anywhere;white-space:pre-line}', + '.wks-gewaehr{margin:.75rem 0}', + '.wks-gewaehr img{display:block;max-width:100%;height:auto;border:1px solid var(--wks-i-rand);border-radius:calc(var(--wks-i-radius) * .7)}', + '.wks-links{display:flex;flex-wrap:wrap;gap:.25rem 1.25rem;margin:0 0 .75rem}', + '.wks-box{margin:0 0 1rem;padding:.75rem 1rem;border-radius:var(--wks-i-radius);background:var(--wks-i-weich)}', + '.wks-box h4{font-size:1rem;margin:0 0 .25rem}', + '.wks-box p{white-space:pre-line}', + '.wks-fuss{margin-top:1.5rem;padding-top:.75rem;border-top:1px solid #c9ced6;font-size:.875rem}', + '.wks-fuss-links{display:flex;flex-wrap:wrap;align-items:baseline;gap:.35rem 1.25rem;margin:0 0 .5rem}', + '.wks-fuss a{display:inline-block;padding:.2rem 0;min-height:24px;color:inherit}', + '.wks-anbieter{margin:0 0 .35rem;opacity:.85;overflow-wrap:anywhere}', + '.wks-powered{margin:0;opacity:.75;font-size:.8rem}', + '.wks-ergebnis-text{font-size:1.05rem}', + 'dialog.wks-dialog{position:fixed;inset:auto;left:50%;top:50%;transform:translate(-50%,-50%);margin:0;padding:0;border:0;overflow:auto;width:min(40rem,calc(100vw - 1.5rem));max-height:calc(100vh - 1.5rem);max-height:calc(100dvh - 1.5rem);border-radius:var(--wks-radius,10px);background:var(--wks-hintergrund,#fff);color:var(--wks-text,#1f2937);box-shadow:0 20px 50px rgba(0,0,0,.35)}', + 'dialog.wks-dialog::backdrop{background:rgba(15,23,42,.55)}', + '.wks-dialog-innen{padding:20px;font-family:var(--wks-schrift,inherit);font-size:16px;line-height:1.5;text-align:left}', + '.wks-dialog-innen h3{font-size:1.2rem;margin:0 3rem .75rem 0}', + '.wks-dialog-text{font-size:.95rem}', + '.wks-dialog-text p{white-space:pre-line;margin:0 0 .75rem}', + '.wks-schliessen{position:absolute;top:.5rem;right:.5rem;z-index:1;font:inherit;font-size:1.5rem;line-height:1;min-width:44px;min-height:44px;border-radius:var(--wks-i-radius,10px);border:1px solid #858f9e;background:#fff;color:#1f2937;cursor:pointer}', + '@container wks (max-width:34rem){', + '.wks-detail{grid-template-columns:minmax(0,1fr)}', + '.wks-nav{flex-direction:column-reverse;align-items:stretch}.wks-nav .wks-btn{width:100%}', + '.wks-zusammen{grid-template-columns:1fr;gap:0}.wks-zusammen dd{margin-bottom:.5rem}', + '.wks-raster{grid-template-columns:repeat(auto-fill,minmax(9rem,1fr));gap:.6rem}', + '}', + '@media (max-width:30rem){.wks-root{padding:16px}}', + '@media (forced-colors:active){.wks-chip[aria-pressed="true"],.wks-daumen button[aria-pressed="true"]{outline:2px solid Highlight}}', + '}' + ].join('\n') + + function injectStyle () { + if (document.getElementById(STYLE_ID)) return + var st = document.createElement('style') + st.id = STYLE_ID + st.textContent = CSS + document.head.appendChild(st) + } + + function el (tag, cls, text) { + var e = document.createElement(tag) + if (cls) e.className = cls + if (text != null) e.textContent = text + return e + } + + function knopf (cls, text, onClick) { + var b = el('button', cls, text) + b.type = 'button' + if (onClick) b.addEventListener('click', onClick) + return b + } + + function httpsUrl (u) { + if (typeof u !== 'string') return '' + try { + var p = new URL(u) + return p.protocol === 'https:' ? p.href : '' + } catch (e) { return '' } + } + + function istText (v) { return typeof v === 'string' } + function nichtLeer (v) { return typeof v === 'string' && v.replace(/\s+/g, '') !== '' } + function istObjekt (v) { return v !== null && typeof v === 'object' && !Array.isArray(v) } + function liste (v) { return Array.isArray(v) ? v : [] } + function knoten (nl) { return Array.prototype.slice.call(nl) } + + // Pfade des Servers (ohne Host) bekommen die API-Adresse vorangestellt; nur Pfade, die mit genau einem / beginnen + function absUrl (api, pfad) { + if (!istText(pfad) || pfad.charAt(0) !== '/' || pfad.charAt(1) === '/') return '' + return api + pfad + } + + function apiBasis (root) { + var a = String(root.getAttribute('data-api') || '').replace(/\/+$/, '') + if (a) return a + var s = SELF && SELF.src ? SELF : document.querySelector('script[src*="/embed/shop.js"]') + if (s && s.src) { + try { return new URL(s.src).origin } catch (e) { return '' } + } + return '' + } + + function queryWert (name) { + var m = new RegExp('[?&]' + name + '=([^&#]*)').exec(location.search || '') + if (!m) return '' + try { return decodeURIComponent(m[1].replace(/\+/g, ' ')) } catch (e) { return '' } + } + + // Parameter aus der Adresse der Seite entfernen, ohne neu zu laden (nach der Rueckkehr von Stripe) + function bereinigeUrl (namen) { + try { + var rest = String(location.search || '').replace(/^\?/, '').split('&').filter(function (p) { + return p !== '' && namen.indexOf(p.split('=')[0]) < 0 + }).join('&') + history.replaceState(history.state, '', location.pathname + (rest ? '?' + rest : '') + (location.hash || '')) + } catch (e) { /* Adresse bleibt, wie sie ist */ } + } + + // @farben-anfang (reine Funktionen wie in embed/termine.js) + // Relative Leuchtdichte nach WCAG 2.1 fuer '#rrggbb'. + function luminanz (farbe) { + var rgb = [1, 3, 5].map(function (i) { + var c = parseInt(farbe.slice(i, i + 2), 16) / 255 + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4) + }) + return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2] + } + + // Kontrastverhaeltnis zweier Leuchtdichten (1 bis 21). + function kontrast (a, b) { + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) + } + + // Schrift auf der Akzentfarbe: Weiss oder das dunkle Grau, je nachdem was mehr Kontrast gibt; erreicht keines 4,5 : 1, nimmt sie Schwarz. + function textFarbeAuf (farbe) { + var l = luminanz(farbe) + var weiss = kontrast(l, 1) + var dunkel = kontrast(l, luminanz('#111827')) + var beste = Math.max(weiss, dunkel) + if (beste < 4.5 && kontrast(l, 0) > beste) return '#000000' + return weiss >= dunkel ? '#ffffff' : '#111827' + } + // @farben-ende + + // Akzentfarbe des Shops (#rrggbb), wenn die Seite --wks-akzent nicht selbst setzt + function setzeAkzent (box, farbe) { + if (!/^#[0-9a-fA-F]{6}$/.test(farbe || '')) return + if (getComputedStyle(box).getPropertyValue('--wks-akzent').trim()) return + box.style.setProperty('--wks-vorgabe-akzent', farbe) + box.style.setProperty('--wks-vorgabe-akzent-text', textFarbeAuf(farbe)) + } + + // ---------------------------------------------------------------- Warenkorb im Browser (nur IDs und Mengen) + + function korbSchluessel (key) { return 'wks-korb-' + key } + + function korbLesen (key) { + var aus = [] + try { + var roh = window.sessionStorage.getItem(korbSchluessel(key)) + var arr = roh ? JSON.parse(roh) : [] + if (Array.isArray(arr)) { + arr.slice(0, MAX_POSITIONEN).forEach(function (z) { + if (!Array.isArray(z) || z.length !== 3 || !istText(z[0]) || !ID_FORM.test(z[0]) || !istText(z[1]) || (z[1] !== '' && !ID_FORM.test(z[1]))) return + var n = z[2] + if (typeof n !== 'number' || n % 1 !== 0 || n < 1) return + aus.push({ id: z[0], v: z[1], n: Math.min(n, MAX_MENGE) }) + }) + } + } catch (e) { aus = [] } + return aus + } + + function korbSchreiben (key, korb) { + try { + if (korb.length === 0) window.sessionStorage.removeItem(korbSchluessel(key)) + else window.sessionStorage.setItem(korbSchluessel(key), JSON.stringify(korb.map(function (z) { return [z.id, z.v, z.n] }))) + } catch (e) { /* ohne Speicher bleibt der Warenkorb im Speicher der Seite */ } + } + + // Zeilen eines Textes: Absaetze sind durch Leerzeilen getrennt + function absaetze (text, cls) { + var box = el('div', cls) + String(text).split(/\n{2,}/).forEach(function (a) { if (a.trim()) box.appendChild(el('p', '', a.trim())) }) + return box + } + + function adresseText (a) { + if (!istObjekt(a)) return '' + var land = istText(a.land) ? (LAENDER[a.land] || a.land) : '' + return [a.name, a.strasse, [a.plz, a.ort].filter(nichtLeer).join(' '), land].filter(nichtLeer).join(', ') + } + + // ---------------------------------------------------------------- Ein Shop + + function init (root) { + var key = root.getAttribute('data-webklar-shop') + if (!key || root.getAttribute('data-wks-init')) return + root.setAttribute('data-wks-init', '1') + var api = apiBasis(root) + var id = PREFIX + (++zaehler) + '-' + var ausweich = null // Ausweichtext der Seite (Kindknoten des Containers), solange das Widget ihn ersetzt + var dlg = null + var dlgAusloeser = null + var ui = { box: null, live: null, kopf: null, body: null, fuss: null, titel: null, statusBox: null } + var S = null + + function neuerZustand () { + return { + cfg: S ? S.cfg : null, + cfgAt: S ? S.cfgAt : 0, + korb: S ? S.korb : [], + ansicht: '', + vorWiderruf: '', + kategorie: '', + produktId: '', + auswahl: { v: '', n: 1 }, + bild: 0, + lieferart: '', + land: '', + pruef: null, + pruefFehler: null, + pruefSeq: 0, + honig: '', + fehler: {}, + meldung: '', + sendet: false, + tot: false, + fokusGesetzt: false, + seq: 0, + statusSeq: 0, + status: null, + wd: { name: '', email: '', bestellnummer: '', nachricht: '' }, + wdSchritt: 'form', + wdEingang: '' + } + } + S = neuerZustand() + + function fid (name) { return id + String(name).replace(/[^a-z0-9_-]/gi, '-') } + + // ------------------------------------------------------------ Anfragen + + function request (method, pfad, body, frisch) { + var opts = { method: method, credentials: 'omit', headers: { Accept: 'application/json' } } + if (frisch) opts.cache = 'reload' // Konfiguration erneuern: nicht aus dem Browser-Cache (dort steht das alte Formular-Token) + if (body !== undefined) { + opts.headers['Content-Type'] = 'application/json' + opts.body = JSON.stringify(body) + } + var ctrl = typeof AbortController === 'function' ? new AbortController() : null + var timer = null + if (ctrl) { + opts.signal = ctrl.signal + timer = setTimeout(function () { ctrl.abort() }, TIMEOUT_MS) + } + function fertig () { if (timer) clearTimeout(timer) } + return fetch(api + API_PFAD + encodeURIComponent(key) + pfad, opts).then(function (r) { + return r.json().then(function (d) { return { status: r.status, ok: r.ok, body: d } }, function () { return { status: r.status, ok: r.ok, body: null } }) + }, function () { + throw { netz: true } + }).then(function (r) { + fertig() + if (r.ok && r.body !== null && typeof r.body === 'object') return r.body + var fehler = {} + var b = r.body + if (b && typeof b.fehler === 'object' && b.fehler) { + Object.keys(b.fehler).forEach(function (k) { if (istText(b.fehler[k])) fehler[k] = b.fehler[k] }) + } + throw { status: r.status, fehler: fehler, code: b && istText(b.code) ? b.code : '', produktId: b && istText(b.produktId) ? b.produktId : '', netz: r.status >= 500 || r.status === 403 || r.status === 404 || r.status === 0 } + }, function (err) { + fertig() + throw err + }) + } + + // ------------------------------------------------------------ Konfiguration + + // Fail closed: fehlt etwas, das der Shop zwingend braucht, gilt er als nicht aktiv (der Ausweichtext der Seite bleibt) + function konfigGueltig (d) { + if (!d || d.aktiv !== true || !istObjekt(d.shop) || !nichtLeer(d.shop.name) || !nichtLeer(d.formToken)) return false + if (d.nurWiderruf === true) return true + if (typeof d.offen !== 'boolean' || !Array.isArray(d.produkte) || !istObjekt(d.shop.anbieter)) return false + return d.produkte.every(function (p) { + return istObjekt(p) && nichtLeer(p.id) && ID_FORM.test(p.id) && istText(p.name) && istText(p.preisText) + }) + } + + function lade (neu) { + return request('GET', '', undefined, neu).then(function (d) { + if (!konfigGueltig(d)) throw { inaktiv: true } + S.cfg = d + S.cfgAt = performance.now() + return d + }) + } + + function configFrisch () { return !!S.cfg && performance.now() - S.cfgAt <= CONFIG_ALTER_MAX_MS } + + // Alte Konfiguration (das Formular-Token laeuft nach 2 h ab) erneuern. Aufgeloest wird mit true, wenn neu geladen wurde. + // Ist der Server nicht erreichbar, geht es mit der alten Konfiguration weiter: der naechste Aufruf meldet es. + function sichereConfig () { + if (configFrisch()) return Promise.resolve(false) + return lade(true).then(function () { bereinigeKorb(); return true }, function (err) { + if (err && err.inaktiv) { zeigeAusweich(); throw err } + return false + }) + } + + // Der Server speichert nichts (und antwortet doch mit Erfolg), wenn das Formular-Token juenger als 3 Sekunden ist: das Widget wartet vor dem + // Absenden, bis es alt genug ist (gemessen ab Eintreffen der Konfiguration; das Token ist beim Server immer etwas aelter). + function formWartezeitMs () { + return Math.max(0, FORM_MIN_VORGABE_S * 1000 + FORM_PUFFER_MS - (performance.now() - S.cfgAt)) + } + + // true = Token alt genug; false = die Ansicht hat sich inzwischen geaendert: nicht senden. + function warteAufFormToken (senden) { + var ms = formWartezeitMs() + if (ms <= 0) return Promise.resolve(true) + var zustand = S + var seq = S.seq + var beschriftung = senden.textContent + senden.textContent = 'Einen Moment …' + ansage('Einen Moment …') + return new Promise(function (resolve) { + setTimeout(function () { + if (S !== zustand || S.seq !== seq || S.tot) { zustand.sendet = false; resolve(false); return } + senden.textContent = beschriftung + resolve(true) + }, ms) + }) + } + + // ------------------------------------------------------------ Daten des Shops + + function shop () { return S.cfg.shop } + function nurWiderruf () { return S.cfg.nurWiderruf === true } + function rechtstexte () { return istObjekt(S.cfg.rechtstexte) ? S.cfg.rechtstexte : null } + function gewaehr () { + var g = S.cfg.gewaehrleistung + return istObjekt(g) && nichtLeer(g.text) ? g : null + } + // Bestellen ist nur moeglich, wenn der Shop geoeffnet ist und die bestaetigten Rechtstexte samt Gewaehrleistungshinweis vorliegen + function bestellbar () { + var r = rechtstexte() + return !nurWiderruf() && S.cfg.offen === true && !!r && nichtLeer(r.agb) && nichtLeer(r.widerrufsbelehrung) && !!gewaehr() + } + function produktMitId (pid) { + var p = liste(S.cfg.produkte) + for (var i = 0; i < p.length; i++) if (p[i].id === pid) return p[i] + return null + } + function varianteMitId (p, vid) { + var w = p && istObjekt(p.varianten) ? liste(p.varianten.werte) : [] + for (var i = 0; i < w.length; i++) if (w[i].id === vid) return w[i] + return null + } + function hatVarianten (p) { return !!p && istObjekt(p.varianten) && liste(p.varianten.werte).length > 0 } + function anzahlImKorb () { return S.korb.reduce(function (s, z) { return s + z.n }, 0) } + function speichereKorb () { korbSchreiben(key, S.korb) } + + // Positionen, die es nicht (mehr) gibt, fliegen aus dem Warenkorb; gibt die Anzahl entfernter Positionen zurueck + function bereinigeKorb () { + var vorher = S.korb.length + S.korb = S.korb.filter(function (z) { + var p = produktMitId(z.id) + if (!p) return false + if (hatVarianten(p)) return !!varianteMitId(p, z.v) + return z.v === '' + }) + var weg = vorher - S.korb.length + if (weg > 0) { + speichereKorb() + S.korbHinweis = true + } + return weg + } + + function lieferInfoText () { + var s = shop() + var teile = [] + var laender = liste(s.lieferlaender).map(function (l) { return istObjekt(l) ? l.name : '' }).filter(nichtLeer) + if (laender.length) teile.push('Lieferung nach: ' + laender.join(', ')) + if (istObjekt(s.abholung) && s.abholung.aktiv === true) teile.push('Abholung möglich') + if (nichtLeer(s.zahlungsarten)) teile.push('Zahlung: ' + s.zahlungsarten) + return teile.join(' · ') + } + + function anbieterZeile () { + var a = shop().anbieter + if (!istObjekt(a)) return '' + var teile = [adresseText(a)] + if (nichtLeer(a.email)) teile.push('E-Mail: ' + a.email) + if (nichtLeer(a.telefon)) teile.push('Telefon: ' + a.telefon) + return teile.filter(nichtLeer).join(', ') + } + + // ------------------------------------------------------------ Geruest + + function bauGeruest (box) { + box.classList.add('wks-root') + var live = el('div', 'wks-vh') + live.setAttribute('aria-live', 'polite') + live.setAttribute('aria-atomic', 'true') + var kopf = el('div', 'wks-kopf') + var body = el('div', 'wks-body') + var fuss = el('div', 'wks-fuss') + box.appendChild(live) + box.appendChild(kopf) + box.appendChild(body) + box.appendChild(fuss) + ui.box = box + ui.live = live + ui.kopf = kopf + ui.body = body + ui.fuss = fuss + } + + function ansage (text) { + if (ui.live) ui.live.textContent = text + } + + function add (node) { ui.body.appendChild(node); return node } + + function titel (text) { + var h = el('h3', 'wks-titel', text) + h.tabIndex = -1 + h.id = fid('titel') + ui.titel = h + return h + } + + function meldungAnzeigen () { + if (S.meldung) { + add(el('p', 'wks-meldung', S.meldung)) + ansage(S.meldung) + } + } + + function inBestellung (a) { return a === 'raster' || a === 'detail' || a === 'korb' || a === 'kasse' } + + function zeichneKopf () { + ui.kopf.textContent = '' + var zeile = el('div', 'wks-kopfzeile') + zeile.appendChild(el('h2', 'wks-shopname', shop().name)) + if (bestellbar() && S.ansicht !== 'status') { + var n = anzahlImKorb() + var kk = knopf('wks-btn wks-btn-sek', 'Warenkorb (' + n + ')', function () { gehe('korb') }) + kk.setAttribute('aria-label', 'Warenkorb, ' + n + ' Stück') + zeile.appendChild(kk) + } + ui.kopf.appendChild(zeile) + if (bestellbar() && inBestellung(S.ansicht)) { + var info = lieferInfoText() + if (info) ui.kopf.appendChild(el('p', 'wks-lieferinfo', info)) + } + } + + function zeichne (fokus) { + S.seq++ + S.fokusGesetzt = false + S.statusSeq++ + ui.body.textContent = '' + ui.titel = null + zeichneKopf() + var f = { + raster: zeichneRaster, detail: zeichneDetail, korb: zeichneKorb, kasse: zeichneKasse, + status: zeichneStatus, widerruf: zeichneWiderruf, geschlossen: zeichneGeschlossen + } + ;(f[S.ansicht] || zeichneRaster)() + zeichneFuss() + if (fokus) { + if (ui.titel && !S.fokusGesetzt) ui.titel.focus() + try { + if (ui.box.getBoundingClientRect().top < 0) ui.box.scrollIntoView({ block: 'start' }) + } catch (e) { /* Scrollen ist nur eine Hilfe */ } + } + } + + function gehe (ansicht, opt) { + opt = opt || {} + if (ansicht === 'widerruf' && S.ansicht !== 'widerruf') S.vorWiderruf = S.ansicht + S.ansicht = ansicht + S.fehler = opt.fehler || {} + S.meldung = opt.meldung || '' + zeichne(opt.fokus !== false) + } + + function startAnsicht () { + if (nurWiderruf()) return 'widerruf' + return bestellbar() ? 'raster' : 'geschlossen' + } + + // Fuss: immer AGB, Widerrufsbelehrung, Versand & Zahlung, Vertrag widerrufen, Impressum, Datenschutz (soweit bekannt) + function zeichneFuss () { + ui.fuss.textContent = '' + var r = rechtstexte() + var links = el('p', 'wks-fuss-links') + if (r && nichtLeer(r.agb)) links.appendChild(knopf('wks-link', 'AGB', function (e) { zeigeAgb(e.currentTarget) })) + if (r && nichtLeer(r.widerrufsbelehrung)) links.appendChild(knopf('wks-link', 'Widerrufsbelehrung', function (e) { zeigeBelehrung(e.currentTarget) })) + if (r && nichtLeer(r.versandUndZahlung)) links.appendChild(knopf('wks-link', 'Versand & Zahlung', function (e) { zeigeVersand(e.currentTarget) })) + if (!nurWiderruf()) links.appendChild(knopf('wks-btn wks-btn-sek', 'Vertrag widerrufen', zeigeWiderruf)) // im Widerrufs-Modus ist das Formular schon die ganze Ansicht + var imp = r ? httpsUrl(r.impressumUrl) : '' + if (imp) links.appendChild(externerLink('Impressum', imp)) + var ds = r ? httpsUrl(r.datenschutzUrl) : '' + if (ds) links.appendChild(externerLink('Datenschutz', ds)) + ui.fuss.appendChild(links) + var anb = anbieterZeile() + if (anb) ui.fuss.appendChild(el('p', 'wks-anbieter', 'Anbieter: ' + anb)) + ui.fuss.appendChild(el('p', 'wks-powered', 'Shop-Technik: WEBklar')) + } + + function externerLink (text, href) { + var a = el('a', '', text) + a.href = href + a.target = '_blank' + a.rel = 'noopener noreferrer' + return a + } + + // Shop nicht erreichbar oder nicht aktiv: der Ausweichtext der Seite bleibt bzw. kommt zurueck + function zeigeAusweich () { + S.tot = true + root.classList.remove('wks-root') + root.textContent = '' + if (ausweich) root.appendChild(ausweich.cloneNode(true)) // Kopie: ein zweiter Aufruf darf den Text nicht verlieren + } + + // Netz weg oder Server kaputt mitten in der Strecke: Meldung und "Erneut versuchen"; nie ein selbststaendiger neuer Abruf + function ausfall (wiederhole, meldung) { + S.sendet = false + ui.body.textContent = '' + ui.titel = null + var p = el('p', 'wks-fehlertext', meldung || 'Gerade nicht erreichbar. Bitte versuchen Sie es später noch einmal.') + p.tabIndex = -1 + add(p) + add(knopf('wks-btn wks-btn-sek', 'Erneut versuchen', function () { wiederhole() })) + ansage(p.textContent) + p.focus() + } + + function meldung429 (err) { + return (err.fehler && err.fehler._) || 'Zu viele Anfragen. Bitte versuchen Sie es später noch einmal.' + } + + // ------------------------------------------------------------ Dialog fuer Rechtstexte + + function dialogOeffnen (ueberschrift, bloecke, ausloeser) { + if (!dlg) { + dlg = el('dialog', 'wks-dialog') + dlg.addEventListener('click', function (e) { if (e.target === dlg) dialogSchliessen() }) + dlg.addEventListener('close', function () { if (dlgAusloeser && dlgAusloeser.focus) dlgAusloeser.focus() }) + root.appendChild(dlg) + } + dlgAusloeser = ausloeser || null + dlg.textContent = '' + var innen = el('div', 'wks-dialog-innen') + var zu = knopf('wks-schliessen', '×', dialogSchliessen) + zu.setAttribute('aria-label', 'Schließen') + innen.appendChild(zu) + var h = el('h3', '', ueberschrift) + h.tabIndex = -1 + innen.appendChild(h) + var text = el('div', 'wks-dialog-text') + text.tabIndex = 0 // der Text scrollt: auch mit der Tastatur erreichbar + text.setAttribute('role', 'region') + text.setAttribute('aria-label', ueberschrift) + bloecke.forEach(function (b) { + if (!nichtLeer(b.text)) return + if (b.titel) text.appendChild(el('h4', 'wks-untertitel', b.titel)) + text.appendChild(absaetze(b.text, '')) + }) + innen.appendChild(text) + var nav = el('div', 'wks-nav') + nav.appendChild(knopf('wks-btn wks-btn-sek', 'Schließen', dialogSchliessen)) + innen.appendChild(nav) + dlg.appendChild(innen) + dlg.setAttribute('aria-label', ueberschrift) + if (typeof dlg.showModal === 'function') { + if (!dlg.open) dlg.showModal() + } else { + dlg.setAttribute('open', '') + } + h.focus() + } + + function dialogSchliessen () { + if (!dlg) return + if (typeof dlg.close === 'function') dlg.close() + else dlg.removeAttribute('open') + if (dlgAusloeser && dlgAusloeser.focus) dlgAusloeser.focus() + } + + function zeigeAgb (ausloeser) { + var r = rechtstexte() + if (r) dialogOeffnen('Allgemeine Geschäftsbedingungen', [{ text: r.agb }], ausloeser) + } + function zeigeBelehrung (ausloeser) { + var r = rechtstexte() + if (r) dialogOeffnen('Widerrufsbelehrung', [{ text: r.widerrufsbelehrung }, { titel: 'Muster-Widerrufsformular', text: r.widerrufsformular }], ausloeser) + } + function zeigeVersand (ausloeser) { + var r = rechtstexte() + var s = shop() + var bloecke = [] + if (nichtLeer(s.versandText)) bloecke.push({ titel: 'Versandkosten', text: s.versandText }) + if (nichtLeer(s.mindestbestellText)) bloecke.push({ text: s.mindestbestellText }) + if (nichtLeer(s.lieferzeit)) bloecke.push({ titel: 'Lieferzeit', text: s.lieferzeit }) + if (r && nichtLeer(r.versandUndZahlung)) bloecke.push({ text: r.versandUndZahlung }) + dialogOeffnen('Versand und Zahlung', bloecke, ausloeser) + } + + // ------------------------------------------------------------ Formularhelfer + + function honigfeld () { + var wrap = el('div', 'wks-hp') + wrap.setAttribute('aria-hidden', 'true') + wrap.style.cssText = 'position:absolute;left:-10000px;top:auto;width:1px;height:1px;overflow:hidden' + var lab = el('label', '', 'Bitte leer lassen') + var inp = document.createElement('input') + inp.type = 'text' + inp.name = 'kontakt_url' + inp.value = S.honig + inp.setAttribute('autocomplete', 'off') + inp.setAttribute('tabindex', '-1') + inp.addEventListener('input', function () { S.honig = inp.value }) + lab.appendChild(inp) + wrap.appendChild(lab) + return wrap + } + + // opt: { name, label, typ (text|email|textarea), wert, pflicht, hinweis, autocomplete, maxlength, onInput }; refs sammelt { name: { input, fehler, beschreibung } } + function feld (refs, opt) { + var wrap = el('div', 'wks-feld') + var fieldId = fid('f-' + opt.name) + var lab = el('label', 'wks-label', opt.label) + lab.htmlFor = fieldId + if (opt.pflicht) { + var st = el('span', 'wks-stern', ' *') + st.setAttribute('aria-hidden', 'true') + lab.appendChild(st) + } else { + lab.appendChild(document.createTextNode(' (freiwillig)')) + } + wrap.appendChild(lab) + var beschreibung = [] + if (opt.hinweis) { + var h = el('p', 'wks-hinweis', opt.hinweis) + h.id = fieldId + '-h' + beschreibung.push(h.id) + wrap.appendChild(h) + } + var inp + if (opt.typ === 'textarea') { + inp = document.createElement('textarea') + inp.rows = 4 + } else { + inp = document.createElement('input') + inp.type = opt.typ || 'text' + } + inp.id = fieldId + inp.name = opt.name + inp.className = 'wks-eingabe' + inp.value = opt.wert || '' + if (opt.pflicht) inp.required = true + if (opt.autocomplete) inp.setAttribute('autocomplete', opt.autocomplete) + if (opt.maxlength) inp.maxLength = opt.maxlength + if (beschreibung.length) inp.setAttribute('aria-describedby', beschreibung.join(' ')) + var fehler = el('p', 'wks-feldfehler') + fehler.id = fieldId + '-e' + fehler.hidden = true + inp.addEventListener('input', function () { opt.onInput(inp.value) }) + inp.addEventListener('change', function () { opt.onInput(inp.value) }) + wrap.appendChild(inp) + wrap.appendChild(fehler) + refs[opt.name] = { input: inp, fehler: fehler, beschreibung: beschreibung } + return wrap + } + + // Fehlertexte an die Felder schreiben; liefert die Meldungen ohne Feld + function zeigeFeldfehler (refs, fehler) { + var uebrig = [] + var erster = null + Object.keys(refs).forEach(function (k) { + var r = refs[k] + var text = fehler[k] + if (text) { + r.fehler.textContent = text + r.fehler.hidden = false + r.input.setAttribute('aria-invalid', 'true') + r.input.setAttribute('aria-describedby', r.beschreibung.concat([r.fehler.id]).join(' ')) + if (!erster) erster = r.input + } else { + r.fehler.textContent = '' + r.fehler.hidden = true + r.input.removeAttribute('aria-invalid') + if (r.beschreibung.length) r.input.setAttribute('aria-describedby', r.beschreibung.join(' ')) + else r.input.removeAttribute('aria-describedby') + } + }) + Object.keys(fehler).forEach(function (k) { if (!refs[k]) uebrig.push(fehler[k]) }) + if (erster) { + S.fokusGesetzt = true + erster.focus() + ansage('Bitte prüfen Sie Ihre Eingaben: ' + Object.keys(fehler).map(function (k) { return fehler[k] }).join(' ')) + } + return uebrig + } + + // Meldungen ohne eigenes Feld in den Fehlerkasten: nie verschlucken + function zeigeRest (box, texte) { + texte.forEach(function (t) { box.appendChild(el('p', 'wks-fehlertext', t)) }) + if (texte.length) ansage(texte.join(' ')) + } + + function mengeFeld (wert, onChange, labelText, idName) { + var wrap = el('div', 'wks-zeile-menge') + var inp = document.createElement('input') + inp.type = 'number' + inp.min = '1' + inp.max = String(MAX_MENGE) + inp.step = '1' + inp.className = 'wks-eingabe wks-eingabe-menge' + inp.id = fid(idName) + inp.value = String(wert) + inp.setAttribute('inputmode', 'numeric') + var lab = el('label', '', labelText) + lab.htmlFor = inp.id + inp.addEventListener('change', function () { + var n = parseInt(inp.value, 10) + if (!(n >= 1)) n = 1 + if (n > MAX_MENGE) n = MAX_MENGE + inp.value = String(n) + onChange(n) + }) + wrap.appendChild(lab) + wrap.appendChild(inp) + return wrap + } + + // ------------------------------------------------------------ Raster + + function preisAnzeige (p) { return nichtLeer(p.preisAbText) ? p.preisAbText : p.preisText } + + function bildElement (p, grosse, cls) { + var b = liste(p.bilder)[0] + var src = istObjekt(b) ? absUrl(api, b[grosse]) : '' + if (!src) return null + var img = el('img', cls) + img.src = src + img.alt = p.name + img.loading = 'lazy' + img.decoding = 'async' + return img + } + + function zeichneRaster () { + var kategorien = liste(S.cfg.kategorien).filter(nichtLeer) + add(titel('Unsere Artikel')) + meldungAnzeigen() + if (S.korbHinweis) { + S.korbHinweis = false + add(el('p', 'wks-meldung', 'Einige Artikel in Ihrem Warenkorb sind nicht mehr verfügbar und wurden entfernt.')) + } + var chips = null + if (kategorien.length > 1) { + chips = el('div', 'wks-chips') + chips.setAttribute('role', 'group') + chips.setAttribute('aria-label', 'Kategorien') + add(chips) + } + var ul = el('ul', 'wks-raster') + var leer = el('p', '', 'Zurzeit sind keine Artikel verfügbar.') + function fuelle () { + ul.textContent = '' + var n = 0 + liste(S.cfg.produkte).forEach(function (p) { + if (S.kategorie && p.kategorie !== S.kategorie) return + n++ + ul.appendChild(karte(p)) + }) + ul.hidden = n === 0 + leer.hidden = n !== 0 + } + if (chips) { + var alle = [''] + kategorien.forEach(function (k) { alle.push(k) }) + alle.forEach(function (k) { + var b = knopf('wks-chip', k || 'Alle', function () { + S.kategorie = k + knoten(chips.childNodes).forEach(function (c) { c.setAttribute('aria-pressed', String(c === b)) }) + fuelle() + ansage(k ? 'Kategorie ' + k : 'Alle Artikel') + }) + b.setAttribute('aria-pressed', String(S.kategorie === k)) + chips.appendChild(b) + }) + } + add(ul) + add(leer) + fuelle() + var hinweis = el('p', 'wks-hinweiszeile') + if (nichtLeer(shop().steuerHinweisKurz)) hinweis.appendChild(document.createTextNode(shop().steuerHinweisKurz + ' ')) + hinweis.appendChild(knopf('wks-link', 'zzgl. Versand', function (e) { zeigeVersand(e.currentTarget) })) + add(hinweis) + } + + function karte (p) { + var li = el('li') + var art = el('div', 'wks-karte') + var bild = bildElement(p, 'klein', 'wks-karte-bild') + art.appendChild(bild || el('span', 'wks-karte-leer')) + var h = el('h4', 'wks-karte-name') + var b = knopf('wks-karte-knopf', p.name, function () { zeigeDetail(p.id) }) + h.appendChild(b) + art.appendChild(h) + if (nichtLeer(p.kurz)) art.appendChild(el('p', 'wks-karte-text', p.kurz)) + art.appendChild(el('p', 'wks-preis', preisAnzeige(p))) + if (nichtLeer(p.grundpreisText)) art.appendChild(el('p', 'wks-grundpreis', p.grundpreisText)) + if (p.ausverkauft === true || p.verfuegbar === false) art.appendChild(el('p', 'wks-aus', 'Ausverkauft')) + li.appendChild(art) + return li + } + + // ------------------------------------------------------------ Detail + + function zeigeDetail (pid) { + S.produktId = pid + S.auswahl = { v: '', n: 1 } + S.bild = 0 + gehe('detail') + } + + function herstellerZeilen (dl, p) { + var si = istObjekt(p.sicherheit) ? p.sicherheit : {} + var h = istObjekt(si.hersteller) ? si.hersteller : null + function adr (a) { return [a.name, a.anschrift, a.kontakt].filter(nichtLeer).join(', ') } + function zeile (l, w) { if (nichtLeer(w)) { dl.appendChild(el('dt', '', l)); dl.appendChild(el('dd', '', w)) } } + if (h && nichtLeer(adr(h))) zeile('Hersteller', adr(h)) + else zeile('Verkäufer und Verantwortlicher', anbieterZeile()) + var eu = istObjekt(si.euVerantwortlicher) ? adr(si.euVerantwortlicher) : '' + zeile('Verantwortliche Person in der EU', eu) + zeile('Sicherheitshinweise', si.hinweise) + } + + function zeichneDetail () { + var p = produktMitId(S.produktId) + if (!p) { gehe('raster'); return } + var bilder = liste(p.bilder).filter(function (b) { return istObjekt(b) && absUrl(api, b.gross) }) + var mitVar = hatVarianten(p) + var verkaeuflich = p.ausverkauft !== true && p.verfuegbar !== false + + add(knopf('wks-btn wks-btn-sek', 'Zurück zur Übersicht', function () { gehe('raster') })) + add(titel(p.name)) + meldungAnzeigen() + + var gitter = el('div', 'wks-detail') + var links = el('div', 'wks-galerie') + if (bilder.length) { + var gross = el('img', 'wks-galerie-gross') + gross.alt = p.name + gross.decoding = 'async' + gross.src = absUrl(api, bilder[Math.min(S.bild, bilder.length - 1)].gross) + links.appendChild(gross) + if (bilder.length > 1) { + var daumen = el('div', 'wks-daumen') + bilder.forEach(function (b, i) { + var db = el('button') + db.type = 'button' + db.setAttribute('aria-label', 'Bild ' + (i + 1) + ' von ' + bilder.length + ' anzeigen') + db.setAttribute('aria-pressed', String(i === S.bild)) + var di = el('img') + di.src = absUrl(api, b.klein) || absUrl(api, b.gross) + di.alt = '' + di.loading = 'lazy' + db.appendChild(di) + db.addEventListener('click', function () { + S.bild = i + gross.src = absUrl(api, b.gross) + knoten(daumen.childNodes).forEach(function (c, j) { c.setAttribute('aria-pressed', String(j === i)) }) + }) + daumen.appendChild(db) + }) + links.appendChild(daumen) + } + } + gitter.appendChild(links) + + var rechts = el('div', 'wks-info') + var preisEl = el('p', 'wks-preis') + var gpEl = el('p', 'wks-grundpreis') + var verfEl = el('p', verkaeuflich ? 'wks-klein' : 'wks-aus') + rechts.appendChild(preisEl) + rechts.appendChild(gpEl) + var hinweis = el('p', 'wks-hinweiszeile') + if (nichtLeer(shop().steuerHinweisKurz)) hinweis.appendChild(document.createTextNode(shop().steuerHinweisKurz + ' ')) + hinweis.appendChild(knopf('wks-link', 'zzgl. Versand', function (e) { zeigeVersand(e.currentTarget) })) + rechts.appendChild(hinweis) + verfEl.textContent = verkaeuflich ? 'Verfügbar' : 'Ausverkauft' + rechts.appendChild(verfEl) + + var form = el('form', 'wks-form') + form.noValidate = true + var varSel = null + var fehlerVar = el('p', 'wks-feldfehler') + fehlerVar.hidden = true + fehlerVar.id = fid('var-e') + function aktualisierePreis () { + var w = mitVar ? varianteMitId(p, S.auswahl.v) : null + preisEl.textContent = w && nichtLeer(w.preisText) ? w.preisText : preisAnzeige(p) + var gp = w && nichtLeer(w.grundpreisText) ? w.grundpreisText : (w ? '' : p.grundpreisText) + gpEl.textContent = nichtLeer(gp) ? gp : '' + gpEl.hidden = !nichtLeer(gp) + if (w) { + verfEl.textContent = w.verfuegbar === false ? 'Ausverkauft' : 'Verfügbar' + verfEl.className = w.verfuegbar === false ? 'wks-aus' : 'wks-klein' + } + } + if (mitVar) { + var vf = el('div', 'wks-feld') + var vl = el('label', 'wks-label', p.varianten.name) + varSel = document.createElement('select') + varSel.id = fid('variante') + varSel.className = 'wks-eingabe' + vl.htmlFor = varSel.id + var o0 = document.createElement('option') + o0.value = '' + o0.textContent = 'Bitte wählen' + varSel.appendChild(o0) + liste(p.varianten.werte).forEach(function (w) { + var o = document.createElement('option') + o.value = w.id + o.textContent = w.name + (nichtLeer(w.preisText) ? ' – ' + w.preisText : '') + (w.verfuegbar === false ? ' (ausverkauft)' : '') + if (w.verfuegbar === false) o.disabled = true + varSel.appendChild(o) + }) + varSel.value = S.auswahl.v + varSel.addEventListener('change', function () { + S.auswahl.v = varSel.value + fehlerVar.hidden = true + varSel.removeAttribute('aria-invalid') + aktualisierePreis() + }) + vf.appendChild(vl) + vf.appendChild(varSel) + vf.appendChild(fehlerVar) + form.appendChild(vf) + } + aktualisierePreis() + var bestaetigung = el('p', 'wks-meldung') + bestaetigung.hidden = true + if (verkaeuflich) { + var mf = el('div', 'wks-feld') + var mw = mengeFeld(S.auswahl.n, function (n) { S.auswahl.n = n }, 'Menge', 'menge') + mf.appendChild(mw) + form.appendChild(mf) + var plus = el('button', 'wks-btn', 'In den Warenkorb') + plus.type = 'submit' + form.appendChild(plus) + form.addEventListener('submit', function (e) { + e.preventDefault() + if (mitVar && !S.auswahl.v) { + fehlerVar.textContent = 'Bitte wählen Sie eine Variante (' + p.varianten.name + ').' + fehlerVar.hidden = false + varSel.setAttribute('aria-invalid', 'true') + varSel.setAttribute('aria-describedby', fehlerVar.id) + varSel.focus() + ansage(fehlerVar.textContent) + return + } + var n = parseInt(form.querySelector('input[type="number"]').value, 10) + if (!(n >= 1)) n = 1 + legeInKorb(p.id, mitVar ? S.auswahl.v : '', n) + bestaetigung.textContent = p.name + ' wurde in den Warenkorb gelegt.' + bestaetigung.hidden = false + ansage(bestaetigung.textContent) + zeichneKopf() + }) + } + rechts.appendChild(form) + rechts.appendChild(bestaetigung) + var weiter = el('div', 'wks-nav') + weiter.appendChild(knopf('wks-btn wks-btn-sek', 'Weiter einkaufen', function () { gehe('raster') })) + weiter.appendChild(knopf('wks-btn wks-btn-sek', 'Zum Warenkorb', function () { gehe('korb') })) + rechts.appendChild(weiter) + gitter.appendChild(rechts) + add(gitter) + + if (nichtLeer(p.beschreibung)) { + add(el('h4', 'wks-untertitel', 'Beschreibung')) + add(absaetze(p.beschreibung, 'wks-beschreibung')) + } + var fakten = el('dl', 'wks-zusammen') + function fakt (l, w) { if (nichtLeer(w)) { fakten.appendChild(el('dt', '', l)); fakten.appendChild(el('dd', '', w)) } } + fakt('Lieferzeit', shop().lieferzeit) + fakt('Lieferart', p.lieferHinweis) + fakt('Widerrufsrecht', p.widerrufText) + if (fakten.childNodes.length) add(fakten) + + if (istObjekt(p.lebensmittel) && nichtLeer(p.lebensmittel.pflichtangaben)) { + add(el('h4', 'wks-untertitel', 'Angaben nach Lebensmittelrecht')) + add(absaetze(p.lebensmittel.pflichtangaben, 'wks-text')) + } + add(el('h4', 'wks-untertitel', 'Produktsicherheit')) + var sicher = el('dl', 'wks-zusammen') + herstellerZeilen(sicher, p) + add(sicher) + } + + function legeInKorb (pid, vid, n) { + var gefunden = null + S.korb.forEach(function (z) { if (z.id === pid && z.v === vid) gefunden = z }) + if (gefunden) gefunden.n = Math.min(MAX_MENGE, gefunden.n + n) + else if (S.korb.length < MAX_POSITIONEN) S.korb.push({ id: pid, v: vid, n: Math.min(MAX_MENGE, n) }) + speichereKorb() + S.pruef = null + } + + // ------------------------------------------------------------ Warenkorb + + function zeichneKorb () { + add(titel('Warenkorb')) + meldungAnzeigen() + if (S.korbHinweis) { + S.korbHinweis = false + add(el('p', 'wks-meldung', 'Einige Artikel in Ihrem Warenkorb sind nicht mehr verfügbar und wurden entfernt.')) + } + if (S.korb.length === 0) { + add(el('p', '', 'Ihr Warenkorb ist leer.')) + var nav0 = el('div', 'wks-nav') + nav0.appendChild(knopf('wks-btn', 'Weiter einkaufen', function () { gehe('raster') })) + add(nav0) + return + } + var ul = el('ul', 'wks-zeilen') + S.korb.forEach(function (z, i) { + var p = produktMitId(z.id) + var w = hatVarianten(p) ? varianteMitId(p, z.v) : null + var li = el('li') + var info = el('div', 'wks-zeile-info') + info.appendChild(el('p', 'wks-zeile-name', p.name)) + if (w) info.appendChild(el('p', 'wks-klein', p.varianten.name + ': ' + w.name)) + var einzel = w && nichtLeer(w.preisText) ? w.preisText : p.preisText + info.appendChild(el('p', 'wks-klein', 'Einzelpreis: ' + einzel)) + var gp = w ? w.grundpreisText : p.grundpreisText + if (nichtLeer(gp)) info.appendChild(el('p', 'wks-grundpreis', gp)) + var ausv = w ? w.verfuegbar === false : (p.ausverkauft === true || p.verfuegbar === false) + if (ausv) info.appendChild(el('p', 'wks-aus', 'Ausverkauft')) + li.appendChild(info) + li.appendChild(mengeFeld(z.n, function (n) { z.n = n; speichereKorb(); S.pruef = null; zeichneKopf() }, 'Menge', 'korb-menge-' + i)) + var weg = knopf('wks-btn wks-btn-sek', 'Entfernen', function () { + S.korb.splice(i, 1) + speichereKorb() + S.pruef = null + S.meldung = p.name + ' wurde aus dem Warenkorb entfernt.' + zeichne(false) + if (ui.titel) { S.fokusGesetzt = true; ui.titel.focus() } + }) + weg.setAttribute('aria-label', p.name + ' aus dem Warenkorb entfernen') + li.appendChild(weg) + ul.appendChild(li) + }) + add(ul) + add(el('p', 'wks-hinweiszeile', 'Die Preise, Versandkosten und die Gesamtsumme sehen Sie im nächsten Schritt.')) + var nav = el('div', 'wks-nav') + nav.appendChild(knopf('wks-btn wks-btn-sek', 'Weiter einkaufen', function () { gehe('raster') })) + nav.appendChild(knopf('wks-btn', 'Zur Kasse', function () { gehe('kasse') })) + add(nav) + } + + // ------------------------------------------------------------ Kasse + + function kannVersenden () { + var s = shop() + if (liste(s.lieferlaender).length === 0) return false + return !S.korb.some(function (z) { var p = produktMitId(z.id); return p && p.lieferart === 'abholung' }) + } + function kannAbholen () { return istObjekt(shop().abholung) && shop().abholung.aktiv === true } + + function positionenBody () { + return S.korb.map(function (z) { + var o = { produktId: z.id, menge: z.n } + if (z.v) o.varianteId = z.v + return o + }) + } + + function landCode () { + var l = liste(shop().lieferlaender) + if (S.land) return S.land + return l.length && istObjekt(l[0]) ? l[0].code : '' + } + + function zeichneKasse () { + if (S.korb.length === 0) { gehe('korb'); return } + var s = shop() + var versand = kannVersenden() + var abholung = kannAbholen() + if (!versand && !abholung) { + add(titel('Bestellung')) + add(el('p', 'wks-fehlertext', 'Mit diesem Warenkorb ist keine Bestellung möglich. Bitte ändern Sie Ihren Warenkorb.')) + add(knopf('wks-btn', 'Zum Warenkorb', function () { gehe('korb') })) + return + } + if (S.lieferart !== 'versand' && S.lieferart !== 'abholung') S.lieferart = versand ? 'versand' : 'abholung' + if (S.lieferart === 'versand' && !versand) S.lieferart = 'abholung' + if (S.lieferart === 'abholung' && !abholung) S.lieferart = 'versand' + if (!S.land) S.land = landCode() + + add(titel('Bestellung prüfen')) + meldungAnzeigen() + var form = el('form', 'wks-form') + form.noValidate = true + + var fs = el('fieldset', 'wks-feld') + fs.appendChild(el('legend', '', 'Lieferart')) + var landWrap = el('div', 'wks-feld') + var landSel = document.createElement('select') + landSel.id = fid('land') + landSel.className = 'wks-eingabe' + var landLab = el('label', 'wks-label', 'Lieferland') + landLab.htmlFor = landSel.id + liste(s.lieferlaender).forEach(function (l) { + if (!istObjekt(l)) return + var o = document.createElement('option') + o.value = l.code + o.textContent = l.name + landSel.appendChild(o) + }) + landSel.value = S.land + landWrap.appendChild(landLab) + landWrap.appendChild(landSel) + function landSichtbar () { landWrap.hidden = S.lieferart !== 'versand' } + function radio (wert, text) { + var r = el('div', 'wks-radio') + var inp = document.createElement('input') + inp.type = 'radio' + inp.name = fid('lieferart') + inp.id = fid('la-' + wert) + inp.value = wert + inp.checked = S.lieferart === wert + inp.addEventListener('change', function () { + if (!inp.checked) return + S.lieferart = wert + landSichtbar() + pruefe() + }) + var lab = el('label', '', text) + lab.htmlFor = inp.id + r.appendChild(inp) + r.appendChild(lab) + return r + } + if (versand) fs.appendChild(radio('versand', 'Versand')) + if (abholung) { + var abText = 'Abholung' + if (nichtLeer(s.abholung.adresse)) abText += ' (' + s.abholung.adresse + ')' + fs.appendChild(radio('abholung', abText)) + if (nichtLeer(s.abholung.hinweis)) fs.appendChild(el('p', 'wks-hinweis', s.abholung.hinweis)) + } + form.appendChild(fs) + form.appendChild(landWrap) + landSichtbar() + landSel.addEventListener('change', function () { S.land = landSel.value; pruefe() }) + + // Pflichtinformationen: stehen unmittelbar ueber dem Knopf und kommen zum grossen Teil vom Server (/pruefen) + var pflicht = el('div', 'wks-pflicht') + form.appendChild(pflicht) + + form.appendChild(honigfeld()) + var fehlerBox = el('div', 'wks-fehlerbox') + form.appendChild(fehlerBox) + var senden = el('button', 'wks-btn', 'Zahlungspflichtig bestellen') + senden.type = 'submit' + senden.disabled = true + var nav = el('div', 'wks-nav') + nav.appendChild(knopf('wks-btn wks-btn-sek', 'Zurück zum Warenkorb', function () { gehe('korb') })) + nav.appendChild(senden) + form.appendChild(nav) + add(form) + + function fuellePflicht () { + pflicht.textContent = '' + var ps = S.pruef + if (S.pruefFehler) { + pflicht.appendChild(el('p', 'wks-fehlertext', S.pruefFehler.text)) + if (S.pruefFehler.zumWarenkorb) pflicht.appendChild(knopf('wks-btn wks-btn-sek', 'Warenkorb ändern', function () { gehe('korb') })) + if (S.pruefFehler.erneut) pflicht.appendChild(knopf('wks-btn wks-btn-sek', 'Erneut versuchen', function () { pruefe() })) + senden.disabled = true + return + } + if (!ps) { + pflicht.appendChild(el('p', 'wks-laden', 'Wird berechnet …')) + senden.disabled = true + return + } + pflicht.appendChild(el('h4', '', 'Ihre Bestellung')) + var ul = el('ul', 'wks-pos') + liste(ps.positionen).forEach(function (z) { + var li = el('li') + var t = el('span', 'wks-pos-text', z.menge + ' × ' + z.name + (nichtLeer(z.varianteText) ? ' (' + z.varianteText + ')' : '') + ', je ' + z.einzelText) + li.appendChild(t) + li.appendChild(el('span', '', z.summeText)) + ul.appendChild(li) + }) + pflicht.appendChild(ul) + var t = ps.texte + var dl = el('dl', 'wks-summen') + dl.appendChild(el('dt', '', 'Zwischensumme')) + dl.appendChild(el('dd', '', t.zwischensumme)) + dl.appendChild(el('dt', '', S.lieferart === 'abholung' ? 'Lieferart' : 'Versandkosten')) + dl.appendChild(el('dd', '', t.versand)) + dl.appendChild(el('dt', 'wks-gesamt', 'Gesamtsumme')) + dl.appendChild(el('dd', 'wks-gesamt', t.gesamt)) + pflicht.appendChild(dl) + if (nichtLeer(t.steuerHinweis)) pflicht.appendChild(el('p', 'wks-klein', t.steuerHinweis)) + else if (nichtLeer(s.steuerHinweisKurz)) pflicht.appendChild(el('p', 'wks-klein', s.steuerHinweisKurz)) + if (nichtLeer(s.mindestbestellText)) pflicht.appendChild(el('p', 'wks-klein', s.mindestbestellText)) + + pflicht.appendChild(el('h4', '', 'Lieferung und Zahlung')) + var lz = el('dl', 'wks-zusammen') + function zeile (l, w) { if (nichtLeer(w)) { lz.appendChild(el('dt', '', l)); lz.appendChild(el('dd', '', w)) } } + zeile('Lieferart', S.lieferart === 'abholung' ? 'Abholung' : 'Versand' + (S.land ? ' nach ' + landName(S.land) : '')) + zeile('Lieferzeit', s.lieferzeit) + zeile('Zahlungsarten', s.zahlungsarten) + zeile('Anbieter', anbieterZeile()) + pflicht.appendChild(lz) + + pflicht.appendChild(el('h4', '', 'Widerrufsrecht')) + pflicht.appendChild(el('p', '', 'Sie haben ein gesetzliches Widerrufsrecht, soweit unten für einzelne Artikel nichts anderes steht. Einzelheiten finden Sie in der Widerrufsbelehrung.')) + liste(ps.positionen).forEach(function (z) { + var txt = nichtLeer(z.widerrufText) ? z.widerrufText : ((produktMitId(z.produktId) || {}).widerrufText) + if (nichtLeer(txt)) pflicht.appendChild(el('p', '', z.name + ': ' + txt)) + }) + var links = el('p', 'wks-links') + links.appendChild(knopf('wks-link', 'AGB lesen', function (e) { zeigeAgb(e.currentTarget) })) + links.appendChild(knopf('wks-link', 'Widerrufsbelehrung lesen', function (e) { zeigeBelehrung(e.currentTarget) })) + links.appendChild(knopf('wks-link', 'Versand & Zahlung lesen', function (e) { zeigeVersand(e.currentTarget) })) + pflicht.appendChild(links) + + var g = gewaehr() + var bildUrl = g ? httpsUrl(g.bild) : '' + var gbox = el('div', 'wks-gewaehr') + if (bildUrl) { + var img = el('img') + img.src = bildUrl + img.alt = g.text + img.loading = 'lazy' + img.decoding = 'async' + gbox.appendChild(img) + } else if (g) { + gbox.appendChild(absaetze(g.text, 'wks-text')) + } + pflicht.appendChild(gbox) + pflicht.appendChild(el('p', '', 'Name, Lieferadresse und Zahlung geben Sie im nächsten Schritt bei unserem Zahlungsdienstleister Stripe ein.')) + senden.disabled = S.sendet + } + + function landName (code) { + var l = liste(shop().lieferlaender) + for (var i = 0; i < l.length; i++) if (istObjekt(l[i]) && l[i].code === code) return l[i].name + return code + } + + // Zusammenfassung vom Server (nie im Browser gerechnet); bei jeder Aenderung neu, veraltete Antworten werden verworfen + function pruefe () { + var seq = ++S.pruefSeq + var ansichtSeq = S.seq + S.pruef = null + S.pruefFehler = null + fuellePflicht() + request('POST', '/pruefen', { positionen: positionenBody(), lieferart: S.lieferart, land: landCode() }).then(function (d) { + if (seq !== S.pruefSeq || ansichtSeq !== S.seq) return + if (!d || d.ok !== true || !istObjekt(d.texte) || !Array.isArray(d.positionen)) throw { netz: true } + S.pruef = d + fuellePflicht() + ansage('Die Zusammenfassung wurde aktualisiert. Gesamtsumme ' + d.texte.gesamt + '.') + }).catch(function (err) { + if (seq !== S.pruefSeq || ansichtSeq !== S.seq) return + if (err && err.inaktiv) { zeigeAusweich(); return } + if (err && err.status === 429) S.pruefFehler = { text: meldung429(err), erneut: true } + else if (err && !err.netz && err.fehler && Object.keys(err.fehler).length) { + S.pruefFehler = { text: Object.keys(err.fehler).map(function (k) { return err.fehler[k] }).join(' '), zumWarenkorb: true } + } else S.pruefFehler = { text: 'Gerade nicht erreichbar. Bitte versuchen Sie es später noch einmal.', erneut: true } + fuellePflicht() + ansage(S.pruefFehler.text) + }) + } + S.pruefSeq++ + pruefe() + + form.addEventListener('submit', function (e) { + e.preventDefault() + if (S.sendet || !S.pruef || S.pruefFehler) return + fehlerBox.textContent = '' + bestelle(senden, fehlerBox) + }) + } + + // Verbindlich bestellen: der Server rechnet alles neu und legt die Bestellung an; die Antwort fuehrt zur Zahlung bei Stripe + function bestelle (senden, fehlerBox) { + S.sendet = true + senden.disabled = true + senden.setAttribute('aria-busy', 'true') + sichereConfig().then(function (neu) { + if (neu) { + S.sendet = false + gehe('kasse', { meldung: 'Der Shop wurde aktualisiert. Bitte prüfen Sie Ihre Bestellung noch einmal.' }) + return null + } + return warteAufFormToken(senden).then(function (los) { + if (!los) return null + return request('POST', '/bestellung', { + positionen: positionenBody(), + lieferart: S.lieferart, + land: landCode(), + rueckUrl: location.href, + formToken: S.cfg.formToken, + kontakt_url: S.honig + }).then(function (d) { + var ziel = d ? httpsUrl(d.url) : '' + if (!ziel) throw { netz: true } + senden.textContent = 'Weiterleitung zu Stripe …' + ansage('Sie werden zur Zahlung bei Stripe weitergeleitet.') + location.href = ziel // S.sendet bleibt gesetzt: kein zweiter Klick waehrend der Weiterleitung + }) + }) + }).catch(function (err) { + S.sendet = false + senden.removeAttribute('aria-busy') + senden.disabled = false + if (err && err.inaktiv) return + nachSendeFehler(err, senden, fehlerBox) + }) + } + + function nachSendeFehler (err, senden, fehlerBox) { + if (err && err.code === 'neu_laden') { + // Formular-Token abgelaufen oder ungueltig: Konfiguration neu holen, dem Besucher Bescheid geben + lade(true).then(function () { + bereinigeKorb() + gehe('kasse', { fokus: false, meldung: 'Das Formular wurde erneuert. Bitte versuchen Sie es noch einmal.' }) + }, function (e2) { if (e2 && e2.inaktiv) zeigeAusweich(); else ausfall(function () { zeichne(true) }) }) + return + } + if (err && err.status === 429) { err.fehler = { _: meldung429(err) } } + if (err && !err.netz && err.fehler && Object.keys(err.fehler).length) { + fehlerBox.textContent = '' + zeigeRest(fehlerBox, Object.keys(err.fehler).map(function (k) { return err.fehler[k] })) + return + } + ausfall(function () { zeichne(true) }) + } + + // ------------------------------------------------------------ Rueckkehr von Stripe + + function zeigeRueckkehr (r) { + if (r.art === 'abbruch') { + S.ansicht = bestellbar() ? 'korb' : startAnsicht() + S.meldung = 'Die Zahlung wurde abgebrochen. Ihre Bestellung wurde nicht abgeschlossen.' + (S.korb.length ? ' Ihr Warenkorb ist noch gefüllt.' : '') + zeichne(true) + return + } + S.ansicht = 'status' + S.status = { id: r.id, t: r.t, daten: null, fehler: false, versuch: 0 } + zeichne(true) + holeStatus(S.statusSeq) + } + + function holeStatus (seq) { + var st = S.status + st.versuch++ + request('GET', '/bestellung/' + encodeURIComponent(st.id) + '?t=' + encodeURIComponent(st.t)).then(function (d) { + if (seq !== S.statusSeq) return + if (!d || !istText(d.status)) throw { netz: true } + st.daten = d + st.fehler = false + if (d.status !== 'offen' && d.status !== 'storniert') { + S.korb = [] + speichereKorb() + zeichneKopf() + } + fuelleStatus() + if (d.status === 'offen' && st.versuch < STATUS_VERSUCHE) { + setTimeout(function () { if (seq === S.statusSeq) holeStatus(seq) }, STATUS_ABSTAND_MS) + } + }).catch(function (err) { + if (seq !== S.statusSeq) return + if (err && err.inaktiv) return + if (!st.daten) st.fehler = true + fuelleStatus() + }) + } + + function zeichneStatus () { + add(titel('Ihre Bestellung')) + var box = el('div', 'wks-statusbox') + box.id = fid('status') + add(box) + ui.statusBox = box + add(knopf('wks-btn wks-btn-sek', 'Zurück zum Shop', function () { gehe(startAnsicht()) })) + fuelleStatus() + } + + function fuelleStatus () { + var box = ui.statusBox + var st = S.status + if (!box || !st || S.ansicht !== 'status') return + box.textContent = '' + var d = st.daten + var text = '' + if (!d) { + if (st.fehler) { + box.appendChild(el('p', 'wks-fehlertext', 'Wir können den Stand Ihrer Bestellung gerade nicht abrufen. Wenn Sie bezahlt haben, erhalten Sie eine Bestätigung per E-Mail.')) + text = box.textContent + } else { + box.appendChild(el('p', 'wks-laden', 'Ihre Bestellung wird geprüft …')) + text = 'Ihre Bestellung wird geprüft.' + } + ansage(text) + return + } + var nr = nichtLeer(d.bestellnummer) ? d.bestellnummer : '' + if (d.status === 'storniert') { + box.appendChild(el('p', 'wks-ergebnis-text', 'Ihre Bestellung' + (nr ? ' ' + nr : '') + ' wurde nicht abgeschlossen. Die Zahlung ist nicht erfolgt oder wurde abgebrochen.')) + box.appendChild(el('p', '', 'Wenn Sie den Artikel noch möchten, legen Sie ihn bitte erneut in den Warenkorb.')) + } else { + box.appendChild(el('p', 'wks-ergebnis-text', 'Vielen Dank für Ihre Bestellung' + (nr ? ' ' + nr : '') + '!')) + if (d.status === 'offen') { + box.appendChild(el('p', '', 'Die Zahlung ist noch nicht bestätigt. Bei einer SEPA-Lastschrift kann das einige Tage dauern. Sobald sie bestätigt ist, erhalten Sie eine E-Mail.')) + } else { + box.appendChild(el('p', '', 'Ihre Zahlung ist eingegangen.' + (nichtLeer(d.email) ? ' Eine Bestätigung senden wir an ' + d.email + '.' : ' Eine Bestätigung senden wir Ihnen per E-Mail.'))) + } + var dl = el('dl', 'wks-zusammen') + if (nr) { dl.appendChild(el('dt', '', 'Bestellnummer')); dl.appendChild(el('dd', '', nr)) } + if (nichtLeer(d.gesamtText)) { dl.appendChild(el('dt', '', 'Gesamtsumme')); dl.appendChild(el('dd', '', d.gesamtText)) } + if (d.lieferart === 'versand' || d.lieferart === 'abholung') { dl.appendChild(el('dt', '', 'Lieferart')); dl.appendChild(el('dd', '', d.lieferart === 'abholung' ? 'Abholung' : 'Versand')) } + if (dl.childNodes.length) box.appendChild(dl) + } + ansage(box.textContent) + } + + // ------------------------------------------------------------ Geschlossen + + function zeichneGeschlossen () { + add(titel('Shop geschlossen')) + add(el('p', 'wks-ergebnis-text', 'Der Shop ist gerade geschlossen.')) + } + + // ------------------------------------------------------------ Widerruf (Widerrufsfunktion, § 356a BGB) + + function zeigeWiderruf () { + S.wdSchritt = 'form' + S.wdEingang = '' + gehe('widerruf') + } + + function eingangText (e) { + if (istText(e)) return e + if (istObjekt(e)) return [e.datum, nichtLeer(e.uhrzeit) ? e.uhrzeit + ' Uhr' : ''].filter(nichtLeer).join(', ') + return '' + } + + function zeichneWiderruf () { + add(titel('Vertrag widerrufen')) + var w = S.wd + if (S.wdSchritt === 'fertig') { + var e = eingangText(S.wdEingang) + add(el('p', 'wks-ergebnis-text', 'Ihr Widerruf ist am ' + (e || 'heute') + ' bei uns eingegangen. Eine Eingangsbestätigung haben wir Ihnen per E-Mail geschickt.')) + ansage('Ihr Widerruf ist bei uns eingegangen.') + if (!nurWiderruf()) { + var nav0 = el('div', 'wks-nav') + nav0.appendChild(knopf('wks-btn wks-btn-sek', 'Zurück zum Shop', zurueckVomWiderruf)) + add(nav0) + } + return + } + if (S.wdSchritt === 'pruefen') { + add(el('p', '', 'Bitte prüfen Sie Ihre Angaben. Mit „Widerruf bestätigen“ erklären Sie den Widerruf Ihres Vertrags.')) + var dl = el('dl', 'wks-zusammen') + function zeile (l, v) { if (nichtLeer(v)) { dl.appendChild(el('dt', '', l)); dl.appendChild(el('dd', '', v)) } } + zeile('Name', w.name.trim()) + zeile('E-Mail-Adresse', w.email.trim()) + zeile('Bestellnummer', w.bestellnummer.trim()) + zeile('Erklärung', w.nachricht.trim()) + add(dl) + var fb = el('div', 'wks-fehlerbox') + add(fb) + var senden = el('button', 'wks-btn', 'Widerruf bestätigen') + senden.type = 'button' + var nav1 = el('div', 'wks-nav') + nav1.appendChild(knopf('wks-btn wks-btn-sek', 'Angaben ändern', function () { S.wdSchritt = 'form'; zeichne(true) })) + nav1.appendChild(senden) + add(nav1) + senden.addEventListener('click', function () { sendeWiderruf(senden, fb) }) + return + } + add(el('p', '', 'Hier können Sie Ihren Vertrag widerrufen. Bitte geben Sie Ihren Namen, die E-Mail-Adresse Ihrer Bestellung und möglichst die Bestellnummer an. Sie steht in Ihrer Bestätigungs-E-Mail.')) + meldungAnzeigen() + var form = el('form', 'wks-form') + form.noValidate = true + var refs = {} + form.appendChild(feld(refs, { name: 'name', label: 'Ihr Name', typ: 'text', wert: w.name, pflicht: true, autocomplete: 'name', maxlength: 100, onInput: function (v) { w.name = v } })) + form.appendChild(feld(refs, { name: 'email', label: 'E-Mail-Adresse der Bestellung', typ: 'email', wert: w.email, pflicht: true, autocomplete: 'email', maxlength: 254, onInput: function (v) { w.email = v } })) + form.appendChild(feld(refs, { name: 'bestellnummer', label: 'Bestellnummer', typ: 'text', wert: w.bestellnummer, pflicht: false, maxlength: 40, onInput: function (v) { w.bestellnummer = v } })) + form.appendChild(feld(refs, { name: 'nachricht', label: 'Erklärung', typ: 'textarea', wert: w.nachricht, pflicht: false, maxlength: 1000, hinweis: 'Zum Beispiel, welche Artikel Sie widerrufen. Ohne Angabe widerrufen Sie die gesamte Bestellung.', onInput: function (v) { w.nachricht = v } })) + form.appendChild(honigfeld()) + var fehlerBox = el('div', 'wks-fehlerbox') + form.appendChild(fehlerBox) + var weiter = el('button', 'wks-btn', 'Weiter') + weiter.type = 'submit' + var nav = el('div', 'wks-nav') + if (!nurWiderruf()) nav.appendChild(knopf('wks-btn wks-btn-sek', 'Zurück zum Shop', zurueckVomWiderruf)) + nav.appendChild(weiter) + form.appendChild(nav) + add(form) + if (Object.keys(S.fehler).length) zeigeRest(fehlerBox, zeigeFeldfehler(refs, S.fehler)) + form.addEventListener('submit', function (e) { + e.preventDefault() + var f = {} + if (!w.name.trim()) f.name = 'Bitte geben Sie Ihren Namen an.' + if (!EMAIL_FORM.test(w.email.trim())) f.email = 'Bitte geben Sie eine gültige E-Mail-Adresse an.' + fehlerBox.textContent = '' + zeigeFeldfehler(refs, f) + if (Object.keys(f).length) return + S.fehler = {} + S.wdSchritt = 'pruefen' + zeichne(true) + }) + } + + function zurueckVomWiderruf () { + S.wdSchritt = 'form' + var z = S.vorWiderruf + gehe(z && z !== 'widerruf' && z !== 'status' && z !== 'kasse' ? z : startAnsicht()) + } + + function sendeWiderruf (senden, fehlerBox) { + if (S.sendet) return + var w = S.wd + S.sendet = true + senden.disabled = true + senden.setAttribute('aria-busy', 'true') + sichereConfig().then(function () { + return warteAufFormToken(senden) + }).then(function (los) { + if (!los) return null + return request('POST', '/widerruf', { + name: w.name.trim(), email: w.email.trim(), bestellnummer: w.bestellnummer.trim(), nachricht: w.nachricht.trim(), + formToken: S.cfg.formToken, kontakt_url: S.honig + }).then(function (d) { + S.sendet = false + if (d && d.aktiv === false) { zeigeAusweich(); return } + if (!d || d.ok !== true) throw { netz: true } + S.wdEingang = d.eingang + S.wdSchritt = 'fertig' + S.wd = { name: '', email: '', bestellnummer: '', nachricht: '' } + zeichne(true) + }) + }).catch(function (err) { + S.sendet = false + senden.removeAttribute('aria-busy') + senden.disabled = false + if (err && err.inaktiv) return + if (err && err.code === 'neu_laden') { + lade(true).then(function () { S.wdSchritt = 'form'; gehe('widerruf', { fokus: false, meldung: 'Das Formular wurde erneuert. Bitte senden Sie es noch einmal ab.' }) }, function () { ausfall(function () { zeichne(true) }) }) + return + } + if (err && err.status === 429) err.fehler = { _: meldung429(err) } + if (err && !err.netz && err.fehler && Object.keys(err.fehler).length) { + var felder = ['name', 'email', 'bestellnummer', 'nachricht'] + if (Object.keys(err.fehler).some(function (k) { return felder.indexOf(k) >= 0 })) { + S.wdSchritt = 'form' + gehe('widerruf', { fehler: err.fehler }) + return + } + fehlerBox.textContent = '' + zeigeRest(fehlerBox, Object.keys(err.fehler).map(function (k) { return err.fehler[k] })) + return + } + ausfall(function () { zeichne(true) }) + }) + } + + // ------------------------------------------------------------ Start + + function starte () { + var cfg = S.cfg + var at = S.cfgAt + S = neuerZustand() + S.cfg = cfg + S.cfgAt = at + setzeAkzent(ui.box, shop().akzent) + if (!nurWiderruf()) { + S.korb = korbLesen(key) + bereinigeKorb() + } + var r = rueckkehr + if (r) { + rueckkehr = null + bereinigeUrl(['wk_bestellung', 'wk_t', 'wk_abbruch']) + if (!nurWiderruf()) { zeigeRueckkehr(r); return } + } + var wunsch = root.getAttribute('data-ansicht') || queryAnsicht + queryAnsicht = '' + if (wunsch === 'widerruf') { gehe('widerruf', { fokus: false }); return } + gehe(startAnsicht(), { fokus: false }) + if (wunsch === 'agb' && rechtstexte() && nichtLeer(rechtstexte().agb)) zeigeAgb(null) + } + + // Zurueck per Browser-Verlauf von Stripe (Seite aus dem Zwischenspeicher): der Knopf "Zahlungspflichtig bestellen" muss wieder gehen + window.addEventListener('pageshow', function (e) { + if (e.persisted && S && S.sendet && S.cfg && !S.tot) { + S.sendet = false + zeichne(false) + } + }) + + // Der Ausweichtext der Seite wird beiseite gelegt, sobald der Shop aktiv ist; scheitert der Aufbau, steht er wieder da + lade().then(function () { + injectStyle() + ausweich = document.createDocumentFragment() + while (root.firstChild) ausweich.appendChild(root.firstChild) + try { + bauGeruest(root) + starte() + } catch (e) { + zeigeAusweich() + } + }, function () { /* nicht erreichbar oder nicht aktiv: der Ausweichtext der Seite bleibt unberuehrt stehen */ }) + } + + function run () { + var b = queryWert('wk_bestellung') + var t = queryWert('wk_t') + if (ID_FORM.test(b) && TOKEN_FORM.test(t)) rueckkehr = { art: 'status', id: b, t: t } + else if (queryWert('wk_abbruch')) rueckkehr = { art: 'abbruch' } + var a = queryWert('ansicht') + if (a === 'widerruf' || a === 'agb') queryAnsicht = a + var nodes = document.querySelectorAll('[data-webklar-shop]') + for (var i = 0; i < nodes.length; i++) init(nodes[i]) + } + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run) + else run() +})() diff --git a/test/shopEmbedStatic.test.js b/test/shopEmbedStatic.test.js new file mode 100644 index 0000000..b82316b --- /dev/null +++ b/test/shopEmbedStatic.test.js @@ -0,0 +1,186 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync, statSync } from 'node:fs' +import vm from 'node:vm' + +// Statische Pruefung des Einbau-Skripts public/embed/shop.js (Plan 2026-10-05-webshop.md, Task H1; Muster test/termineEmbedStatic.test.js): +// - Kundentext nur ueber textContent/Attribute: kein innerHTML, kein eval. +// - Das Widget rechnet nie: keine Datums-, Zahlen- oder Zeitzonenformatierung im Browser (alles kommt als fertiger Text vom Server). +// - Einziger Browser-Speicher ist der Warenkorb in der sessionStorage, immer in try/catch; keine Cookies, keine Fremdadressen im Code. +// - Pflichttexte der Kasse und der Widerrufsfunktion (§ 312j Abs. 3 BGB, § 356a BGB) stehen im Skript. + +const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8') +const QUELLE = lies('public/embed/shop.js') + +/** Quelltext ohne Kommentare (die Kopfzeile darf Beispiele mit Adressen enthalten, der Code nicht). */ +const OHNE_KOMMENTARE = QUELLE.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1') + +test('Quelltext ist gueltiges JavaScript (kompiliert, wird nicht ausgefuehrt) und unter 120 KB', () => { + assert.doesNotThrow(() => new vm.Script(QUELLE, { filename: 'shop.js' })) + assert.ok(statSync(new URL('../public/embed/shop.js', import.meta.url)).size < 120 * 1024, 'Skript ist zu gross') +}) + +test('ES5-Stil wie termine.js: IIFE mit use strict, keine Pfeilfunktionen, let/const, Schablonenzeichenketten', () => { + assert.match(OHNE_KOMMENTARE, /^\s*\(function \(\) \{\s*'use strict'/) + assert.doesNotMatch(OHNE_KOMMENTARE, /=>/, 'keine Pfeilfunktionen') + assert.doesNotMatch(OHNE_KOMMENTARE, /\b(?:let|const)\s/, 'var statt let/const') + assert.ok(!OHNE_KOMMENTARE.includes('`'), 'keine Schablonenzeichenketten') +}) + +test('innerHTML gar nicht (Fremddaten nur ueber textContent); kein outerHTML, insertAdjacentHTML, document.write, eval, new Function', () => { + assert.ok(!/innerHTML/.test(OHNE_KOMMENTARE), 'innerHTML darf im Widget nicht vorkommen') + for (const verboten of ['outerHTML', 'insertAdjacentHTML', 'document.write', 'eval(', 'new Function', 'setTimeout("', "setTimeout('", 'createContextualFragment', 'srcdoc']) { + assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf im Widget nicht vorkommen`) + } +}) + +test('keine Zahlen-, Datums- oder Zeitzonenrechnung im Browser (Date, Intl, toLocale, toFixed)', () => { + assert.ok(!/\bDate\b/.test(OHNE_KOMMENTARE), 'Date darf nicht vorkommen') + for (const verboten of ['toLocale', 'Intl.', 'toFixed', 'getTimezoneOffset', 'resolvedOptions', 'getFullYear', 'getHours', 'Date.now']) { + assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf im Widget nicht vorkommen`) + } +}) + +test('keine Cookies, kein localStorage, kein Referrer, kein Beacon; keine festen Fremdadressen im Code', () => { + for (const verboten of ['localStorage', 'document.cookie', 'indexedDB', 'document.referrer', 'sendBeacon', 'navigator.userAgent']) { + assert.ok(!OHNE_KOMMENTARE.includes(verboten), `${verboten} darf im Widget nicht vorkommen`) + } + assert.doesNotMatch(OHNE_KOMMENTARE, /https?:\/\//, 'Adressen gehoeren in data-api, nicht in den Code') + assert.doesNotMatch(OHNE_KOMMENTARE, /\bimport\s*\(|\bimportScripts\b|\bXMLHttpRequest\b|\bWebSocket\b/) +}) + +test('sessionStorage nur fuer den Warenkorb unter wks-korb- und jeder Zugriff steht in try/catch', () => { + const treffer = [...OHNE_KOMMENTARE.matchAll(/sessionStorage/g)] + assert.ok(treffer.length >= 3, 'lesen, schreiben, entfernen') + for (const t of treffer) { + const davor = OHNE_KOMMENTARE.slice(0, t.index) + const tryPos = davor.lastIndexOf('try {') + assert.ok(tryPos >= 0, 'sessionStorage ohne try davor') + const zwischen = davor.slice(tryPos) + assert.ok(!/\bcatch\b/.test(zwischen), 'sessionStorage steht ausserhalb des try-Blocks') + assert.ok(zwischen.length < 600, 'try und sessionStorage liegen weit auseinander') + } + assert.ok(OHNE_KOMMENTARE.includes("'wks-korb-'"), 'Schluessel wks-korb-') + // gespeichert werden nur IDs und Mengen: das Array [id, variante, menge] + assert.match(OHNE_KOMMENTARE, /korb\.map\(function \(z\) \{ return \[z\.id, z\.v, z\.n\] \}\)/) +}) + +test('Anfragen ohne Cookies und nur an die Shop-Schnittstelle, eine Stelle fuer alle Anfragen, Zeitgrenze 20 s', () => { + assert.ok(OHNE_KOMMENTARE.includes("credentials: 'omit'"), "fetch braucht credentials: 'omit'") + assert.ok(OHNE_KOMMENTARE.includes('/api/public/shop/v1/')) + assert.equal([...OHNE_KOMMENTARE.matchAll(/\bfetch\(/g)].length, 1, 'genau ein fetch') + assert.match(OHNE_KOMMENTARE, /TIMEOUT_MS\s*=\s*20000\b/) + for (const pfad of ["'/pruefen'", "'/bestellung'", "'/widerruf'", "'/bestellung/'"]) assert.ok(OHNE_KOMMENTARE.includes(pfad), `Pfad ${pfad} fehlt`) + assert.equal([...OHNE_KOMMENTARE.matchAll(/request\('POST', '\/bestellung'/g)].length, 1, 'genau eine Stelle legt Bestellungen an') +}) + +test('Pflichttexte: Knopf der Kasse, Widerrufsfunktion, Gewaehrleistungsbild, Stripe-Hinweis, Fuss, Kopfzeile, geschlossen', () => { + for (const noetig of [ + "'Zahlungspflichtig bestellen'", "'Vertrag widerrufen'", "'Widerruf bestätigen'", "'Weiter'", + 'Name, Lieferadresse und Zahlung geben Sie im nächsten Schritt bei unserem Zahlungsdienstleister Stripe ein.', + 'Der Shop ist gerade geschlossen.', 'Shop-Technik: WEBklar', 'Lieferung nach: ', 'Abholung möglich', 'Zahlung: ', + "'AGB'", "'Widerrufsbelehrung'", "'Versand & Zahlung'", "'Impressum'", "'Datenschutz'", 'zzgl. Versand', + 'Vielen Dank für Ihre Bestellung', 'bei uns eingegangen. Eine Eingangsbestätigung haben wir Ihnen per E-Mail geschickt.', + 'rel = \'noopener noreferrer\'', 'img.alt = g.text', "img.loading = 'lazy'", 'gewaehrleistung', + 'wk_bestellung', 'wk_t', 'wk_abbruch', 'replaceState', 'kontakt_url', 'formToken', 'rueckUrl: location.href', 'nurWiderruf', + 'data-webklar-shop', 'data-api', 'data-ansicht', "'ansicht'", 'aria-live', 'aria-pressed', 'tabIndex = -1', 'showModal', + ]) { + assert.ok(QUELLE.includes(noetig), `${noetig} fehlt im Skript`) + } + // der Knopf heisst genau so (kein Zusatz) und steht in der Kasse + assert.ok(OHNE_KOMMENTARE.includes("el('button', 'wks-btn', 'Zahlungspflichtig bestellen')")) + assert.ok(OHNE_KOMMENTARE.includes("el('button', 'wks-btn', 'Widerruf bestätigen')")) + assert.ok(OHNE_KOMMENTARE.includes("knopf('wks-btn wks-btn-sek', 'Vertrag widerrufen'"), 'Widerruf im Fuss ist ein Knopf') +}) + +test('Weiterleitung zu Stripe nur zu einer https-Adresse (httpsUrl), Links der Rechtstexte nur als https', () => { + assert.match(OHNE_KOMMENTARE, /var ziel = d \? httpsUrl\(d\.url\) : ''/) + assert.match(OHNE_KOMMENTARE, /location\.href = ziel/) + assert.equal([...OHNE_KOMMENTARE.matchAll(/location\.href\s*=/g)].length, 1, 'nur eine Weiterleitung') + assert.match(OHNE_KOMMENTARE, /httpsUrl\(r\.impressumUrl\)/) + assert.match(OHNE_KOMMENTARE, /httpsUrl\(r\.datenschutzUrl\)/) + assert.match(OHNE_KOMMENTARE, /httpsUrl\(g\.bild\)/) + // Bildpfade des Servers (ohne Host) bekommen nur die API-Adresse vorangestellt + assert.match(OHNE_KOMMENTARE, /function absUrl \(api, pfad\)/) +}) + +test('Die Zusammenfassung der Kasse kommt vom Server (/pruefen) und wird bei jeder Aenderung neu geholt; veraltete Antworten verworfen', () => { + assert.match(OHNE_KOMMENTARE, /request\('POST', '\/pruefen'/) + assert.ok(OHNE_KOMMENTARE.includes('pruefSeq'), 'Zaehler gegen veraltete Antworten') + assert.match(OHNE_KOMMENTARE, /senden\.disabled = true/, 'Knopf bleibt gesperrt, bis die Zusammenfassung da ist') + // Preise werden nie im Widget gerechnet: keine Multiplikation von Preisen + assert.doesNotMatch(OHNE_KOMMENTARE, /preisCent|summeCent|einzelCent|parseFloat/) +}) + +test('Warten auf das Formular-Token (3 s) vor Bestellung und Widerruf; Konfiguration nach 100 min erneuert', () => { + assert.match(OHNE_KOMMENTARE, /FORM_MIN_VORGABE_S\s*=\s*3\b/) + assert.match(OHNE_KOMMENTARE, /FORM_PUFFER_MS\s*=\s*500\b/) + assert.match(OHNE_KOMMENTARE, /CONFIG_ALTER_MAX_MS\s*=\s*100 \* 60 \* 1000/) + for (const stelle of ["request('POST', '/bestellung'", "request('POST', '/widerruf'"]) { + const vor = OHNE_KOMMENTARE.slice(0, OHNE_KOMMENTARE.indexOf(stelle)) + const warte = vor.lastIndexOf('warteAufFormToken(senden)') + assert.ok(warte >= 0 && vor.length - warte < 400, `${stelle} nur nach warteAufFormToken`) + } +}) + +test('Warenkorb-Rueckkehr: bis zu fuenf Abrufe im Abstand von 2 s solange die Bestellung offen ist', () => { + assert.match(OHNE_KOMMENTARE, /STATUS_VERSUCHE\s*=\s*5\b/) + assert.match(OHNE_KOMMENTARE, /STATUS_ABSTAND_MS\s*=\s*2000\b/) + assert.ok(OHNE_KOMMENTARE.includes("d.status === 'offen' && st.versuch < STATUS_VERSUCHE")) +}) + +test('Stile stehen einmal in