/* Webshop, Reiter „Rechtliches“ (Plan Block G, Task G6): die Rechtstexte des Haendlers. Er ist Verkaeufer und verantwortlich; WEBklar liefert Muster und keine Rechtsberatung. Schnittstelle (server/routes/shop.js): GET /api/shop/rechtstexte?projectId= => { entwurf, bestaetigt: { texte, at, von, aktuell } | null, muster: { agb, widerrufsbelehrung, widerrufsformular }, versandUndZahlung, gewaehrleistung: { bild, text }, hash, bestaetigungText, vollstaendig, probleme: [Text] }; PUT /api/shop/rechtstexte { projectId, rechtstexte } speichert den Entwurf (Antwort wie GET, Feldfehler als err.fehler); POST /api/shop/rechtstexte/bestaetigen { projectId, hash } bestaetigt den gespeicherten Entwurf (409 texte_geaendert, 400 unvollstaendig, 400 nicht_bestaetigbar mit probleme: Platzhalter in eckigen Klammern, Musterkopfzeile, Widerrufsbelehrung ohne Adresse der Widerrufsfunktion; 403 in der Kundenansicht). Der Hash kommt aus der letzten Antwort von GET oder PUT; bestaetigt wird nur, was gespeichert ist: bei ungespeicherten Aenderungen heisst der Knopf „Erst speichern“. Ersetzen eines vorhandenen Textes durch das Muster fragt vorher in der Seite (kein confirm()). render(panel, ctx): ctx = { projectId, nurLesen, zeigeMeldung(text, art), neuLaden(), request } (siehe shop.js). Text kommt nur ueber textContent in die Seite (h), nie ueber innerHTML. */ import { abschnitt, allgemeinerFehler, datumText, feld, frage, h, request, setzeFehler, zeigeFehler } from './shop-dom.js' export const INTRO = 'Du bist Verkäufer und für deine Rechtstexte verantwortlich. Wir stellen Muster bereit, die du prüfen und anpassen musst – das ist keine Rechtsberatung. Lass deine Texte am besten von einer Kanzlei oder einem Händlerbund prüfen.' export const NICHT_BESTAETIGT = 'Änderungen noch nicht bestätigt' export const GEWAEHRLEISTUNG_NOTIZ = 'Dieser Hinweis wird in deinem Shop automatisch vor dem Bestell-Knopf angezeigt. Du musst nichts dafür tun.' const TEXTE = [ { key: 'agb', label: 'AGB', zeilen: 14, max: 20000, hinweis: 'Das Muster ist für kleine Shops gedacht. Prüfe es und passe es an dein Angebot an. Höchstens 20.000 Zeichen: Der Text steht vollständig in der Bestätigungs-E-Mail an deine Kunden.' }, { key: 'widerrufsbelehrung', label: 'Widerrufsbelehrung', zeilen: 14, max: 12000, hinweis: 'Das Muster nutzt deine Angaben aus den Einstellungen (Anbieter, Rücksendekosten). Prüfe sie dort, bevor du das Muster einsetzt. Höchstens 12.000 Zeichen. Die Widerrufsbelehrung muss die Adresse deiner Widerrufsfunktion enthalten; das Muster tut das.' }, { key: 'widerrufsformular', label: 'Muster-Widerrufsformular', zeilen: 8, max: 3000, hinweis: 'Höchstens 3.000 Zeichen.' }, ] const ADRESSEN = [ { key: 'impressumUrl', label: 'Adresse deines Impressums', platzhalter: 'https://www.deine-website.de/impressum' }, { key: 'datenschutzUrl', label: 'Adresse deiner Datenschutzerklärung', platzhalter: 'https://www.deine-website.de/datenschutz' }, ] const text = (wert) => (typeof wert === 'string' ? wert : '') const normal = (wert) => text(wert).replace(/\r\n?/g, '\n').trim() /** Nur Adressen auf dem eigenen Server (kein //host, kein Schema) als Bildquelle. */ const eigenerPfad = (pfad) => (typeof pfad === 'string' && /^\/(?!\/)[\w./-]*$/.test(pfad) ? pfad : '') export async function render(panel, ctx = {}) { const projectId = ctx.projectId const aufruf = ctx.request || request const nurLesen = ctx.nurLesen === true let daten = await aufruf(`/api/shop/rechtstexte?projectId=${encodeURIComponent(projectId)}`) /* ----------------------------------------------------------------- Felder --- */ const felder = {} const platzFragen = {} const musterKnoepfe = [] const bereiche = TEXTE.map(({ key, label, zeilen, max, hinweis }) => { const area = h('textarea', { rows: zeilen, maxlength: max, 'data-text': key }) felder[key] = area platzFragen[key] = h('div', { 'data-frage-platz': key }) const muster = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': `muster-${key}` }, 'Muster einsetzen') muster.addEventListener('click', () => musterEinsetzen(key, label, muster)) musterKnoepfe.push(muster) return h('div', null, feld(key, label, area, hinweis), nurLesen ? null : h('div', { class: 'sh-aktionen' }, muster), platzFragen[key]) }) const adressFelder = ADRESSEN.map(({ key, label, platzhalter }) => { const eingabe = h('input', { type: 'url', inputmode: 'url', autocomplete: 'off', maxlength: 500, placeholder: platzhalter, 'data-text': key }) felder[key] = eingabe return feld(key, label, eingabe) }) function fuelle(entwurf) { const e = entwurf && typeof entwurf === 'object' ? entwurf : {} for (const key of [...TEXTE, ...ADRESSEN].map((t) => t.key)) felder[key].value = text(e[key]) } fuelle(daten.entwurf) /** Ungespeicherte Aenderungen: Eingaben gegen den Entwurf der letzten Antwort. */ const geaendert = () => [...TEXTE, ...ADRESSEN].some(({ key }) => normal(felder[key].value) !== normal(daten.entwurf?.[key])) /* ----------------------------------------------------------- Muster einsetzen --- */ function musterEinsetzen(key, label, knopf) { const muster = text(daten.muster?.[key]) const area = felder[key] const setze = () => { area.value = muster platzFragen[key].replaceChildren() zeichne() area.focus() } // nichts zu verlieren: leer oder schon das Muster if (normal(area.value) === '' || normal(area.value) === normal(muster)) { setze() return } const ja = h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'muster-ja' }, 'Ja, Muster einsetzen') const nein = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'muster-nein' }, 'Abbrechen') const box = frage(`${label} durch das Muster ersetzen?`, h('p', { class: 'sh-hinweis' }, 'Dein bisheriger Text wird überschrieben. Du kannst ihn danach nur zurückholen, indem du die Seite neu lädst, bevor du speicherst.'), h('div', { class: 'sh-aktionen' }, ja, nein)) ja.addEventListener('click', setze) nein.addEventListener('click', () => { platzFragen[key].replaceChildren(); knopf.focus() }) platzFragen[key].replaceChildren(box) ja.focus() } /* ----------------------------------------------------------------- Lesetexte --- */ const lesetext = (inhalt, art) => h('div', { class: 'sh-begruendung', 'data-lesetext': art }, text(inhalt)) const bild = eigenerPfad(daten.gewaehrleistung?.bild) const gewaehrleistung = abschnitt('Hinweis zur gesetzlichen Gewährleistung', GEWAEHRLEISTUNG_NOTIZ, bild ? h('img', { class: 'sh-gewaehrleistung-bild', src: bild, alt: text(daten.gewaehrleistung?.text), loading: 'lazy', 'data-lesetext': 'gewaehrleistung-bild' }) : null, lesetext(daten.gewaehrleistung?.text, 'gewaehrleistung')) const versandZahlung = abschnitt('Versand und Zahlung', 'Dieser Text wird aus deinen Einstellungen erzeugt, damit er stimmt. Du kannst ihn hier nicht ändern; ändere die Einstellungen, wenn etwas nicht passt.', lesetext(daten.versandUndZahlung, 'versand-zahlung')) /* ------------------------------------------------------------ Status, Bestaetigen --- */ const statusPlatz = h('div', { 'data-status-platz': '' }) const hinweisBest = h('p', { class: 'sh-hinweis', 'data-hinweis': 'bestaetigen' }) const haken = h('input', { type: 'checkbox', 'data-feld': 'bestaetigung' }) const bestaetigen = h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'bestaetigen' }, 'Texte bestätigen') const fehlerBest = h('p', { class: 'sh-fehler', 'data-fehler': 'bestaetigung', role: 'alert' }) // Punkte, die das Bestaetigen verhindern (Platzhalter, Musterkopf, fehlende Adresse der Widerrufsfunktion), vom Server const problemPlatz = h('div', { 'data-probleme-platz': '' }) const bestBlock = h('section', { class: 'kb-panel sh-abschnitt', 'data-block': 'bestaetigen' }, h('h3', null, 'Texte bestätigen'), problemPlatz, h('label', { class: 'sh-haken' }, haken, h('span', null, text(daten.bestaetigungText))), hinweisBest, fehlerBest, h('div', { class: 'sh-aktionen' }, bestaetigen)) /** Status, Banner und Bestaetigungsblock nach dem Stand von daten und den Eingaben zeichnen. */ function zeichne() { const b = daten.bestaetigt const teile = [] if (b) { const wann = datumText(b.at) const wer = text(b.von) const wo = `${wann ? ` am ${wann} Uhr` : ''}${wer ? ` von ${wer}` : ''}` if (b.aktuell) { teile.push(h('p', { class: 'sh-zustand sh-zustand-offen', 'data-status': 'bestaetigt' }, `Bestätigt${wo}.`)) } else { teile.push(h('div', { class: 'sh-box sh-box-warn', role: 'note', 'data-status': 'unbestaetigt' }, h('span', null, h('strong', null, NICHT_BESTAETIGT), ` – zuletzt bestätigt${wo}. Neue Bestellungen laufen mit dieser Fassung weiter, bis du die geänderten Texte bestätigst.`))) } } else { teile.push(h('p', { class: 'sh-zustand sh-zustand-zu', 'data-status': 'offen' }, 'Noch nicht bestätigt. Ohne bestätigte Texte kann dein Shop nicht öffnen.')) } statusPlatz.replaceChildren(...teile) const schmutzig = geaendert() const probleme = !schmutzig && Array.isArray(daten.probleme) ? daten.probleme.filter((t) => typeof t === 'string' && t !== '') : [] problemPlatz.replaceChildren(...(probleme.length ? [h('div', { class: 'sh-box sh-box-warn', role: 'alert', 'data-probleme': '' }, h('strong', null, 'Das musst du vor dem Bestätigen noch ändern:'), h('ul', null, probleme.map((t) => h('li', null, t))))] : [])) const bereitZumBestaetigen = !schmutzig && daten.vollstaendig === true && probleme.length === 0 // schon bestaetigt und nichts geaendert: nichts zu tun bestBlock.hidden = Boolean(b?.aktuell) && !schmutzig bestaetigen.textContent = schmutzig ? 'Erst speichern' : 'Texte bestätigen' bestaetigen.disabled = !bereitZumBestaetigen || !haken.checked hinweisBest.textContent = schmutzig ? 'Du hast Änderungen, die noch nicht gespeichert sind. Speichere zuerst den Entwurf, dann kannst du ihn bestätigen.' : daten.vollstaendig === true ? '' : 'Zum Bestätigen müssen AGB, Widerrufsbelehrung, Muster-Widerrufsformular sowie die Adressen von Impressum und Datenschutz ausgefüllt und gespeichert sein.' } /* ------------------------------------------------------------------- Aufbau --- */ const speichern = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'entwurf-speichern' }, 'Entwurf speichern') const form = h('form', { class: 'sh-form sh-rechtliches', novalidate: true, 'aria-label': 'Rechtstexte des Webshops' }, abschnitt('Deine Texte', null, ...bereiche), abschnitt('Impressum und Datenschutz', 'Diese beiden Seiten gehören zu deiner Website. Der Shop verlinkt sie in der Fußzeile und vor der Bestellung.', ...adressFelder), allgemeinerFehler(), nurLesen ? null : h('div', { class: 'sh-aktionen' }, speichern)) const intro = h('div', { class: 'sh-box sh-box-info', role: 'note', 'data-hinweis': 'intro' }, INTRO) panel.replaceChildren(...[intro, statusPlatz, form, versandZahlung, gewaehrleistung, nurLesen ? null : bestBlock].filter(Boolean)) for (const el of Object.values(felder)) el.addEventListener('input', zeichne) haken.addEventListener('change', zeichne) /* ---------------------------------------------------------------- Speichern --- */ let laeuft = false form.addEventListener('submit', async (ev) => { ev.preventDefault() if (nurLesen || laeuft) return laeuft = true speichern.disabled = true setzeFehler(form, {}) try { const rechtstexte = Object.fromEntries([...TEXTE, ...ADRESSEN].map(({ key }) => [key, felder[key].value.trim()])) daten = await aufruf('/api/shop/rechtstexte', { method: 'PUT', body: { projectId, rechtstexte } }) fuelle(daten.entwurf) haken.checked = false zeichne() if (typeof ctx.zeigeMeldung === 'function') ctx.zeigeMeldung('Der Entwurf ist gespeichert.', 'ok') } catch (err) { zeigeFehler(form, err) } finally { laeuft = false speichern.disabled = false } }) /* --------------------------------------------------------------- Bestaetigen --- */ let bestaetigtLaeuft = false bestaetigen.addEventListener('click', async () => { if (nurLesen || bestaetigtLaeuft || bestaetigen.disabled) return bestaetigtLaeuft = true bestaetigen.disabled = true fehlerBest.textContent = '' try { daten = await aufruf('/api/shop/rechtstexte/bestaetigen', { method: 'POST', body: { projectId, hash: daten.hash } }) haken.checked = false zeichne() if (typeof ctx.zeigeMeldung === 'function') ctx.zeigeMeldung('Die Texte sind bestätigt.', 'ok') if (typeof ctx.neuLaden === 'function') { try { await ctx.neuLaden() } catch { /* bestaetigt ist bestaetigt */ } } } catch (err) { fehlerBest.textContent = err.message // 400 nicht_bestaetigbar: die Liste des Servers statt des allgemeinen Satzes zeigen if (err?.code === 'nicht_bestaetigbar' && Array.isArray(err.body?.probleme)) { daten = { ...daten, probleme: err.body.probleme } fehlerBest.textContent = '' } // 409: die Texte haben sich inzwischen geaendert; der neue Stand wird geholt, ungespeicherte Eingaben gibt es hier nicht (sie sperren den Knopf) if (err?.status === 409) { try { const neu = await aufruf(`/api/shop/rechtstexte?projectId=${encodeURIComponent(projectId)}`) daten = neu fuelle(daten.entwurf) haken.checked = false } catch { /* die Meldung bleibt stehen */ } } zeichne() } finally { bestaetigtLaeuft = false } }) if (nurLesen) { for (const el of form.querySelectorAll('input, select, textarea, button')) el.disabled = true for (const el of panel.querySelectorAll('button')) el.disabled = true } zeichne() }