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:
ClaudeandClaude Opus 5.5 committed 2026-10-05 08:57:02 +00:00
1 parent 0ebd2e6663
commit 809da80c9b
11 files changed
+333 -19

No files matched your search

+82
View File
@@ -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 } }])
}))
+2
View File
@@ -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'])
+76
View File
@@ -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'), /&lt;img src=x onerror=1&gt; 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')
})
+51
View File
@@ -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'))
})