Shop G2: Hülle #webshop mit Anfrage, Bestellseite, Statuskarte und fünf Reitern

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 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 09:05:01 +00:00
1 parent 7464a4ef55
commit 738afa2d0e
11 files changed
+1407 -16

No files matched your search

+1 -1
View File
@@ -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,
+167
View File
@@ -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')))
}
+178
View File
@@ -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)
}
+8
View File
@@ -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 …'))
}
+8
View File
@@ -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 …'))
}
+8
View File
@@ -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 …'))
}
+8
View File
@@ -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 …'))
}
+8
View File
@@ -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 …'))
}
+87 -2
View File
@@ -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; }
}
+358 -13
View File
@@ -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=<id> (nach der Zahlung, „Danke!“) und #webshop?stripe=zurueck|neu&projekt=<id> (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=<id> | #webshop?stripe=zurueck|neu&projekt=<id>. */
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 = ''
}
+576
View File
@@ -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 <b>keine</b> Mützen.' }) })),
undefined,
async (fx) => {
const root = await oeffne(fx)
assert.match(fx.text(root), /Wir verkaufen <b>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'/)
})