551 lines
30 KiB
JavaScript
551 lines
30 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 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,
|
|
aktiv: p.aktiv,
|
|
varianten: 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) : '' }))
|
|
: []
|
|
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),
|
|
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) })) }
|
|
: 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()
|
|
}
|