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>
362 lines
18 KiB
JavaScript
362 lines
18 KiB
JavaScript
/* 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 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 = ''
|
|
}
|