From 3ed96a5838e48f20f50b25e3efc3b0b5a8036278 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:10:30 +0000 Subject: [PATCH] =?UTF-8?q?Shop=20G3:=20Reiter=20=C3=9Cbersicht=20mit=20Ch?= =?UTF-8?q?eckliste=20und=20=C3=96ffnen-Schalter,=20Stripe-Konto,=20Einbau?= =?UTF-8?q?-Code=20mit=20Vorschau=20und=20Kennzahlen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Platzhalter-Prüfungen in shopView.test.js hängen nicht mehr am Text „Kommt gleich …“. Co-Authored-By: Claude Opus 5.5 --- public/shop-tab-uebersicht.js | 251 +++++++++++++++++++- public/shop.css | 17 ++ test/shopTabUebersicht.test.js | 414 +++++++++++++++++++++++++++++++++ test/shopView.test.js | 8 +- 4 files changed, 681 insertions(+), 9 deletions(-) create mode 100644 test/shopTabUebersicht.test.js diff --git a/public/shop-tab-uebersicht.js b/public/shop-tab-uebersicht.js index e57ff49..7a58df1 100644 --- a/public/shop-tab-uebersicht.js +++ b/public/shop-tab-uebersicht.js @@ -1,8 +1,249 @@ -/* 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). */ +/* Webshop, Reiter „Übersicht“ (Plan Block G, Task G3): vier Karten aus GET /api/shop/status. + - „Bereit zum Öffnen“: Checkliste (status.bereitschaft.punkte) und der Schalter „Shop öffnen“ / „Shop schließen“ (POST /api/shop/oeffnen + { projectId, offen }); geöffnet werden kann nur, wenn alles erfüllt ist, der Server prüft erneut (409 nicht_bereit mit den Punkten). + - „Stripe-Konto“: einrichten (POST /api/shop/stripe/einrichten), Angaben ergänzen (…/link), Status aktualisieren (…/aktualisieren), + Link zum Stripe-Dashboard. Navigiert wird nur auf https-Adressen. Kommt man von Stripe zurück (ctx.stripeRueckkehr), wird der Status + einmal automatisch neu gelesen. + - „Auf deiner Website“: Einbau-Code nur zum Lesen und Kopieren, Vorschau-Link; „Zahlen“: Kennzahlen, nur was der Server liefert. + Mit ctx.nurLesen (Shop abgeschaltet) gibt es keine Knöpfe, nur Links. In der Kundenansicht antwortet der Server mit 403: der Text wird gezeigt. + render(panel, ctx): ctx = { projectId, status, nurLesen, stripeRueckkehr, aktuell(), neuLaden(), zeigeMeldung(text, art), request } (shop.js). + Fremdtext kommt nur über textContent in die Seite. */ -import { h } from './shop-dom.js' +import { abschnitt, euroText, h } from './shop-dom.js' -export function render(panel) { - panel.replaceChildren(h('p', { class: 'sh-hinweis' }, 'Kommt gleich …')) +const DASHBOARD_URL = 'https://dashboard.stripe.com/' +const FEHLER_URL = 'Stripe hat keine gültige Adresse geliefert. Bitte versuch es gleich noch einmal.' +const zurueckGelesen = new WeakSet() // je ctx wird der Stand nach der Rückkehr von Stripe nur einmal automatisch gelesen + +const istHttps = (url) => { + if (typeof url !== 'string' || /[\s\u0000-\u001f\\]/.test(url)) return false + try { + const u = new URL(url) + return u.protocol === 'https:' && u.username === '' && u.password === '' + } catch { + return false + } +} +const zahl = (wert) => (Number.isInteger(wert) && wert >= 0 ? wert : null) +const liste = (wert) => (Array.isArray(wert) ? wert.filter((x) => typeof x === 'string') : []) + +/** Externer Link (neuer Tab, ohne Opener); der Aufrufer prüft die Adresse vorher. */ +const externLink = (url, text, extra = {}) => h('a', { href: url, target: '_blank', rel: 'noopener noreferrer', ...extra }, text) + +export function render(panel, ctx) { + let st = ctx.status || {} + let beschaeftigt = false + const wurzel = h('div', { class: 'sh-uebersicht' }) + panel.replaceChildren(wurzel) + + const hier = () => wurzel.parentNode === panel && ctx.aktuell?.() !== false + const melde = (text, art = 'ok') => ctx.zeigeMeldung?.(text, art) + + /** Stand neu holen (die Hülle zeichnet bei einem Wechsel des Zustands selbst neu); Fehler sind hier nicht wichtig, der alte Stand bleibt. */ + async function ladeNeu() { + try { + const neu = await ctx.neuLaden?.() + if (neu && typeof neu === 'object') st = neu + else if (ctx.status) st = ctx.status + } catch { + /* alter Stand bleibt */ + } + } + + /** Eine Aktion mit Sperre gegen Doppelklick; Fehler als Meldung, danach wird die Karte neu gezeichnet. */ + async function aktion(fn) { + if (beschaeftigt) return + beschaeftigt = true + wurzel.setAttribute('aria-busy', 'true') + for (const knopf of wurzel.querySelectorAll('button')) knopf.setAttribute('disabled', '') + try { + await fn() + } catch (err) { + if (hier()) { + if (Array.isArray(err?.body?.punkte) && st.bereitschaft) st = { ...st, bereitschaft: { ...st.bereitschaft, bereit: false, punkte: err.body.punkte } } + melde(err?.message || 'Das hat nicht geklappt. Bitte versuch es noch einmal.', 'fehler') + } + } finally { + beschaeftigt = false + wurzel.removeAttribute('aria-busy') + if (hier()) zeichne() + } + } + + const post = (pfad, body) => ctx.request(pfad, { method: 'POST', body: { projectId: ctx.projectId, ...body } }) + + /* ------------------------------------------------------------ Öffnen --- */ + const oeffnen = (offen) => aktion(async () => { + const antwort = await post('/api/shop/oeffnen', { offen }) + st = { ...st, schalter: antwort.schalter === true, geoeffnet: antwort.geoeffnet === true, bereitschaft: antwort.bereitschaft || st.bereitschaft } + await ladeNeu() + if (!hier()) return + if (!offen) melde('Dein Shop ist jetzt geschlossen.') + else if (st.geoeffnet) melde('Dein Shop ist jetzt geöffnet.') + else melde('Der Schalter steht auf „geöffnet“. Dein Shop geht live, sobald er fertig eingebaut ist.') + }) + + function bereitKarte() { + const b = st.bereitschaft + const inhalt = [] + if (b && Array.isArray(b.punkte)) { + inhalt.push(h('ul', { class: 'sh-checkliste', 'data-liste': 'bereitschaft' }, b.punkte.map((p) => h('li', { class: p.ok ? 'sh-ok' : 'sh-offen', 'data-punkt': String(p.id ?? ''), 'data-ok': p.ok ? '1' : '0' }, + h('span', { class: 'sh-haken-zeichen', 'aria-hidden': 'true' }, p.ok ? '✓' : '○'), + h('span', { class: 'sh-sr' }, p.ok ? 'Erledigt: ' : 'Noch offen: '), + h('span', null, typeof p.text === 'string' ? p.text : ''))))) + } else { + inhalt.push(h('p', { class: 'sh-hinweis' }, 'Die Checkliste erscheint, sobald dein Shop eingebaut wird.')) + } + const offen = st.geoeffnet === true + inhalt.push(h('p', { class: `sh-zustand ${offen ? 'sh-zustand-offen' : 'sh-zustand-zu'}`, 'data-zustand': offen ? 'offen' : 'zu', role: 'status' }, offen ? 'Dein Shop ist geöffnet.' : 'Dein Shop ist geschlossen.')) + if (st.schalter === true && !offen) inhalt.push(h('p', { class: 'sh-hinweis' }, 'Der Schalter steht auf „geöffnet“, aber dein Shop ist gerade nicht erreichbar. Prüfe die Punkte oben.')) + if (b && !ctx.nurLesen) { + const bereit = b.bereit === true + const schliessen = st.schalter === true + inhalt.push(h('div', { class: 'sh-aktionen' }, h('button', { + type: 'button', + class: schliessen ? 'kb-btn-outline' : 'kb-btn-primary', + 'data-aktion': schliessen ? 'schliessen' : 'oeffnen', + disabled: !schliessen && !bereit, + onclick: () => oeffnen(!schliessen), + }, schliessen ? 'Shop schließen' : 'Shop öffnen'))) + if (!schliessen && !bereit) inhalt.push(h('p', { class: 'sh-hinweis' }, 'Du kannst deinen Shop öffnen, sobald alle Punkte erledigt sind.')) + } + return abschnitt('Bereit zum Öffnen', 'Das braucht dein Shop, bevor Käufer bei dir bestellen können.', ...inhalt) + } + + /* ------------------------------------------------------------ Stripe --- */ + /** Zu Stripe gehen: nur auf eine https-Adresse; sonst Fehlermeldung und kein Wechsel. */ + function gehZuStripe(url) { + if (!istHttps(url)) throw new Error(FEHLER_URL) + melde('Du wirst zu Stripe weitergeleitet …') + window.location.href = url + } + + const stripeEinrichten = (pfad) => aktion(async () => { + const antwort = await post(pfad, {}) + if (pfad.endsWith('/einrichten')) st = { ...st, stripe: { ...(st.stripe || {}), verbunden: true } } // das Konto ist angelegt, auch wenn der Link nicht klappt + try { + gehZuStripe(antwort?.url) + } catch (err) { + await ladeNeu() + throw err + } + }) + + const stripeAktualisieren = (art) => aktion(async () => { + const antwort = await post('/api/shop/stripe/aktualisieren', {}) + if (antwort?.stripe) st = { ...st, stripe: antwort.stripe } + await ladeNeu() // die Checkliste hängt an den Zahlungen + if (!hier()) return + if (art === 'neu') melde('Der Link bei Stripe war abgelaufen. Wir haben den Stand neu gelesen. Mit „Angaben ergänzen“ bekommst du einen neuen Link.') + else if (art === 'zurueck') melde('Willkommen zurück von Stripe. Wir haben den Stand deines Kontos neu gelesen.') + else melde('Der Stand deines Stripe-Kontos ist aktuell.') + }) + + function stripeZeilen(s) { + if (!s || typeof s !== 'object') return [h('li', null, 'Der Stand ist noch nicht gelesen. Klicke auf „Status aktualisieren“.')] + const faellig = [...new Set([...liste(s.currently_due), ...liste(s.past_due)])] + const zeilen = [ + h('li', { 'data-stripe': 'zahlungen', class: s.charges_enabled ? 'sh-ok' : 'sh-offen' }, s.charges_enabled ? 'Zahlungen: aktiv' : 'Zahlungen: noch nicht freigeschaltet'), + h('li', { 'data-stripe': 'auszahlungen', class: s.payouts_enabled ? 'sh-ok' : 'sh-offen' }, s.payouts_enabled ? 'Auszahlungen: aktiv' : 'Auszahlungen: noch nicht freigeschaltet'), + ] + if (s.details_submitted === false) zeilen.push(h('li', { 'data-stripe': 'angaben', class: 'sh-offen' }, 'Deine Angaben bei Stripe sind noch nicht abgeschickt.')) + zeilen.push(h('li', { 'data-stripe': 'fehlend', class: faellig.length ? 'sh-offen' : 'sh-ok' }, + faellig.length === 0 ? 'Keine fehlenden Angaben' : faellig.length === 1 ? '1 Angabe fehlt noch' : `${faellig.length} Angaben fehlen noch`)) + if (typeof s.disabled_reason === 'string' && s.disabled_reason) zeilen.push(h('li', { 'data-stripe': 'gesperrt', class: 'sh-offen' }, `Stripe hat dein Konto vorerst eingeschränkt (Grund: ${s.disabled_reason}). Die Einzelheiten findest du im Stripe-Dashboard.`)) + return zeilen + } + + function stripeKarte() { + const stripe = st.stripe || {} + if (!stripe.verbunden) { + return abschnitt('Stripe-Konto', null, + h('p', { class: 'sh-hinweis' }, 'Damit Käufer bei dir bezahlen können, brauchst du ein Stripe-Konto.'), + h('ul', { class: 'sh-erklaerung-liste', 'data-stripe': 'erklaerung' }, + h('li', null, 'Zahlungen gehen direkt auf dein eigenes Stripe-Konto.'), + h('li', null, 'Die Stripe-Gebühren zahlst du direkt an Stripe.'), + h('li', null, 'WEBklar bekommt kein Geld deiner Käufer.'), + h('li', null, 'PayPal ist über Stripe für Shops wie deinen leider nicht möglich.')), + ctx.nurLesen ? null : h('div', { class: 'sh-aktionen' }, + h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'stripe-einrichten', onclick: () => stripeEinrichten('/api/shop/stripe/einrichten') }, 'Stripe-Konto einrichten'))) + } + const s = stripe.status + const fertig = s?.charges_enabled === true + return abschnitt('Stripe-Konto', null, + h('ul', { class: 'sh-stripe-status', 'data-stripe': 'status' }, stripeZeilen(s)), + typeof s?.hinweis === 'string' && s.hinweis ? h('div', { class: 'sh-box sh-box-warn', role: 'note', 'data-stripe': 'hinweis' }, s.hinweis) : null, + h('div', { class: 'sh-aktionen' }, + ctx.nurLesen ? null : [ + h('button', { type: 'button', class: fertig ? 'kb-btn-outline' : 'kb-btn-primary', 'data-aktion': 'stripe-link', onclick: () => stripeEinrichten('/api/shop/stripe/link') }, 'Angaben ergänzen'), + h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'stripe-aktualisieren', onclick: () => stripeAktualisieren('') }, 'Status aktualisieren'), + ], + externLink(DASHBOARD_URL, 'Stripe-Dashboard öffnen', { class: 'kb-btn-outline', 'data-link': 'dashboard' }))) + } + + /* ------------------------------------------------------- Website --- */ + async function kopiere(snippet, status) { + try { + await globalThis.navigator.clipboard.writeText(snippet) + status.textContent = 'Der Code ist kopiert.' + return + } catch { + /* kein Zugriff auf die Zwischenablage: Ausweichweg über ein verstecktes Textfeld */ + } + let geklappt = false + const feld = h('textarea', { class: 'sh-sr', readonly: true, 'aria-hidden': 'true', tabindex: '-1' }) + feld.value = snippet + try { + document.body.append(feld) + feld.select() + geklappt = document.execCommand('copy') === true + } catch { + geklappt = false + } + feld.remove() + status.textContent = geklappt + ? 'Der Code ist kopiert.' + : 'Das Kopieren hat nicht geklappt. Markiere den Code und kopiere ihn von Hand (Strg+C, am Mac Cmd+C).' + } + + function websiteKarte() { + const snippet = typeof st.snippet === 'string' && st.snippet ? st.snippet : '' + const vorschau = istHttps(st.vorschauUrl) ? st.vorschauUrl : '' + const status = h('p', { class: 'sh-hinweis', role: 'status', 'data-kopiert': '' }) + return abschnitt('Auf deiner Website', + 'WEBklar baut den Shop für dich in deine Website ein. Du musst nichts selbst einfügen.', + snippet ? h('pre', { class: 'sh-code', tabindex: '0', 'aria-label': 'Einbau-Code' }, h('code', { 'data-snippet': '' }, snippet)) : h('p', { class: 'sh-hinweis' }, 'Der Einbau-Code erscheint, sobald dein Shop eingerichtet ist.'), + snippet || vorschau ? h('div', { class: 'sh-aktionen' }, + snippet && !ctx.nurLesen ? h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'kopieren', onclick: () => kopiere(snippet, status) }, 'Code kopieren') : null, + vorschau ? externLink(vorschau, 'Shop-Vorschau öffnen', { class: 'kb-btn-outline', 'data-link': 'vorschau' }) : null) : null, + status) + } + + /* --------------------------------------------------------- Zahlen --- */ + function zahlenKarte() { + const z = st.zahlen + if (!z || typeof z !== 'object') return null + const zeilen = [] + const artikel = zahl(z.artikel) + const aktiv = zahl(z.aktiveArtikel) + if (artikel !== null) zeilen.push(['Artikel', aktiv !== null && aktiv !== artikel ? `${artikel} (davon ${aktiv} aktiv)` : String(artikel), 'artikel']) + if (zahl(z.offeneBestellungen) !== null) zeilen.push(['Offene Bestellungen', String(z.offeneBestellungen), 'offene']) + if (zahl(z.bestellungen30) !== null) zeilen.push(['Bestellungen (30 Tage)', String(z.bestellungen30), 'bestellungen30']) + if (zahl(z.umsatz30Cent) !== null) zeilen.push(['Umsatz (30 Tage)', euroText(z.umsatz30Cent), 'umsatz30']) + if (!zeilen.length) return null + return abschnitt('Zahlen', null, h('dl', { class: 'sh-fakten', 'data-liste': 'zahlen' }, zeilen.map(([name, wert, key]) => h('div', { 'data-zahl': key }, h('dt', null, name), h('dd', null, wert))))) + } + + function zeichne() { + wurzel.replaceChildren(...[bereitKarte(), stripeKarte(), websiteKarte(), zahlenKarte()].filter(Boolean)) + } + + zeichne() + // vom Onboarding bei Stripe zurück: den Stand einmal automatisch lesen + const rueck = ctx.stripeRueckkehr + if (rueck && !ctx.nurLesen && st.stripe?.verbunden && !zurueckGelesen.has(ctx)) { + zurueckGelesen.add(ctx) + return stripeAktualisieren(rueck) + } + return undefined } diff --git a/public/shop.css b/public/shop.css index c013ad6..cc460be 100644 --- a/public/shop.css +++ b/public/shop.css @@ -92,3 +92,20 @@ .sh-fakten > div { grid-template-columns: minmax(0, 1fr); } .sh-aktionen > * { flex: 1 1 auto; } } + +/* Reiter Übersicht (shop-tab-uebersicht.js) */ +.sh-checkliste, .sh-stripe-status, .sh-erklaerung-liste { margin: 0 0 12px; padding: 0; list-style: none; display: grid; gap: 6px; } +.sh-checkliste li, .sh-stripe-status li { display: flex; align-items: flex-start; gap: 10px; line-height: 1.5; } +.sh-checkliste .sh-haken-zeichen { flex: none; width: 20px; text-align: center; font-weight: 700; } +.sh-checkliste .sh-ok .sh-haken-zeichen { color: #86efac; } +.sh-checkliste .sh-offen .sh-haken-zeichen { color: var(--kb-muted); } +.sh-stripe-status .sh-ok { color: #86efac; } +.sh-stripe-status .sh-offen { color: #fcd34d; } +.sh-erklaerung-liste { list-style: disc; padding-left: 20px; display: block; line-height: 1.6; color: var(--kb-muted); } +.sh-erklaerung-liste li { margin-bottom: 4px; } +.sh-zustand { margin: 12px 0 0; font-weight: 600; } +.sh-zustand-offen { color: #86efac; } +.sh-zustand-zu { color: var(--kb-muted); } +.sh-code { margin: 0 0 8px; padding: 12px 14px; overflow-x: auto; border-radius: 10px; border: 1px solid var(--kb-border-strong); background: rgba(0, 0, 0, 0.25); font-size: 13px; line-height: 1.5; white-space: pre; } +.sh-code code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +a.kb-btn-outline { display: inline-flex; align-items: center; text-decoration: none; } diff --git a/test/shopTabUebersicht.test.js b/test/shopTabUebersicht.test.js new file mode 100644 index 0000000..034e025 --- /dev/null +++ b/test/shopTabUebersicht.test.js @@ -0,0 +1,414 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { installFakeDom } from './support/fakeDom.js' +import { h, request } from '../public/shop-dom.js' +import { render } from '../public/shop-tab-uebersicht.js' + +// Task G3: Reiter „Übersicht“ (public/shop-tab-uebersicht.js) mit Fake-DOM und Fake-fetch. Die Antworten sind von Hand gebaut +// (Form: server/routes/shop.js, GET /status, POST /oeffnen, POST /stripe/einrichten|link|aktualisieren). + +const SNIPPET = '
\n' +const VORSCHAU = 'https://project.webklar.com/shop/abc123' +const URL_STRIPE = 'https://connect.stripe.test/setup/e/acct_1/xyz' + +const punkte = (...oks) => oks.map((ok, i) => ({ id: `p${i}`, ok, text: `Punkt ${i} ${ok ? 'erledigt' : 'fehlt'}` })) +const bereitschaft = (...oks) => ({ bereit: oks.every(Boolean), punkte: punkte(...oks) }) +const STRIPE_OK = { charges_enabled: true, payouts_enabled: true, details_submitted: true, currently_due: [], past_due: [], disabled_reason: null } +const status = (extra = {}) => ({ + verfuegbar: true, state: 'active', bereitschaft: bereitschaft(true, true, true), geoeffnet: false, schalter: false, siteKey: 'abc123', + snippet: SNIPPET, vorschauUrl: VORSCHAU, stripe: { verbunden: true, status: STRIPE_OK }, + zahlen: { artikel: 4, aktiveArtikel: 3, offeneBestellungen: 2, bestellungen30: 11, umsatz30Cent: 123450 }, ...extra, +}) + +/** Fake-Server: jede Anfrage geht an route(aufruf) ⇒ { status, body }; Standard 200 {}. */ +function starte(route = () => ({ body: {} })) { + return installFakeDom({ antworten: Array.from({ length: 40 }, () => route) }) +} + +/** ctx wie shop.js ihn baut; meldungen sammelt zeigeMeldung, neuLaden gibt `neu` (oder den alten Stand) zurück. */ +function baueCtx(st, { nurLesen = false, stripeRueckkehr = '', neu = null } = {}) { + const ctx = { + projectId: 'p1', + status: st, + nurLesen, + stripeRueckkehr, + meldungen: [], + geladen: 0, + aktuell: () => true, + request, + zeigeMeldung: (text, art) => ctx.meldungen.push({ text, art }), + neuLaden: async () => { + ctx.geladen += 1 + if (neu) ctx.status = typeof neu === 'function' ? neu() : neu + return ctx.status + }, + } + return ctx +} + +async function zeige(fx, st, optionen = {}) { + const ctx = baueCtx(st, optionen) + const panel = h('div', { id: 'sh-panel' }) + fx.document.body.append(panel) + await render(panel, ctx) + await fx.ruhe() + return { panel, ctx } +} + +const mit = (route, fn) => async () => { + const fx = starte(route) + try { + await fn(fx) + } finally { + fx.restore() + } +} +const knopf = (fx, panel, aktion) => fx.finde(panel, `[data-aktion="${aktion}"]`) +const posts = (fx) => fx.fetchLog.filter((a) => a.method !== 'GET') +const karte = (fx, panel, titel) => fx.alle(panel, 'section').find((s) => fx.finde(s, 'h3')?.textContent === titel) + +/* ------------------------------------------------------------ Darstellung --- */ +test('Übersicht: vier Karten, Checkliste mit Haken und Kreis, Zustand geschlossen', mit(undefined, async (fx) => { + const { panel } = await zeige(fx, status({ bereitschaft: bereitschaft(true, false, true) })) + assert.deepEqual(fx.alle(panel, 'section').map((s) => fx.finde(s, 'h3').textContent), ['Bereit zum Öffnen', 'Stripe-Konto', 'Auf deiner Website', 'Zahlen']) + const eintraege = fx.alle(panel, '[data-punkt]') + assert.equal(eintraege.length, 3) + assert.deepEqual(eintraege.map((li) => li.dataset.ok), ['1', '0', '1']) + assert.match(fx.text(eintraege[0]), /✓/) + assert.match(fx.text(eintraege[1]), /○/) + assert.match(fx.text(eintraege[1]), /Punkt 1 fehlt/) + assert.equal(fx.finde(panel, '[data-zustand]').textContent, 'Dein Shop ist geschlossen.') +})) + +test('Übersicht: geöffnet zeigt den Zustand und „Shop schließen“', mit(undefined, async (fx) => { + const { panel } = await zeige(fx, status({ geoeffnet: true, schalter: true })) + assert.equal(fx.finde(panel, '[data-zustand]').textContent, 'Dein Shop ist geöffnet.') + assert.equal(knopf(fx, panel, 'schliessen').textContent, 'Shop schließen') + assert.equal(knopf(fx, panel, 'oeffnen'), null) +})) + +test('Übersicht: ohne Bereitschaft (null) gibt es Hinweis, aber keinen Schalter', mit(undefined, async (fx) => { + const { panel } = await zeige(fx, status({ bereitschaft: null, snippet: null, vorschauUrl: null, zahlen: null, stripe: { verbunden: false, status: null } })) + assert.match(fx.text(karte(fx, panel, 'Bereit zum Öffnen')), /Die Checkliste erscheint/) + assert.equal(knopf(fx, panel, 'oeffnen'), null) + assert.equal(knopf(fx, panel, 'schliessen'), null) + assert.ok(!karte(fx, panel, 'Zahlen'), 'ohne Zahlen keine Karte') + assert.match(fx.text(karte(fx, panel, 'Auf deiner Website')), /Der Einbau-Code erscheint/) +})) + +test('Stripe nicht verbunden: Erklärung (eigenes Konto, Gebühren, kein Geld über WEBklar, kein PayPal) und „Stripe-Konto einrichten“', mit(undefined, async (fx) => { + const { panel } = await zeige(fx, status({ stripe: { verbunden: false, status: null } })) + const text = fx.text(karte(fx, panel, 'Stripe-Konto')) + assert.match(text, /direkt auf dein eigenes Stripe-Konto/) + assert.match(text, /Stripe-Gebühren zahlst du direkt an Stripe/) + assert.match(text, /WEBklar bekommt kein Geld deiner Käufer/) + assert.match(text, /PayPal ist über Stripe für Shops wie deinen leider nicht möglich/) + assert.equal(knopf(fx, panel, 'stripe-einrichten').textContent, 'Stripe-Konto einrichten') + assert.equal(knopf(fx, panel, 'stripe-link'), null) + assert.equal(fx.finde(panel, '[data-link="dashboard"]'), null) +})) + +test('Stripe verbunden und fertig: Zahlungen und Auszahlungen aktiv, keine fehlenden Angaben, drei Aktionen', mit(undefined, async (fx) => { + const { panel } = await zeige(fx, status()) + const k = karte(fx, panel, 'Stripe-Konto') + assert.equal(fx.finde(k, '[data-stripe="zahlungen"]').textContent, 'Zahlungen: aktiv') + assert.equal(fx.finde(k, '[data-stripe="auszahlungen"]').textContent, 'Auszahlungen: aktiv') + assert.equal(fx.finde(k, '[data-stripe="fehlend"]').textContent, 'Keine fehlenden Angaben') + assert.equal(knopf(fx, panel, 'stripe-link').textContent, 'Angaben ergänzen') + assert.equal(knopf(fx, panel, 'stripe-aktualisieren').textContent, 'Status aktualisieren') + const dash = fx.finde(panel, '[data-link="dashboard"]') + assert.equal(dash.tagName, 'A') + assert.equal(dash.getAttribute('href'), 'https://dashboard.stripe.com/') + assert.equal(dash.getAttribute('target'), '_blank') + assert.equal(dash.getAttribute('rel'), 'noopener noreferrer') + assert.equal(dash.textContent, 'Stripe-Dashboard öffnen') + assert.equal(knopf(fx, panel, 'stripe-einrichten'), null) +})) + +test('Stripe verbunden mit offenen Punkten: Zähler der fehlenden Angaben, Hinweis und Einschränkung', mit(undefined, async (fx) => { + const s = { charges_enabled: false, payouts_enabled: false, details_submitted: false, currently_due: ['a', 'b', 'c'], past_due: ['b'], disabled_reason: 'requirements.past_due', hinweis: 'Klarna ist in deinem Stripe-Konto noch nicht aktiv.' } + const { panel } = await zeige(fx, status({ stripe: { verbunden: true, status: s } })) + const k = karte(fx, panel, 'Stripe-Konto') + assert.equal(fx.finde(k, '[data-stripe="zahlungen"]').textContent, 'Zahlungen: noch nicht freigeschaltet') + assert.equal(fx.finde(k, '[data-stripe="auszahlungen"]').textContent, 'Auszahlungen: noch nicht freigeschaltet') + assert.equal(fx.finde(k, '[data-stripe="fehlend"]').textContent, '3 Angaben fehlen noch', 'überfällige zählen nicht doppelt') + assert.match(fx.finde(k, '[data-stripe="angaben"]').textContent, /noch nicht abgeschickt/) + assert.match(fx.finde(k, '[data-stripe="gesperrt"]').textContent, /requirements\.past_due/) + assert.equal(fx.finde(k, '[data-stripe="hinweis"]').textContent, 'Klarna ist in deinem Stripe-Konto noch nicht aktiv.') + const eins = await zeige(fx, status({ stripe: { verbunden: true, status: { ...STRIPE_OK, currently_due: ['x'] } } })) + assert.equal(fx.finde(eins.panel, '[data-stripe="fehlend"]').textContent, '1 Angabe fehlt noch') +})) + +test('Stripe verbunden, Stand noch nicht gelesen (status null): Hinweis statt Zeilen', mit(undefined, async (fx) => { + const { panel } = await zeige(fx, status({ stripe: { verbunden: true, status: null } })) + assert.match(fx.text(karte(fx, panel, 'Stripe-Konto')), /noch nicht gelesen/) +})) + +test('Website-Karte: Code nur lesbar im
, Vorschau-Link, Satz „WEBklar baut den Shop ein“', mit(undefined, async (fx) => {
+  const { panel } = await zeige(fx, status())
+  const k = karte(fx, panel, 'Auf deiner Website')
+  const code = fx.finde(k, 'pre code')
+  assert.equal(code.textContent, SNIPPET)
+  assert.equal(fx.finde(k, 'textarea, input'), null, 'nicht editierbar')
+  assert.match(fx.text(k), /WEBklar baut den Shop für dich in deine Website ein/)
+  const vorschau = fx.finde(k, '[data-link="vorschau"]')
+  assert.equal(vorschau.getAttribute('href'), VORSCHAU)
+  assert.equal(vorschau.getAttribute('target'), '_blank')
+  assert.equal(vorschau.getAttribute('rel'), 'noopener noreferrer')
+}))
+
+test('Vorschau-Link nur für https-Adressen', mit(undefined, async (fx) => {
+  for (const schlecht of ['http://project.webklar.com/shop/x', 'javascript:alert(1)', '//evil.example/x', 'https://user:pw@evil.example/']) {
+    const { panel } = await zeige(fx, status({ vorschauUrl: schlecht }))
+    assert.equal(fx.finde(panel, '[data-link="vorschau"]'), null, schlecht)
+  }
+}))
+
+test('Zahlen: Artikel, offene Bestellungen, Bestellungen und Umsatz 30 Tage; fehlende Werte fehlen', mit(undefined, async (fx) => {
+  const { panel } = await zeige(fx, status())
+  const k = karte(fx, panel, 'Zahlen')
+  assert.equal(fx.finde(k, '[data-zahl="artikel"] dd').textContent, '4 (davon 3 aktiv)')
+  assert.equal(fx.finde(k, '[data-zahl="offene"] dd').textContent, '2')
+  assert.equal(fx.finde(k, '[data-zahl="bestellungen30"] dd').textContent, '11')
+  assert.equal(fx.finde(k, '[data-zahl="umsatz30"] dd').textContent, '1.234,50\u00a0€')
+  const teil = await zeige(fx, status({ zahlen: { artikel: 0 } }))
+  assert.equal(fx.alle(teil.panel, '[data-zahl]').length, 1)
+  assert.equal(fx.finde(teil.panel, '[data-zahl="artikel"] dd').textContent, '0')
+}))
+
+test('Fremdtext (Punkte, Hinweis, Snippet) kommt nur als Text in die Seite', mit(undefined, async (fx) => {
+  const boese = ''
+  const st = status({
+    bereitschaft: { bereit: false, punkte: [{ id: 'x', ok: false, text: boese }] },
+    stripe: { verbunden: true, status: { ...STRIPE_OK, hinweis: boese } },
+    snippet: boese,
+  })
+  const { panel } = await zeige(fx, st)
+  assert.equal(fx.alle(panel, 'img').length, 0)
+  assert.ok(fx.text(panel).includes(boese))
+}))
+
+/* ---------------------------------------------------------------- Öffnen --- */
+test('„Shop öffnen“ ist gesperrt, solange nicht alles bereit ist, und tut dann nichts', mit(undefined, async (fx) => {
+  const { panel, ctx } = await zeige(fx, status({ bereitschaft: bereitschaft(true, false, true) }))
+  const k = knopf(fx, panel, 'oeffnen')
+  assert.equal(k.textContent, 'Shop öffnen')
+  assert.equal(k.disabled, true)
+  await fx.klick(k)
+  assert.equal(posts(fx).length, 0)
+  assert.equal(ctx.meldungen.length, 0)
+}))
+
+test('„Shop öffnen“ schickt { projectId, offen: true }, zeigt den neuen Zustand und lädt den Stand neu', mit((a) => {
+  if (a.method === 'POST' && a.url === '/api/shop/oeffnen') return { body: { schalter: true, geoeffnet: true, bereitschaft: bereitschaft(true, true, true) } }
+  return { body: {} }
+}, async (fx) => {
+  const { panel, ctx } = await zeige(fx, status(), { neu: () => status({ schalter: true, geoeffnet: true }) })
+  assert.equal(knopf(fx, panel, 'oeffnen').disabled, false)
+  await fx.klick(knopf(fx, panel, 'oeffnen'))
+  assert.deepEqual(posts(fx).map((a) => [a.method, a.url, a.body]), [['POST', '/api/shop/oeffnen', { projectId: 'p1', offen: true }]])
+  assert.equal(fx.finde(panel, '[data-zustand]').textContent, 'Dein Shop ist geöffnet.')
+  assert.equal(knopf(fx, panel, 'schliessen').textContent, 'Shop schließen')
+  assert.equal(ctx.geladen, 1)
+  assert.deepEqual(ctx.meldungen, [{ text: 'Dein Shop ist jetzt geöffnet.', art: 'ok' }])
+}))
+
+test('„Shop schließen“ schickt { projectId, offen: false }', mit((a) => {
+  if (a.url === '/api/shop/oeffnen') return { body: { schalter: false, geoeffnet: false, bereitschaft: bereitschaft(true, true, true) } }
+  return { body: {} }
+}, async (fx) => {
+  const { panel, ctx } = await zeige(fx, status({ schalter: true, geoeffnet: true }), { neu: () => status() })
+  await fx.klick(knopf(fx, panel, 'schliessen'))
+  assert.deepEqual(posts(fx).map((a) => a.body), [{ projectId: 'p1', offen: false }])
+  assert.equal(fx.finde(panel, '[data-zustand]').textContent, 'Dein Shop ist geschlossen.')
+  assert.equal(knopf(fx, panel, 'oeffnen').textContent, 'Shop öffnen')
+  assert.equal(ctx.meldungen[0].text, 'Dein Shop ist jetzt geschlossen.')
+}))
+
+test('409 nicht_bereit: die Punkte des Servers ersetzen die Checkliste, der Fehlertext erscheint, der Knopf ist wieder frei zu', mit((a) => {
+  if (a.url === '/api/shop/oeffnen') return { status: 409, body: { error: 'Der Shop kann noch nicht geöffnet werden. Bitte erledige zuerst die offenen Punkte.', code: 'nicht_bereit', punkte: punkte(true, false, false) } }
+  return { body: {} }
+}, async (fx) => {
+  const { panel, ctx } = await zeige(fx, status()) // veralteter Stand: alles bereit
+  await fx.klick(knopf(fx, panel, 'oeffnen'))
+  assert.deepEqual(fx.alle(panel, '[data-punkt]').map((li) => li.dataset.ok), ['1', '0', '0'])
+  assert.equal(ctx.meldungen.length, 1)
+  assert.equal(ctx.meldungen[0].art, 'fehler')
+  assert.match(ctx.meldungen[0].text, /noch nicht geöffnet werden/)
+  assert.equal(knopf(fx, panel, 'oeffnen').disabled, true, 'nach dem Fehler stimmt der Stand: nicht bereit')
+}))
+
+test('Kundenansicht: 403 des Servers wird als Fehlertext gezeigt', mit((a) => {
+  if (a.url.startsWith('/api/shop/')) return { status: 403, body: { error: 'In der Kundenansicht kann das nicht geändert werden.' } }
+  return { body: {} }
+}, async (fx) => {
+  const { panel, ctx } = await zeige(fx, status())
+  await fx.klick(knopf(fx, panel, 'oeffnen'))
+  await fx.klick(knopf(fx, panel, 'stripe-aktualisieren'))
+  assert.deepEqual(ctx.meldungen, [
+    { text: 'In der Kundenansicht kann das nicht geändert werden.', art: 'fehler' },
+    { text: 'In der Kundenansicht kann das nicht geändert werden.', art: 'fehler' },
+  ])
+  assert.equal(knopf(fx, panel, 'oeffnen').disabled, false, 'Knöpfe sind nach dem Fehler wieder bedienbar')
+}))
+
+/* ---------------------------------------------------------------- Stripe --- */
+test('„Stripe-Konto einrichten“ schickt { projectId } und geht nur auf eine https-Adresse', mit((a) => {
+  if (a.url === '/api/shop/stripe/einrichten') return { body: { url: URL_STRIPE } }
+  return { body: {} }
+}, async (fx) => {
+  const { panel } = await zeige(fx, status({ stripe: { verbunden: false, status: null } }))
+  await fx.klick(knopf(fx, panel, 'stripe-einrichten'))
+  assert.deepEqual(posts(fx).map((a) => [a.url, a.body]), [['/api/shop/stripe/einrichten', { projectId: 'p1' }]])
+  assert.equal(fx.window.location.href, URL_STRIPE)
+}))
+
+test('Adresse von Stripe ohne https: keine Navigation, Fehlertext, Stand wird neu geladen', async () => {
+  for (const url of ['http://connect.stripe.test/x', 'javascript:alert(1)', '//evil.example', '', undefined, 'https://u:p@evil.example/']) {
+    const fx = starte((a) => (a.url === '/api/shop/stripe/einrichten' ? { body: { url } } : { body: {} }))
+    try {
+      fx.window.location.href = 'http://localhost/dashboard.html'
+      const { panel, ctx } = await zeige(fx, status({ stripe: { verbunden: false, status: null } }))
+      await fx.klick(knopf(fx, panel, 'stripe-einrichten'))
+      assert.equal(fx.window.location.href, 'http://localhost/dashboard.html', String(url))
+      assert.equal(ctx.meldungen.at(-1).art, 'fehler')
+      assert.match(ctx.meldungen.at(-1).text, /keine gültige Adresse/)
+      assert.equal(ctx.geladen, 1, 'das Konto ist angelegt: Stand neu holen')
+    } finally {
+      fx.restore()
+    }
+  }
+})
+
+test('„Angaben ergänzen“ holt einen neuen Link (POST /stripe/link) und geht dorthin', mit((a) => {
+  if (a.url === '/api/shop/stripe/link') return { body: { url: URL_STRIPE } }
+  return { body: {} }
+}, async (fx) => {
+  const { panel } = await zeige(fx, status({ stripe: { verbunden: true, status: { ...STRIPE_OK, currently_due: ['x'] } } }))
+  await fx.klick(knopf(fx, panel, 'stripe-link'))
+  assert.deepEqual(posts(fx).map((a) => [a.url, a.body]), [['/api/shop/stripe/link', { projectId: 'p1' }]])
+  assert.equal(fx.window.location.href, URL_STRIPE)
+}))
+
+test('„Status aktualisieren“ schickt { projectId } und zeigt den neuen Stand samt Meldung', mit((a) => {
+  if (a.url === '/api/shop/stripe/aktualisieren') return { body: { stripe: { verbunden: true, status: { ...STRIPE_OK, charges_enabled: false, currently_due: ['a', 'b'] } } } }
+  return { body: {} }
+}, async (fx) => {
+  const neuerStand = status({ stripe: { verbunden: true, status: { ...STRIPE_OK, charges_enabled: false, currently_due: ['a', 'b'] } } })
+  const { panel, ctx } = await zeige(fx, status(), { neu: neuerStand })
+  await fx.klick(knopf(fx, panel, 'stripe-aktualisieren'))
+  assert.deepEqual(posts(fx).map((a) => [a.url, a.body]), [['/api/shop/stripe/aktualisieren', { projectId: 'p1' }]])
+  assert.equal(fx.finde(panel, '[data-stripe="zahlungen"]').textContent, 'Zahlungen: noch nicht freigeschaltet')
+  assert.equal(fx.finde(panel, '[data-stripe="fehlend"]').textContent, '2 Angaben fehlen noch')
+  assert.equal(ctx.geladen, 1)
+  assert.deepEqual(ctx.meldungen, [{ text: 'Der Stand deines Stripe-Kontos ist aktuell.', art: 'ok' }])
+}))
+
+test('Rückkehr von Stripe (stripeRueckkehr): der Stand wird einmal automatisch gelesen', mit((a) => {
+  if (a.url === '/api/shop/stripe/aktualisieren') return { body: { stripe: { verbunden: true, status: STRIPE_OK } } }
+  return { body: {} }
+}, async (fx) => {
+  const ctx = baueCtx(status({ stripe: { verbunden: true, status: { ...STRIPE_OK, charges_enabled: false } } }), { stripeRueckkehr: 'zurueck', neu: status() })
+  const panel = h('div')
+  fx.document.body.append(panel)
+  await render(panel, ctx)
+  assert.deepEqual(posts(fx).map((a) => [a.url, a.body]), [['/api/shop/stripe/aktualisieren', { projectId: 'p1' }]])
+  assert.equal(fx.finde(panel, '[data-stripe="zahlungen"]').textContent, 'Zahlungen: aktiv')
+  assert.match(ctx.meldungen[0].text, /Willkommen zurück von Stripe/)
+  assert.equal(ctx.meldungen[0].art, 'ok')
+  await render(panel, ctx) // Reiterwechsel mit demselben ctx: nicht noch einmal
+  assert.equal(posts(fx).length, 1)
+}))
+
+test('Rückkehr „neu“ (Link abgelaufen): Stand lesen und auf „Angaben ergänzen“ hinweisen; ohne Konto kein Aufruf', mit((a) => {
+  if (a.url === '/api/shop/stripe/aktualisieren') return { body: { stripe: { verbunden: true, status: STRIPE_OK } } }
+  return { body: {} }
+}, async (fx) => {
+  const { ctx } = await zeige(fx, status(), { stripeRueckkehr: 'neu' })
+  assert.equal(posts(fx).length, 1)
+  assert.match(ctx.meldungen[0].text, /abgelaufen/)
+  const ohne = await zeige(fx, status({ stripe: { verbunden: false, status: null } }), { stripeRueckkehr: 'zurueck' })
+  assert.equal(posts(fx).length, 1, 'kein Konto, nichts zu lesen')
+  assert.equal(ohne.ctx.meldungen.length, 0)
+}))
+
+test('Fehler beim automatischen Lesen nach der Rückkehr wird gemeldet', mit(() => ({ status: 502, body: { error: 'Stripe ist gerade nicht erreichbar. Bitte versuch es gleich noch einmal.' } }), async (fx) => {
+  const { ctx, panel } = await zeige(fx, status(), { stripeRueckkehr: 'zurueck' })
+  assert.deepEqual(ctx.meldungen, [{ text: 'Stripe ist gerade nicht erreichbar. Bitte versuch es gleich noch einmal.', art: 'fehler' }])
+  assert.ok(knopf(fx, panel, 'stripe-aktualisieren'), 'die Karte bleibt bedienbar')
+}))
+
+/* ---------------------------------------------------------------- Kopieren --- */
+function setzeNavigator(wert) {
+  const alt = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
+  Object.defineProperty(globalThis, 'navigator', { value: wert, configurable: true, writable: true, enumerable: true })
+  return () => (alt ? Object.defineProperty(globalThis, 'navigator', alt) : delete globalThis.navigator)
+}
+
+test('„Code kopieren“ schreibt den Code in die Zwischenablage und bestätigt', mit(undefined, async (fx) => {
+  const kopiert = []
+  const zurueck = setzeNavigator({ clipboard: { writeText: async (t) => { kopiert.push(t) } } })
+  try {
+    const { panel } = await zeige(fx, status())
+    await fx.klick(knopf(fx, panel, 'kopieren'))
+    assert.deepEqual(kopiert, [SNIPPET])
+    assert.equal(fx.finde(panel, '[data-kopiert]').textContent, 'Der Code ist kopiert.')
+  } finally {
+    zurueck()
+  }
+}))
+
+test('„Code kopieren“ ohne Zwischenablage: Ausweich über execCommand, sonst Hinweis zum Kopieren von Hand', mit(undefined, async (fx) => {
+  const zurueck = setzeNavigator({ clipboard: { writeText: async () => { throw new Error('verweigert') } } })
+  try {
+    const { panel } = await zeige(fx, status())
+    fx.document.execCommand = (befehl) => befehl === 'copy'
+    await fx.klick(knopf(fx, panel, 'kopieren'))
+    assert.equal(fx.finde(panel, '[data-kopiert]').textContent, 'Der Code ist kopiert.')
+    assert.equal(fx.alle(fx.document.body, 'textarea').length, 0, 'das Hilfsfeld wird wieder entfernt')
+    fx.document.execCommand = () => false
+    await fx.klick(knopf(fx, panel, 'kopieren'))
+    assert.match(fx.finde(panel, '[data-kopiert]').textContent, /Das Kopieren hat nicht geklappt/)
+    delete fx.document.execCommand
+    await fx.klick(knopf(fx, panel, 'kopieren'))
+    assert.match(fx.finde(panel, '[data-kopiert]').textContent, /Das Kopieren hat nicht geklappt/)
+  } finally {
+    zurueck()
+  }
+}))
+
+/* ---------------------------------------------------------------- Nur lesen --- */
+test('nurLesen (Shop abgeschaltet): keine Knöpfe, nur Links', mit(undefined, async (fx) => {
+  const { panel } = await zeige(fx, status({ geoeffnet: false, schalter: true }), { nurLesen: true })
+  assert.equal(fx.alle(panel, 'button').length, 0)
+  assert.ok(fx.finde(panel, '[data-link="dashboard"]'))
+  assert.ok(fx.finde(panel, '[data-link="vorschau"]'))
+  assert.ok(fx.finde(panel, '[data-zustand]'))
+  const ohneKonto = await zeige(fx, status({ stripe: { verbunden: false, status: null } }), { nurLesen: true })
+  assert.equal(fx.alle(ohneKonto.panel, 'button').length, 0)
+  assert.match(fx.text(karte(fx, ohneKonto.panel, 'Stripe-Konto')), /eigenes Stripe-Konto/)
+}))
+
+test('nurLesen: die Rückkehr von Stripe löst keinen Aufruf aus', mit(undefined, async (fx) => {
+  await zeige(fx, status(), { nurLesen: true, stripeRueckkehr: 'zurueck' })
+  assert.equal(fx.fetchLog.length, 0)
+}))
+
+test('Antwort nach dem Wechsel des Reiters zeichnet nicht in das fremde Panel', mit((a) => {
+  if (a.url === '/api/shop/oeffnen') return { body: { schalter: true, geoeffnet: true, bereitschaft: bereitschaft(true) } }
+  return { body: {} }
+}, async (fx) => {
+  const { panel, ctx } = await zeige(fx, status())
+  const klickt = fx.klick(knopf(fx, panel, 'oeffnen'))
+  panel.replaceChildren(h('p', null, 'anderer Reiter'))
+  await klickt
+  assert.equal(fx.text(panel), 'anderer Reiter')
+  assert.equal(ctx.meldungen.length, 0)
+}))
+
+/* ---------------------------------------------------------------- Quelle --- */
+test('Quelltext: kein innerHTML, outerHTML, insertAdjacentHTML, document.write, eval', () => {
+  const quelle = readFileSync(new URL('../public/shop-tab-uebersicht.js', import.meta.url), 'utf8')
+  for (const verboten of ['innerHTML', 'outerHTML', 'insertAdjacentHTML', 'document.write', 'eval(', 'new Function']) assert.ok(!quelle.includes(verboten), verboten)
+})
diff --git a/test/shopView.test.js b/test/shopView.test.js
index 5cebe91..653d31c 100644
--- a/test/shopView.test.js
+++ b/test/shopView.test.js
@@ -350,14 +350,14 @@ test('ordered ohne Rückkehr: kein „Danke!“; scheitert „Zahlung fortsetzen
 ))
 
 /* ------------------------------------------------------------------ Reiter --- */
