Shop G6: Reiter Einstellungen und Rechtliches (Felder, Versand, Zahlungsarten, Muster einsetzen, Entwurf, Bestätigung mit Hash)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 09:12:46 +00:00
1 parent 3ed96a5838
commit 648d0e07fe
5 files changed
+1148 -10

No files matched your search

+221 -5
View File
@@ -1,8 +1,224 @@
/* Webshop, Reiter „Rechtliches“: Platzhalter, bis der Reiter gebaut ist (Plan Block G, Tasks G3 bis G6 ersetzen diese Datei).
render(panel, ctx): ctx = { projectId, status, nurLesen, stripeRueckkehr, neuLaden(), zeigeMeldung(text, art), request } (siehe shop.js). */
/* 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 };
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,
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 { h } from './shop-dom.js'
import { abschnitt, allgemeinerFehler, datumText, feld, frage, h, request, setzeFehler, zeigeFehler } from './shop-dom.js'
export function render(panel) {
panel.replaceChildren(h('p', { class: 'sh-hinweis' }, 'Kommt gleich …'))
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: 30000, hinweis: 'Das Muster ist für kleine Shops gedacht. Prüfe es und passe es an dein Angebot an.' },
{ key: 'widerrufsbelehrung', label: 'Widerrufsbelehrung', zeilen: 14, max: 20000, hinweis: 'Das Muster nutzt deine Angaben aus den Einstellungen (Anbieter, Rücksendekosten). Prüfe sie dort, bevor du das Muster einsetzt.' },
{ key: 'widerrufsformular', label: 'Muster-Widerrufsformular', zeilen: 8, max: 5000, hinweis: null },
]
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' })
const bestBlock = h('section', { class: 'kb-panel sh-abschnitt', 'data-block': 'bestaetigen' },
h('h3', null, 'Texte bestätigen'),
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 bereitZumBestaetigen = !schmutzig && daten.vollstaendig === true
// 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
// 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()
}