Files
Webklar-Kundenbereich/public/shop-tab-rechtliches.js
T
ClaudeandClaude Opus 5.5 08d837d690 Shop Review B I-6, I-7: Archiv der bestaetigten Rechtstexte, G09 ohne Link-Ersatz
I-6: Neue Collection shopRechtstexteArchiv (ID r + 35 Hex aus sha256(shopId + hash)); das
Bestaetigen legt die Fassung samt Versand-/Zahlungstext und Gewaehrleistungstext ab, G09 zeigt
die Fassung zum rechtstexteHash der Bestellung (Rueckfall mit Warnung auf die aktuell
bestaetigte), loescheShopDaten loescht das Archiv mit.

I-7: Grenzen AGB 20000, Belehrung 12000, Formular 3000 plus Pruefung der maskierten Laenge beim
Speichern; G09 ersetzt die AGB nie mehr durch einen Link, sondern scheitert (Marker bleibt
offen, wird wiederholt). Test mit Texten in Hoechstlaenge gegen die Grenzen der Warteschlange.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:58:31 +00:00

241 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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()
}