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

+253 -5
View File
@@ -1,8 +1,256 @@
/* Webshop, Reiter „Einstellungen“: Platzhalter, bis der Reiter gebaut ist (Plan Block G, Tasks G3 bis G6 ersetzen diese Datei). /* Webshop, Reiter „Einstellungen“ (Plan Block G, Task G6): Shopname und Akzentfarbe, Anbieter, Steuer, Versand, Abholung, Lieferzeit,
render(panel, ctx): ctx = { projectId, status, nurLesen, stripeRueckkehr, neuLaden(), zeigeMeldung(text, art), request } (siehe shop.js). */ Mindestbestellwert, Ruecksendekosten und Zahlungsarten.
Schnittstelle (server/routes/shop.js): GET /api/shop/einstellungen?projectId= => { einstellungen, version, vollstaendig } und
PUT /api/shop/einstellungen { projectId, einstellungen, version } => dasselbe; ein Konflikt (409, EINSTELLUNG_VERALTET) heisst: jemand
hat zwischendurch gespeichert, dann zeigt der Reiter den Text und „Neu laden“. Geprueft wird auf dem Server (server/services/shop/
einstellungen.js); dessen Feldfehler tragen Pfade wie 'anbieter.plz' oder 'versand.kostenCent.AT' und stehen am Feld (data-feld).
Betraege werden hier in ganze Cent umgerechnet (euroEingabe), unlesbare Betraege werden schon im Browser gemeldet.
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, centZuEuro, euroEingabe, feld, h, hakenFeld, request, setzeFehler, zeigeFehler } from './shop-dom.js'
export function render(panel) { const LAENDER = [['DE', 'Deutschland'], ['AT', 'Österreich']]
panel.replaceChildren(h('p', { class: 'sh-hinweis' }, 'Kommt gleich …')) const VERSAND_ZIELE = [['DE', 'Nur Deutschland'], ['DE,AT', 'Deutschland und Österreich']]
const RUECKSENDUNG = [['kaeufer', 'Der Käufer trägt die Kosten der Rücksendung'], ['haendler', 'Ich trage die Kosten der Rücksendung']]
export const SEPA_WARNUNG = 'Bei Lastschrift ist das Geld erst nach einigen Tagen sicher. Versende erst, wenn die Bestellung als bezahlt markiert ist.'
export const STRIPE_HINWEIS = 'Schalte Klarna oder Lastschrift nur ein, wenn sie in deinem Stripe-Konto aktiviert sind.'
export const PAYPAL_HINWEIS = 'PayPal ist über Stripe für Shops wie deinen leider nicht möglich.'
export const ANBIETER_HINWEIS = 'Diese Angaben erscheinen im Shop, in der Bestellbestätigung und in der Widerrufsbelehrung.'
const text = (wert) => (typeof wert === 'string' ? wert : '')
const textfeld = (optionen = {}) => h('input', { type: 'text', autocomplete: 'off', ...optionen })
const auswahl = (liste) => h('select', null, liste.map(([wert, name]) => h('option', { value: wert }, name)))
export async function render(panel, ctx = {}) {
const projectId = ctx.projectId
const aufruf = ctx.request || request
const nurLesen = ctx.nurLesen === true
const antwort = await aufruf(`/api/shop/einstellungen?projectId=${encodeURIComponent(projectId)}`)
let version = Number.isInteger(antwort?.version) ? antwort.version : 0
/* ----------------------------------------------------------------- Felder --- */
const shopname = textfeld({ maxlength: 80 })
const akzent = h('input', { type: 'color' })
const anbieterName = textfeld({ maxlength: 120, autocomplete: 'organization' })
const strasse = textfeld({ maxlength: 120, autocomplete: 'street-address' })
const plz = textfeld({ maxlength: 10, inputmode: 'numeric', autocomplete: 'postal-code' })
const ort = textfeld({ maxlength: 80, autocomplete: 'address-level2' })
const land = auswahl(LAENDER)
const email = h('input', { type: 'email', maxlength: 254, autocomplete: 'email' })
const telefon = h('input', { type: 'tel', maxlength: 40, autocomplete: 'tel' })
const ustId = textfeld({ maxlength: 20, placeholder: 'DE123456789' })
const kleinunternehmer = h('input', { type: 'checkbox' })
const versandAktiv = h('input', { type: 'checkbox' })
const laender = auswahl(VERSAND_ZIELE)
const kostenDE = euroEingabe()
const kostenAT = euroEingabe()
const freiAb = euroEingabe()
const abholungAktiv = h('input', { type: 'checkbox' })
const abholAdresse = h('textarea', { rows: 3, maxlength: 300 })
const abholHinweis = h('textarea', { rows: 3, maxlength: 300 })
const lieferzeit = textfeld({ maxlength: 80, placeholder: '2–4 Werktage' })
const mindest = euroEingabe()
const rueck = auswahl(RUECKSENDUNG)
// Karte (mit Apple Pay und Google Pay) ist immer dabei und nicht abwaehlbar
const karte = h('input', { type: 'checkbox', disabled: true })
karte.checked = true
const klarna = h('input', { type: 'checkbox' })
const sepa = h('input', { type: 'checkbox' })
/* ----------------------------------------------------------- Aufbau/Fuellen --- */
const versandAt = h('div', { class: 'sh-versand-at' }, feld('versand.kostenCent.AT', 'Versandkosten Österreich (€)', kostenAT))
const versandDetails = h('div', { class: 'sh-versand-details' },
feld('versand.laender', 'Wohin lieferst du?', laender),
feld('versand.kostenCent.DE', 'Versandkosten Deutschland (€)', kostenDE),
versandAt,
feld('versand.freiAbCent', 'Versandkostenfrei ab (€, freiwillig)', freiAb, 'Ab diesem Warenwert zahlen deine Kunden keinen Versand. Leer lassen, wenn es das nicht geben soll.'))
const abholDetails = h('div', { class: 'sh-abhol-details' },
feld('abholung.adresse', 'Abholadresse', abholAdresse, 'Hier können deine Kunden die Ware abholen.'),
feld('abholung.hinweis', 'Hinweis zur Abholung (freiwillig)', abholHinweis, 'Zum Beispiel deine Öffnungszeiten.'))
const zeigeTeile = () => {
versandDetails.hidden = !versandAktiv.checked
versandAt.hidden = laender.value !== 'DE,AT'
abholDetails.hidden = !abholungAktiv.checked
}
/** Felder aus einem Stand des Servers fuellen (Standardwerte fuer alles, was fehlt). */
function fuelle(e) {
const s = e && typeof e === 'object' ? e : {}
const a = s.anbieter || {}
const v = s.versand || {}
const ab = s.abholung || {}
const kosten = v.kostenCent || {}
shopname.value = text(s.shopname)
akzent.value = /^#[0-9a-fA-F]{6}$/.test(text(s.akzent)) ? s.akzent : '#111111'
anbieterName.value = text(a.name)
strasse.value = text(a.strasse)
plz.value = text(a.plz)
ort.value = text(a.ort)
land.value = a.land === 'AT' ? 'AT' : 'DE'
email.value = text(a.email)
telefon.value = text(a.telefon)
ustId.value = text(a.ustId)
kleinunternehmer.checked = s.kleinunternehmer !== false
versandAktiv.checked = v.aktiv !== false
laender.value = Array.isArray(v.laender) && v.laender.includes('AT') ? 'DE,AT' : 'DE'
kostenDE.value = centZuEuro(kosten.DE)
kostenAT.value = centZuEuro(kosten.AT)
freiAb.value = centZuEuro(v.freiAbCent)
abholungAktiv.checked = ab.aktiv === true
abholAdresse.value = text(ab.adresse)
abholHinweis.value = text(ab.hinweis)
lieferzeit.value = text(s.lieferzeit)
mindest.value = centZuEuro(s.mindestbestellCent)
rueck.value = s.ruecksendekosten === 'haendler' ? 'haendler' : 'kaeufer'
const zahl = Array.isArray(s.zahlungsarten) ? s.zahlungsarten : ['card']
klarna.checked = zahl.includes('klarna')
sepa.checked = zahl.includes('sepa_debit')
zeigeTeile()
}
fuelle(antwort?.einstellungen)
versandAktiv.addEventListener('change', zeigeTeile)
laender.addEventListener('change', zeigeTeile)
abholungAktiv.addEventListener('change', zeigeTeile)
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'einstellungen-speichern' }, 'Einstellungen speichern')
const konfliktPlatz = h('div', { 'data-konflikt-platz': '' })
const zeigeKonflikt = (meldung) => konfliktPlatz.replaceChildren(h('div', { class: 'sh-box sh-box-warn', role: 'alert', 'data-konflikt': '' },
h('span', null, meldung),
h('button', {
type: 'button',
class: 'kb-btn-outline',
'data-aktion': 'neu-laden',
onclick: () => { render(panel, ctx).catch((err) => panel.replaceChildren(h('p', { class: 'sh-fehler', role: 'alert' }, err.message))) },
}, 'Neu laden')))
const form = h('form', { class: 'sh-form sh-einstellungen', novalidate: true, 'aria-label': 'Einstellungen des Webshops' },
abschnitt('Shop', null,
feld('shopname', 'Shopname', shopname, 'So heißt dein Shop. Er steht im Shop und in den E-Mails an deine Kunden.'),
feld('akzent', 'Akzentfarbe', akzent, 'Diese Farbe bekommen Knöpfe und Akzente in deinem Shop.')),
abschnitt('Anbieter', ANBIETER_HINWEIS,
feld('anbieter.name', 'Name oder Firma', anbieterName),
feld('anbieter.strasse', 'Straße und Hausnummer', strasse),
feld('anbieter.plz', 'Postleitzahl', plz),
feld('anbieter.ort', 'Ort', ort),
feld('anbieter.land', 'Land', land),
feld('anbieter.email', 'E-Mail-Adresse', email),
feld('anbieter.telefon', 'Telefon (freiwillig)', telefon),
feld('anbieter.ustId', 'USt-IdNr. (freiwillig)', ustId, 'Zum Beispiel DE123456789. Lass das Feld leer, wenn du keine hast.')),
abschnitt('Steuer', null,
hakenFeld('kleinunternehmer', kleinunternehmer, 'Ich bin Kleinunternehmer nach § 19 UStG', 'Dann steht im Shop der Hinweis, dass keine Umsatzsteuer berechnet wird. Ohne Haken gelten deine Preise inklusive Umsatzsteuer.')),
abschnitt('Versand', null,
hakenFeld('versand.aktiv', versandAktiv, 'Ich versende Bestellungen'),
versandDetails),
abschnitt('Abholung', null,
hakenFeld('abholung.aktiv', abholungAktiv, 'Kunden können Bestellungen abholen'),
abholDetails),
abschnitt('Lieferzeit und Rücksendung', null,
feld('lieferzeit', 'Lieferzeit', lieferzeit, 'Pflichtangabe, zum Beispiel „2–4 Werktage“. Die Angabe steht im Shop und in der Bestellbestätigung.'),
feld('mindestbestellCent', 'Mindestbestellwert (€, freiwillig)', mindest, 'Leer lassen, wenn es keinen Mindestbestellwert geben soll.'),
feld('ruecksendekosten', 'Rücksendekosten bei Widerruf', rueck)),
abschnitt('Zahlungsarten', 'Dein Shop zeigt genau diese Zahlungsarten an.',
hakenFeld('zahlung.card', karte, 'Karte (inklusive Apple Pay und Google Pay)', 'Immer dabei.'),
hakenFeld('zahlung.klarna', klarna, 'Klarna'),
hakenFeld('zahlung.sepa_debit', sepa, 'SEPA-Lastschrift'),
h('div', { class: 'sh-box sh-box-warn', role: 'note', 'data-hinweis': 'sepa' }, SEPA_WARNUNG),
h('p', { class: 'sh-hinweis', 'data-hinweis': 'stripe' }, STRIPE_HINWEIS),
h('p', { class: 'sh-hinweis', 'data-hinweis': 'paypal' }, PAYPAL_HINWEIS),
h('p', { class: 'sh-fehler', 'data-fehler': 'zahlungsarten', role: 'alert' })),
allgemeinerFehler(),
konfliktPlatz,
nurLesen ? null : h('div', { class: 'sh-aktionen' }, speichern))
/* ------------------------------------------------------------ Einsammeln --- */
/** Eingabe => { einstellungen } oder { fehler } (nur was der Browser selbst nicht lesen kann: Betraege). */
function sammle() {
const fehler = {}
const betrag = (steuer, name, { pflicht = false } = {}) => {
if (!steuer.istLesbar()) {
fehler[name] = 'Bitte gib einen Betrag wie 4,90 an.'
return null
}
const cent = steuer.inCent()
if (cent === null && pflicht) fehler[name] = 'Bitte gib die Versandkosten an. Für kostenlosen Versand trage 0,00 ein.'
return cent
}
const mitAt = laender.value === 'DE,AT'
const aktiv = versandAktiv.checked
const kostenCent = { DE: betrag(kostenDE, 'versand.kostenCent.DE', { pflicht: aktiv }) }
if (mitAt) kostenCent.AT = betrag(kostenAT, 'versand.kostenCent.AT', { pflicht: aktiv })
const freiAbCent = betrag(freiAb, 'versand.freiAbCent')
const mindestbestellCent = betrag(mindest, 'mindestbestellCent')
if (Object.keys(fehler).length) return { fehler }
const zahlungsarten = ['card', klarna.checked ? 'klarna' : null, sepa.checked ? 'sepa_debit' : null].filter(Boolean)
return {
einstellungen: {
shopname: shopname.value.trim(),
akzent: akzent.value,
anbieter: {
name: anbieterName.value.trim(),
strasse: strasse.value.trim(),
plz: plz.value.trim(),
ort: ort.value.trim(),
land: land.value,
email: email.value.trim(),
telefon: telefon.value.trim(),
ustId: ustId.value.trim(),
},
kleinunternehmer: kleinunternehmer.checked,
versand: { aktiv, laender: mitAt ? ['DE', 'AT'] : ['DE'], kostenCent, freiAbCent },
abholung: { aktiv: abholungAktiv.checked, adresse: abholAdresse.value.trim(), hinweis: abholHinweis.value.trim() },
lieferzeit: lieferzeit.value.trim(),
mindestbestellCent,
ruecksendekosten: rueck.value,
zahlungsarten,
},
}
}
/* ---------------------------------------------------------------- Speichern --- */
let laeuft = false
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
if (nurLesen || laeuft) return
laeuft = true
speichern.disabled = true
konfliktPlatz.replaceChildren()
setzeFehler(form, {})
try {
const eingabe = sammle()
if (eingabe.fehler) {
setzeFehler(form, eingabe.fehler)
form.querySelector('[aria-invalid]')?.focus()
return
}
const gespeichert = await aufruf('/api/shop/einstellungen', { method: 'PUT', body: { projectId, einstellungen: eingabe.einstellungen, version } })
if (Number.isInteger(gespeichert?.version)) version = gespeichert.version
if (gespeichert?.einstellungen) fuelle(gespeichert.einstellungen)
if (typeof ctx.zeigeMeldung === 'function') ctx.zeigeMeldung('Die Einstellungen sind gespeichert.', 'ok')
if (typeof ctx.neuLaden === 'function') {
try { await ctx.neuLaden() } catch { /* gespeichert ist gespeichert; der Stand erscheint beim naechsten Laden */ }
}
} catch (err) {
if (err?.status === 409 || err?.code === 'EINSTELLUNG_VERALTET') zeigeKonflikt(err.message)
else zeigeFehler(form, err)
} finally {
laeuft = false
speichern.disabled = false
}
})
if (nurLesen) {
for (const el of form.querySelectorAll('input, select, textarea, button')) el.disabled = true
}
panel.replaceChildren(form)
} }
+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). /* Webshop, Reiter „Rechtliches“ (Plan Block G, Task G6): die Rechtstexte des Haendlers. Er ist Verkaeufer und verantwortlich; WEBklar liefert
render(panel, ctx): ctx = { projectId, status, nurLesen, stripeRueckkehr, neuLaden(), zeigeMeldung(text, art), request } (siehe shop.js). */ 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) { 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.'
panel.replaceChildren(h('p', { class: 'sh-hinweis' }, 'Kommt gleich …')) 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()
} }
+4
View File
@@ -109,3 +109,7 @@
.sh-code { margin: 0 0 8px; padding: 12px 14px; overflow-x: auto; border-radius: 10px; border: 1px solid var(--kb-border-strong); background: rgba(0, 0, 0, 0.25); font-size: 13px; line-height: 1.5; white-space: pre; } .sh-code { margin: 0 0 8px; padding: 12px 14px; overflow-x: auto; border-radius: 10px; border: 1px solid var(--kb-border-strong); background: rgba(0, 0, 0, 0.25); font-size: 13px; line-height: 1.5; white-space: pre; }
.sh-code code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } .sh-code code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
a.kb-btn-outline { display: inline-flex; align-items: center; text-decoration: none; } a.kb-btn-outline { display: inline-flex; align-items: center; text-decoration: none; }
/* Reiter Rechtliches (shop-tab-rechtliches.js) */
.sh-gewaehrleistung-bild { display: block; max-width: 100%; height: auto; margin: 0 0 12px; border-radius: 8px; background: #fff; }
.sh-rechtliches textarea { font-family: inherit; line-height: 1.5; }
+335
View File
@@ -0,0 +1,335 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { installFakeDom } from './support/fakeDom.js'
import { h, request } from '../public/shop-dom.js'
import { ANBIETER_HINWEIS, PAYPAL_HINWEIS, SEPA_WARNUNG, STRIPE_HINWEIS, render } from '../public/shop-tab-einstellungen.js'
// Task G6: Reiter „Einstellungen“ mit Fake-DOM und Fake-fetch. Die Antworten des Servers sind von Hand gebaut
// (Form: server/routes/shop.js und server/services/shop/einstellungen.js).
const STANDARD = {
shopname: '',
anbieter: { name: '', strasse: '', plz: '', ort: '', land: 'DE', email: '', telefon: '', ustId: '' },
kleinunternehmer: true,
versand: { aktiv: true, laender: ['DE'], kostenCent: { DE: 490 }, freiAbCent: null },
abholung: { aktiv: false, adresse: '', hinweis: '' },
lieferzeit: '2–4 Werktage',
zahlungsarten: ['card'],
akzent: '#111111',
mindestbestellCent: null,
ruecksendekosten: 'kaeufer',
}
const GEFUELLT = {
shopname: 'Mützenlädchen',
anbieter: { name: 'Erika Muster', strasse: 'Hauptstraße 1', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'erika@example.com', telefon: '0631 123456', ustId: 'DE123456789' },
kleinunternehmer: false,
versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 990 }, freiAbCent: 5000 },
abholung: { aktiv: true, adresse: 'Hinterhof 2\n67655 Kaiserslautern', hinweis: 'Mo bis Fr 10 bis 18 Uhr' },
lieferzeit: '1–2 Werktage',
zahlungsarten: ['card', 'klarna', 'sepa_debit'],
akzent: '#ff8800',
mindestbestellCent: 1500,
ruecksendekosten: 'haendler',
}
const ANTWORT = (einstellungen, version = 3) => ({ einstellungen, version, vollstaendig: true })
/** Fake-Server: GET /api/shop/einstellungen antwortet aus `welt.get()`, PUT aus `welt.put(aufruf)` (Standard: Echo mit Version + 1). */
function startFx(welt) {
const route = (aufruf) => {
if (aufruf.method === 'GET' && aufruf.url.startsWith('/api/shop/einstellungen?projectId=')) return { body: typeof welt.get === 'function' ? welt.get() : welt.get }
if (aufruf.method === 'PUT' && aufruf.url === '/api/shop/einstellungen') {
if (welt.put) return welt.put(aufruf)
return { body: ANTWORT(aufruf.body.einstellungen, aufruf.body.version + 1) }
}
return { status: 404, body: { error: 'unbekannt' } }
}
return installFakeDom({ antworten: Array.from({ length: 40 }, () => route) })
}
const mitDom = (welt, fn) => async () => {
const fx = startFx(welt)
try {
await fn(fx)
} finally {
fx.restore()
}
}
function ctxVon(extra = {}) {
const log = { meldungen: [], neuGeladen: 0 }
const ctx = {
projectId: 'p1',
nurLesen: false,
request,
zeigeMeldung: (text, art) => log.meldungen.push([text, art]),
neuLaden: async () => { log.neuGeladen += 1 },
...extra,
}
return { ctx, log }
}
async function oeffne(fx, ctxExtra) {
const root = h('div', { id: 'panel' })
fx.document.body.append(root)
const { ctx, log } = ctxVon(ctxExtra)
await render(root, ctx)
await fx.ruhe()
return { root, ctx, log }
}
const feldVon = (root, name) => root.querySelector(`[data-feld="${name}"]`)
const fehlerVon = (root, name) => root.querySelector(`[data-fehler="${name}"]`)
const speichernKnopf = (root) => root.querySelector('[data-aktion="einstellungen-speichern"]')
const puts = (fx) => fx.fetchLog.filter((a) => a.method === 'PUT')
/* --------------------------------------------------------------- Anzeige --- */
test('Standardwerte: Felder gefüllt, Länder nur DE, Karte fest, Hinweise zu Klarna, Lastschrift und PayPal', mitDom({ get: ANTWORT(STANDARD, 0) }, async (fx) => {
const { root } = await oeffne(fx)
assert.equal(fx.fetchLog[0].url, '/api/shop/einstellungen?projectId=p1')
for (const titel of ['Shop', 'Anbieter', 'Steuer', 'Versand', 'Abholung', 'Zahlungsarten']) {
assert.ok(fx.finde(root, (el) => el.tagName === 'H3' && el.textContent === titel), titel)
}
assert.equal(feldVon(root, 'shopname').value, '')
assert.equal(feldVon(root, 'akzent').type, 'color')
assert.equal(feldVon(root, 'akzent').value, '#111111')
assert.equal(feldVon(root, 'anbieter.land').value, 'DE')
assert.equal(feldVon(root, 'kleinunternehmer').checked, true)
assert.equal(feldVon(root, 'versand.aktiv').checked, true)
assert.equal(feldVon(root, 'versand.laender').value, 'DE')
assert.equal(feldVon(root, 'versand.kostenCent.DE').value, '4,90')
assert.equal(feldVon(root, 'versand.kostenCent.DE').type, 'text', 'Betrag als Text, Komma bleibt erlaubt')
assert.equal(feldVon(root, 'versand.freiAbCent').value, '')
assert.equal(feldVon(root, 'lieferzeit').value, '2–4 Werktage')
assert.equal(feldVon(root, 'mindestbestellCent').value, '')
assert.equal(feldVon(root, 'ruecksendekosten').value, 'kaeufer')
assert.equal(fx.text(root).includes(ANBIETER_HINWEIS), true)
assert.equal(fx.text(root).includes(SEPA_WARNUNG), true)
assert.equal(fx.text(root).includes(STRIPE_HINWEIS), true)
assert.equal(fx.text(root).includes(PAYPAL_HINWEIS), true)
assert.match(SEPA_WARNUNG, /^Bei Lastschrift ist das Geld erst nach einigen Tagen sicher\. Versende erst, wenn die Bestellung als bezahlt markiert ist\.$/)
assert.match(fx.text(root), /Kleinunternehmer nach § 19 UStG/)
// Österreich und Abholung sind zu: ihre Felder sind versteckt
assert.equal(feldVon(root, 'versand.kostenCent.AT').closest('.sh-versand-at').hidden, true)
assert.equal(feldVon(root, 'abholung.adresse').closest('.sh-abhol-details').hidden, true)
}))
test('Zahlungsarten: Karte immer gesetzt und gesperrt, Klarna und Lastschrift frei wählbar', mitDom({ get: ANTWORT(STANDARD) }, async (fx) => {
const { root } = await oeffne(fx)
const karte = feldVon(root, 'zahlung.card')
assert.equal(karte.checked, true)
assert.equal(karte.disabled, true)
assert.match(fx.text(root), /inklusive Apple Pay und Google Pay/)
assert.equal(feldVon(root, 'zahlung.klarna').checked, false)
assert.equal(feldVon(root, 'zahlung.klarna').disabled, false)
assert.equal(feldVon(root, 'zahlung.sepa_debit').checked, false)
await fx.klick(karte)
assert.equal(karte.checked, true, 'ein Klick ändert nichts')
}))
test('gespeicherte Werte werden angezeigt, Österreich und Abholung sind sichtbar', mitDom({ get: ANTWORT(GEFUELLT) }, async (fx) => {
const { root } = await oeffne(fx)
assert.equal(feldVon(root, 'shopname').value, 'Mützenlädchen')
assert.equal(feldVon(root, 'akzent').value, '#ff8800')
assert.equal(feldVon(root, 'anbieter.plz').value, '67655')
assert.equal(feldVon(root, 'anbieter.ustId').value, 'DE123456789')
assert.equal(feldVon(root, 'kleinunternehmer').checked, false)
assert.equal(feldVon(root, 'versand.laender').value, 'DE,AT')
assert.equal(feldVon(root, 'versand.kostenCent.AT').value, '9,90')
assert.equal(feldVon(root, 'versand.kostenCent.AT').closest('.sh-versand-at').hidden, false)
assert.equal(feldVon(root, 'versand.freiAbCent').value, '50,00')
assert.equal(feldVon(root, 'abholung.aktiv').checked, true)
assert.equal(feldVon(root, 'abholung.adresse').value, 'Hinterhof 2\n67655 Kaiserslautern')
assert.equal(feldVon(root, 'abholung.adresse').closest('.sh-abhol-details').hidden, false)
assert.equal(feldVon(root, 'mindestbestellCent').value, '15,00')
assert.equal(feldVon(root, 'ruecksendekosten').value, 'haendler')
assert.equal(feldVon(root, 'zahlung.klarna').checked, true)
assert.equal(feldVon(root, 'zahlung.sepa_debit').checked, true)
}))
test('Schalter blenden Österreich, Versand- und Abholfelder ein und aus', mitDom({ get: ANTWORT(STANDARD) }, async (fx) => {
const { root } = await oeffne(fx)
const at = feldVon(root, 'versand.kostenCent.AT').closest('.sh-versand-at')
await fx.setze(feldVon(root, 'versand.laender'), 'DE,AT')
assert.equal(at.hidden, false)
await fx.setze(feldVon(root, 'versand.laender'), 'DE')
assert.equal(at.hidden, true)
const versandDetails = feldVon(root, 'versand.laender').closest('.sh-versand-details')
await fx.klick(feldVon(root, 'versand.aktiv'))
assert.equal(versandDetails.hidden, true)
const abhol = feldVon(root, 'abholung.adresse').closest('.sh-abhol-details')
await fx.klick(feldVon(root, 'abholung.aktiv'))
assert.equal(abhol.hidden, false)
}))
/* ------------------------------------------------------------- Speichern --- */
test('Speichern: Körper mit ganzen Cent, Version, Meldung und neuLaden; der Stand des Servers kommt zurück ins Formular', mitDom({
get: ANTWORT(STANDARD, 2),
put: (aufruf) => ({ body: ANTWORT({ ...aufruf.body.einstellungen, anbieter: { ...aufruf.body.einstellungen.anbieter, email: 'erika@example.com' } }, 3) }),
}, async (fx) => {
const { root, log } = await oeffne(fx)
await fx.setze(feldVon(root, 'shopname'), ' Mützenlädchen ')
await fx.setze(feldVon(root, 'akzent'), '#ff8800')
await fx.setze(feldVon(root, 'anbieter.name'), 'Erika Muster')
await fx.setze(feldVon(root, 'anbieter.strasse'), 'Hauptstraße 1')
await fx.setze(feldVon(root, 'anbieter.plz'), '67655')
await fx.setze(feldVon(root, 'anbieter.ort'), 'Kaiserslautern')
await fx.setze(feldVon(root, 'anbieter.email'), 'Erika@Example.com')
await fx.setze(feldVon(root, 'anbieter.ustId'), 'DE123456789')
await fx.klick(feldVon(root, 'kleinunternehmer'))
await fx.setze(feldVon(root, 'versand.laender'), 'DE,AT')
await fx.setze(feldVon(root, 'versand.kostenCent.DE'), '4,9')
await fx.setze(feldVon(root, 'versand.kostenCent.AT'), '9,90')
await fx.setze(feldVon(root, 'versand.freiAbCent'), '50')
await fx.klick(feldVon(root, 'abholung.aktiv'))
await fx.setze(feldVon(root, 'abholung.adresse'), 'Hinterhof 2')
await fx.setze(feldVon(root, 'abholung.hinweis'), 'Mo bis Fr')
await fx.setze(feldVon(root, 'lieferzeit'), '1–2 Werktage')
await fx.setze(feldVon(root, 'mindestbestellCent'), '15')
await fx.setze(feldVon(root, 'ruecksendekosten'), 'haendler')
await fx.klick(feldVon(root, 'zahlung.klarna'))
await fx.klick(feldVon(root, 'zahlung.sepa_debit'))
await fx.klick(speichernKnopf(root))
assert.equal(puts(fx).length, 1)
assert.deepEqual(puts(fx)[0].body, {
projectId: 'p1',
version: 2,
einstellungen: {
shopname: 'Mützenlädchen',
akzent: '#ff8800',
anbieter: { name: 'Erika Muster', strasse: 'Hauptstraße 1', plz: '67655', ort: 'Kaiserslautern', land: 'DE', email: 'Erika@Example.com', telefon: '', ustId: 'DE123456789' },
kleinunternehmer: false,
versand: { aktiv: true, laender: ['DE', 'AT'], kostenCent: { DE: 490, AT: 990 }, freiAbCent: 5000 },
abholung: { aktiv: true, adresse: 'Hinterhof 2', hinweis: 'Mo bis Fr' },
lieferzeit: '1–2 Werktage',
mindestbestellCent: 1500,
ruecksendekosten: 'haendler',
zahlungsarten: ['card', 'klarna', 'sepa_debit'],
},
})
assert.deepEqual(log.meldungen, [['Die Einstellungen sind gespeichert.', 'ok']])
assert.equal(log.neuGeladen, 1)
assert.equal(feldVon(root, 'anbieter.email').value, 'erika@example.com', 'normalisierter Stand des Servers')
// zweites Speichern schickt die neue Version
await fx.klick(speichernKnopf(root))
assert.equal(puts(fx)[1].body.version, 3)
}))
test('Speichern ohne Österreich schickt nur Deutschland; ohne Versand dürfen die Kosten leer sein', mitDom({ get: ANTWORT({ ...STANDARD, versand: { ...STANDARD.versand, aktiv: false }, abholung: { aktiv: true, adresse: 'Laden', hinweis: '' } }) }, async (fx) => {
const { root } = await oeffne(fx)
await fx.setze(feldVon(root, 'versand.kostenCent.DE'), '')
await fx.klick(speichernKnopf(root))
const e = puts(fx)[0].body.einstellungen
assert.deepEqual(e.versand, { aktiv: false, laender: ['DE'], kostenCent: { DE: null }, freiAbCent: null })
assert.deepEqual(e.zahlungsarten, ['card'])
assert.equal(e.mindestbestellCent, null)
}))
test('unlesbare oder fehlende Beträge werden im Browser gemeldet, es geht nichts an den Server', mitDom({ get: ANTWORT(STANDARD) }, async (fx) => {
const { root } = await oeffne(fx)
await fx.setze(feldVon(root, 'versand.kostenCent.DE'), 'viel')
await fx.setze(feldVon(root, 'mindestbestellCent'), '1,234')
await fx.klick(speichernKnopf(root))
assert.equal(puts(fx).length, 0)
assert.match(fehlerVon(root, 'versand.kostenCent.DE').textContent, /Betrag wie 4,90/)
assert.match(fehlerVon(root, 'mindestbestellCent').textContent, /Betrag wie 4,90/)
assert.equal(feldVon(root, 'versand.kostenCent.DE').getAttribute('aria-invalid'), 'true')
await fx.setze(feldVon(root, 'versand.kostenCent.DE'), '')
await fx.setze(feldVon(root, 'mindestbestellCent'), '')
await fx.klick(speichernKnopf(root))
assert.equal(puts(fx).length, 0)
assert.match(fehlerVon(root, 'versand.kostenCent.DE').textContent, /kostenlosen Versand trage 0,00 ein/)
assert.equal(fehlerVon(root, 'mindestbestellCent').textContent, '')
await fx.setze(feldVon(root, 'versand.kostenCent.DE'), '0')
await fx.klick(speichernKnopf(root))
assert.equal(puts(fx).length, 1)
assert.equal(puts(fx)[0].body.einstellungen.versand.kostenCent.DE, 0)
}))
test('Feldfehler des Servers stehen am Feld, Unbekanntes allgemein; der Knopf ist danach wieder frei', mitDom({
get: ANTWORT(STANDARD),
put: () => ({ status: 400, body: { error: 'Die Einstellungen sind nicht gültig.', fehler: { 'anbieter.plz': 'Bitte gib eine deutsche Postleitzahl mit 5 Ziffern an.', 'versand.aktiv': 'Bitte biete Versand oder Abholung an.', 'dings.unbekannt': 'Etwas stimmt nicht.' } } }),
}, async (fx) => {
const { root, log } = await oeffne(fx)
await fx.klick(speichernKnopf(root))
assert.equal(fehlerVon(root, 'anbieter.plz').textContent, 'Bitte gib eine deutsche Postleitzahl mit 5 Ziffern an.')
assert.equal(feldVon(root, 'anbieter.plz').getAttribute('aria-invalid'), 'true')
assert.equal(fehlerVon(root, 'versand.aktiv').textContent, 'Bitte biete Versand oder Abholung an.')
assert.equal(fehlerVon(root, '_').textContent, 'Etwas stimmt nicht.')
assert.equal(speichernKnopf(root).disabled, false)
assert.deepEqual(log.meldungen, [])
assert.equal(log.neuGeladen, 0)
}))
test('409 (Einstellungen veraltet): Hinweis mit „Neu laden“, das den neuen Stand holt', async () => {
let stand = ANTWORT(STANDARD, 1)
const fx = startFx({
get: () => stand,
put: () => ({ status: 409, body: { error: 'Die Einstellungen wurden inzwischen geändert. Bitte lade die Seite neu.', code: 'EINSTELLUNG_VERALTET' } }),
})
try {
const { root } = await oeffne(fx)
await fx.klick(speichernKnopf(root))
const konflikt = root.querySelector('[data-konflikt]')
assert.ok(konflikt)
assert.match(konflikt.textContent, /inzwischen geändert/)
assert.equal(fehlerVon(root, '_').textContent, '')
stand = ANTWORT({ ...STANDARD, shopname: 'Von anderer Seite' }, 2)
await fx.klick(root.querySelector('[data-aktion="neu-laden"]'))
await fx.ruhe()
assert.equal(feldVon(root, 'shopname').value, 'Von anderer Seite')
assert.equal(root.querySelector('[data-konflikt]'), null)
await fx.klick(speichernKnopf(root))
assert.equal(puts(fx).at(-1).body.version, 2)
} finally {
fx.restore()
}
})
test('Netzwerkfehler beim Speichern: Meldung allgemein, nichts gespeichert', mitDom({
get: ANTWORT(STANDARD),
put: () => ({ status: 500, body: { error: 'Der Server hat Probleme.' } }),
}, async (fx) => {
const { root, log } = await oeffne(fx)
await fx.klick(speichernKnopf(root))
assert.equal(fehlerVon(root, '_').textContent, 'Der Server hat Probleme.')
assert.deepEqual(log.meldungen, [])
}))
/* -------------------------------------------------------------- nur lesen --- */
test('nurLesen: alle Felder gesperrt, kein Speichern-Knopf, kein Versand', mitDom({ get: ANTWORT(GEFUELLT) }, async (fx) => {
const { root } = await oeffne(fx, { nurLesen: true })
const steuerungen = fx.alle(root, (el) => ['INPUT', 'SELECT', 'TEXTAREA'].includes(el.tagName))
assert.ok(steuerungen.length > 20)
assert.deepEqual(steuerungen.filter((el) => !el.disabled).map((el) => el.dataset.feld), [])
assert.equal(speichernKnopf(root), null)
assert.equal(fx.alle(root, (el) => el.tagName === 'BUTTON').filter((b) => b.getAttribute('type') === 'submit').length, 0)
assert.equal(feldVon(root, 'shopname').value, 'Mützenlädchen', 'Werte bleiben sichtbar')
// auch ein abgesendetes Formular schreibt nichts
await fx.sende(root.querySelector('form'), 'submit')
assert.equal(puts(fx).length, 0)
}))
test('Ladefehler wird geworfen (die Hülle zeigt ihn mit „Noch einmal versuchen“)', async () => {
const fx = installFakeDom({ antworten: [{ status: 500, body: { error: 'Kaputt.' } }] })
try {
const root = h('div')
await assert.rejects(render(root, ctxVon().ctx), /Kaputt\./)
} finally {
fx.restore()
}
})
/* ---------------------------------------------------------------- statisch --- */
test('statisch: kein innerHTML, outerHTML, insertAdjacentHTML, document.write und kein Import aus termine*.js', () => {
const quelle = readFileSync(new URL('../public/shop-tab-einstellungen.js', import.meta.url), 'utf8')
const ohneKommentare = quelle.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
assert.doesNotMatch(ohneKommentare, /innerHTML|outerHTML|insertAdjacentHTML|document\.write|confirm\(/)
assert.doesNotMatch(ohneKommentare, /from ['"]\.\/termine/)
})
+335
View File
@@ -0,0 +1,335 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { installFakeDom } from './support/fakeDom.js'
import { h, request } from '../public/shop-dom.js'
import { GEWAEHRLEISTUNG_NOTIZ, INTRO, NICHT_BESTAETIGT, render } from '../public/shop-tab-rechtliches.js'
// Task G6: Reiter „Rechtliches“ mit Fake-DOM und Fake-fetch. Die Antworten sind von Hand gebaut (Form: rechtstexteAntwort in server/routes/shop.js).
const BESTAETIGUNG = 'Ich habe die Texte geprüft. Ich bin als Verkäufer für Rechtstexte, Produktangaben und Preise verantwortlich. WEBklar stellt Muster bereit und berät nicht rechtlich.'
const MUSTER = { agb: 'MUSTER-AGB\nZeile zwei', widerrufsbelehrung: 'MUSTER-WIDERRUF', widerrufsformular: 'MUSTER-FORMULAR' }
const VOLL = {
agb: 'Meine AGB', widerrufsbelehrung: 'Meine Belehrung', widerrufsformular: 'Mein Formular',
impressumUrl: 'https://example.com/impressum', datenschutzUrl: 'https://example.com/datenschutz',
}
const LEER = { agb: '', widerrufsbelehrung: '', widerrufsformular: '', impressumUrl: '', datenschutzUrl: '' }
const antwort = (entwurf, extra = {}) => ({
entwurf,
bestaetigt: null,
muster: MUSTER,
versandUndZahlung: 'Versand nach Deutschland: 4,90 €.\nZahlung per Karte.',
gewaehrleistung: { bild: '/img/gewaehrleistung-eu.webp', text: 'GESETZLICHE GEWÄHRLEISTUNG\n\nMindestens zwei Jahre.' },
hash: 'hash-1',
bestaetigungText: BESTAETIGUNG,
vollstaendig: Object.values(entwurf).every((w) => w.trim() !== ''),
...extra,
})
const bestaetigt = (aktuell, extra = {}) => ({ texte: VOLL, at: '2026-10-05T12:30:00.000Z', von: 'Erika Muster', aktuell, ...extra })
/** Fake-Server: GET/PUT /api/shop/rechtstexte und POST …/bestaetigen aus `welt` (get(), put(aufruf), post(aufruf)). */
function startFx(welt) {
const route = (aufruf) => {
if (aufruf.method === 'GET' && aufruf.url.startsWith('/api/shop/rechtstexte?projectId=')) return { body: welt.get() }
if (aufruf.method === 'PUT' && aufruf.url === '/api/shop/rechtstexte') return welt.put(aufruf)
if (aufruf.method === 'POST' && aufruf.url === '/api/shop/rechtstexte/bestaetigen') return welt.post(aufruf)
return { status: 404, body: { error: 'unbekannt' } }
}
return installFakeDom({ antworten: Array.from({ length: 40 }, () => route) })
}
const mitDom = (welt, fn) => async () => {
const fx = startFx(welt)
try {
await fn(fx)
} finally {
fx.restore()
}
}
async function oeffne(fx, ctxExtra = {}) {
const root = h('div', { id: 'panel' })
fx.document.body.append(root)
const log = { meldungen: [], neuGeladen: 0 }
const ctx = {
projectId: 'p1',
nurLesen: false,
request,
zeigeMeldung: (text, art) => log.meldungen.push([text, art]),
neuLaden: async () => { log.neuGeladen += 1 },
...ctxExtra,
}
await render(root, ctx)
await fx.ruhe()
return { root, log }
}
const text = (root, key) => root.querySelector(`[data-text="${key}"]`)
const knopf = (root, aktion) => root.querySelector(`[data-aktion="${aktion}"]`)
const haken = (root) => root.querySelector('[data-feld="bestaetigung"]')
const sendet = (fx, art) => fx.fetchLog.filter((a) => a.method === art)
/* --------------------------------------------------------------- Anzeige --- */
test('Anzeige: Hinweis, Texte aus dem Entwurf, erzeugte Texte nur lesbar, Gewährleistungsbild mit Hinweis', mitDom({ get: () => antwort(VOLL) }, async (fx) => {
const { root } = await oeffne(fx)
assert.equal(fx.fetchLog[0].url, '/api/shop/rechtstexte?projectId=p1')
assert.equal(fx.text(root.querySelector('[data-hinweis="intro"]')), INTRO)
assert.match(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\.$/)
assert.equal(text(root, 'agb').value, 'Meine AGB')
assert.equal(text(root, 'widerrufsbelehrung').value, 'Meine Belehrung')
assert.equal(text(root, 'widerrufsformular').value, 'Mein Formular')
assert.equal(text(root, 'impressumUrl').value, 'https://example.com/impressum')
assert.equal(text(root, 'datenschutzUrl').value, 'https://example.com/datenschutz')
for (const titel of ['Versand und Zahlung', 'Hinweis zur gesetzlichen Gewährleistung', 'Muster-Widerrufsformular']) assert.ok(fx.text(root).includes(titel), titel)
const versand = root.querySelector('[data-lesetext="versand-zahlung"]')
assert.equal(versand.textContent, 'Versand nach Deutschland: 4,90 €.\nZahlung per Karte.')
assert.equal(versand.tagName === 'TEXTAREA' || versand.tagName === 'INPUT', false, 'nur lesbar, kein Eingabefeld')
const bild = root.querySelector('img')
assert.equal(bild.getAttribute('src'), '/img/gewaehrleistung-eu.webp')
assert.match(bild.getAttribute('alt'), /GESETZLICHE GEWÄHRLEISTUNG/)
assert.equal(root.querySelector('[data-lesetext="gewaehrleistung"]').textContent.startsWith('GESETZLICHE GEWÄHRLEISTUNG'), true)
assert.equal(fx.text(root).includes(GEWAEHRLEISTUNG_NOTIZ), true)
assert.match(GEWAEHRLEISTUNG_NOTIZ, /automatisch vor dem Bestell-Knopf/)
assert.equal(fx.alle(root, (el) => el.tagName === 'TEXTAREA').length, 3)
}))
test('ein Gewährleistungsbild von fremder Adresse wird nicht geladen', mitDom({ get: () => antwort(VOLL, { gewaehrleistung: { bild: '//boese.example/x.webp', text: 'Text' } }) }, async (fx) => {
const { root } = await oeffne(fx)
assert.equal(root.querySelector('img'), null)
}))
/* ------------------------------------------------------------ Status, Banner --- */
test('Status: noch nie bestätigt, aktuell bestätigt (Datum und Person) und Banner bei unbestätigten Änderungen', async () => {
const fall = [
[null, 'offen', /Noch nicht bestätigt/],
[bestaetigt(true), 'bestaetigt', /Bestätigt am 05\.10\.2026,? 14:30 Uhr von Erika Muster\./],
[bestaetigt(false), 'unbestaetigt', /Änderungen noch nicht bestätigt.*zuletzt bestätigt am 05\.10\.2026,? 14:30 Uhr von Erika Muster/],
]
for (const [b, art, muster] of fall) {
const fx = startFx({ get: () => antwort(VOLL, { bestaetigt: b }) })
try {
const { root } = await oeffne(fx)
const status = root.querySelector('[data-status]')
assert.equal(status.dataset.status, art)
assert.match(status.textContent, muster)
assert.equal(root.querySelector('[data-status="unbestaetigt"]') !== null, art === 'unbestaetigt')
// schon bestätigt und nichts geändert: kein Bestätigungsblock
assert.equal(root.querySelector('[data-block="bestaetigen"]').hidden, art === 'bestaetigt')
} finally {
fx.restore()
}
}
assert.equal(NICHT_BESTAETIGT, 'Änderungen noch nicht bestätigt')
})
/* -------------------------------------------------------- Muster einsetzen --- */
test('Muster einsetzen: leeres Feld wird sofort gefüllt, ein vorhandener Text erst nach Rückfrage', mitDom({ get: () => antwort({ ...LEER, widerrufsbelehrung: 'Mein eigener Text' }) }, async (fx) => {
const { root } = await oeffne(fx)
// leer: sofort
await fx.klick(knopf(root, 'muster-agb'))
assert.equal(text(root, 'agb').value, 'MUSTER-AGB\nZeile zwei')
assert.equal(root.querySelector('[data-frage-platz="agb"]').children.length, 0)
// vorhandener Text: Rückfrage, Abbrechen lässt ihn stehen
await fx.klick(knopf(root, 'muster-widerrufsbelehrung'))
const platz = root.querySelector('[data-frage-platz="widerrufsbelehrung"]')
assert.match(platz.textContent, /Widerrufsbelehrung durch das Muster ersetzen\?/)
assert.equal(text(root, 'widerrufsbelehrung').value, 'Mein eigener Text')
await fx.klick(knopf(root, 'muster-nein'))
assert.equal(text(root, 'widerrufsbelehrung').value, 'Mein eigener Text')
assert.equal(platz.children.length, 0)
// und mit „Ja“ wird ersetzt
await fx.klick(knopf(root, 'muster-widerrufsbelehrung'))
await fx.klick(knopf(root, 'muster-ja'))
assert.equal(text(root, 'widerrufsbelehrung').value, 'MUSTER-WIDERRUF')
assert.equal(platz.children.length, 0)
// Formular: leer ⇒ Muster
await fx.klick(knopf(root, 'muster-widerrufsformular'))
assert.equal(text(root, 'widerrufsformular').value, 'MUSTER-FORMULAR')
assert.equal(sendet(fx, 'PUT').length, 0, 'Muster einsetzen speichert nichts')
}))
test('Muster einsetzen: ist der Text schon das Muster, braucht es keine Rückfrage', mitDom({ get: () => antwort({ ...LEER, agb: 'MUSTER-AGB\nZeile zwei' }) }, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(knopf(root, 'muster-agb'))
assert.equal(root.querySelector('[data-frage-platz="agb"]').children.length, 0)
}))
/* ------------------------------------------------------------ Entwurf speichern --- */
test('Entwurf speichern: Körper mit fünf Feldern, Antwort füllt die Felder, Meldung', mitDom({
get: () => antwort(LEER),
put: (aufruf) => ({ body: antwort({ ...aufruf.body.rechtstexte, agb: 'Serverfassung der AGB' }, { hash: 'hash-2' }) }),
}, async (fx) => {
const { root, log } = await oeffne(fx)
await fx.setze(text(root, 'agb'), ' Meine AGB ')
await fx.setze(text(root, 'widerrufsbelehrung'), 'Meine Belehrung')
await fx.setze(text(root, 'widerrufsformular'), 'Mein Formular')
await fx.setze(text(root, 'impressumUrl'), 'https://example.com/impressum')
await fx.setze(text(root, 'datenschutzUrl'), 'https://example.com/datenschutz')
await fx.klick(knopf(root, 'entwurf-speichern'))
assert.deepEqual(sendet(fx, 'PUT').map((a) => a.body), [{ projectId: 'p1', rechtstexte: { ...VOLL, agb: 'Meine AGB' } }])
assert.equal(text(root, 'agb').value, 'Serverfassung der AGB')
assert.deepEqual(log.meldungen, [['Der Entwurf ist gespeichert.', 'ok']])
}))
test('Entwurf speichern: Feldfehler des Servers stehen am Feld', mitDom({
get: () => antwort(VOLL),
put: () => ({ status: 400, body: { error: 'Ungültig.', fehler: { impressumUrl: 'Impressum: Die Adresse muss mit https:// beginnen.', agb: 'Die AGB darf höchstens 30000 Zeichen lang sein.' } } }),
}, async (fx) => {
const { root, log } = await oeffne(fx)
await fx.setze(text(root, 'impressumUrl'), 'http://example.com/impressum')
await fx.klick(knopf(root, 'entwurf-speichern'))
assert.equal(root.querySelector('[data-fehler="impressumUrl"]').textContent, 'Impressum: Die Adresse muss mit https:// beginnen.')
assert.equal(text(root, 'impressumUrl').getAttribute('aria-invalid'), 'true')
assert.match(root.querySelector('[data-fehler="agb"]').textContent, /höchstens 30000/)
assert.deepEqual(log.meldungen, [])
assert.equal(knopf(root, 'entwurf-speichern').disabled, false)
}))
/* ---------------------------------------------------------------- Bestätigen --- */
test('Bestätigen: Haken nötig, dann POST mit dem Hash aus der letzten Antwort; danach Status bestätigt', async () => {
let aktuell = antwort(VOLL, { hash: 'hash-abc' })
const fx = startFx({
get: () => aktuell,
post: () => {
aktuell = antwort(VOLL, { hash: 'hash-abc', bestaetigt: bestaetigt(true) })
return { body: aktuell }
},
})
try {
const { root, log } = await oeffne(fx)
assert.equal(root.querySelector('[data-block="bestaetigen"]').textContent.includes(BESTAETIGUNG), true)
assert.equal(knopf(root, 'bestaetigen').textContent, 'Texte bestätigen')
assert.equal(knopf(root, 'bestaetigen').disabled, true, 'ohne Haken gesperrt')
await fx.klick(knopf(root, 'bestaetigen'))
assert.equal(sendet(fx, 'POST').length, 0)
await fx.klick(haken(root))
assert.equal(knopf(root, 'bestaetigen').disabled, false)
await fx.klick(knopf(root, 'bestaetigen'))
assert.deepEqual(sendet(fx, 'POST').map((a) => [a.url, a.body]), [['/api/shop/rechtstexte/bestaetigen', { projectId: 'p1', hash: 'hash-abc' }]])
assert.equal(root.querySelector('[data-status]').dataset.status, 'bestaetigt')
assert.equal(root.querySelector('[data-block="bestaetigen"]').hidden, true)
assert.deepEqual(log.meldungen, [['Die Texte sind bestätigt.', 'ok']])
assert.equal(log.neuGeladen, 1)
} finally {
fx.restore()
}
})
test('ungespeicherte Änderungen sperren das Bestätigen: „Erst speichern“, nach dem Speichern gilt der neue Hash', async () => {
const fx = startFx({
get: () => antwort(VOLL, { hash: 'alt', bestaetigt: bestaetigt(true) }),
put: (aufruf) => ({ body: antwort(aufruf.body.rechtstexte, { hash: 'neu', bestaetigt: bestaetigt(false) }) }),
post: () => ({ body: antwort(VOLL, { hash: 'neu', bestaetigt: bestaetigt(true) }) }),
})
try {
const { root } = await oeffne(fx)
assert.equal(root.querySelector('[data-block="bestaetigen"]').hidden, true)
await fx.setze(text(root, 'agb'), 'Geänderte AGB')
assert.equal(root.querySelector('[data-block="bestaetigen"]').hidden, false)
assert.equal(knopf(root, 'bestaetigen').textContent, 'Erst speichern')
assert.equal(knopf(root, 'bestaetigen').disabled, true)
assert.match(root.querySelector('[data-hinweis="bestaetigen"]').textContent, /noch nicht gespeichert/)
await fx.klick(haken(root))
await fx.klick(knopf(root, 'bestaetigen'))
assert.equal(sendet(fx, 'POST').length, 0)
// zurückändern: wieder sauber
await fx.setze(text(root, 'agb'), 'Meine AGB')
assert.equal(knopf(root, 'bestaetigen').textContent, 'Texte bestätigen')
await fx.setze(text(root, 'agb'), 'Geänderte AGB')
await fx.klick(knopf(root, 'entwurf-speichern'))
assert.equal(knopf(root, 'bestaetigen').textContent, 'Texte bestätigen')
assert.equal(root.querySelector('[data-status]').dataset.status, 'unbestaetigt')
assert.equal(haken(root).checked, false, 'nach neuen Texten muss der Haken neu gesetzt werden')
assert.equal(knopf(root, 'bestaetigen').disabled, true)
await fx.klick(haken(root))
await fx.klick(knopf(root, 'bestaetigen'))
assert.deepEqual(sendet(fx, 'POST').map((a) => a.body), [{ projectId: 'p1', hash: 'neu' }])
} finally {
fx.restore()
}
})
test('unvollständige Texte lassen sich nicht bestätigen und sagen warum', mitDom({ get: () => antwort({ ...VOLL, datenschutzUrl: '' }) }, async (fx) => {
const { root } = await oeffne(fx)
await fx.klick(haken(root))
assert.equal(knopf(root, 'bestaetigen').disabled, true)
assert.match(root.querySelector('[data-hinweis="bestaetigen"]').textContent, /ausgefüllt und gespeichert/)
await fx.klick(knopf(root, 'bestaetigen'))
assert.equal(sendet(fx, 'POST').length, 0)
}))
test('409 beim Bestätigen (Texte inzwischen geändert): Meldung, neuer Stand, Haken wieder frei', async () => {
let stand = antwort(VOLL, { hash: 'alt' })
const fx = startFx({
get: () => stand,
post: () => {
stand = antwort({ ...VOLL, agb: 'Von anderer Seite geändert' }, { hash: 'neu' })
return { status: 409, body: { error: 'Die Texte wurden inzwischen geändert. Bitte lies sie noch einmal durch und bestätige dann erneut.', code: 'texte_geaendert' } }
},
})
try {
const { root } = await oeffne(fx)
await fx.klick(haken(root))
await fx.klick(knopf(root, 'bestaetigen'))
assert.match(root.querySelector('[data-fehler="bestaetigung"]').textContent, /inzwischen geändert/)
assert.equal(text(root, 'agb').value, 'Von anderer Seite geändert')
assert.equal(haken(root).checked, false)
await fx.klick(haken(root))
await fx.klick(knopf(root, 'bestaetigen'))
assert.equal(sendet(fx, 'POST').at(-1).body.hash, 'neu')
} finally {
fx.restore()
}
})
test('Fehler beim Bestätigen (zum Beispiel Kundenansicht, 403) steht am Block', mitDom({
get: () => antwort(VOLL),
post: () => ({ status: 403, body: { error: 'In der Kundenansicht kannst Du nichts bestätigen.' } }),
}, async (fx) => {
const { root, log } = await oeffne(fx)
await fx.klick(haken(root))
await fx.klick(knopf(root, 'bestaetigen'))
assert.equal(root.querySelector('[data-fehler="bestaetigung"]').textContent, 'In der Kundenansicht kannst Du nichts bestätigen.')
assert.deepEqual(log.meldungen, [])
assert.equal(sendet(fx, 'GET').length, 1, 'bei 403 wird nicht neu geladen')
}))
/* -------------------------------------------------------------- nur lesen --- */
test('nurLesen: Felder gesperrt, keine Knöpfe, kein Bestätigungsblock; Status und Lesetexte bleiben', mitDom({ get: () => antwort(VOLL, { bestaetigt: bestaetigt(false) }) }, async (fx) => {
const { root } = await oeffne(fx, { nurLesen: true })
const steuerungen = fx.alle(root, (el) => ['INPUT', 'SELECT', 'TEXTAREA'].includes(el.tagName))
assert.equal(steuerungen.length, 5)
assert.deepEqual(steuerungen.filter((el) => !el.disabled), [])
assert.equal(fx.alle(root, (el) => el.tagName === 'BUTTON').length, 0)
assert.equal(haken(root), null)
assert.equal(text(root, 'agb').value, 'Meine AGB')
assert.ok(root.querySelector('[data-status="unbestaetigt"]'))
assert.equal(root.querySelector('[data-lesetext="versand-zahlung"]').textContent.includes('Zahlung per Karte'), true)
await fx.sende(root.querySelector('form'), 'submit')
assert.equal(sendet(fx, 'PUT').length, 0)
}))
test('Ladefehler wird geworfen', async () => {
const fx = installFakeDom({ antworten: [{ status: 500, body: { error: 'Kaputt.' } }] })
try {
await assert.rejects(render(h('div'), { projectId: 'p1', request }), /Kaputt\./)
} finally {
fx.restore()
}
})
/* ---------------------------------------------------------------- statisch --- */
test('statisch: kein innerHTML, outerHTML, insertAdjacentHTML, document.write, confirm() und kein Import aus termine*.js', () => {
const quelle = readFileSync(new URL('../public/shop-tab-rechtliches.js', import.meta.url), 'utf8')
const ohneKommentare = quelle.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
assert.doesNotMatch(ohneKommentare, /innerHTML|outerHTML|insertAdjacentHTML|document\.write|confirm\(/)
assert.doesNotMatch(ohneKommentare, /from ['"]\.\/termine/)
})