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; }