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:
1 parent
7464a4ef55
commit
738afa2d0e
11 files changed
+1407
-16
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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')))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 …'))
|
||||
}
|
||||
@@ -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 …'))
|
||||
}
|
||||
@@ -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 …'))
|
||||
}
|
||||
@@ -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 …'))
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 = ''
|
||||
}
|
||||
@@ -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'/)
|
||||
})
|
||||
Reference in new issue
Block a user