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>
266 lines
15 KiB
JavaScript
266 lines
15 KiB
JavaScript
/* 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)
|
||
}
|