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).
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)
}
+221 -5
View File
@@ -1,8 +1,224 @@
/* Webshop, Reiter „Rechtliches“: Platzhalter, bis der Reiter gebaut ist (Plan Block G, Tasks G3 bis G6 ersetzen diese Datei).
render(panel, ctx): ctx = { projectId, status, nurLesen, stripeRueckkehr, neuLaden(), zeigeMeldung(text, art), request } (siehe shop.js). */
/* Webshop, Reiter „Rechtliches“ (Plan Block G, Task G6): die Rechtstexte des Haendlers. Er ist Verkaeufer und verantwortlich; WEBklar liefert
Muster und keine Rechtsberatung.
Schnittstelle (server/routes/shop.js): GET /api/shop/rechtstexte?projectId= => { entwurf, bestaetigt: { texte, at, von, aktuell } | null,
muster: { agb, widerrufsbelehrung, widerrufsformular }, versandUndZahlung, gewaehrleistung: { bild, text }, hash, bestaetigungText, vollstaendig };
PUT /api/shop/rechtstexte { projectId, rechtstexte } speichert den Entwurf (Antwort wie GET, Feldfehler als err.fehler);
POST /api/shop/rechtstexte/bestaetigen { projectId, hash } bestaetigt den gespeicherten Entwurf (409 texte_geaendert, 400 unvollstaendig,
403 in der Kundenansicht). Der Hash kommt aus der letzten Antwort von GET oder PUT; bestaetigt wird nur, was gespeichert ist: bei ungespeicherten
Aenderungen heisst der Knopf „Erst speichern“. Ersetzen eines vorhandenen Textes durch das Muster fragt vorher in der Seite (kein confirm()).
render(panel, ctx): ctx = { projectId, nurLesen, zeigeMeldung(text, art), neuLaden(), request } (siehe shop.js).
Text kommt nur ueber textContent in die Seite (h), nie ueber innerHTML. */
import { h } from './shop-dom.js'
import { abschnitt, allgemeinerFehler, datumText, feld, frage, h, request, setzeFehler, zeigeFehler } from './shop-dom.js'
export function render(panel) {
panel.replaceChildren(h('p', { class: 'sh-hinweis' }, 'Kommt gleich …'))
export const INTRO = 'Du bist Verkäufer und für deine Rechtstexte verantwortlich. Wir stellen Muster bereit, die du prüfen und anpassen musst – das ist keine Rechtsberatung. Lass deine Texte am besten von einer Kanzlei oder einem Händlerbund prüfen.'
export const NICHT_BESTAETIGT = 'Änderungen noch nicht bestätigt'
export const GEWAEHRLEISTUNG_NOTIZ = 'Dieser Hinweis wird in deinem Shop automatisch vor dem Bestell-Knopf angezeigt. Du musst nichts dafür tun.'
const TEXTE = [
{ key: 'agb', label: 'AGB', zeilen: 14, max: 30000, hinweis: 'Das Muster ist für kleine Shops gedacht. Prüfe es und passe es an dein Angebot an.' },
{ key: 'widerrufsbelehrung', label: 'Widerrufsbelehrung', zeilen: 14, max: 20000, hinweis: 'Das Muster nutzt deine Angaben aus den Einstellungen (Anbieter, Rücksendekosten). Prüfe sie dort, bevor du das Muster einsetzt.' },
{ key: 'widerrufsformular', label: 'Muster-Widerrufsformular', zeilen: 8, max: 5000, hinweis: null },
]
const ADRESSEN = [
{ key: 'impressumUrl', label: 'Adresse deines Impressums', platzhalter: 'https://www.deine-website.de/impressum' },
{ key: 'datenschutzUrl', label: 'Adresse deiner Datenschutzerklärung', platzhalter: 'https://www.deine-website.de/datenschutz' },
]
const text = (wert) => (typeof wert === 'string' ? wert : '')
const normal = (wert) => text(wert).replace(/\r\n?/g, '\n').trim()
/** Nur Adressen auf dem eigenen Server (kein //host, kein Schema) als Bildquelle. */
const eigenerPfad = (pfad) => (typeof pfad === 'string' && /^\/(?!\/)[\w./-]*$/.test(pfad) ? pfad : '')
export async function render(panel, ctx = {}) {
const projectId = ctx.projectId
const aufruf = ctx.request || request
const nurLesen = ctx.nurLesen === true
let daten = await aufruf(`/api/shop/rechtstexte?projectId=${encodeURIComponent(projectId)}`)
/* ----------------------------------------------------------------- Felder --- */
const felder = {}
const platzFragen = {}
const musterKnoepfe = []
const bereiche = TEXTE.map(({ key, label, zeilen, max, hinweis }) => {
const area = h('textarea', { rows: zeilen, maxlength: max, 'data-text': key })
felder[key] = area
platzFragen[key] = h('div', { 'data-frage-platz': key })
const muster = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': `muster-${key}` }, 'Muster einsetzen')
muster.addEventListener('click', () => musterEinsetzen(key, label, muster))
musterKnoepfe.push(muster)
return h('div', null,
feld(key, label, area, hinweis),
nurLesen ? null : h('div', { class: 'sh-aktionen' }, muster),
platzFragen[key])
})
const adressFelder = ADRESSEN.map(({ key, label, platzhalter }) => {
const eingabe = h('input', { type: 'url', inputmode: 'url', autocomplete: 'off', maxlength: 500, placeholder: platzhalter, 'data-text': key })
felder[key] = eingabe
return feld(key, label, eingabe)
})
function fuelle(entwurf) {
const e = entwurf && typeof entwurf === 'object' ? entwurf : {}
for (const key of [...TEXTE, ...ADRESSEN].map((t) => t.key)) felder[key].value = text(e[key])
}
fuelle(daten.entwurf)
/** Ungespeicherte Aenderungen: Eingaben gegen den Entwurf der letzten Antwort. */
const geaendert = () => [...TEXTE, ...ADRESSEN].some(({ key }) => normal(felder[key].value) !== normal(daten.entwurf?.[key]))
/* ----------------------------------------------------------- Muster einsetzen --- */
function musterEinsetzen(key, label, knopf) {
const muster = text(daten.muster?.[key])
const area = felder[key]
const setze = () => {
area.value = muster
platzFragen[key].replaceChildren()
zeichne()
area.focus()
}
// nichts zu verlieren: leer oder schon das Muster
if (normal(area.value) === '' || normal(area.value) === normal(muster)) {
setze()
return
}
const ja = h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'muster-ja' }, 'Ja, Muster einsetzen')
const nein = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'muster-nein' }, 'Abbrechen')
const box = frage(`${label} durch das Muster ersetzen?`,
h('p', { class: 'sh-hinweis' }, 'Dein bisheriger Text wird überschrieben. Du kannst ihn danach nur zurückholen, indem du die Seite neu lädst, bevor du speicherst.'),
h('div', { class: 'sh-aktionen' }, ja, nein))
ja.addEventListener('click', setze)
nein.addEventListener('click', () => { platzFragen[key].replaceChildren(); knopf.focus() })
platzFragen[key].replaceChildren(box)
ja.focus()
}
/* ----------------------------------------------------------------- Lesetexte --- */
const lesetext = (inhalt, art) => h('div', { class: 'sh-begruendung', 'data-lesetext': art }, text(inhalt))
const bild = eigenerPfad(daten.gewaehrleistung?.bild)
const gewaehrleistung = abschnitt('Hinweis zur gesetzlichen Gewährleistung', GEWAEHRLEISTUNG_NOTIZ,
bild ? h('img', { class: 'sh-gewaehrleistung-bild', src: bild, alt: text(daten.gewaehrleistung?.text), loading: 'lazy', 'data-lesetext': 'gewaehrleistung-bild' }) : null,
lesetext(daten.gewaehrleistung?.text, 'gewaehrleistung'))
const versandZahlung = abschnitt('Versand und Zahlung', 'Dieser Text wird aus deinen Einstellungen erzeugt, damit er stimmt. Du kannst ihn hier nicht ändern; ändere die Einstellungen, wenn etwas nicht passt.',
lesetext(daten.versandUndZahlung, 'versand-zahlung'))
/* ------------------------------------------------------------ Status, Bestaetigen --- */
const statusPlatz = h('div', { 'data-status-platz': '' })
const hinweisBest = h('p', { class: 'sh-hinweis', 'data-hinweis': 'bestaetigen' })
const haken = h('input', { type: 'checkbox', 'data-feld': 'bestaetigung' })
const bestaetigen = h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'bestaetigen' }, 'Texte bestätigen')
const fehlerBest = h('p', { class: 'sh-fehler', 'data-fehler': 'bestaetigung', role: 'alert' })
const bestBlock = h('section', { class: 'kb-panel sh-abschnitt', 'data-block': 'bestaetigen' },
h('h3', null, 'Texte bestätigen'),
h('label', { class: 'sh-haken' }, haken, h('span', null, text(daten.bestaetigungText))),
hinweisBest,
fehlerBest,
h('div', { class: 'sh-aktionen' }, bestaetigen))
/** Status, Banner und Bestaetigungsblock nach dem Stand von daten und den Eingaben zeichnen. */
function zeichne() {
const b = daten.bestaetigt
const teile = []
if (b) {
const wann = datumText(b.at)
const wer = text(b.von)
const wo = `${wann ? ` am ${wann} Uhr` : ''}${wer ? ` von ${wer}` : ''}`
if (b.aktuell) {
teile.push(h('p', { class: 'sh-zustand sh-zustand-offen', 'data-status': 'bestaetigt' }, `Bestätigt${wo}.`))
} else {
teile.push(h('div', { class: 'sh-box sh-box-warn', role: 'note', 'data-status': 'unbestaetigt' },
h('span', null, h('strong', null, NICHT_BESTAETIGT), ` – zuletzt bestätigt${wo}. Neue Bestellungen laufen mit dieser Fassung weiter, bis du die geänderten Texte bestätigst.`)))
}
} else {
teile.push(h('p', { class: 'sh-zustand sh-zustand-zu', 'data-status': 'offen' }, 'Noch nicht bestätigt. Ohne bestätigte Texte kann dein Shop nicht öffnen.'))
}
statusPlatz.replaceChildren(...teile)
const schmutzig = geaendert()
const bereitZumBestaetigen = !schmutzig && daten.vollstaendig === true
// schon bestaetigt und nichts geaendert: nichts zu tun
bestBlock.hidden = Boolean(b?.aktuell) && !schmutzig
bestaetigen.textContent = schmutzig ? 'Erst speichern' : 'Texte bestätigen'
bestaetigen.disabled = !bereitZumBestaetigen || !haken.checked
hinweisBest.textContent = schmutzig
? 'Du hast Änderungen, die noch nicht gespeichert sind. Speichere zuerst den Entwurf, dann kannst du ihn bestätigen.'
: daten.vollstaendig === true
? ''
: 'Zum Bestätigen müssen AGB, Widerrufsbelehrung, Muster-Widerrufsformular sowie die Adressen von Impressum und Datenschutz ausgefüllt und gespeichert sein.'
}
/* ------------------------------------------------------------------- Aufbau --- */
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'entwurf-speichern' }, 'Entwurf speichern')
const form = h('form', { class: 'sh-form sh-rechtliches', novalidate: true, 'aria-label': 'Rechtstexte des Webshops' },
abschnitt('Deine Texte', null, ...bereiche),
abschnitt('Impressum und Datenschutz', 'Diese beiden Seiten gehören zu deiner Website. Der Shop verlinkt sie in der Fußzeile und vor der Bestellung.', ...adressFelder),
allgemeinerFehler(),
nurLesen ? null : h('div', { class: 'sh-aktionen' }, speichern))
const intro = h('div', { class: 'sh-box sh-box-info', role: 'note', 'data-hinweis': 'intro' }, INTRO)
panel.replaceChildren(...[intro, statusPlatz, form, versandZahlung, gewaehrleistung, nurLesen ? null : bestBlock].filter(Boolean))
for (const el of Object.values(felder)) el.addEventListener('input', zeichne)
haken.addEventListener('change', zeichne)
/* ---------------------------------------------------------------- Speichern --- */
let laeuft = false
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
if (nurLesen || laeuft) return
laeuft = true
speichern.disabled = true
setzeFehler(form, {})
try {
const rechtstexte = Object.fromEntries([...TEXTE, ...ADRESSEN].map(({ key }) => [key, felder[key].value.trim()]))
daten = await aufruf('/api/shop/rechtstexte', { method: 'PUT', body: { projectId, rechtstexte } })
fuelle(daten.entwurf)
haken.checked = false
zeichne()
if (typeof ctx.zeigeMeldung === 'function') ctx.zeigeMeldung('Der Entwurf ist gespeichert.', 'ok')
} catch (err) {
zeigeFehler(form, err)
} finally {
laeuft = false
speichern.disabled = false
}
})
/* --------------------------------------------------------------- Bestaetigen --- */
let bestaetigtLaeuft = false
bestaetigen.addEventListener('click', async () => {
if (nurLesen || bestaetigtLaeuft || bestaetigen.disabled) return
bestaetigtLaeuft = true
bestaetigen.disabled = true
fehlerBest.textContent = ''
try {
daten = await aufruf('/api/shop/rechtstexte/bestaetigen', { method: 'POST', body: { projectId, hash: daten.hash } })
haken.checked = false
zeichne()
if (typeof ctx.zeigeMeldung === 'function') ctx.zeigeMeldung('Die Texte sind bestätigt.', 'ok')
if (typeof ctx.neuLaden === 'function') {
try { await ctx.neuLaden() } catch { /* bestaetigt ist bestaetigt */ }
}
} catch (err) {
fehlerBest.textContent = err.message
// 409: die Texte haben sich inzwischen geaendert; der neue Stand wird geholt, ungespeicherte Eingaben gibt es hier nicht (sie sperren den Knopf)
if (err?.status === 409) {
try {
const neu = await aufruf(`/api/shop/rechtstexte?projectId=${encodeURIComponent(projectId)}`)
daten = neu
fuelle(daten.entwurf)
haken.checked = false
} catch { /* die Meldung bleibt stehen */ }
}
zeichne()
} finally {
bestaetigtLaeuft = false
}
})
if (nurLesen) {
for (const el of form.querySelectorAll('input, select, textarea, button')) el.disabled = true
for (const el of panel.querySelectorAll('button')) el.disabled = true
}
zeichne()
}
+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 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; }