Shop G3: Reiter Übersicht mit Checkliste und Öffnen-Schalter, Stripe-Konto, Einbau-Code mit Vorschau und Kennzahlen

Die Platzhalter-Prüfungen in shopView.test.js hängen nicht mehr am Text „Kommt gleich …“.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-05 09:10:30 +00:00
1 parent 6d8487633b
commit 3ed96a5838
4 files changed
+681 -9

No files matched your search

+246 -5
View File
@@ -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
}
+17
View File
@@ -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; }
+414
View File
@@ -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 = '<div data-webklar-shop="abc123"></div>\n<script src="https://project.webklar.com/embed/shop.js" defer></script>'
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 <pre><code>, 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 = '<img src=x onerror=alert(1)>'
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)
})
+4 -4
View File
@@ -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`)
}))