/* 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 { abschnitt, euroText, h } from './shop-dom.js' 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 }