-test('Zustand active: fünf Reiter in fester Reihenfolge, Übersicht offen, Modul ist ein Platzhalter; kein Hinweis-Banner', mitDom(welt1('active', status('active')), undefined, async (fx) => {
+test('Zustand active: fünf Reiter in fester Reihenfolge, Übersicht offen; kein Hinweis-Banner', mitDom(welt1('active', status('active')), undefined, async (fx) => {
   const root = await oeffne(fx)
   const knoepfe = fx.alle(root, '[role="tab"]')
   assert.deepEqual(knoepfe.map((k) => fx.text(k)), ['Übersicht', 'Artikel', 'Bestellungen', 'Einstellungen', 'Rechtliches'])
   assert.deepEqual(knoepfe.map((k) => k.dataset.reiter), ['uebersicht', 'artikel', 'bestellungen', 'einstellungen', 'rechtliches'])
   assert.deepEqual(knoepfe.map((k) => k.getAttribute('aria-selected')), ['true', 'false', 'false', 'false', 'false'])
   assert.equal(fx.finde(root, '[role="tablist"]').getAttribute('aria-label'), 'Bereiche des Webshops')
-  assert.match(fx.text(fx.finde(root, '[role="tabpanel"]')), /Kommt gleich …/)
+  assert.notEqual(fx.text(fx.finde(root, '[role="tabpanel"]')), '', 'die Übersicht zeichnet ihr Panel')
   assert.equal(fx.finde(root, '[data-hinweis]'), null)
   assert.deepEqual(REITER.map((r) => r.key), ['uebersicht', 'artikel', 'bestellungen', 'einstellungen', 'rechtliches'])
 }))
@@ -401,12 +401,12 @@ test('Reiterwechsel per Klick und Pfeiltasten lädt das Modul des Reiters mit ct
   }
 }))
 
-test('Die echten Reiter-Module werden beim Wählen geladen (Platzhalter „Kommt gleich …“)', mitDom(welt1('active', status('active')), undefined, async (fx) => {
+test('Die echten Reiter-Module werden beim Wählen geladen', mitDom(welt1('active', status('active')), undefined, async (fx) => {
   const root = await oeffne(fx)
   for (const key of ['artikel', 'bestellungen', 'einstellungen', 'rechtliches', 'uebersicht']) {
     await fx.klick(fx.finde(root, `#sh-tab-${key}`))
     await fx.ruhe()
-    assert.match(fx.text(fx.finde(root, '#sh-panel')), /Kommt gleich …/, key)
+    assert.notEqual(fx.text(fx.finde(root, '#sh-panel')), '', `${key} zeichnet sein Panel (Platzhalter oder echter Inhalt)`)
   }
   for (const r of REITER) assert.equal(typeof (await r.laden()).render, 'function', `${r.key} exportiert render`)
 }))