From 738afa2d0e995b94a422a3ae649f91b71de340e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:05:01 +0000 Subject: [PATCH] =?UTF-8?q?Shop=20G2:=20H=C3=BClle=20#webshop=20mit=20Anfr?= =?UTF-8?q?age,=20Bestellseite,=20Statuskarte=20und=20f=C3=BCnf=20Reitern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eigene Helfer in shop-dom.js (inkl. euroEingabe, datumText), Anfrageformular und -karten in shop-anfrage.js, Reiter als lazy geladene Platzhalter-Module für G3 bis G6. Die Bestellseite zeigt den Hinweis zu „Ändern“ nur noch, wenn es Schritte gibt. Co-Authored-By: Claude Opus 5.5 --- public/funktion-bestellen.js | 2 +- public/shop-anfrage.js | 167 +++++++++ public/shop-dom.js | 178 ++++++++++ public/shop-tab-artikel.js | 8 + public/shop-tab-bestellungen.js | 8 + public/shop-tab-einstellungen.js | 8 + public/shop-tab-rechtliches.js | 8 + public/shop-tab-uebersicht.js | 8 + public/shop.css | 89 ++++- public/shop.js | 371 +++++++++++++++++++- test/shopView.test.js | 576 +++++++++++++++++++++++++++++++ 11 files changed, 1407 insertions(+), 16 deletions(-) create mode 100644 public/shop-anfrage.js create mode 100644 public/shop-dom.js create mode 100644 public/shop-tab-artikel.js create mode 100644 public/shop-tab-bestellungen.js create mode 100644 public/shop-tab-einstellungen.js create mode 100644 public/shop-tab-rechtliches.js create mode 100644 public/shop-tab-uebersicht.js create mode 100644 test/shopView.test.js diff --git a/public/funktion-bestellen.js b/public/funktion-bestellen.js index a32d0ae..364ff1e 100644 --- a/public/funktion-bestellen.js +++ b/public/funktion-bestellen.js @@ -207,7 +207,7 @@ function baueSeite(root, { ctx, daten, zumSchritt, key, gruppenFn }, uebernahme) const seite = h('section', { class: 'kb-panel tm-bestellen' }, h('h3', null, 'Prüfen und bestellen'), - gruppenFn ? h('p', { class: 'tm-abschnitt-text tm-hinweis' }, 'Lies alles in Ruhe durch. Mit „Ändern“ kommst du zu dem Schritt zurück, in dem du die Angabe gemacht hast.') : null, + gruppenFn ? h('p', { class: 'tm-abschnitt-text tm-hinweis' }, springt ? 'Lies alles in Ruhe durch. Mit „Ändern“ kommst du zu dem Schritt zurück, in dem du die Angabe gemacht hast.' : 'Lies alles in Ruhe durch.') : null, gruppenFn ? h('div', { class: 'tm-bestellen-zusammenfassung' }, gruppen) : null, offenBlock, preisBlock, diff --git a/public/shop-anfrage.js b/public/shop-anfrage.js new file mode 100644 index 0000000..ed26364 --- /dev/null +++ b/public/shop-anfrage.js @@ -0,0 +1,167 @@ +/* Webshop im Kundenbereich: die Anfrage („auf Anfrage“, Zustand off). Das Formular (renderAnfrageFormular), die Karte einer offenen oder + abgelehnten Anfrage (anfrageKarte) und die Zusammenfassung der Angaben (anfrageZeilen, auch fuer die Bestellseite). + Schnittstelle (server/routes/shop.js): POST /api/shop/anfrage { projectId, anfrage: { warenart, beschreibung, artikelzahl, versand, abholung, + unternehmer, keineAusgeschlossenen, nachricht } } und POST /api/shop/anfrage/zurueckziehen { projectId }. Geprueft wird nur auf dem Server + (Feldfehler kommen als err.fehler zurueck und stehen am Feld). Text kommt nur ueber textContent in die Seite (h). */ + +import { allgemeinerFehler, datumText, feld, h, hakenFeld, request, setzeFehler, zeigeFehler } from './shop-dom.js' + +export const WARENARTEN = [['waren', 'Waren'], ['lebensmittel', 'Lebensmittel'], ['kleidung', 'Kleidung'], ['kunst', 'Kunst'], ['sonstiges', 'Sonstiges']] +export const ARTIKELZAHLEN = [['bis20', 'bis 20'], ['bis100', 'bis 100'], ['mehr', 'mehr als 100']] +export const AUSGESCHLOSSEN = 'Alkohol, Tabak/E-Zigaretten, Arzneimittel, Waffen/Feuerwerk, Erotik, lebende Tiere, digitale Inhalte, Gutscheine' + +const labelVon = (liste, key) => liste.find(([k]) => k === key)?.[1] || '' +const text = (wert) => (typeof wert === 'string' ? wert : '') + +/** Angaben der Anfrage als Zeilen [{ name, wert }] (Gruppe der Bestellseite und Zusammenfassung der Karte). */ +export function anfrageZeilen(anfrage) { + const a = anfrage && typeof anfrage === 'object' ? anfrage : {} + const wege = [a.versand === true ? 'Versand' : '', a.abholung === true ? 'Abholung' : ''].filter(Boolean).join(' und ') + return [ + ['Was Du verkaufst', labelVon(WARENARTEN, a.warenart)], + ['Beschreibung', text(a.beschreibung)], + ['Artikel', labelVon(ARTIKELZAHLEN, a.artikelzahl)], + ['Lieferung', wege], + ['Nachricht', text(a.nachricht)], + ].filter(([, wert]) => wert !== '').map(([name, wert]) => ({ name, wert })) +} + +/** Die Angaben als Definitionsliste (nur Text). */ +export const anfrageListe = (anfrage) => h('dl', { class: 'sh-fakten' }, anfrageZeilen(anfrage).map(({ name, wert }) => h('div', null, h('dt', null, name), h('dd', null, wert)))) + +/** + * Formular „Webshop anfragen“ in root. optionen: projectId, vorgabe (frueherer Stand der Anfrage zum Vorausfuellen), preis ('299 €'), + * ustHinweis (Satz zur Umsatzsteuer), nachSenden(anfrage) (die Anfrage ist gespeichert: die Ansicht zeichnet sich neu). + * Der Server prueft alles; ein 409 (z. B. laeuft schon eine Anfrage) zeigt seinen Text und laesst die Ansicht neu laden. + */ +export function renderAnfrageFormular(root, { projectId, vorgabe = null, preis = '299 €', ustHinweis = '', nachSenden } = {}) { + const v = vorgabe && typeof vorgabe === 'object' ? vorgabe : {} + const auswahl = (liste, aktuell) => { + const optionen = [h('option', { value: '' }, 'Bitte wählen …'), ...liste.map(([wert, name]) => h('option', { value: wert }, name))] + const select = h('select', null, optionen) + select.value = aktuell || '' + return select + } + const warenart = auswahl(WARENARTEN, v.warenart) + const artikelzahl = auswahl(ARTIKELZAHLEN, v.artikelzahl) + const beschreibung = h('textarea', { rows: 4, maxlength: 1500 }) + beschreibung.value = text(v.beschreibung) + const versand = h('input', { type: 'checkbox' }) + versand.checked = v.versand === true + const abholung = h('input', { type: 'checkbox' }) + abholung.checked = v.abholung === true + const unternehmer = h('input', { type: 'checkbox' }) + const ausgeschlossen = h('input', { type: 'checkbox' }) + const nachricht = h('textarea', { rows: 3, maxlength: 1000 }) + nachricht.value = text(v.nachricht) + const absenden = h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'anfrage-senden' }, 'Anfrage senden') + + const form = h('form', { class: 'sh-form', novalidate: true, 'aria-label': 'Webshop anfragen' }, + feld('warenart', 'Was möchtest Du verkaufen?', warenart), + feld('beschreibung', 'Beschreibe kurz, was Du verkaufst', beschreibung, 'Zum Beispiel: handgemachte Kerzen, Bio-Honig oder Fanartikel eines Vereins.'), + feld('artikelzahl', 'Wie viele Artikel möchtest Du etwa anbieten?', artikelzahl), + h('fieldset', { class: 'sh-gruppe' }, + h('legend', null, 'Wie bekommen Deine Kunden die Ware?'), + hakenFeld('versand', versand, 'Versand'), + hakenFeld('abholung', abholung, 'Abholung')), + hakenFeld('unternehmer', unternehmer, 'Ich verkaufe als Unternehmer (nicht privat).'), + hakenFeld('keineAusgeschlossenen', ausgeschlossen, `Ich verkaufe keine ausgeschlossenen Waren (${AUSGESCHLOSSEN}).`), + feld('nachricht', 'Noch etwas, das wir wissen sollten? (freiwillig)', nachricht), + allgemeinerFehler(), + h('div', { class: 'sh-aktionen' }, absenden)) + + let laeuft = false + form.addEventListener('submit', async (ev) => { + ev.preventDefault() + if (laeuft) return + laeuft = true + absenden.disabled = true + setzeFehler(form, {}) + try { + const antwort = await request('/api/shop/anfrage', { + method: 'POST', + body: { + projectId, + anfrage: { + warenart: warenart.value, + beschreibung: beschreibung.value.trim(), + artikelzahl: artikelzahl.value, + versand: versand.checked, + abholung: abholung.checked, + unternehmer: unternehmer.checked, + keineAusgeschlossenen: ausgeschlossen.checked, + nachricht: nachricht.value.trim(), + }, + }, + }) + if (typeof nachSenden === 'function') await nachSenden(antwort?.anfrage ?? null) + return + } catch (err) { + zeigeFehler(form, err) + if (err?.status === 409 && typeof nachSenden === 'function') { + // der Stand hat sich geaendert (z. B. laeuft schon eine Anfrage): Ansicht neu laden, die Meldung des Servers bleibt am Formular stehen + try { await nachSenden(null) } catch { /* der Fehler steht schon am Formular */ } + return + } + } + laeuft = false + absenden.disabled = false + }) + + const erklaerung = h('div', { class: 'sh-erklaerung' }, + h('h3', null, 'Webshop anfragen'), + h('p', null, 'Mit dem Webshop verkaufst Du direkt auf Deiner Website: Deine Kunden legen Artikel in den Warenkorb und bezahlen online.'), + h('ul', null, + h('li', null, `${preis} einmalig für den Einbau in Deine Website, keine laufende Gebühr von WEBklar.`), + h('li', null, 'Die Zahlungen gehen direkt auf Dein eigenes Stripe-Konto. Die Gebühren von Stripe zahlst Du direkt an Stripe.'), + h('li', null, 'Deine Kunden zahlen mit Karte, Apple Pay, Google Pay, Klarna oder SEPA-Lastschrift.'), + h('li', null, 'PayPal ist über Stripe leider nicht möglich.'), + h('li', null, 'Wir sehen uns Deine Anfrage kurz an. Erst nach unserer Zusage kannst Du bestellen.')), + ustHinweis ? h('p', { class: 'sh-hinweis' }, ustHinweis) : null) + + const karte = h('section', { class: 'kb-panel sh-anfrage' }, erklaerung, form) + root.replaceChildren(karte) + return karte +} + +/** + * Karte einer Anfrage im Stand offen (wird geprueft, mit „Anfrage zurückziehen“) oder abgelehnt (Begruendung, „Neue Anfrage stellen“). + * ansicht = status.anfrage (anfrageAnsicht des Servers), optionen: projectId, neu() (Formular zeigen), neuLaden() (Stand holen), + * kundenansicht (sperrt das Zurueckziehen). + */ +export function anfrageKarte(ansicht, { projectId, neu, neuLaden } = {}) { + const offen = ansicht?.status === 'offen' + const meldung = h('p', { class: 'sh-fehler', role: 'alert', 'data-meldung': 'anfrage' }) + const datum = datumText(ansicht?.at) + let aktion + if (offen) { + aktion = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zurueckziehen' }, 'Anfrage zurückziehen') + aktion.addEventListener('click', async () => { + if (aktion.disabled) return + aktion.disabled = true + meldung.textContent = '' + try { + await request('/api/shop/anfrage/zurueckziehen', { method: 'POST', body: { projectId } }) + if (typeof neuLaden === 'function') await neuLaden() + return + } catch (err) { + meldung.textContent = err.message + // 409: die Anfrage ist inzwischen entschieden; der neue Stand erscheint + if (err?.status === 409 && typeof neuLaden === 'function') { try { await neuLaden() } catch { /* Meldung bleibt */ } return } + } + aktion.disabled = false + }) + } else { + aktion = h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'neue-anfrage', onclick: () => { if (typeof neu === 'function') neu() } }, 'Neue Anfrage stellen') + } + return h('section', { class: 'kb-panel sh-info sh-anfrage-karte', 'data-anfrage': offen ? 'offen' : 'abgelehnt' }, + h('span', { class: `sh-pille ${offen ? 'sh-pille-wait' : 'sh-pille-bad'}` }, offen ? 'In Prüfung' : 'Abgelehnt'), + h('h3', null, offen ? 'Deine Anfrage wird geprüft' : 'Deine Anfrage wurde leider abgelehnt'), + offen + ? h('p', null, `${datum ? `Du hast Deine Anfrage am ${datum} Uhr gesendet. ` : ''}Wir melden uns, sobald wir sie angesehen haben. Bis dahin kannst Du sie zurückziehen und eine neue stellen.`) + : h('p', null, 'Unsere Begründung:'), + !offen ? h('blockquote', { class: 'sh-begruendung' }, text(ansicht?.begruendung) || 'Dazu liegt keine Begründung vor.') : null, + ansicht?.anfrage ? h('div', { class: 'sh-zusammenfassung' }, h('h4', null, 'Deine Angaben'), anfrageListe(ansicht.anfrage)) : null, + meldung, + h('div', { class: 'sh-aktionen' }, aktion, h('button', { type: 'button', class: 'kb-btn-outline', 'data-view': 'chat' }, 'Frage dazu? Chat öffnen'))) +} diff --git a/public/shop-dom.js b/public/shop-dom.js new file mode 100644 index 0000000..94a5683 --- /dev/null +++ b/public/shop-dom.js @@ -0,0 +1,178 @@ +/* Webshop im Kundenbereich: Helfer ohne Zustand (Elemente bauen, Schnittstelle, Formularfelder, Hinweiskarten, Euro und Datum). + Eigene Kopie der Helfer aus termine-dom.js (die Webshop-Dateien importieren nichts aus termine*.js); Klassen mit sh-. + Alle Module der Ansicht (shop.js, shop-anfrage.js, shop-tab-*.js) bauen die Seite nur hiermit: Kundentext kommt ausschliesslich + ueber textContent in die Seite (h), nie ueber innerHTML. */ + +/** Element bauen: Texte nur als Textknoten, Attribute mit on… als Ereignis, true setzt ein Attribut ohne Wert, false/null/undefined lassen es weg. */ +export function h(tag, attrs, ...kinder) { + const el = document.createElement(tag) + for (const [name, wert] of Object.entries(attrs || {})) { + if (wert === undefined || wert === null || wert === false) continue + if (name === 'class') el.className = wert + else if (name.startsWith('on')) el.addEventListener(name.slice(2), wert) + else el.setAttribute(name, wert === true ? '' : wert) + } + for (const kind of kinder.flat()) if (kind !== undefined && kind !== null && kind !== false) el.append(kind) + return el +} + +/** Schnittstelle: JSON hin und her; Fehler als Error mit status, fehler (Feldfehler), code und body (ganze Antwort), Netzwerkfehler mit status 0. */ +export async function request(path, { method = 'GET', body } = {}) { + let response + try { + response = await fetch(path, { + method, + credentials: 'same-origin', + headers: body ? { 'Content-Type': 'application/json' } : {}, + body: body ? JSON.stringify(body) : undefined, + }) + } catch { + throw Object.assign(new Error('Keine Verbindung. Bitte versuch es gleich noch einmal.'), { status: 0, fehler: {} }) + } + const data = await response.json().catch(() => ({})) + if (!response.ok) { + const err = new Error(data.error || (response.status === 502 ? 'Server nicht erreichbar. Bitte kurz warten und noch einmal versuchen.' : `Fehler ${response.status}`)) + err.status = response.status + err.fehler = data.fehler || {} + err.code = data.code + err.body = data !== null && typeof data === 'object' ? data : {} + throw err + } + return data +} + +let idZaehler = 0 +const neueId = () => `sh-f${++idZaehler}` + +/** Formularfeld mit Beschriftung, Hinweis und Platz fuer den Fehler (data-feld = Feldname der Schnittstelle). */ +export function feld(name, label, steuer, hinweis) { + const id = steuer.id || (steuer.id = neueId()) + steuer.dataset.feld = name + return h('div', { class: 'sh-feld' }, + h('label', { for: id }, label), + steuer, + hinweis ? h('p', { class: 'sh-hinweis' }, hinweis) : null, + h('p', { class: 'sh-fehler', 'data-fehler': name, role: 'alert' })) +} + +/** Ankreuzfeld (input type=checkbox) mit Beschriftung, Hinweis und Platz fuer den Fehler. */ +export function hakenFeld(name, steuer, text, hinweis) { + steuer.dataset.feld = name + return h('div', { class: 'sh-feld sh-feld-haken' }, + h('label', { class: 'sh-haken' }, steuer, h('span', null, text, hinweis ? h('small', { class: 'sh-hinweis' }, hinweis) : null)), + h('p', { class: 'sh-fehler', 'data-fehler': name, role: 'alert' })) +} + +/** Platz fuer Fehler, die zu keinem Feld gehoeren (Feldname „_“). */ +export const allgemeinerFehler = () => h('p', { class: 'sh-fehler', 'data-fehler': '_', role: 'alert' }) + +export function setzeFehler(form, fehler) { + for (const el of form.querySelectorAll('[data-fehler]')) el.textContent = '' + for (const el of form.querySelectorAll('[aria-invalid]')) el.removeAttribute('aria-invalid') + const rest = [] + for (const [name, text] of Object.entries(fehler || {})) { + // „_“ und Namen ohne Platz im Formular werden allgemein gezeigt + const ziel = name === '_' ? null : form.querySelector(`[data-fehler="${CSS.escape(name)}"]`) + if (ziel) { + ziel.textContent = text + form.querySelector(`[data-feld="${CSS.escape(name)}"]`)?.setAttribute('aria-invalid', 'true') + } else { + rest.push(text) + } + } + const allgemein = form.querySelector('[data-fehler="_"]') + if (allgemein) allgemein.textContent = rest.join(' ') +} + +/** Fehler der Schnittstelle am Formular zeigen (Feldfehler am Feld, sonst allgemein); der Fokus geht auf das erste fehlerhafte Feld. */ +export function zeigeFehler(form, err) { + const fehler = err.fehler && Object.keys(err.fehler).length ? err.fehler : { _: err.message } + setzeFehler(form, fehler) + form.querySelector('[aria-invalid]')?.focus() +} + +/** Rueckfrage in der Seite (kein confirm()): Formular mit Titel; der Fokus gehoert dem Aufrufer. */ +export function frage(titel, ...inhalt) { + return h('form', { class: 'sh-frage-box', novalidate: true, tabindex: '-1', 'aria-label': titel, onsubmit: (ev) => ev.preventDefault() }, h('strong', null, titel), ...inhalt) +} + +/** Abschnitt eines Reiters: Karte mit Ueberschrift und einem erklaerenden Satz (hinweis darf fehlen). */ +export const abschnitt = (titel, hinweis, ...inhalt) => h('section', { class: 'kb-panel sh-abschnitt' }, + h('h3', null, titel), + hinweis ? h('p', { class: 'sh-hinweis sh-abschnitt-text' }, hinweis) : null, + ...inhalt) + +export const infoKarte = (titel, text, { chat = false } = {}) => h('div', { class: 'kb-panel sh-info' }, + h('h3', null, titel), + text ? h('p', null, text) : null, + chat ? h('button', { type: 'button', class: 'kb-btn-outline', 'data-view': 'chat' }, 'Chat öffnen') : null) + +export const fehlerKarte = (text, nochmal) => h('div', { class: 'kb-panel sh-info', role: 'alert' }, + h('p', null, text), + nochmal ? h('button', { type: 'button', class: 'kb-btn-outline', onclick: nochmal }, 'Noch einmal versuchen') : null) + +/* ------------------------------------------------------------------ Euro und Datum --- */ +/** + * Euro aus einem Feld in Cent (nur Anzeige und Vorpruefung, den Betrag prueft der Server). Deutsche Schreibweise: ',' trennt die Cent, + * '.' die Tausender ('1.234,50'); '45.50' und '12.5' gelten als Euro und Cent. null bei allem anderen (leer, Buchstaben, mehr als zwei + * Nachkommastellen). + */ +export function euroZuCent(text) { + if (typeof text !== 'string') return null + const s = text.replace(/€/g, '').trim() + if (!/^[\d.,]+$/.test(s)) return null + let ganz = s + let rest = '' + const komma = s.indexOf(',') + if (komma >= 0) { + ganz = s.slice(0, komma) + rest = s.slice(komma + 1) + if (/[.,]/.test(rest)) return null + if (ganz.includes('.') && !/^\d{1,3}(\.\d{3})+$/.test(ganz)) return null + ganz = ganz.replaceAll('.', '') + } else if (/^\d{1,3}(\.\d{3})+$/.test(s)) { + ganz = s.replaceAll('.', '') + } else if (s.includes('.')) { + const teile = s.split('.') + if (teile.length !== 2) return null + ;[ganz, rest] = teile + } + if (!/^\d{1,9}$/.test(ganz) || !/^\d{0,2}$/.test(rest)) return null + return Number(ganz) * 100 + Number(rest.padEnd(2, '0')) +} + +/** 1250 ⇒ '12,50' (Feldwert); unbrauchbar ⇒ ''. */ +export const centZuEuro = (cent) => (Number.isInteger(cent) && cent >= 0 ? `${Math.floor(cent / 100)},${String(cent % 100).padStart(2, '0')}` : '') + +/** 1250 ⇒ '12,50 €' (mit Tausenderpunkt, geschuetztes Leerzeichen vor €); unbrauchbar ⇒ ''. */ +export function euroText(cent) { + if (!Number.isInteger(cent) || cent < 0) return '' + return `${String(Math.floor(cent / 100)).replace(/\B(?=(\d{3})+(?!\d))/g, '.')},${String(cent % 100).padStart(2, '0')} €` +} + +/** + * Eingabefeld fuer einen Euro-Betrag (Text, nie type=number: Komma statt Punkt). Der Wert steht als '12,50' im Feld; beim Verlassen wird eine + * lesbare Eingabe ('12,5', '12.50 €') in diese Schreibweise gebracht. steuer.inCent() liefert die Cent (null bei leer oder unlesbar), + * steuer.istLeer() und steuer.istLesbar() erklaeren warum. Gesendet wird, was der Aufrufer daraus macht; der Server prueft den Betrag. + */ +export function euroEingabe({ cent = null, id, placeholder = '0,00', label } = {}) { + const steuer = h('input', { type: 'text', inputmode: 'decimal', autocomplete: 'off', placeholder, id, 'aria-label': label }) + steuer.value = centZuEuro(cent) + steuer.istLeer = () => String(steuer.value).trim() === '' + steuer.inCent = () => (steuer.istLeer() ? null : euroZuCent(String(steuer.value))) + steuer.istLesbar = () => steuer.istLeer() || steuer.inCent() !== null + steuer.addEventListener('change', () => { + const c = steuer.inCent() + if (c !== null) steuer.value = centZuEuro(c) + }) + return steuer +} + +/** Datum und Uhrzeit in Berlin als '05.10.2026, 14:30'; nur das Datum mit { zeit: false }; bei fehlendem oder ungueltigem Wert ''. */ +export function datumText(wert, { zeit = true } = {}) { + const tag = new Date(wert) + if (!wert || Number.isNaN(tag.getTime())) return '' + const optionen = { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' } + if (zeit) Object.assign(optionen, { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }) + return tag.toLocaleString('de-DE', optionen) +} diff --git a/public/shop-tab-artikel.js b/public/shop-tab-artikel.js new file mode 100644 index 0000000..8ad4dbd --- /dev/null +++ b/public/shop-tab-artikel.js @@ -0,0 +1,8 @@ +/* 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). */ + +import { h } from './shop-dom.js' + +export function render(panel) { + panel.replaceChildren(h('p', { class: 'sh-hinweis' }, 'Kommt gleich …')) +} diff --git a/public/shop-tab-bestellungen.js b/public/shop-tab-bestellungen.js new file mode 100644 index 0000000..3432ae6 --- /dev/null +++ b/public/shop-tab-bestellungen.js @@ -0,0 +1,8 @@ +/* Webshop, Reiter „Bestellungen“: 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). */ + +import { h } from './shop-dom.js' + +export function render(panel) { + panel.replaceChildren(h('p', { class: 'sh-hinweis' }, 'Kommt gleich …')) +} diff --git a/public/shop-tab-einstellungen.js b/public/shop-tab-einstellungen.js new file mode 100644 index 0000000..1e5f3c4 --- /dev/null +++ b/public/shop-tab-einstellungen.js @@ -0,0 +1,8 @@ +/* Webshop, Reiter „Einstellungen“: 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). */ + +import { h } from './shop-dom.js' + +export function render(panel) { + panel.replaceChildren(h('p', { class: 'sh-hinweis' }, 'Kommt gleich …')) +} diff --git a/public/shop-tab-rechtliches.js b/public/shop-tab-rechtliches.js new file mode 100644 index 0000000..e51a402 --- /dev/null +++ b/public/shop-tab-rechtliches.js @@ -0,0 +1,8 @@ +/* Webshop, Reiter „Rechtliches“: 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). */ + +import { h } from './shop-dom.js' + +export function render(panel) { + panel.replaceChildren(h('p', { class: 'sh-hinweis' }, 'Kommt gleich …')) +} diff --git a/public/shop-tab-uebersicht.js b/public/shop-tab-uebersicht.js new file mode 100644 index 0000000..e57ff49 --- /dev/null +++ b/public/shop-tab-uebersicht.js @@ -0,0 +1,8 @@ +/* Webshop, Reiter „Übersicht“: 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). */ + +import { h } from './shop-dom.js' + +export function render(panel) { + panel.replaceChildren(h('p', { class: 'sh-hinweis' }, 'Kommt gleich …')) +} diff --git a/public/shop.css b/public/shop.css index b51904f..c013ad6 100644 --- a/public/shop.css +++ b/public/shop.css @@ -1,9 +1,94 @@ /* Webshop im Kundenbereich (shop.js): Farben und Abstaende aus den Tokens von kundenbereich.css (.kb-page); nur Klassen mit sh-. - Task G1 legt nur die Verdrahtung an, die Oberflaeche (Reiter, Karten, Formulare) kommt mit G2 bis G6. */ + Aufbau wie termine.css: Hinweise und Meldungen, Reiter (5, auf dem Handy waagerecht scrollbar), Karten, Formulare. Die Bestellseite und + die Statuskarte (funktion-bestellen.js) tragen weiter ihre tm-Klassen aus termine.css, die dashboard.html immer mitlaedt. */ #view-webshop .kb-panel { max-width: none; } +#view-webshop .fn-project { margin-bottom: 16px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 14px 18px; } #view-webshop button:focus-visible, #view-webshop input:focus-visible, #view-webshop select:focus-visible, #view-webshop textarea:focus-visible, -#view-webshop a:focus-visible { outline: 3px solid #d4b4ff; outline-offset: 2px; } +#view-webshop summary:focus-visible, #view-webshop a:focus-visible { outline: 3px solid #d4b4ff; outline-offset: 2px; } +.sh-sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } +/* -------------------------------------------------- Hinweise und Meldungen --- */ +.sh-box { display: flex; align-items: center; justify-content: space-between; gap: 10px 16px; flex-wrap: wrap; margin-bottom: 16px; padding: 12px 16px; border-radius: 12px; font-size: 14px; line-height: 1.5; } +.sh-box-warn { background: rgba(251, 191, 36, 0.12); border: 1px solid rgba(251, 191, 36, 0.45); } +.sh-box-info { background: var(--kb-purple-soft); border: 1px solid var(--kb-purple-line); } +.sh-hinweise:empty { display: none; } +.sh-meldung { margin: 0 0 16px; padding: 12px 16px; border-radius: 12px; font-size: 14px; line-height: 1.5; scroll-margin-top: calc(var(--kb-appbar-h, 76px) + 12px); } +/* leer bleibt die Live-Region im Dokument (sonst liest ein Screenreader die naechste Meldung nicht vor), nimmt aber keinen Platz */ +.sh-meldung:empty { margin: 0; padding: 0; border: 0; } +.sh-meldung:focus { outline: none; } +.sh-meldung-ok { background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4); } +.sh-meldung-fehler { background: rgba(248, 113, 113, 0.1); border: 1px solid rgba(248, 113, 113, 0.5); color: #fecaca; } .sh-info p { margin: 0 0 12px; line-height: 1.55; color: var(--kb-muted); } .sh-info p:last-child { margin-bottom: 0; } + +/* ----------------------------------------------------------------- Reiter --- */ +/* fuenf Reiter in einer Zeile; wenn der Platz nicht reicht (Handy), scrollt die Leiste waagerecht statt umzubrechen */ +.sh-reiterleiste { display: flex; flex-wrap: nowrap; gap: 6px; margin-bottom: 18px; padding-bottom: 12px; border-bottom: 1px solid var(--kb-border); overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: thin; } +.sh-reiter { + flex: none; padding: 10px 16px; border-radius: 999px; border: 1px solid transparent; background: transparent; color: var(--kb-muted); + font: inherit; font-size: 14px; font-weight: 700; white-space: nowrap; cursor: pointer; +} +.sh-reiter:hover { color: #fff; background: rgba(255, 255, 255, 0.06); } +.sh-reiter[aria-selected='true'] { color: #fff; background: var(--kb-purple-soft); border-color: var(--kb-purple-line); } +.sh-panel:focus { outline: none; } + +/* ------------------------------------------------------------ Karten, Pillen --- */ +.sh-abschnitt { margin-bottom: 16px; } +.sh-abschnitt h3 { margin-bottom: 4px; } +.sh-abschnitt h4 { margin: 18px 0 4px; font-size: 14.5px; } +.sh-abschnitt-text { margin: 0 0 14px; line-height: 1.55; } +.sh-pille { display: inline-block; margin-bottom: 8px; padding: 3px 11px; border-radius: 999px; font-size: 12px; font-weight: 700; white-space: nowrap; } +.sh-pille-ok { color: #86efac; background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4); } +.sh-pille-wait { color: #fcd34d; background: rgba(251, 191, 36, 0.1); border: 1px solid rgba(251, 191, 36, 0.4); } +.sh-pille-off { color: var(--kb-muted); background: rgba(255, 255, 255, 0.05); border: 1px solid var(--kb-border-strong); } +.sh-pille-bad { color: #fca5a5; background: rgba(248, 113, 113, 0.1); border: 1px solid rgba(248, 113, 113, 0.45); } +.sh-aktionen { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; } +.sh-fakten { display: grid; gap: 8px; margin: 12px 0 0; } +.sh-fakten > div { display: grid; grid-template-columns: minmax(120px, 190px) minmax(0, 1fr); gap: 4px 14px; } +.sh-fakten dt { font-size: 13px; font-weight: 600; color: var(--kb-muted); } +.sh-fakten dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; } +.sh-begruendung { margin: 0 0 12px; padding: 10px 14px; border-left: 3px solid var(--kb-purple-line); background: rgba(255, 255, 255, 0.04); border-radius: 0 10px 10px 0; line-height: 1.55; overflow-wrap: anywhere; white-space: pre-wrap; } +.sh-zusammenfassung { margin-top: 16px; } +.sh-zusammenfassung h4 { margin: 0 0 4px; font-size: 14.5px; } + +/* ------------------------------------------------------------------ Anfrage --- */ +.sh-anfrage h3 { margin-bottom: 6px; } +.sh-erklaerung p { margin: 0 0 10px; line-height: 1.55; color: var(--kb-muted); } +.sh-erklaerung ul { margin: 0 0 12px; padding-left: 20px; line-height: 1.6; } +.sh-erklaerung li { margin-bottom: 4px; } +.sh-form { margin-top: 18px; padding-top: 6px; border-top: 1px solid var(--kb-border); } + +/* ------------------------------------------------------------------ Formulare --- */ +.sh-feld { display: grid; gap: 6px; margin: 12px 0 0; min-width: 0; } +.sh-feld > label, .sh-label { font-size: 13px; font-weight: 600; } +.sh-feld input:not([type=checkbox]):not([type=radio]), .sh-feld select, .sh-feld textarea { + width: 100%; box-sizing: border-box; padding: 11px 12px; border-radius: 12px; font: inherit; font-size: 16px; resize: vertical; + border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit; +} +.sh-feld select option { color: #111; } +.sh-feld [aria-invalid='true'] { border-color: #f87171; } +.sh-feld input:focus, .sh-feld select:focus, .sh-feld textarea:focus { border-color: var(--kb-purple-line); box-shadow: 0 0 0 3px var(--kb-purple-soft); } +.sh-hinweis { margin: 0; font-size: 12.5px; color: var(--kb-muted); } +.sh-fehler { margin: 0; font-size: 13px; color: #fca5a5; } +.sh-fehler:empty { display: none; } +.sh-gruppe { margin: 14px 0 0; padding: 0; border: 0; min-width: 0; } +.sh-gruppe legend { padding: 0; font-size: 13px; font-weight: 600; } +.sh-haken { display: flex; align-items: flex-start; gap: 10px; margin: 8px 0 4px; font-size: 14px; line-height: 1.5; cursor: pointer; } +.sh-haken input { flex: none; width: 18px; height: 18px; margin-top: 2px; accent-color: var(--kb-purple); } +.sh-haken > span { font-weight: 600; } +.sh-haken small { display: block; margin-top: 2px; font-weight: 400; } +.sh-feld-haken { margin-top: 4px; } +.sh-frage-box { display: grid; gap: 10px; margin: 12px 0; padding: 14px 16px; border-radius: 12px; border: 1px solid var(--kb-purple-line); background: var(--kb-purple-soft); } +.sh-frage-box:focus { outline: none; } + +/* ----------------------------------------------------------------- Handy --- */ +@media (max-width: 880px), (pointer: coarse) { + #view-webshop button, #view-webshop .kb-btn-outline, #view-webshop .kb-btn-primary { min-height: 44px; } + .sh-haken { align-items: center; min-height: 44px; margin-top: 0; margin-bottom: 0; } + .sh-haken input { margin-top: 0; } +} +@media (max-width: 600px) { + .sh-fakten > div { grid-template-columns: minmax(0, 1fr); } + .sh-aktionen > * { flex: 1 1 auto; } +} diff --git a/public/shop.js b/public/shop.js index 4cca57f..5cd0968 100644 --- a/public/shop.js +++ b/public/shop.js @@ -1,16 +1,361 @@ -/* Webshop im Kundenbereich (ES-Modul, wird von app.js erst beim Oeffnen der Ansicht #webshop geladen). - initShop(rootEl, { projectId }): Platzhalter, bis die Huelle mit Anfrage, Bestellseite und Reitern steht (Task G2). - Fremdtext kommt nur ueber textContent in die Seite. Styles: shop.css. */ +/* Webshop im Kundenbereich (ES-Modul, wird von app.js erst beim Oeffnen der Ansicht #webshop geladen). Das ist die Huelle (Muster: termine.js): + initShop(rootEl, { projectId }): Rueckkehr lesen, Projektwahl und je nach Stand des Projekts (GET /api/shop/status) die Ansicht: + - nicht verfuegbar (GET /api/funktionen shop.verfuegbar): Infokarte; keine Projekte: leere Karte; + - state off: keine Anfrage oder zurueckgezogen: das Anfrageformular (shop-anfrage.js); offen: Karte „wird geprueft“ mit „Anfrage + zurueckziehen“; abgelehnt: Begruendung und „Neue Anfrage stellen“; angenommen: die Bestellseite (funktion-bestellen.js, key 'shop'); + - state ordered: Statuskarte mit „Zahlung fortsetzen“ (funktion-bestellen.js); + - state setup | active | disabled: die Reiter Uebersicht, Artikel, Bestellungen, Einstellungen, Rechtliches (lazy geladen, siehe REITER); + setup mit Hinweis „Wir bauen Deinen Shop gerade ein …“, disabled mit Banner der Abschaltung und nur zum Lesen (ctx.nurLesen). + Rueckkehr: #webshop?bestellt=1&projekt= (nach der Zahlung, „Danke!“) und #webshop?stripe=zurueck|neu&projekt= (vom Onboarding bei + Stripe) werden einmal gelesen und aus der Adresse genommen. + Die Reiter sind Module mit render(panel, ctx) => void | Promise; ctx = { projectId, status, nurLesen, stripeRueckkehr, aktuell(), neuLaden(), + zeigeMeldung(text, art), request } (baueCtx). neuLaden() holt den Stand (GET /api/shop/status), haelt ctx.status aktuell und gibt ihn zurueck; + hat sich dabei der Zustand der Funktion geaendert (z. B. von setup zu active), zeichnet die Huelle die Ansicht neu. Server: server/routes/shop.js. + Helfer ohne Zustand: shop-dom.js. Styles: shop.css. Fremdtext kommt nur ueber textContent in die Seite (nie innerHTML). */ + +import { fehlerKarte, h, infoKarte, request } from './shop-dom.js' +import { anfrageKarte, anfrageZeilen, renderAnfrageFormular } from './shop-anfrage.js' +import { renderBestellen, statusKarte } from './funktion-bestellen.js' + +/** Die Reiter: jeder ein Modul mit render(panel, ctx), erst beim ersten Oeffnen geladen. */ +export const REITER = [ + { key: 'uebersicht', label: 'Übersicht', laden: () => import('./shop-tab-uebersicht.js') }, + { key: 'artikel', label: 'Artikel', laden: () => import('./shop-tab-artikel.js') }, + { key: 'bestellungen', label: 'Bestellungen', laden: () => import('./shop-tab-bestellungen.js') }, + { key: 'einstellungen', label: 'Einstellungen', laden: () => import('./shop-tab-einstellungen.js') }, + { key: 'rechtliches', label: 'Rechtliches', laden: () => import('./shop-tab-rechtliches.js') }, +] + +const HINWEIS_SETUP = 'Wir bauen Deinen Shop gerade ein. Du kannst schon Artikel, Stripe und Rechtstexte vorbereiten.' +const HINWEIS_ABGESCHALTET = 'Dein Webshop ist abgeschaltet. Du kannst Bestellungen noch ansehen und exportieren.' +const DANKE_SETUP = 'Danke! Die Zahlung ist bei uns angekommen.' +const NICHT_VERFUEGBAR = 'Der Webshop ist noch nicht verfügbar.' +const SHOP_PREIS = '299 €' +const DOKUMENT_TITEL = [ + ['leistungsbeschreibung', 'Leistungsbeschreibung'], + ['avv', 'Auftragsverarbeitungsvertrag (AVV)'], + ['widerruf', 'Widerrufsbelehrung'], +] + +// Auswahl bleibt beim Wechsel zwischen Funktionen und Webshop erhalten +const auswahl = { projectId: '', reiter: 'uebersicht' } +/** Gemerkte Auswahl (Website, Reiter) vergessen; fuer Tests, damit eine Ansicht nicht von der vorigen abhaengt. */ +export function vergissAuswahl() { + auswahl.projectId = '' + auswahl.reiter = 'uebersicht' +} +let S = null // aktuelle Sitzung der Ansicht; eine alte Antwort darf eine neuere nicht ueberschreiben (S !== s) + +const zustandVon = (projekt) => projekt?.features?.shop?.state || 'off' +const text = (wert) => (typeof wert === 'string' ? wert : '') + +/* --------------------------------------------------------- Einstieg --- */ +/** Rueckkehr aus der Adresse lesen und diese bereinigen: #webshop?bestellt=1&projekt= | #webshop?stripe=zurueck|neu&projekt=. */ +function leseRueckkehr() { + const hash = window.location.hash || '' + const leer = { bestellt: false, stripe: '', projekt: '' } + if (!hash.startsWith('#webshop?')) return leer + const query = new URLSearchParams(hash.slice(hash.indexOf('?') + 1)) + window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}#webshop`) + const stripe = query.get('stripe') + return { bestellt: query.get('bestellt') === '1', stripe: stripe === 'zurueck' || stripe === 'neu' ? stripe : '', projekt: query.get('projekt') || '' } +} export function initShop(root, { projectId } = {}) { - if (!root) return - const wunsch = root.dataset?.wunschProjekt || projectId || '' - if (root.dataset && root.dataset.wunschProjekt) delete root.dataset.wunschProjekt - const panel = document.createElement('div') - panel.className = 'kb-panel sh-info' - if (wunsch) panel.dataset.projekt = wunsch - const text = document.createElement('p') - text.textContent = 'Webshop wird geladen …' - panel.append(text) - root.replaceChildren(panel) + if (!root) return Promise.resolve() + return starte(root, projectId) +} + +async function starte(root, projectId) { + const rueckkehr = leseRueckkehr() + const wunsch = rueckkehr.projekt || projectId || root.dataset?.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab + if (root.dataset) delete root.dataset.wunschProjekt + if (wunsch) auswahl.projectId = wunsch + const s = { root, projects: [], funktionen: null, projekt: null, status: null, lauf: 0, buehne: null, meldungEl: null, danke: false, stripe: '', neueAnfrage: false } + S = s + try { + await ladeFunktionen(s) + } catch (err) { + if (S === s) root.replaceChildren(fehlerKarte(err.message, () => starte(root, auswahl.projectId))) + return + } + if (S !== s) return + if (!s.funktionen?.shop?.verfuegbar) { + root.replaceChildren(infoKarte(NICHT_VERFUEGBAR, 'Sobald er für Dich bereit ist, findest Du ihn hier.')) + return + } + if (!s.projects.length) { + root.replaceChildren(h('div', { class: 'kb-panel kb-empty' }, 'Noch keine Website-Projekte zugewiesen.')) + return + } + // Wahl wie bei der Kachel: das gewuenschte Projekt, dann aktiv, in Einrichtung, bestellt, abgeschaltet, das erste + const hat = (zustaende) => s.projects.find((p) => zustaende.includes(zustandVon(p))) + s.projekt = s.projects.find((p) => p.projectId === auswahl.projectId) || hat(['active']) || hat(['setup']) || hat(['ordered']) || hat(['disabled']) || s.projects[0] + auswahl.projectId = s.projekt.projectId + // „Danke!“ und die Stripe-Rueckkehr gelten nur fuer das Projekt, mit dem man zurueckkam + const passt = !rueckkehr.projekt || rueckkehr.projekt === s.projekt.projectId + s.danke = rueckkehr.bestellt && passt + s.stripe = passt ? rueckkehr.stripe : '' + s.meldungEl = h('div', { class: 'sh-meldung', role: 'status', 'aria-live': 'polite', tabindex: '-1' }) + s.buehne = h('div', { id: 'shop-buehne' }) + root.replaceChildren(...(s.projects.length > 1 ? [projektwahl(s)] : []), s.meldungEl, s.buehne) + await zeigeProjekt(s) +} + +function projektwahl(s) { + const select = h('select', { id: 'shop-project' }, s.projects.map((p) => { + const option = h('option', { value: p.projectId }, p.projectName) + option.selected = p.projectId === s.projekt.projectId + return option + })) + select.addEventListener('change', () => { + auswahl.projectId = select.value + s.projekt = s.projects.find((p) => p.projectId === select.value) + s.danke = false // der Dank gilt dem Projekt, mit dem man von Stripe zurueckkam + s.stripe = '' + s.neueAnfrage = false + zeigeMeldung(s, '') + zeigeProjekt(s) + }) + return h('div', { class: 'fn-project kb-panel' }, h('label', { for: 'shop-project' }, 'Website'), select) +} + +/** Liste der Funktionen holen (GET /api/funktionen): Projekte, features.shop, legal.shop, ustHinweis, shop.verfuegbar. Wirft bei einem Fehler. */ +async function ladeFunktionen(s) { + const antwort = await request('/api/funktionen') + s.funktionen = antwort + s.projects = Array.isArray(antwort.projects) ? antwort.projects : [] + if (s.projekt) s.projekt = s.projects.find((p) => p.projectId === s.projekt.projectId) || s.projekt +} + +const ladeStatus = (projectId) => request(`/api/shop/status?projectId=${encodeURIComponent(projectId)}`) + +/** Meldung in der Live-Region ueber der Ansicht (art: ok | fehler); leerer Text leert sie. */ +function zeigeMeldung(s, meldung, art = 'ok') { + const el = s.meldungEl + if (!el) return + el.textContent = meldung || '' + el.className = `sh-meldung sh-meldung-${art}` + el.setAttribute('role', art === 'fehler' ? 'alert' : 'status') + el.setAttribute('aria-live', art === 'fehler' ? 'assertive' : 'polite') + if (!meldung) return + if (art === 'fehler') el.focus({ preventScroll: true }) + el.scrollIntoView({ block: 'nearest' }) +} + +/** + * Stand des Projekts holen und die Ansicht zeichnen. Jeder Aufruf zaehlt s.lauf hoch: die Antwort einer frueheren Website (langsam, dann zur + * anderen gewechselt) wird verworfen und erscheint nie unter der neuen. + */ +async function zeigeProjekt(s, optionen = {}) { + const projekt = s.projekt + const lauf = ++s.lauf + const aktuell = () => S === s && s.lauf === lauf + s.buehne.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…')) + let status + try { + status = await ladeStatus(projekt.projectId) + } catch (err) { + if (aktuell()) s.buehne.replaceChildren(fehlerKarte(err.message, () => zeigeProjekt(s))) + return + } + if (!aktuell()) return + await zeichneAnsicht(s, { status, aktuell, ...optionen }) +} + +/** + * Liste und Stand frisch holen und die Ansicht neu zeichnen (nach einer Aktion der Karten). Scheitert das Laden, wirft die Funktion und die + * Karte bleibt mit ihrer Meldung stehen. + */ +async function ladeNeu(s, optionen = {}) { + const lauf = s.lauf + await ladeFunktionen(s) + const status = await ladeStatus(s.projekt.projectId) + if (S !== s || s.lauf !== lauf) return // inzwischen hat ein Wechsel der Website die Ansicht ersetzt + const nr = ++s.lauf + await zeichneAnsicht(s, { status, aktuell: () => S === s && s.lauf === nr, ...optionen }) +} + +/** Die Ansicht zum Zustand aus dem frisch geladenen Stand (status.state). */ +async function zeichneAnsicht(s, { status, aktuell, bestellformular = false, meldung = null }) { + s.status = status + if (!status.verfuegbar) { + s.buehne.replaceChildren(infoKarte(NICHT_VERFUEGBAR, 'Sobald er für Dich bereit ist, findest Du ihn hier.')) + return + } + const zustand = status.state || zustandVon(s.projekt) + if (zustand === 'off') zeigeAnfrage(s, { status, aktuell }) + else if (zustand === 'ordered') zeigeBestellung(s, { status, aktuell, bestellformular }) + else if (zustand === 'setup' || zustand === 'active' || zustand === 'disabled') zeigeReiter(s, { status, aktuell, zustand }) + else s.buehne.replaceChildren(infoKarte('Webshop', 'Der Stand Deines Webshops ist gerade nicht lesbar. Bitte lade die Seite neu.')) + if (meldung) zeigeMeldung(s, meldung.text, meldung.art) +} + +/* ----------------------------------------------- Zustand off: Anfrage --- */ +function zeigeAnfrage(s, { status, aktuell }) { + const projectId = s.projekt.projectId + const ansicht = status.anfrage + const f = s.funktionen?.features?.shop + if (ansicht?.status === 'angenommen') { + zeigeBestellseite(s, { status, aktuell }) + return + } + if (ansicht?.status === 'offen') { + s.buehne.replaceChildren(anfrageKarte(ansicht, { projectId, neuLaden: () => ladeNeu(s) })) + return + } + if (ansicht?.status === 'abgelehnt' && !s.neueAnfrage) { + s.buehne.replaceChildren(anfrageKarte(ansicht, { projectId, neu: () => { s.neueAnfrage = true; zeigeAnfrage(s, { status, aktuell }) } })) + return + } + // keine Anfrage, zurueckgezogen oder „Neue Anfrage stellen“: das Formular, mit den frueheren Angaben vorausgefuellt + const wurzel = h('div', { class: 'sh-wurzel' }) + s.buehne.replaceChildren(wurzel) + renderAnfrageFormular(wurzel, { + projectId, + vorgabe: ansicht?.anfrage || null, + preis: text(f?.priceLabel) || SHOP_PREIS, + ustHinweis: text(s.funktionen?.ustHinweis), + nachSenden: async () => { s.neueAnfrage = false; await ladeNeu(s) }, + }) +} + +/* ----------------------------------------------- Bestellseite, Statuskarte --- */ +/** Die Bestellung (Daten fuer renderBestellen) aus GET /api/funktionen: features.shop (Preis, Zustimmungen, Unterlagen), legal.shop, ustHinweis. */ +export function bestellungVon(funktionen) { + const f = funktionen?.features?.shop + if (!f || !f.consent) return null + const dokumente = DOKUMENT_TITEL + .filter(([id]) => typeof f[id]?.url === 'string' && f[id].url !== '') + .map(([id, titel]) => ({ id, titel, version: f[id].version, url: f[id].url })) + const belehrung = funktionen?.legal?.shop?.belehrung + return { + preis: text(f.priceLabel) || SHOP_PREIS, + preisHinweis: text(funktionen.ustHinweis), + einrichtung: text(f.setupEstimate), + consent: f.consent, + dokumente, + belehrung: typeof belehrung === 'string' ? belehrung : null, + } +} + +/** Daten fuer renderBestellen: nur die Bestellung, keine offenen Punkte (die Anfrage ist angenommen). Ist die Anfrage nicht (mehr) angenommen, gibt es keine Bestellung. */ +function bestelldaten(s, status) { + const bestellbar = status?.state === 'off' ? status.anfrage?.status === 'angenommen' : status?.state === 'ordered' + return { bestellung: bestellbar ? bestellungVon(s.funktionen) : null, offen: [], kundenansicht: false, anfrage: status?.anfrage?.anfrage || null } +} + +/** Zusammenfassung der Anfrage fuer die Bestellseite: eine Gruppe ohne „Ändern“ (zumSchritt fehlt). */ +const zusammenfassungVon = (anfrage) => () => (anfrage + ? [{ schritt: 'anfrage', titel: 'Deine Anfrage', zeilen: anfrageZeilen(anfrage).map(({ name, wert }) => `${name}: ${wert}`) }] + : []) + +function zeigeBestellseite(s, { status, aktuell }) { + const wurzel = h('div', { class: 'sh-wurzel' }) + s.buehne.replaceChildren(wurzel) + const ctx = { + projectId: s.projekt.projectId, + aktuell, + // nach einem 409 der Bestellung: Stand neu holen; die Seite zeichnet sich mit den neuen Daten (ohne bestellung: mit dem Hinweis zum Neuladen) + neuLaden: async () => { + await ladeFunktionen(s) + const neu = await ladeStatus(s.projekt.projectId) + if (aktuell()) s.status = neu + return bestelldaten(s, neu) + }, + } + const daten = bestelldaten(s, status) + renderBestellen(wurzel, { ctx, daten, key: 'shop', zusammenfassung: zusammenfassungVon(daten.anfrage) }) +} + +/** Zustand ordered: Statuskarte „Zahlung offen“ mit „Zahlung fortsetzen“; scheitert das, zeichnet weiter() die Ansicht neu (Bestellseite bei fehlender Zustimmung). */ +function zeigeBestellung(s, { status, aktuell, bestellformular }) { + if (bestellformular) { + zeigeBestellseite(s, { status, aktuell }) + return + } + const f = s.funktionen?.features?.shop + const eintrag = { ...s.projekt.features?.shop, state: 'ordered' } + s.buehne.replaceChildren(statusKarte({ + projekt: s.projekt, + bestellung: eintrag, + einrichtung: text(f?.setupEstimate), + danke: s.danke, + kundenansicht: false, + key: 'shop', + name: 'Webshop', + }, { + weiter: async ({ bestellen, meldung }) => { + await ladeNeu(s, { bestellformular: bestellen, meldung: { text: meldung, art: 'fehler' } }) + }, + })) +} + +/* ----------------------------------------------- Zustand setup, active, disabled: Reiter --- */ +function zeigeReiter(s, { status, aktuell, zustand }) { + const hinweis = zustand === 'setup' ? HINWEIS_SETUP : zustand === 'disabled' ? HINWEIS_ABGESCHALTET : '' + const hinweisBox = h('div', { class: 'sh-hinweise' }, hinweis ? h('div', { class: `sh-box ${zustand === 'disabled' ? 'sh-box-warn' : 'sh-box-info'}`, role: 'note', 'data-hinweis': zustand }, hinweis) : null) + + // neuLaden: Stand holen; hat sich der Zustand geaendert, wird die Ansicht neu gezeichnet (sonst bleibt der Reiter, wie er ist) + const neuLaden = async () => { + const neu = await ladeStatus(s.projekt.projectId) + if (!aktuell()) return neu + s.status = neu + ctx.status = neu + if ((neu.state || 'off') !== zustand) await zeichneAnsicht(s, { status: neu, aktuell }) + return neu + } + const ctx = { + projectId: s.projekt.projectId, + status, + nurLesen: zustand === 'disabled', + stripeRueckkehr: s.stripe, + aktuell, + request, + // eine Meldung, die erst nach dem Wechsel der Website oder der Ansicht eintrifft, gehoert nicht mehr auf diese Seite + zeigeMeldung: (meldung, art) => { if (aktuell()) zeigeMeldung(s, meldung, art) }, + neuLaden, + } + + const panel = h('div', { class: 'sh-panel', role: 'tabpanel', id: 'sh-panel', tabindex: '0' }) + let aktiv = null // Schluessel des gezeigten Reiters + let zaehler = 0 // nur der zuletzt gewaehlte Reiter zeichnet in das Panel + const knoepfe = REITER.map((r) => h('button', { + type: 'button', role: 'tab', class: 'sh-reiter', id: `sh-tab-${r.key}`, 'aria-controls': 'sh-panel', 'data-reiter': r.key, + onclick: () => waehleReiter(r.key), + }, r.label)) + const leiste = h('div', { class: 'sh-reiterleiste', role: 'tablist', 'aria-label': 'Bereiche des Webshops' }, knoepfe) + leiste.addEventListener('keydown', (ev) => { + const i = REITER.findIndex((r) => r.key === aktiv) + const ziel = { ArrowRight: (i + 1) % REITER.length, ArrowLeft: (i + REITER.length - 1) % REITER.length, Home: 0, End: REITER.length - 1 }[ev.key] + if (ziel === undefined) return + ev.preventDefault() + waehleReiter(REITER[ziel].key) + knoepfe[ziel].focus() + }) + + function waehleReiter(key, { merken = true } = {}) { + const reiter = REITER.find((r) => r.key === key) || REITER[0] + if (merken) auswahl.reiter = reiter.key + aktiv = reiter.key + const nr = ++zaehler + for (const k of knoepfe) { + const an = k.dataset.reiter === reiter.key + k.setAttribute('aria-selected', String(an)) + k.tabIndex = an ? 0 : -1 + } + panel.setAttribute('aria-labelledby', `sh-tab-${reiter.key}`) + zeigeMeldung(s, '') + panel.replaceChildren() + reiter.laden() + .then((modul) => (nr === zaehler && aktuell() ? modul.render(panel, ctx) : undefined)) + .catch((err) => { if (nr === zaehler && aktuell()) panel.replaceChildren(fehlerKarte(err.message, () => waehleReiter(reiter.key))) }) + } + + s.buehne.replaceChildren(hinweisBox, leiste, panel) + // vom Onboarding bei Stripe zurueck: die Uebersicht zeigt den Stand der Stripe-Karte + waehleReiter(s.stripe && zustand !== 'disabled' ? 'uebersicht' : auswahl.reiter, { merken: false }) + if (s.danke && zustand === 'setup') zeigeMeldung(s, DANKE_SETUP) + s.danke = false + s.stripe = '' } diff --git a/test/shopView.test.js b/test/shopView.test.js new file mode 100644 index 0000000..5cebe91 --- /dev/null +++ b/test/shopView.test.js @@ -0,0 +1,576 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync, readdirSync } from 'node:fs' +import { installFakeDom } from './support/fakeDom.js' +import { centZuEuro, datumText, euroEingabe, euroText, euroZuCent, h } from '../public/shop-dom.js' +import { REITER, bestellungVon, initShop, vergissAuswahl } from '../public/shop.js' + +// Task G2: die Hülle #webshop (public/shop.js, shop-anfrage.js, shop-dom.js) mit Fake-DOM und Fake-fetch: jeder Zustand zeigt das Richtige, +// die Anfrage schickt den richtigen Körper, die Bestellseite geht an /api/funktionen/shop/checkout, die Reiter laden ihre Module, die Rückkehr +// wird gelesen und aus der Adresse genommen. Die Server-Antworten sind von Hand gebaut (Form: server/routes/shop.js, funktionen.js). + +const URL_ZAHLUNG = 'https://checkout.stripe.test/c/pay/cs_test_shop' +const CONSENT = { + version: 'v-shop', + knopf: 'Zahlungspflichtig bestellen · 299 €', + items: [{ id: 'unterlagen', text: 'Ich habe die Unterlagen gelesen.', haken: true }, { id: 'info', text: 'Hinweis ohne Haken.', haken: false }], +} +const FEATURE = { + key: 'shop', + name: 'Webshop', + priceLabel: '299 €', + setupEstimate: 'meist innerhalb von 1–2 Wochen', + consent: CONSENT, + leistungsbeschreibung: { version: '2026-10-05', url: '/rechtliches/webshop-leistungsbeschreibung-2026-10-05.pdf' }, + avv: { version: '2026-10-05', url: '/rechtliches/webshop-avv-2026-10-05.pdf' }, + widerruf: { version: '2026-10-05', url: '' }, +} +const UST = 'einmalig, ohne Umsatzsteuer gemäß § 19 UStG' +const ANFRAGE = { warenart: 'kleidung', beschreibung: 'Handgemachte Mützen und Schals.', artikelzahl: 'bis20', versand: true, abholung: false, unternehmer: true, keineAusgeschlossenen: true, nachricht: '' } + +const projekt = (id, name, state = 'off', extra = {}) => ({ projectId: id, projectName: name, features: { shop: { state, ...extra } } }) +const funktionen = (projects, { verfuegbar = true } = {}) => ({ + projects, + features: { shop: FEATURE }, + ustHinweis: UST, + legal: { shop: { version: '2026-10-05', links: {}, belehrung: 'Widerrufsbelehrung samt Muster-Widerrufsformular.' } }, + shop: { verfuegbar }, +}) +const status = (state, extra = {}) => ({ + verfuegbar: true, state, ticketWoid: null, anfrage: null, bereitschaft: null, geoeffnet: false, schalter: false, siteKey: null, + snippet: null, vorschauUrl: null, stripe: { verbunden: false, status: null }, zahlen: null, ...extra, +}) +const anfrageAnsicht = (stand, extra = {}) => ({ status: stand, anfrage: ANFRAGE, at: '2026-10-05T10:30:00.000Z', entschiedenAt: null, begruendung: '', ...extra }) + +/** + * Fake-Server: GET /api/funktionen und GET /api/shop/status?projectId= antworten aus dem veränderlichen Zustand `welt`; alle anderen + * Aufrufe nimmt `sonst(aufruf, welt)` entgegen (Standard 200 {}). Jeder Aufruf steht in fx.fetchLog. + */ +function starteMitWelt(welt, sonst = () => ({})) { + const route = (aufruf) => { + if (aufruf.method === 'GET' && aufruf.url === '/api/funktionen') return { body: welt.funktionen } + if (aufruf.method === 'GET' && aufruf.url.startsWith('/api/shop/status?projectId=')) { + const id = decodeURIComponent(aufruf.url.split('=')[1]) + return { body: welt.status[id] ?? status('off') } + } + return sonst(aufruf, welt) || { body: {} } + } + return installFakeDom({ antworten: Array.from({ length: 60 }, () => route) }) +} + +const mitDom = (welt, sonst, fn) => async () => { + const fx = starteMitWelt(welt, sonst) + try { + await fn(fx) + } finally { + fx.restore() + } +} + +async function oeffne(fx, { hash = '', wunsch = '', projectId, behalten = false } = {}) { + if (!behalten) vergissAuswahl() + fx.window.location.hash = hash + const root = h('div', { id: 'webshop-body' }) + if (wunsch) root.dataset.wunschProjekt = wunsch + fx.document.body.append(root) + await initShop(root, projectId ? { projectId } : {}) + await fx.ruhe() + return root +} + +const posts = (fx) => fx.fetchLog.filter((a) => a.method !== 'GET') +const welt1 = (zustand, stand, extra) => ({ funktionen: funktionen([projekt('p1', 'Mein Laden', zustand, extra)]), status: { p1: stand } }) + +/* ------------------------------------------------------------------ Hilfen --- */ +test('euroZuCent und centZuEuro: deutsche Schreibweise, nur Anzeige', () => { + assert.equal(euroZuCent('12,50'), 1250) + assert.equal(euroZuCent('12,5'), 1250) + assert.equal(euroZuCent('12.50 €'), 1250) + assert.equal(euroZuCent('1.234,50'), 123450) + assert.equal(euroZuCent('7'), 700) + for (const schlecht of ['', 'abc', '12,345', '1,2,3', '-5', null, undefined]) assert.equal(euroZuCent(schlecht), null, String(schlecht)) + assert.equal(centZuEuro(1250), '12,50') + assert.equal(centZuEuro(5), '0,05') + assert.equal(centZuEuro(-1), '') + assert.equal(euroText(123450), '1.234,50 €') + assert.equal(euroText(null), '') +}) + +test('euroEingabe: Eingabe wird beim Verlassen in die Schreibweise 12,50 gebracht, inCent liefert Cent oder null', async () => { + const fx = installFakeDom() + try { + const feld = euroEingabe({ cent: 990 }) + assert.equal(feld.value, '9,90') + assert.equal(feld.type, 'text', 'Text statt number: Komma bleibt erlaubt') + await fx.setze(feld, '12,5') + assert.equal(feld.value, '12,50') + assert.equal(feld.inCent(), 1250) + await fx.setze(feld, 'zwölf') + assert.equal(feld.value, 'zwölf', 'Unlesbares bleibt stehen, der Server meldet den Fehler') + assert.equal(feld.inCent(), null) + assert.equal(feld.istLesbar(), false) + await fx.setze(feld, '') + assert.equal(feld.istLeer(), true) + assert.equal(feld.istLesbar(), true) + assert.equal(feld.inCent(), null) + } finally { + fx.restore() + } +}) + +test('datumText: Berliner Zeit, Datum allein, ungültig leer', () => { + assert.match(datumText('2026-10-05T12:30:00.000Z'), /^05\.10\.2026,? 14:30$/) + assert.equal(datumText('2026-10-05T12:30:00.000Z', { zeit: false }), '05.10.2026') + assert.equal(datumText(''), '') + assert.equal(datumText('kein Datum'), '') + assert.equal(datumText(null), '') +}) + +/* ---------------------------------------------------------------- Zustände --- */ +test('nicht verfügbar: Infokarte, keine Abfrage des Stands', mitDom({ funktionen: funktionen([projekt('p1', 'A')], { verfuegbar: false }), status: {} }, undefined, async (fx) => { + const root = await oeffne(fx) + assert.match(fx.text(root), /Der Webshop ist noch nicht verfügbar\./) + assert.deepEqual(fx.fetchLog.map((a) => a.url), ['/api/funktionen']) +})) + +test('keine Projekte: leere Karte', mitDom({ funktionen: funktionen([]), status: {} }, undefined, async (fx) => { + const root = await oeffne(fx) + assert.match(fx.text(root), /Noch keine Website-Projekte zugewiesen\./) + assert.equal(fx.finde(root, 'select'), null) +})) + +test('Fehler beim Laden: Fehlerkarte mit „Noch einmal versuchen“', async () => { + const fx = installFakeDom({ antworten: [{ status: 500, body: { error: 'Interner Serverfehler' } }, { body: funktionen([]) }] }) + try { + const root = h('div') + fx.document.body.append(root) + await initShop(root) + assert.match(fx.text(root), /Interner Serverfehler/) + await fx.klick(fx.finde(root, 'button')) + assert.match(fx.text(root), /Noch keine Website-Projekte|Der Webshop ist noch nicht verfügbar/) + } finally { + fx.restore() + } +}) + +test('keine Anfrage: Erklärung, Formular mit den Pflichthaken, Knopf „Anfrage senden“', mitDom(welt1('off', status('off')), undefined, async (fx) => { + const root = await oeffne(fx) + const text = fx.text(root) + assert.match(text, /299 €/) + assert.match(text, /eigenes Stripe-Konto/) + assert.match(text, /Karte, Apple Pay, Google Pay, Klarna oder SEPA/) + assert.match(text, /PayPal ist über Stripe leider nicht möglich/) + assert.match(text, /Ich verkaufe als Unternehmer \(nicht privat\)\./) + assert.match(text, /Ich verkaufe keine ausgeschlossenen Waren \(Alkohol, Tabak\/E-Zigaretten, Arzneimittel, Waffen\/Feuerwerk, Erotik, lebende Tiere, digitale Inhalte, Gutscheine\)\./) + assert.equal(fx.text(fx.finde(root, '[data-aktion="anfrage-senden"]')), 'Anfrage senden') + for (const name of ['warenart', 'beschreibung', 'artikelzahl', 'versand', 'abholung', 'unternehmer', 'keineAusgeschlossenen', 'nachricht']) { + assert.ok(fx.finde(root, `[data-feld="${name}"]`), `Feld ${name}`) + } + assert.equal(fx.finde(root, '[data-feld="unternehmer"]').checked, false, 'Pflichthaken sind nie vorausgewählt') + assert.deepEqual(fx.fetchLog.map((a) => a.url), ['/api/funktionen', '/api/shop/status?projectId=p1']) +})) + +test('Anfrage senden: Körper laut Schnittstelle, danach zeigt die Ansicht „wird geprüft“', mitDom( + welt1('off', status('off')), + (aufruf, welt) => { + if (aufruf.method === 'POST' && aufruf.url === '/api/shop/anfrage') { + welt.status.p1 = status('off', { anfrage: anfrageAnsicht('offen') }) + return { body: { anfrage: anfrageAnsicht('offen') } } + } + return null + }, + async (fx) => { + const root = await oeffne(fx) + await fx.setze(fx.finde(root, '[data-feld="warenart"]'), 'kleidung') + await fx.setze(fx.finde(root, '[data-feld="beschreibung"]'), ' Handgemachte Mützen und Schals. ') + await fx.setze(fx.finde(root, '[data-feld="artikelzahl"]'), 'bis20') + await fx.klick(fx.finde(root, '[data-feld="versand"]')) + await fx.klick(fx.finde(root, '[data-feld="unternehmer"]')) + await fx.klick(fx.finde(root, '[data-feld="keineAusgeschlossenen"]')) + await fx.klick(fx.finde(root, '[data-aktion="anfrage-senden"]')) + await fx.ruhe() + assert.deepEqual(posts(fx), [{ + method: 'POST', + url: '/api/shop/anfrage', + body: { projectId: 'p1', anfrage: { warenart: 'kleidung', beschreibung: 'Handgemachte Mützen und Schals.', artikelzahl: 'bis20', versand: true, abholung: false, unternehmer: true, keineAusgeschlossenen: true, nachricht: '' } }, + }]) + assert.match(fx.text(root), /Deine Anfrage wird geprüft/) + assert.ok(fx.finde(root, '[data-aktion="zurueckziehen"]')) + }, +)) + +test('Anfrage mit Feldfehlern vom Server: Fehler stehen am Feld, nichts wird neu gezeichnet, der Knopf ist wieder frei', mitDom( + welt1('off', status('off')), + (aufruf) => (aufruf.method === 'POST' + ? { status: 400, body: { error: 'Bitte wähle, was du verkaufen möchtest.', fehler: { warenart: 'Bitte wähle, was du verkaufen möchtest.', unternehmer: 'Bitte bestätige, dass du als Unternehmer verkaufst.', _: 'Sonst etwas.' } } } + : null), + async (fx) => { + const root = await oeffne(fx) + await fx.klick(fx.finde(root, '[data-aktion="anfrage-senden"]')) + await fx.ruhe() + assert.equal(fx.text(fx.finde(root, '[data-fehler="warenart"]')), 'Bitte wähle, was du verkaufen möchtest.') + assert.equal(fx.text(fx.finde(root, '[data-fehler="unternehmer"]')), 'Bitte bestätige, dass du als Unternehmer verkaufst.') + assert.equal(fx.text(fx.finde(root, '[data-fehler="_"]')), 'Sonst etwas.') + assert.equal(fx.finde(root, '[data-feld="warenart"]').getAttribute('aria-invalid'), 'true') + assert.equal(fx.finde(root, '[data-aktion="anfrage-senden"]').disabled, false) + assert.equal(fx.fetchLog.filter((a) => a.url.startsWith('/api/shop/status')).length, 1, 'kein Neuladen nach einem 400') + }, +)) + +test('Anfrage offen: Karte mit Zusammenfassung; „Anfrage zurückziehen“ schickt POST und zeigt wieder das Formular, vorausgefüllt', mitDom( + welt1('off', status('off', { anfrage: anfrageAnsicht('offen') })), + (aufruf, welt) => { + if (aufruf.url === '/api/shop/anfrage/zurueckziehen') { + welt.status.p1 = status('off', { anfrage: anfrageAnsicht('zurueckgezogen') }) + return { body: { anfrage: anfrageAnsicht('zurueckgezogen') } } + } + return null + }, + async (fx) => { + const root = await oeffne(fx) + const text = fx.text(root) + assert.match(text, /Deine Anfrage wird geprüft/) + assert.match(text, /Kleidung/) + assert.match(text, /Handgemachte Mützen und Schals\./) + assert.match(text, /bis 20/) + assert.match(text, /Versand/) + assert.equal(fx.finde(root, '[data-aktion="anfrage-senden"]'), null) + await fx.klick(fx.finde(root, '[data-aktion="zurueckziehen"]')) + await fx.ruhe() + assert.deepEqual(posts(fx), [{ method: 'POST', url: '/api/shop/anfrage/zurueckziehen', body: { projectId: 'p1' } }]) + assert.ok(fx.finde(root, '[data-aktion="anfrage-senden"]'), 'zurückgezogen: wieder das Formular') + assert.equal(fx.finde(root, '[data-feld="beschreibung"]').value, 'Handgemachte Mützen und Schals.') + assert.equal(fx.finde(root, '[data-feld="warenart"]').value, 'kleidung') + assert.equal(fx.finde(root, '[data-feld="versand"]').checked, true) + }, +)) + +test('Zurückziehen scheitert mit 409 (inzwischen entschieden): Meldung, der neue Stand erscheint', mitDom( + welt1('off', status('off', { anfrage: anfrageAnsicht('offen') })), + (aufruf, welt) => { + if (aufruf.url === '/api/shop/anfrage/zurueckziehen') { + welt.status.p1 = status('off', { anfrage: anfrageAnsicht('abgelehnt', { begruendung: 'Passt nicht zu unserem Angebot.' }) }) + return { status: 409, body: { error: 'Die Anfrage kann nur zurückgezogen werden, solange sie offen ist.' } } + } + return null + }, + async (fx) => { + const root = await oeffne(fx) + await fx.klick(fx.finde(root, '[data-aktion="zurueckziehen"]')) + await fx.ruhe() + assert.match(fx.text(root), /Passt nicht zu unserem Angebot\./) + }, +)) + +test('Anfrage abgelehnt: Begründung als Text; „Neue Anfrage stellen“ zeigt das Formular mit den alten Angaben', mitDom( + welt1('off', status('off', { anfrage: anfrageAnsicht('abgelehnt', { begruendung: 'Wir verkaufen keine Mützen.' }) })), + undefined, + async (fx) => { + const root = await oeffne(fx) + assert.match(fx.text(root), /Wir verkaufen keine<\/b> Mützen\./, 'Fremdtext bleibt Text') + assert.equal(fx.finde(root, 'blockquote b'), null, 'kein Element aus dem Text') + assert.equal(fx.finde(root, '[data-aktion="anfrage-senden"]'), null) + await fx.klick(fx.finde(root, '[data-aktion="neue-anfrage"]')) + assert.ok(fx.finde(root, '[data-aktion="anfrage-senden"]')) + assert.equal(fx.finde(root, '[data-feld="beschreibung"]').value, 'Handgemachte Mützen und Schals.') + assert.equal(fx.finde(root, '[data-feld="unternehmer"]').checked, false, 'die Haken setzt Du neu') + }, +)) + +test('Anfrage angenommen, Funktion aus: Bestellseite mit Preis, Unterlagen, Belehrung und Zusammenfassung; Bestellen geht an /api/funktionen/shop/checkout', mitDom( + welt1('off', status('off', { anfrage: anfrageAnsicht('angenommen') })), + (aufruf) => (aufruf.url === '/api/funktionen/shop/checkout' ? { body: { url: URL_ZAHLUNG } } : null), + async (fx) => { + const root = await oeffne(fx) + const text = fx.text(root) + assert.match(text, /Prüfen und bestellen/) + assert.match(text, /299 €/) + assert.match(text, new RegExp(UST)) + assert.match(text, /Widerrufsbelehrung samt Muster-Widerrufsformular\./) + assert.match(text, /Deine Anfrage/) + assert.match(text, /Mützen und Schals/) + assert.equal(fx.finde(root, '[data-aktion="aendern"]'), null, 'ohne Schritte kein „Ändern“') + assert.doesNotMatch(text, /Mit „Ändern“/) + const links = fx.alle(root, 'a').map((a) => a.getAttribute('href')) + assert.deepEqual(links, ['/rechtliches/webshop-leistungsbeschreibung-2026-10-05.pdf', '/rechtliches/webshop-avv-2026-10-05.pdf'], 'nur Unterlagen mit Adresse') + const knopf = fx.finde(root, '[data-aktion="bestellen"]') + assert.equal(fx.text(knopf), 'Zahlungspflichtig bestellen · 299 €') + assert.equal(knopf.disabled, true, 'ohne Haken gesperrt') + await fx.klick(fx.finde(root, '[data-haken="unterlagen"]')) + await fx.klick(knopf) + await fx.ruhe() + assert.deepEqual(posts(fx), [{ method: 'POST', url: '/api/funktionen/shop/checkout', body: { projectId: 'p1', zustimmung: { version: 'v-shop', haken: ['unterlagen'] } } }]) + assert.equal(fx.window.location.href, URL_ZAHLUNG) + }, +)) + +test('bestellungVon: ohne Register-Eintrag null, sonst Preis, Hinweis, Einrichtung, Zustimmungen, Unterlagen mit Adresse, Belehrung', () => { + assert.equal(bestellungVon({}), null) + assert.equal(bestellungVon({ features: { shop: {} } }), null) + const b = bestellungVon(funktionen([])) + assert.equal(b.preis, '299 €') + assert.equal(b.preisHinweis, UST) + assert.equal(b.einrichtung, 'meist innerhalb von 1–2 Wochen') + assert.equal(b.consent, CONSENT) + assert.deepEqual(b.dokumente.map((d) => d.id), ['leistungsbeschreibung', 'avv']) + assert.equal(b.belehrung, 'Widerrufsbelehrung samt Muster-Widerrufsformular.') + assert.equal(bestellungVon({ ...funktionen([]), legal: { shop: null } }).belehrung, null) +}) + +test('Zustand ordered: Statuskarte „Zahlung offen“; „Zahlung fortsetzen“ schickt fortsetzen: true; nach der Zahlung (bestellt=1) steht „Danke!“ da und die Adresse ist bereinigt', mitDom( + welt1('ordered', status('ordered', { anfrage: anfrageAnsicht('angenommen') }), { orderedAt: '2026-10-05T09:00:00.000Z' }), + (aufruf) => (aufruf.url === '/api/funktionen/shop/checkout' ? { body: { url: URL_ZAHLUNG } } : null), + async (fx) => { + const root = await oeffne(fx, { hash: '#webshop?bestellt=1&projekt=p1' }) + assert.equal(fx.window.location.hash, '#webshop', 'Rückkehr einmal gelesen und aus der Adresse genommen') + const text = fx.text(root) + assert.match(text, /Deine Bestellung wartet auf die Zahlung/) + assert.match(text, /Danke!/) + assert.match(text, /05\.10\.2026/) + await fx.klick(fx.finde(root, '[data-aktion="fortsetzen"]')) + await fx.ruhe() + assert.deepEqual(posts(fx), [{ method: 'POST', url: '/api/funktionen/shop/checkout', body: { projectId: 'p1', fortsetzen: true } }]) + assert.equal(fx.window.location.href, URL_ZAHLUNG) + }, +)) + +test('ordered ohne Rückkehr: kein „Danke!“; scheitert „Zahlung fortsetzen“ mit zustimmung_fehlt, erscheint die Bestellseite mit der Meldung', mitDom( + welt1('ordered', status('ordered', { anfrage: anfrageAnsicht('angenommen') })), + (aufruf) => (aufruf.url === '/api/funktionen/shop/checkout' + ? { status: 409, body: { error: 'Bitte bestätige die Unterlagen noch einmal.', code: 'zustimmung_fehlt' } } + : null), + async (fx) => { + const root = await oeffne(fx) + assert.doesNotMatch(fx.text(root), /Danke!/) + await fx.klick(fx.finde(root, '[data-aktion="fortsetzen"]')) + await fx.ruhe() + assert.ok(fx.finde(root, '[data-aktion="bestellen"]'), 'Bestellseite statt Karte') + assert.match(fx.text(root), /Bitte bestätige die Unterlagen noch einmal\./) + }, +)) + +/* ------------------------------------------------------------------ Reiter --- */ +test('Zustand active: fünf Reiter in fester Reihenfolge, Übersicht offen, Modul ist ein Platzhalter; kein Hinweis-Banner', mitDom(welt1('active', status('active')), undefined, async (fx) => { + const root = await oeffne(fx) + const knoepfe = fx.alle(root, '[role="tab"]') + assert.deepEqual(knoepfe.map((k) => fx.text(k)), ['Übersicht', 'Artikel', 'Bestellungen', 'Einstellungen', 'Rechtliches']) + assert.deepEqual(knoepfe.map((k) => k.dataset.reiter), ['uebersicht', 'artikel', 'bestellungen', 'einstellungen', 'rechtliches']) + assert.deepEqual(knoepfe.map((k) => k.getAttribute('aria-selected')), ['true', 'false', 'false', 'false', 'false']) + assert.equal(fx.finde(root, '[role="tablist"]').getAttribute('aria-label'), 'Bereiche des Webshops') + assert.match(fx.text(fx.finde(root, '[role="tabpanel"]')), /Kommt gleich …/) + assert.equal(fx.finde(root, '[data-hinweis]'), null) + assert.deepEqual(REITER.map((r) => r.key), ['uebersicht', 'artikel', 'bestellungen', 'einstellungen', 'rechtliches']) +})) + +test('Reiterwechsel per Klick und Pfeiltasten lädt das Modul des Reiters mit ctx { projectId, status, neuLaden, zeigeMeldung, request }', mitDom(welt1('active', status('active', { siteKey: 'sk1' })), undefined, async (fx) => { + const gesehen = [] + const alt = REITER.map((r) => r.laden) + REITER.forEach((r, i) => { r.laden = async () => ({ render: (panel, ctx) => { gesehen.push({ key: r.key, ctx }); panel.replaceChildren(h('p', null, `Inhalt ${r.key}`)) } }) }) + try { + const root = await oeffne(fx) + assert.deepEqual(gesehen.map((g) => g.key), ['uebersicht']) + const ctx = gesehen[0].ctx + assert.equal(ctx.projectId, 'p1') + assert.equal(ctx.status.siteKey, 'sk1') + assert.equal(ctx.nurLesen, false) + assert.equal(typeof ctx.neuLaden, 'function') + assert.equal(typeof ctx.zeigeMeldung, 'function') + assert.equal(typeof ctx.request, 'function') + await fx.klick(fx.finde(root, '#sh-tab-artikel')) + await fx.ruhe() + assert.deepEqual(gesehen.map((g) => g.key), ['uebersicht', 'artikel']) + assert.match(fx.text(fx.finde(root, '[role="tabpanel"]')), /Inhalt artikel/) + assert.equal(fx.finde(root, '#sh-tab-artikel').getAttribute('aria-selected'), 'true') + assert.equal(fx.finde(root, '#sh-tab-uebersicht').tabIndex, -1) + assert.equal(fx.finde(root, '#sh-panel').getAttribute('aria-labelledby'), 'sh-tab-artikel') + await fx.sende(fx.finde(root, '[role="tablist"]'), 'keydown', { key: 'ArrowRight' }) + await fx.ruhe() + assert.equal(gesehen.at(-1).key, 'bestellungen') + await fx.sende(fx.finde(root, '[role="tablist"]'), 'keydown', { key: 'End' }) + await fx.ruhe() + assert.equal(gesehen.at(-1).key, 'rechtliches') + await fx.sende(fx.finde(root, '[role="tablist"]'), 'keydown', { key: 'ArrowRight' }) + await fx.ruhe() + assert.equal(gesehen.at(-1).key, 'uebersicht', 'am Ende springt es nach vorn') + await fx.sende(fx.finde(root, '[role="tablist"]'), 'keydown', { key: 'Home' }) + await fx.sende(fx.finde(root, '[role="tablist"]'), 'keydown', { key: 'ArrowLeft' }) + await fx.ruhe() + assert.equal(gesehen.at(-1).key, 'rechtliches', 'vor dem ersten Reiter springt es ans Ende') + } finally { + REITER.forEach((r, i) => { r.laden = alt[i] }) + } +})) + +test('Die echten Reiter-Module werden beim Wählen geladen (Platzhalter „Kommt gleich …“)', mitDom(welt1('active', status('active')), undefined, async (fx) => { + const root = await oeffne(fx) + for (const key of ['artikel', 'bestellungen', 'einstellungen', 'rechtliches', 'uebersicht']) { + await fx.klick(fx.finde(root, `#sh-tab-${key}`)) + await fx.ruhe() + assert.match(fx.text(fx.finde(root, '#sh-panel')), /Kommt gleich …/, key) + } + for (const r of REITER) assert.equal(typeof (await r.laden()).render, 'function', `${r.key} exportiert render`) +})) + +test('Zustand setup: Hinweis „Wir bauen Deinen Shop gerade ein …“ über den Reitern, Reiter nicht schreibgeschützt', mitDom(welt1('setup', status('setup')), undefined, async (fx) => { + const gesehen = [] + const alt = REITER[0].laden + REITER[0].laden = async () => ({ render: (panel, ctx) => { gesehen.push(ctx) } }) + try { + const root = await oeffne(fx) + assert.equal(fx.text(fx.finde(root, '[data-hinweis="setup"]')), 'Wir bauen Deinen Shop gerade ein. Du kannst schon Artikel, Stripe und Rechtstexte vorbereiten.') + assert.equal(fx.alle(root, '[role="tab"]').length, 5) + assert.equal(gesehen[0].nurLesen, false) + } finally { + REITER[0].laden = alt + } +})) + +test('Zustand disabled: Banner der Abschaltung, ctx.nurLesen = true', mitDom(welt1('disabled', status('disabled')), undefined, async (fx) => { + const gesehen = [] + const alt = REITER[0].laden + REITER[0].laden = async () => ({ render: (panel, ctx) => { gesehen.push(ctx) } }) + try { + const root = await oeffne(fx) + assert.equal(fx.text(fx.finde(root, '[data-hinweis="disabled"]')), 'Dein Webshop ist abgeschaltet. Du kannst Bestellungen noch ansehen und exportieren.') + assert.equal(gesehen[0].nurLesen, true) + assert.equal(fx.alle(root, '[role="tab"]').length, 5) + } finally { + REITER[0].laden = alt + } +})) + +test('Stripe-Rückkehr (#webshop?stripe=zurueck): Adresse bereinigt, Übersicht gewählt, ctx.stripeRueckkehr gesetzt; ungültiger Wert ignoriert', mitDom(welt1('active', status('active')), undefined, async (fx) => { + const gesehen = [] + const alt = REITER.map((r) => r.laden) + REITER.forEach((r) => { r.laden = async () => ({ render: (panel, ctx) => { gesehen.push({ key: r.key, rueck: ctx.stripeRueckkehr }) } }) }) + try { + await oeffne(fx, { hash: '#webshop?stripe=neu&projekt=p1' }) + assert.equal(fx.window.location.hash, '#webshop') + assert.deepEqual(gesehen, [{ key: 'uebersicht', rueck: 'neu' }]) + gesehen.length = 0 + await oeffne(fx, { hash: '#webshop?stripe=%3Cscript%3E' }) + assert.deepEqual(gesehen, [{ key: 'uebersicht', rueck: '' }]) + } finally { + REITER.forEach((r, i) => { r.laden = alt[i] }) + } +})) + +test('Danke nach der Zahlung, wenn der Webhook schon durch ist (setup): Meldung „Danke!“ in der Live-Region', mitDom(welt1('setup', status('setup')), undefined, async (fx) => { + const root = await oeffne(fx, { hash: '#webshop?bestellt=1&projekt=p1' }) + assert.equal(fx.window.location.hash, '#webshop') + assert.match(fx.text(fx.finde(root, '.sh-meldung')), /Danke! Die Zahlung ist bei uns angekommen\./) +})) + +test('ctx.neuLaden: holt den Stand und hält ctx.status aktuell; bei neuem Zustand zeichnet die Hülle die Ansicht neu', mitDom(welt1('setup', status('setup')), undefined, async (fx) => { + let ctx = null + const alt = REITER[0].laden + REITER[0].laden = async () => ({ render: (panel, c) => { ctx = c } }) + try { + const root = await oeffne(fx) + assert.ok(fx.finde(root, '[data-hinweis="setup"]')) + // gleicher Zustand: nur ctx.status ist neu, die Ansicht bleibt + const gleich = await ctx.neuLaden() + assert.equal(gleich.state, 'setup') + assert.equal(ctx.status, gleich) + assert.ok(fx.finde(root, '[data-hinweis="setup"]')) + // anderer Zustand: neu gezeichnet, ohne Banner + fx.antworten.length = 0 + fx.antworten.push(() => ({ body: status('active', { siteKey: 'neu' }) })) + const aktiv = await ctx.neuLaden() + await fx.ruhe() + assert.equal(aktiv.state, 'active') + assert.equal(fx.finde(root, '[data-hinweis="setup"]'), null) + } finally { + REITER[0].laden = alt + } +})) + +test('ctx.zeigeMeldung schreibt in die Live-Region (ok: status, fehler: alert)', mitDom(welt1('active', status('active')), undefined, async (fx) => { + let ctx = null + const alt = REITER[0].laden + REITER[0].laden = async () => ({ render: (panel, c) => { ctx = c } }) + try { + const root = await oeffne(fx) + const live = fx.finde(root, '.sh-meldung') + ctx.zeigeMeldung('Gespeichert.', 'ok') + assert.equal(fx.text(live), 'Gespeichert.') + assert.equal(live.getAttribute('role'), 'status') + ctx.zeigeMeldung('Das ging schief.', 'fehler') + assert.equal(live.getAttribute('role'), 'alert') + assert.equal(live.getAttribute('aria-live'), 'assertive') + ctx.zeigeMeldung('') + assert.equal(fx.text(live), '') + } finally { + REITER[0].laden = alt + } +})) + +/* ------------------------------------------------------------ Projektwahl --- */ +test('Projektwahl: bei mehr als einem Projekt ein Auswahlfeld; das Wunschprojekt aus dem Behälter gilt, Wechsel lädt den Stand des anderen', mitDom({ + funktionen: funktionen([projekt('p1', 'Laden Eins', 'active'), projekt('p2', 'Laden Zwei', 'off')]), + status: { p1: status('active'), p2: status('off', { anfrage: anfrageAnsicht('offen') }) }, +}, undefined, async (fx) => { + const root = await oeffne(fx, { wunsch: 'p2' }) + assert.equal(root.dataset.wunschProjekt, undefined, 'der Wunsch ist verbraucht') + const select = fx.finde(root, '#shop-project') + assert.equal(select.value, 'p2') + assert.match(fx.text(root), /Deine Anfrage wird geprüft/) + await fx.setze(select, 'p1') + await fx.ruhe() + assert.equal(fx.alle(root, '[role="tab"]').length, 5) + assert.ok(fx.fetchLog.some((a) => a.url === '/api/shop/status?projectId=p1')) +})) + +test('Ohne Wunsch wird das aktive Projekt gewählt; ein einziges Projekt hat kein Auswahlfeld', mitDom({ + funktionen: funktionen([projekt('p1', 'Eins', 'off'), projekt('p2', 'Zwei', 'active')]), + status: { p1: status('off'), p2: status('active') }, +}, undefined, async (fx) => { + const root = await oeffne(fx) + assert.equal(fx.finde(root, '#shop-project').value, 'p2') + assert.equal(fx.alle(root, '[role="tab"]').length, 5) +})) + +test('Rückkehr mit &projekt= wählt dieses Projekt', mitDom({ + funktionen: funktionen([projekt('p1', 'Eins', 'active'), projekt('p2', 'Zwei', 'ordered')]), + status: { p1: status('active'), p2: status('ordered', { anfrage: anfrageAnsicht('angenommen') }) }, +}, undefined, async (fx) => { + const root = await oeffne(fx, { hash: '#webshop?bestellt=1&projekt=p2' }) + assert.equal(fx.finde(root, '#shop-project').value, 'p2') + assert.match(fx.text(root), /Danke!/) +})) + +test('Ein Projekt: kein Auswahlfeld', mitDom(welt1('off', status('off')), undefined, async (fx) => { + const root = await oeffne(fx) + assert.equal(fx.finde(root, 'select#shop-project'), null) +})) + +/* ---------------------------------------------------------------- statisch --- */ +const SHOP_DATEIEN = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /^shop.*\.js$/.test(f)) +const ohneKommentare = (quelle) => quelle.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '') + +test('statisch: shop*.js hat kein innerHTML, outerHTML, insertAdjacentHTML, document.write und keine Importe aus termine*.js', () => { + assert.ok(SHOP_DATEIEN.includes('shop.js') && SHOP_DATEIEN.includes('shop-dom.js') && SHOP_DATEIEN.includes('shop-anfrage.js')) + assert.equal(SHOP_DATEIEN.filter((f) => /^shop-tab-/.test(f)).length, 5) + for (const datei of SHOP_DATEIEN) { + const quelle = readFileSync(new URL(`../public/${datei}`, import.meta.url), 'utf8') + assert.doesNotMatch(ohneKommentare(quelle), /innerHTML|outerHTML|insertAdjacentHTML|document\.write/, datei) + assert.doesNotMatch(ohneKommentare(quelle), /from '\.\/termine[^']*'/, `${datei}: eigene Helfer statt termine-dom.js`) + } +}) + +test('statisch: Du-Form in den Texten des Portals (kein „Sie“, kein „Ihre“ in shop*.js)', () => { + for (const datei of SHOP_DATEIEN) { + const quelle = ohneKommentare(readFileSync(new URL(`../public/${datei}`, import.meta.url), 'utf8')) + assert.doesNotMatch(quelle, /\bSie\b/, datei) + assert.doesNotMatch(quelle, /\bIh(r|re|ren|rem|rer|res|nen)\b/, datei) + } +}) + +test('statisch: Die Hülle nutzt für den Stand GET /api/shop/status, für die Anfrage die zwei Routen und für die Bestellung den Schlüssel shop', () => { + const huelle = readFileSync(new URL('../public/shop.js', import.meta.url), 'utf8') + const anfrage = readFileSync(new URL('../public/shop-anfrage.js', import.meta.url), 'utf8') + assert.match(huelle, /\/api\/shop\/status\?projectId=/) + assert.match(huelle, /key: 'shop'/) + assert.match(anfrage, /'\/api\/shop\/anfrage'/) + assert.match(anfrage, /'\/api\/shop\/anfrage\/zurueckziehen'/) +})