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 = ''
|
||||
}
|
||||
Reference in new issue
Block a user