diff --git a/public/shop-tab-artikel.js b/public/shop-tab-artikel.js index 8ad4dbd..792ad5a 100644 --- a/public/shop-tab-artikel.js +++ b/public/shop-tab-artikel.js @@ -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/-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() } diff --git a/public/shop.css b/public/shop.css index 286b25f..cdbd296 100644 --- a/public/shop.css +++ b/public/shop.css @@ -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); } +} diff --git a/test/shopTabArtikel.test.js b/test/shopTabArtikel.test.js new file mode 100644 index 0000000..273afbb --- /dev/null +++ b/test/shopTabArtikel.test.js @@ -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') +})