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:
1 parent
6d8487633b
commit
3ed96a5838
4 files changed
+681
-9
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
Reference in new issue
Block a user