diff --git a/public/shop-tab-einstellungen.js b/public/shop-tab-einstellungen.js index 1e5f3c4..c9fbdc0 100644 --- a/public/shop-tab-einstellungen.js +++ b/public/shop-tab-einstellungen.js @@ -1,8 +1,256 @@ -/* Webshop, Reiter „Einstellungen“: 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 „Einstellungen“ (Plan Block G, Task G6): Shopname und Akzentfarbe, Anbieter, Steuer, Versand, Abholung, Lieferzeit, + 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) { - panel.replaceChildren(h('p', { class: 'sh-hinweis' }, 'Kommt gleich …')) +const LAENDER = [['DE', 'Deutschland'], ['AT', 'Österreich']] +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) } diff --git a/public/shop-tab-rechtliches.js b/public/shop-tab-rechtliches.js index e51a402..b27c30c 100644 --- a/public/shop-tab-rechtliches.js +++ b/public/shop-tab-rechtliches.js @@ -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() } diff --git a/public/shop.css b/public/shop.css index cc460be..286b25f 100644 --- a/public/shop.css +++ b/public/shop.css @@ -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 code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } 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; } diff --git a/test/shopTabEinstellungen.test.js b/test/shopTabEinstellungen.test.js new file mode 100644 index 0000000..1f62b54 --- /dev/null +++ b/test/shopTabEinstellungen.test.js @@ -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/) +}) diff --git a/test/shopTabRechtliches.test.js b/test/shopTabRechtliches.test.js new file mode 100644 index 0000000..692dfd9 --- /dev/null +++ b/test/shopTabRechtliches.test.js @@ -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/) +})