/* 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= (nach der Zahlung, „Danke!“) und #webshop?stripe=zurueck|neu&projekt= (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, renderEinschaltSeite, statusKarte } from './funktion-bestellen.js' import { meldeReiter, reiterEintraege } from './fn-reiter.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, bearbeiten, erstatten und exportieren.' const DANKE_SETUP = 'Danke! Die Zahlung ist bei uns angekommen.' const NICHT_VERFUEGBAR = 'Der Webshop ist noch nicht verfügbar.' const PRIVAT_TITEL = 'Der Webshop ist für Betriebe' // Abo-Modell, Text S3 const PRIVAT_TEXT = 'Für Privatpersonen gibt es den Webshop leider nicht. Stimmt das nicht? Schreib uns im Chat.' 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 : '') // Abo-Modell (Antwort von GET /api/funktionen, strikt): die Reiter zeichnet die Etage, die Ansicht meldet sie (fn-reiter.js) const istAbo = (s) => s.funktionen?.modell === 'abo' /** Mitarbeiter in der Kundenansicht (window.KbView, von der Hülle gesetzt): Einschalten bleibt gesperrt. */ const inKundenansicht = () => typeof window !== 'undefined' && Boolean(window.KbView?.id?.()) /** Eine Ansicht ohne Reiter meldet die leere Liste, damit die Etage nicht die Reiter der vorigen stehen laesst. */ const keineReiter = (s) => { if (istAbo(s)) meldeReiter(s.root, { eintraege: [] }) } /* --------------------------------------------------------- Einstieg --- */ /** Rueckkehr aus der Adresse lesen und diese bereinigen: #webshop?bestellt=1&projekt= | #webshop?stripe=zurueck|neu&projekt=. */ 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, einschalten } = {}) { if (!root) return Promise.resolve() return starte(root, projectId, einschalten) } async function starte(root, projectId, einschalten) { 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, einschalten } S = s try { await ladeFunktionen(s) } catch (err) { if (S === s) root.replaceChildren(fehlerKarte(err.message, () => starte(root, auswahl.projectId, einschalten))) return } if (S !== s) return if (!s.funktionen?.shop?.verfuegbar) { keineReiter(s) root.replaceChildren(infoKarte(NICHT_VERFUEGBAR, 'Sobald er für dich bereit ist, findest du ihn hier.')) return } if (!s.projects.length) { keineReiter(s) 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 keineReiter(s) 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 keineReiter(s) // die Ansicht mit Reitern (zeigeReiter) meldet ihre Liste gleich danach 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 } // Abo-Modell: den Webshop gibt es nur fuer Betriebe; Privatkunden und eine leere oder fehlende Kundenart (E4) bekommen den Hinweis statt des Formulars if (istAbo(s) && s.funktionen.kundenart !== 'unternehmen') { s.buehne.replaceChildren(infoKarte(PRIVAT_TITEL, PRIVAT_TEXT, { chat: true })) 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), modell: s.funktionen?.modell, 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) if (istAbo(s)) { // Abo-Modell: die Zusage der Anfrage fuehrt zum Einschalten (kein Preis, keine Haken, kein Checkout) renderEinschaltSeite(wurzel, { ctx, daten: { ...daten, kundenansicht: inKundenansicht() }, key: 'shop', zusammenfassung: zusammenfassungVon(daten.anfrage), einschalten: s.einschalten, nachEinschalten: () => ladeNeu(s) }) return } 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', abwickeln: true, // Bestellungen, Erstattungen und Widerrufe gehen auch bei abgeschaltetem Shop (Pflichten gegenueber Kaeufern) 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 abo = istAbo(s) // Abo-Modell: keine eigene Leiste, die Etage zeichnet sie (kn:reiter) 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 = abo ? [] : 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 = abo ? null : 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 } if (abo) { panel.setAttribute('aria-label', reiter.label) meldeReiter(s.root, { eintraege: reiterEintraege('shop', REITER), aktiv: reiter.key, bei: (neu) => waehleReiter(neu) }) } else 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].filter(Boolean)) // 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 = '' }