Shop G4: Reiter Artikel mit Liste (Hoch/Runter, Aktiv-Schalter, Löschen mit Rückfrage), Editor (Preis, Grundpreis, Varianten, Bilder mit Upload, Produktsicherheit, Lebensmittel) und Feldfehlern
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
648d0e07fe
commit
cc668d3765
3 files changed
+1157
-5
No files matched your search
+547
-5
@@ -1,8 +1,550 @@
|
||||
/* Webshop, Reiter „Artikel“: 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 „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 { h } from './shop-dom.js'
|
||||
import { abschnitt, centZuEuro, euroEingabe, euroText, euroZuCent, feld, fehlerKarte, frage, h, hakenFeld, request, setzeFehler, zeigeFehler } from './shop-dom.js'
|
||||
|
||||
export function render(panel) {
|
||||
panel.replaceChildren(h('p', { class: 'sh-hinweis' }, 'Kommt gleich …'))
|
||||
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()
|
||||
}
|
||||
@@ -113,3 +113,35 @@ a.kb-btn-outline { display: inline-flex; align-items: center; text-decoration: n
|
||||
/* 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; }
|
||||
|
||||
/* ----------------------------------------------------- Artikel (shop-tab-artikel.js) --- */
|
||||
.sh-artikelliste { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 10px; }
|
||||
.sh-artikel { display: grid; grid-template-columns: 56px minmax(0, 1fr) auto; gap: 8px 14px; align-items: center; padding: 12px; border-radius: 12px; border: 1px solid var(--kb-border); background: rgba(255, 255, 255, 0.03); }
|
||||
.sh-artikel-bild { width: 56px; height: 56px; border-radius: 10px; object-fit: cover; background: rgba(255, 255, 255, 0.06); display: block; }
|
||||
.sh-artikel-info { display: grid; gap: 2px; min-width: 0; }
|
||||
.sh-artikel-name { overflow-wrap: anywhere; }
|
||||
.sh-artikel-preis { font-variant-numeric: tabular-nums; }
|
||||
.sh-artikel-bestand { font-size: 13px; color: var(--kb-muted); }
|
||||
.sh-artikel-aus { color: #fca5a5; }
|
||||
.sh-artikel-aktionen { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; justify-content: flex-end; }
|
||||
.sh-schalter { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 600; cursor: pointer; }
|
||||
.sh-schalter input { width: 18px; height: 18px; accent-color: var(--kb-purple); }
|
||||
.sh-artikel .sh-frage-box { grid-column: 1 / -1; margin: 4px 0 0; }
|
||||
.sh-artikel-editor .sh-abschnitt { margin-bottom: 16px; }
|
||||
.sh-einklappbar > summary { cursor: pointer; padding: 2px 0; }
|
||||
.sh-einklappbar > summary strong { font-size: 17px; }
|
||||
.sh-einklappbar[open] > summary { margin-bottom: 6px; }
|
||||
.sh-untergruppe[hidden], .sh-feld[hidden], .sh-abschnitt[hidden] { display: none; }
|
||||
.sh-grundpreis { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: 0 14px; }
|
||||
.sh-wertzeile { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr) minmax(0, 1fr) auto; gap: 0 12px; align-items: end; margin-bottom: 4px; }
|
||||
.sh-wertzeile > button { margin-bottom: 2px; }
|
||||
.sh-bilder { list-style: none; margin: 10px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.sh-bild { display: grid; gap: 6px; justify-items: start; padding: 10px; border-radius: 12px; border: 1px solid var(--kb-border); }
|
||||
.sh-bild img { width: 96px; height: 96px; object-fit: cover; border-radius: 8px; }
|
||||
.sh-bild .sh-pille { margin-bottom: 0; }
|
||||
.sh-bild .sh-aktionen { margin-top: 0; }
|
||||
@media (max-width: 600px) {
|
||||
.sh-artikel { grid-template-columns: 56px minmax(0, 1fr); }
|
||||
.sh-artikel-aktionen { grid-column: 1 / -1; justify-content: flex-start; }
|
||||
.sh-grundpreis, .sh-wertzeile { grid-template-columns: minmax(0, 1fr); }
|
||||
}
|
||||
Reference in new issue
Block a user