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); }
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { installFakeDom } from './support/fakeDom.js'
|
||||
import { euroText, h, request } from '../public/shop-dom.js'
|
||||
import { render } from '../public/shop-tab-artikel.js'
|
||||
|
||||
// Task G4: Reiter „Artikel“ (public/shop-tab-artikel.js) mit Fake-DOM und Fake-fetch: Liste, Aktiv-Schalter, Sortierung, Editor (Anlegen, Varianten,
|
||||
// Bilder mit rohem Upload, Feldfehler am richtigen Feld), Loeschen mit Rueckfrage, nurLesen. Die Server-Antworten sind von Hand gebaut
|
||||
// (Form: server/routes/shop.js produktAnsicht, server/services/shop/produkte.js).
|
||||
|
||||
const P = 'p1'
|
||||
const vorschau = (id) => ({ id, klein: `/api/shop/bild/${P}/${id}-s.webp`, gross: `/api/shop/bild/${P}/${id}-l.webp` })
|
||||
|
||||
/** Artikel wie ihn GET /api/shop/produkte liefert (alle gespeicherten Felder plus id, sort, bildUrls). */
|
||||
const artikel = (id, name, extra = {}) => {
|
||||
const a = {
|
||||
id,
|
||||
name,
|
||||
kategorie: '',
|
||||
kurz: '',
|
||||
beschreibung: '',
|
||||
preisCent: 1250,
|
||||
steuersatz: 19,
|
||||
grundpreis: null,
|
||||
bestand: null,
|
||||
aktiv: true,
|
||||
sort: 0,
|
||||
varianten: null,
|
||||
bilder: [],
|
||||
lieferart: 'beides',
|
||||
widerruf: 'gesetzlich',
|
||||
sicherheit: { hersteller: 'selbst', euVerantwortlicher: null, hinweise: '' },
|
||||
lebensmittel: { aktiv: false, pflichtangaben: '' },
|
||||
...extra,
|
||||
}
|
||||
a.bildUrls = a.bilder.map(vorschau)
|
||||
return a
|
||||
}
|
||||
|
||||
/** Der Artikel so, wie er beim Speichern zum Server geht (ohne id, sort, bildUrls). */
|
||||
const koerper = (a, extra = {}) => {
|
||||
const { id, sort, bildUrls, ...rest } = a
|
||||
void id; void sort; void bildUrls
|
||||
return { ...rest, ...extra }
|
||||
}
|
||||
|
||||
const LEER_KOERPER = {
|
||||
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: '' },
|
||||
}
|
||||
|
||||
/**
|
||||
* Fake-Server: GET produkte und einstellungen aus den Parametern, alles andere aus sonst(aufruf) (Standard 200 {}).
|
||||
* Der Upload (POST /api/shop/bild, rohe Bytes) laeuft am Fake-fetch vorbei: Aufrufe stehen in uploads, die Antwort kommt aus upload(aufruf)
|
||||
* (Objekt oder Promise davon; Standard 201 { imageId: 'neu1' }), weil fakeDom nur JSON-Koerper kennt.
|
||||
*/
|
||||
function starte({ produkte = [], einstellungen = { kleinunternehmer: false }, nurLesen = false, sonst = () => null, upload, maxBilder = 4 } = {}) {
|
||||
const route = (aufruf) => {
|
||||
if (aufruf.method === 'GET' && aufruf.url === `/api/shop/produkte?projectId=${P}`) return { body: { produkte, maxArtikel: 500, maxBilder } }
|
||||
if (aufruf.method === 'GET' && aufruf.url === `/api/shop/einstellungen?projectId=${P}`) return { body: { einstellungen, version: 1, vollstaendig: true } }
|
||||
return sonst(aufruf) || { body: {} }
|
||||
}
|
||||
const fx = installFakeDom({ antworten: Array.from({ length: 80 }, () => route) })
|
||||
const uploads = []
|
||||
const echterFetch = globalThis.fetch
|
||||
let zaehler = 0
|
||||
globalThis.fetch = async (url, optionen = {}) => {
|
||||
if (optionen.headers?.['Content-Type'] !== 'application/octet-stream') return echterFetch(url, optionen)
|
||||
const aufruf = { method: optionen.method, url: String(url), datei: optionen.body, credentials: optionen.credentials }
|
||||
uploads.push(aufruf)
|
||||
zaehler += 1
|
||||
const antwort = await (upload ? upload(aufruf, zaehler) : { status: 201, body: { imageId: `neu${zaehler}` } })
|
||||
const { status = 201, body = {} } = antwort
|
||||
return { ok: status >= 200 && status < 300, status, json: async () => structuredClone(body) }
|
||||
}
|
||||
const meldungen = []
|
||||
const panel = h('div', { class: 'sh-panel' })
|
||||
fx.document.body.append(panel)
|
||||
const ctx = { projectId: P, nurLesen, aktuell: () => true, request, zeigeMeldung: (text, art) => meldungen.push({ text, art: art || 'ok' }) }
|
||||
return { fx, panel, ctx, uploads, meldungen }
|
||||
}
|
||||
|
||||
const mit = (optionen, fn) => async () => {
|
||||
const t = starte(optionen)
|
||||
try {
|
||||
await render(t.panel, t.ctx)
|
||||
await t.fx.ruhe()
|
||||
await fn(t)
|
||||
} finally {
|
||||
t.fx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
const aenderungen = (fx) => fx.fetchLog.filter((a) => a.method !== 'GET')
|
||||
const knopf = (fx, wurzel, aktion) => fx.finde(wurzel, `[data-aktion="${aktion}"]`)
|
||||
const feldVon = (fx, wurzel, name) => fx.finde(wurzel, `[data-feld="${name}"]`)
|
||||
const fehlerVon = (fx, wurzel, name) => fx.finde(wurzel, `[data-fehler="${name}"]`)
|
||||
const zeile = (fx, wurzel, id) => fx.finde(wurzel, `[data-artikel="${id}"]`)
|
||||
const bereich = (fx, wurzel, name) => fx.finde(wurzel, `[data-bereich="${name}"]`)
|
||||
const datei = (name, typ, groesse) => ({ name, type: typ, size: groesse })
|
||||
|
||||
const DREI = () => [
|
||||
artikel('a1', 'Mütze', { kategorie: 'Winter', sort: 0, bestand: 5, bilder: ['b1'] }),
|
||||
artikel('a2', 'Schal', { kategorie: 'Winter', sort: 1, aktiv: false, bestand: 0 }),
|
||||
artikel('a3', 'Handschuhe', { kategorie: 'Sommer', sort: 2, preisCent: null, varianten: { name: 'Größe', werte: [{ id: 'aaaaaaaa', name: 'S', preisCent: 800, bestand: 2 }, { id: 'bbbbbbbb', name: 'L', preisCent: 1000, bestand: null }] } }),
|
||||
]
|
||||
|
||||
/* ------------------------------------------------------------------ Liste --- */
|
||||
test('Liste: Name, Preis, Bestand, Schalter „aktiv“, Hoch/Runter, Bearbeiten und Löschen je Artikel', mit({ produkte: DREI() }, async ({ fx, panel }) => {
|
||||
assert.deepEqual(fx.fetchLog.map((a) => a.url).sort(), [`/api/shop/einstellungen?projectId=${P}`, `/api/shop/produkte?projectId=${P}`])
|
||||
const reihenfolge = fx.alle(panel, '[data-artikel]').map((li) => li.getAttribute('data-artikel'))
|
||||
assert.deepEqual(reihenfolge, ['a1', 'a2', 'a3'])
|
||||
const a1 = zeile(fx, panel, 'a1')
|
||||
assert.match(fx.text(a1), /Mütze/)
|
||||
assert.equal(fx.text(a1).includes(euroText(1250)), true)
|
||||
assert.equal(fx.text(fx.finde(a1, '[data-bestand]')), '5 Stück')
|
||||
assert.equal(fx.text(fx.finde(zeile(fx, panel, 'a2'), '[data-bestand]')), 'ausverkauft')
|
||||
assert.equal(fx.text(fx.finde(zeile(fx, panel, 'a1'), '.sh-artikel-preis')), euroText(1250))
|
||||
const a3 = zeile(fx, panel, 'a3')
|
||||
assert.equal(fx.text(fx.finde(a3, '.sh-artikel-preis')), `ab ${euroText(800)}`, 'Varianten: ab dem kleinsten Preis')
|
||||
assert.equal(fx.text(fx.finde(a3, '[data-bestand]')), 'unbegrenzt', 'ein Wert ohne Bestandsgrenze ⇒ unbegrenzt')
|
||||
assert.equal(knopf(fx, a1, 'aktiv').checked, true)
|
||||
assert.equal(knopf(fx, zeile(fx, panel, 'a2'), 'aktiv').checked, false)
|
||||
assert.equal(knopf(fx, a1, 'hoch').disabled, true, 'der erste kann nicht höher')
|
||||
assert.equal(knopf(fx, a3, 'runter').disabled, true, 'der letzte kann nicht tiefer')
|
||||
assert.equal(knopf(fx, a1, 'runter').disabled, false)
|
||||
for (const a of ['bearbeiten', 'loeschen']) assert.ok(knopf(fx, a1, a), a)
|
||||
assert.equal(fx.finde(a1, 'img').getAttribute('src'), `/api/shop/bild/${P}/b1-s.webp`, 'Vorschau des Titelbilds')
|
||||
assert.ok(knopf(fx, panel, 'artikel-neu'))
|
||||
assert.match(fx.text(panel), /3 von höchstens 500 Artikeln/)
|
||||
assert.deepEqual(aenderungen(fx), [])
|
||||
}))
|
||||
|
||||
test('Liste ohne Artikel: Hinweis und Knopf „Artikel anlegen“, der den Editor öffnet', mit({ produkte: [] }, async ({ fx, panel }) => {
|
||||
assert.match(fx.text(panel), /Du hast noch keine Artikel/)
|
||||
assert.equal(fx.text(knopf(fx, panel, 'artikel-neu')), 'Artikel anlegen')
|
||||
assert.equal(fx.finde(panel, '[data-artikel]'), null)
|
||||
await fx.klick(knopf(fx, panel, 'artikel-neu'))
|
||||
assert.ok(fx.finde(panel, 'form'), 'Editor statt Liste')
|
||||
assert.equal(knopf(fx, panel, 'artikel-neu'), null)
|
||||
assert.match(fx.text(panel), /Artikel anlegen/)
|
||||
await fx.klick(knopf(fx, panel, 'abbrechen'))
|
||||
assert.equal(fx.finde(panel, 'form'), null)
|
||||
assert.ok(knopf(fx, panel, 'artikel-neu'))
|
||||
}))
|
||||
|
||||
test('Laden schlägt fehl: Fehlerkarte mit „Noch einmal versuchen“', async () => {
|
||||
const fx = installFakeDom({ antworten: [{ status: 500, body: { error: 'Interner Serverfehler' } }, { status: 500, body: { error: 'Interner Serverfehler' } }] })
|
||||
try {
|
||||
const panel = h('div')
|
||||
fx.document.body.append(panel)
|
||||
await render(panel, { projectId: P, nurLesen: false, request, zeigeMeldung() {} })
|
||||
assert.match(fx.text(panel), /Interner Serverfehler/)
|
||||
assert.match(fx.text(panel), /Noch einmal versuchen/)
|
||||
} finally {
|
||||
fx.restore()
|
||||
}
|
||||
})
|
||||
|
||||
/* ------------------------------------------------------------------ Schalter --- */
|
||||
test('Schalter „aktiv“: PUT mit dem ganzen Artikel und umgedrehtem aktiv, danach zeigt die Liste den neuen Stand', mit({
|
||||
produkte: DREI(),
|
||||
sonst: (a) => (a.method === 'PUT' ? { body: { produkt: artikel('a1', 'Mütze', { kategorie: 'Winter', sort: 0, bestand: 5, bilder: ['b1'], aktiv: false }) } } : null),
|
||||
}, async ({ fx, panel, meldungen }) => {
|
||||
await fx.klick(knopf(fx, zeile(fx, panel, 'a1'), 'aktiv'))
|
||||
const [put, ...rest] = aenderungen(fx)
|
||||
assert.equal(rest.length, 0)
|
||||
assert.equal(put.method, 'PUT')
|
||||
assert.equal(put.url, '/api/shop/produkte/a1')
|
||||
assert.deepEqual(put.body, { projectId: P, produkt: koerper(DREI()[0], { aktiv: false }) })
|
||||
assert.equal(knopf(fx, zeile(fx, panel, 'a1'), 'aktiv').checked, false)
|
||||
assert.match(meldungen.at(-1).text, /Mütze.*inaktiv/)
|
||||
}))
|
||||
|
||||
test('Schalter „aktiv“ schlägt fehl: Fehlermeldung, Schalter springt zurück', mit({
|
||||
produkte: DREI(),
|
||||
sonst: (a) => (a.method === 'PUT' ? { status: 500, body: { error: 'Interner Serverfehler' } } : null),
|
||||
}, async ({ fx, panel, meldungen }) => {
|
||||
const schalter = knopf(fx, zeile(fx, panel, 'a1'), 'aktiv')
|
||||
await fx.klick(schalter)
|
||||
assert.deepEqual(meldungen.at(-1), { text: 'Interner Serverfehler', art: 'fehler' })
|
||||
assert.equal(knopf(fx, zeile(fx, panel, 'a1'), 'aktiv').checked, true)
|
||||
assert.equal(knopf(fx, zeile(fx, panel, 'a1'), 'aktiv').disabled, false)
|
||||
}))
|
||||
|
||||
/* ---------------------------------------------------------------- Sortierung --- */
|
||||
test('Runter und Hoch: POST sortierung mit der neuen Reihenfolge, die Liste folgt der Antwort', mit({
|
||||
produkte: DREI(),
|
||||
sonst: (a) => {
|
||||
if (a.url !== '/api/shop/produkte/sortierung') return null
|
||||
const nach = (id) => DREI().find((x) => x.id === id)
|
||||
return { body: { produkte: a.body.ids.map(nach) } }
|
||||
},
|
||||
}, async ({ fx, panel }) => {
|
||||
await fx.klick(knopf(fx, zeile(fx, panel, 'a1'), 'runter'))
|
||||
assert.deepEqual(aenderungen(fx).at(-1), { method: 'POST', url: '/api/shop/produkte/sortierung', body: { projectId: P, ids: ['a2', 'a1', 'a3'] } })
|
||||
assert.deepEqual(fx.alle(panel, '[data-artikel]').map((li) => li.getAttribute('data-artikel')), ['a2', 'a1', 'a3'])
|
||||
await fx.klick(knopf(fx, zeile(fx, panel, 'a3'), 'hoch'))
|
||||
assert.deepEqual(aenderungen(fx).at(-1).body.ids, ['a2', 'a3', 'a1'])
|
||||
assert.deepEqual(fx.alle(panel, '[data-artikel]').map((li) => li.getAttribute('data-artikel')), ['a2', 'a3', 'a1'])
|
||||
}))
|
||||
|
||||
/* -------------------------------------------------------------------- Anlegen --- */
|
||||
test('Anlegen: Felder, Preis aus „12,5“, Grundpreis, Datalist mit vorhandenen Kategorien, POST mit allem', mit({
|
||||
produkte: DREI(),
|
||||
sonst: (a) => (a.method === 'POST' ? { status: 201, body: { produkt: artikel('neu', 'Kerze', { sort: 3, preisCent: 1250 }) } } : null),
|
||||
}, async ({ fx, panel, meldungen }) => {
|
||||
await fx.klick(knopf(fx, panel, 'artikel-neu'))
|
||||
const form = fx.finde(panel, 'form')
|
||||
const kategorienOptionen = fx.alle(form, 'datalist option').map((o) => o.getAttribute('value'))
|
||||
assert.deepEqual(kategorienOptionen, ['Winter', 'Sommer'])
|
||||
assert.equal(feldVon(fx, form, 'kategorie').getAttribute('list'), fx.finde(form, 'datalist').id)
|
||||
assert.equal(bereich(fx, form, 'steuersatz').hidden, false, 'kein Kleinunternehmer ⇒ Steuersatz sichtbar')
|
||||
assert.equal(bereich(fx, form, 'varianten').hidden, true)
|
||||
await fx.setze(feldVon(fx, form, 'name'), 'Kerze')
|
||||
await fx.setze(feldVon(fx, form, 'kategorie'), 'Deko')
|
||||
await fx.setze(feldVon(fx, form, 'kurz'), 'Duftet gut')
|
||||
await fx.setze(feldVon(fx, form, 'beschreibung'), 'Erster Absatz\n\nZweiter Absatz')
|
||||
await fx.setze(feldVon(fx, form, 'preisCent'), '12,5')
|
||||
assert.equal(feldVon(fx, form, 'preisCent').value, '12,50')
|
||||
await fx.setze(feldVon(fx, form, 'steuersatz'), '7')
|
||||
await fx.setze(feldVon(fx, form, 'grundpreis.menge'), '500')
|
||||
await fx.setze(feldVon(fx, form, 'grundpreis.einheit'), 'g')
|
||||
await fx.setze(feldVon(fx, form, 'bestand'), '8')
|
||||
await fx.setze(feldVon(fx, form, 'lieferart'), 'abholung')
|
||||
await fx.setze(feldVon(fx, form, 'widerruf'), 'verderblich')
|
||||
await fx.klick(knopf(fx, form, 'speichern'))
|
||||
assert.deepEqual(aenderungen(fx), [{
|
||||
method: 'POST',
|
||||
url: '/api/shop/produkte',
|
||||
body: {
|
||||
projectId: P,
|
||||
produkt: {
|
||||
...LEER_KOERPER,
|
||||
name: 'Kerze',
|
||||
kategorie: 'Deko',
|
||||
kurz: 'Duftet gut',
|
||||
beschreibung: 'Erster Absatz\n\nZweiter Absatz',
|
||||
preisCent: 1250,
|
||||
steuersatz: 7,
|
||||
grundpreis: { menge: 500, einheit: 'g' },
|
||||
bestand: 8,
|
||||
lieferart: 'abholung',
|
||||
widerruf: 'verderblich',
|
||||
},
|
||||
},
|
||||
}])
|
||||
assert.equal(fx.finde(panel, 'form'), null, 'zurück zur Liste')
|
||||
assert.deepEqual(fx.alle(panel, '[data-artikel]').map((li) => li.getAttribute('data-artikel')), ['a1', 'a2', 'a3', 'neu'])
|
||||
assert.deepEqual(meldungen.at(-1), { text: 'Der Artikel wurde angelegt.', art: 'ok' })
|
||||
}))
|
||||
|
||||
test('Kleinunternehmer: Steuersatz ausgeblendet, gesendet wird 0; Bestand leer ⇒ unbegrenzt (null)', mit({
|
||||
einstellungen: { kleinunternehmer: true },
|
||||
sonst: (a) => (a.method === 'POST' ? { status: 201, body: { produkt: artikel('neu', 'X') } } : null),
|
||||
}, async ({ fx, panel }) => {
|
||||
await fx.klick(knopf(fx, panel, 'artikel-neu'))
|
||||
const form = fx.finde(panel, 'form')
|
||||
assert.equal(bereich(fx, form, 'steuersatz').hidden, true)
|
||||
await fx.setze(feldVon(fx, form, 'name'), 'X')
|
||||
await fx.setze(feldVon(fx, form, 'preisCent'), '3')
|
||||
await fx.klick(knopf(fx, form, 'speichern'))
|
||||
const { produkt } = aenderungen(fx)[0].body
|
||||
assert.equal(produkt.steuersatz, 0)
|
||||
assert.equal(produkt.bestand, null)
|
||||
assert.equal(produkt.preisCent, 300)
|
||||
}))
|
||||
|
||||
test('Produktsicherheit und Lebensmittel: Hersteller „ein anderer“, EU-Verantwortlicher nur mit Haken, Pflichtangaben nur für Lebensmittel', mit({
|
||||
sonst: (a) => (a.method === 'POST' ? { status: 201, body: { produkt: artikel('neu', 'Honig') } } : null),
|
||||
}, async ({ fx, panel }) => {
|
||||
await fx.klick(knopf(fx, panel, 'artikel-neu'))
|
||||
const form = fx.finde(panel, 'form')
|
||||
assert.equal(bereich(fx, form, 'hersteller').hidden, true)
|
||||
assert.equal(bereich(fx, form, 'eu').hidden, true)
|
||||
assert.equal(bereich(fx, form, 'lebensmittel').hidden, true)
|
||||
await fx.setze(feldVon(fx, form, 'sicherheit.herstellerArt'), 'andere')
|
||||
assert.equal(bereich(fx, form, 'hersteller').hidden, false)
|
||||
await fx.setze(feldVon(fx, form, 'sicherheit.hersteller.name'), 'Imkerei Berg')
|
||||
await fx.setze(feldVon(fx, form, 'sicherheit.hersteller.anschrift'), 'Dorfstr. 1, 12345 Berg')
|
||||
await fx.setze(feldVon(fx, form, 'sicherheit.hersteller.kontakt'), 'berg@example.test')
|
||||
await fx.klick(feldVon(fx, form, 'sicherheit.ausserhalbEu'))
|
||||
assert.equal(bereich(fx, form, 'eu').hidden, false)
|
||||
await fx.setze(feldVon(fx, form, 'sicherheit.euVerantwortlicher.name'), 'EU Vertretung GmbH')
|
||||
await fx.setze(feldVon(fx, form, 'sicherheit.euVerantwortlicher.anschrift'), 'Hauptstr. 2, 10115 Berlin')
|
||||
await fx.setze(feldVon(fx, form, 'sicherheit.euVerantwortlicher.kontakt'), 'eu@example.test')
|
||||
await fx.setze(feldVon(fx, form, 'sicherheit.hinweise'), 'Nicht für Kinder unter 1 Jahr.')
|
||||
await fx.klick(feldVon(fx, form, 'lebensmittel.aktiv'))
|
||||
assert.equal(bereich(fx, form, 'lebensmittel').hidden, false)
|
||||
assert.match(fx.text(form), /Bezeichnung, Zutaten, Allergene hervorgehoben, Menge, ggf\. Hersteller/)
|
||||
await fx.setze(feldVon(fx, form, 'lebensmittel.pflichtangaben'), 'Honig, 250 g, enthält keine Allergene')
|
||||
await fx.setze(feldVon(fx, form, 'name'), 'Honig')
|
||||
await fx.setze(feldVon(fx, form, 'preisCent'), '6,90')
|
||||
await fx.klick(knopf(fx, form, 'speichern'))
|
||||
const { produkt } = aenderungen(fx)[0].body
|
||||
assert.deepEqual(produkt.sicherheit, {
|
||||
hersteller: { name: 'Imkerei Berg', anschrift: 'Dorfstr. 1, 12345 Berg', kontakt: 'berg@example.test' },
|
||||
euVerantwortlicher: { name: 'EU Vertretung GmbH', anschrift: 'Hauptstr. 2, 10115 Berlin', kontakt: 'eu@example.test' },
|
||||
hinweise: 'Nicht für Kinder unter 1 Jahr.',
|
||||
})
|
||||
assert.deepEqual(produkt.lebensmittel, { aktiv: true, pflichtangaben: 'Honig, 250 g, enthält keine Allergene' })
|
||||
}))
|
||||
|
||||
/* ------------------------------------------------------------------ Varianten --- */
|
||||
test('Varianten: Schalter blendet Preis und Bestand des Artikels aus, Zeilen hinzufügen und entfernen, Körper mit Werten', mit({
|
||||
sonst: (a) => (a.method === 'POST' ? { status: 201, body: { produkt: artikel('neu', 'Shirt') } } : null),
|
||||
}, async ({ fx, panel }) => {
|
||||
await fx.klick(knopf(fx, panel, 'artikel-neu'))
|
||||
const form = fx.finde(panel, 'form')
|
||||
await fx.setze(feldVon(fx, form, 'name'), 'Shirt')
|
||||
await fx.setze(feldVon(fx, form, 'preisCent'), '99')
|
||||
await fx.setze(feldVon(fx, form, 'bestand'), '4')
|
||||
await fx.klick(feldVon(fx, form, 'variantenAn'))
|
||||
assert.equal(bereich(fx, form, 'varianten').hidden, false)
|
||||
assert.equal(bereich(fx, form, 'einzelpreis').hidden, true)
|
||||
assert.equal(bereich(fx, form, 'einzelbestand').hidden, true)
|
||||
assert.equal(fx.alle(form, '[data-wert]').length, 1, 'eine leere Zeile zum Start')
|
||||
await fx.setze(feldVon(fx, form, 'varianten.name'), 'Größe')
|
||||
await fx.setze(feldVon(fx, form, 'varianten.werte.0.name'), 'S')
|
||||
await fx.setze(feldVon(fx, form, 'varianten.werte.0.preisCent'), '10')
|
||||
await fx.setze(feldVon(fx, form, 'varianten.werte.0.bestand'), '5')
|
||||
await fx.klick(knopf(fx, form, 'wert-neu'))
|
||||
await fx.klick(knopf(fx, form, 'wert-neu'))
|
||||
assert.equal(fx.alle(form, '[data-wert]').length, 3)
|
||||
await fx.setze(feldVon(fx, form, 'varianten.werte.1.name'), 'M')
|
||||
await fx.setze(feldVon(fx, form, 'varianten.werte.1.preisCent'), '12,5')
|
||||
await fx.setze(feldVon(fx, form, 'varianten.werte.2.name'), 'weg')
|
||||
await fx.klick(fx.alle(form, '[data-aktion="wert-weg"]')[2])
|
||||
assert.equal(fx.alle(form, '[data-wert]').length, 2)
|
||||
assert.equal(feldVon(fx, form, 'varianten.werte.1.name').value, 'M', 'Eingaben bleiben beim Neuzeichnen erhalten')
|
||||
await fx.klick(knopf(fx, form, 'speichern'))
|
||||
assert.deepEqual(aenderungen(fx)[0].body.produkt, {
|
||||
...LEER_KOERPER,
|
||||
name: 'Shirt',
|
||||
preisCent: null,
|
||||
bestand: null,
|
||||
varianten: { name: 'Größe', werte: [{ name: 'S', preisCent: 1000, bestand: 5 }, { name: 'M', preisCent: 1250, bestand: null }] },
|
||||
})
|
||||
}))
|
||||
|
||||
test('Varianten ausschalten: der Körper trägt wieder Preis und Bestand des Artikels, varianten = null', mit({
|
||||
produkte: DREI(),
|
||||
sonst: (a) => (a.method === 'PUT' ? { body: { produkt: artikel('a3', 'Handschuhe') } } : null),
|
||||
}, async ({ fx, panel }) => {
|
||||
await fx.klick(knopf(fx, zeile(fx, panel, 'a3'), 'bearbeiten'))
|
||||
const form = fx.finde(panel, 'form')
|
||||
assert.equal(feldVon(fx, form, 'variantenAn').checked, true)
|
||||
assert.equal(feldVon(fx, form, 'varianten.name').value, 'Größe')
|
||||
assert.equal(feldVon(fx, form, 'varianten.werte.1.name').value, 'L')
|
||||
assert.equal(feldVon(fx, form, 'varianten.werte.0.preisCent').value, '8,00')
|
||||
await fx.klick(feldVon(fx, form, 'variantenAn'))
|
||||
await fx.setze(feldVon(fx, form, 'preisCent'), '9,99')
|
||||
await fx.klick(knopf(fx, form, 'speichern'))
|
||||
const put = aenderungen(fx)[0]
|
||||
assert.equal(put.url, '/api/shop/produkte/a3')
|
||||
assert.equal(put.body.produkt.varianten, null)
|
||||
assert.equal(put.body.produkt.preisCent, 999)
|
||||
}))
|
||||
|
||||
test('Bearbeiten: Werte-IDs der Varianten bleiben im Körper erhalten', mit({
|
||||
produkte: DREI(),
|
||||
sonst: (a) => (a.method === 'PUT' ? { body: { produkt: artikel('a3', 'Handschuhe') } } : null),
|
||||
}, async ({ fx, panel }) => {
|
||||
await fx.klick(knopf(fx, zeile(fx, panel, 'a3'), 'bearbeiten'))
|
||||
await fx.klick(knopf(fx, fx.finde(panel, 'form'), 'speichern'))
|
||||
assert.deepEqual(aenderungen(fx)[0].body.produkt.varianten.werte.map((w) => w.id), ['aaaaaaaa', 'bbbbbbbb'])
|
||||
assert.deepEqual(aenderungen(fx)[0].body.produkt, koerper(DREI()[2]))
|
||||
}))
|
||||
|
||||
/* --------------------------------------------------------------------- Bilder --- */
|
||||
test('Bilder: roher Upload, Vorschau, „wird hochgeladen“, Titelbild, Entfernen, Bild-IDs im Körper', async () => {
|
||||
let freigabe
|
||||
const t = starte({
|
||||
produkte: [],
|
||||
upload: (aufruf, n) => (n === 1 ? new Promise((ok) => { freigabe = () => ok({ status: 201, body: { imageId: 'bild1', w: 800, h: 600 } }) }) : { status: 201, body: { imageId: `bild${n}` } }),
|
||||
sonst: (a) => (a.method === 'POST' ? { status: 201, body: { produkt: artikel('neu', 'Tasche') } } : null),
|
||||
})
|
||||
const { fx, panel, uploads } = t
|
||||
try {
|
||||
await render(panel, t.ctx)
|
||||
await fx.klick(knopf(fx, panel, 'artikel-neu'))
|
||||
const form = fx.finde(panel, 'form')
|
||||
const eingabe = fx.finde(form, 'input[type="file"]')
|
||||
assert.match(eingabe.getAttribute('accept'), /image\/jpeg,image\/png,image\/webp/)
|
||||
const erste = datei('a.jpg', 'image/jpeg', 1000)
|
||||
eingabe.files = [erste]
|
||||
const lauf = fx.sende(eingabe, 'change')
|
||||
await fx.ruhe()
|
||||
assert.match(fx.text(fx.finde(form, '[data-bild-status]')), /wird hochgeladen/)
|
||||
assert.equal(eingabe.disabled, true)
|
||||
assert.equal(knopf(fx, form, 'speichern').disabled, true, 'während des Uploads nicht speichern')
|
||||
freigabe()
|
||||
await lauf
|
||||
assert.equal(uploads.length, 1)
|
||||
assert.deepEqual(uploads[0], { method: 'POST', url: `/api/shop/bild?projectId=${P}`, datei: erste, credentials: 'same-origin' }, 'rohe Datei, kein JSON')
|
||||
assert.equal(fx.text(fx.finde(form, '[data-bild-status]')), '')
|
||||
assert.equal(eingabe.disabled, false)
|
||||
assert.equal(knopf(fx, form, 'speichern').disabled, false)
|
||||
assert.equal(fx.finde(form, '[data-bild="bild1"] img').getAttribute('src'), `/api/shop/bild/${P}/bild1-s.webp`)
|
||||
assert.match(fx.text(fx.finde(form, '[data-bild="bild1"]')), /Titelbild/)
|
||||
|
||||
for (const name of ['b.png', 'c.webp']) {
|
||||
eingabe.files = [datei(name, name.endsWith('png') ? 'image/png' : 'image/webp', 2000)]
|
||||
await fx.sende(eingabe, 'change')
|
||||
}
|
||||
assert.deepEqual(fx.alle(form, '[data-bild]').map((li) => li.getAttribute('data-bild')), ['bild1', 'bild2', 'bild3'])
|
||||
await fx.klick(knopf(fx, fx.finde(form, '[data-bild="bild3"]'), 'bild-titel'))
|
||||
assert.deepEqual(fx.alle(form, '[data-bild]').map((li) => li.getAttribute('data-bild')), ['bild3', 'bild1', 'bild2'], 'erstes = Titelbild')
|
||||
assert.equal(knopf(fx, fx.finde(form, '[data-bild="bild3"]'), 'bild-titel'), null, 'das Titelbild hat den Knopf nicht')
|
||||
await fx.klick(knopf(fx, fx.finde(form, '[data-bild="bild1"]'), 'bild-weg'))
|
||||
assert.deepEqual(fx.alle(form, '[data-bild]').map((li) => li.getAttribute('data-bild')), ['bild3', 'bild2'])
|
||||
await fx.setze(feldVon(fx, form, 'name'), 'Tasche')
|
||||
await fx.setze(feldVon(fx, form, 'preisCent'), '20')
|
||||
await fx.klick(knopf(fx, form, 'speichern'))
|
||||
assert.deepEqual(aenderungen(fx)[0].body.produkt.bilder, ['bild3', 'bild2'])
|
||||
} finally {
|
||||
fx.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test('Bilder: höchstens vier, danach verschwindet das Feld; zu großes oder falsches Bild wird nicht gesendet', mit({ maxBilder: 2 }, async ({ fx, panel, uploads }) => {
|
||||
await fx.klick(knopf(fx, panel, 'artikel-neu'))
|
||||
const form = fx.finde(panel, 'form')
|
||||
const eingabe = fx.finde(form, 'input[type="file"]')
|
||||
eingabe.files = [datei('riesig.jpg', 'image/jpeg', 9 * 1024 * 1024)]
|
||||
await fx.sende(eingabe, 'change')
|
||||
assert.equal(fehlerVon(fx, form, 'bilder').textContent, 'Das Bild ist größer als 8 MB.')
|
||||
eingabe.files = [datei('x.gif', 'image/gif', 100)]
|
||||
await fx.sende(eingabe, 'change')
|
||||
assert.match(fehlerVon(fx, form, 'bilder').textContent, /JPEG-, PNG- oder WebP/)
|
||||
assert.equal(uploads.length, 0)
|
||||
for (const n of [1, 2]) {
|
||||
eingabe.files = [datei(`${n}.jpg`, 'image/jpeg', 100)]
|
||||
await fx.sende(eingabe, 'change')
|
||||
}
|
||||
assert.equal(fx.alle(form, '[data-bild]').length, 2)
|
||||
assert.equal(eingabe.parentNode.hidden, true, 'Limit erreicht ⇒ kein weiteres Bild')
|
||||
assert.equal(fehlerVon(fx, form, 'bilder').textContent, '', 'ein neuer Versuch löscht den alten Fehler')
|
||||
}))
|
||||
|
||||
test('Bilder: Serverfehler 413 und 415 stehen am Bilder-Abschnitt, kein Bild kommt dazu', async () => {
|
||||
const antworten = [{ status: 413, body: { error: 'Das Bild ist größer als 8 MB.' } }, { status: 415, body: {} }]
|
||||
const t = starte({ upload: () => antworten.shift() })
|
||||
try {
|
||||
await render(t.panel, t.ctx)
|
||||
const { fx, panel } = t
|
||||
await fx.klick(knopf(fx, panel, 'artikel-neu'))
|
||||
const form = fx.finde(panel, 'form')
|
||||
const eingabe = fx.finde(form, 'input[type="file"]')
|
||||
eingabe.files = [datei('a.jpg', 'image/jpeg', 100)]
|
||||
await fx.sende(eingabe, 'change')
|
||||
assert.equal(fehlerVon(fx, form, 'bilder').textContent, 'Das Bild ist größer als 8 MB.')
|
||||
eingabe.files = [datei('b.jpg', 'image/jpeg', 100)]
|
||||
await fx.sende(eingabe, 'change')
|
||||
assert.match(fehlerVon(fx, form, 'bilder').textContent, /JPEG-, PNG- oder WebP/)
|
||||
assert.equal(fx.alle(form, '[data-bild]').length, 0)
|
||||
assert.equal(eingabe.disabled, false)
|
||||
} finally {
|
||||
t.fx.restore()
|
||||
}
|
||||
})
|
||||
|
||||
/* ----------------------------------------------------------------- Feldfehler --- */
|
||||
test('Feldfehler des Servers: Punkt-Pfade stehen am richtigen Feld, zugeklappte Bereiche gehen auf, der Fokus geht zum ersten Fehler', mit({
|
||||
produkte: [artikel('a1', 'Shirt', {
|
||||
preisCent: null,
|
||||
grundpreis: { menge: 500, einheit: 'g' },
|
||||
varianten: { name: 'Größe', werte: [{ id: 'aaaaaaaa', name: 'S', preisCent: 800, bestand: 2 }, { id: 'bbbbbbbb', name: 'M', preisCent: 900, bestand: null }, { id: 'cccccccc', name: 'L', preisCent: 1000, bestand: null }] },
|
||||
sicherheit: { hersteller: { name: 'Fabrik', anschrift: '', kontakt: 'f@example.test' }, euVerantwortlicher: null, hinweise: '' },
|
||||
})],
|
||||
sonst: (a) => (a.method === 'PUT'
|
||||
? {
|
||||
status: 400,
|
||||
body: {
|
||||
error: 'Bitte prüfe deine Eingaben.',
|
||||
fehler: {
|
||||
name: 'Bitte gib einen Namen für den Artikel an.',
|
||||
'grundpreis.menge': 'Bitte gib die Menge für den Grundpreis als Zahl über 0 an.',
|
||||
'varianten.werte.2.preisCent': 'Bitte gib den Preis des Werts als Betrag über 0 € an.',
|
||||
'sicherheit.hersteller.anschrift': 'Bitte gib die Anschrift bei den Hersteller an.',
|
||||
bilder: 'Eines der Bilder gibt es nicht (mehr).',
|
||||
unbekannt: 'Irgendetwas stimmt nicht.',
|
||||
},
|
||||
},
|
||||
}
|
||||
: null),
|
||||
}, async ({ fx, panel }) => {
|
||||
await fx.klick(knopf(fx, zeile(fx, panel, 'a1'), 'bearbeiten'))
|
||||
const form = fx.finde(panel, 'form')
|
||||
const sicherheit = fx.finde(form, 'details')
|
||||
assert.equal(sicherheit.hasAttribute('open'), true, 'Hersteller gesetzt ⇒ Produktsicherheit offen')
|
||||
const lebensmittel = fx.alle(form, 'details')[1]
|
||||
assert.equal(lebensmittel.hasAttribute('open'), false)
|
||||
await fx.klick(knopf(fx, form, 'speichern'))
|
||||
assert.equal(fehlerVon(fx, form, 'name').textContent, 'Bitte gib einen Namen für den Artikel an.')
|
||||
assert.equal(fehlerVon(fx, form, 'grundpreis.menge').textContent, 'Bitte gib die Menge für den Grundpreis als Zahl über 0 an.')
|
||||
assert.equal(fehlerVon(fx, form, 'varianten.werte.2.preisCent').textContent, 'Bitte gib den Preis des Werts als Betrag über 0 € an.')
|
||||
assert.equal(fehlerVon(fx, form, 'sicherheit.hersteller.anschrift').textContent, 'Bitte gib die Anschrift bei den Hersteller an.')
|
||||
assert.equal(fehlerVon(fx, form, 'bilder').textContent, 'Eines der Bilder gibt es nicht (mehr).')
|
||||
assert.equal(fehlerVon(fx, form, '_').textContent, 'Irgendetwas stimmt nicht.', 'Fehler ohne Feld kommen allgemein oben')
|
||||
for (const n of ['name', 'grundpreis.menge', 'varianten.werte.2.preisCent', 'sicherheit.hersteller.anschrift']) {
|
||||
assert.equal(feldVon(fx, form, n).getAttribute('aria-invalid'), 'true', n)
|
||||
}
|
||||
assert.equal(feldVon(fx, form, 'varianten.werte.0.name').getAttribute('aria-invalid'), null)
|
||||
assert.equal(fx.document.activeElement, feldVon(fx, form, 'name'), 'Fokus auf dem ersten Fehler')
|
||||
assert.ok(fx.finde(panel, 'form'), 'der Editor bleibt offen, die Eingaben auch')
|
||||
assert.equal(knopf(fx, form, 'speichern').disabled, false)
|
||||
// ein neuer Versuch räumt die alten Fehler weg
|
||||
await fx.setze(feldVon(fx, form, 'name'), 'Shirt')
|
||||
fx.antworten.unshift(() => ({ body: { produkt: artikel('a1', 'Shirt') } }))
|
||||
await fx.klick(knopf(fx, form, 'speichern'))
|
||||
assert.equal(fx.finde(panel, 'form'), null)
|
||||
}))
|
||||
|
||||
test('Höchstzahl der Artikel: die Meldung des Servers steht im Editor', mit({
|
||||
sonst: (a) => (a.method === 'POST' ? { status: 409, body: { error: 'Du hast die Höchstzahl von 500 Artikeln erreicht. Lösche einen Artikel, bevor du einen neuen anlegst.' } } : null),
|
||||
}, async ({ fx, panel }) => {
|
||||
await fx.klick(knopf(fx, panel, 'artikel-neu'))
|
||||
const form = fx.finde(panel, 'form')
|
||||
await fx.setze(feldVon(fx, form, 'name'), 'Noch einer')
|
||||
await fx.setze(feldVon(fx, form, 'preisCent'), '1')
|
||||
await fx.klick(knopf(fx, form, 'speichern'))
|
||||
assert.match(fehlerVon(fx, form, '_').textContent, /Höchstzahl von 500 Artikeln/)
|
||||
}))
|
||||
|
||||
/* -------------------------------------------------------------------- Löschen --- */
|
||||
test('Löschen: Rückfrage in der Seite, Abbrechen tut nichts, „Ja, löschen“ schickt DELETE und entfernt die Zeile', mit({
|
||||
produkte: DREI(),
|
||||
sonst: (a) => (a.method === 'DELETE' ? { body: { ok: true } } : null),
|
||||
}, async ({ fx, panel, meldungen }) => {
|
||||
const a2 = zeile(fx, panel, 'a2')
|
||||
await fx.klick(knopf(fx, a2, 'loeschen'))
|
||||
assert.match(fx.text(a2), /„Schal“ wirklich löschen\?/)
|
||||
assert.deepEqual(aenderungen(fx), [], 'noch nichts gelöscht')
|
||||
await fx.klick(knopf(fx, a2, 'loeschen-nein'))
|
||||
assert.equal(fx.finde(a2, '.sh-frage-box'), null)
|
||||
assert.deepEqual(aenderungen(fx), [])
|
||||
await fx.klick(knopf(fx, a2, 'loeschen'))
|
||||
await fx.klick(knopf(fx, a2, 'loeschen-ja'))
|
||||
assert.deepEqual(aenderungen(fx), [{ method: 'DELETE', url: `/api/shop/produkte/a2?projectId=${P}`, body: undefined }])
|
||||
assert.deepEqual(fx.alle(panel, '[data-artikel]').map((li) => li.getAttribute('data-artikel')), ['a1', 'a3'])
|
||||
assert.match(meldungen.at(-1).text, /Schal.*gelöscht/)
|
||||
}))
|
||||
|
||||
test('Löschen schlägt fehl: Meldung, Zeile bleibt', mit({
|
||||
produkte: DREI(),
|
||||
sonst: (a) => (a.method === 'DELETE' ? { status: 404, body: { error: 'Diesen Artikel gibt es nicht (mehr).' } } : null),
|
||||
}, async ({ fx, panel, meldungen }) => {
|
||||
await fx.klick(knopf(fx, zeile(fx, panel, 'a2'), 'loeschen'))
|
||||
await fx.klick(knopf(fx, zeile(fx, panel, 'a2'), 'loeschen-ja'))
|
||||
assert.deepEqual(meldungen.at(-1), { text: 'Diesen Artikel gibt es nicht (mehr).', art: 'fehler' })
|
||||
assert.ok(zeile(fx, panel, 'a2'))
|
||||
}))
|
||||
|
||||
/* ------------------------------------------------------------------- nurLesen --- */
|
||||
test('nurLesen: nur die Liste, keine Knöpfe und keine Schalter', mit({ produkte: DREI(), nurLesen: true }, async ({ fx, panel }) => {
|
||||
assert.equal(fx.finde(panel, 'button'), null)
|
||||
assert.equal(fx.finde(panel, 'input'), null)
|
||||
assert.match(fx.text(zeile(fx, panel, 'a1')), /Mütze/)
|
||||
assert.match(fx.text(zeile(fx, panel, 'a1')), /aktiv/)
|
||||
assert.match(fx.text(zeile(fx, panel, 'a2')), /inaktiv/)
|
||||
}))
|
||||
|
||||
test('nurLesen ohne Artikel: Hinweis ohne „Artikel anlegen“', mit({ produkte: [], nurLesen: true }, async ({ fx, panel }) => {
|
||||
assert.match(fx.text(panel), /Es gibt noch keine Artikel\./)
|
||||
assert.equal(fx.finde(panel, 'button'), null)
|
||||
}))
|
||||
|
||||
/* --------------------------------------------------------------------- Statik --- */
|
||||
test('Statik: kein innerHTML, outerHTML, insertAdjacentHTML oder document.write; Fremdtext nur über h()', () => {
|
||||
const quelle = readFileSync(new URL('../public/shop-tab-artikel.js', import.meta.url), 'utf8')
|
||||
const code = quelle.replace(/\/\*[\s\S]*?\*\//g, '').split('\n').filter((z) => !z.trim().startsWith('//')).join('\n')
|
||||
for (const verboten of ['innerHTML', 'outerHTML', 'insertAdjacentHTML', 'document.write', 'eval(', 'new Function']) {
|
||||
assert.equal(code.includes(verboten), false, verboten)
|
||||
}
|
||||
assert.doesNotMatch(code, /from '\.\/termine/, 'keine Importe aus termine*.js')
|
||||
})
|
||||
Reference in new issue
Block a user