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>
241 lines
14 KiB
JavaScript
241 lines
14 KiB
JavaScript
/* 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()
|
||
}
|