Files
Webklar-Kundenbereich/public/shop-tab-einstellungen.js
ClaudeandClaude Opus 5.5 1f56b86791 Shop Review B I-4: Steuerart und Lieferzeit ohne Vorbelegung
kleinunternehmer ist ab jetzt null (nicht gewaehlt), lieferzeit leer. istVollstaendig verlangt eine
bewusste Wahl (true oder false), der Shop kann sonst nicht geoeffnet werden. Im Reiter
Einstellungen gibt es zwei Pflicht-Radiobuttons ohne Vorauswahl; Preise, Artikel und Muster
rechnen bei null wie bei Regelbesteuerung. Tests und Fixtures angepasst.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 09:50:54 +00:00

266 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 { abschnitt, allgemeinerFehler, centZuEuro, euroEingabe, feld, h, hakenFeld, request, setzeFehler, zeigeFehler } from './shop-dom.js'
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' })
// Steuerart: keine Vorauswahl, der Haendler muss sich entscheiden (null = noch nicht gewaehlt)
const steuerJa = h('input', { type: 'radio', name: 'sh-kleinunternehmer', value: 'ja', required: true })
const steuerNein = h('input', { type: 'radio', name: 'sh-kleinunternehmer', value: 'nein', required: true })
steuerJa.dataset.feld = 'kleinunternehmer'
steuerNein.dataset.feld = 'kleinunternehmer'
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)
steuerJa.checked = s.kleinunternehmer === true
steuerNein.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', 'Pflichtangabe: Bitte wähle, wie bei dir die Umsatzsteuer behandelt wird. Ohne diese Angabe kannst du den Shop nicht öffnen.',
h('fieldset', { class: 'sh-feld sh-radiogruppe', 'aria-required': 'true' },
h('legend', null, 'Umsatzsteuer'),
h('label', { class: 'sh-haken' }, steuerJa, h('span', null, 'Ich bin Kleinunternehmer nach § 19 UStG', h('small', { class: 'sh-hinweis' }, 'Dann steht im Shop der Hinweis, dass keine Umsatzsteuer berechnet wird.'))),
h('label', { class: 'sh-haken' }, steuerNein, h('span', null, 'Ich weise Umsatzsteuer aus', h('small', { class: 'sh-hinweis' }, 'Dann gelten deine Preise inklusive Umsatzsteuer; den Steuersatz wählst du je Artikel.'))),
h('p', { class: 'sh-fehler', 'data-fehler': 'kleinunternehmer', role: 'alert' }))),
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: steuerJa.checked ? true : steuerNein.checked ? false : null,
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)
}