Der Editor sendet neben bestand den beim Laden gesehenen Wert (bestandGeladen, je Variantenwert mit id). speichereProdukt behaelt unter der Artikelsperre den aktuell gespeicherten Stand, wenn gesendet gleich geladen ist, und schreibt nur vom Haendler geaenderte Bestaende. bestandGeladen wird nie gespeichert; ohne den Wert (alter Browser) gilt der gesendete Bestand wie bisher. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
571 lines
31 KiB
JavaScript
571 lines
31 KiB
JavaScript
/* Webshop, Reiter „Artikel“ (Plan 2026-10-05-webshop.md, Task G4; Spec 4.3). render(panel, ctx): ctx = { projectId, nurLesen, aktuell(), zeigeMeldung(text, art), request } (shop.js).
|
|
Zwei Ansichten im selben Reiter: die Liste (Name, Preis, Bestand, Schalter „aktiv“, Hoch/Runter, Bearbeiten, Loeschen) und der Editor (alle Felder des Artikels,
|
|
Varianten, Bilder mit Upload, Produktsicherheit, Lebensmittel). Server: GET/POST /api/shop/produkte, PUT/DELETE /api/shop/produkte/:id, POST /api/shop/produkte/sortierung,
|
|
POST /api/shop/bild (rohe Bytes), Vorschau GET /api/shop/bild/:projectId/<id>-s.webp, GET /api/shop/einstellungen (Kleinunternehmer). Der Server prueft alles; der Editor
|
|
gibt Feldfehler (auch mit Punkt-Pfad wie 'varianten.werte.2.preisCent') am Feld aus (data-fehler = Pfad). Fremdtext kommt nur ueber textContent in die Seite (h), nie ueber innerHTML. */
|
|
|
|
import { abschnitt, centZuEuro, euroEingabe, euroText, euroZuCent, feld, fehlerKarte, frage, h, hakenFeld, request, setzeFehler, zeigeFehler } from './shop-dom.js'
|
|
|
|
const MAX_WERTE = 30
|
|
const BILD_MAX_BYTES = 8 * 1024 * 1024
|
|
const BILD_TYPEN = ['image/jpeg', 'image/png', 'image/webp']
|
|
const EINHEITEN = [['g', 'g'], ['kg', 'kg'], ['ml', 'ml'], ['l', 'l'], ['m', 'm'], ['m2', 'm²']]
|
|
const STEUERSAETZE = [19, 7, 0]
|
|
const LIEFERARTEN = [['beides', 'Versand und Abholung'], ['abholung', 'Nur Abholung']]
|
|
const WIDERRUFE = [
|
|
['gesetzlich', 'Gesetzliches Widerrufsrecht (Standard)'],
|
|
['verderblich', 'Ausgeschlossen: schnell verderbliche Ware'],
|
|
['individuell', 'Ausgeschlossen: nach Kundenwunsch angefertigt'],
|
|
['hygiene', 'Ausgeschlossen: versiegelt, aus Hygienegründen'],
|
|
]
|
|
const BILD_FEHLER = {
|
|
413: 'Das Bild ist größer als 8 MB.',
|
|
415: 'Dieses Bild kann nicht verwendet werden. Bitte nimm eine JPEG-, PNG- oder WebP-Datei.',
|
|
}
|
|
|
|
const text = (wert) => (typeof wert === 'string' ? wert : '')
|
|
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
|
|
let zaehler = 0
|
|
|
|
/** Ganze Zahl aus dem Feld: leer = null, nur Ziffern = Zahl, sonst der Text (der Server meldet den Fehler). */
|
|
const ganzeZahl = (eingabe) => {
|
|
const t = String(eingabe ?? '').trim()
|
|
if (t === '') return null
|
|
return /^\d{1,12}$/.test(t) ? Number(t) : t
|
|
}
|
|
|
|
const waehle = (paare, aktuell) => {
|
|
const sel = h('select', null, paare.map(([wert, label]) => h('option', { value: String(wert) }, label)))
|
|
sel.value = String(aktuell)
|
|
return sel
|
|
}
|
|
|
|
const kurzesFeld = (wert, extra) => {
|
|
const el = h('input', { type: 'text', autocomplete: 'off', ...extra })
|
|
el.value = wert
|
|
return el
|
|
}
|
|
|
|
const langesFeld = (wert, zeilen = 4) => {
|
|
const el = h('textarea', { rows: zeilen })
|
|
el.value = wert
|
|
return el
|
|
}
|
|
|
|
/** Einklappbarer Abschnitt (details); offen = beim Zeichnen aufgeklappt. */
|
|
const einklappbar = (titel, hinweis, offen, ...inhalt) => h('details', { class: 'kb-panel sh-abschnitt sh-einklappbar', open: offen },
|
|
h('summary', null, h('strong', null, titel)),
|
|
hinweis ? h('p', { class: 'sh-hinweis sh-abschnitt-text' }, hinweis) : null,
|
|
...inhalt)
|
|
|
|
/** Rohe Bytes hochladen (POST /api/shop/bild); Fehler tragen status und einen deutschen Text. */
|
|
async function ladeBildHoch(projectId, datei) {
|
|
let response
|
|
try {
|
|
response = await fetch(`/api/shop/bild?projectId=${encodeURIComponent(projectId)}`, {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: { 'Content-Type': 'application/octet-stream' },
|
|
body: datei,
|
|
})
|
|
} catch {
|
|
throw Object.assign(new Error('Keine Verbindung. Bitte versuch es gleich noch einmal.'), { status: 0 })
|
|
}
|
|
const data = await response.json().catch(() => ({}))
|
|
if (!response.ok) {
|
|
throw Object.assign(new Error(text(data?.error) || BILD_FEHLER[response.status] || `Fehler ${response.status}`), { status: response.status })
|
|
}
|
|
return data
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ Anzeige --- */
|
|
/** Preis fuer die Liste: bei Varianten „ab …“ (oder der eine Preis), sonst der Preis des Artikels. */
|
|
function preisAnzeige(p) {
|
|
const werte = Array.isArray(p?.varianten?.werte) ? p.varianten.werte : []
|
|
if (werte.length > 0) {
|
|
const preise = werte.map((w) => w.preisCent).filter(Number.isInteger)
|
|
if (preise.length === 0) return ''
|
|
const min = Math.min(...preise)
|
|
return min === Math.max(...preise) ? euroText(min) : `ab ${euroText(min)}`
|
|
}
|
|
return euroText(p?.preisCent)
|
|
}
|
|
|
|
/** Bestand fuer die Liste: „unbegrenzt“, „n Stück“ oder „ausverkauft“ (bei Varianten ueber alle Werte). */
|
|
function bestandAnzeige(p) {
|
|
const werte = Array.isArray(p?.varianten?.werte) ? p.varianten.werte : []
|
|
const stueck = (n) => (n === 0 ? 'ausverkauft' : `${n} Stück`)
|
|
if (werte.length > 0) {
|
|
if (werte.every((w) => w.bestand === 0)) return 'ausverkauft'
|
|
if (werte.some((w) => !Number.isInteger(w.bestand))) return 'unbegrenzt'
|
|
return stueck(werte.reduce((summe, w) => summe + w.bestand, 0))
|
|
}
|
|
return Number.isInteger(p?.bestand) ? stueck(p.bestand) : 'unbegrenzt'
|
|
}
|
|
|
|
/**
|
|
* Der Bestand, den der Haendler beim Laden gesehen hat (bestandGeladen am Artikel und je Variantenwert mit id). Der Server schreibt
|
|
* einen Bestand nur, wenn er sich davon unterscheidet; sonst bleibt der aktuelle Stand (zwischenzeitliche Verkaeufe gehen nicht verloren).
|
|
*/
|
|
const bestandOderNull = (wert) => (Number.isInteger(wert) ? wert : null)
|
|
|
|
/** Der Artikel in der Form, die der Server beim Speichern erwartet (aus der Listenansicht). */
|
|
function produktKoerper(p, ueberschreibe = {}) {
|
|
return {
|
|
name: p.name,
|
|
kategorie: p.kategorie,
|
|
kurz: p.kurz,
|
|
beschreibung: p.beschreibung,
|
|
preisCent: p.preisCent ?? null,
|
|
steuersatz: p.steuersatz,
|
|
grundpreis: p.grundpreis ?? null,
|
|
bestand: p.bestand ?? null,
|
|
bestandGeladen: bestandOderNull(p.bestand),
|
|
aktiv: p.aktiv,
|
|
varianten: istObjekt(p.varianten) && Array.isArray(p.varianten.werte)
|
|
? { ...p.varianten, werte: p.varianten.werte.map((w) => (typeof w?.id === 'string' ? { ...w, bestandGeladen: bestandOderNull(w.bestand) } : w)) }
|
|
: (p.varianten ?? null),
|
|
bilder: Array.isArray(p.bilder) ? p.bilder : [],
|
|
lieferart: p.lieferart,
|
|
widerruf: p.widerruf,
|
|
sicherheit: p.sicherheit,
|
|
lebensmittel: p.lebensmittel,
|
|
...ueberschreibe,
|
|
}
|
|
}
|
|
|
|
const leererArtikel = () => ({
|
|
name: '', kategorie: '', kurz: '', beschreibung: '', preisCent: null, steuersatz: 19, grundpreis: null, bestand: null, aktiv: true, varianten: null, bilder: [],
|
|
lieferart: 'beides', widerruf: 'gesetzlich', sicherheit: { hersteller: 'selbst', euVerantwortlicher: null, hinweise: '' }, lebensmittel: { aktiv: false, pflichtangaben: '' },
|
|
})
|
|
|
|
/* ---------------------------------------------------------------------- Reiter --- */
|
|
export async function render(panel, ctx) {
|
|
const api = ctx.request || request
|
|
const nurLesen = ctx.nurLesen === true
|
|
const projectId = ctx.projectId
|
|
const q = `projectId=${encodeURIComponent(projectId)}`
|
|
const noch = () => (typeof ctx.aktuell === 'function' ? ctx.aktuell() !== false : true)
|
|
const meldung = (t, art = 'ok') => ctx.zeigeMeldung?.(t, art)
|
|
const stand = { produkte: [], maxArtikel: 500, maxBilder: 4, kleinunternehmer: false }
|
|
let beschaeftigt = false
|
|
|
|
panel.replaceChildren(h('p', { class: 'sh-hinweis' }, 'Artikel werden geladen …'))
|
|
try {
|
|
const [liste, einstellungen] = await Promise.all([api(`/api/shop/produkte?${q}`), api(`/api/shop/einstellungen?${q}`).catch(() => null)])
|
|
if (!noch()) return
|
|
stand.produkte = Array.isArray(liste?.produkte) ? liste.produkte : []
|
|
if (Number.isInteger(liste?.maxArtikel)) stand.maxArtikel = liste.maxArtikel
|
|
if (Number.isInteger(liste?.maxBilder)) stand.maxBilder = liste.maxBilder
|
|
stand.kleinunternehmer = einstellungen?.einstellungen?.kleinunternehmer === true
|
|
} catch (err) {
|
|
if (noch()) panel.replaceChildren(fehlerKarte(err.message, () => render(panel, ctx)))
|
|
return
|
|
}
|
|
|
|
const vorschauUrl = (id) => `/api/shop/bild/${encodeURIComponent(projectId)}/${id}-s.webp`
|
|
|
|
/* ------------------------------------------------------------------ Liste --- */
|
|
function zeigeListe() {
|
|
if (!noch()) return
|
|
const anzahl = stand.produkte.length
|
|
const neuKnopf = nurLesen ? null : h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'artikel-neu', onclick: () => zeigeEditor(null) }, 'Artikel anlegen')
|
|
if (anzahl === 0) {
|
|
panel.replaceChildren(abschnitt('Artikel', null,
|
|
h('p', { class: 'sh-leer' }, nurLesen ? 'Es gibt noch keine Artikel.' : 'Du hast noch keine Artikel. Lege deinen ersten Artikel an, dann kann er in deinem Shop erscheinen.'),
|
|
neuKnopf ? h('div', { class: 'sh-aktionen' }, neuKnopf) : null))
|
|
return
|
|
}
|
|
const zeilen = stand.produkte.map((p, i) => artikelZeile(p, i, anzahl))
|
|
panel.replaceChildren(abschnitt('Artikel', `${anzahl} von höchstens ${stand.maxArtikel} Artikeln. Mit „Hoch“ und „Runter“ bestimmst du die Reihenfolge im Shop.`,
|
|
h('ul', { class: 'sh-artikelliste' }, zeilen),
|
|
neuKnopf ? h('div', { class: 'sh-aktionen' }, neuKnopf) : null))
|
|
}
|
|
|
|
function artikelZeile(p, index, gesamt) {
|
|
const bild = Array.isArray(p.bildUrls) && p.bildUrls[0]?.klein ? p.bildUrls[0].klein : ''
|
|
const bestand = bestandAnzeige(p)
|
|
const info = h('div', { class: 'sh-artikel-info' },
|
|
h('strong', { class: 'sh-artikel-name' }, p.name || '(ohne Namen)'),
|
|
p.kategorie ? h('span', { class: 'sh-hinweis' }, p.kategorie) : null,
|
|
h('span', { class: 'sh-artikel-preis' }, preisAnzeige(p)),
|
|
h('span', { class: `sh-artikel-bestand${bestand === 'ausverkauft' ? ' sh-artikel-aus' : ''}`, 'data-bestand': '' }, bestand))
|
|
const li = h('li', { class: 'sh-artikel', 'data-artikel': p.id },
|
|
bild ? h('img', { class: 'sh-artikel-bild', src: bild, alt: '', width: 56, height: 56, loading: 'lazy' }) : h('span', { class: 'sh-artikel-bild sh-artikel-bild-leer', 'aria-hidden': 'true' }),
|
|
info)
|
|
if (nurLesen) {
|
|
li.append(h('span', { class: `sh-pille ${p.aktiv ? 'sh-pille-ok' : 'sh-pille-off'}` }, p.aktiv ? 'aktiv' : 'inaktiv'))
|
|
return li
|
|
}
|
|
const schalter = h('input', { type: 'checkbox', role: 'switch', 'aria-label': `${p.name} ist aktiv`, 'data-aktion': 'aktiv', checked: p.aktiv })
|
|
schalter.addEventListener('change', () => wechsleAktiv(p, schalter))
|
|
li.append(h('div', { class: 'sh-artikel-aktionen' },
|
|
h('label', { class: 'sh-schalter' }, schalter, h('span', null, 'aktiv')),
|
|
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'hoch', 'aria-label': `${p.name} nach oben`, disabled: index === 0, onclick: () => verschiebe(index, -1) }, 'Hoch'),
|
|
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'runter', 'aria-label': `${p.name} nach unten`, disabled: index === gesamt - 1, onclick: () => verschiebe(index, 1) }, 'Runter'),
|
|
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'bearbeiten', 'aria-label': `${p.name} bearbeiten`, onclick: () => zeigeEditor(p) }, 'Bearbeiten'),
|
|
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'loeschen', 'aria-label': `${p.name} löschen`, onclick: () => frageLoeschen(p, li) }, 'Löschen')))
|
|
return li
|
|
}
|
|
|
|
/** Fuehrt eine Aktion der Liste aus; waehrenddessen keine zweite. */
|
|
async function aktion(fn) {
|
|
if (beschaeftigt) return false
|
|
beschaeftigt = true
|
|
try {
|
|
await fn()
|
|
return true
|
|
} catch (err) {
|
|
meldung(err.message, 'fehler')
|
|
return false
|
|
} finally {
|
|
beschaeftigt = false
|
|
}
|
|
}
|
|
|
|
async function wechsleAktiv(p, schalter) {
|
|
const soll = schalter.checked
|
|
schalter.disabled = true
|
|
const ok = await aktion(async () => {
|
|
const antwort = await api(`/api/shop/produkte/${encodeURIComponent(p.id)}`, { method: 'PUT', body: { projectId, produkt: produktKoerper(p, { aktiv: soll }) } })
|
|
if (!noch()) return
|
|
stand.produkte = stand.produkte.map((x) => (x.id === p.id ? antwort.produkt : x))
|
|
meldung(soll ? `„${p.name}“ ist jetzt aktiv.` : `„${p.name}“ ist jetzt inaktiv.`)
|
|
})
|
|
if (!noch()) return
|
|
if (ok) zeigeListe()
|
|
else {
|
|
schalter.checked = !soll
|
|
schalter.disabled = false
|
|
}
|
|
}
|
|
|
|
async function verschiebe(index, richtung) {
|
|
const ids = stand.produkte.map((p) => p.id)
|
|
const ziel = index + richtung
|
|
if (ziel < 0 || ziel >= ids.length) return
|
|
;[ids[index], ids[ziel]] = [ids[ziel], ids[index]]
|
|
const ok = await aktion(async () => {
|
|
const antwort = await api('/api/shop/produkte/sortierung', { method: 'POST', body: { projectId, ids } })
|
|
if (noch()) stand.produkte = Array.isArray(antwort?.produkte) ? antwort.produkte : stand.produkte
|
|
})
|
|
if (ok && noch()) zeigeListe()
|
|
}
|
|
|
|
function frageLoeschen(p, li) {
|
|
li.querySelector('.sh-frage-box')?.remove()
|
|
const box = frage(`„${p.name}“ wirklich löschen?`,
|
|
h('p', { class: 'sh-hinweis' }, 'Der Artikel und seine Bilder werden gelöscht. Das kannst du nicht rückgängig machen. Bereits aufgegebene Bestellungen bleiben unverändert.'),
|
|
h('div', { class: 'sh-aktionen' },
|
|
h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'loeschen-ja', onclick: () => loesche(p) }, 'Ja, löschen'),
|
|
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'loeschen-nein', onclick: () => box.remove() }, 'Abbrechen')))
|
|
li.append(box)
|
|
box.focus()
|
|
}
|
|
|
|
async function loesche(p) {
|
|
const ok = await aktion(async () => {
|
|
await api(`/api/shop/produkte/${encodeURIComponent(p.id)}?${q}`, { method: 'DELETE' })
|
|
if (!noch()) return
|
|
stand.produkte = stand.produkte.filter((x) => x.id !== p.id)
|
|
meldung(`„${p.name}“ wurde gelöscht.`)
|
|
})
|
|
if (ok && noch()) zeigeListe()
|
|
}
|
|
|
|
/* ----------------------------------------------------------------- Editor --- */
|
|
function zeigeEditor(vorhanden) {
|
|
if (!noch()) return
|
|
const id = vorhanden?.id || null
|
|
const p = vorhanden ? { ...leererArtikel(), ...vorhanden } : leererArtikel()
|
|
const si = istObjekt(p.sicherheit) ? p.sicherheit : {}
|
|
const andererHersteller = istObjekt(si.hersteller) ? si.hersteller : null
|
|
const eu = istObjekt(si.euVerantwortlicher) ? si.euVerantwortlicher : null
|
|
const lm = istObjekt(p.lebensmittel) ? p.lebensmittel : {}
|
|
const gp = istObjekt(p.grundpreis) ? p.grundpreis : null
|
|
zaehler += 1
|
|
|
|
/* Grunddaten */
|
|
const name = kurzesFeld(text(p.name))
|
|
const kategorienListe = `sh-kategorien-${zaehler}`
|
|
const kategorien = [...new Set(stand.produkte.map((x) => text(x.kategorie).trim()).filter(Boolean))]
|
|
const kategorie = kurzesFeld(text(p.kategorie), { list: kategorienListe })
|
|
const kategorieFeld = feld('kategorie', 'Kategorie', kategorie, 'Frei wählbar; im Shop werden die Artikel nach Kategorie gruppiert.')
|
|
kategorieFeld.append(h('datalist', { id: kategorienListe }, kategorien.map((k) => h('option', { value: k }))))
|
|
const kurz = kurzesFeld(text(p.kurz))
|
|
const beschreibung = langesFeld(text(p.beschreibung), 6)
|
|
|
|
/* Preis, Bestand */
|
|
const preis = euroEingabe({ cent: Number.isInteger(p.preisCent) ? p.preisCent : null })
|
|
const steuer = waehle(STEUERSAETZE.map((s) => [s, `${s} %`]), STEUERSAETZE.includes(p.steuersatz) ? p.steuersatz : 19)
|
|
const gpMenge = kurzesFeld(gp ? String(gp.menge).replace('.', ',') : '', { inputmode: 'decimal' })
|
|
const gpEinheit = waehle([['', 'kein Grundpreis'], ...EINHEITEN], gp ? gp.einheit : '')
|
|
const bestand = kurzesFeld(Number.isInteger(p.bestand) ? String(p.bestand) : '', { inputmode: 'numeric' })
|
|
|
|
/* Varianten */
|
|
const hatVarianten = istObjekt(p.varianten) && Array.isArray(p.varianten.werte) && p.varianten.werte.length > 0
|
|
const variantenAn = h('input', { type: 'checkbox', checked: hatVarianten })
|
|
const variantenName = kurzesFeld(hatVarianten ? text(p.varianten.name) : '')
|
|
const werte = hatVarianten
|
|
? p.varianten.werte.map((w) => ({ id: w.id, name: text(w.name), preis: centZuEuro(w.preisCent), bestand: Number.isInteger(w.bestand) ? String(w.bestand) : '', geladen: bestandOderNull(w.bestand) }))
|
|
: []
|
|
const werteBox = h('div', { class: 'sh-werte' })
|
|
const wertNeuKnopf = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'wert-neu', onclick: () => { werte.push({ id: undefined, name: '', preis: '', bestand: '' }); zeichneWerte() } }, 'Wert hinzufügen')
|
|
|
|
function zeichneWerte() {
|
|
werteBox.replaceChildren(...werte.map((w, i) => {
|
|
const wName = kurzesFeld(w.name)
|
|
const wPreis = euroEingabe({ cent: null })
|
|
wPreis.value = w.preis
|
|
const wBestand = kurzesFeld(w.bestand, { inputmode: 'numeric' })
|
|
for (const typ of ['input', 'change']) {
|
|
wName.addEventListener(typ, () => { w.name = wName.value })
|
|
wPreis.addEventListener(typ, () => { w.preis = wPreis.value })
|
|
wBestand.addEventListener(typ, () => { w.bestand = wBestand.value })
|
|
}
|
|
return h('div', { class: 'sh-wertzeile', 'data-wert': String(i) },
|
|
feld(`varianten.werte.${i}.name`, `Wert ${i + 1}`, wName),
|
|
feld(`varianten.werte.${i}.preisCent`, `Preis ${i + 1} in €`, wPreis),
|
|
feld(`varianten.werte.${i}.bestand`, `Bestand ${i + 1}`, wBestand, i === 0 ? 'Leer = unbegrenzt.' : undefined),
|
|
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'wert-weg', 'aria-label': `Wert ${i + 1} entfernen`, onclick: () => { werte.splice(i, 1); zeichneWerte() } }, 'Entfernen'))
|
|
}))
|
|
wertNeuKnopf.disabled = werte.length >= MAX_WERTE
|
|
}
|
|
zeichneWerte()
|
|
|
|
/* Bilder */
|
|
const bilder = (Array.isArray(p.bilder) ? p.bilder : []).map((bid) => ({ id: bid, klein: p.bildUrls?.find((b) => b.id === bid)?.klein || vorschauUrl(bid) }))
|
|
const bilderListe = h('ul', { class: 'sh-bilder' })
|
|
const bildFehler = h('p', { class: 'sh-fehler', 'data-fehler': 'bilder', role: 'alert' })
|
|
const bildStatus = h('p', { class: 'sh-hinweis', role: 'status', 'data-bild-status': '' })
|
|
const bildInput = h('input', { type: 'file', accept: BILD_TYPEN.join(','), 'aria-label': 'Bild hinzufügen' })
|
|
const bildFeld = h('div', { class: 'sh-feld' }, h('label', null, 'Bild hinzufügen'), bildInput)
|
|
let hochladen = false
|
|
let speichernKnopf = null
|
|
|
|
function zeichneBilder() {
|
|
bilderListe.replaceChildren(...bilder.map((b, i) => h('li', { class: 'sh-bild', 'data-bild': b.id },
|
|
h('img', { src: b.klein, alt: i === 0 ? 'Titelbild' : `Bild ${i + 1}`, width: 96, height: 96 }),
|
|
i === 0 ? h('span', { class: 'sh-pille sh-pille-ok' }, 'Titelbild') : null,
|
|
h('div', { class: 'sh-aktionen' },
|
|
i > 0 ? h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'bild-titel', onclick: () => { bilder.unshift(...bilder.splice(i, 1)); zeichneBilder() } }, 'Als Titelbild') : null,
|
|
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'bild-weg', 'aria-label': `Bild ${i + 1} entfernen`, onclick: () => { bilder.splice(i, 1); zeichneBilder() } }, 'Entfernen')))))
|
|
bildFeld.hidden = bilder.length >= stand.maxBilder
|
|
bildInput.disabled = hochladen
|
|
bildStatus.textContent = hochladen ? 'Bild wird hochgeladen …' : ''
|
|
if (speichernKnopf) speichernKnopf.disabled = hochladen
|
|
}
|
|
bildInput.addEventListener('change', async () => {
|
|
const datei = bildInput.files?.[0]
|
|
bildInput.value = ''
|
|
if (!datei || hochladen) return
|
|
bildFehler.textContent = ''
|
|
if (!BILD_TYPEN.includes(datei.type)) {
|
|
bildFehler.textContent = BILD_FEHLER[415]
|
|
return
|
|
}
|
|
if (datei.size > BILD_MAX_BYTES) {
|
|
bildFehler.textContent = BILD_FEHLER[413]
|
|
return
|
|
}
|
|
hochladen = true
|
|
zeichneBilder()
|
|
try {
|
|
const antwort = await ladeBildHoch(projectId, datei)
|
|
if (typeof antwort?.imageId === 'string') bilder.push({ id: antwort.imageId, klein: vorschauUrl(antwort.imageId) })
|
|
} catch (err) {
|
|
bildFehler.textContent = err.message
|
|
} finally {
|
|
hochladen = false
|
|
zeichneBilder()
|
|
}
|
|
})
|
|
|
|
/* Lieferung, Widerruf */
|
|
const lieferart = waehle(LIEFERARTEN, LIEFERARTEN.some(([w]) => w === p.lieferart) ? p.lieferart : 'beides')
|
|
const widerruf = waehle(WIDERRUFE, WIDERRUFE.some(([w]) => w === p.widerruf) ? p.widerruf : 'gesetzlich')
|
|
|
|
/* Produktsicherheit */
|
|
const hersteller = waehle([['selbst', 'Ich bin der Hersteller'], ['andere', 'Ein anderer Hersteller']], andererHersteller ? 'andere' : 'selbst')
|
|
const hName = kurzesFeld(text(andererHersteller?.name))
|
|
const hAnschrift = kurzesFeld(text(andererHersteller?.anschrift))
|
|
const hKontakt = kurzesFeld(text(andererHersteller?.kontakt))
|
|
const herstellerBox = h('div', { class: 'sh-untergruppe', 'data-bereich': 'hersteller' },
|
|
feld('sicherheit.hersteller.name', 'Name des Herstellers', hName),
|
|
feld('sicherheit.hersteller.anschrift', 'Anschrift des Herstellers', hAnschrift),
|
|
feld('sicherheit.hersteller.kontakt', 'Kontakt des Herstellers (E-Mail oder Telefon)', hKontakt),
|
|
h('p', { class: 'sh-fehler', 'data-fehler': 'sicherheit.hersteller', role: 'alert' }))
|
|
const euAn = h('input', { type: 'checkbox', checked: eu !== null })
|
|
const euName = kurzesFeld(text(eu?.name))
|
|
const euAnschrift = kurzesFeld(text(eu?.anschrift))
|
|
const euKontakt = kurzesFeld(text(eu?.kontakt))
|
|
const euBox = h('div', { class: 'sh-untergruppe', 'data-bereich': 'eu' },
|
|
feld('sicherheit.euVerantwortlicher.name', 'Name der verantwortlichen Person in der EU', euName),
|
|
feld('sicherheit.euVerantwortlicher.anschrift', 'Anschrift in der EU', euAnschrift),
|
|
feld('sicherheit.euVerantwortlicher.kontakt', 'Kontakt in der EU (E-Mail oder Telefon)', euKontakt))
|
|
const hinweise = langesFeld(text(si.hinweise), 3)
|
|
|
|
/* Lebensmittel */
|
|
const lmAn = h('input', { type: 'checkbox', checked: lm.aktiv === true })
|
|
const pflicht = langesFeld(text(lm.pflichtangaben), 6)
|
|
const lmBox = h('div', { class: 'sh-untergruppe', 'data-bereich': 'lebensmittel' },
|
|
feld('lebensmittel.pflichtangaben', 'Pflichtangaben', pflicht, 'Bezeichnung, Zutaten, Allergene hervorgehoben, Menge, ggf. Hersteller'))
|
|
|
|
const aktivHaken = h('input', { type: 'checkbox', checked: p.aktiv !== false })
|
|
|
|
/* Bereiche ein- und ausblenden */
|
|
const einzelPreis = h('div', { class: 'sh-untergruppe', 'data-bereich': 'einzelpreis' }, feld('preisCent', 'Preis in € (brutto)', preis, 'Der Gesamtpreis, den deine Kundschaft bezahlt, inklusive Umsatzsteuer.'))
|
|
const steuerBox = h('div', { class: 'sh-untergruppe', 'data-bereich': 'steuersatz' }, feld('steuersatz', 'Steuersatz', steuer, 'Der Umsatzsteuersatz, der in diesem Preis enthalten ist.'))
|
|
steuerBox.hidden = stand.kleinunternehmer
|
|
const bestandAbschnitt = abschnitt('Bestand', null, feld('bestand', 'Bestand (Stück)', bestand, 'Leer lassen = unbegrenzt verfügbar. Bei 0 zeigt der Shop den Artikel als ausverkauft.'))
|
|
bestandAbschnitt.dataset.bereich = 'einzelbestand'
|
|
const variantenBox = h('div', { class: 'sh-untergruppe', 'data-bereich': 'varianten' },
|
|
feld('varianten.name', 'Name der Auswahl', variantenName, 'Zum Beispiel „Größe“ oder „Farbe“.'),
|
|
werteBox,
|
|
h('p', { class: 'sh-fehler', 'data-fehler': 'varianten.werte', role: 'alert' }),
|
|
h('div', { class: 'sh-aktionen' }, wertNeuKnopf),
|
|
h('p', { class: 'sh-hinweis' }, `Bis zu ${MAX_WERTE} Werte. Mit Varianten zählen nur der Preis und der Bestand der einzelnen Werte.`))
|
|
const zeigeBereiche = () => {
|
|
const an = variantenAn.checked
|
|
variantenBox.hidden = !an
|
|
einzelPreis.hidden = an
|
|
bestandAbschnitt.hidden = an
|
|
herstellerBox.hidden = hersteller.value !== 'andere'
|
|
euBox.hidden = !euAn.checked
|
|
lmBox.hidden = !lmAn.checked
|
|
}
|
|
variantenAn.addEventListener('change', () => {
|
|
if (variantenAn.checked && werte.length === 0) {
|
|
werte.push({ id: undefined, name: '', preis: '', bestand: '' })
|
|
zeichneWerte()
|
|
}
|
|
zeigeBereiche()
|
|
})
|
|
hersteller.addEventListener('change', zeigeBereiche)
|
|
euAn.addEventListener('change', zeigeBereiche)
|
|
lmAn.addEventListener('change', zeigeBereiche)
|
|
|
|
/* Artikel aus den Feldern lesen (der Server prueft; hier wird nichts vorab verworfen) */
|
|
function sammle() {
|
|
const mitVarianten = variantenAn.checked
|
|
const menge = gpMenge.value.trim()
|
|
const gpAn = menge !== '' || gpEinheit.value !== ''
|
|
return {
|
|
name: name.value,
|
|
kategorie: kategorie.value,
|
|
kurz: kurz.value,
|
|
beschreibung: beschreibung.value,
|
|
preisCent: mitVarianten ? null : preis.inCent(),
|
|
steuersatz: stand.kleinunternehmer ? 0 : Number(steuer.value),
|
|
grundpreis: gpAn ? { menge: /^\d+([.,]\d+)?$/.test(menge) ? Number(menge.replace(',', '.')) : menge, einheit: gpEinheit.value } : null,
|
|
bestand: mitVarianten ? null : ganzeZahl(bestand.value),
|
|
// Beim Bearbeiten: der beim Laden gesehene Bestand (der Server behaelt den aktuellen Stand, wenn er nicht geaendert wurde)
|
|
...(id ? { bestandGeladen: bestandOderNull(p.bestand) } : {}),
|
|
aktiv: aktivHaken.checked,
|
|
varianten: mitVarianten
|
|
? {
|
|
name: variantenName.value,
|
|
werte: werte.map((w) => ({
|
|
id: w.id,
|
|
name: w.name,
|
|
preisCent: euroZuCent(String(w.preis)),
|
|
bestand: ganzeZahl(w.bestand),
|
|
...(id && typeof w.id === 'string' ? { bestandGeladen: w.geladen } : {}),
|
|
})),
|
|
}
|
|
: null,
|
|
bilder: bilder.map((b) => b.id),
|
|
lieferart: lieferart.value,
|
|
widerruf: widerruf.value,
|
|
sicherheit: {
|
|
hersteller: hersteller.value === 'andere' ? { name: hName.value, anschrift: hAnschrift.value, kontakt: hKontakt.value } : 'selbst',
|
|
euVerantwortlicher: euAn.checked ? { name: euName.value, anschrift: euAnschrift.value, kontakt: euKontakt.value } : null,
|
|
hinweise: hinweise.value,
|
|
},
|
|
lebensmittel: { aktiv: lmAn.checked, pflichtangaben: pflicht.value },
|
|
}
|
|
}
|
|
|
|
const titel = h('h3', { tabindex: '-1' }, id ? 'Artikel bearbeiten' : 'Artikel anlegen')
|
|
speichernKnopf = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'speichern' }, 'Speichern')
|
|
const form = h('form', { class: 'sh-artikel-editor', novalidate: true, 'aria-label': id ? 'Artikel bearbeiten' : 'Artikel anlegen' },
|
|
h('div', { class: 'kb-panel sh-abschnitt' }, titel,
|
|
h('p', { class: 'sh-fehler', 'data-fehler': '_', role: 'alert' }),
|
|
h('p', { class: 'sh-fehler', 'data-fehler': 'produkt', role: 'alert' })),
|
|
abschnitt('Grunddaten', null,
|
|
feld('name', 'Name', name),
|
|
kategorieFeld,
|
|
feld('kurz', 'Kurzbeschreibung', kurz, 'Höchstens 300 Zeichen, erscheint in der Artikelliste des Shops.'),
|
|
feld('beschreibung', 'Beschreibung', beschreibung, 'Höchstens 5000 Zeichen. Absätze sind erlaubt, HTML nicht.')),
|
|
abschnitt('Preis', null,
|
|
einzelPreis,
|
|
steuerBox,
|
|
h('div', { class: 'sh-grundpreis' },
|
|
feld('grundpreis.menge', 'Grundpreis: Menge', gpMenge, 'Nur bei Ware, die nach Gewicht, Volumen oder Länge verkauft wird, zum Beispiel 500 bei 500 g. Der Shop zeigt dann den Preis je 1 kg, 1 l, 1 m oder 1 m².'),
|
|
feld('grundpreis.einheit', 'Grundpreis: Einheit', gpEinheit))),
|
|
bestandAbschnitt,
|
|
abschnitt('Varianten', 'Optional: Wenn dein Artikel zum Beispiel in mehreren Größen mit eigenem Preis erhältlich ist.',
|
|
hakenFeld('variantenAn', variantenAn, 'Dieser Artikel hat Varianten'),
|
|
variantenBox),
|
|
abschnitt('Bilder', `Bis zu ${stand.maxBilder} Bilder (JPEG, PNG oder WebP, je höchstens 8 MB). Das erste Bild ist das Titelbild.`,
|
|
bilderListe,
|
|
bildFeld,
|
|
bildStatus,
|
|
bildFehler),
|
|
abschnitt('Lieferung', null, feld('lieferart', 'Lieferart', lieferart)),
|
|
abschnitt('Widerrufsrecht', null,
|
|
feld('widerruf', 'Widerrufsrecht für diesen Artikel', widerruf,
|
|
'Das ist deine eigene rechtliche Einschätzung als Verkäufer. Das gesetzliche Widerrufsrecht gilt, solange kein Ausnahmegrund (§ 312g Abs. 2 BGB) auf deine Ware zutrifft. WEBklar berät nicht rechtlich.')),
|
|
einklappbar('Produktsicherheit', 'Pflichtangaben nach der EU-Produktsicherheitsverordnung (GPSR): Wer stellt die Ware her, und worauf muss man achten?',
|
|
andererHersteller !== null || eu !== null || text(si.hinweise) !== '',
|
|
feld('sicherheit.herstellerArt', 'Hersteller', hersteller, 'Wenn du die Ware selbst herstellst, gelten deine Angaben aus den Einstellungen.'),
|
|
herstellerBox,
|
|
hakenFeld('sicherheit.ausserhalbEu', euAn, 'Der Hersteller sitzt außerhalb der EU', 'Dann brauchst du eine verantwortliche Person in der EU.'),
|
|
euBox,
|
|
feld('sicherheit.hinweise', 'Warn- und Sicherheitshinweise', hinweise, 'Zum Beispiel „Nicht für Kinder unter 3 Jahren geeignet“. Höchstens 2000 Zeichen.')),
|
|
einklappbar('Lebensmittel', 'Für Lebensmittel muss der Shop vor dem Kauf bestimmte Angaben zeigen (Lebensmittelinformationsverordnung).', lm.aktiv === true,
|
|
hakenFeld('lebensmittel.aktiv', lmAn, 'Dieser Artikel ist ein Lebensmittel'),
|
|
lmBox),
|
|
abschnitt('Sichtbarkeit', null, hakenFeld('aktiv', aktivHaken, 'Artikel ist aktiv', 'Nur aktive Artikel erscheinen im Shop.')),
|
|
h('div', { class: 'sh-aktionen' },
|
|
speichernKnopf,
|
|
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: () => zeigeListe() }, 'Abbrechen')))
|
|
|
|
// Fehlerhinweise in zugeklappten Bereichen sichtbar machen
|
|
const oeffneFehlerBereiche = () => {
|
|
for (const el of form.querySelectorAll('[data-fehler]')) if (el.textContent !== '') el.closest('details')?.setAttribute('open', '')
|
|
}
|
|
let sendet = false
|
|
form.addEventListener('submit', async (ev) => {
|
|
ev.preventDefault()
|
|
if (sendet || hochladen) return
|
|
setzeFehler(form, {})
|
|
bildFehler.textContent = ''
|
|
sendet = true
|
|
speichernKnopf.disabled = true
|
|
try {
|
|
const antwort = await api(id ? `/api/shop/produkte/${encodeURIComponent(id)}` : '/api/shop/produkte', { method: id ? 'PUT' : 'POST', body: { projectId, produkt: sammle() } })
|
|
if (!noch()) return
|
|
const gespeichert = antwort.produkt
|
|
stand.produkte = id ? stand.produkte.map((x) => (x.id === id ? gespeichert : x)) : [...stand.produkte, gespeichert]
|
|
meldung(id ? 'Der Artikel wurde gespeichert.' : 'Der Artikel wurde angelegt.')
|
|
zeigeListe()
|
|
} catch (err) {
|
|
if (!noch()) return
|
|
zeigeFehler(form, err)
|
|
oeffneFehlerBereiche()
|
|
} finally {
|
|
sendet = false
|
|
speichernKnopf.disabled = hochladen
|
|
}
|
|
})
|
|
|
|
zeichneBilder()
|
|
zeigeBereiche()
|
|
panel.replaceChildren(form)
|
|
titel.focus()
|
|
}
|
|
|
|
zeigeListe()
|
|
}
|