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 = ''
}