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:
1 parent
3ed96a5838
commit
648d0e07fe
5 files changed
+1148
-10
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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; }
|
||||
Reference in new issue
Block a user