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