Shop G1: Verdrahtung #webshop, Webshop-Kachel, Bestellseite mit key und optionaler Zusammenfassung
Dashboard-Abschnitt view-webshop, shop.css und Platzhalter shop.js, app.js (Hash, Refresher, Label, Elternansicht), Kachel in funktionen.js nach shop.verfuegbar, GET /api/funktionen liefert shop.verfuegbar, funktion-bestellen.js nimmt key/name/zusammenfassung (Standard: Terminplaner unverändert). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0ebd2e6663
commit
809da80c9b
11 files changed
+333
-19
No files matched your search
+4
-3
@@ -139,7 +139,7 @@ function projectState(project) {
|
||||
return ['kb-state-off', 'Noch keine Website']
|
||||
}
|
||||
|
||||
const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen', news: 'News-Pinnwand', termine: 'Terminplaner' }
|
||||
const FEATURE_LABELS = { preview: 'Vorschau', chat: 'Chat', invoices: 'Rechnungen', news: 'News-Pinnwand', termine: 'Terminplaner', shop: 'Webshop' }
|
||||
|
||||
function renderProjectCard(project, features) {
|
||||
const li = document.createElement('li')
|
||||
@@ -1910,7 +1910,7 @@ function initPasswordView() {
|
||||
// "projekt" (Projektdetails als eigene Seite) gibt es ebenfalls nur in der App-Ansicht.
|
||||
// "passwort" oeffnet das Konto-Menue (Profilbild). #fortschritt fuehrt zu Projekte und scrollt zum Abschnitt
|
||||
// "Was wir zuletzt erledigt haben" (#pj-activity); hinter dem Hash darf ein Query stehen (#news?bestellt=1, Rueckkehr von Stripe).
|
||||
const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', analyse: 'analyse', termine: 'termine', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort' }
|
||||
const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', analyse: 'analyse', termine: 'termine', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort', webshop: 'webshop' }
|
||||
const HASH_ALIASES = { wartung: 'vertraege', abo: 'vertraege' }
|
||||
const viewLoaders = { chat: initChat }
|
||||
// Funktionen und News laden bei jedem Oeffnen neu (Zustand aendert sich durch Bestellung und Freischaltung); Modul erst dann laden
|
||||
@@ -1919,6 +1919,7 @@ const viewRefreshers = {
|
||||
news: () => import('/funktionen.js').then((m) => m.initNews(document.getElementById('news-body'))),
|
||||
analyse: () => import('/analyse.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))),
|
||||
termine: () => import('/termine.js').then((m) => m.initTermine(document.getElementById('termine-body'))),
|
||||
webshop: () => import('/shop.js').then((m) => m.initShop(document.getElementById('webshop-body'))),
|
||||
}
|
||||
const loadedViews = new Set()
|
||||
|
||||
@@ -2111,7 +2112,7 @@ const appLayout = window.matchMedia('(max-width: 880px), (pointer: coarse) and (
|
||||
const coarsePointer = window.matchMedia('(pointer: coarse)')
|
||||
// Unterseite -> Tab, zu dem sie gehoert (und Text neben dem Zurueck-Pfeil).
|
||||
// "passwort" setzt onAppViewShown: die Ansicht, aus der das Konto-Menue geoeffnet wurde.
|
||||
const APP_PARENT = { news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects' }
|
||||
const APP_PARENT = { news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects', webshop: 'funktionen' }
|
||||
const APP_PARENT_LABEL = { vertraege: 'Verträge', projects: 'Projekte', funktionen: 'Funktionen', invoices: 'Rechnungen', chat: 'Chat' }
|
||||
const appShell = { view: 'projects', scroll: {}, fromHistory: false, sideCards: [], installPrompt: null }
|
||||
const TEXT_INPUT = 'textarea, input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=date]):not([type=button]):not([type=submit]):not([type=range]):not([type=color])'
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<link rel="stylesheet" href="/funktionen.css">
|
||||
<link rel="stylesheet" href="/analyse.css">
|
||||
<link rel="stylesheet" href="/termine.css">
|
||||
<link rel="stylesheet" href="/shop.css">
|
||||
</head>
|
||||
<body class="portal-page dashboard-page kb-page">
|
||||
<div class="dot-background" aria-hidden="true">
|
||||
@@ -196,6 +197,15 @@
|
||||
<div id="termine-body"><p class="kb-empty">Lade…</p></div>
|
||||
</section>
|
||||
|
||||
<!-- Webshop: Anfrage, Bestellung, Artikel, Bestellungen, Einstellungen (shop.js, initShop); Unterseite von Funktionen -->
|
||||
<section class="kb-view" id="view-webshop">
|
||||
<header class="kb-head">
|
||||
<h1>Webshop</h1>
|
||||
<p>Dein eigener Online-Shop auf deiner Website – Zahlungen gehen direkt auf dein Stripe-Konto.</p>
|
||||
</header>
|
||||
<div id="webshop-body"><p class="kb-empty">Lade…</p></div>
|
||||
</section>
|
||||
|
||||
<!-- Rechnungen -->
|
||||
<section class="kb-view" id="view-invoices">
|
||||
<header class="kb-head">
|
||||
|
||||
@@ -4,12 +4,15 @@
|
||||
Es gibt keine Texte des Registers im Client: Knopf, Haken, Belehrung, Preis und Unterlagen kommen als `bestellung` aus
|
||||
GET /api/termine/einstellungen (Server: bestellungAnsicht), und der Knopf zeigt genau bestellung.consent.knopf, denselben Text, den der
|
||||
Server im Nachweis speichert. Fremdtext kommt nur über textContent in die Seite (h aus termine-dom.js), nie über innerHTML.
|
||||
Zusammenfassung: zusammenfassung() aus termine-view.js. Checkout: POST /api/funktionen/termine/checkout (Server: routes/funktionen.js). */
|
||||
Zusammenfassung: zusammenfassung() aus termine-view.js (Standard für key 'termine'; andere Funktionen übergeben eine eigene Funktion oder
|
||||
zeigen keine). Checkout: POST /api/funktionen/<key>/checkout (Server: routes/funktionen.js), key ist standardmäßig 'termine'. */
|
||||
|
||||
import { fehlerKarte, h, request } from './termine-dom.js'
|
||||
import { FRAGEBOGEN_SCHRITTE, zusammenfassung } from './termine-view.js'
|
||||
|
||||
const CHECKOUT = '/api/funktionen/termine/checkout'
|
||||
const STANDARD_KEY = 'termine'
|
||||
// Checkout-Pfad der Funktion; der Schlüssel ist ein Registerschlüssel (Kleinbuchstaben), alles andere fällt auf den Terminplaner zurück
|
||||
const checkoutPfad = (key) => `/api/funktionen/${typeof key === 'string' && /^[a-z][a-z0-9-]{0,31}$/.test(key) ? key : STANDARD_KEY}/checkout`
|
||||
const RECHTLICHES = '/rechtliches/'
|
||||
const KUNDENANSICHT = 'In der Kundenansicht kann nicht bestellt werden.'
|
||||
const ZAHLUNGSSEITE = 'Die Zahlungsseite konnte nicht geöffnet werden. Bitte versuch es noch einmal oder schreib uns im Chat.'
|
||||
@@ -113,12 +116,16 @@ function ladeBeiRueckkehrNeu(element) {
|
||||
* Der Knopf ist gesperrt, solange ein Haken fehlt, offene Punkte bleiben oder die Kundenansicht läuft. Ein Klick sperrt ihn, sendet
|
||||
* bestellKoerper und geht zur Zahlungsseite. Bei 409 lädt die Seite die Daten neu und setzt die Haken zurück (bestellung_offen zeigt die
|
||||
* Punkte aus der Antwort, fassung_veraltet die neue Fassung aus der Antwort, ein Abo-Code den Text der Antwort).
|
||||
* Weitere Optionen: key = Schlüssel der Funktion im Register (Standard 'termine', bestimmt den Checkout-Pfad), zusammenfassung =
|
||||
* Funktion (einstellung, fragebogen, branchen) => [{ schritt, titel, zeilen }]; ohne sie zeigt nur der Terminplaner seine Zusammenfassung,
|
||||
* andere Funktionen lassen den Block weg.
|
||||
*/
|
||||
export function renderBestellen(root, { ctx, daten, zumSchritt } = {}) {
|
||||
return baueSeite(root, { ctx, daten, zumSchritt }, {})
|
||||
export function renderBestellen(root, { ctx, daten, zumSchritt, key = STANDARD_KEY, zusammenfassung: gruppenFunktion } = {}) {
|
||||
const gruppenFn = typeof gruppenFunktion === 'function' ? gruppenFunktion : key === STANDARD_KEY ? zusammenfassung : null
|
||||
return baueSeite(root, { ctx, daten, zumSchritt, key, gruppenFn }, {})
|
||||
}
|
||||
|
||||
function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) {
|
||||
function baueSeite(root, { ctx, daten, zumSchritt, key, gruppenFn }, uebernahme) {
|
||||
const roh = daten?.bestellung
|
||||
if (!roh || !istConsent(roh.consent)) {
|
||||
const karte = fehlerKarte('Die Bestellseite konnte gerade nicht geladen werden. Bitte lade die Seite neu.')
|
||||
@@ -140,7 +147,7 @@ function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) {
|
||||
const gehe = (schritt) => { if (springt) zumSchritt(schritt) }
|
||||
|
||||
// 1 Zusammenfassung: je Gruppe die Angaben als Text und „Ändern“
|
||||
const gruppen = zusammenfassung(daten.einstellung, daten.fragebogen, daten.branchen).map((gruppe) => h('div', { class: 'tm-bestellen-gruppe', 'data-schritt': gruppe.schritt },
|
||||
const gruppen = (gruppenFn ? gruppenFn(daten.einstellung, daten.fragebogen, daten.branchen) : []).map((gruppe) => h('div', { class: 'tm-bestellen-gruppe', 'data-schritt': gruppe.schritt },
|
||||
h('div', { class: 'tm-bestellen-gruppe-kopf' },
|
||||
h('h4', null, gruppe.titel),
|
||||
springt
|
||||
@@ -200,8 +207,8 @@ function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) {
|
||||
|
||||
const seite = h('section', { class: 'kb-panel tm-bestellen' },
|
||||
h('h3', null, 'Prüfen und bestellen'),
|
||||
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.'),
|
||||
h('div', { class: 'tm-bestellen-zusammenfassung' }, gruppen),
|
||||
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('div', { class: 'tm-bestellen-zusammenfassung' }, gruppen) : null,
|
||||
offenBlock,
|
||||
preisBlock,
|
||||
belehrungBlock,
|
||||
@@ -246,7 +253,7 @@ function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) {
|
||||
karte.focus({ preventScroll: true })
|
||||
return
|
||||
}
|
||||
const neueSeite = baueSeite(root, { ctx, daten: neu, zumSchritt }, {
|
||||
const neueSeite = baueSeite(root, { ctx, daten: neu, zumSchritt, key, gruppenFn }, {
|
||||
offen: err.code === 'bestellung_offen' ? err.body?.offen : undefined,
|
||||
consent: err.code === 'fassung_veraltet' ? err.body?.consent : undefined,
|
||||
})
|
||||
@@ -264,7 +271,7 @@ function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) {
|
||||
aktualisiere()
|
||||
zeigeMeldung('')
|
||||
try {
|
||||
const antwort = await request(CHECKOUT, { method: 'POST', body: bestellKoerper({ projectId: ctx?.projectId, bestellung, gewaehlt }) })
|
||||
const antwort = await request(checkoutPfad(key), { method: 'POST', body: bestellKoerper({ projectId: ctx?.projectId, bestellung, gewaehlt }) })
|
||||
if (geheZu(antwort?.url)) return // die Seite wird verlassen, der Knopf bleibt gesperrt
|
||||
zeigeMeldung(ZAHLUNGSSEITE)
|
||||
} catch (err) {
|
||||
@@ -288,13 +295,15 @@ function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) {
|
||||
* projekt = Eintrag des Projekts aus GET /api/funktionen (projectId), bestellung = projekt.features.termine (state, orderedAt, ticketWoid,
|
||||
* invoiceUrl), einrichtung = Text der Zeiteinschätzung (Register: setupEstimate), danke = Rückkehr von Stripe, kundenansicht = Sitzung in der
|
||||
* Kundenansicht, meldung = Text, der am Knopf „Zahlung fortsetzen“ steht (z. B. der Fehler eines Versuchs vor dem Neuzeichnen).
|
||||
* key = Schlüssel der Funktion (Standard 'termine', Checkout-Pfad), name = Name der Funktion in
|
||||
* den Texten (Standard 'Terminplaner'), hinweisEinrichtung = Satz unter den Fakten im Zustand setup (Standard: Einstellungen gesperrt).
|
||||
* weiter(fall) wird gerufen, wenn „Zahlung fortsetzen“ scheitert (Ruling R110): fall = { bestellen, meldung }, bestellen ist wahr bei 409
|
||||
* zustimmung_fehlt (keine offene Sitzung mit Nachweis: die Ansicht zeigt wieder das Bestellformular), meldung der Fehlertext der Antwort.
|
||||
* weiter() lädt neu und zeichnet die Ansicht nach dem Zustand von neuem, damit keine veraltete Karte stehen bleibt (z. B. wenn die Bestellung
|
||||
* inzwischen bezahlt ist und der Server 400 antwortet); wirft es, bleibt die Karte stehen und der Fehler steht am Knopf. Fehlt weiter,
|
||||
* steht die Meldung des Servers am Knopf.
|
||||
*/
|
||||
export function statusKarte({ projekt, bestellung, einrichtung, danke, kundenansicht, meldung } = {}, { weiter } = {}) {
|
||||
export function statusKarte({ projekt, bestellung, einrichtung, danke, kundenansicht, meldung, key = STANDARD_KEY, name = 'Terminplaner', hinweisEinrichtung } = {}, { weiter } = {}) {
|
||||
const bestellt = bestellung?.state === 'ordered'
|
||||
const datum = datumTag(bestellung?.orderedAt)
|
||||
const ticket = bestellung?.ticketWoid
|
||||
@@ -317,7 +326,7 @@ export function statusKarte({ projekt, bestellung, einrichtung, danke, kundenans
|
||||
fortsetzenKnopf.disabled = true
|
||||
meldungEl.textContent = ''
|
||||
try {
|
||||
const antwort = await request(CHECKOUT, { method: 'POST', body: fortsetzenKoerper(projekt?.projectId) })
|
||||
const antwort = await request(checkoutPfad(key), { method: 'POST', body: fortsetzenKoerper(projekt?.projectId) })
|
||||
if (geheZu(antwort?.url)) return // die Seite wird verlassen
|
||||
meldungEl.textContent = ZAHLUNGSSEITE
|
||||
} catch (err) {
|
||||
@@ -341,12 +350,12 @@ export function statusKarte({ projekt, bestellung, einrichtung, danke, kundenans
|
||||
const karte = h('section', { class: 'kb-panel tm-statuskarte', 'data-state': bestellt ? 'ordered' : 'setup' },
|
||||
danke ? h('p', { class: 'tm-danke', role: 'status', 'data-danke': true }, DANKE) : null,
|
||||
h('span', { class: 'tm-pille tm-pille-wait' }, bestellt ? 'Zahlung offen' : 'In Einrichtung'),
|
||||
h('h3', null, bestellt ? 'Deine Bestellung wartet auf die Zahlung' : 'Wir richten deinen Terminplaner ein'),
|
||||
h('p', null, bestellt ? 'Sobald deine Zahlung bei uns ankommt, legen wir mit der Einrichtung los.' : 'Sobald die Einrichtung fertig ist, schalten wir deinen Terminplaner für dich frei.'),
|
||||
h('h3', null, bestellt ? 'Deine Bestellung wartet auf die Zahlung' : `Wir richten deinen ${text(name) || 'Terminplaner'} ein`),
|
||||
h('p', null, bestellt ? 'Sobald deine Zahlung bei uns ankommt, legen wir mit der Einrichtung los.' : `Sobald die Einrichtung fertig ist, schalten wir deinen ${text(name) || 'Terminplaner'} für dich frei.`),
|
||||
fakten.length > 0 ? h('dl', { class: 'tm-fakten' }, fakten) : null,
|
||||
bestellt
|
||||
? h('p', { class: 'tm-hinweis' }, 'Zahlst du per SEPA-Lastschrift, kann es einige Tage dauern, bis die Zahlung bei uns ankommt.')
|
||||
: h('p', { class: 'tm-hinweis' }, 'Deine Einstellungen sind jetzt gesperrt, Änderungen laufen über den Chat.'),
|
||||
: h('p', { class: 'tm-hinweis' }, text(hinweisEinrichtung) || 'Deine Einstellungen sind jetzt gesperrt, Änderungen laufen über den Chat.'),
|
||||
meldungEl,
|
||||
bestellt && kundenansicht === true ? h('p', { class: 'tm-hinweis' }, KUNDENANSICHT) : null,
|
||||
h('div', { class: 'tm-aktionen' },
|
||||
|
||||
+55
-1
@@ -71,6 +71,7 @@ const ICONS = {
|
||||
news: '<path d="M12 17v5"/><path d="M9 10.8a2 2 0 0 1-1.1 1.8l-1.8.9A2 2 0 0 0 5 15.2V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.8a2 2 0 0 0-1.1-1.8l-1.8-.9A2 2 0 0 1 15 10.8V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/>',
|
||||
calendar: '<rect x="3" y="5" width="18" height="16" rx="3"/><path d="M8 3v4M16 3v4M3 10h18"/>',
|
||||
chart: '<path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/>',
|
||||
shop: '<circle cx="9" cy="20" r="1.5"/><circle cx="18" cy="20" r="1.5"/><path d="M2 3h3l2.7 12.4a1 1 0 0 0 1 .8h9.1a1 1 0 0 0 1-.8L21 7H6"/>',
|
||||
}
|
||||
const svg = (name) => `<svg class="fn-ico" viewBox="0 0 24 24" aria-hidden="true">${ICONS[name]}</svg>`
|
||||
|
||||
@@ -211,6 +212,53 @@ async function showTermineCount(root, projects) {
|
||||
el.hidden = false
|
||||
}
|
||||
|
||||
/* Webshop: Kachel nur, wenn der Server den Shop fuer diesen Kunden freigibt (shop.verfuegbar der Antwort, Schalter SHOP_ENABLED); sonst
|
||||
gar keine Kachel (der Shop startet im Pilot und wird nicht vorab angekuendigt). Die Ansicht #webshop (shop.js) zeigt die Anfrage und ihren
|
||||
Stand; die Kachel kennt nur den Zustand des Funktionsdokuments (projects[].features.shop: off | ordered | setup | active | disabled) und
|
||||
baut sich inline wie die Terminplaner-Kachel, damit funktionen.js keinen weiteren Import braucht. Der Preis kommt aus dem Register
|
||||
(features.shop.priceLabel), Rueckfall 299 €. */
|
||||
const SHOP_PREIS = '299 €'
|
||||
const shopOf = (project) => project.features?.shop || { state: 'off' }
|
||||
|
||||
// Zielprojekt: aktiv, dann ordered/setup, dann abgeschaltet, dann das erste
|
||||
function pickShopProject(projects) {
|
||||
return projects.find((p) => shopOf(p).state === 'active')
|
||||
|| projects.find((p) => isSetup(shopOf(p).state))
|
||||
|| projects.find((p) => shopOf(p).state === 'disabled')
|
||||
|| projects[0]
|
||||
}
|
||||
|
||||
function shopTile(projects, features) {
|
||||
const preis = textOr(features?.shop?.priceLabel, SHOP_PREIS)
|
||||
const active = projects.filter((p) => shopOf(p).state === 'active').length
|
||||
const target = pickShopProject(projects)
|
||||
const state = target ? shopOf(target).state : 'off'
|
||||
const knopf = (text) => `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="webshop" data-project="${esc(target.projectId)}">${text}</button>`
|
||||
// Zustand -> Pille und Knopf (aus: noch keine Funktion, die Anfrage selbst zeigt die Ansicht #webshop)
|
||||
const ZUSTAENDE = {
|
||||
off: [['Aus', 'off'], 'Webshop anfragen'],
|
||||
ordered: [['Zahlung offen', 'setup'], 'Zahlung offen'],
|
||||
setup: [['In Einrichtung', 'setup'], 'In Einrichtung'],
|
||||
active: [['Aktiv', 'on'], 'Webshop öffnen'],
|
||||
disabled: [['Abgeschaltet', 'off'], 'Webshop ansehen'],
|
||||
}
|
||||
const [pill, knopfText] = ZUSTAENDE[state] || ZUSTAENDE.off
|
||||
const action = target ? knopf(knopfText) : ''
|
||||
let extra = ''
|
||||
if (!target) extra = '<p class="fn-count">Sobald deine Website angelegt ist, kannst du den Webshop anfragen.</p>'
|
||||
else if (state === 'disabled') extra = '<p>Der Webshop ist für diese Website abgeschaltet. Deine Bestellungen kannst du noch ansehen.</p>'
|
||||
const count = projects.length > 1 && active ? `<p class="fn-count">aktiv für ${active} von ${projects.length} Websites</p>` : ''
|
||||
return `
|
||||
<article class="fn-tile" data-fn-tile="shop">
|
||||
<div class="fn-tile-head">${svg('shop')}<span class="fn-pill fn-pill-${pill[1]}">${pill[0]}</span></div>
|
||||
<h3>Webshop</h3>
|
||||
<p>Dein eigener Online-Shop auf Deiner Website. Zahlungen gehen direkt auf Dein Stripe-Konto. ${esc(preis)} einmalig.</p>
|
||||
${extra}
|
||||
${count}
|
||||
${action}
|
||||
</article>`
|
||||
}
|
||||
|
||||
const soonTile = (icon, title, text) => `
|
||||
<article class="fn-tile fn-tile-soon">
|
||||
<div class="fn-tile-head">${svg(icon)}<span class="fn-pill fn-pill-soon">Kommt bald</span></div>
|
||||
@@ -220,11 +268,12 @@ const soonTile = (icon, title, text) => `
|
||||
|
||||
export async function initFunktionen(root) {
|
||||
if (!root) return
|
||||
const { projects = [], termine, features } = await request('/api/funktionen')
|
||||
const { projects = [], termine, features, shop } = await request('/api/funktionen')
|
||||
root.innerHTML = `
|
||||
<div class="fn-tiles">
|
||||
${newsTile(projects, newsOffer(features).price)}
|
||||
${termine?.verfuegbar ? termineTile(projects, termine) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)}
|
||||
${shop?.verfuegbar ? shopTile(projects, features) : ''}
|
||||
${analyseTile(projects)}
|
||||
</div>`
|
||||
root.querySelector('[data-fn-tile="news"] .fn-tile-cta')?.addEventListener('click', (ev) => { selection.projectId = ev.currentTarget.dataset.project })
|
||||
@@ -234,6 +283,11 @@ export async function initFunktionen(root) {
|
||||
const body = document.getElementById('termine-body')
|
||||
if (body) body.dataset.wunschProjekt = ev.currentTarget.dataset.project
|
||||
})
|
||||
// Die Webshop-Ansicht liest das gewaehlte Projekt aus ihrem Behaelter (shop.js, initShop)
|
||||
root.querySelector('[data-fn-tile="shop"] .fn-tile-cta[data-project]')?.addEventListener('click', (ev) => {
|
||||
const body = document.getElementById('webshop-body')
|
||||
if (body) body.dataset.wunschProjekt = ev.currentTarget.dataset.project
|
||||
})
|
||||
if (termine?.verfuegbar) await showTermineCount(root, projects)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/* 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. */
|
||||
|
||||
#view-webshop .kb-panel { max-width: none; }
|
||||
#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; }
|
||||
|
||||
.sh-info p { margin: 0 0 12px; line-height: 1.55; color: var(--kb-muted); }
|
||||
.sh-info p:last-child { margin-bottom: 0; }
|
||||
@@ -0,0 +1,16 @@
|
||||
/* 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. */
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '../services/siteFeatures.js'
|
||||
import { nachweisTexte, speichereZustimmung, zustimmungFuerSitzung, zustimmungsfassungen } from '../services/featureConsents.js'
|
||||
import { termineFuerKunde } from '../services/termine/schalter.js'
|
||||
import { shopFuerKunde } from '../services/shop/schalter.js'
|
||||
import { aboMemo } from '../services/aboStatus.js'
|
||||
import { featureConfig, featureHaken, featureKeys, featureSperre, oeffentlicheAnsicht } from '../services/featureRegistry.js'
|
||||
import { aboMeldung, bestellFehler, istFortsetzen, pruefeAbo, pruefeZustimmung } from '../services/featureOrder.js'
|
||||
@@ -173,6 +174,9 @@ router.get('/', requireSession, async (req, res) => {
|
||||
// Terminplaner: Schalter TERMINE_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel, dann auch
|
||||
// mit dem Abo des Kunden (bestellbar, grund) und dem Preis des Registers
|
||||
termine: await termineAngebot(req.session.email, customerId),
|
||||
// Webshop: Schalter SHOP_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel; Anfrage und Zustand je Projekt
|
||||
// kommen aus projects[].features.shop und GET /api/shop/status
|
||||
shop: { verfuegbar: shopFuerKunde(req.session.email) },
|
||||
})
|
||||
} catch (err) {
|
||||
fail(res, err, 'Die Funktionen konnten gerade nicht geladen werden. Bitte lade die Seite gleich noch einmal.')
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { installFakeDom } from './support/fakeDom.js'
|
||||
import { h } from '../public/termine-dom.js'
|
||||
import * as bestellen from '../public/funktion-bestellen.js'
|
||||
|
||||
// public/funktion-bestellen.js, Task G1: Checkout-Pfad und Zusammenfassung sind Parameter (key, zusammenfassung); ohne sie bleibt alles
|
||||
// beim Terminplaner. Ohne Server und ohne den schweren Fragebogen-Test: die Bestellung ist von Hand gebaut.
|
||||
|
||||
const URL_ZAHLUNG = 'https://checkout.stripe.test/c/pay/cs_test_9'
|
||||
const mitDom = (optionen, fn) => async () => {
|
||||
const fx = installFakeDom(optionen)
|
||||
try {
|
||||
await fn(fx)
|
||||
} finally {
|
||||
fx.restore()
|
||||
}
|
||||
}
|
||||
const bestellung = () => ({
|
||||
preis: '299 €',
|
||||
preisHinweis: 'einmalig, ohne Umsatzsteuer gemäß § 19 UStG',
|
||||
consent: { version: 'v-shop', knopf: 'Zahlungspflichtig bestellen', items: [{ id: 'a', text: 'Ich stimme zu.', haken: true }, { id: 'b', text: 'Hinweis', haken: false }] },
|
||||
})
|
||||
const daten = () => ({ kundenansicht: false, offen: [], bestellung: bestellung(), einstellung: {}, fragebogen: {}, branchen: [] })
|
||||
|
||||
async function bestelleUndGibLog(fx, optionen) {
|
||||
const wurzel = h('div')
|
||||
document.body.append(wurzel)
|
||||
bestellen.renderBestellen(wurzel, { ctx: { projectId: 'p1', aktuell: () => true }, daten: daten(), ...optionen })
|
||||
await fx.klick(fx.finde(wurzel, 'input[type="checkbox"]'))
|
||||
await fx.klick(fx.finde(wurzel, '[data-aktion="bestellen"]'))
|
||||
await fx.ruhe()
|
||||
return { wurzel, log: fx.fetchLog }
|
||||
}
|
||||
|
||||
test('renderBestellen mit key "shop": Checkout geht an /api/funktionen/shop/checkout, Körper unverändert', mitDom({ antworten: [{ body: { url: URL_ZAHLUNG } }] }, async (fx) => {
|
||||
const { log } = await bestelleUndGibLog(fx, { key: 'shop' })
|
||||
assert.deepEqual(log, [{ method: 'POST', url: '/api/funktionen/shop/checkout', body: { projectId: 'p1', zustimmung: { version: 'v-shop', haken: ['a'] } } }])
|
||||
assert.equal(fx.window.location.href, URL_ZAHLUNG)
|
||||
}))
|
||||
|
||||
test('renderBestellen ohne key: weiter /api/funktionen/termine/checkout; ein unzulässiger key fällt darauf zurück', mitDom({ antworten: [{ body: { url: URL_ZAHLUNG } }] }, async (fx) => {
|
||||
const { log } = await bestelleUndGibLog(fx, {})
|
||||
assert.equal(log[0].url, '/api/funktionen/termine/checkout')
|
||||
}))
|
||||
test('renderBestellen: ein key mit Pfadzeichen wird nie in den Pfad übernommen', mitDom({ antworten: [{ body: { url: URL_ZAHLUNG } }] }, async (fx) => {
|
||||
const { log } = await bestelleUndGibLog(fx, { key: '../abo/x?y' })
|
||||
assert.equal(log[0].url, '/api/funktionen/termine/checkout')
|
||||
}))
|
||||
|
||||
test('renderBestellen: ohne Zusammenfassungsfunktion (key shop) kein Zusammenfassungsblock; mit Funktion werden ihre Gruppen gezeichnet', mitDom({}, async (fx) => {
|
||||
const ohne = h('div')
|
||||
document.body.append(ohne)
|
||||
bestellen.renderBestellen(ohne, { ctx: { projectId: 'p1' }, daten: daten(), key: 'shop' })
|
||||
assert.equal(fx.finde(ohne, '.tm-bestellen-zusammenfassung'), null)
|
||||
assert.ok(fx.finde(ohne, '[data-aktion="bestellen"]'), 'Preis, Zustimmungen und Knopf bleiben')
|
||||
|
||||
const mit = h('div')
|
||||
document.body.append(mit)
|
||||
const aufrufe = []
|
||||
bestellen.renderBestellen(mit, {
|
||||
ctx: { projectId: 'p1' }, daten: daten(), key: 'shop',
|
||||
zusammenfassung: (...args) => { aufrufe.push(args.length); return [{ schritt: 'artikel', titel: 'Artikel', zeilen: ['3 Artikel'] }] },
|
||||
})
|
||||
assert.equal(aufrufe.length, 1)
|
||||
assert.ok(fx.finde(mit, '.tm-bestellen-zusammenfassung'))
|
||||
assert.match(fx.text(fx.finde(mit, '.tm-bestellen-gruppe')), /Artikel/)
|
||||
}))
|
||||
|
||||
test('statusKarte: key und name steuern Fortsetzen-Pfad und Texte; ohne Angaben bleibt es der Terminplaner', mitDom({ antworten: [{ body: { url: URL_ZAHLUNG } }] }, async (fx) => {
|
||||
const shop = bestellen.statusKarte({ projekt: { projectId: 'p1' }, bestellung: { state: 'setup' }, key: 'shop', name: 'Webshop' })
|
||||
assert.match(fx.text(shop), /Wir richten deinen Webshop ein/)
|
||||
assert.doesNotMatch(fx.text(shop), /Terminplaner/)
|
||||
const termine = bestellen.statusKarte({ projekt: { projectId: 'p1' }, bestellung: { state: 'setup' } })
|
||||
assert.match(fx.text(termine), /Wir richten deinen Terminplaner ein/)
|
||||
|
||||
const offen = bestellen.statusKarte({ projekt: { projectId: 'p1' }, bestellung: { state: 'ordered' }, key: 'shop', name: 'Webshop' })
|
||||
document.body.append(offen)
|
||||
await fx.klick(fx.finde(offen, '[data-aktion="fortsetzen"]'))
|
||||
await fx.ruhe()
|
||||
assert.deepEqual(fx.fetchLog, [{ method: 'POST', url: '/api/funktionen/shop/checkout', body: { projectId: 'p1', fortsetzen: true } }])
|
||||
}))
|
||||
@@ -67,6 +67,8 @@ test('liste_zustaende', async () => {
|
||||
// Terminplaner: zweite Funktion je Projekt, Schalter TERMINE_ENABLED (Vorgabe off) - Einzelheiten in funktionenTermine.test.js
|
||||
for (const p of body.projects) assert.equal(p.features.termine.state, 'off')
|
||||
assert.deepEqual(body.termine, { verfuegbar: false })
|
||||
// Webshop: Schalter SHOP_ENABLED (Vorgabe off) ⇒ nur das Flag, keine weiteren Felder
|
||||
assert.deepEqual(body.shop, { verfuegbar: false })
|
||||
|
||||
// Register-Ansicht (B12): Preise und Bestelltexte beider Funktionen aus config.features, auch bei termine.verfuegbar: false
|
||||
assert.deepEqual(Object.keys(body.features).sort(), ['news', 'shop', 'termine'])
|
||||
|
||||
@@ -488,3 +488,79 @@ test('Ausschalten: Hinweis vorher, nach "Ja, ausschalten" Bestätigung, erst "OK
|
||||
ok.handlers.click()
|
||||
assert.equal(reloads, 1)
|
||||
})
|
||||
|
||||
// ---- Webshop-Kachel (Task G1): nur bei shop.verfuegbar, Pille und Knopf nach dem Zustand des Funktionsdokuments
|
||||
const shopProject = (id, state, extra = {}) => ({ projectId: id, projectName: `P ${id}`, liveUrl: '', features: { news, shop: { state, ...extra } }, analyse: { comingSoon: true } })
|
||||
|
||||
test('Webshop-Kachel: ohne shop.verfuegbar (fehlt, false) gar keine Kachel', async () => {
|
||||
for (const extra of [{}, { shop: { verfuegbar: false } }, { shop: {} }, { shop: null }]) {
|
||||
const html = await renderMit([shopProject('a', 'off')], extra)
|
||||
assert.doesNotMatch(html, /data-fn-tile="shop"/, JSON.stringify(extra))
|
||||
assert.doesNotMatch(html, /Webshop/, JSON.stringify(extra))
|
||||
}
|
||||
})
|
||||
|
||||
test('Webshop-Kachel: Text mit Preis, Knopf und Pille je Zustand, Ziel #webshop mit Projekt', async () => {
|
||||
const faelle = [
|
||||
['off', 'Webshop anfragen', 'Aus', 'off'],
|
||||
['ordered', 'Zahlung offen', 'Zahlung offen', 'setup'],
|
||||
['setup', 'In Einrichtung', 'In Einrichtung', 'setup'],
|
||||
['active', 'Webshop öffnen', 'Aktiv', 'on'],
|
||||
['disabled', 'Webshop ansehen', 'Abgeschaltet', 'off'],
|
||||
]
|
||||
for (const [state, knopf, pille, klasse] of faelle) {
|
||||
const html = await renderMit([shopProject('p1', state)], { shop: { verfuegbar: true } })
|
||||
const t = tile(html, 'shop')
|
||||
assert.match(t, /<h3>Webshop<\/h3>/, state)
|
||||
assert.match(t, /Dein eigener Online-Shop auf Deiner Website\. Zahlungen gehen direkt auf Dein Stripe-Konto\. 299 € einmalig\./, state)
|
||||
assert.match(t, new RegExp(`<button type="button" class="kb-btn-primary fn-tile-cta" data-view="webshop" data-project="p1">${knopf}</button>`), state)
|
||||
assert.match(t, new RegExp(`fn-pill fn-pill-${klasse}">${pille}</span>`), state)
|
||||
}
|
||||
// ohne Dokument (features.shop fehlt) gilt off
|
||||
const ohne = await renderMit([{ projectId: 'p1', projectName: 'P', features: { news }, analyse: { comingSoon: true } }], { shop: { verfuegbar: true } })
|
||||
assert.match(tile(ohne, 'shop'), /Webshop anfragen/)
|
||||
assert.match(tile(ohne, 'shop'), /fn-pill-off">Aus</)
|
||||
})
|
||||
|
||||
test('Webshop-Kachel: Preis aus dem Register maskiert, Rückfall 299 €, Kachel steht nach dem Terminplaner und vor der Analyse', async () => {
|
||||
const html = await renderMit([shopProject('p1', 'off')], { shop: { verfuegbar: true }, features: { shop: { priceLabel: '<img src=x onerror=1> 349 €' } } })
|
||||
assert.doesNotMatch(html, /<img/)
|
||||
assert.match(tile(html, 'shop'), /<img src=x onerror=1> 349 € einmalig/)
|
||||
const rueck = await renderMit([shopProject('p1', 'off')], { shop: { verfuegbar: true }, features: { shop: { priceLabel: '' } } })
|
||||
assert.match(tile(rueck, 'shop'), /299 € einmalig/)
|
||||
const mitAnalyse = await renderMit([{ ...shopProject('p1', 'off'), analyse: { state: 'off', eligible: true } }], { shop: { verfuegbar: true }, termine: { verfuegbar: true, bestellbar: true } })
|
||||
const reihenfolge = ['news', 'termine', 'shop', 'analyse'].map((k) => mitAnalyse.indexOf(`data-fn-tile="${k}"`))
|
||||
assert.ok(reihenfolge.every((i) => i >= 0))
|
||||
assert.deepEqual([...reihenfolge].sort((a, b) => a - b), reihenfolge)
|
||||
})
|
||||
|
||||
test('Webshop-Kachel: mehrere Websites ⇒ aktives zuerst, dann Einrichtung, dann abgeschaltet; ohne Website ein Hinweis statt Knopf', async () => {
|
||||
const mehrere = await renderMit([shopProject('a', 'off'), shopProject('b', 'disabled'), shopProject('c', 'setup'), shopProject('d', 'active')], { shop: { verfuegbar: true } })
|
||||
assert.match(tile(mehrere, 'shop'), /data-project="d">Webshop öffnen/)
|
||||
assert.match(tile(mehrere, 'shop'), /aktiv für 1 von 4 Websites/)
|
||||
const ohneAktiv = await renderMit([shopProject('a', 'off'), shopProject('b', 'disabled'), shopProject('c', 'ordered')], { shop: { verfuegbar: true } })
|
||||
assert.match(tile(ohneAktiv, 'shop'), /data-project="c">Zahlung offen/)
|
||||
const keine = await renderMit([], { shop: { verfuegbar: true } })
|
||||
assert.match(tile(keine, 'shop'), /Sobald deine Website angelegt ist/)
|
||||
assert.doesNotMatch(tile(keine, 'shop'), /<button/)
|
||||
})
|
||||
|
||||
test('Webshop-Kachel: Projekt-ID maskiert, Klick legt das Wunschprojekt in #webshop-body', async () => {
|
||||
const bose = await renderMit([shopProject('"><img src=x>', 'off')], { shop: { verfuegbar: true } })
|
||||
assert.doesNotMatch(bose, /<img/)
|
||||
// Klick-Handler: derselbe Aufbau wie bei der Terminplaner-Kachel
|
||||
globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => ({ projects: [shopProject('p7', 'active')], shop: { verfuegbar: true } }) })
|
||||
const handlers = {}
|
||||
const knopf = { dataset: { project: 'p7' }, addEventListener: (typ, fn) => { handlers[typ] = fn } }
|
||||
const root = { innerHTML: '', querySelector: (sel) => (sel === '[data-fn-tile="shop"] .fn-tile-cta[data-project]' ? knopf : null) }
|
||||
const body = { dataset: {} }
|
||||
const vorher = globalThis.document
|
||||
globalThis.document = { getElementById: (id) => (id === 'webshop-body' ? body : null) }
|
||||
try {
|
||||
await initFunktionen(root)
|
||||
handlers.click({ currentTarget: knopf })
|
||||
} finally {
|
||||
globalThis.document = vorher
|
||||
}
|
||||
assert.equal(body.dataset.wunschProjekt, 'p7')
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
// Task G1: der Webshop ist im Kundenbereich verdrahtet (dashboard.html, app.js, funktionen.js, shop.css, shop.js). Rein statisch, ohne DOM.
|
||||
const lies = (pfad) => readFileSync(new URL(`../${pfad}`, import.meta.url), 'utf8')
|
||||
|
||||
test('dashboard.html: shop.css nach termine.css, Abschnitt #view-webshop mit Kopf „Webshop“ und #webshop-body', () => {
|
||||
const html = lies('public/dashboard.html')
|
||||
assert.match(html, /<link rel="stylesheet" href="\/termine\.css">\s*<link rel="stylesheet" href="\/shop\.css">/)
|
||||
assert.match(html, /<section class="kb-view" id="view-webshop">[\s\S]*?<h1>Webshop<\/h1>[\s\S]*?<div id="webshop-body">[\s\S]*?<\/section>/)
|
||||
assert.equal([...html.matchAll(/id="view-webshop"/g)].length, 1)
|
||||
assert.doesNotMatch(html, /data-view="webshop"/, 'kein eigener Navigationseintrag: Unterseite von Funktionen')
|
||||
})
|
||||
|
||||
test('app.js: VIEW_HASHES, viewRefreshers, FEATURE_LABELS und APP_PARENT kennen den Webshop', () => {
|
||||
const app = lies('public/app.js')
|
||||
assert.match(app, /VIEW_HASHES = \{[^}]*webshop: 'webshop'/)
|
||||
assert.match(app, /FEATURE_LABELS = \{[^}]*shop: 'Webshop'/)
|
||||
assert.match(app, /webshop: \(\) => import\('\/shop\.js'\)\.then\(\(m\) => m\.initShop\(document\.getElementById\('webshop-body'\)\)\)/)
|
||||
// APP_PARENT: die Reihenfolge der bisherigen Einträge bleibt (termineView.test.js), webshop steht am Ende
|
||||
assert.match(app, /APP_PARENT = \{ news: 'funktionen', analyse: 'funktionen', termine: 'funktionen', /)
|
||||
assert.match(app, /APP_PARENT = \{[^}]*projekt: 'projects', webshop: 'funktionen' \}/)
|
||||
// die Ansicht ist vom Hash aus erreichbar: kein zweiter Schlüssel mit demselben Hash
|
||||
const hashes = app.match(/VIEW_HASHES = \{([^}]*)\}/)[1]
|
||||
assert.equal([...hashes.matchAll(/'webshop'/g)].length, 1)
|
||||
})
|
||||
|
||||
test('shop.js und shop.css: die Hülle exportiert initShop, die Styles hängen an #view-webshop und sh-', () => {
|
||||
const js = lies('public/shop.js')
|
||||
assert.match(js, /export function initShop\(root, \{ projectId \} = \{\}\)/)
|
||||
assert.doesNotMatch(js.replace(/\/\*[\s\S]*?\*\//g, ''), /innerHTML|insertAdjacentHTML|outerHTML|document\.write/, 'Text nie als HTML')
|
||||
const css = lies('public/shop.css')
|
||||
assert.match(css, /#view-webshop/)
|
||||
assert.doesNotMatch(css, /(^|[}\s,])\.tm-/m, 'keine Klassen des Terminplaners neu definieren')
|
||||
})
|
||||
|
||||
test('funktionen.js: Webshop-Kachel inline, die erlaubten Importe bleiben (zwei absolute, ein relativer, kein dynamischer)', () => {
|
||||
const fn = lies('public/funktionen.js')
|
||||
assert.deepEqual(fn.match(/from '\/[^']+'/g), ["from '/fn-util.js'", "from '/analyse-view.js'"])
|
||||
assert.deepEqual(fn.match(/from '\.[^']+'/g), ["from './vertraege-link.js'"])
|
||||
assert.doesNotMatch(fn, /import\s*\(/)
|
||||
assert.match(fn, /data-fn-tile="shop"/)
|
||||
assert.match(fn, /getElementById\('webshop-body'\)/)
|
||||
})
|
||||
|
||||
test('Dateinamen: Webshop-Dateien beginnen nie mit termine (eigene Regeln der termine*.js-Tests)', async () => {
|
||||
const { readdirSync } = await import('node:fs')
|
||||
const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /^shop.*\.(js|css)$/.test(f))
|
||||
assert.ok(dateien.includes('shop.js') && dateien.includes('shop.css'))
|
||||
})
|
||||
Reference in new issue
Block a user